]> Nutra Git (v1) - gamesguru/ffpass.git/commitdiff
v0.5.1: Import & export for AES. Config files.
authorShane Jaroch <chown_tee@proton.me>
Thu, 25 Dec 2025 19:27:03 +0000 (14:27 -0500)
committerShane Jaroch <chown_tee@proton.me>
Thu, 25 Dec 2025 22:08:02 +0000 (17:08 -0500)
    tests: Avoid modifying .json files, use temp storage.

    tests: Add test for AES export.

    Add import logic for AES.

    [wip] tests: Add tests for import logic (AES & 3DES).

    Add argcomplete (optional end-user add-on).

.envrc [new file with mode: 0644]
Makefile
ffpass/__init__.py
requirements-dev.txt [new file with mode: 0644]
requirements.txt [new file with mode: 0644]
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/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 cf7baaca4b0c77e9b2ee6c2e8bdfd2d36ddf3541..b2ddd00164b8df9163e47c7937fcd792c140b2c0 100644 (file)
--- a/Makefile
+++ b/Makefile
@@ -1,13 +1,29 @@
+.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' --count --select=E901,E999,F821,F822,F823 --show-source --statistics
+       flake8 . --exclude '*venv' --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
+
+
+.PHONY: install
+install:
+       pip install .
+
+.PHONY: test
+test:
+       @echo 'Remember to run make install to test against the latest :)'
+       coverage run -m pytest -s tests/
+       coverage report -m --omit="tests/*"
+
 
+.PHONY: clean
 clean:
        rm -rf *.egg-info build dist
index 56262cbe8b268fcbfd9e4b767ed7c85671f91f75..4f4cb00827b48f93515e9c354612ae2348793f25 100644 (file)
@@ -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
@@ -54,8 +55,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):
@@ -86,18 +93,27 @@ def getKey(directory: Path, masterPassword=""):
     row = next(c)
     globalSalt, item2 = row
 
+    # 1. Unpack item2 so it is an Object, not a Tuple
+    decodedItem2, _ = der_decode(item2)
+
     try:
-        decodedItem2, _ = der_decode(item2)
+        # Structure: Sequence[0] (AlgoID) -> [0] (OID)
+        algorithm_oid = decodedItem2[0][0].asTuple()
+    except (IndexError, AttributeError):
+        raise ValueError("Could not decode password validation data structure.")
+
+    if algorithm_oid == OID_PKCS12_3DES:
         encryption_method = '3DES'
         entrySalt = decodedItem2[0][1][0].asOctets()
         cipherT = decodedItem2[1].asOctets()
         clearText = decrypt3DES(
             globalSalt, masterPassword, entrySalt, cipherT
-        )  # usual Mozilla PBE
-    except AttributeError:
+        )
+    elif algorithm_oid == OID_PBES2:
         encryption_method = 'AES'
-        decodedItem2 = der_decode(item2)
         clearText = decrypt_aes(decodedItem2, masterPassword, globalSalt)
+    else:
+        raise ValueError(f"Unknown encryption method OID: {algorithm_oid}")
 
     if clearText != b"password-check\x02\x02":
         raise WrongPassword()
@@ -118,24 +134,30 @@ def getKey(directory: Path, masterPassword=""):
             "The Firefox database appears to be broken. Try to add a password to rebuild it."
         )  # CKA_ID
 
+    # Determine encryption method for the key itself
     if encryption_method == 'AES':
-        decodedA11 = der_decode(a11)
+        # 2. Unpack a11 so it is also an Object (Consistency Fix)
+        decodedA11, _ = der_decode(a11)
         key = decrypt_aes(decodedA11, masterPassword, globalSalt)
+        key = PKCS7unpad(key)
     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}"
+        assert oid == OID_PKCS12_3DES, f"The key is encoded with an unknown format {oid}"
         entrySalt = decodedA11[0][1][0].asOctets()
+        # FIX: Ciphertext is at index [1] of the Sequence, NOT inside parameters [0][1]
         cipherT = decodedA11[1].asOctets()
         key = decrypt3DES(globalSalt, masterPassword, entrySalt, cipherT)
+        key = PKCS7unpad(key)
+    # else: (impossible, handled above)
 
     logging.info("{}: {}".format(encryption_method, key.hex()))
-    return key[:24]
+    return key
 
 
-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):
@@ -143,9 +165,18 @@ def PKCS7unpad(b):
 
 
 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])
+    # Expects decoded_item as an ASN.1 OBJECT (Sequence), NOT a tuple.
+    # Structure:
+    #   [0] AlgorithmIdentifier (Metadata)
+    #   [1] EncryptedData (Ciphertext)
+
+    # 1. Get PBKDF2 Parameters from Metadata [0]
+    # Path: AlgoID[0] -> Params[1] -> KeyDerivFunc[0] -> PBKDF2Params[1]
+    pbkdf2_params = decoded_item[0][1][0][1]
+
+    entry_salt = pbkdf2_params[0].asOctets()
+    iteration_count = int(pbkdf2_params[1])
+    key_length = int(pbkdf2_params[2])
     assert key_length == 32
 
     encoded_password = sha1(global_salt + master_password.encode('utf-8')).digest()
