config: default hardening + operator docs (Phase 2 closure — SEC-H1, SEC-H3, SEC-M4, DEPL-H1, DEPL-M2 + doc-only carve-outs)

Eleven findings from the architecture diligence audit's Phase 2 bundle
closed in one PR. All touch the same backend config + Helm chart +
operator docs surface, so reviewing in one diff is the natural fit.

config.go: three new fail-closed Validate() branches behind sentinels
=====================================================================

Three new error sentinels exported from internal/config/config.go for
tests to pin via errors.Is + message-text:
  - ErrAgentBootstrapTokenRequired (SEC-H1)
  - ErrACMEInsecureWithoutAck      (SEC-M4)
  - ErrDemoModeAckExpired          (SEC-H3)

SEC-H1 (staged): introduces CERTCTL_AGENT_BOOTSTRAP_TOKEN_DENY_EMPTY
as an opt-in feature flag. When true AND the bootstrap token is empty,
Validate() returns ErrAgentBootstrapTokenRequired and the server
refuses to start. Default in THIS release: false (warn-mode
pass-through preserved). WORKSPACE-ROADMAP.md schedules the default
flip to true for v2.2.0 — operators get one upgrade window.

SEC-M4: upgrades the existing boot-time WARN log for
CERTCTL_ACME_INSECURE=true into a hard refuse-to-start gate behind
CERTCTL_ACME_INSECURE_ACK=true. The ACK env var must be paired with
the existing INSECURE flag; either alone fails closed. The boot-time
WARN log at cmd/server/main.go:611 continues to fire for the ACK'd
case so every restart logs the reminder.

SEC-H3: tightens the sticky DemoModeAck bit so it expires after 24h.
When DemoModeAck=true, Validate() now requires CERTCTL_DEMO_MODE_ACK_TS
to be set as a unix-epoch timestamp within the last 24h (24h-tolerance
on the past side, 1-minute clock-skew on the future side). Catches the
"forgotten demo deployment promoted to production" failure mode —
next container restart past 24h refuses unless re-ack'd.

Tests in internal/config/config_test.go cover every new branch:
positive (passes when properly set), negative (each fail-closed path
fires with the matching sentinel + message-text). 11 new tests added.

Helm chart + HA runbook (DEPL-H1)
=================================

Created docs/operator/runbooks/ha.md documenting the three values
flips required for production HA: server.replicas, podDisruptionBudget,
service.sessionAffinity. Cross-link comments added to
deploy/helm/certctl/values.yaml next to the server.replicas (line 19)
and podDisruptionBudget (line 566) defaults. DEFAULTS DO NOT CHANGE
— that's the point per the prompt's 'do not flip networkPolicy default'
guidance: a default-enabled PDB blocks fresh helm install on
single-node clusters.

CI guard (DEPL-M2)
==================

scripts/ci-guards/no-change-me-in-prod-compose.sh grep-fails any
'change-me-' literal in compose files OTHER than docker-compose.demo.yml.
Catches the placeholder-credential-leak regression one layer earlier
than the runtime Validate() fail-closed guards from Bundle 2 (2026-05-12).
Excludes comment lines so docs explaining the pattern don't trip the
guard. Verified to fire on a synthetic leak; clean on the current tree.

Consolidated 'Security carve-outs' doc section
==============================================

docs/operator/security.md grows by one new section documenting the
seven existing carve-outs in one canonical place:
  - SEC-M3: 3 InsecureSkipVerify=true sites (Agent dev, verify probe, tlsprobe)
  - SEC-M5: F5 connector InsecureSkipVerify per-config field
  - SEC-M4: ACME insecure + new ACK gate
  - SEC-L1: CSP 'unsafe-inline' on style-src (Tailwind carve-out)
  - SEC-L2: break-glass Argon2id rest-defense reminder
  - SEC-L3: 1 MB body-size cap + CERTCTL_MAX_BODY_SIZE override
  - DEPL-M2: change-me-* placeholder credentials in demo overlay
  - DEPL-M3: K8s NetworkPolicy operator-opt-in default

