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 }