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>
84 lines
2.5 KiB
Go
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
|
|
}
|