From: Shane Jaroch Date: Fri, 26 Dec 2025 05:31:34 +0000 (-0500) Subject: Add fixes & tests for master password X-Git-Url: https://git.nutra.tk/v1?a=commitdiff_plain;h=d2274e781a24161b48111f7196489a0efcbdecf3;p=gamesguru%2Fffpass.git Add fixes & tests for master password --- diff --git a/ffpass/__init__.py b/ffpass/__init__.py index c655291..1282af5 100644 --- a/ffpass/__init__.py +++ b/ffpass/__init__.py @@ -469,6 +469,10 @@ def guessDir(): def askpass(directory): password = "" + n = 0 + # Allow 1 automatic check + 2 user prompts = 3 attempts total. + # The condition "n < n_max" must allow n=0, n=1, n=2. + n_max = 3 while True: try: keys, _ = get_all_keys(directory, password) @@ -477,9 +481,13 @@ def askpass(directory): 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: ") - else: - break + + if n > 0: + logging.error(f"wrong master password after {n_max - 1} prompts!") return None diff --git a/scripts/generate_mp_profile.py b/scripts/generate_mp_profile.py new file mode 100755 index 0000000..59d7e04 --- /dev/null +++ b/scripts/generate_mp_profile.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Created on Fri Dec 26 00:10:05 2025 + +@author: shane +""" + +import hmac +import json +import secrets +import sqlite3 +from hashlib import sha1 +from pathlib import Path + +from Crypto.Cipher import AES, DES3 +# Dependencies: pyasn1, pycryptodome +from pyasn1.codec.der.encoder import encode as der_encode +from pyasn1.type.univ import Integer, ObjectIdentifier, OctetString, Sequence + +# Constants +MASTER_PASSWORD = "password123" +GLOBAL_SALT = secrets.token_bytes(20) +# We will generate a 24-byte (3DES) master key to encrypt the database +REAL_MASTER_KEY = secrets.token_bytes(24) + +# OIDs +OID_PKCS12_3DES = (1, 2, 840, 113_549, 1, 12, 5, 1, 3) +MAGIC1 = b"\xf8\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01" +MAGIC_AES = (2, 16, 840, 1, 101, 3, 4, 1, 42) + + +def PKCS7pad(b, block_size=8): + pad_len = (-len(b) - 1) % block_size + 1 + return b + bytes([pad_len] * pad_len) + + +def derive_3des_key(global_salt, master_password, entry_salt): + """ + Derives Key and IV using the specific Firefox/NSS PKCS#12-like KDF. + Matches decrypt3DES in ffpass/__init__.py + """ + hp = sha1(global_salt + master_password.encode()).digest() + pes = entry_salt + b"\x00" * (20 - len(entry_salt)) + chp = sha1(hp + entry_salt).digest() + k1 = hmac.new(chp, pes + entry_salt, sha1).digest() + tk = hmac.new(chp, pes, sha1).digest() + k2 = hmac.new(chp, tk + entry_salt, sha1).digest() + k = k1 + k2 + iv = k[-8:] + key = k[:24] + return key, iv + + +def asn1_wrap_3des(entry_salt, ciphertext): + """ + Wraps the salt and ciphertext in the ASN.1 structure expected by Firefox. + Structure: Sequence[ Sequence[ OID, Sequence[Salt, Iters] ], Ciphertext ] + """ + # 1. Algorithm Identifier + params = Sequence() + params[0] = OctetString(entry_salt) + params[1] = Integer(1) # Iterations + + algo_id = Sequence() + algo_id[0] = ObjectIdentifier(OID_PKCS12_3DES) + algo_id[1] = params + + # 2. Outer Sequence + outer = Sequence() + outer[0] = algo_id + outer[1] = OctetString(ciphertext) + + return der_encode(outer) + + +def encrypt_pbe(data, global_salt, master_password): + """ + Encrypts data (e.g. password-check or master key) using 3DES PBE. + Returns the DER-encoded ASN.1 blob. + """ + entry_salt = secrets.token_bytes(20) + key, iv = derive_3des_key(global_salt, master_password, entry_salt) + + cipher = DES3.new(key, DES3.MODE_CBC, iv) + padded_data = PKCS7pad(data) + ciphertext = cipher.encrypt(padded_data) + + return asn1_wrap_3des(entry_salt, ciphertext) + + +def encode_login_data(key, data): + """ + Encrypts a username or password using the Master Key (AES-256 logic). + Matches encodeLoginData in ffpass/__init__.py + """ + # Use AES-256 if key is 32 bytes, else 3DES. Our REAL_MASTER_KEY is 24 bytes (3DES). + # To match modern Firefox better, let's pretend we use 3DES for the DB entry + # but the logic handles whatever key we give it. + # Let's stick to the AES path here if we want; but wait, REAL_MASTER_KEY is 24 bytes. + # We must use 3DES logic for the login entry if key is 24 bytes. + + asn1data = Sequence() + asn1data[0] = OctetString(MAGIC1) + asn1data[1] = Sequence() + + if len(key) == 32: + # AES Logic + iv = secrets.token_bytes(16) + cipher = AES.new(key, AES.MODE_CBC, iv) + ciphertext = cipher.encrypt(PKCS7pad(data.encode(), block_size=16)) + asn1data[1][0] = ObjectIdentifier(MAGIC_AES) + asn1data[1][1] = OctetString(iv) + asn1data[2] = OctetString(ciphertext) + else: + # 3DES Logic (matches our 24-byte master key) + # OID: 1.2.840.113549.3.7 (des-ede3-cbc) + OID_3DES_CBC = (1, 2, 840, 113_549, 3, 7) + iv = secrets.token_bytes(8) + cipher = DES3.new(key, DES3.MODE_CBC, iv) + ciphertext = cipher.encrypt(PKCS7pad(data.encode(), block_size=8)) + asn1data[1][0] = ObjectIdentifier(OID_3DES_CBC) + asn1data[1][1] = OctetString(iv) + asn1data[2] = OctetString(ciphertext) + + from base64 import b64encode + + return b64encode(der_encode(asn1data)).decode() + + +def create_mp_profile(): + base_dir = Path("tests/firefox-mp-test") + if base_dir.exists(): + import shutil + + shutil.rmtree(base_dir) + base_dir.mkdir(parents=True) + + print(f"Generating Real Encrypted MP profile in {base_dir}...") + + # 1. Create key4.db + conn = sqlite3.connect(base_dir / "key4.db") + c = conn.cursor() + c.execute("CREATE TABLE metadata (id TEXT PRIMARY KEY, item1, item2)") + c.execute("CREATE TABLE nssPrivate (a11, a102)") + + # A. Metadata: Password Check + # The tool verifies password by decrypting this and checking for "password-check\x02\x02" + # The encrypt_pbe function handles padding. + password_check_blob = encrypt_pbe(b"password-check", GLOBAL_SALT, MASTER_PASSWORD) + c.execute( + "INSERT INTO metadata VALUES ('password', ?, ?)", + (GLOBAL_SALT, password_check_blob), + ) + + # B. nssPrivate: Encrypted Master Key + # The tool decrypts this to get the key used for logins.json + master_key_blob = encrypt_pbe(REAL_MASTER_KEY, GLOBAL_SALT, MASTER_PASSWORD) + c.execute("INSERT INTO nssPrivate VALUES (?, ?)", (master_key_blob, MAGIC1)) + + conn.commit() + conn.close() + + # 2. Create logins.json + # These strings are actually encrypted with REAL_MASTER_KEY now + logins_data = { + "nextId": 2, + "logins": [ + { + "id": 1, + "hostname": "https://locked.com", + "encryptedUsername": encode_login_data(REAL_MASTER_KEY, "secret_user"), + "encryptedPassword": encode_login_data(REAL_MASTER_KEY, "secret_pass"), + "deleted": False, + } + ], + } + + with open(base_dir / "logins.json", "w") as f: + json.dump(logins_data, f) + + print("Done.") + + +if __name__ == "__main__": + create_mp_profile() diff --git a/tests/firefox-mp-test/key4.db b/tests/firefox-mp-test/key4.db new file mode 100644 index 0000000..c8ed7d9 Binary files /dev/null and b/tests/firefox-mp-test/key4.db differ diff --git a/tests/firefox-mp-test/logins.json b/tests/firefox-mp-test/logins.json new file mode 100644 index 0000000..d7e7507 --- /dev/null +++ b/tests/firefox-mp-test/logins.json @@ -0,0 +1 @@ +{"nextId": 2, "logins": [{"id": 1, "hostname": "https://locked.com", "encryptedUsername": "MDoEEPgAAAAAAAAAAAAAAAAAAAEwFAYIKoZIhvcNAwcECLSofBkhyi6aBBAUN847VIaI3v/ONszuHiXI", "encryptedPassword": "MDoEEPgAAAAAAAAAAAAAAAAAAAEwFAYIKoZIhvcNAwcECGgTq8PZx0pVBBBmptJXLWpTeDwYKhku6o2r", "deleted": false}]} \ No newline at end of file diff --git a/tests/test_mixed_keys_run.py b/tests/test_mixed_keys_run.py index 5d49d6c..e77ed02 100644 --- a/tests/test_mixed_keys_run.py +++ b/tests/test_mixed_keys_run.py @@ -1,11 +1,18 @@ #!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Created on Fri Dec 25 19:30:48 2025 + +@author: shane +""" import os import shutil -import pytest import sys -from unittest.mock import patch from pathlib import Path +from unittest.mock import patch + +import pytest OS_NEWLINE = os.linesep HEADER = "url,username,password" diff --git a/tests/test_mp_stdin.py b/tests/test_mp_stdin.py new file mode 100644 index 0000000..247dcf4 --- /dev/null +++ b/tests/test_mp_stdin.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Created on Fri Dec 26 00:13:51 2025 + +@author: shane +""" + +import shutil +import sys +from io import StringIO +from pathlib import Path +from unittest.mock import patch + +import pytest + +# Allow importing ffpass from source +sys.path.insert(0, str(Path(__file__).parent.parent)) + +import ffpass # noqa: E402 +from ffpass import main # noqa: E402 + +MASTER_PASSWORD = "password123" + + +@pytest.fixture +def mp_profile(tmp_path): + """ + Setup the MP profile with REAL encrypted data. + Requires running scripts/generate_mp_profile.py first. + """ + src = Path("tests/firefox-mp-test") + if not src.exists(): + pytest.fail( + "Run scripts/generate_mp_profile.py first to generate real crypto assets" + ) + dst = tmp_path / "firefox-mp-test" + shutil.copytree(src, dst) + return dst + + +def test_export_with_correct_password(mp_profile): + """ + Verifies that providing the correct password via stdin allows + successful decryption of the database. + """ + # Mock user input to return correct password immediately + ffpass.getpass = lambda x: MASTER_PASSWORD + + # Capture stdout to verify CSV output + capture = StringIO() + + with patch("sys.argv", ["ffpass", "export", "-d", str(mp_profile)]), patch( + "sys.stdout", capture + ): + + # Run real main() - no internal crypto mocks! + # This proves verify_password -> decrypt_key -> decodeLoginData all work + try: + main() + except SystemExit: + pass + + output = capture.getvalue() + print(output) # For debugging failures + + # Verify we successfully decrypted the specific credentials in logins.json + assert "url,username,password" in output + assert "https://locked.com,secret_user,secret_pass" in output + + +def test_export_with_wrong_password_retry(mp_profile): + """ + Verifies the retry logic: + 1. Enter wrong password -> fail + 2. Enter correct password -> succeed + """ + # Create an iterator that yields Wrong, then Right + # This simulates the user typing correctly on the second attempt + inputs = iter(["wrong_pass", MASTER_PASSWORD]) + + ffpass.getpass = lambda x: next(inputs) + + capture = StringIO() + + with patch("sys.argv", ["ffpass", "export", "-d", str(mp_profile)]), patch( + "sys.stdout", capture + ): + + try: + main() + except SystemExit: + pass + + output = capture.getvalue() + + # It should eventually succeed and print the data + assert "secret_user" in output + + +def test_import_with_stdin_password(mp_profile): + """ + Verifies that import also respects the password prompt mechanism. + """ + ffpass.getpass = lambda x: MASTER_PASSWORD + + # Prepare input CSV for import + input_csv = "url,username,password\nhttps://newsite.com,new_user,new_pass" + + # We need to mock stdin for the CSV data itself + # AND mock ffpass.getpass for the master password + + # ffpass.main_import reads from args.file. + # If args.file is sys.stdin, we must patch sys.stdin. + + with patch("sys.argv", ["ffpass", "import", "-d", str(mp_profile)]), patch( + "sys.stdin", StringIO(input_csv) + ): + + try: + main() + except SystemExit: + pass + + # Verify the new login was actually added to the file + # We can check by running export again or inspecting the JSON + import json + + with open(mp_profile / "logins.json", "r") as f: + data = json.load(f) + + # The file is encrypted, so we can't grep "new_user" directly. + # We just check that the login count increased (was 1, now 2) + assert len(data["logins"]) == 2 + assert data["nextId"] == 3