Merge issue/0004e-control-tls: TLS on the HTTP control plane (H5)
membershipd serves https with the bus CA cert; the client pins the CA and refuses a plaintext control plane when a CA is provided.
This commit is contained in:
+19
-4
@@ -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)
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
+26
-5
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user