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>
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
module github.com/pangolinvpn/server
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/alicebob/miniredis/v2 v2.38.0
|
||||
github.com/redis/go-redis/v9 v9.20.1
|
||||
google.golang.org/grpc v1.81.1
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
package mtls
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const (
|
||||
bootstrapTokenPrefix = "enroll:token:"
|
||||
bootstrapTokenTTL = 15 * time.Minute
|
||||
)
|
||||
|
||||
// BootstrapTokenManager issues and validates one-time enrollment tokens.
|
||||
// Tokens are generated before a node is provisioned (called by task #14 during
|
||||
// cloud-init preparation) and consumed exactly once during the Enroll RPC.
|
||||
type BootstrapTokenManager struct {
|
||||
redis redis.Cmdable
|
||||
}
|
||||
|
||||
// NewBootstrapTokenManager creates a manager backed by the given Redis client.
|
||||
func NewBootstrapTokenManager(r redis.Cmdable) *BootstrapTokenManager {
|
||||
return &BootstrapTokenManager{redis: r}
|
||||
}
|
||||
|
||||
// IssueToken allocates a cryptographically random 32-byte token for a node.
|
||||
// The mapping enroll:token:{token} → nodeUUID is stored in Redis with a 15-minute TTL.
|
||||
//
|
||||
// Callers (task #14) invoke this pre-flight when a new node record is created;
|
||||
// the token is then injected into the node's cloud-init user-data.
|
||||
func (m *BootstrapTokenManager) IssueToken(ctx context.Context, nodeUUID string) (string, error) {
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("mtls: generate token entropy: %w", err)
|
||||
}
|
||||
token := hex.EncodeToString(raw) // 64-char hex string
|
||||
|
||||
key := bootstrapTokenPrefix + token
|
||||
if err := m.redis.Set(ctx, key, nodeUUID, bootstrapTokenTTL).Err(); err != nil {
|
||||
return "", fmt.Errorf("mtls: store bootstrap token: %w", err)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// ConsumeToken atomically retrieves and deletes the token (GETDEL).
|
||||
// Returns the bound nodeUUID on the first and only successful call.
|
||||
// A second call—or any call after the 15-minute TTL—returns an error.
|
||||
func (m *BootstrapTokenManager) ConsumeToken(ctx context.Context, token string) (string, error) {
|
||||
key := bootstrapTokenPrefix + token
|
||||
nodeUUID, err := m.redis.GetDel(ctx, key).Result()
|
||||
if err == redis.Nil {
|
||||
return "", fmt.Errorf("mtls: bootstrap token not found, already used, or expired")
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("mtls: consume bootstrap token: %w", err)
|
||||
}
|
||||
return nodeUUID, nil
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package mtls
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func newTestBootstrapManager(t *testing.T) (*BootstrapTokenManager, *miniredis.Miniredis) {
|
||||
t.Helper()
|
||||
mr := miniredis.RunT(t)
|
||||
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() { rdb.Close() })
|
||||
return NewBootstrapTokenManager(rdb), mr
|
||||
}
|
||||
|
||||
func TestBootstrap_IssueToken_Format(t *testing.T) {
|
||||
mgr, _ := newTestBootstrapManager(t)
|
||||
|
||||
token, err := mgr.IssueToken(context.Background(), "node-abc")
|
||||
if err != nil {
|
||||
t.Fatalf("IssueToken: %v", err)
|
||||
}
|
||||
if len(token) != 64 {
|
||||
t.Errorf("token length = %d; want 64 hex chars", len(token))
|
||||
}
|
||||
if strings.ContainsAny(token, "ghijklmnopqrstuvwxyz !@#") {
|
||||
t.Errorf("token contains non-hex chars: %q", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrap_ConsumeToken_OnceOnly(t *testing.T) {
|
||||
mgr, _ := newTestBootstrapManager(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const nodeUUID = "node-11111111-2222-3333-4444-555555555555"
|
||||
|
||||
token, err := mgr.IssueToken(ctx, nodeUUID)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueToken: %v", err)
|
||||
}
|
||||
|
||||
// First consume: must succeed.
|
||||
got, err := mgr.ConsumeToken(ctx, token)
|
||||
if err != nil {
|
||||
t.Fatalf("ConsumeToken (first): %v", err)
|
||||
}
|
||||
if got != nodeUUID {
|
||||
t.Errorf("ConsumeToken returned %q; want %q", got, nodeUUID)
|
||||
}
|
||||
|
||||
// Second consume: must fail (one-time guarantee).
|
||||
_, err = mgr.ConsumeToken(ctx, token)
|
||||
if err == nil {
|
||||
t.Fatal("ConsumeToken (second): expected error for already-consumed token, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrap_ConsumeToken_NeverIssued(t *testing.T) {
|
||||
mgr, _ := newTestBootstrapManager(t)
|
||||
|
||||
_, err := mgr.ConsumeToken(context.Background(), "deadbeefdeadbeef")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-existent token, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrap_ConsumeToken_ExpiredTTL(t *testing.T) {
|
||||
mgr, mr := newTestBootstrapManager(t)
|
||||
ctx := context.Background()
|
||||
|
||||
token, err := mgr.IssueToken(ctx, "node-expired")
|
||||
if err != nil {
|
||||
t.Fatalf("IssueToken: %v", err)
|
||||
}
|
||||
|
||||
// Advance miniredis clock past the 15-minute TTL.
|
||||
mr.FastForward(bootstrapTokenTTL + time.Second)
|
||||
|
||||
_, err = mgr.ConsumeToken(ctx, token)
|
||||
if err == nil {
|
||||
t.Fatal("expected error after TTL expiry, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrap_IssueToken_Uniqueness(t *testing.T) {
|
||||
mgr, _ := newTestBootstrapManager(t)
|
||||
ctx := context.Background()
|
||||
|
||||
t1, _ := mgr.IssueToken(ctx, "node-A")
|
||||
t2, _ := mgr.IssueToken(ctx, "node-B")
|
||||
if t1 == t2 {
|
||||
t.Error("IssueToken returned duplicate tokens for different nodes")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package mtls
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newTestCA creates a CA in the given temporary directory.
|
||||
func newTestCA(t *testing.T, dir string) *CA {
|
||||
t.Helper()
|
||||
ca, err := NewCA(CAConfig{
|
||||
KeyPath: filepath.Join(dir, "ca.key"),
|
||||
CertPath: filepath.Join(dir, "ca.crt"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewCA: %v", err)
|
||||
}
|
||||
return ca
|
||||
}
|
||||
|
||||
// newTestCSR generates an ECDSA P-256 key and a CSR PEM for testing.
|
||||
func newTestCSR(t *testing.T) ([]byte, *ecdsa.PrivateKey) {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate CSR key: %v", err)
|
||||
}
|
||||
tmpl := &x509.CertificateRequest{
|
||||
Subject: pkix.Name{CommonName: "ignored-by-signer"},
|
||||
}
|
||||
csrDER, err := x509.CreateCertificateRequest(rand.Reader, tmpl, key)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCertificateRequest: %v", err)
|
||||
}
|
||||
csrPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER})
|
||||
return csrPEM, key
|
||||
}
|
||||
|
||||
func TestCA_GenerateAndLoad(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// First call: generate a new CA.
|
||||
ca1 := newTestCA(t, dir)
|
||||
if len(ca1.CAPEM()) == 0 {
|
||||
t.Fatal("CAPEM returned empty")
|
||||
}
|
||||
|
||||
// Second call with same paths: must load from disk without error.
|
||||
ca2, err := NewCA(CAConfig{
|
||||
KeyPath: filepath.Join(dir, "ca.key"),
|
||||
CertPath: filepath.Join(dir, "ca.crt"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewCA reload: %v", err)
|
||||
}
|
||||
|
||||
// The CA certificates must be byte-identical.
|
||||
if !bytes.Equal(ca1.CAPEM(), ca2.CAPEM()) {
|
||||
t.Fatal("reloaded CA cert differs from generated CA cert")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCA_SignCSR_CNOverride(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ca := newTestCA(t, dir)
|
||||
|
||||
csrPEM, _ := newTestCSR(t)
|
||||
nodeUUID := "node-11111111-2222-3333-4444-555555555555"
|
||||
|
||||
certPEM, err := ca.SignCSR(csrPEM, nodeUUID)
|
||||
if err != nil {
|
||||
t.Fatalf("SignCSR: %v", err)
|
||||
}
|
||||
|
||||
block, _ := pem.Decode(certPEM)
|
||||
if block == nil {
|
||||
t.Fatal("SignCSR returned empty PEM")
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCertificate: %v", err)
|
||||
}
|
||||
|
||||
// CN must match nodeUUID regardless of what the CSR contained.
|
||||
if cert.Subject.CommonName != nodeUUID {
|
||||
t.Errorf("CN = %q; want %q", cert.Subject.CommonName, nodeUUID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCA_SignCSR_VerifiesAgainstCAPool(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ca := newTestCA(t, dir)
|
||||
|
||||
csrPEM, _ := newTestCSR(t)
|
||||
certPEM, err := ca.SignCSR(csrPEM, "node-test")
|
||||
if err != nil {
|
||||
t.Fatalf("SignCSR: %v", err)
|
||||
}
|
||||
|
||||
block, _ := pem.Decode(certPEM)
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCertificate: %v", err)
|
||||
}
|
||||
|
||||
// Build a pool with only our CA and verify the issued cert against it.
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(ca.CACert())
|
||||
|
||||
_, err = cert.Verify(x509.VerifyOptions{
|
||||
Roots: pool,
|
||||
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("cert.Verify: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCA_SignCSR_RejectsInvalidPEM(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ca := newTestCA(t, dir)
|
||||
|
||||
_, err := ca.SignCSR([]byte("not-a-pem"), "any-uuid")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid CSR PEM, got nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package mtls
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// crlKey is the Redis SET that holds revoked node UUIDs.
|
||||
const crlKey = "mtls:revoked"
|
||||
|
||||
// DB is the minimal persistence interface for revocation records.
|
||||
// In production this is backed by Postgres; in tests a no-op is sufficient.
|
||||
type DB interface {
|
||||
RecordRevocation(ctx context.Context, nodeUUID string) error
|
||||
}
|
||||
|
||||
// CRL manages certificate revocation.
|
||||
//
|
||||
// Redis is the authoritative hot-path store (checked on every TLS handshake).
|
||||
// DB is the durable backing store consulted on restart to re-populate Redis.
|
||||
type CRL struct {
|
||||
redis redis.Cmdable
|
||||
db DB // may be nil in tests
|
||||
}
|
||||
|
||||
// NewCRL creates a CRL manager.
|
||||
// db may be nil; if non-nil, Revoke also persists to it.
|
||||
func NewCRL(r redis.Cmdable, db DB) *CRL {
|
||||
return &CRL{redis: r, db: db}
|
||||
}
|
||||
|
||||
// Revoke marks nodeUUID as revoked.
|
||||
// The UUID is written to the Redis revocation set immediately;
|
||||
// if db is non-nil the record is also persisted there.
|
||||
func (c *CRL) Revoke(ctx context.Context, nodeUUID string) error {
|
||||
if err := c.redis.SAdd(ctx, crlKey, nodeUUID).Err(); err != nil {
|
||||
return fmt.Errorf("mtls: revoke in redis: %w", err)
|
||||
}
|
||||
if c.db != nil {
|
||||
if err := c.db.RecordRevocation(ctx, nodeUUID); err != nil {
|
||||
return fmt.Errorf("mtls: revoke in db: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsRevoked returns true when nodeUUID is in the revocation set.
|
||||
//
|
||||
// On Redis error the function returns true (fail-safe: reject rather than
|
||||
// silently allow a potentially revoked node to connect).
|
||||
func (c *CRL) IsRevoked(nodeUUID string) bool {
|
||||
ctx := context.Background()
|
||||
revoked, err := c.redis.SIsMember(ctx, crlKey, nodeUUID).Result()
|
||||
if err != nil {
|
||||
// Fail-safe: treat transient Redis errors as revoked.
|
||||
return true
|
||||
}
|
||||
return revoked
|
||||
}
|
||||
|
||||
// VerifyPeerCertificate is a tls.Config.VerifyPeerCertificate callback.
|
||||
// It is invoked by the TLS stack after standard chain validation succeeds.
|
||||
// If the leaf certificate's CN corresponds to a revoked node, the handshake
|
||||
// is aborted.
|
||||
//
|
||||
// When no client certificate is presented (e.g. during Enroll) verifiedChains
|
||||
// is empty and this function is a no-op; the identity interceptor handles
|
||||
// the per-method enforcement.
|
||||
func (c *CRL) VerifyPeerCertificate(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
|
||||
for _, chain := range verifiedChains {
|
||||
if len(chain) == 0 {
|
||||
continue
|
||||
}
|
||||
cn := chain[0].Subject.CommonName
|
||||
if cn != "" && c.IsRevoked(cn) {
|
||||
return fmt.Errorf("mtls: certificate revoked for node %q", cn)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package mtls
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"testing"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// noopDB satisfies the DB interface without persisting anything.
|
||||
type noopDB struct{}
|
||||
|
||||
func (noopDB) RecordRevocation(_ context.Context, _ string) error { return nil }
|
||||
|
||||
func newTestCRL(t *testing.T) (*CRL, *miniredis.Miniredis) {
|
||||
t.Helper()
|
||||
mr := miniredis.RunT(t)
|
||||
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() { rdb.Close() })
|
||||
return NewCRL(rdb, noopDB{}), mr
|
||||
}
|
||||
|
||||
func TestCRL_RevokeAndIsRevoked(t *testing.T) {
|
||||
crl, _ := newTestCRL(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const nodeUUID = "node-revoked-uuid"
|
||||
|
||||
if crl.IsRevoked(nodeUUID) {
|
||||
t.Fatal("IsRevoked = true before Revoke; want false")
|
||||
}
|
||||
|
||||
if err := crl.Revoke(ctx, nodeUUID); err != nil {
|
||||
t.Fatalf("Revoke: %v", err)
|
||||
}
|
||||
|
||||
if !crl.IsRevoked(nodeUUID) {
|
||||
t.Fatal("IsRevoked = false after Revoke; want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCRL_IsRevoked_UnknownNode(t *testing.T) {
|
||||
crl, _ := newTestCRL(t)
|
||||
|
||||
if crl.IsRevoked("never-revoked") {
|
||||
t.Fatal("IsRevoked = true for unknown node; want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCRL_VerifyPeerCertificate_Revoked(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ca := newTestCA(t, dir)
|
||||
|
||||
crl, _ := newTestCRL(t)
|
||||
ctx := context.Background()
|
||||
|
||||
nodeUUID := "node-revoked-cert"
|
||||
csrPEM, _ := newTestCSR(t)
|
||||
certPEM, err := ca.SignCSR(csrPEM, nodeUUID)
|
||||
if err != nil {
|
||||
t.Fatalf("SignCSR: %v", err)
|
||||
}
|
||||
|
||||
block, _ := pem.Decode(certPEM)
|
||||
clientCert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCertificate: %v", err)
|
||||
}
|
||||
|
||||
// Build verified chains as the TLS stack would.
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(ca.CACert())
|
||||
chains, err := clientCert.Verify(x509.VerifyOptions{
|
||||
Roots: pool,
|
||||
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("cert.Verify: %v", err)
|
||||
}
|
||||
|
||||
// Before revocation: VerifyPeerCertificate must pass.
|
||||
if err := crl.VerifyPeerCertificate(nil, chains); err != nil {
|
||||
t.Fatalf("VerifyPeerCertificate before revoke: %v", err)
|
||||
}
|
||||
|
||||
// Revoke the node.
|
||||
if err := crl.Revoke(ctx, nodeUUID); err != nil {
|
||||
t.Fatalf("Revoke: %v", err)
|
||||
}
|
||||
|
||||
// After revocation: VerifyPeerCertificate must reject.
|
||||
if err := crl.VerifyPeerCertificate(nil, chains); err == nil {
|
||||
t.Fatal("VerifyPeerCertificate after revoke: expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCRL_VerifyPeerCertificate_NoClientCert(t *testing.T) {
|
||||
crl, _ := newTestCRL(t)
|
||||
|
||||
// Empty verifiedChains = no client cert presented (e.g. during Enroll).
|
||||
if err := crl.VerifyPeerCertificate(nil, nil); err != nil {
|
||||
t.Errorf("VerifyPeerCertificate with no chains: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package mtls
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/peer"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// nodeUUIDKey is the unexported context key for the verified node UUID.
|
||||
type nodeUUIDKey struct{}
|
||||
|
||||
// enrollFullMethod is the one RPC exempt from mTLS enforcement.
|
||||
// The agent has no certificate yet when it calls Enroll; the bootstrap token
|
||||
// provides authentication for that call instead.
|
||||
const enrollFullMethod = "/pangolin.agent.v1.AgentService/Enroll"
|
||||
|
||||
// UnaryServerInterceptor returns a gRPC unary interceptor that enforces mTLS identity.
|
||||
//
|
||||
// For the Enroll method: the call is passed through without a certificate check.
|
||||
// For all other methods: a verified client certificate is required; its CN is
|
||||
// injected into the context as the authoritative node UUID.
|
||||
func UnaryServerInterceptor() grpc.UnaryServerInterceptor {
|
||||
return func(
|
||||
ctx context.Context,
|
||||
req interface{},
|
||||
info *grpc.UnaryServerInfo,
|
||||
handler grpc.UnaryHandler,
|
||||
) (interface{}, error) {
|
||||
if info.FullMethod == enrollFullMethod {
|
||||
return handler(ctx, req)
|
||||
}
|
||||
|
||||
nodeUUID, err := extractNodeUUID(ctx, info.FullMethod)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return handler(context.WithValue(ctx, nodeUUIDKey{}, nodeUUID), req)
|
||||
}
|
||||
}
|
||||
|
||||
// StreamServerInterceptor returns a gRPC stream interceptor that enforces mTLS identity.
|
||||
// Same whitelist logic as the unary interceptor applies.
|
||||
func StreamServerInterceptor() grpc.StreamServerInterceptor {
|
||||
return func(
|
||||
srv interface{},
|
||||
ss grpc.ServerStream,
|
||||
info *grpc.StreamServerInfo,
|
||||
handler grpc.StreamHandler,
|
||||
) error {
|
||||
if info.FullMethod == enrollFullMethod {
|
||||
return handler(srv, ss)
|
||||
}
|
||||
|
||||
nodeUUID, err := extractNodeUUID(ss.Context(), info.FullMethod)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
enriched := &wrappedStream{
|
||||
ServerStream: ss,
|
||||
ctx: context.WithValue(ss.Context(), nodeUUIDKey{}, nodeUUID),
|
||||
}
|
||||
return handler(srv, enriched)
|
||||
}
|
||||
}
|
||||
|
||||
// NodeUUIDFromContext retrieves the verified node UUID that was injected by the
|
||||
// interceptor. Returns ("", false) when the context carries no UUID (e.g. in
|
||||
// Enroll handlers where the identity is not yet established).
|
||||
func NodeUUIDFromContext(ctx context.Context) (string, bool) {
|
||||
v, ok := ctx.Value(nodeUUIDKey{}).(string)
|
||||
return v, ok && v != ""
|
||||
}
|
||||
|
||||
// ─── internals ───────────────────────────────────────────────────────────────
|
||||
|
||||
// extractNodeUUID reads the verified TLS peer certificate's CN from the context.
|
||||
// It returns an Unauthenticated status error when no valid cert is present.
|
||||
func extractNodeUUID(ctx context.Context, method string) (string, error) {
|
||||
p, ok := peer.FromContext(ctx)
|
||||
if !ok {
|
||||
return "", status.Errorf(codes.Unauthenticated,
|
||||
"mtls: no peer info for method %s", method)
|
||||
}
|
||||
|
||||
tlsInfo, ok := p.AuthInfo.(credentials.TLSInfo)
|
||||
if !ok {
|
||||
return "", status.Errorf(codes.Unauthenticated,
|
||||
"mtls: non-TLS connection for method %s", method)
|
||||
}
|
||||
|
||||
chains := tlsInfo.State.VerifiedChains
|
||||
if len(chains) == 0 || len(chains[0]) == 0 {
|
||||
return "", status.Errorf(codes.Unauthenticated,
|
||||
"mtls: client certificate required for method %s", method)
|
||||
}
|
||||
|
||||
cn := chains[0][0].Subject.CommonName
|
||||
if cn == "" {
|
||||
return "", status.Errorf(codes.Unauthenticated,
|
||||
"mtls: empty CN in client certificate for method %s", method)
|
||||
}
|
||||
return cn, nil
|
||||
}
|
||||
|
||||
// wrappedStream overrides the context on a ServerStream.
|
||||
type wrappedStream struct {
|
||||
grpc.ServerStream
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (w *wrappedStream) Context() context.Context {
|
||||
return w.ctx
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package mtls
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/peer"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// contextWithTLSPeer builds a context carrying a gRPC peer whose TLS state
|
||||
// includes the given verified certificate chain (as the TLS stack would set
|
||||
// after a successful mTLS handshake).
|
||||
func contextWithTLSPeer(chains [][]*x509.Certificate) context.Context {
|
||||
state := tls.ConnectionState{VerifiedChains: chains}
|
||||
p := &peer.Peer{AuthInfo: credentials.TLSInfo{State: state}}
|
||||
return peer.NewContext(context.Background(), p)
|
||||
}
|
||||
|
||||
// okHandler is a trivial gRPC handler that returns ("ok", nil).
|
||||
func okHandler(_ context.Context, _ interface{}) (interface{}, error) {
|
||||
return "ok", nil
|
||||
}
|
||||
|
||||
// ─── Unary interceptor ───────────────────────────────────────────────────────
|
||||
|
||||
func TestUnaryInterceptor_EnrollWhitelisted(t *testing.T) {
|
||||
interceptor := UnaryServerInterceptor()
|
||||
|
||||
// Enroll must pass through even without any peer/TLS info.
|
||||
info := &grpc.UnaryServerInfo{FullMethod: enrollFullMethod}
|
||||
_, err := interceptor(context.Background(), nil, info, okHandler)
|
||||
if err != nil {
|
||||
t.Errorf("Enroll should be whitelisted, got error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnaryInterceptor_NoPeer_Unauthenticated(t *testing.T) {
|
||||
interceptor := UnaryServerInterceptor()
|
||||
|
||||
info := &grpc.UnaryServerInfo{FullMethod: "/pangolin.agent.v1.AgentService/Heartbeat"}
|
||||
// Context has no peer at all.
|
||||
_, err := interceptor(context.Background(), nil, info, okHandler)
|
||||
if err == nil {
|
||||
t.Fatal("expected Unauthenticated, got nil")
|
||||
}
|
||||
if code := status.Code(err); code != codes.Unauthenticated {
|
||||
t.Errorf("status code = %v; want Unauthenticated", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnaryInterceptor_NoCert_Unauthenticated(t *testing.T) {
|
||||
interceptor := UnaryServerInterceptor()
|
||||
|
||||
// Peer present but no verified chains (client sent no cert).
|
||||
ctx := contextWithTLSPeer(nil)
|
||||
info := &grpc.UnaryServerInfo{FullMethod: "/pangolin.agent.v1.AgentService/Heartbeat"}
|
||||
_, err := interceptor(ctx, nil, info, okHandler)
|
||||
if err == nil {
|
||||
t.Fatal("expected Unauthenticated with no cert, got nil")
|
||||
}
|
||||
if code := status.Code(err); code != codes.Unauthenticated {
|
||||
t.Errorf("status code = %v; want Unauthenticated", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnaryInterceptor_ValidCert_InjectsUUID(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ca := newTestCA(t, dir)
|
||||
|
||||
csrPEM, _ := newTestCSR(t)
|
||||
nodeUUID := "node-deadbeef-cafe"
|
||||
certPEM, err := ca.SignCSR(csrPEM, nodeUUID)
|
||||
if err != nil {
|
||||
t.Fatalf("SignCSR: %v", err)
|
||||
}
|
||||
|
||||
block, _ := pem.Decode(certPEM)
|
||||
clientCert, _ := x509.ParseCertificate(block.Bytes)
|
||||
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(ca.CACert())
|
||||
chains, err := clientCert.Verify(x509.VerifyOptions{
|
||||
Roots: pool,
|
||||
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("cert.Verify: %v", err)
|
||||
}
|
||||
|
||||
ctx := contextWithTLSPeer(chains)
|
||||
info := &grpc.UnaryServerInfo{FullMethod: "/pangolin.agent.v1.AgentService/Heartbeat"}
|
||||
|
||||
interceptor := UnaryServerInterceptor()
|
||||
var capturedCtx context.Context
|
||||
captureHandler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
capturedCtx = ctx
|
||||
return "ok", nil
|
||||
}
|
||||
|
||||
_, err = interceptor(ctx, nil, info, captureHandler)
|
||||
if err != nil {
|
||||
t.Fatalf("interceptor with valid cert: %v", err)
|
||||
}
|
||||
|
||||
got, ok := NodeUUIDFromContext(capturedCtx)
|
||||
if !ok {
|
||||
t.Fatal("NodeUUIDFromContext: not found in context")
|
||||
}
|
||||
if got != nodeUUID {
|
||||
t.Errorf("NodeUUIDFromContext = %q; want %q", got, nodeUUID)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Stream interceptor (whitelist only) ─────────────────────────────────────
|
||||
|
||||
// mockServerStream is a minimal grpc.ServerStream for testing.
|
||||
type mockServerStream struct {
|
||||
grpc.ServerStream
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (m *mockServerStream) Context() context.Context { return m.ctx }
|
||||
|
||||
func TestStreamInterceptor_EnrollWhitelisted(t *testing.T) {
|
||||
interceptor := StreamServerInterceptor()
|
||||
|
||||
info := &grpc.StreamServerInfo{FullMethod: enrollFullMethod}
|
||||
ss := &mockServerStream{ctx: context.Background()}
|
||||
|
||||
err := interceptor(nil, ss, info, func(_ interface{}, _ grpc.ServerStream) error {
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("Enroll stream should be whitelisted, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamInterceptor_NoCert_Unauthenticated(t *testing.T) {
|
||||
interceptor := StreamServerInterceptor()
|
||||
|
||||
info := &grpc.StreamServerInfo{FullMethod: "/pangolin.agent.v1.AgentService/StreamEvents"}
|
||||
ss := &mockServerStream{ctx: contextWithTLSPeer(nil)}
|
||||
|
||||
err := interceptor(nil, ss, info, func(_ interface{}, _ grpc.ServerStream) error {
|
||||
return nil
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected Unauthenticated, got nil")
|
||||
}
|
||||
if code := status.Code(err); code != codes.Unauthenticated {
|
||||
t.Errorf("status code = %v; want Unauthenticated", code)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── NodeUUIDFromContext ──────────────────────────────────────────────────────
|
||||
|
||||
func TestNodeUUIDFromContext_Empty(t *testing.T) {
|
||||
_, ok := NodeUUIDFromContext(context.Background())
|
||||
if ok {
|
||||
t.Error("NodeUUIDFromContext on bare context should return ok=false")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package mtls
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
)
|
||||
|
||||
// NewServerTLSConfig builds the *tls.Config for the Pangolin gRPC server.
|
||||
//
|
||||
// Single-port strategy:
|
||||
// - ClientAuth = tls.VerifyClientCertIfGiven: agents that have not yet enrolled
|
||||
// complete the TLS handshake without a client cert; the identity interceptor
|
||||
// enforces cert presence for all non-Enroll RPCs at the application layer.
|
||||
// - ClientCAs is set to a pool containing only the Pangolin Node CA, so the TLS
|
||||
// stack will verify any presented certificate against it.
|
||||
// - VerifyPeerCertificate is wired to CRL.VerifyPeerCertificate: if the leaf cert
|
||||
// CN is revoked the handshake fails immediately, before any RPC handler runs.
|
||||
// - Minimum TLS version is 1.3.
|
||||
//
|
||||
// The caller must set cfg.Certificates with the server's own TLS certificate
|
||||
// (typically from Let's Encrypt) before using this config.
|
||||
func NewServerTLSConfig(ca *CA, crl *CRL) *tls.Config {
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(ca.CACert())
|
||||
|
||||
return &tls.Config{
|
||||
ClientAuth: tls.VerifyClientCertIfGiven,
|
||||
ClientCAs: pool,
|
||||
MinVersion: tls.VersionTLS13,
|
||||
VerifyPeerCertificate: crl.VerifyPeerCertificate,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run from the server/ directory to fetch deps and run tests.
|
||||
# Usage: cd server && bash setup.sh
|
||||
set -euo pipefail
|
||||
go mod tidy
|
||||
go test ./internal/mtls/...
|
||||
Reference in New Issue
Block a user