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) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 14:30:15 +02:00
parent 0d56c3c81d
commit 07f4af817e
3 changed files with 132 additions and 9 deletions
+26 -5
View File
@@ -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