v0.6.0: Import & export for AES and mixed keys.
authorShane Jaroch <chown_tee@proton.me>
Thu, 25 Dec 2025 19:27:03 +0000 (14:27 -0500)
committerShane Jaroch <chown_tee@proton.me>
Fri, 26 Dec 2025 11:23:49 +0000 (06:23 -0500)
    - GH Actions on Windows/macOS
    - Add argcomplete (optional end-user add-on).
    - Add fixes & tests for master password.

    - tests: Generate mixed key test data. Use some mocks.
    - tests: Avoid modifying .json files, use temp storage.
    - tests: Add test for AES export/import (AES, 3DES, and mixed keys).

    - Tidy up, lint, and format.
    - Fix edge cases with argcomplete.
    - Windows: Fix profile discovery and CSV printing.
    - Better loop logic (for imports w/ empty row or missing header).

    - Config files.
    - Add stuff back that was removed accidentally in a frenzy.

20 files changed:
.envrc [new file with mode: 0644]
.github/workflows/testing.yaml
.github/workflows/windows-and-mac.yaml [new file with mode: 0644]
Makefile
ffpass/__init__.py [changed mode: 0644->0755]
requirements-dev.txt [new file with mode: 0644]
requirements.txt [new file with mode: 0644]
scripts/generate_mixed_profile.py [new file with mode: 0755]
scripts/generate_mp_profile.py [new file with mode: 0755]
setup.py
tests/firefox-146-aes/key4.db [new file with mode: 0644]
tests/firefox-146-aes/logins.json [new file with mode: 0644]
tests/firefox-mixed-keys/key4.db [new file with mode: 0644]
tests/firefox-mixed-keys/logins.json [new file with mode: 0644]
tests/firefox-mp-test/key4.db [new file with mode: 0644]
tests/firefox-mp-test/logins.json [new file with mode: 0644]
tests/test_key.py
tests/test_mixed_keys_run.py [new file with mode: 0644]
tests/test_mp_stdin.py [new file with mode: 0644]
tests/test_run.py

diff --git a/.envrc b/.envrc
new file mode 100644 (file)
index 0000000..488515d
--- /dev/null
+++ b/.envrc
@@ -0,0 +1,4 @@
+source .venv/bin/activate
+unset PS1
+eval "$(register-python-argcomplete ffpass)"
+
index 24ddd974ffd828f6dfc07ecc43cb5b338ffde1ad..d00081b2dbb29e0f115b15f25770ea240142a0f7 100644 (file)
@@ -5,7 +5,7 @@ on: [push, pull_request_target]
 jobs:
   test:
     name: Test
-    runs-on: ubuntu-latest
+    runs-on: [ubuntu-latest]
     steps:
     - name: Checkout
       uses: actions/checkout@v4
@@ -29,7 +29,7 @@ jobs:
     - name: Lint with flake8
       run: |
         pip install flake8
-        flake8 --ignore=E741,E501 .
+        flake8 . --exclude='*venv,build' --ignore=E741,E501
 
     - name: Upload Unit Test Results
       if: always()
