forked from wrenn/wrenn
Moves 12 packages from internal/ to pkg/ (config, id, validate, events, db, auth, lifecycle, scheduler, channels, audit, service) so they can be imported by the enterprise repo as a Go module dependency. Introduces pkg/cpextension (shared Extension interface + ServerContext) and pkg/cpserver (Run() entrypoint with functional options) so the enterprise main.go can call cpserver.Run(cpserver.WithExtensions(...)) without duplicating the 20-step server bootstrap. Adds db/migrations/embed.go for go:embed access to OSS SQL migrations from the enterprise module. cmd/control-plane/main.go is reduced to a 10-line wrapper around cpserver.Run.
252 lines
7.8 KiB
Go
252 lines
7.8 KiB
Go
package auth
|
|
|
|
import (
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"math/big"
|
|
"net"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
// CPCertRenewInterval is how often the control plane should renew its client
|
|
// certificate. It is set to half the cert TTL so there is always a wide safety
|
|
// margin before expiry.
|
|
const CPCertRenewInterval = cpCertTTL / 2
|
|
|
|
const (
|
|
hostCertTTL = 7 * 24 * time.Hour
|
|
cpCertTTL = 24 * time.Hour
|
|
)
|
|
|
|
// CA holds a parsed certificate authority ready to issue leaf certificates.
|
|
type CA struct {
|
|
Cert *x509.Certificate
|
|
Key *ecdsa.PrivateKey
|
|
PEM string // PEM-encoded certificate for embedding in register/refresh responses
|
|
}
|
|
|
|
// ParseCA parses PEM-encoded CA certificate and private key strings.
|
|
// The cert and key are expected to be ECDSA P-256.
|
|
func ParseCA(certPEM, keyPEM string) (*CA, error) {
|
|
certBlock, _ := pem.Decode([]byte(certPEM))
|
|
if certBlock == nil {
|
|
return nil, fmt.Errorf("failed to decode CA certificate PEM")
|
|
}
|
|
cert, err := x509.ParseCertificate(certBlock.Bytes)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse CA certificate: %w", err)
|
|
}
|
|
|
|
keyBlock, _ := pem.Decode([]byte(keyPEM))
|
|
if keyBlock == nil {
|
|
return nil, fmt.Errorf("failed to decode CA key PEM")
|
|
}
|
|
keyIface, err := x509.ParseECPrivateKey(keyBlock.Bytes)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse CA private key: %w", err)
|
|
}
|
|
|
|
return &CA{Cert: cert, Key: keyIface, PEM: certPEM}, nil
|
|
}
|
|
|
|
// HostCert holds all material returned when issuing a leaf cert for a host agent.
|
|
type HostCert struct {
|
|
CertPEM string
|
|
KeyPEM string
|
|
Fingerprint string // hex-encoded SHA-256 of DER bytes, stored in hosts.cert_fingerprint
|
|
ExpiresAt time.Time // stored in hosts.cert_expires_at
|
|
TLSCert tls.Certificate
|
|
}
|
|
|
|
// IssueHostCert generates an ECDSA P-256 key pair and issues a 7-day server
|
|
// certificate for the host agent. hostID becomes the common name; the host's
|
|
// IP address (parsed from hostAddr) is added as an IP SAN so Go's TLS
|
|
// stack can verify the connection without disabling hostname checking.
|
|
func IssueHostCert(ca *CA, hostID, hostAddr string) (HostCert, error) {
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
return HostCert{}, fmt.Errorf("generate host key: %w", err)
|
|
}
|
|
|
|
serial, err := randomSerial()
|
|
if err != nil {
|
|
return HostCert{}, err
|
|
}
|
|
|
|
now := time.Now()
|
|
expires := now.Add(hostCertTTL)
|
|
|
|
tmpl := &x509.Certificate{
|
|
SerialNumber: serial,
|
|
Subject: pkix.Name{CommonName: hostID},
|
|
NotBefore: now.Add(-time.Minute), // small clock-skew tolerance
|
|
NotAfter: expires,
|
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
|
}
|
|
|
|
// Extract IP from "ip:port" address; fall back to DNS SAN if not parseable.
|
|
host, _, err := net.SplitHostPort(hostAddr)
|
|
if err != nil {
|
|
host = hostAddr
|
|
}
|
|
if ip := net.ParseIP(host); ip != nil {
|
|
tmpl.IPAddresses = []net.IP{ip}
|
|
} else {
|
|
tmpl.DNSNames = []string{host}
|
|
}
|
|
|
|
derBytes, err := x509.CreateCertificate(rand.Reader, tmpl, ca.Cert, &key.PublicKey, ca.Key)
|
|
if err != nil {
|
|
return HostCert{}, fmt.Errorf("create host certificate: %w", err)
|
|
}
|
|
|
|
certPEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}))
|
|
keyDER, err := x509.MarshalECPrivateKey(key)
|
|
if err != nil {
|
|
return HostCert{}, fmt.Errorf("marshal host key: %w", err)
|
|
}
|
|
keyPEM := string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}))
|
|
|
|
tlsCert, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM))
|
|
if err != nil {
|
|
return HostCert{}, fmt.Errorf("build TLS certificate: %w", err)
|
|
}
|
|
|
|
fp := fmt.Sprintf("%x", sha256.Sum256(derBytes))
|
|
|
|
return HostCert{
|
|
CertPEM: certPEM,
|
|
KeyPEM: keyPEM,
|
|
Fingerprint: fp,
|
|
ExpiresAt: expires,
|
|
TLSCert: tlsCert,
|
|
}, nil
|
|
}
|
|
|
|
// IssueCPClientCert generates a short-lived (24h) ECDSA client certificate for
|
|
// the control plane to present during mTLS handshakes with host agents.
|
|
// Called once at CP startup; the result is embedded into the shared HTTP client.
|
|
func IssueCPClientCert(ca *CA) (tls.Certificate, error) {
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
return tls.Certificate{}, fmt.Errorf("generate CP client key: %w", err)
|
|
}
|
|
|
|
serial, err := randomSerial()
|
|
if err != nil {
|
|
return tls.Certificate{}, err
|
|
}
|
|
|
|
now := time.Now()
|
|
tmpl := &x509.Certificate{
|
|
SerialNumber: serial,
|
|
Subject: pkix.Name{CommonName: "wrenn-cp"},
|
|
NotBefore: now.Add(-time.Minute),
|
|
NotAfter: now.Add(cpCertTTL),
|
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
|
|
}
|
|
|
|
derBytes, err := x509.CreateCertificate(rand.Reader, tmpl, ca.Cert, &key.PublicKey, ca.Key)
|
|
if err != nil {
|
|
return tls.Certificate{}, fmt.Errorf("create CP client certificate: %w", err)
|
|
}
|
|
|
|
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
|
|
keyDER, err := x509.MarshalECPrivateKey(key)
|
|
if err != nil {
|
|
return tls.Certificate{}, fmt.Errorf("marshal CP client key: %w", err)
|
|
}
|
|
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
|
|
|
return tls.X509KeyPair(certPEM, keyPEM)
|
|
}
|
|
|
|
// AgentTLSConfigFromPEM returns a tls.Config for the host agent using the
|
|
// PEM-encoded CA certificate. This is used on the agent side where only the
|
|
// CA certificate (not the private key) is available.
|
|
func AgentTLSConfigFromPEM(caCertPEM string, getCert func(*tls.ClientHelloInfo) (*tls.Certificate, error)) *tls.Config {
|
|
pool := x509.NewCertPool()
|
|
if !pool.AppendCertsFromPEM([]byte(caCertPEM)) {
|
|
return nil
|
|
}
|
|
return &tls.Config{
|
|
ClientAuth: tls.RequireAndVerifyClientCert,
|
|
ClientCAs: pool,
|
|
GetCertificate: getCert,
|
|
MinVersion: tls.VersionTLS13,
|
|
}
|
|
}
|
|
|
|
// CPCertStore provides lock-free read/write access to the control plane's
|
|
// current client TLS certificate. It is used with tls.Config.GetClientCertificate
|
|
// to enable hot-swap without restarting the HTTP client.
|
|
//
|
|
// The zero value is not usable; use NewCPCertStore to create one.
|
|
type CPCertStore struct {
|
|
ptr atomic.Pointer[tls.Certificate]
|
|
ca *CA
|
|
}
|
|
|
|
// NewCPCertStore issues an initial CP client certificate from ca and returns a
|
|
// store that can renew it in place. Returns an error if the initial issuance fails.
|
|
func NewCPCertStore(ca *CA) (*CPCertStore, error) {
|
|
s := &CPCertStore{ca: ca}
|
|
if err := s.Refresh(); err != nil {
|
|
return nil, err
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// Refresh issues a fresh CP client certificate and atomically stores it.
|
|
// If issuance fails the existing cert is unchanged.
|
|
func (s *CPCertStore) Refresh() error {
|
|
cert, err := IssueCPClientCert(s.ca)
|
|
if err != nil {
|
|
return fmt.Errorf("renew CP client certificate: %w", err)
|
|
}
|
|
s.ptr.Store(&cert)
|
|
return nil
|
|
}
|
|
|
|
// GetClientCertificate satisfies tls.Config.GetClientCertificate. It is called
|
|
// per-handshake and always returns the most recently stored certificate.
|
|
func (s *CPCertStore) GetClientCertificate(_ *tls.CertificateRequestInfo) (*tls.Certificate, error) {
|
|
cert := s.ptr.Load()
|
|
if cert == nil {
|
|
return nil, fmt.Errorf("no CP client certificate available")
|
|
}
|
|
return cert, nil
|
|
}
|
|
|
|
// CPClientTLSConfig returns a tls.Config for the CP's outbound HTTP client.
|
|
// It uses certStore.GetClientCertificate so the certificate can be renewed
|
|
// without replacing the config or transport.
|
|
func CPClientTLSConfig(ca *CA, certStore *CPCertStore) *tls.Config {
|
|
pool := x509.NewCertPool()
|
|
pool.AddCert(ca.Cert)
|
|
return &tls.Config{
|
|
RootCAs: pool,
|
|
GetClientCertificate: certStore.GetClientCertificate,
|
|
MinVersion: tls.VersionTLS13,
|
|
}
|
|
}
|
|
|
|
// randomSerial returns a random 128-bit certificate serial number.
|
|
func randomSerial() (*big.Int, error) {
|
|
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("generate serial number: %w", err)
|
|
}
|
|
return serial, nil
|
|
}
|