219 lines
8.5 KiB
Python
219 lines
8.5 KiB
Python
"""DrainSecretProvider — shared-bearer-secret auth for the drain-control endpoint.
|
|
|
|
Uses the non-interactive token capability of the ``DashboardAuthProvider``
|
|
ABC (``supports_token`` / ``verify_token`` + the route-agnostic ``token_auth``
|
|
middleware seam). ``nous-account-service`` (NAS) provisions a **per-agent
|
|
unique** shared secret into each deployed agent's environment; this provider
|
|
verifies an inbound ``Authorization`` bearer against it with a constant-time
|
|
compare and vouches for the caller as the ``drain-control`` principal. No
|
|
login, cookie, session or refresh — the interactive ABC methods raise
|
|
``NotImplementedError``. A real auth plugin (not an ad-hoc header check on the
|
|
route) so the credential lives inside the dashboard auth framework.
|
|
|
|
Security properties:
|
|
* Per-agent unique secret — a leak's blast radius is one agent.
|
|
* Fail-CLOSED entropy gate at registration: a weak/short/low-entropy secret is
|
|
never silently accepted (>= 43 url-safe-base64 chars ~= 256 bits, enough
|
|
distinct characters, Shannon entropy floor).
|
|
* ``hmac.compare_digest`` on the request path — not a timing oracle.
|
|
|
|
Configuration — the secret is a CREDENTIAL, so it is env-only (provisioned by
|
|
NAS at deploy time); behavioural knobs live in config.yaml::
|
|
|
|
HERMES_DASHBOARD_DRAIN_SECRET # per-agent shared secret (>=43 url-safe-b64 chars)
|
|
|
|
dashboard:
|
|
drain_auth:
|
|
scope: drain # capability label attached to the principal
|
|
min_secret_chars: 43 # entropy bar (default 43 ~= 256 bits)
|
|
|
|
When the env var is unset the plugin is a no-op (records a skip reason).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hmac
|
|
import logging
|
|
import math
|
|
import os
|
|
from collections import Counter
|
|
from typing import Optional
|
|
|
|
from hermes_cli.dashboard_auth import (
|
|
DashboardAuthProvider,
|
|
LoginStart,
|
|
Session,
|
|
TokenPrincipal,
|
|
)
|
|
from plugins.dashboard_auth._shared import load_config_section
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# token_urlsafe(32) produces exactly 43 chars, so a correctly-provisioned
|
|
# secret clears the default bar exactly.
|
|
_DEFAULT_MIN_SECRET_CHARS = 43
|
|
# Rejects degenerate values like "aaaa..." that are long but trivially low-entropy.
|
|
_MIN_DISTINCT_CHARS = 16
|
|
# Distribution-aware second guard on top of length + distinct-count.
|
|
_MIN_SHANNON_BITS = 128.0
|
|
|
|
# Kept here (not imported from web_server) to avoid a heavy import at plugin load.
|
|
DRAIN_ROUTE_PATH = "/api/gateway/drain"
|
|
|
|
LAST_SKIP_REASON: str = ""
|
|
|
|
|
|
def _shannon_bits(value: str) -> float:
|
|
"""Total Shannon entropy (bits) of ``value`` over its character distribution."""
|
|
if not value:
|
|
return 0.0
|
|
n = len(value)
|
|
per_char = -sum((c / n) * math.log2(c / n) for c in Counter(value).values())
|
|
return per_char * n
|
|
|
|
|
|
def assess_secret_strength(
|
|
secret: str, *, min_chars: int = _DEFAULT_MIN_SECRET_CHARS
|
|
) -> Optional[str]:
|
|
"""Return a human-readable rejection reason if ``secret`` is too weak, else ``None``.
|
|
|
|
Checks, in order: length >= ``min_chars``, distinct chars >=
|
|
``_MIN_DISTINCT_CHARS``, Shannon entropy >= ``_MIN_SHANNON_BITS``.
|
|
"""
|
|
if not secret:
|
|
return "secret is empty"
|
|
if len(secret) < min_chars:
|
|
return (
|
|
f"secret too short: {len(secret)} chars (need >= {min_chars}; "
|
|
"use a >=256-bit value, e.g. `python -c \"import secrets; "
|
|
"print(secrets.token_urlsafe(32))\"`)"
|
|
)
|
|
distinct = len(set(secret))
|
|
if distinct < _MIN_DISTINCT_CHARS:
|
|
return (
|
|
f"secret has only {distinct} distinct characters (need >= "
|
|
f"{_MIN_DISTINCT_CHARS}); looks structured/low-entropy"
|
|
)
|
|
bits = _shannon_bits(secret)
|
|
if bits < _MIN_SHANNON_BITS:
|
|
return (
|
|
f"secret entropy too low: {bits:.0f} bits (need >= "
|
|
f"{_MIN_SHANNON_BITS:.0f}); looks structured/repeated"
|
|
)
|
|
return None
|
|
|
|
|
|
class DrainSecretProvider(DashboardAuthProvider):
|
|
"""Non-interactive shared-bearer-secret provider for drain control."""
|
|
|
|
name = "drain-secret"
|
|
display_name = "Drain Control (service credential)"
|
|
supports_token = True
|
|
supports_session = False
|
|
|
|
def __init__(self, *, secret: str, scope: str = "drain") -> None:
|
|
# Defence in depth: construction enforces the entropy bar too, so a
|
|
# caller bypassing register() still can't build a weak provider.
|
|
reason = assess_secret_strength(secret)
|
|
if reason is not None:
|
|
raise ValueError(f"drain secret rejected: {reason}")
|
|
self._secret = secret
|
|
self._scope = scope or "drain"
|
|
|
|
# ---- token capability (the only thing this provider implements) --------
|
|
|
|
def verify_token(self, *, token: str) -> Optional[TokenPrincipal]:
|
|
"""Constant-time compare; ``drain-control`` principal on match, else
|
|
``None`` so the generic seam falls through / fails closed."""
|
|
if token and hmac.compare_digest(token.encode("utf-8"), self._secret.encode("utf-8")):
|
|
return TokenPrincipal(principal="drain-control", provider=self.name, scopes=(self._scope,))
|
|
return None
|
|
|
|
# ---- interactive methods: unsupported (service credential only) --------
|
|
|
|
def start_login(self, *, redirect_uri: str) -> LoginStart:
|
|
raise NotImplementedError(
|
|
"DrainSecretProvider is a non-interactive service credential; there is no login flow."
|
|
)
|
|
|
|
def complete_login(
|
|
self, *, code: str, state: str, code_verifier: str, redirect_uri: str
|
|
) -> Session:
|
|
raise NotImplementedError("DrainSecretProvider is a non-interactive service credential.")
|
|
|
|
def verify_session(self, *, access_token: str) -> Optional[Session]:
|
|
# Never mints a Session, so never recognises a cookie. Return None
|
|
# (don't raise) so it stacks harmlessly in the cookie-verify loop.
|
|
return None
|
|
|
|
def refresh_session(self, *, refresh_token: str) -> Session:
|
|
raise NotImplementedError("DrainSecretProvider is a non-interactive service credential.")
|
|
|
|
def revoke_session(self, *, refresh_token: str) -> None:
|
|
return None
|
|
|
|
|
|
# ---- Plugin entry point ----
|
|
|
|
def _load_config_drain_auth_section() -> dict:
|
|
return load_config_section(logger, "dashboard-auth-drain", "dashboard", "drain_auth")
|
|
|
|
|
|
def register(ctx) -> None:
|
|
"""Register ``DrainSecretProvider`` when a strong secret is set.
|
|
|
|
No-op (records a skip reason) when ``HERMES_DASHBOARD_DRAIN_SECRET`` is
|
|
unset or fails the entropy gate. On success also registers the drain
|
|
route as token-authable via the generic seam.
|
|
"""
|
|
global LAST_SKIP_REASON
|
|
LAST_SKIP_REASON = ""
|
|
|
|
secret = os.environ.get("HERMES_DASHBOARD_DRAIN_SECRET", "").strip()
|
|
if not secret:
|
|
LAST_SKIP_REASON = (
|
|
"HERMES_DASHBOARD_DRAIN_SECRET is not set. Set a per-agent >=256-bit secret "
|
|
"(e.g. `python -c \"import secrets; print(secrets.token_urlsafe(32))\"`) to enable "
|
|
"NAS-driven drain coordination; leave it unset to disable the drain endpoint."
|
|
)
|
|
logger.debug("dashboard-auth-drain: %s", LAST_SKIP_REASON)
|
|
return
|
|
|
|
section = _load_config_drain_auth_section()
|
|
scope = str(section.get("scope", "drain") or "drain").strip() or "drain"
|
|
try:
|
|
min_chars = int(section.get("min_secret_chars", _DEFAULT_MIN_SECRET_CHARS))
|
|
except (TypeError, ValueError):
|
|
min_chars = _DEFAULT_MIN_SECRET_CHARS
|
|
|
|
reason = assess_secret_strength(secret, min_chars=min_chars)
|
|
if reason is not None:
|
|
LAST_SKIP_REASON = (
|
|
f"HERMES_DASHBOARD_DRAIN_SECRET rejected — {reason}. "
|
|
"The drain endpoint stays disabled (fail-closed)."
|
|
)
|
|
logger.warning("dashboard-auth-drain: %s", LAST_SKIP_REASON)
|
|
return
|
|
|
|
try:
|
|
provider = DrainSecretProvider(secret=secret, scope=scope)
|
|
except ValueError as exc:
|
|
LAST_SKIP_REASON = f"DrainSecretProvider construction failed: {exc}"
|
|
logger.warning("dashboard-auth-drain: %s", LAST_SKIP_REASON)
|
|
return
|
|
|
|
ctx.register_dashboard_auth_provider(provider)
|
|
|
|
# Opt the drain endpoint into the token-auth seam so the interactive
|
|
# cookie gate doesn't bounce NAS's bearer call.
|
|
try:
|
|
from hermes_cli.dashboard_auth.token_auth import register_token_route
|
|
|
|
register_token_route(DRAIN_ROUTE_PATH)
|
|
except Exception as exc: # noqa: BLE001 — seam import must not crash plugin load
|
|
logger.warning("dashboard-auth-drain: could not register token route %s: %s", DRAIN_ROUTE_PATH, exc)
|
|
|
|
logger.info(
|
|
"dashboard-auth-drain: registered drain service-credential provider (scope=%s, route=%s)",
|
|
scope, DRAIN_ROUTE_PATH,
|
|
)
|