From 51f9cf13dcedd535275d49fd9d50cb6e15cbdb6e Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Thu, 14 May 2026 04:35:39 +0000 Subject: [PATCH] refactor(config): extract Auth family + 2 exported + 1 unexported helpers (Phase 9, 5 of N) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The biggest single-sprint cut so far (-502 lines) and the FIRST split that moves EXPORTED helpers. Public-surface invariant verified end-to- end via broader-importer build (cmd/server + internal/auth + internal/api/...). What moved ========== internal/config/auth.go (new, 601 lines including BSL header + Phase 9 doc-comment + 4 imports + 5 types + 3 helpers) Five types: - NamedAPIKey (one named API-key entry; admin flag for actor attribution in audit trail) - AuthType (+ 3 consts: AuthTypeAPIKey / AuthTypeNone / AuthTypeOIDC — the typed enum that replaced the pre-G-1 string-literal map. "jwt" stays out forever per ValidAuthTypes() invariant pinned by config_test.go's property test) - AuthConfig (top-level: Type, Secret, NamedKeys, AgentBootstrapToken + DenyEmpty flag, Session, TrustedProxies, DemoModeAck + TS + ResidualStrict, OIDC pre-login binding knobs, Breakglass, BootstrapAdminGroups + ProviderID + BootstrapToken) - SessionConfig (Auth Bundle 2 Phase 4: IdleTimeout, AbsoluteTimeout, SigningKeyRetention, GCInterval, SameSite, BindIP, BindUserAgent) - BreakglassConfig (Auth Bundle 2 Phase 7.5: Enabled + LockoutThreshold + Duration + Reset) Three helpers (TWO exported — first sprint to move public-API): - ValidAuthTypes() — single source of truth for the allowed CERTCTL_AUTH_TYPE set. EXPORTED. External callers (verified clean via broader-importer build): cmd/server/main.go:115 internal/auth/middleware.go (doc ref) internal/api/handler/health.go (doc ref) - ParseNamedAPIKeys() — parses CERTCTL_API_KEYS_NAMED with L-004 rotation-aware duplicate-name handling + slog.Info "rotation window active" observability. EXPORTED. Test caller in config_test.go + production caller in Load() in config.go (intra-package, resolves via same-package lookup after move). - isValidKeyName() — alphanumeric + hyphen + underscore validator. Unexported; only called from ParseNamedAPIKeys (intra-file edge after the move — one fewer cross-file edge). External-importer surface (verified resolves clean post-move) ============================================================== The package name stays `config`, so every external reference continues to resolve. Live grep confirms the surface: cmd/server/main.go: - config.AuthType(...) (cast) - config.AuthTypeNone (const) - config.AuthTypeAPIKey (const) - config.AuthTypeOIDC (const) - config.ValidAuthTypes() (func) cmd/server/auth_backfill.go: - config.AuthType(...) (cast) - config.AuthTypeNone (const) internal/auth/middleware.go: - config.AuthType (doc reference + field-comment) - config.AuthTypeConsts (doc reference) internal/api/handler/health.go: - config.AuthType + config.ValidAuthTypes() (doc references) Verification (the critical broader-importer build): go build ./cmd/server/... ./internal/auth/... ./internal/api/router/... ./internal/api/handler/... ./internal/scheduler/... → clean If the move had accidentally renamed a symbol or changed a package boundary, that broader build would have failed loud. What stayed in config.go (intentionally) ======================================== - ErrAgentBootstrapTokenRequired sentinel (top-of-file Phase-2 sentinel block) — tied to Validate()'s fail-closed behavior, not to AuthConfig's struct shape. Same precedent as Sprint 2's ErrACMEInsecureWithoutAck and Sprint 3's leaving ErrDemoModeAckExpired in place. - demoModeAckMaxAge const (top-of-file) — tied to Validate()'s 24h TS-freshness check, not to struct shape. - The Validate() body that branches on AuthType / DemoModeAck / AgentBootstrapTokenDenyEmpty / DemoModeResidualStrict — cross- cutting validation logic that stays where the other Validate() branches live. - The Load() body that calls ParseNamedAPIKeys() during initial cfg.Auth.NamedKeys construction; same-package resolution. - Shared getEnv / getEnvBool / getEnvInt / getEnvDuration + splitComma + trimSpace helpers (splitComma + trimSpace are used by ParseNamedAPIKeys via same-package lookup). Edit shape ========== Two sed passes (the now-standard Sprint-3-onward pattern): 1. sed -i '847,1204d' — deleted the 358-line struct + enum + ValidAuthTypes block. 2. sed -i '1925,2068d' — deleted the 144-line helper block (positions shifted by Sprint 5's struct removal already applied; ParseNamedAPIKeys' new doc-comment start moved from 2283 → 1925). Then gofmt -w. No residual double-blank-line at either join — both removals happened mid-blank-separated regions cleanly. Public-surface invariant ======================== Every type, exported function, exported constant, exported field, and doc-comment is byte-identical to pre-split. Package stays `config`. Every external caller path is preserved. Verification (all clean): gofmt -l internal/config/ → clean go build ./internal/config/... → clean go test ./internal/config/... -count=1 → ok (0.70s) staticcheck ./internal/config/... → clean go build ./cmd/server/... ./internal/auth/... ./internal/api/router/... ./internal/api/handler/... ./internal/scheduler/... → clean grep -nE '^type (AuthConfig|SessionConfig|BreakglassConfig|NamedAPIKey|AuthType)|^func (ValidAuthTypes|ParseNamedAPIKeys|isValidKeyName)' internal/config/config.go → empty (none remain in config.go) grep -nE '^type (AuthConfig|SessionConfig|BreakglassConfig|NamedAPIKey|AuthType)|^func (ValidAuthTypes|ParseNamedAPIKeys|isValidKeyName)' internal/config/auth.go → 5 types + 3 funcs (correct) LOC delta: config.go: 2467 → 1963 (-504 lines: -358 struct block, -144 helper block, -2 from misc cleanup collapse) auth.go: new, 601 lines (incl. 101-line Phase 9 doc-comment + BSL header + package decl + 4 imports) Notable milestone: config.go is now BELOW 2000 LOC for the first time since the original audit. From 3403 → 1963 = -42.3% across Sprints 1+2+3+4+5. Cumulative Phase 9 progress (Sprints 1+2+3+4+5 from config.go): Pre-Phase-9: 3403 LOC After Sprint 1 (Notifier): 3335 LOC (-68) After Sprint 2 (ACME): 3108 LOC (-227) After Sprint 3 (SCEP): 2774 LOC (-334) After Sprint 4 (EST): 2467 LOC (-307) After Sprint 5 (Auth): 1963 LOC (-504) Total Sprint 1+2+3+4+5: -1440 LOC (-42.3%) Pattern lesson — exported-helper move ===================================== Pre-move check: enumerate every external caller via `grep -rnE 'config\.'`. If the symbol's external callers ARE all inside the same package, the move is trivial. If they're external, the move is still safe IFF the package name doesn't change — only the file the symbol lives IN changes. Same-package resolution at compile time guarantees the import-path that external code uses (`config.AuthType`, `config.ValidAuthTypes`) keeps working. The broader-importer build is the load-bearing verification: if it goes red, the move is wrong; green = safe. Next queued (Sprint 6): Server family from config.go → internal/config/server.go (~270 LOC). Includes ServerConfig + ServerTLSConfig + DatabaseConfig + SchedulerConfig + LogConfig + RateLimitConfig + CORSConfig + isLoopbackAddr (unexported HIGH-12 demo-mode helper). No exported helpers — back to the Sprint-3-style helper-bundle pattern, just bigger family. Closes: cowork/certctl-architecture-diligence-audit.html#fix-ARCH-M2 (partial — 5 of 12 — full ARCH-M2 closure is the aggregate) --- internal/config/auth.go | 601 ++++++++++++++++++++++++++++++++++++++ internal/config/config.go | 504 -------------------------------- 2 files changed, 601 insertions(+), 504 deletions(-) create mode 100644 internal/config/auth.go diff --git a/internal/config/auth.go b/internal/config/auth.go new file mode 100644 index 0000000..e85b8d1 --- /dev/null +++ b/internal/config/auth.go @@ -0,0 +1,601 @@ +// Copyright 2026 certctl LLC. All rights reserved. +// SPDX-License-Identifier: BUSL-1.1 + +package config + +import ( + "fmt" + "log/slog" + "strings" + "time" +) + +// Phase 9 ARCH-M2 closure Sprint 5 (2026-05-14): extracted from +// config.go. The largest split so far and the first to move +// EXPORTED helpers — every external importer of +// config.AuthType / config.AuthTypeNone / config.AuthTypeAPIKey / +// config.AuthTypeOIDC / config.ValidAuthTypes / config.ParseNamedAPIKeys +// resolves the same after the move because the package name stays +// `config`. Public-surface invariant is verified by: +// +// - broader-importer build: cmd/server/main.go + auth_backfill.go +// reference config.AuthType + config.AuthTypeNone + +// config.AuthTypeAPIKey + config.AuthTypeOIDC + +// config.ValidAuthTypes — all compile clean after the move. +// - internal/auth/middleware.go and internal/api/handler/health.go +// reference config.AuthType in doc comments + type fields. +// - go test ./internal/config/... — package tests (including +// config_test.go which pins "jwt" out of ValidAuthTypes per G-1) +// stay green. +// +// What lives here +// =============== +// Five types (one ergonomic enum + four config structs): +// +// NamedAPIKey — one named API-key entry with optional +// admin flag. Used by the authentication +// middleware for actor attribution in the +// audit trail (M-002 / M-003). +// AuthType (+ const) — the discriminator for the API auth +// middleware shape, with three named +// constants (AuthTypeAPIKey / AuthTypeNone / +// AuthTypeOIDC). The G-1 invariant pins +// "jwt" OUT of this set forever. +// AuthConfig — the top-level authentication configuration +// (Type, Secret, NamedKeys, AgentBootstrapToken, +// DemoModeAck + TS, OIDC pre-login binding +// knobs, embedded Session + Breakglass + +// the bootstrap-admin-group surface). +// SessionConfig — Auth Bundle 2 Phase 4 session-service +// tunables (idle / absolute / signing-key +// retention / GC / SameSite / IP+UA bind). +// BreakglassConfig — Auth Bundle 2 Phase 7.5 local-password +// break-glass tunables (enabled gate + +// lockout-threshold / duration / reset). +// +// Two exported helpers (FIRST sprint to move public-API helpers): +// +// ValidAuthTypes() — single source of truth for the allowed +// CERTCTL_AUTH_TYPE set. Called from: +// - cmd/server/main.go (runtime guard) +// - the validator below in config.go +// - the helm chart template +// - the property test in config_test.go +// that pins "jwt" out of the slice. +// ParseNamedAPIKeys() — parses the CERTCTL_API_KEYS_NAMED env-var +// into a []NamedAPIKey with rotation-aware +// duplicate-name handling (L-004 contract). +// +// One unexported helper: +// +// isValidKeyName() — alphanumeric + hyphen + underscore +// validator for the Name field of +// NamedAPIKey. Only called from +// ParseNamedAPIKeys (intra-file edge +// after the move). +// +// What stayed in config.go +// ======================== +// - ErrAgentBootstrapTokenRequired sentinel (top of config.go, in +// the Phase-2 sentinel block) — tied to Validate()'s behavior, +// not to AuthConfig's struct shape. Same precedent as Sprint 2's +// ErrACMEInsecureWithoutAck (which also stayed in config.go). +// ErrDemoModeAckExpired likewise (same reasoning). +// - The Validate() body that branches on AuthType / DemoModeAck / +// AgentBootstrapTokenDenyEmpty — cross-cutting validation that +// stays where the other Validate() branches live. +// - The Load() body that calls ParseNamedAPIKeys() and synthesizes +// the AuthConfig + SessionConfig + BreakglassConfig zero-values. +// - The shared getEnv / getEnvBool / getEnvInt / getEnvDuration +// helpers + splitComma + trimSpace (used by ParseNamedAPIKeys), +// shared across every config family. +// +// Public-surface invariant: go doc internal/config AuthConfig / +// SessionConfig / BreakglassConfig / NamedAPIKey / AuthType / +// AuthTypeAPIKey / AuthTypeNone / AuthTypeOIDC / ValidAuthTypes / +// ParseNamedAPIKeys all produce identical output before and after +// this split. + +// NamedAPIKey represents a single named API key with an optional admin flag. +// Named keys allow real actor attribution in the audit trail (M-002) and provide +// the admin-gate basis for privileged endpoints like bulk revocation (M-003). +type NamedAPIKey struct { + // Name is the identifier for the key (alphanumeric, hyphens, underscores). + // This value is recorded as the actor on every audit event the key authenticates. + Name string + // Key is the raw API-key secret the client presents as `Authorization: Bearer `. + Key string + // Admin controls whether the key has admin privileges (bulk revocation, etc.). + Admin bool +} + +// AuthType is the discriminator for the API auth middleware shape. The +// string alias preserves env-var roundtrip (the value flows through getEnv +// as a plain string) while giving us a typed surface for switches and +// validation. Use the named constants below rather than string literals +// so future enum additions/removals are caught at compile time. +// +// G-1 (P1): the pre-G-1 validAuthTypes map literal accepted "jwt" with no +// JWT middleware behind it (silent auth downgrade — the configured type +// was logged as "jwt" but every request routed through the api-key bearer +// middleware regardless). Operators who set CERTCTL_AUTH_TYPE=jwt thought +// they had JWT auth; they didn't. The typed alias + ValidAuthTypes() +// helper make the allowed set the single source of truth across config +// validation, the runtime defense-in-depth switch in main.go, and the +// helm-chart template guard (`certctl.validateAuthType`). +type AuthType string + +const ( + // AuthTypeAPIKey routes requests through the api-key bearer middleware. + // CERTCTL_AUTH_SECRET (or CERTCTL_API_KEYS_NAMED) is required. + AuthTypeAPIKey AuthType = "api-key" + + // AuthTypeNone disables authentication entirely. Development only — + // the server logs a loud Warn at startup. Operators who need + // JWT/OIDC/mTLS run an authenticating gateway (oauth2-proxy / Envoy + // ext_authz / Traefik ForwardAuth / Pomerium) in front of certctl + // and set this value on the upstream certctl process. See + // docs/architecture.md "Authenticating-gateway pattern". + AuthTypeNone AuthType = "none" + + // AuthTypeOIDC (Auth Bundle 2 Phase 0) reserves the literal that the + // OIDC handler chain (Bundle 2 Phase 5+6) consumes. Pre-Bundle-2 + // behavior: the literal is allowed by the validator but the handler + // chain is not yet wired, so the runtime guard in cmd/server/main.go + // surfaces a clear "oidc auth-type configured but Bundle 2 handlers + // not registered" error rather than silently falling back to api-key + // (the failure mode that drove G-1's jwt-literal removal). Once + // Bundle 2's session middleware + OIDC service ship, the runtime + // guard relaxes and CERTCTL_AUTH_TYPE=oidc routes through them. + // + // Note: this is the AUTH-TYPE literal value, NOT the JWT alg literal. + // ID tokens are JWTs internally but the auth-type config string is + // "oidc". The G-1 closure test (TestValidAuthTypesDoesNotContainJWT) + // stays passing because "jwt" is never added back to the slice. + AuthTypeOIDC AuthType = "oidc" +) + +// ValidAuthTypes returns the allowed CERTCTL_AUTH_TYPE values. The set is +// intentionally narrow — JWT was accepted pre-G-1 with no middleware +// implementation behind it. Single source of truth referenced by the +// validator below, the runtime guard in cmd/server/main.go, the helm +// chart template (`certctl.validateAuthType`), and the property test in +// config_test.go that pins "jwt" out of the slice forever. +// +// Bundle 2 Phase 0 adds AuthTypeOIDC to the slice. The G-1 invariant +// remains: "jwt" stays out of the allowed set forever; OIDC ID tokens +// are JWTs internally but the auth-type literal is "oidc", so the +// silent-downgrade attack surface that "jwt" represented does not +// regress. +func ValidAuthTypes() []AuthType { + return []AuthType{AuthTypeAPIKey, AuthTypeNone, AuthTypeOIDC} +} + +// AuthConfig contains authentication configuration. +type AuthConfig struct { + // Type sets the authentication mechanism for the REST API. + // Valid values: "api-key" (default, production) and "none" (development + // only — disables authentication on the API and logs a loud Warn at + // startup). For JWT/OIDC, run an authenticating gateway (oauth2-proxy / + // Envoy / Traefik ForwardAuth / Pomerium) in front of certctl and set + // CERTCTL_AUTH_TYPE=none on the upstream — see docs/architecture.md + // "Authenticating-gateway pattern" and docs/upgrade-to-v2-jwt-removal.md. + // Setting: CERTCTL_AUTH_TYPE environment variable. Default: "api-key". + // Use the AuthType constants (AuthTypeAPIKey / AuthTypeNone) for typed + // comparisons; the field stays `string` to preserve env-var roundtrip + // shape used by getEnv() and downstream Helm/compose interpolation. + Type string + + // Secret is the legacy authentication secret (comma-separated API keys). + // DEPRECATED in favor of NamedKeys — retained for backward compatibility. + // When NamedKeys is empty and Secret is set, each comma-separated key is + // registered as a synthesized named key (legacy-key-0, legacy-key-1, ...) + // with actor attribution defaulting to "legacy-key-". + // Setting: CERTCTL_AUTH_SECRET environment variable. + Secret string + + // NamedKeys is the parsed set of named API keys. Populated from + // CERTCTL_API_KEYS_NAMED via ParseNamedAPIKeys during Load(). When + // non-empty, this takes precedence over the legacy Secret field. + // Setting: CERTCTL_API_KEYS_NAMED="name1:key1,name2:key2:admin" + NamedKeys []NamedAPIKey + + // AgentBootstrapToken is the pre-shared secret enforced on the agent + // registration endpoint (POST /api/v1/agents). Bundle-5 / Audit H-007 / + // CWE-306 + CWE-288: pre-Bundle-5, any host with network reach to the + // server could self-register an agent and start polling for work — no + // shared secret required. Post-Bundle-5: when this field is non-empty, + // the registration handler requires `Authorization: Bearer ` + // (constant-time comparison via crypto/subtle.ConstantTimeCompare); 401 + // on missing/wrong/malformed. + // + // Backwards compatibility: when empty (the v2.0.x default), the server + // logs a startup WARN announcing the v2.2.0 deprecation — the field + // will become required in v2.2.0 and unset will fail-loud — and accepts + // registrations as today. Existing demo deploys that don't set it keep + // working through v2.1.x. + // + // Generation guidance: `openssl rand -hex 32` (256-bit entropy). + // Setting: CERTCTL_AGENT_BOOTSTRAP_TOKEN environment variable. + AgentBootstrapToken string + + // AgentBootstrapTokenDenyEmpty is the staged feature flag for SEC-H1 + // (Phase 2, 2026-05-13). When true AND AgentBootstrapToken is empty, + // Validate() returns ErrAgentBootstrapTokenRequired and the server + // refuses to start. Default: false (warn-mode pass-through preserved + // for backward compatibility with operators on the v2.1.x line). + // WORKSPACE-ROADMAP.md schedules the default flip to true for the + // v2.2.0 cut — operators get one upgrade-window to set a real token. + // Setting: CERTCTL_AGENT_BOOTSTRAP_TOKEN_DENY_EMPTY environment variable. + AgentBootstrapTokenDenyEmpty bool + + // Session holds the Auth Bundle 2 Phase 4 session-service tunables. + // Defaults are documented on the SessionConfig fields. The session + // service is wired into cmd/server/main.go alongside the OIDC + // service in Phase 5; pre-Phase-5 deployments that run with the + // legacy `api-key` auth type ignore this struct entirely. + Session SessionConfig + + // TrustedProxies is the comma-separated list of CIDR ranges from + // which X-Forwarded-For is honored. Empty (default) disables XFF + // trust entirely — every request's source IP is read from + // r.RemoteAddr regardless of XFF headers. Audit 2026-05-10 LOW-5 + // closure: pre-fix the audit subsystem trusted any caller-supplied + // XFF for IP attribution, letting an attacker inject arbitrary IPs + // into audit rows + session IP-binding. Post-fix XFF is read only + // when the direct connection's RemoteAddr is in this allowlist. + // Setting: CERTCTL_TRUSTED_PROXIES (e.g. "10.0.0.0/8,192.168.0.0/16"). + TrustedProxies []string + + // DemoModeAck must be true to allow CERTCTL_AUTH_TYPE=none with a + // non-loopback listen address. Default false. Audit 2026-05-10 + // HIGH-12 closure: pre-fix, an operator who flipped Type=none + // "temporarily" or via misconfig exposed admin functions to anyone + // reachable on port 8443 — the demo-mode synthetic actor + // `actor-demo-anon` is wired with `AdminKey=true`, so every + // request was served as a full admin. The control plane is + // HTTPS-only but a misconfigured ingress / public bind meant + // unauthenticated full admin. Post-fix: Validate() refuses to + // start when Type=none AND the listener binds to a non-loopback + // address (0.0.0.0, ::, or a routable IP) UNLESS the operator + // also sets DemoModeAck=true to acknowledge the bypass. Production + // deployments MUST set Type to a real authn type (api-key | oidc). + // Setting: CERTCTL_DEMO_MODE_ACK environment variable. + DemoModeAck bool + + // DemoModeAckTS is the unix-epoch timestamp at which DemoModeAck was + // last acknowledged. Phase 2 SEC-H3 closure (2026-05-13): the sticky + // DemoModeAck bit now expires after 24h. When DemoModeAck=true, + // Validate() requires DemoModeAckTS to be set AND parse as a unix + // epoch within the last demoModeAckMaxAge (24h); otherwise + // ErrDemoModeAckExpired fires and the server refuses to start. + // + // This catches the canonical "demo deployment accidentally + // promoted to production and forgotten about" failure mode: the + // container restart that re-loads config now refuses unless the + // operator re-supplies a fresh timestamp. + // + // Setting: CERTCTL_DEMO_MODE_ACK_TS (unix epoch, e.g. `$(date +%s)`). + // The demo compose helper sets this automatically at compose-up. + DemoModeAckTS string + + // DemoModeResidualStrict refuses startup when Auth.Type != none + // and `actor-demo-anon` has residual role grants in actor_roles. + // Default false (emit WARN log + audit row instead). Audit + // 2026-05-11 A-8 closure — closes the deferred Phase 2 leg of + // HIGH-12 (cowork/auth-bundles-fixes-2026-05-10/11-high-12-...). + // + // Note: migration 000029 unconditionally seeds the + // `ar-demo-anon-admin` grant of `r-admin` to `actor-demo-anon` + // for every install, so production deploys will see this WARN + // out of the box. The intended workflow at production cutover is: + // 1. POST /api/v1/auth/demo-residual/cleanup (or run the + // DELETE FROM actor_roles WHERE actor_id='actor-demo-anon' + // SQL emitted by the WARN). + // 2. Optionally set this flag for subsequent boots to refuse + // startup if the rows somehow get re-seeded. + // + // Setting: CERTCTL_DEMO_MODE_RESIDUAL_STRICT environment variable. + DemoModeResidualStrict bool + + // OIDCBCLMaxAgeSeconds is the iat-freshness skew window for OIDC + // back-channel-logout tokens. logout_tokens with iat outside the + // window are rejected with audit outcome=iat_stale (in the past) + // or iat_future (in the future). Audit 2026-05-10 HIGH-3 closure. + // Default 60s matches the ID-token skew tolerance in + // internal/auth/oidc/service.go. Range: 10-300; values outside + // this window indicate IdP clock misconfiguration that warrants + // operator attention. + // Setting: CERTCTL_OIDC_BCL_MAX_AGE_SECONDS environment variable. + OIDCBCLMaxAgeSeconds int + + // OIDCPreLoginRequireUA enables the RFC 9700 §4.7.1 user-agent + // binding check on /auth/oidc/callback. Audit 2026-05-10 MED-16. + // Default true. Operators on enterprise proxies that rewrite the + // UA header set this false; the binding value is still persisted + // + audited even when enforcement is off so retroactive forensics + // remain possible. + // Setting: CERTCTL_OIDC_PRELOGIN_REQUIRE_UA environment variable. + OIDCPreLoginRequireUA bool + + // OIDCPreLoginRequireIP enables the RFC 9700 §4.7.1 source-IP + // binding check on /auth/oidc/callback. Audit 2026-05-10 MED-16. + // Default true. Operators on dual-stack v4/v6 or mobile + // carrier-grade NAT where source IP routinely flips set this + // false; persistence + audit behave the same as UA above. + // Setting: CERTCTL_OIDC_PRELOGIN_REQUIRE_IP environment variable. + OIDCPreLoginRequireIP bool + + // Breakglass holds the Auth Bundle 2 Phase 7.5 break-glass admin + // tunables. Default-OFF; the entire surface is invisible (404 + // instead of 403) when CERTCTL_BREAKGLASS_ENABLED is not true. + // Threat model: enabling break-glass is a deliberate bypass of + // the SSO security boundary; operators turn it on during SSO + // incidents and turn it off after recovery. + Breakglass BreakglassConfig + + // BootstrapAdminGroups is the comma-separated list of IdP group + // names that grant the FIRST OIDC-authenticated user the r-admin + // role. Auth Bundle 2 Phase 7 / Decision 3. Empty (default) + // disables the OIDC-first-admin bootstrap path; the env-var-token + // path (BootstrapToken below) remains the fallback for fresh + // deployments without OIDC. When both are configured, OIDC wins + // on group match. + // Setting: CERTCTL_BOOTSTRAP_ADMIN_GROUPS environment variable. + BootstrapAdminGroups []string + + // BootstrapOIDCProviderID restricts the OIDC-first-admin bootstrap + // path to a specific provider id (matches the seeded provider + // name in oidc_providers.id). Empty (default) accepts a match + // from any configured provider. Useful when an operator + // configures multiple IdPs and wants only the corporate IdP to + // be eligible for bootstrap. + // Setting: CERTCTL_BOOTSTRAP_OIDC_PROVIDER_ID environment variable. + BootstrapOIDCProviderID string + + // BootstrapToken is the one-shot pre-shared secret that gates the + // Bundle 1 Phase 6 bootstrap endpoint (POST /v1/auth/bootstrap). When + // set at server startup AND no admin-roled actors exist, the + // bootstrap endpoint becomes callable: an operator POSTs the token + // and a desired admin-key name; the server mints a fresh API key, + // grants it the r-admin role, and returns the key value once. The + // token is then invalidated in memory; subsequent calls return 410 + // Gone. The endpoint also returns 410 Gone when admin actors already + // exist (no need for the bootstrap path). + // + // Server NEVER logs this token. The minted admin key is returned in + // the HTTP response body only; not logged. Operators who lose track + // of the minted key can rotate it via the regular RBAC API after + // bootstrap. + // + // Generation guidance: `openssl rand -hex 32` (256-bit entropy). + // Setting: CERTCTL_BOOTSTRAP_TOKEN environment variable. + BootstrapToken string +} + +// SessionConfig contains the Auth Bundle 2 Phase 4 session-service +// tunables. Every field is operator-overridable via the documented +// CERTCTL_SESSION_* env var; defaults are the conservative values from +// the Phase 4 spec. +// +// Bundle 2 Phase 4 / OWASP ASVS V3 (Session Management). The defaults +// (1h idle / 8h absolute / 24h key retention / 1h GC / Lax cookies / +// no IP-or-UA bind) are the conservative starting point that matches +// the prompt; tightening to Strict + IP/UA bind suits high-security +// environments at the cost of breaking inbound deep-links from external +// apps and login-from-mobile-on-cellular flows. +type SessionConfig struct { + // IdleTimeout: maximum time between authenticated requests on a + // session before re-auth is required. Default 1h. Wire: + // CERTCTL_SESSION_IDLE_TIMEOUT. + IdleTimeout time.Duration + + // AbsoluteTimeout: maximum lifetime of a session regardless of + // activity. Default 8h. Wire: CERTCTL_SESSION_ABSOLUTE_TIMEOUT. + AbsoluteTimeout time.Duration + + // SigningKeyRetention: time a retired signing key stays valid for + // verification before being purged from the keys table. Default + // 24h. Wire: CERTCTL_SESSION_SIGNING_KEY_RETENTION. + SigningKeyRetention time.Duration + + // GCInterval: scheduler tick interval for the session-GC sweep. + // Default 1h. Wire: CERTCTL_SESSION_GC_INTERVAL. + GCInterval time.Duration + + // SameSite: SameSite cookie attribute. Valid values: "Lax" + // (default) or "Strict". Strict is recommended for high-security + // environments at the cost of breaking inbound deep-links from + // external apps. Wire: CERTCTL_SESSION_SAMESITE. + SameSite string + + // BindIP: when true, the session middleware compares the request's + // client IP to the session row's recorded IP on every Validate. + // Mismatch -> 401, audit row, session NOT auto-revoked (user may + // have legitimate IP change). Default false. Wire: + // CERTCTL_SESSION_BIND_IP. + BindIP bool + + // BindUserAgent: when true, the session middleware compares the + // request's User-Agent to the session row's recorded UA on every + // Validate. Default false; useful only in tightly-controlled + // environments. Wire: CERTCTL_SESSION_BIND_USER_AGENT. + BindUserAgent bool +} + +// BreakglassConfig contains the Auth Bundle 2 Phase 7.5 break-glass +// admin tunables. Decision 4: operator-toggleable local-password +// admin for the SSO-broken case. Default-OFF; the entire surface is +// invisible (404 NOT 403) when Enabled=false. +// +// Threat model (load-bearing): enabling break-glass is a deliberate +// bypass of the SSO security boundary. An attacker who phishes the +// password OR finds it in a compromised password manager bypasses +// MFA, OIDC, and every group-claim gate. Recommendation: keep +// CERTCTL_BREAKGLASS_ENABLED=false in steady-state. Enable only +// during SSO-broken incidents. Disable after recovery. WebAuthn +// pairing (v3 per Decision 12) is the load-bearing second factor. +type BreakglassConfig struct { + // Enabled gates the entire service surface. Default false. + // Wire: CERTCTL_BREAKGLASS_ENABLED. + Enabled bool + + // LockoutThreshold is the failure count that trips the lockout. + // Default 5. Wire: CERTCTL_BREAKGLASS_LOCKOUT_THRESHOLD. + LockoutThreshold int + + // LockoutDuration is how long the account stays locked after the + // threshold trips. Default 15m. + // Wire: CERTCTL_BREAKGLASS_LOCKOUT_DURATION. + LockoutDuration time.Duration + + // LockoutResetInterval is the idle time after last_failure_at + // before the failure counter resets to 0 on next attempt. + // Default 1h. Wire: CERTCTL_BREAKGLASS_LOCKOUT_RESET_INTERVAL. + LockoutResetInterval time.Duration +} + +// ParseNamedAPIKeys parses the CERTCTL_API_KEYS_NAMED environment variable. +// Format: "name1:key1,name2:key2:admin,name3:key3" +// The ":admin" suffix is optional; if present, the key has admin privileges. +// Returns a typed []NamedAPIKey so main.go can pass it directly to the +// middleware layer without type assertion gymnastics. +// +// Audit L-004 (CWE-924) — graceful key rotation contract: +// +// Two entries MAY share the same Name during a rotation overlap window: +// CERTCTL_API_KEYS_NAMED="alice:OLDKEY:admin,alice:NEWKEY:admin" +// When duplicates appear, both keys validate at the auth middleware +// (NewAuthWithNamedKeys iterates every entry on every request, so the +// match is by hash regardless of name collisions). Both produce the +// same UserKey context value (the shared name), which keeps the audit +// trail and per-user rate-limit bucket (Bundle B M-025) consistent +// across the rollover. +// +// The duplicate-name path is restricted: every entry sharing a name +// MUST carry the same admin flag — mixing admin=true with admin=false +// under the same identity would let a non-admin caller present the +// admin-flagged key and bypass the gate (or vice-versa). The contract +// is "rotate ONE key at a time"; the privilege level stays constant +// within the overlap window. +// +// Exact (name,key) duplicates are still rejected — that's a typo, +// not a rotation. Rotation requires DIFFERENT keys under the same +// name. +// +// Once the rollover is complete, the operator removes the OLDKEY +// entry and restarts. Single-entry steady state resumes. +// +// See docs/security.md::API key rotation for the full operator runbook. +func ParseNamedAPIKeys(input string) ([]NamedAPIKey, error) { + if input == "" { + return nil, nil + } + + parts := splitComma(input) + var keys []NamedAPIKey + // nameToAdmin pins the admin flag for any name we've seen before; it + // is consulted on subsequent duplicate-name entries to enforce the + // "matching admin" contract above. + nameToAdmin := make(map[string]bool) + // nameSeen records whether we've seen a name at all (used to + // distinguish first-occurrence from duplicate-occurrence; we need + // this separate from nameToAdmin because admin=false is a valid + // recorded state). + nameSeen := make(map[string]bool) + // pairSeen rejects exact (name,key) duplicates as typos. + pairSeen := make(map[string]bool) + + for _, part := range parts { + part = trimSpace(part) + if part == "" { + continue + } + + // Split by colon: name:key or name:key:admin + fields := strings.Split(part, ":") + if len(fields) < 2 || len(fields) > 3 { + return nil, fmt.Errorf("invalid named key format: %s (expected name:key or name:key:admin)", part) + } + + name := trimSpace(fields[0]) + key := trimSpace(fields[1]) + admin := false + + if len(fields) == 3 { + adminStr := trimSpace(fields[2]) + if adminStr == "admin" { + admin = true + } else { + return nil, fmt.Errorf("invalid admin flag: %s (expected 'admin')", adminStr) + } + } + + // Validate name format: alphanumeric, hyphens, underscores + if !isValidKeyName(name) { + return nil, fmt.Errorf("invalid key name: %s (must be alphanumeric, hyphens, underscores)", name) + } + + if key == "" { + return nil, fmt.Errorf("empty key for name: %s", name) + } + + // Typo guard: same (name,key) pair twice is never legitimate — + // rotation requires DIFFERENT keys under the same name. + pairKey := name + "\x00" + key + if pairSeen[pairKey] { + return nil, fmt.Errorf("duplicate (name,key) entry for name %q — rotation requires DIFFERENT keys under the same name", name) + } + pairSeen[pairKey] = true + + // Duplicate-name path: allowed iff admin flag matches the prior + // entry for the same name (L-004 rotation overlap contract). + if nameSeen[name] { + priorAdmin := nameToAdmin[name] + if priorAdmin != admin { + return nil, fmt.Errorf("duplicate key name %q with mismatched admin flag — rotation overlap requires both entries carry the same privilege level (prior=%v, this=%v)", name, priorAdmin, admin) + } + } else { + nameSeen[name] = true + nameToAdmin[name] = admin + } + + keys = append(keys, NamedAPIKey{ + Name: name, + Key: key, + Admin: admin, + }) + } + + // Rotation-window observability: emit a one-shot startup INFO log + // per name with multiple entries so operators can see the active + // overlap state in logs. (Single-entry steady state stays silent.) + nameCounts := make(map[string]int) + for _, k := range keys { + nameCounts[k.Name]++ + } + for name, count := range nameCounts { + if count > 1 { + slog.Info("api-key rotation window active", + "name", name, + "entries", count, + "see", "docs/security.md::api-key-rotation", + ) + } + } + + return keys, nil +} + +// isValidKeyName checks if a key name is valid (alphanumeric, hyphens, underscores). +func isValidKeyName(s string) bool { + if len(s) == 0 { + return false + } + for _, c := range s { + if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') { + return false + } + } + return true +} diff --git a/internal/config/config.go b/internal/config/config.go index c186678..defad6d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -844,365 +844,6 @@ type LogConfig struct { Format string } -// NamedAPIKey represents a single named API key with an optional admin flag. -// Named keys allow real actor attribution in the audit trail (M-002) and provide -// the admin-gate basis for privileged endpoints like bulk revocation (M-003). -type NamedAPIKey struct { - // Name is the identifier for the key (alphanumeric, hyphens, underscores). - // This value is recorded as the actor on every audit event the key authenticates. - Name string - // Key is the raw API-key secret the client presents as `Authorization: Bearer `. - Key string - // Admin controls whether the key has admin privileges (bulk revocation, etc.). - Admin bool -} - -// AuthType is the discriminator for the API auth middleware shape. The -// string alias preserves env-var roundtrip (the value flows through getEnv -// as a plain string) while giving us a typed surface for switches and -// validation. Use the named constants below rather than string literals -// so future enum additions/removals are caught at compile time. -// -// G-1 (P1): the pre-G-1 validAuthTypes map literal accepted "jwt" with no -// JWT middleware behind it (silent auth downgrade — the configured type -// was logged as "jwt" but every request routed through the api-key bearer -// middleware regardless). Operators who set CERTCTL_AUTH_TYPE=jwt thought -// they had JWT auth; they didn't. The typed alias + ValidAuthTypes() -// helper make the allowed set the single source of truth across config -// validation, the runtime defense-in-depth switch in main.go, and the -// helm-chart template guard (`certctl.validateAuthType`). -type AuthType string - -const ( - // AuthTypeAPIKey routes requests through the api-key bearer middleware. - // CERTCTL_AUTH_SECRET (or CERTCTL_API_KEYS_NAMED) is required. - AuthTypeAPIKey AuthType = "api-key" - - // AuthTypeNone disables authentication entirely. Development only — - // the server logs a loud Warn at startup. Operators who need - // JWT/OIDC/mTLS run an authenticating gateway (oauth2-proxy / Envoy - // ext_authz / Traefik ForwardAuth / Pomerium) in front of certctl - // and set this value on the upstream certctl process. See - // docs/architecture.md "Authenticating-gateway pattern". - AuthTypeNone AuthType = "none" - - // AuthTypeOIDC (Auth Bundle 2 Phase 0) reserves the literal that the - // OIDC handler chain (Bundle 2 Phase 5+6) consumes. Pre-Bundle-2 - // behavior: the literal is allowed by the validator but the handler - // chain is not yet wired, so the runtime guard in cmd/server/main.go - // surfaces a clear "oidc auth-type configured but Bundle 2 handlers - // not registered" error rather than silently falling back to api-key - // (the failure mode that drove G-1's jwt-literal removal). Once - // Bundle 2's session middleware + OIDC service ship, the runtime - // guard relaxes and CERTCTL_AUTH_TYPE=oidc routes through them. - // - // Note: this is the AUTH-TYPE literal value, NOT the JWT alg literal. - // ID tokens are JWTs internally but the auth-type config string is - // "oidc". The G-1 closure test (TestValidAuthTypesDoesNotContainJWT) - // stays passing because "jwt" is never added back to the slice. - AuthTypeOIDC AuthType = "oidc" -) - -// ValidAuthTypes returns the allowed CERTCTL_AUTH_TYPE values. The set is -// intentionally narrow — JWT was accepted pre-G-1 with no middleware -// implementation behind it. Single source of truth referenced by the -// validator below, the runtime guard in cmd/server/main.go, the helm -// chart template (`certctl.validateAuthType`), and the property test in -// config_test.go that pins "jwt" out of the slice forever. -// -// Bundle 2 Phase 0 adds AuthTypeOIDC to the slice. The G-1 invariant -// remains: "jwt" stays out of the allowed set forever; OIDC ID tokens -// are JWTs internally but the auth-type literal is "oidc", so the -// silent-downgrade attack surface that "jwt" represented does not -// regress. -func ValidAuthTypes() []AuthType { - return []AuthType{AuthTypeAPIKey, AuthTypeNone, AuthTypeOIDC} -} - -// AuthConfig contains authentication configuration. -type AuthConfig struct { - // Type sets the authentication mechanism for the REST API. - // Valid values: "api-key" (default, production) and "none" (development - // only — disables authentication on the API and logs a loud Warn at - // startup). For JWT/OIDC, run an authenticating gateway (oauth2-proxy / - // Envoy / Traefik ForwardAuth / Pomerium) in front of certctl and set - // CERTCTL_AUTH_TYPE=none on the upstream — see docs/architecture.md - // "Authenticating-gateway pattern" and docs/upgrade-to-v2-jwt-removal.md. - // Setting: CERTCTL_AUTH_TYPE environment variable. Default: "api-key". - // Use the AuthType constants (AuthTypeAPIKey / AuthTypeNone) for typed - // comparisons; the field stays `string` to preserve env-var roundtrip - // shape used by getEnv() and downstream Helm/compose interpolation. - Type string - - // Secret is the legacy authentication secret (comma-separated API keys). - // DEPRECATED in favor of NamedKeys — retained for backward compatibility. - // When NamedKeys is empty and Secret is set, each comma-separated key is - // registered as a synthesized named key (legacy-key-0, legacy-key-1, ...) - // with actor attribution defaulting to "legacy-key-". - // Setting: CERTCTL_AUTH_SECRET environment variable. - Secret string - - // NamedKeys is the parsed set of named API keys. Populated from - // CERTCTL_API_KEYS_NAMED via ParseNamedAPIKeys during Load(). When - // non-empty, this takes precedence over the legacy Secret field. - // Setting: CERTCTL_API_KEYS_NAMED="name1:key1,name2:key2:admin" - NamedKeys []NamedAPIKey - - // AgentBootstrapToken is the pre-shared secret enforced on the agent - // registration endpoint (POST /api/v1/agents). Bundle-5 / Audit H-007 / - // CWE-306 + CWE-288: pre-Bundle-5, any host with network reach to the - // server could self-register an agent and start polling for work — no - // shared secret required. Post-Bundle-5: when this field is non-empty, - // the registration handler requires `Authorization: Bearer ` - // (constant-time comparison via crypto/subtle.ConstantTimeCompare); 401 - // on missing/wrong/malformed. - // - // Backwards compatibility: when empty (the v2.0.x default), the server - // logs a startup WARN announcing the v2.2.0 deprecation — the field - // will become required in v2.2.0 and unset will fail-loud — and accepts - // registrations as today. Existing demo deploys that don't set it keep - // working through v2.1.x. - // - // Generation guidance: `openssl rand -hex 32` (256-bit entropy). - // Setting: CERTCTL_AGENT_BOOTSTRAP_TOKEN environment variable. - AgentBootstrapToken string - - // AgentBootstrapTokenDenyEmpty is the staged feature flag for SEC-H1 - // (Phase 2, 2026-05-13). When true AND AgentBootstrapToken is empty, - // Validate() returns ErrAgentBootstrapTokenRequired and the server - // refuses to start. Default: false (warn-mode pass-through preserved - // for backward compatibility with operators on the v2.1.x line). - // WORKSPACE-ROADMAP.md schedules the default flip to true for the - // v2.2.0 cut — operators get one upgrade-window to set a real token. - // Setting: CERTCTL_AGENT_BOOTSTRAP_TOKEN_DENY_EMPTY environment variable. - AgentBootstrapTokenDenyEmpty bool - - // Session holds the Auth Bundle 2 Phase 4 session-service tunables. - // Defaults are documented on the SessionConfig fields. The session - // service is wired into cmd/server/main.go alongside the OIDC - // service in Phase 5; pre-Phase-5 deployments that run with the - // legacy `api-key` auth type ignore this struct entirely. - Session SessionConfig - - // TrustedProxies is the comma-separated list of CIDR ranges from - // which X-Forwarded-For is honored. Empty (default) disables XFF - // trust entirely — every request's source IP is read from - // r.RemoteAddr regardless of XFF headers. Audit 2026-05-10 LOW-5 - // closure: pre-fix the audit subsystem trusted any caller-supplied - // XFF for IP attribution, letting an attacker inject arbitrary IPs - // into audit rows + session IP-binding. Post-fix XFF is read only - // when the direct connection's RemoteAddr is in this allowlist. - // Setting: CERTCTL_TRUSTED_PROXIES (e.g. "10.0.0.0/8,192.168.0.0/16"). - TrustedProxies []string - - // DemoModeAck must be true to allow CERTCTL_AUTH_TYPE=none with a - // non-loopback listen address. Default false. Audit 2026-05-10 - // HIGH-12 closure: pre-fix, an operator who flipped Type=none - // "temporarily" or via misconfig exposed admin functions to anyone - // reachable on port 8443 — the demo-mode synthetic actor - // `actor-demo-anon` is wired with `AdminKey=true`, so every - // request was served as a full admin. The control plane is - // HTTPS-only but a misconfigured ingress / public bind meant - // unauthenticated full admin. Post-fix: Validate() refuses to - // start when Type=none AND the listener binds to a non-loopback - // address (0.0.0.0, ::, or a routable IP) UNLESS the operator - // also sets DemoModeAck=true to acknowledge the bypass. Production - // deployments MUST set Type to a real authn type (api-key | oidc). - // Setting: CERTCTL_DEMO_MODE_ACK environment variable. - DemoModeAck bool - - // DemoModeAckTS is the unix-epoch timestamp at which DemoModeAck was - // last acknowledged. Phase 2 SEC-H3 closure (2026-05-13): the sticky - // DemoModeAck bit now expires after 24h. When DemoModeAck=true, - // Validate() requires DemoModeAckTS to be set AND parse as a unix - // epoch within the last demoModeAckMaxAge (24h); otherwise - // ErrDemoModeAckExpired fires and the server refuses to start. - // - // This catches the canonical "demo deployment accidentally - // promoted to production and forgotten about" failure mode: the - // container restart that re-loads config now refuses unless the - // operator re-supplies a fresh timestamp. - // - // Setting: CERTCTL_DEMO_MODE_ACK_TS (unix epoch, e.g. `$(date +%s)`). - // The demo compose helper sets this automatically at compose-up. - DemoModeAckTS string - - // DemoModeResidualStrict refuses startup when Auth.Type != none - // and `actor-demo-anon` has residual role grants in actor_roles. - // Default false (emit WARN log + audit row instead). Audit - // 2026-05-11 A-8 closure — closes the deferred Phase 2 leg of - // HIGH-12 (cowork/auth-bundles-fixes-2026-05-10/11-high-12-...). - // - // Note: migration 000029 unconditionally seeds the - // `ar-demo-anon-admin` grant of `r-admin` to `actor-demo-anon` - // for every install, so production deploys will see this WARN - // out of the box. The intended workflow at production cutover is: - // 1. POST /api/v1/auth/demo-residual/cleanup (or run the - // DELETE FROM actor_roles WHERE actor_id='actor-demo-anon' - // SQL emitted by the WARN). - // 2. Optionally set this flag for subsequent boots to refuse - // startup if the rows somehow get re-seeded. - // - // Setting: CERTCTL_DEMO_MODE_RESIDUAL_STRICT environment variable. - DemoModeResidualStrict bool - - // OIDCBCLMaxAgeSeconds is the iat-freshness skew window for OIDC - // back-channel-logout tokens. logout_tokens with iat outside the - // window are rejected with audit outcome=iat_stale (in the past) - // or iat_future (in the future). Audit 2026-05-10 HIGH-3 closure. - // Default 60s matches the ID-token skew tolerance in - // internal/auth/oidc/service.go. Range: 10-300; values outside - // this window indicate IdP clock misconfiguration that warrants - // operator attention. - // Setting: CERTCTL_OIDC_BCL_MAX_AGE_SECONDS environment variable. - OIDCBCLMaxAgeSeconds int - - // OIDCPreLoginRequireUA enables the RFC 9700 §4.7.1 user-agent - // binding check on /auth/oidc/callback. Audit 2026-05-10 MED-16. - // Default true. Operators on enterprise proxies that rewrite the - // UA header set this false; the binding value is still persisted - // + audited even when enforcement is off so retroactive forensics - // remain possible. - // Setting: CERTCTL_OIDC_PRELOGIN_REQUIRE_UA environment variable. - OIDCPreLoginRequireUA bool - - // OIDCPreLoginRequireIP enables the RFC 9700 §4.7.1 source-IP - // binding check on /auth/oidc/callback. Audit 2026-05-10 MED-16. - // Default true. Operators on dual-stack v4/v6 or mobile - // carrier-grade NAT where source IP routinely flips set this - // false; persistence + audit behave the same as UA above. - // Setting: CERTCTL_OIDC_PRELOGIN_REQUIRE_IP environment variable. - OIDCPreLoginRequireIP bool - - // Breakglass holds the Auth Bundle 2 Phase 7.5 break-glass admin - // tunables. Default-OFF; the entire surface is invisible (404 - // instead of 403) when CERTCTL_BREAKGLASS_ENABLED is not true. - // Threat model: enabling break-glass is a deliberate bypass of - // the SSO security boundary; operators turn it on during SSO - // incidents and turn it off after recovery. - Breakglass BreakglassConfig - - // BootstrapAdminGroups is the comma-separated list of IdP group - // names that grant the FIRST OIDC-authenticated user the r-admin - // role. Auth Bundle 2 Phase 7 / Decision 3. Empty (default) - // disables the OIDC-first-admin bootstrap path; the env-var-token - // path (BootstrapToken below) remains the fallback for fresh - // deployments without OIDC. When both are configured, OIDC wins - // on group match. - // Setting: CERTCTL_BOOTSTRAP_ADMIN_GROUPS environment variable. - BootstrapAdminGroups []string - - // BootstrapOIDCProviderID restricts the OIDC-first-admin bootstrap - // path to a specific provider id (matches the seeded provider - // name in oidc_providers.id). Empty (default) accepts a match - // from any configured provider. Useful when an operator - // configures multiple IdPs and wants only the corporate IdP to - // be eligible for bootstrap. - // Setting: CERTCTL_BOOTSTRAP_OIDC_PROVIDER_ID environment variable. - BootstrapOIDCProviderID string - - // BootstrapToken is the one-shot pre-shared secret that gates the - // Bundle 1 Phase 6 bootstrap endpoint (POST /v1/auth/bootstrap). When - // set at server startup AND no admin-roled actors exist, the - // bootstrap endpoint becomes callable: an operator POSTs the token - // and a desired admin-key name; the server mints a fresh API key, - // grants it the r-admin role, and returns the key value once. The - // token is then invalidated in memory; subsequent calls return 410 - // Gone. The endpoint also returns 410 Gone when admin actors already - // exist (no need for the bootstrap path). - // - // Server NEVER logs this token. The minted admin key is returned in - // the HTTP response body only; not logged. Operators who lose track - // of the minted key can rotate it via the regular RBAC API after - // bootstrap. - // - // Generation guidance: `openssl rand -hex 32` (256-bit entropy). - // Setting: CERTCTL_BOOTSTRAP_TOKEN environment variable. - BootstrapToken string -} - -// SessionConfig contains the Auth Bundle 2 Phase 4 session-service -// tunables. Every field is operator-overridable via the documented -// CERTCTL_SESSION_* env var; defaults are the conservative values from -// the Phase 4 spec. -// -// Bundle 2 Phase 4 / OWASP ASVS V3 (Session Management). The defaults -// (1h idle / 8h absolute / 24h key retention / 1h GC / Lax cookies / -// no IP-or-UA bind) are the conservative starting point that matches -// the prompt; tightening to Strict + IP/UA bind suits high-security -// environments at the cost of breaking inbound deep-links from external -// apps and login-from-mobile-on-cellular flows. -type SessionConfig struct { - // IdleTimeout: maximum time between authenticated requests on a - // session before re-auth is required. Default 1h. Wire: - // CERTCTL_SESSION_IDLE_TIMEOUT. - IdleTimeout time.Duration - - // AbsoluteTimeout: maximum lifetime of a session regardless of - // activity. Default 8h. Wire: CERTCTL_SESSION_ABSOLUTE_TIMEOUT. - AbsoluteTimeout time.Duration - - // SigningKeyRetention: time a retired signing key stays valid for - // verification before being purged from the keys table. Default - // 24h. Wire: CERTCTL_SESSION_SIGNING_KEY_RETENTION. - SigningKeyRetention time.Duration - - // GCInterval: scheduler tick interval for the session-GC sweep. - // Default 1h. Wire: CERTCTL_SESSION_GC_INTERVAL. - GCInterval time.Duration - - // SameSite: SameSite cookie attribute. Valid values: "Lax" - // (default) or "Strict". Strict is recommended for high-security - // environments at the cost of breaking inbound deep-links from - // external apps. Wire: CERTCTL_SESSION_SAMESITE. - SameSite string - - // BindIP: when true, the session middleware compares the request's - // client IP to the session row's recorded IP on every Validate. - // Mismatch -> 401, audit row, session NOT auto-revoked (user may - // have legitimate IP change). Default false. Wire: - // CERTCTL_SESSION_BIND_IP. - BindIP bool - - // BindUserAgent: when true, the session middleware compares the - // request's User-Agent to the session row's recorded UA on every - // Validate. Default false; useful only in tightly-controlled - // environments. Wire: CERTCTL_SESSION_BIND_USER_AGENT. - BindUserAgent bool -} - -// BreakglassConfig contains the Auth Bundle 2 Phase 7.5 break-glass -// admin tunables. Decision 4: operator-toggleable local-password -// admin for the SSO-broken case. Default-OFF; the entire surface is -// invisible (404 NOT 403) when Enabled=false. -// -// Threat model (load-bearing): enabling break-glass is a deliberate -// bypass of the SSO security boundary. An attacker who phishes the -// password OR finds it in a compromised password manager bypasses -// MFA, OIDC, and every group-claim gate. Recommendation: keep -// CERTCTL_BREAKGLASS_ENABLED=false in steady-state. Enable only -// during SSO-broken incidents. Disable after recovery. WebAuthn -// pairing (v3 per Decision 12) is the load-bearing second factor. -type BreakglassConfig struct { - // Enabled gates the entire service surface. Default false. - // Wire: CERTCTL_BREAKGLASS_ENABLED. - Enabled bool - - // LockoutThreshold is the failure count that trips the lockout. - // Default 5. Wire: CERTCTL_BREAKGLASS_LOCKOUT_THRESHOLD. - LockoutThreshold int - - // LockoutDuration is how long the account stays locked after the - // threshold trips. Default 15m. - // Wire: CERTCTL_BREAKGLASS_LOCKOUT_DURATION. - LockoutDuration time.Duration - - // LockoutResetInterval is the idle time after last_failure_at - // before the failure counter resets to 0 on next attempt. - // Default 1h. Wire: CERTCTL_BREAKGLASS_LOCKOUT_RESET_INTERVAL. - LockoutResetInterval time.Duration -} - // RateLimitConfig contains rate limiting configuration. // // Bundle B / Audit M-025 (OWASP ASVS L2 §11.2.1): pre-bundle the rate @@ -2280,151 +1921,6 @@ func (c *Config) GetLogLevel() slog.Level { } } -// ParseNamedAPIKeys parses the CERTCTL_API_KEYS_NAMED environment variable. -// Format: "name1:key1,name2:key2:admin,name3:key3" -// The ":admin" suffix is optional; if present, the key has admin privileges. -// Returns a typed []NamedAPIKey so main.go can pass it directly to the -// middleware layer without type assertion gymnastics. -// -// Audit L-004 (CWE-924) — graceful key rotation contract: -// -// Two entries MAY share the same Name during a rotation overlap window: -// CERTCTL_API_KEYS_NAMED="alice:OLDKEY:admin,alice:NEWKEY:admin" -// When duplicates appear, both keys validate at the auth middleware -// (NewAuthWithNamedKeys iterates every entry on every request, so the -// match is by hash regardless of name collisions). Both produce the -// same UserKey context value (the shared name), which keeps the audit -// trail and per-user rate-limit bucket (Bundle B M-025) consistent -// across the rollover. -// -// The duplicate-name path is restricted: every entry sharing a name -// MUST carry the same admin flag — mixing admin=true with admin=false -// under the same identity would let a non-admin caller present the -// admin-flagged key and bypass the gate (or vice-versa). The contract -// is "rotate ONE key at a time"; the privilege level stays constant -// within the overlap window. -// -// Exact (name,key) duplicates are still rejected — that's a typo, -// not a rotation. Rotation requires DIFFERENT keys under the same -// name. -// -// Once the rollover is complete, the operator removes the OLDKEY -// entry and restarts. Single-entry steady state resumes. -// -// See docs/security.md::API key rotation for the full operator runbook. -func ParseNamedAPIKeys(input string) ([]NamedAPIKey, error) { - if input == "" { - return nil, nil - } - - parts := splitComma(input) - var keys []NamedAPIKey - // nameToAdmin pins the admin flag for any name we've seen before; it - // is consulted on subsequent duplicate-name entries to enforce the - // "matching admin" contract above. - nameToAdmin := make(map[string]bool) - // nameSeen records whether we've seen a name at all (used to - // distinguish first-occurrence from duplicate-occurrence; we need - // this separate from nameToAdmin because admin=false is a valid - // recorded state). - nameSeen := make(map[string]bool) - // pairSeen rejects exact (name,key) duplicates as typos. - pairSeen := make(map[string]bool) - - for _, part := range parts { - part = trimSpace(part) - if part == "" { - continue - } - - // Split by colon: name:key or name:key:admin - fields := strings.Split(part, ":") - if len(fields) < 2 || len(fields) > 3 { - return nil, fmt.Errorf("invalid named key format: %s (expected name:key or name:key:admin)", part) - } - - name := trimSpace(fields[0]) - key := trimSpace(fields[1]) - admin := false - - if len(fields) == 3 { - adminStr := trimSpace(fields[2]) - if adminStr == "admin" { - admin = true - } else { - return nil, fmt.Errorf("invalid admin flag: %s (expected 'admin')", adminStr) - } - } - - // Validate name format: alphanumeric, hyphens, underscores - if !isValidKeyName(name) { - return nil, fmt.Errorf("invalid key name: %s (must be alphanumeric, hyphens, underscores)", name) - } - - if key == "" { - return nil, fmt.Errorf("empty key for name: %s", name) - } - - // Typo guard: same (name,key) pair twice is never legitimate — - // rotation requires DIFFERENT keys under the same name. - pairKey := name + "\x00" + key - if pairSeen[pairKey] { - return nil, fmt.Errorf("duplicate (name,key) entry for name %q — rotation requires DIFFERENT keys under the same name", name) - } - pairSeen[pairKey] = true - - // Duplicate-name path: allowed iff admin flag matches the prior - // entry for the same name (L-004 rotation overlap contract). - if nameSeen[name] { - priorAdmin := nameToAdmin[name] - if priorAdmin != admin { - return nil, fmt.Errorf("duplicate key name %q with mismatched admin flag — rotation overlap requires both entries carry the same privilege level (prior=%v, this=%v)", name, priorAdmin, admin) - } - } else { - nameSeen[name] = true - nameToAdmin[name] = admin - } - - keys = append(keys, NamedAPIKey{ - Name: name, - Key: key, - Admin: admin, - }) - } - - // Rotation-window observability: emit a one-shot startup INFO log - // per name with multiple entries so operators can see the active - // overlap state in logs. (Single-entry steady state stays silent.) - nameCounts := make(map[string]int) - for _, k := range keys { - nameCounts[k.Name]++ - } - for name, count := range nameCounts { - if count > 1 { - slog.Info("api-key rotation window active", - "name", name, - "entries", count, - "see", "docs/security.md::api-key-rotation", - ) - } - } - - return keys, nil -} - -// isValidKeyName checks if a key name is valid (alphanumeric, hyphens, underscores). -func isValidKeyName(s string) bool { - if len(s) == 0 { - return false - } - for _, c := range s { - if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') { - return false - } - } - return true -} - // isLoopbackAddr returns true when host is bound to a loopback // interface only (127.0.0.1, ::1, or "localhost"). Used by the // HIGH-12 demo-mode startup guard to refuse non-loopback binds when