From: Shane Jaroch Date: Fri, 26 Dec 2025 01:53:33 +0000 (-0500) Subject: update tests X-Git-Url: https://git.nutra.tk/v2?a=commitdiff_plain;h=1ce479c1969406c70b64d5ecb2ef970cadfd6eb7;p=gamesguru%2Fffpass.git update tests --- diff --git a/ffpass/__init__.py b/ffpass/__init__.py index 537fb82..b91b75b 100644 --- a/ffpass/__init__.py +++ b/ffpass/__init__.py @@ -165,7 +165,7 @@ def get_all_keys(directory, pwd=""): conn = sqlite3.connect(str(db)) c = conn.cursor() - # 1. Get Global Salt + # 1. Get Global Salt & Password Check Entry c.execute("SELECT item1, item2 FROM metadata WHERE id = 'password'") try: global_salt, item2 = next(c) @@ -173,7 +173,46 @@ def get_all_keys(directory, pwd=""): logging.info(f"[*] Global Salt: {censor(global_salt)}") - # 2. Check Password (simplified via key decryption attempt) + # 2. VERIFY PASSWORD EXPLICITLY + # This ensures we throw WrongPassword BEFORE trying to decrypt keys + # and fail with a different error if keys are missing but password is correct. + 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: + # Re-implement AES decrypt inline for password check + 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: + # If any crypto fails during verification, it's a wrong password + # (or corrupted salt/metadata, but we assume password first) + logging.debug(f"Password check failed: {e}") + raise WrongPassword() + + logging.info("[*] Password Verified Correctly") + # 3. Find ALL Keys c.execute("SELECT a11, a102 FROM nssPrivate") rows = c.fetchall() @@ -189,11 +228,12 @@ def get_all_keys(directory, pwd=""): 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)") + logging.debug(f"[*] Key #{idx}: Failed to decrypt (Corrupt?)") if not found_keys: - # If no keys decrypted, the password is definitely wrong - raise WrongPassword() + # We verified the password is correct above, but still found no keys. + # This is a database corruption issue, NOT a wrong password. + raise Exception("Database corrupted: Password verified, but no valid master keys found.") return found_keys, global_salt @@ -381,7 +421,6 @@ def askpass(directory): password = "" while True: try: - # 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]) @@ -473,10 +512,10 @@ def makeParser(): def main(): - # Default level ERROR (Silent), INFO for verbose, DEBUG for debug parser = makeParser() args = parser.parse_args() + # Default level ERROR (Silent), INFO for verbose, DEBUG for debug log_level = logging.ERROR if args.verbose: log_level = logging.INFO @@ -499,7 +538,6 @@ def main(): 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: 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_legacy-key-mixed.py b/tests/test_legacy-key-mixed.py deleted file mode 100644 index ea13d52..0000000 --- a/tests/test_legacy-key-mixed.py +++ /dev/null @@ -1,126 +0,0 @@ -#!/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 index b8f8b33..da7eb8c 100644 --- a/tests/test_mixed_keys_run.py +++ b/tests/test_mixed_keys_run.py @@ -2,112 +2,85 @@ import os import shutil +import pytest import sys -from pathlib import Path from unittest.mock import patch +from pathlib import Path -import pytest - -# Add project root to path so we can import ffpass internals for mocking +# Add project root to path 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"] +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 + 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?" - ) + 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): - """ - 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. + # 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 a valid string so the check passes - return "valid_utf8_string", "AES-Standard" + 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 (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"]) + # 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: + 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 + except StopIteration: + return "extra" + raise ValueError("Wrong Key") - # Capture stdout - from io import StringIO + mock_decode.side_effect = decode_side_effect - captured_output = StringIO() - sys.stdout = captured_output + # Capture stdout + from io import StringIO + captured_output = StringIO() + sys.stdout = captured_output - try: - main() - except SystemExit: - pass - finally: - sys.stdout = sys.__stdout__ + try: + main() + except SystemExit: + pass + finally: + sys.stdout = sys.__stdout__ - return captured_output.getvalue() + return captured_output.getvalue() def stdout_splitter(input_text): @@ -115,17 +88,7 @@ def stdout_splitter(input_text): 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 + profile_path = clean_profile('firefox-mixed-keys') + output = run_ffpass_internal('export', profile_path) actual = stdout_splitter(output) - assert actual == EXPECTED_MIXED_OUTPUT