[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