From acb0e2bacb8b893a1cc2cc16bdeeca2432c373d9 Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 4 Jun 2026 17:26:32 +1000 Subject: [PATCH] feat(dashboard-auth): add BasicAuthProvider username/password plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bundled, zero-infrastructure 'just put a password on my dashboard' provider that uses the supports_password extension point. No external IDP, no database: sessions are stateless HMAC-signed tokens the provider mints and verifies itself, and passwords are hashed with stdlib scrypt (no third-party dependency — deliberately avoids bcrypt to keep the dep surface unchanged). - plugins/dashboard_auth/basic: BasicAuthProvider (scrypt verify with a constant-time dummy-hash path for unknown users so the endpoint is not a username-timing oracle; access/refresh tokens carry a 'kind' claim that verify/refresh enforce; cross-secret tokens are rejected). The register() entry point mirrors the Nous plugin's config/env precedence (env wins; empty treated as unset) and LAST_SKIP_REASON channel. - config.py: document the canonical dashboard.basic_auth.* surface (username / password_hash / password / secret / session_ttl_seconds). Activates only when username + (password or password_hash) are set, so OAuth users and loopback/--insecure operators are unaffected. Without an explicit secret a random per-process key is generated (logged): fine for a single process, but sessions then don't survive restart or span workers. --- hermes_cli/config.py | 28 ++ plugins/dashboard_auth/basic/__init__.py | 491 +++++++++++++++++++++++ plugins/dashboard_auth/basic/plugin.yaml | 7 + 3 files changed, 526 insertions(+) create mode 100644 plugins/dashboard_auth/basic/__init__.py create mode 100644 plugins/dashboard_auth/basic/plugin.yaml diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 1b72c3152..a6948c23f 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1482,6 +1482,34 @@ DEFAULT_CONFIG = { "client_id": "", # agent:{instance_id} — Portal provisions this "portal_url": "", # blank → use plugin default (production Portal) }, + # Username/password gate configuration — read by the bundled + # ``dashboard_auth/basic`` plugin (a self-hosted "just put a + # password on my dashboard" provider that needs no OAuth IDP). + # The plugin registers a password provider when ``username`` plus + # either ``password_hash`` (preferred — no plaintext at rest) or + # ``password`` (plaintext, hashed in-memory at load) are set. Each + # key is overridable by an env var + # (``HERMES_DASHBOARD_BASIC_AUTH_USERNAME`` / + # ``_PASSWORD_HASH`` / ``_PASSWORD`` / ``_SECRET`` / + # ``_TTL_SECONDS``), env winning when non-empty. Leave ``username`` + # empty (the default) to keep the plugin a no-op — loopback / + # ``--insecure`` operators and OAuth users are unaffected. + # + # ``secret`` is the HMAC key used to sign the stateless session + # tokens this provider mints. When empty, a random per-process key + # is generated — fine for a single process, but sessions then + # don't survive a restart or span multiple workers. Set an + # explicit ``secret`` (32+ random bytes, base64/hex/raw) for + # stable multi-worker / restart-surviving sessions. Compute a + # ``password_hash`` with + # ``python -c "from plugins.dashboard_auth.basic import hash_password; print(hash_password('PW'))"``. + "basic_auth": { + "username": "", # blank → plugin no-op (no password provider) + "password_hash": "", # scrypt$... (preferred — no plaintext at rest) + "password": "", # plaintext fallback (hashed in-memory at load) + "secret": "", # token-signing key; blank → random per-process + "session_ttl_seconds": 0, # 0 → plugin default (12h) + }, # Public URL override (env: ``HERMES_DASHBOARD_PUBLIC_URL``). # When set, this is the complete authority — scheme + host + # optional path prefix (e.g. ``https://example.com/hermes``) — diff --git a/plugins/dashboard_auth/basic/__init__.py b/plugins/dashboard_auth/basic/__init__.py new file mode 100644 index 000000000..12ec0fe51 --- /dev/null +++ b/plugins/dashboard_auth/basic/__init__.py @@ -0,0 +1,491 @@ +"""BasicAuthProvider — username/password dashboard auth (no OAuth IDP). + +A self-hosted "just put a password on my dashboard" provider. It plugs +into the same ``DashboardAuthProvider`` framework as the Nous OAuth +provider, but authenticates with a username + password instead of an +OAuth redirect: it sets ``supports_password = True`` and implements +``complete_password_login``. The login page renders a credential form for +it; everything downstream of login (session cookies, verify, refresh, +ws-tickets, logout) is identical to the OAuth path because a password +session is just a :class:`Session` with provider-minted opaque tokens. + +This provider has **no external IDP and no database**. Credentials are +configured up front; sessions are stateless HMAC-signed tokens this +provider mints and verifies itself. That keeps it zero-infrastructure — +appropriate for a single-box self-hosted dashboard. + +Configuration surfaces (env wins over config.yaml when set non-empty), +mirroring the Nous provider's precedence convention: + + ``config.yaml`` — canonical surface:: + + dashboard: + basic_auth: + username: admin # required + # Provide EITHER a precomputed scrypt hash (preferred — no + # plaintext at rest) ... + password_hash: "scrypt$..." # see hash_password() + # ... OR a plaintext password (hashed in-memory at load). + password: "s3cret" + secret: "<32+ random bytes, base64 or hex>" # optional; token-signing key + session_ttl_seconds: 43200 # optional; access-token lifetime (default 12h) + + Environment overrides:: + + HERMES_DASHBOARD_BASIC_AUTH_USERNAME + HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH # preferred + HERMES_DASHBOARD_BASIC_AUTH_PASSWORD # plaintext fallback + HERMES_DASHBOARD_BASIC_AUTH_SECRET + HERMES_DASHBOARD_BASIC_AUTH_TTL_SECONDS + +If ``secret`` is not configured, a random per-process secret is generated +at startup. That's fine for a single-process dashboard, but means all +sessions are invalidated on restart and sessions don't survive across +multiple worker processes — set an explicit ``secret`` for stable +multi-worker / restart-surviving sessions. + +Password hashing uses stdlib :func:`hashlib.scrypt` (memory-hard, no +third-party dependency). ``complete_password_login`` runs a constant-time +comparison and always performs a hash even for an unknown username, so +the endpoint is not a username-enumeration timing oracle. + +Skip reasons: + Like the Nous provider, this exposes a module-level ``LAST_SKIP_REASON`` + the gate's fail-closed branch can surface when the plugin loads but + declines to register (no username/password configured). +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import logging +import os +import secrets +import time +from typing import Any, Optional + +from hermes_cli.dashboard_auth import ( + DashboardAuthProvider, + InvalidCredentialsError, + LoginStart, + RefreshExpiredError, + Session, +) + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Defaults +# --------------------------------------------------------------------------- + +# Access-token lifetime. The middleware transparently refreshes via the +# refresh token (30-day) when the access token lapses, so this controls +# how often a refresh round trip happens, not how long the user stays +# logged in. +_DEFAULT_TTL_SECONDS = 12 * 60 * 60 # 12h +_REFRESH_TTL_SECONDS = 30 * 24 * 60 * 60 # 30d + +# scrypt parameters (RFC 7914 / stdlib hashlib.scrypt). n must be a power +# of two; these are the widely-recommended interactive-login parameters +# (~16 MiB, a few ms on commodity hardware). +_SCRYPT_N = 2**14 +_SCRYPT_R = 8 +_SCRYPT_P = 1 +_SCRYPT_DKLEN = 32 +_SCRYPT_SALT_BYTES = 16 + +# Length of the HMAC-SHA256 digest appended as a fixed-length suffix to +# signed tokens (no separator — binary HMAC bytes can't be confused with +# a delimiter). +_SIG_LEN = hashlib.sha256().digest_size + + +LAST_SKIP_REASON: str = "" + + +# --------------------------------------------------------------------------- +# Password hashing (stdlib scrypt) +# --------------------------------------------------------------------------- + + +def hash_password(password: str) -> str: + """Return a ``scrypt$n$r$p$$`` hash string. + + Use this to precompute ``password_hash`` for config.yaml so plaintext + never sits at rest. Exposed as a module function so operators can run + ``python -c "from plugins.dashboard_auth.basic import hash_password; + print(hash_password('pw'))"``. + """ + salt = secrets.token_bytes(_SCRYPT_SALT_BYTES) + dk = hashlib.scrypt( + password.encode("utf-8"), + salt=salt, + n=_SCRYPT_N, + r=_SCRYPT_R, + p=_SCRYPT_P, + dklen=_SCRYPT_DKLEN, + maxmem=0, + ) + return ( + f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}$" + f"{base64.b64encode(salt).decode()}${base64.b64encode(dk).decode()}" + ) + + +def _verify_password(password: str, encoded: str) -> bool: + """Constant-time scrypt verify. False on any malformed hash string.""" + try: + scheme, n_s, r_s, p_s, salt_b64, dk_b64 = encoded.split("$") + if scheme != "scrypt": + return False + n, r, p = int(n_s), int(r_s), int(p_s) + salt = base64.b64decode(salt_b64) + expected = base64.b64decode(dk_b64) + except (ValueError, TypeError): + return False + try: + actual = hashlib.scrypt( + password.encode("utf-8"), + salt=salt, + n=n, + r=r, + p=p, + dklen=len(expected), + maxmem=0, + ) + except (ValueError, MemoryError): + return False + return hmac.compare_digest(actual, expected) + + +# A fixed dummy hash used to spend ~equal time when the username is +# unknown, so an attacker can't distinguish "no such user" (fast) from +# "wrong password" (slow scrypt) by timing. Computed once at import. +_DUMMY_HASH = hash_password("dummy-password-for-constant-time-verify") + + +# --------------------------------------------------------------------------- +# Token signing (stateless HMAC-signed blobs) +# --------------------------------------------------------------------------- + + +def _sign(payload: dict, secret: bytes) -> str: + raw = json.dumps(payload, separators=(",", ":")).encode() + sig = hmac.new(secret, raw, hashlib.sha256).digest() + return base64.urlsafe_b64encode(raw + sig).decode() + + +def _unsign(token: str, secret: bytes) -> Optional[dict]: + try: + blob = base64.urlsafe_b64decode(token.encode()) + if len(blob) <= _SIG_LEN: + return None + raw, sig = blob[:-_SIG_LEN], blob[-_SIG_LEN:] + expected = hmac.new(secret, raw, hashlib.sha256).digest() + if not hmac.compare_digest(sig, expected): + return None + return json.loads(raw) + except Exception: + return None + + +# --------------------------------------------------------------------------- +# Provider +# --------------------------------------------------------------------------- + + +class BasicAuthProvider(DashboardAuthProvider): + """Username/password provider with stateless HMAC-signed sessions.""" + + name = "basic" + display_name = "Username & Password" + supports_password = True + + def __init__( + self, + *, + username: str, + password_hash: str, + secret: bytes, + ttl_seconds: int = _DEFAULT_TTL_SECONDS, + ) -> None: + if not username: + raise ValueError("username must be non-empty") + if not password_hash: + raise ValueError("password_hash must be non-empty") + if len(secret) < 16: + raise ValueError("secret must be at least 16 bytes") + self._username = username + self._password_hash = password_hash + self._secret = secret + self._ttl = max(60, int(ttl_seconds)) + + # ---- OAuth methods: not used (pure-password provider) ------------------ + + def start_login(self, *, redirect_uri: str) -> LoginStart: + raise NotImplementedError( + "BasicAuthProvider is password-only; there is no OAuth redirect " + "flow. The login page POSTs to /auth/password-login instead." + ) + + def complete_login( + self, *, code: str, state: str, code_verifier: str, redirect_uri: str + ) -> Session: + raise NotImplementedError( + "BasicAuthProvider is password-only; use complete_password_login." + ) + + # ---- password login ---------------------------------------------------- + + def complete_password_login( + self, *, username: str, password: str + ) -> Session: + # Constant-time-ish: always run a scrypt verify (against the real + # hash if the username matches, else a dummy hash) so an unknown + # username and a wrong password take comparable time. Compare the + # username with compare_digest too, to avoid a length/byte timing + # leak on the username itself. + username_ok = hmac.compare_digest( + username.encode("utf-8"), self._username.encode("utf-8") + ) + target_hash = self._password_hash if username_ok else _DUMMY_HASH + password_ok = _verify_password(password, target_hash) + if not (username_ok and password_ok): + raise InvalidCredentialsError("invalid username or password") + return self._mint_session(self._username) + + # ---- session lifecycle ------------------------------------------------- + + def verify_session(self, *, access_token: str) -> Optional[Session]: + payload = _unsign(access_token, self._secret) + if ( + payload is None + or payload.get("kind") != "access" + or payload.get("exp", 0) <= int(time.time()) + ): + return None + return self._session_from_payload(access_token, "", payload) + + def refresh_session(self, *, refresh_token: str) -> Session: + if not refresh_token: + raise RefreshExpiredError("no refresh token present in session") + payload = _unsign(refresh_token, self._secret) + if ( + payload is None + or payload.get("kind") != "refresh" + or payload.get("exp", 0) <= int(time.time()) + ): + raise RefreshExpiredError("refresh token expired or invalid") + return self._mint_session(str(payload.get("sub", self._username))) + + def revoke_session(self, *, refresh_token: str) -> None: + # Stateless tokens — nothing to revoke server-side. The session + # expires within its TTL. Best-effort no-op, must not raise. + _ = refresh_token + return None + + # ---- internals --------------------------------------------------------- + + def _mint_session(self, user_id: str) -> Session: + now = int(time.time()) + exp = now + self._ttl + access_token = _sign( + {"sub": user_id, "kind": "access", "exp": exp}, self._secret + ) + refresh_token = _sign( + {"sub": user_id, "kind": "refresh", "exp": now + _REFRESH_TTL_SECONDS}, + self._secret, + ) + return Session( + user_id=user_id, + email="", + display_name=user_id, + org_id="", + provider=self.name, + expires_at=exp, + access_token=access_token, + refresh_token=refresh_token, + ) + + def _session_from_payload( + self, access_token: str, refresh_token: str, payload: dict + ) -> Session: + user_id = str(payload.get("sub", "")) + return Session( + user_id=user_id, + email="", + display_name=user_id, + org_id="", + provider=self.name, + expires_at=int(payload["exp"]), + access_token=access_token, + refresh_token=refresh_token, + ) + + +# --------------------------------------------------------------------------- +# Plugin entry point +# --------------------------------------------------------------------------- + + +def _load_config_basic_auth_section() -> dict: + """Return ``dashboard.basic_auth`` from config.yaml, or ``{}``. + + Robust to load_config() raising, the keys being absent, or the value + not being a dict — every shape falls through to ``{}``. + """ + try: + from hermes_cli.config import cfg_get, load_config + + cfg = load_config() + except Exception as exc: # noqa: BLE001 — broad catch is intentional + logger.debug( + "dashboard-auth-basic: load_config() raised %s; " + "falling back to env-only configuration", + exc, + ) + return {} + section = cfg_get(cfg, "dashboard", "basic_auth", default=None) + return section if isinstance(section, dict) else {} + + +def _resolve(env_name: str, cfg_section: dict, cfg_key: str) -> str: + """Env-wins-over-config resolution; empty env treated as unset.""" + env = os.environ.get(env_name, "").strip() + if env: + return env + return str(cfg_section.get(cfg_key, "") or "").strip() + + +def _resolve_secret(cfg_section: dict) -> bytes: + """Resolve the token-signing secret. + + Accepts base64 or hex or raw text from config/env. When unset, + generates a random per-process secret (sessions then don't survive a + restart or span multiple workers — logged at INFO). + """ + raw = _resolve( + "HERMES_DASHBOARD_BASIC_AUTH_SECRET", cfg_section, "secret" + ) + if not raw: + logger.info( + "dashboard-auth-basic: no 'secret' configured; generating a " + "random per-process signing key. Sessions will not survive a " + "restart or span multiple workers. Set dashboard.basic_auth." + "secret (or HERMES_DASHBOARD_BASIC_AUTH_SECRET) for stable " + "sessions." + ) + return secrets.token_bytes(32) + # Try base64, then hex, then fall back to the raw UTF-8 bytes. + for decoder in (base64.b64decode, bytes.fromhex): + try: + decoded = decoder(raw) + if len(decoded) >= 16: + return decoded + except (ValueError, TypeError): + pass + return raw.encode("utf-8") + + +def register(ctx) -> None: + """Plugin entry — registers BasicAuthProvider when credentials exist. + + Loopback / ``--insecure`` operators and anyone using the OAuth + provider leave ``dashboard.basic_auth`` unset, so this plugin is a + no-op for them. When username + (password or password_hash) are + configured, it registers a password provider that the login page + renders as a credential form. + """ + global LAST_SKIP_REASON + LAST_SKIP_REASON = "" + + section = _load_config_basic_auth_section() + username = _resolve( + "HERMES_DASHBOARD_BASIC_AUTH_USERNAME", section, "username" + ) + password_hash = _resolve( + "HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH", section, "password_hash" + ) + plaintext = _resolve( + "HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", section, "password" + ) + ttl_raw = _resolve( + "HERMES_DASHBOARD_BASIC_AUTH_TTL_SECONDS", section, "session_ttl_seconds" + ) + + if not username: + LAST_SKIP_REASON = ( + "dashboard.basic_auth.username is not set (and " + "HERMES_DASHBOARD_BASIC_AUTH_USERNAME is empty). Set a username " + "and a password (or password_hash) under dashboard.basic_auth in " + "config.yaml to enable username/password dashboard login, or use " + "the OAuth provider, or pass --insecure to skip the auth gate." + ) + logger.debug("dashboard-auth-basic: %s", LAST_SKIP_REASON) + return + + if not password_hash and not plaintext: + LAST_SKIP_REASON = ( + "dashboard.basic_auth.username is set but neither password_hash " + "nor password is configured. Provide one of them (password_hash " + "is preferred — compute it with " + "plugins.dashboard_auth.basic.hash_password)." + ) + logger.warning("dashboard-auth-basic: %s", LAST_SKIP_REASON) + return + + # Precedence (env-wins convention): a password supplied via the + # HERMES_DASHBOARD_BASIC_AUTH_PASSWORD env var overrides a config.yaml + # password_hash, so an operator can rotate the password by setting an + # env var without editing config. A password_hash (precomputed) wins + # over a config-only plaintext password at the same tier — it's the + # preferred at-rest form. Concretely: + # * env password set → hash it (overrides any config hash) + # * else config password_hash set → use it + # * else config plaintext password → hash it in-memory + plaintext_from_env = os.environ.get( + "HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", "" + ).strip() + if plaintext_from_env: + password_hash = hash_password(plaintext_from_env) + logger.info( + "dashboard-auth-basic: hashed env-supplied password in-memory " + "(overrides any config password_hash)." + ) + elif not password_hash: + # config-only plaintext password. + password_hash = hash_password(plaintext) + logger.info( + "dashboard-auth-basic: hashed plaintext password in-memory. " + "For production, precompute dashboard.basic_auth.password_hash " + "and remove the plaintext password from config." + ) + + secret = _resolve_secret(section) + + try: + ttl = int(ttl_raw) if ttl_raw else _DEFAULT_TTL_SECONDS + except ValueError: + ttl = _DEFAULT_TTL_SECONDS + + try: + provider = BasicAuthProvider( + username=username, + password_hash=password_hash, + secret=secret, + ttl_seconds=ttl, + ) + except ValueError as exc: + LAST_SKIP_REASON = f"BasicAuthProvider construction failed: {exc}" + logger.warning("dashboard-auth-basic: %s", LAST_SKIP_REASON) + return + + ctx.register_dashboard_auth_provider(provider) + logger.info( + "dashboard-auth-basic: registered password provider (username=%s)", + username, + ) diff --git a/plugins/dashboard_auth/basic/plugin.yaml b/plugins/dashboard_auth/basic/plugin.yaml new file mode 100644 index 000000000..586688f71 --- /dev/null +++ b/plugins/dashboard_auth/basic/plugin.yaml @@ -0,0 +1,7 @@ +name: basic +version: 1.0.0 +description: "Dashboard auth provider — username/password (no OAuth IDP). A self-hosted 'just put a password on my dashboard' provider. Activates when dashboard.basic_auth.username plus a password (or password_hash) are configured via config.yaml (canonical surface) or the HERMES_DASHBOARD_BASIC_AUTH_* env vars. Sessions are stateless HMAC-signed tokens minted by the provider; password hashing uses stdlib scrypt (no third-party dependency). Set dashboard.basic_auth.secret for restart-surviving / multi-worker sessions." +author: NousResearch +kind: backend +requires_env: + - HERMES_DASHBOARD_BASIC_AUTH_USERNAME