07f4af817e
Audit H5 (Alto, public). The control plane was signed but plaintext, so a network MITM could read all metadata (subjects, endpoints, public keys, sealed keys, blob hashes, the social graph) and drop requests. Signing gives integrity, not confidentiality. - membershipd serves the control plane over TLS (ListenAndServeTLS, MinVersion 1.2) with the same CA-signed cert as the data plane when --tls-cert is set; the fail-open guard already requires --bus-auth enforce alongside it. - The client gets a separate Options.CtrlTLS so the HTTP client pins the bus CA, independent of the NATS data-plane TLS. Connect now sets both planes' TLS from the one CA and REFUSES a plaintext http:// control-plane URL when a CA is provided, so metadata is never sent in the clear when TLS is expected. Connect's signature is unchanged; callers (worker/chat --ca, mobile NewSession) must pass an https:// control-plane URL when they pass a CA. Documented for the deploy step. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
88 lines
3.2 KiB
Go
88 lines
3.2 KiB
Go
package client_test
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/enmanuel/unibus/pkg/blobstore"
|
|
"github.com/enmanuel/unibus/pkg/client"
|
|
"github.com/enmanuel/unibus/pkg/embeddednats"
|
|
"github.com/enmanuel/unibus/pkg/membership"
|
|
"github.com/enmanuel/unibus/pkg/room"
|
|
)
|
|
|
|
// TestConnectRequiresHTTPSWithCA covers audit H5's client contract: when a CA is
|
|
// provided the control-plane URL must be https://. A signed request gives
|
|
// integrity but not confidentiality, so silently talking http:// to a bus the
|
|
// operator secured with a CA would leak all metadata to a MITM. Connect refuses
|
|
// the plaintext URL outright (error path; the scheme is checked before any
|
|
// network use, so a bogus CA path is irrelevant).
|
|
func TestConnectRequiresHTTPSWithCA(t *testing.T) {
|
|
_, err := client.Connect("nats://127.0.0.1:4222", "http://127.0.0.1:8470", mustIdentity(t), "/nonexistent/ca.crt")
|
|
if err == nil {
|
|
t.Fatalf("Connect with a CA and an http:// control plane must be refused")
|
|
}
|
|
if !strings.Contains(err.Error(), "https") {
|
|
t.Fatalf("error should point the caller at https, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestControlPlaneOverTLS proves the control plane works over TLS pinned to the
|
|
// bus CA (golden) and that a client lacking the CA cannot complete the handshake
|
|
// (error path) — so a network observer can neither read nor inject control-plane
|
|
// traffic. The data plane is left plaintext here to isolate the HTTP-TLS wiring.
|
|
func TestControlPlaneOverTLS(t *testing.T) {
|
|
dir := t.TempDir()
|
|
store, err := membership.Open(filepath.Join(dir, "unibus.db"))
|
|
if err != nil {
|
|
t.Fatalf("store: %v", err)
|
|
}
|
|
t.Cleanup(func() { store.Close() })
|
|
blobs, err := blobstore.New(filepath.Join(dir, "blobs"))
|
|
if err != nil {
|
|
t.Fatalf("blobs: %v", err)
|
|
}
|
|
|
|
ns, err := embeddednats.StartServer(embeddednats.ServerConfig{
|
|
StoreDir: filepath.Join(dir, "js"), Host: "127.0.0.1", Port: freePort(t),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("nats: %v", err)
|
|
}
|
|
t.Cleanup(func() { ns.Shutdown(); ns.WaitForShutdown() })
|
|
natsURL := embeddednats.ClientURL(ns)
|
|
|
|
// An https control plane wrapping the real membership server.
|
|
ts := httptest.NewTLSServer(membership.NewServer(store, blobs, membership.AuthOff))
|
|
t.Cleanup(ts.Close)
|
|
|
|
pool := x509.NewCertPool()
|
|
pool.AddCert(ts.Certificate())
|
|
|
|
// Golden: trusting the control-plane CA, an https control-plane request works.
|
|
good, err := client.NewWithOptions(natsURL, ts.URL, mustIdentity(t),
|
|
client.Options{CtrlTLS: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}})
|
|
if err != nil {
|
|
t.Fatalf("connect with the pinned CA: %v", err)
|
|
}
|
|
defer good.Close()
|
|
if _, err := good.CreateRoom("room.tls.ctrl", room.ModeNATS); err != nil {
|
|
t.Fatalf("control plane over TLS should succeed with the pinned CA: %v", err)
|
|
}
|
|
|
|
// Error path: without the CA the https handshake fails, so the request errors.
|
|
bad, err := client.NewWithOptions(natsURL, ts.URL, mustIdentity(t),
|
|
client.Options{CtrlTLS: &tls.Config{RootCAs: x509.NewCertPool(), MinVersion: tls.VersionTLS12}})
|
|
if err != nil {
|
|
t.Fatalf("nats connect (bad CA case): %v", err)
|
|
}
|
|
defer bad.Close()
|
|
if _, err := bad.CreateRoom("room.tls.fail", room.ModeNATS); err == nil {
|
|
t.Fatalf("a control-plane request without the CA must fail the TLS handshake")
|
|
}
|
|
}
|