stage-2: secrets manager with keyring
Credential broker via keyring (zero plaintext on disk), CLI secrets subcommand, 29 new tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
69
src/secrets.py
Normal file
69
src/secrets.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Echo Core secrets manager — keyring wrapper."""
|
||||
|
||||
import json
|
||||
import keyring
|
||||
|
||||
SERVICE = "echo-core"
|
||||
|
||||
# Required secrets that should exist for full functionality
|
||||
REQUIRED_SECRETS = ["discord_token"]
|
||||
|
||||
|
||||
def set_secret(name: str, value: str) -> None:
|
||||
"""Store a secret in the system keyring."""
|
||||
keyring.set_password(SERVICE, name, value)
|
||||
# Track secret name in the registry
|
||||
names = _get_registry()
|
||||
if name not in names:
|
||||
names.append(name)
|
||||
_save_registry(names)
|
||||
|
||||
|
||||
def get_secret(name: str) -> str | None:
|
||||
"""Retrieve a secret from keyring. Returns None if not found."""
|
||||
return keyring.get_password(SERVICE, name)
|
||||
|
||||
|
||||
def get_json_secret(name: str) -> dict | None:
|
||||
"""Retrieve and JSON-deserialize a secret."""
|
||||
raw = get_secret(name)
|
||||
if raw is None:
|
||||
return None
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
def delete_secret(name: str) -> bool:
|
||||
"""Delete a secret. Returns True if existed."""
|
||||
try:
|
||||
keyring.delete_password(SERVICE, name)
|
||||
except keyring.errors.PasswordDeleteError:
|
||||
pass
|
||||
names = _get_registry()
|
||||
if name in names:
|
||||
names.remove(name)
|
||||
_save_registry(names)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def list_secrets() -> list[str]:
|
||||
"""List all stored secret names."""
|
||||
return _get_registry()
|
||||
|
||||
|
||||
def check_secrets() -> dict[str, bool]:
|
||||
"""Check which required secrets exist. Returns {name: exists}."""
|
||||
return {name: get_secret(name) is not None for name in REQUIRED_SECRETS}
|
||||
|
||||
|
||||
def _get_registry() -> list[str]:
|
||||
"""Get list of secret names from keyring registry."""
|
||||
raw = keyring.get_password(SERVICE, "_registry")
|
||||
if raw is None:
|
||||
return []
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
def _save_registry(names: list[str]) -> None:
|
||||
"""Save secret names registry to keyring."""
|
||||
keyring.set_password(SERVICE, "_registry", json.dumps(sorted(set(names))))
|
||||
Reference in New Issue
Block a user