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},