Skip to content

Instantly share code, notes, and snippets.

@szapp
Last active August 11, 2025 23:41
Show Gist options
  • Save szapp/0a2750d673ec2a63d06dfda2d87eeca0 to your computer and use it in GitHub Desktop.
Save szapp/0a2750d673ec2a63d06dfda2d87eeca0 to your computer and use it in GitHub Desktop.
Keep tags and the hierarchical organization of nested groups when migrating from KeePassXC to ProtonPass

This is an effort to keep the hierarchical organization of nested groups and tags when migrating from KeePassXC to ProtonPass. The process is to export the KeePassXC database into XML and adjusting its properties and attributes to add custom string fields for all entries containing their tags (not supported by ProtonPass) and a "path" of the nested groups. This is a compromise because Proton Pass's search can find those well, giving a worse but similar experience to searching/filtering by tag or group.

  1. Export KeePassXC database as XML file.
  2. Before import in Proton Pass, run the script to automatically
    • Add group hierarchy as custom field (excl. Root), e.g. "Insurance" or "Work / Company-Name"
    • Copy tags into custom field, e.g. "Passkey,Phone"
  3. After import in Proton Pass, manually adjust the following things that are lost during export/import
    • Create new Passkeys where necessary
    • Fix reference fields (like reference passwords or usernames from clones entries)
    • Re-add all attachments
    • Move additional URLs to the websites field, drop any extra URLs that share the domain (causes double suggestions)
    • Change entry type for credit cards
  4. After cleaning ensure that changes from then on are traceable
    • Create a secure password
    • Export the entire PP database and secure it with PGP and the password. This will allow to track changes with diffs, if ever changing back to KeePassXC.
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13"
# dependencies = [
# "cyclopts",
# ]
# ///
import logging
import sys
import xml.etree.ElementTree as ET
from typing import Iterable
import cyclopts
app = cyclopts.App()
logger = logging.getLogger(__name__)
def read_node_title(node: ET.Element) -> str:
"""Get the title of a node.
Args:
node: The XML node.
Returns:
The title of the node.
"""
for ele in node.findall("String"):
if ele.find("Key").text == "Title":
return ele.find("Value").text
return "UNKNOWN"
def add_tag_field(node: ET.Element):
"""Traverses all entries and adds a string field with the tags.
Args:
node: The XML node.
"""
for entry in node.findall(".//Entry"):
tags = entry.find("Tags")
if tags is not None and tags.text:
logger.info("Entry '%s' has tags '%s'", read_node_title(entry), tags.text)
ele = ET.Element("String")
ET.SubElement(ele, "Key").text = "Tags"
ET.SubElement(ele, "Value").text = tags.text
entry.insert(0, ele) # Insert at the beginning of the entry
def rename_url_fields(node: ET.Element) -> None:
"""Traverses all entries and renames the additional URL fields.
Args:
node: The XML node.
"""
raise NotImplementedError("This function is not implemented correctly.")
for entry in node.findall(".//Entry"):
sub_strings = entry.findall("String")
for sub_string in sub_strings:
if sub_string.find("Key").text.startswith("KP2A_URL"):
logger.info(
"Entry '%s' has extra URL '%s'.",
read_node_title(entry),
sub_string.find("Value").text,
)
sub_string.find("Key").text = "URL"
def add_group_field(node: ET.Element, path: str = "") -> None:
"""Traverses the group hierarchy and adds a string field with the group path.
Args:
node: The XML node.
path: The recursive path to the group.
"""
sub_groups = node.findall("Group")
for group in sub_groups:
group_name = group.find("Name").text
sub_path = " / ".join([path, group_name]).strip(" / ")
add_group_field(group, sub_path)
for entry in group.findall("Entry") + group.findall("Entry/History/Entry"):
logger.info("Adjust entry '%s / %s'.", sub_path, read_node_title(entry))
ele = ET.Element("String")
ET.SubElement(ele, "Key").text = "Group"
ET.SubElement(ele, "Value").text = sub_path
entry.insert(0, ele) # Insert at the beginning of the entry
def keep_groups(node: ET.Element, group_paths: Iterable[str], path: str = "") -> None:
"""Keep only the specified groups in the KeePass database.
Args:
node: The XML node.
group_paths: Paths to the groups to be kept.
path: The recursive path to the group.
"""
group_paths_list = list(group_paths)
sub_groups = node.findall("Group")
for group in sub_groups:
group_name = group.find("Name").text
sub_path = " / ".join([path, group_name]).strip(" / ")
if sub_path in group_paths_list:
logger.debug("Keeping group '%s'.", sub_path)
keep_groups(group, group_paths_list, sub_path)
group_paths_list.remove(sub_path)
elif any(sub_path.startswith(f"{s} / ") for s in group_paths_list):
logger.debug("Keeping group '%s' by recursion.", sub_path)
keep_groups(group, group_paths_list, sub_path)
else:
logger.info("Removing group '%s'.", sub_path)
node.remove(group)
if not path:
for group_path in group_paths_list:
logger.warning("Group '%s' not found.", group_path)
def remove_groups(node: ET.Element, group_paths: Iterable[str], path: str = "") -> None:
"""Remove unwanted groups from the KeePass database.
Args:
node: The XML node.
group_paths: Paths to the groups to be removed.
path: The recursive path to the group.
"""
group_paths_list = list(group_paths)
sub_groups = node.findall("Group")
for group in sub_groups:
group_name = group.find("Name").text
sub_path = " / ".join([path, group_name]).strip(" / ")
if sub_path in group_paths_list:
logger.info("Removing group '%s'.", sub_path)
node.remove(group)
group_paths_list.remove(sub_path)
for group_path in group_paths_list:
logger.warning("Group '%s' not found.", group_path)
@app.default
def main(
kdbx: cyclopts.types.ExistingPath,
*,
exclude_group: list[str] = [],
only_group: list[str] = [],
) -> int:
"""Transcribe tags and groups for a KeePassXC xml-export into string fields.
Args:
kdbx: Path to the KeePassXC xml-export file.
exclude_group: List of groups to be excluded.
only_group: List of groups to consider.
Returns:
Zero on success, non-zero on failure.
"""
logger.info("Read the KeePassXC XML file.")
content = kdbx.read_text(encoding="utf-8")
kdbx_out = kdbx.with_stem(kdbx.stem + "_converted")
logger.info("Parse the XML content.")
try:
root = ET.fromstring(content)
root_group = root.find("Root/Group")
except Exception:
logger.error("Failed parsing XML content.")
return 1
if only_group:
logger.info("Keep specified groups.")
keep_groups(root_group, only_group)
if exclude_group:
logger.info("Exclude specified groups.")
remove_groups(root_group, exclude_group)
logger.info("Transcribe tags into string field.")
add_tag_field(root_group)
# logger.info("Rename extra URL fields.")
# rename_url_fields(root_group)
logger.info("Transcribe group hierarchy into string field.")
add_group_field(root_group)
logger.info("Write the XML content to a new file.")
kdbx_out.write_text(
ET.tostring(root, encoding="utf-8").decode("utf-8"),
encoding="utf-8",
)
return 0
if __name__ == "__main__":
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)-8s | %(message)s",
)
sys.exit(app())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment