From d7dc2d2aadbb20a308cf74c0581d32e54697ed7d Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Fri, 7 Aug 2026 11:20:10 +0000 Subject: [PATCH] fix(P11): SVID chain validation (REQ-126, F9) ---ci--- project: orca phase: 11 milestone: v0.12 status: execute ---/ci--- VerifySVIDWithChain: validates the full cert chain against the CA pool + checks the SPIFFE URI SAN. Rejects certs from unknown CAs even with correct URI (F9). VerifySVID retained for backward compat (mTLS callers that already verified the chain). 2 new tests. Build + vet green. --- internal/identity/spiffe.go | 41 ++++++++++++++++++++++++++++++++ internal/identity/spiffe_test.go | 24 +++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/internal/identity/spiffe.go b/internal/identity/spiffe.go index a1ce6ea..b436e31 100644 --- a/internal/identity/spiffe.go +++ b/internal/identity/spiffe.go @@ -99,6 +99,47 @@ func VerifySVID(certPEM []byte, spiffeID string) error { return fmt.Errorf("identity: cert missing %q: %w", spiffeID, ErrSpiffeURIMissing) } +// VerifySVIDWithChain validates the SVID cert chain against the CA +// pool AND checks the SPIFFE URI SAN (REQ-126, F9). The CA pool is the +// cluster root CA (or the step-ca root). Rejects certs signed by +// unknown CAs even with a correct URI. This is the hardened +// verification path; VerifySVID (above) only checks the URI and is +// retained for backward compatibility (callers that have already +// verified the chain via mTLS). +func VerifySVIDWithChain(certPEM []byte, spiffeID string, caPool *x509.CertPool) error { + if caPool == nil { + return fmt.Errorf("identity: VerifySVIDWithChain requires a non-nil CA pool (REQ-126)") + } + block, _ := pem.Decode(certPEM) + if block == nil { + return fmt.Errorf("identity: parse cert: PEM decode failed: %w", ErrStepCLI) + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return fmt.Errorf("identity: parse cert: %w", err) + } + // Verify the cert chain against the CA pool. + if _, err := cert.Verify(x509.VerifyOptions{ + Roots: caPool, + // SVIDs are client certs (workload identity); they don't have + // EKU for serverAuth, so we use the default (any EKU). + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, + }); err != nil { + return fmt.Errorf("identity: SVID chain validation failed: %w (REQ-126: unknown CA or expired)", err) + } + // Check the SPIFFE URI SAN. + want, err := url.Parse(spiffeID) + if err != nil { + return fmt.Errorf("identity: parse spiffe id: %w", err) + } + for _, u := range cert.URIs { + if u.String() == want.String() { + return nil + } + } + return fmt.Errorf("identity: cert missing %q: %w", spiffeID, ErrSpiffeURIMissing) +} + func SpiffeIDFromCert(cert *x509.Certificate) string { for _, u := range cert.URIs { if u.Scheme == "spiffe" { diff --git a/internal/identity/spiffe_test.go b/internal/identity/spiffe_test.go index 0917abf..3ec3bc9 100644 --- a/internal/identity/spiffe_test.go +++ b/internal/identity/spiffe_test.go @@ -240,3 +240,27 @@ func TestSanitize(t *testing.T) { t.Errorf("sanitize = %q, want %q", got, want) } } + +// --- REQ-126 / F9 SVID chain validation tests --- + +// TestVerifySVIDWithChain_RejectsUnknownCA verifies a cert from a +// wrong CA is rejected. +func TestVerifySVIDWithChain_RejectsUnknownCA(t *testing.T) { + // Generate a cert signed by a different CA (not the pool's CA). + certPEM := mintTestSVIDCert(t, "spiffe://orca.local/ns/test/sa/web/alloc-1") + // Empty CA pool (no trusted roots). + emptyPool := x509.NewCertPool() + err := VerifySVIDWithChain(certPEM, "spiffe://orca.local/ns/test/sa/web/alloc-1", emptyPool) + if err == nil { + t.Error("VerifySVIDWithChain should reject cert from unknown CA (REQ-126)") + } +} + +// TestVerifySVIDWithChain_NilPoolRejected verifies nil CA pool errors. +func TestVerifySVIDWithChain_NilPoolRejected(t *testing.T) { + certPEM := mintTestSVIDCert(t, "spiffe://orca.local/ns/test/sa/web/alloc-1") + err := VerifySVIDWithChain(certPEM, "spiffe://orca.local/ns/test/sa/web/alloc-1", nil) + if err == nil { + t.Error("nil CA pool should error (REQ-126)") + } +}