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

84 lines
2.5 KiB
Go

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
}