@@ -153,8 +184,26 @@ def decrypt_aes(decoded_item, master_password, global_salt):
         '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()
+    # 2. Get IV from Metadata [0]
+    # AlgoID[0] -> Params[1] -> EncryptionScheme[1] -> IV[1]
+    iv_obj = decoded_item[0][1][1][1]
+    init_vector = iv_obj.asOctets()
+
+    # IF 14 bytes, THEN assume it's the raw payload of an ASN.1 OctetString,
+    #   and add missing the header (0x04 0x0E) to make a full 16-byte block.
+    if len(init_vector) == 14:
+        init_vector = b'\x04\x0e' + init_vector
+    # IF 18 bytes (Standard ASN.1 OctetString: Tag 0x04 + Len 0x10 + 16 bytes), THEN strip header.
+    elif len(init_vector) == 18 and init_vector.startswith(b'\x04\x10'):
+        init_vector = init_vector[2:]
+
+    # Final check
+    if len(init_vector) != 16:
+        raise ValueError(f"Incorrect IV length: {len(init_vector)} bytes (expected 16).")
+
+    # 3. Get Ciphertext from Data [1]
+    encrypted_value = decoded_item[1].asOctets()
+
     cipher = AES.new(key, AES.MODE_CBC, init_vector)
     return cipher.decrypt(encrypted_value)
 
@@ -177,23 +226,55 @@ 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
+
+    algo_oid = asn1data[1][0].asTuple()
     iv = asn1data[1][1].asOctets()
     ciphertext = asn1data[2].asOctets()
-    des = DES3.new(key, DES3.MODE_CBC, iv)
-    return PKCS7unpad(des.decrypt(ciphertext)).decode()
+
+    # Handle encryption types
+    if algo_oid == MAGIC2:
+        # 3DES logic (ensure key is 24 bytes)
+        des = DES3.new(key[:24], DES3.MODE_CBC, iv)
+        return PKCS7unpad(des.decrypt(ciphertext)).decode()
+
+    elif algo_oid == MAGIC_AES:
+        # AES logic (use full key, all 32 bytes)
+        cipher = AES.new(key, AES.MODE_CBC, iv)
+        return PKCS7unpad(cipher.decrypt(ciphertext)).decode()
+
+    else:
+        raise ValueError(f"Unknown encryption OID: {algo_oid}")
 
 
 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)} bytes. "
+            "Known types: [3DES: 24 bytes], [AES-256: 32 bytes]."
+        )
+
     return b64encode(der_encode(asn1data)).decode()
 
 
@@ -395,6 +476,13 @@ def makeParser():
 
     parser_import.set_defaults(func=main_import)
     parser_export.set_defaults(func=main_export)
+
+    try:
+        import argcomplete
+        argcomplete.autocomplete(parser)
+    except ModuleNotFoundError:
+        pass
+
     return parser
 
 
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..5f86850
--- /dev/null
@@ -0,0 +1,3 @@
+pyasn1==0.6.1
+pycryptodome==3.23.0
+
index cb58157ec5e3c538f1341ea2348ac8041e806371..211d2658be0e6c7c4687caec804aea4c939ae310 100755 (executable)
--- a/setup.py
+++ b/setup.py
@@ -10,7 +10,7 @@ def read(fname):
 
 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
index a244a833f53d596450307b7f221f5350ef9b07c0..ee6d5eec5a9582c328bdeb66b5c8123445af6f96 100644 (file)
@@ -1,6 +1,9 @@
 #!/usr/bin/env python3
 
 import subprocess
+import shutil
+import pytest
+from pathlib import Path
 
 MASTER_PASSWORD = 'test'
 HEADER = 'url,username,password\n'
@@ -9,8 +12,23 @@ EXPECTED_EXPORT_OUTPUT = f'{HEADER}http://www.stealmylogin.com,test,test\n'
 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 = ["ffpass", mode, "-d", str(path)]
+
     if mode == 'import':
         ffpass_input = HEADER + IMPORT_CREDENTIAL
     else:
@@ -19,31 +37,45 @@ def run_ffpass(mode, path):
     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 test_legacy_firefox_export(clean_profile):
+    r = run_ffpass('export', clean_profile('firefox-70'))
+    r.check_returncode()
+    assert r.stdout == EXPECTED_EXPORT_OUTPUT
+
+
+def test_firefox_export(clean_profile):
+    r = run_ffpass('export', clean_profile('firefox-84'))
     r.check_returncode()
     assert r.stdout == EXPECTED_EXPORT_OUTPUT
 
 
-def test_firefox_export():
-    r = run_ffpass('export', 'tests/firefox-84')
+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 r.stdout == EXPECTED_EXPORT_OUTPUT
 
 
-def test_legacy_firefox():
-    r = run_ffpass('import', '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()
 
-    r = run_ffpass('export', 'tests/firefox-70')
+    r = run_ffpass('export', profile_path)
     r.check_returncode()
     assert r.stdout == EXPECTED_IMPORT_OUTPUT
 
 
-def test_firefox():
-    r = run_ffpass('import', 'tests/firefox-84')
+def test_firefox(clean_profile):
+    profile_path = clean_profile('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
+