From: Shane Jaroch Date: Fri, 26 Dec 2025 02:11:10 +0000 (-0500) Subject: more tidying up, linting, and formatting X-Git-Url: https://git.nutra.tk/v2?a=commitdiff_plain;h=b83beade63e5843286ce6fd257d3cc85fe80de9d;p=gamesguru%2Fffpass.git more tidying up, linting, and formatting --- diff --git a/Makefile b/Makefile index 18d587a..75736b1 100644 --- a/Makefile +++ b/Makefile @@ -9,8 +9,8 @@ dist: flake8 .PHONY: flake8 flake8: - 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 + 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 .PHONY: install diff --git a/ffpass/__init__.py b/ffpass/__init__.py index ee9764d..7a10ff7 100644 --- a/ffpass/__init__.py +++ b/ffpass/__init__.py @@ -63,11 +63,13 @@ def censor(data): """ Censors the middle third of a hex string or bytes object. """ - if not data: return "None" + if not data: + return None s = data.hex() if isinstance(data, (bytes, bytearray)) else str(data) length = len(s) - if length <= 12: return s + if length <= 12: + return s third = length // 3 return f"{s[:third]}.....{s[2*third:]}" @@ -136,7 +138,7 @@ def decrypt_key_entry(a11, global_salt, master_password): entry_salt = decoded[0][1][0].asOctets() ciphertext = decoded[1].asOctets() - logging.debug(f" > Method: PKCS12-3DES-Derivation") + 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)) @@ -148,7 +150,8 @@ def decrypt_key_entry(a11, global_salt, master_password): def get_all_keys(directory, pwd=""): db = Path(directory) / "key4.db" - if not db.exists(): raise NoDatabase() + if not db.exists(): + raise NoDatabase() conn = sqlite3.connect(str(db)) c = conn.cursor() @@ -157,7 +160,8 @@ def get_all_keys(directory, pwd=""): c.execute("SELECT item1, item2 FROM metadata WHERE id = 'password'") try: global_salt, item2 = next(c) - except StopIteration: raise NoDatabase() + except StopIteration: + raise NoDatabase() logging.info(f"[*] Global Salt: {censor(global_salt)}") @@ -167,7 +171,7 @@ def get_all_keys(directory, pwd=""): try: algorithm_oid = decodedItem2[0][0].asTuple() except (IndexError, AttributeError): - raise ValueError("Could not decode password validation data structure.") + raise ValueError("Could not decode password validation data structure.") if algorithm_oid == OID_PKCS12_3DES: entrySalt = decodedItem2[0][1][0].asOctets() @@ -234,8 +238,10 @@ def try_decrypt_login(key, ciphertext, iv): pt = cipher.decrypt(ciphertext) res = PKCS7unpad(pt) text = res.decode('utf-8') - if is_valid_text(text): return text, "AES-Standard" - except: pass + if is_valid_text(text): + return text, "AES-Standard" + except: + pass # Try 3DES if len(key) == 24: @@ -244,16 +250,20 @@ def try_decrypt_login(key, ciphertext, iv): pt = cipher.decrypt(ciphertext) res = PKCS7unpad(pt) text = res.decode('utf-8') - if is_valid_text(text): return text, "3DES-Standard" - except: pass + if is_valid_text(text): + return text, "3DES-Standard" + except: + pass return None, None def is_valid_text(text): - if not text or len(text) < 2: return False + 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 + if sum(1 for c in text if c in printable) / len(text) < 0.9: + return False return True @@ -264,7 +274,8 @@ def decodeLoginData(key, data): ciphertext = asn1data[2].asOctets() text, method = try_decrypt_login(key, ciphertext, iv) - if text: return text + if text: + return text raise ValueError("Decryption failed") except Exception: raise ValueError("Decryption failed") @@ -315,7 +326,8 @@ def exportLogins(key, jsonLogins): return [] logins = [] for row in jsonLogins["logins"]: - if row.get("deleted"): continue + if row.get("deleted"): + continue try: user = decodeLoginData(key, row["encryptedUsername"]) pw = decodeLoginData(key, row["encryptedPassword"]) @@ -371,24 +383,29 @@ def addNewLogins(key, jsonLogins, logins): jsonLogins["logins"].append(entry) jsonLogins["nextId"] += len(logins) +# Constants used to guess cross-platform +PROFILE_GUESS_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"), +} + +def getProfiles(): + 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 + 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"), - } - - if sys.platform not in dirs: + 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") @@ -481,7 +498,22 @@ def makeParser(): ) for sub in subparsers.choices.values(): - sub.add_argument("-d", "--directory", "--dir", type=Path, default=None, help="Firefox profile directory") + sub.add_argument( + "-p", # matches native: firefox -p + "-d", + "--directory", + "--dir", + type=Path, + metavar="DIRECTORY", + default=None, + help="Firefox profile directory", + # argcomplete + choices=getProfiles(), + ) + try: + pass + except ImportError: + pass sub.add_argument("-v", "--verbose", action="store_true") sub.add_argument("--debug", action="store_true") @@ -492,29 +524,33 @@ def makeParser(): import argcomplete argcomplete.autocomplete(parser) except ModuleNotFoundError: - pass + logging.info( + "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="%(message)s") + parser = makeParser() args = parser.parse_args() - # Default level ERROR (Silent), INFO for verbose, DEBUG for debug - log_level = logging.ERROR - if args.verbose: - log_level = logging.INFO + # Default level WARN (quiet), INFO for verbose, DEBUG for debug if args.debug: - log_level = logging.DEBUG - - logging.basicConfig(level=log_level, format="%(message)s") + logging.getLogger().setLevel(logging.DEBUG) + elif args.verbose: + logging.getLogger().setLevel(logging.INFO) + else: + logging.getLogger().setLevel(logging.WARNING) if args.directory is None: try: args.directory = guessDir() except NoProfile: - print("No Firefox profile found.") + logging.warning("No Firefox profile selected.") parser.print_help() parser.exit() args.directory = args.directory.expanduser() diff --git a/scripts/generate_mixed_profile.py b/scripts/generate_mixed_profile.py index 9dc1c5a..7a9a983 100755 --- a/scripts/generate_mixed_profile.py +++ b/scripts/generate_mixed_profile.py @@ -6,14 +6,14 @@ Created on Thu Dec 25 20:34:59 2025 @author: shane """ -import sqlite3 import json -import os +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(): @@ -31,16 +31,19 @@ def create_mixed_profile(): # 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')) + 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)) + 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)) + c.execute("INSERT INTO nssPrivate VALUES (?, ?)", (b"blob_modern_32", MAGIC1)) conn.commit() conn.close() @@ -54,11 +57,11 @@ def create_mixed_profile(): { "id": 1, "hostname": "http://www.mixedkeys.com", - "encryptedUsername": "QUFBQUFBQUE=", # Base64 for 'AAAAAAAA' + "encryptedUsername": "QUFBQUFBQUE=", # Base64 for 'AAAAAAAA' "encryptedPassword": "QUFBQUFBQUE=", - "deleted": False + "deleted": False, } - ] + ], } with open(base_dir / "logins.json", "w") as f: @@ -66,5 +69,6 @@ def create_mixed_profile(): print("Done.") + if __name__ == "__main__": create_mixed_profile() diff --git a/tests/test_mixed_keys_run.py b/tests/test_mixed_keys_run.py index da7eb8c..5d49d6c 100644 --- a/tests/test_mixed_keys_run.py +++ b/tests/test_mixed_keys_run.py @@ -7,25 +7,23 @@ import sys from unittest.mock import patch from pathlib import Path -# Add project root to path -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from ffpass import get_all_keys - OS_NEWLINE = os.linesep -HEADER = 'url,username,password' -EXPECTED_MIXED_OUTPUT = [HEADER, 'http://www.mixedkeys.com,modern_user,modern_pass'] +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 + 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.") + pytest.fail( + f"Test profile '{profile_name}' not found. Run generate_mixed_profile.py first." + ) shutil.copytree(src, dst) return dst + return _setup @@ -36,51 +34,54 @@ def run_ffpass_internal(mode, 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: + 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') + 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: + 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"]) + 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: + def decode_side_effect(key, data): + if len(key) == 32: + try: return next(return_values) - except StopIteration: + except StopIteration: return "extra" - raise ValueError("Wrong Key") + raise ValueError("Wrong Key") + + mock_decode.side_effect = decode_side_effect - mock_decode.side_effect = decode_side_effect + # Capture stdout + from io import StringIO - # Capture stdout - from io import StringIO - captured_output = StringIO() - sys.stdout = captured_output + captured_output = StringIO() + sys.stdout = captured_output - try: - main() - except SystemExit: - pass - finally: - sys.stdout = sys.__stdout__ + try: + main() + except SystemExit: + pass + finally: + sys.stdout = sys.__stdout__ - return captured_output.getvalue() + return captured_output.getvalue() def stdout_splitter(input_text): @@ -88,7 +89,7 @@ def stdout_splitter(input_text): def test_mixed_key_rotation_export(clean_profile): - profile_path = clean_profile('firefox-mixed-keys') - output = run_ffpass_internal('export', profile_path) + profile_path = clean_profile("firefox-mixed-keys") + output = run_ffpass_internal("export", profile_path) actual = stdout_splitter(output) assert actual == EXPECTED_MIXED_OUTPUT