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>
100 lines
2.5 KiB
Go
100 lines
2.5 KiB
Go
package mtls
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/alicebob/miniredis/v2"
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
func newTestBootstrapManager(t *testing.T) (*BootstrapTokenManager, *miniredis.Miniredis) {
|
|
t.Helper()
|
|
mr := miniredis.RunT(t)
|
|
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
|
t.Cleanup(func() { rdb.Close() })
|
|
return NewBootstrapTokenManager(rdb), mr
|
|
}
|
|
|
|
func TestBootstrap_IssueToken_Format(t *testing.T) {
|
|
mgr, _ := newTestBootstrapManager(t)
|
|
|
|
token, err := mgr.IssueToken(context.Background(), "node-abc")
|
|
if err != nil {
|
|
t.Fatalf("IssueToken: %v", err)
|
|
}
|
|
if len(token) != 64 {
|
|
t.Errorf("token length = %d; want 64 hex chars", len(token))
|
|
}
|
|
if strings.ContainsAny(token, "ghijklmnopqrstuvwxyz !@#") {
|
|
t.Errorf("token contains non-hex chars: %q", token)
|
|
}
|
|
}
|
|
|
|
func TestBootstrap_ConsumeToken_OnceOnly(t *testing.T) {
|
|
mgr, _ := newTestBootstrapManager(t)
|
|
ctx := context.Background()
|
|
|
|
const nodeUUID = "node-11111111-2222-3333-4444-555555555555"
|
|
|
|
token, err := mgr.IssueToken(ctx, nodeUUID)
|
|
if err != nil {
|
|
t.Fatalf("IssueToken: %v", err)
|
|
}
|
|
|
|
// First consume: must succeed.
|
|
got, err := mgr.ConsumeToken(ctx, token)
|
|
if err != nil {
|
|
t.Fatalf("ConsumeToken (first): %v", err)
|
|
}
|
|
if got != nodeUUID {
|
|
t.Errorf("ConsumeToken returned %q; want %q", got, nodeUUID)
|
|
}
|
|
|
|
// Second consume: must fail (one-time guarantee).
|
|
_, err = mgr.ConsumeToken(ctx, token)
|
|
if err == nil {
|
|
t.Fatal("ConsumeToken (second): expected error for already-consumed token, got nil")
|
|
}
|
|
}
|
|
|
|
func TestBootstrap_ConsumeToken_NeverIssued(t *testing.T) {
|
|
mgr, _ := newTestBootstrapManager(t)
|
|
|
|
_, err := mgr.ConsumeToken(context.Background(), "deadbeefdeadbeef")
|
|
if err == nil {
|
|
t.Fatal("expected error for non-existent token, got nil")
|
|
}
|
|
}
|
|
|
|
func TestBootstrap_ConsumeToken_ExpiredTTL(t *testing.T) {
|
|
mgr, mr := newTestBootstrapManager(t)
|
|
ctx := context.Background()
|
|
|
|
token, err := mgr.IssueToken(ctx, "node-expired")
|
|
if err != nil {
|
|
t.Fatalf("IssueToken: %v", err)
|
|
}
|
|
|
|
// Advance miniredis clock past the 15-minute TTL.
|
|
mr.FastForward(bootstrapTokenTTL + time.Second)
|
|
|
|
_, err = mgr.ConsumeToken(ctx, token)
|
|
if err == nil {
|
|
t.Fatal("expected error after TTL expiry, got nil")
|
|
}
|
|
}
|
|
|
|
func TestBootstrap_IssueToken_Uniqueness(t *testing.T) {
|
|
mgr, _ := newTestBootstrapManager(t)
|
|
ctx := context.Background()
|
|
|
|
t1, _ := mgr.IssueToken(ctx, "node-A")
|
|
t2, _ := mgr.IssueToken(ctx, "node-B")
|
|
if t1 == t2 {
|
|
t.Error("IssueToken returned duplicate tokens for different nodes")
|
|
}
|
|
}
|