Skip to content

Security and secrets

Secret values must not be printed, logged, committed, placed in command arguments, or echoed into chat. The architecture invariants and protected-path rules define deny-by-default boundaries for secrets, auth code, migrations, compose/deploy infrastructure, agent rule files, lockfiles, and off-limits hosts.

The system wiki must not cite or summarize raw secret files. Safe evidence is metadata and source code that describes boundaries, not credential material.

ADR 0100 moves Moth’s own Google identity onto the same Google API client path as Mark while keeping separate blast-radius boundaries. Mark’s Google surface uses MOTH_GOOGLE_SA_JSON plus MOTH_GOOGLE_SA_SUBJECT; Moth’s own surface uses MOTH_IDENTITY_GOOGLE_SA_JSON plus MOTH_IDENTITY_GOOGLE_SA_SUBJECT. credentials.py resolves those names only through Agent Vault broker aliases, and the raw-secret startup denylist treats both service-account JSON env names as secret carriers.

credentials.py also brokers per-vault memlink request signing through the prontera oracle so the signing key never leaves prontera (INV-024): memlink_scope_sign (HMAC, /sign, ADR 0143) and memlink_scope_sign_ed25519 (Ed25519, /sign-ed25519, ADR 0146; default-off behind STEELMOTH_RUNTIME_MEMLINK_SIGN_ED25519_ENABLED). Both reuse the broker session token + X-Agent-Vault-Client-Vault read-binding and fail closed (empty signature) on any broker error.

Both compose files set STEELMOTH_SECRET_BROKER_BASE_URL to the prontera URL so every steelmoth service resolves vault aliases at the same host as the vault itself. The runtime gsuite package no longer imports the retired SMTP/IMAP module for Moth mail; identity="moth" falls back to Mark with a visible notice only when the Moth identity service-account JSON or subject is not loaded. The broker aliases for Moth’s SMTP app-password/email remain because other surfaces still use them; they are no longer the runtime Google Workspace path.

ADR 0105 (2026-05-23) consolidated the secret broker onto prontera. Vault material AND alias resolution now share one host:

steelmoth container
→ https://prontera.sole-augmented.ts.net/secrets/v1/secrets/{alias}
→ nginx (prontera)
→ systemd unit agent-vault-secret-broker (prontera loopback :14350)
→ Agent Vault upstream (prontera 100.100.245.3:14321)

The deployed broker script lives at prontera:/opt/agent-vault-secret-broker/agent-vault-secret-broker.py (NOT in this repo). The alias→(vault, key) JSON map is loaded by systemd via /etc/agent-vault-secret-broker/env. The nginx /secrets/ location block proxies to loopback :14350 and is configured in /etc/nginx/sites-enabled/agent-vault.prontera on prontera.

Steelmoth containers send their bearer token (from the steelmoth_runtime_agent_vault_token Docker secret) as Authorization: Bearer …; the prontera broker validates the token against the upstream vault before resolving the alias. The Python client (steelmoth_runtime.credentials) caches each alias for 60 seconds in-process to keep the steady-state cost effectively free.

