From 75939a192c2861b7a4bb7af93387c7fab4acf194 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 7 Jun 2026 12:44:13 +0200 Subject: [PATCH] test: TLS data plane end to end + CA/keypair loaders client/tls_test: mints a throwaway CA + server cert in-memory; a client pinning the CA completes the handshake and operates (golden), a client without the CA fails the handshake (error path). busauth/tls_test: golden load of a CA PEM and a server keypair, plus error paths (missing file, non-PEM). Harness body extracted to bootHarness(ctrlMode, natsAuth, natsTLS). Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/busauth/tls_test.go | 95 ++++++++++++++++++++++++++++++++ pkg/client/client_test.go | 22 ++++++-- pkg/client/tls_test.go | 113 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 224 insertions(+), 6 deletions(-) create mode 100644 pkg/busauth/tls_test.go create mode 100644 pkg/client/tls_test.go diff --git a/pkg/busauth/tls_test.go b/pkg/busauth/tls_test.go new file mode 100644 index 00000000..0b94fccf --- /dev/null +++ b/pkg/busauth/tls_test.go @@ -0,0 +1,95 @@ +package busauth + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "testing" + "time" +) + +// writeSelfSigned writes a self-signed cert + key PEM pair to dir and returns +// their paths. It is enough to exercise both LoadCATLSConfig (reads the cert as +// a CA) and ServerTLSConfig (reads the cert+key as a server keypair). +func writeSelfSigned(t *testing.T, dir string) (certPath, keyPath string) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("key: %v", err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "unibus-tls-test"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + IsCA: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + BasicConstraintsValid: true, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatalf("cert: %v", err) + } + certPath = filepath.Join(dir, "cert.pem") + keyPath = filepath.Join(dir, "key.pem") + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + if err := os.WriteFile(certPath, certPEM, 0o644); err != nil { + t.Fatalf("write cert: %v", err) + } + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + t.Fatalf("marshal key: %v", err) + } + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil { + t.Fatalf("write key: %v", err) + } + return certPath, keyPath +} + +// Golden: a valid CA PEM loads into a config with a non-empty RootCAs pool, and +// a valid keypair loads into a config presenting one certificate. +func TestLoadTLSConfigsGolden(t *testing.T) { + dir := t.TempDir() + certPath, keyPath := writeSelfSigned(t, dir) + + caCfg, err := LoadCATLSConfig(certPath) + if err != nil { + t.Fatalf("LoadCATLSConfig: %v", err) + } + if caCfg.RootCAs == nil { + t.Fatalf("expected a populated RootCAs pool") + } + + srvCfg, err := ServerTLSConfig(certPath, keyPath) + if err != nil { + t.Fatalf("ServerTLSConfig: %v", err) + } + if len(srvCfg.Certificates) != 1 { + t.Fatalf("expected exactly one server certificate, got %d", len(srvCfg.Certificates)) + } +} + +// Error path: missing file, and a file that is not valid PEM. +func TestLoadTLSConfigsErrors(t *testing.T) { + if _, err := LoadCATLSConfig("/no/such/ca.crt"); err == nil { + t.Fatalf("expected error for missing CA file") + } + dir := t.TempDir() + junk := filepath.Join(dir, "junk.crt") + if err := os.WriteFile(junk, []byte("not a pem"), 0o644); err != nil { + t.Fatalf("write junk: %v", err) + } + if _, err := LoadCATLSConfig(junk); err == nil { + t.Fatalf("expected error for non-PEM CA file") + } + if _, err := ServerTLSConfig("/no/such/server.crt", "/no/such/server.key"); err == nil { + t.Fatalf("expected error for missing server keypair") + } +} diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go index 00d199cb..8a4745c8 100644 --- a/pkg/client/client_test.go +++ b/pkg/client/client_test.go @@ -1,6 +1,7 @@ package client_test import ( + "crypto/tls" "encoding/hex" "net" "net/http" @@ -59,6 +60,12 @@ func newHarnessMode(t *testing.T, mode membership.AuthMode) *testHarness { // flags compose. The store is created before NATS so the authenticator can // consult IsAuthorized for live revocation. func newHarnessFull(t *testing.T, ctrlMode membership.AuthMode, natsAuth bool) *testHarness { + return bootHarness(t, ctrlMode, natsAuth, nil) +} + +// bootHarness is the shared body: a store, an embedded NATS (optionally with the +// nkey authenticator and/or TLS), and the membershipd HTTP server in ctrlMode. +func bootHarness(t *testing.T, ctrlMode membership.AuthMode, natsAuth bool, natsTLS *tls.Config) *testHarness { t.Helper() dir := t.TempDir() @@ -67,13 +74,16 @@ func newHarnessFull(t *testing.T, ctrlMode membership.AuthMode, natsAuth bool) * t.Fatalf("membership store: %v", err) } - var ns *server.Server - if natsAuth { - ns, err = embeddednats.StartHostAuth(filepath.Join(dir, "js"), "127.0.0.1", freePort(t), - busauth.NewNkeyAuthenticator(store.IsAuthorized)) - } else { - ns, err = embeddednats.Start(filepath.Join(dir, "js"), freePort(t)) + cfg := embeddednats.ServerConfig{ + StoreDir: filepath.Join(dir, "js"), + Host: "127.0.0.1", + Port: freePort(t), + TLS: natsTLS, } + if natsAuth { + cfg.Auth = busauth.NewNkeyAuthenticator(store.IsAuthorized) + } + ns, err := embeddednats.StartServer(cfg) if err != nil { store.Close() t.Fatalf("embedded nats: %v", err) diff --git a/pkg/client/tls_test.go b/pkg/client/tls_test.go new file mode 100644 index 00000000..236168a7 --- /dev/null +++ b/pkg/client/tls_test.go @@ -0,0 +1,113 @@ +package client_test + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "net" + "testing" + "time" + + "github.com/enmanuel/unibus/pkg/client" + "github.com/enmanuel/unibus/pkg/membership" + "github.com/enmanuel/unibus/pkg/room" +) + +// genTestCA mints a throwaway self-signed CA plus a server certificate (SAN +// 127.0.0.1 / localhost) signed by it, mirroring deploy/tls/generate-certs.sh +// without shelling out to openssl. It returns the server's *tls.Config (cert it +// presents) and the CA pool a client must trust to complete the handshake. +func genTestCA(t *testing.T) (server *tls.Config, caPool *x509.CertPool) { + t.Helper() + + // --- CA --- + caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("ca key: %v", err) + } + caTmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "unibus-test-ca"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + IsCA: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + BasicConstraintsValid: true, + } + caDER, err := x509.CreateCertificate(rand.Reader, caTmpl, caTmpl, &caKey.PublicKey, caKey) + if err != nil { + t.Fatalf("ca cert: %v", err) + } + caCert, err := x509.ParseCertificate(caDER) + if err != nil { + t.Fatalf("parse ca: %v", err) + } + + // --- server cert signed by the CA --- + srvKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("server key: %v", err) + } + srvTmpl := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "unibus-test-server"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + DNSNames: []string{"localhost"}, + IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)}, + } + srvDER, err := x509.CreateCertificate(rand.Reader, srvTmpl, caCert, &srvKey.PublicKey, caKey) + if err != nil { + t.Fatalf("server cert: %v", err) + } + + srvCertPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: srvDER}) + srvKeyDER, err := x509.MarshalECPrivateKey(srvKey) + if err != nil { + t.Fatalf("marshal server key: %v", err) + } + srvKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: srvKeyDER}) + srvPair, err := tls.X509KeyPair(srvCertPEM, srvKeyPEM) + if err != nil { + t.Fatalf("server keypair: %v", err) + } + + pool := x509.NewCertPool() + pool.AddCert(caCert) + return &tls.Config{Certificates: []tls.Certificate{srvPair}, MinVersion: tls.VersionTLS12}, pool +} + +// TestNatsTLS validates the TLS data plane: a client trusting the bus CA +// completes the handshake and uses the bus (golden); a client that does NOT +// trust the CA fails the handshake (error path). +func TestNatsTLS(t *testing.T) { + serverTLS, caPool := genTestCA(t) + h := bootHarness(t, membership.AuthOff, false, serverTLS) + waitHealth(t, h.ctrlURL) + + // Golden: client pinning the CA connects over TLS and operates. + clientTLS := &tls.Config{RootCAs: caPool, MinVersion: tls.VersionTLS12} + a, err := client.NewWithOptions(h.natsURL, h.ctrlURL, mustIdentity(t), client.Options{TLS: clientTLS}) + if err != nil { + t.Fatalf("client trusting the CA should complete the TLS handshake: %v", err) + } + defer a.Close() + if _, err := a.CreateRoom("room.tls", room.ModeNATS); err != nil { + t.Fatalf("TLS client should operate on the bus: %v", err) + } + + // Error path: a client that does not trust the CA fails the handshake. Use an + // empty pool (system roots would also reject this private CA, but an empty + // pool makes the intent explicit and avoids depending on the host's roots). + badTLS := &tls.Config{RootCAs: x509.NewCertPool(), MinVersion: tls.VersionTLS12} + if _, err := client.NewWithOptions(h.natsURL, h.ctrlURL, mustIdentity(t), client.Options{TLS: badTLS}); err == nil { + t.Fatalf("client without the CA must fail the TLS handshake") + } +}