Files
pangolin/server/internal/mtls/crl_test.go
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

108 lines
2.6 KiB
Go

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)
}
}