mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:21:37 +00:00
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:
+147
-15
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user