Files
certctl/internal/service/acme_phase4_test.go
T
shankar0123 4dc8d3fa5b acme-server: key rollover + revocation + ARI (Phase 4/7)
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'.
2026-05-03 16:51:06 +00:00

367 lines
12 KiB
Go

// Copyright (c) certctl
// SPDX-License-Identifier: BSL-1.1
package service
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"errors"
"io"
"log/slog"
"math/big"
"strings"
"testing"
"time"
jose "github.com/go-jose/go-jose/v4"
"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"
)
// Phase 4 — service-layer tests for RotateAccountKey + RevokeCert +
// RenewalInfo against the in-memory fakeACMERepo. These exercise the
// service contract; full-stack JWS-flow tests live in the api/acme +
// handler test packages.
// --- RotateAccountKey ---------------------------------------------------
func newTestRSAJWKForSvc(t *testing.T) (*rsa.PrivateKey, *jose.JSONWebKey) {
t.Helper()
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa.GenerateKey: %v", err)
}
jwk := &jose.JSONWebKey{Key: priv.Public(), Algorithm: string(jose.RS256), Use: "sig"}
return priv, jwk
}
func newTestECDSAJWKForSvc(t *testing.T) (*ecdsa.PrivateKey, *jose.JSONWebKey) {
t.Helper()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("ecdsa.GenerateKey: %v", err)
}
jwk := &jose.JSONWebKey{Key: priv.Public(), Algorithm: string(jose.ES256), Use: "sig"}
return priv, jwk
}
func TestRotateAccountKey_HappyPath(t *testing.T) {
cfg := config.ACMEServerConfig{NonceTTL: 5 * time.Minute}
profiles := map[string]*domain.CertificateProfile{"prof-corp": {ID: "prof-corp"}}
svc, repo, _ := newSvcWithAudit(t, cfg, profiles)
_, oldJWK := newTestRSAJWKForSvc(t)
oldThumb, err := acme.JWKThumbprint(oldJWK)
if err != nil {
t.Fatalf("thumb: %v", err)
}
oldPEM, err := acme.JWKToPEM(oldJWK)
if err != nil {
t.Fatalf("pem: %v", err)
}
repo.accounts["acme-acc-test"] = &domain.ACMEAccount{
AccountID: "acme-acc-test",
ProfileID: "prof-corp",
JWKThumbprint: oldThumb,
JWKPEM: oldPEM,
Status: domain.ACMEAccountStatusValid,
}
_, newJWK := newTestECDSAJWKForSvc(t)
rolled, err := svc.RotateAccountKey(context.Background(), repo.accounts["acme-acc-test"], newJWK)
if err != nil {
t.Fatalf("RotateAccountKey: %v", err)
}
newThumb, _ := acme.JWKThumbprint(newJWK)
if rolled.JWKThumbprint != newThumb {
t.Errorf("rolled thumbprint = %q, want %q", rolled.JWKThumbprint, newThumb)
}
if repo.accounts["acme-acc-test"].JWKThumbprint != newThumb {
t.Errorf("persisted thumbprint not updated")
}
}
func TestRotateAccountKey_DuplicateNewKey(t *testing.T) {
cfg := config.ACMEServerConfig{NonceTTL: 5 * time.Minute}
profiles := map[string]*domain.CertificateProfile{"prof-corp": {ID: "prof-corp"}}
svc, repo, _ := newSvcWithAudit(t, cfg, profiles)
_, oldJWK := newTestRSAJWKForSvc(t)
_, newJWK := newTestECDSAJWKForSvc(t)
oldThumb, _ := acme.JWKThumbprint(oldJWK)
newThumb, _ := acme.JWKThumbprint(newJWK)
oldPEM, _ := acme.JWKToPEM(oldJWK)
newPEM, _ := acme.JWKToPEM(newJWK)
// Account A holds the OLD key (will request rotation).
repo.accounts["acme-acc-A"] = &domain.ACMEAccount{
AccountID: "acme-acc-A",
ProfileID: "prof-corp",
JWKThumbprint: oldThumb,
JWKPEM: oldPEM,
Status: domain.ACMEAccountStatusValid,
}
// Account B already holds the NEW key — collision target.
repo.accounts["acme-acc-B"] = &domain.ACMEAccount{
AccountID: "acme-acc-B",
ProfileID: "prof-corp",
JWKThumbprint: newThumb,
JWKPEM: newPEM,
Status: domain.ACMEAccountStatusValid,
}
// Wire the thumbprint→account index that GetAccountByThumbprint
// consults.
repo.thumbToAccount["prof-corp|"+newThumb] = "acme-acc-B"
_, err := svc.RotateAccountKey(context.Background(), repo.accounts["acme-acc-A"], newJWK)
if !errors.Is(err, ErrACMEKeyRolloverDuplicateKey) {
t.Errorf("got err=%v, want ErrACMEKeyRolloverDuplicateKey", err)
}
}
// --- RevokeCert ---------------------------------------------------------
// stubRevoker captures the args RevokeCertificateWithActor receives.
type stubRevoker struct {
calls []revokeCall
err error
}
type revokeCall struct {
certID, reason, actor string
}
func (s *stubRevoker) RevokeCertificateWithActor(ctx context.Context, certID, reason, actor string) error {
s.calls = append(s.calls, revokeCall{certID, reason, actor})
return s.err
}
// stubCertRepo is a minimal CertificateRepository for revoke + renewal-info tests.
type stubCertRepo struct {
repository.CertificateRepository
cert *domain.ManagedCertificate
version *domain.CertificateVersion
getErr error
}
func (s *stubCertRepo) GetVersionBySerial(ctx context.Context, issuerID, serial string) (*domain.CertificateVersion, error) {
if s.getErr != nil {
return nil, s.getErr
}
if s.version != nil && s.version.SerialNumber == serial {
return s.version, nil
}
return nil, errors.New("not found")
}
func (s *stubCertRepo) Get(ctx context.Context, id string) (*domain.ManagedCertificate, error) {
if s.cert != nil && s.cert.ID == id {
return s.cert, nil
}
return nil, errors.New("not found")
}
// stubIssuerConn is a no-op IssuerConnector for firstAvailableIssuer().
// We don't need the connector itself to do anything; just that
// firstAvailableIssuer returns ok=true.
// stubRenewalPolicies is a minimal RenewalPolicyLookup.
type stubRenewalPolicies struct {
pol *domain.RenewalPolicy
}
func (s *stubRenewalPolicies) Get(ctx context.Context, id string) (*domain.RenewalPolicy, error) {
if s.pol != nil && s.pol.ID == id {
return s.pol, nil
}
return nil, errors.New("not found")
}
func generateRevocationFixture(t *testing.T) (cert *domain.ManagedCertificate, version *domain.CertificateVersion, der []byte, certPriv *ecdsa.PrivateKey) {
t.Helper()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("genkey: %v", err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(0xabcdef12),
Subject: pkix.Name{CommonName: "leaf.example.com"},
NotBefore: time.Now().Add(-1 * time.Hour),
NotAfter: time.Now().Add(90 * 24 * time.Hour),
}
der, err = x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv)
if err != nil {
t.Fatalf("CreateCertificate: %v", err)
}
parsed, _ := x509.ParseCertificate(der)
serialHex := strings.ToLower(parsed.SerialNumber.Text(16))
cert = &domain.ManagedCertificate{
ID: "mc-test-001",
IssuerID: "iss-test",
ExpiresAt: parsed.NotAfter,
Status: domain.CertificateStatusActive,
}
version = &domain.CertificateVersion{
CertificateID: cert.ID,
SerialNumber: serialHex,
NotBefore: parsed.NotBefore,
NotAfter: parsed.NotAfter,
}
return cert, version, der, priv
}
// minimalIssuerRegistryWithOne returns an IssuerRegistry that reports
// one available issuer so firstAvailableIssuer() is happy.
func minimalIssuerRegistryWithOne() *IssuerRegistry {
r := NewIssuerRegistry(slog.New(slog.NewTextHandler(io.Discard, nil)))
r.issuers["iss-test"] = nil // map entry is enough for first-available iteration
return r
}
func TestRevokeCert_NotConfigured(t *testing.T) {
cfg := config.ACMEServerConfig{NonceTTL: 5 * time.Minute}
svc, _, _ := newSvcWithAudit(t, cfg, nil)
err := svc.RevokeCert(context.Background(), &acme.VerifiedRequest{}, []byte{1, 2, 3}, 0)
if !errors.Is(err, ErrACMERevocationUnconfigured) {
t.Errorf("got err=%v, want ErrACMERevocationUnconfigured", err)
}
}
func TestRevokeCert_KidPath_AccountDoesNotOwn(t *testing.T) {
cfg := config.ACMEServerConfig{NonceTTL: 5 * time.Minute}
svc, _, _ := newSvcWithAudit(t, cfg, nil)
revoker := &stubRevoker{}
cert, version, der, _ := generateRevocationFixture(t)
certRepo := &stubCertRepo{cert: cert, version: version}
svc.SetIssuancePipeline(nil, certRepo, minimalIssuerRegistryWithOne())
svc.SetRevocationDelegate(revoker)
verified := &acme.VerifiedRequest{
Account: &domain.ACMEAccount{AccountID: "acme-acc-NotOwner"},
}
err := svc.RevokeCert(context.Background(), verified, der, 0)
if !errors.Is(err, ErrACMERevocationUnauthorized) {
t.Errorf("got err=%v, want ErrACMERevocationUnauthorized", err)
}
if len(revoker.calls) != 0 {
t.Errorf("revoker should not have been called: %+v", revoker.calls)
}
}
func TestRevokeCert_JWKPath_KeyMismatch(t *testing.T) {
cfg := config.ACMEServerConfig{NonceTTL: 5 * time.Minute}
svc, _, _ := newSvcWithAudit(t, cfg, nil)
revoker := &stubRevoker{}
cert, version, der, _ := generateRevocationFixture(t)
certRepo := &stubCertRepo{cert: cert, version: version}
svc.SetIssuancePipeline(nil, certRepo, minimalIssuerRegistryWithOne())
svc.SetRevocationDelegate(revoker)
// Different JWK than the cert's own pubkey → 401.
_, otherJWK := newTestECDSAJWKForSvc(t)
verified := &acme.VerifiedRequest{JWK: otherJWK}
err := svc.RevokeCert(context.Background(), verified, der, 0)
if !errors.Is(err, ErrACMERevocationUnauthorized) {
t.Errorf("got err=%v, want ErrACMERevocationUnauthorized", err)
}
}
func TestRevokeCert_JWKPath_HappyPath(t *testing.T) {
cfg := config.ACMEServerConfig{NonceTTL: 5 * time.Minute}
svc, _, _ := newSvcWithAudit(t, cfg, nil)
revoker := &stubRevoker{}
cert, version, der, certPriv := generateRevocationFixture(t)
certRepo := &stubCertRepo{cert: cert, version: version}
svc.SetIssuancePipeline(nil, certRepo, minimalIssuerRegistryWithOne())
svc.SetRevocationDelegate(revoker)
// JWK == cert's own pubkey.
jwk := &jose.JSONWebKey{Key: certPriv.Public(), Algorithm: string(jose.ES256)}
verified := &acme.VerifiedRequest{JWK: jwk}
if err := svc.RevokeCert(context.Background(), verified, der, 1 /*keyCompromise*/); err != nil {
t.Fatalf("RevokeCert: %v", err)
}
if len(revoker.calls) != 1 {
t.Fatalf("revoker calls = %d, want 1", len(revoker.calls))
}
got := revoker.calls[0]
if got.certID != cert.ID {
t.Errorf("certID = %q, want %q", got.certID, cert.ID)
}
if got.reason != string(domain.RevocationReasonKeyCompromise) {
t.Errorf("reason = %q, want keyCompromise", got.reason)
}
if !strings.HasPrefix(got.actor, "acme-cert-key:") {
t.Errorf("actor = %q, want prefix acme-cert-key:", got.actor)
}
}
func TestRevokeCert_AlreadyRevoked(t *testing.T) {
cfg := config.ACMEServerConfig{NonceTTL: 5 * time.Minute}
svc, _, _ := newSvcWithAudit(t, cfg, nil)
revoker := &stubRevoker{}
cert, version, der, certPriv := generateRevocationFixture(t)
cert.Status = domain.CertificateStatusRevoked
certRepo := &stubCertRepo{cert: cert, version: version}
svc.SetIssuancePipeline(nil, certRepo, minimalIssuerRegistryWithOne())
svc.SetRevocationDelegate(revoker)
jwk := &jose.JSONWebKey{Key: certPriv.Public(), Algorithm: string(jose.ES256)}
err := svc.RevokeCert(context.Background(), &acme.VerifiedRequest{JWK: jwk}, der, 0)
if !errors.Is(err, ErrACMERevocationAlreadyRevoked) {
t.Errorf("got err=%v, want ErrACMERevocationAlreadyRevoked", err)
}
}
func TestRevokeCert_ReasonClamping(t *testing.T) {
cases := []struct {
code int
want string
}{
{0, "unspecified"},
{1, "keyCompromise"},
{4, "superseded"},
{8, "unspecified"}, // out-of-range RFC 5280 code
{99, "unspecified"}, // out-of-range
{-1, "unspecified"}, // negative
}
for _, c := range cases {
got := mapACMERevocationReason(c.code)
if got != c.want {
t.Errorf("code=%d: got %q, want %q", c.code, got, c.want)
}
}
}
// --- RenewalInfo --------------------------------------------------------
func TestRenewalInfo_Disabled(t *testing.T) {
cfg := config.ACMEServerConfig{NonceTTL: 5 * time.Minute, ARIEnabled: false}
svc, _, _ := newSvcWithAudit(t, cfg, nil)
_, _, err := svc.RenewalInfo(context.Background(), "prof-corp", "abc.def")
if !errors.Is(err, ErrACMEARIDisabled) {
t.Errorf("got err=%v, want ErrACMEARIDisabled", err)
}
}
func TestRenewalInfo_BadCertID(t *testing.T) {
cfg := config.ACMEServerConfig{
NonceTTL: 5 * time.Minute,
ARIEnabled: true,
ARIPollInterval: 6 * time.Hour,
}
profiles := map[string]*domain.CertificateProfile{"prof-corp": {ID: "prof-corp"}}
svc, _, _ := newSvcWithAudit(t, cfg, profiles)
_, _, err := svc.RenewalInfo(context.Background(), "prof-corp", "not-a-valid-cert-id")
if !errors.Is(err, ErrACMEARIBadCertID) {
t.Errorf("got err=%v, want ErrACMEARIBadCertID", err)
}
}