mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-10 13:08:57 +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:
@@ -50,6 +50,23 @@ var SpecParityExceptions = map[string]string{
|
||||
// operator-facing description.
|
||||
"GET /scep-mtls": "Phase 6.5 mTLS sibling route — same wire format as /scep with cert-required gate; documented in docs/legacy-est-scep.md",
|
||||
"POST /scep-mtls": "Phase 6.5 mTLS sibling route — same wire format as /scep with cert-required gate; documented in docs/legacy-est-scep.md",
|
||||
|
||||
// ACME server (RFC 8555 + RFC 9773 ARI) — Phase 1a foundation.
|
||||
// Like SCEP/EST, ACME is a wire-protocol surface (JWS-signed JSON
|
||||
// over HTTPS per RFC 7515) whose semantics are dictated by the RFC
|
||||
// rather than by an OpenAPI document. Documenting every endpoint
|
||||
// in openapi.yaml would duplicate RFC 8555 §7.1 + §7.2 with no
|
||||
// information gain. The canonical reference is docs/acme-server.md.
|
||||
// Subsequent phases will extend this list with new-account,
|
||||
// new-order, finalize, authz, challenge, cert, key-change,
|
||||
// revoke-cert, renewal-info — each gets its own exception entry
|
||||
// in the same commit that lands the route.
|
||||
"GET /acme/profile/{id}/directory": "RFC 8555 §7.1.1 directory; documented in docs/acme-server.md",
|
||||
"HEAD /acme/profile/{id}/new-nonce": "RFC 8555 §7.2 new-nonce; documented in docs/acme-server.md",
|
||||
"GET /acme/profile/{id}/new-nonce": "RFC 8555 §7.2 new-nonce (GET form); documented in docs/acme-server.md",
|
||||
"GET /acme/directory": "RFC 8555 §7.1.1 directory (default-profile shorthand); documented in docs/acme-server.md",
|
||||
"HEAD /acme/new-nonce": "RFC 8555 §7.2 new-nonce (default-profile shorthand); documented in docs/acme-server.md",
|
||||
"GET /acme/new-nonce": "RFC 8555 §7.2 new-nonce GET (default-profile shorthand); documented in docs/acme-server.md",
|
||||
}
|
||||
|
||||
func TestRouter_OpenAPIParity(t *testing.T) {
|
||||
|
||||
@@ -143,6 +143,19 @@ type HandlerRegistry struct {
|
||||
// Both endpoints are admin-gated (M-008 pin updated to include
|
||||
// admin_est.go).
|
||||
AdminEST handler.AdminESTHandler
|
||||
// ACME handles RFC 8555 ACME server endpoints under
|
||||
// /acme/profile/<id>/* and the optional /acme/* shorthand.
|
||||
// Phase 1a wires:
|
||||
// GET /acme/profile/{id}/directory
|
||||
// HEAD /acme/profile/{id}/new-nonce
|
||||
// GET /acme/profile/{id}/new-nonce
|
||||
// GET /acme/directory (shorthand)
|
||||
// HEAD /acme/new-nonce (shorthand)
|
||||
// GET /acme/new-nonce (shorthand)
|
||||
// Subsequent phases add new-account + account/<id>, orders,
|
||||
// authzs, challenges, key-change, revoke-cert, ARI. See
|
||||
// docs/acme-server.md for the configuration reference.
|
||||
ACME handler.ACMEHandler
|
||||
}
|
||||
|
||||
// RegisterHandlers sets up all API routes with their handlers.
|
||||
@@ -389,6 +402,26 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
|
||||
r.Register("DELETE /api/v1/health-checks/{id}", http.HandlerFunc(reg.HealthChecks.DeleteHealthCheck))
|
||||
r.Register("GET /api/v1/health-checks/{id}/history", http.HandlerFunc(reg.HealthChecks.GetHealthCheckHistory))
|
||||
r.Register("POST /api/v1/health-checks/{id}/acknowledge", http.HandlerFunc(reg.HealthChecks.AcknowledgeHealthCheck))
|
||||
|
||||
// ACME (RFC 8555 + RFC 9773 ARI) server endpoints. Phase 1a wires
|
||||
// directory + new-nonce only; Phases 1b-4 extend with the JWS-
|
||||
// authenticated POST surface (new-account, new-order, finalize,
|
||||
// challenges, revoke, ARI). Routes go through r.Register so the
|
||||
// standard middleware chain (CORS, body-limit, audit) applies —
|
||||
// ACME's own per-op metrics + RFC 8555 §6.5 Replay-Nonce headers
|
||||
// are added by the handler.
|
||||
//
|
||||
// Per-profile path family (canonical):
|
||||
r.Register("GET /acme/profile/{id}/directory", http.HandlerFunc(reg.ACME.Directory))
|
||||
r.Register("HEAD /acme/profile/{id}/new-nonce", http.HandlerFunc(reg.ACME.NewNonce))
|
||||
r.Register("GET /acme/profile/{id}/new-nonce", http.HandlerFunc(reg.ACME.NewNonce))
|
||||
// Default-profile shorthand. The handler's profile-resolution path
|
||||
// returns userActionRequired (RFC 7807 + RFC 8555 §6.7) when
|
||||
// CERTCTL_ACME_SERVER_DEFAULT_PROFILE_ID is unset; when set it
|
||||
// dispatches to the same handler as the per-profile path.
|
||||
r.Register("GET /acme/directory", http.HandlerFunc(reg.ACME.Directory))
|
||||
r.Register("HEAD /acme/new-nonce", http.HandlerFunc(reg.ACME.NewNonce))
|
||||
r.Register("GET /acme/new-nonce", http.HandlerFunc(reg.ACME.NewNonce))
|
||||
}
|
||||
|
||||
// RegisterESTHandlers sets up EST (RFC 7030) routes under
|
||||
|
||||
Reference in New Issue
Block a user