Added unit tests (#58)
authorlmcclell <48396038+lmcclell@users.noreply.github.com>
Sat, 30 Jan 2021 22:47:14 +0000 (09:47 +1100)
committerGitHub <noreply@github.com>
Sat, 30 Jan 2021 22:47:14 +0000 (23:47 +0100)
* Add unit testing for key decryption

* Add functional tests

14 files changed:
.github/workflows/testing.yaml [new file with mode: 0644]
.gitignore
README.md
ffpass/__init__.py
tests/firefox-70/key4.db [new file with mode: 0644]
tests/firefox-70/logins.json [new file with mode: 0644]
tests/firefox-84/key4.db [new file with mode: 0644]
tests/firefox-84/logins.json [new file with mode: 0644]
tests/firefox-mp-70/key4.db [new file with mode: 0644]
tests/firefox-mp-70/logins.json [new file with mode: 0644]
tests/firefox-mp-84/key4.db [new file with mode: 0644]
tests/firefox-mp-84/logins.json [new file with mode: 0644]
tests/test_key.py [new file with mode: 0644]
tests/test_run.py [new file with mode: 0644]

diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml
new file mode 100644 (file)
index 0000000..259fae8
--- /dev/null
@@ -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"
index 746645fa8ef5427ba8515a3075ab47299743eb3c..948d39257e0884877735775cd713764c3a6376e6 100644 (file)
@@ -4,3 +4,7 @@ __pycache__/
 build/
 dist/
 *.egg-info/
+junit/
+.vscode
+.coverage
+pytest.xml
index 498e2456310229ee2f190c022d4fd3047fbf2e1a..d551c4e91948c84bdbb741c98cefb10a52bda7d0 100644 (file)
--- 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
 <!-- end list -->
 
 ``` 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.
 <!-- end list -->
 
 ``` bash
-ffpass export --to passwords.csv
+ffpass export --file passwords.csv
 ```
 
 ### Import in Google Chrome
index 2e803149bfda2e96e98cb252f44e747f56654b3d..e01bb2db7b93c6dafb94072b40605a975c868aa4 100644 (file)
@@ -1,5 +1,4 @@
 #!/usr/bin/env python3
-
 """
 The MIT License (MIT)
 Copyright (c) 2018 Louis Abraham <louis.abraham@yahoo.fr>
@@ -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 (file)
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 (file)
index 0000000..83a6ed8
--- /dev/null
@@ -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 (file)
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 (file)
index 0000000..83a6ed8
--- /dev/null
@@ -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 (file)
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 (file)
index 0000000..83a6ed8
--- /dev/null
@@ -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 (file)
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 (file)
index 0000000..83a6ed8
--- /dev/null
@@ -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 (file)
index 0000000..db102e3
--- /dev/null
@@ -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 (file)
index 0000000..a244a83
--- /dev/null
@@ -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