734c9fa0fa
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---
137 lines
4.1 KiB
Go
137 lines
4.1 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 := "/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, "'", "'\\''") + "'"
|
|
}
|