Each entry cites the file:line, the rationale for the carve-out, and
the operator action.

CHANGELOG + ENVIRONMENTS coverage
==================================

CHANGELOG.md grows by one new '### Breaking changes (scheduled for
v2.2.0)' section under Unreleased, documenting SEC-H1 / SEC-M4 / SEC-H3
with explicit upgrade-window guidance for each.

deploy/ENVIRONMENTS.md adds five rows: AGENT_BOOTSTRAP_TOKEN +
AGENT_BOOTSTRAP_TOKEN_DENY_EMPTY + DEMO_MODE_ACK + DEMO_MODE_ACK_TS +
ACME_INSECURE_ACK. G-3 env-docs-drift CI guard stays clean.

WORKSPACE-ROADMAP.md (cowork-side) schedules the SEC-H1 default-flip
for v2.2.0.

Sandbox limitation
==================

The certctl repo's working tree is 6.1 GB which fills the sandbox
volume; the go1.25.10 toolchain download (go.mod requires it,
sandbox has 1.25.9) keeps failing on disk-full. Local 'go build' /
'go test' were NOT run in this commit's verification path.
make verify MUST be run on the operator's workstation before push
per CLAUDE.md operating rules.

CI guards (no-change-me, G-3 env-docs-drift, doc-rot-detector, +
all existing) verified clean by running each individually.

Closes: cowork/certctl-architecture-diligence-audit.html#fix-SEC-H1,
        cowork/certctl-architecture-diligence-audit.html#fix-SEC-H3,
        cowork/certctl-architecture-diligence-audit.html#fix-SEC-M4,
        cowork/certctl-architecture-diligence-audit.html#fix-DEPL-H1,
        cowork/certctl-architecture-diligence-audit.html#fix-DEPL-M2,
        cowork/certctl-architecture-diligence-audit.html#fix-DEPL-M3,
        cowork/certctl-architecture-diligence-audit.html#fix-SEC-M3,
        cowork/certctl-architecture-diligence-audit.html#fix-SEC-M5,
        cowork/certctl-architecture-diligence-audit.html#fix-SEC-L1,
        cowork/certctl-architecture-diligence-audit.html#fix-SEC-L2,
        cowork/certctl-architecture-diligence-audit.html#fix-SEC-L3
