Files
orca/internal/identity/spiffe.go
T
Jon Chery 0d5ff663b4 fix(P13): step-ca /tmp hardening (REQ-128, F10)
---ci---
project: orca
phase: 13
milestone: v0.12
status: execute
---/ci---

step-ca cert/key temp files moved from world-readable /tmp/orca-* to
/etc/orca/step-tmp/orca-* (0700). mkdir + chmod 700 before writing.
Fixes both stepca.go and spiffe.go. All tests updated + pass.
2026-08-07 11:22:07 +00:00

178 lines
5.7 KiB
Go

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 := "/etc/orca/step-tmp/orca-svid-" + sanitize(spiffeID) + ".crt"
keyOut := "/etc/orca/step-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("orca-oidc"))
sb.WriteString(" --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)
}
// 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" {
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, "'", "'\\''") + "'"
}