diff --git a/pkg/busauth/authenticator.go b/pkg/busauth/authenticator.go new file mode 100644 index 0000000..3de74a0 --- /dev/null +++ b/pkg/busauth/authenticator.go @@ -0,0 +1,57 @@ +package busauth + +import ( + "encoding/base64" + + server "github.com/nats-io/nats-server/v2/server" + "github.com/nats-io/nkeys" +) + +// nkeyAuthenticator is a NATS server.Authentication that authorizes a client by +// verifying the nkey signature over the server-presented nonce and then +// consulting the bus user allowlist. Authorization is checked on every new +// connection via the injected predicate (not a static Options.Nkeys map), so +// revoking a user denies its next connection without restarting the server. +type nkeyAuthenticator struct { + // isAuthorized reports whether the lowercase-hex Ed25519 public key behind an + // nkey belongs to an active bus user. Injected (membership.Store.IsAuthorized) + // so this package stays free of the store dependency. + isAuthorized func(signPubHex string) bool +} + +// NewNkeyAuthenticator builds a NATS custom authenticator backed by isAuthorized. +// Pass it to embeddednats so the data plane only accepts registered identities. +func NewNkeyAuthenticator(isAuthorized func(signPubHex string) bool) server.Authentication { + return &nkeyAuthenticator{isAuthorized: isAuthorized} +} + +// Check verifies the client's nkey signature against the nonce the server +// presented, then maps the nkey to its allowlist key and checks authorization. +// Any malformed input or failed verification yields false (fail closed). The +// signature decoding mirrors nats-server's own (raw-url base64, then std base64 +// fallback) so genuine clients using nats.Nkey are accepted unchanged. +func (a *nkeyAuthenticator) Check(c server.ClientAuthentication) bool { + opts := c.GetOpts() + if opts.Nkey == "" { + return false + } + sig, err := base64.RawURLEncoding.DecodeString(opts.Sig) + if err != nil { + sig, err = base64.StdEncoding.DecodeString(opts.Sig) + if err != nil { + return false + } + } + pub, err := nkeys.FromPublicKey(opts.Nkey) + if err != nil { + return false + } + if err := pub.Verify(c.GetNonce(), sig); err != nil { + return false + } + signPubHex, err := SignPubHexFromNkey(opts.Nkey) + if err != nil { + return false + } + return a.isAuthorized(signPubHex) +} diff --git a/pkg/embeddednats/embeddednats.go b/pkg/embeddednats/embeddednats.go index 1116b97..7291fda 100644 --- a/pkg/embeddednats/embeddednats.go +++ b/pkg/embeddednats/embeddednats.go @@ -30,6 +30,16 @@ func Start(storeDir string, port int) (*server.Server, error) { // to expose it to the LAN so remote peers (phones, other PCs) can connect. An // empty host falls back to the nats-server default ("0.0.0.0", all interfaces). func StartHost(storeDir, host string, port int) (*server.Server, error) { + return StartHostAuth(storeDir, host, port, nil) +} + +// StartHostAuth is StartHost with an optional custom client authenticator. When +// auth is non-nil it is installed as Options.CustomClientAuthentication, so the +// data plane only accepts clients the authenticator approves (nkey signature + +// bus allowlist). When auth is nil the server accepts any client (the legacy, +// network-trusted behavior) — used by dev stacks and tests that have not enabled +// bus auth. +func StartHostAuth(storeDir, host string, port int, auth server.Authentication) (*server.Server, error) { opts := &server.Options{ JetStream: true, StoreDir: storeDir, @@ -40,6 +50,14 @@ func StartHost(storeDir, host string, port int) (*server.Server, error) { NoLog: true, NoSigs: true, } + if auth != nil { + opts.CustomClientAuthentication = auth + // A CustomClientAuthentication alone does not make the server advertise a + // nonce in its INFO line, and nats.go refuses to connect with an nkey to a + // server that does not ("nkeys not supported by the server"). Forcing the + // nonce makes nkey clients sign the challenge our authenticator verifies. + opts.AlwaysEnableNonce = true + } ns, err := server.NewServer(opts) if err != nil {