master password v146 non-mixed profiles. inspect. ruff. working
authorShane Jaroch <chown_tee@proton.me>
Fri, 9 Jan 2026 10:39:01 +0000 (05:39 -0500)
committerShane Jaroch <chown_tee@proton.me>
Sat, 10 Jan 2026 10:29:40 +0000 (05:29 -0500)
use --reveal-keys flag
use git-sqlite-filter for *.db files

23 files changed:
.gitattributes [new file with mode: 0644]
Makefile
ffpass/__init__.py
ffpass/nss.py [new file with mode: 0644]
requirements-dev.txt
tests/conftest.py [new file with mode: 0644]
tests/firefox-146-aes/key4.db
tests/firefox-14iv/cert9.db [new file with mode: 0644]
tests/firefox-14iv/key4.db [new file with mode: 0644]
tests/firefox-14iv/logins.json [new file with mode: 0644]
tests/firefox-14iv/pkcs11.txt [new file with mode: 0644]
tests/firefox-70/key4.db
tests/firefox-84/key4.db
tests/firefox-mixed-keys/key4.db
tests/firefox-mp-70/key4.db
tests/firefox-mp-84/key4.db
tests/firefox-mp-test/key4.db
tests/test_inspect.py [new file with mode: 0644]
tests/test_key.py
tests/test_mixed_keys_run.py
tests/test_native_verify.py [new file with mode: 0644]
tests/test_run.py
tests/test_sha256_mock.py [new file with mode: 0644]

diff --git a/.gitattributes b/.gitattributes
new file mode 100644 (file)
index 0000000..f42f38d
--- /dev/null
@@ -0,0 +1,7 @@
+*.db filter=sqlite diff=sqlite
+
+# NOTE: you can also configure the git filter/smudge for this repo:
+# git config set --local filter.sqlite.clean=git-sqlite-clean %f
+# git config set --local filter.sqlite.smudge=git-sqlite-smudge %f
+# git config set --local filter.sqlite.required=true
+
index 9e052482f3c020cd7f8ea16ec8b2b368691bf100..d9951e31a5f41cf2327205d5de61bb09e6af4bdb 100644 (file)
--- a/Makefile
+++ b/Makefile
@@ -22,7 +22,7 @@ release: build        ## Upload release to PyPI (via Twine)
 
 
 
-LINT_LOCS_PY ?= ffpass/ scripts tests/
+LINT_LOCS_PY ?= ffpass/ tests/
 
 .PHONY: format
 format:        ## Not phased in yet, no-op
@@ -33,6 +33,7 @@ format:       ## Not phased in yet, no-op
 .PHONY: lint
 lint:  ## Lint the code
        flake8 --count --show-source --statistics
+       ruff check ${LINT_LOCS_PY}
 
 
 .PHONY: test
@@ -54,5 +55,8 @@ clean:        ## Clean up build files/cache
        find . \
                  -name .venv -prune \
                  -o -name __pycache__ -print \
+                 -o -name *.egg -print \
                  -o -name .pytest_cache -print \
+                 -o -name .coverage -print \
+                 -o -name .mypy_cache -print \
                | xargs -r rm -rf
index 31849340db137990f9014ff96732be87891ba906..a134e2d1a6e6b08fe19db9fff9ac45fe35c832d3 100755 (executable)
@@ -9,11 +9,11 @@ The MIT License (MIT)
 Copyright (c) 2018 Louis Abraham <louis.abraham@yahoo.fr>
 Laurent Clevy (@lorenzo2472)
 # from https://github.com/lclevy/firepwd/blob/master/firepwd.py
