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) <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user