Created
February 6, 2026 05:34
-
-
Save zbowling/94353a984c2e39eb144fffb9e24396a5 to your computer and use it in GitHub Desktop.
Import KWallet Chrome keys and portal tokens to GNOME Keyring
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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