73fd281bb5
ECDSA P-256 self-signed CA with disk persistence (load-or-generate),
CSR signing (CN=nodeUUID, 90d validity, EKU=ClientAuth), one-time
bootstrap tokens via Redis GETDEL (15min TTL), CRL revocation with
Redis SET + DB interface, gRPC unary+stream interceptors that extract
CN from verified TLS chains (Enroll whitelisted, others require cert),
and NewServerTLSConfig (VerifyClientCertIfGiven + TLS 1.3 + CRL hook).
Frozen API: SignCSR / CAPEM / IssueToken / ConsumeToken / Revoke /
NewServerTLSConfig / UnaryServerInterceptor / NodeUUIDFromContext
Tests cover: CA sign+verify, token one-time guarantee, TTL expiry,
revocation rejection, interceptor whitelist (5 categories).
Redis layer backed by miniredis in tests.
Run setup.sh from server/ to fetch deps and verify tests pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
193 lines
6.0 KiB
Go
193 lines
6.0 KiB
Go
// Package mtls provides the mTLS/CA security foundation for the Pangolin control plane.
|
|
// It implements a lightweight self-signed CA, CSR signing, one-time bootstrap tokens,
|
|
// CRL revocation, and gRPC identity extraction interceptors.
|
|
package mtls
|
|
|
|
import (
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"math/big"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
)
|
|
|
|
// CAConfig specifies where to persist the CA key and certificate.
|
|
// Both paths must be writable on first run; subsequent runs load from disk.
|
|
type CAConfig struct {
|
|
// KeyPath is the file path for the CA private key (PEM-encoded ECDSA P-256).
|
|
// The key never leaves the control-plane disk.
|
|
KeyPath string
|
|
// CertPath is the file path for the CA certificate (PEM-encoded X.509).
|
|
CertPath string
|
|
}
|
|
|
|
// CA is a lightweight self-signed certificate authority.
|
|
// It signs node client certificates and provides the CA cert for agent validation.
|
|
type CA struct {
|
|
key *ecdsa.PrivateKey
|
|
cert *x509.Certificate
|
|
certPEM []byte
|
|
}
|
|
|
|
// NewCA loads an existing CA from disk or generates a new ECDSA P-256 CA.
|
|
// Generated key and certificate are persisted to the paths in cfg.
|
|
func NewCA(cfg CAConfig) (*CA, error) {
|
|
keyPEM, errKey := os.ReadFile(cfg.KeyPath)
|
|
certPEM, errCert := os.ReadFile(cfg.CertPath)
|
|
|
|
if errKey == nil && errCert == nil {
|
|
key, err := parseECPrivateKey(keyPEM)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("mtls: parse CA key: %w", err)
|
|
}
|
|
cert, err := parseCertificate(certPEM)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("mtls: parse CA cert: %w", err)
|
|
}
|
|
return &CA{key: key, cert: cert, certPEM: certPEM}, nil
|
|
}
|
|
|
|
// Generate a new CA key pair and self-signed certificate.
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("mtls: generate CA key: %w", err)
|
|
}
|
|
|
|
serial, err := randomSerial()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
now := time.Now()
|
|
template := &x509.Certificate{
|
|
SerialNumber: serial,
|
|
Subject: pkix.Name{
|
|
Organization: []string{"Pangolin"},
|
|
CommonName: "Pangolin Node CA",
|
|
},
|
|
NotBefore: now.Add(-time.Minute), // slight back-date for clock skew
|
|
NotAfter: now.Add(10 * 365 * 24 * time.Hour),
|
|
IsCA: true,
|
|
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
|
BasicConstraintsValid: true,
|
|
}
|
|
|
|
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("mtls: self-sign CA: %w", err)
|
|
}
|
|
|
|
cert, err := x509.ParseCertificate(certDER)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("mtls: parse self-signed CA: %w", err)
|
|
}
|
|
|
|
keyDER, err := x509.MarshalECPrivateKey(key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("mtls: marshal CA key: %w", err)
|
|
}
|
|
keyPEMBytes := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
|
certPEMBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
|
|
|
// Persist to disk; create parent directories as needed.
|
|
if err := os.MkdirAll(filepath.Dir(cfg.KeyPath), 0o700); err != nil {
|
|
return nil, fmt.Errorf("mtls: mkdir for CA key: %w", err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(cfg.CertPath), 0o755); err != nil {
|
|
return nil, fmt.Errorf("mtls: mkdir for CA cert: %w", err)
|
|
}
|
|
// Key: owner-only read (0600)
|
|
if err := os.WriteFile(cfg.KeyPath, keyPEMBytes, 0o600); err != nil {
|
|
return nil, fmt.Errorf("mtls: write CA key: %w", err)
|
|
}
|
|
if err := os.WriteFile(cfg.CertPath, certPEMBytes, 0o644); err != nil {
|
|
return nil, fmt.Errorf("mtls: write CA cert: %w", err)
|
|
}
|
|
|
|
return &CA{key: key, cert: cert, certPEM: certPEMBytes}, nil
|
|
}
|
|
|
|
// SignCSR signs a PEM-encoded CSR and issues a client certificate.
|
|
// The certificate's Subject CN is overridden to nodeUUID regardless of what
|
|
// the CSR requests. Validity is 90 days; EKU = ClientAuth only.
|
|
func (ca *CA) SignCSR(csrPEM []byte, nodeUUID string) ([]byte, error) {
|
|
block, _ := pem.Decode(csrPEM)
|
|
if block == nil {
|
|
return nil, fmt.Errorf("mtls: invalid CSR PEM")
|
|
}
|
|
csr, err := x509.ParseCertificateRequest(block.Bytes)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("mtls: parse CSR: %w", err)
|
|
}
|
|
if err := csr.CheckSignature(); err != nil {
|
|
return nil, fmt.Errorf("mtls: CSR signature invalid: %w", err)
|
|
}
|
|
|
|
serial, err := randomSerial()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
now := time.Now()
|
|
template := &x509.Certificate{
|
|
SerialNumber: serial,
|
|
Subject: pkix.Name{
|
|
CommonName: nodeUUID,
|
|
},
|
|
NotBefore: now.Add(-time.Minute),
|
|
NotAfter: now.Add(90 * 24 * time.Hour),
|
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
|
|
}
|
|
|
|
certDER, err := x509.CreateCertificate(rand.Reader, template, ca.cert, csr.PublicKey, ca.key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("mtls: sign CSR: %w", err)
|
|
}
|
|
|
|
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}), nil
|
|
}
|
|
|
|
// CAPEM returns the CA certificate in PEM format.
|
|
// This is sent to agents during Enroll so they can pin the server's CA.
|
|
func (ca *CA) CAPEM() []byte {
|
|
return ca.certPEM
|
|
}
|
|
|
|
// CACert returns the parsed CA certificate for building x509.CertPool entries.
|
|
func (ca *CA) CACert() *x509.Certificate {
|
|
return ca.cert
|
|
}
|
|
|
|
// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
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("mtls: generate serial: %w", err)
|
|
}
|
|
return serial, nil
|
|
}
|
|
|
|
func parseECPrivateKey(pemData []byte) (*ecdsa.PrivateKey, error) {
|
|
block, _ := pem.Decode(pemData)
|
|
if block == nil {
|
|
return nil, fmt.Errorf("no PEM block found")
|
|
}
|
|
return x509.ParseECPrivateKey(block.Bytes)
|
|
}
|
|
|
|
func parseCertificate(pemData []byte) (*x509.Certificate, error) {
|
|
block, _ := pem.Decode(pemData)
|
|
if block == nil {
|
|
return nil, fmt.Errorf("no PEM block found")
|
|
}
|
|
return x509.ParseCertificate(block.Bytes)
|
|
}
|