Files
wangjia 73fd281bb5 feat(mtls): implement mTLS/CA + bootstrap token framework [tsk_FUQws_DMIcXa]
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>
2026-06-13 01:44:21 +08:00

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)
}