diff --git a/.github/workflows/windows-and-mac.yaml b/.github/workflows/windows-and-mac.yaml
new file mode 100644 (file)
index 0000000..29f3b2f
--- /dev/null
@@ -0,0 +1,50 @@
+name: windows-and-mac
+
+on: [push, pull_request_target]
+
+jobs:
+  windows:
+    name: windows
+    runs-on: [windows-latest]
+    steps:
+    - name: Checkout
+      uses: actions/checkout@v4
+
+    - name: Set up Python
+      uses: actions/setup-python@v5
+      with:
+        python-version: '3.x'
+
+    - name: Install dependencies
+      run: |
+        python -m pip install --upgrade pip
+        pip install .
+
+    - name: Test with pytest
+      run: |
+        pip install pytest
+        pip install pytest-cov
+        python -m pytest tests --junit-xml pytest.xml
+
+  macOS:
+    name: macOS
+    runs-on: [macos-latest]
+    steps:
+    - name: Checkout
+      uses: actions/checkout@v4
+
+    - name: Set up Python
+      uses: actions/setup-python@v5
+      with:
+        python-version: '3.x'
+
+    - name: Install dependencies
+      run: |
+        python -m pip install --upgrade pip
+        pip install .
+
+    - name: Test with pytest
+      run: |
+        pip install pytest
+        pip install pytest-cov
+        python -m pytest tests --junit-xml pytest.xml
\ No newline at end of file
index cf7baaca4b0c77e9b2ee6c2e8bdfd2d36ddf3541..2390acd299dc65b0b72483cbf357d6331f4a40ec 100644 (file)
--- a/Makefile
+++ b/Makefile
@@ -1,13 +1,39 @@
+SHELL:=/bin/bash
+
+.PHONY: pypi
 pypi: dist
        twine upload dist/*
-       
+
+.PHONY: dist
 dist: flake8
        -rm dist/*
        ./setup.py sdist bdist_wheel
 
+.PHONY: flake8
 flake8:
-       flake8 . --count --select=E901,E999,F821,F822,F823 --show-source --statistics
-       flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
+       flake8 . --exclude '*venv,build' --count --select=E901,E999,F821,F822,F823 --show-source --statistics
+       flake8 . --exclude '*venv,build' --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
+       # CI pipeline
+       flake8 . --exclude='*venv,build' --ignore=E741,E501
+
+
+.PHONY: install
+install:
+       pip install .
+
+.PHONY: test
+test:
+       @echo 'Remember to run make install to test against the latest :)'
+       coverage run -m pytest -svv tests/
+       coverage report -m --omit="tests/*"
+
 
+.PHONY: clean
 clean:
        rm -rf *.egg-info build dist
+       rm -f .coverage
+       find . \
+                 -name .venv -prune \
+                 -o -name __pycache__ -print \
+                 -o -name .pytest_cache -print \
+               | xargs -r rm -rf
old mode 100644 (file)
new mode 100755 (executable)
index 56262cb..e7f7a09
@@ -1,4 +1,5 @@
 #!/usr/bin/env python3
+# PYTHON_ARGCOMPLETE_OK
 
 # Reverse-engineering by Laurent Clevy (@lclevy)
 # from https://github.com/lclevy/firepwd/blob/master/firepwd.py
@@ -26,10 +27,10 @@ If you found this code useful, add a star on <https://github.com/louisabraham/ff
 \033[0m\033[F\033[F
 """
 
+
 import sys
 from base64 import b64decode, b64encode
 from hashlib import sha1, pbkdf2_hmac
-import hmac
 import argparse
 import json
 from pathlib import Path
@@ -42,6 +43,7 @@ from urllib.parse import urlparse
 import sqlite3
 import os.path
 import logging
+import string
 
 from pyasn1.codec.der.decoder import decode as der_decode
 from pyasn1.codec.der.encoder import encode as der_encode
@@ -54,8 +56,14 @@ MAGIC1 = b"\xf8\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01"
 # des-ede3-cbc
 MAGIC2 = (1, 2, 840, 113_549, 3, 7)
 
+# aes-256-cbc
+MAGIC_AES = (2, 16, 840, 1, 101, 3, 4, 1, 42)
+
 # pkcs-12-PBEWithSha1AndTripleDESCBC
-MAGIC3 = (1, 2, 840, 113_549, 1, 12, 5, 1, 3)
+OID_PKCS12_3DES = (1, 2, 840, 113_549, 1, 12, 5, 1, 3)
+
+# pkcs5PBES2
+OID_PBES2 = (1, 2, 840, 113_549, 1, 5, 13)
 
 
 class NoDatabase(Exception):
@@ -70,96 +78,44 @@ class NoProfile(Exception):
     pass
 
 
-def getKey(directory: Path, masterPassword=""):
-    dbfile: Path = directory / "key4.db"
+def censor(data):
+    """
+    Censors the middle third of a hex string or bytes object.
+    """
+    if not data:
+        return None
+    s = data.hex() if isinstance(data, (bytes, bytearray)) else str(data)
 
-    if not dbfile.exists():
-        raise NoDatabase()
+    length = len(s)
+    if length <= 12:
+        return s
 
-    conn = sqlite3.connect(dbfile.as_posix())
-    c = conn.cursor()
-    c.execute("""
-        SELECT item1, item2
-        FROM metadata
-        WHERE id = 'password';
-    """)
-    row = next(c)
-    globalSalt, item2 = row
+    third = length // 3
+    two_thirds = (2 * length) // 3
+    return f"{s[:third]}.....{s[two_thirds:]}"
 
-    try:
-        decodedItem2, _ = der_decode(item2)
-        encryption_method = '3DES'
-        entrySalt = decodedItem2[0][1][0].asOctets()
-        cipherT = decodedItem2[1].asOctets()
-        clearText = decrypt3DES(
-            globalSalt, masterPassword, entrySalt, cipherT
-        )  # usual Mozilla PBE
-    except AttributeError:
-        encryption_method = 'AES'
-        decodedItem2 = der_decode(item2)
-        clearText = decrypt_aes(decodedItem2, masterPassword, globalSalt)
-
-    if clearText != b"password-check\x02\x02":
-        raise WrongPassword()
 
-    logging.info("password checked")
+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 iv_bytes[2:]
+    return iv_bytes
 
-    # decrypt 3des key to decrypt "logins.json" content
-    c.execute("""
-        SELECT a11, a102
-        FROM nssPrivate
-        WHERE a102 = ?;
-    """, (MAGIC1,))
-    try:
-        row = next(c)
-        a11, a102 = row  # CKA_ID
-    except StopIteration:
-        raise Exception(
-            "The Firefox database appears to be broken. Try to add a password to rebuild it."
-        )  # CKA_ID
-
-    if encryption_method == 'AES':
-        decodedA11 = der_decode(a11)
-        key = decrypt_aes(decodedA11, masterPassword, globalSalt)
-    elif encryption_method == '3DES':
-        decodedA11, _ = der_decode(a11)
-        oid = decodedA11[0][0].asTuple()
-        assert oid == MAGIC3, f"The key is encoded with an unknown format {oid}"
-        entrySalt = decodedA11[0][1][0].asOctets()
-        cipherT = decodedA11[1].asOctets()
-        key = decrypt3DES(globalSalt, masterPassword, entrySalt, cipherT)
-
-    logging.info("{}: {}".format(encryption_method, key.hex()))
-    return key[:24]
 
-
-def PKCS7pad(b):
-    l = (-len(b) - 1) % 8 + 1
-    return b + bytes([l] * l)
+def PKCS7pad(b, block_size=8):
+    pad_len = (-len(b) - 1) % block_size + 1
+    return b + bytes([pad_len] * pad_len)
 
 
 def PKCS7unpad(b):
+    if not b:
+        return b
     return b[: -b[-1]]
 
 
-def decrypt_aes(decoded_item, master_password, global_salt):
-    entry_salt = decoded_item[0][0][1][0][1][0].asOctets()
-    iteration_count = int(decoded_item[0][0][1][0][1][1])
-    key_length = int(decoded_item[0][0][1][0][1][2])
-    assert key_length == 32
-
-    encoded_password = sha1(global_salt + master_password.encode('utf-8')).digest()
-    key = pbkdf2_hmac(
-        'sha256', encoded_password,
-        entry_salt, iteration_count, dklen=key_length)
-
-    init_vector = b'\x04\x0e' + decoded_item[0][0][1][1][1].asOctets()
-    encrypted_value = decoded_item[0][1].asOctets()
-    cipher = AES.new(key, AES.MODE_CBC, init_vector)
-    return cipher.decrypt(encrypted_value)
-
-
 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()
@@ -173,27 +129,211 @@ 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):
+    try:
+        decoded, _ = der_decode(a11)
+        key_oid = decoded[0][0].asTuple()
+
+        if key_oid == OID_PBES2:
+            # AES Logic
+            algo = decoded[0][1][0]
+            pbkdf2_params = algo[1]
+            entry_salt = pbkdf2_params[0].asOctets()
+            iters = int(pbkdf2_params[1])
+            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)")
+
+            enc_pwd = sha1(global_salt + master_password.encode('utf-8')).digest()
+            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)}")
+
+            cipher = AES.new(k, AES.MODE_CBC, iv)
+            return PKCS7unpad(cipher.decrypt(decoded[1].asOctets()))
+
+        elif key_oid == OID_PKCS12_3DES:
+            # 3DES Logic
+            entry_salt = decoded[0][1][0].asOctets()
+            ciphertext = decoded[1].asOctets()
+
+            logging.debug("  > Method: PKCS12-3DES-Derivation")
+            logging.debug(f"  > Salt: {censor(entry_salt)} (Local) + {censor(global_salt)} (Global)")
+
+            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):
+    """
+    Verifies the master password against the metadata entry (item2).
+    Raises WrongPassword on failure.
+    """
+    try:
+        decodedItem2, _ = der_decode(item2)
+        try:
+            algorithm_oid = decodedItem2[0][0].asTuple()
+        except (IndexError, AttributeError):
+            raise ValueError("Could not decode password validation data structure.")
+
+        if algorithm_oid == OID_PKCS12_3DES:
+            entrySalt = decodedItem2[0][1][0].asOctets()
+            cipherT = decodedItem2[1].asOctets()
+            clearText = decrypt3DES(global_salt, pwd, entrySalt, cipherT)
+        elif algorithm_oid == OID_PBES2:
+            algo = decodedItem2[0][1][0]
+            pbkdf2_params = algo[1]
+            entry_salt = pbkdf2_params[0].asOctets()
+            iters = int(pbkdf2_params[1])
+            key_len = int(pbkdf2_params[2])
+            enc_pwd = sha1(global_salt + pwd.encode('utf-8')).digest()
+            k = pbkdf2_hmac('sha256', enc_pwd, entry_salt, iters, dklen=key_len)
+            iv = clean_iv(decodedItem2[0][1][1][1].asOctets())
+            cipher = AES.new(k, AES.MODE_CBC, iv)
+            clearText = cipher.decrypt(decodedItem2[1].asOctets())
+        else:
+            raise ValueError(f"Unknown encryption method OID: {algorithm_oid}")
+
+        if clearText != b"password-check\x02\x02":
+            raise WrongPassword()
+
+    except Exception as e:
+        logging.debug(f"Password check failed: {e}")
+        raise WrongPassword()
+
+
+def get_all_keys(directory, pwd=""):
+    db = Path(directory) / "key4.db"
+    if not db.exists():
+        raise NoDatabase()
+
+    conn = sqlite3.connect(str(db))
+    c = conn.cursor()
+
+    # 1. Get Global Salt
+    c.execute("SELECT item1, item2 FROM metadata WHERE id = 'password'")
+    try:
+        global_salt, item2 = next(c)
+    except StopIteration:
+        raise NoDatabase()
+
+    logging.info(f"[*] Global Salt: {censor(global_salt)}")
+
+    # 2. VERIFY PASSWORD EXPLICITLY
+    verify_password(global_salt, item2, pwd)
+    logging.info("[*] Password Verified Correctly")
+
+    # 3. Find ALL Keys
+    c.execute("SELECT a11, a102 FROM nssPrivate")
+    rows = c.fetchall()
+    logging.info(f"[*] Found {len(rows)} entries in nssPrivate")
+
+    # Check if rows exist BEFORE assuming corruption.
+    # If the table is empty, it's just an empty DB, not corruption.
+    if not rows:
+        raise NoDatabase()
+
+    found_keys = []
+    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)
+
+        if key:
+            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?)")
+
+    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.")
+
+    return found_keys, global_salt
+
+
+def try_decrypt_login(key, ciphertext, iv):
+    # Try AES
+    if len(key) in [16, 24, 32]:
+        try:
+            cipher = AES.new(key, AES.MODE_CBC, iv)
+            pt = cipher.decrypt(ciphertext)
+            res = PKCS7unpad(pt)
+            text = res.decode('utf-8')
+            if is_valid_text(text):
+                return text, "AES-Standard"
+        except Exception:
+            pass
+
+    # Try 3DES
+    if len(key) == 24:
+        try:
+            cipher = DES3.new(key, DES3.MODE_CBC, iv[:8])
+            pt = cipher.decrypt(ciphertext)
+            res = PKCS7unpad(pt)
+            text = res.decode('utf-8')
+            if is_valid_text(text):
+                return text, "3DES-Standard"
+        except Exception:
+            pass
+
+    return None, None
+
+
+def is_valid_text(text):
+    if not text or len(text) < 2:
+        return False
+    printable = set(string.printable)
+    if sum(1 for c in text if c in printable) / len(text) < 0.9:
+        return False
+    return True
+
+
 def decodeLoginData(key, data):
-    # first base64 decoding, then ASN1DERdecode
-    asn1data, _ = der_decode(b64decode(data))
-    assert asn1data[0].asOctets() == MAGIC1
-    assert asn1data[1][0].asTuple() == MAGIC2
-    iv = asn1data[1][1].asOctets()
-    ciphertext = asn1data[2].asOctets()
-    des = DES3.new(key, DES3.MODE_CBC, iv)
-    return PKCS7unpad(des.decrypt(ciphertext)).decode()
+    try:
+        asn1data, _ = der_decode(b64decode(data))
+        iv = clean_iv(asn1data[1][1].asOctets())
+        ciphertext = asn1data[2].asOctets()
+
+        text, method = try_decrypt_login(key, ciphertext, iv)
+        if text:
+            return text
+        raise ValueError("Decryption failed")
+    except Exception:
+        raise ValueError("Decryption failed")
 
 
 def encodeLoginData(key, data):
-    iv = secrets.token_bytes(8)
-    des = DES3.new(key, DES3.MODE_CBC, iv)
-    ciphertext = des.encrypt(PKCS7pad(data.encode()))
     asn1data = Sequence()
     asn1data[0] = OctetString(MAGIC1)
     asn1data[1] = Sequence()
-    asn1data[1][0] = ObjectIdentifier(MAGIC2)
-    asn1data[1][1] = OctetString(iv)
-    asn1data[2] = OctetString(ciphertext)
+
+    if len(key) == 32:  # AES-256
+        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)
+
+    elif len(key) == 24:  # 3DES
+        iv = secrets.token_bytes(8)
+        des = DES3.new(key, DES3.MODE_CBC, iv)
+        ciphertext = des.encrypt(PKCS7pad(data.encode(), block_size=8))
+
+        asn1data[1][0] = ObjectIdentifier(MAGIC2)
+        asn1data[1][1] = OctetString(iv)
+        asn1data[2] = OctetString(ciphertext)
+    else:
+        raise ValueError(f"Unknown key type/size: {len(key)}")
+
     return b64encode(der_encode(asn1data)).decode()
 
 
@@ -216,34 +356,70 @@ def exportLogins(key, jsonLogins):
     for row in jsonLogins["logins"]:
         if row.get("deleted"):
             continue
-        encUsername = row["encryptedUsername"]
-        encPassword = row["encryptedPassword"]
-        logins.append(
-            (
-                row["hostname"],
-                decodeLoginData(key, encUsername),
-                decodeLoginData(key, encPassword),
-            )
-        )
+        try:
+            user = decodeLoginData(key, row["encryptedUsername"])
+            pw = decodeLoginData(key, row["encryptedPassword"])
+            logins.append((row["hostname"], user, pw))
+        except Exception as e:
+            if logging.getLogger().isEnabledFor(logging.DEBUG):
+                logging.debug(f"Failed to decrypt {row.get('hostname')}: {e}")
+            continue
     return logins
 
 
-def lower_header(csv_file):
-    it = iter(csv_file)
-    yield next(it).lower()
-    yield from it
+def readCSV(csv_file):
 
+    reader = csv.reader(csv_file)
 
-def readCSV(csv_file):
     logins = []
-    reader = csv.DictReader(lower_header(csv_file))
-    for row in reader:
-        logins.append((rawURL(row["url"]), row["username"], row["password"]))
-    logging.info(f'read {len(logins)} logins')
+    first_row = None
+
+    # Loop through logins
+    for i, row in enumerate(reader):
+
+        logging.debug(f"row: {row}")
+
+        # Peek at the first line to detect if it is a header or normal row
+        if first_row is None:
+            logging.debug(f"first_row: {row}")
+            first_row = row
+
+            # Break if we get an empty first row
+            if (not row) or (len(row) != 3) or (not row[1] and not row[2]):
+                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 (
+                "http://" not in first_row[0]
+                and first_row[1].lower() in {"username", "uname", "user", "u"}  # noqa: W503 line break before binary operator
+                and first_row[2].lower() in {"password", "passwd", "pass", "p"}  # noqa: W503
+            ):
+                logging.debug(f"Continuing (skipping) over first row: [is_header={True}].")
+                continue
+
+            # ~~~ END peek at first row ~~~~~~~~~~
+
+        # Break if we get an empty row at any time
+        if (not row) or (len(row) != 3) or (not row[1] and not row[2]):
+            logging.debug(f"Breaking loop since we got an empty row at index={i}.")
+            break
+
+        u, n, p = row
+        logins.append((rawURL(u), n, p))
+
     return logins
 
 
 def rawURL(url):
+    if not url:
+        return ""
+
+    # Fix for schemeless URLs (e.g. "test.com" -> "https://test.com")
+    # Without a scheme, urlparse puts the whole string in 'path' and leaves 'netloc' empty.
+    # ffpass expects 'netloc' to be populated to strip paths.
+    if "://" not in url:
+        url = "https://" + url
+
     p = urlparse(url)
     return type(p)(*p[:2], *[""] * 4).geturl()
 
@@ -251,9 +427,9 @@ def rawURL(url):
 def addNewLogins(key, jsonLogins, logins):
     nextId = jsonLogins["nextId"]
     timestamp = int(datetime.now().timestamp() * 1000)
-    logging.info('adding logins')
+    logging.warning(f'adding {len(logins)} logins')
     for i, (url, username, password) in enumerate(logins, nextId):
-        logging.debug(f'adding {url} {username}')
+        logging.info(f'adding {url} {username}')
         entry = {
             "id": i,
             "hostname": url,
@@ -274,23 +450,30 @@ def addNewLogins(key, jsonLogins, logins):
     jsonLogins["nextId"] += len(logins)
 
 
-def guessDir():
-    dirs = {
-        "darwin": "~/Library/Application Support/Firefox/Profiles",
-        "linux": "~/.mozilla/firefox",
-        "win32": os.path.expandvars(r"%LOCALAPPDATA%\Mozilla\Firefox\Profiles"),
-        "cygwin": os.path.expandvars(r"%LOCALAPPDATA%\Mozilla\Firefox\Profiles"),
-    }
+# Constants used to guess cross-platform
+PROFILE_GUESS_DIRS = {
+    "darwin": "~/Library/Application Support/Firefox/Profiles",
+    "linux": "~/.mozilla/firefox",
+    "win32": os.path.expandvars("%APPDATA%\\Mozilla\\Firefox\\Profiles"),
+    "cygwin": os.path.expandvars("%APPDATA%\\Mozilla\\Firefox\\Profiles"),
+}
+
+
+def getProfiles() -> list[Path]:
+    paths = Path(PROFILE_GUESS_DIRS[sys.platform]).expanduser()
+    logging.debug(f"Paths: {paths}")
+    profiles = [path.parent for path in paths.glob(os.path.join("*", "logins.json"))]
+    logging.debug(f"Profiles: {profiles}")
+    return profiles
+
 
-    if sys.platform not in dirs:
+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("Please specify a profile to parse (-d path/to/profile)")
         raise NoProfile
 
-    paths = Path(dirs[sys.platform]).expanduser()
-    profiles = [path.parent for path in paths.glob(os.path.join("*", "logins.json"))]
-    logging.debug(f"Paths: {paths}")
-    logging.debug(f"Profiles: {profiles}")
+    profiles = getProfiles()
 
     if len(profiles) == 0:
         logging.error("Cannot find any Firefox profiles")
@@ -309,40 +492,52 @@ 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:
-            key = getKey(directory, password)
+            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:
-            password = getpass("Master Password:")
-        else:
-            break
-    return key
+            n += 1
+            if n >= n_max:
+                break
+            password = getpass("Master Password: ")
+
+    if n > 0:
+        logging.error(f"wrong master password after {n_max - 1} prompts!")
+    return None
 
 
 def main_export(args):
-    try:
-        key = askpass(args.directory)
-    except NoDatabase:
-        # if the database is empty, we are done!
+    # Removed try/except NoDatabase here to let it bubble to main() for proper logging
+    key = askpass(args.directory)
+
+    if not key:
+        logging.error("Failed to derive master key.")
         return
+
     jsonLogins = getJsonLogins(args.directory)
     logins = exportLogins(key, jsonLogins)
-    writer = csv.writer(args.file)
+    # 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"])
     writer.writerows(logins)
 
 
 def main_import(args):
-    if args.file == sys.stdin:
-        try:
-            key = getKey(args.directory)
-        except WrongPassword:
-            # it is not possible to read the password
-            # if stdin is used for input
-            logging.error("Password is not empty. You have to specify FROM_FILE.")
-            sys.exit(1)
-    else:
-        key = askpass(args.directory)
+    # askpass handles stdin/tty detection for the password prompt automatically
+    key = askpass(args.directory)
+
+    if not key:
+        logging.error("Failed to derive master key.")
+        return
+
     jsonLogins = getJsonLogins(args.directory)
     logins = readCSV(args.file)
     addNewLogins(key, jsonLogins, logins)
@@ -367,65 +562,79 @@ def makeParser():
     )
 
     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():
-        sub.add_argument(
+        arg = sub.add_argument(
+            "-p",  # matches native: firefox -p
             "-d",
             "--directory",
             "--dir",
             type=Path,
+            metavar="DIRECTORY",
             default=None,
             help="Firefox profile directory",
         )
+        # Use argcomplete completer instead of 'choices='
+        # This allows arbitrary paths (tests) but gives users tab completion.
+        arg.completer = lambda **kwargs: [str(p) for p in getProfiles()]
+
         sub.add_argument("-v", "--verbose", action="store_true")
         sub.add_argument("--debug", action="store_true")
 
     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 ModuleNotFoundError:
+        sys.stderr(
+            "NOTE: You can run 'pip install argcomplete' "
+            "and add the hook to your shell RC for tab completion."
+        )
+
     return parser
 
 
 def main():
-    logging.basicConfig(level=logging.ERROR, format="%(levelname)s: %(message)s")
-
     parser = makeParser()
     args = parser.parse_args()
 
-    if args.verbose:
-        log_level = logging.INFO
-    elif args.debug:
+    # Determine log level
+    log_level = logging.WARNING
+    if args.debug:
         log_level = logging.DEBUG
-    else:
-        log_level = logging.ERROR
+    elif args.verbose:
+        log_level = logging.INFO
 
-    logging.getLogger().setLevel(log_level)
+    logging.basicConfig(level=log_level, format="%(message)s")
 
+    # Try to obtain profile directory
     if args.directory is None:
         try:
-            args.directory = guessDir()
+            args.directory = guessDir().expanduser()
         except NoProfile:
-            print("")
+            logging.error("No Firefox profile selected.")
             parser.print_help()
             parser.exit()
-    args.directory = args.directory.expanduser()
 
+    # Run arg parser
     try:
-        args.func(args)
+        # Wrap in try/except for BrokenPipeError to allow piping to head, i.e., ffpass export | head -5
+        try:
+            args.func(args)
+        except BrokenPipeError:
+            sys.stdout = os.fdopen(1, 'w')
+
     except NoDatabase:
-        logging.error("Firefox password database is empty. Please create it from Firefox.")
+        logging.error("Firefox password database is empty.")
 
 
 if __name__ == "__main__":
diff --git a/requirements-dev.txt b/requirements-dev.txt
new file mode 100644 (file)
index 0000000..ded13e8
--- /dev/null
@@ -0,0 +1,4 @@
+coverage==7.13.0
+flake8==7.3.0
+pytest==9.0.2
+
diff --git a/requirements.txt b/requirements.txt
new file mode 100644 (file)
index 0000000..9a633f2
--- /dev/null
@@ -0,0 +1,4 @@
+argcomplete>=3.5.2
+pyasn1~=0.6.1
+pycryptodome~=3.23.0
+
diff --git a/scripts/generate_mixed_profile.py b/scripts/generate_mixed_profile.py
new file mode 100755 (executable)
index 0000000..7a9a983
--- /dev/null
@@ -0,0 +1,74 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+Created on Thu Dec 25 20:34:59 2025
+
+@author: shane
+"""
+
+import json
+import sqlite3
+from pathlib import Path
+
+# Constants for Firefox Crypto
+MAGIC1 = b"\xf8\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01"
+
+
+def create_mixed_profile():
+    base_dir = Path("tests/firefox-mixed-keys")
+    if base_dir.exists():
+        print(f"Directory {base_dir} already exists. Skipping generation.")
+        return
+    base_dir.mkdir(parents=True)
+
+    print(f"Generating mixed key profile in {base_dir}...")
+
+    # 1. Create key4.db with TWO keys
+    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)")
+
+    # Metadata: Simple password check (Salt + Check Blob)
+    # This is a dummy check that our mock/test logic accepts
+    c.execute(
+        "INSERT INTO metadata VALUES ('password', ?, ?)",
+        (b"global_salt", b"pw_check_blob"),
+    )
+
+    # nssPrivate: Insert the MIXED keys
+    # Key 0: Legacy 24-byte key blob (We simulate this decrypting to 24 bytes)
+    # In a real DB, this is ASN.1 wrapped. For our integration test,
+    # we rely on the mocked decryptor in the test to interpret this specific blob.
+    c.execute("INSERT INTO nssPrivate VALUES (?, ?)", (b"blob_legacy_24", MAGIC1))
+
+    # Key 1: Modern 32-byte key blob
+    c.execute("INSERT INTO nssPrivate VALUES (?, ?)", (b"blob_modern_32", MAGIC1))
+
+    conn.commit()
+    conn.close()
+
+    # 2. Create logins.json encrypted with the MODERN key
+    # Our test infrastructure mocks the decryption, so we can use dummy base64 strings
+    # The crucial part is that the test asserts it extracts data using the modern key logic
+    logins_data = {
+        "nextId": 2,
+        "logins": [
+            {
+                "id": 1,
+                "hostname": "http://www.mixedkeys.com",
+                "encryptedUsername": "QUFBQUFBQUE=",  # Base64 for 'AAAAAAAA'
+                "encryptedPassword": "QUFBQUFBQUE=",
+                "deleted": False,
+            }
+        ],
+    }
+
+    with open(base_dir / "logins.json", "w") as f:
+        json.dump(logins_data, f)
+
+    print("Done.")
+
+
+if __name__ == "__main__":
+    create_mixed_profile()
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()
index cb58157ec5e3c538f1341ea2348ac8041e806371..57fb9a8f50435265864ca70d07f8a71c2337fe9a 100755 (executable)
--- a/setup.py
+++ b/setup.py
@@ -5,12 +5,12 @@ from setuptools import setup
 
 
 def read(fname):
-    return open(os.path.join(os.path.dirname(__file__), fname)).read()
+    return open(os.path.join(os.path.dirname(__file__), fname), encoding="utf-8").read()
 
 
 setup(
     name="ffpass",
-    version="0.5.0",
+    version="0.6.0",
     author="Louis Abraham",
     license="MIT",
     author_email="louis.abraham@yahoo.fr",
diff --git a/tests/firefox-146-aes/key4.db b/tests/firefox-146-aes/key4.db
new file mode 100644 (file)
index 0000000..935e417
Binary files /dev/null and b/tests/firefox-146-aes/key4.db differ
diff --git a/tests/firefox-146-aes/logins.json b/tests/firefox-146-aes/logins.json
new file mode 100644 (file)
index 0000000..28ee0d2
--- /dev/null
@@ -0,0 +1 @@
+{"nextId":2,"logins":[{"id":1,"hostname":"http://www.stealmylogin.com","httpRealm":null,"formSubmitURL":"","usernameField":"","passwordField":"","encryptedUsername":"MEMEEPgAAAAAAAAAAAAAAAAAAAEwHQYJYIZIAWUDBAEqBBCNW6CWkegCZc+s8lP/Vl5PBBDWyrV026klbUVJLhE4r8+p","encryptedPassword":"MEMEEPgAAAAAAAAAAAAAAAAAAAEwHQYJYIZIAWUDBAEqBBBTE1NvcNTjoXhqsv3V0OMlBBBs1jTmrS3qup3KR/MAyxjl","guid":"{207341aa-f648-40dc-ad20-36e9bd9ab40b}","encType":1,"timeCreated":1766692909450,"timeLastUsed":1766692909450,"timePasswordChanged":1766692909450,"timesUsed":1,"syncCounter":1,"everSynced":false,"encryptedUnknownFields":"MEMEEPgAAAAAAAAAAAAAAAAAAAEwHQYJYIZIAWUDBAEqBBCsxHtLjm13zN4xOGY2IC2JBBDhG8zk6rYnDx6csYr0YxLU"}],"potentiallyVulnerablePasswords":[],"dismissedBreachAlertsByLoginGUID":{},"version":3}
\ No newline at end of file
diff --git a/tests/firefox-mixed-keys/key4.db b/tests/firefox-mixed-keys/key4.db
new file mode 100644 (file)
index 0000000..74a40c6
Binary files /dev/null and b/tests/firefox-mixed-keys/key4.db differ
diff --git a/tests/firefox-mixed-keys/logins.json b/tests/firefox-mixed-keys/logins.json
new file mode 100644 (file)
index 0000000..b45b714
--- /dev/null
@@ -0,0 +1 @@
+{"nextId": 2, "logins": [{"id": 1, "hostname": "http://www.mixedkeys.com", "encryptedUsername": "QUFBQUFBQUE=", "encryptedPassword": "QUFBQUFBQUE=", "deleted": false}]}
\ No newline at end of file
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 db102e3f32bb9200f65e683aab5a90ea7ff32934..edffedb261f533f5e183d4c213b1d35f3a143410 100644 (file)
 import ffpass
 from pathlib import Path
 import pytest
+import sqlite3
+import shutil
 
+# This key corresponds to the static 'tests/firefox-84' profile in your repo
 TEST_KEY = b'\xbfh\x13\x1a\xda\xb5\x9d\xe3X\x10\xe0\xa8\x8a\xc2\xe5\xbcE\xf2I\r\xa2pm\xf4'
 MASTER_PASSWORD = 'test'
+MAGIC1 = b"\xf8\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01"
+
+
+def _get_key(directory, password=""):
+    """
+    Helper to adapt the new get_all_keys API to legacy test expectations.
+    """
+    keys, _ = ffpass.get_all_keys(directory, password)
+    # Simulate askpass logic: prefer 32-byte key, else first found
+    best = next((k for k in keys if len(k) == 32), keys[0])
+    return best
+
+
+@pytest.fixture
+def mixed_key_profile(tmp_path):
+    """
+    Creates a temporary profile based on firefox-84, but duplicates
+    the key entry in key4.db to simulate a "Key Rotation" scenario
+    (multiple keys present).
+    """
+    # 1. Base the profile on an existing valid one
+    src = Path('tests/firefox-84')
+    dst = tmp_path / "firefox-mixed"
+    shutil.copytree(src, dst)
+
+    # 2. Open the DB
+    db_path = dst / "key4.db"
+    conn = sqlite3.connect(str(db_path))
+    c = conn.cursor()
+
+    # 3. Fetch the existing key row
+    c.execute("SELECT * FROM nssPrivate WHERE a102 = ?", (MAGIC1,))
+    row = c.fetchone()
+
+    # Get column names to find the 'id' column index
+    col_names = [d[0] for d in c.description]
+
+    if row:
+        row_list = list(row)
+
+        # 4. Modify the UNIQUE 'id' column to avoid IntegrityError
+        if 'id' in col_names:
+            id_idx = col_names.index('id')
+            original_id = row_list[id_idx]
+
+            # Increment ID if integer, or change if bytes
+            if isinstance(original_id, int):
+                row_list[id_idx] = original_id + 100  # Ensure uniqueness
+            else:
+                # Fallback for blobs/bytes
+                row_list[id_idx] = b'\xff' * len(original_id)
+
+        # 5. Insert the duplicate row
+        placeholders = ",".join(["?"] * len(row_list))
+        c.execute(f"INSERT INTO nssPrivate VALUES ({placeholders})", row_list)
+        conn.commit()
+
+    conn.close()
+    return dst
 
 
 def test_firefox_key():
-    key = ffpass.getKey(Path('tests/firefox-84'))
+    key = _get_key(Path('tests/firefox-84'))
     assert key == TEST_KEY
 
 
 def test_firefox_mp_key():
-    key = ffpass.getKey(Path('tests/firefox-mp-84'), MASTER_PASSWORD)
+    key = _get_key(Path('tests/firefox-mp-84'), MASTER_PASSWORD)
     assert key == TEST_KEY
 
 
 def test_firefox_wrong_masterpassword_key():
     with pytest.raises(ffpass.WrongPassword):
-        ffpass.getKey(Path('tests/firefox-mp-84'), 'wrongpassword')
+        _get_key(Path('tests/firefox-mp-84'), 'wrongpassword')
 
 
 def test_legacy_firefox_key():
-    key = ffpass.getKey(Path('tests/firefox-70'))
+    key = _get_key(Path('tests/firefox-70'))
     assert key == TEST_KEY
 
 
 def test_legacy_firefox_mp_key():
-    key = ffpass.getKey(Path('tests/firefox-mp-70'), MASTER_PASSWORD)
+    key = _get_key(Path('tests/firefox-mp-70'), MASTER_PASSWORD)
     assert key == TEST_KEY
 
 
 def test_legacy_firefox_wrong_masterpassword_key():
     with pytest.raises(ffpass.WrongPassword):
-        ffpass.getKey(Path('tests/firefox-mp-70'), 'wrongpassword')
+        _get_key(Path('tests/firefox-mp-70'), 'wrongpassword')
+
+
+def test_mixed_key_retrieval(mixed_key_profile):
+    """
+    Verifies that get_all_keys() finds multiple keys in the DB.
+    """
+    keys, _ = ffpass.get_all_keys(mixed_key_profile)
+
+    # Since we manually duplicated the key row in the fixture,
+    # we expect exactly 2 keys to be decrypted.
+    assert len(keys) == 2
+
+    # Both keys should be valid (and identical in this specific test case)
+    assert keys[0] == TEST_KEY
+    assert keys[1] == TEST_KEY
diff --git a/tests/test_mixed_keys_run.py b/tests/test_mixed_keys_run.py
new file mode 100644 (file)
index 0000000..e77ed02
--- /dev/null
@@ -0,0 +1,102 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+Created on Fri Dec 25 19:30:48 2025
+
+@author: shane
+"""
+
+import os
+import shutil
+import sys
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+OS_NEWLINE = os.linesep
+HEADER = "url,username,password"
+EXPECTED_MIXED_OUTPUT = [HEADER, "http://www.mixedkeys.com,modern_user,modern_pass"]
+
+
+@pytest.fixture
+def clean_profile(tmp_path):
+    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
+
+    test_args = ["ffpass", mode, "-d", str(path)]
+
+    # We patch get_all_keys directly to avoid the infinite loop issue
+    # and avoid needing complex ASN.1 mocking for the password check.
+    with (
+        patch("sys.argv", test_args),
+        patch("ffpass.get_all_keys") as mock_get_keys,
+        patch("ffpass.try_decrypt_login") as mock_decrypt_login,
+    ):
+
+        # 1. Mock Key Return
+        # Simulate finding two keys: Legacy (24 bytes) and Modern (32 bytes)
+        mock_get_keys.return_value = ([b"L" * 24, b"M" * 32], b"salt")
+
+        # 2. Mock Golden Key Check
+        # Verify the tool checks if the key works on the first row
+        def try_login_side_effect(key, ct, iv):
+            if key == b"M" * 32:
+                return "valid_utf8", "AES-Standard"
+            return None, None
+
+        mock_decrypt_login.side_effect = try_login_side_effect
+
+        # 3. Mock Final Decryption
+        with patch("ffpass.decodeLoginData") as mock_decode:
+            # Use iterator to return user then pass
+            return_values = iter(["modern_user", "modern_pass"])
+
+            def decode_side_effect(key, data):
+                if len(key) == 32:
+                    try:
+                        return next(return_values)
+                    except StopIteration:
+                        return "extra"
+                raise ValueError("Wrong Key")
+
+            mock_decode.side_effect = decode_side_effect
+
+            # Capture stdout
+            from io import StringIO
+
+            captured_output = StringIO()
+            sys.stdout = captured_output
+
+            try:
+                main()
+            except SystemExit:
+                pass
+            finally:
+                sys.stdout = sys.__stdout__
+
+            return captured_output.getvalue()
+
+
+def stdout_splitter(input_text):
+    return [x for x in input_text.splitlines() if x != ""]
+
+
+def test_mixed_key_rotation_export(clean_profile):
+    profile_path = clean_profile("firefox-mixed-keys")
+    output = run_ffpass_internal("export", profile_path)
+    actual = stdout_splitter(output)
+    assert actual == EXPECTED_MIXED_OUTPUT
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
index a244a833f53d596450307b7f221f5350ef9b07c0..b6aea287b197f0c1fc342e6c0aa4c50a4d16013e 100644 (file)
@@ -1,49 +1,88 @@
 #!/usr/bin/env python3
 
+import os
 import subprocess
+import shutil
+import pytest
+from pathlib import Path
+
+OS_NEWLINE = os.linesep
 
 MASTER_PASSWORD = 'test'
-HEADER = 'url,username,password\n'
-IMPORT_CREDENTIAL = 'http://www.example.com,foo,bar\n'
-EXPECTED_EXPORT_OUTPUT = f'{HEADER}http://www.stealmylogin.com,test,test\n'
-EXPECTED_IMPORT_OUTPUT = EXPECTED_EXPORT_OUTPUT + IMPORT_CREDENTIAL
+HEADER = 'url,username,password'
+IMPORT_CREDENTIAL = 'http://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(mode, path):
-    command = ["ffpass", mode, "-d", path]
+    command = ["python", "./ffpass/__init__.py", mode, "-d", str(path)]
+
     if mode == 'import':
-        ffpass_input = HEADER + IMPORT_CREDENTIAL
+        ffpass_input = OS_NEWLINE.join([HEADER, IMPORT_CREDENTIAL])
     else:
         ffpass_input = None
 
     return subprocess.run(command, stdout=subprocess.PIPE, input=ffpass_input, encoding='utf-8')
 
 
-def test_legacy_firefox_export():
-    r = run_ffpass('export', 'tests/firefox-70')
+def stdout_splitter(input_text):
+    return [x for x in input_text.splitlines()]
+
+
+def test_legacy_firefox_export(clean_profile):
+    r = run_ffpass('export', clean_profile('firefox-70'))
     r.check_returncode()
-    assert r.stdout == EXPECTED_EXPORT_OUTPUT
+    actual_export_output = stdout_splitter(r.stdout)
+    assert actual_export_output == EXPECTED_EXPORT_OUTPUT
 
 
-def test_firefox_export():
-    r = run_ffpass('export', 'tests/firefox-84')
+def test_firefox_export(clean_profile):
+    r = run_ffpass('export', clean_profile('firefox-84'))
     r.check_returncode()
-    assert r.stdout == EXPECTED_EXPORT_OUTPUT
+    assert stdout_splitter(r.stdout) == EXPECTED_EXPORT_OUTPUT
 
 
-def test_legacy_firefox():
-    r = run_ffpass('import', 'tests/firefox-70')
+def test_firefox_aes_export(clean_profile):
+    # This uses your new AES-encrypted profile
+    profile_path = clean_profile('firefox-146-aes')
+    r = run_ffpass('export', profile_path)
     r.check_returncode()
+    assert stdout_splitter(r.stdout) == EXPECTED_EXPORT_OUTPUT
 
-    r = run_ffpass('export', 'tests/firefox-70')
+
+def test_legacy_firefox(clean_profile):
+    profile_path = clean_profile('firefox-70')
+
+    # modifies the temp file, not the original
+    r = run_ffpass('import', profile_path)
     r.check_returncode()
-    assert r.stdout == EXPECTED_IMPORT_OUTPUT
 
+    r = run_ffpass('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')
 
-def test_firefox():
-    r = run_ffpass('import', 'tests/firefox-84')
+    r = run_ffpass('import', profile_path)
     r.check_returncode()
 
-    r = run_ffpass('export', 'tests/firefox-84')
+    r = run_ffpass('export', profile_path)
     r.check_returncode()
-    assert r.stdout == EXPECTED_IMPORT_OUTPUT
+    assert stdout_splitter(r.stdout) == EXPECTED_IMPORT_OUTPUT