The private compose scripts avoid persistent runtime-token files on the Steelmoth VPS. When a runtime-facing compose start lacks a non-empty STEELMOTH_RUNTIME_AGENT_VAULT_TOKEN_FILE, scripts/compose-up.sh delegates to scripts/with-ephemeral-runtime-token.sh. The wrapper copies a runtime identity token from an existing mounted Docker secret, a systemd credential, or explicit one-shot stdin into a private temporary directory, points Compose at that file for the duration of the start/recreate, and deletes the temporary source afterward. Stopped runtime containers with stale deleted temp-file mounts are removed before startup so compose can recreate them with a fresh ephemeral source. The wrapper does not write /etc/agent-vault/*.token files.

This concentration is required by INV-024 (architecture invariant) and HARD RULE 10 (agent-instruction rule). No steelmoth compose file, sidecar, library, or in-process service may proxy vault aliases — the prontera URL is the only resolution path. The prontera-assets/agent-vault-secret-broker/ directory in this repo holds documentation-only deployment artefacts (Dockerfile-equivalent, README) for the prontera-side install; nothing in it is loaded as runtime code in any steelmoth container.

Adding a new alias means editing the systemd EnvironmentFile= on prontera and reloading the service (sudo systemctl reload agent-vault-secret-broker). It does NOT mean adding env vars to any compose file in this repo.

The memlink scope-auth HMAC (alias memlink-scope) is one such alias, notable because two services resolve it through this one path: the mark-side signers (steelmoth-api / steelmoth-worker-moth / stm-control-plane) via credentials.secret_value("MEMLINK_SCOPE_HMAC_SECRET"), and the Memlink verifier via memlink_hybrid.infrastructure.config._scope_auth_credential() (which reuses the embedding key’s in-process BrokeredSecretSupplier — one broker client, not a second). It is never an env var or .env value: an earlier cutover attempt that materialized it as AGENT_VAULT_SESSION_TOKEN_MEMLINK_SCOPE in the repo-root .env (#168/#169) was reverted as an INV-006/INV-024 breach, and scripts/check-agent-vault-no-local-secret-materialization.py now fails CI on any re-introduction in a compose / .env / script file. See ADR 0135 → “Signing-secret resolution (broker-only)”.

The public dashboard auth path follows the same boundary. scripts/compose-dashboard-deploy.sh resolves startup authority through scripts/with-ephemeral-runtime-token.sh, writes the one-shot token into a short-lived Docker volume mounted at /run/steelmoth-dashboard-token, and starts stm-dashboard-app with AGENT_VAULT_TOKEN_FILE pointing at that volume. The dashboard entrypoint deletes that token file immediately after reading it. It first attempts to mint an Agent Vault session, but Agent Vault 0.22 requires member-level authority for session minting while the runtime identity is intentionally proxy-only. When session minting is denied, the entrypoint uses the same proxy token only in memory for the read-only broker fetch, then unsets it before Node starts. The entrypoint fetches dashboard-auth-secret and dashboard-authentik-client-secret through the prontera broker and writes those dashboard secrets only to container-local temporary files under the runtime secret directory. A Node preload reads those files after process start, sets the runtime-only Auth.js env values, deletes the _FILE env names, and removes temporary files under the runtime secret directory. The alias names are safe to document; the values are not.

The broker-write surface is source-grounded in docs/specs/prontera-broker-write-endpoints.md v4. The accepted target makes Mark the first client (client-mark), reserves client-{slug} for future tenants, keeps runtime identities read-only (proxy), and requires the prontera broker to verify a fresh Authentik step-up MFA proof before every dashboard/user secret write.

Dashboard self-service writes are limited to client-owned aliases such as client-mark-*; system/operator aliases such as moth-* stay outside the dashboard write path. No standing write-capable token may live on the steelmoth VPS.

As of the 2026-05-30 implementation slice, the live prontera broker has guarded challenge/write/delete handlers, a non-secret client registry for mark -> client-mark, the corrected Agent Vault mutation API shapes, and a dedicated prontera-local writer token (mark-steelmoth-secret-broker-writer-v2) scoped only to client-mark. When /etc/agent-vault-secret-broker/env is loaded, --check-config reports writes_configured=true.

Broker writes are not authorized by dashboard trust alone. Challenge creation uses the dashboard Authentik OAuth access token for Mark/client binding, and PUT/DELETE require a fresh Authentik step-up token with matching nonce, fresh auth_time, accepted MFA amr/acr, and matching subject hash. Missing step-up still returns 401, moth-* challenge creation returns 403, and the runtime token direct write probe returns 403.

The dashboard Telegram connector implements the first self-service write path. It stores the user-entered bot token only in server memory during the five-minute challenge window, redirects through Authentik with prompt=login, max_age=0, the broker nonce, S256 PKCE, and the provider-advertised acr_values when present, then writes client-mark-telegram-bot-token to client-mark as CLIENT_MARK_TELEGRAM_BOT_TOKEN through the broker. The token value is not sent to chat, the runtime API, memlink, logs, or the steelmoth VPS filesystem. The write-challenge authorization resolves the Authentik OAuth access_token from a short-lived server-side cache keyed by an Auth.js access_token_ref; older sessions without a valid cached reference are stopped with telegram_session_refresh_required before prontera is contacted.

The prontera Telegram Bot API broker now resolves bot credentials from Agent Vault by preferring client-mark/CLIENT_MARK_TELEGRAM_BOT_TOKEN and falling back to the existing client-mark/TELEGRAM_TOKEN. That fallback keeps the current bot path working until Mark completes the dashboard connect flow.

The urgent independent prerequisite was to ensure the runtime Agent Vault grants are proxy where the runtime should be read-only before any broker write path is exposed; the 2026-05-30 handover records that mark-steelmoth-runtime-vps has client-mark:proxy and staging:proxy, and that a direct runtime write probe returns 403 without creating a probe credential row.

Steelmoth dashboard authentication is fail-closed in two stages before route handlers run:

  • Transport/header trust — the runtime derives peer metadata from the immediate socket peer (request.client.host) rather than from forwarded headers. When internal hop auth is configured, the dashboard-to-runtime hop credential is required before the runtime accepts any trusted x-authentik-* identity headers. x-forwarded-for is metadata only; it can help logs and forensics after the runtime boundary passes, but it is not a credential and must not grant peer trust. Focused regression tests model the socket peer through TestClient(..., client=(host, port)) and assert that spoofing x-forwarded-for: 127.0.0.1 from an untrusted socket returns 401 UNAUTHENTICATED, including on client-provisioning routes.
  • Principal identityRuntimeConfig.primary_authentik_email rejects any Authentik-issued user whose email does not match the configured canonical operator with 403 PERMISSION_DENIED — applied by require_authed (web_chat/auth.py), which layers the pin over the shared _authenticate helper (observer fix S1.2; there is NO observer exemption — an observer principal never reaches a plain require_authed route). This is a single-operator pin; it does not grant role. Removing this gate is explicitly out of scope of RFC 0074 (multi-tenant ADR 0072 territory).
  • Role_role_for() (web_chat/auth.py) returns one of THREE roles (ADR 0225): "admin" iff the caller is a member of config.admin_group (default dashboard-admins); else "observer" iff the caller is in config.observer_group (default dashboard-observers) AND this runtime is the operator brain (is_canonical_authentik_email(config.primary_authentik_email) — observer fix S1.1: a client gateway ignores the observer group); else "user". Admin is checked FIRST, so a principal in both groups is admin — never an escalation. Group membership flows through the OIDC groups claim, parsed at _split_groups() from the comma-separated x-authentik-groups header that the trusted Next.js proxy injects. The observer is the read-only operator-view role: it is admitted ONLY via require_admin_read (GET/HEAD admin reads) and the two declared read-shaped POSTs; every write and every un-redacted secrets read stays require_admin. Group changes take effect at the user’s next sign-in — revoking the observer group lags for the current session’s JWT lifetime (documented limit, operator-approved 2026-07-25).

The legacy email-based admin rail (config.admin_emails, STEELMOTH_ADMIN_EMAILS, MOTH_HYBRID_ADMIN_EMAILS) has been fully removed as of RFC 0074 PR-D: the RuntimeConfig field is gone, the env loading is gone, the compose envs are gone, owner_identity._first_admin_email is gone, and the wire contract no longer carries admin_emails or the legacy requesting_user_is_self_in_list alias. Settings PATCHes to STEELMOTH_ADMIN_EMAILS or MOTH_HYBRID_ADMIN_EMAILS still return 400 INVALID_ARGUMENT with guidance pointing at the configured Authentik admin group, as a permanent operator-visible signal that those env keys are no longer meaningful.

The dashboard frontend matches: dashboard/lib/auth.ts:isAdmin() trusts only user.role === "admin"; dashboard/middleware.ts:isDocsAdmin() checks dashboard-admins for the docs host edge gate; dashboard/lib/private-auth.ts defaults the private loopback bypass identity groups to ["dashboard-admins"]; and dashboard/components/settings/admin-sections/AdminAssignmentsSection.tsx is a read-only viewer + “Manage in Authentik →” link.

Env aliases relevant to admin role + identity:

Env (current alias) Legacy alias Status
STEELMOTH_ADMIN_GROUP MOTH_HYBRID_ADMIN_GROUP Active — sole backend role source (default dashboard-admins)
STEELMOTH_PRIMARY_AUTHENTIK_EMAIL MOTH_HYBRID_PRIMARY_AUTHENTIK_EMAIL Active — principal identity pin (single-operator gate); not a role source
STEELMOTH_ADMIN_EMAILS MOTH_HYBRID_ADMIN_EMAILS Removed by RFC 0074 PR-D; PATCH on these keys returns 400 INVALID_ARGUMENT with deprecation guidance

ADR 0098 documents the single break-glass path for Authentik unreachability: SSH into the VPS, set STEELMOTH_ADMIN_GROUP to the permanent emergency-admins Authentik group (which the operator maintains as a never-removed-from membership), restart the API container, log in. There is no in-band bypass code path. The full procedure lives in docs/operations/runbook.md. The existing dashboard/lib/private-auth.ts loopback bypass is scoped to the private dashboard container (compose.steelmoth.private.yml) as a dev/internal-tooling path and is NOT positioned as the public-dashboard break-glass.

  • Update this page when protected paths, secret checks, Agent Vault boundaries, host-boundary rules, or the admin-authorization contract change.
  • Do not add secret-bearing paths to system-wiki sources.
  • This page does not replace a threat model for a new runtime or dashboard surface.
  • Multi-tenant admin role semantics under ADR 0072 are not yet specified; a future ADR will revisit whether config.admin_group becomes per-tenant.