Files
certctl/internal/api/acme/keychange.go
T
shankar0123 21aeed4f4e legal: addlicense headers + normalize legacy variants (Phase 0 RED-4)
Phase 0 closure (Path B2, post-rewrite):

addlicense sweep — adds the canonical certctl LLC copyright + BUSL-1.1
SPDX header to every production Go file. Template:

  // Copyright 2026 certctl LLC. All rights reserved.
  // SPDX-License-Identifier: BUSL-1.1

Coverage: 338 / 338 production Go files (cmd/ + internal/, excluding
*_test.go and **/testdata/**). Pre-sweep coverage was 22 / 338 (6.5%);
post-sweep is 338 / 338 (100%).

Normalized 22 pre-existing legacy headers (`// Copyright (c) certctl`
+ `// SPDX-License-Identifier: BSL-1.1`) and 1 file using a
`Certctl Contributors` attribution. The legacy SPDX ID `BSL-1.1`
is non-standard; the official SPDX identifier for Business Source
License 1.1 is `BUSL-1.1` (capital U). All 338 files now share the
canonical form.

Generated via:
  addlicense -c "certctl LLC" -y 2026 \
    -f cowork/legal/copyright-header.tpl \
    -ignore '**/testdata/**' -ignore '**/*_test.go' \
    cmd/ internal/

Verification:
  find cmd internal -name '*.go' -not -name '*_test.go' \
    -not -path '*/testdata/*' \
    -exec grep -L '^// Copyright 2026 certctl LLC' {} \; | wc -l

  Returns: 0

gofmt clean. Header additions are comments only, no compile impact.

Closes: cowork/certctl-architecture-diligence-audit.html#fix-RED-4
2026-05-13 21:23:35 +00:00

273 lines
10 KiB
Go

// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-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
}
}