--- /dev/null
+source .venv/bin/activate
+unset PS1
+eval "$(register-python-argcomplete ffpass)"
+
jobs:
test:
name: Test
- runs-on: ubuntu-latest
+ runs-on: [ubuntu-latest]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Lint with flake8
run: |
pip install flake8
- flake8 --ignore=E741,E501 .
+ flake8 . --exclude='*venv,build' --ignore=E741,E501
- name: Upload Unit Test Results
if: always()
--- /dev/null
+name: windows-and-mac
+
+on: [push, pull_request_target]
+
+jobs:
+ windows:
+ name: windows
+ runs-on: [windows-latest]
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.x'
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install .
+
+ - name: Test with pytest
+ run: |
+ pip install pytest
+ pip install pytest-cov
+ python -m pytest tests --junit-xml pytest.xml
+
+ macOS:
+ name: macOS
+ runs-on: [macos-latest]
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.x'
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install .
+
+ - name: Test with pytest
+ run: |
+ pip install pytest
+ pip install pytest-cov
+ python -m pytest tests --junit-xml pytest.xml
\ No newline at end of file
+SHELL:=/bin/bash
+
+.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,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
+ # CI pipeline
+ flake8 . --exclude='*venv,build' --ignore=E741,E501
+
+
+.PHONY: install
+install:
+ pip install .
+
+.PHONY: test
+test:
+ @echo 'Remember to run make install to test against the latest :)'
+ coverage run -m pytest -svv tests/
+ coverage report -m --omit="tests/*"
+
+.PHONY: clean
clean:
rm -rf *.egg-info build dist
+ rm -f .coverage
+ find . \
+ -name .venv -prune \
+ -o -name __pycache__ -print \
+ -o -name .pytest_cache -print \
+ | xargs -r rm -rf
#!/usr/bin/env python3
+# PYTHON_ARGCOMPLETE_OK
# Reverse-engineering by Laurent Clevy (@lclevy)
# from https://github.com/lclevy/firepwd/blob/master/firepwd.py
\033[0m\033[F\033[F
"""
+
import sys
from base64 import b64decode, b64encode
from hashlib import sha1, pbkdf2_hmac
-import hmac
import argparse
import json
from pathlib import Path
import sqlite3
import os.path
import logging
+import string
from pyasn1.codec.der.decoder import decode as der_decode
from pyasn1.codec.der.encoder import encode as der_encode
# 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):
pass
-def getKey(directory: Path, masterPassword=""):
- dbfile: Path = directory / "key4.db"
+def censor(data):
+ """
+ Censors the middle third of a hex string or bytes object.
+ """
+ if not data:
+ return None
+ s = data.hex() if isinstance(data, (bytes, bytearray)) else str(data)
- if not dbfile.exists():
- raise NoDatabase()
+ length = len(s)
+ if length <= 12:
+ return s
- conn = sqlite3.connect(dbfile.as_posix())
- c = conn.cursor()
- c.execute("""
- SELECT item1, item2
- FROM metadata
- WHERE id = 'password';
- """)
- row = next(c)
- globalSalt, item2 = row
+ third = length // 3
+ two_thirds = (2 * length) // 3
+ return f"{s[:third]}.....{s[two_thirds:]}"
- try:
- decodedItem2, _ = der_decode(item2)
- encryption_method = '3DES'
- entrySalt = decodedItem2[0][1][0].asOctets()
- cipherT = decodedItem2[1].asOctets()
- clearText = decrypt3DES(
- globalSalt, masterPassword, entrySalt, cipherT
- ) # usual Mozilla PBE
- except AttributeError:
- encryption_method = 'AES'
- decodedItem2 = der_decode(item2)
- clearText = decrypt_aes(decodedItem2, masterPassword, globalSalt)
-
- if clearText != b"password-check\x02\x02":
- raise WrongPassword()
- logging.info("password checked")
+def clean_iv(iv_bytes):
+ if len(iv_bytes) == 14:
+ return b'\x04\x0e' + iv_bytes
+ elif len(iv_bytes) == 18 and iv_bytes.startswith(b'\x04\x10'):
+ return iv_bytes[2:]
+ return iv_bytes
- # decrypt 3des key to decrypt "logins.json" content
- c.execute("""
- SELECT a11, a102
- FROM nssPrivate
- WHERE a102 = ?;
- """, (MAGIC1,))
- try:
- row = next(c)
- a11, a102 = row # CKA_ID
- except StopIteration:
- raise Exception(
- "The Firefox database appears to be broken. Try to add a password to rebuild it."
- ) # CKA_ID
-
- if encryption_method == 'AES':
- decodedA11 = der_decode(a11)
- key = decrypt_aes(decodedA11, masterPassword, globalSalt)
- 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}"
- entrySalt = decodedA11[0][1][0].asOctets()
- cipherT = decodedA11[1].asOctets()
- key = decrypt3DES(globalSalt, masterPassword, entrySalt, cipherT)
-
- logging.info("{}: {}".format(encryption_method, key.hex()))
- return key[:24]
-
-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):
+ if not b:
+ return b
return b[: -b[-1]]
-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])
- assert key_length == 32
-
- encoded_password = sha1(global_salt + master_password.encode('utf-8')).digest()
- key = pbkdf2_hmac(
- '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()
- cipher = AES.new(key, AES.MODE_CBC, init_vector)
- return cipher.decrypt(encrypted_value)
-
-
def decrypt3DES(globalSalt, masterPassword, entrySalt, encryptedData):
+ import hmac
hp = sha1(globalSalt + masterPassword.encode()).digest()
pes = entrySalt + b"\x00" * (20 - len(entrySalt))
chp = sha1(hp + entrySalt).digest()
return DES3.new(key, DES3.MODE_CBC, iv).decrypt(encryptedData)
+def decrypt_key_entry(a11, global_salt, master_password):
+ try:
+ decoded, _ = der_decode(a11)
+ key_oid = decoded[0][0].asTuple()
+
+ if key_oid == OID_PBES2:
+ # AES Logic
+ algo = decoded[0][1][0]
+ pbkdf2_params = algo[1]
+ entry_salt = pbkdf2_params[0].asOctets()
+ iters = int(pbkdf2_params[1])
+ key_len = int(pbkdf2_params[2])
+
+ logging.debug(f" > Method: PBKDF2-HMAC-SHA256 | Iterations: {iters}")
+ logging.debug(f" > Salt: {censor(entry_salt)} (Local) + {censor(global_salt)} (Global)")
+
+ enc_pwd = sha1(global_salt + master_password.encode('utf-8')).digest()
+ k = pbkdf2_hmac('sha256', enc_pwd, entry_salt, iters, dklen=key_len)
+
+ iv = clean_iv(decoded[0][1][1][1].asOctets())
+ logging.debug(f" > Cipher: AES-256-CBC | IV: {censor(iv)}")
+
+ cipher = AES.new(k, AES.MODE_CBC, iv)
+ return PKCS7unpad(cipher.decrypt(decoded[1].asOctets()))
+
+ elif key_oid == OID_PKCS12_3DES:
+ # 3DES Logic
+ entry_salt = decoded[0][1][0].asOctets()
+ ciphertext = decoded[1].asOctets()
+
+ 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))
+
+ except Exception as e:
+ logging.debug(f" > Failed: {e}")
+ return None
+
+
+def verify_password(global_salt, item2, pwd):
+ """
+ Verifies the master password against the metadata entry (item2).
+ Raises WrongPassword on failure.
+ """
+ try:
+ decodedItem2, _ = der_decode(item2)
+ try:
+ algorithm_oid = decodedItem2[0][0].asTuple()
+ except (IndexError, AttributeError):
+ raise ValueError("Could not decode password validation data structure.")
+
+ if algorithm_oid == OID_PKCS12_3DES:
+ entrySalt = decodedItem2[0][1][0].asOctets()
+ cipherT = decodedItem2[1].asOctets()
+ clearText = decrypt3DES(global_salt, pwd, entrySalt, cipherT)
+ elif algorithm_oid == OID_PBES2:
+ algo = decodedItem2[0][1][0]
+ pbkdf2_params = algo[1]
+ entry_salt = pbkdf2_params[0].asOctets()
+ iters = int(pbkdf2_params[1])
+ key_len = int(pbkdf2_params[2])
+ enc_pwd = sha1(global_salt + pwd.encode('utf-8')).digest()
+ k = pbkdf2_hmac('sha256', enc_pwd, entry_salt, iters, dklen=key_len)
+ iv = clean_iv(decodedItem2[0][1][1][1].asOctets())
+ cipher = AES.new(k, AES.MODE_CBC, iv)
+ clearText = cipher.decrypt(decodedItem2[1].asOctets())
+ else:
+ raise ValueError(f"Unknown encryption method OID: {algorithm_oid}")
+
+ if clearText != b"password-check\x02\x02":
+ raise WrongPassword()
+
+ except Exception as e:
+ logging.debug(f"Password check failed: {e}")
+ raise WrongPassword()
+
+
+def get_all_keys(directory, pwd=""):
+ db = Path(directory) / "key4.db"
+ if not db.exists():
+ raise NoDatabase()
+
+ conn = sqlite3.connect(str(db))
+ c = conn.cursor()
+
+ # 1. Get Global Salt
+ c.execute("SELECT item1, item2 FROM metadata WHERE id = 'password'")
+ try:
+ global_salt, item2 = next(c)
+ except StopIteration:
+ raise NoDatabase()
+
+ logging.info(f"[*] Global Salt: {censor(global_salt)}")
+
+ # 2. VERIFY PASSWORD EXPLICITLY
+ verify_password(global_salt, item2, pwd)
+ logging.info("[*] Password Verified Correctly")
+
+ # 3. Find ALL Keys
+ c.execute("SELECT a11, a102 FROM nssPrivate")
+ rows = c.fetchall()
+ logging.info(f"[*] Found {len(rows)} entries in nssPrivate")
+
+ # Check if rows exist BEFORE assuming corruption.
+ # If the table is empty, it's just an empty DB, not corruption.
+ if not rows:
+ raise NoDatabase()
+
+ found_keys = []
+ for idx, (a11, a102) in enumerate(rows):
+ logging.debug(f"[*] Attempting to decrypt Key #{idx} (ID: {censor(a102)})...")
+
+ key = decrypt_key_entry(a11, global_salt, pwd)
+
+ if key:
+ logging.info(f"[*] Decrypted Key #{idx}: {len(key)} bytes | ID: {a102.hex()}")
+ found_keys.append(key)
+ else:
+ logging.debug(f"[*] Key #{idx}: Failed to decrypt (Corrupt?)")
+
+ if not found_keys:
+ # Rows existed, but none decrypted successfully.
+ # Since password was verified in step 2, this IS corruption.
+ raise Exception("Database corrupted: Password verified, but no valid master keys could be decrypted.")
+
+ return found_keys, global_salt
+
+
+def try_decrypt_login(key, ciphertext, iv):
+ # Try AES
+ if len(key) in [16, 24, 32]:
+ try:
+ cipher = AES.new(key, AES.MODE_CBC, iv)
+ pt = cipher.decrypt(ciphertext)
+ res = PKCS7unpad(pt)
+ text = res.decode('utf-8')
+ if is_valid_text(text):
+ return text, "AES-Standard"
+ except Exception:
+ pass
+
+ # Try 3DES
+ if len(key) == 24:
+ try:
+ cipher = DES3.new(key, DES3.MODE_CBC, iv[:8])
+ pt = cipher.decrypt(ciphertext)
+ res = PKCS7unpad(pt)
+ text = res.decode('utf-8')
+ if is_valid_text(text):
+ return text, "3DES-Standard"
+ except Exception:
+ pass
+
+ return None, None
+
+
+def is_valid_text(text):
+ 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
+ return True
+
+
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
- iv = asn1data[1][1].asOctets()
- ciphertext = asn1data[2].asOctets()
- des = DES3.new(key, DES3.MODE_CBC, iv)
- return PKCS7unpad(des.decrypt(ciphertext)).decode()
+ try:
+ asn1data, _ = der_decode(b64decode(data))
+ iv = clean_iv(asn1data[1][1].asOctets())
+ ciphertext = asn1data[2].asOctets()
+
+ text, method = try_decrypt_login(key, ciphertext, iv)
+ if text:
+ return text
+ raise ValueError("Decryption failed")
+ except Exception:
+ raise ValueError("Decryption failed")
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)}")
+
return b64encode(der_encode(asn1data)).decode()
for row in jsonLogins["logins"]:
if row.get("deleted"):
continue
- encUsername = row["encryptedUsername"]
- encPassword = row["encryptedPassword"]
- logins.append(
- (
- row["hostname"],
- decodeLoginData(key, encUsername),
- decodeLoginData(key, encPassword),
- )
- )
+ try:
+ user = decodeLoginData(key, row["encryptedUsername"])
+ pw = decodeLoginData(key, row["encryptedPassword"])
+ logins.append((row["hostname"], user, pw))
+ except Exception as e:
+ if logging.getLogger().isEnabledFor(logging.DEBUG):
+ logging.debug(f"Failed to decrypt {row.get('hostname')}: {e}")
+ continue
return logins
-def lower_header(csv_file):
- it = iter(csv_file)
- yield next(it).lower()
- yield from it
+def readCSV(csv_file):
+ reader = csv.reader(csv_file)
-def readCSV(csv_file):
logins = []
- reader = csv.DictReader(lower_header(csv_file))
- for row in reader:
- logins.append((rawURL(row["url"]), row["username"], row["password"]))
- logging.info(f'read {len(logins)} logins')
+ first_row = None
+
+ # Loop through logins
+ for i, row in enumerate(reader):
+
+ logging.debug(f"row: {row}")
+
+ # Peek at the first line to detect if it is a header or normal row
+ if first_row is None:
+ logging.debug(f"first_row: {row}")
+ first_row = row
+
+ # Break if we get an empty first row
+ if (not row) or (len(row) != 3) or (not row[1] and not row[2]):
+ logging.debug(f"Breaking loop since we got an empty row at index={i}.")
+ break
+ # Heuristic: if it lacks a URL (index=1) and has user,pass (index=2,3), assume it's a header and continue
+ if (
+ "http://" not in first_row[0]
+ and first_row[1].lower() in {"username", "uname", "user", "u"} # noqa: W503 line break before binary operator
+ and first_row[2].lower() in {"password", "passwd", "pass", "p"} # noqa: W503
+ ):
+ logging.debug(f"Continuing (skipping) over first row: [is_header={True}].")
+ continue
+
+ # ~~~ END peek at first row ~~~~~~~~~~
+
+ # Break if we get an empty row at any time
+ if (not row) or (len(row) != 3) or (not row[1] and not row[2]):
+ logging.debug(f"Breaking loop since we got an empty row at index={i}.")
+ break
+
+ u, n, p = row
+ logins.append((rawURL(u), n, p))
+
return logins
def rawURL(url):
+ if not url:
+ return ""
+
+ # Fix for schemeless URLs (e.g. "test.com" -> "https://test.com")
+ # Without a scheme, urlparse puts the whole string in 'path' and leaves 'netloc' empty.
+ # ffpass expects 'netloc' to be populated to strip paths.
+ if "://" not in url:
+ url = "https://" + url
+
p = urlparse(url)
return type(p)(*p[:2], *[""] * 4).geturl()
def addNewLogins(key, jsonLogins, logins):
nextId = jsonLogins["nextId"]
timestamp = int(datetime.now().timestamp() * 1000)
- logging.info('adding logins')
+ logging.warning(f'adding {len(logins)} logins')
for i, (url, username, password) in enumerate(logins, nextId):
- logging.debug(f'adding {url} {username}')
+ logging.info(f'adding {url} {username}')
entry = {
"id": i,
"hostname": url,
jsonLogins["nextId"] += len(logins)
-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"),
- }
+# Constants used to guess cross-platform
+PROFILE_GUESS_DIRS = {
+ "darwin": "~/Library/Application Support/Firefox/Profiles",
+ "linux": "~/.mozilla/firefox",
+ "win32": os.path.expandvars("%APPDATA%\\Mozilla\\Firefox\\Profiles"),
+ "cygwin": os.path.expandvars("%APPDATA%\\Mozilla\\Firefox\\Profiles"),
+}
+
+
+def getProfiles() -> list[Path]:
+ 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
+
- if sys.platform not in dirs:
+def guessDir() -> Path:
+ 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")
def askpass(directory):
password = ""
+ n = 0
+ # Allow 1 automatic check + 2 user prompts = 3 attempts total.
+ # The condition "n < n_max" must allow n=0, n=1, n=2.
+ n_max = 3
while True:
try:
- key = getKey(directory, password)
+ keys, _ = get_all_keys(directory, password)
+ # Prefer 32-byte key, fallback to first
+ best_key = next((k for k in keys if len(k) == 32), keys[0])
+ logging.info(f"Selected Master Key: {len(best_key)} bytes (from {len(keys)} candidates)")
+ return best_key
except WrongPassword:
- password = getpass("Master Password:")
- else:
- break
- return key
+ n += 1
+ if n >= n_max:
+ break
+ password = getpass("Master Password: ")
+
+ if n > 0:
+ logging.error(f"wrong master password after {n_max - 1} prompts!")
+ return None
def main_export(args):
- try:
- key = askpass(args.directory)
- except NoDatabase:
- # if the database is empty, we are done!
+ # Removed try/except NoDatabase here to let it bubble to main() for proper logging
+ key = askpass(args.directory)
+
+ if not key:
+ logging.error("Failed to derive master key.")
return
+
jsonLogins = getJsonLogins(args.directory)
logins = exportLogins(key, jsonLogins)
- writer = csv.writer(args.file)
+ # Hard-code to "\n" to fix Windows bug with every other row empty: [a, "", b, "", ...].
+ writer = csv.writer(args.file, lineterminator="\n")
writer.writerow(["url", "username", "password"])
writer.writerows(logins)
def main_import(args):
- if args.file == sys.stdin:
- try:
- key = getKey(args.directory)
- except WrongPassword:
- # it is not possible to read the password
- # if stdin is used for input
- logging.error("Password is not empty. You have to specify FROM_FILE.")
- sys.exit(1)
- else:
- key = askpass(args.directory)
+ # askpass handles stdin/tty detection for the password prompt automatically
+ key = askpass(args.directory)
+
+ if not key:
+ logging.error("Failed to derive master key.")
+ return
+
jsonLogins = getJsonLogins(args.directory)
logins = readCSV(args.file)
addNewLogins(key, jsonLogins, logins)
)
parser_import.add_argument(
- "-f",
- "--file",
- dest="file",
- type=argparse.FileType("r", encoding="utf-8"),
- default=sys.stdin,
+ "-f", "--file", dest="file", type=argparse.FileType("r", encoding="utf-8"), default=sys.stdin
)
parser_export.add_argument(
- "-f",
- "--file",
- dest="file",
- type=argparse.FileType("w", encoding="utf-8"),
- default=sys.stdout,
+ "-f", "--file", dest="file", type=argparse.FileType("w", encoding="utf-8"), default=sys.stdout
)
for sub in subparsers.choices.values():
- sub.add_argument(
+ arg = sub.add_argument(
+ "-p", # matches native: firefox -p
"-d",
"--directory",
"--dir",
type=Path,
+ metavar="DIRECTORY",
default=None,
help="Firefox profile directory",
)
+ # Use argcomplete completer instead of 'choices='
+ # This allows arbitrary paths (tests) but gives users tab completion.
+ arg.completer = lambda **kwargs: [str(p) for p in getProfiles()]
+
sub.add_argument("-v", "--verbose", action="store_true")
sub.add_argument("--debug", action="store_true")
parser_import.set_defaults(func=main_import)
parser_export.set_defaults(func=main_export)
+
+ # Try to load argcomplete
+ try:
+ import argcomplete
+ argcomplete.autocomplete(parser)
+
+ except ModuleNotFoundError:
+ sys.stderr(
+ "NOTE: 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="%(levelname)s: %(message)s")
-
parser = makeParser()
args = parser.parse_args()
- if args.verbose:
- log_level = logging.INFO
- elif args.debug:
+ # Determine log level
+ log_level = logging.WARNING
+ if args.debug:
log_level = logging.DEBUG
- else:
- log_level = logging.ERROR
+ elif args.verbose:
+ log_level = logging.INFO
- logging.getLogger().setLevel(log_level)
+ logging.basicConfig(level=log_level, format="%(message)s")
+ # Try to obtain profile directory
if args.directory is None:
try:
- args.directory = guessDir()
+ args.directory = guessDir().expanduser()
except NoProfile:
- print("")
+ logging.error("No Firefox profile selected.")
parser.print_help()
parser.exit()
- args.directory = args.directory.expanduser()
+ # Run arg parser
try:
- args.func(args)
+ # Wrap in try/except for BrokenPipeError to allow piping to head, i.e., ffpass export | head -5
+ try:
+ args.func(args)
+ except BrokenPipeError:
+ sys.stdout = os.fdopen(1, 'w')
+
except NoDatabase:
- logging.error("Firefox password database is empty. Please create it from Firefox.")
+ logging.error("Firefox password database is empty.")
if __name__ == "__main__":
--- /dev/null
+coverage==7.13.0
+flake8==7.3.0
+pytest==9.0.2
+
--- /dev/null
+argcomplete>=3.5.2
+pyasn1~=0.6.1
+pycryptodome~=3.23.0
+
--- /dev/null
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+Created on Thu Dec 25 20:34:59 2025
+
+@author: shane
+"""
+
+import json
+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():
+ print(f"Directory {base_dir} already exists. Skipping generation.")
+ return
+ base_dir.mkdir(parents=True)
+
+ print(f"Generating mixed key profile in {base_dir}...")
+
+ # 1. Create key4.db with TWO keys
+ conn = sqlite3.connect(base_dir / "key4.db")
+ c = conn.cursor()
+ c.execute("CREATE TABLE metadata (id TEXT PRIMARY KEY, item1, item2)")
+ c.execute("CREATE TABLE nssPrivate (a11, a102)")
+
+ # 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"),
+ )
+
+ # 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))
+
+ # Key 1: Modern 32-byte key blob
+ c.execute("INSERT INTO nssPrivate VALUES (?, ?)", (b"blob_modern_32", MAGIC1))
+
+ conn.commit()
+ conn.close()
+
+ # 2. Create logins.json encrypted with the MODERN key
+ # Our test infrastructure mocks the decryption, so we can use dummy base64 strings
+ # The crucial part is that the test asserts it extracts data using the modern key logic
+ logins_data = {
+ "nextId": 2,
+ "logins": [
+ {
+ "id": 1,
+ "hostname": "http://www.mixedkeys.com",
+ "encryptedUsername": "QUFBQUFBQUE=", # Base64 for 'AAAAAAAA'
+ "encryptedPassword": "QUFBQUFBQUE=",
+ "deleted": False,
+ }
+ ],
+ }
+
+ with open(base_dir / "logins.json", "w") as f:
+ json.dump(logins_data, f)
+
+ print("Done.")
+
+
+if __name__ == "__main__":
+ create_mixed_profile()
--- /dev/null
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+Created on Fri Dec 26 00:10:05 2025
+
+@author: shane
+"""
+
+import hmac
+import json
+import secrets
+import sqlite3
+from hashlib import sha1
+from pathlib import Path
+
+from Crypto.Cipher import AES, DES3
+# Dependencies: pyasn1, pycryptodome
+from pyasn1.codec.der.encoder import encode as der_encode
+from pyasn1.type.univ import Integer, ObjectIdentifier, OctetString, Sequence
+
+# Constants
+MASTER_PASSWORD = "password123"
+GLOBAL_SALT = secrets.token_bytes(20)
+# We will generate a 24-byte (3DES) master key to encrypt the database
+REAL_MASTER_KEY = secrets.token_bytes(24)
+
+# OIDs
+OID_PKCS12_3DES = (1, 2, 840, 113_549, 1, 12, 5, 1, 3)
+MAGIC1 = b"\xf8\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01"
+MAGIC_AES = (2, 16, 840, 1, 101, 3, 4, 1, 42)
+
+
+def PKCS7pad(b, block_size=8):
+ pad_len = (-len(b) - 1) % block_size + 1
+ return b + bytes([pad_len] * pad_len)
+
+
+def derive_3des_key(global_salt, master_password, entry_salt):
+ """
+ Derives Key and IV using the specific Firefox/NSS PKCS#12-like KDF.
+ Matches decrypt3DES in ffpass/__init__.py
+ """
+ hp = sha1(global_salt + master_password.encode()).digest()
+ pes = entry_salt + b"\x00" * (20 - len(entry_salt))
+ chp = sha1(hp + entry_salt).digest()
+ k1 = hmac.new(chp, pes + entry_salt, sha1).digest()
+ tk = hmac.new(chp, pes, sha1).digest()
+ k2 = hmac.new(chp, tk + entry_salt, sha1).digest()
+ k = k1 + k2
+ iv = k[-8:]
+ key = k[:24]
+ return key, iv
+
+
+def asn1_wrap_3des(entry_salt, ciphertext):
+ """
+ Wraps the salt and ciphertext in the ASN.1 structure expected by Firefox.
+ Structure: Sequence[ Sequence[ OID, Sequence[Salt, Iters] ], Ciphertext ]
+ """
+ # 1. Algorithm Identifier
+ params = Sequence()
+ params[0] = OctetString(entry_salt)
+ params[1] = Integer(1) # Iterations
+
+ algo_id = Sequence()
+ algo_id[0] = ObjectIdentifier(OID_PKCS12_3DES)
+ algo_id[1] = params
+
+ # 2. Outer Sequence
+ outer = Sequence()
+ outer[0] = algo_id
+ outer[1] = OctetString(ciphertext)
+
+ return der_encode(outer)
+
+
+def encrypt_pbe(data, global_salt, master_password):
+ """
+ Encrypts data (e.g. password-check or master key) using 3DES PBE.
+ Returns the DER-encoded ASN.1 blob.
+ """
+ entry_salt = secrets.token_bytes(20)
+ key, iv = derive_3des_key(global_salt, master_password, entry_salt)
+
+ cipher = DES3.new(key, DES3.MODE_CBC, iv)
+ padded_data = PKCS7pad(data)
+ ciphertext = cipher.encrypt(padded_data)
+
+ return asn1_wrap_3des(entry_salt, ciphertext)
+
+
+def encode_login_data(key, data):
+ """
+ Encrypts a username or password using the Master Key (AES-256 logic).
+ Matches encodeLoginData in ffpass/__init__.py
+ """
+ # Use AES-256 if key is 32 bytes, else 3DES. Our REAL_MASTER_KEY is 24 bytes (3DES).
+ # To match modern Firefox better, let's pretend we use 3DES for the DB entry
+ # but the logic handles whatever key we give it.
+ # Let's stick to the AES path here if we want; but wait, REAL_MASTER_KEY is 24 bytes.
+ # We must use 3DES logic for the login entry if key is 24 bytes.
+
+ asn1data = Sequence()
+ asn1data[0] = OctetString(MAGIC1)
+ asn1data[1] = Sequence()
+
+ if len(key) == 32:
+ # AES Logic
+ 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)
+ else:
+ # 3DES Logic (matches our 24-byte master key)
+ # OID: 1.2.840.113549.3.7 (des-ede3-cbc)
+ OID_3DES_CBC = (1, 2, 840, 113_549, 3, 7)
+ iv = secrets.token_bytes(8)
+ cipher = DES3.new(key, DES3.MODE_CBC, iv)
+ ciphertext = cipher.encrypt(PKCS7pad(data.encode(), block_size=8))
+ asn1data[1][0] = ObjectIdentifier(OID_3DES_CBC)
+ asn1data[1][1] = OctetString(iv)
+ asn1data[2] = OctetString(ciphertext)
+
+ from base64 import b64encode
+
+ return b64encode(der_encode(asn1data)).decode()
+
+
+def create_mp_profile():
+ base_dir = Path("tests/firefox-mp-test")
+ if base_dir.exists():
+ import shutil
+
+ shutil.rmtree(base_dir)
+ base_dir.mkdir(parents=True)
+
+ print(f"Generating Real Encrypted MP profile in {base_dir}...")
+
+ # 1. Create key4.db
+ conn = sqlite3.connect(base_dir / "key4.db")
+ c = conn.cursor()
+ c.execute("CREATE TABLE metadata (id TEXT PRIMARY KEY, item1, item2)")
+ c.execute("CREATE TABLE nssPrivate (a11, a102)")
+
+ # A. Metadata: Password Check
+ # The tool verifies password by decrypting this and checking for "password-check\x02\x02"
+ # The encrypt_pbe function handles padding.
+ password_check_blob = encrypt_pbe(b"password-check", GLOBAL_SALT, MASTER_PASSWORD)
+ c.execute(
+ "INSERT INTO metadata VALUES ('password', ?, ?)",
+ (GLOBAL_SALT, password_check_blob),
+ )
+
+ # B. nssPrivate: Encrypted Master Key
+ # The tool decrypts this to get the key used for logins.json
+ master_key_blob = encrypt_pbe(REAL_MASTER_KEY, GLOBAL_SALT, MASTER_PASSWORD)
+ c.execute("INSERT INTO nssPrivate VALUES (?, ?)", (master_key_blob, MAGIC1))
+
+ conn.commit()
+ conn.close()
+
+ # 2. Create logins.json
+ # These strings are actually encrypted with REAL_MASTER_KEY now
+ logins_data = {
+ "nextId": 2,
+ "logins": [
+ {
+ "id": 1,
+ "hostname": "https://locked.com",
+ "encryptedUsername": encode_login_data(REAL_MASTER_KEY, "secret_user"),
+ "encryptedPassword": encode_login_data(REAL_MASTER_KEY, "secret_pass"),
+ "deleted": False,
+ }
+ ],
+ }
+
+ with open(base_dir / "logins.json", "w") as f:
+ json.dump(logins_data, f)
+
+ print("Done.")
+
+
+if __name__ == "__main__":
+ create_mp_profile()
def read(fname):
- return open(os.path.join(os.path.dirname(__file__), fname)).read()
+ return open(os.path.join(os.path.dirname(__file__), fname), encoding="utf-8").read()
setup(
name="ffpass",
- version="0.5.0",
+ version="0.6.0",
author="Louis Abraham",
license="MIT",
author_email="louis.abraham@yahoo.fr",
--- /dev/null
+{"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
--- /dev/null
+{"nextId": 2, "logins": [{"id": 1, "hostname": "http://www.mixedkeys.com", "encryptedUsername": "QUFBQUFBQUE=", "encryptedPassword": "QUFBQUFBQUE=", "deleted": false}]}
\ No newline at end of file
--- /dev/null
+{"nextId": 2, "logins": [{"id": 1, "hostname": "https://locked.com", "encryptedUsername": "MDoEEPgAAAAAAAAAAAAAAAAAAAEwFAYIKoZIhvcNAwcECLSofBkhyi6aBBAUN847VIaI3v/ONszuHiXI", "encryptedPassword": "MDoEEPgAAAAAAAAAAAAAAAAAAAEwFAYIKoZIhvcNAwcECGgTq8PZx0pVBBBmptJXLWpTeDwYKhku6o2r", "deleted": false}]}
\ No newline at end of file
import ffpass
from pathlib import Path
import pytest
+import sqlite3
+import shutil
+# This key corresponds to the static 'tests/firefox-84' profile in your repo
TEST_KEY = b'\xbfh\x13\x1a\xda\xb5\x9d\xe3X\x10\xe0\xa8\x8a\xc2\xe5\xbcE\xf2I\r\xa2pm\xf4'
MASTER_PASSWORD = 'test'
+MAGIC1 = b"\xf8\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01"
+
+
+def _get_key(directory, password=""):
+ """
+ Helper to adapt the new get_all_keys API to legacy test expectations.
+ """
+ keys, _ = ffpass.get_all_keys(directory, password)
+ # Simulate askpass logic: prefer 32-byte key, else first found
+ best = next((k for k in keys if len(k) == 32), keys[0])
+ return best
+
+
+@pytest.fixture
+def mixed_key_profile(tmp_path):
+ """
+ Creates a temporary profile based on firefox-84, but duplicates
+ the key entry in key4.db to simulate a "Key Rotation" scenario
+ (multiple keys present).
+ """
+ # 1. Base the profile on an existing valid one
+ src = Path('tests/firefox-84')
+ dst = tmp_path / "firefox-mixed"
+ shutil.copytree(src, dst)
+
+ # 2. Open the DB
+ db_path = dst / "key4.db"
+ conn = sqlite3.connect(str(db_path))
+ c = conn.cursor()
+
+ # 3. Fetch the existing key row
+ c.execute("SELECT * FROM nssPrivate WHERE a102 = ?", (MAGIC1,))
+ row = c.fetchone()
+
+ # Get column names to find the 'id' column index
+ col_names = [d[0] for d in c.description]
+
+ if row:
+ row_list = list(row)
+
+ # 4. Modify the UNIQUE 'id' column to avoid IntegrityError
+ if 'id' in col_names:
+ id_idx = col_names.index('id')
+ original_id = row_list[id_idx]
+
+ # Increment ID if integer, or change if bytes
+ if isinstance(original_id, int):
+ row_list[id_idx] = original_id + 100 # Ensure uniqueness
+ else:
+ # Fallback for blobs/bytes
+ row_list[id_idx] = b'\xff' * len(original_id)
+
+ # 5. Insert the duplicate row
+ placeholders = ",".join(["?"] * len(row_list))
+ c.execute(f"INSERT INTO nssPrivate VALUES ({placeholders})", row_list)
+ conn.commit()
+
+ conn.close()
+ return dst
def test_firefox_key():
- key = ffpass.getKey(Path('tests/firefox-84'))
+ key = _get_key(Path('tests/firefox-84'))
assert key == TEST_KEY
def test_firefox_mp_key():
- key = ffpass.getKey(Path('tests/firefox-mp-84'), MASTER_PASSWORD)
+ key = _get_key(Path('tests/firefox-mp-84'), MASTER_PASSWORD)
assert key == TEST_KEY
def test_firefox_wrong_masterpassword_key():
with pytest.raises(ffpass.WrongPassword):
- ffpass.getKey(Path('tests/firefox-mp-84'), 'wrongpassword')
+ _get_key(Path('tests/firefox-mp-84'), 'wrongpassword')
def test_legacy_firefox_key():
- key = ffpass.getKey(Path('tests/firefox-70'))
+ key = _get_key(Path('tests/firefox-70'))
assert key == TEST_KEY
def test_legacy_firefox_mp_key():
- key = ffpass.getKey(Path('tests/firefox-mp-70'), MASTER_PASSWORD)
+ key = _get_key(Path('tests/firefox-mp-70'), MASTER_PASSWORD)
assert key == TEST_KEY
def test_legacy_firefox_wrong_masterpassword_key():
with pytest.raises(ffpass.WrongPassword):
- ffpass.getKey(Path('tests/firefox-mp-70'), 'wrongpassword')
+ _get_key(Path('tests/firefox-mp-70'), 'wrongpassword')
+
+
+def test_mixed_key_retrieval(mixed_key_profile):
+ """
+ Verifies that get_all_keys() finds multiple keys in the DB.
+ """
+ keys, _ = ffpass.get_all_keys(mixed_key_profile)
+
+ # Since we manually duplicated the key row in the fixture,
+ # we expect exactly 2 keys to be decrypted.
+ assert len(keys) == 2
+
+ # Both keys should be valid (and identical in this specific test case)
+ assert keys[0] == TEST_KEY
+ assert keys[1] == TEST_KEY
--- /dev/null
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+Created on Fri Dec 25 19:30:48 2025
+
+@author: shane
+"""
+
+import os
+import shutil
+import sys
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+OS_NEWLINE = os.linesep
+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
+ dst = tmp_path / profile_name
+ if not src.exists():
+ pytest.fail(
+ f"Test profile '{profile_name}' not found. Run generate_mixed_profile.py first."
+ )
+ shutil.copytree(src, dst)
+ return dst
+
+ return _setup
+
+
+def run_ffpass_internal(mode, path):
+ from ffpass import main
+
+ test_args = ["ffpass", mode, "-d", str(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,
+ ):
+
+ # 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")
+
+ # 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:
+ 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"])
+
+ def decode_side_effect(key, data):
+ if len(key) == 32:
+ try:
+ return next(return_values)
+ except StopIteration:
+ return "extra"
+ raise ValueError("Wrong Key")
+
+ mock_decode.side_effect = decode_side_effect
+
+ # Capture stdout
+ from io import StringIO
+
+ captured_output = StringIO()
+ sys.stdout = captured_output
+
+ try:
+ main()
+ except SystemExit:
+ pass
+ finally:
+ sys.stdout = sys.__stdout__
+
+ return captured_output.getvalue()
+
+
+def stdout_splitter(input_text):
+ return [x for x in input_text.splitlines() if x != ""]
+
+
+def test_mixed_key_rotation_export(clean_profile):
+ profile_path = clean_profile("firefox-mixed-keys")
+ output = run_ffpass_internal("export", profile_path)
+ actual = stdout_splitter(output)
+ assert actual == EXPECTED_MIXED_OUTPUT
--- /dev/null
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+Created on Fri Dec 26 00:13:51 2025
+
+@author: shane
+"""
+
+import shutil
+import sys
+from io import StringIO
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+# Allow importing ffpass from source
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+import ffpass # noqa: E402
+from ffpass import main # noqa: E402
+
+MASTER_PASSWORD = "password123"
+
+
+@pytest.fixture
+def mp_profile(tmp_path):
+ """
+ Setup the MP profile with REAL encrypted data.
+ Requires running scripts/generate_mp_profile.py first.
+ """
+ src = Path("tests/firefox-mp-test")
+ if not src.exists():
+ pytest.fail(
+ "Run scripts/generate_mp_profile.py first to generate real crypto assets"
+ )
+ dst = tmp_path / "firefox-mp-test"
+ shutil.copytree(src, dst)
+ return dst
+
+
+def test_export_with_correct_password(mp_profile):
+ """
+ Verifies that providing the correct password via stdin allows
+ successful decryption of the database.
+ """
+ # Mock user input to return correct password immediately
+ ffpass.getpass = lambda x: MASTER_PASSWORD
+
+ # Capture stdout to verify CSV output
+ capture = StringIO()
+
+ with patch("sys.argv", ["ffpass", "export", "-d", str(mp_profile)]), patch(
+ "sys.stdout", capture
+ ):
+
+ # Run real main() - no internal crypto mocks!
+ # This proves verify_password -> decrypt_key -> decodeLoginData all work
+ try:
+ main()
+ except SystemExit:
+ pass
+
+ output = capture.getvalue()
+ print(output) # For debugging failures
+
+ # Verify we successfully decrypted the specific credentials in logins.json
+ assert "url,username,password" in output
+ assert "https://locked.com,secret_user,secret_pass" in output
+
+
+def test_export_with_wrong_password_retry(mp_profile):
+ """
+ Verifies the retry logic:
+ 1. Enter wrong password -> fail
+ 2. Enter correct password -> succeed
+ """
+ # Create an iterator that yields Wrong, then Right
+ # This simulates the user typing correctly on the second attempt
+ inputs = iter(["wrong_pass", MASTER_PASSWORD])
+
+ ffpass.getpass = lambda x: next(inputs)
+
+ capture = StringIO()
+
+ with patch("sys.argv", ["ffpass", "export", "-d", str(mp_profile)]), patch(
+ "sys.stdout", capture
+ ):
+
+ try:
+ main()
+ except SystemExit:
+ pass
+
+ output = capture.getvalue()
+
+ # It should eventually succeed and print the data
+ assert "secret_user" in output
+
+
+def test_import_with_stdin_password(mp_profile):
+ """
+ Verifies that import also respects the password prompt mechanism.
+ """
+ ffpass.getpass = lambda x: MASTER_PASSWORD
+
+ # Prepare input CSV for import
+ input_csv = "url,username,password\nhttps://newsite.com,new_user,new_pass"
+
+ # We need to mock stdin for the CSV data itself
+ # AND mock ffpass.getpass for the master password
+
+ # ffpass.main_import reads from args.file.
+ # If args.file is sys.stdin, we must patch sys.stdin.
+
+ with patch("sys.argv", ["ffpass", "import", "-d", str(mp_profile)]), patch(
+ "sys.stdin", StringIO(input_csv)
+ ):
+
+ try:
+ main()
+ except SystemExit:
+ pass
+
+ # Verify the new login was actually added to the file
+ # We can check by running export again or inspecting the JSON
+ import json
+
+ with open(mp_profile / "logins.json", "r") as f:
+ data = json.load(f)
+
+ # The file is encrypted, so we can't grep "new_user" directly.
+ # We just check that the login count increased (was 1, now 2)
+ assert len(data["logins"]) == 2
+ assert data["nextId"] == 3
#!/usr/bin/env python3
+import os
import subprocess
+import shutil
+import pytest
+from pathlib import Path
+
+OS_NEWLINE = os.linesep
MASTER_PASSWORD = 'test'
-HEADER = 'url,username,password\n'
-IMPORT_CREDENTIAL = 'http://www.example.com,foo,bar\n'
-EXPECTED_EXPORT_OUTPUT = f'{HEADER}http://www.stealmylogin.com,test,test\n'
-EXPECTED_IMPORT_OUTPUT = EXPECTED_EXPORT_OUTPUT + IMPORT_CREDENTIAL
+HEADER = 'url,username,password'
+IMPORT_CREDENTIAL = 'http://www.example.com,foo,bar'
+EXPECTED_EXPORT_OUTPUT = [HEADER, 'http://www.stealmylogin.com,test,test']
+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 = ["python", "./ffpass/__init__.py", mode, "-d", str(path)]
+
if mode == 'import':
- ffpass_input = HEADER + IMPORT_CREDENTIAL
+ ffpass_input = OS_NEWLINE.join([HEADER, IMPORT_CREDENTIAL])
else:
ffpass_input = None
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 stdout_splitter(input_text):
+ return [x for x in input_text.splitlines()]
+
+
+def test_legacy_firefox_export(clean_profile):
+ r = run_ffpass('export', clean_profile('firefox-70'))
r.check_returncode()
- assert r.stdout == EXPECTED_EXPORT_OUTPUT
+ actual_export_output = stdout_splitter(r.stdout)
+ assert actual_export_output == EXPECTED_EXPORT_OUTPUT
-def test_firefox_export():
- r = run_ffpass('export', 'tests/firefox-84')
+def test_firefox_export(clean_profile):
+ r = run_ffpass('export', clean_profile('firefox-84'))
r.check_returncode()
- assert r.stdout == EXPECTED_EXPORT_OUTPUT
+ assert stdout_splitter(r.stdout) == EXPECTED_EXPORT_OUTPUT
-def test_legacy_firefox():
- r = run_ffpass('import', 'tests/firefox-70')
+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 stdout_splitter(r.stdout) == EXPECTED_EXPORT_OUTPUT
- r = run_ffpass('export', '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()
- assert r.stdout == EXPECTED_IMPORT_OUTPUT
+ r = run_ffpass('export', profile_path)
+ r.check_returncode()
+ assert stdout_splitter(r.stdout) == EXPECTED_IMPORT_OUTPUT
+
+
+def test_firefox(clean_profile):
+ profile_path = clean_profile('firefox-84')
-def test_firefox():
- r = run_ffpass('import', 'tests/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
+ assert stdout_splitter(r.stdout) == EXPECTED_IMPORT_OUTPUT