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,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
|
||||
}
|
||||
Reference in New Issue
Block a user