From: Shane Jaroch Date: Fri, 9 Jan 2026 10:39:01 +0000 (-0500) Subject: master password v146 non-mixed profiles. inspect. ruff. X-Git-Url: https://git.nutra.tk/v1?a=commitdiff_plain;h=33e2251220bb0593b4d4e637eb2c99be435cf433;p=gamesguru%2Fffpass.git master password v146 non-mixed profiles. inspect. ruff. use --reveal-keys flag use git-sqlite-filter for *.db files --- diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f42f38d --- /dev/null +++ b/.gitattributes @@ -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 + diff --git a/Makefile b/Makefile index 9e05248..d9951e3 100644 --- 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 diff --git a/ffpass/__init__.py b/ffpass/__init__.py index 3184934..a134e2d 100755 --- a/ffpass/__init__.py +++ b/ffpass/__init__.py @@ -9,11 +9,11 @@ The MIT License (MIT) Copyright (c) 2018 Louis Abraham Laurent Clevy (@lorenzo2472) # from https://github.com/lclevy/firepwd/blob/master/firepwd.py -\x1B[34m\033[F\033[F +\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 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 index 0000000..a68ab24 --- /dev/null +++ b/ffpass/nss.py @@ -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() diff --git a/requirements-dev.txt b/requirements-dev.txt index 64ce1ef..f979348 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -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 index 0000000..469b634 --- /dev/null +++ b/tests/conftest.py @@ -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 diff --git a/tests/firefox-146-aes/key4.db b/tests/firefox-146-aes/key4.db index 935e417..95735f1 100644 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 index 0000000..dbf9976 --- /dev/null +++ b/tests/firefox-14iv/cert9.db @@ -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 index 0000000..6aa5fb7 --- /dev/null +++ b/tests/firefox-14iv/key4.db @@ -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 index 0000000..1c9452f --- /dev/null +++ b/tests/firefox-14iv/logins.json @@ -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 index 0000000..f8c8cbf --- /dev/null +++ b/tests/firefox-14iv/pkcs11.txt @@ -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}) + diff --git a/tests/firefox-70/key4.db b/tests/firefox-70/key4.db index 9dcdd64..1304782 100644 Binary files a/tests/firefox-70/key4.db and b/tests/firefox-70/key4.db differ diff --git a/tests/firefox-84/key4.db b/tests/firefox-84/key4.db index 36f2e37..762c46e 100644 Binary files a/tests/firefox-84/key4.db and b/tests/firefox-84/key4.db differ diff --git a/tests/firefox-mixed-keys/key4.db b/tests/firefox-mixed-keys/key4.db index 74a40c6..9a46863 100644 Binary files a/tests/firefox-mixed-keys/key4.db and b/tests/firefox-mixed-keys/key4.db differ diff --git a/tests/firefox-mp-70/key4.db b/tests/firefox-mp-70/key4.db index 9be3138..ca711e3 100644 Binary files a/tests/firefox-mp-70/key4.db and b/tests/firefox-mp-70/key4.db differ diff --git a/tests/firefox-mp-84/key4.db b/tests/firefox-mp-84/key4.db index 51017f1..e8e4583 100644 Binary files a/tests/firefox-mp-84/key4.db and b/tests/firefox-mp-84/key4.db differ diff --git a/tests/firefox-mp-test/key4.db b/tests/firefox-mp-test/key4.db index c8ed7d9..11695ab 100644 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 index 0000000..4d810ae --- /dev/null +++ b/tests/test_inspect.py @@ -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 diff --git a/tests/test_key.py b/tests/test_key.py index edffedb..54b1bb7 100644 --- a/tests/test_key.py +++ b/tests/test_key.py @@ -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): diff --git a/tests/test_mixed_keys_run.py b/tests/test_mixed_keys_run.py index 5d9582e..e96a23a 100644 --- a/tests/test_mixed_keys_run.py +++ b/tests/test_mixed_keys_run.py @@ -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 index 0000000..cd86b82 --- /dev/null +++ b/tests/test_native_verify.py @@ -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" diff --git a/tests/test_run.py b/tests/test_run.py index 801a56a..31eed21 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -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 index 0000000..584f9e6 --- /dev/null +++ b/tests/test_sha256_mock.py @@ -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.")