bitwarden-to-keepass/bitwarden-to-keepass.py
jabashque 1c548daaf4 Overhaul group and subgroup creation to better match Bitwarden behavior
In Bitwarden, one could make it so their account only has a certain set
of folders, like so:
- testfolder/a
- testfolder/a/b/c
- testfolder/a/b/c/d

The resulting hierarchy would look like this:
testfolder/a
`- b/c
   `- d

As shown above, Bitwarden allows one to name a folder something like
"foo/bar" even if a folder "foo" doesn't exist. Bitwarden handles this
by just putting this folder in the root with the name as-is.

In addition, as shown with "testfolder/a/b/c", a folder name may not
necessarily be just anything that comes after the last `/` delimiter.
Instead, it's dependent on which ever existing folder happens to match
the most of the folder's name's prefix.

As such, the following references were used to replicate this behavior
in bitwarden-to-keepass:
ecdd08624f/common/src/services/folder.service.ts (L108)
ecdd08624f/common/src/misc/serviceUtils.ts (L7)
2021-07-26 09:44:11 -07:00

180 lines
6.9 KiB
Python

import json
import logging
import os
import re
import subprocess
from argparse import ArgumentParser
from typing import Dict, List, Optional
from shlex import quote
from pykeepass import PyKeePass, create_database
from pykeepass.exceptions import CredentialsError
from pykeepass.group import Group as KPGroup
import folder as FolderType
from item import Item, Types as ItemTypes
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s :: %(levelname)s :: %(message)s',
datefmt='%Y-%m-%d %H:%M:%S',
)
kp: Optional[PyKeePass] = None
def bitwarden_to_keepass(args):
global kp
try:
kp = PyKeePass(args.database_path, password=args.database_password, keyfile=args.database_keyfile)
except FileNotFoundError:
logging.info('KeePass database does not exist, creating a new one.')
kp = create_database(args.database_path, password=args.database_password, keyfile=args.database_keyfile)
except CredentialsError as e:
logging.error(f'Wrong password for KeePass database: {e}')
return
folders = subprocess.check_output(f'{quote(args.bw_path)} list folders --session {quote(args.bw_session)}', shell=True, encoding='utf8')
folders = json.loads(folders)
groups_by_id = load_folders(folders)
logging.info(f'Folders done ({len(groups_by_id)}).')
items = subprocess.check_output(f'{quote(args.bw_path)} list items --session {quote(args.bw_session)}', shell=True, encoding='utf8')
items = json.loads(items)
logging.info(f'Starting to process {len(items)} items.')
for item in items:
if item['type'] in [ItemTypes.CARD, ItemTypes.IDENTITY]:
logging.warning(f'Skipping credit card or identity item "{item["name"]}".')
continue
bw_item = Item(item)
is_duplicate_title = False
try:
while True:
entry_title = bw_item.get_name() if not is_duplicate_title else '{name} - ({item_id}'.format(name=bw_item.get_name(), item_id=bw_item.get_id())
try:
entry = kp.add_entry(
destination_group=groups_by_id[bw_item.get_folder_id()],
title=entry_title,
username=bw_item.get_username(),
password=bw_item.get_password(),
notes=bw_item.get_notes()
)
break
except Exception as e:
if 'already exists' in str(e):
is_duplicate_title = True
continue
raise
totp_secret, totp_settings = bw_item.get_totp()
if totp_secret and totp_settings:
entry.set_custom_property('TOTP Seed', totp_secret)
entry.set_custom_property('TOTP Settings', totp_settings)
for uri in bw_item.get_uris():
entry.url = uri['uri']
break # todo append additional uris to notes?
for field in bw_item.get_custom_fields():
entry.set_custom_property(field['name'], field['value'])
for attachment in bw_item.get_attachments():
attachment_tmp_path = f'/tmp/attachment/{attachment["fileName"]}'
attachment_path = subprocess.check_output(f'{quote(args.bw_path)} get attachment'
f' --raw {quote(attachment["id"])} '
f'--itemid {quote(bw_item.get_id())} '
f'--output {quote(attachment_tmp_path)} --session {quote(args.bw_session)}', shell=True, encoding='utf8').rstrip()
attachment_id = kp.add_binary(open(attachment_path, 'rb').read())
entry.add_attachment(attachment_id, attachment['fileName'])
os.remove(attachment_path)
except Exception as e:
logging.warning(f'Skipping item named "{item["name"]}" because of this error: {repr(e)}')
continue
logging.info('Saving changes to KeePass database.')
kp.save()
logging.info('Export completed.')
def load_folders(folders) -> Dict[str, KPGroup]:
# sort folders so that in the case of nested folders, the parents would be guaranteed to show up before the children
folders.sort(key=lambda x: x['name'])
# dict to store mapping of Bitwarden folder id to keepass group
groups_by_id: Dict[str, KPGroup] = {}
# build up folder tree
folder_root: FolderType.Folder = FolderType.Folder(None)
folder_root.keepass_group = kp.root_group
groups_by_id[None] = kp.root_group
for folder in folders:
if folder['id'] is not None:
new_folder: FolderType.Folder = FolderType.Folder(folder['id'])
# regex lifted from https://github.com/bitwarden/jslib/blob/ecdd08624f61ccff8128b7cb3241f39e664e1c7f/common/src/services/folder.service.ts#L108
folder_name_parts: List[str] = re.sub(r'^\/+|\/+$', '', folder['name']).split('/')
FolderType.nested_traverse_insert(folder_root, folder_name_parts, new_folder, '/')
# create keepass groups based off folder tree
def add_keepass_group(folder: FolderType.Folder):
parent_group: KPGroup = folder.parent.keepass_group
new_group: KPGroup = kp.add_group(parent_group, folder.name)
folder.keepass_group = new_group
groups_by_id[folder.id] = new_group
FolderType.bfs_traverse_execute(folder_root, add_keepass_group)
return groups_by_id
def check_args(args):
if args.database_keyfile:
if not os.path.isfile(args.database_keyfile) or not os.access(args.database_keyfile, os.R_OK):
logging.error('Key File for KeePass database is not readable.')
return False
if not os.path.isfile(args.bw_path) or not os.access(args.bw_path, os.X_OK):
logging.error('bitwarden-cli was not found or not executable. Did you set correct \'--bw-path\'?')
return False
return True
def environ_or_required(key):
return (
{'default': os.environ.get(key)} if os.environ.get(key)
else {'required': True}
)
parser = ArgumentParser()
parser.add_argument(
'--bw-session',
help='Session generated from bitwarden-cli (bw login)',
**environ_or_required('BW_SESSION'),
)
parser.add_argument(
'--database-path',
help='Path to KeePass database. If database does not exists it will be created.',
**environ_or_required('DATABASE_PATH'),
)
parser.add_argument(
'--database-password',
help='Password for KeePass database',
**environ_or_required('DATABASE_PASSWORD'),
)
parser.add_argument(
'--database-keyfile',
help='Path to Key File for KeePass database',
default=os.environ.get('DATABASE_KEYFILE', None),
)
parser.add_argument(
'--bw-path',
help='Path for bw binary',
default=os.environ.get('BW_PATH', 'bw'),
)
args = parser.parse_args()
check_args(args) and bitwarden_to_keepass(args)