bitwarden-to-keepass/bitwarden-to-keepass.py

344 lines
11 KiB
Python

"""Export Bitwarden items into a KeePass database.
This module provides functionality to export items from a Bitwarden vault into a KeePass database,
including logins (with TOTP seeds, URIs, custom fields, attachments, notes) and secure notes.
"""
import json
import logging
import os
import re
import subprocess
from argparse import ArgumentParser
from typing import Dict, List, Optional
from pykeepass import PyKeePass, create_database
from pykeepass.entry import Entry as KPEntry
from pykeepass.exceptions import CredentialsError
from pykeepass.group import Group as KPGroup
import folder as FolderType
from item import CustomFieldType, Item, ItemType
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s :: %(levelname)s :: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
kp: Optional[PyKeePass] = None
def initialize_keepass_db(database_path: str, password: str, keyfile: Optional[str] = None) -> Optional[PyKeePass]:
"""Initialize or open a KeePass database.
Args:
database_path: Path to KeePass database file
password: Password for KeePass database
keyfile: Optional path to key file
Returns:
PyKeePass instance if successful, None if failed
"""
try:
kp = PyKeePass(database_path, password=password, keyfile=keyfile)
except FileNotFoundError:
logging.info("KeePass database does not exist, creating a new one.")
kp = create_database(database_path, password=password, keyfile=keyfile)
except CredentialsError as e:
logging.error(f"Wrong password for KeePass database: {e}")
return None
return kp
def fetch_bitwarden_data(bw_path: str, bw_session: str) -> tuple[list, list]:
"""Fetch folders and items from Bitwarden vault.
Args:
bw_path: Path to Bitwarden CLI executable
bw_session: Bitwarden session token
Returns:
Tuple containing (folders, items) lists from Bitwarden
"""
folders = subprocess.check_output([bw_path, "list", "folders", "--session", bw_session], encoding="utf8")
folders = json.loads(folders)
items = subprocess.check_output([bw_path, "list", "items", "--session", bw_session], encoding="utf8")
items = json.loads(items)
return folders, items
def process_entry_title(kp: PyKeePass, group: KPGroup, title: str, item_id: str) -> str:
"""Generate a unique title for a KeePass entry.
Args:
kp: KeePass database instance
group: KeePass group to check for duplicates
title: Desired entry title
item_id: Bitwarden item ID for fallback
Returns:
Unique entry title
"""
if not kp.find_entries(title=title, group=group, first=True):
return title
return f"{title} - ({item_id})"
def create_keepass_entry(kp: PyKeePass, bw_item: Item, group: KPGroup) -> Optional[KPEntry]:
"""Create a new KeePass entry from a Bitwarden item.
Args:
kp: KeePass database instance
bw_item: Bitwarden item wrapper
group: KeePass group to add entry to
Returns:
Created KeePass entry or None if creation failed
"""
try:
entry_title = process_entry_title(kp, group, bw_item.get_name(), bw_item.get_id())
return kp.add_entry(
destination_group=group,
title=entry_title,
username=bw_item.get_username(),
password=bw_item.get_password(),
notes=bw_item.get_notes(),
)
except Exception as e:
logging.warning(f'Failed to create entry "{bw_item.get_name()}": {repr(e)}')
return None
def add_totp_to_entry(entry: KPEntry, bw_item: Item) -> None:
"""Add TOTP configuration to KeePass entry.
Args:
entry: KeePass entry to modify
bw_item: Bitwarden item containing TOTP data
"""
totp_secret, totp_settings = bw_item.get_totp()
if totp_secret and totp_settings:
entry.set_custom_property("TOTP Seed", totp_secret, protect=True)
entry.set_custom_property("TOTP Settings", totp_settings)
def add_custom_fields_to_entry(entry: KPEntry, bw_item: Item) -> None:
"""Add custom fields from Bitwarden item to KeePass entry.
Args:
entry: KeePass entry to modify
bw_item: Bitwarden item containing custom fields
"""
for field in bw_item.get_custom_fields():
entry.set_custom_property(
field["name"],
field["value"],
protect=field["type"] == CustomFieldType.HIDDEN,
)
def add_attachments_to_entry(entry: KPEntry, bw_item: Item, bw_path: str, bw_session: str) -> None:
"""Add attachments from Bitwarden item to KeePass entry.
Args:
entry: KeePass entry to modify
bw_item: Bitwarden item containing attachments
bw_path: Path to Bitwarden CLI executable
bw_session: Bitwarden session token
"""
for attachment in bw_item.get_attachments():
attachment_raw = subprocess.check_output(
[
bw_path,
"get",
"attachment",
attachment["id"],
"--raw",
"--itemid",
bw_item.get_id(),
"--session",
bw_session,
],
)
attachment_id = entry._kp.add_binary(attachment_raw)
entry.add_attachment(attachment_id, attachment["fileName"])
def bitwarden_to_keepass(args):
"""Convert Bitwarden vault items to KeePass database entries.
Args:
args: ArgumentParser namespace containing configuration options including:
- database_path: Path to KeePass database
- database_password: Password for KeePass database
- database_keyfile: Optional path to key file
- bw_path: Path to Bitwarden CLI executable
- bw_session: Bitwarden session token
Returns:
PyKeePass: The KeePass database instance with imported items
"""
kp = initialize_keepass_db(args.database_path, args.database_password, args.database_keyfile)
if not kp:
return None
folders, items = fetch_bitwarden_data(args.bw_path, args.bw_session)
groups_by_id = load_folders(kp, folders)
logging.info(f"Folders done ({len(groups_by_id)}).")
logging.info(f"Starting to process {len(items)} items.")
for item in items:
if item["type"] in [ItemType.CARD, ItemType.IDENTITY]:
logging.warning(f'Skipping credit card or identity item "{item["name"]}".')
continue
bw_item = Item(item)
entry = create_keepass_entry(kp, bw_item, groups_by_id[bw_item.get_folder_id()])
if not entry:
continue
add_totp_to_entry(entry, bw_item)
set_kp_entry_urls(entry, [uri["uri"] for uri in bw_item.get_uris()])
add_custom_fields_to_entry(entry, bw_item)
add_attachments_to_entry(entry, bw_item, args.bw_path, args.bw_session)
logging.info("Saving changes to KeePass database.")
kp.save()
logging.info("Export completed.")
return kp
def set_kp_entry_urls(entry: KPEntry, urls: List[str]) -> None:
"""Store a list of URLs from a Bitwarden entry in KeePass entry attributes.
Maps URLs to different KeePass attributes and custom properties based on their type:
- Android app identifiers
- iOS app identifiers
- Generic URLs
Args:
entry: KeePass entry object to store URLs in
urls: List of URL strings from Bitwarden
"""
android_apps = ios_apps = extra_urls = 0
for url in urls:
match url.partition("://"):
case ("androidapp", "://", app_id):
prop_name = "AndroidApp" if android_apps == 0 else f"AndroidApp_{android_apps}"
android_apps += 1
entry.set_custom_property(prop_name, app_id)
case ("iosapp", "://", app_id):
ios_apps += 1
entry.set_custom_property(f"iOS app #{ios_apps}", app_id)
case _:
if entry.url is None:
entry.url = url
else:
extra_urls += 1
entry.set_custom_property(f"URL_{extra_urls}", url)
def load_folders(kp: PyKeePass, folders) -> Dict[str, KPGroup]:
"""Create KeePass folder structure from Bitwarden folders.
Args:
kp: KeePass database instance
folders: List of folder objects from Bitwarden
Returns:
Dictionary mapping Bitwarden folder IDs to KeePass group objects
"""
folders.sort(key=lambda x: x["name"])
groups_by_id: Dict[str, KPGroup] = {}
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"])
folder_name_parts: List[str] = re.sub(r"^\/+|\/+$", "", folder["name"]).split("/")
FolderType.nested_traverse_insert(folder_root, folder_name_parts, new_folder, "/")
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):
"""Validate command line arguments.
Args:
args: ArgumentParser namespace containing runtime configuration
Returns:
bool: True if arguments are valid, False otherwise
"""
if args.database_keyfile and (
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):
"""Get argument configuration based on environment variable presence.
Args:
key: Environment variable name to check
Returns:
dict: ArgumentParser argument configuration
"""
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)