]> Nutra Git (v1) - gamesguru/ffpass.git/commitdiff
Add fixes & tests for master password
authorShane Jaroch <chown_tee@proton.me>
Fri, 26 Dec 2025 05:31:34 +0000 (00:31 -0500)
committerShane Jaroch <chown_tee@proton.me>
Fri, 26 Dec 2025 06:04:10 +0000 (01:04 -0500)
ffpass/__init__.py
scripts/generate_mp_profile.py [new file with mode: 0755]
tests/firefox-mp-test/key4.db [new file with mode: 0644]
tests/firefox-mp-test/logins.json [new file with mode: 0644]
tests/test_mixed_keys_run.py
tests/test_mp_stdin.py [new file with mode: 0644]

index c6552911d17eb3b3e7fdc11b069209870217cc6b..1282af575a9ba5bab636cbc3be53d73d47edec50 100644 (file)
@@ -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 (executable)
index 0000000..59d7e04
--- /dev/null
@@ -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 (file)
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 (file)
index 0000000..d7e7507
--- /dev/null
@@ -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
index 5d49d6c39ce6a197be0f3fb2d7033e990daddb33..e77ed028c4c0bfcc00886897e22f3d0797bdc9a2 100644 (file)
@@ -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 (file)
index 0000000..247dcf4
--- /dev/null
@@ -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