Files
certctl/internal/api/handler/acme.go
T
shankar0123 7e22204ba7 acme-server: HTTP-01 + DNS-01 + TLS-ALPN-01 challenge validation (Phase 3/7)
Wires up the actual challenge-validation machinery so profiles in
acme_auth_mode='challenge' resolve end-to-end. After this commit,
cert-manager 1.15+ with `solver: http01: ingress` against a
challenge-mode profile completes a real HTTP-01 flow and gets a cert.
DNS-01 + TLS-ALPN-01 share the same code path with the appropriate
validator selection.

Architecture (the load-bearing parts):
  - 3 separate semaphore-bounded worker pools (one per challenge type),
    so HTTP-01 and DNS-01 can't starve each other under load. Default
    weight 10 per type; tunable via CERTCTL_ACME_SERVER_HTTP01_CONCURRENCY,
    DNS01_CONCURRENCY, TLSALPN01_CONCURRENCY.
  - 30s per-challenge timeout (configurable via PoolConfig.PerChallengeTimeout).
  - HTTP-01 validator runs validation.IsReservedIPForDial (newly
    exported wrapper preserving the existing private impl byte-for-byte
    for the network scanner + ValidateSafeURL paths) on the resolved
    IP — both at the initial dial and every redirect hop. SSRF probes
    into private IP space are refused before the connect.
  - DNS-01 validator uses a dedicated resolver pointed at
    CERTCTL_ACME_SERVER_DNS01_RESOLVER (default 8.8.8.8:53) — does
    NOT use the system resolver to keep behavior deterministic across
    deployments. Wildcard handling: `*.example.com` queries
    _acme-challenge.example.com.
  - TLS-ALPN-01 validator (RFC 8737) connects with ALPN `acme-tls/1`,
    inspects the id-pe-acmeIdentifier extension (OID 1.3.6.1.5.5.7.1.31),
    asserts the ASN.1 OCTET STRING value equals SHA-256 of the key
    authorization. Cert chain is intentionally NOT validated
    (InsecureSkipVerify=true is correct per RFC 8737 — the proof is
    in the extension, not the chain). Documented in docs/tls.md L-001
    table + the //nolint:gosec comment carries the justification.
    SSRF guard: same posture as HTTP-01.
  - Validation is asynchronous: handler accepts the POST and returns
    200 immediately with status=processing; the worker-pool fires a
    callback that updates challenge → authz → order in a fresh
    background-context WithinTx. The order auto-promotes to `ready`
    when ALL authzs become valid; auto-fails to `invalid` when ANY
    authz becomes invalid.

