#!/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}))