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>
108 lines
2.6 KiB
Go
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)
|
|
}
|
|
}
|