From 8feb06cc193c474d652a811626cb15bc22c51da7 Mon Sep 17 00:00:00 2001 From: Gabriel Ebner Date: Sun, 11 Aug 2024 16:10:29 -0700 Subject: [PATCH] First version. --- pass2bitwarden.py | 129 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100755 pass2bitwarden.py diff --git a/pass2bitwarden.py b/pass2bitwarden.py new file mode 100755 index 0000000..210a5bb --- /dev/null +++ b/pass2bitwarden.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +import os +from pathlib import Path +import subprocess +from datetime import datetime, UTC +import re +from tqdm import tqdm +import json + +def run(*cmd, **kwargs): + return subprocess.run(cmd, capture_output=True, **kwargs).stdout + +pass_dir = Path.home() / '.password-store' +files = pass_dir.rglob('*.gpg') + +def decode_pass_file(contents: str, fn: str): + if contents.startswith('-----BEGIN PGP') or fn == 'www/gh-2fa-rec.gpg': + return { + 'password': None, + 'totp': None, + 'username': None, + 'url': None, + 'notes': contents, + 'is_pure_note': True, + } + + lines = contents.splitlines() + password = lines[0] if len(lines) > 0 else '' + notes: list[str] = [] + username = None + totp = None + url = None + fields: dict[str, str] = {} + for line in lines[1:]: + if line.startswith('otpauth:'): + if totp is None: + totp = line + continue + if m := re.match('^(user|username): (.*)$', line): + if username is None: + username = m[2] + continue + if m := re.match('^(url): (.*)$', line): + if url is None: + url = m[2] + continue + notes.append(line) + return { + 'password': password, + 'totp': totp, + 'username': username, + 'url': url, + 'notes': '\n'.join(notes) if len(notes) > 0 else None, + } + +def stripstart(s, prefix): + return s[len(prefix):] if s.startswith(prefix) else s + +def stripend(s, suffix): + return s[:-len(suffix)] if s.endswith(suffix) else s + +def dtfmt(d: datetime): + return d.strftime('%Y-%m-%dT%H:%M:%S.000Z') + +def passwd(s): + # if s is not None: return '****' + return s + +def pass2bitwarden(hist: list[tuple[str, datetime]], filename: str): + contents = decode_pass_file(hist[0][0], filename) + name = stripend(filename, '.gpg') + name = stripstart(name, 'www/') + url = contents['url'] + if url is None and re.match(r'^[^/]+\.(com|coffee|org|at|nl|de|jp|st|com|net|uk)$', name): + url = f'https://{name}' + + password_history = [] + last_passwd = contents['password'] + for c, t in hist[1:]: + p = decode_pass_file(c, filename)['password'] + if p == '': + continue # bitwarden does not support empty passwords.... + if p != last_passwd: # skip duplicate history entries + password_history.append({ + 'lastUsedDate': dtfmt(t), + 'password': passwd(p), + }) + last_passwd = p + + if 'is_pure_note' in contents: + return { + 'type': 2, # note + 'creationDate': dtfmt(hist[-1][1]), + 'revisionDate': dtfmt(hist[0][1]), + 'name': name, + 'notes': contents['notes'], + 'secureNote': {'type': 0}, + } + + notes = contents['notes'] + notes = '' if notes is None else notes + '\n' + notes += f'last updated: {hist[0][1]}' + + return { + 'type': 1, # password + 'creationDate': dtfmt(hist[-1][1]), + 'revisionDate': dtfmt(hist[0][1]), + 'name': name, + 'login': { + 'uris': [ { 'match': None, 'uri': url } for url in [url] if url is not None ], + 'username': contents['username'], + 'password': passwd(contents['password']), + 'totp': passwd(contents['totp']), + }, + 'notes': notes, + 'passwordHistory': password_history, + } + +items = [] +for file in tqdm(list(files)): + rfile = file.relative_to(pass_dir) + + hist = run('git', 'log', '--pretty=%H %at', str(rfile), cwd=str(pass_dir)).decode('UTF-8').splitlines() + hist = [ (run('gpg', '--decrypt', '--quiet', input=run('git', 'show', f'{h}:{str(rfile)}', cwd=str(pass_dir))).decode('UTF-8'), + datetime.fromtimestamp(int(t), tz=UTC)) for h in hist for h, t in (h.split(' '),) ] + items.append(pass2bitwarden(hist, str(rfile))) + +print(json.dumps({'items': items})) +