From: lmcclell <48396038+lmcclell@users.noreply.github.com> Date: Sat, 30 Jan 2021 22:47:14 +0000 (+1100) Subject: Added unit tests (#58) X-Git-Url: https://git.nutra.tk/v2?a=commitdiff_plain;h=485a8d19b4e55f8a0883b8e003e687f08189268e;p=gamesguru%2Fffpass.git Added unit tests (#58) * Add unit testing for key decryption * Add functional tests --- diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml new file mode 100644 index 0000000..259fae8 --- /dev/null +++ b/.github/workflows/testing.yaml @@ -0,0 +1,58 @@ +name: ffpass + +on: [push, pull_request_target] + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Set up Python + uses: actions/setup-python@v2 + 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 + + - name: Lint with flake8 + run: | + pip install flake8 + flake8 --ignore=E741,E501 . + + - name: Upload Unit Test Results + if: always() + uses: actions/upload-artifact@v2 + with: + name: Unit Test Results + path: pytest.xml + + publish-test-results: + name: "Publish Unit Tests Results" + needs: test + runs-on: ubuntu-latest + if: success() || failure() + + steps: + - name: Download Artifacts + uses: actions/download-artifact@v2 + with: + path: artifacts + + - name: Publish Unit Test Results + uses: EnricoMi/publish-unit-test-result-action@v1.7 + with: + check_name: Unit Test Results + github_token: ${{ secrets.GITHUB_TOKEN }} + files: "artifacts/Unit Test Results/pytest.xml" diff --git a/.gitignore b/.gitignore index 746645f..948d392 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ __pycache__/ build/ dist/ *.egg-info/ +junit/ +.vscode +.coverage +pytest.xml diff --git a/README.md b/README.md index 498e245..d551c4e 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,8 @@ pip install ffpass ``` bash ffpass export > passwords.csv -ffpass export -t passwords.csv -ffpass export --to passwords.csv +ffpass export -f passwords.csv +ffpass export --file passwords.csv ``` ### Usage @@ -47,7 +47,7 @@ ffpass export --to passwords.csv optional arguments: -h, --help show this help message and exit - -t TO_FILE, --to TO_FILE + -f FILE, --file FILE file to export password (defaults to stdout) -d DIRECTORY, --directory DIRECTORY, --dir DIRECTORY Firefox profile directory -v, --verbose @@ -57,7 +57,7 @@ ffpass export --to passwords.csv ``` bash ffpass import < passwords.csv ffpass import -f passwords.csv -ffpass import --from passwords.csv +ffpass import --file passwords.csv ``` By default, it works with the passwords exported from Google Chrome. @@ -70,7 +70,7 @@ By default, it works with the passwords exported from Google Chrome. optional arguments: -h, --help show this help message and exit - -f FROM_FILE, --from FROM_FILE + -f FILE, --file FILE file to import from (defaults to stdin) -d DIRECTORY, --directory DIRECTORY, --dir DIRECTORY [Firefox profile directory](https://support.mozilla.org/en-US/kb/profiles-where-firefox-stores-user-data#w_how-do-i-find-my-profile) -v, --verbose @@ -114,7 +114,7 @@ Now, Firefox can more accurately import logins saved in Chrome/Chromium on Windo ``` bash -ffpass import --from passwords.csv +ffpass import --file passwords.csv ``` Restart Firefox, making sure it didn't leave any process still open. @@ -129,7 +129,7 @@ Restart Firefox, making sure it didn't leave any process still open. ``` bash -ffpass export --to passwords.csv +ffpass export --file passwords.csv ``` ### Import in Google Chrome diff --git a/ffpass/__init__.py b/ffpass/__init__.py index 2e80314..e01bb2d 100644 --- a/ffpass/__init__.py +++ b/ffpass/__init__.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 - """ The MIT License (MIT) Copyright (c) 2018 Louis Abraham @@ -11,10 +10,9 @@ ffpass can import and export passwords from Firefox Quantum. \x1B[0m\033[1m\033[F\033[F example of usage: + ffpass export --file passwords.csv - ffpass export --to passwords.csv - - ffpass import --from passwords.csv + ffpass import --file passwords.csv \033[0m\033[1;32m\033[F\033[F @@ -38,6 +36,7 @@ from datetime import datetime from urllib.parse import urlparse import sqlite3 import os.path +import logging from pyasn1.codec.der.decoder import decode as der_decode from pyasn1.codec.der.encoder import encode as der_encode @@ -62,14 +61,8 @@ class WrongPassword(Exception): pass -def _err(message): - print(f'error: {message}', file=sys.stderr) - - -def _msg(message): - if not args.verbose: - return - print(message, file=sys.stderr) +class NoProfile(Exception): + pass def getKey(directory: Path, masterPassword=""): @@ -104,7 +97,7 @@ def getKey(directory: Path, masterPassword=""): if clearText != b"password-check\x02\x02": raise WrongPassword() - _msg("password checked") + logging.info("password checked") # decrypt 3des key to decrypt "logins.json" content c.execute(""" @@ -131,7 +124,7 @@ def getKey(directory: Path, masterPassword=""): cipherT = decodedA11[1].asOctets() key = decrypt3DES(globalSalt, masterPassword, entrySalt, cipherT) - _msg("{}: {}".format(encryption_method, key.hex())) + logging.info("{}: {}".format(encryption_method, key.hex())) return key[:24] @@ -171,7 +164,7 @@ def decrypt3DES(globalSalt, masterPassword, entrySalt, encryptedData): k = k1 + k2 iv = k[-8:] key = k[:24] - _msg("key={} iv={}".format(key.hex(), iv.hex())) + logging.info("key={} iv={}".format(key.hex(), iv.hex())) return DES3.new(key, DES3.MODE_CBC, iv).decrypt(encryptedData) @@ -212,7 +205,7 @@ def dumpJsonLogins(directory, jsonLogins): def exportLogins(key, jsonLogins): if "logins" not in jsonLogins: - _err("no 'logins' key in logins.json") + logging.error("no 'logins' key in logins.json") return [] logins = [] for row in jsonLogins["logins"]: @@ -228,17 +221,18 @@ def exportLogins(key, jsonLogins): return logins -def lower_header(from_file): - it = iter(from_file) +def lower_header(csv_file): + it = iter(csv_file) yield next(it).lower() yield from it -def readCSV(from_file): +def readCSV(csv_file): logins = [] - reader = csv.DictReader(lower_header(from_file)) + 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') return logins @@ -250,7 +244,9 @@ def rawURL(url): def addNewLogins(key, jsonLogins, logins): nextId = jsonLogins["nextId"] timestamp = int(datetime.now().timestamp() * 1000) + logging.info('adding logins') for i, (url, username, password) in enumerate(logins, nextId): + logging.debug(f'adding {url} {username}') entry = { "id": i, "hostname": url, @@ -280,24 +276,27 @@ def guessDir(): } if sys.platform not in dirs: - _msg(f"Automatic profile selection is not supported for {sys.platform}") - return + 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}") if len(profiles) == 0: - _err("Cannot find any Firefox profiles") - return + logging.error("Cannot find any Firefox profiles") + raise NoProfile if len(profiles) > 1: - _msg("More than one profile detected. Please specify a profile to parse (-d path/to/profile)") - _msg("valid profiles:\n\t" + '\n\t'.join(map(str, profiles))) - return + logging.error("More than one profile detected. Please specify a profile to parse (-d path/to/profile)") + logging.error("valid profiles:\n\t\t" + '\n\t\t'.join(map(str, profiles))) + raise NoProfile profile_path = profiles[0] - _msg(f"Using profile: {profile_path}") + logging.info(f"Using profile: {profile_path}") return profile_path @@ -321,29 +320,29 @@ def main_export(args): return jsonLogins = getJsonLogins(args.directory) logins = exportLogins(key, jsonLogins) - writer = csv.writer(args.to_file) + writer = csv.writer(args.file) writer.writerow(["url", "username", "password"]) writer.writerows(logins) def main_import(args): - if args.from_file == sys.stdin: + 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 - _err("Password is not empty. You have to specify FROM_FILE.") + logging.error("Password is not empty. You have to specify FROM_FILE.") sys.exit(1) else: key = askpass(args.directory) jsonLogins = getJsonLogins(args.directory) - logins = readCSV(args.from_file) + logins = readCSV(args.file) addNewLogins(key, jsonLogins, logins) dumpJsonLogins(args.directory, jsonLogins) -def makeParser(required_dir): +def makeParser(): parser = argparse.ArgumentParser( prog="ffpass", description=__doc__, @@ -362,15 +361,15 @@ def makeParser(required_dir): parser_import.add_argument( "-f", - "--from", - dest="from_file", + "--file", + dest="file", type=argparse.FileType("r", encoding="utf-8"), default=sys.stdin, ) parser_export.add_argument( - "-t", - "--to", - dest="to_file", + "-f", + "--file", + dest="file", type=argparse.FileType("w", encoding="utf-8"), default=sys.stdout, ) @@ -381,11 +380,11 @@ def makeParser(required_dir): "--directory", "--dir", type=Path, - required=required_dir, default=None, help="Firefox profile directory", ) 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) @@ -393,19 +392,33 @@ def makeParser(required_dir): def main(): - global args - args = makeParser(False).parse_args() + 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: + log_level = logging.DEBUG + else: + log_level = logging.ERROR + + logging.getLogger().setLevel(log_level) + if args.directory is None: - guessed_dir = guessDir() - if guessed_dir is None: - args = makeParser(True).parse_args() - else: - args.directory = guessed_dir + try: + args.directory = guessDir() + except NoProfile: + print("") + parser.print_help() + parser.exit() args.directory = args.directory.expanduser() + try: args.func(args) except NoDatabase: - _err("Firefox password database is empty. Please create it from Firefox.") + logging.error("Firefox password database is empty. Please create it from Firefox.") if __name__ == "__main__": diff --git a/tests/firefox-70/key4.db b/tests/firefox-70/key4.db new file mode 100644 index 0000000..9dcdd64 Binary files /dev/null and b/tests/firefox-70/key4.db differ diff --git a/tests/firefox-70/logins.json b/tests/firefox-70/logins.json new file mode 100644 index 0000000..83a6ed8 --- /dev/null +++ b/tests/firefox-70/logins.json @@ -0,0 +1 @@ +{"nextId":2,"logins":[{"id":1,"hostname":"http://www.stealmylogin.com","httpRealm":null,"formSubmitURL":"https://example.com","usernameField":"username","passwordField":"password","encryptedUsername":"MDIEEPgAAAAAAAAAAAAAAAAAAAEwFAYIKoZIhvcNAwcECKnaoufEsN+EBAglQGuxstdAVw==","encryptedPassword":"MDIEEPgAAAAAAAAAAAAAAAAAAAEwFAYIKoZIhvcNAwcECEvvw+bkhjriBAgjXBr8vnzt6A==","guid":"{417f43f0-09fb-1341-abe4-adf0045a5750}","encType":1,"timeCreated":1610850335449,"timeLastUsed":1610850335449,"timePasswordChanged":1610850335449,"timesUsed":1}],"potentiallyVulnerablePasswords":[],"dismissedBreachAlertsByLoginGUID":{},"version":2} \ No newline at end of file diff --git a/tests/firefox-84/key4.db b/tests/firefox-84/key4.db new file mode 100644 index 0000000..36f2e37 Binary files /dev/null and b/tests/firefox-84/key4.db differ diff --git a/tests/firefox-84/logins.json b/tests/firefox-84/logins.json new file mode 100644 index 0000000..83a6ed8 --- /dev/null +++ b/tests/firefox-84/logins.json @@ -0,0 +1 @@ +{"nextId":2,"logins":[{"id":1,"hostname":"http://www.stealmylogin.com","httpRealm":null,"formSubmitURL":"https://example.com","usernameField":"username","passwordField":"password","encryptedUsername":"MDIEEPgAAAAAAAAAAAAAAAAAAAEwFAYIKoZIhvcNAwcECKnaoufEsN+EBAglQGuxstdAVw==","encryptedPassword":"MDIEEPgAAAAAAAAAAAAAAAAAAAEwFAYIKoZIhvcNAwcECEvvw+bkhjriBAgjXBr8vnzt6A==","guid":"{417f43f0-09fb-1341-abe4-adf0045a5750}","encType":1,"timeCreated":1610850335449,"timeLastUsed":1610850335449,"timePasswordChanged":1610850335449,"timesUsed":1}],"potentiallyVulnerablePasswords":[],"dismissedBreachAlertsByLoginGUID":{},"version":2} \ No newline at end of file diff --git a/tests/firefox-mp-70/key4.db b/tests/firefox-mp-70/key4.db new file mode 100644 index 0000000..9be3138 Binary files /dev/null and b/tests/firefox-mp-70/key4.db differ diff --git a/tests/firefox-mp-70/logins.json b/tests/firefox-mp-70/logins.json new file mode 100644 index 0000000..83a6ed8 --- /dev/null +++ b/tests/firefox-mp-70/logins.json @@ -0,0 +1 @@ +{"nextId":2,"logins":[{"id":1,"hostname":"http://www.stealmylogin.com","httpRealm":null,"formSubmitURL":"https://example.com","usernameField":"username","passwordField":"password","encryptedUsername":"MDIEEPgAAAAAAAAAAAAAAAAAAAEwFAYIKoZIhvcNAwcECKnaoufEsN+EBAglQGuxstdAVw==","encryptedPassword":"MDIEEPgAAAAAAAAAAAAAAAAAAAEwFAYIKoZIhvcNAwcECEvvw+bkhjriBAgjXBr8vnzt6A==","guid":"{417f43f0-09fb-1341-abe4-adf0045a5750}","encType":1,"timeCreated":1610850335449,"timeLastUsed":1610850335449,"timePasswordChanged":1610850335449,"timesUsed":1}],"potentiallyVulnerablePasswords":[],"dismissedBreachAlertsByLoginGUID":{},"version":2} \ No newline at end of file diff --git a/tests/firefox-mp-84/key4.db b/tests/firefox-mp-84/key4.db new file mode 100644 index 0000000..51017f1 Binary files /dev/null and b/tests/firefox-mp-84/key4.db differ diff --git a/tests/firefox-mp-84/logins.json b/tests/firefox-mp-84/logins.json new file mode 100644 index 0000000..83a6ed8 --- /dev/null +++ b/tests/firefox-mp-84/logins.json @@ -0,0 +1 @@ +{"nextId":2,"logins":[{"id":1,"hostname":"http://www.stealmylogin.com","httpRealm":null,"formSubmitURL":"https://example.com","usernameField":"username","passwordField":"password","encryptedUsername":"MDIEEPgAAAAAAAAAAAAAAAAAAAEwFAYIKoZIhvcNAwcECKnaoufEsN+EBAglQGuxstdAVw==","encryptedPassword":"MDIEEPgAAAAAAAAAAAAAAAAAAAEwFAYIKoZIhvcNAwcECEvvw+bkhjriBAgjXBr8vnzt6A==","guid":"{417f43f0-09fb-1341-abe4-adf0045a5750}","encType":1,"timeCreated":1610850335449,"timeLastUsed":1610850335449,"timePasswordChanged":1610850335449,"timesUsed":1}],"potentiallyVulnerablePasswords":[],"dismissedBreachAlertsByLoginGUID":{},"version":2} \ No newline at end of file diff --git a/tests/test_key.py b/tests/test_key.py new file mode 100644 index 0000000..db102e3 --- /dev/null +++ b/tests/test_key.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 + +import ffpass +from pathlib import Path +import pytest + +TEST_KEY = b'\xbfh\x13\x1a\xda\xb5\x9d\xe3X\x10\xe0\xa8\x8a\xc2\xe5\xbcE\xf2I\r\xa2pm\xf4' +MASTER_PASSWORD = 'test' + + +def test_firefox_key(): + key = ffpass.getKey(Path('tests/firefox-84')) + assert key == TEST_KEY + + +def test_firefox_mp_key(): + key = ffpass.getKey(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') + + +def test_legacy_firefox_key(): + key = ffpass.getKey(Path('tests/firefox-70')) + assert key == TEST_KEY + + +def test_legacy_firefox_mp_key(): + key = ffpass.getKey(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') diff --git a/tests/test_run.py b/tests/test_run.py new file mode 100644 index 0000000..a244a83 --- /dev/null +++ b/tests/test_run.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 + +import subprocess + +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 + + +def run_ffpass(mode, path): + command = ["ffpass", mode, "-d", path] + if mode == 'import': + ffpass_input = 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') + r.check_returncode() + assert r.stdout == EXPECTED_EXPORT_OUTPUT + + +def test_firefox_export(): + r = run_ffpass('export', 'tests/firefox-84') + r.check_returncode() + assert r.stdout == EXPECTED_EXPORT_OUTPUT + + +def test_legacy_firefox(): + r = run_ffpass('import', 'tests/firefox-70') + r.check_returncode() + + r = run_ffpass('export', 'tests/firefox-70') + r.check_returncode() + assert r.stdout == EXPECTED_IMPORT_OUTPUT + + +def test_firefox(): + r = run_ffpass('import', 'tests/firefox-84') + r.check_returncode() + + r = run_ffpass('export', 'tests/firefox-84') + r.check_returncode() + assert r.stdout == EXPECTED_IMPORT_OUTPUT