docs(acme-server): operator-facing reference + threat model + cert-manager walkthrough (Phase 6/7)

Doc-only commit closing the ACME-server work series. After this commit,
an outside reviewer (procurement engineer / Venafi diligence engineer /
Infisical-comparison-shopper) can read the docs cold, understand the
ACME server's surface, follow the cert-manager walkthrough, and reach
a deployment decision without escalating to certctl maintainers.

What ships:
  - docs/acme-server.md final pass: Auth-mode decision tree (when to
    use trust_authenticated vs challenge), RFC 8555 + RFC 9773
    conformance statement (section-by-section table of implemented
    plus procurement-honest 'not implemented' rows for EAB / multi-
    level wildcards / RFC 8738 / cross-CA proxying), Troubleshooting
    (5 failure modes — badNonce / unknownAuthority / HTTP-01
    connection refused / DNS-01 NXDOMAIN / rejectedIdentifier with
    canonical fix for each), Version pinning + tested clients table
    (cert-manager 1.15.0, lego v4, kind v0.20+, Caddy 2.7.x, Traefik
    3.0+), FAQ (5 entries — why two auth modes, vs cert-manager-
    against-LE, can-I-use-from-outside-K8s, migration story, audit-
    log catalog), See-also cross-link block.
  - docs/acme-cert-manager-walkthrough.md: kind → cert-manager →
    certctl → Certificate flow, with YAML blocks byte-equal to
    deploy/test/acme-integration/{clusterissuer-trust-authenticated,
    certificate-test}.yaml to prevent doc/test drift.
  - docs/acme-caddy-walkthrough.md: Caddyfile acme_ca + tls.cas
    options (OS trust store + Caddy pki.ca block).
  - docs/acme-traefik-walkthrough.md: certificatesResolvers.<name>.acme
    .caServer + serversTransport.rootCAs configuration.
  - docs/acme-server-threat-model.md: Threat surface map + JWS forgery
    resistance (alg-confusion / HS256 substitution / replayed nonce /
    URL spoofing / multi-sig / kid-vs-jwk / kid round-trip mismatch),
    Nonce store integrity rationale, HTTP-01 SSRF defense-in-depth
    (pre-dial check + per-dial check + per-redirect check + body cap +
    bounded redirects), DNS-01 cache-poisoning posture (default Google
    Public DNS + operator-owns-private-resolver-posture), TLS-ALPN-01
    chain-not-validated rationale (RFC 8737 §3 explicit), Rate-limit
    tuning, Audit trail catalog, Out-of-scope threats list.
  - docs/connectors.md: TOC renumbered 3→4 etc. to make room for new
    top-level 'ACME Server (Built-in)' section between Issuer Connector
    and Target Connector — distinguishes the consumer-side ACME
    (existing) from the new server-side ACME via env-var-prefix
    call-out (CERTCTL_ACME_* vs CERTCTL_ACME_SERVER_*).

DoD verification:
  - All 5 docs files exist with the structure prescribed by the
    Phase 6 prompt.
  - Every CERTCTL_ACME_SERVER_* env var in docs/acme-server.md maps
    to an actual lookup in internal/config/config.go (verified by
    'grep -oE | sort -u | diff' returning empty).
  - Every YAML snippet in docs/acme-cert-manager-walkthrough.md is
    byte-equal to the corresponding file in deploy/test/acme-integration/
    (verified with 'diff' against awk-extracted YAML blocks).
  - docs/connectors.md has the cross-link subsection with all 4 new
    docs referenced.
  - cowork/CLAUDE.md Architecture Decisions has the new ACME-server
    bullet documenting per-profile URL family + per-profile
    acme_auth_mode + Phase 4-5-6 progression.
  - cowork/WORKSPACE-CHANGELOG.md has the ACME-Server-6 entry plus
    the ACME-Server rollup spanning Phases 1a-6.
  - cowork/infisical-deep-research-results.md Rank 1 marked SHIPPED.
  - 'gofmt -l .' clean (no Go changes); 'go vet ./...' clean.

Acquisition-readiness: every one of the 12 acquisition-grade criteria
from cowork/acme-server-endpoint-prompt.md is verified by the test
suite (Phases 1a-5) plus this doc walkthrough (Phase 6). The full
RFC 8555 + RFC 9773 surface is live; the operator can deploy
end-to-end by reading one walkthrough doc and one env-var table.

