INITIAL COMMIT
authorlouisabraham <louis.abraham@yahoo.fr>
Fri, 10 Aug 2018 09:02:54 +0000 (11:02 +0200)
committerlouisabraham <louis.abraham@yahoo.fr>
Fri, 10 Aug 2018 09:02:54 +0000 (11:02 +0200)
.gitignore [new file with mode: 0644]
Makefile [new file with mode: 0644]
README.md [new file with mode: 0644]
README.rst [new file with mode: 0644]
ffpass.py [new file with mode: 0755]
setup.py [new file with mode: 0755]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..746645f
--- /dev/null
@@ -0,0 +1,6 @@
+__pycache__/
+*.py[cod]
+
+build/
+dist/
+*.egg-info/
diff --git a/Makefile b/Makefile
new file mode 100644 (file)
index 0000000..a1622c0
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,13 @@
+pypi: dist
+       twine upload dist/*
+       
+dist: flake8
+       -rm dist/*
+       ./setup.py sdist bdist_wheel
+
+flake8:
+       flake8 . --count --select=E901,E999,F821,F822,F823 --show-source --statistics
+       flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
+
+doc:
+       pandoc README.md -o README.rst
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644 (file)
index 0000000..f137fdd
--- /dev/null
+++ b/README.md
@@ -0,0 +1,132 @@
+<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.2.0/css/all.css" integrity="sha384-hWVjflwFxL6sNzntih27bfxkr27PmbbK/iSvJ+a4+0owXq79v+lsFkW54bOGbiDQ" crossorigin="anonymous">
+
+# ffpass - Import and Export passwords for Firefox Quantum
+
+The latest version of Firefox doesn’t allow to import or export the
+stored logins and passwords.
+
+This tools interacts with the encrypted password database of Firefox to
+provide these features.
+
+## Installation
+
+``` bash
+pip install ffpass
+```
+
+## Features
+
+  - Support master passwords
+  - Automatic profile selection for Linux and macOS
+  - Export to CSV
+  - Import from CSV compatible with Google Chrome
+
+## Export to CSV
+
+``` bash
+ffpass export > passwords.csv
+ffpass export -t passwords.csv
+ffpass export --to passwords.csv
+```
+
+### Usage
+
+    usage: ffpass export [-h] [-t TO_FILE] [-d DIRECTORY] [-v]
+    
+    outputs a CSV with header `url,username,password`
+    
+    optional arguments:
+      -h, --help            show this help message and exit
+      -t TO_FILE, --to TO_FILE
+      -d DIRECTORY, --directory DIRECTORY, --dir DIRECTORY
+                            Firefox profile directory
+      -v, --verbose
+
+## Import from CSV
+
+``` bash
+ffpass import < passwords.csv
+ffpass import -f passwords.csv
+ffpass import --from passwords.csv
+```
+
+By default, it works with the passwords exported from Google Chrome.
+
+### Usage
+
+    usage: ffpass import [-h] [-f FROM_FILE] [-d DIRECTORY] [-v]
+    
+    imports a CSV with columns `url,username,password` (order insensitive)
+    
+    optional arguments:
+      -h, --help            show this help message and exit
+      -f FROM_FILE, --from FROM_FILE
+      -d DIRECTORY, --directory DIRECTORY, --dir DIRECTORY
+                            Firefox profile directory
+      -v, --verbose
+
+## Transfer from Google Chrome to Firefox
+
+### Export from Google Chrome
+
+1.  Open Chrome and enter the following in the address bar:
+    `chrome://flags/#PasswordExport`
+2.  Click Default next to “Password export” and choose Enabled.
+3.  Click Relaunch Now. Chrome will restart.
+4.  Click the Chrome menu <i class="fa fa-ellipsis-v"></i> in the
+    toolbar and choose Settings.
+5.  Scroll to the bottom and click Advanced.
+6.  Scroll to the “Passwords and forms” section and click “Manage
+    passwords”.
+7.  Click <i class="fa fa-ellipsis-v"></i> next to Saved Passwords and
+    choose Export.
+8.  Click Export Passwords, enter the password you use to log in to your
+    computer, and save the file to `passwords.csv` (or any other
+    available name).
+
+*(instructions from https://support.1password.com/import-chrome/)*
+
+### Import in Firefox
+
+``` bash
+ffpass import --from passwords.csv
+```
+
+## Transfer from Firefox to Google Chrome
+
+### Export from Firefox
+
+``` bash
+ffpass export --to passwords.csv
+```
+
+### Import in Google Chrome
+
+1.  Open Chrome and enter the following in the address bar:
+    `chrome://flags/#PasswordImport`
+2.  Click Default next to “Password import” and choose Enabled.
+3.  Click Relaunch Now. Chrome will restart.
+4.  Click the Chrome menu <i class="fa fa-ellipsis-v"></i> in the
+    toolbar and choose Settings.
+5.  Scroll to the bottom and click Advanced.
+6.  Scroll to the “Passwords and forms” section and click “Manage
+    passwords”.
+7.  Click <i class="fa fa-ellipsis-v"></i> next to Saved Passwords and
+    choose Import.
+8.  Select the file `passwords.csv` and click Import.
+
+## Troubleshoot
+
+  - `ffpass export: error: the following arguments are required:
+    -d/--directory/--dir`
+    
+    It means either that (launch with option `--verbose` to
+        know):
+    
+        - Automatic profile selection is not supported for your platform.
+        - There is more than one user profile for Firefox.
+    
+    You have to provide the `--dir` option with your Firefox Profile
+    Folder. To find it, follow these
+    [instructions](https://support.mozilla.org/en-US/kb/profiles-where-firefox-stores-user-data#w_how-do-i-find-my-profile)
+    on the website of Firefox.
diff --git a/README.rst b/README.rst
new file mode 100644 (file)
index 0000000..2aa3ed5
--- /dev/null
@@ -0,0 +1,146 @@
+ffpass - Import and Export passwords for Firefox Quantum
+========================================================
+
+The latest version of Firefox doesn’t allow to import or export the
+stored logins and passwords.
+
+This tools interacts with the encrypted password database of Firefox to
+provide these features.
+
+Installation
+------------
+
+.. code:: bash
+
+   pip install ffpass
+
+Features
+--------
+
+-  Support master passwords
+-  Automatic profile selection for Linux and macOS
+-  Export to CSV
+-  Import from CSV compatible with Google Chrome
+
+Export to CSV
+-------------
+
+.. code:: bash
+
+   ffpass export > passwords.csv
+   ffpass export -t passwords.csv
+   ffpass export --to passwords.csv
+
+Usage
+~~~~~
+
+::
+
+   usage: ffpass export [-h] [-t TO_FILE] [-d DIRECTORY] [-v]
+
+   outputs a CSV with header `url,username,password`
+
+   optional arguments:
+     -h, --help            show this help message and exit
+     -t TO_FILE, --to TO_FILE
+     -d DIRECTORY, --directory DIRECTORY, --dir DIRECTORY
+                           Firefox profile directory
+     -v, --verbose
+
+Import from CSV
+---------------
+
+.. code:: bash
+
+   ffpass import < passwords.csv
+   ffpass import -f passwords.csv
+   ffpass import --from passwords.csv
+
+By default, it works with the passwords exported from Google Chrome.
+
+.. _usage-1:
+
+Usage
+~~~~~
+
+::
+
+   usage: ffpass import [-h] [-f FROM_FILE] [-d DIRECTORY] [-v]
+
+   imports a CSV with columns `url,username,password` (order insensitive)
+
+   optional arguments:
+     -h, --help            show this help message and exit
+     -f FROM_FILE, --from FROM_FILE
+     -d DIRECTORY, --directory DIRECTORY, --dir DIRECTORY
+                           Firefox profile directory
+     -v, --verbose
+
+Transfer from Google Chrome to Firefox
+--------------------------------------
+
+Export from Google Chrome
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+1. Open Chrome and enter the following in the address bar:
+   ``chrome://flags/#PasswordExport``
+2. Click Default next to “Password export” and choose Enabled.
+3. Click Relaunch Now. Chrome will restart.
+4. Click the Chrome menu in the toolbar and choose Settings.
+5. Scroll to the bottom and click Advanced.
+6. Scroll to the “Passwords and forms” section and click “Manage
+   passwords”.
+7. Click next to Saved Passwords and choose Export.
+8. Click Export Passwords, enter the password you use to log in to your
+   computer, and save the file to ``passwords.csv`` (or any other
+   available name).
+
+*(instructions from https://support.1password.com/import-chrome/)*
+
+Import in Firefox
+~~~~~~~~~~~~~~~~~
+
+.. code:: bash
+
+   ffpass import --from passwords.csv
+
+Transfer from Firefox to Google Chrome
+--------------------------------------
+
+Export from Firefox
+~~~~~~~~~~~~~~~~~~~
+
+.. code:: bash
+
+   ffpass export --to passwords.csv
+
+Import in Google Chrome
+~~~~~~~~~~~~~~~~~~~~~~~
+
+1. Open Chrome and enter the following in the address bar:
+   ``chrome://flags/#PasswordImport``
+2. Click Default next to “Password import” and choose Enabled.
+3. Click Relaunch Now. Chrome will restart.
+4. Click the Chrome menu in the toolbar and choose Settings.
+5. Scroll to the bottom and click Advanced.
+6. Scroll to the “Passwords and forms” section and click “Manage
+   passwords”.
+7. Click next to Saved Passwords and choose Import.
+8. Select the file ``passwords.csv`` and click Import.
+
+Troubleshoot
+------------
+
+-  ``ffpass export: error: the following arguments are required: -d/--directory/--dir``
+
+   It means either that (launch with option ``--verbose`` to know):
+
+   ::
+
+      - Automatic profile selection is not supported for your platform.
+      - There is more than one user profile for Firefox.
+
+   You have to provide the ``--dir`` option with your Firefox Profile
+   Folder. To find it, follow these
+   `instructions <https://support.mozilla.org/en-US/kb/profiles-where-firefox-stores-user-data#w_how-do-i-find-my-profile>`__
+   on the website of Firefox.
diff --git a/ffpass.py b/ffpass.py
new file mode 100755 (executable)
index 0000000..26a312a
--- /dev/null
+++ b/ffpass.py
@@ -0,0 +1,296 @@
+#!/usr/bin/env python3
+
+"""
+The MIT License (MIT)
+Copyright (c) 2018 Louis Abraham <louis.abraham@yahoo.fr>
+
+\x1B[34m\033[F\033[F
+
+ffpass can import and export passwords from Firefox Quantum.
+
+\x1B[0m\033[1m\033[F\033[F
+
+example of usage:
+
+    ffpass export --to passwords.csv
+    
+    ffpass import --from passwords.csv
+
+\033[0m\033[F\033[F
+"""
+
+import sys
+from base64 import b64decode, b64encode
+from hashlib import sha1
+import hmac
+import argparse
+import json
+from pathlib import Path
+import csv
+import secrets
+from getpass import getpass
+from uuid import uuid4
+from datetime import datetime
+import configparser
+from urllib.parse import urlparse
+import sqlite3
+
+from pyasn1.codec.der.decoder import decode as der_decode
+from pyasn1.codec.der.encoder import encode as der_encode
+from pyasn1.type.univ import Sequence, OctetString, ObjectIdentifier
+from Crypto.Cipher import DES3
+
+
+MAGIC1 = b'\xf8\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01'
+MAGIC2 = (1, 2, 840, 113549, 3, 7)
+
+
+class WrongPassword(Exception):
+    pass
+
+
+def getKey(directory, masterPassword=''):
+    # firefox 58.0.2 / NSS 3.35 with key4.db in SQLite
+    conn = sqlite3.connect((directory/'key4.db').as_posix())
+    c = conn.cursor()
+    # first check password
+    c.execute("SELECT item1,item2 FROM metadata WHERE id = 'password';")
+    row = next(c)
+    globalSalt = row[0]  # item1
+    item2 = row[1]
+    decodedItem2, _ = der_decode(item2)
+    entrySalt = decodedItem2[0][1][0].asOctets()
+    cipherT = decodedItem2[1].asOctets()
+    clearText = decrypt3DES(globalSalt, masterPassword,
+                            entrySalt, cipherT)  # usual Mozilla PBE
+    if clearText != b'password-check\x02\x02':
+        raise WrongPassword()
+    if args.verbose:
+        print('password checked', file=sys.stderr)
+    # decrypt 3des key to decrypt "logins.json" content
+    c.execute("SELECT a11,a102 FROM nssPrivate;")
+    row = next(c)
+    a11 = row[0]  # CKA_VALUE
+    assert row[1] == MAGIC1  # CKA_ID
+    decodedA11, _ = der_decode(a11)
+    entrySalt = decodedA11[0][1][0].asOctets()
+    cipherT = decodedA11[1].asOctets()
+    key = decrypt3DES(globalSalt, masterPassword, entrySalt, cipherT)
+    if args.verbose:
+        print('3deskey', key.hex(), file=sys.stderr)
+    return key[:24]
+
+
+def PKCS7pad(b):
+    l = (-len(b) - 1) % 8 + 1
+    return b + bytes([l]*l)
+
+
+def PKCS7unpad(b):
+    return b[:-b[-1]]
+
+
+def decrypt3DES(globalSalt, masterPassword, entrySalt, encryptedData):
+    hp = sha1(globalSalt+masterPassword.encode()).digest()
+    pes = entrySalt + b'\x00'*(20-len(entrySalt))
+    chp = sha1(hp+entrySalt).digest()
+    k1 = hmac.new(chp, pes+entrySalt, sha1).digest()
+    tk = hmac.new(chp, pes, sha1).digest()
+    k2 = hmac.new(chp, tk+entrySalt, sha1).digest()
+    k = k1+k2
+    iv = k[-8:]
+    key = k[:24]
+    if args.verbose:
+        print('key='+key.hex(), 'iv='+iv.hex(), file=sys.stderr)
+    return DES3.new(key, DES3.MODE_CBC, iv).decrypt(encryptedData)
+
+
+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()
+
+
+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)
+    return b64encode(der_encode(asn1data)).decode()
+
+
+def getJsonLogins(directory):
+    with open(directory / 'logins.json', 'r') as loginf:
+        jsonLogins = json.load(loginf)
+    return jsonLogins
+
+
+def dumpJsonLogins(directory, jsonLogins):
+    with open(directory / 'logins.json', 'w') as loginf:
+        json.dump(jsonLogins, loginf, separators=',:')
+
+
+def exportLogins(key, jsonLogins):
+    if 'logins' not in jsonLogins:
+        print("error: no 'logins' key in logins.json", file=sys.stderr)
+        return []
+    logins = []
+    for row in jsonLogins['logins']:
+        encUsername = row['encryptedUsername']
+        encPassword = row['encryptedPassword']
+        logins.append((row['hostname'],
+                       decodeLoginData(key, encUsername),
+                       decodeLoginData(key, encPassword)))
+    return logins
+
+
+def readCSV(from_file):
+    logins = []
+    reader = csv.DictReader(from_file)
+    for row in reader:
+        logins.append((rawURL(row['url']),
+                       row['username'],
+                       row['password']))
+    return logins
+
+
+def rawURL(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)
+    for i, (url, username, password) in enumerate(logins, nextId):
+        entry = {
+            'id': i,
+            'hostname': url,
+            'httpRealm': None,
+            'formSubmitURL': '',
+            'usernameField': '',
+            'passwordField': '',
+            'encryptedUsername': encodeLoginData(key, username),
+            'encryptedPassword': encodeLoginData(key, password),
+            'guid': '{%s}' % uuid4(),
+            'encType': 1,
+            'timeCreated': timestamp,
+            'timeLastUsed': timestamp,
+            'timePasswordChanged': timestamp,
+            'timesUsed': 0
+        }
+        jsonLogins['logins'].append(entry)
+    jsonLogins['nextId'] = i + 1
+
+
+def guessDir():
+    dirs = {
+        'darwin': '~/Library/Application Support/Firefox',
+        'linux2': '~/.mozilla/firefox'
+    }
+    if sys.platform in dirs:
+        path = Path(dirs[sys.platform]).expanduser()
+        config = configparser.ConfigParser()
+        config.read(path / 'profiles.ini')
+        if len(config.sections()) == 2:
+            profile = config[config.sections()[1]]
+            ans = path / profile['Path']
+            if args.verbose:
+                print('Using profile:', ans, file=sys.stderr)
+            return ans
+        else:
+            if args.verbose:
+                print('There is more than one profile', file=sys.stderr)
+    elif args.verbose:
+        print('Automatic profile selection not supported for platform',
+              sys.platform, file=sys.stderr)
+
+
+def askpass(directory):
+    password = ''
+    while True:
+        try:
+            key = getKey(directory, password)
+        except WrongPassword:
+            password = getpass('Master Password:')
+        else:
+            break
+    return key
+
+
+def main_export(args):
+    key = askpass(args.directory)
+    jsonLogins = getJsonLogins(args.directory)
+    logins = exportLogins(key, jsonLogins)
+    writer = csv.writer(args.to_file)
+    writer.writerow(['url', 'username', 'password'])
+    writer.writerows(logins)
+
+
+def main_import(args):
+    if args.from_file == sys.stdin:
+        try:
+            key = getKey(args.directory)
+        except WrongPassword:
+            print('Password is not empty. You have to specify FROM_FILE.',
+                  file=sys.stderr)
+            sys.exit(1)
+    else:
+        key = askpass(args.directory)
+    jsonLogins = getJsonLogins(args.directory)
+    logins = readCSV(args.from_file)
+    addNewLogins(key, jsonLogins, logins)
+    dumpJsonLogins(args.directory, jsonLogins)
+
+
+def makeParser(required_dir):
+    parser = argparse.ArgumentParser(prog='ffpass', description=__doc__,
+                                     formatter_class=argparse.RawDescriptionHelpFormatter)
+    subparsers = parser.add_subparsers(dest='mode')
+    subparsers.required = True
+
+    parser_export = subparsers.add_parser(
+        'export', description='outputs a CSV with header `url,username,password`')
+    parser_import = subparsers.add_parser(
+        'import', description='imports a CSV with columns `url,username,password` (order insensitive)')
+
+    parser_import.add_argument('-f', '--from', dest='from_file',
+                               type=argparse.FileType('r'), default=sys.stdin)
+    parser_export.add_argument('-t', '--to', dest='to_file',
+                               type=argparse.FileType('w'), default=sys.stdout)
+
+    for sub in subparsers.choices.values():
+        sub.add_argument('-d', '--directory', '--dir', type=Path, required=required_dir,
+                         default=None, help='Firefox profile directory')
+        sub.add_argument('-v', '--verbose', action='store_true')
+
+    parser_import.set_defaults(func=main_import)
+    parser_export.set_defaults(func=main_export)
+    return parser
+
+
+def main():
+    global args
+    args = makeParser(False).parse_args()
+    guessed_dir = guessDir()
+    if args.directory is None:
+        if guessed_dir is None:
+            args = makeParser(True).parse_args()
+        else:
+            args.directory = guessed_dir
+    args.directory = args.directory.expanduser()
+    args.func(args)
+
+
+if __name__ == '__main__':
+    main()
diff --git a/setup.py b/setup.py
new file mode 100755 (executable)
index 0000000..2e482c6
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,28 @@
+#!/usr/bin/env python3
+
+import os
+from setuptools import setup
+
+
+def read(fname):
+    return open(os.path.join(os.path.dirname(__file__), fname)).read()
+
+
+setup(
+    name='ffpass',
+    version='0.2.1',
+    author='Louis Abraham',
+    license='MIT',
+    author_email='louis.abraham@yahoo.fr',
+    description='Import and Export passwords for Firefox',
+    long_description=read('README.rst'),
+    url='https://github.com/louisabraham/ffpass',
+    use_scm_version=False,
+    install_requires=['pyasn1', 'Crypto'],
+    python_requires='>=3',
+    entry_points={'console_scripts': ['ffpass = ffpass:main']},
+    classifiers=[
+        'Topic :: Utilities',
+        'Topic :: Security :: Cryptography'
+    ],
+)