From d8bc6edcfb06235142dc1f25d922d00b7dab9520 Mon Sep 17 00:00:00 2001 From: Shane Jaroch Date: Thu, 25 Dec 2025 14:27:03 -0500 Subject: [PATCH] v0.5.1: Import & export for AES. Config files. 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 | 4 + Makefile | 22 ++++- ffpass/__init__.py | 138 ++++++++++++++++++++++++------ requirements-dev.txt | 4 + requirements.txt | 3 + setup.py | 2 +- tests/firefox-146-aes/key4.db | Bin 0 -> 294912 bytes tests/firefox-146-aes/logins.json | 1 + tests/test_run.py | 54 +++++++++--- 9 files changed, 188 insertions(+), 40 deletions(-) create mode 100644 .envrc create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 tests/firefox-146-aes/key4.db create mode 100644 tests/firefox-146-aes/logins.json diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..488515d --- /dev/null +++ b/.envrc @@ -0,0 +1,4 @@ +source .venv/bin/activate +unset PS1 +eval "$(register-python-argcomplete ffpass)" + diff --git a/Makefile b/Makefile index cf7baac..b2ddd00 100644 --- 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 diff --git a/ffpass/__init__.py b/ffpass/__init__.py index 56262cb..4f4cb00 100644 --- a/ffpass/__init__.py +++ b/ffpass/__init__.py @@ -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 index 0000000..ded13e8 --- /dev/null +++ b/requirements-dev.txt @@ -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 index 0000000..5f86850 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +pyasn1==0.6.1 +pycryptodome==3.23.0 + diff --git a/setup.py b/setup.py index cb58157..211d265 100755 --- 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 index 0000000000000000000000000000000000000000..935e417f99389fdaceb2479c489c6e32ffbeb7d2 GIT binary patch literal 294912 zcmeI*Ym8h~9RTpTyVGfBVOK2Gilj1?5ZFrK-aE6miytgYAM|BQYl-FMGVN~3+Ll6h zfi5UZkyZkP_#)ASm^6ljhfxv42p=Hu!3dI=XncOc5Tgb$C`cPXz31#~mRco#F-H44 zxpU`#&*Oj3`JJ=d{nn3dT0b^1+SsvY@9y?QqglD7k|dS$8;we(QY}CA@-tIvWf1CS zN-`6?!*{iE&K(PSrw&){0;uDNp;E?SiA|IkEx+pf{wqZ92F?TPlx_q^qsR}O7i z+1N64!TObrncBu#VsDUUxOl^wO&70hY~0XTzH!5<^=p=IX>4A3 z(fXm~E6;9}|HJX_Z2BwjKX+bj;qpaEW$fya(S76B?kXFH+t*F(iO<6``G>QasD44M zws7I%~P|%<=J< zV^@3I=&so=wWD&x#$$3c`&U(K3r}5~3{Ow{x^15v-99m!W6n`I#;2Q)$&#g6|MKqI z!c%5eGd4bc-RR!gG}WWhOt&7BWiac%vnQFmaPi{gp5eG1yZJVwCcQ$V5 zf{mNEoZV=r<)_@Yc3ys(<);;%V`Zq#LiJ#H91M?x@iBB84E+WN!mS9mrQtRhZs&zt zJKVN~+xGHyUd)$H-(%V|B+Al|DNCnc8FFQ5NS37`Tb71&SsLg<~(N3W9DWY+l*tIacnb=ZN{-hY%OAI5nGGcT1>ab z%tg$+H1=E?doGPVBU={T^0@Xq4Xt@xdmiWCO3RXGp}G@>k)1HSD9=OMJT5KILt`G7 zmdB;#acOy6S{|2{=cBRDj(87fc^=U6JfP+INVpHgoC9$(10iQKPOllK*NoFEr%=|n zVoj@46Xw&3o7GCg!dr2(T5*}JxXe~O9|LjhKpZ;|#}34eE@Dj)Yl==yU@T&`B4#V% z))#T>i@5bg-1;JJeG#|5NW&_MG_15p!%B;YSwzPo9@!!b8(m~!ql+wTbdd#(A`2Qt z7Bq@1=E-87Ear*YMbs{$b`iCUs9i+uB5D^=yNE}qh{vhOWB#aJMC~GK7jZ|5Jm!yw zt%!%Mh=;9+hpmW*t!M_-q8W};(TqP(;drExZTsjzv(=1y)(OMLb;7VSoiKoQ!jP>K zhLoK!xf9L~iwC;izcK66^Ke?@gcU*G&n<<>3#@R|jc zv-@JH^i?YJ%N|FUa#UYmX!`G$Work|FK1CrlKU!`ruz@2yKD6&2Uj0_y*HWDn_ig? z%|4!(wyKR4ufI`y;p9tBsV}+xx!3M|a9;fnKiPWIx-Y)#!5E;jqvi1Af8Ec|-isW&U-r4S%MfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+0D5E$mfbqPGCp?I@Xpc6;pUE3yE)iO zhtpWHEZu)gdU>tB&EFhYe#MW@eB(e@l5{1Fw5K+2I!jmm?Qyly_1Cp`4=#A|;zRS!yJzlK z()6)C>z+ID?$aN7;w;_A#rIA5Qo0Pj}DGt2e#!m}%a-;j$ZR&pvs