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/.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/.github/workflows/testing.yaml b/.github/workflows/testing.yaml index 24ddd97..1446a7e 100644 --- a/.github/workflows/testing.yaml +++ b/.github/workflows/testing.yaml @@ -1,3 +1,4 @@ +--- name: ffpass on: [push, pull_request_target] @@ -5,55 +6,32 @@ on: [push, pull_request_target] jobs: test: name: Test - runs-on: ubuntu-latest + 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 --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 new file mode 100644 index 0000000..0ede6a5 --- /dev/null +++ b/.github/workflows/windows-and-mac.yaml @@ -0,0 +1,49 @@ +--- +name: windows-and-mac + +on: [push, pull_request_target] + +jobs: + 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: + 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 diff --git a/Makefile b/Makefile index cf7baac..d246140 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,67 @@ -pypi: dist - twine upload dist/* - -dist: flake8 +SHELL:=/bin/bash + +.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: deps +deps: ## Install runtime and dev dependencies + PIP_USER=0 pip install -r requirements.txt -r requirements-dev.txt + + +.PHONY: build +build: lint ## Build release +build: -rm dist/* ./setup.py sdist bdist_wheel -flake8: - flake8 . --count --select=E901,E999,F821,F822,F823 --show-source --statistics - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics -clean: +.PHONY: release +release: build ## Upload release to PyPI (via Twine) + twine upload dist/* + + + +LINT_LOCS_PY ?= ffpass/ 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 + ruff check ${LINT_LOCS_PY} + + +.PHONY: test +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 up build files/cache rm -rf *.egg-info build dist + rm -f .coverage + 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 old mode 100644 new mode 100755 index 56262cb..a134e2d --- 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 @@ -8,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 @@ -26,36 +27,43 @@ \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 +import binascii import csv +import json +import logging +import os.path import secrets -from getpass import getpass -from uuid import uuid4 +import sqlite3 +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 -import sqlite3 -import os.path -import logging +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 - +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" # 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): @@ -66,100 +74,55 @@ class WrongPassword(Exception): pass -class NoProfile(Exception): +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 -def getKey(directory: Path, masterPassword=""): - dbfile: Path = directory / "key4.db" +class NoProfile(Exception): + pass - if not dbfile.exists(): - raise NoDatabase() - 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 +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) - 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() + length = len(s) + if length <= 12: + return s - logging.info("password checked") + third = length // 3 + two_thirds = (2 * length) // 3 + return f"{s[:third]}...{s[two_thirds:]}" - # 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 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 -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 +136,315 @@ def decrypt3DES(globalSalt, masterPassword, entrySalt, encryptedData): return DES3.new(key, DES3.MODE_CBC, iv).decrypt(encryptedData) +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() + + 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 = 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)}") + + 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): # noqa: C901 + """ + Raises WrongPassword on failure. + Returns the successful hashing method ('sha1' or 'sha256'). + """ + try: + decodedItem2, _ = der_decode(item2) + try: + algorithm_oid = decodedItem2[0][0].asTuple() + except (IndexError, AttributeError): + raise ValueError("Could not decode password validation data structure.") + + # 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 seen_14_byte_iv: + raise IncompatibleCryptoError() + + raise WrongPassword() + + except WrongPassword: + raise + 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 + hash_method = 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, hash_method) + + 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() @@ -205,7 +456,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): @@ -216,34 +467,78 @@ 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 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}]." + ) + 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 +546,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,31 +569,68 @@ 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"), +} - if sys.platform not in 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"))] +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 + + +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("Please specify a profile to parse (-d path/to/profile)") + raise NoProfile + + profiles = getProfiles() if len(profiles) == 0: logging.error("Cannot find any Firefox profiles") 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] @@ -307,42 +639,204 @@ def guessDir(): 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 + # 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: - key = getKey(directory, password) - except WrongPassword: - password = getpass("Master Password:") - else: + 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, 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 - return key + + 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}") + + conn.close() def main_export(args): - try: - key = askpass(args.directory) - except NoDatabase: - # if the database is empty, we are done! + # args.directory is passed. allow_native=True for export. + key, native_logins = askpass(args.directory, allow_native=True) + + 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) - 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 + # 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.") + return + jsonLogins = getJsonLogins(args.directory) logins = readCSV(args.file) addNewLogins(key, jsonLogins, logins) @@ -365,6 +859,14 @@ 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", @@ -382,50 +884,77 @@ def makeParser(): ) 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_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): + 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 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/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 new file mode 100644 index 0000000..f979348 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +coverage==7.13.0 +flake8==7.3.0 +pytest==9.0.2 +ruff==0.14.11 + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b7383ab --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +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..2db9257 --- /dev/null +++ b/scripts/generate_mp_profile.py @@ -0,0 +1,185 @@ +#!/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 +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.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/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/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 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 new file mode 100644 index 0000000..a3f3726 --- /dev/null +++ b/tests/firefox-146-aes/key4.db @@ -0,0 +1,13 @@ +PRAGMA user_version = 0; +PRAGMA foreign_keys=OFF; +BEGIN TRANSACTION; +CREATE TABLE metaData (id PRIMARY KEY UNIQUE ON CONFLICT REPLACE, item1, item2); +CREATE TABLE nssPrivate (id PRIMARY KEY UNIQUE ON CONFLICT ABORT, a0, a1, a2, a3, a4, a10, a11, a12, a80, a81, a82, a83, a84, a85, a86, a87, a88, a89, a8a, a8b, a8c, a90, a100, a101, a102, a103, a104, a105, a106, a107, a108, a109, a10a, a10b, a10c, a110, a111, a120, a121, a122, a123, a124, a125, a126, a127, a128, a129, a130, a131, a132, a133, a134, a160, a161, a162, a163, a164, a165, a166, a170, a171, a172, a180, a181, a200, a201, a202, a210, a40000211, a40000212, a40000213, a220, a221, a222, a223, a224, a225, a226, a227, a22e, a22f, a22a, a22b, a22c, a22d, a250, a251, a252, a300, a301, a302, a400, a401, a402, a403, a404, a405, a406, a480, a481, a482, a500, a501, a502, a503, a601, a602, a603, a604, a605, a606, a607, a608, a609, a60a, a60b, a60c, a60d, a60e, a60f, a610, a611, a612, a617, a618, a619, a61a, a61b, a61c, a61d, a61e, a61f, a620, a621, a622, a623, a624, a625, a626, a627, a629, a628, a62a, a62b, a62c, a62d, a62e, a62f, a630, a631, a632, a633, a634, a635, a636, a637, a80000001, ace534351, ace534352, ace534353, ace534354, ace534355, ace534356, ace534357, ace534358, ace534364, ace534365, ace534366, ace534367, ace534368, ace534369, ace534373, ace534374, ace536351, ace536352, ace536353, ace536354, ace536355, ace536356, ace536357, ace536358, ace536359, ace53635a, ace53635b, ace53635c, ace53635d, ace53635e, ace53635f, ace536360, ace5363b4, ace5363b5, ad5a0db00); +INSERT INTO "metaData" ("id", "item1", "item2") VALUES ('password', X'F93F912988415C345CE8BF46CABB5884090A04B1', X'308181306D06092A864886F70D01050D3060304106092A864886F70D01050C303404209C4E5B7A06CABF4215AC5D13762E0B1DEE4F1D62FDEC46E13A3EAFD8B680FFAE020101020120300A06082A864886F70D0209301B060960864801650304012A040E925930A1FB223607A8E9EFC243CD0410CDBAF974799027EEB36E6DD3A1B97BFD'); +INSERT INTO "metaData" ("id", "item1", "item2") VALUES ('sig_key_3f4a3840_00000011', X'308180305C06092A864886F70D01050E304F304106092A864886F70D01050C3034042085A9A58C21A8986B4BF5A3F3B494CD1618F933B7FD0D48FCE685435DD027FB85020101020120300A06082A864886F70D0209300A06082A864886F70D0209042002FE4A8E3814E455A108399507993030B66F4BCB0C8E23ABBB87BA7864B3DB8C', NULL); +INSERT INTO "nssPrivate" ("id", "a0", "a1", "a2", "a3", "a4", "a10", "a11", "a12", "a80", "a81", "a82", "a83", "a84", "a85", "a86", "a87", "a88", "a89", "a8a", "a8b", "a8c", "a90", "a100", "a101", "a102", "a103", "a104", "a105", "a106", "a107", "a108", "a109", "a10a", "a10b", "a10c", "a110", "a111", "a120", "a121", "a122", "a123", "a124", "a125", "a126", "a127", "a128", "a129", "a130", "a131", "a132", "a133", "a134", "a160", "a161", "a162", "a163", "a164", "a165", "a166", "a170", "a171", "a172", "a180", "a181", "a200", "a201", "a202", "a210", "a40000211", "a40000212", "a40000213", "a220", "a221", "a222", "a223", "a224", "a225", "a226", "a227", "a22e", "a22f", "a22a", "a22b", "a22c", "a22d", "a250", "a251", "a252", "a300", "a301", "a302", "a400", "a401", "a402", "a403", "a404", "a405", "a406", "a480", "a481", "a482", "a500", "a501", "a502", "a503", "a601", "a602", "a603", "a604", "a605", "a606", "a607", "a608", "a609", "a60a", "a60b", "a60c", "a60d", "a60e", "a60f", "a610", "a611", "a612", "a617", "a618", "a619", "a61a", "a61b", "a61c", "a61d", "a61e", "a61f", "a620", "a621", "a622", "a623", "a624", "a625", "a626", "a627", "a629", "a628", "a62a", "a62b", "a62c", "a62d", "a62e", "a62f", "a630", "a631", "a632", "a633", "a634", "a635", "a636", "a637", "a80000001", "ace534351", "ace534352", "ace534353", "ace534354", "ace534355", "ace534356", "ace534357", "ace534358", "ace534364", "ace534365", "ace534366", "ace534367", "ace534368", "ace534369", "ace534373", "ace534374", "ace536351", "ace536352", "ace536353", "ace536354", "ace536355", "ace536356", "ace536357", "ace536358", "ace536359", "ace53635a", "ace53635b", "ace53635c", "ace53635d", "ace53635e", "ace53635f", "ace536360", "ace5363b4", "ace5363b5", "ad5a0db00") VALUES (1061828672, X'00000004', X'01', X'01', X'A5005A', NULL, NULL, X'3081A1306D06092A864886F70D01050D3060304106092A864886F70D01050C3034042044F7FB06E1135919092A88CBF58BAA0809EDD4587B4B930FAACC5FE2745128BA020101020120300A06082A864886F70D0209301B060960864801650304012A040E11E4D7138AD979C2B1F971C3547C0430EA10B058C0464C85BF5DF466F1C00C477D33C9F658C731254ACAB651EEB25CB51EDA46411A9C8A9E2F5DCA294CAB544A', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, X'0000001F', NULL, X'F8000000000000000000000000000001', X'00', X'01', X'01', X'01', X'01', X'01', NULL, X'00', NULL, NULL, X'A5005A', X'A5005A', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, X'00000020', X'01', X'00', X'00', X'01', NULL, X'01', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); +CREATE INDEX issuer ON nssPrivate (a81); +CREATE INDEX subject ON nssPrivate (a101); +CREATE INDEX label ON nssPrivate (a3); +CREATE INDEX ckaid ON nssPrivate (a102); +COMMIT; diff --git a/tests/firefox-146-aes/logins.json b/tests/firefox-146-aes/logins.json new file mode 100644 index 0000000..28ee0d2 --- /dev/null +++ b/tests/firefox-146-aes/logins.json @@ -0,0 +1 @@ +{"nextId":2,"logins":[{"id":1,"hostname":"http://www.stealmylogin.com","httpRealm":null,"formSubmitURL":"","usernameField":"","passwordField":"","encryptedUsername":"MEMEEPgAAAAAAAAAAAAAAAAAAAEwHQYJYIZIAWUDBAEqBBCNW6CWkegCZc+s8lP/Vl5PBBDWyrV026klbUVJLhE4r8+p","encryptedPassword":"MEMEEPgAAAAAAAAAAAAAAAAAAAEwHQYJYIZIAWUDBAEqBBBTE1NvcNTjoXhqsv3V0OMlBBBs1jTmrS3qup3KR/MAyxjl","guid":"{207341aa-f648-40dc-ad20-36e9bd9ab40b}","encType":1,"timeCreated":1766692909450,"timeLastUsed":1766692909450,"timePasswordChanged":1766692909450,"timesUsed":1,"syncCounter":1,"everSynced":false,"encryptedUnknownFields":"MEMEEPgAAAAAAAAAAAAAAAAAAAEwHQYJYIZIAWUDBAEqBBCsxHtLjm13zN4xOGY2IC2JBBDhG8zk6rYnDx6csYr0YxLU"}],"potentiallyVulnerablePasswords":[],"dismissedBreachAlertsByLoginGUID":{},"version":3} \ No newline at end of file diff --git a/tests/firefox-14iv/cert9.db b/tests/firefox-14iv/cert9.db new file mode 100644 index 0000000..dbf9976 --- /dev/null +++ b/tests/firefox-14iv/cert9.db @@ -0,0 +1,9 @@ +PRAGMA user_version = 0; +PRAGMA foreign_keys=OFF; +BEGIN TRANSACTION; +CREATE TABLE nssPublic (id PRIMARY KEY UNIQUE ON CONFLICT ABORT, a0, a1, a2, a3, a4, a10, a11, a12, a80, a81, a82, a83, a84, a85, a86, a87, a88, a89, a8a, a8b, a8c, a90, a100, a101, a102, a103, a104, a105, a106, a107, a108, a109, a10a, a10b, a10c, a110, a111, a120, a121, a122, a123, a124, a125, a126, a127, a128, a129, a130, a131, a132, a133, a134, a160, a161, a162, a163, a164, a165, a166, a170, a171, a172, a180, a181, a200, a201, a202, a210, a40000211, a40000212, a40000213, a220, a221, a222, a223, a224, a225, a226, a227, a22e, a22f, a22a, a22b, a22c, a22d, a250, a251, a252, a300, a301, a302, a400, a401, a402, a403, a404, a405, a406, a480, a481, a482, a500, a501, a502, a503, a601, a602, a603, a604, a605, a606, a607, a608, a609, a60a, a60b, a60c, a60d, a60e, a60f, a610, a611, a612, a617, a618, a619, a61a, a61b, a61c, a61d, a61e, a61f, a620, a621, a622, a623, a624, a625, a626, a627, a629, a628, a62a, a62b, a62c, a62d, a62e, a62f, a630, a631, a632, a633, a634, a635, a636, a637, a80000001, ace534351, ace534352, ace534353, ace534354, ace534355, ace534356, ace534357, ace534358, ace534364, ace534365, ace534366, ace534367, ace534368, ace534369, ace534373, ace534374, ace536351, ace536352, ace536353, ace536354, ace536355, ace536356, ace536357, ace536358, ace536359, ace53635a, ace53635b, ace53635c, ace53635d, ace53635e, ace53635f, ace536360, ace5363b4, ace5363b5, ad5a0db00); +CREATE INDEX issuer ON nssPublic (a81); +CREATE INDEX subject ON nssPublic (a101); +CREATE INDEX label ON nssPublic (a3); +CREATE INDEX ckaid ON nssPublic (a102); +COMMIT; diff --git a/tests/firefox-14iv/key4.db b/tests/firefox-14iv/key4.db new file mode 100644 index 0000000..b8798c3 --- /dev/null +++ b/tests/firefox-14iv/key4.db @@ -0,0 +1,13 @@ +PRAGMA user_version = 0; +PRAGMA foreign_keys=OFF; +BEGIN TRANSACTION; +CREATE TABLE metaData (id PRIMARY KEY UNIQUE ON CONFLICT REPLACE, item1, item2); +CREATE TABLE nssPrivate (id PRIMARY KEY UNIQUE ON CONFLICT ABORT, a0, a1, a2, a3, a4, a10, a11, a12, a80, a81, a82, a83, a84, a85, a86, a87, a88, a89, a8a, a8b, a8c, a90, a100, a101, a102, a103, a104, a105, a106, a107, a108, a109, a10a, a10b, a10c, a110, a111, a120, a121, a122, a123, a124, a125, a126, a127, a128, a129, a130, a131, a132, a133, a134, a160, a161, a162, a163, a164, a165, a166, a170, a171, a172, a180, a181, a200, a201, a202, a210, a40000211, a40000212, a40000213, a220, a221, a222, a223, a224, a225, a226, a227, a22e, a22f, a22a, a22b, a22c, a22d, a250, a251, a252, a300, a301, a302, a400, a401, a402, a403, a404, a405, a406, a480, a481, a482, a500, a501, a502, a503, a601, a602, a603, a604, a605, a606, a607, a608, a609, a60a, a60b, a60c, a60d, a60e, a60f, a610, a611, a612, a617, a618, a619, a61a, a61b, a61c, a61d, a61e, a61f, a620, a621, a622, a623, a624, a625, a626, a627, a629, a628, a62a, a62b, a62c, a62d, a62e, a62f, a630, a631, a632, a633, a634, a635, a636, a637, a80000001, ace534351, ace534352, ace534353, ace534354, ace534355, ace534356, ace534357, ace534358, ace534364, ace534365, ace534366, ace534367, ace534368, ace534369, ace534373, ace534374, ace536351, ace536352, ace536353, ace536354, ace536355, ace536356, ace536357, ace536358, ace536359, ace53635a, ace53635b, ace53635c, ace53635d, ace53635e, ace53635f, ace536360, ace5363b4, ace5363b5, ad5a0db00); +INSERT INTO "metaData" ("id", "item1", "item2") VALUES ('password', X'644F4F43B2F24340237D05E7B85C14E5E212B923433D10CC7256FB84636BFC42878E1E9782A728CDDDDC0F5B25A19D76', X'308182306E06092A864886F70D01050D3061304206092A864886F70D01050C30350420C7AC67B9D422F20BC46845ACA1735D455AA3C745B0AC75D0A68643B6BC90094E02022710020120300A06082A864886F70D0209301B060960864801650304012A040E54673F250D10A73193432DE314E00410EAEF13D0C501C4AEB5CF7236D07C8906'); +INSERT INTO "metaData" ("id", "item1", "item2") VALUES ('sig_key_0e5820d8_00000011', X'308181305D06092A864886F70D01050E3050304206092A864886F70D01050C303504205C16F6B2E6557B038CB674D990B6263C1BA6C802A726E55C23481ABB60F5D2A002022710020120300A06082A864886F70D0209300A06082A864886F70D0209042079183F69FC307A354FC658767A65E85B12372231AEE96991E88CF08DD8B9EC71', NULL); +INSERT INTO "nssPrivate" ("id", "a0", "a1", "a2", "a3", "a4", "a10", "a11", "a12", "a80", "a81", "a82", "a83", "a84", "a85", "a86", "a87", "a88", "a89", "a8a", "a8b", "a8c", "a90", "a100", "a101", "a102", "a103", "a104", "a105", "a106", "a107", "a108", "a109", "a10a", "a10b", "a10c", "a110", "a111", "a120", "a121", "a122", "a123", "a124", "a125", "a126", "a127", "a128", "a129", "a130", "a131", "a132", "a133", "a134", "a160", "a161", "a162", "a163", "a164", "a165", "a166", "a170", "a171", "a172", "a180", "a181", "a200", "a201", "a202", "a210", "a40000211", "a40000212", "a40000213", "a220", "a221", "a222", "a223", "a224", "a225", "a226", "a227", "a22e", "a22f", "a22a", "a22b", "a22c", "a22d", "a250", "a251", "a252", "a300", "a301", "a302", "a400", "a401", "a402", "a403", "a404", "a405", "a406", "a480", "a481", "a482", "a500", "a501", "a502", "a503", "a601", "a602", "a603", "a604", "a605", "a606", "a607", "a608", "a609", "a60a", "a60b", "a60c", "a60d", "a60e", "a60f", "a610", "a611", "a612", "a617", "a618", "a619", "a61a", "a61b", "a61c", "a61d", "a61e", "a61f", "a620", "a621", "a622", "a623", "a624", "a625", "a626", "a627", "a629", "a628", "a62a", "a62b", "a62c", "a62d", "a62e", "a62f", "a630", "a631", "a632", "a633", "a634", "a635", "a636", "a637", "a80000001", "ace534351", "ace534352", "ace534353", "ace534354", "ace534355", "ace534356", "ace534357", "ace534358", "ace534364", "ace534365", "ace534366", "ace534367", "ace534368", "ace534369", "ace534373", "ace534374", "ace536351", "ace536352", "ace536353", "ace536354", "ace536355", "ace536356", "ace536357", "ace536358", "ace536359", "ace53635a", "ace53635b", "ace53635c", "ace53635d", "ace53635e", "ace53635f", "ace536360", "ace5363b4", "ace5363b5", "ad5a0db00") VALUES (240656600, X'00000004', X'01', X'01', X'A5005A', NULL, NULL, X'3081A2306E06092A864886F70D01050D3061304206092A864886F70D01050C303504200A4D100BDCC7FA08A0F4757F9A1789D9F5ADF1FF3AA19092C5D8F97D918AE8ED02022710020120300A06082A864886F70D0209301B060960864801650304012A040EB179C6E4302F05738D48CFA24C40043027CD8D2097D6DFB0B4AB4D5381620E8051C6760A7F4A7E9C1B91BDDEF1CAFF72C9B0777B731C67EACF053FBE4084D35E', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, X'0000001F', NULL, X'F8000000000000000000000000000001', X'00', X'01', X'01', X'01', X'01', X'01', NULL, X'00', NULL, NULL, X'A5005A', X'A5005A', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, X'00000020', X'01', X'00', X'00', X'01', NULL, X'01', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); +CREATE INDEX issuer ON nssPrivate (a81); +CREATE INDEX subject ON nssPrivate (a101); +CREATE INDEX label ON nssPrivate (a3); +CREATE INDEX ckaid ON nssPrivate (a102); +COMMIT; diff --git a/tests/firefox-14iv/logins.json b/tests/firefox-14iv/logins.json new file mode 100644 index 0000000..1c9452f --- /dev/null +++ b/tests/firefox-14iv/logins.json @@ -0,0 +1 @@ +{"nextId":2,"logins":[{"id":1,"hostname":"https://google.com","httpRealm":null,"formSubmitURL":"","usernameField":"","passwordField":"","encryptedUsername":"MEMEEPgAAAAAAAAAAAAAAAAAAAEwHQYJYIZIAWUDBAEqBBA63Oz6jeYnP00WiDwnpJm/BBCNJ9Xc0ixY1lCgfmuyae/U","encryptedPassword":"MEMEEPgAAAAAAAAAAAAAAAAAAAEwHQYJYIZIAWUDBAEqBBBM/Qf7rAPdDR/Oey+lbM2iBBCx9dIeWLdgBe+qO1GdTk4Q","guid":"{6d4599e1-eefe-43b4-bfcd-7d22686786f5}","encType":1,"timeCreated":1767954808671,"timeLastUsed":1767954808671,"timePasswordChanged":1767954808671,"timesUsed":1,"syncCounter":1,"everSynced":false,"encryptedUnknownFields":"MEMEEPgAAAAAAAAAAAAAAAAAAAEwHQYJYIZIAWUDBAEqBBC+YISsXgHQM/LGd8pxRCRsBBBL1ixroXEdzpajpiqvsz1f"}],"potentiallyVulnerablePasswords":[],"dismissedBreachAlertsByLoginGUID":{},"version":3} \ No newline at end of file diff --git a/tests/firefox-14iv/pkcs11.txt b/tests/firefox-14iv/pkcs11.txt new file mode 100644 index 0000000..f8c8cbf --- /dev/null +++ b/tests/firefox-14iv/pkcs11.txt @@ -0,0 +1,5 @@ +library= +name=NSS Internal PKCS #11 Module +parameters=configdir='sql:/home/shane/.mozilla/firefox/7ls4dcbd.test' certPrefix='' keyPrefix='' secmod='secmod.db' flags=optimizeSpace updatedir='' updateCertPrefix='' updateKeyPrefix='' updateid='' updateTokenDescription='' +NSS=Flags=internal,critical trustOrder=75 cipherOrder=100 slotParams=(1={slotFlags=[ECC,RSA,DSA,DH,RC2,RC4,DES,RANDOM,SHA1,MD5,MD2,SSL,TLS,AES,Camellia,SEED,SHA256,SHA512] askpw=any timeout=30}) + diff --git a/tests/firefox-70/key4.db b/tests/firefox-70/key4.db index 9dcdd64..81ebfe0 100644 Binary files a/tests/firefox-70/key4.db and b/tests/firefox-70/key4.db differ diff --git a/tests/firefox-84/key4.db b/tests/firefox-84/key4.db index 36f2e37..6259f80 100644 Binary files a/tests/firefox-84/key4.db and b/tests/firefox-84/key4.db differ diff --git a/tests/firefox-mixed-keys/key4.db b/tests/firefox-mixed-keys/key4.db new file mode 100644 index 0000000..66d0d4e --- /dev/null +++ b/tests/firefox-mixed-keys/key4.db @@ -0,0 +1,9 @@ +PRAGMA user_version = 0; +PRAGMA foreign_keys=OFF; +BEGIN TRANSACTION; +CREATE TABLE metadata (id TEXT PRIMARY KEY, item1, item2); +CREATE TABLE nssPrivate (a11, a102); +INSERT INTO "metadata" ("id", "item1", "item2") VALUES ('password', X'676C6F62616C5F73616C74', X'70775F636865636B5F626C6F62'); +INSERT INTO "nssPrivate" ("a11", "a102") VALUES (X'626C6F625F6C65676163795F3234', X'F8000000000000000000000000000001'); +INSERT INTO "nssPrivate" ("a11", "a102") VALUES (X'626C6F625F6D6F6465726E5F3332', X'F8000000000000000000000000000001'); +COMMIT; 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-70/key4.db b/tests/firefox-mp-70/key4.db index 9be3138..5fb28fa 100644 Binary files a/tests/firefox-mp-70/key4.db and b/tests/firefox-mp-70/key4.db differ diff --git a/tests/firefox-mp-84/key4.db b/tests/firefox-mp-84/key4.db index 51017f1..067d9f3 100644 Binary files a/tests/firefox-mp-84/key4.db and b/tests/firefox-mp-84/key4.db differ diff --git a/tests/firefox-mp-test/key4.db b/tests/firefox-mp-test/key4.db new file mode 100644 index 0000000..a6071c0 --- /dev/null +++ b/tests/firefox-mp-test/key4.db @@ -0,0 +1,8 @@ +PRAGMA user_version = 0; +PRAGMA foreign_keys=OFF; +BEGIN TRANSACTION; +CREATE TABLE metadata (id TEXT PRIMARY KEY, item1, item2); +CREATE TABLE nssPrivate (a11, a102); +INSERT INTO "metadata" ("id", "item1", "item2") VALUES ('password', X'BA3B648D6B717FCE048E0E315C6F2330ABD015FA', X'303C3028060B2A864886F70D010C0501033019041435B16FB4E00CF2E8DB4C61BDABAE8561A4C9A57B0201010410160E10B48BCC7A3C6290A5FF3BE22F7D'); +INSERT INTO "nssPrivate" ("a11", "a102") VALUES (X'304C3028060B2A864886F70D010C050103301904148B9CAAFD3DAA7652317AB6F66612F6FA66EDA9870201010420CD652B28246E01A2F556EF3B49DEDC60772D08209220E1452BEA5F4823535A5C', X'F8000000000000000000000000000001'); +COMMIT; diff --git a/tests/firefox-mp-test/logins.json b/tests/firefox-mp-test/logins.json new file mode 100644 index 0000000..d7e7507 --- /dev/null +++ b/tests/firefox-mp-test/logins.json @@ -0,0 +1 @@ +{"nextId": 2, "logins": [{"id": 1, "hostname": "https://locked.com", "encryptedUsername": "MDoEEPgAAAAAAAAAAAAAAAAAAAEwFAYIKoZIhvcNAwcECLSofBkhyi6aBBAUN847VIaI3v/ONszuHiXI", "encryptedPassword": "MDoEEPgAAAAAAAAAAAAAAAAAAAEwFAYIKoZIhvcNAwcECGgTq8PZx0pVBBBmptJXLWpTeDwYKhku6o2r", "deleted": false}]} \ No newline at end of file diff --git a/tests/test_inspect.py b/tests/test_inspect.py new file mode 100644 index 0000000..4d810ae --- /dev/null +++ b/tests/test_inspect.py @@ -0,0 +1,58 @@ +from io import StringIO +from unittest.mock import patch + +from ffpass import main + + +def test_inspect_output(clean_profile): + """ + Verifies that the 'inspect' command runs and produces Expected output + including warnings for 14-byte IVs. + """ + # 1. Test 14-byte IV profile (Should Warn, Default = Obscure) + profile_14 = clean_profile("firefox-14iv") + + with patch("sys.stdout", new=StringIO()) as fake_out: + with patch("sys.argv", ["ffpass", "inspect", "-d", str(profile_14)]): + main() + + output = fake_out.getvalue() + assert "Inspecting Profile:" in output + assert "WARNING: Non-standard 14-byte IV detected" in output + assert "REQUIRES Native NSS backend" in output + assert "KDF OID:" in output + # Verify obscuration (partial) + assert "(Use --reveal-keys to show full)" in output + # Should contain "..." for omitted middle part + assert "..." in output + # But not "[HIDDEN]" anymore + assert "[HIDDEN]" not in output + + # 2. Test Standard Profile (AES-256) WITH --reveal-keys + profile_std = clean_profile("firefox-146-aes") + + with patch("sys.stdout", new=StringIO()) as fake_out: + with patch( + "sys.argv", ["ffpass", "inspect", "-d", str(profile_std), "--reveal-keys"] + ): + main() + + output = fake_out.getvalue() + # Verify basic structure exists (proving ASN.1 parsing worked) + assert "[METADATA]" in output + assert "ID: password" in output + assert "Encryption Scheme:" in output + + # Verify it revealed keys + assert "(Use --reveal-keys to show full)" not in output + assert "Global Salt:" in output + # Should NOT be hidden/partial + assert "..." not in output + + # 3. Test Legacy Profile (3DES) + profile_legacy = clean_profile("firefox-70") + with patch("sys.stdout", new=StringIO()) as fake_out: + with patch("sys.argv", ["ffpass", "inspect", "-d", str(profile_legacy)]): + main() + output = fake_out.getvalue() + assert "ID: password" in output diff --git a/tests/test_key.py b/tests/test_key.py index db102e3..54b1bb7 100644 --- a/tests/test_key.py +++ b/tests/test_key.py @@ -1,38 +1,119 @@ #!/usr/bin/env python3 -import ffpass +import shutil +import sqlite3 from pathlib import Path + import pytest -TEST_KEY = b'\xbfh\x13\x1a\xda\xb5\x9d\xe3X\x10\xe0\xa8\x8a\xc2\xe5\xbcE\xf2I\r\xa2pm\xf4' -MASTER_PASSWORD = 'test' +import ffpass + +# This key corresponds to the static 'tests/firefox-84' profile in your repo +TEST_KEY = ( + b"\xbfh\x13\x1a\xda\xb5\x9d\xe3X\x10\xe0\xa8\x8a\xc2\xe5\xbcE\xf2I\r\xa2pm\xf4" +) +MASTER_PASSWORD = "test" +MAGIC1 = b"\xf8\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01" + + +def _get_key(directory, password=""): + """ + Helper to adapt the new get_all_keys API to legacy test expectations. + """ + keys, _ = ffpass.get_all_keys(directory, password) + # Simulate askpass logic: prefer 32-byte key, else first found + best = next((k for k in keys if len(k) == 32), keys[0]) + return best + + +@pytest.fixture +def mixed_key_profile(tmp_path): + """ + Creates a temporary profile based on firefox-84, but duplicates + the key entry in key4.db to simulate a "Key Rotation" scenario + (multiple keys present). + """ + # 1. Base the profile on an existing valid one + src = Path("tests/firefox-84") + dst = tmp_path / "firefox-mixed" + shutil.copytree(src, dst) + + # 2. Open the DB + db_path = dst / "key4.db" + conn = sqlite3.connect(str(db_path)) + c = conn.cursor() + + # 3. Fetch the existing key row + c.execute("SELECT * FROM nssPrivate WHERE a102 = ?", (MAGIC1,)) + row = c.fetchone() + + # Get column names to find the 'id' column index + col_names = [d[0] for d in c.description] + + if row: + row_list = list(row) + + # 4. Modify the UNIQUE 'id' column to avoid IntegrityError + if "id" in col_names: + id_idx = col_names.index("id") + original_id = row_list[id_idx] + + # Increment ID if integer, or change if bytes + if isinstance(original_id, int): + row_list[id_idx] = original_id + 100 # Ensure uniqueness + else: + # Fallback for blobs/bytes + row_list[id_idx] = b"\xff" * len(original_id) + + # 5. Insert the duplicate row + placeholders = ",".join(["?"] * len(row_list)) + c.execute(f"INSERT INTO nssPrivate VALUES ({placeholders})", row_list) + conn.commit() + + conn.close() + return dst def test_firefox_key(): - key = ffpass.getKey(Path('tests/firefox-84')) + key = _get_key(Path("tests/firefox-84")) assert key == TEST_KEY def test_firefox_mp_key(): - key = ffpass.getKey(Path('tests/firefox-mp-84'), MASTER_PASSWORD) + key = _get_key(Path("tests/firefox-mp-84"), MASTER_PASSWORD) assert key == TEST_KEY def test_firefox_wrong_masterpassword_key(): with pytest.raises(ffpass.WrongPassword): - ffpass.getKey(Path('tests/firefox-mp-84'), 'wrongpassword') + _get_key(Path("tests/firefox-mp-84"), "wrongpassword") def test_legacy_firefox_key(): - key = ffpass.getKey(Path('tests/firefox-70')) + key = _get_key(Path("tests/firefox-70")) assert key == TEST_KEY def test_legacy_firefox_mp_key(): - key = ffpass.getKey(Path('tests/firefox-mp-70'), MASTER_PASSWORD) + key = _get_key(Path("tests/firefox-mp-70"), MASTER_PASSWORD) assert key == TEST_KEY def test_legacy_firefox_wrong_masterpassword_key(): with pytest.raises(ffpass.WrongPassword): - ffpass.getKey(Path('tests/firefox-mp-70'), 'wrongpassword') + _get_key(Path("tests/firefox-mp-70"), "wrongpassword") + + +def test_mixed_key_retrieval(mixed_key_profile): + """ + Verifies that get_all_keys() finds multiple keys in the DB. + """ + keys, _ = ffpass.get_all_keys(mixed_key_profile) + + # Since we manually duplicated the key row in the fixture, + # we expect exactly 2 keys to be decrypted. + assert len(keys) == 2 + + # Both keys should be valid (and identical in this specific test case) + assert keys[0] == TEST_KEY + assert keys[1] == TEST_KEY diff --git a/tests/test_mixed_keys_run.py b/tests/test_mixed_keys_run.py new file mode 100644 index 0000000..e96a23a --- /dev/null +++ b/tests/test_mixed_keys_run.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Created on Fri Dec 25 19:30:48 2025 + +@author: shane +""" + +import sys +from unittest.mock import patch + +HEADER = "url,username,password" +EXPECTED_MIXED_OUTPUT = [HEADER, "http://www.mixedkeys.com,modern_user,modern_pass"] + + +def run_ffpass_internal(mode, path): + from ffpass import main + + test_args = ["ffpass", mode, "-d", str(path)] + + # We patch get_all_keys directly to avoid the infinite loop issue + # and avoid needing complex ASN.1 mocking for the password check. + with ( + patch("sys.argv", test_args), + patch("sys.stdin.isatty", return_value=False), + patch("sys.stdin.readline", return_value=""), + patch("ffpass.get_all_keys") as mock_get_keys, + patch("ffpass.try_decrypt_login") as mock_decrypt_login, + ): + + # 1. Mock Key Return + # Simulate finding two keys: Legacy (24 bytes) and Modern (32 bytes) + mock_get_keys.return_value = ([b"L" * 24, b"M" * 32], b"salt") + + # 2. Mock Golden Key Check + # Verify the tool checks if the key works on the first row + def try_login_side_effect(key, ct, iv): + if key == b"M" * 32: + return "valid_utf8", "AES-Standard" + return None, None + + mock_decrypt_login.side_effect = try_login_side_effect + + # 3. Mock Final Decryption + with patch("ffpass.decodeLoginData") as mock_decode: + # Use iterator to return user then pass + return_values = iter(["modern_user", "modern_pass"]) + + def decode_side_effect(key, data): + if len(key) == 32: + try: + return next(return_values) + except StopIteration: + return "extra" + raise ValueError("Wrong Key") + + mock_decode.side_effect = decode_side_effect + + # Capture stdout + from io import StringIO + + captured_output = StringIO() + sys.stdout = captured_output + + try: + main() + except SystemExit: + pass + finally: + sys.stdout = sys.__stdout__ + + return captured_output.getvalue() + + +def stdout_splitter(input_text): + return [x for x in input_text.splitlines() if x != ""] + + +def test_mixed_key_rotation_export(clean_profile): + profile_path = clean_profile("firefox-mixed-keys") + output = run_ffpass_internal("export", profile_path) + actual = stdout_splitter(output) + assert actual == EXPECTED_MIXED_OUTPUT diff --git a/tests/test_mp_stdin.py b/tests/test_mp_stdin.py new file mode 100644 index 0000000..247dcf4 --- /dev/null +++ b/tests/test_mp_stdin.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Created on Fri Dec 26 00:13:51 2025 + +@author: shane +""" + +import shutil +import sys +from io import StringIO +from pathlib import Path +from unittest.mock import patch + +import pytest + +# Allow importing ffpass from source +sys.path.insert(0, str(Path(__file__).parent.parent)) + +import ffpass # noqa: E402 +from ffpass import main # noqa: E402 + +MASTER_PASSWORD = "password123" + + +@pytest.fixture +def mp_profile(tmp_path): + """ + Setup the MP profile with REAL encrypted data. + Requires running scripts/generate_mp_profile.py first. + """ + src = Path("tests/firefox-mp-test") + if not src.exists(): + pytest.fail( + "Run scripts/generate_mp_profile.py first to generate real crypto assets" + ) + dst = tmp_path / "firefox-mp-test" + shutil.copytree(src, dst) + return dst + + +def test_export_with_correct_password(mp_profile): + """ + Verifies that providing the correct password via stdin allows + successful decryption of the database. + """ + # Mock user input to return correct password immediately + ffpass.getpass = lambda x: MASTER_PASSWORD + + # Capture stdout to verify CSV output + capture = StringIO() + + with patch("sys.argv", ["ffpass", "export", "-d", str(mp_profile)]), patch( + "sys.stdout", capture + ): + + # Run real main() - no internal crypto mocks! + # This proves verify_password -> 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_native_verify.py b/tests/test_native_verify.py new file mode 100644 index 0000000..cd86b82 --- /dev/null +++ b/tests/test_native_verify.py @@ -0,0 +1,44 @@ +import pytest + +from ffpass import get_native_logins + + +@pytest.mark.xfail( + reason="Native libnss3 may not support legacy (3DES) profiles on modern systems" +) +def test_native_old_profile(clean_profile): + # Firefox 70 profile (legacy) + profile = clean_profile("firefox-native-verify") + # Password 'test' (from test_run.py) + logins = get_native_logins(str(profile), "test") + assert len(logins) > 0 + + # Expected based on test_run.py: 'http://www.stealmylogin.com' + found = False + for login in logins: + if login["hostname"] == "http://www.stealmylogin.com": + found = True + assert login["username"] == "test" + assert login["password"] == "test" + break + assert found, "Expected login not found in native export" + + +def test_native_new_profile(clean_profile): + # Firefox 14-byte IV profile (from user) + profile = clean_profile("firefox-14iv") + # Password 'pass' + logins = get_native_logins(str(profile), "pass") + + assert logins is not None, "Native login decryption failed for new profile" + assert len(logins) > 0 + + # Expected: https://google.com, test, pass + found = False + for login in logins: + if login["hostname"] == "https://google.com": + found = True + assert login["username"] == "test" + assert login["password"] == "pass" + break + assert found, "Expected login not found in native export" diff --git a/tests/test_run.py b/tests/test_run.py index a244a83..31eed21 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -2,48 +2,81 @@ import subprocess -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 +MASTER_PASSWORD = "test" +HEADER = "url,username,password" +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] -def run_ffpass(mode, path): - command = ["ffpass", mode, "-d", path] - if mode == 'import': - ffpass_input = HEADER + IMPORT_CREDENTIAL +def run_ffpass_cmd(mode, path): + command = ["python", "./ffpass/__init__.py", mode, "--debug", "--dir", str(path)] + + if mode == "import": + ffpass_input = "\n".join([HEADER, IMPORT_CREDENTIAL]) else: - ffpass_input = None + # Pass password via stdin to avoid interactive prompt hang and verify pipe support + ffpass_input = MASTER_PASSWORD + + return subprocess.run( + command, stdout=subprocess.PIPE, input=ffpass_input, encoding="utf-8" + ) + + +def stdout_splitter(input_text): + return [x for x in input_text.splitlines()] + + +def test_legacy_firefox_export(clean_profile): + 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 - return subprocess.run(command, stdout=subprocess.PIPE, input=ffpass_input, encoding='utf-8') + +def test_firefox_export(clean_profile): + r = run_ffpass_cmd("export", clean_profile("firefox-84")) + r.check_returncode() + assert stdout_splitter(r.stdout) == EXPECTED_EXPORT_OUTPUT -def test_legacy_firefox_export(): - r = run_ffpass('export', '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_cmd("export", profile_path) r.check_returncode() - assert r.stdout == EXPECTED_EXPORT_OUTPUT + assert stdout_splitter(r.stdout) == EXPECTED_EXPORT_OUTPUT -def test_firefox_export(): - r = run_ffpass('export', 'tests/firefox-84') +def test_legacy_firefox(clean_profile): + profile_path = clean_profile("firefox-70") + + # modifies the temp file, not the original + r = run_ffpass_cmd("import", profile_path) r.check_returncode() - assert r.stdout == EXPECTED_EXPORT_OUTPUT + r = run_ffpass_cmd("export", profile_path) + r.check_returncode() + assert stdout_splitter(r.stdout) == EXPECTED_IMPORT_OUTPUT -def test_legacy_firefox(): - r = run_ffpass('import', 'tests/firefox-70') + +def test_firefox(clean_profile): + profile_path = clean_profile("firefox-84") + + r = run_ffpass_cmd("import", profile_path) r.check_returncode() - r = run_ffpass('export', 'tests/firefox-70') + r = run_ffpass_cmd("export", profile_path) r.check_returncode() - assert r.stdout == EXPECTED_IMPORT_OUTPUT + assert stdout_splitter(r.stdout) == EXPECTED_IMPORT_OUTPUT + +def test_firefox_aes(clean_profile): + profile_path = clean_profile("firefox-146-aes") -def test_firefox(): - r = run_ffpass('import', 'tests/firefox-84') + r = run_ffpass_cmd("import", profile_path) r.check_returncode() - r = run_ffpass('export', 'tests/firefox-84') + r = run_ffpass_cmd("export", profile_path) r.check_returncode() - assert r.stdout == EXPECTED_IMPORT_OUTPUT + assert stdout_splitter(r.stdout) == EXPECTED_IMPORT_OUTPUT diff --git a/tests/test_sha256_mock.py b/tests/test_sha256_mock.py new file mode 100644 index 0000000..584f9e6 --- /dev/null +++ b/tests/test_sha256_mock.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 + +import os +import sqlite3 +from hashlib import pbkdf2_hmac, sha1, sha256 + +from Crypto.Cipher import AES +from pyasn1.codec.der.encoder import encode as der_encode +from pyasn1.type.univ import Integer, ObjectIdentifier, OctetString, Sequence + +import ffpass + +# Constants from ffpass (redefined here for test generation) +OID_PBES2 = (1, 2, 840, 113_549, 1, 5, 13) +OID_PBKDF2 = (1, 2, 840, 113_549, 1, 5, 12) +OID_AES256_CBC = (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 build_pbes2_sequence( + global_salt, master_password, entry_salt, iters, plaintext, hash_method="sha256" +): + """ + Constructs a DER-encoded PBES2 sequence just like Firefox/ffpass expects. + """ + # 1. Derive Key + if hash_method == "sha1": + enc_pwd = sha1(global_salt + master_password.encode("utf-8")).digest() + elif hash_method == "sha256": + enc_pwd = sha256(global_salt + master_password.encode("utf-8")).digest() + elif hash_method == "plaintext": + enc_pwd = master_password.encode("utf-8") + else: + raise ValueError(f"Unknown hash method: {hash_method}") + + key_len = 32 + k = pbkdf2_hmac("sha256", enc_pwd, entry_salt, iters, dklen=key_len) + + # 2. Encrypt + iv = os.urandom(16) + cipher = AES.new(k, AES.MODE_CBC, iv) + ciphertext = cipher.encrypt(PKCS7pad(plaintext, block_size=16)) + + # 3. Build ASN.1 Structure + # ... (rest is same) + # Re-using previous logic structure but just checking it matches + + # Key Derivation Func + kdf_params = Sequence() + kdf_params.setComponentByPosition(0, OctetString(entry_salt)) + kdf_params.setComponentByPosition(1, Integer(iters)) + kdf_params.setComponentByPosition(2, Integer(key_len)) + + # Add PRF OID (AlgorithmIdentifier) + # 1.2.840.113549.2.9 = hmacWithSHA256 + alg_id_prf = Sequence() + alg_id_prf.setComponentByPosition(0, ObjectIdentifier((1, 2, 840, 113_549, 2, 9))) + # Parameters matches NULL usually or excluded? Let's check spec or assume explicit NULL is safe or omitted. + # ffpass logic checks len(pbkdf2_params) > 3. So we must add it as 4th element. + kdf_params.setComponentByPosition(3, alg_id_prf) + + kdf = Sequence() + kdf.setComponentByPosition(0, ObjectIdentifier(OID_PBKDF2)) + kdf.setComponentByPosition(1, kdf_params) + + # Encryption Scheme + enc_scheme = Sequence() + enc_scheme.setComponentByPosition(0, ObjectIdentifier(OID_AES256_CBC)) + enc_scheme.setComponentByPosition(1, OctetString(iv)) + + pbes2_seq = Sequence() + pbes2_seq.setComponentByPosition(0, kdf) + pbes2_seq.setComponentByPosition(1, enc_scheme) + + alg_id = Sequence() + alg_id.setComponentByPosition(0, ObjectIdentifier(OID_PBES2)) + alg_id.setComponentByPosition(1, pbes2_seq) + + top = Sequence() + top.setComponentByPosition(0, alg_id) + top.setComponentByPosition(1, OctetString(ciphertext)) + + return der_encode(top) + + +def test_plaintext_decryption(tmp_path): + print("Setting up test_plaintext_decryption...") + db_path = tmp_path / "key4.db" + + if db_path.exists(): + os.remove(db_path) + + conn = sqlite3.connect(str(db_path)) + c = conn.cursor() + c.execute("CREATE TABLE metadata (id TEXT, item1 BLOB, item2 BLOB)") + c.execute("CREATE TABLE nssPrivate (a11 BLOB, a102 BLOB)") + + global_salt = os.urandom(20) + master_password = "test-plaintext" + + entry_salt_check = os.urandom(20) + # Use PLAINTEXT here! + item2 = build_pbes2_sequence( + global_salt, + master_password, + entry_salt_check, + iters=1000, + plaintext=b"password-check", + hash_method="plaintext", + ) + + c.execute( + "INSERT INTO metadata (id, item1, item2) VALUES (?, ?, ?)", + ("password", global_salt, item2), + ) + + real_master_key = b"B" * 32 + entry_salt_key = os.urandom(20) + + a11 = build_pbes2_sequence( + global_salt, + master_password, + entry_salt_key, + iters=1000, + plaintext=real_master_key, + hash_method="plaintext", + ) + a102 = b"\x00" * 16 + + c.execute("INSERT INTO nssPrivate (a11, a102) VALUES (?, ?)", (a11, a102)) + + conn.commit() + conn.close() + + print(f"Database created at {db_path}") + + print("Attempting to unlock (plaintext)...") + keys, returned_salt = ffpass.get_all_keys(tmp_path, master_password) + + print(f"Unlocked! Found {len(keys)} keys.") + assert len(keys) == 1 + assert keys[0] == real_master_key + assert returned_salt == global_salt + print("SUCCESS: Master key verified correctly with PLAINTEXT hashing.") + + +def test_sha256_decryption(tmp_path): + import logging + + logging.basicConfig(level=logging.DEBUG) + print("Setting up test_sha256_decryption...") + db_path = tmp_path / "key4.db" + + conn = sqlite3.connect(str(db_path)) + c = conn.cursor() + c.execute("CREATE TABLE metadata (id TEXT, item1 BLOB, item2 BLOB)") + c.execute("CREATE TABLE nssPrivate (a11 BLOB, a102 BLOB)") + + global_salt = os.urandom(20) + master_password = "test-new-profile" + + # 1. Create 'password' metadata entry + # item1 = global_salt + # item2 = encrypted "password-check" (padded to "password-check\x02\x02") + + entry_salt_check = os.urandom(20) + # Use SHA256 here! + item2 = build_pbes2_sequence( + global_salt, + master_password, + entry_salt_check, + iters=1000, + plaintext=b"password-check", + hash_method="sha256", + ) + + c.execute( + "INSERT INTO metadata (id, item1, item2) VALUES (?, ?, ?)", + ("password", global_salt, item2), + ) + + # 2. Create a 'nssPrivate' master key entry + # The actual master key (random 32 bytes) + real_master_key = b"A" * 32 + entry_salt_key = os.urandom(20) + + # Encrypt the master key using the same derived key logic + a11 = build_pbes2_sequence( + global_salt, + master_password, + entry_salt_key, + iters=1000, + plaintext=real_master_key, + hash_method="sha256", + ) + # a102 is just the ID/Name of the key (usually looks like magic bytes) + a102 = b"\x00" * 16 + + c.execute("INSERT INTO nssPrivate (a11, a102) VALUES (?, ?)", (a11, a102)) + + conn.commit() + conn.close() + + print(f"Database created at {db_path}") + + # Now run ffpass logic + print("Attempting to unlock...") + keys, returned_salt = ffpass.get_all_keys(tmp_path, master_password) + + print(f"Unlocked! Found {len(keys)} keys.") + assert len(keys) == 1 + assert keys[0] == real_master_key + assert returned_salt == global_salt + print("SUCCESS: Master key verified correctly with SHA256 hashing.")