Metadata-Version: 2.4
Name: firefox-logins
Version: 0.1.0
Summary: Read and write Firefox saved passwords by driving the browser's own NSS Secret Decoder Ring.
Author-email: Daniel Hails <firefox-logins@hails.info>
License: MIT
Project-URL: Homepage, https://github.com/DJRHails/firefox-logins
Project-URL: Issues, https://github.com/DJRHails/firefox-logins/issues
Project-URL: Source, https://github.com/DJRHails/firefox-logins
Keywords: firefox,nss,logins,passwords,credentials,migration,browser
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: MacOS :: MacOS X
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: System :: Recovery Tools
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: dev
Requires-Dist: pytest==8.3.3; extra == "dev"
Requires-Dist: ruff==0.7.4; extra == "dev"
Requires-Dist: ty==0.0.1a4; extra == "dev"
Dynamic: license-file

# firefox-logins

Read **and write** Firefox saved passwords, by driving the browser's own NSS
Secret Decoder Ring through `ctypes`.

Plenty of tools decrypt `logins.json`. This one also seals new credentials into
it, using the same code path Firefox itself uses — so entries it writes are
indistinguishable from ones you saved by hand. That makes browser-to-browser
password migration possible without a plaintext CSV round trip.

No runtime dependencies: the crypto comes from the Firefox install already on
the machine.

## Install

```bash
pip install firefox-logins
```

Requires Python 3.11+ and a Firefox installation. Tested on macOS; Linux paths
are supported but less exercised.

## Usage

### Read

```python
from firefox_logins import LoginStore

with LoginStore.open() as store:
    for login in store:
        print(login.hostname, login.username, login.password)
```

`LoginStore.open()` uses the profile Firefox would launch. Pick another with
`find_profiles()`, and pass `primary_password=` if the profile has one.

### Write

```python
from firefox_logins import Login, LoginStore

with LoginStore.open() as store:
    store.add(Login(
        hostname="https://example.com",
        username="ada",
        password="hunter2",
        form_submit_url="https://example.com",
    ))
    written = store.save()   # Firefox must be quit
```

`save()` decrypts every entry — existing and new — before replacing the file,
and abandons the write if any fails. It backs up to `logins.json.bak`, writes
to a staging file, then renames, so an interrupted run cannot leave a partial
file where your credentials were.

Set exactly one of `form_submit_url` (form logins) or `http_realm` (HTTP auth);
Firefox rejects entries with both or neither, so the constructor does too.

### Profiles

```python
from firefox_logins import find_profiles

for profile in find_profiles():
    print(profile.name, profile.path, profile.is_default)
```

### Low-level

```python
from firefox_logins import SecretDecoderRing, keyid_from_blob

with SecretDecoderRing("/path/to/profile") as sdr:
    sdr.selftest()
    blob = sdr.encrypt("secret")
    assert sdr.decrypt(blob) == "secret"
```

## Notes on the write path

Four things are needed to seal a value the way Firefox does, none of them
covered by the read-only references this was built against. They are recorded
here because each one fails in a way that is easy to misdiagnose:

1. **`libnss3` declares `@rpath/libmozglue`**, which the loader cannot resolve
   from a host process. The dependency is loaded by absolute path first, and
   the working directory stays inside the Firefox bundle through `NSS_Init`,
   because NSS `dlopen`s softokn and freebl during initialisation.
2. **Firefox does not export `PK11SDR_Encrypt`** — only
   `PK11SDR_EncryptWithMechanism`.
3. **The mechanism must be `CKM_AES_CBC`.** `CKM_AES_CBC_PAD` and both DES3
   forms return `SEC_ERROR_INVALID_ARGS`, because the Secret Decoder Ring
   applies PKCS#7 padding itself rather than delegating it to PKCS#11.
4. **The key id must be non-empty.** NSS's default is
   `f8000000000000000000000000000001`; `keyid_from_blob()` recovers a profile's
   own from any entry it already holds, since the stored value is DER
   `SEQUENCE { OCTET STRING keyid, AlgorithmIdentifier, OCTET STRING ciphertext }`.

`selftest()` runs a seal/unseal round trip and is called automatically when a
`LoginStore` is opened, so a wrong mechanism or key id fails immediately rather
than producing a file Firefox silently cannot read.

## Scope and safety

This reads and writes credentials belonging to the user running it, using keys
already present in that user's own profile. It grants no access that running as
that user does not already imply, and it cannot bypass a Primary Password —
without the correct one, NSS refuses to unlock the key slot.

If a profile has a Primary Password, pass it:

```python
LoginStore.open(primary_password="…")
```

Quit Firefox before writing. Firefox rewrites `logins.json` on exit, so changes
made while it runs are discarded; `save()` refuses by default and explains why.

## Companion

[`chromium-reader`](https://github.com/DJRHails/chromium-reader) does the
equivalent job for Chromium's on-disk storage formats.

## License

MIT
