#!/usr/bin/env python3 """Store and retrieve arbitrary key-value secrets in the Secret Service keyring.""" import argparse import sys from enum import IntEnum from typing import NoReturn import secretstorage from jeepney.io.blocking import DBusConnection # type: ignore[import-untyped] from secretstorage.collection import Collection from secretstorage.exceptions import ( ItemNotFoundException, LockedException, PromptDismissedException, SecretStorageException, ) from secretstorage.item import Item SCHEMA = "de.uyani.keyring-op" KEY_ATTRIBUTE = "key" UNLOCK_TIMEOUT = 120.0 class Exit(IntEnum): """Process exit codes. USAGE matches the value argparse exits with.""" OK = 0 NOT_FOUND = 1 USAGE = 2 LOCKED = 3 BACKEND = 4 AMBIGUOUS = 5 INPUT = 6 def fail(message: str, code: Exit) -> NoReturn: print(message, file=sys.stderr) raise SystemExit(code) def attributes_for(key: str) -> dict[str, str]: return {"xdg:schema": SCHEMA, KEY_ATTRIBUTE: key} def validate_key(key: str) -> str: """Reject keys that would make list output ambiguous.""" if not key or "\n" in key: fail("key must be non-empty and must not contain a newline", Exit.USAGE) return key def open_collection(connection: DBusConnection) -> Collection: """Return the default collection, unlocking it if needed.""" try: collection = secretstorage.get_collection_by_alias(connection, "default") except ItemNotFoundException: fail("no default keyring collection exists", Exit.BACKEND) if collection.is_locked(): print("keyring is locked, requesting unlock", file=sys.stderr) try: # unlock returns True when the prompt was dismissed, False on success. dismissed = collection.unlock(timeout=UNLOCK_TIMEOUT) except TimeoutError: fail(f"unlock request timed out after {UNLOCK_TIMEOUT:g}s", Exit.LOCKED) if dismissed: fail("unlock request was dismissed", Exit.LOCKED) return collection def find_item(collection: Collection, key: str) -> Item | None: """Return the single item matching key, or None. Ambiguity is fatal.""" matches = list(collection.search_items(attributes_for(key))) if len(matches) > 1: fail(f"{len(matches)} entries match key: {key}", Exit.AMBIGUOUS) return matches[0] if matches else None def require_item(collection: Collection, key: str) -> Item: item = find_item(collection, key) if item is None: fail(f"key not found: {key}", Exit.NOT_FOUND) return item def cmd_list(collection: Collection) -> None: items = collection.search_items({"xdg:schema": SCHEMA}) for key in sorted(item.get_attributes().get(KEY_ATTRIBUTE, "") for item in items): print(key) def cmd_add(collection: Collection, key: str, strip: bool) -> None: if sys.stdin.isatty(): print("reading secret from terminal, end with Ctrl-D", file=sys.stderr) secret = sys.stdin.buffer.read() if strip: secret = secret.removesuffix(b"\n") if not secret: fail("refusing to store an empty secret", Exit.INPUT) collection.create_item( f"keyring-op: {key}", attributes_for(key), secret, replace=True ) def cmd_get(collection: Collection, key: str) -> None: sys.stdout.buffer.write(require_item(collection, key).get_secret()) sys.stdout.buffer.flush() def cmd_remove(collection: Collection, key: str) -> None: require_item(collection, key).delete() def make_parser(prog: str) -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog) commands = parser.add_subparsers(dest="command", required=True) commands.add_parser("list", help="List all keys") add = commands.add_parser( "add", help="Add a new key or update the value of an existing key" ) add.add_argument("key", help="Key name") add.add_argument( "--strip", action="store_true", help="Drop one trailing newline from stdin before storing", ) get = commands.add_parser("get", help="Get value of a key") get.add_argument("key", help="Key name") remove = commands.add_parser("remove", help="Remove a key") remove.add_argument("key", help="Key name") return parser def dispatch(collection: Collection, args: argparse.Namespace) -> None: if args.command == "list": cmd_list(collection) elif args.command == "add": cmd_add(collection, validate_key(args.key), args.strip) elif args.command == "get": cmd_get(collection, validate_key(args.key)) elif args.command == "remove": cmd_remove(collection, validate_key(args.key)) def main() -> int: args = make_parser("keyring-op").parse_args() try: with secretstorage.dbus_init() as connection: dispatch(open_collection(connection), args) except (LockedException, PromptDismissedException) as error: fail(f"keyring is locked: {error}", Exit.LOCKED) except SecretStorageException as error: fail(f"keyring backend error: {error}", Exit.BACKEND) except BrokenPipeError: # The reader went away. Silence the interpreter's shutdown warning. sys.stderr.close() return Exit.OK return Exit.OK if __name__ == "__main__": sys.exit(main())