[PATCH] importer: protonpass2json: from ProtonPass JSON

Alexei Colin ac at alexeicolin.com
Mon Jun 24 03:21:18 UTC 2024


Signed-off-by: Alexei Colin <ac at alexeicolin.com>
---
Please accept yet another liberator script.

I should have added this to pass-import extension instead of this
standalone script, but didn't realize it existed until after was done.

Please consider a simple edit to the website in the section "Migrating
to pass": it would help to mention the pass-import extension in this
section specifically, in addition to its listing in the extensions
section above:

    To free password data from the clutches of other (bloated) password
    managers, various users have come up with different password store
    organizations that work best for them. The [pass-import] extension
    supports importing passwords from many such programs. Alternatively,
    some users have contributed standalone scripts to help with the import:

One more correction to the website: "browserpass: Chrome plugin" --
browser pass supports both Firefox and Chrome. Also, it seems to be more
mature compared to passff, so it is worth pointing Firefox users to it.

Thank you.


 contrib/importers/protonpass2json2pass.py | 242 ++++++++++++++++++++++
 1 file changed, 242 insertions(+)
 create mode 100755 contrib/importers/protonpass2json2pass.py

diff --git a/contrib/importers/protonpass2json2pass.py b/contrib/importers/protonpass2json2pass.py
new file mode 100755
index 0000000..ad9cd21
--- /dev/null
+++ b/contrib/importers/protonpass2json2pass.py
@@ -0,0 +1,242 @@
+#!/usr/bin/env python3
+
+# Copyright 2015 David Francoeur <dfrancoeur04 at gmail.com>
+# Copyright 2017 Nathan Sommer <nsommer at wooster.edu>
+# Copyright 2024 Alexei Colin <ac at alexeicolin.com>
+#
+# This file is licensed under the GPLv2+. Please see COPYING for more
+# information.
+#
+# ProtonPass exports to JSON. Items are grouped into vaults. Each item is
+# imported into a file at path <Vault Name>/<Item Title>/<Username>
+# or, if username is blank, then a file at path <Vault Name>/<Item Name>
+# with the following content:
+#
+# <Password>
+# <OTP URI>
+# user: <Username>
+# url: <URL>
+# passkeys:
+# <serialized JSON object>
+#
+# notes: <Notes>
+#
+# Any missing fields will be omitted from the entry.
+# Items that have multiple URLs have multiple 'url:' lines.
+#
+# The two-factor authentication token (TOTP) URI is imported in the format
+# expected by pass-otp extension. Passkeys are imported as a serialized JSON
+# object (a list of dictionaries); a blank line terminates the object.
+#
+# Notes (rather than login type items) are imported as files without a
+# password (first line is empty) and with only the 'notes:' lines.
+# Multiline notes start on the line following the 'notes:' header line.
+#
+# The vault directory name in the path can be converted to lowercase using the
+# --to-lower switch.
+#
+# Items can be filtered by vault name or by item type by passing regular
+# expressions to --include-vaults, --exclude-vaults, --include-types,
+# --exclude-types. The include filters take precedence over the exclude
+# filters. Use the start and end markers to match a whole word rather
+# than a prefix or suffix, e.g. '^foo$'.
+#
+# Default usage: ./protonpass2json2pass.py input.json
+#
+# To see the full usage: ./protonpass2json2pass.py -h
+
+import argparse
+import json
+import os
+import re
+from subprocess import Popen, PIPE
+import sys
+
+
+class UsageArgParser(argparse.ArgumentParser):
+    """
+    Custom ArgumentParser class which prints the full usage message if the
+    input file is not provided.
+    """
+    def error(self, message):
+        print(message, file=sys.stderr)
+        self.print_help()
+        sys.exit(2)
+
+
+def pass_import_entry(path, data):
+    """Import new password entry to password-store using pass insert command"""
+    proc = Popen(['pass', 'insert', '--multiline', path], stdin=PIPE,
+                 stdout=PIPE)
+    proc.communicate(data.encode('utf8'))
+    proc.wait()
+
+
+def confirmation(prompt):
+    """
+    Ask the user for 'y' or 'n' confirmation and return a boolean indicating
+    the user's choice. Returns True if the user simply presses enter.
+    """
+
+    prompt = '{0} {1} '.format(prompt, '(Y/n)')
+
+    while True:
+        user_input = input(prompt)
+
+        if len(user_input) > 0:
+            first_char = user_input.lower()[0]
+        else:
+            first_char = 'y'
+
+        if first_char == 'y':
+            return True
+        elif first_char == 'n':
+            return False
+
+        print('Please enter y or n')
+
+def sanitize_path(path):
+    """ Replace characters that cannot be part of a file path"""
+    return path.replace("/", "_")
+
+def insert_file_contents(filename, args):
+    """ Read the file and insert each entry """
+
+    entries = []
+
+    with open(filename, 'rb') as fin:
+        jobj = json.load(fin)
+        vaults = jobj['vaults']
+        for _, vault in vaults.items():
+            vault_name = vault['name']
+            if not eval_filter(vault_name, args.include_vaults, args.exclude_vaults):
+                continue
+
+            vault_path = sanitize_path(vault_name)
+            if args.to_lower:
+                vault_path = vault_path.lower()
+            for item in vault['items']:
+                try:
+                    item_path, data = parse_item(item,
+                         args.include_types, args.exclude_types)
+                except:
+                    print("Failed to parse item:", file=sys.stderr)
+                    print(json.dumps(item, indent=4), file=sys.stderr)
+                    raise
+
+                if item_path:
+                    path = os.path.join(vault_path, item_path)
+                    entries.append((path, data))
+
+    if len(entries) == 0:
+        print('No entries to import')
+        return
+
+    print('Entries to import:')
+    for (path, data) in entries:
+        print(path)
+    if confirmation('Proceed?'):
+        for (path, data) in entries:
+            pass_import_entry(path, data)
+            print(path, 'imported!')
+
+
+def parse_item(item, include_types=None, exclude_types=None):
+    item_data = item['data']
+    item_type = item_data['type']
+    metadata = item_data['metadata']
+    content = item_data['content']
+
+    if not eval_filter(item_type, include_types, exclude_types):
+        return None, None
+
+    item_name = metadata['name']
+
+    fields = ""
+    if content:
+        password = content['password']
+
+        # pass-otp extension expects line containing just the otpauth:// URI
+        otp_uri = content['totpUri']
+        if otp_uri:
+            fields += '{}\n'.format(otp_uri)
+
+        username = content['username']
+        if username:
+            fields += 'user: {}\n'.format(username)
+
+        for url in content['urls']:
+            fields += 'url: {}\n'.format(url)
+
+        passkeys = content['passkeys']
+        if passkeys:
+            fields += 'passkeys:\n'
+            fields += json.dumps(passkeys, indent=4)
+            fields += '\n'
+    else:
+        password, username = "", ""
+
+    note = metadata['note']
+    if note:
+        fields += 'notes:'
+        if '\n' in note: # start multiline conent on a new line
+            fields += '\n' # don't use os.linesep to keep content deterministic
+        else:
+            fields += ' '
+        fields += note + '\n'
+
+    # first line must be password, will be blank for notes or if no password
+    data = '{}\n'.format(password)
+    data += fields
+
+    path = sanitize_path(item_name)
+    if username:
+        path = os.path.join(path, sanitize_path(username))
+    return path, data
+
+
+def eval_filter(value, include_re, exclude_re):
+    """Returns whether item should be included based on regexp filters
+
+    Include filter takes precedence over exclude filter."""
+    if include_re:
+        return re.match(include_re, value)
+
+    if exclude_re:
+        return not re.match(exclude_re, value)
+    return True
+
+
+def main():
+    description = 'Import pass entries from an exported ProtonPass JSON file.'
+    parser = UsageArgParser(description=description)
+
+    parser.add_argument('--include-vaults',
+        help="Regular expression to filter out items by type, "
+             "takes precedence over exclude "
+             "(e.g. '^Personal$' to include only the vault named 'Personal')")
+    parser.add_argument('--exclude-vaults',
+        help="Regular expression to filter out vaults by name "
+             "(e.g. '^Notes' to exclude vaults that begin with 'Notes')")
+
+    parser.add_argument('--include-types',
+        help="Regular expression to filter items by type, "
+             "takes precedence over exclude "
+             "(e.g. '^(login|notes)$' to include only logins and notes)")
+    parser.add_argument('--exclude-types',
+        help="Regular expression to filter out items by type " + \
+             "(e.g. '^note$' to exclude all notes)")
+
+    parser.add_argument('--to-lower', action='store_true',
+        help="Convert names to lower case in file paths")
+    parser.add_argument('input_file', help="The JSON file to read from")
+
+    args = parser.parse_args()
+
+    input_file = args.input_file
+    print("File to read:", input_file)
+    insert_file_contents(input_file, args)
+
+
+if __name__ == '__main__':
+    main()
-- 
2.45.2




More information about the Password-Store mailing list