mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 21:11:30 +00:00
acme-server: foundation — directory + new-nonce + per-profile routing (Phase 1a/7)
First slice of the RFC 8555 ACME server endpoint (master plan at cowork/acme-server-endpoint-prompt.md, per-phase prompts at cowork/acme-server-prompts/). This commit lands the smallest viable end-to-end deployable slice: an ACME client running curl -sk https://certctl/acme/profile/<id>/directory curl -sk -I https://certctl/acme/profile/<id>/new-nonce successfully fetches the directory document and a Replay-Nonce. Account creation, JWS verification, orders, challenges, and revocation are all out of scope for this phase and arrive in Phases 1b–4. Closes the Rank 1 LHF from the 2026-05-03 Infisical deep-research (cowork/infisical-deep-research-results.md). Pre-fix, certctl was an ACME consumer only — no /acme/directory endpoint, no JWS verifier, no challenge validators. K8s customers running cert-manager could not point at certctl as an ACME issuer; they had to deploy a certctl agent on every node. What ships: - internal/api/acme/{directory,nonce,errors}.go (+ tests). - internal/api/handler/acme.go + acme_handler_test.go. - internal/repository/postgres/acme.go (nonce ops only — Phase 1b extends with account CRUD; Phases 2-4 extend with order / authz / challenge CRUD). - internal/service/acme.go (BuildDirectory + IssueNonce stubs; Phase 1b adds VerifyJWS / NewAccount / etc.). - migrations/000025_acme_server.{up,down}.sql ships the full 5-table ACME schema (acme_accounts / acme_orders / acme_authorizations / acme_challenges / acme_nonces) PLUS the per-profile certificate_profiles.acme_auth_mode column. Phase 1a actively uses only acme_nonces; remaining tables are empty until Phases 1b-4 plug in. - internal/config/config.go: ACMEServerConfig struct + ACMEServer field on Config. Env vars use CERTCTL_ACME_SERVER_* prefix to avoid colliding with the existing consumer-side ACMEConfig at config.go:1746 (CERTCTL_ACME_DIRECTORY_URL / PROFILE / CHALLENGE_TYPE etc.). Phase 1a wires Enabled + DefaultAuthMode + DefaultProfileID + NonceTTL + DirectoryMeta; Order/Authz TTLs + per-challenge-type concurrency caps + DNS01 resolver are reserved fields parsed in 1a so operators can set them ahead of Phases 2/3. - cmd/server/main.go: wire ACMEHandler into the HandlerRegistry literal alongside the existing certificate / EST / SCEP / etc. handlers. - internal/api/router/router.go: HandlerRegistry.ACME field + 6 Register calls (3 per-profile + 3 shorthand). - internal/api/router/openapi_parity_test.go: 6 new entries in SpecParityExceptions. ACME is a wire-protocol surface (JWS-signed JSON over HTTPS per RFC 7515) whose semantics are dictated by RFC 8555 + RFC 9773 rather than by an OpenAPI document, same precedent as SCEP/EST. The canonical reference is docs/acme-server.md. - docs/acme-server.md: Phase-1a-shaped reference. Configuration table for every CERTCTL_ACME_SERVER_* env var. Per-profile auth-mode decision tree skeleton. TLS trust bootstrap section flagging cert-manager's ClusterIssuer.spec.acme.caBundle requirement (the single biggest first-time-deploy footgun; the full cert-manager walkthrough lands in Phase 6 but the requirement is documented up front). Architecture decisions baked in: - URL family is /acme/profile/<id>/* (per-profile, canonical) with /acme/* shorthand active when CERTCTL_ACME_SERVER_DEFAULT_PROFILE_ID is set. Path matches existing per-profile precedent in EST + SCEP. - Auth mode is per-profile (acme_auth_mode column on certificate_profiles), NOT server-wide. One certctl-server can serve trust_authenticated for an internal-PKI profile and challenge for a public-trust-style profile simultaneously. The column is read at request time, not cached at server start — operators flipping a profile's mode via SQL take effect on the next order without restart. - Nonces are DB-backed (acme_nonces table). Survive server restart. The RFC 8555 §6.5 replay defense requires the store to outlast the client's nonce caching window; an in-memory-only nonce store would lose every in-flight order on restart. - Per-op atomic counters on service.ACMEService.Metrics() — certctl_acme_directory_total, certctl_acme_directory_failures_total, certctl_acme_new_nonce_total, certctl_acme_new_nonce_failures_total. Naming follows certctl frozen decision 0.10 cardinality discipline. Phase 1b will extend with new_account counters; Phase 2 with order / finalize / cert; Phase 3 with per-challenge-type counters. Audit fixes #11 + #12 (cowork/acme-server-prompts/audit-additions.md) applied: - #11: CERTCTL_ACME_SERVER_* prefix avoids the consumer-side CERTCTL_ACME_* namespace collision. - #12: prior-attempt WIP from two failed Phase-1 dispatches was discarded at phase start; this commit starts from a clean tree. Tests: - 14 unit tests in internal/api/acme/ (directory, nonce, errors). - 7 handler-level tests via httptest.NewServer + mockACMEService (mirrors the mockSCEPService pattern at scep_handler_test.go). - 7 service-layer tests with mocked repo + injected profileLookup. - All pass under -race -count=1 -short. Deferred to Phase 1b: - JWS verification (go-jose v4 — see master-prompt §8a for the API surface and audit doc for the speculation pitfalls). - new-account / account/<id> endpoints + AccountService. - Nonce *consumption* path (issue path is in this commit; consume is only invoked by JWS-verified POSTs which Phase 1b adds). Engineering history: cowork/WORKSPACE-CHANGELOG.md "ACME-Server-1a". Per-phase implementation plan: cowork/acme-server-prompts/. Master plan + audit fixes: cowork/acme-server-endpoint-prompt.md + cowork/acme-server-prompt-audit.md + cowork/acme-server-prompts/audit-additions.md.
This commit is contained in:
@@ -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.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ type Config struct {
|
|||||||
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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user