From d01da9d39635da20d38bf605ebdf66ba6d8dbaf9 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 7 Jun 2026 16:11:45 +0200 Subject: [PATCH] fix(0005d): require TLS on a public bind (close N4 plaintext control plane) The H2 guard refused "public bind without enforce" and "TLS flags without enforce", but it still ALLOWED a public bind with enforce and no --tls-cert: the control plane then served metadata (subjects, pubkeys, sealed keys, the social graph) over plaintext HTTP publicly, so audit H5 reappeared as the N4 gap (TLS was a capability, not a requirement; report 0006). Fix: validateBootConfig now also refuses a non-loopback --bind unless both --tls-cert and --tls-key are set. Public deployments must serve HTTPS; loopback dev is unaffected (no TLS still allowed there). Verification (cmd/membershipd/config_test.go): - TestGap_PublicEnforceNoTLS: validateBootConfig("0.0.0.0", enforce, "", "") now returns an error mentioning --tls-cert (golden public+enforce+TLS allowed; edge loopback-without-TLS still allowed). - TestBootConfigPolicy table updated: public+enforce+notls / +certonly / +keyonly and lan-ip+enforce+notls are now refused; public+enforce+tls and loopback+enforce+tls allowed. - CGO_ENABLED=0 go build ./... && go vet ./... && go test -count=1 ./... green. Refs: report 0006 N4, issue 0005d. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/membershipd/config.go | 12 ++++++++++-- cmd/membershipd/config_test.go | 35 ++++++++++++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/cmd/membershipd/config.go b/cmd/membershipd/config.go index a593382..6842597 100644 --- a/cmd/membershipd/config.go +++ b/cmd/membershipd/config.go @@ -43,9 +43,12 @@ func isLoopbackBind(bind string) bool { // configuration that would expose the bus without enforced authentication: // // - a non-loopback --bind without --bus-auth enforce (the data plane and -// control plane would both accept anyone), and +// control plane would both accept anyone), // - --tls-cert/--tls-key without --bus-auth enforce (TLS encrypts the channel -// but authenticates no one — encrypted access for everybody is still open). +// but authenticates no one — encrypted access for everybody is still open), and +// - a non-loopback --bind WITHOUT --tls-cert/--tls-key (the control plane would +// serve metadata over plaintext HTTP publicly — audit H5 reappearing, the N4 +// gap the re-audit found: TLS was available but not mandatory). // // It is a pure function of the parsed flags so the command can fail fast at // startup and tests can assert the policy without booting a server. @@ -60,6 +63,11 @@ func validateBootConfig(bind string, mode membership.AuthMode, tlsCert, tlsKey s "refusing to start: --tls-cert/--tls-key set but --bus-auth is %q; TLS without enforced auth is fail-open (encrypted channel, no authentication) — set --bus-auth enforce", mode) } + if !isLoopbackBind(bind) && (tlsCert == "" || tlsKey == "") { + return fmt.Errorf( + "refusing to start: --bind %q is not loopback but --tls-cert/--tls-key are not both set; a public control plane must serve HTTPS or its metadata (subjects, pubkeys, sealed keys, the social graph) travels in cleartext to a network MITM (audit H5/N4) — provide a CA-signed --tls-cert/--tls-key, or bind 127.0.0.1 for local dev", + bind) + } return nil } diff --git a/cmd/membershipd/config_test.go b/cmd/membershipd/config_test.go index 8adbb1c..6791b7b 100644 --- a/cmd/membershipd/config_test.go +++ b/cmd/membershipd/config_test.go @@ -30,6 +30,31 @@ func TestAudit_FailOpenTLSWithoutAuth(t *testing.T) { } } +// TestGap_PublicEnforceNoTLS ports the re-auditor's N4 gap: the H2 guard refused +// "public without enforce" and "TLS without enforce", but ALLOWED a public bind +// with enforce and NO --tls-cert, so the control plane served metadata over +// plaintext HTTP publicly (H5 reappearing). The guard now refuses it. +func TestGap_PublicEnforceNoTLS(t *testing.T) { + // The exact auditor configuration: public bind, enforce on, no TLS cert/key. + err := validateBootConfig("0.0.0.0", membership.AuthEnforce, "", "") + if err == nil { + t.Fatalf("public bind + enforce + NO --tls-cert must be refused: the control plane would serve plaintext HTTP publicly (audit N4)") + } + if !strings.Contains(err.Error(), "tls-cert") { + t.Fatalf("error should point the operator at --tls-cert/--tls-key, got: %v", err) + } + + // Golden: the same public+enforce config WITH a cert/key is allowed. + if err := validateBootConfig("0.0.0.0", membership.AuthEnforce, "server.crt", "server.key"); err != nil { + t.Fatalf("public + enforce + TLS is the intended production config, got: %v", err) + } + + // Edge: loopback without TLS stays allowed (local dev is not a public exposure). + if err := validateBootConfig("127.0.0.1", membership.AuthOff, "", ""); err != nil { + t.Fatalf("loopback dev without TLS must remain allowed, got: %v", err) + } +} + // TestBootConfigPolicy is the full table: the golden secure-public config is // allowed, dev loopback is allowed, and every fail-open shape is refused. func TestBootConfigPolicy(t *testing.T) { @@ -41,19 +66,25 @@ func TestBootConfigPolicy(t *testing.T) { key string wantErr bool }{ - // Golden: the intended public production config. + // Golden: the intended public production config — enforce AND TLS. {"public+enforce+tls", "0.0.0.0", membership.AuthEnforce, "s.crt", "s.key", false}, - {"public+enforce+notls", "0.0.0.0", membership.AuthEnforce, "", "", false}, // Edge: local dev on loopback may stay open (no auth, no TLS). {"loopback+off", "127.0.0.1", membership.AuthOff, "", "", false}, {"loopback-ipv6+off", "::1", membership.AuthOff, "", "", false}, {"localhost+off", "localhost", membership.AuthOff, "", "", false}, {"loopback+soft", "127.0.0.1", membership.AuthSoft, "", "", false}, + // Edge: loopback with full enforce+TLS is also fine. + {"loopback+enforce+tls", "127.0.0.1", membership.AuthEnforce, "s.crt", "s.key", false}, // Error: public bind without enforce. {"public+off", "0.0.0.0", membership.AuthOff, "", "", true}, {"public+soft", "0.0.0.0", membership.AuthSoft, "", "", true}, {"lan-ip+off", "192.168.1.10", membership.AuthOff, "", "", true}, {"empty-bind+off", "", membership.AuthOff, "", "", true}, + // Error (N4): public bind + enforce but NO TLS -> plaintext control plane. + {"public+enforce+notls", "0.0.0.0", membership.AuthEnforce, "", "", true}, + {"public+enforce+certonly", "0.0.0.0", membership.AuthEnforce, "s.crt", "", true}, + {"public+enforce+keyonly", "0.0.0.0", membership.AuthEnforce, "", "s.key", true}, + {"lan-ip+enforce+notls", "192.168.1.10", membership.AuthEnforce, "", "", true}, // Error: TLS flags without enforce (cert or key alone is enough to trip it). {"loopback+tlscert+off", "127.0.0.1", membership.AuthOff, "s.crt", "", true}, {"loopback+tlskey+soft", "127.0.0.1", membership.AuthSoft, "", "s.key", true},