]> Nutra Git (v1) - gamesguru/ffpass.git/commitdiff
update tests
authorShane Jaroch <chown_tee@proton.me>
Fri, 26 Dec 2025 01:53:33 +0000 (20:53 -0500)
committerShane Jaroch <chown_tee@proton.me>
Fri, 26 Dec 2025 01:58:48 +0000 (20:58 -0500)
ffpass/__init__.py
tests/test_key.py
tests/test_legacy-key-mixed.py [deleted file]
tests/test_mixed_keys_run.py

index 537fb8260843578c6fea29065322ecaaac6cce69..b91b75b97722ea12e5ff782157ccad6275f117fc 100644 (file)
@@ -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:
index db102e3f32bb9200f65e683aab5a90ea7ff32934..edffedb261f533f5e183d4c213b1d35f3a143410 100644 (file)
 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 (file)
index ea13d52..0000000
+++ /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
index b8f8b332356df801fdfc96a2cf368e0f31585bd0..da7eb8cfc511d69835a2a80e98d5eed94470fa4a 100644 (file)
 
 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