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:
MoltBot Service
2026-02-13 11:40:11 +00:00
parent f2973aa76f
commit 010580b3c3
3 changed files with 416 additions and 0 deletions

69
src/secrets.py Normal file
View 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))))