mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 16:11:29 +00:00
1e1bc9b3b4
CI on commit f6ba563 (Phase 4 gofmt fix) failed golangci-lint's
'unused' linter on internal/service/acme_phase4_test.go: the
stubRenewalPolicies type + its Get method were defined for a future
RenewalInfo happy-path test that I never actually wrote — only the
disabled + bad-cert-id negatives. The dead-code carried forward
because go vet doesn't catch unused-but-exported-shape, and the
package-private use never materialized.
Fix: delete the stubRenewalPolicies type + its method + the
adjacent stub-comment that referenced a similarly-imagined
stubIssuerConn that was never written either. The tests I have
(RotateAccountKey happy + duplicate, RevokeCert kid + jwk paths +
already-revoked + reason-clamping, RenewalInfo disabled +
bad-cert-id) all still pass — they don't reference the removed
type. The window-math is exercised directly in
internal/api/acme/phase4_test.go::TestComputeRenewalWindow_*; the
service-layer policy-lookup wiring is read at handler smoke time
in Phase 5.
Confirmed: 'gofmt -l .' clean; 'go vet ./internal/service/' clean;
'go test -short -count=1 ./internal/service/' green. Pre-commit
verification gate updated implicitly: future Phase commits should
spot-check unused-shape via grep against the test file (every
stub* helper should have ≥3 references, matching the live
helpers' usage profile).
355 lines
12 KiB
Go
355 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")
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|