04e27518af
TestSecureBusEndToEnd boots the server with control-plane enforce, NATS nkey auth, and TLS all on; two registered peers connect with nkey+TLS, A creates a Matrix room, invites B, publishes, and B decrypts — proving the three layers compose. This is the headline golden of issue 0001. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
186 lines
6.2 KiB
Go
186 lines
6.2 KiB
Go
package client_test
|
|
|
|
import (
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/hex"
|
|
"encoding/pem"
|
|
"math/big"
|
|
"net"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/enmanuel/unibus/pkg/client"
|
|
"github.com/enmanuel/unibus/pkg/frame"
|
|
"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")
|
|
}
|
|
}
|
|
|
|
// TestSecureBusEndToEnd is the headline golden of issue 0001: with ALL three
|
|
// layers active at once — control-plane request signing (enforce), NATS nkey
|
|
// auth, and TLS — two registered peers run an encrypted room end to end. A
|
|
// creates a Matrix-policy room, invites B, A publishes and B decrypts. This
|
|
// proves the layers compose: signed HTTP control plane + authenticated,
|
|
// encrypted data plane + E2E room content.
|
|
func TestSecureBusEndToEnd(t *testing.T) {
|
|
serverTLS, caPool := genTestCA(t)
|
|
h := bootHarness(t, membership.AuthEnforce, true, serverTLS)
|
|
waitHealth(t, h.ctrlURL)
|
|
|
|
clientTLS := &tls.Config{RootCAs: caPool, MinVersion: tls.VersionTLS12}
|
|
secure := func(t *testing.T, handle string) (*client.Client, membership.AuthMode) {
|
|
id := mustIdentity(t)
|
|
if err := h.store.AddUser(hex.EncodeToString(id.SignPub), handle, membership.RoleMember); err != nil {
|
|
t.Fatalf("register %s: %v", handle, err)
|
|
}
|
|
c, err := client.NewWithOptions(h.natsURL, h.ctrlURL, id, client.Options{UseNkey: true, TLS: clientTLS})
|
|
if err != nil {
|
|
t.Fatalf("connect %s securely: %v", handle, err)
|
|
}
|
|
return c, 0
|
|
}
|
|
|
|
a, _ := secure(t, "alice")
|
|
defer a.Close()
|
|
b, _ := secure(t, "bob")
|
|
defer b.Close()
|
|
|
|
roomID, err := a.CreateRoom("room.secure", room.ModeMatrix)
|
|
if err != nil {
|
|
t.Fatalf("A create encrypted room over secure bus: %v", err)
|
|
}
|
|
if err := a.Invite(roomID, b.Endpoint()); err != nil {
|
|
t.Fatalf("A invite B: %v", err)
|
|
}
|
|
if err := b.Join(roomID); err != nil {
|
|
t.Fatalf("B join: %v", err)
|
|
}
|
|
|
|
var mu sync.Mutex
|
|
var got []string
|
|
sub, err := b.Subscribe(roomID, func(_ frame.Frame, plaintext []byte) {
|
|
mu.Lock()
|
|
got = append(got, string(plaintext))
|
|
mu.Unlock()
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("B subscribe: %v", err)
|
|
}
|
|
defer sub.Unsubscribe()
|
|
time.Sleep(150 * time.Millisecond)
|
|
|
|
const msg = "mensaje sobre bus seguro (auth+TLS+E2E)"
|
|
if err := a.Publish(roomID, []byte(msg)); err != nil {
|
|
t.Fatalf("A publish: %v", err)
|
|
}
|
|
if !waitFor(&mu, &got, func(rs []string) bool {
|
|
for _, r := range rs {
|
|
if r == msg {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}, 2*time.Second) {
|
|
t.Fatalf("B did not receive/decrypt the message over the secured bus; got %v", snapshot(&mu, &got))
|
|
}
|
|
}
|