Files
hermes-agent/keystore/client.py
T
Shannon Sands 712bdfb949 fix(gateway): revoke deleted keystore-backed env vars on refresh
Force-refresh now also clears env vars that were previously injected by the
keystore but no longer exist in the current injectable secret set. This lets
credential deletion/revocation propagate in long-lived gateway processes
without restart, while still preserving external env precedence.

Adds a regression test covering deletion of a keystore-backed OPENAI_API_KEY
followed by gateway refresh.
2026-03-29 08:38:29 +10:00

407 lines
14 KiB
Python

"""High-level keystore client for CLI and agent integration.
This is the main entry point for all keystore consumers. It wraps
EncryptedStore with:
- Automatic path resolution (``~/.hermes/keystore/secrets.db``)
- Unlock flow (credential store → env var → interactive prompt)
- Injectable secret injection into ``os.environ``
- .env migration helper
- Singleton pattern (one client per process)
Usage in CLI startup::
from keystore.client import get_keystore
ks = get_keystore()
ks.ensure_unlocked() # prompts if needed
ks.inject_env() # populates os.environ with injectable secrets
Usage in gateway startup::
ks = get_keystore()
ks.ensure_unlocked(interactive=False) # raises if can't auto-unlock
ks.inject_env()
"""
import getpass
import logging
import os
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from keystore.store import (
EncryptedStore,
KeystoreError,
KeystoreLocked,
PassphraseMismatch,
SecretEntry,
)
from keystore import credential_store
from keystore.categories import SecretCategory, default_category
logger = logging.getLogger(__name__)
def _hermes_home() -> Path:
return Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
def _default_db_path() -> Path:
return _hermes_home() / "keystore" / "secrets.db"
def _env_file_path() -> Path:
return _hermes_home() / ".env"
def _owned_env_names() -> set[str]:
raw = os.getenv("HERMES_KEYSTORE_OWNED_VARS", "")
return {x for x in raw.split(",") if x}
def _set_owned_env_names(names: set[str]) -> None:
os.environ["HERMES_KEYSTORE_OWNED_VARS"] = ",".join(sorted(names))
class KeystoreClient:
"""High-level keystore interface for CLI, gateway, and agent startup."""
def __init__(self, db_path: Optional[str | Path] = None):
path = Path(db_path) if db_path else _default_db_path()
self._store = EncryptedStore(path)
self._injected: Dict[str, bool] = {}
@property
def is_initialized(self) -> bool:
return self._store.is_initialized
@property
def is_unlocked(self) -> bool:
return self._store.is_unlocked
def initialize(self, passphrase: str) -> None:
"""Initialize a new keystore with the given passphrase."""
self._store.initialize(passphrase)
def unlock(self, passphrase: str) -> None:
"""Unlock with a known passphrase."""
self._store.unlock(passphrase)
def lock(self) -> None:
"""Lock the keystore."""
self._store.lock()
def ensure_unlocked(self, interactive: bool = True) -> bool:
"""Ensure the keystore is unlocked, trying all available methods.
Unlock priority:
1. Already unlocked → no-op
2. OS credential store (if ``hermes keystore remember`` was used)
3. ``HERMES_KEYSTORE_PASSPHRASE`` env var
4. Interactive passphrase prompt (if ``interactive=True``)
Returns True if unlocked, False if not initialized (caller should
set up the keystore), raises PassphraseMismatch on wrong passphrase.
When ``interactive=False`` (gateway/headless), raises KeystoreLocked
if no automatic unlock method succeeds.
"""
if self._store.is_unlocked:
return True
if not self._store.is_initialized:
return False
# 1. Try credential store
passphrase = credential_store.retrieve_passphrase()
if passphrase:
try:
self._store.unlock(passphrase)
logger.debug("Unlocked via credential store (%s)", credential_store.backend_name())
return True
except PassphraseMismatch:
logger.warning(
"Stored passphrase is stale (credential store: %s). "
"Run 'hermes keystore remember' to update it.",
credential_store.backend_name(),
)
# 2. Try env var
env_passphrase = os.getenv("HERMES_KEYSTORE_PASSPHRASE")
if env_passphrase:
try:
self._store.unlock(env_passphrase)
logger.debug("Unlocked via HERMES_KEYSTORE_PASSPHRASE env var")
return True
except PassphraseMismatch:
logger.warning("HERMES_KEYSTORE_PASSPHRASE env var has wrong passphrase")
# 3. Interactive prompt
if not interactive:
raise KeystoreLocked(
"Keystore is locked and no automatic unlock method succeeded. "
"Set HERMES_KEYSTORE_PASSPHRASE env var, or run "
"'hermes keystore remember' to cache the passphrase."
)
max_attempts = 3
for attempt in range(max_attempts):
try:
passphrase = getpass.getpass("🔐 Keystore passphrase: ")
if not passphrase:
continue
self._store.unlock(passphrase)
return True
except PassphraseMismatch:
remaining = max_attempts - attempt - 1
if remaining > 0:
print(f" ✗ Incorrect passphrase ({remaining} attempts remaining)")
else:
print(" ✗ Incorrect passphrase")
raise PassphraseMismatch("Too many incorrect passphrase attempts")
def inject_env(self, force: bool = False) -> Dict[str, bool]:
"""Inject all ``injectable`` secrets into ``os.environ``.
Args:
force: Refresh mode for long-lived processes. When ``False``
(default), existing env vars are preserved so shell/Docker env
wins over keystore values at startup. When ``True``, only env
vars that were previously injected by this client instance are
overwritten. Externally provided env vars that were skipped on
initial injection remain authoritative across refreshes.
Returns:
Dict of ``{secret_name: injected_or_overwritten}``.
"""
secrets = self._store.get_injectable_secrets()
previous = dict(self._injected)
owned = _owned_env_names()
injected = {}
current_names = set(secrets.keys())
# Force-refresh also acts as revocation for previously keystore-owned
# env vars that have been deleted from the keystore or are no longer
# injectable. External env vars are never in `owned`, so they are not
# touched here.
if force:
removed = owned - current_names
for name in removed:
os.environ.pop(name, None)
owned -= removed
for name, value in secrets.items():
should_write = False
if name not in os.environ:
should_write = True
elif force and (previous.get(name) is True or name in owned):
# Only refresh vars we previously injected ourselves.
should_write = True
if should_write:
os.environ[name] = value
injected[name] = True
owned.add(name)
else:
# Existing env var remains authoritative. Preserve ownership
# markers for vars we previously injected so later refreshes
# can update them. Only discard ownership when the key was
# never ours in this process.
injected[name] = False
if not (previous.get(name) is True or name in owned):
owned.discard(name)
self._injected = injected
_set_owned_env_names(owned)
count_written = sum(1 for v in injected.values() if v)
count_skipped = sum(1 for v in injected.values() if not v)
logger.info(
"Keystore: %s %d secrets (%d skipped)",
"refreshed" if force else "injected",
count_written,
count_skipped,
)
return injected
# ------------------------------------------------------------------
# Secret management
# ------------------------------------------------------------------
def set_secret(
self,
name: str,
value: str,
category: Optional[str] = None,
description: str = "",
tags: Optional[List[str]] = None,
) -> None:
"""Store a secret. Category defaults based on the name."""
cat = category or default_category(name).value
self._store.set(name, value, category=cat, description=description, tags=tags)
def get_secret(self, name: str, requester: str = "cli") -> Optional[str]:
"""Retrieve a secret."""
return self._store.get(name, requester=requester)
def delete_secret(self, name: str) -> bool:
"""Delete a secret."""
return self._store.delete(name)
def list_secrets(self) -> List[SecretEntry]:
"""List all secrets (metadata only)."""
return self._store.list_secrets()
def set_category(self, name: str, category: str) -> bool:
"""Change a secret's access category."""
# Validate
try:
SecretCategory(category)
except ValueError:
raise KeystoreError(
f"Invalid category '{category}'. "
f"Must be one of: {', '.join(c.value for c in SecretCategory)}"
)
return self._store.set_category(name, category)
def get_access_log(self, limit: int = 50) -> List[dict]:
"""Return recent access log entries."""
return self._store.get_access_log(limit)
def change_passphrase(self, old_passphrase: str, new_passphrase: str) -> None:
"""Change the master passphrase."""
self._store.change_passphrase(old_passphrase, new_passphrase)
def secret_count(self) -> int:
"""Return the number of stored secrets."""
return self._store.secret_count()
# ------------------------------------------------------------------
# Credential store (passphrase caching)
# ------------------------------------------------------------------
def remember_passphrase(self, passphrase: str) -> Tuple[bool, str]:
"""Store the passphrase in the OS credential store.
Returns (success, backend_name_or_error_message).
"""
backend = credential_store.backend_name()
if not credential_store.is_available():
return False, (
"No credential store backend available.\n\n"
"Options:\n"
" • Set HERMES_KEYSTORE_PASSPHRASE env var for headless/Docker\n"
" • Install keyring: pip install keyring\n"
" • Install keyctl: apt install keyutils (Linux)\n"
" • Type your passphrase each time (most secure)"
)
# Verify the passphrase is correct first
try:
self._store.unlock(passphrase)
except PassphraseMismatch:
return False, "Incorrect passphrase"
if credential_store.store_passphrase(passphrase):
return True, backend
return False, f"Failed to store passphrase in {backend}"
def forget_passphrase(self) -> Tuple[bool, str]:
"""Remove the passphrase from the OS credential store."""
backend = credential_store.backend_name()
if credential_store.delete_passphrase():
return True, backend or "credential store"
return False, "No stored passphrase found"
# ------------------------------------------------------------------
# Migration from .env
# ------------------------------------------------------------------
def migrate_from_env(self, env_path: Optional[Path] = None) -> Dict[str, str]:
"""Import secrets from a .env file into the keystore.
Returns a dict of {secret_name: category} for each migrated secret.
Skips blank values and comments. Does NOT delete the .env file
(the caller should handle backup/stub creation).
"""
path = env_path or _env_file_path()
if not path.exists():
return {}
migrated = {}
with open(path, encoding="utf-8", errors="replace") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip()
# Strip surrounding quotes
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
value = value[1:-1]
if not value:
continue
# Skip non-secret config values
if not _looks_like_secret(key, value):
continue
category = default_category(key).value
self._store.set(
key, value,
category=category,
description=f"Migrated from .env",
tags=["migrated"],
)
migrated[key] = category
logger.info("Migrated %d secrets from %s", len(migrated), path)
return migrated
def _looks_like_secret(key: str, value: str) -> bool:
"""Heuristic: does this .env entry look like a secret?"""
secret_indicators = (
"KEY", "TOKEN", "SECRET", "PASSWORD", "PASSWD",
"AUTH", "CREDENTIAL", "API_KEY",
)
key_upper = key.upper()
for indicator in secret_indicators:
if indicator in key_upper:
return True
# Long random-looking values are probably secrets
if len(value) >= 20 and not value.startswith("/") and not value.startswith("http"):
return True
return False
# =========================================================================
# Singleton
# =========================================================================
_instance: Optional[KeystoreClient] = None
def get_keystore(db_path: Optional[str | Path] = None) -> KeystoreClient:
"""Get the global keystore client (singleton per process)."""
global _instance
if _instance is None:
_instance = KeystoreClient(db_path)
return _instance
def reset_keystore() -> None:
"""Reset the global singleton (for testing)."""
global _instance
if _instance is not None:
try:
_instance.lock()
except Exception:
pass
_instance = None