From: Shane Jaroch Date: Thu, 25 Dec 2025 19:27:03 +0000 (-0500) Subject: v0.6.0: Import & export for AES and mixed keys. X-Git-Url: https://git.nutra.tk/v1?a=commitdiff_plain;h=c163dcb028ea5bd26fefada92a11bfa81ffc455d;p=gamesguru%2Fffpass.git v0.6.0: Import & export for AES and mixed keys. - GH Actions on Windows/macOS - Add argcomplete (optional end-user add-on). - Add fixes & tests for master password. - tests: Generate mixed key test data. Use some mocks. - tests: Avoid modifying .json files, use temp storage. - tests: Add test for AES export/import (AES, 3DES, and mixed keys). - Tidy up, lint, and format. - Fix edge cases with argcomplete. - Windows: Fix profile discovery and CSV printing. - Better loop logic (for imports w/ empty row or missing header). - Config files. - Add stuff back that was removed accidentally in a frenzy. --- diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..488515d --- /dev/null +++ b/.envrc @@ -0,0 +1,4 @@ +source .venv/bin/activate +unset PS1 +eval "$(register-python-argcomplete ffpass)" + diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml index 24ddd97..d00081b 100644 --- a/.github/workflows/testing.yaml +++ b/.github/workflows/testing.yaml @@ -5,7 +5,7 @@ on: [push, pull_request_target] jobs: test: name: Test - runs-on: ubuntu-latest + runs-on: [ubuntu-latest] steps: - name: Checkout uses: actions/checkout@v4 @@ -29,7 +29,7 @@ jobs: - name: Lint with flake8 run: | pip install flake8 - flake8 --ignore=E741,E501 . + flake8 . --exclude='*venv,build' --ignore=E741,E501 - name: Upload Unit Test Results if: always() diff --git a/.github/workflows/windows-and-mac.yaml b/.github/workflows/windows-and-mac.yaml new file mode 100644 index 0000000..29f3b2f --- /dev/null +++ b/.github/workflows/windows-and-mac.yaml @@ -0,0 +1,50 @@ +name: windows-and-mac + +on: [push, pull_request_target] + +jobs: + windows: + name: windows + runs-on: [windows-latest] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install . + + - name: Test with pytest + run: | + pip install pytest + pip install pytest-cov + python -m pytest tests --junit-xml pytest.xml + + macOS: + name: macOS + runs-on: [macos-latest] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install . + + - name: Test with pytest + run: | + pip install pytest + pip install pytest-cov + python -m pytest tests --junit-xml pytest.xml \ No newline at end of file diff --git a/Makefile b/Makefile index cf7baac..2390acd 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,39 @@ +SHELL:=/bin/bash + +.PHONY: pypi pypi: dist twine upload dist/* - + +.PHONY: dist dist: flake8 -rm dist/* ./setup.py sdist bdist_wheel +.PHONY: flake8 flake8: - flake8 . --count --select=E901,E999,F821,F822,F823 --show-source --statistics - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + flake8 . --exclude '*venv,build' --count --select=E901,E999,F821,F822,F823 --show-source --statistics + flake8 . --exclude '*venv,build' --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + # CI pipeline + flake8 . --exclude='*venv,build' --ignore=E741,E501 + + +.PHONY: install +install: + pip install . + +.PHONY: test +test: + @echo 'Remember to run make install to test against the latest :)' + coverage run -m pytest -svv tests/ + coverage report -m --omit="tests/*" + +.PHONY: clean clean: rm -rf *.egg-info build dist + rm -f .coverage + find . \ + -name .venv -prune \ + -o -name __pycache__ -print \ + -o -name .pytest_cache -print \ + | xargs -r rm -rf diff --git a/ffpass/__init__.py b/ffpass/__init__.py old mode 100644 new mode 100755 index 56262cb..e7f7a09 --- a/ffpass/__init__.py +++ b/ffpass/__init__.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +# PYTHON_ARGCOMPLETE_OK # Reverse-engineering by Laurent Clevy (@lclevy) # from https://github.com/lclevy/firepwd/blob/master/firepwd.py @@ -26,10 +27,10 @@ If you found this code useful, add a star on Method: PBKDF2-HMAC-SHA256 | Iterations: {iters}") + logging.debug(f" > Salt: {censor(entry_salt)} (Local) + {censor(global_salt)} (Global)") + + enc_pwd = sha1(global_salt + master_password.encode('utf-8')).digest() + k = pbkdf2_hmac('sha256', enc_pwd, entry_salt, iters, dklen=key_len) + + iv = clean_iv(decoded[0][1][1][1].asOctets()) + logging.debug(f" > Cipher: AES-256-CBC | IV: {censor(iv)}") + + cipher = AES.new(k, AES.MODE_CBC, iv) + return PKCS7unpad(cipher.decrypt(decoded[1].asOctets())) + + elif key_oid == OID_PKCS12_3DES: + # 3DES Logic + entry_salt = decoded[0][1][0].asOctets() + ciphertext = decoded[1].asOctets() + + logging.debug(" > Method: PKCS12-3DES-Derivation") + logging.debug(f" > Salt: {censor(entry_salt)} (Local) + {censor(global_salt)} (Global)") + + return PKCS7unpad(decrypt3DES(global_salt, master_password, entry_salt, ciphertext)) + + except Exception as e: + logging.debug(f" > Failed: {e}") + return None + + +def verify_password(global_salt, item2, pwd): + """ + Verifies the master password against the metadata entry (item2). + Raises WrongPassword on failure. + """ + try: + decodedItem2, _ = der_decode(item2) + try: + algorithm_oid = decodedItem2[0][0].asTuple() + except (IndexError, AttributeError): + raise ValueError("Could not decode password validation data structure.") + + if algorithm_oid == OID_PKCS12_3DES: + entrySalt = decodedItem2[0][1][0].asOctets() + cipherT = decodedItem2[1].asOctets() + clearText = decrypt3DES(global_salt, pwd, entrySalt, cipherT) + elif algorithm_oid == OID_PBES2: + algo = decodedItem2[0][1][0] + pbkdf2_params = algo[1] + entry_salt = pbkdf2_params[0].asOctets() + iters = int(pbkdf2_params[1]) + key_len = int(pbkdf2_params[2]) + enc_pwd = sha1(global_salt + pwd.encode('utf-8')).digest() + k = pbkdf2_hmac('sha256', enc_pwd, entry_salt, iters, dklen=key_len) + iv = clean_iv(decodedItem2[0][1][1][1].asOctets()) + cipher = AES.new(k, AES.MODE_CBC, iv) + clearText = cipher.decrypt(decodedItem2[1].asOctets()) + else: + raise ValueError(f"Unknown encryption method OID: {algorithm_oid}") + + if clearText != b"password-check\x02\x02": + raise WrongPassword() + + except Exception as e: + logging.debug(f"Password check failed: {e}") + raise WrongPassword() + + +def get_all_keys(directory, pwd=""): + db = Path(directory) / "key4.db" + if not db.exists(): + raise NoDatabase() + + conn = sqlite3.connect(str(db)) + c = conn.cursor() + + # 1. Get Global Salt + c.execute("SELECT item1, item2 FROM metadata WHERE id = 'password'") + try: + global_salt, item2 = next(c) + except StopIteration: + raise NoDatabase() + + logging.info(f"[*] Global Salt: {censor(global_salt)}") + + # 2. VERIFY PASSWORD EXPLICITLY + verify_password(global_salt, item2, pwd) + logging.info("[*] Password Verified Correctly") + + # 3. Find ALL Keys + c.execute("SELECT a11, a102 FROM nssPrivate") + rows = c.fetchall() + logging.info(f"[*] Found {len(rows)} entries in nssPrivate") + + # Check if rows exist BEFORE assuming corruption. + # If the table is empty, it's just an empty DB, not corruption. + if not rows: + raise NoDatabase() + + found_keys = [] + for idx, (a11, a102) in enumerate(rows): + logging.debug(f"[*] Attempting to decrypt Key #{idx} (ID: {censor(a102)})...") + + key = decrypt_key_entry(a11, global_salt, pwd) + + if key: + logging.info(f"[*] Decrypted Key #{idx}: {len(key)} bytes | ID: {a102.hex()}") + found_keys.append(key) + else: + logging.debug(f"[*] Key #{idx}: Failed to decrypt (Corrupt?)") + + if not found_keys: + # Rows existed, but none decrypted successfully. + # Since password was verified in step 2, this IS corruption. + raise Exception("Database corrupted: Password verified, but no valid master keys could be decrypted.") + + return found_keys, global_salt + + +def try_decrypt_login(key, ciphertext, iv): + # Try AES + if len(key) in [16, 24, 32]: + try: + cipher = AES.new(key, AES.MODE_CBC, iv) + pt = cipher.decrypt(ciphertext) + res = PKCS7unpad(pt) + text = res.decode('utf-8') + if is_valid_text(text): + return text, "AES-Standard" + except Exception: + pass + + # Try 3DES + if len(key) == 24: + try: + cipher = DES3.new(key, DES3.MODE_CBC, iv[:8]) + pt = cipher.decrypt(ciphertext) + res = PKCS7unpad(pt) + text = res.decode('utf-8') + if is_valid_text(text): + return text, "3DES-Standard" + except Exception: + pass + + return None, None + + +def is_valid_text(text): + if not text or len(text) < 2: + return False + printable = set(string.printable) + if sum(1 for c in text if c in printable) / len(text) < 0.9: + return False + return True + + def decodeLoginData(key, data): - # first base64 decoding, then ASN1DERdecode - asn1data, _ = der_decode(b64decode(data)) - assert asn1data[0].asOctets() == MAGIC1 - assert asn1data[1][0].asTuple() == MAGIC2 - iv = asn1data[1][1].asOctets() - ciphertext = asn1data[2].asOctets() - des = DES3.new(key, DES3.MODE_CBC, iv) - return PKCS7unpad(des.decrypt(ciphertext)).decode() + try: + asn1data, _ = der_decode(b64decode(data)) + iv = clean_iv(asn1data[1][1].asOctets()) + ciphertext = asn1data[2].asOctets() + + text, method = try_decrypt_login(key, ciphertext, iv) + if text: + return text + raise ValueError("Decryption failed") + except Exception: + raise ValueError("Decryption failed") def encodeLoginData(key, data): - iv = secrets.token_bytes(8) - des = DES3.new(key, DES3.MODE_CBC, iv) - ciphertext = des.encrypt(PKCS7pad(data.encode())) asn1data = Sequence() asn1data[0] = OctetString(MAGIC1) asn1data[1] = Sequence() - asn1data[1][0] = ObjectIdentifier(MAGIC2) - asn1data[1][1] = OctetString(iv) - asn1data[2] = OctetString(ciphertext) + + if len(key) == 32: # AES-256 + iv = secrets.token_bytes(16) + cipher = AES.new(key, AES.MODE_CBC, iv) + ciphertext = cipher.encrypt(PKCS7pad(data.encode(), block_size=16)) + + asn1data[1][0] = ObjectIdentifier(MAGIC_AES) + asn1data[1][1] = OctetString(iv) + asn1data[2] = OctetString(ciphertext) + + elif len(key) == 24: # 3DES + iv = secrets.token_bytes(8) + des = DES3.new(key, DES3.MODE_CBC, iv) + ciphertext = des.encrypt(PKCS7pad(data.encode(), block_size=8)) + + asn1data[1][0] = ObjectIdentifier(MAGIC2) + asn1data[1][1] = OctetString(iv) + asn1data[2] = OctetString(ciphertext) + else: + raise ValueError(f"Unknown key type/size: {len(key)}") + return b64encode(der_encode(asn1data)).decode() @@ -216,34 +356,70 @@ def exportLogins(key, jsonLogins): for row in jsonLogins["logins"]: if row.get("deleted"): continue - encUsername = row["encryptedUsername"] - encPassword = row["encryptedPassword"] - logins.append( - ( - row["hostname"], - decodeLoginData(key, encUsername), - decodeLoginData(key, encPassword), - ) - ) + try: + user = decodeLoginData(key, row["encryptedUsername"]) + pw = decodeLoginData(key, row["encryptedPassword"]) + logins.append((row["hostname"], user, pw)) + except Exception as e: + if logging.getLogger().isEnabledFor(logging.DEBUG): + logging.debug(f"Failed to decrypt {row.get('hostname')}: {e}") + continue return logins -def lower_header(csv_file): - it = iter(csv_file) - yield next(it).lower() - yield from it +def readCSV(csv_file): + reader = csv.reader(csv_file) -def readCSV(csv_file): logins = [] - reader = csv.DictReader(lower_header(csv_file)) - for row in reader: - logins.append((rawURL(row["url"]), row["username"], row["password"])) - logging.info(f'read {len(logins)} logins') + first_row = None + + # Loop through logins + for i, row in enumerate(reader): + + logging.debug(f"row: {row}") + + # Peek at the first line to detect if it is a header or normal row + if first_row is None: + logging.debug(f"first_row: {row}") + first_row = row + + # Break if we get an empty first row + if (not row) or (len(row) != 3) or (not row[1] and not row[2]): + logging.debug(f"Breaking loop since we got an empty row at index={i}.") + break + # Heuristic: if it lacks a URL (index=1) and has user,pass (index=2,3), assume it's a header and continue + if ( + "http://" not in first_row[0] + and first_row[1].lower() in {"username", "uname", "user", "u"} # noqa: W503 line break before binary operator + and first_row[2].lower() in {"password", "passwd", "pass", "p"} # noqa: W503 + ): + logging.debug(f"Continuing (skipping) over first row: [is_header={True}].") + continue + + # ~~~ END peek at first row ~~~~~~~~~~ + + # Break if we get an empty row at any time + if (not row) or (len(row) != 3) or (not row[1] and not row[2]): + logging.debug(f"Breaking loop since we got an empty row at index={i}.") + break + + u, n, p = row + logins.append((rawURL(u), n, p)) + return logins def rawURL(url): + if not url: + return "" + + # Fix for schemeless URLs (e.g. "test.com" -> "https://test.com") + # Without a scheme, urlparse puts the whole string in 'path' and leaves 'netloc' empty. + # ffpass expects 'netloc' to be populated to strip paths. + if "://" not in url: + url = "https://" + url + p = urlparse(url) return type(p)(*p[:2], *[""] * 4).geturl() @@ -251,9 +427,9 @@ def rawURL(url): def addNewLogins(key, jsonLogins, logins): nextId = jsonLogins["nextId"] timestamp = int(datetime.now().timestamp() * 1000) - logging.info('adding logins') + logging.warning(f'adding {len(logins)} logins') for i, (url, username, password) in enumerate(logins, nextId): - logging.debug(f'adding {url} {username}') + logging.info(f'adding {url} {username}') entry = { "id": i, "hostname": url, @@ -274,23 +450,30 @@ def addNewLogins(key, jsonLogins, logins): jsonLogins["nextId"] += len(logins) -def guessDir(): - dirs = { - "darwin": "~/Library/Application Support/Firefox/Profiles", - "linux": "~/.mozilla/firefox", - "win32": os.path.expandvars(r"%LOCALAPPDATA%\Mozilla\Firefox\Profiles"), - "cygwin": os.path.expandvars(r"%LOCALAPPDATA%\Mozilla\Firefox\Profiles"), - } +# Constants used to guess cross-platform +PROFILE_GUESS_DIRS = { + "darwin": "~/Library/Application Support/Firefox/Profiles", + "linux": "~/.mozilla/firefox", + "win32": os.path.expandvars("%APPDATA%\\Mozilla\\Firefox\\Profiles"), + "cygwin": os.path.expandvars("%APPDATA%\\Mozilla\\Firefox\\Profiles"), +} + + +def getProfiles() -> list[Path]: + paths = Path(PROFILE_GUESS_DIRS[sys.platform]).expanduser() + logging.debug(f"Paths: {paths}") + profiles = [path.parent for path in paths.glob(os.path.join("*", "logins.json"))] + logging.debug(f"Profiles: {profiles}") + return profiles + - if sys.platform not in dirs: +def guessDir() -> Path: + if sys.platform not in PROFILE_GUESS_DIRS: logging.error(f"Automatic profile selection is not supported for {sys.platform}") logging.error("Please specify a profile to parse (-d path/to/profile)") raise NoProfile - paths = Path(dirs[sys.platform]).expanduser() - profiles = [path.parent for path in paths.glob(os.path.join("*", "logins.json"))] - logging.debug(f"Paths: {paths}") - logging.debug(f"Profiles: {profiles}") + profiles = getProfiles() if len(profiles) == 0: logging.error("Cannot find any Firefox profiles") @@ -309,40 +492,52 @@ def guessDir(): def askpass(directory): password = "" + n = 0 + # Allow 1 automatic check + 2 user prompts = 3 attempts total. + # The condition "n < n_max" must allow n=0, n=1, n=2. + n_max = 3 while True: try: - key = getKey(directory, password) + keys, _ = get_all_keys(directory, password) + # Prefer 32-byte key, fallback to first + best_key = next((k for k in keys if len(k) == 32), keys[0]) + logging.info(f"Selected Master Key: {len(best_key)} bytes (from {len(keys)} candidates)") + return best_key except WrongPassword: - password = getpass("Master Password:") - else: - break - return key + n += 1 + if n >= n_max: + break + password = getpass("Master Password: ") + + if n > 0: + logging.error(f"wrong master password after {n_max - 1} prompts!") + return None def main_export(args): - try: - key = askpass(args.directory) - except NoDatabase: - # if the database is empty, we are done! + # Removed try/except NoDatabase here to let it bubble to main() for proper logging + key = askpass(args.directory) + + if not key: + logging.error("Failed to derive master key.") return + jsonLogins = getJsonLogins(args.directory) logins = exportLogins(key, jsonLogins) - writer = csv.writer(args.file) + # Hard-code to "\n" to fix Windows bug with every other row empty: [a, "", b, "", ...]. + writer = csv.writer(args.file, lineterminator="\n") writer.writerow(["url", "username", "password"]) writer.writerows(logins) def main_import(args): - if args.file == sys.stdin: - try: - key = getKey(args.directory) - except WrongPassword: - # it is not possible to read the password - # if stdin is used for input - logging.error("Password is not empty. You have to specify FROM_FILE.") - sys.exit(1) - else: - key = askpass(args.directory) + # askpass handles stdin/tty detection for the password prompt automatically + key = askpass(args.directory) + + if not key: + logging.error("Failed to derive master key.") + return + jsonLogins = getJsonLogins(args.directory) logins = readCSV(args.file) addNewLogins(key, jsonLogins, logins) @@ -367,65 +562,79 @@ def makeParser(): ) parser_import.add_argument( - "-f", - "--file", - dest="file", - type=argparse.FileType("r", encoding="utf-8"), - default=sys.stdin, + "-f", "--file", dest="file", type=argparse.FileType("r", encoding="utf-8"), default=sys.stdin ) parser_export.add_argument( - "-f", - "--file", - dest="file", - type=argparse.FileType("w", encoding="utf-8"), - default=sys.stdout, + "-f", "--file", dest="file", type=argparse.FileType("w", encoding="utf-8"), default=sys.stdout ) for sub in subparsers.choices.values(): - sub.add_argument( + arg = sub.add_argument( + "-p", # matches native: firefox -p "-d", "--directory", "--dir", type=Path, + metavar="DIRECTORY", default=None, help="Firefox profile directory", ) + # Use argcomplete completer instead of 'choices=' + # This allows arbitrary paths (tests) but gives users tab completion. + arg.completer = lambda **kwargs: [str(p) for p in getProfiles()] + sub.add_argument("-v", "--verbose", action="store_true") sub.add_argument("--debug", action="store_true") parser_import.set_defaults(func=main_import) parser_export.set_defaults(func=main_export) + + # Try to load argcomplete + try: + import argcomplete + argcomplete.autocomplete(parser) + + except ModuleNotFoundError: + sys.stderr( + "NOTE: You can run 'pip install argcomplete' " + "and add the hook to your shell RC for tab completion." + ) + return parser def main(): - logging.basicConfig(level=logging.ERROR, format="%(levelname)s: %(message)s") - parser = makeParser() args = parser.parse_args() - if args.verbose: - log_level = logging.INFO - elif args.debug: + # Determine log level + log_level = logging.WARNING + if args.debug: log_level = logging.DEBUG - else: - log_level = logging.ERROR + elif args.verbose: + log_level = logging.INFO - logging.getLogger().setLevel(log_level) + logging.basicConfig(level=log_level, format="%(message)s") + # Try to obtain profile directory if args.directory is None: try: - args.directory = guessDir() + args.directory = guessDir().expanduser() except NoProfile: - print("") + logging.error("No Firefox profile selected.") parser.print_help() parser.exit() - args.directory = args.directory.expanduser() + # Run arg parser try: - args.func(args) + # Wrap in try/except for BrokenPipeError to allow piping to head, i.e., ffpass export | head -5 + try: + args.func(args) + except BrokenPipeError: + sys.stdout = os.fdopen(1, 'w') + except NoDatabase: - logging.error("Firefox password database is empty. Please create it from Firefox.") + logging.error("Firefox password database is empty.") if __name__ == "__main__": diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..ded13e8 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +coverage==7.13.0 +flake8==7.3.0 +pytest==9.0.2 + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9a633f2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +argcomplete>=3.5.2 +pyasn1~=0.6.1 +pycryptodome~=3.23.0 + diff --git a/scripts/generate_mixed_profile.py b/scripts/generate_mixed_profile.py new file mode 100755 index 0000000..7a9a983 --- /dev/null +++ b/scripts/generate_mixed_profile.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Created on Thu Dec 25 20:34:59 2025 + +@author: shane +""" + +import json +import sqlite3 +from pathlib import Path + +# Constants for Firefox Crypto +MAGIC1 = b"\xf8\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01" + + +def create_mixed_profile(): + base_dir = Path("tests/firefox-mixed-keys") + if base_dir.exists(): + print(f"Directory {base_dir} already exists. Skipping generation.") + return + base_dir.mkdir(parents=True) + + print(f"Generating mixed key profile in {base_dir}...") + + # 1. Create key4.db with TWO keys + conn = sqlite3.connect(base_dir / "key4.db") + c = conn.cursor() + c.execute("CREATE TABLE metadata (id TEXT PRIMARY KEY, item1, item2)") + c.execute("CREATE TABLE nssPrivate (a11, a102)") + + # Metadata: Simple password check (Salt + Check Blob) + # This is a dummy check that our mock/test logic accepts + c.execute( + "INSERT INTO metadata VALUES ('password', ?, ?)", + (b"global_salt", b"pw_check_blob"), + ) + + # nssPrivate: Insert the MIXED keys + # Key 0: Legacy 24-byte key blob (We simulate this decrypting to 24 bytes) + # In a real DB, this is ASN.1 wrapped. For our integration test, + # we rely on the mocked decryptor in the test to interpret this specific blob. + c.execute("INSERT INTO nssPrivate VALUES (?, ?)", (b"blob_legacy_24", MAGIC1)) + + # Key 1: Modern 32-byte key blob + c.execute("INSERT INTO nssPrivate VALUES (?, ?)", (b"blob_modern_32", MAGIC1)) + + conn.commit() + conn.close() + + # 2. Create logins.json encrypted with the MODERN key + # Our test infrastructure mocks the decryption, so we can use dummy base64 strings + # The crucial part is that the test asserts it extracts data using the modern key logic + logins_data = { + "nextId": 2, + "logins": [ + { + "id": 1, + "hostname": "http://www.mixedkeys.com", + "encryptedUsername": "QUFBQUFBQUE=", # Base64 for 'AAAAAAAA' + "encryptedPassword": "QUFBQUFBQUE=", + "deleted": False, + } + ], + } + + with open(base_dir / "logins.json", "w") as f: + json.dump(logins_data, f) + + print("Done.") + + +if __name__ == "__main__": + create_mixed_profile() diff --git a/scripts/generate_mp_profile.py b/scripts/generate_mp_profile.py new file mode 100755 index 0000000..59d7e04 --- /dev/null +++ b/scripts/generate_mp_profile.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Created on Fri Dec 26 00:10:05 2025 + +@author: shane +""" + +import hmac +import json +import secrets +import sqlite3 +from hashlib import sha1 +from pathlib import Path + +from Crypto.Cipher import AES, DES3 +# Dependencies: pyasn1, pycryptodome +from pyasn1.codec.der.encoder import encode as der_encode +from pyasn1.type.univ import Integer, ObjectIdentifier, OctetString, Sequence + +# Constants +MASTER_PASSWORD = "password123" +GLOBAL_SALT = secrets.token_bytes(20) +# We will generate a 24-byte (3DES) master key to encrypt the database +REAL_MASTER_KEY = secrets.token_bytes(24) + +# OIDs +OID_PKCS12_3DES = (1, 2, 840, 113_549, 1, 12, 5, 1, 3) +MAGIC1 = b"\xf8\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01" +MAGIC_AES = (2, 16, 840, 1, 101, 3, 4, 1, 42) + + +def PKCS7pad(b, block_size=8): + pad_len = (-len(b) - 1) % block_size + 1 + return b + bytes([pad_len] * pad_len) + + +def derive_3des_key(global_salt, master_password, entry_salt): + """ + Derives Key and IV using the specific Firefox/NSS PKCS#12-like KDF. + Matches decrypt3DES in ffpass/__init__.py + """ + hp = sha1(global_salt + master_password.encode()).digest() + pes = entry_salt + b"\x00" * (20 - len(entry_salt)) + chp = sha1(hp + entry_salt).digest() + k1 = hmac.new(chp, pes + entry_salt, sha1).digest() + tk = hmac.new(chp, pes, sha1).digest() + k2 = hmac.new(chp, tk + entry_salt, sha1).digest() + k = k1 + k2 + iv = k[-8:] + key = k[:24] + return key, iv + + +def asn1_wrap_3des(entry_salt, ciphertext): + """ + Wraps the salt and ciphertext in the ASN.1 structure expected by Firefox. + Structure: Sequence[ Sequence[ OID, Sequence[Salt, Iters] ], Ciphertext ] + """ + # 1. Algorithm Identifier + params = Sequence() + params[0] = OctetString(entry_salt) + params[1] = Integer(1) # Iterations + + algo_id = Sequence() + algo_id[0] = ObjectIdentifier(OID_PKCS12_3DES) + algo_id[1] = params + + # 2. Outer Sequence + outer = Sequence() + outer[0] = algo_id + outer[1] = OctetString(ciphertext) + + return der_encode(outer) + + +def encrypt_pbe(data, global_salt, master_password): + """ + Encrypts data (e.g. password-check or master key) using 3DES PBE. + Returns the DER-encoded ASN.1 blob. + """ + entry_salt = secrets.token_bytes(20) + key, iv = derive_3des_key(global_salt, master_password, entry_salt) + + cipher = DES3.new(key, DES3.MODE_CBC, iv) + padded_data = PKCS7pad(data) + ciphertext = cipher.encrypt(padded_data) + + return asn1_wrap_3des(entry_salt, ciphertext) + + +def encode_login_data(key, data): + """ + Encrypts a username or password using the Master Key (AES-256 logic). + Matches encodeLoginData in ffpass/__init__.py + """ + # Use AES-256 if key is 32 bytes, else 3DES. Our REAL_MASTER_KEY is 24 bytes (3DES). + # To match modern Firefox better, let's pretend we use 3DES for the DB entry + # but the logic handles whatever key we give it. + # Let's stick to the AES path here if we want; but wait, REAL_MASTER_KEY is 24 bytes. + # We must use 3DES logic for the login entry if key is 24 bytes. + + asn1data = Sequence() + asn1data[0] = OctetString(MAGIC1) + asn1data[1] = Sequence() + + if len(key) == 32: + # AES Logic + iv = secrets.token_bytes(16) + cipher = AES.new(key, AES.MODE_CBC, iv) + ciphertext = cipher.encrypt(PKCS7pad(data.encode(), block_size=16)) + asn1data[1][0] = ObjectIdentifier(MAGIC_AES) + asn1data[1][1] = OctetString(iv) + asn1data[2] = OctetString(ciphertext) + else: + # 3DES Logic (matches our 24-byte master key) + # OID: 1.2.840.113549.3.7 (des-ede3-cbc) + OID_3DES_CBC = (1, 2, 840, 113_549, 3, 7) + iv = secrets.token_bytes(8) + cipher = DES3.new(key, DES3.MODE_CBC, iv) + ciphertext = cipher.encrypt(PKCS7pad(data.encode(), block_size=8)) + asn1data[1][0] = ObjectIdentifier(OID_3DES_CBC) + asn1data[1][1] = OctetString(iv) + asn1data[2] = OctetString(ciphertext) + + from base64 import b64encode + + return b64encode(der_encode(asn1data)).decode() + + +def create_mp_profile(): + base_dir = Path("tests/firefox-mp-test") + if base_dir.exists(): + import shutil + + shutil.rmtree(base_dir) + base_dir.mkdir(parents=True) + + print(f"Generating Real Encrypted MP profile in {base_dir}...") + + # 1. Create key4.db + conn = sqlite3.connect(base_dir / "key4.db") + c = conn.cursor() + c.execute("CREATE TABLE metadata (id TEXT PRIMARY KEY, item1, item2)") + c.execute("CREATE TABLE nssPrivate (a11, a102)") + + # A. Metadata: Password Check + # The tool verifies password by decrypting this and checking for "password-check\x02\x02" + # The encrypt_pbe function handles padding. + password_check_blob = encrypt_pbe(b"password-check", GLOBAL_SALT, MASTER_PASSWORD) + c.execute( + "INSERT INTO metadata VALUES ('password', ?, ?)", + (GLOBAL_SALT, password_check_blob), + ) + + # B. nssPrivate: Encrypted Master Key + # The tool decrypts this to get the key used for logins.json + master_key_blob = encrypt_pbe(REAL_MASTER_KEY, GLOBAL_SALT, MASTER_PASSWORD) + c.execute("INSERT INTO nssPrivate VALUES (?, ?)", (master_key_blob, MAGIC1)) + + conn.commit() + conn.close() + + # 2. Create logins.json + # These strings are actually encrypted with REAL_MASTER_KEY now + logins_data = { + "nextId": 2, + "logins": [ + { + "id": 1, + "hostname": "https://locked.com", + "encryptedUsername": encode_login_data(REAL_MASTER_KEY, "secret_user"), + "encryptedPassword": encode_login_data(REAL_MASTER_KEY, "secret_pass"), + "deleted": False, + } + ], + } + + with open(base_dir / "logins.json", "w") as f: + json.dump(logins_data, f) + + print("Done.") + + +if __name__ == "__main__": + create_mp_profile() diff --git a/setup.py b/setup.py index cb58157..57fb9a8 100755 --- a/setup.py +++ b/setup.py @@ -5,12 +5,12 @@ 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( name="ffpass", - version="0.5.0", + version="0.6.0", author="Louis Abraham", license="MIT", author_email="louis.abraham@yahoo.fr", diff --git a/tests/firefox-146-aes/key4.db b/tests/firefox-146-aes/key4.db new file mode 100644 index 0000000..935e417 Binary files /dev/null and b/tests/firefox-146-aes/key4.db differ 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-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/firefox-mp-test/key4.db b/tests/firefox-mp-test/key4.db new file mode 100644 index 0000000..c8ed7d9 Binary files /dev/null and b/tests/firefox-mp-test/key4.db differ 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_key.py b/tests/test_key.py index db102e3..edffedb 100644 --- a/tests/test_key.py +++ b/tests/test_key.py @@ -3,36 +3,113 @@ import ffpass from pathlib import Path import pytest +import sqlite3 +import shutil +# 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..e77ed02 --- /dev/null +++ b/tests/test_mixed_keys_run.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Created on Fri Dec 25 19:30:48 2025 + +@author: shane +""" + +import os +import shutil +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +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): + 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. Run generate_mixed_profile.py first." + ) + shutil.copytree(src, dst) + return dst + + return _setup + + +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("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_run.py b/tests/test_run.py index a244a83..b6aea28 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -1,49 +1,88 @@ #!/usr/bin/env python3 +import os import subprocess +import shutil +import pytest +from pathlib import Path + +OS_NEWLINE = os.linesep MASTER_PASSWORD = 'test' -HEADER = 'url,username,password\n' -IMPORT_CREDENTIAL = 'http://www.example.com,foo,bar\n' -EXPECTED_EXPORT_OUTPUT = f'{HEADER}http://www.stealmylogin.com,test,test\n' -EXPECTED_IMPORT_OUTPUT = EXPECTED_EXPORT_OUTPUT + IMPORT_CREDENTIAL +HEADER = 'url,username,password' +IMPORT_CREDENTIAL = 'http://www.example.com,foo,bar' +EXPECTED_EXPORT_OUTPUT = [HEADER, 'http://www.stealmylogin.com,test,test'] +EXPECTED_IMPORT_OUTPUT = EXPECTED_EXPORT_OUTPUT + [IMPORT_CREDENTIAL] + + +@pytest.fixture +def clean_profile(tmp_path): + """ + Copies the requested profile to a temporary directory and returns + the path to the new copy. + """ + def _setup(profile_name): + src = Path('tests') / profile_name + dst = tmp_path / profile_name + shutil.copytree(src, dst) + return dst + return _setup def run_ffpass(mode, path): - command = ["ffpass", mode, "-d", path] + command = ["python", "./ffpass/__init__.py", mode, "-d", str(path)] + if mode == 'import': - ffpass_input = HEADER + IMPORT_CREDENTIAL + ffpass_input = OS_NEWLINE.join([HEADER, IMPORT_CREDENTIAL]) else: ffpass_input = None return subprocess.run(command, stdout=subprocess.PIPE, input=ffpass_input, encoding='utf-8') -def test_legacy_firefox_export(): - r = run_ffpass('export', 'tests/firefox-70') +def stdout_splitter(input_text): + return [x for x in input_text.splitlines()] + + +def test_legacy_firefox_export(clean_profile): + r = run_ffpass('export', clean_profile('firefox-70')) r.check_returncode() - assert r.stdout == EXPECTED_EXPORT_OUTPUT + actual_export_output = stdout_splitter(r.stdout) + assert actual_export_output == EXPECTED_EXPORT_OUTPUT -def test_firefox_export(): - r = run_ffpass('export', 'tests/firefox-84') +def test_firefox_export(clean_profile): + r = run_ffpass('export', clean_profile('firefox-84')) r.check_returncode() - assert r.stdout == EXPECTED_EXPORT_OUTPUT + assert stdout_splitter(r.stdout) == EXPECTED_EXPORT_OUTPUT -def test_legacy_firefox(): - r = run_ffpass('import', 'tests/firefox-70') +def test_firefox_aes_export(clean_profile): + # This uses your new AES-encrypted profile + profile_path = clean_profile('firefox-146-aes') + r = run_ffpass('export', profile_path) r.check_returncode() + assert stdout_splitter(r.stdout) == EXPECTED_EXPORT_OUTPUT - r = run_ffpass('export', 'tests/firefox-70') + +def test_legacy_firefox(clean_profile): + profile_path = clean_profile('firefox-70') + + # modifies the temp file, not the original + r = run_ffpass('import', profile_path) r.check_returncode() - assert r.stdout == EXPECTED_IMPORT_OUTPUT + r = run_ffpass('export', profile_path) + r.check_returncode() + assert stdout_splitter(r.stdout) == EXPECTED_IMPORT_OUTPUT + + +def test_firefox(clean_profile): + profile_path = clean_profile('firefox-84') -def test_firefox(): - r = run_ffpass('import', 'tests/firefox-84') + r = run_ffpass('import', profile_path) r.check_returncode() - r = run_ffpass('export', 'tests/firefox-84') + r = run_ffpass('export', profile_path) r.check_returncode() - assert r.stdout == EXPECTED_IMPORT_OUTPUT + assert stdout_splitter(r.stdout) == EXPECTED_IMPORT_OUTPUT