mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 19:11:30 +00:00
4dc8d3fa5b
Closes the RFC 8555 + RFC 9773 surface beyond the issuance happy-path:
- POST /acme/profile/<id>/key-change (RFC 8555 §7.3.5)
- POST /acme/profile/<id>/revoke-cert (RFC 8555 §7.6)
- GET /acme/profile/<id>/renewal-info/<cert-id> (RFC 9773 ARI)
After this commit, ACME clients can rotate account keys, revoke certs
through the ACME surface (rather than only via the certctl GUI/API),
and fetch ARI for proactive renewal scheduling.
Architecture:
- Key rollover: outer JWS verified against the registered account key
(existing kid path); the inner JWS — embedded as the outer's payload
— verified against the embedded NEW jwk in a new dedicated routine
(ParseAndVerifyKeyChangeInner) that enforces RFC 8555 §7.3.5
inner-only invariants: MUST use jwk + MUST NOT use kid, payload
.account == outer.kid, payload.oldKey thumbprint-equals registered.
A single WithinTx swaps the stored thumbprint+pem and writes the
audit row. Concurrent-rollover safety via SELECT…FOR UPDATE on the
conflicting account row in UpdateAccountJWKWithTx; the loser
observes the winner's new thumbprint and is told to retry (409).
- Revocation: two auth paths. kid → AccountOwnsCertificate single-
indexed COUNT lookup over acme_orders. jwk → constant-time RFC 7638
thumbprint compare against the cert's pubkey. Both paths route
through service.RevocationSvc.RevokeCertificateWithActor so the
existing CRL/OCSP refresh + audit + metrics pipeline applies. RFC
5280 §5.3.1 numeric reason codes clamp to certctl's
domain.ValidRevocationReasons; codes 8 (removeFromCRL) + 10
(aACompromise) clamp to 'unspecified' since they aren't in the set.
- ARI is GET-only and unauth per RFC 9773 §4. Cert-id wire shape is
base64url(AKI).base64url(serial); ParseARICertID strict-decodes,
SerialHex emits the canonical certctl-shape lowercase-no-leading-
zeros hex used in certificate_versions.serial_number.
ComputeRenewalWindow has 3 branches: bound RenewalPolicy →
[notAfter - days, notAfter - days/2]; no policy → last 33% of
validity; past expiry → [now, now + 1d] (renew immediately).
Retry-After honors CERTCTL_ACME_SERVER_ARI_POLL_INTERVAL.
What ships:
- internal/api/acme/{keychange,ari}.go (+ phase4_test.go: 15 tests).
- internal/api/acme/order.go: RevokeCertRequest wire shape.
- internal/api/handler/acme.go: KeyChange, RevokeCert, RenewalInfo
+ 11 new writeServiceError mappings.
- internal/repository/postgres/acme.go: UpdateAccountJWKWithTx (FOR
UPDATE + expectedOldThumbprint precondition; ErrACMEAccountKey-
ConcurrentUpdate sentinel) + AccountOwnsCertificate.
- internal/service/acme.go: RotateAccountKey + RevokeCert +
RenewalInfo; CertificateRevoker + RenewalPolicyLookup interfaces;
SetRevocationDelegate + SetRenewalPolicyLookup wiring; 11 new
sentinels; 6 new metrics.
- internal/service/acme_phase4_test.go: service-layer tests for
RotateAccountKey (happy + duplicate-key) + RevokeCert (kid mismatch
+ jwk mismatch + jwk happy + already-revoked + reason-clamping) +
RenewalInfo (disabled + bad cert-id).
- internal/api/router/router.go: 6 new register calls (3 per-profile
+ 3 shorthand). Router parity exceptions extended in lockstep
(in-tree SpecParityExceptions + CI-only openapi-handler-exceptions
.yaml).
- cmd/server/main.go: SetRevocationDelegate(revocationSvc) +
SetRenewalPolicyLookup(renewalPolicyRepo) at startup.
- internal/config/config.go: CERTCTL_ACME_SERVER_ARI_ENABLED (default
true) + CERTCTL_ACME_SERVER_ARI_POLL_INTERVAL (default 6h);
BuildDirectory's ariEnabled flag now flips on under
cfg.ARIEnabled.
- docs/acme-server.md: phase status flipped to Phase 4; endpoints
table grows 6 rows (3 per-profile + 3 shorthand); FAQ section
appended explaining how to rotate keys, revoke certs, and consume
ARI.
Tests:
- 'go vet ./...' clean across the repo.
- 'go test -short -count=1 ./...' green across every package.
- phase4_test.go covers: keychange happy-path + 5 negatives +
MapKeyChangeErrorToProblem coverage; ARI cert-id round-trip + 6
malformed cases + BuildARICertID from a generated cert; window-
math 3 branches.
- service-layer tests confirm: RotateAccountKey atomically swaps the
thumbprint (verifies persisted state) and rejects duplicate keys;
RevokeCert routes through the stub RevocationSvc with the right
actor string + reason on the jwk path, rejects mismatched keys,
rejects already-revoked certs, clamps reason codes correctly;
RenewalInfo respects ARIEnabled + cert-id format.
Engineering history: cowork/WORKSPACE-CHANGELOG.md 'ACME-Server-4'.
273 lines
10 KiB
Go
273 lines
10 KiB
Go
// Copyright (c) certctl
|
|
// SPDX-License-Identifier: BSL-1.1
|
|
|
|
package acme
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
|
|
jose "github.com/go-jose/go-jose/v4"
|
|
)
|
|
|
|
// Phase 4 — RFC 8555 §7.3.5 key rollover.
|
|
//
|
|
// The wire shape is a doubly-signed JWS:
|
|
//
|
|
// JWS-outer signed by the OLD account key (kid = account URL):
|
|
// protected: { alg, kid, nonce, url }
|
|
// payload: <JWS-inner-as-bytes>
|
|
//
|
|
// JWS-inner signed by the NEW account key (jwk = newkey):
|
|
// protected: { alg, jwk, url=<same key-change URL> }
|
|
// payload: { account: <kid-URL>, oldKey: <OLD JWK> }
|
|
//
|
|
// The handler runs the existing VerifyJWS pipeline against the outer
|
|
// (kid path), then hands the resulting Payload bytes to ParseAndVerify-
|
|
// KeyChangeInner so the inner is processed in isolation. Two key
|
|
// distinctions vs. the outer:
|
|
//
|
|
// - The inner JWS does NOT carry a `nonce` header. Per RFC 8555 §7.3.5
|
|
// the outer's nonce is the only nonce; the inner is a self-contained
|
|
// proof-of-possession blob.
|
|
// - The inner JWS uses `jwk` not `kid` and the verifier must succeed
|
|
// when the embedded `jwk` itself is the verification key.
|
|
//
|
|
// This matches what go-jose's lego implementation, cert-manager, and
|
|
// boulder all expect.
|
|
|
|
// KeyChangeInnerPayload is the parsed body of the inner JWS — RFC 8555
|
|
// §7.3.5 mandates exactly two fields.
|
|
type KeyChangeInnerPayload struct {
|
|
// Account is the kid URL of the account whose key is being rotated.
|
|
// MUST equal the outer's `kid` header. Mismatch → keyChange's
|
|
// "account" field doesn't match outer.kid.
|
|
Account string `json:"account"`
|
|
|
|
// OldKey is the JWK currently on file for the account. The server
|
|
// asserts this matches what we have in the database (byte-equal
|
|
// canonicalized) so a stale rollover request can't slip through.
|
|
OldKey *jose.JSONWebKey `json:"oldKey"`
|
|
}
|
|
|
|
// KeyChangeInner is the verified inner JWS — fields the service layer
|
|
// needs to commit the rollover.
|
|
type KeyChangeInner struct {
|
|
// NewJWK is the JWK the inner JWS is signed by. After verification
|
|
// this is the key the account's row will be updated to.
|
|
NewJWK *jose.JSONWebKey
|
|
|
|
// Payload is the inner's parsed JSON: { account, oldKey }.
|
|
Payload KeyChangeInnerPayload
|
|
|
|
// URL is the inner protected-header `url` value, asserted equal to
|
|
// the outer's URL.
|
|
URL string
|
|
|
|
// Algorithm is the negotiated alg the inner was signed with.
|
|
Algorithm string
|
|
}
|
|
|
|
// Sentinel errors. Each maps to an RFC 8555 §6.7 problem type via the
|
|
// service's writeServiceError; tests assert via errors.Is.
|
|
var (
|
|
ErrKeyChangeInnerMalformed = errors.New("acme keychange: inner JWS malformed")
|
|
ErrKeyChangeInnerAlgRejected = errors.New("acme keychange: inner JWS uses disallowed signature algorithm")
|
|
ErrKeyChangeInnerMissingJWK = errors.New("acme keychange: inner JWS protected header MUST contain `jwk`")
|
|
ErrKeyChangeInnerForbidsKID = errors.New("acme keychange: inner JWS MUST NOT contain `kid` (use `jwk`)")
|
|
ErrKeyChangeInnerInvalidJWK = errors.New("acme keychange: inner JWS embedded JWK is invalid")
|
|
ErrKeyChangeInnerURLMissing = errors.New("acme keychange: inner JWS protected header `url` is required")
|
|
ErrKeyChangeInnerURLMismatch = errors.New("acme keychange: inner JWS `url` does not match outer JWS `url`")
|
|
ErrKeyChangeInnerSignatureBad = errors.New("acme keychange: inner JWS signature did not verify against embedded JWK")
|
|
ErrKeyChangeInnerPayloadParse = errors.New("acme keychange: inner JWS payload is not parseable JSON")
|
|
ErrKeyChangeInnerAccountMismatch = errors.New("acme keychange: inner JWS payload `account` does not match outer JWS `kid`")
|
|
ErrKeyChangeInnerOldKeyMissing = errors.New("acme keychange: inner JWS payload missing `oldKey`")
|
|
ErrKeyChangeInnerOldKeyMismatch = errors.New("acme keychange: inner JWS payload `oldKey` does not match registered account key")
|
|
)
|
|
|
|
// ParseAndVerifyKeyChangeInner parses the inner JWS bytes (i.e. the
|
|
// outer JWS's verified payload), runs the same allow-list +
|
|
// signature-verification pipeline as VerifyJWS, and asserts the inner-
|
|
// only invariants from RFC 8555 §7.3.5 (must use `jwk`, must NOT use
|
|
// `kid`, URL must match).
|
|
//
|
|
// Caller passes:
|
|
//
|
|
// - innerBytes: the outer JWS's verified payload (the inner JWS in
|
|
// compact serialization).
|
|
// - outerKID: the outer JWS's `kid` header value. The inner's payload
|
|
// `account` field MUST equal this.
|
|
// - outerURL: the outer JWS's `url` header value. The inner's
|
|
// protected-header `url` MUST equal this.
|
|
// - registeredOldJWK: the JWK currently stored on the account row.
|
|
// The inner's payload `oldKey` MUST canonicalize-equal this.
|
|
//
|
|
// Returns the verified KeyChangeInner on success, or one of the
|
|
// sentinel errors above on any validation failure.
|
|
func ParseAndVerifyKeyChangeInner(innerBytes []byte, outerKID, outerURL string, registeredOldJWK *jose.JSONWebKey) (*KeyChangeInner, error) {
|
|
// Parse against the same allow-list that VerifyJWS uses.
|
|
jws, err := jose.ParseSigned(string(innerBytes), AllowedSignatureAlgorithms)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrKeyChangeInnerMalformed, err)
|
|
}
|
|
if len(jws.Signatures) != 1 {
|
|
return nil, fmt.Errorf("%w: multi-signature inner JWS", ErrKeyChangeInnerMalformed)
|
|
}
|
|
sig := jws.Signatures[0]
|
|
if !algorithmAllowed(sig.Protected.Algorithm) {
|
|
return nil, fmt.Errorf("%w: %s", ErrKeyChangeInnerAlgRejected, sig.Protected.Algorithm)
|
|
}
|
|
|
|
// RFC 8555 §7.3.5: the inner MUST use `jwk` and MUST NOT use `kid`.
|
|
if sig.Protected.KeyID != "" {
|
|
return nil, ErrKeyChangeInnerForbidsKID
|
|
}
|
|
jwk := sig.Protected.JSONWebKey
|
|
if jwk == nil {
|
|
return nil, ErrKeyChangeInnerMissingJWK
|
|
}
|
|
if !jwk.Valid() {
|
|
return nil, ErrKeyChangeInnerInvalidJWK
|
|
}
|
|
|
|
// URL header MUST equal the outer's URL.
|
|
innerURL, err := extractStringHeader(sig.Protected.ExtraHeaders, "url")
|
|
if err != nil {
|
|
return nil, ErrKeyChangeInnerURLMissing
|
|
}
|
|
if innerURL == "" {
|
|
return nil, ErrKeyChangeInnerURLMissing
|
|
}
|
|
if innerURL != outerURL {
|
|
return nil, fmt.Errorf("%w: inner=%q outer=%q", ErrKeyChangeInnerURLMismatch, innerURL, outerURL)
|
|
}
|
|
|
|
// Verify the inner signature against the embedded jwk.
|
|
verifiedPayload, err := jws.Verify(jwk.Key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrKeyChangeInnerSignatureBad, err)
|
|
}
|
|
|
|
// Parse the inner payload.
|
|
var payload KeyChangeInnerPayload
|
|
if err := json.Unmarshal(verifiedPayload, &payload); err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrKeyChangeInnerPayloadParse, err)
|
|
}
|
|
|
|
// `account` MUST equal outer's kid.
|
|
if payload.Account != outerKID {
|
|
return nil, fmt.Errorf("%w: payload=%q outer.kid=%q",
|
|
ErrKeyChangeInnerAccountMismatch, payload.Account, outerKID)
|
|
}
|
|
|
|
// `oldKey` MUST be present and canonicalize-equal to registered.
|
|
if payload.OldKey == nil {
|
|
return nil, ErrKeyChangeInnerOldKeyMissing
|
|
}
|
|
if !payload.OldKey.Valid() {
|
|
return nil, fmt.Errorf("%w: oldKey did not validate", ErrKeyChangeInnerOldKeyMismatch)
|
|
}
|
|
eq, err := jwksThumbprintEqual(payload.OldKey, registeredOldJWK)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: thumbprint compare: %v", ErrKeyChangeInnerOldKeyMismatch, err)
|
|
}
|
|
if !eq {
|
|
return nil, ErrKeyChangeInnerOldKeyMismatch
|
|
}
|
|
|
|
return &KeyChangeInner{
|
|
NewJWK: jwk,
|
|
Payload: payload,
|
|
URL: innerURL,
|
|
Algorithm: sig.Protected.Algorithm,
|
|
}, nil
|
|
}
|
|
|
|
// jwksThumbprintEqual compares two JWKs by RFC 7638 thumbprint, which
|
|
// is the canonical identity for a public key. We deliberately compare
|
|
// thumbprints rather than serialized bytes because go-jose may emit
|
|
// fields in different orders for "equal" keys.
|
|
//
|
|
// Returns (true, nil) when both thumbprints exist and match in
|
|
// constant time; (false, err) on any thumbprint computation error;
|
|
// (false, nil) when the thumbprints differ.
|
|
func jwksThumbprintEqual(a, b *jose.JSONWebKey) (bool, error) {
|
|
if a == nil || b == nil {
|
|
return false, nil
|
|
}
|
|
tA, err := JWKThumbprint(a)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
tB, err := JWKThumbprint(b)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return subtle.ConstantTimeCompare([]byte(tA), []byte(tB)) == 1, nil
|
|
}
|
|
|
|
// MapKeyChangeErrorToProblem renders an inner-JWS validation error as
|
|
// an RFC 7807 + RFC 8555 §6.7 Problem the handler emits via
|
|
// WriteProblem.
|
|
//
|
|
// All inner-JWS errors map to operator-friendly problem types. The
|
|
// detail string is a concise summary; the full err.Error() context is
|
|
// suppressed to avoid leaking internal-state details (master-prompt
|
|
// criterion #10).
|
|
func MapKeyChangeErrorToProblem(err error) Problem {
|
|
switch {
|
|
case errors.Is(err, ErrKeyChangeInnerSignatureBad),
|
|
errors.Is(err, ErrKeyChangeInnerOldKeyMismatch):
|
|
// Both indicate "you don't actually possess the rollover key
|
|
// pair" — treat as unauthorized per RFC 8555 §7.3.5.
|
|
return Problem{
|
|
Type: "urn:ietf:params:acme:error:unauthorized",
|
|
Detail: "key rollover proof failed: " + plainCause(err),
|
|
Status: 401,
|
|
}
|
|
case errors.Is(err, ErrKeyChangeInnerURLMismatch),
|
|
errors.Is(err, ErrKeyChangeInnerURLMissing):
|
|
return Problem{
|
|
Type: "urn:ietf:params:acme:error:unauthorized",
|
|
Detail: "key rollover inner URL: " + plainCause(err),
|
|
Status: 401,
|
|
}
|
|
case errors.Is(err, ErrKeyChangeInnerAlgRejected):
|
|
return Malformed("key rollover inner JWS uses disallowed algorithm")
|
|
case errors.Is(err, ErrKeyChangeInnerForbidsKID):
|
|
return Malformed("key rollover inner JWS MUST use `jwk`, not `kid`")
|
|
case errors.Is(err, ErrKeyChangeInnerMissingJWK),
|
|
errors.Is(err, ErrKeyChangeInnerInvalidJWK):
|
|
return Malformed("key rollover inner JWS missing or invalid `jwk`")
|
|
case errors.Is(err, ErrKeyChangeInnerAccountMismatch):
|
|
return Malformed("key rollover inner `account` does not match outer kid")
|
|
case errors.Is(err, ErrKeyChangeInnerOldKeyMissing):
|
|
return Malformed("key rollover inner missing `oldKey`")
|
|
case errors.Is(err, ErrKeyChangeInnerPayloadParse):
|
|
return Malformed("key rollover inner payload is not valid JSON")
|
|
case errors.Is(err, ErrKeyChangeInnerMalformed):
|
|
return Malformed("key rollover inner JWS malformed")
|
|
default:
|
|
return Malformed("key rollover request rejected")
|
|
}
|
|
}
|
|
|
|
// plainCause extracts the leaf error text without leaking the full
|
|
// wrap chain. Used by MapKeyChangeErrorToProblem to keep the operator-
|
|
// facing detail concise.
|
|
func plainCause(err error) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
// Walk to the leaf cause; emit its message verbatim.
|
|
for {
|
|
next := errors.Unwrap(err)
|
|
if next == nil {
|
|
return err.Error()
|
|
}
|
|
err = next
|
|
}
|
|
}
|