>From 90f166d0457e45c39ce6589cb33fc6c79a88bfdc Mon Sep 17 00:00:00 2001
From: Daniele Pizzolli <daniele.pizzolli@create-net.org>
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 <daniele.pizzolli@create-net.org>
+#
+# 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 <user@example.com>":
+                #    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 user@example.com | \
+                #     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 username@example.com | head -n1) == "password0" ]] &&
+	[[ $("$PASS" show username@example.net | 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 username1@example.com | head -n1) == "password0" ]] &&
+	[[ $("$PASS" show username1@example.net | 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 username@example.com | 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 username3@example.com | head -n1) == "password0" ]]
+'
+
+test_done
-- 
2.1.4

