- 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>
264 lines
7.8 KiB
Go
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
|
|
}
|