// 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/certctl-io/certctl/internal/api/acme" "github.com/certctl-io/certctl/internal/config" "github.com/certctl-io/certctl/internal/domain" "github.com/certctl-io/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") } // generateRevocationFixture builds a self-signed leaf cert + the // matching managed-certificate + version domain rows the RevokeCert // tests below feed into the stubCertRepo. The cert is signed under its // own key so the jwk-path tests can present a verifying JWK. 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) } }