This commit is contained in:
shankar0123
2026-05-13 19:50:00 +00:00
parent 95cb002905
commit 69a2b5c55a
8 changed files with 722 additions and 4 deletions
+148 -2
View File
@@ -2,6 +2,7 @@ package config
import (
"crypto/tls"
"errors"
"fmt"
"log/slog"
"net"
@@ -11,6 +12,54 @@ import (
"time"
)
// Phase 2 (Default Hardening + Operator Docs) introduced three new
// error sentinels surfaced by Validate(). Tests pin them by
// errors.Is(err, ErrX) AND by message-text match for double safety;
// downstream callers may inspect the wrapped chain to react to a
// specific failure class without parsing the user-facing message.
//
// All three are staged behavior. Their default in this release is
// "off / opt-in" — production deploys must explicitly acknowledge to
// activate. The default-flip schedule lives in WORKSPACE-ROADMAP.md.
var (
// ErrAgentBootstrapTokenRequired is returned by Validate() when
// CERTCTL_AGENT_BOOTSTRAP_TOKEN_DENY_EMPTY=true and the token is
// empty. Phase 2 SEC-H1 closure — staged feature flag (default
// false this release; default true scheduled for v2.2.0).
ErrAgentBootstrapTokenRequired = errors.New(
"CERTCTL_AGENT_BOOTSTRAP_TOKEN is empty and CERTCTL_AGENT_BOOTSTRAP_TOKEN_DENY_EMPTY=true — refuse to start. " +
"Generate a real secret (e.g. openssl rand -base64 32) and set CERTCTL_AGENT_BOOTSTRAP_TOKEN, " +
"or unset CERTCTL_AGENT_BOOTSTRAP_TOKEN_DENY_EMPTY to keep the warn-mode pass-through default",
)
// ErrACMEInsecureWithoutAck is returned by Validate() when
// CERTCTL_ACME_INSECURE=true and CERTCTL_ACME_INSECURE_ACK is missing
// or false. Phase 2 SEC-M4 closure: upgrade the existing boot-time
// WARN log to a hard refuse-to-start gate behind an explicit ACK.
ErrACMEInsecureWithoutAck = errors.New(
"CERTCTL_ACME_INSECURE=true but CERTCTL_ACME_INSECURE_ACK is not true — refuse to start. " +
"ACME directory TLS verification is DISABLED; every round-trip skips certificate chain validation. " +
"Production deploys MUST NOT enable this. To unlock for dev / Pebble / step-ca with self-signed roots, " +
"set CERTCTL_ACME_INSECURE_ACK=true alongside CERTCTL_ACME_INSECURE=true",
)
// ErrDemoModeAckExpired is returned by Validate() when
// DemoModeAck=true and CERTCTL_DEMO_MODE_ACK_TS is missing or older
// than 24h. Phase 2 SEC-H3 closure: the sticky DemoModeAck bit now
// expires, forcing operators of accidentally-promoted demo
// deployments to re-acknowledge the synthetic-admin posture daily.
ErrDemoModeAckExpired = errors.New(
"CERTCTL_DEMO_MODE_ACK=true requires CERTCTL_DEMO_MODE_ACK_TS=<unix-epoch> set within the last 24h — refuse to start. " +
"This guard catches forgotten demo deployments accidentally left in production. " +
"Set CERTCTL_DEMO_MODE_ACK_TS=$(date +%s) at every compose-up; the demo compose helper script does this automatically",
)
)
// demoModeAckMaxAge is the maximum allowable age of
// CERTCTL_DEMO_MODE_ACK_TS before the demo-mode ACK is considered
// expired. Hard-coded at 24h per Phase 2 SEC-H3.
const demoModeAckMaxAge = 24 * time.Hour
// Config represents the complete application configuration.
// All configuration values are read from environment variables with CERTCTL_ prefix.
type Config struct {
@@ -655,6 +704,20 @@ type ACMEConfig struct {
// Only use for testing with self-signed ACME servers like Pebble. Never in production.
// Setting: CERTCTL_ACME_INSECURE environment variable.
Insecure bool
// InsecureAck is the Phase 2 SEC-M4 closure (2026-05-13): when
// Insecure=true, Validate() refuses to start unless InsecureAck is
// also true. Pre-Phase-2 the Insecure flag only emitted a boot-time
// WARN log; this guard converts that to a hard fail-closed gate so
// the dev-only escape hatch cannot be flipped accidentally in
// production via a copy-pasted Pebble runbook.
//
// Acknowledged (Insecure=true + InsecureAck=true): boot proceeds + WARN logs.
// Unack'd (Insecure=true + InsecureAck=false): ErrACMEInsecureWithoutAck.
// Off (Insecure=false): InsecureAck is ignored entirely.
//
// Setting: CERTCTL_ACME_INSECURE_ACK environment variable.
InsecureAck bool
}
// ACMEServerConfig is the SERVER-side ACME (RFC 8555 + RFC 9773 ARI)
@@ -1590,6 +1653,16 @@ type AuthConfig struct {
// 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
@@ -1624,6 +1697,22 @@ type AuthConfig struct {
// 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
@@ -1915,7 +2004,8 @@ func Load() (*Config, error) {
Secret: getEnv("CERTCTL_AUTH_SECRET", ""),
// Audit 2026-05-10 HIGH-12 closure: required-true to allow
// CERTCTL_AUTH_TYPE=none with a non-loopback listen address.
DemoModeAck: getEnvBool("CERTCTL_DEMO_MODE_ACK", false),
DemoModeAck: getEnvBool("CERTCTL_DEMO_MODE_ACK", false),
DemoModeAckTS: getEnv("CERTCTL_DEMO_MODE_ACK_TS", ""),
// Audit 2026-05-11 A-8 closure: when true, the preflight
// residual-grants detector refuses startup if actor-demo-anon
// has any actor_roles rows. Default false (WARN-only).
@@ -1927,7 +2017,8 @@ func Load() (*Config, error) {
// Bundle-5 / Audit H-007: agent-registration bootstrap secret.
// Empty (default) = warn-mode pass-through; v2.2.0 will require it.
AgentBootstrapToken: getEnv("CERTCTL_AGENT_BOOTSTRAP_TOKEN", ""),
AgentBootstrapToken: getEnv("CERTCTL_AGENT_BOOTSTRAP_TOKEN", ""),
AgentBootstrapTokenDenyEmpty: getEnvBool("CERTCTL_AGENT_BOOTSTRAP_TOKEN_DENY_EMPTY", false),
// Bundle 1 Phase 6: one-shot bootstrap token for the
// /v1/auth/bootstrap endpoint that mints the first admin
// key. Empty = bootstrap endpoint disabled (default).
@@ -2114,6 +2205,7 @@ func Load() (*Config, error) {
Profile: getEnv("CERTCTL_ACME_PROFILE", ""),
ARIEnabled: getEnvBool("CERTCTL_ACME_ARI_ENABLED", false),
Insecure: getEnvBool("CERTCTL_ACME_INSECURE", false),
InsecureAck: getEnvBool("CERTCTL_ACME_INSECURE_ACK", false),
},
// ACME server (RFC 8555 + RFC 9773 ARI) — distinct from the
// consumer-side ACME issuer connector above. Server uses
@@ -2603,6 +2695,60 @@ func (c *Config) Validate() error {
return fmt.Errorf("auth secret is required for auth type %s", c.Auth.Type)
}
// Phase 2 SEC-H1 closure (2026-05-13): the AgentBootstrapTokenDenyEmpty
// staged feature flag. When the operator opts in via
// CERTCTL_AGENT_BOOTSTRAP_TOKEN_DENY_EMPTY=true AND the bootstrap
// token is empty, Validate() returns a fail-closed error. Default
// flag value is false, preserving the existing v2.1.x warn-mode
// pass-through behavior for backward compatibility. The default-flip
// to true is scheduled for v2.2.0 in WORKSPACE-ROADMAP.md — operators
// get one upgrade window to set a real token.
if c.Auth.AgentBootstrapTokenDenyEmpty && c.Auth.AgentBootstrapToken == "" {
return fmt.Errorf("phase-2 SEC-H1 fail-closed guard: %w", ErrAgentBootstrapTokenRequired)
}
// Phase 2 SEC-M4 closure (2026-05-13): convert the existing boot-time
// WARN log for CERTCTL_ACME_INSECURE=true into a hard refuse-to-start
// gate behind an explicit ACK env var. The dev-only escape hatch can
// no longer be flipped accidentally via a copy-pasted Pebble runbook
// — production deploys must explicitly set both Insecure=true AND
// InsecureAck=true to acknowledge they understand the consequences.
// The boot-time WARN log path in cmd/server/main.go continues to fire
// for the ACK'd case so the operator sees the reminder every restart.
if c.ACME.Insecure && !c.ACME.InsecureAck {
return fmt.Errorf("phase-2 SEC-M4 fail-closed guard: %w", ErrACMEInsecureWithoutAck)
}
// Phase 2 SEC-H3 closure (2026-05-13): the sticky DemoModeAck bit
// now expires after demoModeAckMaxAge (24h). When the operator sets
// CERTCTL_DEMO_MODE_ACK=true, they MUST also set
// CERTCTL_DEMO_MODE_ACK_TS=$(date +%s) and re-supply it within the
// 24h window on every restart. The demo compose helper script does
// this automatically at compose-up. Catches the canonical
// "forgotten demo deployment promoted to production" failure mode:
// the next container restart refuses unless the operator re-acks.
if c.Auth.DemoModeAck {
if c.Auth.DemoModeAckTS == "" {
return fmt.Errorf("phase-2 SEC-H3 fail-closed guard (missing TS): %w", ErrDemoModeAckExpired)
}
ackEpoch, err := strconv.ParseInt(strings.TrimSpace(c.Auth.DemoModeAckTS), 10, 64)
if err != nil {
return fmt.Errorf("phase-2 SEC-H3 fail-closed guard: CERTCTL_DEMO_MODE_ACK_TS=%q must parse as a unix epoch integer (try $(date +%%s)); parse error %w: %w",
c.Auth.DemoModeAckTS, err, ErrDemoModeAckExpired)
}
ackTime := time.Unix(ackEpoch, 0)
if time.Since(ackTime) > demoModeAckMaxAge {
return fmt.Errorf("phase-2 SEC-H3 fail-closed guard (TS age %s exceeds %s): %w",
time.Since(ackTime).Round(time.Second), demoModeAckMaxAge, ErrDemoModeAckExpired)
}
// Future-dated timestamps are also rejected — likely operator clock skew
// or a typo. Allow a small future skew (1m) to absorb minor clock drift.
if time.Until(ackTime) > time.Minute {
return fmt.Errorf("phase-2 SEC-H3 fail-closed guard (TS is %s in the future, exceeds 1m clock-skew tolerance): %w",
time.Until(ackTime).Round(time.Second), ErrDemoModeAckExpired)
}
}
// Audit 2026-05-10 HIGH-12 closure: refuse to start when
// CERTCTL_AUTH_TYPE=none is bound to a non-loopback address unless
// the operator explicitly acknowledges the bypass via
+229
View File
@@ -7,10 +7,12 @@ import (
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"log/slog"
"math/big"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
@@ -398,6 +400,233 @@ func TestLoad_CommaSeparatedList(t *testing.T) {
}
}
// Phase 2 SEC-H1 (2026-05-13) — AgentBootstrapTokenDenyEmpty staged flag.
// When false (default), an empty token is permitted (v2.1.x warn-mode
// pass-through preserved). When true, an empty token fails closed.
func TestValidate_AgentBootstrapTokenDenyEmpty_DefaultFalse_AllowsEmpty(t *testing.T) {
cfg := &Config{
Server: validServerConfig(t),
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
Log: LogConfig{Level: "info", Format: "json"},
Auth: AuthConfig{Type: "api-key", Secret: "test-secret", AgentBootstrapToken: "", AgentBootstrapTokenDenyEmpty: false},
Keygen: KeygenConfig{Mode: "agent"},
Scheduler: validSchedulerConfig(),
}
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate() returned error with deny-empty=false + empty token: %v", err)
}
}
func TestValidate_AgentBootstrapTokenDenyEmpty_True_EmptyTokenFailsClosed(t *testing.T) {
cfg := &Config{
Server: validServerConfig(t),
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
Log: LogConfig{Level: "info", Format: "json"},
Auth: AuthConfig{Type: "api-key", Secret: "test-secret", AgentBootstrapToken: "", AgentBootstrapTokenDenyEmpty: true},
Keygen: KeygenConfig{Mode: "agent"},
Scheduler: validSchedulerConfig(),
}
err := cfg.Validate()
if err == nil {
t.Fatal("Validate() returned nil; want ErrAgentBootstrapTokenRequired")
}
if !errors.Is(err, ErrAgentBootstrapTokenRequired) {
t.Errorf("Validate() err = %v; want errors.Is to match ErrAgentBootstrapTokenRequired", err)
}
if !strings.Contains(err.Error(), "CERTCTL_AGENT_BOOTSTRAP_TOKEN_DENY_EMPTY=true") {
t.Errorf("Validate() error = %q; want message to mention the deny-empty env var name", err.Error())
}
}
func TestValidate_AgentBootstrapTokenDenyEmpty_True_RealTokenPasses(t *testing.T) {
cfg := &Config{
Server: validServerConfig(t),
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
Log: LogConfig{Level: "info", Format: "json"},
Auth: AuthConfig{Type: "api-key", Secret: "test-secret", AgentBootstrapToken: "a-real-32-byte-token-value-here-x", AgentBootstrapTokenDenyEmpty: true},
Keygen: KeygenConfig{Mode: "agent"},
Scheduler: validSchedulerConfig(),
}
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate() returned error with deny-empty=true + real token: %v", err)
}
}
// Phase 2 SEC-M4 (2026-05-13) — ACME insecure now requires explicit ACK.
func TestValidate_ACMEInsecure_WithoutAck_FailsClosed(t *testing.T) {
cfg := &Config{
Server: validServerConfig(t),
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
Log: LogConfig{Level: "info", Format: "json"},
Auth: AuthConfig{Type: "api-key", Secret: "test-secret"},
Keygen: KeygenConfig{Mode: "agent"},
Scheduler: validSchedulerConfig(),
ACME: ACMEConfig{Insecure: true, InsecureAck: false},
}
err := cfg.Validate()
if err == nil {
t.Fatal("Validate() returned nil; want ErrACMEInsecureWithoutAck")
}
if !errors.Is(err, ErrACMEInsecureWithoutAck) {
t.Errorf("Validate() err = %v; want errors.Is to match ErrACMEInsecureWithoutAck", err)
}
if !strings.Contains(err.Error(), "CERTCTL_ACME_INSECURE_ACK") {
t.Errorf("Validate() error = %q; want message to mention CERTCTL_ACME_INSECURE_ACK", err.Error())
}
}
func TestValidate_ACMEInsecure_WithAck_Passes(t *testing.T) {
cfg := &Config{
Server: validServerConfig(t),
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
Log: LogConfig{Level: "info", Format: "json"},
Auth: AuthConfig{Type: "api-key", Secret: "test-secret"},
Keygen: KeygenConfig{Mode: "agent"},
Scheduler: validSchedulerConfig(),
ACME: ACMEConfig{Insecure: true, InsecureAck: true},
}
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate() returned error with Insecure=true + InsecureAck=true: %v", err)
}
}
func TestValidate_ACMEInsecureFalse_IgnoresAck(t *testing.T) {
// InsecureAck is irrelevant when Insecure=false. No fail-closed branch.
cfg := &Config{
Server: validServerConfig(t),
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
Log: LogConfig{Level: "info", Format: "json"},
Auth: AuthConfig{Type: "api-key", Secret: "test-secret"},
Keygen: KeygenConfig{Mode: "agent"},
Scheduler: validSchedulerConfig(),
ACME: ACMEConfig{Insecure: false, InsecureAck: false},
}
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate() returned error with Insecure=false: %v", err)
}
}
// Phase 2 SEC-H3 (2026-05-13) — DemoModeAck now expires after 24h via DemoModeAckTS.
// Note: DemoModeAck=true on a loopback bind requires only the timestamp guard;
// no HIGH-12 cross-firing because the existing HIGH-12 guard fires only on
// non-loopback hosts. All tests here keep the server host as loopback so we
// observe ONLY the new SEC-H3 behavior.
func TestValidate_DemoModeAck_MissingTS_FailsClosed(t *testing.T) {
cfg := &Config{
Server: validServerConfig(t),
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
Log: LogConfig{Level: "info", Format: "json"},
Auth: AuthConfig{Type: "api-key", Secret: "test-secret", DemoModeAck: true, DemoModeAckTS: ""},
Keygen: KeygenConfig{Mode: "agent"},
Scheduler: validSchedulerConfig(),
}
err := cfg.Validate()
if err == nil {
t.Fatal("Validate() returned nil; want ErrDemoModeAckExpired with empty TS")
}
if !errors.Is(err, ErrDemoModeAckExpired) {
t.Errorf("Validate() err = %v; want errors.Is to match ErrDemoModeAckExpired", err)
}
if !strings.Contains(err.Error(), "CERTCTL_DEMO_MODE_ACK_TS") {
t.Errorf("Validate() error = %q; want message to mention CERTCTL_DEMO_MODE_ACK_TS", err.Error())
}
}
func TestValidate_DemoModeAck_StaleTS_FailsClosed(t *testing.T) {
// TS older than 24h → expired.
staleEpoch := time.Now().Add(-25 * time.Hour).Unix()
cfg := &Config{
Server: validServerConfig(t),
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
Log: LogConfig{Level: "info", Format: "json"},
Auth: AuthConfig{Type: "api-key", Secret: "test-secret", DemoModeAck: true, DemoModeAckTS: strconv.FormatInt(staleEpoch, 10)},
Keygen: KeygenConfig{Mode: "agent"},
Scheduler: validSchedulerConfig(),
}
err := cfg.Validate()
if err == nil {
t.Fatal("Validate() returned nil; want ErrDemoModeAckExpired with 25h-old TS")
}
if !errors.Is(err, ErrDemoModeAckExpired) {
t.Errorf("Validate() err = %v; want errors.Is to match ErrDemoModeAckExpired", err)
}
}
func TestValidate_DemoModeAck_FreshTS_Passes(t *testing.T) {
// TS within 24h → passes.
freshEpoch := time.Now().Add(-1 * time.Hour).Unix()
cfg := &Config{
Server: validServerConfig(t),
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
Log: LogConfig{Level: "info", Format: "json"},
Auth: AuthConfig{Type: "api-key", Secret: "test-secret", DemoModeAck: true, DemoModeAckTS: strconv.FormatInt(freshEpoch, 10)},
Keygen: KeygenConfig{Mode: "agent"},
Scheduler: validSchedulerConfig(),
}
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate() returned error with 1h-old TS: %v", err)
}
}
func TestValidate_DemoModeAck_NonNumericTS_FailsClosed(t *testing.T) {
cfg := &Config{
Server: validServerConfig(t),
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
Log: LogConfig{Level: "info", Format: "json"},
Auth: AuthConfig{Type: "api-key", Secret: "test-secret", DemoModeAck: true, DemoModeAckTS: "yesterday"},
Keygen: KeygenConfig{Mode: "agent"},
Scheduler: validSchedulerConfig(),
}
err := cfg.Validate()
if err == nil {
t.Fatal("Validate() returned nil; want ErrDemoModeAckExpired with non-numeric TS")
}
if !errors.Is(err, ErrDemoModeAckExpired) {
t.Errorf("Validate() err = %v; want errors.Is to match ErrDemoModeAckExpired", err)
}
if !strings.Contains(err.Error(), "parse") {
t.Errorf("Validate() error = %q; want message to mention parse failure", err.Error())
}
}
func TestValidate_DemoModeAck_FutureDatedTS_FailsClosed(t *testing.T) {
// > 1m future-dated → clock-skew rejection.
futureEpoch := time.Now().Add(10 * time.Minute).Unix()
cfg := &Config{
Server: validServerConfig(t),
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
Log: LogConfig{Level: "info", Format: "json"},
Auth: AuthConfig{Type: "api-key", Secret: "test-secret", DemoModeAck: true, DemoModeAckTS: strconv.FormatInt(futureEpoch, 10)},
Keygen: KeygenConfig{Mode: "agent"},
Scheduler: validSchedulerConfig(),
}
err := cfg.Validate()
if err == nil {
t.Fatal("Validate() returned nil; want ErrDemoModeAckExpired with future-dated TS")
}
if !errors.Is(err, ErrDemoModeAckExpired) {
t.Errorf("Validate() err = %v; want errors.Is to match ErrDemoModeAckExpired", err)
}
if !strings.Contains(err.Error(), "future") {
t.Errorf("Validate() error = %q; want message to mention future-dated TS", err.Error())
}
}
func TestValidate_DemoModeAckFalse_IgnoresTS(t *testing.T) {
// DemoModeAck=false → TS is irrelevant; no fail-closed branch.
cfg := &Config{
Server: validServerConfig(t),
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
Log: LogConfig{Level: "info", Format: "json"},
Auth: AuthConfig{Type: "api-key", Secret: "test-secret", DemoModeAck: false, DemoModeAckTS: ""},
Keygen: KeygenConfig{Mode: "agent"},
Scheduler: validSchedulerConfig(),
}
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate() returned error with DemoModeAck=false: %v", err)
}
}
func TestValidate_ValidConfig(t *testing.T) {
cfg := &Config{
Server: validServerConfig(t),