mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:41:41 +00:00
c7f3ec62904b468e16eda65dedf84e5217a0e28f
92 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a0404f2d21 |
fix(docs,code): ARCH-004 + SEC-003-K8S + ARCH-003 — marketing claims now match code truth
Sprint 4 unified-master-audit closure. Three claim-truth-alignment
findings whose README edits land on shared lines, bundled into one
commit.
ARCH-004 — 'full REST API exposed as MCP tools' overclaim:
Pre-fix the README said 'the full REST API is exposed as MCP
tools'; the actual MCP coverage is 162 tools / 220 routes
(~74%). The remaining gap is intentional: protocol-conformance
endpoints (ACME/SCEP/EST/OCSP/CRL), browser-only auth flow,
health/ready, and streaming/binary downloads — categories that
don't fit the request-response JSON tool shape.
Fix:
- README L78 qualified to 'the bulk of the REST API surface'
with explicit numbers + pointer to the new coverage doc.
- New docs/reference/mcp-coverage.md publishes the exclusion
categories with rationale + the canonical commands to
re-derive route + tool counts.
- New scripts/ci-guards/mcp-coverage-parity.sh fails the build
if the tool count drops below (routes − exclusions − 40-slack),
so a future regression that drops 50+ tools surfaces in CI.
Verified locally: clean at 162 tools / 220 routes / 37
intentional exclusions.
SEC-003-K8S — Kubernetes Secrets connector is a runtime stub:
Pre-fix README L67 marketed 'fifteen native target connectors'
with Kubernetes Secrets in the list, but realK8sClient's CRUD
methods returned 'real Kubernetes client not implemented' in
production. Per the audit's option (b) recommendation: downgrade
marketing + runtime-guard the stub.
Fix:
- README L12 + L67: 'fourteen production-ready native deployment-
target connectors plus Kubernetes Secrets (preview)'.
- k8ssecret.New() now refuses to construct unless
CERTCTL_K8SSECRET_PREVIEW_ACK=true is set, mirroring the
SEC-H3 ACK pattern. NewWithClient path (test injection)
unchanged.
- docs/reference/connectors/index.md moves Kubernetes Secrets
out of the canonical fourteen-target list into a new 'Preview
connectors' subsection.
- Regression tests in k8ssecret_test.go pin the new gate
(rejects without ACK, accepts with ACK, still rejects nil
config even with ACK).
ARCH-003 — CERTCTL_KEYGEN_MODE=server breaks the blanket claim:
Pre-fix README L12 + L82 said 'private keys stay on your
infrastructure' and 'never touch the control plane' as blanket
promises. Flipping CERTCTL_KEYGEN_MODE=server makes the control
plane mint keys in process memory — breaking the claim — and
the only signal was a boot-time slog WARN. An operator who set
the flag and didn't read logs ran in silent contradiction to the
marketed posture.
Fix:
- config.Validate() refuses to accept KeygenMode='server'
unless DemoModeAck=true (mirroring SEC-H3). Production
deploys (the default Mode='agent' path) are unaffected.
- README L12 + L82 qualified: 'In agent-mode (the default),
private keys ...; a demo-only CERTCTL_KEYGEN_MODE=server
flag mints keys server-side, refuses to start without an
explicit CERTCTL_DEMO_MODE_ACK=true acknowledgement.'
- Regression tests for the new Validate gate land in
config_test.go (note: gate tests landed in the ARCH-002
commit because of contiguous-hunk constraint at the bottom
of the file).
Closes ARCH-004, SEC-003-K8S, ARCH-003.
|
||
|
|
34d5200904 |
fix(auth): ARCH-002 — relax OIDC runtime guard, full Bundle-2 stack ships
Sprint 4 unified-master-audit closure. The README has advertised OIDC
SSO as a v2.1 feature (L18, L74) but cmd/server/main.go retained a
Bundle-2-Phase-0 runtime guard that os.Exit(1)'d the moment any
operator set CERTCTL_AUTH_TYPE=oidc:
CERTCTL_AUTH_TYPE=oidc: the OIDC auth chain is not yet wired in
this build (Auth Bundle 2 Phase 6 ships the session middleware
that consumes this auth-type literal).
That message was true when Phase 0 landed (the literal got reserved
in ValidAuthTypes ahead of the handler chain). It's been stale since
Phase 6 shipped. As of 2026-05-16 the full stack is live:
- session.NewService at cmd/server/main.go:394
- oidcsvc.NewService at cmd/server/main.go:436
- ChainAuthSessionThenBearer at cmd/server/main.go:2012
- csrfMiddleware at cmd/server/main.go:2017
- /auth/oidc/{login,callback,back-channel-logout} routes at router.go
- 6 OIDC handler files in internal/api/handler/
- 2,852 LOC in internal/auth/oidc/ + 1,632 LOC in internal/auth/session/
Fix:
- Introduce config.IsRuntimeSupportedAuthType(AuthType) as the
single source of truth for which auth-type literals the cmd/server
runtime guard accepts. The set is {api-key, none, oidc} —
every entry in ValidAuthTypes(). The helper exists so the test
suite can pin the invariant 'ValidAuthTypes ⊆ runtime-supported'
without grepping cmd/server source.
- cmd/server/main.go's switch collapses to a single
IsRuntimeSupportedAuthType check; the dedicated AuthTypeOIDC
fail-loud case is gone. The G-1 silent-auth-downgrade invariant
stays intact — 'jwt' is still rejected at config.Validate()
time (never made it into ValidAuthTypes()).
- internal/config/auth.go AuthTypeOIDC comment updated to reflect
the post-Phase-6 reality (it was prescriptive pre-fix:
'Once Bundle 2's session middleware + OIDC service ship, the
runtime guard relaxes' — that condition is met).
Regression coverage:
- TestIsRuntimeSupportedAuthType_AcceptsAllValidEntries — every
valid type is runtime-supported (catches future drift).
- TestIsRuntimeSupportedAuthType_AcceptsOIDC — explicit pin on
the ARCH-002 invariant.
- TestIsRuntimeSupportedAuthType_RejectsUnknown — 'jwt', empty,
'saml', 'mtls', 'API-KEY' all rejected.
(Also lands the ARCH-003 keygen-mode tests in the same file —
contiguous hunk in config_test.go.)
Closes ARCH-002.
|
||
|
|
b721596213 |
fix(config): DEPL-004 — expand $(POSTGRES_PASSWORD) placeholder in CERTCTL_DATABASE_URL
Sprint 3 unified-master-audit closure. The Helm chart's _helpers.tpl
(line 133) renders the bundled-Postgres URL with a literal
'$(POSTGRES_PASSWORD)' placeholder:
postgres://certctl:$(POSTGRES_PASSWORD)@db:5432/certctl?sslmode=disable
Kubernetes' '$(VAR)' env-substitution syntax ONLY expands when the
value is a string literal in the Pod spec. Values sourced from
'valueFrom.secretKeyRef' (which is how the chart wires
CERTCTL_DATABASE_URL) are NOT expanded — the literal makes it all
the way to the server, which tries to dial Postgres with
'$(POSTGRES_PASSWORD)' as the password, fails with auth error, and
leaks the placeholder into application error logs.
Fix: in-process expansion at internal/config/config.expandDatabaseURL.
strings.ReplaceAll of the literal '$(POSTGRES_PASSWORD)' token with
os.Getenv('POSTGRES_PASSWORD') when both the token is present AND
the env var is set. Conservative — no os.ExpandEnv (which would
expand any $VAR), no Docker entrypoint shim, no Helm-template-time
password injection that would inline the secret into a second
Kubernetes resource. External-Postgres deploys whose URL embeds
the real password pass through untouched because the placeholder
doesn't match.
Regression coverage in internal/config/config_test.go pins:
- happy-path placeholder substitution
- non-placeholder URL passes through unchanged
- placeholder + empty POSTGRES_PASSWORD leaves the URL alone
- multi-occurrence safety via ReplaceAll
Closes DEPL-004.
|
||
|
|
8f2e5771db |
fix(middleware): SEC-006 — TTL-evict idle token-bucket rate-limiter entries
Sprint 2 unified-master-audit closure. Pre-fix the keyed rate
limiter's bucket map had no eviction. The package-level comment
explicitly noted the leak: high-cardinality unauthenticated traffic
(CGNAT churn, Tor exit lists, botnets, infinite-cardinality scanners)
grew process memory unboundedly. Production deploys with millions of
unique IPs would eventually OOM.
Fix:
- RateLimitConfig.BucketTTL (env CERTCTL_RATE_LIMIT_BUCKET_TTL,
default 1h, clamp-floor 1m). 1h chosen to be well above realistic
operator IP churn windows (returning clients keep their bucket)
and well below the unbounded-leak window the pre-fix code
allowed.
- tokenBucket gains a lastAccess field updated on every allow()
call via touch(); reading via lastAccessTime() under the bucket's
own mutex.
- keyedRateLimiter.sweepLoop runs in a single goroutine per
limiter (production wires 2: default + no-auth fallback), waking
every BucketTTL/4. sweep() removes any bucket whose lastAccess
is older than the cutoff and bumps evictedTotal atomically.
- Both NewRateLimiter call sites in cmd/server/main.go (default
stack and no-auth fallback) now thread cfg.RateLimit.BucketTTL.
Regression coverage:
- TestKeyedRateLimiter_SweepEvictsIdleBuckets: 1000 synthetic IP
keys populate the map, advance past TTL, call sweep() directly,
assert map drained to 0 + evictedTotal=1000 + fresh key creates
new bucket (map not poisoned).
- TestKeyedRateLimiter_SweepKeepsActiveBuckets: inverse — a bucket
touched within the TTL window survives the sweep. Catches a
future regression that inverts the cutoff comparison.
Closes SEC-006.
|
||
|
|
037876fa0f |
fix(scheduler): SCALE-001 — cap ClaimPendingJobs per-tick (default 1000)
Sprint 2 unified-master-audit closure. Pre-fix the scheduler invoked
ClaimPendingJobs(ctx, "", 0). limit:0 loads every Pending row in a
single transaction — a 100K-job burst (cert-fleet sweep, post-outage
recovery, large agent-fleet first boot) marshalled the full queue
into process memory before boundedFanOut's semaphore could back-
pressure the upstream CAs.
Fix:
- SchedulerConfig.JobClaimLimit (env CERTCTL_SCHEDULER_JOB_CLAIM_LIMIT,
default 1000). ≤0 normalised to 1000 in SetClaimLimit — fail-safe
vs. legacy unlimited semantics.
- JobService.claimLimit threaded into the existing
ProcessPendingJobs flow; ClaimPendingJobs(ctx, "", s.claimLimit).
- cmd/server/main.go wires jobService.SetClaimLimit(cfg.Scheduler.JobClaimLimit).
- 'processing pending jobs' log line now includes claim_limit so
operators can spot the cap engaging (count == claim_limit ⇒
queue is running ahead of fan-out; bump CERTCTL_SCHEDULER_JOB_CLAIM_LIMIT
or CERTCTL_RENEWAL_CONCURRENCY).
- Test wiring keeps the legacy zero-value (unlimited) for byte-
for-byte compatibility with the existing 600+ JobService unit
tests — only production code goes through SetClaimLimit.
Regression coverage:
- mockJobRepo.LastClaimLimit records the limit passed through
ClaimPendingJobs so tests can pin the propagation.
- TestProcessPendingJobs_RespectsClaimLimit: 10 Pending rows,
SetClaimLimit(3), expect exactly 3 transition to Running plus
LastClaimLimit=3 on the mock.
- TestSetClaimLimit_NormalisesNonPositive: 0/-1/-1000 all
normalise to 1000.
Closes SCALE-001.
|
||
|
|
a41fc2d75c |
feat(ratelimit): Phase 13 Sprint 13.3 — wire backend selector + scheduler janitor + docs + helm (ARCH-M1 closure complete)
Phase 13 Sprint 13.3 — the completion half of the ARCH-M1
substantive close. Sprint 13.2 shipped the Postgres-backed
sliding-window limiter + multi-replica integration test; Sprint 13.3
wires the 6 call sites in cmd/server/main.go through the operator-
chosen backend selector, adds the rate_limit_buckets scheduler
janitor sweep, rewrites the observability doc, exposes the env-var
in the helm chart, and promotes the multi-replica integration test
to a required CI status check.
Signature ground-truth (sprint 13.2 + 13.3)
===========================================
Prompt-template signatures: `Allow(key string) error` and "5 call
sites." Actual repo: `Allow(key string, now time.Time) error` and 6
NewSlidingWindowLimiter call sites in cmd/server/main.go (the prompt
miscounted the second EST per-principal arm). Per CLAUDE.md "the repo
is truth," matched the live shape.
What changed
============
internal/config/server.go (+40 LOC):
- Added `SlidingWindowBackend string` + `SlidingWindowJanitorInterval
time.Duration` to RateLimitConfig with full operator-facing
documentation of the two valid values (memory|postgres) +
when-to-use-which decision tree.
internal/config/config.go (+27 LOC):
- Load() reads CERTCTL_RATE_LIMIT_BACKEND (default "memory") +
CERTCTL_RATE_LIMIT_JANITOR_INTERVAL (default 5m).
- Validate() rejects anything other than ""/"memory"/"postgres"
(empty = memory equivalence for test-built Configs that bypass
Load()). Janitor interval must be ≥ 1 minute when set.
- Failure modes return clear ::error:: with the env-var name + the
valid values, so an operator typo ("postgress" → memory in a
3-replica cluster) fails fast at startup.
internal/ratelimit/factory.go (NEW, 67 LOC):
- NewLimiter(backend, db, maxN, window, mapCap) Limiter — single
factory the 6 cmd/server/main.go call sites route through.
- Drop-in signature: same maxN/window/mapCap as
NewSlidingWindowLimiter (mapCap accepted + ignored for postgres
— the rate_limit_buckets table grows until the janitor sweeps).
- Defensive panic on unknown backend (config.Validate is SoT;
this is belt-and-suspenders).
internal/ratelimit/postgres_gc.go (NEW, 73 LOC):
- PostgresGC struct + NewPostgresGC + GarbageCollect.
- Single-statement DELETE FROM rate_limit_buckets WHERE
updated_at < NOW() - maxWindow. Idempotent.
- maxWindow <= 0 is a no-op (operator opt-out).
internal/scheduler/scheduler.go (+90 LOC):
- New RateLimitGarbageCollector interface (mirrors the
ACMEGarbageCollector / SessionGarbageCollector contracts).
- rateLimitGC field + rateLimitGCInterval + rateLimitGCRunning
on Scheduler.
- SetRateLimitGarbageCollector(gc) + SetRateLimitGCInterval(d)
Setters following the existing acmeGC/sessionGC pattern.
- rateLimitGCLoop() — JitteredTicker + atomic.Bool guard +
per-tick context.WithTimeout(1m). Logs row count at Debug.
- Loop counted in the Start() WaitGroup only when the GC is
non-nil; cmd/server/main.go skips SetRateLimitGarbageCollector
when backend=memory so the loop never launches for that case.
cmd/server/main.go (35 LOC diff):
- All 6 ratelimit.NewSlidingWindowLimiter call sites now route
through ratelimit.NewLimiter(cfg.RateLimit.SlidingWindowBackend,
db, ...). Grep verification post-fix returns ZERO hits.
- Six sites: breakglass loginLimiter (580), ocspLimiter (1003),
exportLimiter (1068), EST failed-basic (1535), EST per-principal
SCEP-mTLS arm (1591), EST per-principal SCEP arm (1613). The
intune.NewPerDeviceRateLimiter site at line 1823 stays unmoved
— its inner type-alias wrapper is the prompt's
out-of-scope (cmd/server/*.go only).
- Conditionally constructs PostgresGC + wires the scheduler janitor
when backend=postgres; logs the wiring decision either way so
operators see "rate-limit GC sweep enabled (postgres backend)"
or "in-memory backend self-prunes" in the boot log.
internal/api/handler/{est,export,certificates,auth_breakglass}.go:
- Replaced 5 *ratelimit.SlidingWindowLimiter field/Setter types
with ratelimit.Limiter (the interface). Allow() satisfies the
same call shape on both backends; the in-memory tests that
construct *SlidingWindowLimiter still compile because the
concrete type satisfies the interface (compile-time check in
internal/ratelimit/limiter.go pins this).
docs/operator/observability.md (176 LOC diff):
- Replaced the "per-process, in-memory, reset-on-restart, not
shared across replicas" paragraph with the new
configurable-backend section: operator decision tree,
backend internals (memory vs postgres), janitor description,
falsifiable closure proof (the Sprint 13.2 integration test
name + invocation), helm chart wiring example.
- Updated inventory to reflect the actual handler file paths +
actual cap configurations (the prior doc said "60s window" for
several limiters that actually use 60m / 24h windows).
- Doc smoke confirmed: grep -c 'per-process, in-memory,
reset-on-restart' docs/operator/observability.md = 0.
deploy/helm/certctl/values.yaml + templates/server-configmap.yaml +
templates/server-deployment.yaml:
- Exposed server.rateLimiting.backend (default "memory") +
server.rateLimiting.janitorInterval (default "5m") under the
existing rateLimiting block.
- ConfigMap renders both as rate-limit-backend +
rate-limit-janitor-interval keys.
- Deployment wires CERTCTL_RATE_LIMIT_BACKEND +
CERTCTL_RATE_LIMIT_JANITOR_INTERVAL env vars from the configmap.
- Helm render: `helm template deploy/helm/certctl --set
server.rateLimiting.backend=postgres` shows the env-var on the
server-deployment.yaml output.
.github/workflows/ci.yml (+12 LOC):
- Added a new step in the Go Build & Test job that runs the
Sprint 13.2 multi-replica integration test
(TestRateLimit_PostgresBackend_CapEnforcedAcrossReplicas) with
-tags=integration -race -timeout=300s. Fails the CI status check
if the cross-replica row lock ever stops arbitrating across
replicas — the ARCH-M1 closure regression gate.
Verification (all green locally; postgres integration via CI)
============================================================
$ grep -nE 'NewSlidingWindowLimiter' cmd/server/*.go
(zero hits — Sprint 13.3 receipt)
$ go test -short -count=1 \
./internal/config/... ./internal/ratelimit/... \
./internal/scheduler/... ./internal/api/handler/... \
./cmd/server/...
ok internal/config 1.177s
ok internal/ratelimit 0.007s
ok internal/scheduler 9.165s
ok internal/api/handler 6.245s
ok cmd/server 0.390s
$ staticcheck ./internal/ratelimit/... ./internal/scheduler/... \
./internal/config/... ./internal/api/handler/... ./cmd/server/...
(clean)
$ gofmt -l internal/ cmd/server/
(clean)
$ grep -c 'per-process, in-memory, reset-on-restart' \
docs/operator/observability.md
0 (doc smoke — the audit's verbatim phrasing is gone)
$ bash scripts/ci-guards/G-3-env-docs-drift.sh
G-3 env-docs-drift: clean.
$ bash scripts/ci-guards/complete-path-config-coverage.sh
OK — every CERTCTL_* env var (197) has at least one non-config-
package consumer.
Selector contract verified — config.Validate() rejects any value
other than ""/memory/postgres at startup with a clear error message.
Sprint 13.4 next (ARCH-H1 OpenAPI authoring batch 1) is on a
different axis; ARCH-M1 closure is complete with this commit
modulo the Sprint 13.7 audit-HTML flip + zero-floor pin.
Closes: ARCH-M1 substantive remediation. The cross-replica rate-
limit-cap-enforcement gap that the audit recommended deferring to
v3 is closed; operators with server.replicas > 1 flip
CERTCTL_RATE_LIMIT_BACKEND=postgres and get exactly-cap enforcement
across the cluster (proved by the multi-replica integration test now
gating CI).
|
||
|
|
7f57b1d3bf |
refactor(config): extract Issuers family — LAST in-config cut (Phase 9, 7 of N)
Continuing Phase 9 ARCH-M2 closure. Sprint 7 is the LAST in-config
cut of Phase 9. After this commit lands, the remaining sub-splits
target non-config hotspots (cmd/server/main.go, service/acme.go,
mcp/tools.go, auth_session_oidc.go, cmd/agent/main.go).
What moved
==========
internal/config/issuers.go (new, 435 lines including BSL header +
Phase 9 doc-comment + 12 structs)
Twelve issuer-related structs collected in one place for the first
time:
- KeygenConfig global key-generation policy (agent vs server)
- CAConfig Local CA mode (self-signed vs sub-CA)
- StepCAConfig step-ca (URL + JWK provisioner)
- VaultConfig HashiCorp Vault PKI
- DigiCertConfig DigiCert CertCentral
- SectigoConfig Sectigo Certificate Manager
- GoogleCASConfig Google Cloud CA Service
- AWSACMPCAConfig AWS ACM Private CA
- EntrustConfig Entrust Certificate Services
- GlobalSignConfig GlobalSign Atlas HVCA
- EJBCAConfig EJBCA / Keyfactor
- OpenSSLConfig OpenSSL / custom CA
Simplest split shape of Phase 9 so far
======================================
- ZERO helpers move. Every issuer config is pure data — strings,
ints, bools. No time.Duration, no nested struct, no helper
function reference.
- ZERO imports needed in issuers.go beyond the package declaration.
Verified by: `awk 'NR>=136 && NR<=269 || NR>=355 && NR<=527 ||
NR>=586 && NR<=609' internal/config/config.go | grep -E '\btime\.
|\bos\.|\bfmt\.'` returned empty before the move.
Three sed passes (Sprint-6 pattern, scattered targets)
======================================================
The 12 issuer types were SCATTERED across config.go interleaved
with non-issuer types (OCSPResponderConfig, EncryptionConfig, the
discovery family, DigestConfig, HealthCheckConfig, NetworkScanConfig,
VerificationConfig, ApprovalConfig). Three independent sed deletes
from highest-line to lowest:
Block 3 (line 586-609): OpenSSLConfig alone (24 lines)
Block 2 (line 355-527): KeygenConfig + CAConfig + StepCAConfig +
VaultConfig + DigiCertConfig +
SectigoConfig + GoogleCASConfig
(173 lines)
Block 1 (line 136-269): AWSACMPCAConfig + EntrustConfig +
GlobalSignConfig + EJBCAConfig
(134 lines)
Total: 331 lines deleted.
Highest-line-first ordering keeps every range pre-shift-stable —
no mid-edit re-derivation.
What stayed in config.go
========================
- OCSPResponderConfig (server-side OCSP responder; not issuer-side)
- EncryptionConfig (config-at-rest encryption; not issuer-side)
- CloudDiscoveryConfig + AWSSecretsMgrDiscoveryConfig +
AzureKVDiscoveryConfig + GCPSecretMgrDiscoveryConfig
(cloud-DISCOVERY sources reading certs others issued; not issuer
connectors. Could form a future config/discovery.go split.)
- DigestConfig + HealthCheckConfig (notifier-policy /
health-monitor cadence; not issuer-related)
- NetworkScanConfig + VerificationConfig (discovery / verify;
not issuer-related)
- ApprovalConfig (RBAC issuance-approval workflow; Sprint 6's
deliberate exclusion still applies)
- The Config struct itself (line 67) + every Load() / Validate()
body that references issuer configs by field name.
Public-surface invariant
========================
Every type, exported field, and doc-comment is byte-identical to
pre-split. Package stays `config`. No issuer-config type exports
a method (the entire surface is fields — preserved verbatim).
Every external caller path (`config.AWSACMPCAConfig` /
`config.EntrustConfig` / etc.) resolves the same way.
Verification (all clean):
gofmt -l internal/config/ → clean
go build ./internal/config/... → clean
go test ./internal/config/... -count=1 → ok (0.67s)
staticcheck ./internal/config/... → clean
go build ./cmd/server/...
./internal/auth/...
./internal/api/router/...
./internal/api/handler/...
./internal/scheduler/...
./internal/connector/issuer/... → clean (broader build
expanded to include
issuer packages
this sprint since
they're the most
likely external
consumers of the
moved types)
grep -nE '^type (KeygenConfig|CAConfig|StepCAConfig|VaultConfig|
DigiCertConfig|SectigoConfig|GoogleCASConfig|
OpenSSLConfig|AWSACMPCAConfig|EntrustConfig|
GlobalSignConfig|EJBCAConfig)'
internal/config/config.go → empty (none remain)
grep -nE '^type (KeygenConfig|CAConfig|...)' internal/config/issuers.go
→ 12 types (correct)
LOC delta:
config.go: 1673 → 1342 (-331 lines: -134 Block 1, -173 Block 2,
-24 Block 3)
issuers.go: new, 435 lines (incl. 102-line Phase 9 doc-comment +
BSL header + package decl)
Cumulative Phase 9 progress (Sprints 1-7 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)
After Sprint 6 (Server): 1673 LOC (-290)
After Sprint 7 (Issuers): 1342 LOC (-331)
Total Sprint 1+2+3+4+5+6+7: -2061 LOC (-60.6%)
Notable milestones (Sprint 7)
==============================
- config.go has lost MORE than 60% of its original lines.
- 6 sibling config-package files now exist alongside config.go,
each scoped to a single concern. Total config package size
3898 LOC across 7 files (was 3403 LOC in 1 file pre-Phase-9 —
net 14.6% growth from per-file Phase 9 doc-comments + the file
headers; in exchange, the largest single file dropped from
3403 → 1342 LOC, a 60.6% concentration reduction).
- This is the LAST cut from config.go. The remaining 5 sub-splits
target non-config hotspots and use entirely different file-shape
patterns (subpackage creation for service/acme; per-verb file
splits for handlers; pure-domain grouping for mcp/tools).
Next queued (Sprint 8): cmd/server/main.go split into main.go
(entrypoint) + cmd/server/wire.go (DI assembly) +
cmd/server/migrations.go (boot-time migration path). main.go is
the SECOND-LARGEST hotspot at 2966 LOC. Different from
config.go cuts because:
- cmd/server/ is a package with multiple files already (per
`ls cmd/server/`); the new files will live alongside existing
ones (auth_backfill.go, tls.go, etc.) which means no new
subdirectory needed.
- The cut is by FUNCTIONAL CONCERN (boot sequencing) rather
than by TYPE FAMILY (struct grouping), so the boundary lines
are different in nature.
- Phase 4's migration-hook code (in main.go today) inherits
into migrations.go without code-change — the Phase 9 prompt
explicitly says "Phase 4's pre-install migration hook adds
a path to cmd/server/migrations.go; doing the split first
means double-touching the same lines."
Closes: cowork/certctl-architecture-diligence-audit.html#fix-ARCH-M2
(partial — 7 of 12 — full ARCH-M2 closure is the aggregate)
|
||
|
|
aaddd31d20 |
refactor(config): extract Server family + isLoopbackAddr helper (Phase 9, 6 of N)
Continuing Phase 9 ARCH-M2 closure. Sprint 6 groups the server-tier
infrastructure structs (the things that configure HOW the server
runs) and the HIGH-12 demo-mode startup-guard helper that exclusively
serves the ServerConfig.Host gate.
What moved
==========
internal/config/server.go (new, 374 lines including BSL header +
Phase 9 doc-comment + 2 imports +
7 structs + 1 unexported helper)
Seven structs:
- ServerConfig (HTTP listener: Host, Port, MaxBodySize,
TLS sub-struct, AuditFlushTimeoutSeconds)
- ServerTLSConfig (HTTPS-only TLS material: CertPath + KeyPath)
- DatabaseConfig (URL + MaxConnections + MigrationsPath +
DemoSeed)
- SchedulerConfig (all 15 scheduler-loop tunables: RenewalCheck,
JobProcessor, RenewalConcurrency, agent-health,
notification-process + retry, retry-interval,
job-timeout, AwaitingCSR + Approval timeouts,
short-lived-expiry, CRL-generation, OCSP-rate-
limit, cert-export-rate-limit, deploy-backup-
retention, K8s-kubelet-sync-timeout)
- LogConfig (Level + Format)
- RateLimitConfig (Enabled + RPS + BurstSize + per-user
overrides)
- CORSConfig (AllowedOrigins — empty deny-by-default)
One unexported helper:
- isLoopbackAddr() (HIGH-12 demo-mode guard: 127.0.0.1, ::1,
and "localhost" return true; 0.0.0.0, ::,
and non-localhost hostnames return false.
Same-package callers: Validate() in config.go
+ isLoopbackAddr_test in config_test.go,
both unaffected by the move.)
Three sed passes (highest line numbers first so positions don't shift)
======================================================================
The edit was performed via three independent sed deletes from
highest-line to lowest-line so each delete's range references the
file's pre-shift line numbers:
1. sed -i '1924,1963d' — deleted isLoopbackAddr (40 lines)
2. sed -i '834,893d' — deleted LogConfig + RateLimitConfig +
CORSConfig (60 lines)
3. sed -i '624,810d' — deleted ServerConfig + ServerTLSConfig +
DatabaseConfig + SchedulerConfig
(187 lines)
Total: 287 lines deleted. Reverse-order matters because each delete
shifts subsequent line numbers; doing them top-down would require
re-deriving every range mid-edit.
Why ApprovalConfig stayed in config.go
=======================================
ApprovalConfig (RBAC-related — issuance-approval workflow) sits
between SchedulerConfig and LogConfig in the original file ordering.
It's NOT server-tier infrastructure — it belongs with the Auth/RBAC
surface. Sprint 6's sed ranges deliberately preserve it where it
lives. Operator may want to fold it into a future Auth-followup cut
if the approval surface needs to live adjacent to AuthConfig.
Import-graph hygiene
====================
isLoopbackAddr was the ONLY user of `net` in config.go (verified via
`grep -nE '\bnet\.' internal/config/config.go` → 2 hits, both inside
isLoopbackAddr's body). After the move, config.go's `net` import
becomes unused — would have failed `go vet`. This commit removes the
`net` line from config.go's import block. server.go imports `net`
directly. The `time` import in config.go stays because the still-
in-place OCSPResponderConfig / DigestConfig / HealthCheckConfig /
NetworkScanConfig / VerificationConfig / per-vendor-issuer configs
all reference `time.Duration`.
Public-surface invariant
========================
Every type, exported field, and doc-comment is byte-identical to
pre-split. Package stays `config`. Every external caller of
`config.ServerConfig` / `config.ServerTLSConfig` / `config.DatabaseConfig`
/ `config.SchedulerConfig` / `config.LogConfig` / `config.RateLimitConfig`
/ `config.CORSConfig` resolves the same way. The unexported
isLoopbackAddr is invisible to external consumers; its same-package
callers (Validate, the test) continue to resolve via the package
symbol table.
Verification (all clean):
gofmt -l internal/config/ → clean
go build ./internal/config/... → clean
go test ./internal/config/... -count=1 → ok (0.68s)
staticcheck ./internal/config/... → clean
go build ./cmd/server/...
./internal/auth/...
./internal/api/router/...
./internal/api/handler/...
./internal/scheduler/... → clean (the critical
broader-importer check)
grep -nE '^type (ServerConfig|ServerTLSConfig|DatabaseConfig|SchedulerConfig|LogConfig|RateLimitConfig|CORSConfig)|^func isLoopbackAddr' internal/config/config.go
→ empty (none remain in config.go)
grep -nE '^type (ServerConfig|ServerTLSConfig|DatabaseConfig|SchedulerConfig|LogConfig|RateLimitConfig|CORSConfig)|^func isLoopbackAddr' internal/config/server.go
→ 7 types + 1 func (correct)
grep -nE '\bnet\.' internal/config/config.go
→ empty (the import-removal was load-bearing)
LOC delta:
config.go: 1963 → 1673 (-290 lines: -287 from three sed cuts,
-1 from import-block
line removal,
-2 from misc gofmt cleanup)
server.go: new, 374 lines (incl. 87-line Phase 9 doc-comment +
BSL header + package decl + 2 imports)
Cumulative Phase 9 progress (Sprints 1+2+3+4+5+6 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)
After Sprint 6 (Server): 1673 LOC (-290)
Total Sprint 1+2+3+4+5+6: -1730 LOC (-50.8%)
Notable milestone: config.go has now lost MORE than HALF its original
lines (-50.8%). One more cut from config.go remains (Sprint 7 ~600
LOC of per-vendor issuer configs) before the file split moves on to
non-config hotspots (Sprints 8-12).
Pattern lesson — import-graph cleanup
======================================
Splits that move the LAST consumer of an import need to remove the
import from the source file or `go vet` / build will fail. The check
is `grep -nE '\bnet\.' internal/config/config.go` (or whichever
package) before commit — if empty, drop the import line. Past
sprints didn't hit this because the moved-out helpers used only
shared packages (`strings`, `os`, `fmt`, `time`) that other code in
config.go still uses. Sprint 6's `net` removal is the first
import-rebalancing in Phase 9.
Three-pass sed pattern (also new in Sprint 6)
=============================================
Prior sprints did one or two sed deletes. Sprint 6 needed three
because the Server-family structs straddled ApprovalConfig and
isLoopbackAddr lived far from the struct block. Doing them
highest-line-first means each range references pre-shift line
numbers — no mid-edit re-derivation required.
Next queued (Sprint 7): Issuers family from config.go →
internal/config/issuers.go (~600 LOC). Includes KeygenConfig +
CAConfig + the ten per-vendor configs (StepCA, Vault, DigiCert,
Sectigo, GoogleCAS, AWSACMPCA, Entrust, GlobalSign, EJBCA, OpenSSL).
This is the LAST config.go cut of Phase 9; after Sprint 7 ships,
config.go should drop to ~1100-1200 LOC and the remaining splits
target non-config hotspots (cmd/server/main.go, service/acme.go,
mcp/tools.go, auth_session_oidc.go, cmd/agent/main.go).
Closes: cowork/certctl-architecture-diligence-audit.html#fix-ARCH-M2
(partial — 6 of 12 — full ARCH-M2 closure is the aggregate)
|
||
|
|
51f9cf13dc |
refactor(config): extract Auth family + 2 exported + 1 unexported helpers (Phase 9, 5 of N)
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\.<Symbol>'`. 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)
|
||
|
|
57d55b7390 |
refactor(config): extract EST family + helpers to its own file (Phase 9, 4 of N)
Continuing Phase 9 ARCH-M2 closure. Sprint 4 extracts the EST surface,
mirroring Sprint 3's SCEP cut shape (two structs + multiple helpers
move together).
What moved
==========
internal/config/est.go (new, 396 lines including BSL header +
Phase 9 doc-comment + 2 imports +
2 structs + 5 helpers)
Two structs:
- ESTConfig (top-level: Enabled + Profiles slice +
legacy single-issuer flat fields kept
for backward compat — fewer trigger
fields than SCEP because EST has no
per-profile RA pair or challenge
password in this hardening-bundle
phase)
- ESTProfileConfig (one EST endpoint: PathID, IssuerID,
ProfileID, EnrollmentPassword,
MTLSEnabled, MTLSClientCATrustBundlePath,
ChannelBindingRequired, AllowedAuthModes,
RateLimitPerPrincipal24h,
ServerKeygenEnabled — field surface
spans the full Phase-1-through-5
hardening bundle)
Five unexported helpers:
- loadESTProfilesFromEnv() — reads CERTCTL_EST_PROFILES +
expands each name into an
ESTProfileConfig via the indexed
env-var family. Mirrors
loadSCEPProfilesFromEnv exactly.
- parseAuthModes() — splits a comma-separated env value
into a normalized []string of
auth-mode tokens.
- mergeESTLegacyIntoProfiles() — backward-compat shim: synthesize
Profiles[0] from the legacy flat
fields when Profiles is empty AND
EST is enabled.
- validESTPathID() — path-segment validator (mirrors
validSCEPPathID; kept separate so
future EST-specific path
constraints can land without
affecting SCEP).
- validESTAuthMode() — refuses unknown auth-mode tokens
at startup ("mtls" / "basic"
are valid in Phase 1).
Why move all five helpers together
==================================
Live grep confirms each helper is exclusively EST-specific:
- parseAuthModes() has one production call site (line 1851 inside
loadESTProfilesFromEnv itself, intra-helper) + one test caller
(config_est_profiles_test.go in package `config` — same package
so the move is invisible to the test).
- validESTAuthMode() has exactly one production caller (Validate()
in config.go); validESTPathID() likewise.
- mergeESTLegacyIntoProfiles() called from Load() in config.go.
- loadESTProfilesFromEnv() called from Load() in config.go.
All callers either stay in config.go (Load + Validate) or live in
est.go itself (the intra-helper parseAuthModes call inside
loadESTProfilesFromEnv stays a same-file call after the move — one
LESS cross-file edge to track). The test in
config_est_profiles_test.go is in package `config` so the unexported
callable surface is preserved by same-package resolution.
What stayed in config.go (intentionally)
========================================
- Load() and Validate() bodies — the EST-specific call sites stay
where they are (cross-cutting validation logic, not split-target).
- Every shared getEnv* helper (used by EVERY config family).
- The Config{}.EST master-struct field declaration.
Edit shape
==========
Two sed passes (same approach as Sprint 3):
1. sed -i '611,774d' — deleted the 164-line EST struct block
(ESTConfig + ESTProfileConfig + their doc comments).
2. sed -i '1648,1789d' — deleted the 142-line helper block
(positions already shifted by Sprint 4's struct removal).
Then gofmt -w to collapse a residual double-blank-line at the second
join point (none surfaced at the first).
Public-surface invariant
========================
Every type, field, exported method, and doc-comment is byte-identical
to pre-split. Package stays `config`. Every caller's
`config.ESTConfig` / `config.ESTProfileConfig` import path is
preserved without modification. The five helpers are unexported so
their move is invisible to package consumers; same-package callers
(Load, Validate, the existing test) continue to resolve them via the
package symbol table.
Verification (all clean):
gofmt -l internal/config/ → clean (after -w)
go build ./internal/config/... → clean
go test ./internal/config/... -count=1 → ok (0.58s)
staticcheck ./internal/config/... → clean
go build ./internal/api/router/...
./internal/scheduler/...
./cmd/server/...
./internal/api/handler/... → clean (broader
importers still
resolve every type
and helper)
grep -nE '^type EST|^func .*EST|^func parseAuthModes' config.go
→ empty (none remain in config.go)
grep -nE '^type EST|^func .*EST|^func parseAuthModes' est.go
→ 2 types + 5 funcs (correct: ESTConfig, ESTProfileConfig,
loadESTProfilesFromEnv,
parseAuthModes,
mergeESTLegacyIntoProfiles,
validESTPathID,
validESTAuthMode)
LOC delta:
config.go: 2774 → 2467 (-307 lines: -164 from struct block,
-142 from helper block,
-1 from double-blank collapse)
est.go: new, 396 lines (incl. 87-line Phase 9 doc-comment +
BSL header + package decl + 2 imports)
Cumulative Phase 9 progress (Sprints 1+2+3+4 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)
Total Sprint 1+2+3+4: -936 LOC (-27.5%)
Pattern lesson reinforcement
============================
Sprint 4 confirms the SCEP/EST symmetry the original helper authors
documented inline ("Mirrors loadSCEPProfilesFromEnv exactly").
Sprint 3 + Sprint 4 are now demonstrating the same cut pattern works
across two related-but-distinct protocol surfaces. Sprint 5+ should
be easier because they don't carry the same helper-bundling
complexity (Auth family probably has its own helper cluster too, but
Server / Issuers are likely pure-data per the original audit-questions
output).
Next queued (Sprint 5): Auth family from config.go →
internal/config/auth.go. Includes AuthConfig + SessionConfig +
BreakglassConfig + NamedAPIKey + ParseNamedAPIKeys (note: this is
EXPORTED — only exported function in the config-helpers cluster) +
isValidKeyName + ValidAuthTypes. The exported ParseNamedAPIKeys adds
a wrinkle Sprints 1-4 didn't have: external callers may import it,
so the public-surface check needs to include it. Estimated ~340 LOC
moved.
Closes: cowork/certctl-architecture-diligence-audit.html#fix-ARCH-M2
(partial — 4 of 12 — full ARCH-M2 closure is the aggregate)
|
||
|
|
c461ef3339 |
refactor(config): extract SCEP family + helpers to its own file (Phase 9, 3 of N)
Continuing Phase 9 ARCH-M2 closure. Sprints 1+2 extracted pure-data
structs (NotifierConfig, then the ACME family). Sprint 3 is the
first split that ALSO moves helper functions — the SCEP family has
three structs AND three unexported package-internal helpers that
move together.
What moved
==========
internal/config/scep.go (new, 402 lines including BSL header +
Phase 9 doc-comment + the 3 imports +
3 structs + 3 helpers verbatim)
Three structs:
- SCEPConfig (top-level: Enabled + Profiles slice
+ legacy single-profile flat fields
kept for backward compat)
- SCEPProfileConfig (one endpoint binding: PathID,
IssuerID, ProfileID, ChallengePassword,
RA cert/key, MTLSEnabled + bundle path,
per-profile Intune block)
- SCEPIntuneProfileConfig (Enabled, ConnectorCertPath, Audience,
ChallengeValidity, PerDeviceRateLimit24h,
ClockSkewTolerance)
Three unexported helpers:
- loadSCEPProfilesFromEnv() — reads CERTCTL_SCEP_PROFILES +
expands each name into a
SCEPProfileConfig via the
CERTCTL_SCEP_PROFILE_<NAME>_*
indexed env-var family.
- mergeSCEPLegacyIntoProfiles() — backward-compat shim: synthesize
Profiles[0] from the legacy flat
fields when Profiles is empty.
- validSCEPPathID() — path-segment validator (ASCII
[a-z0-9-], no leading/trailing
hyphen, empty allowed).
Why move the helpers along
==========================
Each helper is exclusively SCEP-specific: live grep across the repo
shows ZERO callers outside internal/config/config.go's Load() and
Validate(). Both still live in config.go and continue to resolve
the moved helpers via same-package lookup. Specifically:
- Load() (still in config.go) calls loadSCEPProfilesFromEnv() during
initial cfg.SCEP construction (call site at the original line ~1840,
now closer to line ~1840 after Sprints 1+2 + 3 deletions).
- Load() calls mergeSCEPLegacyIntoProfiles(&cfg.SCEP) after the
initial profile-load.
- Validate() calls validSCEPPathID(p.PathID) per-profile in the
Profiles-iteration loop.
The unexported helpers getEnv / getEnvBool / getEnvInt / getEnvDuration
used by loadSCEPProfilesFromEnv stay in config.go (shared across every
config family); same-package resolution makes the calls work.
What stayed in config.go
========================
- All Load() + Validate() bodies — the SCEP-specific call sites stay
where they are (cross-cutting validation logic, not split-target).
- Every getEnv* helper.
- The Config{}.SCEP master-struct field declaration.
Edit shape
==========
The edit was performed in two sed passes:
1. sed -i '775,1004d' — deleted the SCEP struct block (the three
types + their doc-comments).
2. sed -i '1813,1916d' — deleted the SCEP helper-function block
(the three helpers + their doc-comments).
Then gofmt -w to collapse a residual double-blank-line at the first
join point. The two-pass approach was necessary because the structs
and helpers live in different regions of config.go (struct
definitions in the top half, function bodies near the bottom).
Public-surface invariant
========================
Every type, field, exported method, and doc-comment is byte-identical
to pre-split. Package stays `config`. Every caller's
`config.SCEPConfig` / `config.SCEPProfileConfig` /
`config.SCEPIntuneProfileConfig` import path is preserved without
modification. The three helpers are unexported so their move is
invisible to package consumers; same-package callers in config.go
continue to resolve them via the package symbol table.
Verification (all clean):
gofmt -l internal/config/ → clean (after -w)
go build ./internal/config/... → clean
go test ./internal/config/... -count=1 → ok (0.68s)
staticcheck ./internal/config/... → clean
go build ./internal/api/router/...
./internal/scheduler/...
./cmd/server/... → clean (broader importers
still resolve every type)
grep -nE '^type SCEP|^func .*SCEP' internal/config/config.go
→ empty (none remain in config.go)
grep -nE '^type SCEP|^func .*SCEP' internal/config/scep.go
→ 3 types + 3 funcs (correct: SCEPConfig, SCEPProfileConfig,
SCEPIntuneProfileConfig,
loadSCEPProfilesFromEnv,
mergeSCEPLegacyIntoProfiles,
validSCEPPathID)
LOC delta:
config.go: 3108 → 2774 (-334 lines: -230 from struct block,
-103 from helper block,
-1 from double-blank collapse)
scep.go: new, 402 lines (incl. 72-line Phase 9 doc-comment + BSL
header + package decl + 3 imports)
Cumulative Phase 9 progress (Sprints 1+2+3 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)
Total Sprint 1+2+3: -629 LOC (-18.5%)
Pattern lesson logged
=====================
The "Do not assume line numbers" rule continues to pay off: every
sprint of Phase 9 has touched line numbers from prior sprints
(Sprint 1's 65-line removal shifted SCEPConfig from line 1083 to
1015 to its Sprint 3 starting position of 786). The Phase 9 prompt
told us to re-derive every fact; the live-grep audit at the start
of each sprint catches the drift.
Next queued (Sprint 4): EST family from config.go →
internal/config/est.go (~250-300 LOC including ESTConfig +
ESTProfileConfig + loadESTProfilesFromEnv +
mergeESTLegacyIntoProfiles + parseAuthModes + validESTPathID +
validESTAuthMode). Same complexity shape as SCEP — three structs
+ multiple helpers + same Load()/Validate() callers that stay
in config.go.
Closes: cowork/certctl-architecture-diligence-audit.html#fix-ARCH-M2
(partial — 3 of 12 — full ARCH-M2 closure is the aggregate)
|
||
|
|
5d5bd02f3e |
refactor(config): extract ACME family to its own file (Phase 9, 2 of N)
Continuing Phase 9 ARCH-M2 closure. Sprint 1 (commit
|
||
|
|
45ddcb75a3 |
refactor(config): extract NotifierConfig to its own file (Phase 9, 1 of N)
Phase 9 of the certctl architecture diligence remediation begins
closing ARCH-M2: the 6 backend mega-files totaling > 13K LOC of
change-risk hotspots. config.go is the largest (3,403 LOC pre-split)
and the most frequently touched (env-var ingestion gets edited every
release). The audit's "3.2K LOC / 11.5K total across 6 files" claim
has drifted upward — live grep shows config.go alone is now 3,403
LOC and the top-6 hotspots total 13,267 LOC. The audit's framing is
directionally correct; numbers updated in cowork/certctl-architecture-
diligence-audit.html with this commit.
This commit ships the FIRST of many splits (one per PR per the
Phase 9 prompt's "Do not bundle" rule):
Extract NotifierConfig (65 lines) → internal/config/notifiers.go
Why NotifierConfig first
========================
- Cleanest possible cut: a single struct, no helper functions, no
validation logic, no cross-references to Load() except via the
Config{}.Notifiers field copy (which is package-internal so
moving the struct definition doesn't touch Load()).
- Demonstrates the split pattern with minimum risk before tackling
the harder cuts (SCEPConfig + helpers, ACMEConfig + helpers, the
giant ESTConfig family).
- Public-surface byte-identical: every caller's
`config.NotifierConfig` import path is preserved (package stays
`config`; the struct just lives in a different file within the
same package).
Live audit (Phase 9 audit questions answered)
==============================================
top-10 production .go files by LOC (find cmd internal -name '*.go'
-not -name '*_test.go' | xargs wc -l | sort -rn | head -10):
3403 internal/config/config.go <-- this commit -68
2966 cmd/server/main.go
1965 internal/service/acme.go
1867 internal/mcp/tools.go
1577 internal/api/handler/auth_session_oidc.go
1489 cmd/agent/main.go
1356 internal/auth/oidc/service.go
1249 internal/scheduler/scheduler.go
1235 internal/connector/issuer/local/local.go
1224 internal/service/scep.go
The audit's "3 others beyond config/main/acme" are:
- internal/mcp/tools.go (1867 LOC)
- internal/api/handler/auth_session_oidc.go (1577 LOC)
- cmd/agent/main.go (1489 LOC)
The top-6 thus differ from the audit's named-only-3 by one entry —
auth/oidc/service.go (1356) edges out the audit's likely fourth pick.
Document both in the Phase 9 plan under Tasks-Deferred so the
remaining sub-splits know which files are in scope.
config.go internals (45 distinct exported `type X struct` defs as of
this commit's pre-state):
Config, ServerConfig, ServerTLSConfig,
DatabaseConfig, SchedulerConfig, LogConfig, AuthConfig,
RateLimitConfig, CORSConfig, KeygenConfig, CAConfig,
StepCAConfig, VaultConfig, DigiCertConfig, SectigoConfig,
GoogleCASConfig, OpenSSLConfig, ESTConfig, ESTProfileConfig,
SCEPConfig, SCEPProfileConfig, SCEPIntuneProfileConfig,
NetworkScanConfig, VerificationConfig, ApprovalConfig,
NamedAPIKey, SessionConfig, BreakglassConfig, EncryptionConfig,
CloudDiscoveryConfig, AWSSecretsMgrDiscoveryConfig,
AzureKVDiscoveryConfig, GCPSecretMgrDiscoveryConfig,
NotifierConfig (THIS COMMIT), DigestConfig, HealthCheckConfig,
ACMEConfig, ACMEServerConfig, ACMEServerDirectoryMeta,
AWSACMPCAConfig, EntrustConfig, GlobalSignConfig, EJBCAConfig,
OCSPResponderConfig
Each is a natural future-split candidate. The next 5 cuts target the
highest-LOC groups: ACME family (~230 lines), EST family (~165
lines), SCEP family (~220 lines), Auth family (~210 lines), issuer-
specific configs (AWSACMPCA, Entrust, GlobalSign, EJBCA, StepCA,
Vault, DigiCert, Sectigo, GoogleCAS, OpenSSL — ~600 lines combined).
Public-surface invariant
========================
- Package name stays `config`.
- Struct + all field names byte-identical.
- Every caller's `config.NotifierConfig` import path preserved.
- Verified via:
go build ./internal/config/... → clean
go test ./internal/config/... -count=1 → ok (0.67s)
gofmt -l internal/config/ → clean
staticcheck ./internal/config/... → clean
LOC delta:
config.go: 3403 → 3335 (-68 lines)
notifiers.go: new, 86 lines (incl. 18-line Phase 9 doc-comment +
BSL header + package decl)
Phase 9 follow-on plan (each = separate commit, separate review)
================================================================
Next cuts from config.go (priority order):
2 of N. ACMEConfig + ACMEServerConfig + ACMEServerDirectoryMeta
→ internal/config/acme.go (~230 lines moved)
3 of N. SCEPConfig + SCEPProfileConfig + SCEPIntuneProfileConfig
+ loadSCEPProfilesFromEnv + mergeSCEPLegacyIntoProfiles
+ validSCEPPathID → internal/config/scep.go (~330 lines)
4 of N. ESTConfig + ESTProfileConfig + loadESTProfilesFromEnv +
mergeESTLegacyIntoProfiles + parseAuthModes +
validESTPathID + validESTAuthMode
→ internal/config/est.go (~250 lines)
5 of N. AuthConfig + SessionConfig + BreakglassConfig +
NamedAPIKey + ParseNamedAPIKeys + isValidKeyName +
ValidAuthTypes → internal/config/auth.go (~340 lines)
6 of N. ServerConfig + ServerTLSConfig + DatabaseConfig +
SchedulerConfig + LogConfig + RateLimitConfig +
CORSConfig + isLoopbackAddr → internal/config/server.go
(~270 lines)
7 of N. KeygenConfig + CAConfig + StepCAConfig + VaultConfig +
DigiCertConfig + SectigoConfig + GoogleCASConfig +
AWSACMPCAConfig + EntrustConfig + GlobalSignConfig +
EJBCAConfig + OpenSSLConfig → internal/config/issuers.go
(~600 lines)
After the config.go cuts land, the same pattern applies to the next
5 hotspots:
8 of N. cmd/server/main.go split: main.go (entrypoint),
wire.go (DI assembly), migrations.go (boot-migration
path). Phase 4's migration-hook lives in main.go today;
migrations.go inherits the path without re-touching it.
9 of N. internal/service/acme.go split: orders.go, authz.go,
challenges.go, nonces.go, gc.go under
internal/service/acme/. Becomes its own subpackage.
10 of N. internal/mcp/tools.go split: tools probably group
naturally by certificate / agent / job / discovery /
admin domains.
11 of N. internal/api/handler/auth_session_oidc.go split: by
handler verb (login, callback, refresh, logout,
backchannel).
12 of N. cmd/agent/main.go split: main.go (entrypoint), poll.go
(work-poll loop), deploy.go (deployment execution),
register.go (bootstrap + registration).
Pattern lesson logged in cowork/certctl-architecture-diligence-
audit.html Tasks-Deferred table.
Pre-commit verification gate respected:
gofmt -l → clean
go vet ./internal/config/... → clean (implicit via go test)
go test ./internal/config/... → ok
staticcheck ./internal/config/... → clean
TestRouterRBACGateCoverage → not affected (config package)
Closes: cowork/certctl-architecture-diligence-audit.html#fix-ARCH-M2
(partial — 1 of N — full ARCH-M2 closure is the aggregate)
|
||
|
|
8191b1ee64 |
scheduler+db: close Phase 6 — scale hardening across pool, jitter, ETag, asyncpoll
Phase 6 of the certctl architecture diligence remediation. Five
findings across the same scheduler-and-DB-pool surface.
SCALE-M1 (Med) — DB pool default bumped 25 → 50
internal/config/config.go line 1972:
MaxConnections: getEnvInt("CERTCTL_DATABASE_MAX_CONNS", 50)
Postgres default max_connections is 100; 50 leaves headroom for
pg_dump + ad-hoc psql + a server replica without exhausting the
DB-side cap. Operator override env var unchanged. Operator-tune
ladder for larger fleets (5K / 50K certs) lives in
docs/operator/scale.md as starter values pending Phase 8 load
tests — explicitly marked TBD.
SCALE-M3 (Med) — async-CA poll budget operator-configurable
Live state was partially-already-shipped: all 4 async-CA
connectors (digicert, entrust, globalsign, sectigo) already have
per-connector CERTCTL_<NAME>_POLL_MAX_WAIT_SECONDS (Audit fix #5
closed pre-Phase-6). What was missing: a global package-default
override. Shipped:
- internal/connector/issuer/asyncpoll/asyncpoll.go gains
SetDefaultMaxWait(d) + effectiveDefaultMaxWait var + the
currentDefaultMaxWait() priority resolver.
- cmd/server/main.go reads CERTCTL_ASYNC_POLL_MAX_WAIT_SECONDS
at boot and calls SetDefaultMaxWait.
- deploy/ENVIRONMENTS.md documents the new env var (G-3 guard
green).
Naming deviation from the prompt's CERTCTL_ASYNC_POLL_MAX_ATTEMPTS:
the live code tracks wall-clock time (MaxWait), not attempt count.
Matched the existing per-connector nomenclature (_POLL_MAX_WAIT_SECONDS)
so the priority chain reads naturally.
SCALE-M5 (Med) — JitteredTicker wrapper for all 15 scheduler loops
internal/scheduler/jitter.go ships NewJitteredTicker(interval,
jitterPct) + DefaultSchedulerJitter (±10%). All 15 sites in
internal/scheduler/scheduler.go migrated from bare time.NewTicker
to NewJitteredTicker(interval, DefaultSchedulerJitter). Base
intervals unchanged; only the per-tick envelope adds ±10%
randomized delay so multiple loops with the same nominal cadence
don't co-fire and spike CPU + DB at wall-clock boundaries.
internal/scheduler/jitter_test.go pins:
- Bounded envelope (each tick within ±jitterPct of interval)
- Mean drift < 30% of nominal (sign-bug detector)
- Stop() releases the goroutine + closes C
- Stop() idempotent (no panic on repeat)
- Zero-jitter behaves like time.NewTicker
- Negative and >=1 jitterPct values clamped defensively
CI guard scripts/ci-guards/no-bare-newticker-in-scheduler.sh blocks
any future bare time.NewTicker in scheduler.go.
SCALE-L1 (Low) — renewal-sweep semaphore behavior documented
docs/operator/scale.md "Scheduler tick budgets" section explains
the per-tick concurrency semaphore (CERTCTL_RENEWAL_CONCURRENCY=25
default), the ctx-cancellation drain on tick-budget overrun, and
operator tuning advice (raise concurrency + DB pool together).
No code change — the behavior is defensible as-is per the audit.
SCALE-L2 (Low) — ETag middleware for top-5 read endpoints
internal/api/middleware/etag.go computes SHA-256 ETag over the
buffered response body, respects If-None-Match, short-circuits
to 304 Not Modified on match. GET/HEAD only; non-2xx responses
pass through unchanged. 64 KiB buffer cap degrades gracefully on
oversized responses (no caching, body still flushes intact).
Wired around the top-5 read endpoints via etagged() helper in
internal/api/router/router.go:
GET /api/v1/certificates
GET /api/v1/agents
GET /api/v1/jobs
GET /api/v1/audit
GET /api/v1/discovered-certificates
internal/api/middleware/etag_test.go pins 11 behaviors including
304-on-repeat, 200-after-mutation-with-new-ETag, POST bypass,
4xx/5xx pass-through, oversized-response degradation, wildcard
match, HEAD-treated-like-GET, byte-equal pass-through.
Cross-cutting fixes:
- internal/config/config_test.go::TestLoad_DefaultValues updated
to assert the new 50 default (was 25).
- deploy/helm/certctl/values.yaml comment corrected — agent
pollInterval is hardcoded 30s, not env-configurable; the
Phase 4 comment mistakenly referenced CERTCTL_AGENT_POLL_INTERVAL
which G-3 caught as a phantom env var.
- asyncpoll.go reformatted by gofmt; functionally unchanged.
Verification (all pass):
grep -nE 'SetMaxOpenConns' internal/repository/postgres/db.go # finds 1 site
grep -nE 'CERTCTL_DATABASE_MAX_CONNS.*50' internal/config/config.go # config default is 50
grep -rnE 'CERTCTL_ASYNC_POLL_MAX_WAIT_SECONDS' internal/ deploy/ENVIRONMENTS.md # wired
grep -cE 'time\.NewTicker\(' internal/scheduler/scheduler.go # 0 (all migrated)
grep -cE 'JitteredTicker' internal/scheduler/scheduler.go # 15
ls internal/scheduler/jitter.go internal/api/middleware/etag.go # both exist
ls docs/operator/scale.md # exists
bash scripts/ci-guards/no-bare-newticker-in-scheduler.sh # clean
bash scripts/ci-guards/G-3-env-docs-drift.sh # clean
go test ./internal/scheduler/ ./internal/api/middleware/ \
./internal/connector/issuer/asyncpoll/ ./internal/config/ # 4/4 packages green
Closes: cowork/certctl-architecture-diligence-audit.html#fix-SCALE-M1
cowork/certctl-architecture-diligence-audit.html#fix-SCALE-M3
cowork/certctl-architecture-diligence-audit.html#fix-SCALE-M5
cowork/certctl-architecture-diligence-audit.html#fix-SCALE-L1
cowork/certctl-architecture-diligence-audit.html#fix-SCALE-L2
|
||
|
|
21aeed4f4e |
legal: addlicense headers + normalize legacy variants (Phase 0 RED-4)
Phase 0 closure (Path B2, post-rewrite):
addlicense sweep — adds the canonical certctl LLC copyright + BUSL-1.1
SPDX header to every production Go file. Template:
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
Coverage: 338 / 338 production Go files (cmd/ + internal/, excluding
*_test.go and **/testdata/**). Pre-sweep coverage was 22 / 338 (6.5%);
post-sweep is 338 / 338 (100%).
Normalized 22 pre-existing legacy headers (`// Copyright (c) certctl`
+ `// SPDX-License-Identifier: BSL-1.1`) and 1 file using a
`Certctl Contributors` attribution. The legacy SPDX ID `BSL-1.1`
is non-standard; the official SPDX identifier for Business Source
License 1.1 is `BUSL-1.1` (capital U). All 338 files now share the
canonical form.
Generated via:
addlicense -c "certctl LLC" -y 2026 \
-f cowork/legal/copyright-header.tpl \
-ignore '**/testdata/**' -ignore '**/*_test.go' \
cmd/ internal/
Verification:
find cmd internal -name '*.go' -not -name '*_test.go' \
-not -path '*/testdata/*' \
-exec grep -L '^// Copyright 2026 certctl LLC' {} \; | wc -l
Returns: 0
gofmt clean. Header additions are comments only, no compile impact.
Closes: cowork/certctl-architecture-diligence-audit.html#fix-RED-4
|
||
|
|
888e10cba0 |
fix(ci): close two CI regressions from Phase 3 + Phase 5
Phase 3 added @playwright/test@^1.49.0 to web/package.json and
Phase 5 added orval@^7.0.0, both without regenerating
web/package-lock.json. CI's npm ci in both the Frontend Build job
and the Dockerfile frontend stage failed:
npm error Missing: @playwright/test@1.60.0 from lock file
npm error Missing: orval ... from lock file
Regenerate web/package-lock.json with:
cd web && npm install --package-lock-only --no-audit
(+6990 / -1893 lines — orval pulls a deep transitive graph). No
node_modules download required; lockfile-only mode keeps the
operation light. Verified clean with 'npm ci --dry-run' (612
packages would install).
Phase 2's SEC-H3 fail-closed branch (CERTCTL_DEMO_MODE_ACK_TS
required when CERTCTL_DEMO_MODE_ACK=true) broke four pre-existing
tests in internal/config/config_test.go that set DemoModeAck=true
without setting DemoModeAckTS:
TestValidate_AuthTypeNone_NonLoopback_AckPasses (l.722)
TestValidate_Bundle2_PlaceholderAuthSecret_DemoAckExempt (l.1799)
TestValidate_Bundle2_PlaceholderEncryptionKey_DemoAckExempt (l.1832)
TestValidate_Bundle2_CORSWildcard_DemoAckExempt (l.1879)
Each test now sets DemoModeAckTS alongside DemoModeAck=true:
DemoModeAckTS: strconv.FormatInt(time.Now().Unix(), 10)
strconv + time were already imported in config_test.go. Verified
locally: 'go test ./internal/config/... -count=1' passes clean
(0.700s), gofmt clean, go vet clean.
Root cause was the sandbox 'disk-full' constraint that forced
deferring npm install to the operator's workstation — but CI runs
npm ci before any workstation operation. Lockfile-only regen
(this commit) is the right fix; works in low-disk environments
because no node_modules download happens.
|
||
|
|
69a2b5c55a |
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
|
||
|
|
a849c8b8cf |
fix(security): close BUNDLE 2 — safe first run, demo mode, agent bootstrap
Bundle 2 closure (2026-05-12 acquisition diligence audit). Closes the
"docker compose up == accidental production" hazard: pre-Bundle-2 the
base deploy/docker-compose.yml WAS the demo path (AUTH_TYPE=none +
DEMO_MODE_ACK=true + KEYGEN_MODE=server + DEMO_SEED=true + literal
change-me-... placeholder creds), the README claimed "drop the demo
overlay for a clean install", and ENVIRONMENTS.md table documented
auth-type default as api-key — three contradictory stories layered on
the same compose file.
Source findings closed:
R2 R3 C1 D9 finding-2 S9 (repo audit)
SEC-H2 SEC-M1 SEC-M3 OPS-M3 LOW-5 HIGH-6 (cowork audit)
Compose split (deploy/docker-compose.yml + deploy/docker-compose.demo.yml):
The base now ships production-shaped — no AUTH_TYPE override, no
KEYGEN_MODE override, no DEMO_MODE_ACK, no DEMO_SEED, no literal
placeholder fallbacks. POSTGRES_PASSWORD / CERTCTL_AUTH_SECRET /
CERTCTL_CONFIG_ENCRYPTION_KEY / CERTCTL_API_KEY / CERTCTL_AGENT_ID
must come from deploy/.env (sample template in deploy/.env.example +
root .env.example). The demo overlay carries the full demo posture
(every env var + every placeholder credential) so the
`-f docker-compose.demo.yml` one-flag flip remains a zero-config
populated-dashboard path.
Fail-closed startup guards (internal/config/config.go::Validate):
Three new gates layered on the existing HIGH-12 demo-mode listen-bind
guard. All three exempt CERTCTL_DEMO_MODE_ACK=true so the demo overlay
keeps working:
• HIGH-6: AUTH_SECRET = "change-me-in-production" → refuse
• HIGH-6: CONFIG_ENCRYPTION_KEY = "change-me-32-char..." → refuse
• LOW-5: CORS_ORIGINS contains "*" (CWE-942 + CWE-352) → refuse
Visible DEMO MODE banner (cmd/server/main.go): every boot under
DEMO_MODE_ACK=true now emits a prominent WARN line with a 6-step
production-promotion checklist. The 2026-04-19 incident (a screenshot
run that kept running for three days) drove this; the per-startup
banner makes the posture unmissable in any log scraper.
Agent enrollment doc alignment:
• docs/reference/configuration.md L83: corrected the non-existent
URL `POST /api/v1/agents/register` to the real route
`POST /api/v1/agents`; added the bootstrap-token note and the
install-agent.sh handoff sequence.
• docs/reference/architecture.md L154: replaced "agents register
themselves at first heartbeat" (false — cmd/agent/main.go fail-
fasts when CERTCTL_AGENT_ID is unset) with the actual two-step
operator-driven flow (REST or GUI registration first, returned ID
fed to install-agent.sh second).
Tests + CI guard:
• 9 new TestValidate_Bundle2_* cases in internal/config/config_test.go
covering: placeholder-secret refused + demo-ack exempt; placeholder
encryption-key refused + demo-ack exempt; real key not mistaken for
placeholder; wildcard CORS refused + demo-ack exempt; wildcard mixed
into a concrete allowlist still refused; concrete allowlist accepted.
• scripts/ci-guards/B2-compose-base-no-demo-env.sh: greps the base
compose for any of the demo-mode env vars + placeholder credentials.
Comments stripped before checking so the narrative header in the
base file can still reference the overlay's posture in prose.
Cold-DB CI smoke (.github/workflows/ci.yml::cold-db-compose-smoke):
Switched to layering -f docker-compose.demo.yml on top of the base —
the new production base requires real env vars the smoke doesn't have,
and the smoke's purpose (catch migration-on-cold-DB regressions + the
bootstrap-token mint path) is orthogonal to which auth posture the
boot lands in.
Receipts:
• Current first-run truth table
compose flag → posture
-f docker-compose.yml (production)
→ requires .env;
fail-fasts on
missing AUTH_SECRET
/ CONFIG_ENCRYPTION
_KEY / POSTGRES
_PASSWORD; agent
fail-fasts on
missing AGENT_ID
-f docker-compose.yml -f docker-compose.demo.yml (demo)
→ zero-config;
AUTH_TYPE=none +
DEMO_MODE_ACK=true
+ KEYGEN=server +
DEMO_SEED=true;
boot banner WARN
-f docker-compose.yml -f docker-compose.dev.yml (dev)
→ base + PgAdmin
+ debug logging
-f docker-compose.test.yml (test, standalone)
→ production-shape
posture, real CA
backends
• Verification (PATH=/tmp/go/bin export GO* paths to /tmp):
gofmt -l # clean (no diffs)
go vet ./internal/config ./cmd/server # clean
go test -short -count=1 ./internal/config/... # PASS (cumulative +
all 9 new Bundle 2
cases green)
go test -short -count=1 # PASS (no regression
./internal/connector/target/configcheck in the Bundle 1 -
closure tests)
go build ./cmd/server ./cmd/agent # clean
./cmd/cli ./cmd/mcp-server
bash scripts/ci-guards/B2-compose-base-no-demo-env.sh # clean
bash scripts/ci-guards/H-1-encryption-key-min-length.sh # clean
bash scripts/ci-guards/G-3-env-docs-drift.sh # clean
Remaining operator warnings (not blocking; tracked in CLAUDE.md
"Open decisions"):
• The first `docker compose -f docker-compose.yml up -d` against a
pre-Bundle-2 .env (placeholder values still in place) will now
fail-fast. This is the intended posture but operators upgrading
from v2.0.x via .env-from-old-master need to rotate before
upgrading. The CHANGELOG note for the v2.1.0 release should
call this out alongside Auth Bundle 2's other breaking changes.
Audit-Closes: BUNDLE-2 R2 R3 C1 D9 S9 SEC-H2 SEC-M1 SEC-M3 OPS-M3 LOW-5 HIGH-6
|
||
|
|
0ab6bc4a73 |
feat(ci): item-1 complete-path config-coverage guard (PARTIAL — sandbox could not verify Go test)
Shell guard verified working in sandbox:
- Green on clean repo: 'OK — every CERTCTL_* env var (194) has at least
one non-config-package consumer.'
- Red on injected orphan: '::error::Orphan env vars — defined in
config.go but no consumer found outside internal/config/' with three
remediation paths listed.
Go test internal/config/coverage_test.go written but NOT verified —
sandbox Go 1.25.9 < go.mod's 1.25.10 requirement; toolchain
auto-download fails (disk full). Operator must run `make verify` from
workstation before merge.
Allowlist scaffold at scripts/ci-guards/complete-path-config-coverage-exceptions.yaml.
Every entry requires name + justification + expires fields; expired
entries fail the guard.
Catches the lying-field bug class — env var defined in config.go that no
business-logic code reads. The 2026-04-29 SCEP MustStaple Phase 5.6 gap
(domain field shipped, service layer never read profile.MustStaple) is
the canonical case this guard would have caught at commit time.
Audit-Closes: post-v2.1.0-anti-rot/item-1
|
||
|
|
a923cf697c |
harden(auth): demo-mode residual-grants detector + cleanup endpoint + CI guard (A-8)
Audit 2026-05-11 A-8 closure. Closes the deferred Phase 2 leg of the
2026-05-10 HIGH-12 closure (
|
||
|
|
b8fac59200 |
chore(fmt): gofmt cleanup on files touched by audit-2026-05-11 fix bundle
Whitespace alignment drift surfaced by gofmt -l after merging 7 fix branches.
Pure formatting, no semantic change. Pre-existing master drift in
internal/auth/oidc/{domain/types.go, integration_keycloak_rotate_test.go,
test_discovery.go} left untouched — that's separate tech debt.
|
||
|
|
2a1a0b347c |
harden(oidc): pre-login UA/IP binding (MED-16) — RFC 9700 §4.7.1
Audit 2026-05-10 MED-16 closure.
WHAT.
Binds the OIDC pre-login row to the (clientIP, userAgent) tuple of
the /auth/oidc/login request, and enforces a constant-time compare
against the /auth/oidc/callback request at consume time. Defeats
replay of a stolen pre-login cookie by a different browser /
source — the secondary defense layer recommended by RFC 9700 §4.7.1
when the primary layer (HMAC integrity + Path=/ + SameSite=Lax on
the cookie) is bypassed via CSRF / XSS / TLS-termination leak.
WHY.
Pre-fix, the pre-login cookie's HMAC verified only that 'some'
caller of /auth/oidc/login was talking to /auth/oidc/callback; it
did not verify that the SAME browser / source was on both sides.
An attacker who exfiltrated the cookie value via any vector could
replay the bytes through their own user-agent and ride the victim's
authorization. RFC 9700 §4.7.1 calls out the gap explicitly and
recommends binding state to a user-agent fingerprint + source IP.
HOW.
Migration:
migrations/000044_prelogin_uaip.up.sql
ALTER TABLE oidc_pre_login_sessions
ADD COLUMN IF NOT EXISTS client_ip TEXT,
ADD COLUMN IF NOT EXISTS user_agent TEXT;
Both nullable for in-flight rolling-deploy compat — the consume-
side check only enforces when both row AND request carry non-empty
values for the leg in question.
Domain:
internal/repository/oidc.go (PreLoginSession) — adds ClientIP +
UserAgent fields.
Repository:
internal/repository/postgres/oidc_prelogin.go — Create persists
via sql.NullString (empty → NULL); LookupAndConsume reads back.
Re-uses package-local nullableString from discovery.go.
Service:
internal/auth/oidc/service.go
- PreLoginStore.CreatePreLogin signature takes (clientIP,
userAgent) as positions 5–6.
- PreLoginStore.LookupAndConsume returns (clientIP, userAgent)
as positions 5–6.
- HandleAuthRequest signature gains (clientIP, userAgent),
threaded to the store.
- HandleCallback adds Step 1.5 — UA / IP constant-time compare
between stored row and incoming request. Per-leg toggles via
preLoginRequireUA / preLoginRequireIP service fields. Empty
values on either side pass through (rolling-deploy + headless-
proxy compat).
- New sentinels ErrPreLoginUAMismatch, ErrPreLoginIPMismatch.
- SetPreLoginBindingRequirements(requireUA, requireIP) helper
for main.go config wiring.
Adapter:
internal/auth/oidc/prelogin.go — PreLoginAdapter passes the new
fields through to the repo row.
Handler:
internal/api/handler/auth_session_oidc.go
- OIDCAuthHandshaker.HandleAuthRequest signature updated.
- LoginInitiate captures clientIPFromRequest + r.UserAgent()
and passes to the service.
- classifyOIDCFailure adds errors.Is dispatch for the two new
sentinels → prelogin_ua_mismatch / prelogin_ip_mismatch
audit categories.
Config:
internal/config/config.go
+ AuthConfig.OIDCPreLoginRequireUA (default true)
env CERTCTL_OIDC_PRELOGIN_REQUIRE_UA
+ AuthConfig.OIDCPreLoginRequireIP (default true)
env CERTCTL_OIDC_PRELOGIN_REQUIRE_IP
cmd/server/main.go calls oidcService.SetPreLoginBindingRequirements
from cfg.Auth.OIDCPreLoginRequire{UA,IP}.
Tests (internal/auth/oidc/service_test.go):
- TestService_HandleCallback_MED16_UAMismatchRejected
- TestService_HandleCallback_MED16_IPMismatchRejected
- TestService_HandleCallback_MED16_BothMatch_Succeeds
- TestService_HandleCallback_MED16_LegacyRowEmptyValues (rolling-
deploy compat — empty stored values pass through)
- TestService_HandleCallback_MED16_RequireUAFalse_AllowsMismatch
(operator escape-hatch — UA mismatch silently allowed)
Mechanical fan-out:
- stubPreLogin / stubPreLoginRepo signatures updated.
- All existing call sites in service_test.go (~40), prelogin_test.go,
bench_test.go, logging_test.go, provider_enabled_test.go,
integration_keycloak_test.go, integration_okta_smoke_test.go,
auth_session_oidc_test.go updated to pass empty strings for the
new params — pre-existing tests do not exercise UA/IP binding
semantics.
VERIFY.
- go vet ./internal/auth/oidc/... ./internal/api/handler/...
./internal/config/... PASS
- go test -short -count=1 -run MED16 ./internal/auth/oidc/... PASS (5/5)
- go test -short -count=1 ./internal/auth/oidc/... PASS (4.6s)
- go test -short -count=1 ./internal/api/handler/... PASS (4.3s)
- go test -short -count=1 ./internal/config/... PASS
Refs: cowork/auth-bundles-audit-2026-05-10.md MED-16
cowork/auth-bundles-fixes-2026-05-10/HANDOFF.md item 6
RFC 9700 §4.7.1 — OAuth 2.0 Security Best Current Practice
|
||
|
|
9cce2ab043 |
harden(auth): LOW + Nit batch — bootstrap audit, crypto/rand, XFF trust, CSRF check, protocol-prefix unify (Batch 1)
Audit 2026-05-10 — close 8 LOWs + 2 Nits in-bundle. Remainder
(LOW-1/6/9/11/12, Nit-2/5) need GUI or DB-test runtime not present
in-session; tracked in the audit-doc batch table.
LOW-2: bootstrap.ValidateAndMint now emits 'bootstrap.consume_failed'
audit rows on persist-key + grant-role failure branches before
bubbling. Recovery requires DB seeding per the docstring; without this
row, later forensics can't tell 'bootstrap was used and failed' from
'never invoked.'
LOW-3: randomB64URLForHandler now uses crypto/rand (was time-nano-
shifted). Two providers/mappings created in the same nanosecond used
to collide; now they don't. Time-nano fallback retained for the
unlikely crypto/rand-broken path.
LOW-4: breakglass.verifyDummy uses s.readRand(salt) for the dummy
Argon2id verify. Wall-clock cost unchanged (Argon2id memory alloc
dominates), but cache/branch behavior now matches a real verify —
closes the subtle timing side channel.
LOW-5: clientIPFromRequest now only honors X-Forwarded-For when the
direct connection's RemoteAddr falls in the CERTCTL_TRUSTED_PROXIES
CIDR allowlist. Default-deny: empty list means XFF is ignored.
SetTrustedProxies wired in cmd/server/main.go from cfg.Auth.TrustedProxies.
LOW-7: internal/auth/protocol_endpoints.go::ProtocolEndpointPrefixes
now carries /scep-mtls + /.well-known/est-mtls (previously only in
router.AuthExemptDispatchPrefixes; the two lists had drifted). The
canonical-prefix coverage test in Phase 12 still pins the set.
LOW-8: docs/operator/rbac.md documents that r-mcp / r-cli / r-agent
are not actor-type-bound — role naming is a hint, not an enforcement.
Operators wanting hard binding must apply periodic audit queries.
Native binding is on the v2 roadmap.
LOW-10: Session.Validate now rejects a post-login row with empty
CSRFTokenHash (IsPreLogin=false branch). validSession test fixture
updated with a valid 64-hex CSRF hash.
Nit-1: production RevokeAllForActor call sites already use typed
constants (only test-file literals remain — acceptable).
Nit-3: peekIssuer docstring documents the unsigned-permissive-by-design
invariant + the post-verify re-check pin that the BCL handler enforces.
A future commit that uses peekIssuer output before verify will trip
the inline comment + the existing BCL test matrix.
Status table updated in cowork/auth-bundles-audit-2026-05-10.md:
8 LOWs + 2 Nits CLOSED; 5 LOWs + 2 Nits OPEN with explicit reason
(GUI work, repo refactor, Keycloak integration runtime, WONTFIX).
Refs: cowork/auth-bundles-audit-2026-05-10.md LOW-2/3/4/5/7/8/10
cowork/auth-bundles-audit-2026-05-10.md Nit-1/3
|
||
|
|
2e97cc10b8 |
fix(config): refuse to start when CERTCTL_AUTH_TYPE=none binds non-loopback (HIGH-12)
Audit 2026-05-10 HIGH-12 closure. Pre-fix, an operator who flipped
CERTCTL_AUTH_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. The control
plane is HTTPS-only, but a misconfigured ingress / public listen-bind
means any reachable client gets full admin without authentication.
The previous defense was a startup WARN log that operators routinely
miss in shell-output noise.
Post-fix: Config.Validate() refuses to start when:
- Auth.Type = 'none'
- AND Server.Host is non-loopback (NOT in {127.0.0.1, ::1, localhost})
- AND Auth.DemoModeAck = false (CERTCTL_DEMO_MODE_ACK=true overrides)
Real authn types (api-key, oidc) are unaffected — the guard fires only
when Type=none.
isLoopbackAddr defensively rejects:
- '' (Go's default-everything bind)
- '0.0.0.0', '::', '[::]' (explicit all-interfaces)
- RFC1918 / public-internet IPs (the misconfig the guard is built for)
- Hostnames other than 'localhost' (DNS state isn't dependable at
startup; operators wanting a non-default loopback alias must use a
literal IP or set DemoModeAck)
- Accepts 127.0.0.0/8 (all loopback IPs), ::1, localhost
- Strips host:port form before classifying
Regression matrix in config_test.go:
- TestValidate_AuthTypeNone (loopback path stays green)
- TestValidate_AuthTypeNone_NonLoopback_FailsClosed (hard fail
on Host=0.0.0.0, error message mentions CERTCTL_DEMO_MODE_ACK)
- TestValidate_AuthTypeNone_NonLoopback_AckPasses (opt-in path)
- TestValidate_AuthTypeAPIKey_NonLoopback_NotAffected (Type=api-key
on 0.0.0.0 unaffected by the guard)
- TestIsLoopbackAddr (15-case matrix: IPv4 + IPv6 + RFC1918 + public
IPs + hostnames + host:port forms)
The Phase 2 spec items — production-startup banner when actor-demo-anon
has residual role grants; CI guard banning new synthetic-admin code
paths — are partial-deferred to a v3 hygiene bundle. The high-impact,
fail-closed leg ships in this commit.
Refs: cowork/auth-bundles-audit-2026-05-10.md HIGH-12
Spec: cowork/auth-bundles-fixes-2026-05-10/11-high-12-demo-mode-guard.md
|
||
|
|
15435ca02b |
fix(oidc/bcl): jti replay-cache + iat freshness check (HIGH-3 closure)
Closes HIGH-3 of the 2026-05-10 audit. Pre-fix the BCL handler
accepted any logout_token whose iat + jti were syntactically present
but never checked (a) that iat fell within a skew window or (b) that
jti hadn't been seen before. A captured logout_token was replayable
indefinitely; once CRIT-2 was fixed, every replay would revoke the
user's current sessions — persistent DoS. RFC 9700 §2.7 + OIDC BCL
1.0 §2.5 require jti replay defense.
- Migration 000040_bcl_replay_cache: oidc_bcl_consumed_jtis table with
composite PK on (jti, issuer_url) — RFC 7519 §4.1.7 per-issuer
uniqueness — and an expires_at index for the GC sweep.
- repository.BCLReplayRepository interface + ErrBCLJTIAlreadyConsumed
sentinel. Postgres impl uses INSERT...ON CONFLICT DO NOTHING
RETURNING true for atomic single-use semantics in one round-trip.
- handler.DefaultBCLVerifier gains WithMaxAge + nowFn clock seam. iat
freshness check rejects tokens whose iat is in the future beyond
max-age OR stale beyond it. Verifier signature extended:
Verify(ctx, jwt) (iss, sub, sid, jti string, iat int64, err error).
- handler.AuthSessionOIDCHandler gains BCLReplayConsumer (interface)
+ WithBCLReplayConsumer(consumer, maxAge) setter. BackChannelLogout
consumes the jti post-verify with TTL = max(24h, 2*maxAge):
- first-receive → 200, sessions revoked, audit outcome=revoked
- replay (ErrBCLJTIAlreadyConsumed) → 200 + Cache-Control: no-store,
audit outcome=jti_replayed, sessions NOT re-revoked
- transient (non-AlreadyConsumed error) → 503 so the IdP retries
- internal/scheduler/scheduler.go: SetBCLReplayGarbageCollector wires
SweepExpired into the existing session-GC tick (no separate ticker
for short-lived replay rows).
- cmd/server/main.go: bclMaxAge from cfg.Auth.OIDCBCLMaxAgeSeconds
(default 60s, env CERTCTL_OIDC_BCL_MAX_AGE_SECONDS); bclReplayRepo
wired into the verifier + handler + scheduler.
- Three regression tests in internal/api/handler/bcl_replay_test.go:
TestBackChannelLogout_FirstReceiveConsumesJTI,
TestBackChannelLogout_ReplayedJTIReturns200WithAudit,
TestBackChannelLogout_TransientConsumeFailureReturns503.
- internal/api/handler/auth_session_oidc_test.go: stubBCLVerifier
gains jti + iat fields; existing TestBackChannelLogout_* tests
rewritten for the new Verify return.
Verification gate green: gofmt clean, go vet clean, go test -short
-count=1 on internal/api/handler / internal/api/router /
internal/scheduler / cmd/server / internal/auth/oidc /
internal/auth/breakglass — all pass.
CRIT-1..CRIT-5 + HIGH-1 + HIGH-2 + HIGH-3 of the 2026-05-10 audit
now closed on this branch. Spec at
cowork/auth-bundles-fixes-2026-05-10/07-high-3-bcl-replay-defense.md.
Refs: cowork/auth-bundles-audit-2026-05-10.md HIGH-3
|
||
|
|
1d01c87663 |
auth-bundle-2 Phase 7 + Phase 7.5: OIDC first-admin bootstrap +
break-glass admin (Argon2id, lockout, default-OFF, surface-invisibility)
Phase 7 — OIDC first-admin bootstrap (Decision 3):
- Optional AdminBootstrapHook closure on *oidc.Service. When wired,
HandleCallback consults the hook AFTER group resolution + user
upsert and BEFORE the empty-mapping fail-closed check. Hook
receives (providerID, groups, userID); returns grantAdmin=true
when the user matches CERTCTL_BOOTSTRAP_ADMIN_GROUPS AND no
admin exists yet in the tenant.
- cmd/server/main.go wires the hook as a closure that:
* Filters by CERTCTL_BOOTSTRAP_OIDC_PROVIDER_ID (if configured).
* Probes AdminExists via authActorRoleRepo (admin-already-exists
silently returns false; bootstrap mode is one-shot per tenant).
* Walks group intersection.
* On match: grants r-admin via authActorRoleRepo.Grant + emits
the bootstrap.oidc_first_admin audit row with
event_category=auth + INFO log.
- Coexists with the Bundle 1 env-var-token bootstrap. Both paths
can be configured; first match wins (admin-existence probe
short-circuits the second).
- HandleCallback's empty-mapping fail-closed check moved AFTER the
hook so a fresh deployment with zero group_role_mappings can
still mint the first admin.
- 5 tests in service_test.go: hook grants admin on match, hook
returns false preserves empty-mapping fail-closed, admin-already-
exists silently falls through to normal mapping, hook-error wraps
+ bubbles, idempotent when admin is already in the mapped role set.
Phase 7.5 — Break-glass admin (Decision 4, default-OFF):
Migration 000038 ships:
- breakglass_credentials table — at-most-one-credential-per-actor
(UNIQUE(actor_id)), Argon2id PHC-format password_hash, lockout
state machine (failure_count, locked_until, last_failure_at).
FK CASCADE on users(id) so deleting a user atomically removes
their credential.
- Two new permissions seeded into r-admin only:
auth.breakglass.admin — set/rotate/unlock/remove credentials.
auth.breakglass.login — actor uses break-glass to log in.
CanonicalPermissions extended in lockstep.
internal/auth/breakglass/service.go (~580 LOC):
- Service.Enabled() reflects CERTCTL_BREAKGLASS_ENABLED.
- SetPassword: Argon2id with OWASP 2024 params (m=64MiB, t=3, p=4,
salt=16 random bytes, output=32 bytes); per-password random salt;
PHC-format hash output. Min 12 / max 256 byte input.
- Authenticate: constant-time-compare via subtle.ConstantTimeCompare
on every code path. Identical 401 + identical timing across the
wrong-password / locked-account / non-existent-actor paths so an
attacker cannot probe whether a given actor has break-glass
configured. Non-existent-actor + locked-account paths run a
verifyDummy() Argon2id pass for timing parity. Lockout state
machine: failure_count++ on every wrong attempt; threshold (default
5) trips locked_until = NOW() + duration (default 15m). Successful
Authenticate resets the counter. Reset-window: failures aged out
after CERTCTL_BREAKGLASS_LOCKOUT_RESET_INTERVAL (default 1h)
auto-reset on next attempt.
- Unlock + RemoveCredential: admin-only (auth.breakglass.admin
gated at the router via rbacGate). Audit rows on every operation.
- All public methods refuse to act when Enabled()==false (returns
ErrDisabled; the handler maps to HTTP 404 — surface invisibility).
internal/repository/postgres/breakglass.go ships the 5-method
postgres impl with atomic single-statement IncrementFailure (so
concurrent racing wrong-password attempts can't observe an
intermediate state and slip past the threshold) and idempotent
ResetFailureCount.
internal/api/handler/auth_breakglass.go ships the 4-endpoint HTTP
surface:
- POST /auth/breakglass/login (auth-exempt; 5/min rate-limited per
source IP via the existing rate limiter; returns 404 when
disabled). On success sets the post-login session cookie + CSRF
cookie via SessionService.Create + 204. On any failure:
uniform 401 + identical timing (the service has already audited
the specific failure category).
- POST /api/v1/auth/breakglass/credentials (auth.breakglass.admin)
- POST /api/v1/auth/breakglass/credentials/{actor_id}/unlock
(auth.breakglass.admin)
- DELETE /api/v1/auth/breakglass/credentials/{actor_id}
(auth.breakglass.admin)
Admin endpoints share the surface-invisibility property: when
CERTCTL_BREAKGLASS_ENABLED=false, every admin endpoint also returns
404 (not 403) so probing via the admin surface gets the same signal
as probing the login endpoint.
Tests (internal/auth/breakglass/service_test.go):
All 8 Phase 7.5 spec-mandated negative cases:
1. Service.Enabled()==false → all ops return ErrDisabled.
2. Wrong password → ErrInvalidCredentials, failure_count++,
audit row with event_category=auth.
3. Failure_count exceeds threshold → locked, subsequent attempts
(including with the CORRECT password) return identical-shape
401 while the lockout window holds.
4. Lockout window expires → next attempt with correct password
succeeds + resets the counter.
5. Password < 12 bytes (or > 256 bytes) → ErrWeakPassword.
6. Password leak hygiene — the service has zero slog calls; the
audit-row map literal never includes the password plaintext.
7. Argon2id hash never appears in logs OR API responses — pinned
by `json:"-"` tag on BreakglassCredential.PasswordHash + a
belt-and-braces json.Marshal probe asserting the hash bytes
never appear in the marshaled output.
8. Constant-time-compare verified via timing-statistical test —
wrong-password vs no-credential paths take statistically
indistinguishable time (within 5x ratio). The verifyDummy()
hash compute on the no-credential + locked paths is what
keeps timing parity; absent that, an attacker could side-
channel "actor doesn't have a credential" via timing.
Plus coverage-lift batch covering: SetPassword first-time vs rotate,
no-caller-id rejection, no-target-id rejection, RNG failure surface,
Authenticate happy-path mints session, no-credential audit row,
session-mint-failure surface, FailureResetInterval recycle, Unlock
+ RemoveCredential happy paths, hash-format unit tests (round-trip,
mismatch, malformed/wrong-version/bad-base64 formats), nil-audit +
nil-session pass-through.
Coverage on internal/auth/breakglass/ at 91.5% per-statement (above
the Phase 7.5 spec ≥ 90% floor).
cmd/server/main.go wiring:
- Constructs breakglassRepo + breakglassService + breakglassHandler
after the OIDC service block.
- breakglassSessionMinterAdapter shim bridges *session.Service.Create
to the breakglass.SessionMinter port.
- Logs WARN at boot when CERTCTL_BREAKGLASS_ENABLED=true (operator
visibility for the deliberate SSO-bypass).
internal/config/config.go gains:
- AuthConfig.BootstrapAdminGroups + BootstrapOIDCProviderID for
Phase 7 (CERTCTL_BOOTSTRAP_ADMIN_GROUPS comma-list +
CERTCTL_BOOTSTRAP_OIDC_PROVIDER_ID).
- AuthConfig.Breakglass nested struct with 4 env vars
(CERTCTL_BREAKGLASS_ENABLED + LOCKOUT_THRESHOLD + LOCKOUT_DURATION
+ LOCKOUT_RESET_INTERVAL).
Router wiring:
- 4 new breakglass routes registered when reg.AuthBreakglass != nil;
public login route via direct r.mux.Handle (auth-exempt), 3 admin
routes via r.Register + rbacGate(auth.breakglass.admin).
- POST /auth/breakglass/login pinned in AuthExemptRouterRoutes
allowlist with Phase 7.5 justification.
- SpecParityExceptions extended with 4 new entries documenting
the Phase 7.5 deferral of full per-endpoint OpenAPI rows
(handler doc-block at the top of auth_breakglass.go is the
operator-facing reference).
Threat model (encoded in service.go + auth_breakglass.go doc-blocks
+ migration 000038 docstrings, to be promoted to docs/operator/auth-
threat-model.md in Phase 12):
- 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. Without it, break-glass is best treated as an emergency-
only path.
- Audit trail surfaces every break-glass action under
event_category=auth; the auditor role can monitor for unexpected
break-glass logins.
Verifications: gofmt clean, go vet clean across all touched packages,
go test -short -count=1 green across internal/auth/oidc (3.0s; new
Phase 7 hook tests integrated alongside the 21+ Phase 3 negatives),
internal/auth/breakglass (3.6s; 8 spec-mandated negatives + coverage
batch passing), internal/config + internal/domain/auth + internal/api/
router + internal/api/handler all green, no regressions in Bundle 1
packages.
|
||
|
|
17b30c1f7f |
auth-bundle-2 Phase 4: session service (cookie minting + signature
validation, idle/absolute expiry, signing-key rotation, CSRF, GC),
15-case negative-test matrix, fail-fatal initial-key bootstrap
Phase 4 of the bundle ships the post-login session lifecycle that backs
every authenticated request once Phase 5 wires the OIDC handlers + the
session middleware. The state machine is the load-bearing primitive for
the Bundle 2 control plane: forge a session cookie and you bypass every
RBAC gate.
Service surface (internal/auth/session/service.go, ~880 LOC):
- Service.Create(actorID, actorType, ip, ua) -> *CreateResult
Mints a session row; signs the cookie value with the active signing
key; returns the cookie payload AND the CSRF token plaintext for
the handler to set on the response.
- Service.Validate(ValidateInput) -> *Session
Parses the cookie, looks up the signing key (incl. retired-but-in-
retention), recomputes HMAC-SHA256, loads the session row, enforces
revocation + absolute + idle expiry + optional IP/UA bind. Maps to
one of 9 sentinel errors; the handler uniformly returns 401 to the
wire (specific reason in the audit row).
- Service.ValidateCSRF(headerValue, *Session) error
Constant-time compares SHA-256(header) against the stored hash on
the session row.
- Service.UpdateLastSeen / Revoke / RevokeAllForActor
- Service.RotateCSRFToken — mints fresh token, persists hash, returns
plaintext; called on login completion, logout, role-change against
actor, explicit operator rotate.
- Service.RotateSigningKey — mints new active key, retires previous;
retired keys stay valid for cfg.SigningKeyRetention so existing
cookies don't immediately fail.
- Service.EnsureInitialSigningKey — idempotent; mints first key on
fresh deploys; emits auth.session_signing_key_bootstrap audit row
with event_category=auth. Wired into cmd/server/main.go AFTER
migrations + RBAC backfill, BEFORE the HTTP listener binds; failure
is FATAL (logger.Error + os.Exit(1)) per the prompt — server refuses
to boot rather than serve session-less.
- Service.GarbageCollect — sweeps expired post-login sessions +
pre-login rows >10min + retired-past-retention signing keys. Wired
into the new internal/scheduler/scheduler.go::sessionGCLoop on a
CERTCTL_SESSION_GC_INTERVAL tick.
Cookie wire format (load-bearing):
v1.<session_id>.<signing_key_id>.<base64url-no-pad(HMAC-SHA256)>
The HMAC input is LENGTH-PREFIXED to defeat concatenation collisions:
len(session_id) || ":" || session_id || ":" || len(signing_key_id) || ":" || signing_key_id
where len(...) is the ASCII decimal byte-length. Without the length
prefix, the bare-concatenation form `session_id || signing_key_id`
would let a forger swap one byte across the boundary — `<a, bc>` and
`<ab, c>` produce identical HMAC inputs. The length prefix moves the
boundary into the input itself so the two cases can never collide.
The v1. version prefix is reserved. A future incompatible upgrade
ships as v2. and the parser rejects unknown prefixes (no fallback).
CSRF token model:
- Plaintext goes in a JS-readable certctl_csrf cookie (HttpOnly=false
intentional; the GUI must read it to echo into X-CSRF-Token header).
- SHA-256 hash of the plaintext lives on the session row.
- Validation: SHA-256(X-CSRF-Token) constant-time-compared.
- Rotated by Service.RotateCSRFToken on login / logout / role-change /
explicit admin-trigger.
Optional defense-in-depth (default OFF):
- CERTCTL_SESSION_BIND_IP — Validate compares client IP to row's
recorded IP. Mismatch -> 401, audit row, session NOT auto-revoked
(user may have legitimate IP change). Mobile + corporate-NAT
environments leave this off.
- CERTCTL_SESSION_BIND_USER_AGENT — same shape against UA.
Configurable lifetimes (env vars wired in internal/config/config.go):
CERTCTL_SESSION_IDLE_TIMEOUT 1h
CERTCTL_SESSION_ABSOLUTE_TIMEOUT 8h
CERTCTL_SESSION_SIGNING_KEY_RETENTION 24h
CERTCTL_SESSION_GC_INTERVAL 1h
CERTCTL_SESSION_SAMESITE Lax
CERTCTL_SESSION_BIND_IP false
CERTCTL_SESSION_BIND_USER_AGENT false
Test surface (internal/auth/session/service_test.go, ~860 LOC):
All 15 prompt-mandated negative cases:
1. Tampered cookie (HMAC byte flipped near segment start where all
6 bits are real — base64url-no-pad's last char carries only 2
bits so a tail-flip is unreliable).
1b. Tampered SESSION_ID segment (same HMAC-recompute outcome).
2. Cookie missing v1. prefix.
3. Cookie with unknown version prefix (v99).
4. Idle expiry — back-dated last_seen_at + idle_expires_at.
5. Absolute expiry — back-dated absolute_expires_at.
6. Revoked session.
7. Wrong signing key id (no row matches).
8. Cookie signed under retired-but-in-retention key SUCCEEDS.
9. Cookie signed under retired-past-retention key FAILS.
10. Concatenation collision — direct evidence that
computeHMAC("abc","de") != computeHMAC("ab","cde") AND that
a forged-boundary-slide cookie is rejected.
11. CSRF token missing.
12. CSRF token mismatch (constant-time compare).
13. IP-bind enabled + IP changed -> ErrSessionIPMismatch + audit row.
14. UA-bind enabled + UA changed -> ErrSessionUAMismatch + audit row.
15. EnsureInitialSigningKey RNG failure -> ErrInitialSigningKeyMintFailed
wrap (cmd/server/main.go treats as fatal).
Plus coverage-lift batch covering: every error wrap on every repo
collaborator (Create, Get, UpdateLastSeen, UpdateCSRFTokenHash,
Revoke, RevokeAllForActor, GC), every RNG-failure surface in Create /
RotateCSRFToken / RotateSigningKey, every alg-pinning helper edge,
the cookie parser's full negative matrix (empty, wrong segment count,
missing prefixes, bad base64, wrong HMAC length), and a real-encryption
round-trip via internal/crypto.EncryptIfKeySet -> DecryptIfKeySet so
the v3-blob path is exercised end-to-end at the session-cookie level.
Coverage:
internal/auth/session 94.5% (floor 90)
internal/auth/session/domain 96+% (floor 90, Phase 1)
.github/coverage-thresholds.yml extended with 2 new gate entries
(internal/auth/session and internal/auth/session/domain). The
why: paragraphs explain why each fail-closed branch is load-bearing.
Repository extensions:
internal/repository/session.go gains UpdateCSRFTokenHash on the
SessionRepository interface; internal/repository/postgres/session.go
ships the implementation. RotateCSRFToken consumes it.
Scheduler extensions:
internal/scheduler/scheduler.go gains SessionGarbageCollector
interface + sessionGC field + sessionGCInterval +
SetSessionGarbageCollector + SetSessionGCInterval + sessionGCLoop.
Pattern matches the existing acmeGCLoop: atomic.Bool guard prevents
concurrent sweeps, sync.WaitGroup tracks for graceful shutdown,
per-tick context.WithTimeout(1m) bounds a stuck Postgres.
Server wiring:
cmd/server/main.go constructs sessionService AFTER the bootstrap
block (post-RBAC backfill) and BEFORE the policy-service block.
EnsureInitialSigningKey runs immediately; failure is fatal via
os.Exit(1). The scheduler section wires SetSessionGarbageCollector
+ SetSessionGCInterval alongside the other interval setters and
emits an Info log so operators can confirm the loop is enabled.
Phase 4 deviation note: Service.GarbageCollect() returns (int, error)
rather than the prompt's literal `error`. The int is the count of
session rows deleted on this sweep; the scheduler discards it (`_, err
:= ...`) but tests + future operator-facing audit rows can read it.
The wider behavior matches the spec exactly.
Verifications: gofmt clean, go vet ./internal/auth/session/...
./internal/scheduler/... ./internal/config/... ./cmd/server/...
./internal/repository/... clean, go test -short -count=1 -race green
across all 3 session packages, full repository + auth + scheduler +
config test sweeps green, no regressions in Bundle 1 packages.
|
||
|
|
2d9110b0c4 |
auth-bundle-2 Phase 0: dependency-add + oidc auth-type literal + runtime guard
Bundle 2 Phase 0 stages the dependencies + auth-type discriminator
literal that later phases consume. No handler chain wired yet; an
operator who sets CERTCTL_AUTH_TYPE=oidc on this commit gets a clear
refuse-to-start error rather than a silent fallback to api-key (the
G-1 failure mode that drove "jwt" out of the allowed set).
Deliverables:
* go.mod: github.com/coreos/go-oidc/v3 v3.18.0 added as a direct
require. Per the pre-bundle dependency audit (Apache-2.0, zero CVEs
ever per OSV.dev, 2,400+ stars, used by Hashicorp Vault + Dex +
Hydra + Authentik + every Kubernetes OIDC integration), this is the
ecosystem-standard Go OIDC client. Pinned to a specific minor
(v3.18.0) per the prompt's "no bare latest" rule.
* go.mod: golang.org/x/oauth2 promoted from // indirect to direct,
bumped from v0.34.0 to v0.36.0 by go mod tidy. Both versions are
OSV-clean. Maintained by the Go team.
* No JSON-path library added (forbidden by the dependency audit; the
group-claim resolver is hand-rolled in Phase 3).
* internal/config/config.go: AuthTypeOIDC constant added with a
load-bearing comment explaining (a) this is the AUTH-TYPE literal,
not a JWT alg literal, so the G-1 closure invariant is preserved
("jwt" stays out of ValidAuthTypes forever); (b) the runtime guard
in cmd/server/main.go intentionally refuses-to-start when oidc is
set pre-Phase-6 to avoid the silent-downgrade failure mode.
ValidAuthTypes() now returns {api-key, none, oidc}.
* internal/config/config_test.go: TestValidAuthTypesIsExactly_APIKey_None
renamed to TestValidAuthTypesIsExactly_APIKey_None_OIDC and now pins
the 3-entry set. TestValidAuthTypesDoesNotContainJWT (G-1 closure
test) still passes because "jwt" is never added back.
TestValidate_GenericInvalidAuthType's bad-types list updated:
"oidc" removed (now valid), "saml" added (correctly rejected per
Decision 5's SAML deferral).
* cmd/server/main.go: defense-in-depth runtime auth-type guard now
has an explicit AuthTypeOIDC case that exit(1)s with an actionable
message: "the OIDC auth chain is not yet wired in this build (Auth
Bundle 2 Phase 6 ships the session middleware that consumes this
auth-type literal)." This closes the lying-field gap the literal
would otherwise create. Phase 6 of Bundle 2 relaxes this case to
fall through alongside api-key + none.
* api/openapi.yaml: /v1/auth/info auth_type enum extended from
[api-key, none] to [api-key, none, oidc] with an in-line comment
explaining the Phase-0-vs-Phase-6 timing so an OpenAPI consumer
isn't surprised by "oidc" appearing here pre-Bundle-2-merge.
* deploy/helm/certctl/templates/_helpers.tpl::certctl.validateAuthType:
valid set extended to include "oidc". Chart-time validation now
passes for type=oidc; the binary's runtime guard takes over to
refuse the start. Once Bundle 2 ships, the runtime guard relaxes
and OIDC works end-to-end with no further chart edits.
* .env.example: CERTCTL_AUTH_TYPE comment block updated to document
the three valid values + the Phase-0-vs-Phase-6 timing.
* internal/auth/oidc/doc.go: new package directory with package doc
+ transitional blank imports for coreos/go-oidc/v3 + x/oauth2 so
go mod tidy keeps both deps as direct requires until Phase 3's
service.go replaces the blanks with real symbol use. Doc explains
the package layout (oidc/ + oidc/domain/ + oidc/groupclaim/ +
oidc/testfixtures/) so the post-Bundle-2 reader can navigate.
Verifications:
* gofmt clean on every changed file.
* go vet clean on internal/config + cmd/server + internal/auth/oidc.
* go test -short -count=1 green on internal/config (including the
G-1 closure + new validation tests), cmd/server, internal/auth (all
Bundle 1 packages), internal/service/auth.
* govulncheck ./... clean (M-024 hard CI gate).
* All 24 ci-guards pass locally.
Phase 0 exit criteria from cowork/auth-bundle-2-prompt.md:
* go.mod shows coreos/go-oidc/v3 as direct: yes.
* golang.org/x/oauth2 is direct (not indirect): yes.
* govulncheck ./... clean: yes.
* No JSON-path library in go.mod / go.sum deltas: confirmed (only
v3 of go-oidc + the x/oauth2 bump landed).
* make verify green: gofmt + vet + go test pass; full make verify
(which would invoke golangci-lint) deferred to CI since the
sandbox doesn't have golangci-lint installed; the operator runs
make verify locally before pushing per CLAUDE.md operating rule.
|
||
|
|
3ef45e2ad4 |
auth-bundle-1 Phase 6-7-8: bootstrap path + scope-down CLI + auditor-role split
# Phase 6 — day-0 admin bootstrap * internal/auth/bootstrap/ (new package): Strategy interface + EnvTokenStrategy with constant-time compare, one-shot consumption via sync.Mutex, optional admin-existence probe. Bundle 2's OIDC- first-admin will plug in alongside as an alternate Strategy. * BootstrapService.ValidateAndMint: validates the operator's CERTCTL_BOOTSTRAP_TOKEN, mints a 32-byte (64-hex-char) random API key value, persists the SHA-256 hash to api_keys, grants r-admin via actor_roles, AddHashed's the runtime keystore so the just- minted key authenticates the next request without restart, and records bootstrap.consume to the audit trail with category=auth. * internal/auth/keystore.go (new): KeyStore interface + StaticKeyStore (immutable env-var-only path) + MutableKeyStore (env-var keys + DB-loaded api_keys + runtime AddHashed). The auth middleware now consumes a KeyStore so the bootstrap path can extend the lookup table at runtime. * migrations/000031_api_keys.up/down.sql: api_keys table with (id, name UNIQUE, key_hash UNIQUE, tenant_id, admin, created_by, created_at, expires_at, last_used_at). Idempotent. * /v1/auth/bootstrap GET (probe) + POST (mint) — auth-exempt. Both routes documented in api/openapi.yaml + AuthExemptRouterRoutes allowlist updated. The token never leaves internal/auth/bootstrap; the minted plaintext key flows only into the HTTP response body. * Startup warning emitted when CERTCTL_BOOTSTRAP_TOKEN is set AND admin actors already exist (config drift signal). * Tests: 4 strategy invariants (empty token born disabled, wrong token=ErrInvalidToken without consumption, one-shot consumption, admin-exists closes path), 5 service tests (happy path + actor- name validation + propagation of strategy errors + nil-deps guard + 32-byte entropy budget), 8 HTTP-handler tests (status 201/410/401/400 mapping + token-leak hygiene scan of slog + audit details + Location header). Token-leak test redirects slog.Default to a buffer for the test scope. # Phase 7 — API-key migration + scope-down CLI * GET /v1/auth/keys handler + service method ListKeys backed by ActorRoleRepository.ListDistinctActors. Returns one row per (actor_id, actor_type) pair with the slice of role IDs they hold. Permission: auth.role.list. * internal/cli/auth_scope_down.go: AuthListKeys, AuthScopeDown (interactive), AuthScopeDownNonInteractive (JSON config), AuthScopeDownSuggest (--suggest with optional --apply). The synthetic actor-demo-anon is filtered out of every interactive / bulk path; non-interactive flow logs and skips it explicitly. * SuggestRoleFromAuditEvents (pure function): walks 30 days of audit events per actor and returns the narrowest matching role (admin / mcp / viewer / agent / operator) plus a one-line reason. Classification: any admin-shaped action wins; otherwise all-MCP → mcp; all-read-only → viewer; all-agent-shaped → agent; otherwise operator. Test table pins all six classifications. * CLI subcommand tree extended: 'auth keys list' + 'auth keys scope-down [--non-interactive <cfg>] [--suggest [--apply]]'. * CHANGELOG.md leads v2.1.0 with the SECURITY: AUDIT YOUR API KEYS call-out + four flow examples. # Phase 8 — auditor role + event_category column * migrations/000032_audit_category.up/down.sql: ALTER TABLE audit_events ADD COLUMN event_category TEXT NOT NULL DEFAULT 'cert_lifecycle' + CHECK constraint (cert_lifecycle/auth/config) + (event_category) and (event_category, timestamp DESC) indexes for the auditor-filter query path. WORM trigger from migration 000018 continues to enforce append-only at the DB layer (DDL is not blocked). * domain.AuditEvent gains EventCategory string (omitempty); domain.EventCategoryCertLifecycle / Auth / Config constants. * AuditService.RecordEventWithCategory sibling of RecordEvent; legacy callers stay on RecordEvent (defaults to cert_lifecycle). Auth callers (RoleService, ActorRoleService, BootstrapService) switched to RecordEventWithCategory(..., 'auth', ...). * GET /v1/audit?category=<cat>: handler accepts the optional query param, validates against the enum (400 on invalid value), dispatches through ListAuditEventsByCategory. OpenAPI updated with the new query param + AuditEvent.event_category schema. * Postgres AuditRepository.Create now writes event_category; AuditRepository.List filters on it; AuditFilter.EventCategory gates the WHERE clause. * Tests: 5 audit-category-filter HTTP tests (dispatch routing, back-compat fallback, 400 for invalid values, all 3 enum values accepted, page+category combine, JSON output surfaces the field). 3 auditor-role invariants (auditor holds exactly audit.read+audit.export, no mutating perms, disjoint from viewer except audit.read). # Cross-phase wiring * HandlerRegistry.Bootstrap field added; cmd/server/main.go wires the bootstrap service ahead of RegisterHandlers (extracted assembleNamedAPIKeys helper into auth_backfill.go, moved the keystore + bootstrap construction up alongside the auth repos). * AuthCheckResolver / AuthActorRoleService extended with ListKeys to satisfy the Phase 7 surface; existing fakes updated. * fakeAudit + mockAuditService stubs in tests gain RecordEventWithCategory + ListAuditEventsByCategory; existing tests untouched. # Verifications * gofmt -l: clean across every modified file. * go vet ./...: clean. * staticcheck across internal/auth + handler + router + cli + service + repository + cmd + domain: clean. * go test -short -count=1: green across every Bundle-1-touched package — internal/auth (incl. bootstrap), internal/api/handler, internal/api/router, internal/cli, internal/service/auth, internal/service, internal/domain/auth, internal/repository/postgres, cmd/server, cmd/cli, plus internal/scheduler, internal/api/middleware, cmd/agent, internal/mcp. |
||
|
|
aebfd8bd7c |
Revert "chore: drop 'Infisical' label from internal references"
This reverts commit
|
||
|
|
19706e56b3 |
chore: drop 'Infisical' label from internal references
Strategic naming cleanup. Earlier doc-comments + commit messages framed Rank 4 / Rank 5 / Rank 7 work as 'Rank N of the 2026-05-03 Infisical deep-research deliverable' — the 'Infisical' qualifier was a holdover from the original deep-research framing where Infisical (a competing secrets-management platform) was the comparator. Keeping the comparator's name in our source adds noise without value; an external reader sees 'Infisical' and assumes a dependency or shared lineage rather than reading it as the competitive context it was. Mechanical sed across 34 files (32 source / docs + 2 follow-up Python passes to collapse 'deep-research deep-research' duplicates that emerged where the original phrase wrapped across lines): s|Infisical deep-research|deep-research|g s|infisical-deep-research-results|deep-research-results-2026-05-03|g s|infisical-deep-research-prompt|deep-research-prompt-2026-05-03|g s|infisical-deep-research|deep-research|g s|Infisical|deep-research|g s|deep-research deep-research|deep-research|g # collapse-pass Net diff: 63 insertions / 64 deletions across cmd/, docs/, internal/, migrations/. Pure text substitution; zero behavior change. Code path unchanged — go vet clean, tests for TestApproval pass on both internal/service and internal/api/handler packages. Workspace docs (cowork/) carry the same references and will be swept separately — they're not under certctl/ git control. The two filename references (cowork/infisical-deep-research-results.md + cowork/infisical-deep-research-prompt.md) get renamed alongside that sweep to deep-research-results-2026-05-03.md / deep-research-prompt-2026-05-03.md so cross-references in the certctl repo doc-comments resolve cleanly. |
||
|
|
03c61f4c20 |
scheduler, certificate, renewal: gate issuance on profile-driven approval
Closes Rank 7 of the 2026-05-03 Infisical deep-research deliverable
(cowork/infisical-deep-research-results.md Part 5). Pre-fix, certctl
issued certificates unattended — every renewal-loop tick that crossed
a renewal threshold created a Job at Status=Pending which the
scheduler dispatched directly to the issuer connector. PCI-DSS Level
1, FedRAMP Moderate / High, SOC 2 Type II, and HIPAA-regulated PHI
customers all ask the same procurement question: "How do you enforce
two-person integrity on cert issuance?" Today's answer: "We don't."
After this commit chain: "Per-profile RequiresApproval=true creates a
parallel ApprovalRequest row; the renewal-loop creates the Job at
Status=AwaitingApproval; an authorized approver (different from the
requester per the same-actor RBAC check) calls
POST /api/v1/approvals/{id}/approve, transitioning the Job to
Pending; the scheduler picks it up."
This commit (4 of 4) wires the gate into the manual TriggerRenewal
entry point + main.go service construction + Config.Approval +
docs + WORKSPACE-ROADMAP follow-up entries. The previous commits
in the chain shipped:
- 1 (
|
||
|
|
bee47f0318 |
acme-server: cert-manager integration test + production hardening (Phase 5/7)
Closes the production-readiness loop on the ACME surface. After this
commit, certctl ships per-account rate limits + a GC sweeper for
expired ACME state + a kind-driven cert-manager 1.15 integration test
+ a lego-driven RFC conformance harness + a k6 loadtest scenario for
the unauthenticated ACME path.
Architecture:
- Rate limits live in-memory + per-replica. Restart wipes the
counters; orders/hour caps are eventual-consistency anyway. A
3-replica certctl-server fleet behind an LB effectively has 3x
the configured throughput per account; persistent rate limiting
is a follow-up if production telemetry shows abuse patterns we
can't catch in a single restart cycle. Per-key + per-action
isolation: ActionNewOrder/acc-1, ActionKeyChange/acc-1, and
ActionChallengeRespond/<challenge-id> are independent buckets.
- GC loop follows the existing scheduler-loop pattern (atomic.Bool
+ sync.WaitGroup; see crlGenerationLoop for shape). Three
independent SQL sweeps per tick (DELETE expired nonces; UPDATE
pending authzs whose expires_at < now() to expired; UPDATE
pending/ready/processing orders whose expires_at < now() to
invalid). Each sweep is a single statement; failures are logged-
and-continued so a failing nonces sweep doesn't block authzs.
Per-sweep 1m timeout bounds a stuck Postgres.
- cert-manager integration test is gated on KIND_AVAILABLE so CI
skips it cleanly (kind is too heavy for per-PR). Operators run
locally via 'make acme-cert-manager-test'; the harness brings up
a fresh cluster each run + tears it down on Cleanup.
- lego conformance harness drives a real ACME client through
register → run → cert-PEM-landed against a hermetic certctl
stack. Catches RFC-shape regressions third-party clients would
hit before they ship.
- k6 ACME-flow scenario hammers the unauthenticated surface
(directory + new-nonce + ARI synthetic-id) at 100 VUs × 5m. JWS-
signed flows are out of scope for k6 (no JWS support); they're
covered by the lego harness above.
What ships:
- internal/api/acme/ratelimit.go (+ ratelimit_test.go: 7 cases —
disable-when-perHour-zero, capacity, per-key isolation, per-
action isolation, refill-over-time, RetryAfter, concurrent-access
with -race + 200 goroutines × 200 calls).
- internal/repository/postgres/acme.go: 4 new methods —
CountActiveOrdersByAccount + GCExpiredNonces + GCExpireAuthorizations
+ GCInvalidateExpiredOrders. Each a single SQL statement.
- internal/service/acme.go: SetRateLimiter + GarbageCollect +
rate-limit gates at 3 entry points (CreateOrder + RotateAccountKey
+ RespondToChallenge) + concurrent-orders gate at CreateOrder.
2 new sentinels (ErrACMERateLimited, ErrACMEConcurrentOrdersExceeded);
5 new GC metrics (gc_runs / gc_run_failures / gc_nonces_reaped /
gc_authzs_expired / gc_orders_invalidated).
- internal/scheduler/scheduler.go: ACMEGarbageCollector interface +
acmeGCRunning atomic.Bool + acmeGCInterval + 2 setters (SetACME-
GarbageCollector + SetACMEGCInterval) + acmeGCLoop following the
crlGenerationLoop shape.
- internal/api/handler/acme.go: writeServiceError gains rateLimited
(429 + RFC 8555 §6.7) + concurrent-orders-exceeded mappings.
- internal/config/config.go: 5 new env vars
(CERTCTL_ACME_SERVER_RATE_LIMIT_ORDERS_PER_HOUR=100,
CERTCTL_ACME_SERVER_RATE_LIMIT_CONCURRENT_ORDERS=5,
CERTCTL_ACME_SERVER_RATE_LIMIT_KEY_CHANGE_PER_HOUR=5,
CERTCTL_ACME_SERVER_RATE_LIMIT_CHALLENGE_RESPONDS_PER_HOUR=60,
CERTCTL_ACME_SERVER_GC_INTERVAL=1m).
- cmd/server/main.go: NewRateLimiter() + SetRateLimiter() at
startup; conditional SetACMEGarbageCollector(acmeService) +
SetACMEGCInterval(cfg.ACMEServer.GCInterval) when Enabled+
GCInterval > 0.
- deploy/test/acme-integration/: kind-config.yaml + cert-manager-
install.sh + clusterissuer-trust-authenticated.yaml +
clusterissuer-challenge.yaml + certificate-test.yaml + conformance-
lego.sh + certmanager_test.go (//go:build integration + KIND_AVAILABLE
gate).
- deploy/test/loadtest/k6/acme_flow.js + README ACME-flows section.
- Makefile: 2 new PHONY targets (acme-cert-manager-test +
acme-rfc-conformance-test).
- docs/acme-server.md: status flipped to Phase 5; Configuration
table grows 5 rows; new 'Phase 5 — operational guidance' section
explaining rate-limit math + GC sweeper semantics + cert-manager
integration + lego conformance + k6 baseline.
Tests:
- 'go vet ./...' clean across the repo.
- 'go test -short -count=1 ./internal/...' green across every
affected package (service / acme / handler / scheduler / repo /
config).
- 'go vet -tags=integration ./deploy/test/acme-integration/' clean
(the integration test compiles cleanly with the build tag).
- The kind/cert-manager harness is gated behind KIND_AVAILABLE so
CI skips by default; operators run locally via 'make acme-cert-
manager-test'.
Engineering history: cowork/WORKSPACE-CHANGELOG.md 'ACME-Server-5'.
|
||
|
|
4dc8d3fa5b |
acme-server: key rollover + revocation + ARI (Phase 4/7)
Closes the RFC 8555 + RFC 9773 surface beyond the issuance happy-path:
- POST /acme/profile/<id>/key-change (RFC 8555 §7.3.5)
- POST /acme/profile/<id>/revoke-cert (RFC 8555 §7.6)
- GET /acme/profile/<id>/renewal-info/<cert-id> (RFC 9773 ARI)
After this commit, ACME clients can rotate account keys, revoke certs
through the ACME surface (rather than only via the certctl GUI/API),
and fetch ARI for proactive renewal scheduling.
Architecture:
- Key rollover: outer JWS verified against the registered account key
(existing kid path); the inner JWS — embedded as the outer's payload
— verified against the embedded NEW jwk in a new dedicated routine
(ParseAndVerifyKeyChangeInner) that enforces RFC 8555 §7.3.5
inner-only invariants: MUST use jwk + MUST NOT use kid, payload
.account == outer.kid, payload.oldKey thumbprint-equals registered.
A single WithinTx swaps the stored thumbprint+pem and writes the
audit row. Concurrent-rollover safety via SELECT…FOR UPDATE on the
conflicting account row in UpdateAccountJWKWithTx; the loser
observes the winner's new thumbprint and is told to retry (409).
- Revocation: two auth paths. kid → AccountOwnsCertificate single-
indexed COUNT lookup over acme_orders. jwk → constant-time RFC 7638
thumbprint compare against the cert's pubkey. Both paths route
through service.RevocationSvc.RevokeCertificateWithActor so the
existing CRL/OCSP refresh + audit + metrics pipeline applies. RFC
5280 §5.3.1 numeric reason codes clamp to certctl's
domain.ValidRevocationReasons; codes 8 (removeFromCRL) + 10
(aACompromise) clamp to 'unspecified' since they aren't in the set.
- ARI is GET-only and unauth per RFC 9773 §4. Cert-id wire shape is
base64url(AKI).base64url(serial); ParseARICertID strict-decodes,
SerialHex emits the canonical certctl-shape lowercase-no-leading-
zeros hex used in certificate_versions.serial_number.
ComputeRenewalWindow has 3 branches: bound RenewalPolicy →
[notAfter - days, notAfter - days/2]; no policy → last 33% of
validity; past expiry → [now, now + 1d] (renew immediately).
Retry-After honors CERTCTL_ACME_SERVER_ARI_POLL_INTERVAL.
What ships:
- internal/api/acme/{keychange,ari}.go (+ phase4_test.go: 15 tests).
- internal/api/acme/order.go: RevokeCertRequest wire shape.
- internal/api/handler/acme.go: KeyChange, RevokeCert, RenewalInfo
+ 11 new writeServiceError mappings.
- internal/repository/postgres/acme.go: UpdateAccountJWKWithTx (FOR
UPDATE + expectedOldThumbprint precondition; ErrACMEAccountKey-
ConcurrentUpdate sentinel) + AccountOwnsCertificate.
- internal/service/acme.go: RotateAccountKey + RevokeCert +
RenewalInfo; CertificateRevoker + RenewalPolicyLookup interfaces;
SetRevocationDelegate + SetRenewalPolicyLookup wiring; 11 new
sentinels; 6 new metrics.
- internal/service/acme_phase4_test.go: service-layer tests for
RotateAccountKey (happy + duplicate-key) + RevokeCert (kid mismatch
+ jwk mismatch + jwk happy + already-revoked + reason-clamping) +
RenewalInfo (disabled + bad cert-id).
- internal/api/router/router.go: 6 new register calls (3 per-profile
+ 3 shorthand). Router parity exceptions extended in lockstep
(in-tree SpecParityExceptions + CI-only openapi-handler-exceptions
.yaml).
- cmd/server/main.go: SetRevocationDelegate(revocationSvc) +
SetRenewalPolicyLookup(renewalPolicyRepo) at startup.
- internal/config/config.go: CERTCTL_ACME_SERVER_ARI_ENABLED (default
true) + CERTCTL_ACME_SERVER_ARI_POLL_INTERVAL (default 6h);
BuildDirectory's ariEnabled flag now flips on under
cfg.ARIEnabled.
- docs/acme-server.md: phase status flipped to Phase 4; endpoints
table grows 6 rows (3 per-profile + 3 shorthand); FAQ section
appended explaining how to rotate keys, revoke certs, and consume
ARI.
Tests:
- 'go vet ./...' clean across the repo.
- 'go test -short -count=1 ./...' green across every package.
- phase4_test.go covers: keychange happy-path + 5 negatives +
MapKeyChangeErrorToProblem coverage; ARI cert-id round-trip + 6
malformed cases + BuildARICertID from a generated cert; window-
math 3 branches.
- service-layer tests confirm: RotateAccountKey atomically swaps the
thumbprint (verifies persisted state) and rejects duplicate keys;
RevokeCert routes through the stub RevocationSvc with the right
actor string + reason on the jwk path, rejects mismatched keys,
rejects already-revoked certs, clamps reason codes correctly;
RenewalInfo respects ARIEnabled + cert-id format.
Engineering history: cowork/WORKSPACE-CHANGELOG.md 'ACME-Server-4'.
|
||
|
|
ec88a61274 |
acme-server: foundation — directory + new-nonce + per-profile routing (Phase 1a/7)
First slice of the RFC 8555 ACME server endpoint (master plan at cowork/acme-server-endpoint-prompt.md, per-phase prompts at cowork/acme-server-prompts/). This commit lands the smallest viable end-to-end deployable slice: an ACME client running curl -sk https://certctl/acme/profile/<id>/directory curl -sk -I https://certctl/acme/profile/<id>/new-nonce successfully fetches the directory document and a Replay-Nonce. Account creation, JWS verification, orders, challenges, and revocation are all out of scope for this phase and arrive in Phases 1b–4. Closes the Rank 1 LHF from the 2026-05-03 Infisical deep-research (cowork/infisical-deep-research-results.md). Pre-fix, certctl was an ACME consumer only — no /acme/directory endpoint, no JWS verifier, no challenge validators. K8s customers running cert-manager could not point at certctl as an ACME issuer; they had to deploy a certctl agent on every node. What ships: - internal/api/acme/{directory,nonce,errors}.go (+ tests). - internal/api/handler/acme.go + acme_handler_test.go. - internal/repository/postgres/acme.go (nonce ops only — Phase 1b extends with account CRUD; Phases 2-4 extend with order / authz / challenge CRUD). - internal/service/acme.go (BuildDirectory + IssueNonce stubs; Phase 1b adds VerifyJWS / NewAccount / etc.). - migrations/000025_acme_server.{up,down}.sql ships the full 5-table ACME schema (acme_accounts / acme_orders / acme_authorizations / acme_challenges / acme_nonces) PLUS the per-profile certificate_profiles.acme_auth_mode column. Phase 1a actively uses only acme_nonces; remaining tables are empty until Phases 1b-4 plug in. - internal/config/config.go: ACMEServerConfig struct + ACMEServer field on Config. Env vars use CERTCTL_ACME_SERVER_* prefix to avoid colliding with the existing consumer-side ACMEConfig at config.go:1746 (CERTCTL_ACME_DIRECTORY_URL / PROFILE / CHALLENGE_TYPE etc.). Phase 1a wires Enabled + DefaultAuthMode + DefaultProfileID + NonceTTL + DirectoryMeta; Order/Authz TTLs + per-challenge-type concurrency caps + DNS01 resolver are reserved fields parsed in 1a so operators can set them ahead of Phases 2/3. - cmd/server/main.go: wire ACMEHandler into the HandlerRegistry literal alongside the existing certificate / EST / SCEP / etc. handlers. - internal/api/router/router.go: HandlerRegistry.ACME field + 6 Register calls (3 per-profile + 3 shorthand). - internal/api/router/openapi_parity_test.go: 6 new entries in SpecParityExceptions. ACME is a wire-protocol surface (JWS-signed JSON over HTTPS per RFC 7515) whose semantics are dictated by RFC 8555 + RFC 9773 rather than by an OpenAPI document, same precedent as SCEP/EST. The canonical reference is docs/acme-server.md. - docs/acme-server.md: Phase-1a-shaped reference. Configuration table for every CERTCTL_ACME_SERVER_* env var. Per-profile auth-mode decision tree skeleton. TLS trust bootstrap section flagging cert-manager's ClusterIssuer.spec.acme.caBundle requirement (the single biggest first-time-deploy footgun; the full cert-manager walkthrough lands in Phase 6 but the requirement is documented up front). Architecture decisions baked in: - URL family is /acme/profile/<id>/* (per-profile, canonical) with /acme/* shorthand active when CERTCTL_ACME_SERVER_DEFAULT_PROFILE_ID is set. Path matches existing per-profile precedent in EST + SCEP. - Auth mode is per-profile (acme_auth_mode column on certificate_profiles), NOT server-wide. One certctl-server can serve trust_authenticated for an internal-PKI profile and challenge for a public-trust-style profile simultaneously. The column is read at request time, not cached at server start — operators flipping a profile's mode via SQL take effect on the next order without restart. - Nonces are DB-backed (acme_nonces table). Survive server restart. The RFC 8555 §6.5 replay defense requires the store to outlast the client's nonce caching window; an in-memory-only nonce store would lose every in-flight order on restart. - Per-op atomic counters on service.ACMEService.Metrics() — certctl_acme_directory_total, certctl_acme_directory_failures_total, certctl_acme_new_nonce_total, certctl_acme_new_nonce_failures_total. Naming follows certctl frozen decision 0.10 cardinality discipline. Phase 1b will extend with new_account counters; Phase 2 with order / finalize / cert; Phase 3 with per-challenge-type counters. Audit fixes #11 + #12 (cowork/acme-server-prompts/audit-additions.md) applied: - #11: CERTCTL_ACME_SERVER_* prefix avoids the consumer-side CERTCTL_ACME_* namespace collision. - #12: prior-attempt WIP from two failed Phase-1 dispatches was discarded at phase start; this commit starts from a clean tree. Tests: - 14 unit tests in internal/api/acme/ (directory, nonce, errors). - 7 handler-level tests via httptest.NewServer + mockACMEService (mirrors the mockSCEPService pattern at scep_handler_test.go). - 7 service-layer tests with mocked repo + injected profileLookup. - All pass under -race -count=1 -short. Deferred to Phase 1b: - JWS verification (go-jose v4 — see master-prompt §8a for the API surface and audit doc for the speculation pitfalls). - new-account / account/<id> endpoints + AccountService. - Nonce *consumption* path (issue path is in this commit; consume is only invoked by JWS-verified POSTs which Phase 1b adds). Engineering history: cowork/WORKSPACE-CHANGELOG.md "ACME-Server-1a". Per-phase implementation plan: cowork/acme-server-prompts/. Master plan + audit fixes: cowork/acme-server-endpoint-prompt.md + cowork/acme-server-prompt-audit.md + cowork/acme-server-prompts/audit-additions.md. |
||
|
|
35e18bfc56 |
scheduler: bound renewal concurrency via CERTCTL_RENEWAL_CONCURRENCY
Closes the #9 acquisition-readiness blocker from the 2026-05-01 issuer coverage audit. Pre-fix, JobService.ProcessPendingJobs ran every claimed job sequentially in a single goroutine: safe but slow, and operators with large fleets had no lever to dial throughput up. Switching to fire-and-forget per-job goroutines would have unbounded the upstream-CA call rate and tripped DigiCert / Entrust / Sectigo rate limits — certctl's response to 429 was to retry on the next tick, re-fanning out the same calls and digging deeper into the limit. Operators need a knob. This commit: - Adds CERTCTL_RENEWAL_CONCURRENCY env var (default 25) loaded via the existing getEnvInt pattern in internal/config/config.go. Documented inline as the cap for the per-tick renewal/issuance/ deployment goroutine fan-out, with operator-tuning guidance: permissive upstream limits + large fleets (>10k certs) → 100; strict limits or async-CA-heavy fleets → 25 or lower. - Wires golang.org/x/sync/semaphore.Weighted around the per-job goroutine launch in JobService.ProcessPendingJobs. Acquire(ctx, 1) is the load-bearing piece — it BLOCKS the loop when at the cap, providing real backpressure rather than fire-and-forget. The fan-out is split into processPendingJobsSequential (legacy, preserved for unit-test wiring that doesn't call SetRenewalConcurrency) and processPendingJobsConcurrent (production, delegates to a generic boundedFanOut helper). - boundedFanOut takes the per-job work as a closure so the cap can be tested directly without standing up the renewal/deployment service graph. processed/failed counters use atomic.Int64 to avoid mutex overhead on every job completion; final log line reads both AFTER wg.Wait so the counts reflect every dispatched job. ctx-aware Acquire ensures a shutdown ctx cancel interrupts the dispatch loop promptly; in-flight goroutines drain via Wait before the function returns so no goroutine outlives the scheduler tick. - shouldSkipJob extracted as a package-private helper so the agent-routed-deployment skip logic is shared between the sequential and concurrent paths byte-for-byte (the audit prompt's "channel-based semaphore without ctx-aware acquire" anti-pattern is explicitly avoided — semaphore.Weighted.Acquire returns on ctx done; channel <- struct{}{} would block forever). - SetRenewalConcurrency setter on JobService normalises ≤0 to 1. semaphore.NewWeighted(0) constructs a semaphore that blocks every Acquire forever; the normalisation prevents a misconfigured env var from wedging the scheduler. - cmd/server/main.go wires SetRenewalConcurrency(cfg.Scheduler. RenewalConcurrency) on the freshly-built jobService, immediately after SetAuditService. Production deployments always take the bounded path; tests that build JobService directly via NewJobService keep their strict-sequential behaviour because renewalConcurrency is the zero value. - Tests in internal/service/job_concurrency_test.go: * TestBoundedFanOut_CapHolds — primary regression guard. 50 jobs × 50ms work × cap=5 → asserts peak in-flight never exceeds 5 AND reaches 5 at least once (catches both upper-bound regressions and gates that incorrectly cap below the configured value). Lock-free max via CompareAndSwap so the measurement instrument doesn't itself constrain concurrency. * TestBoundedFanOut_AllJobsRun — lower-bound: every non-skipped job is dispatched. * TestBoundedFanOut_SkipsAgentRoutedDeployments — pins the shouldSkipJob contract. * TestBoundedFanOut_CtxCancelInterrupts — ctx cancellation interrupts a stuck fan-out within the timeout budget. * TestBoundedFanOut_FailedJobsCounted — per-job errors don't abort the fan-out. * TestSetRenewalConcurrency_NormalizesNonPositive — ≤0 → 1 fail-safe pinned across negative/zero/positive inputs. - docs/features.md: scheduler-loop table augmented with the concurrency-cap env-var pointer alongside the job-processor row. - docs/architecture.md: Concurrency Safety section gains a paragraph explaining the cap, the operator-tuning guidance, the ctx-aware Acquire semantics, and the audit reference. Operator-facing impact: the first big renewal sweep no longer takes down the upstream CA's rate-limit budget. Existing deployments get the bounded path automatically (default 25); operators can override via env var without code changes. Verified locally: - gofmt -l . clean - go vet ./... clean - staticcheck ./... clean - go test -short -count=1 across service / scheduler / config / integration: green - Six new tests under TestBoundedFanOut* + TestSetRenewalConcurrency*: green Audit reference: cowork/issuer-coverage-audit-2026-05-01/RESULTS.md Top-10 fix #9. |
||
|
|
0509790325 |
asyncpoll: refactor Sectigo / Entrust / GlobalSign to bounded polling (Phase 2)
Phase 2 of the #5 acquisition-readiness fix from the 2026-05-01 issuer
coverage audit. Phase 1 (commit
|
||
|
|
7cb453a336 |
chore(fmt): repo-wide gofmt -w sweep — close drift surfaced by ci-pipeline-cleanup Phase 4
Mechanical reformat. The new 'gofmt drift' CI step (added in
ci-pipeline-cleanup Phase 4, commit
|
||
|
|
77abb7096c |
fix(config): wire CERTCTL_DEPLOY_BACKUP_RETENTION + CERTCTL_K8S_DEPLOY_KUBELET_SYNC_TIMEOUT to satisfy G-3 docs-drift guard
CI failed on the G-3 docs-drift guard for the deploy-hardening I
release commit (88e8a417 /
|
||
|
|
ed19312df6 |
feat(ratelimit): per-endpoint rate limit on OCSP + cert-export (Phase 3)
Production hardening II Phase 3 — wire the existing
internal/ratelimit/SlidingWindowLimiter into the OCSP and cert-export
handlers. Removes the DoS vector where an unauthenticated relying
party (or compromised admin token) can hammer the responder /
key-export endpoint at unbounded rates.
OCSP: per-source-IP cap. Default 1000 req/min/IP, 50k tracked IPs
(matches the SCEP/Intune replay cache cap). Configurable via
CERTCTL_OCSP_RATE_LIMIT_PER_IP_MIN; zero disables. Source IP comes
from net.SplitHostPort(r.RemoteAddr) — we deliberately do NOT honor
X-Forwarded-For because OCSP is publicly reachable and untrusted
intermediaries could spoof the header to bypass the limit.
On rate-limit trip: respond with the canonical
ocsp.UnauthorizedErrorResponse pre-built blob from x/crypto/ocsp
(status 6 per RFC 6960 §2.3) plus Retry-After: 60. Using the
unauthorized status (instead of TryLater) avoids hand-rolling DER
for a single rejection path; relying parties retry on any non-good
status anyway.
Cert-export: per-actor cap. Default 50 exports/hr/operator.
Configurable via CERTCTL_CERT_EXPORT_RATE_LIMIT_PER_ACTOR_HR; zero
disables. Actor extracted from the X-Actor request header (set by
the auth middleware); falls back to RemoteAddr if empty (defensive).
On rate-limit trip: HTTP 429 + JSON body
{"error":"rate_limit_exceeded","retry_after_seconds":3600} +
Retry-After: 3600.
NEW config fields in internal/config/config.go::SchedulerConfig:
OCSPRateLimitPerIPMin (default 1000)
CertExportRateLimitPerActorHr (default 50)
WIRED in cmd/server/main.go: ocspLimiter constructed with the
configured cap, 1m window, 50k map cap; exportLimiter same shape with
1h window. Both wired via SetOCSPRateLimiter / SetExportRateLimiter
on their respective handlers. Existing deploys see no behavior
change unless the env vars are set to non-default values + traffic
exceeds the cap.
Pre-commit verification: go build ./... clean; go test -short
-count=1 green for handler + service + config.
|
||
|
|
a808948397 |
feat(est): per-profile dispatch — multi-profile env-var family + back-compat shim
EST RFC 7030 hardening master bundle Phases 0 + 1 of 13. Lays the
foundation for the remaining hardening phases (mTLS auth, HTTP Basic
auth, channel binding, server-keygen, admin observability, GUI, libest
e2e) without changing existing operator behavior — backward-compat
shim preserves the v2.0.66 single-issuer flat env-var setup.
WHAT LANDS:
Phase 0 — Frozen decisions
9 frozen decisions documented in
cowork/est-rfc7030-hardening-prompt.md::Phase 0 frozen decisions
(auth modes mTLS+Basic at GA; RFC 9266 channel binding; multi-profile
env-var family CERTCTL_EST_PROFILES; mTLS sibling URL
/.well-known/est-mtls/<pathID>; serverkeygen ships V2; fullcmc
deferred; renewal device-driven per RFC 7030 §4.2.2; csrattrs
algorithm allow-list profile-derived; libest as e2e reference).
Phase 1 — Multi-profile config + per-profile dispatch
internal/config/config.go: extended ESTConfig with Profiles slice;
added ESTProfileConfig struct with all field contracts (PathID +
IssuerID + ProfileID + EnrollmentPassword + MTLSEnabled +
MTLSClientCATrustBundlePath + ChannelBindingRequired +
AllowedAuthModes + RateLimitPerPrincipal24h + ServerKeygenEnabled).
Forward-looking fields (mTLS, HTTP Basic, channel binding,
rate limit, server-keygen) are dormant in Phase 1 — Phase 2-5 wire
the corresponding handlers; Validate() gates ensure operators can't
set incoherent combinations (MTLSEnabled=true without bundle path,
basic auth without password, mtls auth mode without MTLSEnabled,
ChannelBindingRequired without mTLS, ServerKeygenEnabled without
ProfileID).
loadESTProfilesFromEnv: mirrors loadSCEPProfilesFromEnv exactly.
Reads CERTCTL_EST_PROFILES=corp,iot,wifi and per-profile env vars
CERTCTL_EST_PROFILE_<NAME>_*. Lowercase PathID, uppercase env-var
name. parseAuthModes handles comma-separated normalization.
mergeESTLegacyIntoProfiles: back-compat shim. When CERTCTL_EST_PROFILES
is unset AND CERTCTL_EST_ENABLED=true, synthesizes a single-element
Profiles[0] with PathID="" so existing /.well-known/est/
operators see no behavior change.
validESTPathID + validESTAuthMode: shape validators. PathID matches
[a-z0-9-]+ with no leading/trailing hyphen (mirrors validSCEPPathID
exactly). Auth mode is one of {mtls, basic}.
Per-profile Validate(): refuses every documented misconfiguration
with operator-greppable error messages naming the offending profile
index + PathID + field. Mirrors the SCEP audit-closure pattern.
internal/api/router/router.go: refactored RegisterESTHandlers from
single-handler to map[string]ESTHandler. Empty PathID maps to legacy
/.well-known/est/ root (literal-string r.Register calls preserve
openapi-parity scanner behavior). Non-empty PathIDs dynamic-register
/.well-known/est/<pathID>/{cacerts,simpleenroll,simplereenroll,csrattrs}.
Mirrors the SCEP per-profile dispatch from commit
|
||
|
|
530593507b |
fix(scep-intune): close 11 audit gaps from 2026-04-29 pre-tag review
Closes the eleven gaps identified in the pre-v2.1.0 audit of the SCEP
RFC 8894 + Intune master bundle (cowork/scep-bundle-gap-closure-prompt.md).
Constitutional rule from cowork/CLAUDE.md::Operating Rules — 'Always
take the complete path, not the easy path' — drove this closure: each
gap was a load-bearing wire that crossed multiple layers (config →
validator → service wire-up → tests → docs) and shipping the bundle
without them would have produced lying-field footguns where operator-
visible config options stored values without affecting behavior.
WHAT LANDS:
Phase A — Clock-skew tolerance (master prompt §15 hazard closure)
internal/scep/intune/challenge.go: ValidateChallenge migrated from
positional args to ValidateOptions{} struct; new ClockSkewTolerance
field with default 0 (strict). 24 call sites updated mechanically.
Asymmetric application: now+tolerance >= iat AND now-tolerance < exp.
internal/config/config.go: SCEPIntuneProfileConfig.ClockSkewTolerance
default 60s + Validate() refusal when >= ChallengeValidity.
cmd/server/main.go: SetIntuneIntegration signature extended;
per-profile env-var loader honors CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_CLOCK_SKEW_TOLERANCE.
internal/service/scep.go: intuneClockSkew field + IntuneStatsSnapshot
surfaces clock_skew_tolerance_ns. web/src/api/types.ts mirrors.
4 new tests in challenge_test.go covering accept-within-tolerance,
reject-beyond-tolerance, accept-expired-within-tolerance,
negative-treated-as-zero defensive normalization.
docs/scep-intune.md updated with the new env var + time-bounds rule.
Phase B — unknown-version-rejected golden test
internal/scep/intune/golden_helper_test.go: goldenUnknownVersionPayload
helper + signGoldenChallengeAny generic signer.
challenge_golden_test.go: TestGoldenChallenge_UnknownVersionRejected
uses an in-process ECDSA fixture (the on-disk PEM was generated with
a Go-stdlib version that produces different ecdsa.GenerateKey bytes
from the current call). TestRegenerateGoldenFixtures emits the new
unknown_version fixture file too.
Phase C — Two named Intune e2e tests
internal/api/handler/scep_intune_e2e_test.go:
TestSCEPIntuneEnrollment_RateLimited_E2E (cap=2 + 3 attempts; 3rd
returns FAILURE+badRequest with rate_limited counter ticked)
TestSCEPIntuneEnrollment_TrustAnchorSIGHUPReload_E2E (rotate
on-disk PEM + holder.Reload(); old-key challenge fails with
badMessageCheck; signature_invalid counter ticked)
intuneE2EFixture struct extended with trustHolder + trustPath fields
so tests can rotate.
Phase D — Four new ChromeOS hermetic tests (10 total now)
internal/api/handler/scep_chromeos_test.go:
_RAKeyMismatch — PKIMessage encrypted to wrong RA cert; handler
rejects without reaching service.
_3DESBackwardCompat — RFC 8894 §3.5.2 legacy fallback verified.
_RSACSR + _ECDSACSR — explicit matrix-pair pinning.
buildTestECDSACSR helper for ECDSA P-256 CSR construction;
tripleDESCBCEncrypt mirrors aesCBCEncrypt for 3DES-CBC;
assertChromeOSPositiveCertRep shared assertion.
Phase E — Per-profile counter isolation test
internal/api/handler/scep_profile_counter_isolation_test.go:
TestSCEPHandler_PerProfileIntuneCountersIsolated wires two
SCEPService instances + drives distinct PKIMessages + asserts
counter isolation. Guards against a future cmd/server/main.go
refactor that shares a *intuneCounterTab across profiles.
buildPerProfileIntuneFixture parameterized helper.
Phase F — Server-boot regression tests
cmd/server/preflight_scep_intune_test.go: 3 named tests covering
disabled-backward-compat, broken-config-with-PathID, expired-cert
refusal. preflightSCEPIntuneTrustAnchor signature extended with
pathID arg so error messages carry PathID= for operator log-grep.
Phase G — docs/connectors.md
Four new subsections under §EST/SCEP Integration: multi-profile
dispatch + mTLS sibling route + Intune Connector dispatcher + SCEP
probe in network scanner. Each has a one-paragraph operator
explanation + an env-var or endpoint table.
Phase H — Coverage uplift
internal/service/scep_probe_persist_test.go: 5 unit tests on
persistProbeResult (nil-safe + nil-repo-safe + repo-error swallow +
nil-logger guard) + ListRecentSCEPProbes (empty-slice-not-nil + repo
pass-through) + describeCertAlgorithm (RSA/ECDSA/QF1008-nil-curve
defensive branch/Ed25519/DSA/empty). CI gates (service ≥70, handler
≥75) PASS at 70.9% / 79.3%.
Phase I — deploy/test integration variant
deploy/test/scep_intune_e2e_test.go (//go:build integration):
TestSCEPIntuneEnrollment_Integration + _RateLimited_Integration
against the live docker-compose certctl container. Skip-when-
stack-missing semantics so sandbox + CI both work.
deploy/docker-compose.test.yml: new e2eintune SCEP profile env
vars + bind-mount of deploy/test/fixtures/.
deploy/test/fixtures/README.md: documents the deterministic trust
anchor regeneration recipe.
VERIFICATION (sandbox):
gofmt -d — clean for all changed files
staticcheck — clean for intune + handler + config + service +
cmd/server packages
go vet — clean for the same packages
go test -short — green for intune (95.3% cov), service (70.9%),
handler (79.3%), config (94.0%), cmd/server (boot
path; my preflight tests cover the directly-
testable function), pkcs7 (80.5% informational)
DEFERRED (per closure prompt §7 out-of-scope):
- V3-Pro Conditional Access gating + Microsoft Graph integration
- Standalone certctl-scan CLI binary
- OCSP rate-limiting, OCSP stapling, delta CRLs
Spec preserved at cowork/scep-bundle-gap-closure-prompt.md;
journal at cowork/scep-rfc8894-intune/progress.md (audit-closure
section appended).
|
||
|
|
7612da783a |
feat(scep-intune): per-profile dispatcher + SIGHUP reload + per-device rate limit + compliance hook seam
Phase 8 of the SCEP RFC 8894 + Intune master bundle. Wires the internal/scep/intune validator from Phase 7 into the SCEPService dispatch path, with a SIGHUP-reloadable trust anchor holder, a per-(Subject, Issuer) sliding-window rate limiter, and a nil-default ComplianceCheck seam for V3-Pro. Operator-visible surface (per-profile, all default to off): CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_ENABLED=true CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_CONNECTOR_CERT_PATH=/etc/certctl/intune.pem CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_AUDIENCE=https://certctl.example.com/scep/corp CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_CHALLENGE_VALIDITY=60m CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_PER_DEVICE_RATE_LIMIT_24H=3 Per-profile dispatch (Phase 8.8): an operator running corp-laptops through Intune AND IoT devices through static challenge configures INTUNE_ENABLED=true on the corp profile only — the IoT profile's PKCSReq path skips the dispatcher entirely. Mirrors the per-profile shape established by Phase 1.5. Wire-in surfaces: * config.go (Phase 8.1): SCEPProfileConfig.Intune sub-config of type SCEPIntuneProfileConfig (Enabled/ConnectorCertPath/Audience/ ChallengeValidity/PerDeviceRateLimit24h). Loaded from the indexed CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_* env-var family. Per-profile Validate gate refuses INTUNE_ENABLED=true with empty ConnectorCertPath OR negative PerDeviceRateLimit24h. * cmd/server/main.go (Phase 8.2 + wire-in): preflightSCEPIntuneTrustAnchor helper mirrors preflightSCEPRACertKey/preflightSCEPMTLSTrustBundle shape — fail-loud at boot when the trust anchor file is missing / unreadable / empty / contains an expired cert. The per-profile loop builds the holder + replay cache + rate limiter, calls SetIntuneIntegration on the SCEPService, and starts the SIGHUP watcher. A deferred sweep stops every watcher at shutdown. * internal/scep/intune/trust_anchor_holder.go (Phase 8.5): TrustAnchorHolder mirrors cmd/server/tls.go::certHolder. RWMutex- guarded pool + Reload that swaps a fresh slice on success + WatchSIGHUP goroutine that responds to the same SIGHUP the existing TLS-cert watcher uses. A bad reload (parse error, expired cert) keeps the OLD pool in place so a half-rotation doesn't take Intune enrollment down — same fail-safe pattern. Operators rotate via the on-disk file then 'kill -HUP <certctl-pid>'. * internal/scep/intune/rate_limit.go (Phase 8.6): hand-rolled sliding-window-log limiter keyed by (Subject, Issuer). 100k-entry map cap (matches replay cache); at-cap drops the bucket whose newest timestamp is the oldest. Default 3 enrollments per 24h covers legitimate first-cert + recovery + post-wipe re-enrollment but blocks bulk enumeration from a compromised Connector signing key. maxN <= 0 disables the limiter for tests + the rare operator who wants no per-device cap. Empty subject short-circuits to allow (defense-in-depth: caller's claim validation rejects empty-subject upstream; no shared bucket on ''). Why hand-rolled instead of golang.org/x/time/rate: the rate package is in go.sum as an indirect transitive but not a direct dep. ~30 LoC of stdlib avoids creating a new direct dep. * internal/service/scep.go (Phase 8.3 + 8.4 + 8.7): - SCEPService gains intuneEnabled / intuneTrust / intuneAudience / intuneValidity / intuneReplayCache / intuneRateLimiter / complianceCheck fields. - SetIntuneIntegration() constructor-time injection wires the per-profile state. Profiles with INTUNE_ENABLED=false never call this method, so they pay zero overhead. - SetComplianceCheck() installs the V3-Pro plug-in (see Phase 8.7). - looksIntuneShaped(): JWT-shape pre-check (length > 200 + exactly two dots). Allowed to false-positive (validator catches malformed → ErrChallengeMalformed); MUST NOT false-negative on real Intune challenges. - dispatchIntuneChallenge(): the load-bearing core. Runs ValidateChallenge → CSR-binding via DeviceMatchesCSR → replay cache CheckAndInsert → per-device Allow → optional ComplianceCheck. Each failure leg increments a typed metric label and emits an audit-friendly Warn log line. - PKCSReq + PKCSReqWithEnvelope + RenewalReqWithEnvelope all call dispatchIntuneChallenge first; on outcome.decided=true they either short-circuit (with a typed-error → SCEPFailInfo mapping) or call processEnrollment with action='scep_pkcsreq_intune' (so audit greps can count Intune-vs-static enrollments). - mapIntuneErrorToFailInfo(): typed-error → SCEPFailInfo per RFC 8894 §3.2.1.4.5 (signature/replay/expired → BadMessageCheck; claim-mismatch → BadRequest; default → BadRequest). - intuneFailReason(): typed-error → metric label ('signature_invalid' / 'expired' / 'rate_limited' / etc.). Default 'malformed' so a previously-unseen error category still surfaces in the metric for follow-up. - ComplianceCheck (Phase 8.7): nil-default no-op gate. V3-Pro plugs in via SetComplianceCheck to call Microsoft Graph's compliance API. Returns (compliant, reason, err). nil-err + compliant=false → CertRep FAILURE + 'compliance' reason in audit. err != nil → fail-safe deny (V3-Pro module is responsible for any 'permit on API failure' policy). * internal/service/scep.go also gains parseCSRForIntune() — small private wrapper around encoding/pem + x509 used by the dispatcher for the claim ↔ CSR binding check (separated from the broader processEnrollment because we want to bind BEFORE consuming the replay-cache slot). Tests (gates: ≥85% coverage on intune package, ≥70% on service): * scep_intune_test.go (in internal/service): 14 dispatcher tests covering happy-path Intune enrollment + static-challenge fallback + tampered-challenge reject + claim-mismatch reject + replay detected + rate-limited + compliance-hook nil-default + compliance- hook denies non-compliant + compliance-hook error fails closed + IntuneEnabled accessor + 'no IntuneEnabled = static path unchanged' regression pin + intuneFailReason mapping for every typed error + looksIntuneShaped boundary cases. * trust_anchor_holder_test.go (in internal/scep/intune): NewLoadsBundle, NewRequiresLogger, NewSurfacesLoadError, ReloadHappyPath, ReloadKeepsOldOnFailure, ReloadKeepsOldOnExpired (the fail-safe semantics that make the SIGHUP path operator-friendly), WatchSIGHUPReloadsPool (real SIGHUP to self with poll-for-swap pattern mirroring cmd/server/tls_test.go), WatchSIGHUPStopIsClean (does NOT fire SIGHUP after stop — same caveat as the TLS test: the Go runtime would otherwise terminate the test runner on the next SIGHUP since signal.Stop has removed the handler). * rate_limit_test.go (in internal/scep/intune): AllowsUpToCap, DistinctKeysIndependent, WindowExpiry, DisabledBypass (maxN=0), NegativeCapDisabled, EmptySubjectShortCircuits (defense-in-depth against an empty-subject DoS chokepoint), DefaultCapsHonored, MapCapEvictsOldest (at-cap eviction branch), ConcurrentRaceFree (50 goroutines × 200 inserts), pruneOlderThan + the no-op case. Verification: * gofmt -l on all touched files: clean * go vet ./... : clean * staticcheck on intune/service/config/cmd-server: clean * go test -count=1 -cover ./internal/scep/intune/...: 94.8% (target ≥85%) * go test -short across intune+service+config+handler+cmd-server: all green * G-3 docs-drift CI guard reproduced locally: docs-only filtered= empty, config-only=empty. The new env vars match the existing CERTCTL_SCEP_ allowlist prefix. Refs: cowork/scep-rfc8894-intune-master-prompt.md::Phase 8 cowork/scep-rfc8894-intune/progress.md Constitutional rule: 'Always take the complete path, not the easy path' (cowork/CLAUDE.md::Operating Rules) — operator can flip CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_ENABLED=true and observe the dispatcher pick up Intune-shaped challenges end-to-end with no further code changes. Foundation + plumbing ship together. |
||
|
|
a12a437664 |
feat(scep): mTLS sibling route /scep-mtls/<pathID> (opt-in)
SCEP RFC 8894 + Intune master bundle — Phase 6.5 of 14 (opt-in,
enterprise-procurement-checkbox).
Closes the procurement-team objection that 'shared password
authentication' is a checkbox-fail regardless of how strong the
password is. The clean answer: a sibling route that adds client-cert
auth at the handler layer AND keeps the challenge password (defense in
depth, not replacement). Devices present a bootstrap cert from a
trusted CA (e.g. a manufacturing-time cert), then SCEP-enroll for
their long-lived cert. Same model Apple's MDM and Cisco's BRSKI use.
internal/config/config.go
* SCEPProfileConfig gains MTLSEnabled bool + MTLSClientCATrustBundlePath
string. Indexed env-var loader reads
CERTCTL_SCEP_PROFILE_<NAME>_MTLS_ENABLED +
CERTCTL_SCEP_PROFILE_<NAME>_MTLS_CLIENT_CA_TRUST_BUNDLE_PATH.
* Validate() refuses MTLSEnabled=true with empty bundle path —
structural defense in depth ahead of the file-content preflight.
cmd/server/main.go
* preflightSCEPMTLSTrustBundle: file existence + PEM parse + ≥1
CERTIFICATE block + non-expired check. Returns the parsed
*x509.CertPool ready to inject into the per-profile SCEPHandler.
Failures os.Exit(1) with the offending PathID in the structured log.
* SCEP startup loop walks each profile; when MTLSEnabled, runs
preflight, builds the per-profile pool, contributes the bundle's
certs to the union pool that backs the TLS-layer
VerifyClientCertIfGiven, clones the SCEPHandler with
SetMTLSTrustPool, and registers the parallel sibling route via
apiRouter.RegisterSCEPMTLSHandlers.
* Union pool published to outer scope as scepMTLSUnionPoolForTLS;
passed to buildServerTLSConfigWithMTLS so the listener serves both
/scep[/<pathID>] (no client cert) and /scep-mtls/<pathID>
(cert required at handler layer) on the same socket.
* Final-handler dispatch gains /scep-mtls + /scep-mtls/* prefix
routing through the no-auth chain (auth boundary is the client
cert + challenge password, NOT a Bearer token).
cmd/server/tls.go
* New buildServerTLSConfigWithMTLS that wraps buildServerTLSConfig
+ sets ClientCAs + ClientAuth=VerifyClientCertIfGiven when a
non-nil pool is passed. nil pool = identical TLS shape to the
pre-Phase-6.5 builder (no behavior change for deploys without
mTLS profiles).
* Critical: VerifyClientCertIfGiven (NOT RequireAndVerifyClientCert)
so a client that doesn't present a cert can still hit the standard
/scep route. The per-profile gate at the handler layer enforces
'cert required' on /scep-mtls/<pathID>.
internal/api/handler/scep.go
* SCEPHandler gains mtlsTrustPool *x509.CertPool field +
SetMTLSTrustPool method. Per-profile pool injected by
cmd/server/main.go after preflight.
* HandleSCEPMTLS wrapper: gates on r.TLS.PeerCertificates non-empty
+ per-profile cert.Verify against THIS profile's pool. Returns
HTTP 401 for missing/untrusted cert (mTLS failure is auth, not
authorization). Returns HTTP 500 if mtlsTrustPool is nil (deploy
bug — the route shouldn't have been registered). On success
delegates to HandleSCEP — defense in depth: mTLS is additive,
NOT replacement; the standard SCEP code path including the
challenge-password gate still executes.
* Per-profile re-verification via cert.Verify(...) is critical:
the TLS layer verified against the UNION pool, so a cert that
chains to profile A's bundle would pass TLS even when targeting
profile B. The handler-layer gate prevents cross-profile
bleed-through.
internal/api/router/router.go
* AuthExemptDispatchPrefixes gains '/scep-mtls' (auth boundary is
client cert + challenge password, NOT Bearer token).
* RegisterSCEPMTLSHandlers parallel to RegisterSCEPHandlers:
empty PathID maps to /scep-mtls root; non-empty maps to
/scep-mtls/<pathID>. Each handler in the map MUST have had
SetMTLSTrustPool called.
internal/api/router/openapi_parity_test.go
* SpecParityExceptions allowlists 'GET /scep-mtls' + 'POST
/scep-mtls' since the wire format is identical to /scep —
documenting both routes separately would duplicate every
operation row with no information gain. Documented alternative
in docs/legacy-est-scep.md.
internal/api/handler/scep_mtls_test.go (new, ~210 LoC)
* 6 tests + 2 helpers covering the auth contract:
1. RejectsMissingClientCert — request with r.TLS=nil → 401
2. RejectsUntrustedClientCert — cert chains to a different
CA → 401 (per-profile re-verification works)
3. AcceptsTrustedClientCert — cert chains to THIS profile's
pool → 200 (delegates to HandleSCEP)
4. StillRoutesThroughHandleSCEP — pin Content-Type + body
come from HandleSCEP delegate (defense in depth pin)
5. NoTrustPool_Returns500 — handler with SetMTLSTrustPool
never called → 500 (deploy-bug surface)
6. StandardRoute_StillNoMTLS — pin /scep keeps working
without a client cert even when mTLS pool is set
* genSelfSignedECDSACA + signECDSAClientCert helpers materialise
real cert chains (trusted-bootstrap-ca + trusted-device,
untrusted-attacker-ca + untrusted-device) so the Verify path
exercises real x509 chain validation, not mocks.
docs/features.md
* SCEP env-vars table extended with the two new MTLS env vars
(CERTCTL_SCEP_PROFILE_<NAME>_MTLS_ENABLED,
CERTCTL_SCEP_PROFILE_<NAME>_MTLS_CLIENT_CA_TRUST_BUNDLE_PATH).
Closes the G-3 'env var defined in Go but never documented' gate.
docs/legacy-est-scep.md
* New 'mTLS sibling route (Phase 6.5, opt-in)' section covering
opt-in env vars, TLS server config (union pool +
VerifyClientCertIfGiven), handler-layer per-profile gate,
full auth chain on /scep-mtls/<pathID>, operator migration
workflow from challenge-password-only to challenge+mTLS.
cowork/CLAUDE.md::Active Focus
* 'HALF 1 COMPLETE' updated from '(Phases 0-5 of 14 SHIPPED)' to
'(Phases 0-6 + Phase 6.5 of 14 SHIPPED)'.
Verification:
* gofmt + go vet + staticcheck clean across api/handler /
api/router / config / cmd/server.
* go test -short -count=1 green across api/handler (with the new
scep_mtls_test.go) / api/router / service / config / pkcs7 /
cmd/server / connector/issuer/local.
* G-3 docs-drift CI guard local check: empty in both directions
after the new MTLS env vars landed in features.md.
* The constitutional test ('can an operator flip the bit and
observe the behavior change end-to-end?') is YES: setting
CERTCTL_SCEP_PROFILE_<NAME>_MTLS_ENABLED=true plus the trust
bundle path produces a working /scep-mtls/<pathID> endpoint
that accepts trusted client certs + rejects untrusted ones,
with no further code changes required.
Phase 6.5 of 14 in SCEP RFC 8894 + Intune master bundle.
Half 1 (Phases 0-6 + 6.5) is now FEATURE-COMPLETE for the
ChromeOS / general-MDM use case. Half 2 (Phases 7-12) adds the
Microsoft Intune dynamic-challenge layer.
|
||
|
|
fdd424bf5f |
feat(scep): per-issuer SCEP profiles — multi-endpoint dispatch
SCEP RFC 8894 + Intune master bundle — Phase 1.5 of 14.
Restructures SCEPConfig from a single flat struct (one IssuerID + one
RA pair + one challenge password) to a Profiles slice where each
profile binds its own URL path (/scep/<pathID>), issuer, optional
CertificateProfile, RA cert+key, and challenge password.
This phase is the FOUNDATION for Phases 2-12: every downstream handler
signature, service envelope, CertRep builder, GUI counter, and test
fixture takes a profile_id parameter from here on. Adding multi-profile
support post-bundle would cost 3x what greenfielding it now does.
Backward compat: legacy CERTCTL_SCEP_* flat env vars synthesise a
single-element Profiles[0] with PathID="" (legacy /scep root) when
CERTCTL_SCEP_PROFILES is unset. Existing operators see no behavior
change. New operators write multi-profile config directly via the
indexed env-var form.
Indexed env-var convention:
CERTCTL_SCEP_PROFILES=corp,iot,server
CERTCTL_SCEP_PROFILE_CORP_ISSUER_ID=iss-corp-laptop
CERTCTL_SCEP_PROFILE_CORP_PROFILE_ID=prof-corp-tls
CERTCTL_SCEP_PROFILE_CORP_CHALLENGE_PASSWORD=...
CERTCTL_SCEP_PROFILE_CORP_RA_CERT_PATH=/etc/certctl/scep/corp-ra.crt
CERTCTL_SCEP_PROFILE_CORP_RA_KEY_PATH=/etc/certctl/scep/corp-ra.key
... (etc per profile name)
internal/config/config.go
* SCEPConfig.Profiles []SCEPProfileConfig — primary multi-profile
dispatch source.
* Legacy flat fields (IssuerID, ProfileID, ChallengePassword,
RACertPath, RAKeyPath) preserved with updated docblocks marking
them as merge sources for the backward-compat shim.
* SCEPProfileConfig new struct (PathID, IssuerID, ProfileID,
ChallengePassword, RACertPath, RAKeyPath).
* loadSCEPProfilesFromEnv: reads CERTCTL_SCEP_PROFILES (comma-list
of names), expands each to per-profile env vars
CERTCTL_SCEP_PROFILE_<NAME>_*. Returns nil when unset so the
legacy-shim path takes over.
* mergeSCEPLegacyIntoProfiles: when SCEP enabled + Profiles empty +
any legacy flat field populated, synthesises Profiles[0] with
PathID="". No-op when Profiles already populated (structured form
wins) or SCEP disabled.
* validSCEPPathID: empty allowed (legacy /scep root); non-empty
must be [a-z0-9-] with no leading/trailing hyphen.
* Per-profile Validate gates: PathID format, uniqueness across the
slice, ChallengePassword presence (CWE-306 per profile), RA pair
presence (RFC 8894 §3.2.2), IssuerID presence.
* Legacy single-profile gates skip when Profiles is non-empty so
the per-profile loop owns the gating in the structured case
(avoids double-fire with overlapping error messages).
internal/api/router/router.go
* RegisterSCEPHandlers signature: map[string]handler.SCEPHandler
(was a single SCEPHandler).
* Empty PathID handler registered with literal r.Register('GET /scep'
+ 'POST /scep') so the openapi-parity AST scanner (Bundle D /
Audit M-027) continues to see the documented /scep route. Without
this preservation, the parity test fails because dynamic
string-built routes don't appear in *ast.BasicLit walks.
* Non-empty PathIDs registered dynamically as /scep/<pathID>.
* AuthExempt prefix /scep already covers all /scep[/...] paths via
prefix match — no change needed there.
cmd/server/main.go
* SCEP startup block iterates cfg.SCEP.Profiles, builds one service
+ one handler per profile, stuffs them into a {pathID -> handler}
map, hands the map to apiRouter.RegisterSCEPHandlers.
* Per-profile preflight: preflightSCEPChallengePassword,
preflightSCEPRACertKey, preflightEnrollmentIssuer fire ONCE PER
PROFILE with a profile-scoped slog.Logger so failures report
PathID + IssuerID. Each per-profile failure os.Exits(1) with a
targeted error message.
* Final 'SCEP server enabled' info log reports profile_count.
internal/config/config_scep_profiles_test.go (new, 9 tests / 22 sub-cases)
* TestSCEPConfig_LegacyFlatFields_SynthesizeSingleProfile — the
backward-compat smoke test.
* TestSCEPConfig_MultipleProfiles_LoadFromEnv — structured-form
happy path with two profiles.
* TestSCEPConfig_StructuredFormBeatsLegacy — when both forms set,
structured wins; legacy flat field MUST NOT leak into
Profiles[0].ChallengePassword.
* TestSCEPConfig_PathIDValidation — 13 sub-cases covering valid +
every reject mode (uppercase, slash, leading/trailing hyphen,
underscore, dot, space, non-ASCII).
* TestSCEPConfig_DuplicatePathID_Refuses.
* TestSCEPConfig_MissingPerProfileChallengePassword,
_MissingPerProfileRAPair (3 sub-cases),
_MissingPerProfileIssuerID — per-profile gate triplet.
* TestSCEPConfig_DisabledIgnoresProfiles — gates only fire when
SCEP is enabled.
internal/api/router/router_scep_profiles_test.go (new, 4 tests)
* TestRouter_RegisterSCEPHandlers_LegacyEmptyPathIDMapsToRoot —
empty PathID gets /scep root; both GET + POST routes registered.
* TestRouter_RegisterSCEPHandlers_NonEmptyPathIDMapsToSubpath —
non-empty PathID gets /scep/<pathID>; /scep root NOT registered
when no empty-PathID profile exists.
* TestRouter_RegisterSCEPHandlers_MultipleProfilesNoCrossBleed —
three profiles (default, corp, iot); each path reaches the right
handler instance, verified via per-profile-tagged GetCACaps mock
response.
* TestRouter_RegisterSCEPHandlers_EmptyMapRegistersNoRoutes — no
profiles → no /scep routes (deploy with SCEP disabled).
Verification:
* gofmt clean for the files I touched.
* go vet clean across config / router / cmd/server / domain.
* go test -short -count=1 green across config / router / cmd/server /
api/handler / service / domain / pkcs7.
* Coverage held: handler 79.0% / service 73.2% / pkcs7 100% /
config 96.0% / domain 88.6% / router 100% / cmd/server 19.2%.
* openapi-parity test green (literal /scep registrations preserved).
Phase 1.5 of 14 in SCEP RFC 8894 + Intune master bundle.
Living progress at cowork/scep-rfc8894-intune/progress.md.
|
||
|
|
105c307d62 |
feat(scep): add RFC 8894 message-type constants + RA cert/key config
SCEP RFC 8894 + Intune master bundle — Phase 0 + Phase 1 of 14.
Phase 0 (recon, no code changes):
Baseline tests green at HEAD
|
||
|
|
db71b47c24 |
main: wire CRL/OCSP responder services into runtime
Activates the CRL/OCSP responder pipeline that landed dormant in phases 1-4 (commits |
||
|
|
92afe359e9 |
Bundle O (Coverage Audit Closure): test hygiene + FSM coverage tables — M-004 + M-005 + M-006 closed
Three deliverables shipped:
O.1 (M-004): t.Skip rationale audit — 65 sites, 0 orphans
O.2 (M-005): fuzz targets 9 -> 11 (+ParseNamedAPIKeys, +SanitizeForShell)
O.3 (M-006): FSM coverage tables (5 FSMs catalogued)
O.1 — t.Skip rationale audit:
Inventoried all 65 t.Skip sites in the repo (audit-time estimate
was 41; count grew via Bundle 0.7 keymem tests + Bundle M.Cloud
httptest skips). Every site carries a valid rationale —
none are orphan. Categories: OS-specific (~30), root-only (~5),
external-dep (Docker/PostgreSQL/browser/Vault/DigiCert ~15),
manual-test markers (Parts 23/24/55/56 — 4 from Bundle I),
-short mode (~6), state-dependent (~5). All class (a) per Bundle
O's classification. No edits required; the existing M-009 CI guard
catches new orphan skips going forward.
O.2 — Fuzz target additions:
internal/config/config_fuzz_test.go::FuzzParseNamedAPIKeys
Pins the CERTCTL_API_KEYS_NAMED env-var parser (dual-key
rotation, Bundle G / L-004). 16 seed inputs covering happy-path,
rotation pair, degenerate, whitespace-padded, wrong-case admin,
4-segment, adversarial chars in name, long inputs.
internal/validation/command_fuzz_test.go::FuzzSanitizeForShell
Appended to existing fuzz file. Asserts no panic + output begins+
ends with single-quote. 17 seed inputs covering plain, whitespace,
embedded quotes/backticks/dollars, newlines, NULs, shell-metachar
injection, unicode, 100x apostrophe stress, 10000x length stress.
Total fuzz-target count: 9 -> 11 (per grep verification)
O.3 — FSM coverage tables (NEW: tables/fsm-coverage.md):
Job: legal 92%, illegal 100% ✓ Existential gate
Certificate: legal 93%, illegal 100% ✓ Existential gate
Agent: legal 75%, illegal 100% △ slight Degraded gap
Notification: legal 86%, illegal 100% ✓
Health-check: legal 100% (recompute-on-tick model) ✓
4/5 FSMs meet the ≥80% legal + 100% illegal gate.
Agent's Degraded transitions are the lone gap; tracked as
M-006-extended.
Verification:
go vet ./internal/config/... ./internal/validation/... clean
go test -short -count=1 PASS
grep -rE 'func Fuzz[A-Z]' --include='*_test.go' internal/ | wc -l == 11
Audit deliverables:
gap-backlog.md: M-004 + M-005 + M-006 strikethroughs + Bundle O
closure-log entry covering all 3 sub-deliverables
closure-plan.md: Bundle O [x] closed
tables/fsm-coverage.md: NEW (5 FSMs catalogued)
CHANGELOG.md: [unreleased] Bundle O entry
|
||
|
|
6b5af27546 |
Bundle G: Final audit closure — L-004 + D-003/4/5/7 closed; 54/55 + 7/7
Closes the 2026-04-25 audit's final-closure cluster. Score 51/55 -> 54/55
(98% closed); deferred 4/7 -> 7/7 (100%). All severity-graded findings now
closed except M-029 (frontend per-PR migration backlog, by design incremental).
L-004 (CWE-924) — dual-key API rotation overlap window:
internal/config/config.go::ParseNamedAPIKeys rewritten to allow same-name
duplicate entries iff admin flag matches. Mismatched-admin entries rejected
at startup (privilege escalation guard); exact (name,key) duplicates rejected
(typo guard — rotation requires DIFFERENT keys under the same name). Startup
INFO log per name with multiple entries surfaces the active rotation window.
NewAuthWithNamedKeys was already shaped correctly (constant-time hash compare
across all entries, same UserKey + AdminKey for either bearer); Bundle B's
M-025 per-user rate-limit bucket and audit-trail actor inherit consistency
across the rollover automatically. 8 new tests pin the contract end-to-end.
docs/security.md::API key rotation walks the 6-step zero-downtime rollover.
D-003 — Mutation testing wired:
security-deep-scan.yml gets a go-mutesting step covering ./internal/crypto/...,
./internal/pkcs7/..., ./internal/connector/issuer/local/... with per-package
summary lines extracted into go-mutesting.txt artefact.
D-007 — Frontend semgrep wired (recon found Bundle 7's wiring claim was false):
security-deep-scan.yml gets a 'semgrep p/react-security' step running
returntocorp/semgrep:latest --config=p/react-security against /src/web/src;
results uploaded as semgrep-react.json.
D-004 + D-005 — Operator runbook published:
docs/testing-strategy.md (NEW) consolidates per-tool local-run procedures,
acceptance thresholds, and triage paths for go-mutesting, ZAP baseline DAST,
testssl.sh, and semgrep p/react-security. Closes the 'wired CI-only, no
local-run validation' framing for D-004/D-005 by giving operators the same
commands the CI workflow runs.
Verification:
gofmt -l no diff
go vet ./internal/config/... ./internal/api/middleware/... clean
go test -short -count=1 ./internal/config/... ./internal/api/middleware/... PASS
python3 -c 'yaml.safe_load(...)' YAML OK
G-3 env-var docs guard no phantom env-vars
Audit deliverables:
audit-report.md: L-004 + D-003/4/5/7 boxes flipped [x]; score 51/55 -> 54/55
findings.yaml: 5 status flips; new bundle-G-final-closure closure_log entry
CHANGELOG.md: Bundle G entry under [unreleased]; supersedes Bundle E + F
L-004-deferred framing
|
||
|
|
30f9f1e712 |
Bundle B: Auth & transport surface tightening — 5 findings closed
Closes M-001 + M-002 + M-013 + M-018 + M-025 from
comprehensive-audit-2026-04-25.
M-001 (CWE-916) — PBKDF2 100k -> 600k via v3 blob format
internal/crypto/encryption.go:
- New v3Magic (0x03), pbkdf2IterationsV3 (600,000 — OWASP 2024
Password Storage Cheat Sheet floor), v3SaltSize (16 bytes),
deriveKeyWithSaltV3 helper.
- EncryptIfKeySet now unconditionally writes v3:
magic(0x03) || salt(16) || nonce(12) || ciphertext+tag
- DecryptIfKeySet falls through v3 -> v2 -> v1 with AEAD verification
at each step. Wrong-passphrase v3 reads cannot be silently
misattributed to v2/v1.
- IsLegacyFormat updated to recognize 0x03 as non-legacy.
internal/crypto/encryption_v3_test.go (NEW, 7 tests):
V3 round-trip / V2 read-fallback against deterministic v2 fixture /
V3 wrong-passphrase fails / V3-vs-V2 dispatch order / V2 vs V3 keys
differ for same (passphrase, salt) / iteration-count pin at OWASP
2024 floor / IsLegacyFormat-recognises-V3.
Coverage internal/crypto: 86.7% -> 88.2%.
M-002 (CWE-862) — Auth-exempt allowlist constants + AST regression test
Recon found auth-exempt surface spans TWO layers (audit's claim was
incomplete):
Layer 1 (router.go direct r.mux.Handle):
GET /health, GET /ready, GET /api/v1/auth/info, GET /api/v1/version
Layer 2 (cmd/server/main.go::buildFinalHandler URL-prefix dispatch):
/.well-known/pki/*, /.well-known/est/*, /scep[/...]*
internal/api/router/router.go:
- New AuthExemptRouterRoutes constant with per-entry justifications.
- New AuthExemptDispatchPrefixes constant.
internal/api/router/auth_exempt_test.go (NEW, 2 tests):
AST-walks router.go for every direct mux.Handle call and asserts
set equals AuthExemptRouterRoutes; reads source bytes of Register /
RegisterFunc and asserts they still wrap with middleware.Chain.
cmd/server/auth_exempt_test.go (NEW, 2 tests):
14-case table test on buildFinalHandler asserting documented
prefixes route to noAuthHandler and authenticated routes route to
apiHandler; inverse-overlap pin proves no documented bypass shadows
an authenticated prefix.
M-013 (CWE-942) — CORS deny-by-default verified-already-clean + pin
Audit claim 'default allows all origins if env-var unset' was WRONG.
internal/api/middleware/middleware.go::NewCORS already denies cross-
origin requests when len(cfg.AllowedOrigins) == 0 (no
Access-Control-Allow-Origin header is emitted, same-origin policy
applies).
internal/api/middleware/cors_test.go: +TestNewCORS_NilOriginsDeniesAll
+ TestNewCORS_M013_ContractDocumentedInOrder (5-case table test
pinning the 3-arm dispatch contract).
M-018 (CWE-319 / PCI-DSS Req 4) — Postgres TLS opt-in toggle
deploy/helm/certctl/values.yaml: new postgresql.tls.{mode,caSecretRef}
operator-facing knobs. Default 'disable' preserves in-cluster pod-
network behavior; PCI-scoped operators set verify-full.
deploy/helm/certctl/templates/_helpers.tpl: certctl.databaseURL helper
pipes postgresql.tls.mode into ?sslmode=.
deploy/helm/certctl/templates/server-secret.yaml: uses the helper
instead of hardcoded sslmode=disable.
deploy/docker-compose.yml: CERTCTL_DATABASE_URL is now
${CERTCTL_DATABASE_URL:-...} so operators override without editing.
docs/database-tls.md (NEW): operator runbook covering 4 deployment
shapes, RDS verify-full example with PGSSLROOTCERT mount, and
pg_stat_ssl verification query.
helm template + helm lint clean.
M-025 (OWASP ASVS L2 §11.2.1) — Per-key rate limiting
internal/api/middleware/middleware.go::NewRateLimiter rewritten from
a single global tokenBucket to a keyedRateLimiter map keyed on
'user:'+GetUser(ctx) for authenticated callers
'ip:'+RemoteAddr-host for unauthenticated
- Empty UserKey strings treated as unauthenticated.
- X-Forwarded-For intentionally NOT consulted (header-spoofing risk).
- Create-on-demand bucket allocation under sync.RWMutex with double-
check pattern.
RateLimitConfig.PerUserRPS / PerUserBurstSize fields with env vars
CERTCTL_RATE_LIMIT_PER_USER_RPS / CERTCTL_RATE_LIMIT_PER_USER_BURST
allow per-user budgets distinct from per-IP.
internal/api/middleware/ratelimit_keyed_test.go (NEW, 5 tests):
TwoIPsHaveIndependentBuckets / SameUserDifferentIPsShareBucket /
TwoUsersHaveIndependentBuckets / PerUserBudgetOverride /
EmptyUserKeyTreatedAsAnonymous.
Coverage internal/api/middleware: 82.1% -> 83.7%.
Audit deliverables:
cowork/comprehensive-audit-2026-04-25/audit-report.md: score
25/55 -> 30/55 closed (High 7/9, Medium 7/27 -> 12/27, Low 8/19).
cowork/comprehensive-audit-2026-04-25/findings.yaml: 5 status flips
open -> closed with closure notes citing the Bundle B mechanism.
certctl/CHANGELOG.md: Bundle B section under [unreleased].
Verification:
go test -count=1 -short ./... all green
staticcheck on changed packages no new SA*/ST* hits
(the 4 pre-existing SA1019 sites in cmd/server/main_test.go are
Bundle 9 / M-028 partial closure leftovers tracked in Bundle C)
helm template + helm lint clean
internal/repository/postgres setup-fail sandbox disk pressure,
same on master HEAD before this branch — environmental, not Bundle B
|