--- /dev/null
+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"
build/
dist/
*.egg-info/
+junit/
+.vscode
+.coverage
+pytest.xml
``` 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
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
``` 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.
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
<!-- 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.
<!-- end list -->
``` bash
-ffpass export --to passwords.csv
+ffpass export --file passwords.csv
```
### Import in Google Chrome
#!/usr/bin/env python3
-
"""
The MIT License (MIT)
Copyright (c) 2018 Louis Abraham <louis.abraham@yahoo.fr>
\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
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
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=""):
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("""
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]
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)
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"]:
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
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,
}
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
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__,
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,
)
"--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)
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__":
--- /dev/null
+{"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
--- /dev/null
+{"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
--- /dev/null
+{"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
--- /dev/null
+{"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
--- /dev/null
+#!/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')
--- /dev/null
+#!/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