mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-14 15:58:56 +00:00
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'.
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user