Files
felhom-agent/internal/localapi/cert.go
T
admin 9b0d6c2c82 agent: EnsureLeaf signals + loud-WARNs a regenerated leaf (prevention B.1) v0.46.0
EnsureLeaf returns generated bool; call-site logs INFO 'leaf LOADED' vs WARN 'leaf REGENERATED —
previously issued bootstrap pins now INVALID'. Catches the 2026-06-28 silent-regen incident class.
Test: first=generated, second=loaded+same fp. No new sudo surface.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pg8ANF97SEeKYSN5Jxw3qJ
2026-06-29 21:45:25 +02:00

132 lines
5.3 KiB
Go

package localapi
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/hex"
"encoding/pem"
"fmt"
"math/big"
"net"
"os"
"path/filepath"
"time"
)
// certValidity is the self-signed leaf lifetime. It is long because the leaf's SHA-256
// fingerprint is baked into every guest's bootstrap for pinning — rotating the cert means
// re-issuing bootstraps, so this is deliberately decoupled from short-lived TLS norms. Cert
// rotation is an operator action (slice 10+), not an automatic expiry event.
const certValidity = 10 * 365 * 24 * time.Hour
// EnsureLeaf loads the agent's local-API leaf from certPath/keyPath, generating and persisting
// a fresh self-signed ECDSA-P256 leaf (SAN = bridgeHost, when it is an IP/host) if either file
// is absent. It returns the tls.Certificate to serve and the leaf's SHA-256 fingerprint (the
// agent's pin convention: lowercase hex of the leaf DER) for baking into bootstraps.
//
// Persisting the generated pair keeps the fingerprint STABLE across agent restarts — a fresh
// cert each boot would silently invalidate every already-issued bootstrap's pin.
// The returned `generated` is false when an existing pair was LOADED (the fingerprint is stable) and
// true when a fresh leaf was GENERATED (every previously-issued bootstrap pin is now invalid — the
// caller logs this LOUD, B.1, so an accidental regeneration like the 2026-06-28 migration is visible
// immediately instead of silently breaking every controller's pin for days).
func EnsureLeaf(certPath, keyPath, bridgeHost string) (cert tls.Certificate, fingerprint string, generated bool, err error) {
if fileExists(certPath) && fileExists(keyPath) {
cert, err = tls.LoadX509KeyPair(certPath, keyPath)
if err != nil {
return tls.Certificate{}, "", false, fmt.Errorf("localapi: load leaf %s: %w", certPath, err)
}
fingerprint, err = leafFingerprint(cert)
if err != nil {
return tls.Certificate{}, "", false, err
}
return cert, fingerprint, false, nil // LOADED
}
cert, fingerprint, err = generateLeaf(certPath, keyPath, bridgeHost)
return cert, fingerprint, true, err // GENERATED
}
// generateLeaf creates a self-signed ECDSA-P256 leaf, writes the cert (0644) + key (0600) to
// disk, and returns the loaded pair + its fingerprint.
func generateLeaf(certPath, keyPath, bridgeHost string) (tls.Certificate, string, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return tls.Certificate{}, "", fmt.Errorf("localapi: gen key: %w", err)
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return tls.Certificate{}, "", fmt.Errorf("localapi: gen serial: %w", err)
}
notBefore := time.Now().Add(-1 * time.Hour) // small backdate for clock skew
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: "felhom-agent local-api"},
NotBefore: notBefore,
NotAfter: notBefore.Add(certValidity),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
// SAN: the bridge host (an IP in practice). The controller pins the leaf SHA-256, so the
// SAN is not load-bearing for trust, but a correct SAN keeps standard tooling happy.
if host := bridgeHost; host != "" {
if ip := net.ParseIP(host); ip != nil {
tmpl.IPAddresses = append(tmpl.IPAddresses, ip)
} else {
tmpl.DNSNames = append(tmpl.DNSNames, host)
}
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
return tls.Certificate{}, "", fmt.Errorf("localapi: create cert: %w", err)
}
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
return tls.Certificate{}, "", fmt.Errorf("localapi: marshal key: %w", err)
}
if err := os.MkdirAll(filepath.Dir(certPath), 0o700); err != nil {
return tls.Certificate{}, "", fmt.Errorf("localapi: cert dir: %w", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
if err := os.WriteFile(certPath, certPEM, 0o644); err != nil {
return tls.Certificate{}, "", fmt.Errorf("localapi: write cert: %w", err)
}
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
return tls.Certificate{}, "", fmt.Errorf("localapi: write key: %w", err)
}
syncDir(filepath.Dir(certPath))
cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return tls.Certificate{}, "", fmt.Errorf("localapi: reload generated leaf: %w", err)
}
fp, err := leafFingerprint(cert)
if err != nil {
return tls.Certificate{}, "", err
}
return cert, fp, nil
}
// leafFingerprint returns the lowercase-hex SHA-256 of the leaf certificate DER — the same pin
// convention the agent uses for the Proxmox/PBS host certs.
func leafFingerprint(cert tls.Certificate) (string, error) {
if len(cert.Certificate) == 0 {
return "", fmt.Errorf("localapi: certificate has no leaf")
}
sum := sha256.Sum256(cert.Certificate[0]) // Certificate[0] is the leaf DER
return hex.EncodeToString(sum[:]), nil
}
func fileExists(p string) bool {
if p == "" {
return false
}
_, err := os.Stat(p)
return err == nil
}