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 }