What ships:
  - internal/api/acme/challenge.go: KeyAuthorization (RFC 8555 §8.1) +
    DNS01TXTRecordValue (§8.4) + TLSALPN01ExtensionValue (RFC 8737 §3)
    helpers; IDPEAcmeIdentifierOID; ChallengeProblemFromError mapper
    (4-way: connection / dns / tls / incorrectResponse); 9 sentinel
    errors covering every named failure mode.
  - internal/api/acme/validators.go: ChallengeValidator interface;
    Pool dispatcher with 3 semaphores + per-type in-flight + peak
    gauges; HTTP01Validator + DNS01Validator + TLSALPN01Validator
    implementations; Drain method called from cmd/server/main.go's
    shutdown sequence.
  - internal/api/acme/validators_test.go: KeyAuthorization round-trip,
    DNS01 / TLS-ALPN-01 helper tests, SSRF rejection, bounded-
    concurrency saturation test (peak-in-flight ≤ cap), type-isolation
    test (HTTP-01 saturation doesn't block DNS-01), UnknownType test,
    7-case ChallengeProblemFromError mapping.
  - internal/repository/postgres/acme.go: GetChallengeByID +
    UpdateChallengeWithTx + UpdateAuthzStatusWithTx.
  - internal/service/acme.go: SetValidatorPool wires the *acme.Pool;
    RespondToChallenge dispatches with account-ownership assertion +
    KeyAuthorization computation + processing-status transition (atomic
    + audit); recordChallengeOutcome callback persists the final
    challenge + cascading authz + order-promote/-fail in one WithinTx +
    audit row. 4 new metrics.
  - internal/api/handler/acme.go: Challenge handler; round-trips
    account.JWKPEM through ParseJWKFromPEM to recover the *jose.JSONWebKey
    the validator pool needs.
  - internal/api/router/router.go + openapi_parity_test.go +
    api/openapi-handler-exceptions.yaml: 2 new routes (per-profile +
    shorthand for challenge/{chall_id}) with parity exceptions.
  - cmd/server/main.go: constructs the Pool at startup with the
    per-type concurrency caps from cfg.ACMEServer; ACMEService.ValidatorPool()
    accessor exposed for the shutdown drain sequence.
  - internal/validation/ssrf.go: exported IsReservedIPForDial wrapper
    (private impl unchanged; network scanner + ValidateSafeURL paths
    byte-identical with prior behavior).
  - docs/tls.md: L-001 InsecureSkipVerify table extended with the
    TLS-ALPN-01 validator justification (RFC 8737 §3).
  - docs/acme-server.md: phase status updated; endpoints table grows
    the challenge row; phases-cross-reference flips Phase 3 → live.

Tests:
  - 80%+ coverage on the new files.
  - BoundedConcurrency test: 10 challenges submitted against an
    HTTP-01 pool of weight 3; observed peak-in-flight ≤ 3, all 10
    eventually complete, post-Drain in-flight returns to 0.
  - TypeIsolation test: HTTP-01 saturation does NOT block a DNS-01
    submission; DNS-01 callback fires within 2s.
  - SSRF rejection test: a Validate against `localhost` is refused
    before the dial (ErrChallengeReservedIP or ErrChallengeConnection).

Engineering history: cowork/WORKSPACE-CHANGELOG.md "ACME-Server-3".
2026-05-03 14:09:00 +00:00

888 lines
31 KiB
Go

// Copyright (c) certctl
// SPDX-License-Identifier: BSL-1.1
package handler
import (
"context"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"io"
"net/http"
"time"
jose "github.com/go-jose/go-jose/v4"
"github.com/shankar0123/certctl/internal/api/acme"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/service"
)
// MaxJWSBodyBytes caps the per-request JWS payload at 64 KiB. RFC 8555
// payloads are tiny (a JWK is < 1 KiB; a CSR < 4 KiB), so anything
// larger is either malformed or hostile. The router-level body-limit
// middleware already caps requests at the server-wide
// CERTCTL_MAX_BODY_SIZE (default 1 MiB), but ACME-specifically we
// tighten further.
const MaxJWSBodyBytes = 64 * 1024
// 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).
type ACMEService interface {
BuildDirectory(ctx context.Context, profileID, baseURL string) (*acme.Directory, error)
IssueNonce(ctx context.Context) (string, error)
// Phase 1b — JWS verification + account resource.
VerifyJWS(ctx context.Context, body []byte, requestURL string, expectNewAccount bool, accountKID func(accountID string) string) (*acme.VerifiedRequest, error)
NewAccount(ctx context.Context, profileID string, jwk *jose.JSONWebKey, contact []string, onlyReturnExisting bool, tosAgreed bool) (*domain.ACMEAccount, bool, error)
LookupAccount(ctx context.Context, accountID string) (*domain.ACMEAccount, error)
UpdateAccount(ctx context.Context, accountID string, contact []string) (*domain.ACMEAccount, error)
DeactivateAccount(ctx context.Context, accountID string) (*domain.ACMEAccount, error)
// Phase 2 — orders + finalize + authz + cert download.
CreateOrder(ctx context.Context, accountID, profileID string, identifiers []domain.ACMEIdentifier, notBefore, notAfter *time.Time) (*domain.ACMEOrder, error)
LookupOrder(ctx context.Context, orderID, accountID string) (*domain.ACMEOrder, error)
LookupAuthz(ctx context.Context, authzID string) (*domain.ACMEAuthorization, error)
ListAuthzsByOrder(ctx context.Context, orderID string) ([]*domain.ACMEAuthorization, error)
FinalizeOrder(ctx context.Context, accountID, orderID, profileID string, csr *x509.CertificateRequest, csrPEM string) (*service.FinalizeOrderResult, error)
LookupCertificate(ctx context.Context, certID, accountID string) (string, error)
// Phase 3 — challenge validation.
RespondToChallenge(ctx context.Context, accountID, challengeID string, accountJWK *jose.JSONWebKey) (*domain.ACMEChallenge, 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; new sentinels extend the switch as later
// phases land.
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,
})
case errors.Is(err, service.ErrACMEAccountNotFound):
acme.WriteProblem(w, acme.AccountDoesNotExist("account not found"))
case errors.Is(err, service.ErrACMEAccountDoesNotExist):
acme.WriteProblem(w, acme.AccountDoesNotExist(
"no account exists for this JWK; submit a new-account request without onlyReturnExisting"))
case errors.Is(err, service.ErrACMEOrderNotFound), errors.Is(err, service.ErrACMEAuthzNotFound), errors.Is(err, service.ErrACMECertificateNotFound):
acme.WriteProblem(w, acme.Problem{
Type: "urn:ietf:params:acme:error:malformed",
Detail: "resource not found",
Status: http.StatusNotFound,
})
case errors.Is(err, service.ErrACMEOrderUnauthorized):
acme.WriteProblem(w, acme.Problem{
Type: "urn:ietf:params:acme:error:unauthorized",
Detail: "account does not own this resource",
Status: http.StatusUnauthorized,
})
case errors.Is(err, service.ErrACMEOrderNotReady):
acme.WriteProblem(w, acme.Problem{
Type: "urn:ietf:params:acme:error:orderNotReady",
Detail: "order is not in the `ready` state; complete authorizations first",
Status: http.StatusForbidden,
})
case errors.Is(err, service.ErrACMEUnsupportedAuthMode), errors.Is(err, service.ErrACMEFinalizeUnconfigured), errors.Is(err, service.ErrACMEChallengePoolUnconfigured):
acme.WriteProblem(w, acme.ServerInternal("ACME server is not fully configured; contact the operator"))
case errors.Is(err, service.ErrACMEChallengeNotFound):
acme.WriteProblem(w, acme.Problem{
Type: "urn:ietf:params:acme:error:malformed",
Detail: "challenge not found",
Status: http.StatusNotFound,
})
case errors.Is(err, service.ErrACMEChallengeWrongState):
acme.WriteProblem(w, acme.Problem{
Type: "urn:ietf:params:acme:error:malformed",
Detail: "challenge is no longer in pending state",
Status: http.StatusBadRequest,
})
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"))
}
}
// NewAccount handles POST /acme/profile/{id}/new-account (RFC 8555
// §7.3). The request body is a JWS with `jwk` (NOT `kid`) in the
// protected header — the verifier enforces this via
// ExpectNewAccount=true.
//
// Behavior matrix:
// - JWK already registered + payload.OnlyReturnExisting=false →
// 200 + existing account row (idempotent re-registration per
// RFC 8555 §7.3.1).
// - JWK already registered + payload.OnlyReturnExisting=true →
// same 200 + existing row.
// - JWK new + OnlyReturnExisting=false → 201 + newly-created row.
// - JWK new + OnlyReturnExisting=true → 400 + accountDoesNotExist.
func (h ACMEHandler) NewAccount(w http.ResponseWriter, r *http.Request) {
profileID := r.PathValue("id")
requestURL := h.requestURL(r)
body, err := io.ReadAll(io.LimitReader(r.Body, MaxJWSBodyBytes+1))
if err != nil {
acme.WriteProblem(w, acme.Malformed("could not read request body"))
return
}
if len(body) > MaxJWSBodyBytes {
acme.WriteProblem(w, acme.Malformed("request body too large"))
return
}
verified, err := h.svc.VerifyJWS(r.Context(), body, requestURL, true /*expectNewAccount*/, h.accountKID(r, profileID))
if err != nil {
acme.WriteProblem(w, acme.MapJWSErrorToProblem(err))
return
}
var req acme.NewAccountRequest
if err := json.Unmarshal(verified.Payload, &req); err != nil {
acme.WriteProblem(w, acme.Malformed("could not parse new-account payload"))
return
}
acct, isNew, err := h.svc.NewAccount(
r.Context(), profileID, verified.JWK, req.Contact,
req.OnlyReturnExisting, req.TermsOfServiceAgreed,
)
if err != nil {
writeServiceError(w, err)
return
}
if nonce, err := h.svc.IssueNonce(r.Context()); err == nil {
w.Header().Set("Replay-Nonce", nonce)
}
w.Header().Set("Location", h.accountKID(r, profileID)(acct.AccountID))
w.Header().Set("Content-Type", "application/json")
if isNew {
w.WriteHeader(http.StatusCreated)
} else {
w.WriteHeader(http.StatusOK)
}
_ = json.NewEncoder(w).Encode(
acme.MarshalAccount(acct, h.accountOrdersURL(r, profileID, acct.AccountID)),
)
}
// Account handles POST /acme/profile/{id}/account/{acc-id} (RFC 8555
// §7.3.2 + §7.3.6 + POST-as-GET per §6.3). The verifier requires
// `kid` (NOT `jwk`); the kid path-segment must match the URL
// path-segment.
//
// Payload variants:
// - empty body or empty JSON {}: POST-as-GET; returns the account.
// - {"contact": [...]}: contact update (RFC 8555 §7.3.2).
// - {"status": "deactivated"}: deactivation (RFC 8555 §7.3.6).
//
// Mixing contact + status in one request is permitted; we apply
// status first (deactivation is the more conservative action).
func (h ACMEHandler) Account(w http.ResponseWriter, r *http.Request) {
profileID := r.PathValue("id")
urlAccountID := r.PathValue("acc_id")
requestURL := h.requestURL(r)
body, err := io.ReadAll(io.LimitReader(r.Body, MaxJWSBodyBytes+1))
if err != nil {
acme.WriteProblem(w, acme.Malformed("could not read request body"))
return
}
if len(body) > MaxJWSBodyBytes {
acme.WriteProblem(w, acme.Malformed("request body too large"))
return
}
verified, err := h.svc.VerifyJWS(r.Context(), body, requestURL, false /*expectNewAccount*/, h.accountKID(r, profileID))
if err != nil {
acme.WriteProblem(w, acme.MapJWSErrorToProblem(err))
return
}
// kid path-segment must equal URL path-segment (defense in depth —
// the verifier already round-tripped the kid against the canonical
// URL).
if verified.Account == nil || verified.Account.AccountID != urlAccountID {
acme.WriteProblem(w, acme.Problem{
Type: "urn:ietf:params:acme:error:unauthorized",
Detail: "kid does not match URL account id",
Status: http.StatusUnauthorized,
})
return
}
var (
updated *domain.ACMEAccount
readOnly bool
)
// Empty body or empty JSON object → POST-as-GET (§6.3).
trimmed := trimBody(verified.Payload)
if len(trimmed) == 0 || string(trimmed) == "{}" {
readOnly = true
updated = verified.Account
} else {
var req acme.AccountUpdateRequest
if err := json.Unmarshal(verified.Payload, &req); err != nil {
acme.WriteProblem(w, acme.Malformed("could not parse account update payload"))
return
}
// Status transition first (the more conservative action).
switch req.Status {
case "":
// no-op
case "deactivated":
acct, err := h.svc.DeactivateAccount(r.Context(), urlAccountID)
if err != nil {
writeServiceError(w, err)
return
}
updated = acct
default:
acme.WriteProblem(w, acme.Malformed(
"only `deactivated` is a valid status for account update; got "+req.Status))
return
}
// Contact update.
if req.Contact != nil {
acct, err := h.svc.UpdateAccount(r.Context(), urlAccountID, req.Contact)
if err != nil {
writeServiceError(w, err)
return
}
updated = acct
}
if updated == nil {
// Empty status + nil contact → no-op; treat as POST-as-GET.
updated = verified.Account
readOnly = true
}
}
if nonce, err := h.svc.IssueNonce(r.Context()); err == nil {
w.Header().Set("Replay-Nonce", nonce)
}
if readOnly {
w.Header().Set("Content-Type", "application/json")
} else {
w.Header().Set("Content-Type", "application/json")
}
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(
acme.MarshalAccount(updated, h.accountOrdersURL(r, profileID, updated.AccountID)),
)
}
// requestURL composes the full URL the JWS protected-header `url`
// MUST equal. Equivalent to scheme://host + r.URL.Path.
func (h ACMEHandler) requestURL(r *http.Request) string {
scheme := "https"
if r.TLS == nil {
scheme = "http"
}
return scheme + "://" + r.Host + r.URL.Path
}
// accountKID returns the closure VerifyJWS uses to round-trip-check
// inbound `kid` headers. Centralized so both NewAccount + Account
// build the same URL shape.
func (h ACMEHandler) accountKID(r *http.Request, profileID string) func(accountID string) string {
scheme := "https"
if r.TLS == nil {
scheme = "http"
}
prefix := scheme + "://" + r.Host
if profileID != "" {
prefix += "/acme/profile/" + profileID
} else {
prefix += "/acme"
}
return func(accountID string) string { return prefix + "/account/" + accountID }
}
// accountOrdersURL is the URL Phase 2 will serve account orders at.
// Phase 1b emits it in the account JSON for RFC 8555 §7.1.2.1
// compliance even though hitting it returns 404 until Phase 2.
func (h ACMEHandler) accountOrdersURL(r *http.Request, profileID, accountID string) string {
return h.accountKID(r, profileID)(accountID) + "/orders"
}
// trimBody is a minimal JSON-aware trim that returns a copy with
// outer whitespace removed. We don't need full JSON parsing here —
// just enough to detect empty body / empty object for POST-as-GET
// routing.
func trimBody(b []byte) []byte {
for len(b) > 0 && (b[0] == ' ' || b[0] == '\t' || b[0] == '\n' || b[0] == '\r') {
b = b[1:]
}
for len(b) > 0 {
c := b[len(b)-1]
if c != ' ' && c != '\t' && c != '\n' && c != '\r' {
break
}
b = b[:len(b)-1]
}
return b
}
// --- Phase 2 — orders + finalize + authz + cert handlers ---------------
// NewOrder handles POST /acme/profile/{id}/new-order (RFC 8555 §7.4).
// JWS path: kid (registered account).
func (h ACMEHandler) NewOrder(w http.ResponseWriter, r *http.Request) {
profileID := r.PathValue("id")
requestURL := h.requestURL(r)
body, err := io.ReadAll(io.LimitReader(r.Body, MaxJWSBodyBytes+1))
if err != nil {
acme.WriteProblem(w, acme.Malformed("could not read request body"))
return
}
if len(body) > MaxJWSBodyBytes {
acme.WriteProblem(w, acme.Malformed("request body too large"))
return
}
verified, err := h.svc.VerifyJWS(r.Context(), body, requestURL, false /*expectNewAccount*/, h.accountKID(r, profileID))
if err != nil {
acme.WriteProblem(w, acme.MapJWSErrorToProblem(err))
return
}
if verified.Account == nil {
acme.WriteProblem(w, acme.MapJWSErrorToProblem(acme.ErrJWSAccountNotFound))
return
}
var req acme.NewOrderRequest
if err := json.Unmarshal(verified.Payload, &req); err != nil {
acme.WriteProblem(w, acme.Malformed("could not parse new-order payload"))
return
}
// Identifier validation runs BEFORE order creation. Rejected
// identifiers do NOT create an acme_orders row.
if probs := acme.ValidateIdentifiers(req.Identifiers); len(probs) > 0 {
// Multi-rejection → wrap in subproblems.
w.Header().Set("Content-Type", acme.ProblemContentType)
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(acme.Problem{
Type: "urn:ietf:params:acme:error:rejectedIdentifier",
Detail: "one or more identifiers were rejected",
Status: http.StatusBadRequest,
Subproblems: probs,
})
return
}
// Translate wire shape to domain shape.
domainIDs := make([]domain.ACMEIdentifier, 0, len(req.Identifiers))
for _, id := range req.Identifiers {
domainIDs = append(domainIDs, domain.ACMEIdentifier{Type: id.Type, Value: id.Value})
}
notBefore := parseOptionalTime(req.NotBefore)
notAfter := parseOptionalTime(req.NotAfter)
order, err := h.svc.CreateOrder(r.Context(), verified.Account.AccountID, profileID, domainIDs, notBefore, notAfter)
if err != nil {
writeServiceError(w, err)
return
}
if nonce, err := h.svc.IssueNonce(r.Context()); err == nil {
w.Header().Set("Replay-Nonce", nonce)
}
w.Header().Set("Location", h.orderURL(r, profileID, order.OrderID))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(h.marshalOrderForResponse(r, profileID, order))
}
// Order handles POST /acme/profile/{id}/order/{ord_id} (RFC 8555 §7.4
// POST-as-GET — empty payload returns the current order state).
func (h ACMEHandler) Order(w http.ResponseWriter, r *http.Request) {
profileID := r.PathValue("id")
orderID := r.PathValue("ord_id")
requestURL := h.requestURL(r)
body, err := io.ReadAll(io.LimitReader(r.Body, MaxJWSBodyBytes+1))
if err != nil {
acme.WriteProblem(w, acme.Malformed("could not read request body"))
return
}
if len(body) > MaxJWSBodyBytes {
acme.WriteProblem(w, acme.Malformed("request body too large"))
return
}
verified, err := h.svc.VerifyJWS(r.Context(), body, requestURL, false, h.accountKID(r, profileID))
if err != nil {
acme.WriteProblem(w, acme.MapJWSErrorToProblem(err))
return
}
if verified.Account == nil {
acme.WriteProblem(w, acme.MapJWSErrorToProblem(acme.ErrJWSAccountNotFound))
return
}
order, err := h.svc.LookupOrder(r.Context(), orderID, verified.Account.AccountID)
if err != nil {
writeServiceError(w, err)
return
}
if nonce, err := h.svc.IssueNonce(r.Context()); err == nil {
w.Header().Set("Replay-Nonce", nonce)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(h.marshalOrderForResponse(r, profileID, order))
}
// OrderFinalize handles POST /acme/profile/{id}/order/{ord_id}/finalize
// (RFC 8555 §7.4). Payload carries the base64url-DER CSR.
func (h ACMEHandler) OrderFinalize(w http.ResponseWriter, r *http.Request) {
profileID := r.PathValue("id")
orderID := r.PathValue("ord_id")
requestURL := h.requestURL(r)
body, err := io.ReadAll(io.LimitReader(r.Body, MaxJWSBodyBytes+1))
if err != nil {
acme.WriteProblem(w, acme.Malformed("could not read request body"))
return
}
if len(body) > MaxJWSBodyBytes {
acme.WriteProblem(w, acme.Malformed("request body too large"))
return
}
verified, err := h.svc.VerifyJWS(r.Context(), body, requestURL, false, h.accountKID(r, profileID))
if err != nil {
acme.WriteProblem(w, acme.MapJWSErrorToProblem(err))
return
}
if verified.Account == nil {
acme.WriteProblem(w, acme.MapJWSErrorToProblem(acme.ErrJWSAccountNotFound))
return
}
var req acme.FinalizeRequest
if err := json.Unmarshal(verified.Payload, &req); err != nil {
acme.WriteProblem(w, acme.Malformed("could not parse finalize payload"))
return
}
csrDER, err := base64.RawURLEncoding.DecodeString(req.CSR)
if err != nil {
acme.WriteProblem(w, acme.Problem{
Type: "urn:ietf:params:acme:error:badCSR",
Detail: "csr field is not valid base64url",
Status: http.StatusBadRequest,
})
return
}
csr, err := x509.ParseCertificateRequest(csrDER)
if err != nil {
acme.WriteProblem(w, acme.Problem{
Type: "urn:ietf:params:acme:error:badCSR",
Detail: "csr did not parse as a valid PKCS#10",
Status: http.StatusBadRequest,
})
return
}
csrPEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER}))
result, err := h.svc.FinalizeOrder(r.Context(), verified.Account.AccountID, orderID, profileID, csr, csrPEM)
if err != nil {
writeServiceError(w, err)
return
}
if nonce, err := h.svc.IssueNonce(r.Context()); err == nil {
w.Header().Set("Replay-Nonce", nonce)
}
w.Header().Set("Location", h.orderURL(r, profileID, result.Order.OrderID))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(h.marshalOrderForResponse(r, profileID, result.Order))
}
// Authz handles POST /acme/profile/{id}/authz/{authz_id} (RFC 8555
// §7.5 POST-as-GET).
func (h ACMEHandler) Authz(w http.ResponseWriter, r *http.Request) {
profileID := r.PathValue("id")
authzID := r.PathValue("authz_id")
requestURL := h.requestURL(r)
body, err := io.ReadAll(io.LimitReader(r.Body, MaxJWSBodyBytes+1))
if err != nil {
acme.WriteProblem(w, acme.Malformed("could not read request body"))
return
}
if len(body) > MaxJWSBodyBytes {
acme.WriteProblem(w, acme.Malformed("request body too large"))
return
}
verified, err := h.svc.VerifyJWS(r.Context(), body, requestURL, false, h.accountKID(r, profileID))
if err != nil {
acme.WriteProblem(w, acme.MapJWSErrorToProblem(err))
return
}
if verified.Account == nil {
acme.WriteProblem(w, acme.MapJWSErrorToProblem(acme.ErrJWSAccountNotFound))
return
}
authz, err := h.svc.LookupAuthz(r.Context(), authzID)
if err != nil {
writeServiceError(w, err)
return
}
if nonce, err := h.svc.IssueNonce(r.Context()); err == nil {
w.Header().Set("Replay-Nonce", nonce)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(acme.MarshalAuthorization(authz, h.challengeURLBuilder(r, profileID)))
}
// Cert handles POST /acme/profile/{id}/cert/{cert_id} (RFC 8555 §7.4.2
// POST-as-GET cert download). Returns the PEM chain.
func (h ACMEHandler) Cert(w http.ResponseWriter, r *http.Request) {
profileID := r.PathValue("id")
certID := r.PathValue("cert_id")
requestURL := h.requestURL(r)
body, err := io.ReadAll(io.LimitReader(r.Body, MaxJWSBodyBytes+1))
if err != nil {
acme.WriteProblem(w, acme.Malformed("could not read request body"))
return
}
if len(body) > MaxJWSBodyBytes {
acme.WriteProblem(w, acme.Malformed("request body too large"))
return
}
verified, err := h.svc.VerifyJWS(r.Context(), body, requestURL, false, h.accountKID(r, profileID))
if err != nil {
acme.WriteProblem(w, acme.MapJWSErrorToProblem(err))
return
}
if verified.Account == nil {
acme.WriteProblem(w, acme.MapJWSErrorToProblem(acme.ErrJWSAccountNotFound))
return
}
pemChain, err := h.svc.LookupCertificate(r.Context(), certID, verified.Account.AccountID)
if err != nil {
writeServiceError(w, err)
return
}
if nonce, err := h.svc.IssueNonce(r.Context()); err == nil {
w.Header().Set("Replay-Nonce", nonce)
}
w.Header().Set("Content-Type", "application/pem-certificate-chain")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(pemChain))
}
// orderURL composes the per-order URL for Location headers and the
// finalize URL embedded in the order JSON.
func (h ACMEHandler) orderURL(r *http.Request, profileID, orderID string) string {
scheme := "https"
if r.TLS == nil {
scheme = "http"
}
prefix := scheme + "://" + r.Host
if profileID != "" {
prefix += "/acme/profile/" + profileID
} else {
prefix += "/acme"
}
return prefix + "/order/" + orderID
}
func (h ACMEHandler) authzURL(r *http.Request, profileID, authzID string) string {
scheme := "https"
if r.TLS == nil {
scheme = "http"
}
prefix := scheme + "://" + r.Host
if profileID != "" {
prefix += "/acme/profile/" + profileID
} else {
prefix += "/acme"
}
return prefix + "/authz/" + authzID
}
func (h ACMEHandler) certURL(r *http.Request, profileID, certID string) string {
scheme := "https"
if r.TLS == nil {
scheme = "http"
}
prefix := scheme + "://" + r.Host
if profileID != "" {
prefix += "/acme/profile/" + profileID
} else {
prefix += "/acme"
}
return prefix + "/cert/" + certID
}
// challengeURLBuilder returns a closure for MarshalAuthorization to
// compute per-challenge URLs.
func (h ACMEHandler) challengeURLBuilder(r *http.Request, profileID string) func(challengeID string) string {
scheme := "https"
if r.TLS == nil {
scheme = "http"
}
prefix := scheme + "://" + r.Host
if profileID != "" {
prefix += "/acme/profile/" + profileID
} else {
prefix += "/acme"
}
return func(challengeID string) string { return prefix + "/challenge/" + challengeID }
}
// marshalOrderForResponse builds the OrderResponseJSON for an order,
// fetching the per-order authzs to populate the URL list. The cert URL
// is populated only when status=valid + certificate_id is set.
func (h ACMEHandler) marshalOrderForResponse(r *http.Request, profileID string, order *domain.ACMEOrder) acme.OrderResponseJSON {
authzs, _ := h.svc.ListAuthzsByOrder(r.Context(), order.OrderID)
authzURLs := make([]string, 0, len(authzs))
for _, a := range authzs {
authzURLs = append(authzURLs, h.authzURL(r, profileID, a.AuthzID))
}
finalize := h.orderURL(r, profileID, order.OrderID) + "/finalize"
certURL := ""
if order.CertificateID != "" {
certURL = h.certURL(r, profileID, order.CertificateID)
}
return acme.MarshalOrder(order, authzURLs, finalize, certURL)
}
// parseOptionalTime parses an RFC 3339 string; returns nil on empty or
// parse failure (the latter is best-effort — the spec leaves notBefore
// / notAfter as advisory).
func parseOptionalTime(s string) *time.Time {
if s == "" {
return nil
}
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return nil
}
return &t
}
// Challenge handles POST /acme/profile/{id}/challenge/{chall_id}
// (RFC 8555 §7.5.1). The client posts an empty body (modern ACME) or
// a `{}` payload to indicate "I'm ready for you to validate this
// challenge." The handler dispatches the validator-pool work + returns
// the challenge in its current (processing) state. Clients poll authz
// or challenge for the eventual outcome.
//
// Phase 3: account JWK is needed to compute the key authorization. The
// JWS verifier returns the registered account's stored JWKPEM in the
// VerifiedRequest.Account; we round-trip that PEM through ParseJWKFromPEM
// to get the *jose.JSONWebKey the validator pool needs.
func (h ACMEHandler) Challenge(w http.ResponseWriter, r *http.Request) {
profileID := r.PathValue("id")
challengeID := r.PathValue("chall_id")
requestURL := h.requestURL(r)
body, err := io.ReadAll(io.LimitReader(r.Body, MaxJWSBodyBytes+1))
if err != nil {
acme.WriteProblem(w, acme.Malformed("could not read request body"))
return
}
if len(body) > MaxJWSBodyBytes {
acme.WriteProblem(w, acme.Malformed("request body too large"))
return
}
verified, err := h.svc.VerifyJWS(r.Context(), body, requestURL, false /*expectNewAccount*/, h.accountKID(r, profileID))
if err != nil {
acme.WriteProblem(w, acme.MapJWSErrorToProblem(err))
return
}
if verified.Account == nil {
acme.WriteProblem(w, acme.MapJWSErrorToProblem(acme.ErrJWSAccountNotFound))
return
}
// Reconstruct the account's public JWK from its stored PEM. This
// is what the validator pool needs to compute key authorizations.
jwk, err := acme.ParseJWKFromPEM(verified.Account.JWKPEM)
if err != nil {
acme.WriteProblem(w, acme.ServerInternal("could not parse stored account JWK"))
return
}
ch, err := h.svc.RespondToChallenge(r.Context(), verified.Account.AccountID, challengeID, jwk)
if err != nil {
writeServiceError(w, err)
return
}
if nonce, err := h.svc.IssueNonce(r.Context()); err == nil {
w.Header().Set("Replay-Nonce", nonce)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(marshalChallengeResponse(ch, h.challengeURLBuilder(r, profileID)))
}
// marshalChallengeResponse renders a single ACMEChallenge in the
// RFC 8555 §8 wire shape. Distinct from MarshalAuthorization (which
// embeds challenges in an authz wrapper); the challenge endpoint
// returns one challenge directly per RFC 8555 §7.5.1.
func marshalChallengeResponse(ch *domain.ACMEChallenge, urlBuilder func(string) string) acme.ChallengeResponseJSON {
out := acme.ChallengeResponseJSON{
Type: string(ch.Type),
URL: urlBuilder(ch.ChallengeID),
Status: string(ch.Status),
Token: ch.Token,
}
if ch.ValidatedAt != nil {
out.Validated = ch.ValidatedAt.UTC().Format(time.RFC3339)
}
if ch.Error != nil {
out.Error = &acme.Problem{Type: ch.Error.Type, Detail: ch.Error.Detail, Status: ch.Error.Status}
}
return out
}