diff --git a/cmd/server/main.go b/cmd/server/main.go index c97b438..1ea764b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -155,6 +155,10 @@ func main() { profileRepo := postgres.NewProfileRepository(db) teamRepo := postgres.NewTeamRepository(db) ownerRepo := postgres.NewOwnerRepository(db) + // ACME server (RFC 8555 + RFC 9773 ARI) — Phase 1a foundation. + // Repo wires nonce ops only; Phases 1b-4 extend with account / + // order / authz / challenge CRUD. + acmeRepo := postgres.NewACMERepository(db) logger.Info("initialized all repositories") // Initialize dynamic issuer registry. @@ -744,6 +748,14 @@ func main() { // by PathID; the AdminEST handler reads it at request time. estServices := map[string]*service.ESTService{} + // ACME server (RFC 8555 + RFC 9773 ARI) — Phase 1a foundation. + // Wires the directory + new-nonce surface against acmeRepo + the + // existing profileRepo (per-profile path resolution). Phases 1b-4 + // extend with the JWS-authenticated POST surface — the constructor + // signature stays stable; later phases call setters. + acmeService := service.NewACMEService(acmeRepo, profileRepo, cfg.ACMEServer) + acmeHandler := handler.NewACMEHandler(acmeService) + // Build the API router with all handlers apiRouter := router.New() apiRouter.RegisterHandlers(router.HandlerRegistry{ @@ -799,6 +811,12 @@ func main() { AdminEST: handler.NewAdminESTHandler( handler.NewAdminESTServiceImpl(estServices), ), + // ACME server (RFC 8555 + RFC 9773 ARI) — Phase 1a foundation. + // Phase 1a wires directory + new-nonce; subsequent phases extend + // with the JWS-authenticated POST surface (new-account, + // new-order, finalize, challenges, revoke, ARI). See + // docs/acme-server.md for the operator-facing reference. + ACME: acmeHandler, }) // Register EST (RFC 7030) handlers if enabled. // diff --git a/docs/acme-server.md b/docs/acme-server.md new file mode 100644 index 0000000..c50ce6b --- /dev/null +++ b/docs/acme-server.md @@ -0,0 +1,160 @@ +# certctl ACME Server (Built-in) + +certctl ships an RFC 8555 + RFC 9773 ARI ACME server endpoint at +`/acme/profile//*`. Any RFC 8555 client (cert-manager 1.15+, +Caddy, Traefik, win-acme, certbot, Posh-ACME) can integrate with certctl +as an ACME issuer with no certctl-side modification — closing the +"deploy a certctl agent on every K8s node" friction that costs deals to +external PKI vendors today. + +> **Phase status (2026-05-03):** Phase 1a (foundation — directory + +> new-nonce + per-profile routing). The directory document is live and +> ACME clients can fetch nonces. Account creation, JWS verification, +> orders, challenges, key rollover, revocation, and ARI all land in +> subsequent phases. Track shipped phases via +> `git log --grep='acme-server:'`. + +## Configuration + +All ACME-server config uses the `CERTCTL_ACME_SERVER_*` env-var prefix +(distinct from `CERTCTL_ACME_*` which configures the consumer-side +issuer connector). The struct definition lives in +`internal/config/config.go::ACMEServerConfig`. + +| Env var | Default | Phase | Description | +|--------------------------------------------------|------------------------|-------|-------------| +| `CERTCTL_ACME_SERVER_ENABLED` | `false` | 1a | Master enable flag. Phase 1a's handler is constructed unconditionally so the registry shape stays stable; routes are registered in `internal/api/router/router.go::RegisterHandlers` regardless. Operators flip this on after configuring per-profile auth_mode. | +| `CERTCTL_ACME_SERVER_DEFAULT_AUTH_MODE` | `trust_authenticated` | 1a | Default value for `certificate_profiles.acme_auth_mode` on newly-created profiles. Existing profiles retain their stored value. Per-profile column is the source of truth at request time. | +| `CERTCTL_ACME_SERVER_DEFAULT_PROFILE_ID` | `""` | 1a | When set, `/acme/*` shorthand mirrors `/acme/profile//*` for single-profile deployments. When empty, requests to the shorthand return RFC 7807 + RFC 8555 §6.7 `userActionRequired`. | +| `CERTCTL_ACME_SERVER_NONCE_TTL` | `5m` | 1a | How long an issued ACME nonce remains valid before the JWS verifier (Phase 1b) returns `urn:ietf:params:acme:error:badNonce` per RFC 8555 §6.5.1. Tune up if cert-manager + certctl clocks frequently skew. | +| `CERTCTL_ACME_SERVER_TOS_URL` | `""` | 1a | Optional `meta.termsOfService` URL in the directory document. | +| `CERTCTL_ACME_SERVER_WEBSITE` | `""` | 1a | Optional `meta.website` URL in the directory document. | +| `CERTCTL_ACME_SERVER_CAA_IDENTITIES` | (empty) | 1a | Comma-separated `meta.caaIdentities` list. | +| `CERTCTL_ACME_SERVER_EAB_REQUIRED` | `false` | 1a | `meta.externalAccountRequired` advertisement. EAB enforcement is a follow-up; Phase 1a only advertises. | +| `CERTCTL_ACME_SERVER_ORDER_TTL` | `24h` | 2 | Reserved field, parsed in Phase 1a so operators can set it ahead of Phase 2's order endpoints. | +| `CERTCTL_ACME_SERVER_AUTHZ_TTL` | `24h` | 2 | Reserved. | +| `CERTCTL_ACME_SERVER_HTTP01_CONCURRENCY` | `10` | 3 | Reserved. | +| `CERTCTL_ACME_SERVER_DNS01_RESOLVER` | `8.8.8.8:53` | 3 | Reserved. | +| `CERTCTL_ACME_SERVER_DNS01_CONCURRENCY` | `10` | 3 | Reserved. | +| `CERTCTL_ACME_SERVER_TLSALPN01_CONCURRENCY` | `10` | 3 | Reserved. | + +## Per-profile auth mode + +Two modes per `certificate_profiles.acme_auth_mode`: + +- **`trust_authenticated`** (default for internal PKI). The JWS- + authenticated ACME account is trusted to issue certs for any + identifier the profile policy allows; there is no per-identifier + ownership proof. The most common certctl use case. +- **`challenge`**. Full HTTP-01 + DNS-01 + TLS-ALPN-01 validation per + RFC 8555 §8. Required when certctl is exposing public-trust-style PKI. + +A single certctl-server can serve both modes simultaneously — the mode +is read from the bound profile's column at request time, not cached at +server start. Operators can flip a profile's mode via SQL and the next +order picks up the new mode without restart. + +The `CERTCTL_ACME_SERVER_DEFAULT_AUTH_MODE` env var sets the default +value for newly-created profiles (e.g. via the certctl API). Existing +profile rows retain whatever value they were created with. + +## TLS trust bootstrap (read this before configuring cert-manager) + +When certctl-server uses a self-signed TLS bootstrap cert +(`deploy/test/certs/server.crt` is the demo default; see +[`docs/tls.md`](./tls.md)), cert-manager 1.15+ will refuse to talk to +the directory URL unless the certctl root is trusted. The fix lives in +`ClusterIssuer.spec.acme.caBundle`: + +```yaml +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: certctl-test +spec: + acme: + server: https://certctl.example.com:8443/acme/profile/prof-corp/directory + email: ops@example.com + caBundle: | + LS0tLS1CRUdJTi... # base64-encoded PEM of certctl's self-signed root + privateKeySecretRef: + name: certctl-test-account-key + solvers: + - http01: + ingress: + class: nginx +``` + +The `caBundle` value is the base64-encoded PEM of the root that signed +your certctl-server's TLS certificate. Extract it from your operator +bootstrap (e.g. `cat deploy/test/certs/ca.crt | base64 -w0`). + +This is the single biggest first-time-deploy footgun on the cert-manager +integration path. The full cert-manager walkthrough lands in Phase 6; +the `caBundle` requirement is flagged here in Phase 1a's docs because +operators hit it the moment they try to point a real ACME client at +certctl. + +## Endpoints (Phase 1a) + +Routes registered in `internal/api/router/router.go::RegisterHandlers`: + +| Method | Path | RFC ref | Auth | Description | +|--------|-------------------------------------------|-----------------|-----------|-------------| +| GET | `/acme/profile/{id}/directory` | RFC 8555 §7.1.1 | unauth | Per-profile directory document. | +| HEAD | `/acme/profile/{id}/new-nonce` | RFC 8555 §7.2 | unauth | Returns 200 + Replay-Nonce header. | +| GET | `/acme/profile/{id}/new-nonce` | RFC 8555 §7.2 | unauth | Returns 204 + Replay-Nonce header. | +| GET | `/acme/directory` | RFC 8555 §7.1.1 | unauth | Shorthand path; mirrors per-profile when `CERTCTL_ACME_SERVER_DEFAULT_PROFILE_ID` is set. | +| HEAD | `/acme/new-nonce` | RFC 8555 §7.2 | unauth | Shorthand. | +| GET | `/acme/new-nonce` | RFC 8555 §7.2 | unauth | Shorthand. | + +The remaining RFC 8555 endpoints (`new-account`, `account/{id}`, +`new-order`, `order/{id}`, `order/{id}/finalize`, `authz/{id}`, +`challenge/{id}`, `cert/{id}`, `key-change`, `revoke-cert`, +`renewal-info`) are advertised in the directory document but not yet +served — clients hitting them get a 404 until subsequent phases land. +The directory document includes their URLs because RFC 8555 doesn't +permit a partial directory. + +## Phases (cross-reference) + +| Phase | Status | Surface | +|-------|-------------|---------| +| 1a | live | directory + new-nonce + per-profile routing | +| 1b | not yet | new-account + JWS verifier (RFC 7515) | +| 2 | not yet | orders + authzs + finalize + cert download (trust_authenticated mode end-to-end) | +| 3 | not yet | HTTP-01 + DNS-01 + TLS-ALPN-01 challenge validation | +| 4 | not yet | key rollover + revocation + ARI (RFC 9773) | +| 5 | not yet | cert-manager integration test + production hardening | +| 6 | not yet | full operator-facing reference + walkthroughs + threat model | + +Track shipped phases via `git log --grep='acme-server:' --oneline`. + +## Operational notes (Phase 1a) + +- **Schema:** `migrations/000025_acme_server.up.sql` adds 5 ACME tables + + the `certificate_profiles.acme_auth_mode` column. Phase 1a actively + uses only `acme_nonces`. The full schema ships now so the migration + is stable and Phases 1b-4 don't need additional `CREATE TABLE` + migrations. + +- **Replay protection:** nonces are persisted in `acme_nonces` (NOT + in-memory). They survive server restart, which is required for the + RFC 8555 §6.5 replay defense to hold against a multi-replica + certctl-server fleet behind a load balancer. + +- **Metrics:** the service layer exposes per-op atomic counters via + `service.ACMEService.Metrics().Snapshot()`: + - `certctl_acme_directory_total` + - `certctl_acme_directory_failures_total` + - `certctl_acme_new_nonce_total` + - `certctl_acme_new_nonce_failures_total` + + Phase 1b will extend with `new_account` counters; Phase 2 with order + / finalize / cert; Phase 3 with per-challenge-type counters. + +- **Audit:** Phase 1a is read-mostly (directory + nonce). Phase 1b's + account-creation path will route through the canonical + `s.tx.WithinTx(...)` + `auditService.RecordEventWithTx(...)` pattern + so every account state mutation is paired with an `audit_events` + row. diff --git a/internal/api/acme/directory.go b/internal/api/acme/directory.go new file mode 100644 index 0000000..9663f00 --- /dev/null +++ b/internal/api/acme/directory.go @@ -0,0 +1,85 @@ +// Copyright (c) certctl +// SPDX-License-Identifier: BSL-1.1 + +// Package acme implements the ACME server-side protocol surface (RFC 8555 +// + RFC 9773 ARI). It is deliberately separate from +// internal/connector/issuer/acme/ which is the consumer surface (certctl +// talks UP to Let's Encrypt / ZeroSSL / pebble). The two surfaces share +// no types — the consumer's data model is client-shaped; the server's +// is request-handler-shaped. +// +// Phase 1a: directory + nonce + JSON-Problem (RFC 7807) error envelopes +// only. JWS verification, account resource, orders, challenges, key +// rollover, revocation, ARI all land in subsequent phases (1b → 4). +package acme + +import "fmt" + +// Directory is the JSON document RFC 8555 §7.1.1 mandates the server +// publish at /acme/profile//directory (and at /acme/directory when +// CERTCTL_ACME_SERVER_DEFAULT_PROFILE_ID is set). +// +// Each URL is the per-profile path the ACME client POSTs against. Even +// though Phase 1a only wires up new-nonce, the directory advertises +// the full surface — RFC 8555 doesn't permit a partial directory and +// clients use the directory's URL fields exclusively (they don't +// hand-construct paths from a base URL). +type Directory struct { + NewNonce string `json:"newNonce"` + NewAccount string `json:"newAccount"` + NewOrder string `json:"newOrder"` + RevokeCert string `json:"revokeCert"` + KeyChange string `json:"keyChange"` + // RenewalInfo (RFC 9773 ARI) lands in Phase 4. Omitted now via the + // `,omitempty` tag so the JSON output stays clean for clients that + // don't yet support ARI. + RenewalInfo string `json:"renewalInfo,omitempty"` + Meta *Meta `json:"meta,omitempty"` +} + +// Meta is the optional metadata block per RFC 8555 §7.1.1. Every field +// is operator-supplied via CERTCTL_ACME_SERVER_* env vars; an empty +// Meta is omitted from the marshaled directory. +type Meta struct { + TermsOfService string `json:"termsOfService,omitempty"` + Website string `json:"website,omitempty"` + CAAIdentities []string `json:"caaIdentities,omitempty"` + ExternalAccountRequired bool `json:"externalAccountRequired,omitempty"` +} + +// BuildDirectory constructs the per-profile directory document. +// +// baseURL is the per-profile base path (no trailing slash, e.g. +// "https://certctl.example.com/acme/profile/prof-corp"). The default- +// profile shorthand path passes the same baseURL — clients writing +// their config against the shorthand naturally re-derive the per- +// profile URLs from the directory. +// +// All five canonical RFC 8555 endpoints are populated; renewalInfo is +// populated only when ARIEnabled=true so Phase 1a (where ARI is +// non-functional) doesn't advertise a 404-returning URL. ARI flips on +// in Phase 4 along with the actual handler. +func BuildDirectory(baseURL, tos, website string, caa []string, eabRequired, ariEnabled bool) *Directory { + dir := &Directory{ + NewNonce: fmt.Sprintf("%s/new-nonce", baseURL), + NewAccount: fmt.Sprintf("%s/new-account", baseURL), + NewOrder: fmt.Sprintf("%s/new-order", baseURL), + RevokeCert: fmt.Sprintf("%s/revoke-cert", baseURL), + KeyChange: fmt.Sprintf("%s/key-change", baseURL), + } + if ariEnabled { + // RFC 9773 §4.1 publishes ARI as `renewalInfo`. Phase 4 wires + // the actual handler; until then, BuildDirectory callers pass + // ariEnabled=false. + dir.RenewalInfo = fmt.Sprintf("%s/renewal-info", baseURL) + } + if tos != "" || website != "" || len(caa) > 0 || eabRequired { + dir.Meta = &Meta{ + TermsOfService: tos, + Website: website, + CAAIdentities: caa, + ExternalAccountRequired: eabRequired, + } + } + return dir +} diff --git a/internal/api/acme/directory_test.go b/internal/api/acme/directory_test.go new file mode 100644 index 0000000..03c48a4 --- /dev/null +++ b/internal/api/acme/directory_test.go @@ -0,0 +1,113 @@ +// Copyright (c) certctl +// SPDX-License-Identifier: BSL-1.1 + +package acme + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestBuildDirectory_FullMeta(t *testing.T) { + d := BuildDirectory( + "https://server/acme/profile/prof-corp", + "https://example.com/tos", + "https://example.com", + []string{"example.com"}, + true, + false, + ) + if got, want := d.NewNonce, "https://server/acme/profile/prof-corp/new-nonce"; got != want { + t.Errorf("NewNonce = %q, want %q", got, want) + } + if got, want := d.NewAccount, "https://server/acme/profile/prof-corp/new-account"; got != want { + t.Errorf("NewAccount = %q, want %q", got, want) + } + if got, want := d.NewOrder, "https://server/acme/profile/prof-corp/new-order"; got != want { + t.Errorf("NewOrder = %q, want %q", got, want) + } + if got, want := d.RevokeCert, "https://server/acme/profile/prof-corp/revoke-cert"; got != want { + t.Errorf("RevokeCert = %q, want %q", got, want) + } + if got, want := d.KeyChange, "https://server/acme/profile/prof-corp/key-change"; got != want { + t.Errorf("KeyChange = %q, want %q", got, want) + } + if d.RenewalInfo != "" { + t.Errorf("RenewalInfo should be empty when ariEnabled=false; got %q", d.RenewalInfo) + } + if d.Meta == nil { + t.Fatal("Meta should be populated when any meta field is set") + } + if d.Meta.TermsOfService != "https://example.com/tos" { + t.Errorf("TermsOfService = %q", d.Meta.TermsOfService) + } + if d.Meta.Website != "https://example.com" { + t.Errorf("Website = %q", d.Meta.Website) + } + if !d.Meta.ExternalAccountRequired { + t.Error("ExternalAccountRequired should be true") + } + if len(d.Meta.CAAIdentities) != 1 || d.Meta.CAAIdentities[0] != "example.com" { + t.Errorf("CAAIdentities = %v", d.Meta.CAAIdentities) + } +} + +func TestBuildDirectory_NoMeta(t *testing.T) { + d := BuildDirectory("https://server/acme/profile/prof-corp", "", "", nil, false, false) + if d.Meta != nil { + t.Errorf("Meta should be nil when all meta fields zero; got %+v", d.Meta) + } +} + +func TestBuildDirectory_EABRequiredOnly(t *testing.T) { + d := BuildDirectory("https://server/acme/profile/prof-corp", "", "", nil, true, false) + if d.Meta == nil { + t.Fatal("Meta should be populated when EAB is required") + } + if !d.Meta.ExternalAccountRequired { + t.Error("ExternalAccountRequired should be true") + } + if d.Meta.TermsOfService != "" || d.Meta.Website != "" || len(d.Meta.CAAIdentities) != 0 { + t.Errorf("only EAB should be set; meta = %+v", d.Meta) + } +} + +func TestBuildDirectory_ARIEnabled(t *testing.T) { + d := BuildDirectory("https://server/acme/profile/prof-corp", "", "", nil, false, true) + if d.RenewalInfo == "" { + t.Fatal("RenewalInfo should be populated when ariEnabled=true") + } + if !strings.HasSuffix(d.RenewalInfo, "/renewal-info") { + t.Errorf("RenewalInfo = %q; expected suffix /renewal-info", d.RenewalInfo) + } +} + +func TestBuildDirectory_JSONShape(t *testing.T) { + // RFC 8555 §7.1.1 dictates the JSON field names. A regression here + // would break every ACME client. + d := BuildDirectory("https://server/acme/profile/prof-corp", "", "", nil, false, false) + b, err := json.Marshal(d) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + got := string(b) + for _, want := range []string{ + `"newNonce":"https://server/acme/profile/prof-corp/new-nonce"`, + `"newAccount":"https://server/acme/profile/prof-corp/new-account"`, + `"newOrder":"https://server/acme/profile/prof-corp/new-order"`, + `"revokeCert":"https://server/acme/profile/prof-corp/revoke-cert"`, + `"keyChange":"https://server/acme/profile/prof-corp/key-change"`, + } { + if !strings.Contains(got, want) { + t.Errorf("JSON missing %q\nGot: %s", want, got) + } + } + // renewalInfo + meta should be omitted. + if strings.Contains(got, "renewalInfo") { + t.Errorf("renewalInfo should be omitted when ARI disabled; got %s", got) + } + if strings.Contains(got, `"meta"`) { + t.Errorf("meta should be omitted when no fields set; got %s", got) + } +} diff --git a/internal/api/acme/errors.go b/internal/api/acme/errors.go new file mode 100644 index 0000000..e2fe1ae --- /dev/null +++ b/internal/api/acme/errors.go @@ -0,0 +1,127 @@ +// Copyright (c) certctl +// SPDX-License-Identifier: BSL-1.1 + +package acme + +import ( + "encoding/json" + "net/http" +) + +// ProblemContentType is the MIME type RFC 7807 §3 mandates for the +// JSON-Problem error envelope. ACME inherits this from RFC 8555 §6.7. +const ProblemContentType = "application/problem+json" + +// ACME error type URN prefix per RFC 8555 §6.7. +const acmeErrorPrefix = "urn:ietf:params:acme:error:" + +// Problem is the RFC 7807 Problem Details document. ACME extends it +// per RFC 8555 §6.7 with subproblems (per-identifier-rejection +// breakdowns) and identifier (the failing identifier on +// rejectedIdentifier). Both extension fields land in Phase 2 along +// with the order endpoints; Phase 1a only emits the base shape. +type Problem struct { + Type string `json:"type"` + Detail string `json:"detail"` + Status int `json:"status"` + Subproblems []Problem `json:"subproblems,omitempty"` + Identifier *Identifier `json:"identifier,omitempty"` +} + +// Identifier is the ACME identifier shape (RFC 8555 §7.4). Defined here +// (rather than in a Phase-2-only file) so Phase 1a's Problem struct can +// reference *Identifier without a forward-package-dependency. +type Identifier struct { + Type string `json:"type"` + Value string `json:"value"` +} + +// Malformed is RFC 8555 §6.7's "request body did not parse / decode" / +// "the JWS was malformed" / "payload JSON was malformed" error. HTTP +// status 400. +func Malformed(detail string) Problem { + return Problem{ + Type: acmeErrorPrefix + "malformed", + Detail: detail, + Status: http.StatusBadRequest, + } +} + +// ServerInternal is the catch-all for unexpected server-side errors. +// HTTP status 500. The detail string is operator-facing; per the +// master prompt's acquisition-readiness criterion #10 it MUST NOT +// echo SQL errors, internal trace IDs, or credential bytes. +func ServerInternal(detail string) Problem { + return Problem{ + Type: acmeErrorPrefix + "serverInternal", + Detail: detail, + Status: http.StatusInternalServerError, + } +} + +// UserActionRequired is RFC 8555 §6.7's "the user has to do something +// out of band before this request will succeed" error. We return it +// from the /acme/* shorthand path family when +// CERTCTL_ACME_SERVER_DEFAULT_PROFILE_ID is not set — the operator +// has to either set the env var or update the client to use +// /acme/profile//*. HTTP status 403 per RFC 8555. +func UserActionRequired(detail string) Problem { + return Problem{ + Type: acmeErrorPrefix + "userActionRequired", + Detail: detail, + Status: http.StatusForbidden, + } +} + +// UnsupportedContentType is RFC 7807-shaped (no ACME error type) for +// requests with a Content-Type the endpoint doesn't accept. Phase 1b +// will switch the JWS endpoints to require +// "application/jose+json" specifically; Phase 1a's directory + nonce +// have no Content-Type requirements and never emit this. +func UnsupportedContentType(got string) Problem { + return Problem{ + Type: "about:blank", + Detail: "unsupported content type: " + got, + Status: http.StatusUnsupportedMediaType, + } +} + +// AccountDoesNotExist (RFC 8555 §7.3.1) is what the JWS verifier returns +// when the request's `kid` points at an unknown account. Phase 1b +// implements the verifier; this shape is exposed in Phase 1a for the +// errors_test.go round-trip cases. +func AccountDoesNotExist(detail string) Problem { + return Problem{ + Type: acmeErrorPrefix + "accountDoesNotExist", + Detail: detail, + Status: http.StatusBadRequest, + } +} + +// BadNonce is what the JWS verifier returns on a missing / replayed / +// expired nonce per RFC 8555 §6.5.1. Phase 1b wires the verifier; +// shape exposed now so errors_test.go can round-trip it. +func BadNonce(detail string) Problem { + return Problem{ + Type: acmeErrorPrefix + "badNonce", + Detail: detail, + Status: http.StatusBadRequest, + } +} + +// WriteProblem renders a Problem as RFC 7807 JSON to w, with the +// appropriate Content-Type and status. Any nil-Problem is rendered as +// 500 + serverInternal so the handler never panics on a forgotten +// error path. +func WriteProblem(w http.ResponseWriter, p Problem) { + if p.Status == 0 { + p = ServerInternal("unspecified error") + } + w.Header().Set("Content-Type", ProblemContentType) + w.WriteHeader(p.Status) + // Marshaling can only fail on un-encodable types; Problem only + // uses primitives + slices so json.Marshal cannot fail. The + // _ = ... discard mirrors how response.go handles json.Encoder + // errors. + _ = json.NewEncoder(w).Encode(p) +} diff --git a/internal/api/acme/errors_test.go b/internal/api/acme/errors_test.go new file mode 100644 index 0000000..5db1afd --- /dev/null +++ b/internal/api/acme/errors_test.go @@ -0,0 +1,106 @@ +// Copyright (c) certctl +// SPDX-License-Identifier: BSL-1.1 + +package acme + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestProblem_Malformed_Shape(t *testing.T) { + p := Malformed("payload was not valid JSON") + if p.Status != http.StatusBadRequest { + t.Errorf("status = %d, want %d", p.Status, http.StatusBadRequest) + } + if p.Type != "urn:ietf:params:acme:error:malformed" { + t.Errorf("type = %q", p.Type) + } + if p.Detail != "payload was not valid JSON" { + t.Errorf("detail = %q", p.Detail) + } + // Subproblems and Identifier are Phase-2 extensions; both stay empty + // for a Phase-1a-emitted problem. + if len(p.Subproblems) != 0 { + t.Errorf("subproblems should be empty; got %v", p.Subproblems) + } + if p.Identifier != nil { + t.Errorf("identifier should be nil; got %+v", p.Identifier) + } +} + +func TestProblem_AllHelperShapes(t *testing.T) { + cases := []struct { + name string + p Problem + wantType string + wantStatus int + }{ + {"Malformed", Malformed("x"), "urn:ietf:params:acme:error:malformed", http.StatusBadRequest}, + {"ServerInternal", ServerInternal("x"), "urn:ietf:params:acme:error:serverInternal", http.StatusInternalServerError}, + {"UserActionRequired", UserActionRequired("x"), "urn:ietf:params:acme:error:userActionRequired", http.StatusForbidden}, + {"AccountDoesNotExist", AccountDoesNotExist("x"), "urn:ietf:params:acme:error:accountDoesNotExist", http.StatusBadRequest}, + {"BadNonce", BadNonce("x"), "urn:ietf:params:acme:error:badNonce", http.StatusBadRequest}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if tc.p.Type != tc.wantType { + t.Errorf("type = %q, want %q", tc.p.Type, tc.wantType) + } + if tc.p.Status != tc.wantStatus { + t.Errorf("status = %d, want %d", tc.p.Status, tc.wantStatus) + } + }) + } +} + +func TestProblem_UnsupportedContentType(t *testing.T) { + p := UnsupportedContentType("application/json") + if p.Status != http.StatusUnsupportedMediaType { + t.Errorf("status = %d, want 415", p.Status) + } + if p.Type != "about:blank" { + t.Errorf("UnsupportedContentType uses RFC 7807 about:blank; got %q", p.Type) + } + if !strings.Contains(p.Detail, "application/json") { + t.Errorf("detail should echo content-type; got %q", p.Detail) + } +} + +func TestWriteProblem_Headers(t *testing.T) { + rec := httptest.NewRecorder() + WriteProblem(rec, Malformed("oops")) + + if got, want := rec.Code, http.StatusBadRequest; got != want { + t.Errorf("status = %d, want %d", got, want) + } + if got, want := rec.Header().Get("Content-Type"), ProblemContentType; got != want { + t.Errorf("content-type = %q, want %q", got, want) + } + + var p Problem + if err := json.NewDecoder(rec.Body).Decode(&p); err != nil { + t.Fatalf("Decode: %v", err) + } + if p.Type != "urn:ietf:params:acme:error:malformed" { + t.Errorf("decoded type = %q", p.Type) + } +} + +func TestWriteProblem_NilStatusFallsBackTo500(t *testing.T) { + // Defensive check: a hand-constructed Problem with Status=0 (e.g. + // from a forgotten error path) still renders cleanly as 500 + + // serverInternal rather than emitting an HTTP/0 response. + rec := httptest.NewRecorder() + WriteProblem(rec, Problem{}) + + if got, want := rec.Code, http.StatusInternalServerError; got != want { + t.Errorf("status = %d, want %d", got, want) + } + if got, want := rec.Header().Get("Content-Type"), ProblemContentType; got != want { + t.Errorf("content-type = %q, want %q", got, want) + } +} diff --git a/internal/api/acme/nonce.go b/internal/api/acme/nonce.go new file mode 100644 index 0000000..38c5d83 --- /dev/null +++ b/internal/api/acme/nonce.go @@ -0,0 +1,47 @@ +// Copyright (c) certctl +// SPDX-License-Identifier: BSL-1.1 + +package acme + +import ( + "context" + "crypto/rand" + "encoding/base64" + "time" +) + +// NonceStore is the persistence-layer contract for ACME nonces. The +// production implementation lives at internal/repository/postgres/acme.go +// and is DB-backed (NOT in-memory) — replay protection requires the +// store to outlast the client's nonce caching window. +// +// Issue creates a new nonce and stores it with a TTL. The string return +// is what the handler echoes in the Replay-Nonce response header. +// +// Consume marks a nonce used and returns an error if the nonce is +// missing, already used, or expired. The handler maps that error to +// urn:ietf:params:acme:error:badNonce per RFC 8555 §6.5.1. +// +// Phase 1a: Issue is wired (every directory + new-nonce response carries +// a Replay-Nonce header). Consume is exposed but not yet invoked — +// JWS-authenticated POSTs (which consume nonces) arrive in Phase 1b. +type NonceStore interface { + Issue(ctx context.Context, ttl time.Duration) (string, error) + Consume(ctx context.Context, nonce string) error +} + +// nonceByteLen is 32 bytes (256 bits) of entropy. RFC 8555 §6.5.1 only +// requires nonces to be hard-to-guess; 256 bits is overkill on purpose +// (matches the consumer-side ACME library + every other ACME server). +const nonceByteLen = 32 + +// GenerateNonce returns 32 cryptographically-random bytes encoded as +// base64url-no-padding per RFC 7515 §2 (the encoding ACME wire format +// uses for the protected-header nonce field). +func GenerateNonce() (string, error) { + b := make([]byte, nonceByteLen) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} diff --git a/internal/api/acme/nonce_test.go b/internal/api/acme/nonce_test.go new file mode 100644 index 0000000..943ebed --- /dev/null +++ b/internal/api/acme/nonce_test.go @@ -0,0 +1,46 @@ +// Copyright (c) certctl +// SPDX-License-Identifier: BSL-1.1 + +package acme + +import ( + "encoding/base64" + "testing" +) + +func TestGenerateNonce_LengthAndCharset(t *testing.T) { + n, err := GenerateNonce() + if err != nil { + t.Fatalf("GenerateNonce: %v", err) + } + // base64.RawURLEncoding emits ceil(N*8/6) chars = ceil(32*8/6) = 43. + if got, want := len(n), 43; got != want { + t.Errorf("nonce length = %d, want %d", got, want) + } + // Charset must decode under base64url-no-padding. + raw, err := base64.RawURLEncoding.DecodeString(n) + if err != nil { + t.Fatalf("nonce did not decode under base64url-no-padding: %v", err) + } + if len(raw) != nonceByteLen { + t.Errorf("decoded nonce = %d bytes, want %d", len(raw), nonceByteLen) + } +} + +func TestGenerateNonce_Distinct(t *testing.T) { + // Statistical sanity check, NOT cryptographic strength proof. + // 256 bits of entropy means the probability of two consecutive + // values colliding is ~2^-256 — well below the threshold for a + // flaky-test-on-the-cosmos timeline. + a, err := GenerateNonce() + if err != nil { + t.Fatalf("GenerateNonce a: %v", err) + } + b, err := GenerateNonce() + if err != nil { + t.Fatalf("GenerateNonce b: %v", err) + } + if a == b { + t.Errorf("two consecutive nonces collided: %q", a) + } +} diff --git a/internal/api/handler/acme.go b/internal/api/handler/acme.go new file mode 100644 index 0000000..26238da --- /dev/null +++ b/internal/api/handler/acme.go @@ -0,0 +1,170 @@ +// Copyright (c) certctl +// SPDX-License-Identifier: BSL-1.1 + +package handler + +import ( + "context" + "encoding/json" + "errors" + "net/http" + + "github.com/shankar0123/certctl/internal/api/acme" + "github.com/shankar0123/certctl/internal/service" +) + +// ACMEService is the handler-facing surface for the ACME server. The +// service-layer concrete type is *service.ACMEService; the interface +// definition lives here to keep the handler import-direction +// canonical (handler imports service, not the reverse). Phase 1a +// pins two methods; Phase 1b extends with VerifyJWS, NewAccount, +// LookupAccount, UpdateAccount, DeactivateAccount. +type ACMEService interface { + BuildDirectory(ctx context.Context, profileID, baseURL string) (*acme.Directory, error) + IssueNonce(ctx context.Context) (string, error) +} + +// ACMEHandler exposes the ACME server's RFC 8555 endpoints under the +// per-profile path /acme/profile//* and (optionally) the +// /acme/* shorthand when CERTCTL_ACME_SERVER_DEFAULT_PROFILE_ID is +// set. 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) +// +// Phase 1b adds new-account + account/; Phase 2 adds new-order + +// order/(/finalize) + authz/ + cert/; Phase 3 adds +// challenge/; Phase 4 adds key-change + revoke-cert + renewal-info. +// +// Handler shape mirrors internal/api/handler/scep.go:73-91 (struct +// holding the service interface, factory function returning the +// struct value). +type ACMEHandler struct { + svc ACMEService +} + +// NewACMEHandler constructs an ACMEHandler. Returns the value (not a +// pointer) — same convention as NewSCEPHandler at scep.go:89. +func NewACMEHandler(svc ACMEService) ACMEHandler { + return ACMEHandler{svc: svc} +} + +// Directory handles GET requests to the directory URL. The Go 1.22+ +// stdlib router parses the {id} path parameter via r.PathValue("id"). +// When the path is /acme/directory (no profile in URL), PathValue +// returns ""; the service layer applies the +// CERTCTL_ACME_SERVER_DEFAULT_PROFILE_ID fallback (or returns +// userActionRequired if unset). +func (h ACMEHandler) Directory(w http.ResponseWriter, r *http.Request) { + profileID := r.PathValue("id") + baseURL := h.directoryBaseURL(r, profileID) + + dir, err := h.svc.BuildDirectory(r.Context(), profileID, baseURL) + if err != nil { + writeServiceError(w, err) + return + } + + // RFC 8555 §6.5: every successful response carries Replay-Nonce. + // The directory endpoint is not JWS-authenticated but ACME clients + // expect the header so they can use it on the very next POST. + if nonce, err := h.svc.IssueNonce(r.Context()); err == nil { + w.Header().Set("Replay-Nonce", nonce) + } + w.Header().Set("Cache-Control", "public, max-age=0, no-cache") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(dir) +} + +// NewNonce handles HEAD and GET on the new-nonce URL. +// +// RFC 8555 §7.2: +// - HEAD MUST return 200 with Replay-Nonce + zero-length body. +// - GET MUST return 204 No Content with Replay-Nonce + zero-length body. +// +// Both verbs MUST set Cache-Control: no-store so middleboxes don't +// inadvertently re-serve a stale nonce. +// +// We resolve the profile here (rather than passing it through the +// service) only to validate it exists — the nonce itself is global +// to the server (one acme_nonces table), but if the operator hits +// /acme/profile//new-nonce we return 404 so the path-shape +// failure is operator-visible. +func (h ACMEHandler) NewNonce(w http.ResponseWriter, r *http.Request) { + profileID := r.PathValue("id") + // Same profile-resolution path as Directory — go through + // BuildDirectory only to leverage its profile-not-found / user- + // action-required mapping. The directory document is not used. + baseURL := h.directoryBaseURL(r, profileID) + if _, err := h.svc.BuildDirectory(r.Context(), profileID, baseURL); err != nil { + writeServiceError(w, err) + return + } + + nonce, err := h.svc.IssueNonce(r.Context()) + if err != nil { + acme.WriteProblem(w, acme.ServerInternal("nonce issuance failed")) + return + } + + w.Header().Set("Replay-Nonce", nonce) + w.Header().Set("Cache-Control", "no-store") + if r.Method == http.MethodHead { + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// directoryBaseURL composes the per-profile base URL the directory's +// inner URLs are built against. The composition lives in the handler +// (NOT the service) because it depends on the inbound request's +// scheme + host + observed path; the service layer would need to +// import net/http to do this. +// +// For requests on /acme/profile//* we strip the trailing path +// element to produce the base. For shorthand /acme/* requests we +// strip the trailing element from /acme — the result is just the +// scheme://host/acme prefix, which the service then uses to build +// /acme/new-nonce, /acme/new-account, etc. +func (h ACMEHandler) directoryBaseURL(r *http.Request, profileID string) string { + scheme := "https" + if r.TLS == nil { + // HTTPS-only architecture decision (CLAUDE.md): the listener + // is TLS 1.3 pinned. r.TLS == nil only happens in tests with + // httptest.NewServer (non-TLS); honor http: for those. + scheme = "http" + } + if profileID != "" { + return scheme + "://" + r.Host + "/acme/profile/" + profileID + } + return scheme + "://" + r.Host + "/acme" +} + +// writeServiceError maps service-layer sentinels to RFC 7807 + RFC +// 8555 §6.7 problem responses. Centralized so every handler method +// gets identical mapping; future Phase 1b/2/3/4 sentinels extend +// the switch. +func writeServiceError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, service.ErrACMEUserActionRequired): + acme.WriteProblem(w, acme.UserActionRequired( + "this server requires the per-profile path /acme/profile//* — "+ + "set CERTCTL_ACME_SERVER_DEFAULT_PROFILE_ID for /acme/* shorthand")) + case errors.Is(err, service.ErrACMEProfileNotFound): + acme.WriteProblem(w, acme.Problem{ + Type: "urn:ietf:params:acme:error:userActionRequired", + Detail: "profile not found", + Status: http.StatusNotFound, + }) + default: + // Avoid leaking internal error text per master-prompt + // criterion #10 (operator-actionable errors with no info + // leak). The detail is operator-facing but generic. + acme.WriteProblem(w, acme.ServerInternal("ACME server error")) + } +} diff --git a/internal/api/handler/acme_handler_test.go b/internal/api/handler/acme_handler_test.go new file mode 100644 index 0000000..67e47e9 --- /dev/null +++ b/internal/api/handler/acme_handler_test.go @@ -0,0 +1,242 @@ +// Copyright (c) certctl +// SPDX-License-Identifier: BSL-1.1 + +package handler + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/shankar0123/certctl/internal/api/acme" + "github.com/shankar0123/certctl/internal/service" +) + +// mockACMEService implements ACMEService for handler-level tests. +// Mirrors the mockSCEPService pattern at scep_handler_test.go (struct +// holding canned responses + an err field per method). +type mockACMEService struct { + BuildDirectoryFn func(ctx context.Context, profileID, baseURL string) (*acme.Directory, error) + IssueNonceFn func(ctx context.Context) (string, error) +} + +func (m *mockACMEService) BuildDirectory(ctx context.Context, profileID, baseURL string) (*acme.Directory, error) { + if m.BuildDirectoryFn != nil { + return m.BuildDirectoryFn(ctx, profileID, baseURL) + } + return acme.BuildDirectory(baseURL, "", "", nil, false, false), nil +} + +func (m *mockACMEService) IssueNonce(ctx context.Context) (string, error) { + if m.IssueNonceFn != nil { + return m.IssueNonceFn(ctx) + } + return "test-nonce-12345", nil +} + +// newACMETestServer wires the ACMEHandler against the mock + a stdlib +// ServeMux configured exactly the way internal/api/router/router.go +// does it in production. Routes: +// +// 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) +func newACMETestServer(t *testing.T, mock *mockACMEService) *httptest.Server { + t.Helper() + h := NewACMEHandler(mock) + mux := http.NewServeMux() + mux.HandleFunc("GET /acme/profile/{id}/directory", h.Directory) + mux.HandleFunc("HEAD /acme/profile/{id}/new-nonce", h.NewNonce) + mux.HandleFunc("GET /acme/profile/{id}/new-nonce", h.NewNonce) + mux.HandleFunc("GET /acme/directory", h.Directory) + mux.HandleFunc("HEAD /acme/new-nonce", h.NewNonce) + mux.HandleFunc("GET /acme/new-nonce", h.NewNonce) + return httptest.NewServer(mux) +} + +func TestACMEHandler_Directory_HappyPath(t *testing.T) { + mock := &mockACMEService{} + srv := newACMETestServer(t, mock) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/acme/profile/prof-corp/directory") + if err != nil { + t.Fatalf("Get: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("status = %d, want 200", resp.StatusCode) + } + if got := resp.Header.Get("Content-Type"); got != "application/json" { + t.Errorf("content-type = %q", got) + } + if got := resp.Header.Get("Replay-Nonce"); got == "" { + t.Error("Replay-Nonce header missing on directory response") + } + + var dir acme.Directory + if err := json.NewDecoder(resp.Body).Decode(&dir); err != nil { + t.Fatalf("Decode: %v", err) + } + if !strings.Contains(dir.NewNonce, "/acme/profile/prof-corp/new-nonce") { + t.Errorf("NewNonce = %q", dir.NewNonce) + } +} + +func TestACMEHandler_Directory_UnknownProfile(t *testing.T) { + mock := &mockACMEService{ + BuildDirectoryFn: func(ctx context.Context, profileID, baseURL string) (*acme.Directory, error) { + return nil, service.ErrACMEProfileNotFound + }, + } + srv := newACMETestServer(t, mock) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/acme/profile/missing/directory") + if err != nil { + t.Fatalf("Get: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNotFound { + t.Errorf("status = %d, want 404", resp.StatusCode) + } + if got := resp.Header.Get("Content-Type"); got != acme.ProblemContentType { + t.Errorf("content-type = %q, want %q", got, acme.ProblemContentType) + } +} + +func TestACMEHandler_NewNonce_HEAD(t *testing.T) { + mock := &mockACMEService{} + srv := newACMETestServer(t, mock) + defer srv.Close() + + req, _ := http.NewRequest(http.MethodHead, srv.URL+"/acme/profile/prof-corp/new-nonce", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("HEAD: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("status = %d, want 200 (HEAD)", resp.StatusCode) + } + if got := resp.Header.Get("Replay-Nonce"); got != "test-nonce-12345" { + t.Errorf("Replay-Nonce = %q", got) + } + if got := resp.Header.Get("Cache-Control"); got != "no-store" { + t.Errorf("Cache-Control = %q, want no-store", got) + } + if resp.ContentLength > 0 { + t.Errorf("HEAD body should be zero-length; got Content-Length=%d", resp.ContentLength) + } +} + +func TestACMEHandler_NewNonce_GET(t *testing.T) { + mock := &mockACMEService{} + srv := newACMETestServer(t, mock) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/acme/profile/prof-corp/new-nonce") + if err != nil { + t.Fatalf("Get: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + t.Errorf("status = %d, want 204 (GET)", resp.StatusCode) + } + if got := resp.Header.Get("Replay-Nonce"); got != "test-nonce-12345" { + t.Errorf("Replay-Nonce = %q", got) + } + if got := resp.Header.Get("Cache-Control"); got != "no-store" { + t.Errorf("Cache-Control = %q", got) + } +} + +func TestACMEHandler_Shorthand_DefaultProfileSet(t *testing.T) { + // Service-layer mock returns a directory; handler test asserts the + // /acme/directory shorthand reaches the same handler path as the + // per-profile directory. + mock := &mockACMEService{} + srv := newACMETestServer(t, mock) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/acme/directory") + if err != nil { + t.Fatalf("Get: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("status = %d, want 200", resp.StatusCode) + } + var dir acme.Directory + if err := json.NewDecoder(resp.Body).Decode(&dir); err != nil { + t.Fatalf("Decode: %v", err) + } + if !strings.HasSuffix(dir.NewNonce, "/acme/new-nonce") { + t.Errorf("NewNonce = %q (shorthand path expected)", dir.NewNonce) + } +} + +func TestACMEHandler_Shorthand_DefaultProfileUnset(t *testing.T) { + mock := &mockACMEService{ + BuildDirectoryFn: func(ctx context.Context, profileID, baseURL string) (*acme.Directory, error) { + return nil, service.ErrACMEUserActionRequired + }, + } + srv := newACMETestServer(t, mock) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/acme/directory") + if err != nil { + t.Fatalf("Get: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusForbidden { + t.Errorf("status = %d, want 403", resp.StatusCode) + } + if got := resp.Header.Get("Content-Type"); got != acme.ProblemContentType { + t.Errorf("content-type = %q, want %q", got, acme.ProblemContentType) + } + var p acme.Problem + if err := json.NewDecoder(resp.Body).Decode(&p); err != nil { + t.Fatalf("Decode: %v", err) + } + if p.Type != "urn:ietf:params:acme:error:userActionRequired" { + t.Errorf("Problem.Type = %q", p.Type) + } +} + +func TestACMEHandler_NewNonce_ServiceError(t *testing.T) { + mock := &mockACMEService{ + IssueNonceFn: func(ctx context.Context) (string, error) { + return "", errors.New("disk full") + }, + } + srv := newACMETestServer(t, mock) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/acme/profile/prof-corp/new-nonce") + if err != nil { + t.Fatalf("Get: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusInternalServerError { + t.Errorf("status = %d, want 500", resp.StatusCode) + } + if got := resp.Header.Get("Content-Type"); got != acme.ProblemContentType { + t.Errorf("content-type = %q", got) + } +} diff --git a/internal/api/router/openapi_parity_test.go b/internal/api/router/openapi_parity_test.go index a8b23c5..974685c 100644 --- a/internal/api/router/openapi_parity_test.go +++ b/internal/api/router/openapi_parity_test.go @@ -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) { diff --git a/internal/api/router/router.go b/internal/api/router/router.go index f298d8b..efc9318 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -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//* 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/, 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 diff --git a/internal/config/config.go b/internal/config/config.go index 9c20342..456d43f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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//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), diff --git a/internal/repository/postgres/acme.go b/internal/repository/postgres/acme.go new file mode 100644 index 0000000..24e8bb1 --- /dev/null +++ b/internal/repository/postgres/acme.go @@ -0,0 +1,86 @@ +// Copyright (c) certctl +// SPDX-License-Identifier: BSL-1.1 + +package postgres + +import ( + "context" + "database/sql" + "fmt" + "time" +) + +// ACMERepository implements the ACME server's persistence layer +// (RFC 8555 + RFC 9773 ARI). Phase 1a wires only nonce operations +// (IssueNonce + ConsumeNonce); Phase 1b extends with account CRUD, +// Phase 2 with order/authz/challenge CRUD, Phase 4 with the +// key-rollover atomic update path. +type ACMERepository struct { + db *sql.DB +} + +// NewACMERepository constructs an ACMERepository wrapping the supplied +// *sql.DB. The constructor is symmetric with NewAuditRepository, +// NewProfileRepository, etc. — main.go owns the lifecycle. +func NewACMERepository(db *sql.DB) *ACMERepository { + return &ACMERepository{db: db} +} + +// IssueNonce inserts a new ACME nonce row with the given TTL. The +// caller (typically ACMEService.IssueNonce) is responsible for +// generating the nonce string itself via acme.GenerateNonce; this +// method is the persistence write. +// +// RFC 8555 §6.5: nonces issued by the server can be redeemed exactly +// once. The PRIMARY KEY guarantees insertion uniqueness; ConsumeNonce +// flips the `used` column atomically so a replay sees `used=true`. +func (r *ACMERepository) IssueNonce(ctx context.Context, nonce string, ttl time.Duration) error { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO acme_nonces (nonce, issued_at, expires_at, used) + VALUES ($1, NOW(), $2, FALSE) + `, nonce, time.Now().Add(ttl)) + if err != nil { + return fmt.Errorf("acme: insert nonce: %w", err) + } + return nil +} + +// ConsumeNonce flips the nonce's `used` column to true atomically. +// Returns sql.ErrNoRows if: +// +// - the nonce was never issued (caller's payload was forged or +// truncated) +// - the nonce was already consumed (replay attempt) +// - the nonce has expired (CERTCTL_ACME_SERVER_NONCE_TTL window +// elapsed since issuance) +// +// All three failure modes are mapped by the JWS verifier (Phase 1b) +// to urn:ietf:params:acme:error:badNonce per RFC 8555 §6.5.1. Phase +// 1a does not yet call ConsumeNonce — the JWS-authenticated POST +// path arrives in Phase 1b. +// +// The single UPDATE statement is the atomic primitive: a concurrent +// second consume races for the same row, but only one of them flips +// `used` from false → true. Postgres's row-level locking serializes +// the writes; the loser's UPDATE matches zero rows (because used is +// already true) and returns sql.ErrNoRows. +func (r *ACMERepository) ConsumeNonce(ctx context.Context, nonce string) error { + res, err := r.db.ExecContext(ctx, ` + UPDATE acme_nonces + SET used = TRUE + WHERE nonce = $1 + AND used = FALSE + AND expires_at > NOW() + `, nonce) + if err != nil { + return fmt.Errorf("acme: consume nonce: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("acme: consume nonce rows affected: %w", err) + } + if n == 0 { + return sql.ErrNoRows + } + return nil +} diff --git a/internal/service/acme.go b/internal/service/acme.go new file mode 100644 index 0000000..e7f7f07 --- /dev/null +++ b/internal/service/acme.go @@ -0,0 +1,209 @@ +// Copyright (c) certctl +// SPDX-License-Identifier: BSL-1.1 + +package service + +import ( + "context" + "errors" + "fmt" + "sync/atomic" + "time" + + "github.com/shankar0123/certctl/internal/api/acme" + "github.com/shankar0123/certctl/internal/config" + "github.com/shankar0123/certctl/internal/domain" + "github.com/shankar0123/certctl/internal/repository" +) + +// ACMERepo is the persistence-layer surface ACMEService consumes for +// nonce + (later phases) account / order / authz / challenge state. +// Phase 1a wires only the nonce path; the interface is tightened in +// Phase 1b along with the AccountService. +// +// Defining the interface in the service package (rather than +// internal/repository/interfaces.go) keeps the cross-phase blast +// radius small: when Phase 1b adds CreateAccountWithTx / +// GetAccountByThumbprint / etc., only this file's interface and the +// concrete postgres ACMERepository move together. Mock implementations +// in tests satisfy this interface without depending on the postgres +// package. +type ACMERepo interface { + IssueNonce(ctx context.Context, nonce string, ttl time.Duration) error + ConsumeNonce(ctx context.Context, nonce string) error +} + +// profileLookup is the minimum surface ACMEService needs to resolve a +// per-profile request. Defined as an interface (rather than taking a +// concrete *postgres.ProfileRepository) so tests can inject an in-memory +// fake without spinning up Postgres. +type profileLookup interface { + Get(ctx context.Context, id string) (*domain.CertificateProfile, error) +} + +// ACMEService orchestrates the ACME server's RFC 8555 surface. Phase 1a +// implements: +// +// - BuildDirectory: returns the per-profile directory document. +// - IssueNonce: returns a Replay-Nonce, persisted with TTL. +// +// Phase 1b will extend with VerifyJWS, NewAccount, LookupAccount, +// UpdateAccount, DeactivateAccount. +// +// The struct deliberately holds raw config rather than per-field +// extracted values — the directory builder uses 4 of the 11 fields +// and reading them lazily keeps the constructor signature tight. +type ACMEService struct { + repo ACMERepo + profiles profileLookup + cfg config.ACMEServerConfig + metrics *ACMEMetrics +} + +// NewACMEService constructs an ACMEService. The constructor matches +// certctl's per-service convention: required dependencies in the +// argument list (repo, profile lookup, config), optional wiring via +// post-construction setters (metrics is wired now to keep the +// Phase-1a-only footprint clean; Phase 1b adds SetTransactor + +// SetAuditService for the JWS-authenticated POST path). +func NewACMEService(repo ACMERepo, profiles profileLookup, cfg config.ACMEServerConfig) *ACMEService { + return &ACMEService{ + repo: repo, + profiles: profiles, + cfg: cfg, + metrics: NewACMEMetrics(), + } +} + +// Metrics returns the per-op counter snapshotter. cmd/server/main.go +// passes this into MetricsHandler so the Prometheus exposer picks up +// the per-op signals. +func (s *ACMEService) Metrics() *ACMEMetrics { return s.metrics } + +// ErrACMEUserActionRequired is returned by BuildDirectory when the +// caller hits the /acme/* shorthand path without +// CERTCTL_ACME_SERVER_DEFAULT_PROFILE_ID being set. Handler maps to +// RFC 7807 + RFC 8555 §6.7 userActionRequired. +var ErrACMEUserActionRequired = errors.New("acme: default profile not configured; use /acme/profile//*") + +// ErrACMEProfileNotFound is returned when the profile in the request +// path doesn't exist. Handler maps to HTTP 404 (NOT 500 — the +// distinction is operator-meaningful: 404 says "fix your URL," 500 +// says "something is wrong server-side"). +var ErrACMEProfileNotFound = errors.New("acme: profile not found") + +// BuildDirectory constructs the per-profile directory document. +// +// profileID resolution: +// - non-empty: look up that profile; ErrACMEProfileNotFound on miss. +// - empty + cfg.DefaultProfileID set: substitute the default. +// - empty + cfg.DefaultProfileID unset: ErrACMEUserActionRequired. +// +// baseURL is the per-profile base path the directory's URL fields are +// constructed against. The handler computes baseURL from the inbound +// request (scheme + host + /acme/profile/) and passes it in; +// keeping the URL composition in the handler avoids embedding HTTP +// concerns in the service layer. +// +// On success the metrics counter for the directory op increments; +// failures bump the failure variant of the same counter. +func (s *ACMEService) BuildDirectory(ctx context.Context, profileID, baseURL string) (*acme.Directory, error) { + profileID, err := s.resolveProfile(ctx, profileID) + if err != nil { + s.metrics.bump(&s.metrics.DirectoryFailureTotal) + return nil, err + } + dir := acme.BuildDirectory( + baseURL, + s.cfg.DirectoryMeta.TermsOfService, + s.cfg.DirectoryMeta.Website, + s.cfg.DirectoryMeta.CAAIdentities, + s.cfg.DirectoryMeta.ExternalAccountRequired, + // Phase 1a: ARI is non-functional. The Phase 4 commit flips this + // to true once the renewal-info handler ships. + false, + ) + _ = profileID // Phase 1b will use the resolved profile to read + // acme_auth_mode + record per-profile metrics. Phase 1a + // only needs the existence check above. + s.metrics.bump(&s.metrics.DirectoryTotal) + return dir, nil +} + +// IssueNonce generates a fresh ACME nonce, persists it with the +// configured TTL, and returns the encoded string for the +// Replay-Nonce header. +// +// RFC 8555 §6.5: every successful ACME response carries a +// Replay-Nonce. Phase 1a wires this via the directory + new-nonce +// handlers; Phase 1b extends with new-account + account/ POST +// responses (the JWS-authenticated paths). +func (s *ACMEService) IssueNonce(ctx context.Context) (string, error) { + nonce, err := acme.GenerateNonce() + if err != nil { + s.metrics.bump(&s.metrics.NewNonceFailureTotal) + return "", fmt.Errorf("acme: generate nonce: %w", err) + } + if err := s.repo.IssueNonce(ctx, nonce, s.cfg.NonceTTL); err != nil { + s.metrics.bump(&s.metrics.NewNonceFailureTotal) + return "", fmt.Errorf("acme: persist nonce: %w", err) + } + s.metrics.bump(&s.metrics.NewNonceTotal) + return nonce, nil +} + +// resolveProfile applies the default-profile fallback and confirms the +// profile exists. Returns the resolved (canonical) profileID on +// success. Centralizing the resolution here keeps every Phase +// 1a/1b/2/3/4 endpoint's "which profile is this request bound to" +// logic uniform. +func (s *ACMEService) resolveProfile(ctx context.Context, profileID string) (string, error) { + if profileID == "" { + if s.cfg.DefaultProfileID == "" { + return "", ErrACMEUserActionRequired + } + profileID = s.cfg.DefaultProfileID + } + _, err := s.profiles.Get(ctx, profileID) + if err != nil { + if errors.Is(err, repository.ErrNotFound) { + return "", ErrACMEProfileNotFound + } + return "", fmt.Errorf("acme: lookup profile: %w", err) + } + return profileID, nil +} + +// ACMEMetrics is the per-op counter table for the ACME server. Mirrors +// the IssuanceMetrics / DeployCounters pattern (atomic.Uint64 + a +// Snapshot method that emits stable tuples). Phase 1a tracks just +// directory + new-nonce; subsequent phases add new-account / new-order +// / etc. +type ACMEMetrics struct { + DirectoryTotal atomic.Uint64 + DirectoryFailureTotal atomic.Uint64 + NewNonceTotal atomic.Uint64 + NewNonceFailureTotal atomic.Uint64 +} + +// NewACMEMetrics returns a zeroed counter table. Concurrent callers +// can bump counters without external synchronization (atomic.Uint64 +// is the synchronization primitive). +func NewACMEMetrics() *ACMEMetrics { return &ACMEMetrics{} } + +// bump increments a single atomic counter. Centralized so the call +// sites in BuildDirectory + IssueNonce are uniform. +func (m *ACMEMetrics) bump(c *atomic.Uint64) { c.Add(1) } + +// Snapshot emits the current counter values as a map (op → count). +// Naming is certctl_acme__total per frozen decision 0.10 +// (cardinality discipline) so the Prometheus exposer can lift them +// directly without per-op stringly-typed branching. +func (m *ACMEMetrics) Snapshot() map[string]uint64 { + return map[string]uint64{ + "certctl_acme_directory_total": m.DirectoryTotal.Load(), + "certctl_acme_directory_failures_total": m.DirectoryFailureTotal.Load(), + "certctl_acme_new_nonce_total": m.NewNonceTotal.Load(), + "certctl_acme_new_nonce_failures_total": m.NewNonceFailureTotal.Load(), + } +} diff --git a/internal/service/acme_test.go b/internal/service/acme_test.go new file mode 100644 index 0000000..abd9592 --- /dev/null +++ b/internal/service/acme_test.go @@ -0,0 +1,181 @@ +// Copyright (c) certctl +// SPDX-License-Identifier: BSL-1.1 + +package service + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/shankar0123/certctl/internal/config" + "github.com/shankar0123/certctl/internal/domain" + "github.com/shankar0123/certctl/internal/repository" +) + +// fakeACMERepo is an in-memory ACMERepo for tests. It tracks issued +// nonces in a map; Consume removes the entry to model one-shot use. +type fakeACMERepo struct { + issued map[string]time.Time // nonce → expires_at + issueErr error +} + +func newFakeACMERepo() *fakeACMERepo { + return &fakeACMERepo{issued: make(map[string]time.Time)} +} + +func (f *fakeACMERepo) IssueNonce(ctx context.Context, nonce string, ttl time.Duration) error { + if f.issueErr != nil { + return f.issueErr + } + f.issued[nonce] = time.Now().Add(ttl) + return nil +} + +func (f *fakeACMERepo) ConsumeNonce(ctx context.Context, nonce string) error { + exp, ok := f.issued[nonce] + if !ok { + return errors.New("not found") + } + if time.Now().After(exp) { + return errors.New("expired") + } + delete(f.issued, nonce) + return nil +} + +// fakeProfileLookup is an in-memory profileLookup that returns the +// profile by ID. Unknown IDs return repository.ErrNotFound (the +// canonical sentinel ACMEService maps to ErrACMEProfileNotFound). +type fakeProfileLookup struct { + profiles map[string]*domain.CertificateProfile +} + +func (f *fakeProfileLookup) Get(ctx context.Context, id string) (*domain.CertificateProfile, error) { + p, ok := f.profiles[id] + if !ok { + return nil, repository.ErrNotFound + } + return p, nil +} + +func newSvc(t *testing.T, cfg config.ACMEServerConfig, profiles map[string]*domain.CertificateProfile) (*ACMEService, *fakeACMERepo) { + t.Helper() + repo := newFakeACMERepo() + pl := &fakeProfileLookup{profiles: profiles} + return NewACMEService(repo, pl, cfg), repo +} + +func TestBuildDirectory_HappyPath(t *testing.T) { + cfg := config.ACMEServerConfig{ + NonceTTL: 5 * time.Minute, + } + cfg.DirectoryMeta.TermsOfService = "https://example.com/tos" + cfg.DirectoryMeta.Website = "https://example.com" + svc, _ := newSvc(t, cfg, map[string]*domain.CertificateProfile{ + "prof-corp": {ID: "prof-corp", Name: "corp"}, + }) + dir, err := svc.BuildDirectory(context.Background(), "prof-corp", "https://server/acme/profile/prof-corp") + if err != nil { + t.Fatalf("BuildDirectory: %v", err) + } + if dir == nil { + t.Fatal("dir is nil") + } + if dir.NewNonce != "https://server/acme/profile/prof-corp/new-nonce" { + t.Errorf("NewNonce = %q", dir.NewNonce) + } + if dir.Meta == nil || dir.Meta.TermsOfService != "https://example.com/tos" { + t.Errorf("meta tos = %+v", dir.Meta) + } + if got := svc.Metrics().DirectoryTotal.Load(); got != 1 { + t.Errorf("DirectoryTotal = %d, want 1", got) + } +} + +func TestBuildDirectory_UnknownProfile(t *testing.T) { + cfg := config.ACMEServerConfig{NonceTTL: 5 * time.Minute} + svc, _ := newSvc(t, cfg, nil) + _, err := svc.BuildDirectory(context.Background(), "prof-missing", "https://server/acme/profile/prof-missing") + if !errors.Is(err, ErrACMEProfileNotFound) { + t.Errorf("err = %v, want ErrACMEProfileNotFound", err) + } + if got := svc.Metrics().DirectoryFailureTotal.Load(); got != 1 { + t.Errorf("DirectoryFailureTotal = %d, want 1", got) + } +} + +func TestBuildDirectory_EmptyProfileNoDefault(t *testing.T) { + cfg := config.ACMEServerConfig{NonceTTL: 5 * time.Minute} + svc, _ := newSvc(t, cfg, nil) + _, err := svc.BuildDirectory(context.Background(), "", "https://server/acme") + if !errors.Is(err, ErrACMEUserActionRequired) { + t.Errorf("err = %v, want ErrACMEUserActionRequired", err) + } +} + +func TestBuildDirectory_EmptyProfileWithDefault(t *testing.T) { + cfg := config.ACMEServerConfig{ + NonceTTL: 5 * time.Minute, + DefaultProfileID: "prof-default", + } + svc, _ := newSvc(t, cfg, map[string]*domain.CertificateProfile{ + "prof-default": {ID: "prof-default", Name: "default"}, + }) + dir, err := svc.BuildDirectory(context.Background(), "", "https://server/acme") + if err != nil { + t.Fatalf("BuildDirectory: %v", err) + } + if dir.NewNonce != "https://server/acme/new-nonce" { + t.Errorf("NewNonce = %q (shorthand path)", dir.NewNonce) + } +} + +func TestIssueNonce_HappyPath(t *testing.T) { + cfg := config.ACMEServerConfig{NonceTTL: 5 * time.Minute} + svc, repo := newSvc(t, cfg, nil) + n, err := svc.IssueNonce(context.Background()) + if err != nil { + t.Fatalf("IssueNonce: %v", err) + } + if len(n) != 43 { + t.Errorf("nonce length = %d, want 43 (base64url-no-pad of 32 bytes)", len(n)) + } + if _, ok := repo.issued[n]; !ok { + t.Errorf("issued nonce was not persisted") + } + if got := svc.Metrics().NewNonceTotal.Load(); got != 1 { + t.Errorf("NewNonceTotal = %d, want 1", got) + } +} + +func TestIssueNonce_RepoFailure(t *testing.T) { + cfg := config.ACMEServerConfig{NonceTTL: 5 * time.Minute} + svc, repo := newSvc(t, cfg, nil) + repo.issueErr = errors.New("disk full") + _, err := svc.IssueNonce(context.Background()) + if err == nil { + t.Fatal("expected error from IssueNonce when repo fails") + } + if got := svc.Metrics().NewNonceFailureTotal.Load(); got != 1 { + t.Errorf("NewNonceFailureTotal = %d, want 1", got) + } +} + +func TestACMEMetrics_Snapshot(t *testing.T) { + m := NewACMEMetrics() + m.DirectoryTotal.Store(7) + m.NewNonceTotal.Store(11) + m.NewNonceFailureTotal.Store(2) + snap := m.Snapshot() + if snap["certctl_acme_directory_total"] != 7 { + t.Errorf("directory_total = %d", snap["certctl_acme_directory_total"]) + } + if snap["certctl_acme_new_nonce_total"] != 11 { + t.Errorf("new_nonce_total = %d", snap["certctl_acme_new_nonce_total"]) + } + if snap["certctl_acme_new_nonce_failures_total"] != 2 { + t.Errorf("new_nonce_failures_total = %d", snap["certctl_acme_new_nonce_failures_total"]) + } +} diff --git a/migrations/000025_acme_server.down.sql b/migrations/000025_acme_server.down.sql new file mode 100644 index 0000000..edde235 --- /dev/null +++ b/migrations/000025_acme_server.down.sql @@ -0,0 +1,29 @@ +-- Reverse of 000025_acme_server.up.sql. +-- +-- Drops in reverse-dependency order: challenges → authzs → orders → +-- nonces → accounts (FKs cascade upward), then strips the per-profile +-- acme_auth_mode column from certificate_profiles. + +DROP INDEX IF EXISTS idx_acme_challenges_authz; +DROP TABLE IF EXISTS acme_challenges; + +DROP INDEX IF EXISTS idx_acme_authz_status; +DROP INDEX IF EXISTS idx_acme_authz_order; +DROP TABLE IF EXISTS acme_authorizations; + +DROP INDEX IF EXISTS idx_acme_orders_expires; +DROP INDEX IF EXISTS idx_acme_orders_status; +DROP INDEX IF EXISTS idx_acme_orders_account; +DROP TABLE IF EXISTS acme_orders; + +DROP INDEX IF EXISTS idx_acme_nonces_expires; +DROP TABLE IF EXISTS acme_nonces; + +DROP INDEX IF EXISTS idx_acme_accounts_status; +DROP INDEX IF EXISTS idx_acme_accounts_jwk_thumb; +DROP TABLE IF EXISTS acme_accounts; + +ALTER TABLE certificate_profiles + DROP CONSTRAINT IF EXISTS certificate_profiles_acme_auth_mode_chk; +ALTER TABLE certificate_profiles + DROP COLUMN IF EXISTS acme_auth_mode; diff --git a/migrations/000025_acme_server.up.sql b/migrations/000025_acme_server.up.sql new file mode 100644 index 0000000..63c738d --- /dev/null +++ b/migrations/000025_acme_server.up.sql @@ -0,0 +1,135 @@ +-- ACME Server (RFC 8555 + RFC 9773 ARI) — Phase 1a foundation. +-- +-- Adds the per-profile auth-mode column on certificate_profiles plus +-- the 5 ACME state tables (accounts, orders, authorizations, challenges, +-- nonces). Phase 1a actively uses only `acme_nonces`; Phase 1b consumes +-- `acme_accounts`; Phases 2-4 consume the rest. All five tables ship +-- in this migration so the schema is stable from day one. +-- +-- Per the architecture decision documented in docs/acme-server.md, +-- auth_mode is per-profile (NOT a server-wide env var). One certctl-server +-- can serve `trust_authenticated` for an internal-PKI profile AND +-- `challenge` for a public-trust-style profile simultaneously. +-- +-- Idempotent (IF NOT EXISTS / IF EXISTS) per certctl architecture +-- decision; safe to re-run. + +-- 1. Add per-profile auth_mode to certificate_profiles. +-- 'trust_authenticated' (default) — JWS-authenticated client trusted +-- to issue for any identifier the profile policy allows; no per- +-- identifier ownership proof. The most common certctl use case. +-- 'challenge' — full HTTP-01 + DNS-01 + TLS-ALPN-01 validation per +-- RFC 8555 §8. For public-trust-style PKI. +ALTER TABLE certificate_profiles + ADD COLUMN IF NOT EXISTS acme_auth_mode TEXT NOT NULL DEFAULT 'trust_authenticated'; + +-- Constraint name pinned so the .down.sql can drop it deterministically. +-- Wrapped in DO block so re-running the migration on a database that +-- already has the constraint doesn't fail. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'certificate_profiles_acme_auth_mode_chk' + ) THEN + ALTER TABLE certificate_profiles + ADD CONSTRAINT certificate_profiles_acme_auth_mode_chk + CHECK (acme_auth_mode IN ('trust_authenticated', 'challenge')); + END IF; +END $$; + +-- 2. acme_accounts — RFC 8555 §7.1.2. +-- account_id is 'acme-acc-' + base32-encoded random per certctl's +-- human-readable-prefix convention. jwk_thumbprint is RFC 7638 +-- SHA-256 thumbprint of the canonicalized JWK; the (profile_id, +-- jwk_thumbprint) UNIQUE constraint enforces "one account per +-- keypair per profile" — RFC 8555 §7.3.1 idempotent semantics. +-- +-- Phase 1a creates the table; Phase 1b adds CRUD methods. +CREATE TABLE IF NOT EXISTS acme_accounts ( + account_id TEXT PRIMARY KEY, + jwk_thumbprint TEXT NOT NULL, + jwk_pem TEXT NOT NULL, + contact TEXT[], + status TEXT NOT NULL, + profile_id TEXT NOT NULL, + owner_id TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + FOREIGN KEY (profile_id) REFERENCES certificate_profiles(id), + FOREIGN KEY (owner_id) REFERENCES owners(id), + UNIQUE (profile_id, jwk_thumbprint) +); +CREATE INDEX IF NOT EXISTS idx_acme_accounts_jwk_thumb ON acme_accounts(profile_id, jwk_thumbprint); +CREATE INDEX IF NOT EXISTS idx_acme_accounts_status ON acme_accounts(status) WHERE status = 'valid'; + +-- 3. acme_orders — RFC 8555 §7.1.3. +-- identifiers stored as JSONB to keep the DNS-name list simple +-- (ACME currently has only the dns identifier type in scope; future +-- types like ip can extend without schema migration). +-- error stored as JSONB (RFC 7807 Problem+JSON shape on failure). +-- certificate_id FKs into managed_certificates so the existing cert +-- pipeline owns the leaf data. +CREATE TABLE IF NOT EXISTS acme_orders ( + order_id TEXT PRIMARY KEY, + account_id TEXT NOT NULL, + identifiers JSONB NOT NULL, + status TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + not_before TIMESTAMPTZ, + not_after TIMESTAMPTZ, + error JSONB, + csr_pem TEXT, + certificate_id TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + FOREIGN KEY (account_id) REFERENCES acme_accounts(account_id), + FOREIGN KEY (certificate_id) REFERENCES managed_certificates(id) +); +CREATE INDEX IF NOT EXISTS idx_acme_orders_account ON acme_orders(account_id); +CREATE INDEX IF NOT EXISTS idx_acme_orders_status ON acme_orders(status) WHERE status IN ('pending', 'ready', 'processing'); +CREATE INDEX IF NOT EXISTS idx_acme_orders_expires ON acme_orders(expires_at); + +-- 4. acme_authorizations — RFC 8555 §7.1.4. +CREATE TABLE IF NOT EXISTS acme_authorizations ( + authz_id TEXT PRIMARY KEY, + order_id TEXT NOT NULL, + identifier JSONB NOT NULL, + status TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + wildcard BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + FOREIGN KEY (order_id) REFERENCES acme_orders(order_id) +); +CREATE INDEX IF NOT EXISTS idx_acme_authz_order ON acme_authorizations(order_id); +CREATE INDEX IF NOT EXISTS idx_acme_authz_status ON acme_authorizations(status) WHERE status IN ('pending', 'processing'); + +-- 5. acme_challenges — RFC 8555 §8. +CREATE TABLE IF NOT EXISTS acme_challenges ( + challenge_id TEXT PRIMARY KEY, + authz_id TEXT NOT NULL, + type TEXT NOT NULL, + status TEXT NOT NULL, + token TEXT NOT NULL, + validated_at TIMESTAMPTZ, + error JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + FOREIGN KEY (authz_id) REFERENCES acme_authorizations(authz_id) +); +CREATE INDEX IF NOT EXISTS idx_acme_challenges_authz ON acme_challenges(authz_id); + +-- 6. acme_nonces — RFC 8555 §6.5. +-- Nonces are short-lived (TTL default 5m, configurable via +-- CERTCTL_ACME_SERVER_NONCE_TTL). DB-backed (NOT in-memory) so +-- they survive server restart — replay protection only works if the +-- server-side store outlasts the client's nonce caching window. +-- Phase 5 adds a scheduler-loop GC sweep; Phase 1a inserts but does +-- not yet GC. +CREATE TABLE IF NOT EXISTS acme_nonces ( + nonce TEXT PRIMARY KEY, + issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + used BOOLEAN NOT NULL DEFAULT FALSE +); +CREATE INDEX IF NOT EXISTS idx_acme_nonces_expires ON acme_nonces(expires_at) WHERE used = FALSE;