Skip to content

Instantly share code, notes, and snippets.

@zbowling
Created February 6, 2026 05:34
Show Gist options
  • Select an option

  • Save zbowling/94353a984c2e39eb144fffb9e24396a5 to your computer and use it in GitHub Desktop.

Select an option

Save zbowling/94353a984c2e39eb144fffb9e24396a5 to your computer and use it in GitHub Desktop.
Import KWallet Chrome keys and portal tokens to GNOME Keyring
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = []
# ///
"""
kwallet-to-libsecret.py
Import secrets from a KWallet XML export into GNOME Keyring (libsecret).
Primarily useful for migrating Chrome/Chromium Safe Storage keys after
switching from KDE Plasma to GNOME or another desktop environment.
Usage:
1. Export your KWallet: Open KWalletManager → File → Export as XML
2. Run: python3 kwallet-to-libsecret.py /path/to/wallet-export.xml
3. Restart Chrome
Requirements:
- secret-tool (usually part of libsecret-tools package)
- GNOME Keyring or another Secret Service provider running
Author: Zac Bowling
License: MIT
"""
import argparse
import subprocess
import sys
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
@dataclass
class Secret:
"""A secret to be imported into libsecret."""
folder: str
name: str
secret: str
entry_type: str # "password", "stream", or "map"
schema: str | None = None
attributes: dict = field(default_factory=dict)
@dataclass
class ImportResult:
imported: int = 0
skipped: int = 0
failed: int = 0
def secret_tool_store(label: str, secret: str, **attributes) -> bool:
"""Store a secret in libsecret using secret-tool."""
cmd = ["secret-tool", "store", "--label", label]
for key, value in attributes.items():
cmd.extend([key.replace("_", ":"), value])
result = subprocess.run(
cmd,
input=secret,
capture_output=True,
text=True,
check=False,
)
return result.returncode == 0
def parse_kwallet_xml(xml_path: str) -> list[Secret]:
"""Parse KWallet XML export and extract all secrets."""
tree = ET.parse(xml_path)
root = tree.getroot()
secrets = []
seen_entries = set()
for folder in root.findall("folder"):
folder_name = folder.get("name", "")
# Handle <password> entries
for pw in folder.findall("password"):
name = pw.get("name", "")
value = pw.text.strip() if pw.text else ""
entry_key = (folder_name, name, value, "password")
if entry_key in seen_entries or not value:
continue
seen_entries.add(entry_key)
secrets.append(
Secret(
folder=folder_name,
name=name,
secret=value,
entry_type="password",
)
)
# Handle <stream> entries (binary/base64 data)
for stream in folder.findall("stream"):
name = stream.get("name", "")
# Streams may have newlines in the base64 - normalize
value = "".join(stream.text.split()) if stream.text else ""
entry_key = (folder_name, name, value, "stream")
if entry_key in seen_entries or not value:
continue
seen_entries.add(entry_key)
secrets.append(
Secret(
folder=folder_name,
name=name,
secret=value,
entry_type="stream",
)
)
# Handle <map> entries (key-value pairs)
for map_entry in folder.findall("map"):
map_name = map_entry.get("name", "")
for entry in map_entry.findall("mapentry"):
name = entry.get("name", "")
value = entry.text.strip() if entry.text else ""
entry_key = (folder_name, map_name, name, value, "map")
if entry_key in seen_entries or not value:
continue
seen_entries.add(entry_key)
secrets.append(
Secret(
folder=folder_name,
name=f"{map_name}:{name}",
secret=value,
entry_type="map",
)
)
return secrets
def classify_secret(secret: Secret) -> dict | None:
"""
Classify a secret and return import parameters if it should be imported.
Returns None if the secret should be skipped.
"""
# Chrome Safe Storage key
if secret.folder == "Chrome Keys" and secret.name == "Chrome Safe Storage":
return {
"label": "Chrome Safe Storage",
"attributes": {
"xdg_schema": "chrome_libsecret_os_crypt_password_v2",
"application": "chrome",
},
"category": "chrome",
}
# Chromium Safe Storage key
if secret.folder == "Chromium Keys" and secret.name == "Chromium Safe Storage":
return {
"label": "Chromium Safe Storage",
"attributes": {
"xdg_schema": "chrome_libsecret_os_crypt_password_v2",
"application": "chromium",
},
"category": "chromium",
}
# xdg-desktop-portal tokens (for file dialogs, etc.)
if secret.folder == "xdg-desktop-portal":
app_id = secret.name
return {
"label": f"xdg-desktop-portal: {app_id}",
"attributes": {
"xdg_schema": "org.freedesktop.portal.portal",
"app-id": app_id,
},
"category": "portal",
}
# WiFi passwords - stored differently by NetworkManager, just show them
if secret.folder == "Network Management" and ":psk" in secret.name:
return {
"label": secret.name,
"category": "wifi",
"skip": True, # Don't import, just display
}
# Generic entry - include folder info in label
return {
"label": f"{secret.folder}: {secret.name}",
"attributes": {
"xdg_schema": "org.kde.kwallet.generic",
"kwallet-folder": secret.folder,
"kwallet-entry": secret.name,
},
"category": "other",
}
def import_secrets(
secrets: list[Secret],
categories: set[str],
dry_run: bool = False,
verbose: bool = False,
) -> tuple[ImportResult, list[str]]:
"""Import secrets into libsecret based on selected categories."""
result = ImportResult()
wifi_passwords = []
for secret in secrets:
classification = classify_secret(secret)
if classification is None:
result.skipped += 1
continue
category = classification["category"]
# Collect WiFi passwords for display
if category == "wifi":
wifi_passwords.append(secret.secret)
result.skipped += 1
continue
# Skip if category not selected
if category not in categories:
result.skipped += 1
if verbose:
print(f" [SKIP] {classification['label']} (category: {category})")
continue
# Skip if marked to skip
if classification.get("skip"):
result.skipped += 1
continue
label = classification["label"]
attributes = classification.get("attributes", {})
if dry_run:
print(f" [DRY RUN] {label}")
result.imported += 1
else:
success = secret_tool_store(label, secret.secret, **attributes)
status = "✓" if success else "✗"
print(f" {status} {label}")
if success:
result.imported += 1
else:
result.failed += 1
return result, list(set(wifi_passwords))
def main():
parser = argparse.ArgumentParser(
description="Import KWallet secrets into GNOME Keyring (libsecret)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Categories:
chrome - Chrome Safe Storage encryption key
chromium - Chromium Safe Storage encryption key
portal - xdg-desktop-portal tokens (file dialogs, etc.)
other - All other entries from KWallet
Examples:
%(prog)s wallet.xml # Import Chrome/Chromium keys only
%(prog)s wallet.xml --all # Import everything
%(prog)s wallet.xml --portal # Also import portal tokens
%(prog)s wallet.xml --dry-run --all # Preview all imports
%(prog)s wallet.xml --show-wifi # Show WiFi passwords found
After importing, restart Chrome to access your saved passwords.
""",
)
parser.add_argument("xml_file", help="Path to KWallet XML export file")
parser.add_argument(
"--dry-run",
"-n",
action="store_true",
help="Show what would be imported without actually importing",
)
parser.add_argument(
"--all",
"-a",
action="store_true",
help="Import all entries (not just Chrome/Chromium keys)",
)
parser.add_argument(
"--portal",
action="store_true",
help="Also import xdg-desktop-portal tokens",
)
parser.add_argument(
"--other",
action="store_true",
help="Also import other/generic KWallet entries",
)
parser.add_argument(
"--show-wifi",
action="store_true",
help="Display WiFi PSKs found in the export",
)
parser.add_argument(
"--verbose",
"-v",
action="store_true",
help="Show skipped entries",
)
args = parser.parse_args()
# Check for secret-tool
which_result = subprocess.run(
["which", "secret-tool"], capture_output=True, check=False
)
if which_result.returncode != 0:
print("Error: secret-tool not found.", file=sys.stderr)
print("Install it with: sudo apt install libsecret-tools", file=sys.stderr)
print(" or: sudo dnf install libsecret", file=sys.stderr)
sys.exit(1)
try:
secrets = parse_kwallet_xml(args.xml_file)
except FileNotFoundError:
print(f"Error: File not found: {args.xml_file}", file=sys.stderr)
sys.exit(1)
except ET.ParseError as e:
print(f"Error: Invalid XML: {e}", file=sys.stderr)
sys.exit(1)
# Categorize entries for summary
categories_found = {}
for secret in secrets:
classification = classify_secret(secret)
if classification:
cat = classification["category"]
categories_found[cat] = categories_found.get(cat, 0) + 1
print("Found in KWallet export:")
for cat in ["chrome", "chromium", "portal", "wifi", "other"]:
count = categories_found.get(cat, 0)
if count > 0:
print(f" {cat:12} {count}")
print()
# Determine which categories to import
import_categories = {"chrome", "chromium"} # Always import these
if args.portal or args.all:
import_categories.add("portal")
if args.other or args.all:
import_categories.add("other")
if not any(cat in categories_found for cat in import_categories):
print("No matching entries found to import.")
sys.exit(0)
print("Importing to libsecret..." if not args.dry_run else "Dry run:")
result, wifi_passwords = import_secrets(
secrets,
categories=import_categories,
dry_run=args.dry_run,
verbose=args.verbose,
)
print()
print(
f"Imported: {result.imported}, Skipped: {result.skipped}, "
f"Failed: {result.failed}"
)
if args.show_wifi and wifi_passwords:
print()
print("WiFi PSKs (for manual entry in NetworkManager):")
for psk in wifi_passwords:
print(f" • {psk}")
print()
if not args.dry_run and result.imported > 0:
print("Done! Restart applications to use the imported secrets.")
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment