acme-server: foundation — directory + new-nonce + per-profile routing (Phase 1a/7)

First slice of the RFC 8555 ACME server endpoint (master plan at
cowork/acme-server-endpoint-prompt.md, per-phase prompts at
cowork/acme-server-prompts/). This commit lands the smallest viable
end-to-end deployable slice: an ACME client running

  curl -sk https://certctl/acme/profile/<id>/directory
  curl -sk -I https://certctl/acme/profile/<id>/new-nonce

successfully fetches the directory document and a Replay-Nonce.
Account creation, JWS verification, orders, challenges, and
revocation are all out of scope for this phase and arrive in Phases
1b–4.

Closes the Rank 1 LHF from the 2026-05-03 Infisical deep-research
(cowork/infisical-deep-research-results.md). Pre-fix, certctl was an
ACME consumer only — no /acme/directory endpoint, no JWS verifier,
no challenge validators. K8s customers running cert-manager could
not point at certctl as an ACME issuer; they had to deploy a certctl
agent on every node.

What ships:
  - internal/api/acme/{directory,nonce,errors}.go (+ tests).
  - internal/api/handler/acme.go + acme_handler_test.go.
  - internal/repository/postgres/acme.go (nonce ops only — Phase 1b
    extends with account CRUD; Phases 2-4 extend with order / authz /
    challenge CRUD).
  - internal/service/acme.go (BuildDirectory + IssueNonce stubs;
    Phase 1b adds VerifyJWS / NewAccount / etc.).
  - migrations/000025_acme_server.{up,down}.sql ships the full 5-table
    ACME schema (acme_accounts / acme_orders / acme_authorizations /
    acme_challenges / acme_nonces) PLUS the per-profile
    certificate_profiles.acme_auth_mode column. Phase 1a actively
    uses only acme_nonces; remaining tables are empty until Phases
    1b-4 plug in.
  - internal/config/config.go: ACMEServerConfig struct + ACMEServer
    field on Config. Env vars use CERTCTL_ACME_SERVER_* prefix to
    avoid colliding with the existing consumer-side ACMEConfig at
    config.go:1746 (CERTCTL_ACME_DIRECTORY_URL / PROFILE /
    CHALLENGE_TYPE etc.). Phase 1a wires Enabled +
    DefaultAuthMode + DefaultProfileID + NonceTTL + DirectoryMeta;
    Order/Authz TTLs + per-challenge-type concurrency caps + DNS01
    resolver are reserved fields parsed in 1a so operators can set
    them ahead of Phases 2/3.
  - cmd/server/main.go: wire ACMEHandler into the HandlerRegistry
    literal alongside the existing certificate / EST / SCEP / etc.
    handlers.
  - internal/api/router/router.go: HandlerRegistry.ACME field + 6
    Register calls (3 per-profile + 3 shorthand).
  - internal/api/router/openapi_parity_test.go: 6 new entries in
    SpecParityExceptions. ACME is a wire-protocol surface (JWS-signed
    JSON over HTTPS per RFC 7515) whose semantics are dictated by
    RFC 8555 + RFC 9773 rather than by an OpenAPI document, same
    precedent as SCEP/EST. The canonical reference is
    docs/acme-server.md.
  - docs/acme-server.md: Phase-1a-shaped reference. Configuration
    table for every CERTCTL_ACME_SERVER_* env var. Per-profile
    auth-mode decision tree skeleton. TLS trust bootstrap section
    flagging cert-manager's ClusterIssuer.spec.acme.caBundle
    requirement (the single biggest first-time-deploy footgun;
    the full cert-manager walkthrough lands in Phase 6 but the
    requirement is documented up front).

