mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:51:30 +00:00
62523fb845
Service-layer pin for Rank 8. The fake IntermediateCARepository's
WalkAncestry mirrors the postgres recursive-CTE semantics
(leaf-first ordering, terminate at parent_ca_id IS NULL) so the
AssembleChain pin carries the same weight the production repo would.
Tests:
TestIntermediateCA_CreateRoot_RegistersOperatorSuppliedSelfSigned
Happy path. RFC 5280 §3.2 self-signed root + matching key gets
persisted with parent_ca_id=NULL, state=active, KeyDriverID=...
TestIntermediateCA_CreateRoot_RejectsNonSelfSigned
RFC 5280 §3.2 enforcement. Cert whose embedded public key
doesn't match the actual signer fails CheckSignatureFrom →
ErrCANotSelfSigned.
TestIntermediateCA_CreateRoot_RejectsKeyMismatch
Operator-boundary defense in depth. Cert is well-formed
self-signed but the supplied keyDriverID resolves to a
different key → ErrCAKeyMismatch.
TestIntermediateCA_CreateChild_PathLenTighteningEnforced
RFC 5280 §4.2.1.9 enforcement. Child whose path-len equals or
exceeds parent's → ErrPathLenExceeded. Strictly-tighter child
succeeds.
TestIntermediateCA_CreateChild_NameConstraintsSubset
RFC 5280 §4.2.1.10 enforcement. Widening rejected
("evil.com" outside parent's "example.com"); subdomain
narrowing succeeds ("internal.example.com").
TestIntermediateCA_AssembleChain_4DeepHierarchy ← LOAD-BEARING
The pin the local connector tree-mode delegates to. Builds
root → policy → issuing-A → issuing-B and asserts AssembleChain
returns 4 CERTIFICATE blocks in leaf-to-root order with
matching subject CommonNames at each depth.
TestIntermediateCA_Retire_RefusesIfActiveChildren
Drain-first semantics. retiring → retired with active children
refuses with ErrCAStillHasActiveChildren.
TestIntermediateCA_Retire_TwoPhaseConfirm
First call: active → retiring (no confirm). Second call without
confirm: surfaces "pass confirm=true". Second call with
confirm: retiring → retired.
TestIntermediateCA_MetricsRecordedPerOutcome
Snapshot pin. CreateRoot bumps create_root, CreateChild bumps
create_child, Retire(active) bumps retire_retiring, all
dimensioned by issuer_id.
TestIntermediateCA_LoadHierarchy_FlatList
Returns every CA for an issuer ordered by created_at; caller
renders the tree from parent_ca_id.
Test infrastructure:
fakeIntermediateCARepo — sync.Mutex-guarded map.
WalkAncestry walks
parent_ca_id from leafID
to root (or terminates on
cycle, defense-in-depth).
Compile-time interface
guard.
testCAFixture — mints a self-signed root
cert+key in process,
Adopt()s the key under
a stable ref so CreateRoot
can resolve it.
newTestService — wires IntermediateCAService
with fake repo +
signer.MemoryDriver +
mockAuditRepo (already
lives in testutil_test.go)
+ IntermediateCAMetrics.
Verified locally:
gofmt: clean.
go vet ./...: exit 0.
go test -short -count=1 -run TestIntermediateCA ./internal/service/...
PASS (10/10)
go test -short -count=1 ./internal/service/...: ok 3.844s
Reference: cowork/rank-8-intermediate-ca-hierarchy-prompt.md, commit 2.5.
612 lines
22 KiB
Go
612 lines
22 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/pem"
|
|
"errors"
|
|
"math/big"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/certctl-io/certctl/internal/crypto/signer"
|
|
"github.com/certctl-io/certctl/internal/domain"
|
|
"github.com/certctl-io/certctl/internal/repository"
|
|
)
|
|
|
|
// fakeIntermediateCARepo is an in-memory IntermediateCARepository for
|
|
// service-layer tests. WalkAncestry mirrors the recursive-CTE
|
|
// semantics shipped by the postgres adapter: leaf-first ordering,
|
|
// terminating at the row whose parent_ca_id IS NULL. The AssembleChain
|
|
// pin only carries weight if this fake produces the same shape the
|
|
// production adapter would.
|
|
type fakeIntermediateCARepo struct {
|
|
mu sync.Mutex
|
|
rows map[string]*domain.IntermediateCA
|
|
seq int
|
|
}
|
|
|
|
func newFakeIntermediateCARepo() *fakeIntermediateCARepo {
|
|
return &fakeIntermediateCARepo{rows: make(map[string]*domain.IntermediateCA)}
|
|
}
|
|
|
|
func (f *fakeIntermediateCARepo) Create(ctx context.Context, ca *domain.IntermediateCA) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if ca.ID == "" {
|
|
f.seq++
|
|
ca.ID = "ica-fake-" + strings.ToLower(stringn(f.seq))
|
|
}
|
|
if _, exists := f.rows[ca.ID]; exists {
|
|
return repository.ErrAlreadyExists
|
|
}
|
|
if ca.CreatedAt.IsZero() {
|
|
ca.CreatedAt = time.Now().UTC()
|
|
}
|
|
if ca.UpdatedAt.IsZero() {
|
|
ca.UpdatedAt = ca.CreatedAt
|
|
}
|
|
cp := *ca
|
|
f.rows[ca.ID] = &cp
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeIntermediateCARepo) Get(ctx context.Context, id string) (*domain.IntermediateCA, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
r, ok := f.rows[id]
|
|
if !ok {
|
|
return nil, repository.ErrNotFound
|
|
}
|
|
cp := *r
|
|
return &cp, nil
|
|
}
|
|
|
|
func (f *fakeIntermediateCARepo) ListByIssuer(ctx context.Context, issuerID string) ([]*domain.IntermediateCA, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
var out []*domain.IntermediateCA
|
|
for _, r := range f.rows {
|
|
if r.OwningIssuerID == issuerID {
|
|
cp := *r
|
|
out = append(out, &cp)
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.Before(out[j].CreatedAt) })
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeIntermediateCARepo) ListChildren(ctx context.Context, parentCAID string) ([]*domain.IntermediateCA, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
var out []*domain.IntermediateCA
|
|
for _, r := range f.rows {
|
|
if r.ParentCAID != nil && *r.ParentCAID == parentCAID {
|
|
cp := *r
|
|
out = append(out, &cp)
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.Before(out[j].CreatedAt) })
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeIntermediateCARepo) UpdateState(ctx context.Context, id string, state domain.IntermediateCAState) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
r, ok := f.rows[id]
|
|
if !ok {
|
|
return repository.ErrNotFound
|
|
}
|
|
r.State = state
|
|
r.UpdatedAt = time.Now().UTC()
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeIntermediateCARepo) GetActiveRoot(ctx context.Context, issuerID string) (*domain.IntermediateCA, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
for _, r := range f.rows {
|
|
if r.OwningIssuerID == issuerID && r.ParentCAID == nil && r.State == domain.IntermediateCAStateActive {
|
|
cp := *r
|
|
return &cp, nil
|
|
}
|
|
}
|
|
return nil, repository.ErrNotFound
|
|
}
|
|
|
|
// WalkAncestry mirrors the postgres recursive-CTE: anchor on leafID,
|
|
// then iteratively follow parent_ca_id to the root. Ordering is
|
|
// leaf-first. Returns ErrNotFound when leafID does not exist (matching
|
|
// the postgres adapter's contract).
|
|
func (f *fakeIntermediateCARepo) WalkAncestry(ctx context.Context, leafID string) ([]*domain.IntermediateCA, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
cur, ok := f.rows[leafID]
|
|
if !ok {
|
|
return nil, repository.ErrNotFound
|
|
}
|
|
var out []*domain.IntermediateCA
|
|
visited := map[string]bool{}
|
|
for cur != nil {
|
|
if visited[cur.ID] {
|
|
// Defense in depth: refuse cycles. Production schema's
|
|
// no-self-parent CHECK + the parent_ca_id FK make this
|
|
// unreachable; the fake is paranoid by construction.
|
|
break
|
|
}
|
|
visited[cur.ID] = true
|
|
cp := *cur
|
|
out = append(out, &cp)
|
|
if cur.ParentCAID == nil {
|
|
break
|
|
}
|
|
cur = f.rows[*cur.ParentCAID]
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func stringn(n int) string {
|
|
if n == 0 {
|
|
return "0"
|
|
}
|
|
const digits = "0123456789"
|
|
var b []byte
|
|
for n > 0 {
|
|
b = append([]byte{digits[n%10]}, b...)
|
|
n /= 10
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// Compile-time interface guard.
|
|
var _ repository.IntermediateCARepository = (*fakeIntermediateCARepo)(nil)
|
|
|
|
// testCAFixture is a one-shot helper that builds a self-signed root
|
|
// cert + key in process memory and adopts the key into a MemoryDriver
|
|
// under a stable ref. Returns the PEM-encoded cert, the
|
|
// signer.MemoryDriver, and the keyDriverID the service can pass to
|
|
// CreateRoot.
|
|
func testCAFixture(t *testing.T, drv *signer.MemoryDriver, ref string, subject pkix.Name, pathLen *int, ncs []domain.NameConstraint) []byte {
|
|
t.Helper()
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("ecdsa keygen: %v", err)
|
|
}
|
|
if err := drv.Adopt(ref, key); err != nil {
|
|
t.Fatalf("adopt key: %v", err)
|
|
}
|
|
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
|
if err != nil {
|
|
t.Fatalf("serial: %v", err)
|
|
}
|
|
tmpl := &x509.Certificate{
|
|
SerialNumber: serial,
|
|
Subject: subject,
|
|
Issuer: subject, // self-signed
|
|
NotBefore: time.Now().Add(-time.Hour).UTC(),
|
|
NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour).UTC(),
|
|
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
|
BasicConstraintsValid: true,
|
|
IsCA: true,
|
|
}
|
|
if pathLen != nil {
|
|
tmpl.MaxPathLen = *pathLen
|
|
tmpl.MaxPathLenZero = (*pathLen == 0)
|
|
}
|
|
if len(ncs) > 0 {
|
|
var permitted, excluded []string
|
|
for _, nc := range ncs {
|
|
permitted = append(permitted, nc.Permitted...)
|
|
excluded = append(excluded, nc.Excluded...)
|
|
}
|
|
tmpl.PermittedDNSDomains = permitted
|
|
tmpl.ExcludedDNSDomains = excluded
|
|
tmpl.PermittedDNSDomainsCritical = true
|
|
}
|
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
|
if err != nil {
|
|
t.Fatalf("create cert: %v", err)
|
|
}
|
|
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
|
}
|
|
|
|
// newTestService spins up an IntermediateCAService backed by the
|
|
// in-memory repo + MemoryDriver + a no-op audit service.
|
|
func newTestService(t *testing.T) (*IntermediateCAService, *fakeIntermediateCARepo, *signer.MemoryDriver, *IntermediateCAMetrics) {
|
|
t.Helper()
|
|
repo := newFakeIntermediateCARepo()
|
|
drv := signer.NewMemoryDriver()
|
|
auditRepo := &mockAuditRepo{}
|
|
auditSvc := NewAuditService(auditRepo)
|
|
metrics := NewIntermediateCAMetrics()
|
|
svc := NewIntermediateCAService(repo, nil, drv, auditSvc, metrics)
|
|
return svc, repo, drv, metrics
|
|
}
|
|
|
|
// ==== Tests ====
|
|
|
|
// TestIntermediateCA_CreateRoot_RegistersOperatorSuppliedSelfSigned
|
|
// pins the happy-path: a valid self-signed root cert + matching key
|
|
// gets persisted with parent_ca_id = NULL and state=active.
|
|
func TestIntermediateCA_CreateRoot_RegistersOperatorSuppliedSelfSigned(t *testing.T) {
|
|
svc, repo, drv, _ := newTestService(t)
|
|
pem := testCAFixture(t, drv, "root-key", pkix.Name{CommonName: "Acme Root"}, nil, nil)
|
|
|
|
id, err := svc.CreateRoot(context.Background(), "iss-acme", "Acme Root", "user-admin",
|
|
pem, "root-key", nil)
|
|
if err != nil {
|
|
t.Fatalf("CreateRoot: %v", err)
|
|
}
|
|
if !strings.HasPrefix(id, "ica-") {
|
|
t.Fatalf("expected ica- prefix, got %q", id)
|
|
}
|
|
got, err := repo.Get(context.Background(), id)
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if got.ParentCAID != nil {
|
|
t.Fatalf("expected ParentCAID nil for root, got %v", *got.ParentCAID)
|
|
}
|
|
if got.State != domain.IntermediateCAStateActive {
|
|
t.Fatalf("expected state=active, got %v", got.State)
|
|
}
|
|
if got.KeyDriverID != "root-key" {
|
|
t.Fatalf("expected KeyDriverID=root-key, got %q", got.KeyDriverID)
|
|
}
|
|
}
|
|
|
|
// TestIntermediateCA_CreateRoot_RejectsNonSelfSigned pins RFC 5280
|
|
// §3.2: a cert whose issuer ≠ subject (or whose signature does not
|
|
// verify under its own public key) MUST NOT be registered as a root.
|
|
func TestIntermediateCA_CreateRoot_RejectsNonSelfSigned(t *testing.T) {
|
|
svc, _, drv, _ := newTestService(t)
|
|
|
|
// Build a cert whose issuer differs from subject — the validator
|
|
// in CreateRoot relies on cert.CheckSignatureFrom(cert), which fails
|
|
// when the embedded issuer DN doesn't match the cert's own public
|
|
// key. We achieve that by signing a "child" template with a DIFFERENT
|
|
// key under the same subject — so the public key the verifier loads
|
|
// from the cert (cert.PublicKey) does not match the actual signer.
|
|
signerKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
embeddedKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err := drv.Adopt("mismatched-key", signerKey); err != nil {
|
|
t.Fatalf("adopt: %v", err)
|
|
}
|
|
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
|
tmpl := &x509.Certificate{
|
|
SerialNumber: serial,
|
|
Subject: pkix.Name{CommonName: "Imposter Root"},
|
|
Issuer: pkix.Name{CommonName: "Imposter Root"},
|
|
NotBefore: time.Now().Add(-time.Hour),
|
|
NotAfter: time.Now().Add(time.Hour),
|
|
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
|
BasicConstraintsValid: true,
|
|
IsCA: true,
|
|
}
|
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &embeddedKey.PublicKey, signerKey)
|
|
if err != nil {
|
|
t.Fatalf("create cert: %v", err)
|
|
}
|
|
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
|
|
|
_, err = svc.CreateRoot(context.Background(), "iss-acme", "Bad Root", "user-admin",
|
|
pemBytes, "mismatched-key", nil)
|
|
if !errors.Is(err, ErrCANotSelfSigned) {
|
|
t.Fatalf("expected ErrCANotSelfSigned, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestIntermediateCA_CreateRoot_RejectsKeyMismatch pins the second
|
|
// gate: cert is well-formed self-signed, but the operator-supplied
|
|
// keyDriverID resolves to a DIFFERENT key. CreateRoot must refuse
|
|
// before persisting the row.
|
|
func TestIntermediateCA_CreateRoot_RejectsKeyMismatch(t *testing.T) {
|
|
svc, _, drv, _ := newTestService(t)
|
|
pemBytes := testCAFixture(t, drv, "real-root-key", pkix.Name{CommonName: "Acme Root"}, nil, nil)
|
|
// Adopt an unrelated key under a different ref.
|
|
stranger, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err := drv.Adopt("stranger-key", stranger); err != nil {
|
|
t.Fatalf("adopt: %v", err)
|
|
}
|
|
|
|
_, err := svc.CreateRoot(context.Background(), "iss-acme", "Acme Root", "user-admin",
|
|
pemBytes, "stranger-key", nil)
|
|
if !errors.Is(err, ErrCAKeyMismatch) {
|
|
t.Fatalf("expected ErrCAKeyMismatch, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestIntermediateCA_CreateChild_PathLenTighteningEnforced pins RFC
|
|
// 5280 §4.2.1.9: a child whose requested PathLenConstraint equals or
|
|
// exceeds the parent's MUST be rejected.
|
|
func TestIntermediateCA_CreateChild_PathLenTighteningEnforced(t *testing.T) {
|
|
svc, _, drv, _ := newTestService(t)
|
|
parentPathLen := 1
|
|
rootPEM := testCAFixture(t, drv, "root-key", pkix.Name{CommonName: "Acme Root"}, &parentPathLen, nil)
|
|
rootID, err := svc.CreateRoot(context.Background(), "iss-acme", "Acme Root", "user-admin", rootPEM, "root-key", nil)
|
|
if err != nil {
|
|
t.Fatalf("CreateRoot: %v", err)
|
|
}
|
|
|
|
// Child requests path-len 1, parent has path-len 1 → child >= parent → reject.
|
|
requested := 1
|
|
_, err = svc.CreateChild(context.Background(), rootID, "Acme Policy CA", "user-admin",
|
|
&CreateChildOptions{
|
|
Subject: pkix.Name{CommonName: "Acme Policy CA"},
|
|
Algorithm: signer.AlgorithmECDSAP256,
|
|
TTL: 365 * 24 * time.Hour,
|
|
PathLenConstraint: &requested,
|
|
})
|
|
if !errors.Is(err, ErrPathLenExceeded) {
|
|
t.Fatalf("expected ErrPathLenExceeded, got %v", err)
|
|
}
|
|
|
|
// Child requests path-len 0 (strictly less), under parent path-len 1 → ok.
|
|
tighter := 0
|
|
if _, err := svc.CreateChild(context.Background(), rootID, "Acme Issuing CA", "user-admin",
|
|
&CreateChildOptions{
|
|
Subject: pkix.Name{CommonName: "Acme Issuing CA"},
|
|
Algorithm: signer.AlgorithmECDSAP256,
|
|
TTL: 365 * 24 * time.Hour,
|
|
PathLenConstraint: &tighter,
|
|
}); err != nil {
|
|
t.Fatalf("expected tightening to succeed, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestIntermediateCA_CreateChild_NameConstraintsSubset pins RFC 5280
|
|
// §4.2.1.10 enforcement at service layer. Parent permits "example.com";
|
|
// child trying to widen with "evil.com" must be rejected, while a
|
|
// subdomain "internal.example.com" must succeed.
|
|
func TestIntermediateCA_CreateChild_NameConstraintsSubset(t *testing.T) {
|
|
svc, _, drv, _ := newTestService(t)
|
|
parentNCs := []domain.NameConstraint{{Permitted: []string{"example.com"}}}
|
|
rootPEM := testCAFixture(t, drv, "root-key", pkix.Name{CommonName: "Acme Root"}, nil, parentNCs)
|
|
rootID, err := svc.CreateRoot(context.Background(), "iss-acme", "Acme Root", "user-admin", rootPEM, "root-key", nil)
|
|
if err != nil {
|
|
t.Fatalf("CreateRoot: %v", err)
|
|
}
|
|
|
|
// Widening is rejected.
|
|
_, err = svc.CreateChild(context.Background(), rootID, "Bad Child", "user-admin",
|
|
&CreateChildOptions{
|
|
Subject: pkix.Name{CommonName: "Bad Child"},
|
|
Algorithm: signer.AlgorithmECDSAP256,
|
|
TTL: 365 * 24 * time.Hour,
|
|
NameConstraints: []domain.NameConstraint{{Permitted: []string{"evil.com"}}},
|
|
})
|
|
if !errors.Is(err, ErrNameConstraintExceeded) {
|
|
t.Fatalf("expected ErrNameConstraintExceeded, got %v", err)
|
|
}
|
|
|
|
// Subdomain narrowing succeeds.
|
|
if _, err := svc.CreateChild(context.Background(), rootID, "Acme Internal CA", "user-admin",
|
|
&CreateChildOptions{
|
|
Subject: pkix.Name{CommonName: "Acme Internal CA"},
|
|
Algorithm: signer.AlgorithmECDSAP256,
|
|
TTL: 365 * 24 * time.Hour,
|
|
NameConstraints: []domain.NameConstraint{{Permitted: []string{"internal.example.com"}}},
|
|
}); err != nil {
|
|
t.Fatalf("expected subdomain narrowing to succeed, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestIntermediateCA_AssembleChain_4DeepHierarchy is the LOAD-BEARING
|
|
// pin for AssembleChain: a 4-level hierarchy (root → policy →
|
|
// issuing-A → issuing-B-leaf) must produce a leaf-to-root PEM bundle
|
|
// with exactly 4 CERTIFICATE blocks in the right order. This is what
|
|
// the local connector's tree-mode code-path delegates to at
|
|
// IssueCertificate time.
|
|
func TestIntermediateCA_AssembleChain_4DeepHierarchy(t *testing.T) {
|
|
svc, _, drv, _ := newTestService(t)
|
|
// Root with path-len 3 (allows 3 layers of sub-CAs).
|
|
rootPathLen := 3
|
|
rootPEM := testCAFixture(t, drv, "root-key", pkix.Name{CommonName: "Acme Root"}, &rootPathLen, nil)
|
|
rootID, err := svc.CreateRoot(context.Background(), "iss-acme", "Acme Root", "user-admin", rootPEM, "root-key", nil)
|
|
if err != nil {
|
|
t.Fatalf("CreateRoot: %v", err)
|
|
}
|
|
|
|
policyID, err := svc.CreateChild(context.Background(), rootID, "Policy CA", "user-admin",
|
|
&CreateChildOptions{
|
|
Subject: pkix.Name{CommonName: "Acme Policy CA"},
|
|
Algorithm: signer.AlgorithmECDSAP256,
|
|
TTL: 5 * 365 * 24 * time.Hour,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateChild policy: %v", err)
|
|
}
|
|
|
|
issuingAID, err := svc.CreateChild(context.Background(), policyID, "Issuing A", "user-admin",
|
|
&CreateChildOptions{
|
|
Subject: pkix.Name{CommonName: "Acme Issuing A"},
|
|
Algorithm: signer.AlgorithmECDSAP256,
|
|
TTL: 2 * 365 * 24 * time.Hour,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateChild issuing A: %v", err)
|
|
}
|
|
|
|
issuingBID, err := svc.CreateChild(context.Background(), issuingAID, "Issuing B", "user-admin",
|
|
&CreateChildOptions{
|
|
Subject: pkix.Name{CommonName: "Acme Issuing B"},
|
|
Algorithm: signer.AlgorithmECDSAP256,
|
|
TTL: 365 * 24 * time.Hour,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateChild issuing B: %v", err)
|
|
}
|
|
|
|
chain, err := svc.AssembleChain(context.Background(), issuingBID)
|
|
if err != nil {
|
|
t.Fatalf("AssembleChain: %v", err)
|
|
}
|
|
count := strings.Count(chain, "BEGIN CERTIFICATE")
|
|
if count != 4 {
|
|
t.Fatalf("expected 4 CERTIFICATE blocks, got %d:\n%s", count, chain)
|
|
}
|
|
|
|
// Verify each block parses + the chain is leaf → root by subject CN.
|
|
rest := []byte(chain)
|
|
wantSubjects := []string{"Acme Issuing B", "Acme Issuing A", "Acme Policy CA", "Acme Root"}
|
|
for i := 0; i < 4; i++ {
|
|
var block *pem.Block
|
|
block, rest = pem.Decode(rest)
|
|
if block == nil {
|
|
t.Fatalf("expected block %d, got nil", i)
|
|
}
|
|
cert, err := x509.ParseCertificate(block.Bytes)
|
|
if err != nil {
|
|
t.Fatalf("parse block %d: %v", i, err)
|
|
}
|
|
if cert.Subject.CommonName != wantSubjects[i] {
|
|
t.Fatalf("block %d: expected CN=%q, got %q", i, wantSubjects[i], cert.Subject.CommonName)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestIntermediateCA_Retire_RefusesIfActiveChildren pins drain-first
|
|
// semantics: a CA in retiring state with active children cannot be
|
|
// terminalized — the caller must retire the children first.
|
|
func TestIntermediateCA_Retire_RefusesIfActiveChildren(t *testing.T) {
|
|
svc, _, drv, _ := newTestService(t)
|
|
rootPEM := testCAFixture(t, drv, "root-key", pkix.Name{CommonName: "Acme Root"}, nil, nil)
|
|
rootID, err := svc.CreateRoot(context.Background(), "iss-acme", "Acme Root", "user-admin", rootPEM, "root-key", nil)
|
|
if err != nil {
|
|
t.Fatalf("CreateRoot: %v", err)
|
|
}
|
|
if _, err := svc.CreateChild(context.Background(), rootID, "Child", "user-admin",
|
|
&CreateChildOptions{
|
|
Subject: pkix.Name{CommonName: "Child"},
|
|
Algorithm: signer.AlgorithmECDSAP256,
|
|
TTL: 365 * 24 * time.Hour,
|
|
}); err != nil {
|
|
t.Fatalf("CreateChild: %v", err)
|
|
}
|
|
|
|
// First call: active → retiring (no confirm needed).
|
|
if err := svc.Retire(context.Background(), rootID, "user-admin", "drain start", false); err != nil {
|
|
t.Fatalf("Retire (active→retiring): %v", err)
|
|
}
|
|
// Second call: retiring → retired with active child → must refuse.
|
|
err = svc.Retire(context.Background(), rootID, "user-admin", "terminalize", true)
|
|
if !errors.Is(err, ErrCAStillHasActiveChildren) {
|
|
t.Fatalf("expected ErrCAStillHasActiveChildren, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestIntermediateCA_Retire_TwoPhaseConfirm pins the two-phase
|
|
// transition: first call moves active→retiring without a confirm
|
|
// flag; the second retiring→retired transition requires confirm=true.
|
|
func TestIntermediateCA_Retire_TwoPhaseConfirm(t *testing.T) {
|
|
svc, repo, drv, _ := newTestService(t)
|
|
rootPEM := testCAFixture(t, drv, "root-key", pkix.Name{CommonName: "Acme Root"}, nil, nil)
|
|
rootID, err := svc.CreateRoot(context.Background(), "iss-acme", "Acme Root", "user-admin", rootPEM, "root-key", nil)
|
|
if err != nil {
|
|
t.Fatalf("CreateRoot: %v", err)
|
|
}
|
|
|
|
// First call (no confirm, no children): active → retiring.
|
|
if err := svc.Retire(context.Background(), rootID, "user-admin", "drain", false); err != nil {
|
|
t.Fatalf("first retire: %v", err)
|
|
}
|
|
got, _ := repo.Get(context.Background(), rootID)
|
|
if got.State != domain.IntermediateCAStateRetiring {
|
|
t.Fatalf("expected retiring, got %v", got.State)
|
|
}
|
|
|
|
// Second call without confirm — must surface "pass confirm=true".
|
|
err = svc.Retire(context.Background(), rootID, "user-admin", "terminalize?", false)
|
|
if err == nil || !strings.Contains(err.Error(), "confirm=true") {
|
|
t.Fatalf("expected confirm=true error, got %v", err)
|
|
}
|
|
|
|
// Second call with confirm: retiring → retired.
|
|
if err := svc.Retire(context.Background(), rootID, "user-admin", "terminalize", true); err != nil {
|
|
t.Fatalf("retire confirm: %v", err)
|
|
}
|
|
got, _ = repo.Get(context.Background(), rootID)
|
|
if got.State != domain.IntermediateCAStateRetired {
|
|
t.Fatalf("expected retired, got %v", got.State)
|
|
}
|
|
}
|
|
|
|
// TestIntermediateCA_MetricsRecordedPerOutcome pins the metrics
|
|
// snapshot — every successful CreateRoot / CreateChild / Retire
|
|
// transition lands one row in the snapshot, dimensioned by
|
|
// (issuer_id, kind).
|
|
func TestIntermediateCA_MetricsRecordedPerOutcome(t *testing.T) {
|
|
svc, _, drv, metrics := newTestService(t)
|
|
|
|
rootPEM := testCAFixture(t, drv, "root-key", pkix.Name{CommonName: "Acme Root"}, nil, nil)
|
|
rootID, err := svc.CreateRoot(context.Background(), "iss-acme", "Acme Root", "user-admin", rootPEM, "root-key", nil)
|
|
if err != nil {
|
|
t.Fatalf("CreateRoot: %v", err)
|
|
}
|
|
if _, err := svc.CreateChild(context.Background(), rootID, "Child", "user-admin",
|
|
&CreateChildOptions{
|
|
Subject: pkix.Name{CommonName: "Child"},
|
|
Algorithm: signer.AlgorithmECDSAP256,
|
|
TTL: 365 * 24 * time.Hour,
|
|
}); err != nil {
|
|
t.Fatalf("CreateChild: %v", err)
|
|
}
|
|
if err := svc.Retire(context.Background(), rootID, "user-admin", "drain", false); err != nil {
|
|
t.Fatalf("Retire: %v", err)
|
|
}
|
|
|
|
snap := metrics.SnapshotIntermediateCA()
|
|
want := map[string]uint64{
|
|
"iss-acme/create_root": 1,
|
|
"iss-acme/create_child": 1,
|
|
"iss-acme/retire_retiring": 1,
|
|
}
|
|
got := map[string]uint64{}
|
|
for _, e := range snap {
|
|
got[e.IssuerID+"/"+e.Kind] = e.Count
|
|
}
|
|
for k, v := range want {
|
|
if got[k] != v {
|
|
t.Fatalf("metric %s: expected %d, got %d (snapshot=%v)", k, v, got[k], got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestIntermediateCA_LoadHierarchy_FlatList pins LoadHierarchy: it
|
|
// returns every CA for an issuer, irrespective of state, ordered by
|
|
// created_at. Caller renders the tree from parent_ca_id.
|
|
func TestIntermediateCA_LoadHierarchy_FlatList(t *testing.T) {
|
|
svc, _, drv, _ := newTestService(t)
|
|
rootPEM := testCAFixture(t, drv, "root-key", pkix.Name{CommonName: "Acme Root"}, nil, nil)
|
|
rootID, err := svc.CreateRoot(context.Background(), "iss-acme", "Acme Root", "user-admin", rootPEM, "root-key", nil)
|
|
if err != nil {
|
|
t.Fatalf("CreateRoot: %v", err)
|
|
}
|
|
for i, name := range []string{"Policy CA", "Issuing CA"} {
|
|
_ = i
|
|
if _, err := svc.CreateChild(context.Background(), rootID, name, "user-admin",
|
|
&CreateChildOptions{
|
|
Subject: pkix.Name{CommonName: name},
|
|
Algorithm: signer.AlgorithmECDSAP256,
|
|
TTL: 365 * 24 * time.Hour,
|
|
}); err != nil {
|
|
t.Fatalf("CreateChild %s: %v", name, err)
|
|
}
|
|
}
|
|
|
|
hier, err := svc.LoadHierarchy(context.Background(), "iss-acme")
|
|
if err != nil {
|
|
t.Fatalf("LoadHierarchy: %v", err)
|
|
}
|
|
if len(hier) != 3 {
|
|
t.Fatalf("expected 3 rows, got %d", len(hier))
|
|
}
|
|
}
|