guildhouse-spire-plugins/pkg/sshcert/sshcert.go
Tyler King a58d548518 feat: network-policy extension, governance lifecycle, audit remediation
- Network-policy SPIRE plugin extension
- Governance event notification with merkle anchoring
- Shellstream specs for consent channels + HFL embedded ABI
- All 17 audit findings from AUDIT.md remediated
- SSH credential composer + substrate key manager updates
- Test coverage for config + sshcert packages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:54:46 -04:00

264 lines
7.8 KiB
Go

// Package sshcert builds SSH certificates with Shellstream extensions,
// bridging SPIFFE identity and Guildhouse governance metadata.
package sshcert
import (
"crypto/ed25519"
"crypto/rand"
"encoding/binary"
"fmt"
"math/big"
"net/url"
"strings"
"time"
"github.com/guildhouse-cooperative/guildhouse-spire-plugins/pkg/shellstream"
"golang.org/x/crypto/ssh"
)
// Config holds SSH certificate builder configuration.
type Config struct {
// TrustDomain is the SPIFFE trust domain.
TrustDomain string
// MaxValidSeconds is the server-enforced maximum certificate TTL.
// SSH-SVID spec mandates 3600 (1 hour).
MaxValidSeconds uint64
// MinValidSeconds is the server-enforced minimum certificate TTL.
// SSH-SVID spec mandates 30 seconds.
MinValidSeconds uint64
// DefaultValidSeconds is the TTL used when the request does not specify one.
// SSH-SVID spec recommends 300 (5 minutes).
DefaultValidSeconds uint64
// AllowedPrincipals restricts which SSH principals may appear in certificates.
// If empty, only the SPIFFE ID leaf component is permitted.
AllowedPrincipals []string
// CAKey is the SSH CA signing key. If nil, Build will generate an ephemeral one.
CAKey ssh.Signer
}
// CertRequest describes an SSH certificate to build.
type CertRequest struct {
// SpiffeID is the workload's SPIFFE ID (used as principal).
SpiffeID string
// Extensions are the Shellstream governance extensions to embed.
Extensions *shellstream.ShellstreamExtensions
// ValidSeconds is the requested certificate lifetime in seconds.
// Clamped to [MinValidSeconds, MaxValidSeconds] by the Builder.
ValidSeconds uint64
// Principals are additional SSH principals beyond the SPIFFE ID.
// Validated against Builder.AllowedPrincipals.
Principals []string
}
// Builder creates SSH certificates with Shellstream extensions.
type Builder struct {
config Config
}
// NewBuilder creates an SSH certificate builder.
func NewBuilder(cfg Config) (*Builder, error) {
if cfg.TrustDomain == "" {
return nil, fmt.Errorf("sshcert: trust domain is required")
}
// Apply spec defaults if not configured.
if cfg.MaxValidSeconds == 0 {
cfg.MaxValidSeconds = 3600 // 1 hour per SSH-SVID spec
}
if cfg.MinValidSeconds == 0 {
cfg.MinValidSeconds = 30 // 30 seconds per SSH-SVID spec
}
if cfg.DefaultValidSeconds == 0 {
cfg.DefaultValidSeconds = 300 // 5 minutes per SSH-SVID spec
}
if cfg.MinValidSeconds > cfg.MaxValidSeconds {
return nil, fmt.Errorf("sshcert: min TTL %d exceeds max TTL %d", cfg.MinValidSeconds, cfg.MaxValidSeconds)
}
if cfg.DefaultValidSeconds < cfg.MinValidSeconds || cfg.DefaultValidSeconds > cfg.MaxValidSeconds {
return nil, fmt.Errorf("sshcert: default TTL %d outside [%d, %d]",
cfg.DefaultValidSeconds, cfg.MinValidSeconds, cfg.MaxValidSeconds)
}
return &Builder{config: cfg}, nil
}
// Build creates an SSH certificate from the request.
// Generates an Ed25519 user keypair, builds the certificate with Shellstream
// governance extensions, and signs it with the CA key.
func (b *Builder) Build(req *CertRequest) ([]byte, error) {
if req == nil {
return nil, fmt.Errorf("sshcert: nil request")
}
if req.SpiffeID == "" {
return nil, fmt.Errorf("sshcert: spiffe ID is required")
}
// TTL enforcement.
ttl := req.ValidSeconds
if ttl == 0 {
ttl = b.config.DefaultValidSeconds
}
if ttl < b.config.MinValidSeconds {
return nil, fmt.Errorf("sshcert: requested TTL %d below minimum %d", ttl, b.config.MinValidSeconds)
}
if ttl > b.config.MaxValidSeconds {
return nil, fmt.Errorf("sshcert: requested TTL %d exceeds maximum %d", ttl, b.config.MaxValidSeconds)
}
// Principal validation.
principals := req.Principals
if len(principals) == 0 {
leaf, err := SpiffeIDLeaf(req.SpiffeID)
if err != nil {
return nil, fmt.Errorf("sshcert: invalid SPIFFE ID: %w", err)
}
principals = []string{leaf}
}
if len(b.config.AllowedPrincipals) > 0 {
for _, p := range principals {
if !containsString(b.config.AllowedPrincipals, p) {
return nil, fmt.Errorf("sshcert: principal %q not in allowed list", p)
}
}
}
// Validate SPIFFE ID format for all paths.
if _, err := SpiffeIDLeaf(req.SpiffeID); err != nil {
return nil, fmt.Errorf("sshcert: invalid SPIFFE ID: %w", err)
}
// Generate user keypair.
pubKey, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, fmt.Errorf("sshcert: generate user key: %w", err)
}
sshPubKey, err := ssh.NewPublicKey(pubKey)
if err != nil {
return nil, fmt.Errorf("sshcert: convert public key: %w", err)
}
// Build extensions map with Shellstream governance metadata.
extensions := make(map[string]string)
extensions["permit-pty"] = ""
extensions["permit-user-rc"] = ""
// Embed the SPIFFE ID as a critical option for verification.
criticalOptions := map[string]string{
"spiffe-id": req.SpiffeID,
}
// Encode Shellstream extensions if present.
if req.Extensions != nil {
ssExtensions, err := shellstream.Encode(req.Extensions)
if err != nil {
return nil, fmt.Errorf("sshcert: encode shellstream extensions: %w", err)
}
for k, v := range ssExtensions {
extensions[k] = v
}
}
// Generate a random serial number.
serialBytes := make([]byte, 8)
if _, err := rand.Read(serialBytes); err != nil {
return nil, fmt.Errorf("sshcert: generate serial: %w", err)
}
serial := binary.BigEndian.Uint64(serialBytes)
now := time.Now()
cert := &ssh.Certificate{
CertType: ssh.UserCert,
Key: sshPubKey,
Serial: serial,
KeyId: req.SpiffeID,
ValidPrincipals: principals,
ValidAfter: uint64(now.Unix()),
ValidBefore: uint64(now.Add(time.Duration(ttl) * time.Second).Unix()),
Permissions: ssh.Permissions{
CriticalOptions: criticalOptions,
Extensions: extensions,
},
Nonce: serialBytes, // Reuse random bytes as nonce.
}
// Sign with CA key.
caKey := b.config.CAKey
if caKey == nil {
// Generate ephemeral CA key for development/testing.
_, caPriv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, fmt.Errorf("sshcert: generate ephemeral CA: %w", err)
}
caKey, err = ssh.NewSignerFromKey(caPriv)
if err != nil {
return nil, fmt.Errorf("sshcert: create CA signer: %w", err)
}
}
if err := cert.SignCert(rand.Reader, caKey); err != nil {
return nil, fmt.Errorf("sshcert: sign certificate: %w", err)
}
return ssh.MarshalAuthorizedKey(cert), nil
}
// BuildWithSerial is like Build but also returns the generated serial number.
func (b *Builder) BuildWithSerial(req *CertRequest) (certBytes []byte, serial *big.Int, err error) {
certBytes, err = b.Build(req)
if err != nil {
return nil, nil, err
}
// Parse back to extract serial.
key, _, _, _, parseErr := ssh.ParseAuthorizedKey(certBytes)
if parseErr != nil {
return certBytes, nil, nil // Return cert even if we can't extract serial.
}
if cert, ok := key.(*ssh.Certificate); ok {
serial = new(big.Int).SetUint64(cert.Serial)
}
return certBytes, serial, nil
}
// SpiffeIDLeaf extracts the last path component from a SPIFFE ID URI.
//
// spiffe://guildhouse.dev/ns/prod/sa/web-server → web-server
// spiffe://guildhouse.dev/operator/tyler → tyler
func SpiffeIDLeaf(spiffeID string) (string, error) {
u, err := url.Parse(spiffeID)
if err != nil {
return "", fmt.Errorf("not a valid URI: %w", err)
}
if u.Scheme != "spiffe" {
return "", fmt.Errorf("expected spiffe:// scheme, got %q", u.Scheme)
}
if u.Host == "" {
return "", fmt.Errorf("missing trust domain")
}
path := strings.TrimPrefix(u.Path, "/")
if path == "" {
return "", fmt.Errorf("empty path")
}
parts := strings.Split(path, "/")
leaf := parts[len(parts)-1]
if leaf == "" {
return "", fmt.Errorf("trailing slash in path")
}
return leaf, nil
}
func containsString(haystack []string, needle string) bool {
for _, s := range haystack {
if s == needle {
return true
}
}
return false
}