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). func (a *nkeyAuthenticator) Check(c server.ClientAuthentication) bool { signPubHex, ok := verifyNkey(c) if !ok { return false } return a.isAuthorized(signPubHex) } // verifyNkey performs the shared nkey verification: it checks the client's // signature against the server-presented nonce and returns the lowercase-hex // Ed25519 public key behind the nkey. ok is false on any malformed input or // failed verification (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 verifyNkey(c server.ClientAuthentication) (signPubHex string, ok 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 signPubHex, true } // PermissionsFunc maps a connecting identity (lowercase-hex Ed25519 signing key) // to the NATS permissions it should be granted for this connection. Returning an // error denies the connection (fail closed). It is how the data plane enforces // per-subject access from room membership (issue 0003e, audit H4 residual). type PermissionsFunc func(signPubHex string) (*server.Permissions, error) // nkeyAuthenticatorACL is the nkey authenticator that ALSO scopes the connection // to per-subject permissions derived from room membership. NATS evaluates // permissions once, at connect time, so a peer that joins a room after // connecting must reconnect (client.RefreshSession) to gain that room's subject // — the dynamic-membership reconnection model the audit deferred to this issue. type nkeyAuthenticatorACL struct { isAuthorized func(signPubHex string) bool perms PermissionsFunc // internalPubHex is the lowercase-hex Ed25519 public key of membershipd's own // ephemeral internal service identity. A connection that proves that key is // granted full permissions WITHOUT consulting the allowlist, so the service can // bootstrap and manage JetStream (the replicated nonce bucket and, when // decentralized, the control-plane KV buckets) against its own embedded server // even while the data plane confines every client to its rooms. Empty disables // the internal-identity path entirely (behavior identical to a plain ACL // authenticator). internalPubHex string } // NewNkeyAuthenticatorACL builds an authenticator that authorizes by the bus // allowlist AND registers per-subject permissions from perms. A registered but // permission-less peer can no longer subscribe to or publish on arbitrary // subjects: it is confined to the subjects of the rooms it belongs to (plus the // client infrastructure subjects perms includes). This is the per-subject ACL // the 0004 hardening left as a residual. func NewNkeyAuthenticatorACL(isAuthorized func(signPubHex string) bool, perms PermissionsFunc) server.Authentication { return &nkeyAuthenticatorACL{isAuthorized: isAuthorized, perms: perms} } // NewNkeyAuthenticatorACLInternal is NewNkeyAuthenticatorACL that also recognizes // membershipd's internal service identity (internalPubHex, the lowercase hex of // its ephemeral Ed25519 public key): a connection proving that key is granted // full permissions without an allowlist lookup, so the service can create and // manage JetStream against its own embedded server under enforce (issue 0006a/c — // the replicated nonce bucket and the control-plane KV). Every other identity // goes through the allowlist + per-subject ACL unchanged. An empty internalPubHex // is identical to NewNkeyAuthenticatorACL, so this is a superset and safe to use // everywhere the plain constructor was used. func NewNkeyAuthenticatorACLInternal(isAuthorized func(signPubHex string) bool, perms PermissionsFunc, internalPubHex string) server.Authentication { return &nkeyAuthenticatorACL{isAuthorized: isAuthorized, perms: perms, internalPubHex: internalPubHex} } // fullPermissions grants publish and subscribe on every subject (">"). It is the // permission set for membershipd's own internal service connection, which must // manage the JetStream control plane (nonce bucket + KV buckets) over NATS. It is // NEVER granted to a bus user — only to the process's own ephemeral internal // identity, recognized by exact public-key match in Check. func fullPermissions() *server.Permissions { sp := &server.SubjectPermission{Allow: []string{">"}} return &server.Permissions{Publish: sp, Subscribe: sp} } // Check verifies the nkey, authorizes against the allowlist, then derives and // registers the connection's subject permissions. A permissions-derivation // error denies the connection (fail closed) rather than granting open access. func (a *nkeyAuthenticatorACL) Check(c server.ClientAuthentication) bool { signPubHex, ok := verifyNkey(c) if !ok { return false } // membershipd's own internal service identity bypasses the allowlist and is // granted full permissions so the service can bootstrap JetStream under // enforce. The key is matched exactly against the cryptographically verified // connecting key, so no other identity can claim these permissions. if a.internalPubHex != "" && signPubHex == a.internalPubHex { c.RegisterUser(&server.User{Permissions: fullPermissions()}) return true } if !a.isAuthorized(signPubHex) { return false } perms, err := a.perms(signPubHex) if err != nil { return false // fail closed: never grant open access on a derivation error } c.RegisterUser(&server.User{Permissions: perms}) return true }