From 07f4af817e4cc0513057a343fbd3dd1d90bfe771 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 7 Jun 2026 14:30:15 +0200 Subject: [PATCH] feat(client,membershipd): TLS on the HTTP control plane (H5) 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) --- cmd/membershipd/main.go | 23 +++++++-- pkg/client/client.go | 31 ++++++++++-- pkg/client/control_tls_test.go | 87 ++++++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 9 deletions(-) create mode 100644 pkg/client/control_tls_test.go diff --git a/cmd/membershipd/main.go b/cmd/membershipd/main.go index a7ce3383..629e6993 100644 --- a/cmd/membershipd/main.go +++ b/cmd/membershipd/main.go @@ -6,6 +6,7 @@ package main import ( "context" + "crypto/tls" "flag" "log" "net/http" @@ -139,10 +140,24 @@ func main() { } go func() { - log.Printf("HTTP control-plane API: http://%s", addr) - log.Printf(" health: http://%s/healthz", addr) - if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatalf("http server: %v", err) + var serveErr error + if *tlsCert != "" { + // Serve the control plane over TLS with the same CA-signed cert as the + // data plane (audit H5): metadata (subjects, pubkeys, sealed keys, the + // social graph) is no longer readable by a network MITM. The fail-open + // guard already requires --bus-auth enforce alongside these flags. + httpSrv.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS12} + log.Printf("HTTPS control-plane API: https://%s", addr) + log.Printf(" health: https://%s/healthz", addr) + log.Printf("control-plane TLS: ON (%s)", *tlsCert) + serveErr = httpSrv.ListenAndServeTLS(*tlsCert, *tlsKey) + } else { + log.Printf("HTTP control-plane API: http://%s", addr) + log.Printf(" health: http://%s/healthz", addr) + serveErr = httpSrv.ListenAndServe() + } + if serveErr != nil && serveErr != http.ErrServerClosed { + log.Fatalf("http server: %v", serveErr) } }() diff --git a/pkg/client/client.go b/pkg/client/client.go index 4a7316d0..69de476d 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -24,6 +24,7 @@ import ( "io" "net/http" "strconv" + "strings" "sync" "time" @@ -67,10 +68,15 @@ type Options struct { // with an nkey to a server that does not advertise nkey auth ("nkeys not // supported by the server"), so this is opt-in rather than always-on. UseNkey bool - // TLS, when non-nil, secures the NATS connection and pins the server to this - // config's RootCAs (the bus's self-signed CA). Build it with - // busauth.LoadCATLSConfig(caPath). Nil keeps the connection plaintext. + // TLS, when non-nil, secures the NATS (data plane) connection and pins the + // server to this config's RootCAs (the bus's self-signed CA). Build it with + // busauth.LoadCATLSConfig(caPath). Nil keeps the data plane plaintext. TLS *tls.Config + // CtrlTLS, when non-nil, secures the HTTP control-plane connection and pins it + // to this config's RootCAs. It is separate from TLS so the two planes can be + // secured independently (a test may TLS one and not the other); production + // sets both to the same CA via Connect. Nil keeps the control plane plaintext. + CtrlTLS *tls.Config } // New connects to NATS and records the control-plane URL with default Options @@ -90,11 +96,19 @@ func Connect(natsURL, ctrlURL string, id cs.Identity, caPath string) (*Client, e if caPath == "" { return New(natsURL, ctrlURL, id) } + // A CA implies the bus is TLS on BOTH planes. Refuse a plaintext control-plane + // URL: signing gives integrity, not confidentiality, so sending metadata over + // http:// when the operator provisioned a CA would silently leak it to a MITM + // (audit H5). Force https rather than silently downgrade. + if !strings.HasPrefix(ctrlURL, "https://") { + return nil, fmt.Errorf("client: control-plane URL %q must be https:// when a CA is provided", ctrlURL) + } tlsCfg, err := busauth.LoadCATLSConfig(caPath) if err != nil { return nil, fmt.Errorf("client: load CA %q: %w", caPath, err) } - return NewWithOptions(natsURL, ctrlURL, id, Options{UseNkey: true, TLS: tlsCfg}) + // Pin the same CA on both planes: nkey+TLS on NATS, TLS on the HTTP control plane. + return NewWithOptions(natsURL, ctrlURL, id, Options{UseNkey: true, TLS: tlsCfg, CtrlTLS: tlsCfg}) } // NewWithOptions is New with explicit connection options (nkey auth, and, from @@ -125,13 +139,20 @@ func NewWithOptions(natsURL, ctrlURL string, id cs.Identity, opts Options) (*Cli nc.Close() return nil, fmt.Errorf("client: init jetstream: %w", err) } + // The control-plane HTTP client pins the bus CA when CtrlTLS is set, so an + // https:// control plane is verified against the bus's own CA rather than the + // system roots (audit H5). Without it the client stays plaintext for dev. + httpClient := &http.Client{Timeout: 10 * time.Second} + if opts.CtrlTLS != nil { + httpClient.Transport = &http.Transport{TLSClientConfig: opts.CtrlTLS.Clone()} + } return &Client{ id: id, endpoint: frame.EndpointID(id.SignPub), nc: nc, js: js, ctrlURL: ctrlURL, - http: &http.Client{Timeout: 10 * time.Second}, + http: httpClient, keyCache: map[string]map[int][]byte{}, signCache: map[string][]byte{}, }, nil diff --git a/pkg/client/control_tls_test.go b/pkg/client/control_tls_test.go new file mode 100644 index 00000000..1036fe39 --- /dev/null +++ b/pkg/client/control_tls_test.go @@ -0,0 +1,87 @@ +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") + } +}