]> Nutra Git (v1) - gamesguru/ffpass.git/commitdiff
more tidying up, linting, and formatting
authorShane Jaroch <chown_tee@proton.me>
Fri, 26 Dec 2025 02:11:10 +0000 (21:11 -0500)
committerShane Jaroch <chown_tee@proton.me>
Fri, 26 Dec 2025 03:02:55 +0000 (22:02 -0500)
Makefile
ffpass/__init__.py
scripts/generate_mixed_profile.py
tests/test_mixed_keys_run.py

index 18d587a3bc81d808416b908dc490d88d4f22e93d..75736b1d0ee1a3d280859390a30c8a5ae63f43c6 100644 (file)
--- 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
index ee9764da98cb6f3b7c769716867b0a53c459d3d1..7a10ff78e4a2d46b3563e8b5b9493ab31a93b7a0 100644 (file)
@@ -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()
index 9dc1c5ac11ddb32586872ca9dbe61465ce9e8222..7a9a983630587df8b3541a9bfae3f309ed3ab1fa 100755 (executable)
@@ -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()
index da7eb8cfc511d69835a2a80e98d5eed94470fa4a..5d49d6c39ce6a197be0f3fb2d7033e990daddb33 100644 (file)
@@ -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