-\x1B[34m\033[F\033[F
+\x1b[34m\033[F\033[F
 
 ffpass can import and export passwords from Firefox Quantum.
 
-\x1B[0m\033[1m\033[F\033[F
+\x1b[0m\033[1m\033[F\033[F
 
 example of usage:
     ffpass export --file passwords.csv
@@ -28,30 +28,28 @@ If you found this code useful, add a star on <https://github.com/louisabraham/ff
 """
 
 
-import sys
-from base64 import b64decode, b64encode
-from hashlib import sha1, pbkdf2_hmac
 import argparse
-import json
-from pathlib import Path
+import binascii
 import csv
+import json
+import logging
+import os.path
 import secrets
-from getpass import getpass
-from uuid import uuid4
-from datetime import datetime
-from urllib.parse import urlparse
 import sqlite3
-import os.path
-import logging
 import string
+import sys
+from base64 import b64decode, b64encode
+from datetime import datetime
+from getpass import getpass
+from hashlib import pbkdf2_hmac, sha1, sha256, sha512
+from pathlib import Path
+from urllib.parse import urlparse
+from uuid import uuid4
 
+from Crypto.Cipher import AES, DES3
 from pyasn1.codec.der.decoder import decode as der_decode
 from pyasn1.codec.der.encoder import encode as der_encode
-from pyasn1.type.univ import Sequence, OctetString, ObjectIdentifier
-from Crypto.Cipher import AES, DES3
-
-# noqa: W503
-# line break before binary operator
+from pyasn1.type.univ import ObjectIdentifier, OctetString, Sequence
 
 MAGIC1 = b"\xf8\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01"
 
@@ -76,6 +74,12 @@ class WrongPassword(Exception):
     pass
 
 
+class IncompatibleCryptoError(WrongPassword):
+    """Raised when encryption parameters are detected that ffpass cannot handle (e.g. 14-byte IV), suggesting the password might be correct but verification failed."""
+
+    pass
+
+
 class NoProfile(Exception):
     pass
 
@@ -94,13 +98,13 @@ def censor(data):
 
     third = length // 3
     two_thirds = (2 * length) // 3
-    return f"{s[:third]}.....{s[two_thirds:]}"
+    return f"{s[:third]}...{s[two_thirds:]}"
 
 
 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 b"\x04\x0e" + iv_bytes
+    elif len(iv_bytes) == 18 and iv_bytes.startswith(b"\x04\x10"):
         return iv_bytes[2:]
     return iv_bytes
 
@@ -118,6 +122,7 @@ def PKCS7unpad(b):
 
 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()
@@ -131,7 +136,30 @@ def decrypt3DES(globalSalt, masterPassword, entrySalt, encryptedData):
     return DES3.new(key, DES3.MODE_CBC, iv).decrypt(encryptedData)
 
 
-def decrypt_key_entry(a11, global_salt, master_password):
+def hash_password(global_salt, pwd, method):
+    if method == "sha1":
+        return sha1(global_salt + pwd.encode("utf-8")).digest()
+    elif method == "sha256":
+        return sha256(global_salt + pwd.encode("utf-8")).digest()
+    elif method == "plaintext":
+        return pwd.encode("utf-8")
+    elif method == "sha256_no_salt":
+        return sha256(pwd.encode("utf-8")).digest()
+    elif method == "sha1_no_salt":
+        return sha1(pwd.encode("utf-8")).digest()
+    elif method == "concat":
+        return global_salt + pwd.encode("utf-8")
+
+    # SHA512 Variants
+    elif method == "sha512":
+        return sha512(global_salt + pwd.encode("utf-8")).digest()
+    elif method == "sha512_no_salt":
+        return sha512(pwd.encode("utf-8")).digest()
+
+    raise ValueError(f"Unknown hashing method: {method}")
+
+
+def decrypt_key_entry(a11, global_salt, master_password, hash_method="sha1"):
     try:
         decoded, _ = der_decode(a11)
         key_oid = decoded[0][0].asTuple()
@@ -145,10 +173,12 @@ def decrypt_key_entry(a11, global_salt, master_password):
             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)")
+            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)
+            enc_pwd = hash_password(global_salt, master_password, hash_method)
+            k = pbkdf2_hmac("sha256", enc_pwd, entry_salt, iters, dklen=key_len)
 
             iv = clean_iv(decoded[0][1][1][1].asOctets())
             logging.debug(f"  > Cipher: AES-256-CBC | IV: {censor(iv)}")
@@ -162,19 +192,23 @@ def decrypt_key_entry(a11, global_salt, master_password):
             ciphertext = decoded[1].asOctets()
 
             logging.debug("  > Method: PKCS12-3DES-Derivation")
-            logging.debug(f"  > Salt: {censor(entry_salt)} (Local) + {censor(global_salt)} (Global)")
+            logging.debug(
+                f"  > Salt: {censor(entry_salt)} (Local) + {censor(global_salt)} (Global)"
+            )
 
-            return PKCS7unpad(decrypt3DES(global_salt, master_password, entry_salt, ciphertext))
+            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):
+def verify_password(global_salt, item2, pwd):  # noqa: C901
     """
-    Verifies the master password against the metadata entry (item2).
     Raises WrongPassword on failure.
+    Returns the successful hashing method ('sha1' or 'sha256').
     """
     try:
         decodedItem2, _ = der_decode(item2)
@@ -183,27 +217,98 @@ def verify_password(global_salt, item2, pwd):
         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}")
+        # Try various hashing combinations to guess the correct pre-hashing used by Firefox
+        methods_to_try = [
+            "sha1",
+            "sha256",
+            "plaintext",
+            "sha512",
+            "sha256_no_salt",
+            "sha1_no_salt",
+            "sha512_no_salt",
+        ]
+        seen_14_byte_iv = False
+        for method in methods_to_try:
+            try:
+                logging.debug(
+                    f"Attempting verification with method: {method}. Input Pwd Len: {len(pwd)}"
+                )
+                if algorithm_oid == OID_PKCS12_3DES:
+                    entrySalt = decodedItem2[0][1][0].asOctets()
+                    cipherT = decodedItem2[1].asOctets()
+                    clearText = decrypt3DES(global_salt, pwd, entrySalt, cipherT)
+                    if clearText == b"password-check\x02\x02":
+                        logging.info(f"Verified: Method={method} (3DES)")
+                        return method
+
+                elif algorithm_oid == OID_PBES2:
+                    algo = decodedItem2[0][1][0]
+                    pbkdf2_params = algo[1]
+                    entry_salt = pbkdf2_params[0].asOctets()
+                    iters = int(pbkdf2_params[1])
+                    key_len = int(pbkdf2_params[2])
+
+                    hmac_algo = "sha256"
+                    if len(pbkdf2_params) > 3:
+                        prf_oid = pbkdf2_params[3][0].asTuple()
+                        if prf_oid == (1, 2, 840, 113549, 2, 7):
+                            hmac_algo = "sha1"
+                        elif prf_oid == (1, 2, 840, 113549, 2, 9):
+                            hmac_algo = "sha256"
+                        elif prf_oid == (1, 2, 840, 113549, 2, 11):
+                            hmac_algo = "sha512"
+
+                    cipher_params = decodedItem2[0][1][1]
+                    raw_iv = cipher_params[1].asOctets()
+
+                    iv_candidates = [("standard", raw_iv)]
+                    if len(raw_iv) == 14:
+                        seen_14_byte_iv = True
+                        iv_candidates = [
+                            ("clean_iv", b"\x04\x0e" + raw_iv),
+                            ("pad_start_null", b"\x00\x00" + raw_iv),
+                            ("pad_end_null", raw_iv + b"\x00\x00"),
+                        ]
+
+                    cipherT = decodedItem2[1].asOctets()
+                    for iv_name, iv in iv_candidates:
+
+                        try:
+                            enc_pwd = hash_password(global_salt, pwd, method)
+                            k = pbkdf2_hmac(
+                                hmac_algo, enc_pwd, entry_salt, iters, dklen=key_len
+                            )
+                            cipher = AES.new(k, AES.MODE_CBC, iv)
+                            clearText = cipher.decrypt(cipherT)
+
+                            if clearText == b"password-check\x02\x02":
+                                logging.info(f"Verified: Method={method}, IV={iv_name}")
+                                if iv_name == "pad_start_null":
+                                    logging.warning(
+                                        "NOTICE: Profile uses NULL-PREFIXED IVs."
+                                    )
+                                return method
+                            else:
+                                logging.debug(
+                                    f"    Mismatch: {method} / {iv_name} -> {clearText.hex()}"
+                                )
+
+                        except Exception:
+                            continue
+                else:
+                    raise ValueError(f"Unknown OID: {algorithm_oid}")
+
+            except Exception as outer_e:
+                logging.debug(f"Method {method} skipped: {outer_e}")
+                continue
 
-        if clearText != b"password-check\x02\x02":
-            raise WrongPassword()
+        if seen_14_byte_iv:
+            raise IncompatibleCryptoError()
 
+        raise WrongPassword()
+
+    except WrongPassword:
+        raise
     except Exception as e:
         logging.debug(f"Password check failed: {e}")
         raise WrongPassword()
@@ -227,7 +332,7 @@ def get_all_keys(directory, pwd=""):
     logging.info(f"[*] Global Salt: {censor(global_salt)}")
 
     # 2. VERIFY PASSWORD EXPLICITLY
-    verify_password(global_salt, item2, pwd)
+    hash_method = verify_password(global_salt, item2, pwd)
     logging.info("[*] Password Verified Correctly")
 
     # 3. Find ALL Keys
@@ -244,10 +349,12 @@ def get_all_keys(directory, pwd=""):
     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)
+        key = decrypt_key_entry(a11, global_salt, pwd, hash_method)
 
         if key:
-            logging.info(f"[*] Decrypted Key #{idx}: {len(key)} bytes | ID: {a102.hex()}")
+            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?)")
@@ -255,7 +362,9 @@ def get_all_keys(directory, pwd=""):
     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.")
+        raise Exception(
+            "Database corrupted: Password verified, but no valid master keys could be decrypted."
+        )
 
     return found_keys, global_salt
 
@@ -267,7 +376,7 @@ def try_decrypt_login(key, ciphertext, iv):
             cipher = AES.new(key, AES.MODE_CBC, iv)
             pt = cipher.decrypt(ciphertext)
             res = PKCS7unpad(pt)
-            text = res.decode('utf-8')
+            text = res.decode("utf-8")
             if is_valid_text(text):
                 return text, "AES-Standard"
         except Exception:
@@ -279,7 +388,7 @@ def try_decrypt_login(key, ciphertext, iv):
             cipher = DES3.new(key, DES3.MODE_CBC, iv[:8])
             pt = cipher.decrypt(ciphertext)
             res = PKCS7unpad(pt)
-            text = res.decode('utf-8')
+            text = res.decode("utf-8")
             if is_valid_text(text):
                 return text, "3DES-Standard"
         except Exception:
@@ -391,14 +500,20 @@ def readCSV(csv_file):
                 logging.debug(f"Breaking loop since we got an empty row at index={i}.")
                 break
             # Heuristic: if it lacks a URL (index=1) and has user,pass (index=2,3), assume it's a header and continue
-            if (
-                first_row[0].lower() in {"url", "hostname", "website", "site", "address", "link"}
-                or (
-                    first_row[1].lower() in {"username", "uname", "user", "u"}
-                    and first_row[2].lower() in {"password", "passwd", "pass", "p"}
-                )
+            if first_row[0].lower() in {
+                "url",
+                "hostname",
+                "website",
+                "site",
+                "address",
+                "link",
+            } or (
+                first_row[1].lower() in {"username", "uname", "user", "u"}
+                and first_row[2].lower() in {"password", "passwd", "pass", "p"}
             ):
-                logging.debug(f"Continuing (skipping) over first row: index=0, [is_header={True}].")
+                logging.debug(
+                    f"Continuing (skipping) over first row: index=0, [is_header={True}]."
+                )
                 continue
 
             # ~~~ END peek at first row ~~~~~~~~~~
@@ -431,9 +546,9 @@ def rawURL(url):
 def addNewLogins(key, jsonLogins, logins):
     nextId = jsonLogins["nextId"]
     timestamp = int(datetime.now().timestamp() * 1000)
-    logging.warning(f'adding {len(logins)} logins')
+    logging.warning(f"adding {len(logins)} logins")
     for i, (url, username, password) in enumerate(logins, nextId):
-        logging.info(f'adding {url} {username}')
+        logging.info(f"adding {url} {username}")
         entry = {
             "id": i,
             "hostname": url,
@@ -471,9 +586,37 @@ def getProfiles() -> list[Path]:
     return profiles
 
 
+def get_native_logins(directory, password):
+    """
+    Attempts to decrypt logins using safe, native NSS interactions via ctypes.
+    Returns list of logins or None/Empty list on failure.
+    """
+    try:
+        # Import explicitly to handle potential path issues
+        try:
+            from ffpass.nss import decrypt_logins_native
+        except ImportError:
+            try:
+                from nss import decrypt_logins_native
+            except ImportError:
+                import sys
+
+                root_dir = str(Path(__file__).resolve().parent.parent)
+                if root_dir not in sys.path:
+                    sys.path.append(root_dir)
+                from ffpass.nss import decrypt_logins_native
+
+        return decrypt_logins_native(directory, password)
+    except Exception as e:
+        logging.debug(f"Native decryption attempt failed: {e}")
+        return None
+
+
 def guessDir() -> Path:
     if sys.platform not in PROFILE_GUESS_DIRS:
-        logging.error(f"Automatic profile selection is not supported for {sys.platform}")
+        logging.error(
+            f"Automatic profile selection is not supported for {sys.platform}"
+        )
         logging.error("Please specify a profile to parse (-d path/to/profile)")
         raise NoProfile
 
@@ -484,8 +627,10 @@ def guessDir() -> Path:
         raise NoProfile
 
     if len(profiles) > 1:
-        logging.error("More than one profile detected. Please specify a profile to parse (-d path/to/profile)")
-        logging.error("valid profiles:\n\t\t" + '\n\t\t'.join(map(str, profiles)))
+        logging.error(
+            "More than one profile detected. Please specify a profile to parse (-d path/to/profile)"
+        )
+        logging.error("valid profiles:\n\t\t" + "\n\t\t".join(map(str, profiles)))
         raise NoProfile
 
     profile_path = profiles[0]
@@ -494,40 +639,189 @@ def guessDir() -> Path:
     return profile_path
 
 
-def askpass(directory):
-    password = ""
+def askpass(directory, allow_native=False):
+    # Determine initial password source
+    password = os.environ.get("FFPASS_SECRET", "")
+    is_interactive = sys.stdin.isatty() and not password
+
+    # If interactive and no env secret, prompt immediately for the first attempt
+    if is_interactive:
+        password = getpass("Master Password: ")
+
     n = 0
-    # 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
+    # Max attempts: If env var set, try it once. If fails and non-interactive, stop.
+    # If interactive, allow retry.
+    # User requested trying "twice".
+    n_max = 2
+
     while True:
+        # 1. Try Python Protocol
         try:
             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:
-            n += 1
-            if n >= n_max:
-                break
-            password = getpass("Master Password: ")
+            logging.info(
+                f"Selected Master Key: {len(best_key)} bytes (from {len(keys)} candidates)"
+            )
+            return best_key, None
+        except (WrongPassword, IncompatibleCryptoError):
+            # Python protocol failed.
+            pass  # Fall through to native check
+
+        # 2. Try Native Protocol (Fallback)
+        if allow_native:
+            # We treat native failure as silent (wrong password) unless it succeeds.
+            # But we might want to log if it was attempted.
+            native_res = get_native_logins(directory, password)
+            if native_res:
+                logging.info("Successfully authenticated via Native NSS backend.")
+                return None, native_res
+
+        # 3. Handle Failure / Retry Loop
+        n += 1
+
+        # If we are non-interactive/env-var based, do NOT loop unless we are falling back to interactive?
+        # If FFPASS_SECRET was used (n=1 checked), and verification failed...
+        if os.environ.get("FFPASS_SECRET"):
+            logging.error("FFPASS_SECRET verification failed (both Python and Native).")
+            break
+
+        if n >= n_max:
+            logging.error(f"Wrong master password after {n} prompts.")
+            break
+
+        print("Wrong password. Please try again.")
+        password = getpass("Master Password: ")
+
+    return None, None
+
+
+def main_inspect(args):  # noqa: C901
+    db_path = args.directory / "key4.db"
+    if not db_path.exists():
+        logging.error(f"Error: {db_path} not found.")
+        return
+
+    print(f"Inspecting Profile: {args.directory}")
+    print(f"Database: {db_path}")
+
+    conn = sqlite3.connect(str(db_path))
+    c = conn.cursor()
+
+    print("\n[METADATA]")
+    c.execute("SELECT id, item1, item2 FROM metadata WHERE id = 'password'")
+    row = c.fetchone()
+    if row:
+        print(f"  ID: {row[0]}")
+        if args.reveal_keys:
+            print(f"  Global Salt: {binascii.hexlify(row[1]).decode()}")
+        else:
+            gs_hex = binascii.hexlify(row[1]).decode()
+            print(f"  Global Salt: {censor(gs_hex)} (Use --reveal-keys to show full)")
+        print(f"  Password Check Data ({len(row[2])} bytes)")
+
+        try:
+            decoded, _ = der_decode(row[2])
+            # Structure is typically:
+            # Sequence:
+            #   Sequence (AlgorithmIdentifier for KDF): e.g. pkcs5PBES2
+            #     OID
+            #     Sequence (PBES2-params):
+            #       Sequence (KeyDerivationFunc):
+            #         OID (pkcs5PBKDF2)
+            #         Sequence (PBKDF2-params): [salt, iters, keylen, prf]
+            #       Sequence (EncryptionScheme):
+            #         OID (aes256-cbc)
+            #         OctetString (IV)
+            #   OctetString (Ciphertext)
+
+            pbes2_params = decoded[0][1]
+            kdf_seq = pbes2_params[0]
+            kdf_oid = kdf_seq[0]
+            pbkdf2_params = kdf_seq[1]
+
+            print(f"  KDF OID: {kdf_oid} (Expected: {OID_PBES2} for PBES2)")
+
+            salt = pbkdf2_params[0]
+            iters = pbkdf2_params[1]
+            key_length = pbkdf2_params[2]
+
+            if args.reveal_keys:
+                print(f"    Salt: {binascii.hexlify(salt.asOctets()).decode()}")
+            else:
+                s_hex = binascii.hexlify(salt.asOctets()).decode()
+                print(f"    Salt: {censor(s_hex)} (Use --reveal-keys to show full)")
+            print(f"    Iterations: {iters}")
+            print(f"    Key Length: {key_length}")
+
+            hmac_algo = "Unknown (Default/SHA1)"
+            if len(pbkdf2_params) > 3:
+                prf_oid = pbkdf2_params[3][0].asTuple()
+                if prf_oid == (1, 2, 840, 113549, 2, 7):
+                    hmac_algo = "HMAC-SHA1"
+                elif prf_oid == (1, 2, 840, 113549, 2, 9):
+                    hmac_algo = "HMAC-SHA256"
+                elif prf_oid == (1, 2, 840, 113549, 2, 11):
+                    hmac_algo = "HMAC-SHA512"
+                else:
+                    hmac_algo = f"OID {prf_oid}"
+            print(f"    PRF: {hmac_algo}")
+
+            # Encryption Scheme
+            enc_scheme = pbes2_params[1]
+            enc_oid = enc_scheme[0]
+            iv = enc_scheme[1].asOctets()
+
+            print("  Encryption Scheme:")
+            print(f"    OID: {enc_oid}")
+            if args.reveal_keys:
+                print(f"    IV: {binascii.hexlify(iv).decode()} (Length: {len(iv)})")
+            else:
+                iv_hex = binascii.hexlify(iv).decode()
+                print(f"    IV: {censor(iv_hex)} (Length: {len(iv)})")
+
+            if len(iv) == 14:
+                print("\n  [!] WARNING: Non-standard 14-byte IV detected.")
+                print("  [!] This profile REQUIRES Native NSS backend for decryption.")
+            elif len(iv) == 16:
+                print("  [OK] Standard 16-byte IV.")
+
+        except Exception as e:
+
+            print(f"  [ERROR] ASN.1 Decode Failed: {e}")
+            logging.debug(e, exc_info=True)
+    else:
+        print("  No 'password' entry found via SELECT.")
+
+    print("\n[KEYS]")
+    c.execute("SELECT count(*) FROM nssPrivate")
+    count = c.fetchone()[0]
+    print(f"  nssPrivate Entries: {count}")
 
-    if n > 0:
-        logging.error(f"wrong master password after {n_max - 1} prompts!")
-    return None
+    conn.close()
 
 
 def main_export(args):
-    # Removed try/except NoDatabase here to let it bubble to main() for proper logging
-    key = askpass(args.directory)
+    # args.directory is passed. allow_native=True for export.
+    key, native_logins = askpass(args.directory, allow_native=True)
 
-    if not key:
+    if native_logins:
+        # Native backend succeeded where python failed (or was chosen)
+        # Convert to list of tuples for CSV writer
+        logins = []
+        for login in native_logins:
+            logins.append((login["hostname"], login["username"], login["password"]))
+
+    elif key:
+        # Python backend succeeded
+        jsonLogins = getJsonLogins(args.directory)
+        logins = exportLogins(key, jsonLogins)
+
+    else:
+        # Both failed or user cancelled
         logging.error("Failed to derive master key.")
         return
 
-    jsonLogins = getJsonLogins(args.directory)
-    logins = exportLogins(key, jsonLogins)
     # 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"])
@@ -536,7 +830,8 @@ def main_export(args):
 
 def main_import(args):
     # askpass handles stdin/tty detection for the password prompt automatically
-    key = askpass(args.directory)
+    # Native backend is read-only (export), so allow_native=False
+    key, _ = askpass(args.directory, allow_native=False)
 
     if not key:
         logging.error("Failed to derive master key.")
@@ -564,12 +859,28 @@ def makeParser():
         "import",
         description="imports a CSV with columns `url,username,password` (order insensitive)",
     )
+    parser_inspect = subparsers.add_parser(
+        "inspect", description="inspect key4.db metadata structures"
+    )
+    parser_inspect.add_argument(
+        "--reveal-keys",
+        action="store_true",
+        help="Show sensitive keys/salts/IVs in output",
+    )
 
     parser_import.add_argument(
-        "-f", "--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():
@@ -590,12 +901,15 @@ def makeParser():
         sub.add_argument("-v", "--verbose", action="store_true")
         sub.add_argument("--debug", action="store_true")
 
+    parser_inspect.set_defaults(func=main_inspect)
+
     parser_import.set_defaults(func=main_import)
     parser_export.set_defaults(func=main_export)
 
     # Try to load argcomplete
     try:
         import argcomplete
+
         argcomplete.autocomplete(parser)
 
     except (ImportError, ModuleNotFoundError):
@@ -637,7 +951,7 @@ def main():
         try:
             args.func(args)
         except BrokenPipeError:
-            sys.stdout = os.fdopen(1, 'w')
+            sys.stdout = os.fdopen(1, "w")
 
     except NoDatabase:
         logging.error("Firefox password database is empty.")
diff --git a/ffpass/nss.py b/ffpass/nss.py
new file mode 100644 (file)
index 0000000..a68ab24
--- /dev/null
@@ -0,0 +1,155 @@
+import base64
+import ctypes
+import json
+import os
+from ctypes import (
+    POINTER,
+    Structure,
+    byref,
+    c_char_p,
+    c_int,
+    c_ubyte,
+    c_uint,
+    c_void_p,
+    cast,
+)
+
+
+# --- NSS Structures ---
+class SECItem(Structure):
+    _fields_ = [
+        ("type", c_uint),
+        ("data", POINTER(c_ubyte)),
+        ("len", c_uint),
+    ]
+
+
+# --- Load Library ---
+def load_nss():
+    paths = [
+        "/usr/lib/x86_64-linux-gnu/libnss3.so",
+        "/usr/lib/libnss3.so",
+        "/usr/lib64/libnss3.so",
+        "libnss3.so",
+    ]
+    lib = None
+    for p in paths:
+        try:
+            lib = ctypes.CDLL(p)
+            break
+        except OSError:
+            pass
+    return lib
+
+
+def decrypt_sdr(lib, b64_data):
+    if not b64_data:
+        return None
+
+    try:
+        raw_data = base64.b64decode(b64_data)
+    except Exception:
+        return None
+
+    # Prepare SECItem input
+    item_in = SECItem()
+    item_in.type = 0  # SECItemTypeBuffer
+    item_in.len = len(raw_data)
+
+    # Create byte buffer matching length
+    buf = (c_ubyte * len(raw_data)).from_buffer_copy(raw_data)
+    item_in.data = cast(buf, POINTER(c_ubyte))
+
+    # Output item
+    item_out = SECItem()
+
+    ret = lib.PK11SDR_Decrypt(byref(item_in), byref(item_out), None)
+
+    if ret == 0:
+        # Success
+        content = ctypes.string_at(item_out.data, item_out.len)
+        return content.decode("utf-8", errors="replace")
+    else:
+        return None
+
+
+def decrypt_logins_native(profile_path, password):  # noqa: C901
+    """
+    Uses libnss3 to authenticate and decrypt logins.json.
+    Returns a list of dicts: {'hostname': ..., 'username': ..., 'password': ...}
+    Raises Exception on failure.
+    """
+    lib = load_nss()
+    if not lib:
+        raise ImportError("Could not load libnss3.so")
+
+    # Function Signatures
+    lib.NSS_Init.argtypes = [c_char_p]
+    lib.NSS_Init.restype = c_int
+
+    lib.PK11_GetInternalKeySlot.restype = c_void_p
+
+    lib.PK11_CheckUserPassword.argtypes = [c_void_p, c_char_p]
+    lib.PK11_CheckUserPassword.restype = c_int
+
+    lib.NSS_Shutdown.restype = c_int
+
+    lib.PK11SDR_Decrypt.argtypes = [POINTER(SECItem), POINTER(SECItem), c_void_p]
+    lib.PK11SDR_Decrypt.restype = c_int
+
+    # Initialize NSS
+    db_dir = os.path.abspath(profile_path)
+    if db_dir.endswith("/key4.db") or db_dir.endswith("/logins.json"):
+        db_dir = os.path.dirname(db_dir)
+
+    config_dir = f"sql:{db_dir}".encode("utf-8")
+
+    # We must try to init. If already inited by process?
+    # Python process usually distinct.
+    ret = lib.NSS_Init(config_dir)
+    if ret != 0:
+        raise Exception(f"NSS_Init failed (Ret: {ret})")
+
+    try:
+        # Authenticate
+        slot = lib.PK11_GetInternalKeySlot()
+        if not slot:
+            raise Exception("Could not get internal key slot")
+
+        res = lib.PK11_CheckUserPassword(slot, password.encode("utf-8"))
+        if res != 0:
+            raise ValueError("Invalid Password (NSS rejected)")
+
+        # Read logins.json
+        logins_path = os.path.join(db_dir, "logins.json")
+        if not os.path.exists(logins_path):
+            return []  # No file, empty list
+
+        with open(logins_path, "r") as f:
+            data = json.load(f)
+
+        if "logins" not in data:
+            return []
+
+        results = []
+        for login in data["logins"]:
+            hostname = login.get("hostname", "")
+            enc_user = login.get("encryptedUsername")
+            enc_pass = login.get("encryptedPassword")
+
+            dec_user = decrypt_sdr(lib, enc_user)
+            dec_pass = decrypt_sdr(lib, enc_pass)
+
+            if dec_user is None:
+                dec_user = "(error)"
+            if dec_pass is None:
+                dec_pass = "(error)"
+
+            results.append(
+                {"hostname": hostname, "username": dec_user, "password": dec_pass}
+            )
+
+        return results
+
+    finally:
+        lib.NSS_Shutdown()
index 64ce1efe455c1f261dfc02c76702be8a4985f8a2..f979348289163c55cccb0f19b4971d54626b63a1 100644 (file)
@@ -1,3 +1,5 @@
 coverage==7.13.0
 flake8==7.3.0
 pytest==9.0.2
+ruff==0.14.11
+
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644 (file)
index 0000000..469b634
--- /dev/null
@@ -0,0 +1,20 @@
+import shutil
+from pathlib import Path
+
+import pytest
+
+
+@pytest.fixture
+def clean_profile(tmp_path):
+    """
+    Copies the requested profile to a temporary directory and returns
+    the path to the new copy.
+    """
+
+    def _setup(profile_name):
+        src = Path("tests") / profile_name
+        dst = tmp_path / profile_name
+        shutil.copytree(src, dst)
+        return dst
+
+    return _setup
index 935e417f99389fdaceb2479c489c6e32ffbeb7d2..95735f17b50ada222468ff8f3aff73cc5f9e5fc0 100644 (file)
Binary files a/tests/firefox-146-aes/key4.db and b/tests/firefox-146-aes/key4.db differ
diff --git a/tests/firefox-14iv/cert9.db b/tests/firefox-14iv/cert9.db
new file mode 100644 (file)
index 0000000..dbf9976
--- /dev/null
@@ -0,0 +1,9 @@
+PRAGMA user_version = 0;
+PRAGMA foreign_keys=OFF;
+BEGIN TRANSACTION;
+CREATE TABLE nssPublic (id PRIMARY KEY UNIQUE ON CONFLICT ABORT, a0, a1, a2, a3, a4, a10, a11, a12, a80, a81, a82, a83, a84, a85, a86, a87, a88, a89, a8a, a8b, a8c, a90, a100, a101, a102, a103, a104, a105, a106, a107, a108, a109, a10a, a10b, a10c, a110, a111, a120, a121, a122, a123, a124, a125, a126, a127, a128, a129, a130, a131, a132, a133, a134, a160, a161, a162, a163, a164, a165, a166, a170, a171, a172, a180, a181, a200, a201, a202, a210, a40000211, a40000212, a40000213, a220, a221, a222, a223, a224, a225, a226, a227, a22e, a22f, a22a, a22b, a22c, a22d, a250, a251, a252, a300, a301, a302, a400, a401, a402, a403, a404, a405, a406, a480, a481, a482, a500, a501, a502, a503, a601, a602, a603, a604, a605, a606, a607, a608, a609, a60a, a60b, a60c, a60d, a60e, a60f, a610, a611, a612, a617, a618, a619, a61a, a61b, a61c, a61d, a61e, a61f, a620, a621, a622, a623, a624, a625, a626, a627, a629, a628, a62a, a62b, a62c, a62d, a62e, a62f, a630, a631, a632, a633, a634, a635, a636, a637, a80000001, ace534351, ace534352, ace534353, ace534354, ace534355, ace534356, ace534357, ace534358, ace534364, ace534365, ace534366, ace534367, ace534368, ace534369, ace534373, ace534374, ace536351, ace536352, ace536353, ace536354, ace536355, ace536356, ace536357, ace536358, ace536359, ace53635a, ace53635b, ace53635c, ace53635d, ace53635e, ace53635f, ace536360, ace5363b4, ace5363b5, ad5a0db00);
+CREATE INDEX issuer ON nssPublic (a81);
+CREATE INDEX subject ON nssPublic (a101);
+CREATE INDEX label ON nssPublic (a3);
+CREATE INDEX ckaid ON nssPublic (a102);
+COMMIT;
diff --git a/tests/firefox-14iv/key4.db b/tests/firefox-14iv/key4.db
new file mode 100644 (file)
index 0000000..6aa5fb7
--- /dev/null
@@ -0,0 +1,13 @@
+PRAGMA user_version = 0;
+PRAGMA foreign_keys=OFF;
+BEGIN TRANSACTION;
+CREATE TABLE metaData (id PRIMARY KEY UNIQUE ON CONFLICT REPLACE, item1, item2);
+INSERT INTO "metaData" VALUES('sig_key_0e5820d8_00000011',X'308181305D06092A864886F70D01050E3050304206092A864886F70D01050C303504205C16F6B2E6557B038CB674D990B6263C1BA6C802A726E55C23481ABB60F5D2A002022710020120300A06082A864886F70D0209300A06082A864886F70D0209042079183F69FC307A354FC658767A65E85B12372231AEE96991E88CF08DD8B9EC71',NULL);
+INSERT INTO "metaData" VALUES('password',X'644F4F43B2F24340237D05E7B85C14E5E212B923433D10CC7256FB84636BFC42878E1E9782A728CDDDDC0F5B25A19D76',X'308182306E06092A864886F70D01050D3061304206092A864886F70D01050C30350420C7AC67B9D422F20BC46845ACA1735D455AA3C745B0AC75D0A68643B6BC90094E02022710020120300A06082A864886F70D0209301B060960864801650304012A040E54673F250D10A73193432DE314E00410EAEF13D0C501C4AEB5CF7236D07C8906');
+CREATE TABLE nssPrivate (id PRIMARY KEY UNIQUE ON CONFLICT ABORT, a0, a1, a2, a3, a4, a10, a11, a12, a80, a81, a82, a83, a84, a85, a86, a87, a88, a89, a8a, a8b, a8c, a90, a100, a101, a102, a103, a104, a105, a106, a107, a108, a109, a10a, a10b, a10c, a110, a111, a120, a121, a122, a123, a124, a125, a126, a127, a128, a129, a130, a131, a132, a133, a134, a160, a161, a162, a163, a164, a165, a166, a170, a171, a172, a180, a181, a200, a201, a202, a210, a40000211, a40000212, a40000213, a220, a221, a222, a223, a224, a225, a226, a227, a22e, a22f, a22a, a22b, a22c, a22d, a250, a251, a252, a300, a301, a302, a400, a401, a402, a403, a404, a405, a406, a480, a481, a482, a500, a501, a502, a503, a601, a602, a603, a604, a605, a606, a607, a608, a609, a60a, a60b, a60c, a60d, a60e, a60f, a610, a611, a612, a617, a618, a619, a61a, a61b, a61c, a61d, a61e, a61f, a620, a621, a622, a623, a624, a625, a626, a627, a629, a628, a62a, a62b, a62c, a62d, a62e, a62f, a630, a631, a632, a633, a634, a635, a636, a637, a80000001, ace534351, ace534352, ace534353, ace534354, ace534355, ace534356, ace534357, ace534358, ace534364, ace534365, ace534366, ace534367, ace534368, ace534369, ace534373, ace534374, ace536351, ace536352, ace536353, ace536354, ace536355, ace536356, ace536357, ace536358, ace536359, ace53635a, ace53635b, ace53635c, ace53635d, ace53635e, ace53635f, ace536360, ace5363b4, ace5363b5, ad5a0db00);
+INSERT INTO "nssPrivate" VALUES(240656600,X'00000004',X'01',X'01',X'A5005A',NULL,NULL,X'3081A2306E06092A864886F70D01050D3061304206092A864886F70D01050C303504200A4D100BDCC7FA08A0F4757F9A1789D9F5ADF1FF3AA19092C5D8F97D918AE8ED02022710020120300A06082A864886F70D0209301B060960864801650304012A040EB179C6E4302F05738D48CFA24C40043027CD8D2097D6DFB0B4AB4D5381620E8051C6760A7F4A7E9C1B91BDDEF1CAFF72C9B0777B731C67EACF053FBE4084D35E',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,X'0000001F',NULL,X'F8000000000000000000000000000001',X'00',X'01',X'01',X'01',X'01',X'01',NULL,X'00',NULL,NULL,X'A5005A',X'A5005A',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,X'00000020',X'01',X'00',X'00',X'01',NULL,X'01',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL);
+CREATE INDEX issuer ON nssPrivate (a81);
+CREATE INDEX subject ON nssPrivate (a101);
+CREATE INDEX label ON nssPrivate (a3);
+CREATE INDEX ckaid ON nssPrivate (a102);
+COMMIT;
diff --git a/tests/firefox-14iv/logins.json b/tests/firefox-14iv/logins.json
new file mode 100644 (file)
index 0000000..1c9452f
--- /dev/null
@@ -0,0 +1 @@
+{"nextId":2,"logins":[{"id":1,"hostname":"https://google.com","httpRealm":null,"formSubmitURL":"","usernameField":"","passwordField":"","encryptedUsername":"MEMEEPgAAAAAAAAAAAAAAAAAAAEwHQYJYIZIAWUDBAEqBBA63Oz6jeYnP00WiDwnpJm/BBCNJ9Xc0ixY1lCgfmuyae/U","encryptedPassword":"MEMEEPgAAAAAAAAAAAAAAAAAAAEwHQYJYIZIAWUDBAEqBBBM/Qf7rAPdDR/Oey+lbM2iBBCx9dIeWLdgBe+qO1GdTk4Q","guid":"{6d4599e1-eefe-43b4-bfcd-7d22686786f5}","encType":1,"timeCreated":1767954808671,"timeLastUsed":1767954808671,"timePasswordChanged":1767954808671,"timesUsed":1,"syncCounter":1,"everSynced":false,"encryptedUnknownFields":"MEMEEPgAAAAAAAAAAAAAAAAAAAEwHQYJYIZIAWUDBAEqBBC+YISsXgHQM/LGd8pxRCRsBBBL1ixroXEdzpajpiqvsz1f"}],"potentiallyVulnerablePasswords":[],"dismissedBreachAlertsByLoginGUID":{},"version":3}
\ No newline at end of file
diff --git a/tests/firefox-14iv/pkcs11.txt b/tests/firefox-14iv/pkcs11.txt
new file mode 100644 (file)
index 0000000..f8c8cbf
--- /dev/null
@@ -0,0 +1,5 @@
+library=
+name=NSS Internal PKCS #11 Module
+parameters=configdir='sql:/home/shane/.mozilla/firefox/7ls4dcbd.test' certPrefix='' keyPrefix='' secmod='secmod.db' flags=optimizeSpace updatedir='' updateCertPrefix='' updateKeyPrefix='' updateid='' updateTokenDescription='' 
+NSS=Flags=internal,critical trustOrder=75 cipherOrder=100 slotParams=(1={slotFlags=[ECC,RSA,DSA,DH,RC2,RC4,DES,RANDOM,SHA1,MD5,MD2,SSL,TLS,AES,Camellia,SEED,SHA256,SHA512] askpw=any timeout=30})
+
index 9dcdd64821c476dbbbb75da6f465e744ce8300ae..1304782625bf9fd071c2a330b144ecf8d78ec99b 100644 (file)
Binary files a/tests/firefox-70/key4.db and b/tests/firefox-70/key4.db differ
index 36f2e37e8133e139680ffe986776dfbd3930ffcf..762c46e63beb01b95402798e719341915990acb8 100644 (file)
Binary files a/tests/firefox-84/key4.db and b/tests/firefox-84/key4.db differ
index 74a40c6a9f776b9642acbab45b6632d63fa41cdb..9a46863fd68002183191f99322d4bd9cdb541093 100644 (file)
Binary files a/tests/firefox-mixed-keys/key4.db and b/tests/firefox-mixed-keys/key4.db differ
index 9be3138733915e9f9d3f9ba10ae7181697f89314..ca711e3d5793f1cba64b46882aa2efb27094fbbc 100644 (file)
Binary files a/tests/firefox-mp-70/key4.db and b/tests/firefox-mp-70/key4.db differ
index 51017f14f12ac0f093cf39a55e405681cbae3c63..e8e4583cbce25f288536b464ceec0e37de54912f 100644 (file)
Binary files a/tests/firefox-mp-84/key4.db and b/tests/firefox-mp-84/key4.db differ
index c8ed7d9c3c243641b99e8c7e4d45a6a06b85c377..11695abbe92a11a9a54902070bd7d6b4b20496b9 100644 (file)
Binary files a/tests/firefox-mp-test/key4.db and b/tests/firefox-mp-test/key4.db differ
diff --git a/tests/test_inspect.py b/tests/test_inspect.py
new file mode 100644 (file)
index 0000000..4d810ae
--- /dev/null
@@ -0,0 +1,58 @@
+from io import StringIO
+from unittest.mock import patch
+
+from ffpass import main
+
+
+def test_inspect_output(clean_profile):
+    """
+    Verifies that the 'inspect' command runs and produces Expected output
+    including warnings for 14-byte IVs.
+    """
+    # 1. Test 14-byte IV profile (Should Warn, Default = Obscure)
+    profile_14 = clean_profile("firefox-14iv")
+
+    with patch("sys.stdout", new=StringIO()) as fake_out:
+        with patch("sys.argv", ["ffpass", "inspect", "-d", str(profile_14)]):
+            main()
+
+        output = fake_out.getvalue()
+        assert "Inspecting Profile:" in output
+        assert "WARNING: Non-standard 14-byte IV detected" in output
+        assert "REQUIRES Native NSS backend" in output
+        assert "KDF OID:" in output
+        # Verify obscuration (partial)
+        assert "(Use --reveal-keys to show full)" in output
+        # Should contain "..." for omitted middle part
+        assert "..." in output
+        # But not "[HIDDEN]" anymore
+        assert "[HIDDEN]" not in output
+
+    # 2. Test Standard Profile (AES-256) WITH --reveal-keys
+    profile_std = clean_profile("firefox-146-aes")
+
+    with patch("sys.stdout", new=StringIO()) as fake_out:
+        with patch(
+            "sys.argv", ["ffpass", "inspect", "-d", str(profile_std), "--reveal-keys"]
+        ):
+            main()
+
+        output = fake_out.getvalue()
+        # Verify basic structure exists (proving ASN.1 parsing worked)
+        assert "[METADATA]" in output
+        assert "ID: password" in output
+        assert "Encryption Scheme:" in output
+
+        # Verify it revealed keys
+        assert "(Use --reveal-keys to show full)" not in output
+        assert "Global Salt:" in output
+        # Should NOT be hidden/partial
+        assert "..." not in output
+
+    # 3. Test Legacy Profile (3DES)
+    profile_legacy = clean_profile("firefox-70")
+    with patch("sys.stdout", new=StringIO()) as fake_out:
+        with patch("sys.argv", ["ffpass", "inspect", "-d", str(profile_legacy)]):
+            main()
+        output = fake_out.getvalue()
+        assert "ID: password" in output
index edffedb261f533f5e183d4c213b1d35f3a143410..54b1bb75c0854146986ac84f372da958094056b8 100644 (file)
@@ -1,14 +1,18 @@
 #!/usr/bin/env python3
 
-import ffpass
+import shutil
+import sqlite3
 from pathlib import Path
+
 import pytest
-import sqlite3
-import shutil
+
+import ffpass
 
 # This key corresponds to the static 'tests/firefox-84' profile in your repo
-TEST_KEY = b'\xbfh\x13\x1a\xda\xb5\x9d\xe3X\x10\xe0\xa8\x8a\xc2\xe5\xbcE\xf2I\r\xa2pm\xf4'
-MASTER_PASSWORD = 'test'
+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"
 
 
@@ -30,7 +34,7 @@ def mixed_key_profile(tmp_path):
     (multiple keys present).
     """
     # 1. Base the profile on an existing valid one
-    src = Path('tests/firefox-84')
+    src = Path("tests/firefox-84")
     dst = tmp_path / "firefox-mixed"
     shutil.copytree(src, dst)
 
@@ -50,8 +54,8 @@ def mixed_key_profile(tmp_path):
         row_list = list(row)
 
         # 4. Modify the UNIQUE 'id' column to avoid IntegrityError
-        if 'id' in col_names:
-            id_idx = col_names.index('id')
+        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
@@ -59,7 +63,7 @@ def mixed_key_profile(tmp_path):
                 row_list[id_idx] = original_id + 100  # Ensure uniqueness
             else:
                 # Fallback for blobs/bytes
-                row_list[id_idx] = b'\xff' * len(original_id)
+                row_list[id_idx] = b"\xff" * len(original_id)
 
         # 5. Insert the duplicate row
         placeholders = ",".join(["?"] * len(row_list))
@@ -71,33 +75,33 @@ def mixed_key_profile(tmp_path):
 
 
 def test_firefox_key():
-    key = _get_key(Path('tests/firefox-84'))
+    key = _get_key(Path("tests/firefox-84"))
     assert key == TEST_KEY
 
 
 def test_firefox_mp_key():
-    key = _get_key(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):
-        _get_key(Path('tests/firefox-mp-84'), 'wrongpassword')
+        _get_key(Path("tests/firefox-mp-84"), "wrongpassword")
 
 
 def test_legacy_firefox_key():
-    key = _get_key(Path('tests/firefox-70'))
+    key = _get_key(Path("tests/firefox-70"))
     assert key == TEST_KEY
 
 
 def test_legacy_firefox_mp_key():
-    key = _get_key(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):
-        _get_key(Path('tests/firefox-mp-70'), 'wrongpassword')
+        _get_key(Path("tests/firefox-mp-70"), "wrongpassword")
 
 
 def test_mixed_key_retrieval(mixed_key_profile):
index 5d9582ec8465ee1eb612071baf0f5feddf67b705..e96a23acf57ec7e543a6996d748ec5e0f86cbcfd 100644 (file)
@@ -6,32 +6,13 @@ Created on Fri Dec 25 19:30:48 2025
 @author: shane
 """
 
-import shutil
 import sys
-from pathlib import Path
 from unittest.mock import patch
 
-import pytest
-
 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
 
@@ -41,6 +22,8 @@ def run_ffpass_internal(mode, path):
     # and avoid needing complex ASN.1 mocking for the password check.
     with (
         patch("sys.argv", test_args),
+        patch("sys.stdin.isatty", return_value=False),
+        patch("sys.stdin.readline", return_value=""),
         patch("ffpass.get_all_keys") as mock_get_keys,
         patch("ffpass.try_decrypt_login") as mock_decrypt_login,
     ):
diff --git a/tests/test_native_verify.py b/tests/test_native_verify.py
new file mode 100644 (file)
index 0000000..cd86b82
--- /dev/null
@@ -0,0 +1,44 @@
+import pytest
+
+from ffpass import get_native_logins
+
+
+@pytest.mark.xfail(
+    reason="Native libnss3 may not support legacy (3DES) profiles on modern systems"
+)
+def test_native_old_profile(clean_profile):
+    # Firefox 70 profile (legacy)
+    profile = clean_profile("firefox-native-verify")
+    # Password 'test' (from test_run.py)
+    logins = get_native_logins(str(profile), "test")
+    assert len(logins) > 0
+
+    # Expected based on test_run.py: 'http://www.stealmylogin.com'
+    found = False
+    for login in logins:
+        if login["hostname"] == "http://www.stealmylogin.com":
+            found = True
+            assert login["username"] == "test"
+            assert login["password"] == "test"
+            break
+    assert found, "Expected login not found in native export"
+
+
+def test_native_new_profile(clean_profile):
+    # Firefox 14-byte IV profile (from user)
+    profile = clean_profile("firefox-14iv")
+    # Password 'pass'
+    logins = get_native_logins(str(profile), "pass")
+
+    assert logins is not None, "Native login decryption failed for new profile"
+    assert len(logins) > 0
+
+    # Expected: https://google.com, test, pass
+    found = False
+    for login in logins:
+        if login["hostname"] == "https://google.com":
+            found = True
+            assert login["username"] == "test"
+            assert login["password"] == "pass"
+            break
+    assert found, "Expected login not found in native export"
index 801a56add156000b14ae36626e94dc3e7a42841c..31eed21bdefac271a100b086406f2b17526914b5 100644 (file)
@@ -1,41 +1,26 @@
 #!/usr/bin/env python3
 
 import subprocess
-import shutil
-from pathlib import Path
 
-import pytest
-
-MASTER_PASSWORD = 'test'
-HEADER = 'url,username,password'
-IMPORT_CREDENTIAL = 'https://www.example.com,foo,bar'
-EXPECTED_EXPORT_OUTPUT = [HEADER, 'http://www.stealmylogin.com,test,test']
+MASTER_PASSWORD = "test"
+HEADER = "url,username,password"
+IMPORT_CREDENTIAL = "https://www.example.com,foo,bar"
+EXPECTED_EXPORT_OUTPUT = [HEADER, "http://www.stealmylogin.com,test,test"]
 EXPECTED_IMPORT_OUTPUT = EXPECTED_EXPORT_OUTPUT + [IMPORT_CREDENTIAL]
 
 
-@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_cmd(mode, path):
     command = ["python", "./ffpass/__init__.py", mode, "--debug", "--dir", str(path)]
 
-    if mode == 'import':
+    if mode == "import":
         ffpass_input = "\n".join([HEADER, IMPORT_CREDENTIAL])
     else:
-        ffpass_input = None
+        # Pass password via stdin to avoid interactive prompt hang and verify pipe support
+        ffpass_input = MASTER_PASSWORD
 
-    return subprocess.run(command, stdout=subprocess.PIPE, input=ffpass_input, encoding='utf-8')
+    return subprocess.run(
+        command, stdout=subprocess.PIPE, input=ffpass_input, encoding="utf-8"
+    )
 
 
 def stdout_splitter(input_text):
@@ -43,55 +28,55 @@ def stdout_splitter(input_text):
 
 
 def test_legacy_firefox_export(clean_profile):
-    r = run_ffpass_cmd('export', clean_profile('firefox-70'))
+    r = run_ffpass_cmd("export", clean_profile("firefox-70"))
     r.check_returncode()
     actual_export_output = stdout_splitter(r.stdout)
     assert actual_export_output == EXPECTED_EXPORT_OUTPUT
 
 
 def test_firefox_export(clean_profile):
-    r = run_ffpass_cmd('export', clean_profile('firefox-84'))
+    r = run_ffpass_cmd("export", clean_profile("firefox-84"))
     r.check_returncode()
     assert stdout_splitter(r.stdout) == EXPECTED_EXPORT_OUTPUT
 
 
 def test_firefox_aes_export(clean_profile):
     # This uses your new AES-encrypted profile
-    profile_path = clean_profile('firefox-146-aes')
-    r = run_ffpass_cmd('export', profile_path)
+    profile_path = clean_profile("firefox-146-aes")
+    r = run_ffpass_cmd("export", profile_path)
     r.check_returncode()
     assert stdout_splitter(r.stdout) == EXPECTED_EXPORT_OUTPUT
 
 
 def test_legacy_firefox(clean_profile):
-    profile_path = clean_profile('firefox-70')
+    profile_path = clean_profile("firefox-70")
 
     # modifies the temp file, not the original
-    r = run_ffpass_cmd('import', profile_path)
+    r = run_ffpass_cmd("import", profile_path)
     r.check_returncode()
 
-    r = run_ffpass_cmd('export', profile_path)
+    r = run_ffpass_cmd("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')
+    profile_path = clean_profile("firefox-84")
 
-    r = run_ffpass_cmd('import', profile_path)
+    r = run_ffpass_cmd("import", profile_path)
     r.check_returncode()
 
-    r = run_ffpass_cmd('export', profile_path)
+    r = run_ffpass_cmd("export", profile_path)
     r.check_returncode()
     assert stdout_splitter(r.stdout) == EXPECTED_IMPORT_OUTPUT
 
 
 def test_firefox_aes(clean_profile):
-    profile_path = clean_profile('firefox-146-aes')
+    profile_path = clean_profile("firefox-146-aes")
 
-    r = run_ffpass_cmd('import', profile_path)
+    r = run_ffpass_cmd("import", profile_path)
     r.check_returncode()
 
-    r = run_ffpass_cmd('export', profile_path)
+    r = run_ffpass_cmd("export", profile_path)
     r.check_returncode()
     assert stdout_splitter(r.stdout) == EXPECTED_IMPORT_OUTPUT
diff --git a/tests/test_sha256_mock.py b/tests/test_sha256_mock.py
new file mode 100644 (file)
index 0000000..584f9e6
--- /dev/null
@@ -0,0 +1,218 @@
+#!/usr/bin/env python3
+
+import os
+import sqlite3
+from hashlib import pbkdf2_hmac, sha1, sha256
+
+from Crypto.Cipher import AES
+from pyasn1.codec.der.encoder import encode as der_encode
+from pyasn1.type.univ import Integer, ObjectIdentifier, OctetString, Sequence
+
+import ffpass
+
+# Constants from ffpass (redefined here for test generation)
+OID_PBES2 = (1, 2, 840, 113_549, 1, 5, 13)
+OID_PBKDF2 = (1, 2, 840, 113_549, 1, 5, 12)
+OID_AES256_CBC = (2, 16, 840, 1, 101, 3, 4, 1, 42)
+
+
+def PKCS7pad(b, block_size=8):
+    pad_len = (-len(b) - 1) % block_size + 1
+    return b + bytes([pad_len] * pad_len)
+
+
+def build_pbes2_sequence(
+    global_salt, master_password, entry_salt, iters, plaintext, hash_method="sha256"
+):
+    """
+    Constructs a DER-encoded PBES2 sequence just like Firefox/ffpass expects.
+    """
+    # 1. Derive Key
+    if hash_method == "sha1":
+        enc_pwd = sha1(global_salt + master_password.encode("utf-8")).digest()
+    elif hash_method == "sha256":
+        enc_pwd = sha256(global_salt + master_password.encode("utf-8")).digest()
+    elif hash_method == "plaintext":
+        enc_pwd = master_password.encode("utf-8")
+    else:
+        raise ValueError(f"Unknown hash method: {hash_method}")
+
+    key_len = 32
+    k = pbkdf2_hmac("sha256", enc_pwd, entry_salt, iters, dklen=key_len)
+
+    # 2. Encrypt
+    iv = os.urandom(16)
+    cipher = AES.new(k, AES.MODE_CBC, iv)
+    ciphertext = cipher.encrypt(PKCS7pad(plaintext, block_size=16))
+
+    # 3. Build ASN.1 Structure
+    # ... (rest is same)
+    # Re-using previous logic structure but just checking it matches
+
+    # Key Derivation Func
+    kdf_params = Sequence()
+    kdf_params.setComponentByPosition(0, OctetString(entry_salt))
+    kdf_params.setComponentByPosition(1, Integer(iters))
+    kdf_params.setComponentByPosition(2, Integer(key_len))
+
+    # Add PRF OID (AlgorithmIdentifier)
+    # 1.2.840.113549.2.9 = hmacWithSHA256
+    alg_id_prf = Sequence()
+    alg_id_prf.setComponentByPosition(0, ObjectIdentifier((1, 2, 840, 113_549, 2, 9)))
+    # Parameters matches NULL usually or excluded? Let's check spec or assume explicit NULL is safe or omitted.
+    # ffpass logic checks len(pbkdf2_params) > 3. So we must add it as 4th element.
+    kdf_params.setComponentByPosition(3, alg_id_prf)
+
+    kdf = Sequence()
+    kdf.setComponentByPosition(0, ObjectIdentifier(OID_PBKDF2))
+    kdf.setComponentByPosition(1, kdf_params)
+
+    # Encryption Scheme
+    enc_scheme = Sequence()
+    enc_scheme.setComponentByPosition(0, ObjectIdentifier(OID_AES256_CBC))
+    enc_scheme.setComponentByPosition(1, OctetString(iv))
+
+    pbes2_seq = Sequence()
+    pbes2_seq.setComponentByPosition(0, kdf)
+    pbes2_seq.setComponentByPosition(1, enc_scheme)
+
+    alg_id = Sequence()
+    alg_id.setComponentByPosition(0, ObjectIdentifier(OID_PBES2))
+    alg_id.setComponentByPosition(1, pbes2_seq)
+
+    top = Sequence()
+    top.setComponentByPosition(0, alg_id)
+    top.setComponentByPosition(1, OctetString(ciphertext))
+
+    return der_encode(top)
+
+
+def test_plaintext_decryption(tmp_path):
+    print("Setting up test_plaintext_decryption...")
+    db_path = tmp_path / "key4.db"
+
+    if db_path.exists():
+        os.remove(db_path)
+
+    conn = sqlite3.connect(str(db_path))
+    c = conn.cursor()
+    c.execute("CREATE TABLE metadata (id TEXT, item1 BLOB, item2 BLOB)")
+    c.execute("CREATE TABLE nssPrivate (a11 BLOB, a102 BLOB)")
+
+    global_salt = os.urandom(20)
+    master_password = "test-plaintext"
+
+    entry_salt_check = os.urandom(20)
+    # Use PLAINTEXT here!
+    item2 = build_pbes2_sequence(
+        global_salt,
+        master_password,
+        entry_salt_check,
+        iters=1000,
+        plaintext=b"password-check",
+        hash_method="plaintext",
+    )
+
+    c.execute(
+        "INSERT INTO metadata (id, item1, item2) VALUES (?, ?, ?)",
+        ("password", global_salt, item2),
+    )
+
+    real_master_key = b"B" * 32
+    entry_salt_key = os.urandom(20)
+
+    a11 = build_pbes2_sequence(
+        global_salt,
+        master_password,
+        entry_salt_key,
+        iters=1000,
+        plaintext=real_master_key,
+        hash_method="plaintext",
+    )
+    a102 = b"\x00" * 16
+
+    c.execute("INSERT INTO nssPrivate (a11, a102) VALUES (?, ?)", (a11, a102))
+
+    conn.commit()
+    conn.close()
+
+    print(f"Database created at {db_path}")
+
+    print("Attempting to unlock (plaintext)...")
+    keys, returned_salt = ffpass.get_all_keys(tmp_path, master_password)
+
+    print(f"Unlocked! Found {len(keys)} keys.")
+    assert len(keys) == 1
+    assert keys[0] == real_master_key
+    assert returned_salt == global_salt
+    print("SUCCESS: Master key verified correctly with PLAINTEXT hashing.")
+
+
+def test_sha256_decryption(tmp_path):
+    import logging
+
+    logging.basicConfig(level=logging.DEBUG)
+    print("Setting up test_sha256_decryption...")
+    db_path = tmp_path / "key4.db"
+
+    conn = sqlite3.connect(str(db_path))
+    c = conn.cursor()
+    c.execute("CREATE TABLE metadata (id TEXT, item1 BLOB, item2 BLOB)")
+    c.execute("CREATE TABLE nssPrivate (a11 BLOB, a102 BLOB)")
+
+    global_salt = os.urandom(20)
+    master_password = "test-new-profile"
+
+    # 1. Create 'password' metadata entry
+    # item1 = global_salt
+    # item2 = encrypted "password-check" (padded to "password-check\x02\x02")
+
+    entry_salt_check = os.urandom(20)
+    # Use SHA256 here!
+    item2 = build_pbes2_sequence(
+        global_salt,
+        master_password,
+        entry_salt_check,
+        iters=1000,
+        plaintext=b"password-check",
+        hash_method="sha256",
+    )
+
+    c.execute(
+        "INSERT INTO metadata (id, item1, item2) VALUES (?, ?, ?)",
+        ("password", global_salt, item2),
+    )
+
+    # 2. Create a 'nssPrivate' master key entry
+    # The actual master key (random 32 bytes)
+    real_master_key = b"A" * 32
+    entry_salt_key = os.urandom(20)
+
+    # Encrypt the master key using the same derived key logic
+    a11 = build_pbes2_sequence(
+        global_salt,
+        master_password,
+        entry_salt_key,
+        iters=1000,
+        plaintext=real_master_key,
+        hash_method="sha256",
+    )
+    # a102 is just the ID/Name of the key (usually looks like magic bytes)
+    a102 = b"\x00" * 16
+
+    c.execute("INSERT INTO nssPrivate (a11, a102) VALUES (?, ?)", (a11, a102))
+
+    conn.commit()
+    conn.close()
+
+    print(f"Database created at {db_path}")
+
+    # Now run ffpass logic
+    print("Attempting to unlock...")
+    keys, returned_salt = ffpass.get_all_keys(tmp_path, master_password)
+
+    print(f"Unlocked! Found {len(keys)} keys.")
+    assert len(keys) == 1
+    assert keys[0] == real_master_key
+    assert returned_salt == global_salt
+    print("SUCCESS: Master key verified correctly with SHA256 hashing.")