Hello, I saw some previous attempts to export firefox passwords to pass, here is mine. Feedback will be appreciated.
Best, Daniele
>From 90f166d0457e45c39ce6589cb33fc6c79a88bfdc Mon Sep 17 00:00:00 2001 From: Daniele Pizzolli <[email protected]> Date: Sat, 2 Jan 2016 16:23:45 +0100 Subject: [PATCH] Add importer for Password Exporter for Firefox To assist the migration from the default Firefox password store to passff. Add also some basic tests. More info at: - <https://addons.mozilla.org/en-US/firefox/addon/password-exporter> - <https://addons.mozilla.org/en-US/firefox/addon/passff> --- contrib/importers/password-exporter2pass.py | 181 ++++++++++++++++++++++++++++ tests/t0600-import.sh | 73 +++++++++++ 2 files changed, 254 insertions(+) create mode 100755 contrib/importers/password-exporter2pass.py create mode 100755 tests/t0600-import.sh diff --git a/contrib/importers/password-exporter2pass.py b/contrib/importers/password-exporter2pass.py new file mode 100755 index 0000000..135feda --- /dev/null +++ b/contrib/importers/password-exporter2pass.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (C) 2016 Daniele Pizzolli <[email protected]> +# +# This file is licensed under GPLv2+. Please see COPYING for more +# information. + +"""Import password(s) exported by Password Exporter for Firefox in +csv format to pass format. Supports Password Exporter format 1.1. +""" + +import argparse +import base64 +import csv +import sys +import subprocess + + +PASS_PROG = 'pass' +DEFAULT_USERNAME = 'login' + + +def main(): + "Parse the arguments and run the passimport with appropriate arguments." + description = """\ + Import password(s) exported by Password Exporter for Firefox in csv + format to pass format. Supports Password Exporter format 1.1. + + Check the first line of your exported file. + + Must start with: + + # Generated by Password Exporter; Export format 1.1; + + Support obfuscated export (wrongly called encrypted by Password Exporter). + + It should help you to migrate from the default Firefox password + store to passff. + + Please note that Password Exporter or passff may have problem with + fields containing characters like " or :. + + More info at: + <https://addons.mozilla.org/en-US/firefox/addon/password-exporter> + <https://addons.mozilla.org/en-US/firefox/addon/passff> + """ + parser = argparse.ArgumentParser(description=description) + parser.add_argument( + "filepath", type=str, + help="The password Exporter generated file") + parser.add_argument( + "-p", "--prefix", type=str, + help="Prefix for pass store path, you may want to use: sites") + parser.add_argument( + "-d", "--force", action="store_true", + help="Call pass with --force option") + parser.add_argument( + "-v", "--verbose", action="store_true", + help="Show pass output") + parser.add_argument( + "-q", "--quiet", action="store_true", + help="No output") + + args = parser.parse_args() + + passimport(args.filepath, prefix=args.prefix, force=args.force, + verbose=args.verbose, quiet=args.quiet) + + +def passimport(filepath, prefix=None, force=False, verbose=False, quiet=False): + "Import the password from filepath to pass" + with open(filepath, 'rb') as csvfile: + # Skip the first line if starts with a comment, as usually are + # file exported with Password Exporter + first_line = csvfile.readline() + + if not first_line.startswith( + '# Generated by Password Exporter; Export format 1.1;'): + sys.exit('Input format not supported') + + # Auto detect if the file is obfuscated + obfuscation = False + if first_line.startswith( + ('# Generated by Password Exporter; ' + 'Export format 1.1; Encrypted: true')): + obfuscation = True + + if not first_line.startswith('#'): + csvfile.seek(0) + + reader = csv.DictReader(csvfile, delimiter=',', quotechar='"') + for row in reader: + try: + username = row['username'] + password = row['password'] + + if obfuscation: + username = base64.b64decode(row['username']) + password = base64.b64decode(row['password']) + + # Not sure if some fiel can be empty, anyway tries to be + # reasonably safe + text = '{}\n'.format(password) + if row['passwordField']: + text += '{}: {}\n'.format(row['passwordField'], password) + if username: + text += '{}: {}\n'.format( + row.get('usernameField', DEFAULT_USERNAME), username) + if row['hostname']: + text += 'Hostname: {}\n'.format(row['hostname']) + if row['httpRealm']: + text += 'httpRealm: {}\n'.format(row['httpRealm']) + if row['formSubmitURL']: + text += 'formSubmitURL: {}\n'.format(row['formSubmitURL']) + + # Remove the protocol prefix for http(s) + simplename = row['hostname'].replace( + 'https://', '').replace('http://', '') + + # Rough protection for fancy username like ā; rm -Rf /\nā + userpath = "".join(x for x in username if x.isalnum()) + # TODO add some escape/protection also to the hostname + storename = '{}@{}'.format(userpath, simplename) + storepath = storename + + if prefix: + storepath = '{}/{}'.format(prefix, storename) + + cmd = [PASS_PROG, 'insert', '--multiline'] + + if force: + cmd.append('--force') + + cmd.append(storepath) + + proc = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + stdout, stderr = proc.communicate(text) + retcode = proc.wait() + + # TODO: please note that sometimes pass does not return an + # error + # + # After this command: + # + # pass git config --bool --add pass.signcommits true + # + # pass import will fail with: + # + # gpg: skipped "First Last <[email protected]>": + # secret key not available + # gpg: signing failed: secret key not available + # error: gpg failed to sign the data + # fatal: failed to write commit object + # + # But the retcode is still 0. + # + # Workaround: add the first signing key id explicitly with: + # + # SIGKEY=$(gpg2 --list-keys --with-colons [email protected] | \ + # awk -F : '/:s:$/ {printf "0x%s\n", $5; exit}') + # pass git config --add user.signingkey "${SIGKEY}" + + if retcode: + print 'command {}" failed with exit code {}: {}'.format( + " ".join(cmd), retcode, stdout + stderr) + + if not quiet: + print 'Imported {}'.format(storepath) + + if verbose: + print stdout + stderr + except: + print 'Error: corrupted line: {}'.format(row) + +if __name__ == '__main__': + main() diff --git a/tests/t0600-import.sh b/tests/t0600-import.sh new file mode 100755 index 0000000..4b0debe --- /dev/null +++ b/tests/t0600-import.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash + +test_description='Import check' +cd "$(dirname "$0")" +. ./setup.sh + +# TODO: maybe the usage of trap is not suitable for sharness +# TODO: maybe is possible to deduplicate some test code + +test_expect_success 'Import using password-exporter2pass not obfuscated' ' + "$PASS" init $KEY1 && + tf=$(mktemp) && + cleanup() { rm -f -- "\${tf}"; } && + trap cleanup EXIT && + trap "exit \$?" HUP INT QUIT KILL PIPE TERM && + cat <<-"EOF" > "${tf}" && + # Generated by Password Exporter; Export format 1.1; Encrypted: false + "hostname","username","password","formSubmitURL","httpRealm","usernameField","passwordField" + "https://example.com","username","password0","https://example.com","REALM","login","password" + "https://example.net","username","password1","https://example.net","REALM","login","password" + EOF + ../../contrib/importers/password-exporter2pass.py ${tf} && + [[ $("$PASS" show [email protected] | head -n1) == "password0" ]] && + [[ $("$PASS" show [email protected] | head -n1) == "password1" ]] +' +test_expect_success 'Import using password-exporter2pass obfuscated' ' + "$PASS" init $KEY2 && + tf=$(mktemp) && + cleanup() { rm -f -- "\${tf}"; } && + trap cleanup EXIT && + trap "exit \$?" HUP INT QUIT KILL PIPE TERM && + cat <<-"EOF" > "${tf}" && + # Generated by Password Exporter; Export format 1.1; Encrypted: true + "hostname","username","password","formSubmitURL","httpRealm","usernameField","passwordField" + "https://example.com","dXNlcm5hbWUx","cGFzc3dvcmQw","https://example.com","REALM","login","password" + "https://example.net","dXNlcm5hbWUx","cGFzc3dvcmQx","https://example.net","REALM","login","password" + EOF + ../../contrib/importers/password-exporter2pass.py ${tf} && + [[ $("$PASS" show [email protected] | head -n1) == "password0" ]] && + [[ $("$PASS" show [email protected] | head -n1) == "password1" ]] +' + +test_expect_success 'Import using password-exporter2pass from corrupted file' ' + "$PASS" init $KEY3 && + tf=$(mktemp) && + cleanup() { rm -f -- "\${tf}"; } && + trap cleanup EXIT && + trap "exit \$?" HUP INT QUIT KILL PIPE TERM && + cat <<-"EOF" > "${tf}" && + # Generated by Password Exporter; Export format 1.1; Encrypted: false + "hostname","username","password","formSubmitURL","httpRealm","usernameField","passwordField" + "https://example.net","username2" + "https://example.com","username2","password0","https://example.com","REALM","login","password" + EOF + ../../contrib/importers/password-exporter2pass.py ${tf} && + [[ $("$PASS" show [email protected] | head -n1) == "password0" ]] +' +test_expect_success 'Import using password-exporter2pass from corrupted file 2' ' + "$PASS" init $KEY4 && + tf=$(mktemp) && + cleanup() { rm -f -- "\${tf}"; } && + trap cleanup EXIT && + trap "exit \$?" HUP INT QUIT KILL PIPE TERM && + cat <<-"EOF" > "${tf}" && + # Generated by Password Exporter; Export format 1.1; Encrypted: false + "hostname ERROR","username","password","formSubmitURL","httpRealm","usernameField","passwordField" + "https://example.com","username3","password0","https://example.com","REALM","login","password" + EOF + ../../contrib/importers/password-exporter2pass.py ${tf} && + '!' [[ $("$PASS" show [email protected] | head -n1) == "password0" ]] +' + +test_done -- 2.1.4
_______________________________________________ Password-Store mailing list [email protected] http://lists.zx2c4.com/mailman/listinfo/password-store
