From c163dcb028ea5bd26fefada92a11bfa81ffc455d Mon Sep 17 00:00:00 2001 From: Shane Jaroch Date: Thu, 25 Dec 2025 14:27:03 -0500 Subject: [PATCH 1/6] v0.6.0: Import & export for AES and mixed keys. - GH Actions on Windows/macOS - Add argcomplete (optional end-user add-on). - Add fixes & tests for master password. - tests: Generate mixed key test data. Use some mocks. - tests: Avoid modifying .json files, use temp storage. - tests: Add test for AES export/import (AES, 3DES, and mixed keys). - Tidy up, lint, and format. - Fix edge cases with argcomplete. - Windows: Fix profile discovery and CSV printing. - Better loop logic (for imports w/ empty row or missing header). - Config files. - Add stuff back that was removed accidentally in a frenzy. --- .envrc | 4 + .github/workflows/testing.yaml | 4 +- .github/workflows/windows-and-mac.yaml | 50 +++ Makefile | 32 +- ffpass/__init__.py | 547 +++++++++++++++++-------- requirements-dev.txt | 4 + requirements.txt | 4 + scripts/generate_mixed_profile.py | 74 ++++ scripts/generate_mp_profile.py | 186 +++++++++ setup.py | 4 +- tests/firefox-146-aes/key4.db | Bin 0 -> 294912 bytes tests/firefox-146-aes/logins.json | 1 + tests/firefox-mixed-keys/key4.db | Bin 0 -> 16384 bytes tests/firefox-mixed-keys/logins.json | 1 + tests/firefox-mp-test/key4.db | Bin 0 -> 16384 bytes tests/firefox-mp-test/logins.json | 1 + tests/test_key.py | 89 +++- tests/test_mixed_keys_run.py | 102 +++++ tests/test_mp_stdin.py | 135 ++++++ tests/test_run.py | 79 +++- 20 files changed, 1115 insertions(+), 202 deletions(-) create mode 100644 .envrc create mode 100644 .github/workflows/windows-and-mac.yaml mode change 100644 => 100755 ffpass/__init__.py create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100755 scripts/generate_mixed_profile.py create mode 100755 scripts/generate_mp_profile.py create mode 100644 tests/firefox-146-aes/key4.db create mode 100644 tests/firefox-146-aes/logins.json create mode 100644 tests/firefox-mixed-keys/key4.db create mode 100644 tests/firefox-mixed-keys/logins.json create mode 100644 tests/firefox-mp-test/key4.db create mode 100644 tests/firefox-mp-test/logins.json create mode 100644 tests/test_mixed_keys_run.py create mode 100644 tests/test_mp_stdin.py diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..488515d --- /dev/null +++ b/.envrc @@ -0,0 +1,4 @@ +source .venv/bin/activate +unset PS1 +eval "$(register-python-argcomplete ffpass)" + diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml index 24ddd97..d00081b 100644 --- a/.github/workflows/testing.yaml +++ b/.github/workflows/testing.yaml @@ -5,7 +5,7 @@ on: [push, pull_request_target] jobs: test: name: Test - runs-on: ubuntu-latest + runs-on: [ubuntu-latest] steps: - name: Checkout uses: actions/checkout@v4 @@ -29,7 +29,7 @@ jobs: - name: Lint with flake8 run: | pip install flake8 - flake8 --ignore=E741,E501 . + flake8 . --exclude='*venv,build' --ignore=E741,E501 - name: Upload Unit Test Results if: always() diff --git a/.github/workflows/windows-and-mac.yaml b/.github/workflows/windows-and-mac.yaml new file mode 100644 index 0000000..29f3b2f --- /dev/null +++ b/.github/workflows/windows-and-mac.yaml @@ -0,0 +1,50 @@ +name: windows-and-mac + +on: [push, pull_request_target] + +jobs: + windows: + name: windows + runs-on: [windows-latest] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install . + + - name: Test with pytest + run: | + pip install pytest + pip install pytest-cov + python -m pytest tests --junit-xml pytest.xml + + macOS: + name: macOS + runs-on: [macos-latest] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install . + + - name: Test with pytest + run: | + pip install pytest + pip install pytest-cov + python -m pytest tests --junit-xml pytest.xml \ No newline at end of file diff --git a/Makefile b/Makefile index cf7baac..2390acd 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,39 @@ +SHELL:=/bin/bash + +.PHONY: pypi pypi: dist twine upload dist/* - + +.PHONY: dist dist: flake8 -rm dist/* ./setup.py sdist bdist_wheel +.PHONY: flake8 flake8: - flake8 . --count --select=E901,E999,F821,F822,F823 --show-source --statistics - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + flake8 . --exclude '*venv,build' --count --select=E901,E999,F821,F822,F823 --show-source --statistics + flake8 . --exclude '*venv,build' --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + # CI pipeline + flake8 . --exclude='*venv,build' --ignore=E741,E501 + + +.PHONY: install +install: + pip install . + +.PHONY: test +test: + @echo 'Remember to run make install to test against the latest :)' + coverage run -m pytest -svv tests/ + coverage report -m --omit="tests/*" + +.PHONY: clean clean: rm -rf *.egg-info build dist + rm -f .coverage + find . \ + -name .venv -prune \ + -o -name __pycache__ -print \ + -o -name .pytest_cache -print \ + | xargs -r rm -rf diff --git a/ffpass/__init__.py b/ffpass/__init__.py old mode 100644 new mode 100755 index 56262cb..e7f7a09 --- a/ffpass/__init__.py +++ b/ffpass/__init__.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +# PYTHON_ARGCOMPLETE_OK # Reverse-engineering by Laurent Clevy (@lclevy) # from https://github.com/lclevy/firepwd/blob/master/firepwd.py @@ -26,10 +27,10 @@ \033[0m\033[F\033[F """ + import sys from base64 import b64decode, b64encode from hashlib import sha1, pbkdf2_hmac -import hmac import argparse import json from pathlib import Path @@ -42,6 +43,7 @@ import sqlite3 import os.path import logging +import string from pyasn1.codec.der.decoder import decode as der_decode from pyasn1.codec.der.encoder import encode as der_encode @@ -54,8 +56,14 @@ # des-ede3-cbc MAGIC2 = (1, 2, 840, 113_549, 3, 7) +# aes-256-cbc +MAGIC_AES = (2, 16, 840, 1, 101, 3, 4, 1, 42) + # pkcs-12-PBEWithSha1AndTripleDESCBC -MAGIC3 = (1, 2, 840, 113_549, 1, 12, 5, 1, 3) +OID_PKCS12_3DES = (1, 2, 840, 113_549, 1, 12, 5, 1, 3) + +# pkcs5PBES2 +OID_PBES2 = (1, 2, 840, 113_549, 1, 5, 13) class NoDatabase(Exception): @@ -70,96 +78,44 @@ class NoProfile(Exception): pass -def getKey(directory: Path, masterPassword=""): - dbfile: Path = directory / "key4.db" +def censor(data): + """ + Censors the middle third of a hex string or bytes object. + """ + if not data: + return None + s = data.hex() if isinstance(data, (bytes, bytearray)) else str(data) - if not dbfile.exists(): - raise NoDatabase() + length = len(s) + if length <= 12: + return s - conn = sqlite3.connect(dbfile.as_posix()) - c = conn.cursor() - c.execute(""" - SELECT item1, item2 - FROM metadata - WHERE id = 'password'; - """) - row = next(c) - globalSalt, item2 = row + third = length // 3 + two_thirds = (2 * length) // 3 + return f"{s[:third]}.....{s[two_thirds:]}" - try: - decodedItem2, _ = der_decode(item2) - encryption_method = '3DES' - entrySalt = decodedItem2[0][1][0].asOctets() - cipherT = decodedItem2[1].asOctets() - clearText = decrypt3DES( - globalSalt, masterPassword, entrySalt, cipherT - ) # usual Mozilla PBE - except AttributeError: - encryption_method = 'AES' - decodedItem2 = der_decode(item2) - clearText = decrypt_aes(decodedItem2, masterPassword, globalSalt) - - if clearText != b"password-check\x02\x02": - raise WrongPassword() - logging.info("password checked") +def clean_iv(iv_bytes): + if len(iv_bytes) == 14: + return b'\x04\x0e' + iv_bytes + elif len(iv_bytes) == 18 and iv_bytes.startswith(b'\x04\x10'): + return iv_bytes[2:] + return iv_bytes - # decrypt 3des key to decrypt "logins.json" content - c.execute(""" - SELECT a11, a102 - FROM nssPrivate - WHERE a102 = ?; - """, (MAGIC1,)) - try: - row = next(c) - a11, a102 = row # CKA_ID - except StopIteration: - raise Exception( - "The Firefox database appears to be broken. Try to add a password to rebuild it." - ) # CKA_ID - - if encryption_method == 'AES': - decodedA11 = der_decode(a11) - key = decrypt_aes(decodedA11, masterPassword, globalSalt) - elif encryption_method == '3DES': - decodedA11, _ = der_decode(a11) - oid = decodedA11[0][0].asTuple() - assert oid == MAGIC3, f"The key is encoded with an unknown format {oid}" - entrySalt = decodedA11[0][1][0].asOctets() - cipherT = decodedA11[1].asOctets() - key = decrypt3DES(globalSalt, masterPassword, entrySalt, cipherT) - - logging.info("{}: {}".format(encryption_method, key.hex())) - return key[:24] - -def PKCS7pad(b): - l = (-len(b) - 1) % 8 + 1 - return b + bytes([l] * l) +def PKCS7pad(b, block_size=8): + pad_len = (-len(b) - 1) % block_size + 1 + return b + bytes([pad_len] * pad_len) def PKCS7unpad(b): + if not b: + return b return b[: -b[-1]] -def decrypt_aes(decoded_item, master_password, global_salt): - entry_salt = decoded_item[0][0][1][0][1][0].asOctets() - iteration_count = int(decoded_item[0][0][1][0][1][1]) - key_length = int(decoded_item[0][0][1][0][1][2]) - assert key_length == 32 - - encoded_password = sha1(global_salt + master_password.encode('utf-8')).digest() - key = pbkdf2_hmac( - 'sha256', encoded_password, - entry_salt, iteration_count, dklen=key_length) - - init_vector = b'\x04\x0e' + decoded_item[0][0][1][1][1].asOctets() - encrypted_value = decoded_item[0][1].asOctets() - cipher = AES.new(key, AES.MODE_CBC, init_vector) - return cipher.decrypt(encrypted_value) - - def decrypt3DES(globalSalt, masterPassword, entrySalt, encryptedData): + import hmac hp = sha1(globalSalt + masterPassword.encode()).digest() pes = entrySalt + b"\x00" * (20 - len(entrySalt)) chp = sha1(hp + entrySalt).digest() @@ -173,27 +129,211 @@ def decrypt3DES(globalSalt, masterPassword, entrySalt, encryptedData): return DES3.new(key, DES3.MODE_CBC, iv).decrypt(encryptedData) +def decrypt_key_entry(a11, global_salt, master_password): + try: + decoded, _ = der_decode(a11) + key_oid = decoded[0][0].asTuple() + + if key_oid == OID_PBES2: + # AES Logic + algo = decoded[0][1][0] + pbkdf2_params = algo[1] + entry_salt = pbkdf2_params[0].asOctets() + iters = int(pbkdf2_params[1]) + key_len = int(pbkdf2_params[2]) + + logging.debug(f" > Method: PBKDF2-HMAC-SHA256 | Iterations: {iters}") + logging.debug(f" > Salt: {censor(entry_salt)} (Local) + {censor(global_salt)} (Global)") + + enc_pwd = sha1(global_salt + master_password.encode('utf-8')).digest() + k = pbkdf2_hmac('sha256', enc_pwd, entry_salt, iters, dklen=key_len) + + iv = clean_iv(decoded[0][1][1][1].asOctets()) + logging.debug(f" > Cipher: AES-256-CBC | IV: {censor(iv)}") + + cipher = AES.new(k, AES.MODE_CBC, iv) + return PKCS7unpad(cipher.decrypt(decoded[1].asOctets())) + + elif key_oid == OID_PKCS12_3DES: + # 3DES Logic + entry_salt = decoded[0][1][0].asOctets() + ciphertext = decoded[1].asOctets() + + logging.debug(" > Method: PKCS12-3DES-Derivation") + logging.debug(f" > Salt: {censor(entry_salt)} (Local) + {censor(global_salt)} (Global)") + + return PKCS7unpad(decrypt3DES(global_salt, master_password, entry_salt, ciphertext)) + + except Exception as e: + logging.debug(f" > Failed: {e}") + return None + + +def verify_password(global_salt, item2, pwd): + """ + Verifies the master password against the metadata entry (item2). + Raises WrongPassword on failure. + """ + try: + decodedItem2, _ = der_decode(item2) + try: + algorithm_oid = decodedItem2[0][0].asTuple() + except (IndexError, AttributeError): + raise ValueError("Could not decode password validation data structure.") + + if algorithm_oid == OID_PKCS12_3DES: + entrySalt = decodedItem2[0][1][0].asOctets() + cipherT = decodedItem2[1].asOctets() + clearText = decrypt3DES(global_salt, pwd, entrySalt, cipherT) + elif algorithm_oid == OID_PBES2: + algo = decodedItem2[0][1][0] + pbkdf2_params = algo[1] + entry_salt = pbkdf2_params[0].asOctets() + iters = int(pbkdf2_params[1]) + key_len = int(pbkdf2_params[2]) + enc_pwd = sha1(global_salt + pwd.encode('utf-8')).digest() + k = pbkdf2_hmac('sha256', enc_pwd, entry_salt, iters, dklen=key_len) + iv = clean_iv(decodedItem2[0][1][1][1].asOctets()) + cipher = AES.new(k, AES.MODE_CBC, iv) + clearText = cipher.decrypt(decodedItem2[1].asOctets()) + else: + raise ValueError(f"Unknown encryption method OID: {algorithm_oid}") + + if clearText != b"password-check\x02\x02": + raise WrongPassword() + + except Exception as e: + logging.debug(f"Password check failed: {e}") + raise WrongPassword() + + +def get_all_keys(directory, pwd=""): + db = Path(directory) / "key4.db" + if not db.exists(): + raise NoDatabase() + + conn = sqlite3.connect(str(db)) + c = conn.cursor() + + # 1. Get Global Salt + c.execute("SELECT item1, item2 FROM metadata WHERE id = 'password'") + try: + global_salt, item2 = next(c) + except StopIteration: + raise NoDatabase() + + logging.info(f"[*] Global Salt: {censor(global_salt)}") + + # 2. VERIFY PASSWORD EXPLICITLY + verify_password(global_salt, item2, pwd) + logging.info("[*] Password Verified Correctly") + + # 3. Find ALL Keys + c.execute("SELECT a11, a102 FROM nssPrivate") + rows = c.fetchall() + logging.info(f"[*] Found {len(rows)} entries in nssPrivate") + + # Check if rows exist BEFORE assuming corruption. + # If the table is empty, it's just an empty DB, not corruption. + if not rows: + raise NoDatabase() + + found_keys = [] + for idx, (a11, a102) in enumerate(rows): + logging.debug(f"[*] Attempting to decrypt Key #{idx} (ID: {censor(a102)})...") + + key = decrypt_key_entry(a11, global_salt, pwd) + + if key: + logging.info(f"[*] Decrypted Key #{idx}: {len(key)} bytes | ID: {a102.hex()}") + found_keys.append(key) + else: + logging.debug(f"[*] Key #{idx}: Failed to decrypt (Corrupt?)") + + if not found_keys: + # Rows existed, but none decrypted successfully. + # Since password was verified in step 2, this IS corruption. + raise Exception("Database corrupted: Password verified, but no valid master keys could be decrypted.") + + return found_keys, global_salt + + +def try_decrypt_login(key, ciphertext, iv): + # Try AES + if len(key) in [16, 24, 32]: + try: + cipher = AES.new(key, AES.MODE_CBC, iv) + pt = cipher.decrypt(ciphertext) + res = PKCS7unpad(pt) + text = res.decode('utf-8') + if is_valid_text(text): + return text, "AES-Standard" + except Exception: + pass + + # Try 3DES + if len(key) == 24: + try: + cipher = DES3.new(key, DES3.MODE_CBC, iv[:8]) + pt = cipher.decrypt(ciphertext) + res = PKCS7unpad(pt) + text = res.decode('utf-8') + if is_valid_text(text): + return text, "3DES-Standard" + except Exception: + pass + + return None, None + + +def is_valid_text(text): + if not text or len(text) < 2: + return False + printable = set(string.printable) + if sum(1 for c in text if c in printable) / len(text) < 0.9: + return False + return True + + def decodeLoginData(key, data): - # first base64 decoding, then ASN1DERdecode - asn1data, _ = der_decode(b64decode(data)) - assert asn1data[0].asOctets() == MAGIC1 - assert asn1data[1][0].asTuple() == MAGIC2 - iv = asn1data[1][1].asOctets() - ciphertext = asn1data[2].asOctets() - des = DES3.new(key, DES3.MODE_CBC, iv) - return PKCS7unpad(des.decrypt(ciphertext)).decode() + try: + asn1data, _ = der_decode(b64decode(data)) + iv = clean_iv(asn1data[1][1].asOctets()) + ciphertext = asn1data[2].asOctets() + + text, method = try_decrypt_login(key, ciphertext, iv) + if text: + return text + raise ValueError("Decryption failed") + except Exception: + raise ValueError("Decryption failed") def encodeLoginData(key, data): - iv = secrets.token_bytes(8) - des = DES3.new(key, DES3.MODE_CBC, iv) - ciphertext = des.encrypt(PKCS7pad(data.encode())) asn1data = Sequence() asn1data[0] = OctetString(MAGIC1) asn1data[1] = Sequence() - asn1data[1][0] = ObjectIdentifier(MAGIC2) - asn1data[1][1] = OctetString(iv) - asn1data[2] = OctetString(ciphertext) + + if len(key) == 32: # AES-256 + iv = secrets.token_bytes(16) + cipher = AES.new(key, AES.MODE_CBC, iv) + ciphertext = cipher.encrypt(PKCS7pad(data.encode(), block_size=16)) + + asn1data[1][0] = ObjectIdentifier(MAGIC_AES) + asn1data[1][1] = OctetString(iv) + asn1data[2] = OctetString(ciphertext) + + elif len(key) == 24: # 3DES + iv = secrets.token_bytes(8) + des = DES3.new(key, DES3.MODE_CBC, iv) + ciphertext = des.encrypt(PKCS7pad(data.encode(), block_size=8)) + + asn1data[1][0] = ObjectIdentifier(MAGIC2) + asn1data[1][1] = OctetString(iv) + asn1data[2] = OctetString(ciphertext) + else: + raise ValueError(f"Unknown key type/size: {len(key)}") + return b64encode(der_encode(asn1data)).decode() @@ -216,34 +356,70 @@ def exportLogins(key, jsonLogins): for row in jsonLogins["logins"]: if row.get("deleted"): continue - encUsername = row["encryptedUsername"] - encPassword = row["encryptedPassword"] - logins.append( - ( - row["hostname"], - decodeLoginData(key, encUsername), - decodeLoginData(key, encPassword), - ) - ) + try: + user = decodeLoginData(key, row["encryptedUsername"]) + pw = decodeLoginData(key, row["encryptedPassword"]) + logins.append((row["hostname"], user, pw)) + except Exception as e: + if logging.getLogger().isEnabledFor(logging.DEBUG): + logging.debug(f"Failed to decrypt {row.get('hostname')}: {e}") + continue return logins -def lower_header(csv_file): - it = iter(csv_file) - yield next(it).lower() - yield from it +def readCSV(csv_file): + reader = csv.reader(csv_file) -def readCSV(csv_file): logins = [] - reader = csv.DictReader(lower_header(csv_file)) - for row in reader: - logins.append((rawURL(row["url"]), row["username"], row["password"])) - logging.info(f'read {len(logins)} logins') + first_row = None + + # Loop through logins + for i, row in enumerate(reader): + + logging.debug(f"row: {row}") + + # Peek at the first line to detect if it is a header or normal row + if first_row is None: + logging.debug(f"first_row: {row}") + first_row = row + + # Break if we get an empty first row + if (not row) or (len(row) != 3) or (not row[1] and not row[2]): + logging.debug(f"Breaking loop since we got an empty row at index={i}.") + break + # Heuristic: if it lacks a URL (index=1) and has user,pass (index=2,3), assume it's a header and continue + if ( + "http://" not in first_row[0] + and first_row[1].lower() in {"username", "uname", "user", "u"} # noqa: W503 line break before binary operator + and first_row[2].lower() in {"password", "passwd", "pass", "p"} # noqa: W503 + ): + logging.debug(f"Continuing (skipping) over first row: [is_header={True}].") + continue + + # ~~~ END peek at first row ~~~~~~~~~~ + + # Break if we get an empty row at any time + if (not row) or (len(row) != 3) or (not row[1] and not row[2]): + logging.debug(f"Breaking loop since we got an empty row at index={i}.") + break + + u, n, p = row + logins.append((rawURL(u), n, p)) + return logins def rawURL(url): + if not url: + return "" + + # Fix for schemeless URLs (e.g. "test.com" -> "https://test.com") + # Without a scheme, urlparse puts the whole string in 'path' and leaves 'netloc' empty. + # ffpass expects 'netloc' to be populated to strip paths. + if "://" not in url: + url = "https://" + url + p = urlparse(url) return type(p)(*p[:2], *[""] * 4).geturl() @@ -251,9 +427,9 @@ def rawURL(url): def addNewLogins(key, jsonLogins, logins): nextId = jsonLogins["nextId"] timestamp = int(datetime.now().timestamp() * 1000) - logging.info('adding logins') + logging.warning(f'adding {len(logins)} logins') for i, (url, username, password) in enumerate(logins, nextId): - logging.debug(f'adding {url} {username}') + logging.info(f'adding {url} {username}') entry = { "id": i, "hostname": url, @@ -274,23 +450,30 @@ def addNewLogins(key, jsonLogins, logins): jsonLogins["nextId"] += len(logins) -def guessDir(): - dirs = { - "darwin": "~/Library/Application Support/Firefox/Profiles", - "linux": "~/.mozilla/firefox", - "win32": os.path.expandvars(r"%LOCALAPPDATA%\Mozilla\Firefox\Profiles"), - "cygwin": os.path.expandvars(r"%LOCALAPPDATA%\Mozilla\Firefox\Profiles"), - } +# Constants used to guess cross-platform +PROFILE_GUESS_DIRS = { + "darwin": "~/Library/Application Support/Firefox/Profiles", + "linux": "~/.mozilla/firefox", + "win32": os.path.expandvars("%APPDATA%\\Mozilla\\Firefox\\Profiles"), + "cygwin": os.path.expandvars("%APPDATA%\\Mozilla\\Firefox\\Profiles"), +} + + +def getProfiles() -> list[Path]: + paths = Path(PROFILE_GUESS_DIRS[sys.platform]).expanduser() + logging.debug(f"Paths: {paths}") + profiles = [path.parent for path in paths.glob(os.path.join("*", "logins.json"))] + logging.debug(f"Profiles: {profiles}") + return profiles + - if sys.platform not in dirs: +def guessDir() -> Path: + if sys.platform not in PROFILE_GUESS_DIRS: logging.error(f"Automatic profile selection is not supported for {sys.platform}") logging.error("Please specify a profile to parse (-d path/to/profile)") raise NoProfile - paths = Path(dirs[sys.platform]).expanduser() - profiles = [path.parent for path in paths.glob(os.path.join("*", "logins.json"))] - logging.debug(f"Paths: {paths}") - logging.debug(f"Profiles: {profiles}") + profiles = getProfiles() if len(profiles) == 0: logging.error("Cannot find any Firefox profiles") @@ -309,40 +492,52 @@ def guessDir(): def askpass(directory): password = "" + n = 0 + # Allow 1 automatic check + 2 user prompts = 3 attempts total. + # The condition "n < n_max" must allow n=0, n=1, n=2. + n_max = 3 while True: try: - key = getKey(directory, password) + keys, _ = get_all_keys(directory, password) + # Prefer 32-byte key, fallback to first + best_key = next((k for k in keys if len(k) == 32), keys[0]) + logging.info(f"Selected Master Key: {len(best_key)} bytes (from {len(keys)} candidates)") + return best_key except WrongPassword: - password = getpass("Master Password:") - else: - break - return key + n += 1 + if n >= n_max: + break + password = getpass("Master Password: ") + + if n > 0: + logging.error(f"wrong master password after {n_max - 1} prompts!") + return None def main_export(args): - try: - key = askpass(args.directory) - except NoDatabase: - # if the database is empty, we are done! + # Removed try/except NoDatabase here to let it bubble to main() for proper logging + key = askpass(args.directory) + + if not key: + logging.error("Failed to derive master key.") return + jsonLogins = getJsonLogins(args.directory) logins = exportLogins(key, jsonLogins) - writer = csv.writer(args.file) + # Hard-code to "\n" to fix Windows bug with every other row empty: [a, "", b, "", ...]. + writer = csv.writer(args.file, lineterminator="\n") writer.writerow(["url", "username", "password"]) writer.writerows(logins) def main_import(args): - if args.file == sys.stdin: - try: - key = getKey(args.directory) - except WrongPassword: - # it is not possible to read the password - # if stdin is used for input - logging.error("Password is not empty. You have to specify FROM_FILE.") - sys.exit(1) - else: - key = askpass(args.directory) + # askpass handles stdin/tty detection for the password prompt automatically + key = askpass(args.directory) + + if not key: + logging.error("Failed to derive master key.") + return + jsonLogins = getJsonLogins(args.directory) logins = readCSV(args.file) addNewLogins(key, jsonLogins, logins) @@ -367,65 +562,79 @@ def makeParser(): ) parser_import.add_argument( - "-f", - "--file", - dest="file", - type=argparse.FileType("r", encoding="utf-8"), - default=sys.stdin, + "-f", "--file", dest="file", type=argparse.FileType("r", encoding="utf-8"), default=sys.stdin ) parser_export.add_argument( - "-f", - "--file", - dest="file", - type=argparse.FileType("w", encoding="utf-8"), - default=sys.stdout, + "-f", "--file", dest="file", type=argparse.FileType("w", encoding="utf-8"), default=sys.stdout ) for sub in subparsers.choices.values(): - sub.add_argument( + arg = sub.add_argument( + "-p", # matches native: firefox -p "-d", "--directory", "--dir", type=Path, + metavar="DIRECTORY", default=None, help="Firefox profile directory", ) + # Use argcomplete completer instead of 'choices=' + # This allows arbitrary paths (tests) but gives users tab completion. + arg.completer = lambda **kwargs: [str(p) for p in getProfiles()] + sub.add_argument("-v", "--verbose", action="store_true") sub.add_argument("--debug", action="store_true") parser_import.set_defaults(func=main_import) parser_export.set_defaults(func=main_export) + + # Try to load argcomplete + try: + import argcomplete + argcomplete.autocomplete(parser) + + except ModuleNotFoundError: + sys.stderr( + "NOTE: You can run 'pip install argcomplete' " + "and add the hook to your shell RC for tab completion." + ) + return parser def main(): - logging.basicConfig(level=logging.ERROR, format="%(levelname)s: %(message)s") - parser = makeParser() args = parser.parse_args() - if args.verbose: - log_level = logging.INFO - elif args.debug: + # Determine log level + log_level = logging.WARNING + if args.debug: log_level = logging.DEBUG - else: - log_level = logging.ERROR + elif args.verbose: + log_level = logging.INFO - logging.getLogger().setLevel(log_level) + logging.basicConfig(level=log_level, format="%(message)s") + # Try to obtain profile directory if args.directory is None: try: - args.directory = guessDir() + args.directory = guessDir().expanduser() except NoProfile: - print("") + logging.error("No Firefox profile selected.") parser.print_help() parser.exit() - args.directory = args.directory.expanduser() + # Run arg parser try: - args.func(args) + # Wrap in try/except for BrokenPipeError to allow piping to head, i.e., ffpass export | head -5 + try: + args.func(args) + except BrokenPipeError: + sys.stdout = os.fdopen(1, 'w') + except NoDatabase: - logging.error("Firefox password database is empty. Please create it from Firefox.") + logging.error("Firefox password database is empty.") if __name__ == "__main__": diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..ded13e8 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +coverage==7.13.0 +flake8==7.3.0 +pytest==9.0.2 + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9a633f2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +argcomplete>=3.5.2 +pyasn1~=0.6.1 +pycryptodome~=3.23.0 + diff --git a/scripts/generate_mixed_profile.py b/scripts/generate_mixed_profile.py new file mode 100755 index 0000000..7a9a983 --- /dev/null +++ b/scripts/generate_mixed_profile.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Created on Thu Dec 25 20:34:59 2025 + +@author: shane +""" + +import json +import sqlite3 +from pathlib import Path + +# Constants for Firefox Crypto +MAGIC1 = b"\xf8\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01" + + +def create_mixed_profile(): + base_dir = Path("tests/firefox-mixed-keys") + if base_dir.exists(): + print(f"Directory {base_dir} already exists. Skipping generation.") + return + base_dir.mkdir(parents=True) + + print(f"Generating mixed key profile in {base_dir}...") + + # 1. Create key4.db with TWO keys + conn = sqlite3.connect(base_dir / "key4.db") + c = conn.cursor() + c.execute("CREATE TABLE metadata (id TEXT PRIMARY KEY, item1, item2)") + c.execute("CREATE TABLE nssPrivate (a11, a102)") + + # Metadata: Simple password check (Salt + Check Blob) + # This is a dummy check that our mock/test logic accepts + c.execute( + "INSERT INTO metadata VALUES ('password', ?, ?)", + (b"global_salt", b"pw_check_blob"), + ) + + # nssPrivate: Insert the MIXED keys + # Key 0: Legacy 24-byte key blob (We simulate this decrypting to 24 bytes) + # In a real DB, this is ASN.1 wrapped. For our integration test, + # we rely on the mocked decryptor in the test to interpret this specific blob. + c.execute("INSERT INTO nssPrivate VALUES (?, ?)", (b"blob_legacy_24", MAGIC1)) + + # Key 1: Modern 32-byte key blob + c.execute("INSERT INTO nssPrivate VALUES (?, ?)", (b"blob_modern_32", MAGIC1)) + + conn.commit() + conn.close() + + # 2. Create logins.json encrypted with the MODERN key + # Our test infrastructure mocks the decryption, so we can use dummy base64 strings + # The crucial part is that the test asserts it extracts data using the modern key logic + logins_data = { + "nextId": 2, + "logins": [ + { + "id": 1, + "hostname": "http://www.mixedkeys.com", + "encryptedUsername": "QUFBQUFBQUE=", # Base64 for 'AAAAAAAA' + "encryptedPassword": "QUFBQUFBQUE=", + "deleted": False, + } + ], + } + + with open(base_dir / "logins.json", "w") as f: + json.dump(logins_data, f) + + print("Done.") + + +if __name__ == "__main__": + create_mixed_profile() diff --git a/scripts/generate_mp_profile.py b/scripts/generate_mp_profile.py new file mode 100755 index 0000000..59d7e04 --- /dev/null +++ b/scripts/generate_mp_profile.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Created on Fri Dec 26 00:10:05 2025 + +@author: shane +""" + +import hmac +import json +import secrets +import sqlite3 +from hashlib import sha1 +from pathlib import Path + +from Crypto.Cipher import AES, DES3 +# Dependencies: pyasn1, pycryptodome +from pyasn1.codec.der.encoder import encode as der_encode +from pyasn1.type.univ import Integer, ObjectIdentifier, OctetString, Sequence + +# Constants +MASTER_PASSWORD = "password123" +GLOBAL_SALT = secrets.token_bytes(20) +# We will generate a 24-byte (3DES) master key to encrypt the database +REAL_MASTER_KEY = secrets.token_bytes(24) + +# OIDs +OID_PKCS12_3DES = (1, 2, 840, 113_549, 1, 12, 5, 1, 3) +MAGIC1 = b"\xf8\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01" +MAGIC_AES = (2, 16, 840, 1, 101, 3, 4, 1, 42) + + +def PKCS7pad(b, block_size=8): + pad_len = (-len(b) - 1) % block_size + 1 + return b + bytes([pad_len] * pad_len) + + +def derive_3des_key(global_salt, master_password, entry_salt): + """ + Derives Key and IV using the specific Firefox/NSS PKCS#12-like KDF. + Matches decrypt3DES in ffpass/__init__.py + """ + hp = sha1(global_salt + master_password.encode()).digest() + pes = entry_salt + b"\x00" * (20 - len(entry_salt)) + chp = sha1(hp + entry_salt).digest() + k1 = hmac.new(chp, pes + entry_salt, sha1).digest() + tk = hmac.new(chp, pes, sha1).digest() + k2 = hmac.new(chp, tk + entry_salt, sha1).digest() + k = k1 + k2 + iv = k[-8:] + key = k[:24] + return key, iv + + +def asn1_wrap_3des(entry_salt, ciphertext): + """ + Wraps the salt and ciphertext in the ASN.1 structure expected by Firefox. + Structure: Sequence[ Sequence[ OID, Sequence[Salt, Iters] ], Ciphertext ] + """ + # 1. Algorithm Identifier + params = Sequence() + params[0] = OctetString(entry_salt) + params[1] = Integer(1) # Iterations + + algo_id = Sequence() + algo_id[0] = ObjectIdentifier(OID_PKCS12_3DES) + algo_id[1] = params + + # 2. Outer Sequence + outer = Sequence() + outer[0] = algo_id + outer[1] = OctetString(ciphertext) + + return der_encode(outer) + + +def encrypt_pbe(data, global_salt, master_password): + """ + Encrypts data (e.g. password-check or master key) using 3DES PBE. + Returns the DER-encoded ASN.1 blob. + """ + entry_salt = secrets.token_bytes(20) + key, iv = derive_3des_key(global_salt, master_password, entry_salt) + + cipher = DES3.new(key, DES3.MODE_CBC, iv) + padded_data = PKCS7pad(data) + ciphertext = cipher.encrypt(padded_data) + + return asn1_wrap_3des(entry_salt, ciphertext) + + +def encode_login_data(key, data): + """ + Encrypts a username or password using the Master Key (AES-256 logic). + Matches encodeLoginData in ffpass/__init__.py + """ + # Use AES-256 if key is 32 bytes, else 3DES. Our REAL_MASTER_KEY is 24 bytes (3DES). + # To match modern Firefox better, let's pretend we use 3DES for the DB entry + # but the logic handles whatever key we give it. + # Let's stick to the AES path here if we want; but wait, REAL_MASTER_KEY is 24 bytes. + # We must use 3DES logic for the login entry if key is 24 bytes. + + asn1data = Sequence() + asn1data[0] = OctetString(MAGIC1) + asn1data[1] = Sequence() + + if len(key) == 32: + # AES Logic + iv = secrets.token_bytes(16) + cipher = AES.new(key, AES.MODE_CBC, iv) + ciphertext = cipher.encrypt(PKCS7pad(data.encode(), block_size=16)) + asn1data[1][0] = ObjectIdentifier(MAGIC_AES) + asn1data[1][1] = OctetString(iv) + asn1data[2] = OctetString(ciphertext) + else: + # 3DES Logic (matches our 24-byte master key) + # OID: 1.2.840.113549.3.7 (des-ede3-cbc) + OID_3DES_CBC = (1, 2, 840, 113_549, 3, 7) + iv = secrets.token_bytes(8) + cipher = DES3.new(key, DES3.MODE_CBC, iv) + ciphertext = cipher.encrypt(PKCS7pad(data.encode(), block_size=8)) + asn1data[1][0] = ObjectIdentifier(OID_3DES_CBC) + asn1data[1][1] = OctetString(iv) + asn1data[2] = OctetString(ciphertext) + + from base64 import b64encode + + return b64encode(der_encode(asn1data)).decode() + + +def create_mp_profile(): + base_dir = Path("tests/firefox-mp-test") + if base_dir.exists(): + import shutil + + shutil.rmtree(base_dir) + base_dir.mkdir(parents=True) + + print(f"Generating Real Encrypted MP profile in {base_dir}...") + + # 1. Create key4.db + conn = sqlite3.connect(base_dir / "key4.db") + c = conn.cursor() + c.execute("CREATE TABLE metadata (id TEXT PRIMARY KEY, item1, item2)") + c.execute("CREATE TABLE nssPrivate (a11, a102)") + + # A. Metadata: Password Check + # The tool verifies password by decrypting this and checking for "password-check\x02\x02" + # The encrypt_pbe function handles padding. + password_check_blob = encrypt_pbe(b"password-check", GLOBAL_SALT, MASTER_PASSWORD) + c.execute( + "INSERT INTO metadata VALUES ('password', ?, ?)", + (GLOBAL_SALT, password_check_blob), + ) + + # B. nssPrivate: Encrypted Master Key + # The tool decrypts this to get the key used for logins.json + master_key_blob = encrypt_pbe(REAL_MASTER_KEY, GLOBAL_SALT, MASTER_PASSWORD) + c.execute("INSERT INTO nssPrivate VALUES (?, ?)", (master_key_blob, MAGIC1)) + + conn.commit() + conn.close() + + # 2. Create logins.json + # These strings are actually encrypted with REAL_MASTER_KEY now + logins_data = { + "nextId": 2, + "logins": [ + { + "id": 1, + "hostname": "https://locked.com", + "encryptedUsername": encode_login_data(REAL_MASTER_KEY, "secret_user"), + "encryptedPassword": encode_login_data(REAL_MASTER_KEY, "secret_pass"), + "deleted": False, + } + ], + } + + with open(base_dir / "logins.json", "w") as f: + json.dump(logins_data, f) + + print("Done.") + + +if __name__ == "__main__": + create_mp_profile() diff --git a/setup.py b/setup.py index cb58157..57fb9a8 100755 --- a/setup.py +++ b/setup.py @@ -5,12 +5,12 @@ def read(fname): - return open(os.path.join(os.path.dirname(__file__), fname)).read() + return open(os.path.join(os.path.dirname(__file__), fname), encoding="utf-8").read() setup( name="ffpass", - version="0.5.0", + version="0.6.0", author="Louis Abraham", license="MIT", author_email="louis.abraham@yahoo.fr", diff --git a/tests/firefox-146-aes/key4.db b/tests/firefox-146-aes/key4.db new file mode 100644 index 0000000000000000000000000000000000000000..935e417f99389fdaceb2479c489c6e32ffbeb7d2 GIT binary patch literal 294912 zcmeI*Ym8h~9RTpTyVGfBVOK2Gilj1?5ZFrK-aE6miytgYAM|BQYl-FMGVN~3+Ll6h zfi5UZkyZkP_#)ASm^6ljhfxv42p=Hu!3dI=XncOc5Tgb$C`cPXz31#~mRco#F-H44 zxpU`#&*Oj3`JJ=d{nn3dT0b^1+SsvY@9y?QqglD7k|dS$8;we(QY}CA@-tIvWf1CS zN-`6?!*{iE&K(PSrw&){0;uDNp;E?SiA|IkEx+pf{wqZ92F?TPlx_q^qsR}O7i z+1N64!TObrncBu#VsDUUxOl^wO&70hY~0XTzH!5<^=p=IX>4A3 z(fXm~E6;9}|HJX_Z2BwjKX+bj;qpaEW$fya(S76B?kXFH+t*F(iO<6``G>QasD44M zws7I%~P|%<=J< zV^@3I=&so=wWD&x#$$3c`&U(K3r}5~3{Ow{x^15v-99m!W6n`I#;2Q)$&#g6|MKqI z!c%5eGd4bc-RR!gG}WWhOt&7BWiac%vnQFmaPi{gp5eG1yZJVwCcQ$V5 zf{mNEoZV=r<)_@Yc3ys(<);;%V`Zq#LiJ#H91M?x@iBB84E+WN!mS9mrQtRhZs&zt zJKVN~+xGHyUd)$H-(%V|B+Al|DNCnc8FFQ5NS37`Tb71&SsLg<~(N3W9DWY+l*tIacnb=ZN{-hY%OAI5nGGcT1>ab z%tg$+H1=E?doGPVBU={T^0@Xq4Xt@xdmiWCO3RXGp}G@>k)1HSD9=OMJT5KILt`G7 zmdB;#acOy6S{|2{=cBRDj(87fc^=U6JfP+INVpHgoC9$(10iQKPOllK*NoFEr%=|n zVoj@46Xw&3o7GCg!dr2(T5*}JxXe~O9|LjhKpZ;|#}34eE@Dj)Yl==yU@T&`B4#V% z))#T>i@5bg-1;JJeG#|5NW&_MG_15p!%B;YSwzPo9@!!b8(m~!ql+wTbdd#(A`2Qt z7Bq@1=E-87Ear*YMbs{$b`iCUs9i+uB5D^=yNE}qh{vhOWB#aJMC~GK7jZ|5Jm!yw zt%!%Mh=;9+hpmW*t!M_-q8W};(TqP(;drExZTsjzv(=1y)(OMLb;7VSoiKoQ!jP>K zhLoK!xf9L~iwC;izcK66^Ke?@gcU*G&n<<>3#@R|jc zv-@JH^i?YJ%N|FUa#UYmX!`G$Work|FK1CrlKU!`ruz@2yKD6&2Uj0_y*HWDn_ig? z%|4!(wyKR4ufI`y;p9tBsV}+xx!3M|a9;fnKiPWIx-Y)#!5E;jqvi1Af8Ec|-isW&U-r4S%MfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+0D5E$mfbqPGCp?I@Xpc6;pUE3yE)iO zhtpWHEZu)gdU>tB&EFhYe#MW@eB(e@l5{1Fw5K+2I!jmm?Qyly_1Cp`4=#A|;zRS!yJzlK z()6)C>z+ID?$aN7;w;_A#rIA5Qo0Pj}DGt2e#!m}%a-;j$ZR&pvsHmMx2Rr07M# zC{0^QcrWDKsL*J;b6v$Uu zCG?r+UV5z6J~^wmyX@5M)|mQiAF8TZa;ke;vD`2z2}geLklsdWm5VzW&*nU@#O-{P zecP{O*d^7e@kUfbKmY;|fB*y_009U<00Izz00bcL=LEKB!Q9*)i!`0Yi9d+qo``rV zA~~M${&mp5;XSpsrrwJhA4D|-1Rwwb2tWV=5P$##AOHafKmY>&NMKzzSM&b@+4;X_ zyb$AET@Vm}00bZa0SG_<0uX=z1Rwwb2rQX^rRkMgc7)G|u^%KOe&9?OACX!#-x37_ L(SPL5;m<8!VHR+_ literal 0 HcmV?d00001 diff --git a/tests/firefox-mixed-keys/logins.json b/tests/firefox-mixed-keys/logins.json new file mode 100644 index 0000000..b45b714 --- /dev/null +++ b/tests/firefox-mixed-keys/logins.json @@ -0,0 +1 @@ +{"nextId": 2, "logins": [{"id": 1, "hostname": "http://www.mixedkeys.com", "encryptedUsername": "QUFBQUFBQUE=", "encryptedPassword": "QUFBQUFBQUE=", "deleted": false}]} \ No newline at end of file diff --git a/tests/firefox-mp-test/key4.db b/tests/firefox-mp-test/key4.db new file mode 100644 index 0000000000000000000000000000000000000000..c8ed7d9c3c243641b99e8c7e4d45a6a06b85c377 GIT binary patch literal 16384 zcmeI%K}Zx)7zgn8cE)8(*IA-kIt)Cq!`N;*V|7}Cwq-zR1KZs~MVQ_)v#{ju=+2~J zgm}mfVnpYKAo7qx5J-oha|9LLi%3rrL=a^rguOL}6ryV-|Bv_h-kTXd_C8ro8gh*n_Vg?Zsi|u;)h(*=_i(xHRo>3_&S8eBtH#Q=kTKux_Ztbn0l4S&|#@Rm7z>bPZl{-WX1Oy-e0SG_< z0uX=z1Rwwb2tWV=e@DQgP3rOK$SC)`3AbiHPT6yp#-?7%H(QNiw_RU+bznp9(Y0{% zk(u6^uTk34L?!)@y#K^Qcj;Zrr`6Y4{$%mNb$;*J{M8Vpa_nGhY-#rSWKZ$d{AOyU z>)Kw?7gatJF%S@d00bZa0SG_<0uX=z1Rwwb2<#$(W=Y)>{89LM|6eCceHS%|Rv-WY z2tWV=5P$##AOHafKmY>&kwB5k(^^u`{^ovvw!ZNF decrypt_key -> decodeLoginData all work + try: + main() + except SystemExit: + pass + + output = capture.getvalue() + print(output) # For debugging failures + + # Verify we successfully decrypted the specific credentials in logins.json + assert "url,username,password" in output + assert "https://locked.com,secret_user,secret_pass" in output + + +def test_export_with_wrong_password_retry(mp_profile): + """ + Verifies the retry logic: + 1. Enter wrong password -> fail + 2. Enter correct password -> succeed + """ + # Create an iterator that yields Wrong, then Right + # This simulates the user typing correctly on the second attempt + inputs = iter(["wrong_pass", MASTER_PASSWORD]) + + ffpass.getpass = lambda x: next(inputs) + + capture = StringIO() + + with patch("sys.argv", ["ffpass", "export", "-d", str(mp_profile)]), patch( + "sys.stdout", capture + ): + + try: + main() + except SystemExit: + pass + + output = capture.getvalue() + + # It should eventually succeed and print the data + assert "secret_user" in output + + +def test_import_with_stdin_password(mp_profile): + """ + Verifies that import also respects the password prompt mechanism. + """ + ffpass.getpass = lambda x: MASTER_PASSWORD + + # Prepare input CSV for import + input_csv = "url,username,password\nhttps://newsite.com,new_user,new_pass" + + # We need to mock stdin for the CSV data itself + # AND mock ffpass.getpass for the master password + + # ffpass.main_import reads from args.file. + # If args.file is sys.stdin, we must patch sys.stdin. + + with patch("sys.argv", ["ffpass", "import", "-d", str(mp_profile)]), patch( + "sys.stdin", StringIO(input_csv) + ): + + try: + main() + except SystemExit: + pass + + # Verify the new login was actually added to the file + # We can check by running export again or inspecting the JSON + import json + + with open(mp_profile / "logins.json", "r") as f: + data = json.load(f) + + # The file is encrypted, so we can't grep "new_user" directly. + # We just check that the login count increased (was 1, now 2) + assert len(data["logins"]) == 2 + assert data["nextId"] == 3 diff --git a/tests/test_run.py b/tests/test_run.py index a244a83..b6aea28 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -1,49 +1,88 @@ #!/usr/bin/env python3 +import os import subprocess +import shutil +import pytest +from pathlib import Path + +OS_NEWLINE = os.linesep MASTER_PASSWORD = 'test' -HEADER = 'url,username,password\n' -IMPORT_CREDENTIAL = 'http://www.example.com,foo,bar\n' -EXPECTED_EXPORT_OUTPUT = f'{HEADER}http://www.stealmylogin.com,test,test\n' -EXPECTED_IMPORT_OUTPUT = EXPECTED_EXPORT_OUTPUT + IMPORT_CREDENTIAL +HEADER = 'url,username,password' +IMPORT_CREDENTIAL = 'http://www.example.com,foo,bar' +EXPECTED_EXPORT_OUTPUT = [HEADER, 'http://www.stealmylogin.com,test,test'] +EXPECTED_IMPORT_OUTPUT = EXPECTED_EXPORT_OUTPUT + [IMPORT_CREDENTIAL] + + +@pytest.fixture +def clean_profile(tmp_path): + """ + Copies the requested profile to a temporary directory and returns + the path to the new copy. + """ + def _setup(profile_name): + src = Path('tests') / profile_name + dst = tmp_path / profile_name + shutil.copytree(src, dst) + return dst + return _setup def run_ffpass(mode, path): - command = ["ffpass", mode, "-d", path] + command = ["python", "./ffpass/__init__.py", mode, "-d", str(path)] + if mode == 'import': - ffpass_input = HEADER + IMPORT_CREDENTIAL + ffpass_input = OS_NEWLINE.join([HEADER, IMPORT_CREDENTIAL]) else: ffpass_input = None return subprocess.run(command, stdout=subprocess.PIPE, input=ffpass_input, encoding='utf-8') -def test_legacy_firefox_export(): - r = run_ffpass('export', 'tests/firefox-70') +def stdout_splitter(input_text): + return [x for x in input_text.splitlines()] + + +def test_legacy_firefox_export(clean_profile): + r = run_ffpass('export', clean_profile('firefox-70')) r.check_returncode() - assert r.stdout == EXPECTED_EXPORT_OUTPUT + actual_export_output = stdout_splitter(r.stdout) + assert actual_export_output == EXPECTED_EXPORT_OUTPUT -def test_firefox_export(): - r = run_ffpass('export', 'tests/firefox-84') +def test_firefox_export(clean_profile): + r = run_ffpass('export', clean_profile('firefox-84')) r.check_returncode() - assert r.stdout == EXPECTED_EXPORT_OUTPUT + assert stdout_splitter(r.stdout) == EXPECTED_EXPORT_OUTPUT -def test_legacy_firefox(): - r = run_ffpass('import', 'tests/firefox-70') +def test_firefox_aes_export(clean_profile): + # This uses your new AES-encrypted profile + profile_path = clean_profile('firefox-146-aes') + r = run_ffpass('export', profile_path) r.check_returncode() + assert stdout_splitter(r.stdout) == EXPECTED_EXPORT_OUTPUT - r = run_ffpass('export', 'tests/firefox-70') + +def test_legacy_firefox(clean_profile): + profile_path = clean_profile('firefox-70') + + # modifies the temp file, not the original + r = run_ffpass('import', profile_path) r.check_returncode() - assert r.stdout == EXPECTED_IMPORT_OUTPUT + r = run_ffpass('export', profile_path) + r.check_returncode() + assert stdout_splitter(r.stdout) == EXPECTED_IMPORT_OUTPUT + + +def test_firefox(clean_profile): + profile_path = clean_profile('firefox-84') -def test_firefox(): - r = run_ffpass('import', 'tests/firefox-84') + r = run_ffpass('import', profile_path) r.check_returncode() - r = run_ffpass('export', 'tests/firefox-84') + r = run_ffpass('export', profile_path) r.check_returncode() - assert r.stdout == EXPECTED_IMPORT_OUTPUT + assert stdout_splitter(r.stdout) == EXPECTED_IMPORT_OUTPUT From 9a6af28daea71e17380ef3941731d3867903866c Mon Sep 17 00:00:00 2001 From: Shane Jaroch Date: Fri, 26 Dec 2025 06:51:15 -0500 Subject: [PATCH 2/6] Fix errors w/ Windows & w/ GitHub runner. Configs. --- .github/workflows/testing.yaml | 74 +++++++++-------------- .github/workflows/windows-and-mac.yaml | 69 +++++++++++----------- Makefile | 57 ++++++++++++------ ffpass/__init__.py | 22 ++++--- requirements-dev.txt | 1 - requirements.txt | 1 - scripts/generate_mp_profile.py | 1 - setup.cfg | 81 ++++++++++++++++++++++++++ tests/__init__.py | 0 tests/test_mixed_keys_run.py | 2 - tests/test_run.py | 37 +++++++----- 11 files changed, 216 insertions(+), 129 deletions(-) create mode 100644 setup.cfg create mode 100644 tests/__init__.py diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml index d00081b..1446a7e 100644 --- a/.github/workflows/testing.yaml +++ b/.github/workflows/testing.yaml @@ -1,3 +1,4 @@ +--- name: ffpass on: [push, pull_request_target] @@ -7,53 +8,30 @@ jobs: name: Test runs-on: [ubuntu-latest] steps: - - name: Checkout - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.x' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install . - - - name: Test with pytest - run: | - pip install pytest - pip install pytest-cov - python -m pytest tests --junit-xml pytest.xml - - - name: Lint with flake8 - run: | - pip install flake8 - flake8 . --exclude='*venv,build' --ignore=E741,E501 - - - name: Upload Unit Test Results - if: always() - uses: actions/upload-artifact@v4 - with: - name: Unit Test Results - path: pytest.xml - - - publish-test-results: - name: "Publish Unit Tests Results" - needs: test - runs-on: ubuntu-latest - if: success() || failure() - - steps: - - name: Download Artifacts - uses: actions/download-artifact@v4 - with: - path: artifacts - - - name: Publish Unit Test Results - uses: EnricoMi/publish-unit-test-result-action@v2 + - name: Set up Python + uses: actions/setup-python@v5 with: - check_name: Unit Test Results - github_token: ${{ secrets.GITHUB_TOKEN }} - files: "artifacts/Unit Test Results/pytest.xml" + python-version: "3.x" + + - name: Install dependencies + run: > + python -m pip install --upgrade pip && + python -m pip install + coveralls + -r requirements.txt + -r requirements-dev.txt + + - name: Test with pytest + run: make test + + - name: Lint with flake8 + run: make lint + + - name: Submit coverage report / coveralls + if: always() + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: python -m coveralls --service=github diff --git a/.github/workflows/windows-and-mac.yaml b/.github/workflows/windows-and-mac.yaml index 29f3b2f..0ede6a5 100644 --- a/.github/workflows/windows-and-mac.yaml +++ b/.github/workflows/windows-and-mac.yaml @@ -1,50 +1,49 @@ +--- name: windows-and-mac on: [push, pull_request_target] jobs: windows: - name: windows runs-on: [windows-latest] steps: - - name: Checkout - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.x' + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install . + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install . - - name: Test with pytest - run: | - pip install pytest - pip install pytest-cov - python -m pytest tests --junit-xml pytest.xml + - name: Test with pytest + run: | + pip install pytest + pip install pytest-cov + python -m pytest tests --junit-xml pytest.xml macOS: - name: macOS runs-on: [macos-latest] steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.x' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install . - - - name: Test with pytest - run: | - pip install pytest - pip install pytest-cov - python -m pytest tests --junit-xml pytest.xml \ No newline at end of file + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install . + + - name: Test with pytest + run: | + pip install pytest + pip install pytest-cov + python -m pytest tests --junit-xml pytest.xml diff --git a/Makefile b/Makefile index 2390acd..9e05248 100644 --- a/Makefile +++ b/Makefile @@ -1,35 +1,54 @@ SHELL:=/bin/bash -.PHONY: pypi -pypi: dist - twine upload dist/* +.DEFAULT_GOAL=_help + +# NOTE: must put a character and two pound "\t##" to show up in this list. Keep it brief! IGNORE_ME +.PHONY: _help +_help: + @printf "\nUsage: make , valid commands:\n\n" + @grep "##" $(MAKEFILE_LIST) | grep -v IGNORE_ME | sed -e 's/##//' | column -t -s $$'\t' + -.PHONY: dist -dist: flake8 +.PHONY: build +build: lint ## Build release +build: -rm dist/* ./setup.py sdist bdist_wheel -.PHONY: flake8 -flake8: - flake8 . --exclude '*venv,build' --count --select=E901,E999,F821,F822,F823 --show-source --statistics - flake8 . --exclude '*venv,build' --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - # CI pipeline - flake8 . --exclude='*venv,build' --ignore=E741,E501 +.PHONY: release +release: build ## Upload release to PyPI (via Twine) + twine upload dist/* + + + +LINT_LOCS_PY ?= ffpass/ scripts tests/ + +.PHONY: format +format: ## Not phased in yet, no-op + -black --check ${LINT_LOCS_PY} + -isort --check ${LINT_LOCS_PY} + + +.PHONY: lint +lint: ## Lint the code + flake8 --count --show-source --statistics -.PHONY: install -install: - pip install . .PHONY: test -test: - @echo 'Remember to run make install to test against the latest :)' - coverage run -m pytest -svv tests/ - coverage report -m --omit="tests/*" +test: ## Run pytest & show coverage report + coverage run + coverage report + + + +.PHONY: install +install: ## Install from local source (via pip) + pip install . .PHONY: clean -clean: +clean: ## Clean up build files/cache rm -rf *.egg-info build dist rm -f .coverage find . \ diff --git a/ffpass/__init__.py b/ffpass/__init__.py index e7f7a09..3184934 100755 --- a/ffpass/__init__.py +++ b/ffpass/__init__.py @@ -50,6 +50,8 @@ from pyasn1.type.univ import Sequence, OctetString, ObjectIdentifier from Crypto.Cipher import AES, DES3 +# noqa: W503 +# line break before binary operator MAGIC1 = b"\xf8\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01" @@ -345,7 +347,7 @@ def getJsonLogins(directory): def dumpJsonLogins(directory, jsonLogins): with open(directory / "logins.json", "w") as loginf: - json.dump(jsonLogins, loginf, separators=",:") + json.dump(jsonLogins, loginf, separators=(",", ":")) def exportLogins(key, jsonLogins): @@ -390,11 +392,13 @@ def readCSV(csv_file): break # Heuristic: if it lacks a URL (index=1) and has user,pass (index=2,3), assume it's a header and continue if ( - "http://" not in first_row[0] - and first_row[1].lower() in {"username", "uname", "user", "u"} # noqa: W503 line break before binary operator - and first_row[2].lower() in {"password", "passwd", "pass", "p"} # noqa: W503 + first_row[0].lower() in {"url", "hostname", "website", "site", "address", "link"} + or ( + first_row[1].lower() in {"username", "uname", "user", "u"} + and first_row[2].lower() in {"password", "passwd", "pass", "p"} + ) ): - logging.debug(f"Continuing (skipping) over first row: [is_header={True}].") + logging.debug(f"Continuing (skipping) over first row: index=0, [is_header={True}].") continue # ~~~ END peek at first row ~~~~~~~~~~ @@ -594,11 +598,13 @@ def makeParser(): import argcomplete argcomplete.autocomplete(parser) - except ModuleNotFoundError: - sys.stderr( - "NOTE: You can run 'pip install argcomplete' " + except (ImportError, ModuleNotFoundError): + sys.stderr.write( + "Error: argcomplete not found, run 'pip install argcomplete' " "and add the hook to your shell RC for tab completion." ) + sys.stderr.write(os.linesep) + sys.stderr.flush() return parser diff --git a/requirements-dev.txt b/requirements-dev.txt index ded13e8..64ce1ef 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,3 @@ coverage==7.13.0 flake8==7.3.0 pytest==9.0.2 - diff --git a/requirements.txt b/requirements.txt index 9a633f2..b7383ab 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,3 @@ argcomplete>=3.5.2 pyasn1~=0.6.1 pycryptodome~=3.23.0 - diff --git a/scripts/generate_mp_profile.py b/scripts/generate_mp_profile.py index 59d7e04..2db9257 100755 --- a/scripts/generate_mp_profile.py +++ b/scripts/generate_mp_profile.py @@ -14,7 +14,6 @@ from pathlib import Path from Crypto.Cipher import AES, DES3 -# Dependencies: pyasn1, pycryptodome from pyasn1.codec.der.encoder import encode as der_encode from pyasn1.type.univ import Integer, ObjectIdentifier, OctetString, Sequence diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..af3409b --- /dev/null +++ b/setup.cfg @@ -0,0 +1,81 @@ +[tool:pytest] +# See: https://docs.pytest.org/en/7.1.x/reference/customize.html +testpaths = + tests + +[coverage:run] +# See: https://coverage.readthedocs.io/en/7.2.2/config.html#run +command_line = -m pytest -svv +source = ffpass + +[coverage:report] +fail_under = 75.00 +precision = 2 + +show_missing = True +skip_empty = True +skip_covered = True + +exclude_lines = + pragma: no cover + + + +[flake8] +exclude = .venv,venv,build,dist + +max-complexity = 10 +max-line-length = 127 + +ignore = + # line break before binary operator + W503, + # line too long + E501, + # ambiguous variable name + E741, + + + +[isort] +line_length = 88 +known_first_party = ntclient + +# See: https://copdips.com/2020/04/making-isort-compatible-with-black.html +multi_line_output = 3 +include_trailing_comma = True + + + +[mypy] +show_error_codes = True +;show_error_context = True +;pretty = True + +disallow_incomplete_defs = True +disallow_untyped_defs = True +disallow_untyped_calls = True +disallow_untyped_decorators = True + +;strict_optional = True +no_implicit_optional = True + +warn_return_any = True +warn_redundant_casts = True +warn_unreachable = True + +warn_unused_ignores = True +warn_unused_configs = True +warn_incomplete_stub = True + +# Our tests, they don't return a value typically +[mypy-tests.*] +disallow_untyped_defs = False + +# Our packages, nested dependencies +; [mypy-] +; ignore_missing_imports = True + +# 3rd party packages missing types +[mypy-argcomplete] +ignore_missing_imports = True diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_mixed_keys_run.py b/tests/test_mixed_keys_run.py index e77ed02..5d9582e 100644 --- a/tests/test_mixed_keys_run.py +++ b/tests/test_mixed_keys_run.py @@ -6,7 +6,6 @@ @author: shane """ -import os import shutil import sys from pathlib import Path @@ -14,7 +13,6 @@ import pytest -OS_NEWLINE = os.linesep HEADER = "url,username,password" EXPECTED_MIXED_OUTPUT = [HEADER, "http://www.mixedkeys.com,modern_user,modern_pass"] diff --git a/tests/test_run.py b/tests/test_run.py index b6aea28..801a56a 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -1,16 +1,14 @@ #!/usr/bin/env python3 -import os import subprocess import shutil -import pytest from pathlib import Path -OS_NEWLINE = os.linesep +import pytest MASTER_PASSWORD = 'test' HEADER = 'url,username,password' -IMPORT_CREDENTIAL = 'http://www.example.com,foo,bar' +IMPORT_CREDENTIAL = 'https://www.example.com,foo,bar' EXPECTED_EXPORT_OUTPUT = [HEADER, 'http://www.stealmylogin.com,test,test'] EXPECTED_IMPORT_OUTPUT = EXPECTED_EXPORT_OUTPUT + [IMPORT_CREDENTIAL] @@ -29,11 +27,11 @@ def _setup(profile_name): return _setup -def run_ffpass(mode, path): - command = ["python", "./ffpass/__init__.py", mode, "-d", str(path)] +def run_ffpass_cmd(mode, path): + command = ["python", "./ffpass/__init__.py", mode, "--debug", "--dir", str(path)] if mode == 'import': - ffpass_input = OS_NEWLINE.join([HEADER, IMPORT_CREDENTIAL]) + ffpass_input = "\n".join([HEADER, IMPORT_CREDENTIAL]) else: ffpass_input = None @@ -45,14 +43,14 @@ def stdout_splitter(input_text): def test_legacy_firefox_export(clean_profile): - r = run_ffpass('export', clean_profile('firefox-70')) + r = run_ffpass_cmd('export', clean_profile('firefox-70')) r.check_returncode() actual_export_output = stdout_splitter(r.stdout) assert actual_export_output == EXPECTED_EXPORT_OUTPUT def test_firefox_export(clean_profile): - r = run_ffpass('export', clean_profile('firefox-84')) + r = run_ffpass_cmd('export', clean_profile('firefox-84')) r.check_returncode() assert stdout_splitter(r.stdout) == EXPECTED_EXPORT_OUTPUT @@ -60,7 +58,7 @@ def test_firefox_export(clean_profile): def test_firefox_aes_export(clean_profile): # This uses your new AES-encrypted profile profile_path = clean_profile('firefox-146-aes') - r = run_ffpass('export', profile_path) + r = run_ffpass_cmd('export', profile_path) r.check_returncode() assert stdout_splitter(r.stdout) == EXPECTED_EXPORT_OUTPUT @@ -69,10 +67,10 @@ def test_legacy_firefox(clean_profile): profile_path = clean_profile('firefox-70') # modifies the temp file, not the original - r = run_ffpass('import', profile_path) + r = run_ffpass_cmd('import', profile_path) r.check_returncode() - r = run_ffpass('export', profile_path) + r = run_ffpass_cmd('export', profile_path) r.check_returncode() assert stdout_splitter(r.stdout) == EXPECTED_IMPORT_OUTPUT @@ -80,9 +78,20 @@ def test_legacy_firefox(clean_profile): def test_firefox(clean_profile): profile_path = clean_profile('firefox-84') - r = run_ffpass('import', profile_path) + r = run_ffpass_cmd('import', profile_path) + r.check_returncode() + + r = run_ffpass_cmd('export', profile_path) + r.check_returncode() + assert stdout_splitter(r.stdout) == EXPECTED_IMPORT_OUTPUT + + +def test_firefox_aes(clean_profile): + profile_path = clean_profile('firefox-146-aes') + + r = run_ffpass_cmd('import', profile_path) r.check_returncode() - r = run_ffpass('export', profile_path) + r = run_ffpass_cmd('export', profile_path) r.check_returncode() assert stdout_splitter(r.stdout) == EXPECTED_IMPORT_OUTPUT From 33e2251220bb0593b4d4e637eb2c99be435cf433 Mon Sep 17 00:00:00 2001 From: Shane Jaroch Date: Fri, 9 Jan 2026 05:39:01 -0500 Subject: [PATCH 3/6] master password v146 non-mixed profiles. inspect. ruff. use --reveal-keys flag use git-sqlite-filter for *.db files --- .gitattributes | 7 + Makefile | 6 +- ffpass/__init__.py | 494 +++++++++++++++++++++++++------ ffpass/nss.py | 155 ++++++++++ requirements-dev.txt | 2 + tests/conftest.py | 20 ++ tests/firefox-146-aes/key4.db | Bin 294912 -> 3808 bytes tests/firefox-14iv/cert9.db | 9 + tests/firefox-14iv/key4.db | 13 + tests/firefox-14iv/logins.json | 1 + tests/firefox-14iv/pkcs11.txt | 5 + tests/firefox-70/key4.db | Bin 294912 -> 2320 bytes tests/firefox-84/key4.db | Bin 294912 -> 2931 bytes tests/firefox-mixed-keys/key4.db | Bin 16384 -> 476 bytes tests/firefox-mp-70/key4.db | Bin 294912 -> 2320 bytes tests/firefox-mp-84/key4.db | Bin 294912 -> 2931 bytes tests/firefox-mp-test/key4.db | Bin 16384 -> 618 bytes tests/test_inspect.py | 58 ++++ tests/test_key.py | 34 ++- tests/test_mixed_keys_run.py | 21 +- tests/test_native_verify.py | 44 +++ tests/test_run.py | 61 ++-- tests/test_sha256_mock.py | 218 ++++++++++++++ 23 files changed, 985 insertions(+), 163 deletions(-) create mode 100644 .gitattributes create mode 100644 ffpass/nss.py create mode 100644 tests/conftest.py create mode 100644 tests/firefox-14iv/cert9.db create mode 100644 tests/firefox-14iv/key4.db create mode 100644 tests/firefox-14iv/logins.json create mode 100644 tests/firefox-14iv/pkcs11.txt create mode 100644 tests/test_inspect.py create mode 100644 tests/test_native_verify.py create mode 100644 tests/test_sha256_mock.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f42f38d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +*.db filter=sqlite diff=sqlite + +# NOTE: you can also configure the git filter/smudge for this repo: +# git config set --local filter.sqlite.clean=git-sqlite-clean %f +# git config set --local filter.sqlite.smudge=git-sqlite-smudge %f +# git config set --local filter.sqlite.required=true + diff --git a/Makefile b/Makefile index 9e05248..d9951e3 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,7 @@ release: build ## Upload release to PyPI (via Twine) -LINT_LOCS_PY ?= ffpass/ scripts tests/ +LINT_LOCS_PY ?= ffpass/ tests/ .PHONY: format format: ## Not phased in yet, no-op @@ -33,6 +33,7 @@ format: ## Not phased in yet, no-op .PHONY: lint lint: ## Lint the code flake8 --count --show-source --statistics + ruff check ${LINT_LOCS_PY} .PHONY: test @@ -54,5 +55,8 @@ clean: ## Clean up build files/cache find . \ -name .venv -prune \ -o -name __pycache__ -print \ + -o -name *.egg -print \ -o -name .pytest_cache -print \ + -o -name .coverage -print \ + -o -name .mypy_cache -print \ | xargs -r rm -rf diff --git a/ffpass/__init__.py b/ffpass/__init__.py index 3184934..a134e2d 100755 --- a/ffpass/__init__.py +++ b/ffpass/__init__.py @@ -9,11 +9,11 @@ Copyright (c) 2018 Louis Abraham Laurent Clevy (@lorenzo2472) # from https://github.com/lclevy/firepwd/blob/master/firepwd.py -\x1B[34m\033[F\033[F +\x1b[34m\033[F\033[F ffpass can import and export passwords from Firefox Quantum. -\x1B[0m\033[1m\033[F\033[F +\x1b[0m\033[1m\033[F\033[F example of usage: ffpass export --file passwords.csv @@ -28,30 +28,28 @@ """ -import sys -from base64 import b64decode, b64encode -from hashlib import sha1, pbkdf2_hmac import argparse -import json -from pathlib import Path +import binascii import csv +import json +import logging +import os.path import secrets -from getpass import getpass -from uuid import uuid4 -from datetime import datetime -from urllib.parse import urlparse import sqlite3 -import os.path -import logging import string +import sys +from base64 import b64decode, b64encode +from datetime import datetime +from getpass import getpass +from hashlib import pbkdf2_hmac, sha1, sha256, sha512 +from pathlib import Path +from urllib.parse import urlparse +from uuid import uuid4 +from Crypto.Cipher import AES, DES3 from pyasn1.codec.der.decoder import decode as der_decode from pyasn1.codec.der.encoder import encode as der_encode -from pyasn1.type.univ import Sequence, OctetString, ObjectIdentifier -from Crypto.Cipher import AES, DES3 - -# noqa: W503 -# line break before binary operator +from pyasn1.type.univ import ObjectIdentifier, OctetString, Sequence MAGIC1 = b"\xf8\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01" @@ -76,6 +74,12 @@ class WrongPassword(Exception): pass +class IncompatibleCryptoError(WrongPassword): + """Raised when encryption parameters are detected that ffpass cannot handle (e.g. 14-byte IV), suggesting the password might be correct but verification failed.""" + + pass + + class NoProfile(Exception): pass @@ -94,13 +98,13 @@ def censor(data): third = length // 3 two_thirds = (2 * length) // 3 - return f"{s[:third]}.....{s[two_thirds:]}" + return f"{s[:third]}...{s[two_thirds:]}" def clean_iv(iv_bytes): if len(iv_bytes) == 14: - return b'\x04\x0e' + iv_bytes - elif len(iv_bytes) == 18 and iv_bytes.startswith(b'\x04\x10'): + return b"\x04\x0e" + iv_bytes + elif len(iv_bytes) == 18 and iv_bytes.startswith(b"\x04\x10"): return iv_bytes[2:] return iv_bytes @@ -118,6 +122,7 @@ def PKCS7unpad(b): def decrypt3DES(globalSalt, masterPassword, entrySalt, encryptedData): import hmac + hp = sha1(globalSalt + masterPassword.encode()).digest() pes = entrySalt + b"\x00" * (20 - len(entrySalt)) chp = sha1(hp + entrySalt).digest() @@ -131,7 +136,30 @@ def decrypt3DES(globalSalt, masterPassword, entrySalt, encryptedData): return DES3.new(key, DES3.MODE_CBC, iv).decrypt(encryptedData) -def decrypt_key_entry(a11, global_salt, master_password): +def hash_password(global_salt, pwd, method): + if method == "sha1": + return sha1(global_salt + pwd.encode("utf-8")).digest() + elif method == "sha256": + return sha256(global_salt + pwd.encode("utf-8")).digest() + elif method == "plaintext": + return pwd.encode("utf-8") + elif method == "sha256_no_salt": + return sha256(pwd.encode("utf-8")).digest() + elif method == "sha1_no_salt": + return sha1(pwd.encode("utf-8")).digest() + elif method == "concat": + return global_salt + pwd.encode("utf-8") + + # SHA512 Variants + elif method == "sha512": + return sha512(global_salt + pwd.encode("utf-8")).digest() + elif method == "sha512_no_salt": + return sha512(pwd.encode("utf-8")).digest() + + raise ValueError(f"Unknown hashing method: {method}") + + +def decrypt_key_entry(a11, global_salt, master_password, hash_method="sha1"): try: decoded, _ = der_decode(a11) key_oid = decoded[0][0].asTuple() @@ -145,10 +173,12 @@ def decrypt_key_entry(a11, global_salt, master_password): key_len = int(pbkdf2_params[2]) logging.debug(f" > Method: PBKDF2-HMAC-SHA256 | Iterations: {iters}") - logging.debug(f" > Salt: {censor(entry_salt)} (Local) + {censor(global_salt)} (Global)") + logging.debug( + f" > Salt: {censor(entry_salt)} (Local) + {censor(global_salt)} (Global)" + ) - enc_pwd = sha1(global_salt + master_password.encode('utf-8')).digest() - k = pbkdf2_hmac('sha256', enc_pwd, entry_salt, iters, dklen=key_len) + enc_pwd = hash_password(global_salt, master_password, hash_method) + k = pbkdf2_hmac("sha256", enc_pwd, entry_salt, iters, dklen=key_len) iv = clean_iv(decoded[0][1][1][1].asOctets()) logging.debug(f" > Cipher: AES-256-CBC | IV: {censor(iv)}") @@ -162,19 +192,23 @@ def decrypt_key_entry(a11, global_salt, master_password): ciphertext = decoded[1].asOctets() logging.debug(" > Method: PKCS12-3DES-Derivation") - logging.debug(f" > Salt: {censor(entry_salt)} (Local) + {censor(global_salt)} (Global)") + logging.debug( + f" > Salt: {censor(entry_salt)} (Local) + {censor(global_salt)} (Global)" + ) - return PKCS7unpad(decrypt3DES(global_salt, master_password, entry_salt, ciphertext)) + return PKCS7unpad( + decrypt3DES(global_salt, master_password, entry_salt, ciphertext) + ) except Exception as e: logging.debug(f" > Failed: {e}") return None -def verify_password(global_salt, item2, pwd): +def verify_password(global_salt, item2, pwd): # noqa: C901 """ - Verifies the master password against the metadata entry (item2). Raises WrongPassword on failure. + Returns the successful hashing method ('sha1' or 'sha256'). """ try: decodedItem2, _ = der_decode(item2) @@ -183,27 +217,98 @@ def verify_password(global_salt, item2, pwd): except (IndexError, AttributeError): raise ValueError("Could not decode password validation data structure.") - if algorithm_oid == OID_PKCS12_3DES: - entrySalt = decodedItem2[0][1][0].asOctets() - cipherT = decodedItem2[1].asOctets() - clearText = decrypt3DES(global_salt, pwd, entrySalt, cipherT) - elif algorithm_oid == OID_PBES2: - algo = decodedItem2[0][1][0] - pbkdf2_params = algo[1] - entry_salt = pbkdf2_params[0].asOctets() - iters = int(pbkdf2_params[1]) - key_len = int(pbkdf2_params[2]) - enc_pwd = sha1(global_salt + pwd.encode('utf-8')).digest() - k = pbkdf2_hmac('sha256', enc_pwd, entry_salt, iters, dklen=key_len) - iv = clean_iv(decodedItem2[0][1][1][1].asOctets()) - cipher = AES.new(k, AES.MODE_CBC, iv) - clearText = cipher.decrypt(decodedItem2[1].asOctets()) - else: - raise ValueError(f"Unknown encryption method OID: {algorithm_oid}") + # Try various hashing combinations to guess the correct pre-hashing used by Firefox + methods_to_try = [ + "sha1", + "sha256", + "plaintext", + "sha512", + "sha256_no_salt", + "sha1_no_salt", + "sha512_no_salt", + ] + seen_14_byte_iv = False + for method in methods_to_try: + try: + logging.debug( + f"Attempting verification with method: {method}. Input Pwd Len: {len(pwd)}" + ) + if algorithm_oid == OID_PKCS12_3DES: + entrySalt = decodedItem2[0][1][0].asOctets() + cipherT = decodedItem2[1].asOctets() + clearText = decrypt3DES(global_salt, pwd, entrySalt, cipherT) + if clearText == b"password-check\x02\x02": + logging.info(f"Verified: Method={method} (3DES)") + return method + + elif algorithm_oid == OID_PBES2: + algo = decodedItem2[0][1][0] + pbkdf2_params = algo[1] + entry_salt = pbkdf2_params[0].asOctets() + iters = int(pbkdf2_params[1]) + key_len = int(pbkdf2_params[2]) + + hmac_algo = "sha256" + if len(pbkdf2_params) > 3: + prf_oid = pbkdf2_params[3][0].asTuple() + if prf_oid == (1, 2, 840, 113549, 2, 7): + hmac_algo = "sha1" + elif prf_oid == (1, 2, 840, 113549, 2, 9): + hmac_algo = "sha256" + elif prf_oid == (1, 2, 840, 113549, 2, 11): + hmac_algo = "sha512" + + cipher_params = decodedItem2[0][1][1] + raw_iv = cipher_params[1].asOctets() + + iv_candidates = [("standard", raw_iv)] + if len(raw_iv) == 14: + seen_14_byte_iv = True + iv_candidates = [ + ("clean_iv", b"\x04\x0e" + raw_iv), + ("pad_start_null", b"\x00\x00" + raw_iv), + ("pad_end_null", raw_iv + b"\x00\x00"), + ] + + cipherT = decodedItem2[1].asOctets() + for iv_name, iv in iv_candidates: + + try: + enc_pwd = hash_password(global_salt, pwd, method) + k = pbkdf2_hmac( + hmac_algo, enc_pwd, entry_salt, iters, dklen=key_len + ) + cipher = AES.new(k, AES.MODE_CBC, iv) + clearText = cipher.decrypt(cipherT) + + if clearText == b"password-check\x02\x02": + logging.info(f"Verified: Method={method}, IV={iv_name}") + if iv_name == "pad_start_null": + logging.warning( + "NOTICE: Profile uses NULL-PREFIXED IVs." + ) + return method + else: + logging.debug( + f" Mismatch: {method} / {iv_name} -> {clearText.hex()}" + ) + + except Exception: + continue + else: + raise ValueError(f"Unknown OID: {algorithm_oid}") + + except Exception as outer_e: + logging.debug(f"Method {method} skipped: {outer_e}") + continue - if clearText != b"password-check\x02\x02": - raise WrongPassword() + if seen_14_byte_iv: + raise IncompatibleCryptoError() + raise WrongPassword() + + except WrongPassword: + raise except Exception as e: logging.debug(f"Password check failed: {e}") raise WrongPassword() @@ -227,7 +332,7 @@ def get_all_keys(directory, pwd=""): logging.info(f"[*] Global Salt: {censor(global_salt)}") # 2. VERIFY PASSWORD EXPLICITLY - verify_password(global_salt, item2, pwd) + hash_method = verify_password(global_salt, item2, pwd) logging.info("[*] Password Verified Correctly") # 3. Find ALL Keys @@ -244,10 +349,12 @@ def get_all_keys(directory, pwd=""): for idx, (a11, a102) in enumerate(rows): logging.debug(f"[*] Attempting to decrypt Key #{idx} (ID: {censor(a102)})...") - key = decrypt_key_entry(a11, global_salt, pwd) + key = decrypt_key_entry(a11, global_salt, pwd, hash_method) if key: - logging.info(f"[*] Decrypted Key #{idx}: {len(key)} bytes | ID: {a102.hex()}") + logging.info( + f"[*] Decrypted Key #{idx}: {len(key)} bytes | ID: {a102.hex()}" + ) found_keys.append(key) else: logging.debug(f"[*] Key #{idx}: Failed to decrypt (Corrupt?)") @@ -255,7 +362,9 @@ def get_all_keys(directory, pwd=""): if not found_keys: # Rows existed, but none decrypted successfully. # Since password was verified in step 2, this IS corruption. - raise Exception("Database corrupted: Password verified, but no valid master keys could be decrypted.") + raise Exception( + "Database corrupted: Password verified, but no valid master keys could be decrypted." + ) return found_keys, global_salt @@ -267,7 +376,7 @@ def try_decrypt_login(key, ciphertext, iv): cipher = AES.new(key, AES.MODE_CBC, iv) pt = cipher.decrypt(ciphertext) res = PKCS7unpad(pt) - text = res.decode('utf-8') + text = res.decode("utf-8") if is_valid_text(text): return text, "AES-Standard" except Exception: @@ -279,7 +388,7 @@ def try_decrypt_login(key, ciphertext, iv): cipher = DES3.new(key, DES3.MODE_CBC, iv[:8]) pt = cipher.decrypt(ciphertext) res = PKCS7unpad(pt) - text = res.decode('utf-8') + text = res.decode("utf-8") if is_valid_text(text): return text, "3DES-Standard" except Exception: @@ -391,14 +500,20 @@ def readCSV(csv_file): logging.debug(f"Breaking loop since we got an empty row at index={i}.") break # Heuristic: if it lacks a URL (index=1) and has user,pass (index=2,3), assume it's a header and continue - if ( - first_row[0].lower() in {"url", "hostname", "website", "site", "address", "link"} - or ( - first_row[1].lower() in {"username", "uname", "user", "u"} - and first_row[2].lower() in {"password", "passwd", "pass", "p"} - ) + if first_row[0].lower() in { + "url", + "hostname", + "website", + "site", + "address", + "link", + } or ( + first_row[1].lower() in {"username", "uname", "user", "u"} + and first_row[2].lower() in {"password", "passwd", "pass", "p"} ): - logging.debug(f"Continuing (skipping) over first row: index=0, [is_header={True}].") + logging.debug( + f"Continuing (skipping) over first row: index=0, [is_header={True}]." + ) continue # ~~~ END peek at first row ~~~~~~~~~~ @@ -431,9 +546,9 @@ def rawURL(url): def addNewLogins(key, jsonLogins, logins): nextId = jsonLogins["nextId"] timestamp = int(datetime.now().timestamp() * 1000) - logging.warning(f'adding {len(logins)} logins') + logging.warning(f"adding {len(logins)} logins") for i, (url, username, password) in enumerate(logins, nextId): - logging.info(f'adding {url} {username}') + logging.info(f"adding {url} {username}") entry = { "id": i, "hostname": url, @@ -471,9 +586,37 @@ def getProfiles() -> list[Path]: return profiles +def get_native_logins(directory, password): + """ + Attempts to decrypt logins using safe, native NSS interactions via ctypes. + Returns list of logins or None/Empty list on failure. + """ + try: + # Import explicitly to handle potential path issues + try: + from ffpass.nss import decrypt_logins_native + except ImportError: + try: + from nss import decrypt_logins_native + except ImportError: + import sys + + root_dir = str(Path(__file__).resolve().parent.parent) + if root_dir not in sys.path: + sys.path.append(root_dir) + from ffpass.nss import decrypt_logins_native + + return decrypt_logins_native(directory, password) + except Exception as e: + logging.debug(f"Native decryption attempt failed: {e}") + return None + + def guessDir() -> Path: if sys.platform not in PROFILE_GUESS_DIRS: - logging.error(f"Automatic profile selection is not supported for {sys.platform}") + logging.error( + f"Automatic profile selection is not supported for {sys.platform}" + ) logging.error("Please specify a profile to parse (-d path/to/profile)") raise NoProfile @@ -484,8 +627,10 @@ def guessDir() -> Path: raise NoProfile if len(profiles) > 1: - logging.error("More than one profile detected. Please specify a profile to parse (-d path/to/profile)") - logging.error("valid profiles:\n\t\t" + '\n\t\t'.join(map(str, profiles))) + logging.error( + "More than one profile detected. Please specify a profile to parse (-d path/to/profile)" + ) + logging.error("valid profiles:\n\t\t" + "\n\t\t".join(map(str, profiles))) raise NoProfile profile_path = profiles[0] @@ -494,40 +639,189 @@ def guessDir() -> Path: return profile_path -def askpass(directory): - password = "" +def askpass(directory, allow_native=False): + # Determine initial password source + password = os.environ.get("FFPASS_SECRET", "") + is_interactive = sys.stdin.isatty() and not password + + # If interactive and no env secret, prompt immediately for the first attempt + if is_interactive: + password = getpass("Master Password: ") + n = 0 - # Allow 1 automatic check + 2 user prompts = 3 attempts total. - # The condition "n < n_max" must allow n=0, n=1, n=2. - n_max = 3 + # Max attempts: If env var set, try it once. If fails and non-interactive, stop. + # If interactive, allow retry. + # User requested trying "twice". + n_max = 2 + while True: + # 1. Try Python Protocol try: keys, _ = get_all_keys(directory, password) # Prefer 32-byte key, fallback to first best_key = next((k for k in keys if len(k) == 32), keys[0]) - logging.info(f"Selected Master Key: {len(best_key)} bytes (from {len(keys)} candidates)") - return best_key - except WrongPassword: - n += 1 - if n >= n_max: - break - password = getpass("Master Password: ") + logging.info( + f"Selected Master Key: {len(best_key)} bytes (from {len(keys)} candidates)" + ) + return best_key, None + except (WrongPassword, IncompatibleCryptoError): + # Python protocol failed. + pass # Fall through to native check + + # 2. Try Native Protocol (Fallback) + if allow_native: + # We treat native failure as silent (wrong password) unless it succeeds. + # But we might want to log if it was attempted. + native_res = get_native_logins(directory, password) + if native_res: + logging.info("Successfully authenticated via Native NSS backend.") + return None, native_res + + # 3. Handle Failure / Retry Loop + n += 1 + + # If we are non-interactive/env-var based, do NOT loop unless we are falling back to interactive? + # If FFPASS_SECRET was used (n=1 checked), and verification failed... + if os.environ.get("FFPASS_SECRET"): + logging.error("FFPASS_SECRET verification failed (both Python and Native).") + break + + if n >= n_max: + logging.error(f"Wrong master password after {n} prompts.") + break + + print("Wrong password. Please try again.") + password = getpass("Master Password: ") + + return None, None + + +def main_inspect(args): # noqa: C901 + db_path = args.directory / "key4.db" + if not db_path.exists(): + logging.error(f"Error: {db_path} not found.") + return + + print(f"Inspecting Profile: {args.directory}") + print(f"Database: {db_path}") + + conn = sqlite3.connect(str(db_path)) + c = conn.cursor() + + print("\n[METADATA]") + c.execute("SELECT id, item1, item2 FROM metadata WHERE id = 'password'") + row = c.fetchone() + if row: + print(f" ID: {row[0]}") + if args.reveal_keys: + print(f" Global Salt: {binascii.hexlify(row[1]).decode()}") + else: + gs_hex = binascii.hexlify(row[1]).decode() + print(f" Global Salt: {censor(gs_hex)} (Use --reveal-keys to show full)") + print(f" Password Check Data ({len(row[2])} bytes)") + + try: + decoded, _ = der_decode(row[2]) + # Structure is typically: + # Sequence: + # Sequence (AlgorithmIdentifier for KDF): e.g. pkcs5PBES2 + # OID + # Sequence (PBES2-params): + # Sequence (KeyDerivationFunc): + # OID (pkcs5PBKDF2) + # Sequence (PBKDF2-params): [salt, iters, keylen, prf] + # Sequence (EncryptionScheme): + # OID (aes256-cbc) + # OctetString (IV) + # OctetString (Ciphertext) + + pbes2_params = decoded[0][1] + kdf_seq = pbes2_params[0] + kdf_oid = kdf_seq[0] + pbkdf2_params = kdf_seq[1] + + print(f" KDF OID: {kdf_oid} (Expected: {OID_PBES2} for PBES2)") + + salt = pbkdf2_params[0] + iters = pbkdf2_params[1] + key_length = pbkdf2_params[2] + + if args.reveal_keys: + print(f" Salt: {binascii.hexlify(salt.asOctets()).decode()}") + else: + s_hex = binascii.hexlify(salt.asOctets()).decode() + print(f" Salt: {censor(s_hex)} (Use --reveal-keys to show full)") + print(f" Iterations: {iters}") + print(f" Key Length: {key_length}") + + hmac_algo = "Unknown (Default/SHA1)" + if len(pbkdf2_params) > 3: + prf_oid = pbkdf2_params[3][0].asTuple() + if prf_oid == (1, 2, 840, 113549, 2, 7): + hmac_algo = "HMAC-SHA1" + elif prf_oid == (1, 2, 840, 113549, 2, 9): + hmac_algo = "HMAC-SHA256" + elif prf_oid == (1, 2, 840, 113549, 2, 11): + hmac_algo = "HMAC-SHA512" + else: + hmac_algo = f"OID {prf_oid}" + print(f" PRF: {hmac_algo}") + + # Encryption Scheme + enc_scheme = pbes2_params[1] + enc_oid = enc_scheme[0] + iv = enc_scheme[1].asOctets() + + print(" Encryption Scheme:") + print(f" OID: {enc_oid}") + if args.reveal_keys: + print(f" IV: {binascii.hexlify(iv).decode()} (Length: {len(iv)})") + else: + iv_hex = binascii.hexlify(iv).decode() + print(f" IV: {censor(iv_hex)} (Length: {len(iv)})") + + if len(iv) == 14: + print("\n [!] WARNING: Non-standard 14-byte IV detected.") + print(" [!] This profile REQUIRES Native NSS backend for decryption.") + elif len(iv) == 16: + print(" [OK] Standard 16-byte IV.") + + except Exception as e: + + print(f" [ERROR] ASN.1 Decode Failed: {e}") + logging.debug(e, exc_info=True) + else: + print(" No 'password' entry found via SELECT.") + + print("\n[KEYS]") + c.execute("SELECT count(*) FROM nssPrivate") + count = c.fetchone()[0] + print(f" nssPrivate Entries: {count}") - if n > 0: - logging.error(f"wrong master password after {n_max - 1} prompts!") - return None + conn.close() def main_export(args): - # Removed try/except NoDatabase here to let it bubble to main() for proper logging - key = askpass(args.directory) + # args.directory is passed. allow_native=True for export. + key, native_logins = askpass(args.directory, allow_native=True) - if not key: + if native_logins: + # Native backend succeeded where python failed (or was chosen) + # Convert to list of tuples for CSV writer + logins = [] + for login in native_logins: + logins.append((login["hostname"], login["username"], login["password"])) + + elif key: + # Python backend succeeded + jsonLogins = getJsonLogins(args.directory) + logins = exportLogins(key, jsonLogins) + + else: + # Both failed or user cancelled logging.error("Failed to derive master key.") return - jsonLogins = getJsonLogins(args.directory) - logins = exportLogins(key, jsonLogins) # Hard-code to "\n" to fix Windows bug with every other row empty: [a, "", b, "", ...]. writer = csv.writer(args.file, lineterminator="\n") writer.writerow(["url", "username", "password"]) @@ -536,7 +830,8 @@ def main_export(args): def main_import(args): # askpass handles stdin/tty detection for the password prompt automatically - key = askpass(args.directory) + # Native backend is read-only (export), so allow_native=False + key, _ = askpass(args.directory, allow_native=False) if not key: logging.error("Failed to derive master key.") @@ -564,12 +859,28 @@ def makeParser(): "import", description="imports a CSV with columns `url,username,password` (order insensitive)", ) + parser_inspect = subparsers.add_parser( + "inspect", description="inspect key4.db metadata structures" + ) + parser_inspect.add_argument( + "--reveal-keys", + action="store_true", + help="Show sensitive keys/salts/IVs in output", + ) parser_import.add_argument( - "-f", "--file", dest="file", type=argparse.FileType("r", encoding="utf-8"), default=sys.stdin + "-f", + "--file", + dest="file", + type=argparse.FileType("r", encoding="utf-8"), + default=sys.stdin, ) parser_export.add_argument( - "-f", "--file", dest="file", type=argparse.FileType("w", encoding="utf-8"), default=sys.stdout + "-f", + "--file", + dest="file", + type=argparse.FileType("w", encoding="utf-8"), + default=sys.stdout, ) for sub in subparsers.choices.values(): @@ -590,12 +901,15 @@ def makeParser(): sub.add_argument("-v", "--verbose", action="store_true") sub.add_argument("--debug", action="store_true") + parser_inspect.set_defaults(func=main_inspect) + parser_import.set_defaults(func=main_import) parser_export.set_defaults(func=main_export) # Try to load argcomplete try: import argcomplete + argcomplete.autocomplete(parser) except (ImportError, ModuleNotFoundError): @@ -637,7 +951,7 @@ def main(): try: args.func(args) except BrokenPipeError: - sys.stdout = os.fdopen(1, 'w') + sys.stdout = os.fdopen(1, "w") except NoDatabase: logging.error("Firefox password database is empty.") diff --git a/ffpass/nss.py b/ffpass/nss.py new file mode 100644 index 0000000..a68ab24 --- /dev/null +++ b/ffpass/nss.py @@ -0,0 +1,155 @@ +import base64 +import ctypes +import json +import os +from ctypes import ( + POINTER, + Structure, + byref, + c_char_p, + c_int, + c_ubyte, + c_uint, + c_void_p, + cast, +) + + +# --- NSS Structures --- +class SECItem(Structure): + _fields_ = [ + ("type", c_uint), + ("data", POINTER(c_ubyte)), + ("len", c_uint), + ] + + +# --- Load Library --- +def load_nss(): + paths = [ + "/usr/lib/x86_64-linux-gnu/libnss3.so", + "/usr/lib/libnss3.so", + "/usr/lib64/libnss3.so", + "libnss3.so", + ] + lib = None + for p in paths: + try: + lib = ctypes.CDLL(p) + break + except OSError: + pass + return lib + + +def decrypt_sdr(lib, b64_data): + if not b64_data: + return None + + try: + raw_data = base64.b64decode(b64_data) + except Exception: + return None + + # Prepare SECItem input + item_in = SECItem() + item_in.type = 0 # SECItemTypeBuffer + item_in.len = len(raw_data) + + # Create byte buffer matching length + buf = (c_ubyte * len(raw_data)).from_buffer_copy(raw_data) + item_in.data = cast(buf, POINTER(c_ubyte)) + + # Output item + item_out = SECItem() + + ret = lib.PK11SDR_Decrypt(byref(item_in), byref(item_out), None) + + if ret == 0: + # Success + content = ctypes.string_at(item_out.data, item_out.len) + return content.decode("utf-8", errors="replace") + else: + return None + + +def decrypt_logins_native(profile_path, password): # noqa: C901 + """ + Uses libnss3 to authenticate and decrypt logins.json. + Returns a list of dicts: {'hostname': ..., 'username': ..., 'password': ...} + Raises Exception on failure. + """ + lib = load_nss() + if not lib: + raise ImportError("Could not load libnss3.so") + + # Function Signatures + lib.NSS_Init.argtypes = [c_char_p] + lib.NSS_Init.restype = c_int + + lib.PK11_GetInternalKeySlot.restype = c_void_p + + lib.PK11_CheckUserPassword.argtypes = [c_void_p, c_char_p] + lib.PK11_CheckUserPassword.restype = c_int + + lib.NSS_Shutdown.restype = c_int + + lib.PK11SDR_Decrypt.argtypes = [POINTER(SECItem), POINTER(SECItem), c_void_p] + lib.PK11SDR_Decrypt.restype = c_int + + # Initialize NSS + db_dir = os.path.abspath(profile_path) + if db_dir.endswith("/key4.db") or db_dir.endswith("/logins.json"): + db_dir = os.path.dirname(db_dir) + + config_dir = f"sql:{db_dir}".encode("utf-8") + + # We must try to init. If already inited by process? + # Python process usually distinct. + ret = lib.NSS_Init(config_dir) + if ret != 0: + raise Exception(f"NSS_Init failed (Ret: {ret})") + + try: + # Authenticate + slot = lib.PK11_GetInternalKeySlot() + if not slot: + raise Exception("Could not get internal key slot") + + res = lib.PK11_CheckUserPassword(slot, password.encode("utf-8")) + if res != 0: + raise ValueError("Invalid Password (NSS rejected)") + + # Read logins.json + logins_path = os.path.join(db_dir, "logins.json") + if not os.path.exists(logins_path): + return [] # No file, empty list + + with open(logins_path, "r") as f: + data = json.load(f) + + if "logins" not in data: + return [] + + results = [] + for login in data["logins"]: + hostname = login.get("hostname", "") + enc_user = login.get("encryptedUsername") + enc_pass = login.get("encryptedPassword") + + dec_user = decrypt_sdr(lib, enc_user) + dec_pass = decrypt_sdr(lib, enc_pass) + + if dec_user is None: + dec_user = "(error)" + if dec_pass is None: + dec_pass = "(error)" + + results.append( + {"hostname": hostname, "username": dec_user, "password": dec_pass} + ) + + return results + + finally: + lib.NSS_Shutdown() diff --git a/requirements-dev.txt b/requirements-dev.txt index 64ce1ef..f979348 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,5 @@ coverage==7.13.0 flake8==7.3.0 pytest==9.0.2 +ruff==0.14.11 + diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..469b634 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,20 @@ +import shutil +from pathlib import Path + +import pytest + + +@pytest.fixture +def clean_profile(tmp_path): + """ + Copies the requested profile to a temporary directory and returns + the path to the new copy. + """ + + def _setup(profile_name): + src = Path("tests") / profile_name + dst = tmp_path / profile_name + shutil.copytree(src, dst) + return dst + + return _setup diff --git a/tests/firefox-146-aes/key4.db b/tests/firefox-146-aes/key4.db index 935e417f99389fdaceb2479c489c6e32ffbeb7d2..95735f17b50ada222468ff8f3aff73cc5f9e5fc0 100644 GIT binary patch literal 3808 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 sr` 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 Y1PBlyu&)FTmX3`+*WTK?zIk=+AJYN#fdBvi diff --git a/tests/firefox-84/key4.db b/tests/firefox-84/key4.db index 36f2e37e8133e139680ffe986776dfbd3930ffcf..762c46e63beb01b95402798e719341915990acb8 100644 GIT binary patch literal 2931 zcmeHJOK%%T48HqUEON06_`u;jb_PZcv-1dq%91O~MRGDETM5)8b|E<_^6yJ>SC*@| zNKjuZV5x6sI3Kwrm)wh4d~+U~FWc4T`s-@5UEkj|uNyc!y689F-)~my4|mreSKqd; zr?s9Pb>+=y(#&UZav9TnG@YCsrCEvd(#&HwF3soF!y+#p7R|}}wz-&%&g1M|^J{t6 zTuny5U6p1!Y0`93$D=fFX60fWQ#ozc53A3r|HEI-jz*KqGMhJ}$$Z+p+7Z5Levjj; za(QyRU4LMQu5S_A<&6o~AU~y!Pv0JE@XCV*o4`T9=$-N2)fO@+uuwFZN|$sIotn0%{@cM)sXVGJ#nE;`+kk3SP-)v=G10ICE~ z&az{amq@bc=u_t`CEJDwRwr+a?rcfEQhpU>PGJEkZC8`2L6_*fQQ5b-P+3@WOd9B1x%*khUMU1@1HPZkA)e98HE{z z8HE{z8HHKPDJ`e8oYHbi%PA+jPIjH_I@xvou5dcxY(-Pzqa;8mPfB$`exf`ninoSa z1S(!0AoJ2ID3X`8Xw#;s^op!wBvRBB1hZ`eY@3Qnq-aWBtemxC+X@@i8tL4utTyyk z?jiFIt4|cz!m!F_2FSJs$N?N6N3n+zlmYIKNqC0U{V}&YMtyAO4#Ax}1b6Nb+_^(= z$4hYM4#6ES!6&?2Gi;bvxo`%^)iXdYp#k3R;c5@xKSC*lUU#{7XUXo|+6CM$CE|YQ z|HCn(ozyLc!~VB_IUk1!W!(gRr>Oz~8H4*&Yx!C@Ys=9IB`#_Uf$r8zjHdyiiH2nPg@<<-0R=;+R&oo@%n z{=a>;koF^ZG|A;{v)*pMtTz0ixqsiA6PllgirX*CKUOyn-)Yj9+o9^G#d7uOJ4Jn{ XcJpyTU%iKx={t}Wr|0LR`PtFme8tJK literal 294912 zcmeI*Z){y<9S87p+PmA{!W!)`)yyS#s7U94bN{sKP*S?Cu;SRCvIPgk_O`vu!p5NO z7;H-E62}|=1dO7I223O&CQ6LP7fKM&#Kk{vNTx)MArLi!L=g-#F2r-5d+TLR5@UEH z`#d@K^moqlJm32~pL4pt+%-G5&Mwr8Hy)VVUt1_vqPwFwjxH}2qbTZ3KZW$O-12FW zTfRtt#;>d2sO*d`zVhLfizlPb!{gD%bBjMb@`EFXj`SaX>G1gB$KPm@`veFOAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB=EFmcYJ_j@~tE;-wEP)TZ{;_tzI{n`;ZT<)57+ zB$iRJu>(sEVU zl+X9}u8pq`1Ez1T&Cc91Kfhyc_V(IBy`#Cfv2B}2KT-^x*%Dd>ANDA?_Iq%o@gxl;M7g^>4jFtiqkUY8{KE5l#){4NPE8b zUCXbvJdaYygkwee?0!Xs-1w5qmdRO7L=?*BIrZg|u7 z@m=eSwIuzd&#hKYKb5QwH?ljW?ABm*Ihb7zhRbZoU^P4SXQzSev>`hUW~YtWsg|9l z($mHel{C&FG08GYN#lF6lv0w#l#(o`lw?7rBugqKSyU;>vPwx7R!XL`b8~r(^@Zee zqYlaCkX#PQ<&azs$>oq-4#|};s}g2a!mLV|RSB~OLhnH69SFSxp?9Fs8!`t%=7umb z_>=-rIqbBYWOK`5r{%EPYLd2cDZAY)v)oD;T?wNrVRYIVhBO9dW2!-_nq=Qy4N}!0 zRSn--4c}S~&!s=i?GI`FA+0~mov!y+s+ImMvRP)i%`)3qv&_DsS!P>omf1d=Wd_(R zGsQ+3P?}|HTHtBkZA}|ko;J{0PFgN6CoPwkla|ZNNz3Kjv|L_JS}y0NsK*%bDq~B{Ne&d@y^qNnp>$Rx1PH zy)AF-tF+~NuUz{^{{*&vS0_rXx;_1y)0zF?uLnAx%0Zad!nP&(>HNmw7xsEs5^>!dQR_* zqO2?H`QKYl$D?EE>|7i_6kV4r-J9&s7uFrya_rTW@rsqnWHQ{kJU6N4inspwwaNQS zKl}1$A9!Sc*S?*mZ4okCvwH}|5$tB8^6BbUFnE;@nn=98UX?X2oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5IE}uI@)uEo{mSyy3(D;i>IRW&IRW&#w*k7w!6L)E#8ox zGy((&5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&Ucyk5f!ke4-Z)r>K>s+y> zdi1V9R_Po8;c&oAdT@A&rU!+-e2rN?jEa?>T>Uw8k--btuS?RoH;|rBbqVcXC5(Ro%(cV^rR6WK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB=EFlE9kwp?H3F@5IgZJ11t!$%d)v>c)vA Wv{I?#-1ar`t+o02LkH$&I{yhoOlQLY diff --git a/tests/firefox-mixed-keys/key4.db b/tests/firefox-mixed-keys/key4.db index 74a40c6a9f776b9642acbab45b6632d63fa41cdb..9a46863fd68002183191f99322d4bd9cdb541093 100644 GIT binary patch literal 476 zcma)%T}#6-6o&8nD-OG9gC$3Vh^`$KZj<}$6zYqRqSNNmI(=2AxoHjex1QR z-Q2OQ=RPZAUc0__=Q|e@oMMhc9N;wK1dWv}#t!ibi8w}hi6eU__8m^Mu0>qfwZ>F^ zb5er_PX$&)1^y3@@1ZXfi$AZl%TtRX7LUkv#8HSoB>2+*?F6IBMcTh8H#Y17MHmMx2Rr07M# zC{0^QcrWDKsL*J;b6v$Uu zCG?r+UV5z6J~^wmyX@5M)|mQiAF8TZa;ke;vD`2z2}geLklsdWm5VzW&*nU@#O-{P zecP{O*d^7e@kUfbKmY;|fB*y_009U<00Izz00bcL=LEKB!Q9*)i!`0Yi9d+qo``rV zA~~M${&mp5;XSpsrrwJhA4D|-1Rwwb2tWV=5P$##AOHafKmY>&NMKzzSM&b@+4;X_ zyb$AET@Vm}00bZa0SG_<0uX=z1Rwwb2rQX^rRkMgc7)G|u^%KOe&9?OACX!#-x37_ L(SPL5;m<8!VHR+_ diff --git a/tests/firefox-mp-70/key4.db b/tests/firefox-mp-70/key4.db index 9be3138733915e9f9d3f9ba10ae7181697f89314..ca711e3d5793f1cba64b46882aa2efb27094fbbc 100644 GIT binary patch literal 2320 zcmeHJO>f&c5WV|X5WP4HyY}?{OMB{$j}PLtfW>UnPkeYhwa336-hXVrcCXh} zwU`y@&9WA|ZK!WU+%4DjVivb4?2_1pVwJ?__NghGr$$^I4`Q=j-h}Nt@q2nF?&{?q zcS)>k5!ZFKTE?B&rp+qEbS;ig?PuBlsNWW|Wqq5rJF%>HYw>C%d?o%2tGjf2HUFpS zx_=)}hxzr}c?ed5wN^V5Tm{M~rybZzN?m%ZQtn+ARgoxHgN_=M2M2`;-Wl&*l|cy- zB3O)CgUrE56N@~{$~+_;ivsg=ol@yRCndcuGb@d?d6^<8tddv(mCAe(O;9QZA5&7L z%6-%oR3KgM)f{i@g-;J%w>cfZG*9i%I2DTZc6Tirz%LrrDEw-|JU}CohCE$*y7YWW zDc(@96dZ+&f~SyEXejJ4tN$Bx${P1Bk5_tP*VXp#ef@Y!~D9wTdla? zn4j!7Cdpiv%12p&HYQIQwkDg0;0jr)8T@_;HLYAO%Ig-hwoGV}u$x44EjAlrCzU^wTd2s3Sr}qm5y!7r{9|;d0 z!V9I??+1$~^egIrRC|`yOFRo51&!K%RQvzfs^7S?5z}LDS(oXpICkB0d!o01=|3i} y@R@a)*gfz6ZttJI%f#pHW$LG9-+uZoQD3Ire{ArT&7CNeU*_5FtzBuIv)ZorkB6)QBXmOI_SNOd@Dmm~re>E=f~j zhlB($K_alI*s!3ggjj_|SFmFd@sx^0cC6U2DFQZ8#beQcvf!LEGfb^05@Ln$cXa3a zfA2ZxchC9mI9}${rlfCyI?r^rI{lxc^UDiIPcPI? zA3e6TQ0w2VO>eB#PA)ILaCG^_+NTy?terW&cJ1?vKXI$44vG`N=%ncy(?4>gE^Eht8GG<*ke9dZnMgQtww|$10U-bt?Z{ z8gTXtosG3uH#bjS+W1muYkj1*xW(hoEPS?>I^z-s18UQqdeN9UGMq9#lQPbCR@cuD zhE#TBq|OI2nlsOqE7gxo{p{8tV|Yi#X1Du+lzLI0nJ-nU2l~6&*xbCl zerXU>-VxL7eIRA7KJ%@o^O5S*RQ{d$^vVVSgKDT3)qQP)`~S&{J9_NI^67)MP7yxg zy>%Ml(~Nbx5%1LFt+{wP7cb}1WgIfsil^ClYRA)4@iZ4thvKOdPpjc{C`A?Bb4n~? zM!o1hC#KYk7*j7|PQ8dh^&%$Kix^ceVphF~VfEr{JolE@U0+IWbnBGdNXdvzlpEGtFwIS`Urs3d%L!$FIic+5hO)n$Q1){}`M%uvorw<)cjFhQSH^EouZ&-zUb!|X z*9Yaff0yZt&>g!vSk7u(&f09JSX)ivbMf64i9L>OHQVWX+c-4S9I8}5IrXrA0tdgV zEA>HL3V(A(u>(>$G`p4%Wwa&I+W+-+L3De z&hHlRRHo5R(&|M}}zzV_(GU#i3(oKfm(9!{2=JjhBD<)8gr3y7I^q zH=n!t-rjuAa9%17l=t8H-dErJ!=0a0zWeU)O7DF5@a6qyzJGTp&&wb9+tu68KmME7 z-~HIk>z{Zu-}uIO*m=JF_beP50RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N Z0t5&U*i8a^N|X6(oz2ZF7cZ?1{R{4~^Tq%G diff --git a/tests/firefox-mp-84/key4.db b/tests/firefox-mp-84/key4.db index 51017f14f12ac0f093cf39a55e405681cbae3c63..e8e4583cbce25f288536b464ceec0e37de54912f 100644 GIT binary patch literal 2931 zcmeHJOK%%T48HqUEON06_(0BkVB|2*K&UObvRou5L$Z`WO=1_4lcN8=@48>hyY6~C z`t7=OlW~_O<9a?yvu;{0&qFGw-TGnm8T&u+%h}Ord{w5iZZw`vx>q~GSKaU7{JLD7 z9B^+>B)H=S;Pb0YO8NpekMXtTsuI3X~wJ zwE?RmN*5pm1KCO=Gt}&@l{)wwM8ssG3qoZqDq*(Sne4Jp)`B2!$db4K0(o=a#3I1| z+dBJCK4=7IW30hz5HVYwP@7tFHo@lVtnr$FtQ3?KE?V*g=u`;VIDLFNzCJ(yaVr0u zZ?}KlZ0fk(~TgmN9aSQ~>G(K%+S@s!JeQ zbP=gop;aB6b@gh01+WV)KS=EH0mTNPK#A&r|dDM_?z~>+iov6>#y^N z)z7>DAx@^V({2uQQUA{cU6OHI;1%R0$G47eUAtuyr&wqfhJ|I}Sa=q577G%-Q9&;o zBQOU-?}u5a>P<2a0uxc|HOW*6%!R;Y2)8VU@cQ@~v*>YS7L8dnX3>~MV-}5BG-la$ z$+kN0kV!> z!6tc`x~W$-*&FsN?3<=Slk8go`=(}UvRSse=*Fg-Z+d4*OT9+&cBQ3~nhgiYx&b~h zU=PD8yBQ$+8XzZdfSko1wxkSjzpbTbSlw?kyKQD44#8bG1b5*O+=WAM$4hV*4#6ES z!6&@jGwhgFxp4-_-7`RLp#d)UaJ7f;A7N{R-gdDMXTjkt^&FOqmT@=C|6!YwruD7x zhx2d$ay}j=RCVQzQ%D1_rvb$S4%%VT%Rq(Ip&zNk3FBWp`&3Q2h7wc`4h#Aa1x-JU zLV*x87)uqo_?T+-%4;Pw4IQ5mwQx=ris;3&5787u?qev0jtwGACRC9G1;S?#C_4U> zvnfbOG?i)Gp~s3wm}GJ_-l0+^qV;4!s)SKy8mAb82q~o@VSEs^)9Am7U14xI)u;q@ zDEUya!@s_Ea9GEuHD%l)WA>+!(m6PkdyiiH2nPg@?bV0)80apbU2F&2{=a>;k@iRM zXq?O2ZoS=pS#9`3bN{|~Cv<)u8g9QV{#e~Ud}m2tZil9y=8M&*?+oRk+3m+Uef1uC Qrtd&DoLpRtW@ktL0J1E~AOHXW literal 294912 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 Yk0qg%N*$-RuZ(Z5&CVX&yMHSGPk+T_bN~PV diff --git a/tests/firefox-mp-test/key4.db b/tests/firefox-mp-test/key4.db index c8ed7d9c3c243641b99e8c7e4d45a6a06b85c377..11695abbe92a11a9a54902070bd7d6b4b20496b9 100644 GIT binary patch literal 618 zcma)&O>5gg5Qgvm6-zF5A&1$o*%cy(+1=S78p}pjOL7VVt3{O9#d1^n@4IeDN-31i zVdgN;dwAY$N4IMd-{$f3_&lEG>39$~0_w%~qXP{8*%j-y%Hbk?gQ^u%df*ce?Ok~%j^4PARW;7guzLdBZKh_tfh^ZmDWHZfwY+} ztOgx5C=UohkuNci*+Bsyf-Sr7vH~NGwn1VJh5=$$>{BoiWk?i=y+uhTR%BfORZ_Af zKq9b&V0?(tk&dV;Ae*xetW&tywX-%vhh-L_VBNfzpB`**G*q9egbQ>nf3qx literal 16384 zcmeI%K}Zx)7zgn8cE)8(*IA-kIt)Cq!`N;*V|7}Cwq-zR1KZs~MVQ_)v#{ju=+2~J zgm}mfVnpYKAo7qx5J-oha|9LLi%3rrL=a^rguOL}6ryV-|Bv_h-kTXd_C8ro8gh*n_Vg?Zsi|u;)h(*=_i(xHRo>3_&S8eBtH#Q=kTKux_Ztbn0l4S&|#@Rm7z>bPZl{-WX1Oy-e0SG_< z0uX=z1Rwwb2tWV=e@DQgP3rOK$SC)`3AbiHPT6yp#-?7%H(QNiw_RU+bznp9(Y0{% zk(u6^uTk34L?!)@y#K^Qcj;Zrr`6Y4{$%mNb$;*J{M8Vpa_nGhY-#rSWKZ$d{AOyU z>)Kw?7gatJF%S@d00bZa0SG_<0uX=z1Rwwb2<#$(W=Y)>{89LM|6eCceHS%|Rv-WY z2tWV=5P$##AOHafKmY>&kwB5k(^^u`{^ovvw!ZNF