local: tree-mode chain assembly + byte-equivalence pin (Rank 8 commit 3)

Rank 8 commit 3 of 5. Load-bearing connector rewrite that activates
the first-class CA hierarchy surface shipped by commits 1-2.

Local connector changes:
  - New ChainAssembler interface (single-method seam) defined in the
    connector package — *service.IntermediateCAService satisfies it
    implicitly. Avoids the import cycle that would arise from
    pulling internal/service into internal/connector/issuer/local.

  - Three new optional fields on Connector: hierarchyMode,
    chainAssembler, treeIssuingCAID. Default zero values keep the
    pre-Rank-8 single-sub-CA flow byte-identical (no operator on
    the historical path sees any change in wire bytes).

  - Three new setters: SetHierarchyMode, SetChainAssembler,
    SetTreeIssuingCAID. Wired in cmd/server/main.go in commit 4
    when the issuer's HierarchyMode column is read at boot.

  - resolveChainPEM helper centralizes the dispatch:
      tree mode + ChainAssembler set + treeIssuingCAID set
        → call AssembleChain over intermediate_cas
      otherwise (incl. tree mode with incomplete wiring)
        → fall back to historical c.caCertPEM
    Defense in depth: a misconfigured operator gets a working
    issuance, not a nil-deref panic.

  - IssueCertificate + RenewCertificate both delegate ChainPEM
    population to resolveChainPEM. The cert generation path
    (generateCertificate) is untouched — same key, same template,
    same signing.

Tests (internal/connector/issuer/local/local_hierarchy_test.go):

  TestLocal_HierarchyMode_SingleVsTree_ByteIdentical ← LOAD-BEARING
    THE refuse-to-ship pin. Two connectors against the same on-disk
    CA cert+key:
      - A: pre-Rank-8 single-sub-CA mode (HierarchyMode unset).
      - B: tree mode wired against an in-memory ChainAssembler
        whose 1-level chain matches A's caCertPEM byte-for-byte.
    Asserts:
      1. resA.ChainPEM == resB.ChainPEM (the byte-identical pin).
      2. resA.ChainPEM == fixture root cert PEM (real fact about
         the wire format, not internal consistency).
    Operators on single mode keep getting byte-identical bytes.
    Operators flipping to tree with a 1-level shim see no change.
    Zero behavioral drift for unmigrated deployments.

  TestLocal_HierarchyMode_Tree_LeafChainIncludesAllAncestors
    Multi-level pin. 4-level synthetic chain (root → policy →
    issuingA → issuingB-leaf-CA). Asserts:
      - 4 CERTIFICATE blocks in ChainPEM.
      - Leaf-first ordering (issuingB.CN, issuingA.CN, policy.CN,
        root.CN at depths 0..3).
    This is what tree mode buys operators in exchange for the
    migration overhead.

  TestLocal_HierarchyMode_FallsBackToSingleWhenWiringIncomplete
    Defensive fallback pin. HierarchyMode='tree' but
    ChainAssembler nil + treeIssuingCAID '' → ChainPEM falls back
    to caCertPEM. No panic, no lying field.

Verified locally:
  gofmt: clean.
  go vet ./...: exit 0.
  go test -short -count=1 -run TestLocal_HierarchyMode ./internal/connector/issuer/local/...
    PASS (3/3, including the load-bearing byte-identical pin).
  go test -short -count=1 ./internal/connector/issuer/local/...: ok 4.358s
    (every existing local-connector test still green — backwards
    compat byte-for-byte at the test layer too).

Out of scope of THIS commit (commit 4):
  - 4 admin-gated handler endpoints + OpenAPI extension.
  - cmd/server/main.go wiring that reads Issuer.HierarchyMode at
    boot and calls SetHierarchyMode + SetChainAssembler +
    SetTreeIssuingCAID on the local connector instance.

