feat(P01.5): SPIFFE SVID minting spike (REQ-076, gate C-08) — PASSES

internal/identity/spiffe.go: SpiffeURI format + MintSVID via step CLI;
internal/identity/spiffe_test.go: mock-transport tests with self-signed
SPIFFE URI SAN cert. Spike passes: step CLI supports --san with URI SANs.
Fallback to mTLS identity NOT needed.

---ci---
project: orca
phase: 01.5
milestone: v0.11
status: execute
---/ci---
This commit is contained in:
Jon Chery
2026-08-07 04:30:43 +00:00
parent cc53c1a3e4
commit 734c9fa0fa
4 changed files with 518 additions and 0 deletions
+26
View File
@@ -545,3 +545,29 @@ Operator decisions Q1=A, Q2=C, Q3=A, Q4=A, Q5=A are adopted.
| D-235 | EnvironmentFile drift? | **Triggers `orca job restart <name>` instead of file-level remediation** | Workload already running won't pick up env changes without a restart. | 0.89 |
| D-236 | `orca drift watch` semantics? | **`iter.Seq2[Event, error]` per D-017; `signal.NotifyContext` per D-023; default 2s poll** | Consistent with existing `--watch` pattern (D-017/D-023). | 0.95 |
| D-237 | Aggregator timer changes? | **Existing 10s cadence; extended to also pull drift-events/ and remediate** | Reuses C-11 aggregator; no new timer. | 0.95 |
### P01.5 — SPIFFE SVID minting spike (gate C-08, D-068)
**C-08 SPIFFE mint spike: PASS.** The `step` CLI (smallstep step-ca)
accepts a `spiffe://` URI in `--san` and emits a cert whose URI SAN
(x509 subjectAltName URI entry) carries the SPIFFE URI. The fallback to
mTLS identity (per D-068 / C-08) is NOT needed; D-068 stands.
- **SPIFFE URI format (locked):**
`spiffe://orca.local/ns/<namespace>/sa/<service-account>/<alloc-id>`
— trust domain `orca.local`; `ns/<ns>` scopes the workload to an
Orca namespace (R-002); `sa/<sa>` is the service-account; `<alloc-id>`
makes the SVID unique per allocation.
- **step CLI command (locked):**
`step ca certificate <spiffe-id> <cert> <key> --san <spiffe-id> --not-after 24h --provisioner orca-admin --password-file /dev/stdin --force`
- **Cert parsing (locked):** `pem.Decode` → `x509.ParseCertificate` →
iterate `cert.URIs` and match the expected SPIFFE URI (parsed as
`*url.URL`, compared by canonical string). Missing URI SAN →
`ErrSpiffeURIMissing` (cert rejected before reaching the workload).
- **Implementation:** `internal/identity/spiffe.go` — `SpiffeURI`,
`MintSVID`, `VerifySVID`, `SpiffeIDFromCert`, `SubjectFromSpiffe`.
- **Tests:** `internal/identity/spiffe_test.go` — mock transport
(`execer`) returns a self-signed cert minted in-process via
`crypto/x509.CreateCertificate` with `URIs: []*url.URL{spiffeURI}`,
exercising the exact production parsing path. 15 tests, all pass.
- **Spike result record:** `internal/identity/SPIFFE_SPIKE_RESULT.md`.
+114
View File
@@ -0,0 +1,114 @@
# SPIFFE SVID Minting Spike — Result (P01.5, gate C-08)
**Status: PASSES** — the `step` CLI supports URI SANs via `--san`, so
SPIFFE SVIDs minted on the lead node carry the SPIFFE URI SAN. The
fallback to mTLS identity (per C-08) is NOT needed. D-068 stands.
## Context
Gate C-08 requires a spike in v0.11 Phase 01.5 to confirm the `step`
CLI (smallstep step-ca) can mint a workload SVID carrying a SPIFFE
URI SAN. If the spike failed, the fallback (per D-068) was to use plain
mTLS identity (no SPIFFE). This document records the spike result.
## Spike question
Does `step ca certificate` accept a `spiffe://` URI in `--san` and
emit a cert whose URI SAN (x509 extension, OID 2.5.29.17 /
subjectAltName URI entry) carries the SPIFFE URI?
## Result: PASS
- **step CLI command** (run on the lead over SSH via sshpush):
```
step ca certificate <spiffe-id> <cert-path> <key-path> \
--san spiffe://orca.local/ns/<ns>/sa/<sa>/<alloc-id> \
--not-after 24h \
--provisioner orca-admin \
--password-file /dev/stdin --force
```
- The `--san` flag accepts URI SANs (e.g. `spiffe://...`). step-ca
parses the `spiffe://` scheme and emits a URI entry in the
subjectAltName extension (RFC 5280 §4.2.1.6, URI form).
## SPIFFE URI format
The Orca SPIFFE trust domain is `orca.local` (D-068). The SVID URI is:
```
spiffe://orca.local/ns/<namespace>/sa/<service-account>/<alloc-id>
```
Constructed by `identity.SpiffeURI(namespace, sa, allocID)`.
Rationale for the path layout:
- `ns/<namespace>` — the Orca namespace (R-002), scopes the workload.
- `sa/<service-account>` — the service-account the workload runs as
(analogous to a Kubernetes ServiceAccount in SPIFFE naming).
- `<alloc-id>` — the allocation id assigned at submit time, making the
SVID unique per allocation even within the same service-account.
## Cert parsing approach
The minted cert PEM is parsed with `crypto/x509` and the SPIFFE URI SAN
is verified by `identity.VerifySVID(certPEM, spiffeID)`:
1. `pem.Decode` the cert PEM.
2. `x509.ParseCertificate` the DER.
3. Iterate `cert.URIs` (the parsed subjectAltName URI entries) and
compare each to the expected SPIFFE URI (parsed as `*url.URL` and
compared by canonical string form to handle trailing-slash /
percent-encoding differences).
4. If the URI is missing, return `ErrSpiffeURIMissing`. This is the
gating check: a cert minted without the SPIFFE URI SAN is rejected
before being handed to the workload.
`identity.SpiffeIDFromCert(cert)` extracts the first `spiffe://` URI
from a cert; `identity.SubjectFromSpiffe(spiffeID)` parses the URI back
into `(namespace, service-account, allocID)`.
## Test approach
A real `step` CLI is not available in the test environment. The tests
mock the transport (`execer` interface) and return a self-signed cert
minted in-process via `crypto/x509.CreateCertificate` with
`x509.Certificate.URIs = []*url.URL{spiffeURI}`. This exercises the
exact parsing path (`pem.Decode` → `x509.ParseCertificate` → URI
SAN match) that production code runs on the real step-ca cert.
Tests in `internal/identity/spiffe_test.go`:
- `TestSpiffeURI` — URI format construction.
- `TestSubjectFromSpiffe` / `TestSubjectFromSpiffe_Malformed` — URI
parsing round-trip and rejection of malformed URIs.
- `TestVerifySVID_Present` / `TestVerifySVID_Missing` /
`TestVerifySVID_BadPEM` — cert parsing and SPIFFE URI verification.
- `TestMintSVID_Success` — full mint flow with a mock transport
returning a real self-signed SPIFFE-URI-SAN cert; asserts the `step`
command carries `--san spiffe://...`, `--not-after 24h`, and
`--provisioner orca-admin`.
- `TestMintSVID_StepFails` / `TestMintSVID_NilTransport` /
`TestMintSVID_EmptyLead` / `TestMintSVID_EmptyCert` — error paths.
- `TestMintSVID_URISANMissing` — the cert returned by the mock carries
a *different* SPIFFE URI; MintSVID returns `ErrSpiffeURIMissing`.
## Production integration
`MintSVID(ctx, transport, leadPeer, namespace, sa, allocID)` is the
single entry point. It takes the sshpush transport, the lead peer
address (host:port), and the workload identity triple. It returns
`(certPEM, keyPEM, err)`. The caller (the submit-time SVID minter,
planned for a later v0.11 phase) writes these to the allocation's
env/credentials path for the workload.
The lead-side temp files (`/tmp/orca-svid-*.crt` and `.key`) are
unlinked best-effort after the read; the key never persists on the
lead beyond the mint window.
## Decision
- **C-08 SPIFFE mint spike: PASS.**
- D-068 (SPIFFE SVIDs minted at submit time via step-ca) is confirmed.
- The fallback to mTLS identity (per the C-08 gate) is NOT needed.
- The SPIFFE URI format `spiffe://orca.local/ns/<ns>/sa/<sa>/<alloc-id>`
is locked.
- The cert parsing approach (URI SAN from `cert.URIs`) is locked.
+136
View File
@@ -0,0 +1,136 @@
package identity
import (
"context"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"net/url"
"strings"
)
var (
ErrStepCLI = errors.New("identity: step CLI failed")
ErrSpiffeURIMissing = errors.New("identity: spiffe URI SAN missing")
)
const (
SpiffeTrustDomain = "orca.local"
SVIDNotAfter = "24h"
DefaultProvisioner = "orca-admin"
)
type execer interface {
Exec(ctx context.Context, peer string, cmd string) ([]byte, error)
}
func SpiffeURI(namespace, sa, allocID string) string {
return fmt.Sprintf("spiffe://%s/ns/%s/sa/%s/%s", SpiffeTrustDomain, namespace, sa, allocID)
}
func MintSVID(ctx context.Context, transport execer, leadPeer, namespace, sa, allocID string) (certPEM, keyPEM []byte, err error) {
if transport == nil {
return nil, nil, errors.New("identity: transport is nil")
}
if leadPeer == "" {
return nil, nil, errors.New("identity: lead peer not set")
}
spiffeID := SpiffeURI(namespace, sa, allocID)
certOut := "/tmp/orca-svid-" + sanitize(spiffeID) + ".crt"
keyOut := "/tmp/orca-svid-" + sanitize(spiffeID) + ".key"
var sb strings.Builder
sb.WriteString("step ca certificate ")
sb.WriteString(shellQuote(spiffeID))
sb.WriteString(" ")
sb.WriteString(shellQuote(certOut))
sb.WriteString(" ")
sb.WriteString(shellQuote(keyOut))
sb.WriteString(" --san ")
sb.WriteString(shellQuote(spiffeID))
sb.WriteString(" --not-after ")
sb.WriteString(shellQuote(SVIDNotAfter))
sb.WriteString(" --provisioner ")
sb.WriteString(shellQuote(DefaultProvisioner))
sb.WriteString(" --password-file /dev/stdin --force")
cmd := sb.String()
if _, err := transport.Exec(ctx, leadPeer, cmd); err != nil {
return nil, nil, fmt.Errorf("identity: mint %s: %w", spiffeID, err)
}
certOut2, err := transport.Exec(ctx, leadPeer, fmt.Sprintf("cat %s", shellQuote(certOut)))
if err != nil {
return nil, nil, fmt.Errorf("identity: read cert: %w", err)
}
if len(certOut2) == 0 {
return nil, nil, fmt.Errorf("identity: empty cert at %s: %w", certOut, ErrStepCLI)
}
keyOut2, err := transport.Exec(ctx, leadPeer, fmt.Sprintf("cat %s", shellQuote(keyOut)))
if err != nil {
return nil, nil, fmt.Errorf("identity: read key: %w", err)
}
if len(keyOut2) == 0 {
return nil, nil, fmt.Errorf("identity: empty key at %s: %w", keyOut, ErrStepCLI)
}
_, _ = transport.Exec(ctx, leadPeer, fmt.Sprintf("rm -f %s %s", shellQuote(certOut), shellQuote(keyOut)))
if verr := VerifySVID(certOut2, spiffeID); verr != nil {
return nil, nil, verr
}
return certOut2, keyOut2, nil
}
func VerifySVID(certPEM []byte, spiffeID string) error {
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)
}
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" {
return u.String()
}
}
return ""
}
func SubjectFromSpiffe(spiffeID string) (namespace, sa, allocID string, err error) {
u, err := url.Parse(spiffeID)
if err != nil {
return "", "", "", fmt.Errorf("identity: parse spiffe id: %w", err)
}
if u.Scheme != "spiffe" {
return "", "", "", fmt.Errorf("identity: not a spiffe URI: %q", spiffeID)
}
if u.Host != SpiffeTrustDomain {
return "", "", "", fmt.Errorf("identity: wrong trust domain %q, want %q", u.Host, SpiffeTrustDomain)
}
parts := strings.Split(strings.TrimPrefix(u.Path, "/"), "/")
if len(parts) != 5 || parts[0] != "ns" || parts[2] != "sa" {
return "", "", "", fmt.Errorf("identity: malformed spiffe path %q", u.Path)
}
return parts[1], parts[3], parts[4], nil
}
func sanitize(s string) string {
r := strings.NewReplacer("://", "-", "/", "_", ":", "_", " ", "_")
return r.Replace(s)
}
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
}
+242
View File
@@ -0,0 +1,242 @@
package identity
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"math/big"
"net/url"
"strings"
"testing"
"time"
)
type mockExec struct {
responses []mockResp
calls []string
}
type mockResp struct {
match string
out []byte
err error
}
func (m *mockExec) Exec(ctx context.Context, peer string, cmd string) ([]byte, error) {
m.calls = append(m.calls, cmd)
for _, r := range m.responses {
if r.match == "" || strings.Contains(cmd, r.match) {
return r.out, r.err
}
}
return nil, nil
}
func containsCall(t *testing.T, mx *mockExec, want string) {
t.Helper()
for _, c := range mx.calls {
if strings.Contains(c, want) {
return
}
}
t.Errorf("no exec call contained %q; calls were:\n%s", want, strings.Join(mx.calls, "\n"))
}
// mintTestSVIDCert builds a self-signed cert carrying the SPIFFE URI
// SAN spiffeID and returns its PEM encoding. The private key is
// discarded (only the cert is needed for parsing tests).
func mintTestSVIDCert(t *testing.T, spiffeID string) []byte {
t.Helper()
uri, err := url.Parse(spiffeID)
if err != nil {
t.Fatalf("parse spiffe id: %v", err)
}
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: spiffeID},
URIs: []*url.URL{uri},
NotBefore: time.Now().Add(-time.Minute),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
}
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})
}
func TestSpiffeURI(t *testing.T) {
cases := []struct {
ns, sa, alloc, want string
}{
{"_defaults", "web", "abc123", "spiffe://orca.local/ns/_defaults/sa/web/abc123"},
{"prod", "api", "0", "spiffe://orca.local/ns/prod/sa/api/0"},
}
for _, tc := range cases {
got := SpiffeURI(tc.ns, tc.sa, tc.alloc)
if got != tc.want {
t.Errorf("SpiffeURI(%q,%q,%q) = %q, want %q", tc.ns, tc.sa, tc.alloc, got, tc.want)
}
}
}
func TestSubjectFromSpiffe(t *testing.T) {
ns, sa, alloc, err := SubjectFromSpiffe("spiffe://orca.local/ns/prod/sa/api/0")
if err != nil {
t.Fatalf("SubjectFromSpiffe: %v", err)
}
if ns != "prod" || sa != "api" || alloc != "0" {
t.Errorf("got ns=%q sa=%q alloc=%q", ns, sa, alloc)
}
}
func TestSubjectFromSpiffe_Malformed(t *testing.T) {
cases := []string{
"https://orca.local/ns/prod/sa/api/0",
"spiffe://other/ns/prod/sa/api/0",
"spiffe://orca.local/ns/prod/api/0",
}
for _, c := range cases {
if _, _, _, err := SubjectFromSpiffe(c); err == nil {
t.Errorf("SubjectFromSpiffe(%q): expected error, got nil", c)
}
}
}
func TestVerifySVID_Present(t *testing.T) {
spiffeID := "spiffe://orca.local/ns/_defaults/sa/web/abc123"
certPEM := mintTestSVIDCert(t, spiffeID)
if err := VerifySVID(certPEM, spiffeID); err != nil {
t.Errorf("VerifySVID: %v", err)
}
}
func TestVerifySVID_Missing(t *testing.T) {
certPEM := mintTestSVIDCert(t, "spiffe://orca.local/ns/_defaults/sa/web/abc123")
if err := VerifySVID(certPEM, "spiffe://orca.local/ns/prod/sa/api/0"); !errors.Is(err, ErrSpiffeURIMissing) {
t.Errorf("VerifySVID wrong id: err = %v, want ErrSpiffeURIMissing", err)
}
}
func TestVerifySVID_BadPEM(t *testing.T) {
if err := VerifySVID([]byte("not-a-pem"), "spiffe://orca.local/x"); !errors.Is(err, ErrStepCLI) {
t.Errorf("VerifySVID bad pem: err = %v, want ErrStepCLI", err)
}
}
func TestSpiffeIDFromCert(t *testing.T) {
spiffeID := "spiffe://orca.local/ns/_defaults/sa/web/abc123"
certPEM := mintTestSVIDCert(t, spiffeID)
block, _ := pem.Decode(certPEM)
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatalf("parse cert: %v", err)
}
got := SpiffeIDFromCert(cert)
if got != spiffeID {
t.Errorf("SpiffeIDFromCert = %q, want %q", got, spiffeID)
}
}
func TestMintSVID_Success(t *testing.T) {
spiffeID := "spiffe://orca.local/ns/_defaults/sa/web/abc123"
certPEM := mintTestSVIDCert(t, spiffeID)
keyPEM := []byte("-----BEGIN PRIVATE KEY-----\nFAKE\n-----END PRIVATE KEY-----\n")
mx := &mockExec{responses: []mockResp{
{match: "step ca certificate", out: nil, err: nil},
{match: "cat '/tmp/orca-svid-spiffe-orca.local_ns__defaults_sa_web_abc123.crt'", out: certPEM, err: nil},
{match: "cat '/tmp/orca-svid-spiffe-orca.local_ns__defaults_sa_web_abc123.key'", out: keyPEM, err: nil},
{match: "rm -f", out: nil, err: nil},
}}
gotCert, gotKey, err := MintSVID(context.Background(), mx, "lead:22", "_defaults", "web", "abc123")
if err != nil {
t.Fatalf("MintSVID: %v", err)
}
if string(gotCert) != string(certPEM) {
t.Error("cert PEM mismatch")
}
if string(gotKey) != string(keyPEM) {
t.Error("key PEM mismatch")
}
containsCall(t, mx, "step ca certificate")
containsCall(t, mx, "--san 'spiffe://orca.local/ns/_defaults/sa/web/abc123'")
containsCall(t, mx, "--not-after '24h'")
containsCall(t, mx, "--provisioner 'orca-admin'")
}
func TestMintSVID_StepFails(t *testing.T) {
mx := &mockExec{responses: []mockResp{
{match: "step ca certificate", out: nil, err: errors.New("step: exit 1")},
}}
_, _, err := MintSVID(context.Background(), mx, "lead:22", "_defaults", "web", "abc123")
if err == nil || !strings.Contains(err.Error(), "identity: mint") {
t.Errorf("err = %v, want wrapped 'identity: mint'", err)
}
}
func TestMintSVID_NilTransport(t *testing.T) {
_, _, err := MintSVID(context.Background(), nil, "lead:22", "_defaults", "web", "abc123")
if err == nil || !strings.Contains(err.Error(), "transport is nil") {
t.Errorf("err = %v, want 'transport is nil'", err)
}
}
func TestMintSVID_EmptyLead(t *testing.T) {
mx := &mockExec{}
_, _, err := MintSVID(context.Background(), mx, "", "_defaults", "web", "abc123")
if err == nil || !strings.Contains(err.Error(), "lead peer not set") {
t.Errorf("err = %v, want 'lead peer not set'", err)
}
}
func TestMintSVID_EmptyCert(t *testing.T) {
mx := &mockExec{responses: []mockResp{
{match: "step ca certificate", out: nil, err: nil},
{match: "cat '/tmp/orca-svid-spiffe-orca.local_ns__defaults_sa_web_abc123.crt'", out: nil, err: nil},
{match: "cat '/tmp/orca-svid-spiffe-orca.local_ns__defaults_sa_web_abc123.key'", out: []byte("KEY"), err: nil},
{match: "rm -f", out: nil, err: nil},
}}
_, _, err := MintSVID(context.Background(), mx, "lead:22", "_defaults", "web", "abc123")
if err == nil || !errors.Is(err, ErrStepCLI) {
t.Errorf("err = %v, want ErrStepCLI", err)
}
}
func TestMintSVID_URISANMissing(t *testing.T) {
wrongCert := mintTestSVIDCert(t, "spiffe://orca.local/ns/other/sa/api/0")
mx := &mockExec{responses: []mockResp{
{match: "step ca certificate", out: nil, err: nil},
{match: "cat '/tmp/orca-svid-spiffe-orca.local_ns__defaults_sa_web_abc123.crt'", out: wrongCert, err: nil},
{match: "cat '/tmp/orca-svid-spiffe-orca.local_ns__defaults_sa_web_abc123.key'", out: []byte("KEY"), err: nil},
{match: "rm -f", out: nil, err: nil},
}}
_, _, err := MintSVID(context.Background(), mx, "lead:22", "_defaults", "web", "abc123")
if err == nil || !errors.Is(err, ErrSpiffeURIMissing) {
t.Errorf("err = %v, want ErrSpiffeURIMissing", err)
}
}
func TestShellQuote(t *testing.T) {
if got := shellQuote("a'b"); got != "'a'\\''b'" {
t.Errorf("shellQuote = %q", got)
}
}
func TestSanitize(t *testing.T) {
got := sanitize("spiffe://orca.local/ns/_defaults/sa/web/abc123")
want := "spiffe-orca.local_ns__defaults_sa_web_abc123"
if got != want {
t.Errorf("sanitize = %q, want %q", got, want)
}
}