Architecture decisions baked in:
  - URL family is /acme/profile/<id>/* (per-profile, canonical) with
    /acme/* shorthand active when CERTCTL_ACME_SERVER_DEFAULT_PROFILE_ID
    is set. Path matches existing per-profile precedent in EST + SCEP.
  - Auth mode is per-profile (acme_auth_mode column on
    certificate_profiles), NOT server-wide. One certctl-server can
    serve trust_authenticated for an internal-PKI profile and
    challenge for a public-trust-style profile simultaneously. The
    column is read at request time, not cached at server start —
    operators flipping a profile's mode via SQL take effect on the
    next order without restart.
  - Nonces are DB-backed (acme_nonces table). Survive server restart.
    The RFC 8555 §6.5 replay defense requires the store to outlast
    the client's nonce caching window; an in-memory-only nonce
    store would lose every in-flight order on restart.
  - Per-op atomic counters on service.ACMEService.Metrics() —
    certctl_acme_directory_total, certctl_acme_directory_failures_total,
    certctl_acme_new_nonce_total, certctl_acme_new_nonce_failures_total.
    Naming follows certctl frozen decision 0.10 cardinality discipline.
    Phase 1b will extend with new_account counters; Phase 2 with
    order / finalize / cert; Phase 3 with per-challenge-type counters.

Audit fixes #11 + #12 (cowork/acme-server-prompts/audit-additions.md)
applied:
  - #11: CERTCTL_ACME_SERVER_* prefix avoids the consumer-side
    CERTCTL_ACME_* namespace collision.
  - #12: prior-attempt WIP from two failed Phase-1 dispatches was
    discarded at phase start; this commit starts from a clean tree.

Tests:
  - 14 unit tests in internal/api/acme/ (directory, nonce, errors).
  - 7 handler-level tests via httptest.NewServer + mockACMEService
    (mirrors the mockSCEPService pattern at scep_handler_test.go).
  - 7 service-layer tests with mocked repo + injected profileLookup.
  - All pass under -race -count=1 -short.

Deferred to Phase 1b:
  - JWS verification (go-jose v4 — see master-prompt §8a for the API
    surface and audit doc for the speculation pitfalls).
  - new-account / account/<id> endpoints + AccountService.
  - Nonce *consumption* path (issue path is in this commit; consume
    is only invoked by JWS-verified POSTs which Phase 1b adds).

Engineering history: cowork/WORKSPACE-CHANGELOG.md "ACME-Server-1a".
Per-phase implementation plan: cowork/acme-server-prompts/.
Master plan + audit fixes: cowork/acme-server-endpoint-prompt.md +
cowork/acme-server-prompt-audit.md +
cowork/acme-server-prompts/audit-additions.md.
This commit is contained in:
shankar0123
2026-05-03 12:47:52 +00:00
parent b8b7e1e3dd
commit ec88a61274
18 changed files with 1951 additions and 15 deletions
+18
View File
@@ -155,6 +155,10 @@ func main() {
profileRepo := postgres.NewProfileRepository(db) profileRepo := postgres.NewProfileRepository(db)
teamRepo := postgres.NewTeamRepository(db) teamRepo := postgres.NewTeamRepository(db)
ownerRepo := postgres.NewOwnerRepository(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") logger.Info("initialized all repositories")
// Initialize dynamic issuer registry. // Initialize dynamic issuer registry.
@@ -744,6 +748,14 @@ func main() {
// by PathID; the AdminEST handler reads it at request time. // by PathID; the AdminEST handler reads it at request time.
estServices := map[string]*service.ESTService{} 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 // Build the API router with all handlers
apiRouter := router.New() apiRouter := router.New()
apiRouter.RegisterHandlers(router.HandlerRegistry{ apiRouter.RegisterHandlers(router.HandlerRegistry{
@@ -799,6 +811,12 @@ func main() {
AdminEST: handler.NewAdminESTHandler( AdminEST: handler.NewAdminESTHandler(
handler.NewAdminESTServiceImpl(estServices), 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. // Register EST (RFC 7030) handlers if enabled.
// //
+160
View File
@@ -0,0 +1,160 @@
# certctl ACME Server (Built-in)
certctl ships an RFC 8555 + RFC 9773 ARI ACME server endpoint at
`/acme/profile/<profile-id>/*`. 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/<DefaultProfileID>/*` 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.
+85
View File
@@ -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/<id>/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
}
+113
View File
@@ -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)
}
}
+127
View File
@@ -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/<id>/*. 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)
}
+106
View File
@@ -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)
}
}
+47
View File
@@ -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
}
+46
View File
@@ -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)
}
}
+170
View File
@@ -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/<id>/* 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/<id>; Phase 2 adds new-order +
// order/<id>(/finalize) + authz/<id> + cert/<id>; Phase 3 adds
// challenge/<id>; 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/<bogus>/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/<id>/* 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/<id>/* — "+
"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"))
}
}
+242
View File
@@ -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)
}
}
@@ -50,6 +50,23 @@ var SpecParityExceptions = map[string]string{
// operator-facing description. // 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", "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", "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) { func TestRouter_OpenAPIParity(t *testing.T) {
+33
View File
@@ -143,6 +143,19 @@ type HandlerRegistry struct {
// Both endpoints are admin-gated (M-008 pin updated to include // Both endpoints are admin-gated (M-008 pin updated to include
// admin_est.go). // admin_est.go).
AdminEST handler.AdminESTHandler 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. // 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("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("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)) 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 // RegisterESTHandlers sets up EST (RFC 7030) routes under
+147 -15
View File
@@ -13,21 +13,27 @@ import (
// Config represents the complete application configuration. // Config represents the complete application configuration.
// All configuration values are read from environment variables with CERTCTL_ prefix. // All configuration values are read from environment variables with CERTCTL_ prefix.
type Config struct { type Config struct {
Server ServerConfig Server ServerConfig
Database DatabaseConfig Database DatabaseConfig
Scheduler SchedulerConfig Scheduler SchedulerConfig
Log LogConfig Log LogConfig
Auth AuthConfig Auth AuthConfig
RateLimit RateLimitConfig RateLimit RateLimitConfig
CORS CORSConfig CORS CORSConfig
Keygen KeygenConfig Keygen KeygenConfig
CA CAConfig CA CAConfig
Notifiers NotifierConfig Notifiers NotifierConfig
NetworkScan NetworkScanConfig NetworkScan NetworkScanConfig
EST ESTConfig EST ESTConfig
SCEP SCEPConfig SCEP SCEPConfig
Verification VerificationConfig Verification VerificationConfig
ACME ACMEConfig 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 Vault VaultConfig
DigiCert DigiCertConfig DigiCert DigiCertConfig
Sectigo SectigoConfig Sectigo SectigoConfig
@@ -645,6 +651,108 @@ type ACMEConfig struct {
Insecure bool Insecure bool
} }
// ACMEServerConfig is the SERVER-side ACME (RFC 8555 + RFC 9773 ARI)
// configuration. Distinct from ACMEConfig (the consumer-side issuer
// connector that talks UP to Let's Encrypt / pebble). Server uses
// CERTCTL_ACME_SERVER_* prefix throughout to avoid colliding with
// the existing CERTCTL_ACME_* consumer namespace (DIRECTORY_URL /
// PROFILE / CHALLENGE_TYPE / etc.).
//
// Phase 1a wires Enabled / DefaultAuthMode / DefaultProfileID /
// NonceTTL / DirectoryMeta. Order/Authz TTLs + the per-challenge-type
// concurrency caps + DNS01 resolver are reserved fields populated for
// Phases 2/3 — exposing them now keeps the env-var surface stable
// from day one (operators can set CERTCTL_ACME_SERVER_HTTP01_CONCURRENCY
// today; it's a no-op until Phase 3 reads it).
type ACMEServerConfig struct {
// Enabled is the master toggle. When false, the ACME handler is
// constructed (so the registry-shape stays stable) but no routes
// are registered. Operators flip this on after configuring the
// per-profile auth_mode column on certificate_profiles.
// Setting: CERTCTL_ACME_SERVER_ENABLED.
Enabled bool
// DefaultAuthMode sets the default value of certificate_profiles.acme_auth_mode
// for NEWLY-created profiles (e.g. via API). Existing profile rows
// retain whatever value they were created with — per-profile
// values, once set, override this default. Architecture decision:
// auth mode is per-profile, not server-wide.
// Valid: "trust_authenticated" (default) or "challenge".
// Setting: CERTCTL_ACME_SERVER_DEFAULT_AUTH_MODE.
DefaultAuthMode string
// DefaultProfileID, when set, activates the /acme/* shorthand
// path family — /acme/directory mirrors
// /acme/profile/<DefaultProfileID>/directory etc. When empty,
// requests to the shorthand return RFC 7807
// userActionRequired with a hint pointing at the per-profile
// path. Single-profile deployments can set this for ergonomic
// client config; multi-profile deployments leave it empty.
// Setting: CERTCTL_ACME_SERVER_DEFAULT_PROFILE_ID.
DefaultProfileID string
// NonceTTL is how long an issued ACME nonce remains valid before
// the server rejects it as expired. RFC 8555 §6.5.1 allows the
// server to set any TTL; 5 minutes is the operator-friendly
// default (clock-skew tolerant without enabling long-replay
// attacks). Setting: CERTCTL_ACME_SERVER_NONCE_TTL.
NonceTTL time.Duration
// OrderTTL is the lifetime of an unfulfilled ACME order. Phase 2
// reads; Phase 1a reserves the field. Default: 24h.
// Setting: CERTCTL_ACME_SERVER_ORDER_TTL.
OrderTTL time.Duration
// AuthzTTL is the lifetime of an unfulfilled authorization. Phase 2
// reads; Phase 1a reserves. Default: 24h.
// Setting: CERTCTL_ACME_SERVER_AUTHZ_TTL.
AuthzTTL time.Duration
// HTTP01ConcurrencyMax is the bound on concurrent HTTP-01 validators
// (semaphore weight). Phase 3 reads; Phase 1a reserves. Default: 10.
// Setting: CERTCTL_ACME_SERVER_HTTP01_CONCURRENCY.
HTTP01ConcurrencyMax int
// DNS01Resolver is the resolver address used by the DNS-01 validator.
// Phase 3 reads; Phase 1a reserves. Default: "8.8.8.8:53".
// Setting: CERTCTL_ACME_SERVER_DNS01_RESOLVER.
DNS01Resolver string
// DNS01ConcurrencyMax bounds concurrent DNS-01 validators. Default: 10.
// Setting: CERTCTL_ACME_SERVER_DNS01_CONCURRENCY.
DNS01ConcurrencyMax int
// TLSALPN01ConcurrencyMax bounds concurrent TLS-ALPN-01 validators.
// Default: 10. Setting: CERTCTL_ACME_SERVER_TLSALPN01_CONCURRENCY.
TLSALPN01ConcurrencyMax int
// DirectoryMeta is the optional metadata advertised in the directory
// document per RFC 8555 §7.1.1.
DirectoryMeta ACMEServerDirectoryMeta
}
// ACMEServerDirectoryMeta holds the optional fields of the directory
// `meta` block. Each is populated from a CERTCTL_ACME_SERVER_*
// env var; an all-empty struct produces an omitempty-suppressed JSON
// `meta` field on the directory.
type ACMEServerDirectoryMeta struct {
// TermsOfService is a URL pointing to the operator's ToS document.
// Setting: CERTCTL_ACME_SERVER_TOS_URL.
TermsOfService string
// Website is a URL pointing to the operator's homepage.
// Setting: CERTCTL_ACME_SERVER_WEBSITE.
Website string
// CAAIdentities is the list of CAA-record domain values clients
// should authorize for this server. Setting:
// CERTCTL_ACME_SERVER_CAA_IDENTITIES (comma-separated).
CAAIdentities []string
// ExternalAccountRequired, when true, signals to clients that
// new-account requires an EAB token (RFC 8555 §7.3.4). Phase 1a
// advertises but does not enforce; EAB enforcement is a follow-up.
// Setting: CERTCTL_ACME_SERVER_EAB_REQUIRED.
ExternalAccountRequired bool
}
// OpenSSLConfig contains OpenSSL/Custom CA issuer connector configuration. // OpenSSLConfig contains OpenSSL/Custom CA issuer connector configuration.
type OpenSSLConfig struct { type OpenSSLConfig struct {
// SignScript is the path to a shell script that signs certificate requests. // 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), ARIEnabled: getEnvBool("CERTCTL_ACME_ARI_ENABLED", false),
Insecure: getEnvBool("CERTCTL_ACME_INSECURE", 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{ Digest: DigestConfig{
Enabled: getEnvBool("CERTCTL_DIGEST_ENABLED", false), Enabled: getEnvBool("CERTCTL_DIGEST_ENABLED", false),
Interval: getEnvDuration("CERTCTL_DIGEST_INTERVAL", 24*time.Hour), Interval: getEnvDuration("CERTCTL_DIGEST_INTERVAL", 24*time.Hour),
+86
View File
@@ -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
}
+209
View File
@@ -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/<id>/*")
// 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/<id>) 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/<id> 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_<op>_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(),
}
}
+181
View File
@@ -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"])
}
}
+29
View File
@@ -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;
+135
View File
@@ -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;