Reference: cowork/rank-8-intermediate-ca-hierarchy-prompt.md, commit 3.
This commit is contained in:
shankar0123
2026-05-04 02:19:00 +00:00
parent 5bf2f0cc87
commit 8ff5668eb1
2 changed files with 443 additions and 3 deletions
+110 -3
View File
@@ -110,9 +110,20 @@ type Config struct {
CRLDistributionPointURLs []string `json:"crl_distribution_point_urls,omitempty"`
}
// ChainAssembler assembles the leaf-to-root PEM chain for a given
// IntermediateCA ID. The local connector calls this in tree mode at
// IssueCertificate time to populate IssuanceResult.ChainPEM. Defining
// the seam as a one-method interface inside the connector package
// avoids the import cycle that would arise from importing
// internal/service directly. *service.IntermediateCAService satisfies
// this implicitly.
type ChainAssembler interface {
AssembleChain(ctx context.Context, leafCAID string) (string, error)
}
// Connector implements the issuer.Connector interface for local certificate generation.
//
// It supports two modes:
// It supports three modes (Rank 8 added the third):
//
// Self-signed mode (default):
// - Generates an ephemeral self-signed CA root on first use
@@ -125,6 +136,20 @@ type Config struct {
// - All issued certificates chain to the upstream root
// - Suitable for production when the upstream CA is trusted
//
// Tree mode (when HierarchyMode is "tree" + SetChainAssembler + SetTreeIssuingCAID
// have been wired):
// - Operator-managed N-level CA hierarchy backed by the
// intermediate_cas table.
// - Cert signing still uses c.caCert + c.caSigner (the operator
// pre-positions the issuing-leaf CA cert+key on disk via the same
// CACertPath/CAKeyPath that sub-CA mode uses).
// - Only the chain assembled into IssuanceResult.ChainPEM differs:
// instead of the static c.caCertPEM, the connector calls
// chainAssembler.AssembleChain(treeIssuingCAID), which walks the
// parent_ca_id ancestry up to the registered root.
// - byte-identical to single-sub-CA mode for any 1-level tree (the
// Rank 8 backwards-compat pin).
//
// Features:
// - Instant certificate issuance (no external CA required)
// - Full lifecycle support (issue, renew, revoke)
@@ -143,6 +168,20 @@ type Connector struct {
subCA bool // true when loaded from disk (sub-CA mode)
revokedMap map[string]bool // serial -> revoked status
// Rank 8 — first-class CA hierarchy. Optional; when unset the
// connector behaves byte-identically to the pre-Rank-8 single-sub-CA
// flow. When set:
// - hierarchyMode == "tree" activates the tree-mode chain
// assembly (AssembleChain over the intermediate_cas table).
// - chainAssembler is the seam to *service.IntermediateCAService.
// - treeIssuingCAID is the leaf CA in the tree under which leaves
// are issued. Cert signing still uses c.caCert + c.caSigner; the
// operator pre-positions the matching cert+key on disk for the
// issuing-leaf CA via Config.CACertPath / Config.CAKeyPath.
hierarchyMode string
chainAssembler ChainAssembler
treeIssuingCAID string
// Optional dependencies — set after construction via the
// Set*-style helpers below. The Connector functions correctly with
// any subset of these unset (the Phase-2 responder-cert path falls
@@ -255,6 +294,38 @@ func (c *Connector) SetOCSPResponderKeyDir(dir string) {
c.ocspResponderKeyDir = dir
}
// SetHierarchyMode wires the per-issuer CA-hierarchy posture (Rank 8).
// The empty string and "single" preserve the historical single-sub-CA
// flow byte-for-byte; "tree" activates the intermediate_cas-backed
// chain assembly. Callers that pass "tree" MUST also call
// SetChainAssembler + SetTreeIssuingCAID before issuing certs;
// otherwise the connector falls back to single-mode chain assembly.
func (c *Connector) SetHierarchyMode(mode string) {
c.mu.Lock()
defer c.mu.Unlock()
c.hierarchyMode = mode
}
// SetChainAssembler wires the leaf-to-root chain assembler used in
// tree mode. *service.IntermediateCAService satisfies the interface
// implicitly. Unset = falls back to single-mode chain assembly.
func (c *Connector) SetChainAssembler(a ChainAssembler) {
c.mu.Lock()
defer c.mu.Unlock()
c.chainAssembler = a
}
// SetTreeIssuingCAID records the IntermediateCA ID under which leaves
// are issued in tree mode. Used as the AssembleChain leafCAID input.
// Cert signing still uses the file-on-disk CA cert+key wired via
// Config.CACertPath / Config.CAKeyPath; this ID is purely for chain
// assembly.
func (c *Connector) SetTreeIssuingCAID(id string) {
c.mu.Lock()
defer c.mu.Unlock()
c.treeIssuingCAID = id
}
// ValidateConfig validates the local CA configuration.
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
var cfg Config
@@ -353,12 +424,18 @@ func (c *Connector) IssueCertificate(ctx context.Context, request issuer.Issuanc
return nil, fmt.Errorf("certificate generation failed: %w", err)
}
chainPEM, err := c.resolveChainPEM(ctx)
if err != nil {
c.logger.Error("failed to assemble chain", "error", err)
return nil, fmt.Errorf("chain assembly failed: %w", err)
}
// Create order ID (use serial as order ID for simplicity)
orderID := fmt.Sprintf("local-%s", serial)
result := &issuer.IssuanceResult{
CertPEM: certPEM,
ChainPEM: c.caCertPEM,
ChainPEM: chainPEM,
Serial: serial,
NotBefore: cert.NotBefore,
NotAfter: cert.NotAfter,
@@ -417,6 +494,12 @@ func (c *Connector) RenewCertificate(ctx context.Context, request issuer.Renewal
return nil, fmt.Errorf("certificate generation failed: %w", err)
}
chainPEM, err := c.resolveChainPEM(ctx)
if err != nil {
c.logger.Error("failed to assemble chain", "error", err)
return nil, fmt.Errorf("chain assembly failed: %w", err)
}
// Create order ID
orderID := fmt.Sprintf("local-%s", serial)
if request.OrderID != nil {
@@ -425,7 +508,7 @@ func (c *Connector) RenewCertificate(ctx context.Context, request issuer.Renewal
result := &issuer.IssuanceResult{
CertPEM: certPEM,
ChainPEM: c.caCertPEM,
ChainPEM: chainPEM,
Serial: serial,
NotBefore: cert.NotBefore,
NotAfter: cert.NotAfter,
@@ -440,6 +523,30 @@ func (c *Connector) RenewCertificate(ctx context.Context, request issuer.Renewal
return result, nil
}
// resolveChainPEM returns the chain bytes the local connector attaches
// to IssuanceResult. In single-sub-CA mode (or when tree-mode wiring
// is incomplete) it returns the historical c.caCertPEM byte-for-byte
// — the Rank 8 backwards-compat pin. In tree mode it delegates to
// the registered ChainAssembler, which walks the parent_ca_id ancestry
// over the intermediate_cas table.
func (c *Connector) resolveChainPEM(ctx context.Context) (string, error) {
c.mu.RLock()
mode := c.hierarchyMode
asm := c.chainAssembler
leaf := c.treeIssuingCAID
fallback := c.caCertPEM
c.mu.RUnlock()
if mode == "tree" && asm != nil && leaf != "" {
chain, err := asm.AssembleChain(ctx, leaf)
if err != nil {
return "", err
}
return chain, nil
}
return fallback, nil
}
// RevokeCertificate revokes a certificate by marking it in the in-memory revocation map.
// This is a no-op for practical purposes but tracks revocation state in memory.
// Note: Revocation is not persistent and is lost on service restart.
@@ -0,0 +1,333 @@
package local
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"io"
"log/slog"
"math/big"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/certctl-io/certctl/internal/connector/issuer"
)
// fakeChainAssembler is a tiny in-memory ChainAssembler for the
// hierarchy unit tests. It maps a leafCAID to a pre-built chain PEM
// (leaf-first ordering, matching what *service.IntermediateCAService
// produces in production via WalkAncestry).
type fakeChainAssembler struct {
chains map[string]string
}
func (f *fakeChainAssembler) AssembleChain(ctx context.Context, leafCAID string) (string, error) {
if c, ok := f.chains[leafCAID]; ok {
return c, nil
}
return "", os.ErrNotExist
}
// hierarchyTestFixture builds a self-signed root cert+key in memory,
// writes them to disk under a fresh tempdir, and returns the paths
// + parsed PEM. Both single- and tree-mode connectors load from this
// pair so the signing path is identical and the only thing that can
// differ is chain assembly.
type hierarchyTestFixture struct {
tempDir string
certPEM string
keyPEM string
cert *x509.Certificate
}
func newHierarchyTestFixture(t *testing.T) *hierarchyTestFixture {
t.Helper()
tempDir := t.TempDir()
if err := os.Chmod(tempDir, 0o700); err != nil {
t.Fatalf("chmod tempdir: %v", err)
}
// Mint a self-signed root cert + key in process.
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("ecdsa keygen: %v", err)
}
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
subj := pkix.Name{CommonName: "Hierarchy Test Root"}
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: subj,
Issuer: subj,
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(2 * 365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
IsCA: true,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
if err != nil {
t.Fatalf("create cert: %v", err)
}
cert, _ := x509.ParseCertificate(der)
certPEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
keyDER, err := x509.MarshalECPrivateKey(priv)
if err != nil {
t.Fatalf("marshal ec key: %v", err)
}
keyPEM := string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}))
certPath := filepath.Join(tempDir, "ca.crt")
keyPath := filepath.Join(tempDir, "ca.key")
if err := os.WriteFile(certPath, []byte(certPEM), 0o600); err != nil {
t.Fatalf("write cert: %v", err)
}
if err := os.WriteFile(keyPath, []byte(keyPEM), 0o600); err != nil {
t.Fatalf("write key: %v", err)
}
return &hierarchyTestFixture{
tempDir: tempDir,
certPEM: certPEM,
keyPEM: keyPEM,
cert: cert,
}
}
// makeCSRPEM returns a fresh ECDSA CSR PEM for the given CN. Used by
// both connectors so the signing inputs are identical.
func makeCSRPEM(t *testing.T, cn string) string {
t.Helper()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("csr keygen: %v", err)
}
tmpl := &x509.CertificateRequest{
Subject: pkix.Name{CommonName: cn},
DNSNames: []string{cn},
}
der, err := x509.CreateCertificateRequest(rand.Reader, tmpl, priv)
if err != nil {
t.Fatalf("create csr: %v", err)
}
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: der}))
}
func newSilentLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// TestLocal_HierarchyMode_SingleVsTree_ByteIdentical is the LOAD-
// BEARING backwards-compat pin (Rank 8 commit 3). Two connectors
// configured against the SAME on-disk CA cert+key produce
// byte-identical IssuanceResult.ChainPEM bytes:
// - Connector A: pre-Rank-8 single-sub-CA mode (HierarchyMode unset).
// ChainPEM = c.caCertPEM (the historical path).
// - Connector B: tree mode wired against an in-memory ChainAssembler
// whose AssembleChain returns the SAME PEM bytes for a 1-level
// tree.
//
// Operators on single mode who never touch HierarchyMode keep getting
// byte-identical wire bytes; operators who flip to tree mode and
// register the same CA as the active root see no change in the bytes
// returned. This guarantees zero behavioral drift for unmigrated
// deployments.
func TestLocal_HierarchyMode_SingleVsTree_ByteIdentical(t *testing.T) {
fx := newHierarchyTestFixture(t)
ctx := context.Background()
// Connector A — single-sub-CA mode (historical path).
connA := New(&Config{
CACommonName: "ignored",
ValidityDays: 90,
CACertPath: filepath.Join(fx.tempDir, "ca.crt"),
CAKeyPath: filepath.Join(fx.tempDir, "ca.key"),
}, newSilentLogger())
// Connector B — tree mode wired against an in-memory chain
// assembler that returns the SAME root cert PEM (1-level tree).
connB := New(&Config{
CACommonName: "ignored",
ValidityDays: 90,
CACertPath: filepath.Join(fx.tempDir, "ca.crt"),
CAKeyPath: filepath.Join(fx.tempDir, "ca.key"),
}, newSilentLogger())
connB.SetHierarchyMode("tree")
connB.SetChainAssembler(&fakeChainAssembler{
chains: map[string]string{
"ica-root-1": fx.certPEM, // matches single-mode caCertPEM byte-for-byte
},
})
connB.SetTreeIssuingCAID("ica-root-1")
csrPEM := makeCSRPEM(t, "leaf.example.com")
resA, err := connA.IssueCertificate(ctx, issuer.IssuanceRequest{
CommonName: "leaf.example.com",
CSRPEM: csrPEM,
SANs: []string{"leaf.example.com"},
})
if err != nil {
t.Fatalf("connA.IssueCertificate: %v", err)
}
resB, err := connB.IssueCertificate(ctx, issuer.IssuanceRequest{
CommonName: "leaf.example.com",
CSRPEM: csrPEM,
SANs: []string{"leaf.example.com"},
})
if err != nil {
t.Fatalf("connB.IssueCertificate: %v", err)
}
// The load-bearing assertion: ChainPEM byte-identical between modes.
if resA.ChainPEM != resB.ChainPEM {
t.Fatalf("ChainPEM differs between single and tree modes\nsingle:\n%q\ntree:\n%q",
resA.ChainPEM, resB.ChainPEM)
}
// And the chain MUST match the on-disk root cert bytes — i.e., the
// pin verifies a real fact about the wire format, not just internal
// consistency.
if resA.ChainPEM != fx.certPEM {
t.Fatalf("ChainPEM does not match on-disk root cert PEM\ngot:\n%q\nwant:\n%q",
resA.ChainPEM, fx.certPEM)
}
}
// TestLocal_HierarchyMode_Tree_LeafChainIncludesAllAncestors pins
// the multi-level tree case: a leaf issued under the deepest CA in a
// 4-level hierarchy carries a ChainPEM containing every ancestor up
// through the root. This is what tree mode buys operators in exchange
// for the migration overhead.
func TestLocal_HierarchyMode_Tree_LeafChainIncludesAllAncestors(t *testing.T) {
fx := newHierarchyTestFixture(t)
ctx := context.Background()
// Build a synthetic 4-level chain (root → policy → issuingA →
// issuingB-leaf-CA). The actual cert content doesn't matter for
// this test — we just need 4 distinct CERTIFICATE blocks. Using
// the same root cert 4x with marker comments would NOT work
// because the connector returns the PEM verbatim. Mint 4 fresh
// self-signed certs with distinct subjects so we can verify
// ordering.
type leveledCert struct {
pem string
cert *x509.Certificate
}
mintCert := func(cn string) *leveledCert {
priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
subj := pkix.Name{CommonName: cn}
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: subj,
Issuer: subj,
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(2 * 365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
IsCA: true,
}
der, _ := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
c, _ := x509.ParseCertificate(der)
return &leveledCert{
pem: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})),
cert: c,
}
}
root := mintCert("Hierarchy Root CA")
policy := mintCert("Hierarchy Policy CA")
issuingA := mintCert("Hierarchy Issuing A")
issuingB := mintCert("Hierarchy Issuing B")
// Stitch the chain leaf-to-root (matches AssembleChain output).
chainPEM := issuingB.pem + issuingA.pem + policy.pem + root.pem
conn := New(&Config{
CACommonName: "ignored",
ValidityDays: 90,
CACertPath: filepath.Join(fx.tempDir, "ca.crt"),
CAKeyPath: filepath.Join(fx.tempDir, "ca.key"),
}, newSilentLogger())
conn.SetHierarchyMode("tree")
conn.SetChainAssembler(&fakeChainAssembler{
chains: map[string]string{
"ica-issuing-b": chainPEM,
},
})
conn.SetTreeIssuingCAID("ica-issuing-b")
csrPEM := makeCSRPEM(t, "deep-leaf.example.com")
res, err := conn.IssueCertificate(ctx, issuer.IssuanceRequest{
CommonName: "deep-leaf.example.com",
CSRPEM: csrPEM,
SANs: []string{"deep-leaf.example.com"},
})
if err != nil {
t.Fatalf("IssueCertificate: %v", err)
}
if got, want := strings.Count(res.ChainPEM, "BEGIN CERTIFICATE"), 4; got != want {
t.Fatalf("expected %d CERTIFICATE blocks, got %d:\n%s", want, got, res.ChainPEM)
}
// Verify leaf-first ordering by parsing each block.
rest := []byte(res.ChainPEM)
wantSubjects := []string{
"Hierarchy Issuing B",
"Hierarchy Issuing A",
"Hierarchy Policy CA",
"Hierarchy Root CA",
}
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)
}
c, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatalf("parse block %d: %v", i, err)
}
if c.Subject.CommonName != wantSubjects[i] {
t.Fatalf("block %d: expected CN=%q, got %q", i, wantSubjects[i], c.Subject.CommonName)
}
}
}
// TestLocal_HierarchyMode_FallsBackToSingleWhenWiringIncomplete pins
// the defensive fallback: hierarchyMode set to "tree" but
// ChainAssembler is nil → the connector falls back to the historical
// c.caCertPEM. Defense in depth: a misconfigured operator still gets
// a working issuance, not a nil-deref panic.
func TestLocal_HierarchyMode_FallsBackToSingleWhenWiringIncomplete(t *testing.T) {
fx := newHierarchyTestFixture(t)
ctx := context.Background()
conn := New(&Config{
CACommonName: "ignored",
ValidityDays: 90,
CACertPath: filepath.Join(fx.tempDir, "ca.crt"),
CAKeyPath: filepath.Join(fx.tempDir, "ca.key"),
}, newSilentLogger())
// Tree mode declared, but ChainAssembler + treeIssuingCAID are unset.
conn.SetHierarchyMode("tree")
csrPEM := makeCSRPEM(t, "fallback.example.com")
res, err := conn.IssueCertificate(ctx, issuer.IssuanceRequest{
CommonName: "fallback.example.com",
CSRPEM: csrPEM,
SANs: []string{"fallback.example.com"},
})
if err != nil {
t.Fatalf("IssueCertificate: %v", err)
}
if res.ChainPEM != fx.certPEM {
t.Fatalf("expected fallback to caCertPEM, got %q", res.ChainPEM)
}
}