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>
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
package mtls
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newTestCA creates a CA in the given temporary directory.
|
||||
func newTestCA(t *testing.T, dir string) *CA {
|
||||
t.Helper()
|
||||
ca, err := NewCA(CAConfig{
|
||||
KeyPath: filepath.Join(dir, "ca.key"),
|
||||
CertPath: filepath.Join(dir, "ca.crt"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewCA: %v", err)
|
||||
}
|
||||
return ca
|
||||
}
|
||||
|
||||
// newTestCSR generates an ECDSA P-256 key and a CSR PEM for testing.
|
||||
func newTestCSR(t *testing.T) ([]byte, *ecdsa.PrivateKey) {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate CSR key: %v", err)
|
||||
}
|
||||
tmpl := &x509.CertificateRequest{
|
||||
Subject: pkix.Name{CommonName: "ignored-by-signer"},
|
||||
}
|
||||
csrDER, err := x509.CreateCertificateRequest(rand.Reader, tmpl, key)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCertificateRequest: %v", err)
|
||||
}
|
||||
csrPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER})
|
||||
return csrPEM, key
|
||||
}
|
||||
|
||||
func TestCA_GenerateAndLoad(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// First call: generate a new CA.
|
||||
ca1 := newTestCA(t, dir)
|
||||
if len(ca1.CAPEM()) == 0 {
|
||||
t.Fatal("CAPEM returned empty")
|
||||
}
|
||||
|
||||
// Second call with same paths: must load from disk without error.
|
||||
ca2, err := NewCA(CAConfig{
|
||||
KeyPath: filepath.Join(dir, "ca.key"),
|
||||
CertPath: filepath.Join(dir, "ca.crt"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewCA reload: %v", err)
|
||||
}
|
||||
|
||||
// The CA certificates must be byte-identical.
|
||||
if !bytes.Equal(ca1.CAPEM(), ca2.CAPEM()) {
|
||||
t.Fatal("reloaded CA cert differs from generated CA cert")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCA_SignCSR_CNOverride(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ca := newTestCA(t, dir)
|
||||
|
||||
csrPEM, _ := newTestCSR(t)
|
||||
nodeUUID := "node-11111111-2222-3333-4444-555555555555"
|
||||
|
||||
certPEM, err := ca.SignCSR(csrPEM, nodeUUID)
|
||||
if err != nil {
|
||||
t.Fatalf("SignCSR: %v", err)
|
||||
}
|
||||
|
||||
block, _ := pem.Decode(certPEM)
|
||||
if block == nil {
|
||||
t.Fatal("SignCSR returned empty PEM")
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCertificate: %v", err)
|
||||
}
|
||||
|
||||
// CN must match nodeUUID regardless of what the CSR contained.
|
||||
if cert.Subject.CommonName != nodeUUID {
|
||||
t.Errorf("CN = %q; want %q", cert.Subject.CommonName, nodeUUID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCA_SignCSR_VerifiesAgainstCAPool(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ca := newTestCA(t, dir)
|
||||
|
||||
csrPEM, _ := newTestCSR(t)
|
||||
certPEM, err := ca.SignCSR(csrPEM, "node-test")
|
||||
if err != nil {
|
||||
t.Fatalf("SignCSR: %v", err)
|
||||
}
|
||||
|
||||
block, _ := pem.Decode(certPEM)
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCertificate: %v", err)
|
||||
}
|
||||
|
||||
// Build a pool with only our CA and verify the issued cert against it.
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(ca.CACert())
|
||||
|
||||
_, err = cert.Verify(x509.VerifyOptions{
|
||||
Roots: pool,
|
||||
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("cert.Verify: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCA_SignCSR_RejectsInvalidPEM(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ca := newTestCA(t, dir)
|
||||
|
||||
_, err := ca.SignCSR([]byte("not-a-pem"), "any-uuid")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid CSR PEM, got nil")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user