diff --git a/cmd/membershipd/config.go b/cmd/membershipd/config.go new file mode 100644 index 0000000..1c1886b --- /dev/null +++ b/cmd/membershipd/config.go @@ -0,0 +1,50 @@ +package main + +import ( + "fmt" + "net" + + "github.com/enmanuel/unibus/pkg/membership" +) + +// isLoopbackBind reports whether the --bind value keeps the service reachable +// only from this host. An empty bind means "all interfaces" (public), and a +// hostname we cannot resolve to a loopback literal is treated as public — the +// conservative choice, so an unusual bind never silently slips past the guard. +func isLoopbackBind(bind string) bool { + switch bind { + case "localhost": + return true + case "": + return false // empty binds every interface + } + ip := net.ParseIP(bind) + if ip == nil { + return false // a hostname we can't classify: assume public + } + return ip.IsLoopback() +} + +// validateBootConfig is the fail-open guard (audit H2). It refuses any startup +// 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 +// - --tls-cert/--tls-key without --bus-auth enforce (TLS encrypts the channel +// but authenticates no one — encrypted access for everybody is still open). +// +// 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. +func validateBootConfig(bind string, mode membership.AuthMode, tlsCert, tlsKey string) error { + if !isLoopbackBind(bind) && mode != membership.AuthEnforce { + return fmt.Errorf( + "refusing to start: --bind %q is not loopback but --bus-auth is %q; a public bind requires --bus-auth enforce (or bind 127.0.0.1 for local dev)", + bind, mode) + } + if (tlsCert != "" || tlsKey != "") && mode != membership.AuthEnforce { + return fmt.Errorf( + "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) + } + return nil +} diff --git a/cmd/membershipd/config_test.go b/cmd/membershipd/config_test.go new file mode 100644 index 0000000..50f4739 --- /dev/null +++ b/cmd/membershipd/config_test.go @@ -0,0 +1,72 @@ +package main + +import ( + "strings" + "testing" + + "github.com/enmanuel/unibus/pkg/membership" +) + +// TestAudit_FailOpenTLSWithoutAuth ports the auditor's H2 vector. Before the +// guard, booting with TLS on but the authenticator off ("--bind 0.0.0.0 +// --tls-cert … " without enforce) produced an encrypted data plane that an +// unregistered, nkey-less client could still connect to — a fail-open config +// wearing the appearance of security. validateBootConfig now refuses it, so the +// insecure server never starts (the client therefore has nothing to connect to). +func TestAudit_FailOpenTLSWithoutAuth(t *testing.T) { + // The exact auditor configuration: public bind, TLS provided, auth off. + err := validateBootConfig("0.0.0.0", membership.AuthOff, "server.crt", "server.key") + if err == nil { + t.Fatalf("TLS without enforce on a public bind must be refused at startup") + } + if !strings.Contains(err.Error(), "enforce") { + t.Fatalf("error should point the operator at --bus-auth enforce, got: %v", err) + } + + // And TLS without enforce is rejected even on loopback: TLS implies a + // security posture, so authenticating no one is always a misconfiguration. + if err := validateBootConfig("127.0.0.1", membership.AuthOff, "server.crt", "server.key"); err == nil { + t.Fatalf("TLS flags without enforce must be refused regardless of bind") + } +} + +// 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) { + cases := []struct { + name string + bind string + mode membership.AuthMode + cert string + key string + wantErr bool + }{ + // Golden: the intended public production config. + {"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}, + // 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: 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}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + err := validateBootConfig(c.bind, c.mode, c.cert, c.key) + if c.wantErr && err == nil { + t.Fatalf("config %+v should be refused", c) + } + if !c.wantErr && err != nil { + t.Fatalf("config %+v should be allowed, got: %v", c, err) + } + }) + } +} diff --git a/cmd/membershipd/main.go b/cmd/membershipd/main.go index f7bdc5d..5bbf76d 100644 --- a/cmd/membershipd/main.go +++ b/cmd/membershipd/main.go @@ -52,6 +52,13 @@ func main() { log.Fatalf("%v", err) } + // Fail-open guard (audit H2): a non-loopback bind, or any TLS flag, demands + // --bus-auth enforce. This makes an insecure public startup impossible rather + // than silently exposing the bus with the appearance of security. + if err := validateBootConfig(*bind, authMode, *tlsCert, *tlsKey); err != nil { + log.Fatalf("%v", err) + } + log.SetFlags(log.LstdFlags | log.Lmsgprefix) log.SetPrefix("[membershipd] ")