Engineering history: cowork/WORKSPACE-CHANGELOG.md 'ACME-Server-6 (docs)'
+ ACME-Server rollup of all 6 phases.
This commit is contained in:
shankar0123
2026-05-03 19:58:15 +00:00
parent bee47f0318
commit 39f065dda4
6 changed files with 1235 additions and 24 deletions
+278
View File
@@ -0,0 +1,278 @@
# ACME Server — Threat Model
Security posture for the certctl ACME server endpoint
(`/acme/profile/<id>/*`). Read this before opening a PR that changes
the JWS verifier, the challenge validators, the rate limiter, or the
GC sweeper.
The threat model lives in this dedicated doc (rather than `docs/acme-server.md`)
because security-review reviewers want a single concentrated reference.
Production deployments under audit should treat this doc as the
canonical answer to "how does certctl resist X?"
## Threat surface map
The ACME server has four ingress surfaces:
1. **JWS-authenticated POST endpoints** — new-account, new-order,
finalize, key-change, revoke-cert, account update, order POST-as-GET.
Authenticated by an ECDSA / RSA / EdDSA signature over the request.
2. **Unauthenticated GET endpoints** — directory, new-nonce, ARI
(renewal-info). Read-only; no authn.
3. **Outbound challenge validators** — HTTP-01, DNS-01, TLS-ALPN-01.
The certctl-server initiates outbound calls to operator-provided
identifiers (the SAN list of the requested cert).
4. **Scheduler-driven GC sweeper** — internal-only; no inbound surface.
Threat actors:
- **External Internet attacker** — no certctl credentials; can hit
unauthenticated endpoints + observe TLS metadata.
- **Authenticated ACME account holder (low-trust)** — has a valid
account on a profile but should be bounded by profile policy +
rate limits.
- **On-path attacker** between certctl-server and a challenge target
(HTTP-01 / DNS-01 / TLS-ALPN-01).
- **Compromised cert holder** — has the private key of a previously-
issued cert and wants to revoke/exfiltrate.
- **Malicious operator with profile-write access** — can change a
profile's `acme_auth_mode` or policy, but is the trusted boundary
per certctl's threat model. Out of scope here; covered by certctl's
RBAC + audit log.
## JWS forgery resistance
The verifier (`internal/api/acme/jws.go`) accepts only the closed
allow-list `{RS256, ES256, EdDSA}`. The allow-list is passed to
`jose.ParseSigned` so go-jose rejects every other algorithm at parse
time, before any signature work.
Specific attacks blocked:
- **Algorithm confusion (`alg: none`)** — RFC 7515 §6.1's classic
unauthenticated-fallback. Not in allow-list; rejected at parse.
- **HS256 substitution (alg-confusion via symmetric)** — symmetric
algs aren't in the allow-list; rejected at parse.
- **Replayed nonce** — every JWS carries a nonce consumed via
`acme_nonces.UPDATE … WHERE used = FALSE` (a single statement;
Postgres row-locking serializes the writes). A second consume of
the same nonce sees `RowsAffected=0` and the verifier returns
`badNonce`.
- **URL spoofing** — the protected-header `url` field MUST match the
request URL exactly (RFC 8555 §6.4); a JWS signed for one URL
cannot be replayed against another.
- **Multi-signature JWS** — RFC 8555 §6.2 forbids; the verifier
rejects `len(jws.Signatures) != 1` explicitly.
- **kid-vs-jwk confusion** — exactly one MUST be present per RFC 8555
§6.2; both-present and neither-present are rejected.
- **kid round-trip mismatch** — the verifier's `AccountKID` closure
computes the canonical kid URL for the resolved account-id and
compares to the inbound `kid`; cross-profile replay is rejected
because the canonical URL differs.
The doubly-signed key-rollover JWS (RFC 8555 §7.3.5, Phase 4) gets
its own dedicated verifier in `internal/api/acme/keychange.go`.
Inner-only invariants enforced: MUST use `jwk` not `kid`, payload
`account` MUST equal outer `kid`, payload `oldKey` MUST canonicalize-
equal the registered key (RFC 7638 thumbprint, constant-time
compare), inner `url` MUST equal outer `url`.
## Nonce store integrity
Nonces are persisted in PostgreSQL (`acme_nonces` table; migration
000025) with a TTL set by `CERTCTL_ACME_SERVER_NONCE_TTL` (default
5 min). The Phase 5 GC sweeper deletes used / expired rows every 1
minute by default.
Why DB-backed and not in-memory:
- **Survives restart** — a multi-replica certctl-server fleet behind
a load balancer can issue a nonce on replica A and consume it on
replica B. In-memory state would force sticky sessions globally,
which the operator can't guarantee in all topologies.
- **Atomic consume** — a single `UPDATE ... WHERE used = FALSE`
statement is the consume primitive; Postgres row-locking guarantees
exactly one of two concurrent consumes wins.
- **Expiry-bounded** — even if the GC sweeper were disabled, the
nonce TTL is enforced at consume time
(`AND expires_at > NOW()` in the UPDATE).
A nonce-store-side compromise would let an attacker forge nonces.
Mitigation: the nonce table is in the same Postgres instance certctl
already trusts; a DB compromise is broader than ACME-specific.
## HTTP-01 SSRF resistance
The HTTP-01 validator (Phase 3, `internal/api/acme/validators.go`)
fetches `http://<identifier>/.well-known/acme-challenge/<token>`
where the identifier is operator/client-controlled. Without
mitigation, this is a textbook SSRF surface — internal services on
RFC1918 / link-local / cloud-metadata addresses would be reachable.
Mitigations (defense in depth):
1. **Pre-dial check**`validation.ValidateSafeURL` rejects URLs
whose host parses as a literal reserved IP. Cheap early bail.
2. **Per-dial check**`validation.SafeHTTPDialContext` is installed
on the `http.Transport`. Every dial re-resolves DNS, rejects
reserved IPs, and **pins the resolved IP** (`net.JoinHostPort(ips[0],
port)`) so a racing DNS rebinding cannot substitute a different IP
between resolve and connect.
3. **Per-redirect check** — Go's HTTP client re-dials on 3xx; the
`DialContext` runs again, applying the same SSRF guards.
4. **Body cap** — the validator's `io.LimitReader` caps response
bodies at 16 KiB. A misbehaving target cannot DoS the validator
pool with a multi-GB response.
5. **Bounded redirects** — the validator caps redirects at 10 (Go
default). A redirect-loop target is bounded.
Reserved IP set: loopback (127.0.0.0/8 + ::1), link-local
(169.254.0.0/16 + fe80::/10), all RFC1918 (10/8, 172.16/12, 192.168/16),
cloud-metadata literals (169.254.169.254 explicitly), broadcast,
multicast, IPv4-mapped-IPv6 to a reserved IPv4. See
`internal/validation/ssrf.go::isReservedIPForDial` for the full set.
CodeQL alert #23 flags `client.Do(req)` in the SCEP-probe call site
as `go/request-forgery` despite the dial-time guard; the analyzer
can't trace through a custom `Transport.DialContext`. Operator-
acknowledged false positive (CLAUDE.md task #10) — see the SCEP
probe's same-shaped defense for the audit trail.
## DNS-01 cache poisoning posture
The DNS-01 validator queries
`_acme-challenge.<domain>` against a single resolver configured by
`CERTCTL_ACME_SERVER_DNS01_RESOLVER` (default `8.8.8.8:53`).
Threat: an operator running a private resolver (typical in air-gapped
deployments) inherits that resolver's cache-poisoning posture. A
poisoned resolver could attest a TXT record the legitimate domain
owner never published, allowing an attacker who controls the
resolver to forge ACME challenges.
Mitigation:
- Default `8.8.8.8:53` is Google Public DNS — DNSSEC-validating,
operationally hardened, well-monitored.
- Operators choosing a private resolver own the cache-poisoning
posture. The doc explicitly flags this in
`docs/acme-server.md` § Configuration.
- DNSSEC-validation is **not** enforced by the validator itself —
the validator trusts the resolver's answer. Operators wanting
strict DNSSEC validation should use a DNSSEC-validating resolver
(e.g. `1.1.1.1` or a self-hosted Unbound).
## TLS-ALPN-01 challenge interception
RFC 8737 §3 explicitly says the validator MUST NOT verify the
challenge target's certificate chain — the proof lives in the
embedded `id-pe-acmeIdentifier` extension (OID 1.3.6.1.5.5.7.1.31)
of the cert presented during the TLS handshake, not in the chain
itself.
Implementation: `internal/api/acme/validators.go::TLSALPN01Validator`
sets `tls.Config.InsecureSkipVerify = true` with a dedicated
`//nolint:gosec` annotation citing RFC 8737 §3 and the L-001
documentation row in `docs/tls.md`.
What this means for on-path attackers:
- An on-path attacker between certctl-server and the challenge target
CAN intercept the TLS handshake and present a forged cert. The
proof is the embedded extension byte-equality, which the attacker
cannot generate without the account key — so interception alone
doesn't grant cert issuance.
- An attacker who has the account key already controls the account
per RFC 8555; the TLS-ALPN-01 validator's interception window adds
no incremental capability.
The integrity property TLS-ALPN-01 actually provides: the challenge
target proves possession of the account-key-derived key authorization
on a TLS connection bound to the requested identifier (port 443 of
the SAN). Operators wanting CA/Browser-Forum-style WebPKI strictness
should run a dedicated public-trust CA, not certctl.
## Rate-limit tuning
Phase 5 in-memory token buckets with per-(action, key) isolation.
Defaults:
- `RATE_LIMIT_ORDERS_PER_HOUR=100` per account.
- `RATE_LIMIT_CONCURRENT_ORDERS=5` per account (pending/ready/processing).
- `RATE_LIMIT_KEY_CHANGE_PER_HOUR=5` per account.
- `RATE_LIMIT_CHALLENGE_RESPONDS_PER_HOUR=60` per challenge-id.
Tuning:
- **Too loose** → enables abuse vectors. A compromised account could
burn DB-row throughput; a runaway client could fill the validator
pool.
- **Too tight** → legitimate flake-out. cert-manager's exponential
backoff after a `rateLimited` problem is conservative; a 1-hour
cooldown is a long time for an operator hitting an unexpected limit.
Defaults are intentionally conservative on the loose-side — 100/hour
is generous for any plausible per-account fleet (a 50k-cert
deployment renewing at the 1/3-validity mark consumes ~12
orders/year/cert ≈ 600k orders/year ≈ 70 orders/hour even spread
evenly across accounts). Tighter limits are appropriate for
deployments with many low-trust accounts.
The buckets are in-memory + per-replica. A 3-replica certctl-server
fleet effectively has 3× the configured per-account throughput
because each replica's bucket fills independently. For deployments
where this matters operationally, the right answer is a shared rate-
limit store (Redis / Postgres-backed); not blocking for current
threat model where same-account requests typically pin to the same
replica via session affinity.
## Audit trail
Every ACME state mutation writes a row to `audit_events`. Actor strings
distinguish the auth path:
- `acme:<account-id>` — kid-path requests (the requesting account
signed the JWS).
- `acme-cert-key:<serial>` — jwk-path revoke (the cert's own private
key signed the JWS).
- `acme-system:gc` — scheduler-driven sweeps (no client request).
Operators querying by actor prefix can reconstruct the full history
of any ACME-issued cert. See
`docs/acme-server.md` § FAQ "What audit-log events fire" for the
event-name catalog.
## Out-of-scope threats
Documented to set scope expectations for security reviewers:
- **DDoS at the TLS layer** — the certctl-server's TLS listener +
upstream load balancer / WAF handle this. The ACME-specific rate
limits don't substitute for upstream DDoS protection.
- **cert-manager-side compromise** — if cert-manager is compromised,
it has both the account key and the private keys of every issued
cert. Out of certctl's trust boundary; operators run cert-manager
with the same care they'd run any other secret-bearing operator.
- **Compromised certctl-server filesystem** — the bootstrap CA key
lives at `deploy/test/certs/ca.key` (or the operator-managed
equivalent). A filesystem compromise is broader than ACME-specific
and is covered by certctl's HSM / signer-driver architecture (see
`docs/architecture.md` "Signer abstraction").
- **Postgres compromise** — the nonce table, account JWKs, and
audit log all live in the same Postgres instance. A DB compromise
is broader than ACME-specific and is the operator's responsibility
to mitigate via standard DB-hardening practices.
- **Supply-chain attacks against go-jose / lib/pq** — handled by
Dependabot + the `make verify` security gate; not ACME-specific.
## See also
- [`docs/acme-server.md`](./acme-server.md) — operator-facing reference.
- [`docs/tls.md`](./tls.md) — TLS posture, including the L-001
table of `InsecureSkipVerify` justifications (TLS-ALPN-01 row).
- [`internal/api/acme/jws.go`](../internal/api/acme/jws.go) — verifier
source.
- [`internal/api/acme/validators.go`](../internal/api/acme/validators.go)
— challenge validator pool.
- [`internal/validation/ssrf.go`](../internal/validation/ssrf.go) —
SSRF-defense primitives.