.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
"""
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:]}"
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))
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()
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)}")
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()
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:
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
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")
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"])
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")
)
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")
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()
@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():
# 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()
{
"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:
print("Done.")
+
if __name__ == "__main__":
create_mixed_profile()
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
# 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):
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