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.
This commit is contained in:
shankar0123
2026-05-03 12:47:52 +00:00
parent b8b7e1e3dd
commit ec88a61274
18 changed files with 1951 additions and 15 deletions
+147 -15
View File
@@ -13,21 +13,27 @@ import (
// Config represents the complete application configuration.
// All configuration values are read from environment variables with CERTCTL_ prefix.
type Config struct {
Server ServerConfig
Database DatabaseConfig
Scheduler SchedulerConfig
Log LogConfig
Auth AuthConfig
RateLimit RateLimitConfig
CORS CORSConfig
Keygen KeygenConfig
CA CAConfig
Notifiers NotifierConfig
NetworkScan NetworkScanConfig
EST ESTConfig
SCEP SCEPConfig
Verification VerificationConfig
ACME ACMEConfig
Server ServerConfig
Database DatabaseConfig
Scheduler SchedulerConfig
Log LogConfig
Auth AuthConfig
RateLimit RateLimitConfig
CORS CORSConfig
Keygen KeygenConfig
CA CAConfig
Notifiers NotifierConfig
NetworkScan NetworkScanConfig
EST ESTConfig
SCEP SCEPConfig
Verification VerificationConfig
ACME ACMEConfig
// ACMEServer is the SERVER-side ACME (RFC 8555 + RFC 9773 ARI)
// configuration. Distinct from ACME above (which is the consumer-
// side issuer connector that talks UP to Let's Encrypt / pebble).
// Server uses CERTCTL_ACME_SERVER_* prefix throughout so the two
// namespaces stay unambiguous in operator docs and shell env.
ACMEServer ACMEServerConfig
Vault VaultConfig
DigiCert DigiCertConfig
Sectigo SectigoConfig
@@ -645,6 +651,108 @@ type ACMEConfig struct {
Insecure bool
}
// ACMEServerConfig is the SERVER-side ACME (RFC 8555 + RFC 9773 ARI)
// configuration. Distinct from ACMEConfig (the consumer-side issuer
// connector that talks UP to Let's Encrypt / pebble). Server uses
// CERTCTL_ACME_SERVER_* prefix throughout to avoid colliding with
// the existing CERTCTL_ACME_* consumer namespace (DIRECTORY_URL /
// PROFILE / CHALLENGE_TYPE / etc.).
//
// Phase 1a wires Enabled / DefaultAuthMode / DefaultProfileID /
// NonceTTL / DirectoryMeta. Order/Authz TTLs + the per-challenge-type
// concurrency caps + DNS01 resolver are reserved fields populated for
// Phases 2/3 — exposing them now keeps the env-var surface stable
// from day one (operators can set CERTCTL_ACME_SERVER_HTTP01_CONCURRENCY
// today; it's a no-op until Phase 3 reads it).
type ACMEServerConfig struct {
// Enabled is the master toggle. When false, the ACME handler is
// constructed (so the registry-shape stays stable) but no routes
// are registered. Operators flip this on after configuring the
// per-profile auth_mode column on certificate_profiles.
// Setting: CERTCTL_ACME_SERVER_ENABLED.
Enabled bool
// DefaultAuthMode sets the default value of certificate_profiles.acme_auth_mode
// for NEWLY-created profiles (e.g. via API). Existing profile rows
// retain whatever value they were created with — per-profile
// values, once set, override this default. Architecture decision:
// auth mode is per-profile, not server-wide.
// Valid: "trust_authenticated" (default) or "challenge".
// Setting: CERTCTL_ACME_SERVER_DEFAULT_AUTH_MODE.
DefaultAuthMode string
// DefaultProfileID, when set, activates the /acme/* shorthand
// path family — /acme/directory mirrors
// /acme/profile/<DefaultProfileID>/directory etc. When empty,
// requests to the shorthand return RFC 7807
// userActionRequired with a hint pointing at the per-profile
// path. Single-profile deployments can set this for ergonomic
// client config; multi-profile deployments leave it empty.
// Setting: CERTCTL_ACME_SERVER_DEFAULT_PROFILE_ID.
DefaultProfileID string
// NonceTTL is how long an issued ACME nonce remains valid before
// the server rejects it as expired. RFC 8555 §6.5.1 allows the
// server to set any TTL; 5 minutes is the operator-friendly
// default (clock-skew tolerant without enabling long-replay
// attacks). Setting: CERTCTL_ACME_SERVER_NONCE_TTL.
NonceTTL time.Duration
// OrderTTL is the lifetime of an unfulfilled ACME order. Phase 2
// reads; Phase 1a reserves the field. Default: 24h.
// Setting: CERTCTL_ACME_SERVER_ORDER_TTL.
OrderTTL time.Duration
// AuthzTTL is the lifetime of an unfulfilled authorization. Phase 2
// reads; Phase 1a reserves. Default: 24h.
// Setting: CERTCTL_ACME_SERVER_AUTHZ_TTL.
AuthzTTL time.Duration
// HTTP01ConcurrencyMax is the bound on concurrent HTTP-01 validators
// (semaphore weight). Phase 3 reads; Phase 1a reserves. Default: 10.
// Setting: CERTCTL_ACME_SERVER_HTTP01_CONCURRENCY.
HTTP01ConcurrencyMax int
// DNS01Resolver is the resolver address used by the DNS-01 validator.
// Phase 3 reads; Phase 1a reserves. Default: "8.8.8.8:53".
// Setting: CERTCTL_ACME_SERVER_DNS01_RESOLVER.
DNS01Resolver string
// DNS01ConcurrencyMax bounds concurrent DNS-01 validators. Default: 10.
// Setting: CERTCTL_ACME_SERVER_DNS01_CONCURRENCY.
DNS01ConcurrencyMax int
// TLSALPN01ConcurrencyMax bounds concurrent TLS-ALPN-01 validators.
// Default: 10. Setting: CERTCTL_ACME_SERVER_TLSALPN01_CONCURRENCY.
TLSALPN01ConcurrencyMax int
// DirectoryMeta is the optional metadata advertised in the directory
// document per RFC 8555 §7.1.1.
DirectoryMeta ACMEServerDirectoryMeta
}
// ACMEServerDirectoryMeta holds the optional fields of the directory
// `meta` block. Each is populated from a CERTCTL_ACME_SERVER_*
// env var; an all-empty struct produces an omitempty-suppressed JSON
// `meta` field on the directory.
type ACMEServerDirectoryMeta struct {
// TermsOfService is a URL pointing to the operator's ToS document.
// Setting: CERTCTL_ACME_SERVER_TOS_URL.
TermsOfService string
// Website is a URL pointing to the operator's homepage.
// Setting: CERTCTL_ACME_SERVER_WEBSITE.
Website string
// CAAIdentities is the list of CAA-record domain values clients
// should authorize for this server. Setting:
// CERTCTL_ACME_SERVER_CAA_IDENTITIES (comma-separated).
CAAIdentities []string
// ExternalAccountRequired, when true, signals to clients that
// new-account requires an EAB token (RFC 8555 §7.3.4). Phase 1a
// advertises but does not enforce; EAB enforcement is a follow-up.
// Setting: CERTCTL_ACME_SERVER_EAB_REQUIRED.
ExternalAccountRequired bool
}
// OpenSSLConfig contains OpenSSL/Custom CA issuer connector configuration.
type OpenSSLConfig struct {
// SignScript is the path to a shell script that signs certificate requests.
@@ -1646,6 +1754,30 @@ func Load() (*Config, error) {
ARIEnabled: getEnvBool("CERTCTL_ACME_ARI_ENABLED", false),
Insecure: getEnvBool("CERTCTL_ACME_INSECURE", false),
},
// ACME server (RFC 8555 + RFC 9773 ARI) — distinct from the
// consumer-side ACME issuer connector above. Server uses
// CERTCTL_ACME_SERVER_* prefix throughout (audit fix #11).
// Phase 1a wires Enabled / DefaultAuthMode / DefaultProfileID /
// NonceTTL + DirectoryMeta. Order/Authz TTLs + concurrency
// caps + DNS01 resolver are reserved (Phases 2/3 read).
ACMEServer: ACMEServerConfig{
Enabled: getEnvBool("CERTCTL_ACME_SERVER_ENABLED", false),
DefaultAuthMode: getEnv("CERTCTL_ACME_SERVER_DEFAULT_AUTH_MODE", "trust_authenticated"),
DefaultProfileID: getEnv("CERTCTL_ACME_SERVER_DEFAULT_PROFILE_ID", ""),
NonceTTL: getEnvDuration("CERTCTL_ACME_SERVER_NONCE_TTL", 5*time.Minute),
OrderTTL: getEnvDuration("CERTCTL_ACME_SERVER_ORDER_TTL", 24*time.Hour),
AuthzTTL: getEnvDuration("CERTCTL_ACME_SERVER_AUTHZ_TTL", 24*time.Hour),
HTTP01ConcurrencyMax: getEnvInt("CERTCTL_ACME_SERVER_HTTP01_CONCURRENCY", 10),
DNS01Resolver: getEnv("CERTCTL_ACME_SERVER_DNS01_RESOLVER", "8.8.8.8:53"),
DNS01ConcurrencyMax: getEnvInt("CERTCTL_ACME_SERVER_DNS01_CONCURRENCY", 10),
TLSALPN01ConcurrencyMax: getEnvInt("CERTCTL_ACME_SERVER_TLSALPN01_CONCURRENCY", 10),
DirectoryMeta: ACMEServerDirectoryMeta{
TermsOfService: getEnv("CERTCTL_ACME_SERVER_TOS_URL", ""),
Website: getEnv("CERTCTL_ACME_SERVER_WEBSITE", ""),
CAAIdentities: getEnvList("CERTCTL_ACME_SERVER_CAA_IDENTITIES", nil),
ExternalAccountRequired: getEnvBool("CERTCTL_ACME_SERVER_EAB_REQUIRED", false),
},
},
Digest: DigestConfig{
Enabled: getEnvBool("CERTCTL_DIGEST_ENABLED", false),
Interval: getEnvDuration("CERTCTL_DIGEST_INTERVAL", 24*time.Hour),