169 lines
5.2 KiB
Python
Executable File
169 lines
5.2 KiB
Python
Executable File
#!/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())
|