From: Shane Jaroch Date: Thu, 25 Dec 2025 22:26:33 +0000 (-0500) Subject: v0.6.0: Mixed keys. GH Actions on Windows/macOS X-Git-Url: https://git.nutra.tk/v1?a=commitdiff_plain;h=855f54ea8a9beea37fc654326e748ce58ac22ec6;p=gamesguru%2Fffpass.git v0.6.0: Mixed keys. GH Actions on Windows/macOS tests: Generate mixed key test data. Use some mocks. --- diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml index 24ddd97..7b2bff4 100644 --- a/.github/workflows/testing.yaml +++ b/.github/workflows/testing.yaml @@ -5,7 +5,7 @@ on: [push, pull_request_target] jobs: test: name: Test - runs-on: ubuntu-latest + runs-on: [ubuntu-latest] steps: - name: Checkout uses: actions/checkout@v4 diff --git a/.github/workflows/windows-and-mac.yaml b/.github/workflows/windows-and-mac.yaml new file mode 100644 index 0000000..29f3b2f --- /dev/null +++ b/.github/workflows/windows-and-mac.yaml @@ -0,0 +1,50 @@ +name: windows-and-mac + +on: [push, pull_request_target] + +jobs: + windows: + name: windows + runs-on: [windows-latest] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install . + + - name: Test with pytest + run: | + pip install pytest + pip install pytest-cov + python -m pytest tests --junit-xml pytest.xml + + macOS: + name: macOS + runs-on: [macos-latest] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install . + + - name: Test with pytest + run: | + pip install pytest + pip install pytest-cov + python -m pytest tests --junit-xml pytest.xml \ No newline at end of file diff --git a/Makefile b/Makefile index b2ddd00..18d587a 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ install: .PHONY: test test: @echo 'Remember to run make install to test against the latest :)' - coverage run -m pytest -s tests/ + coverage run -m pytest -svv tests/ coverage report -m --omit="tests/*" diff --git a/ffpass/__init__.py b/ffpass/__init__.py index 4f4cb00..537fb82 100644 --- a/ffpass/__init__.py +++ b/ffpass/__init__.py @@ -1,36 +1,17 @@ #!/usr/bin/env python3 # PYTHON_ARGCOMPLETE_OK -# Reverse-engineering by Laurent Clevy (@lclevy) -# from https://github.com/lclevy/firepwd/blob/master/firepwd.py - """ The MIT License (MIT) Copyright (c) 2018 Louis Abraham Laurent Clevy (@lorenzo2472) -# from https://github.com/lclevy/firepwd/blob/master/firepwd.py -\x1B[34m\033[F\033[F ffpass can import and export passwords from Firefox Quantum. - -\x1B[0m\033[1m\033[F\033[F - -example of usage: - ffpass export --file passwords.csv - - ffpass import --file passwords.csv - -\033[0m\033[1;32m\033[F\033[F - -If you found this code useful, add a star on ! - -\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 @@ -43,6 +24,7 @@ from urllib.parse import urlparse import sqlite3 import os.path import logging +import string from pyasn1.codec.der.decoder import decode as der_decode from pyasn1.codec.der.encoder import encode as der_encode @@ -77,82 +59,26 @@ class NoProfile(Exception): pass -def getKey(directory: Path, masterPassword=""): - dbfile: Path = directory / "key4.db" +def censor(data): + """ + Censors the middle third of a hex string or bytes object. + """ + if not data: return "None" + s = data.hex() if isinstance(data, (bytes, bytearray)) else str(data) - if not dbfile.exists(): - raise NoDatabase() + length = len(s) + if length <= 12: return s - conn = sqlite3.connect(dbfile.as_posix()) - c = conn.cursor() - c.execute(""" - SELECT item1, item2 - FROM metadata - WHERE id = 'password'; - """) - row = next(c) - globalSalt, item2 = row + third = length // 3 + return f"{s[:third]}.....{s[2*third:]}" - # 1. Unpack item2 so it is an Object, not a Tuple - decodedItem2, _ = der_decode(item2) - try: - # Structure: Sequence[0] (AlgoID) -> [0] (OID) - algorithm_oid = decodedItem2[0][0].asTuple() - except (IndexError, AttributeError): - raise ValueError("Could not decode password validation data structure.") - - if algorithm_oid == OID_PKCS12_3DES: - encryption_method = '3DES' - entrySalt = decodedItem2[0][1][0].asOctets() - cipherT = decodedItem2[1].asOctets() - clearText = decrypt3DES( - globalSalt, masterPassword, entrySalt, cipherT - ) - elif algorithm_oid == OID_PBES2: - encryption_method = 'AES' - clearText = decrypt_aes(decodedItem2, masterPassword, globalSalt) - else: - raise ValueError(f"Unknown encryption method OID: {algorithm_oid}") - - if clearText != b"password-check\x02\x02": - raise WrongPassword() - - logging.info("password checked") - - # 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 - - # Determine encryption method for the key itself - if encryption_method == 'AES': - # 2. Unpack a11 so it is also an Object (Consistency Fix) - decodedA11, _ = der_decode(a11) - key = decrypt_aes(decodedA11, masterPassword, globalSalt) - key = PKCS7unpad(key) - elif encryption_method == '3DES': - decodedA11, _ = der_decode(a11) - oid = decodedA11[0][0].asTuple() - assert oid == OID_PKCS12_3DES, f"The key is encoded with an unknown format {oid}" - entrySalt = decodedA11[0][1][0].asOctets() - # FIX: Ciphertext is at index [1] of the Sequence, NOT inside parameters [0][1] - cipherT = decodedA11[1].asOctets() - key = decrypt3DES(globalSalt, masterPassword, entrySalt, cipherT) - key = PKCS7unpad(key) - # else: (impossible, handled above) - - logging.info("{}: {}".format(encryption_method, key.hex())) - return key +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, block_size=8): @@ -161,54 +87,13 @@ def PKCS7pad(b, block_size=8): def PKCS7unpad(b): + if not b: + return b return b[: -b[-1]] -def decrypt_aes(decoded_item, master_password, global_salt): - # Expects decoded_item as an ASN.1 OBJECT (Sequence), NOT a tuple. - # Structure: - # [0] AlgorithmIdentifier (Metadata) - # [1] EncryptedData (Ciphertext) - - # 1. Get PBKDF2 Parameters from Metadata [0] - # Path: AlgoID[0] -> Params[1] -> KeyDerivFunc[0] -> PBKDF2Params[1] - pbkdf2_params = decoded_item[0][1][0][1] - - entry_salt = pbkdf2_params[0].asOctets() - iteration_count = int(pbkdf2_params[1]) - key_length = int(pbkdf2_params[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) - - # 2. Get IV from Metadata [0] - # AlgoID[0] -> Params[1] -> EncryptionScheme[1] -> IV[1] - iv_obj = decoded_item[0][1][1][1] - init_vector = iv_obj.asOctets() - - # IF 14 bytes, THEN assume it's the raw payload of an ASN.1 OctetString, - # and add missing the header (0x04 0x0E) to make a full 16-byte block. - if len(init_vector) == 14: - init_vector = b'\x04\x0e' + init_vector - # IF 18 bytes (Standard ASN.1 OctetString: Tag 0x04 + Len 0x10 + 16 bytes), THEN strip header. - elif len(init_vector) == 18 and init_vector.startswith(b'\x04\x10'): - init_vector = init_vector[2:] - - # Final check - if len(init_vector) != 16: - raise ValueError(f"Incorrect IV length: {len(init_vector)} bytes (expected 16).") - - # 3. Get Ciphertext from Data [1] - encrypted_value = decoded_item[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() @@ -218,32 +103,143 @@ def decrypt3DES(globalSalt, masterPassword, entrySalt, encryptedData): k = k1 + k2 iv = k[-8:] key = k[:24] - logging.info("key={} iv={}".format(key.hex(), iv.hex())) return DES3.new(key, DES3.MODE_CBC, iv).decrypt(encryptedData) +def decrypt_key_entry(a11, global_salt, master_password): + try: + decoded, _ = der_decode(a11) + key_oid = decoded[0][0].asTuple() + + if key_oid == OID_PBES2: + # AES Logic + algo = decoded[0][1][0] + pbkdf2_params = algo[1] + entry_salt = pbkdf2_params[0].asOctets() + iters = int(pbkdf2_params[1]) + key_len = int(pbkdf2_params[2]) + + logging.debug(f" > Method: PBKDF2-HMAC-SHA256 | Iterations: {iters}") + logging.debug(f" > Salt: {censor(entry_salt)} (Local) + {censor(global_salt)} (Global)") + + enc_pwd = sha1(global_salt + master_password.encode('utf-8')).digest() + k = pbkdf2_hmac('sha256', enc_pwd, entry_salt, iters, dklen=key_len) + + iv = clean_iv(decoded[0][1][1][1].asOctets()) + logging.debug(f" > Cipher: AES-256-CBC | IV: {censor(iv)}") + + cipher = AES.new(k, AES.MODE_CBC, iv) + return PKCS7unpad(cipher.decrypt(decoded[1].asOctets())) + + elif key_oid == OID_PKCS12_3DES: + # 3DES Logic + entry_salt = decoded[0][1][0].asOctets() + ciphertext = decoded[1].asOctets() + + logging.debug(f" > Method: PKCS12-3DES-Derivation") + logging.debug(f" > Salt: {censor(entry_salt)} (Local) + {censor(global_salt)} (Global)") + + import hmac + 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] + + logging.debug(f" > Cipher: 3DES-CBC | IV: {censor(iv)}") + return PKCS7unpad(DES3.new(key, DES3.MODE_CBC, iv).decrypt(ciphertext)) + + except Exception as e: + logging.debug(f" > Failed: {e}") + return None + + +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. Check Password (simplified via key decryption attempt) + # 3. Find ALL Keys + c.execute("SELECT a11, a102 FROM nssPrivate") + rows = c.fetchall() + logging.info(f"[*] Found {len(rows)} entries in nssPrivate") + + found_keys = [] + for idx, (a11, a102) in enumerate(rows): + logging.debug(f"[*] Attempting to decrypt Key #{idx} (ID: {censor(a102)})...") + + key = decrypt_key_entry(a11, global_salt, pwd) + + if key: + logging.info(f"[*] Decrypted Key #{idx}: {len(key)} bytes | ID: {a102.hex()}") + found_keys.append(key) + else: + logging.debug(f"[*] Key #{idx}: Failed to decrypt (Wrong Password or Corrupt)") + + if not found_keys: + # If no keys decrypted, the password is definitely wrong + raise WrongPassword() + + 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: 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: 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 - - algo_oid = asn1data[1][0].asTuple() - iv = asn1data[1][1].asOctets() - ciphertext = asn1data[2].asOctets() - - # Handle encryption types - if algo_oid == MAGIC2: - # 3DES logic (ensure key is 24 bytes) - des = DES3.new(key[:24], DES3.MODE_CBC, iv) - return PKCS7unpad(des.decrypt(ciphertext)).decode() - - elif algo_oid == MAGIC_AES: - # AES logic (use full key, all 32 bytes) - cipher = AES.new(key, AES.MODE_CBC, iv) - return PKCS7unpad(cipher.decrypt(ciphertext)).decode() + try: + asn1data, _ = der_decode(b64decode(data)) + iv = clean_iv(asn1data[1][1].asOctets()) + ciphertext = asn1data[2].asOctets() - else: - raise ValueError(f"Unknown encryption OID: {algo_oid}") + 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): @@ -268,12 +264,8 @@ def encodeLoginData(key, data): asn1data[1][0] = ObjectIdentifier(MAGIC2) asn1data[1][1] = OctetString(iv) asn1data[2] = OctetString(ciphertext) - else: - raise ValueError( - f"Unknown key type/size: {len(key)} bytes. " - "Known types: [3DES: 24 bytes], [AES-256: 32 bytes]." - ) + raise ValueError(f"Unknown key type/size: {len(key)}") return b64encode(der_encode(asn1data)).decode() @@ -295,17 +287,15 @@ def exportLogins(key, jsonLogins): return [] logins = [] for row in jsonLogins["logins"]: - if row.get("deleted"): + if row.get("deleted"): continue + 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 - encUsername = row["encryptedUsername"] - encPassword = row["encryptedPassword"] - logins.append( - ( - row["hostname"], - decodeLoginData(key, encUsername), - decodeLoginData(key, encPassword), - ) - ) return logins @@ -320,7 +310,6 @@ def readCSV(csv_file): 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') return logins @@ -392,20 +381,29 @@ def askpass(directory): password = "" while True: try: - key = getKey(directory, password) + # FIX: Use get_all_keys and select best key manually + keys, _ = get_all_keys(directory, password) + # Prefer 32-byte key, fallback to first + best_key = next((k for k in keys if len(k) == 32), keys[0]) + logging.info(f"Selected Master Key: {len(best_key)} bytes (from {len(keys)} candidates)") + return best_key except WrongPassword: - password = getpass("Master Password:") + password = getpass("Master Password: ") else: break - return key + return None def main_export(args): try: key = askpass(args.directory) except NoDatabase: - # if the database is empty, we are done! return + + if not key: + logging.error("Failed to derive master key.") + return + jsonLogins = getJsonLogins(args.directory) logins = exportLogins(key, jsonLogins) writer = csv.writer(args.file) @@ -416,14 +414,17 @@ def main_export(args): def main_import(args): if args.file == sys.stdin: try: - key = getKey(args.directory) + key = askpass(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) + + if not key: + logging.error("Failed to derive master key.") + return + jsonLogins = getJsonLogins(args.directory) logins = readCSV(args.file) addNewLogins(key, jsonLogins, logins) @@ -448,29 +449,14 @@ def makeParser(): ) parser_import.add_argument( - "-f", - "--file", - dest="file", - type=argparse.FileType("r", encoding="utf-8"), - default=sys.stdin, + "-f", "--file", dest="file", type=argparse.FileType("r", encoding="utf-8"), default=sys.stdin ) parser_export.add_argument( - "-f", - "--file", - dest="file", - type=argparse.FileType("w", encoding="utf-8"), - default=sys.stdout, + "-f", "--file", dest="file", type=argparse.FileType("w", encoding="utf-8"), default=sys.stdout ) for sub in subparsers.choices.values(): - sub.add_argument( - "-d", - "--directory", - "--dir", - type=Path, - default=None, - help="Firefox profile directory", - ) + sub.add_argument("-d", "--directory", "--dir", type=Path, default=None, help="Firefox profile directory") sub.add_argument("-v", "--verbose", action="store_true") sub.add_argument("--debug", action="store_true") @@ -487,33 +473,37 @@ def makeParser(): def main(): - logging.basicConfig(level=logging.ERROR, format="%(levelname)s: %(message)s") - + # Default level ERROR (Silent), INFO for verbose, DEBUG for debug parser = makeParser() args = parser.parse_args() + log_level = logging.ERROR if args.verbose: log_level = logging.INFO - elif args.debug: + if args.debug: log_level = logging.DEBUG - else: - log_level = logging.ERROR - logging.getLogger().setLevel(log_level) + logging.basicConfig(level=log_level, format="%(message)s") if args.directory is None: try: args.directory = guessDir() except NoProfile: - print("") + print("No Firefox profile found.") parser.print_help() parser.exit() args.directory = args.directory.expanduser() try: - args.func(args) + # Wrap in try/except for BrokenPipeError to allow piping to head + try: + args.func(args) + except BrokenPipeError: + # Python flushes standard streams on exit; redirect remaining output to devnull to avoid error dump + sys.stdout = os.fdopen(1, 'w') + pass 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/scripts/generate_mixed_profile.py b/scripts/generate_mixed_profile.py new file mode 100755 index 0000000..9dc1c5a --- /dev/null +++ b/scripts/generate_mixed_profile.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Created on Thu Dec 25 20:34:59 2025 + +@author: shane +""" + +import sqlite3 +import json +import os +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/setup.py b/setup.py index 211d265..57fb9a8 100755 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ from setuptools import setup 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( diff --git a/tests/firefox-mixed-keys/key4.db b/tests/firefox-mixed-keys/key4.db new file mode 100644 index 0000000..74a40c6 Binary files /dev/null and b/tests/firefox-mixed-keys/key4.db differ 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/test_legacy-key-mixed.py b/tests/test_legacy-key-mixed.py new file mode 100644 index 0000000..ea13d52 --- /dev/null +++ b/tests/test_legacy-key-mixed.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 + +import os +import subprocess +import shutil +import pytest +import sys +from unittest.mock import patch +from pathlib import Path + +# Add project root to path so we can import ffpass internals for mocking +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from ffpass import get_all_keys + +OS_NEWLINE = os.linesep +HEADER = 'url,username,password' +EXPECTED_MIXED_OUTPUT = [HEADER, 'http://www.mixedkeys.com,modern_user,modern_pass'] + + +@pytest.fixture +def clean_profile(tmp_path): + """ + Copies the requested profile to a temporary directory. + """ + def _setup(profile_name): + src = Path('tests') / profile_name + dst = tmp_path / profile_name + shutil.copytree(src, dst) + return dst + return _setup + + +def run_ffpass_internal(mode, path): + """ + Runs ffpass as a library call instead of subprocess. + This allows us to MOCK the decryption crypto while testing the CLI glue code. + """ + from ffpass import main + + # Mock sys.argv + test_args = ["ffpass", mode, "-d", str(path)] + + # We need to patch the low-level crypto functions because our + # tests/firefox-mixed-keys/key4.db contains dummy blobs ('blob_modern_32'), + # not real encrypted ASN.1 structures. + with patch('sys.argv', test_args), \ + patch('ffpass.decrypt_key_entry') as mock_decrypt_key, \ + patch('ffpass.try_decrypt_login') as mock_decrypt_login: + + # 1. Mock Key Extraction + # When ffpass scans key4.db, it will find our two dummy blobs. + # We simulate them decrypting to keys of different sizes. + def decrypt_side_effect(blob, salt, pwd): + if blob == b'blob_legacy_24': + return b'L' * 24 # Legacy 24-byte key + if blob == b'blob_modern_32': + return b'M' * 32 # Modern 32-byte key + return None + mock_decrypt_key.side_effect = decrypt_side_effect + + # 2. Mock Login Decryption + # When ffpass tries to decrypt the login using a key, verify it uses the RIGHT key. + def login_side_effect(key, ct, iv): + # Only decrypt if the key is the 32-byte "Modern" key + if key == b'M' * 32: + return "modern_user" if "Username" in str(ct) else "modern_pass", "AES-Standard" + # If it tries the legacy key, fail (simulating garbage output) + return None, None + + # We need to be a bit looser here because try_decrypt_login signature takes raw bytes + # We just return success blindly for the 32-byte key to prove selection logic worked + mock_decrypt_login.side_effect = lambda k, c, i: ("modern_user" if len(k) == 32 else None, "AES") if k == b'M'*32 else (None, None) + + # To make the specific values match the EXPECTED_OUTPUT: + # We'll just patch decodeLoginData higher up to keep it simple + with patch('ffpass.decodeLoginData') as mock_decode: + # If the key is 32 bytes, return success data + # If the key is 24 bytes, raise error + def decode_side_effect(key, data): + if len(key) == 32: + if "Username" in data: return "modern_user" # Hacky heuristics for test + return "modern_pass" + 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): + """ + E2E-style test for a profile containing both 3DES (24B) and AES (32B) keys. + Verifies that ffpass correctly identifies and uses the AES key. + """ + # 1. Setup the profile + profile_path = clean_profile('firefox-mixed-keys') + + # 2. Run FFPass (Internal Mocked Version) + output = run_ffpass_internal('export', profile_path) + + # 3. Verify Output + # If the logic works, it ignored the 24-byte key and successfully + # decrypted using the 32-byte key mock. + actual = stdout_splitter(output) + + # We patch the return values to match this exact expectation + # If the tool picked the wrong key, decodeLoginData would have raised ValueError + # and the output would be empty or error logs. + assert actual == EXPECTED_MIXED_OUTPUT diff --git a/tests/test_mixed_keys_run.py b/tests/test_mixed_keys_run.py new file mode 100644 index 0000000..b8f8b33 --- /dev/null +++ b/tests/test_mixed_keys_run.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 + +import os +import shutil +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +# Add project root to path so we can import ffpass internals for mocking +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from ffpass import get_all_keys + +OS_NEWLINE = os.linesep +HEADER = "url,username,password" +EXPECTED_MIXED_OUTPUT = [HEADER, "http://www.mixedkeys.com,modern_user,modern_pass"] + + +@pytest.fixture +def clean_profile(tmp_path): + """ + Copies the requested profile to a temporary directory. + """ + + def _setup(profile_name): + src = Path("tests") / profile_name + dst = tmp_path / profile_name + if not src.exists(): + pytest.fail( + f"Test profile '{profile_name}' not found. Did you run scripts/generate_mixed_profile.py?" + ) + shutil.copytree(src, dst) + return dst + + return _setup + + +def run_ffpass_internal(mode, path): + """ + Runs ffpass as a library call instead of subprocess. + This allows us to MOCK the decryption crypto while testing the CLI glue code. + """ + from ffpass import main + + # Mock sys.argv + test_args = ["ffpass", mode, "-d", str(path)] + + # We need to patch the low-level crypto functions because our + # tests/firefox-mixed-keys/key4.db contains dummy blobs ('blob_modern_32'), + # not real encrypted ASN.1 structures. + with patch("sys.argv", test_args), patch( + "ffpass.decrypt_key_entry" + ) as mock_decrypt_key, patch("ffpass.try_decrypt_login") as mock_decrypt_login: + + # 1. Mock Key Extraction + # When ffpass scans key4.db, it will find our two dummy blobs. + # We simulate them decrypting to keys of different sizes. + def decrypt_side_effect(blob, salt, pwd): + if blob == b"blob_legacy_24": + return b"L" * 24 # Legacy 24-byte key + if blob == b"blob_modern_32": + return b"M" * 32 # Modern 32-byte key + return None + + mock_decrypt_key.side_effect = decrypt_side_effect + + # 2. Mock Golden Key Check (try_decrypt_login) + # This function is called to verify if a key works on the first row. + # We return success only for the 32-byte key. + def try_login_side_effect(key, ct, iv): + if key == b"M" * 32: + # Return a valid string so the check passes + return "valid_utf8_string", "AES-Standard" + return None, None + + mock_decrypt_login.side_effect = try_login_side_effect + + # 3. Mock Final Decryption (decodeLoginData) + # This is used during the actual CSV export loop. + with patch("ffpass.decodeLoginData") as mock_decode: + # Since the test data has identical strings for user/pass, + # we use an iterator to return 'user' first, 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_field" + 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): + """ + E2E-style test for a profile containing both 3DES (24B) and AES (32B) keys. + Verifies that ffpass correctly identifies and uses the AES key. + """ + # 1. Setup the profile + profile_path = clean_profile("firefox-mixed-keys") + + # 2. Run FFPass (Internal Mocked Version) + output = run_ffpass_internal("export", profile_path) + + # 3. Verify Output + actual = stdout_splitter(output) + + assert actual == EXPECTED_MIXED_OUTPUT diff --git a/tests/test_run.py b/tests/test_run.py index ee6d5ee..18dcc37 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -1,15 +1,18 @@ #!/usr/bin/env python3 +import os import subprocess import shutil import pytest from pathlib import Path +OS_NEWLINE = os.linesep + MASTER_PASSWORD = 'test' -HEADER = 'url,username,password\n' -IMPORT_CREDENTIAL = 'http://www.example.com,foo,bar\n' -EXPECTED_EXPORT_OUTPUT = f'{HEADER}http://www.stealmylogin.com,test,test\n' -EXPECTED_IMPORT_OUTPUT = EXPECTED_EXPORT_OUTPUT + IMPORT_CREDENTIAL +HEADER = 'url,username,password' +IMPORT_CREDENTIAL = 'http://www.example.com,foo,bar' +EXPECTED_EXPORT_OUTPUT = [HEADER, 'http://www.stealmylogin.com,test,test'] +EXPECTED_IMPORT_OUTPUT = EXPECTED_EXPORT_OUTPUT + [IMPORT_CREDENTIAL] @pytest.fixture @@ -30,23 +33,28 @@ def run_ffpass(mode, path): command = ["ffpass", mode, "-d", str(path)] if mode == 'import': - ffpass_input = HEADER + IMPORT_CREDENTIAL + ffpass_input = OS_NEWLINE.join([HEADER, IMPORT_CREDENTIAL]) else: ffpass_input = None return subprocess.run(command, stdout=subprocess.PIPE, input=ffpass_input, encoding='utf-8') +def stdout_splitter(input_text): + return [x for x in input_text.splitlines() if x != ""] + + def test_legacy_firefox_export(clean_profile): r = run_ffpass('export', clean_profile('firefox-70')) r.check_returncode() - assert r.stdout == EXPECTED_EXPORT_OUTPUT + actual_export_output = stdout_splitter(r.stdout) + assert actual_export_output == EXPECTED_EXPORT_OUTPUT def test_firefox_export(clean_profile): r = run_ffpass('export', clean_profile('firefox-84')) r.check_returncode() - assert r.stdout == EXPECTED_EXPORT_OUTPUT + assert stdout_splitter(r.stdout) == EXPECTED_EXPORT_OUTPUT def test_firefox_aes_export(clean_profile): @@ -54,7 +62,7 @@ def test_firefox_aes_export(clean_profile): profile_path = clean_profile('firefox-146-aes') r = run_ffpass('export', profile_path) r.check_returncode() - assert r.stdout == EXPECTED_EXPORT_OUTPUT + assert stdout_splitter(r.stdout) == EXPECTED_EXPORT_OUTPUT def test_legacy_firefox(clean_profile): @@ -66,7 +74,7 @@ def test_legacy_firefox(clean_profile): r = run_ffpass('export', profile_path) r.check_returncode() - assert r.stdout == EXPECTED_IMPORT_OUTPUT + assert stdout_splitter(r.stdout) == EXPECTED_IMPORT_OUTPUT def test_firefox(clean_profile): @@ -77,5 +85,4 @@ def test_firefox(clean_profile): r = run_ffpass('export', profile_path) r.check_returncode() - assert r.stdout == EXPECTED_IMPORT_OUTPUT - + assert stdout_splitter(r.stdout) == EXPECTED_IMPORT_OUTPUT