@opengeni/events 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,335 @@
1
+ // packages/events/src/nats-jwt.ts — NATS JWT v2 signing for the auth-callout
2
+ // responder (bring-your-own-compute M-AUTH; dossier §10.1 NATS Accounts per
3
+ // workspace + §17 the isolation smoke).
4
+ //
5
+ // This is the cryptographic core of the auth-callout tenancy boundary. When an
6
+ // external agent connects to NATS presenting its `oge_` enrollment bearer as the
7
+ // connect auth-token, nats-server (configured with `auth_callout`) issues an
8
+ // authorization request on `$SYS.REQ.USER.AUTH`. Our responder (auth-callout.ts)
9
+ // validates the bearer and answers with a SIGNED authorization-response JWT that
10
+ // embeds a SIGNED user JWT scoping the connection to publish/subscribe ONLY
11
+ // `agent.<workspaceId>.>` (+ the reply `_INBOX.>`). That per-subject permission
12
+ // set IS the per-workspace isolation: workspace A's agent literally cannot
13
+ // pub/sub workspace B's subjects (§19 the NATS-Accounts-misconfig leak risk is
14
+ // closed at the JWT-permission layer, not just by subject naming).
15
+ //
16
+ // WHY HAND-ROLL THE JWT ENCODING (vs a dep): the NATS JWT v2 wire format is small,
17
+ // stable, and fully specified (ADR-26 + nats-io/jwt): a base64url header
18
+ // `{"typ":"JWT","alg":"ed25519-nkey"}`, base64url JSON claims whose `jti` is the
19
+ // base32(SHA-512/256(claims-with-blank-jti)), and an ed25519 nkey signature over
20
+ // `header.payload`. nkeys (re-exported by the `nats` package we already depend on)
21
+ // gives us the ed25519 sign primitive; Node `crypto` gives SHA-512/256. So we own
22
+ // the encoding in a few well-tested functions rather than pull an alpha
23
+ // `@nats-io/jwt` (0.0.x) whose nkeys-version compat is uncertain. No `xkey`
24
+ // encryption is used (the bearer is already an authenticated identity claim and
25
+ // the wire is TLS — encryption is an optional ADR-26 hardening, off here).
26
+ //
27
+ // SECURITY: the account SIGNING SEED never leaves this process and is NEVER logged.
28
+ // Callers pass it as a `string` seed; we `fromSeed` it once per sign. The bearer
29
+ // the responder validates is HMAC-verified elsewhere (verifyEnrollmentBearer); this
30
+ // module only mints the scoped NATS credential once identity is proven.
31
+
32
+ import { createHash } from "node:crypto";
33
+ import { nkeys } from "nats";
34
+
35
+ /** The NATS JWT v2 header — constant for every token we mint (ADR-26 / nats-io/jwt:
36
+ * `TokenTypeJwt="JWT"`, `AlgorithmNkey="ed25519-nkey"`). */
37
+ const JWT_HEADER = { typ: "JWT", alg: "ed25519-nkey" } as const;
38
+
39
+ /** NATS user-claim `nats.type` discriminator + `nats.version` for v2 claims. */
40
+ const USER_CLAIM_TYPE = "user";
41
+ const AUTH_RESPONSE_CLAIM_TYPE = "authorization_response";
42
+ const NATS_CLAIM_VERSION = 2;
43
+
44
+ /** A NATS permission set: subject allow/deny lists (ADR-26 `pub`/`sub` →
45
+ * `allow`/`deny`). An empty/undefined list means "no explicit grant" — combined
46
+ * with the agent scope below, the connection can ONLY reach what `allow` lists. */
47
+ export interface NatsPermission {
48
+ allow?: string[];
49
+ deny?: string[];
50
+ }
51
+
52
+ /** The pub/sub permissions embedded in a user JWT. */
53
+ export interface NatsPermissions {
54
+ pub: NatsPermission;
55
+ sub: NatsPermission;
56
+ }
57
+
58
+ /**
59
+ * The minimal nkey keypair surface this module needs — exactly what
60
+ * `nkeys.fromSeed(seed)` returns. Declared structurally so the module does not
61
+ * leak the `nats` nkeys type through its public signature.
62
+ */
63
+ interface NkeyPair {
64
+ getPublicKey(): string;
65
+ sign(input: Uint8Array): Uint8Array;
66
+ }
67
+
68
+ /** base64url (RawURLEncoding — no padding), matching nats-io/jwt's `serialize`. */
69
+ function base64UrlEncode(bytes: Uint8Array): string {
70
+ return Buffer.from(bytes).toString("base64url");
71
+ }
72
+
73
+ /** RFC 4648 base32 (standard alphabet, NO padding) — the encoding nats-io/jwt
74
+ * uses for the `jti` hash. Node has no built-in base32, so a tiny encoder. */
75
+ const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
76
+ function base32NoPadding(bytes: Uint8Array): string {
77
+ let bits = 0;
78
+ let value = 0;
79
+ let out = "";
80
+ for (const byte of bytes) {
81
+ value = (value << 8) | byte;
82
+ bits += 8;
83
+ while (bits >= 5) {
84
+ bits -= 5;
85
+ out += BASE32_ALPHABET[(value >>> bits) & 31];
86
+ }
87
+ }
88
+ if (bits > 0) {
89
+ out += BASE32_ALPHABET[(value << (5 - bits)) & 31];
90
+ }
91
+ return out;
92
+ }
93
+
94
+ /**
95
+ * Compute the canonical NATS `jti`: base32(NoPadding, std-alphabet) of the
96
+ * SHA-512/256 of the claims object SERIALIZED WITH AN EMPTY `jti` (nats-io/jwt's
97
+ * `hash`). nats-server recomputes + verifies this on decode, so it must match
98
+ * byte-for-byte. We serialize the SAME object we will sign, only with `jti:""`.
99
+ */
100
+ function computeJti(claimsWithBlankJti: object): string {
101
+ const json = JSON.stringify(claimsWithBlankJti);
102
+ const digest = createHash("sha512-256").update(json, "utf8").digest();
103
+ return base32NoPadding(digest);
104
+ }
105
+
106
+ /**
107
+ * Encode + sign a NATS v2 JWT. The `claims` MUST already carry `iss`/`sub`/`iat`
108
+ * (+ optional `aud`/`exp`) and a `nats` block; this function fills `jti` (the
109
+ * canonical hash), serializes `header.payload`, signs that with `signingKey`, and
110
+ * appends the base64url signature. Returns the compact `header.payload.signature`.
111
+ */
112
+ function encodeJwt(claims: Record<string, unknown>, signingKey: NkeyPair): string {
113
+ // jti is the hash of the claims with jti blanked — set it blank, hash, then set.
114
+ const withBlankJti = { ...claims, jti: "" };
115
+ const jti = computeJti(withBlankJti);
116
+ const finalClaims = { ...claims, jti };
117
+
118
+ const header = base64UrlEncode(Buffer.from(JSON.stringify(JWT_HEADER), "utf8"));
119
+ const payload = base64UrlEncode(Buffer.from(JSON.stringify(finalClaims), "utf8"));
120
+ const signingInput = `${header}.${payload}`;
121
+ const signature = signingKey.sign(Buffer.from(signingInput, "utf8"));
122
+ return `${signingInput}.${base64UrlEncode(signature)}`;
123
+ }
124
+
125
+ /**
126
+ * Input to mint a workspace-scoped NATS user JWT for an enrolled agent.
127
+ * - `userPublicKey` — the `user_nkey` from the authorization request; it MUST be
128
+ * the `sub` of the user JWT (nats-server rejects a mismatch).
129
+ * - `accountSeed` — the callout account SIGNING seed (`SA...`); both the user JWT
130
+ * `iss` (its public key) and the signature come from it. NEVER logged.
131
+ * - `name` — a human label for the user (the agent id), for server logs.
132
+ * - `permissions` — the pub/sub allow/deny lists (the workspace scope).
133
+ * - `expiresAtSeconds` — optional absolute `exp` (unix seconds). When set the
134
+ * server will expire the connection's credential; we tie it to the bearer's
135
+ * remaining life so a revoked/expired enrollment cannot outlive its bearer.
136
+ */
137
+ export interface MintUserJwtInput {
138
+ userPublicKey: string;
139
+ accountSeed: string;
140
+ name: string;
141
+ permissions: NatsPermissions;
142
+ /** The target account NAME (the `auth_callout.account`) the user binds to; the
143
+ * embedded user JWT's `aud` in server-config mode. */
144
+ audienceAccount: string;
145
+ expiresAtSeconds?: number;
146
+ }
147
+
148
+ /**
149
+ * Mint a signed NATS user JWT scoped by `permissions`. In auth-callout SERVER
150
+ * mode the user JWT is signed by the callout ISSUER ACCOUNT key, and its `iss` is
151
+ * that account's public key. The returned JWT is embedded as `nats.jwt` in the
152
+ * authorization response.
153
+ */
154
+ export function mintUserJwt(input: MintUserJwtInput): string {
155
+ const accountKey = nkeys.fromSeed(Buffer.from(input.accountSeed)) as unknown as NkeyPair;
156
+ const accountPublicKey = accountKey.getPublicKey();
157
+ const nowSeconds = Math.floor(Date.now() / 1000);
158
+
159
+ const natsBlock: Record<string, unknown> = {
160
+ type: USER_CLAIM_TYPE,
161
+ version: NATS_CLAIM_VERSION,
162
+ pub: input.permissions.pub,
163
+ sub: input.permissions.sub,
164
+ // Unlimited subscriptions / data / payload (the workspace subject scope, NOT
165
+ // a connection-resource quota, is the boundary here).
166
+ subs: -1,
167
+ data: -1,
168
+ payload: -1,
169
+ };
170
+
171
+ const claims: Record<string, unknown> = {
172
+ jti: "",
173
+ iat: nowSeconds,
174
+ iss: accountPublicKey,
175
+ name: input.name,
176
+ sub: input.userPublicKey,
177
+ // SERVER-config-mode placement: nats-server reads the embedded user JWT's `aud`
178
+ // as the target account NAME (the configured `auth_callout.account`). This is
179
+ // how the authenticated user binds to that account; the workspace isolation is
180
+ // then carried by the pub/sub permissions below.
181
+ aud: input.audienceAccount,
182
+ nats: natsBlock,
183
+ };
184
+ if (typeof input.expiresAtSeconds === "number") {
185
+ claims.exp = input.expiresAtSeconds;
186
+ }
187
+ return encodeJwt(claims, accountKey);
188
+ }
189
+
190
+ /**
191
+ * Input to mint the authorization RESPONSE JWT the responder publishes back on the
192
+ * request's reply subject (ADR-26 §3).
193
+ * - `userPublicKey` — the request's `user_nkey`; the response `sub`.
194
+ * - `serverId` — the request's `nats.server_id.id` (the server's public key); the
195
+ * response `aud`.
196
+ * - `accountSeed` — the callout account signing seed; signs the response and is
197
+ * its `iss` (public key). NEVER logged.
198
+ * - `userJwt` — the embedded signed user JWT (omit on a denial).
199
+ * - `error` — a human-readable denial message (omit on success). When present the
200
+ * server denies the connection.
201
+ */
202
+ export interface MintAuthResponseInput {
203
+ userPublicKey: string;
204
+ serverId: string;
205
+ accountSeed: string;
206
+ userJwt?: string;
207
+ error?: string;
208
+ }
209
+
210
+ /**
211
+ * Mint the signed authorization-response JWT. On success it carries the embedded
212
+ * user JWT (`nats.jwt`); on denial it carries `nats.error` and NO user JWT, which
213
+ * makes nats-server refuse the connection. Signed by the callout account key (its
214
+ * public key is `iss`); `sub` is the user_nkey, `aud` is the server id.
215
+ */
216
+ export function mintAuthResponse(input: MintAuthResponseInput): string {
217
+ const accountKey = nkeys.fromSeed(Buffer.from(input.accountSeed)) as unknown as NkeyPair;
218
+ const accountPublicKey = accountKey.getPublicKey();
219
+ const nowSeconds = Math.floor(Date.now() / 1000);
220
+
221
+ const natsBlock: Record<string, unknown> = {
222
+ type: AUTH_RESPONSE_CLAIM_TYPE,
223
+ version: NATS_CLAIM_VERSION,
224
+ };
225
+ if (input.userJwt) {
226
+ natsBlock.jwt = input.userJwt;
227
+ }
228
+ if (input.error) {
229
+ natsBlock.error = input.error;
230
+ }
231
+
232
+ const claims: Record<string, unknown> = {
233
+ jti: "",
234
+ iat: nowSeconds,
235
+ iss: accountPublicKey,
236
+ // The response `aud` MUST be the SERVER public key in server-config mode
237
+ // (nats-server validates "Audience must be a server public key"). The
238
+ // authenticated user is placed into the configured `auth_callout.account` (the
239
+ // SAME account the responder + the privileged control plane connect into), so
240
+ // `agent.<ws>.<id>.rpc` request/reply routes; the workspace isolation is carried
241
+ // entirely by the user JWT's pub/sub subject permissions (NOT by cross-account
242
+ // placement, which server-config-mode nats does not support — nats-io#4335).
243
+ aud: input.serverId,
244
+ sub: input.userPublicKey,
245
+ nats: natsBlock,
246
+ };
247
+ return encodeJwt(claims, accountKey);
248
+ }
249
+
250
+ /**
251
+ * The fields the responder needs out of the authorization REQUEST JWT (ADR-26 §2).
252
+ * The request is itself a NATS JWT (`header.payload.signature`) the server signs;
253
+ * we only DECODE it (the server proves its own identity by the connection, and the
254
+ * embedded `auth_token` is independently HMAC-verified), so we read the payload
255
+ * without re-verifying the server signature.
256
+ */
257
+ export interface DecodedAuthRequest {
258
+ /** The public user nkey the response user JWT MUST be `sub`-scoped to. */
259
+ userNkey: string;
260
+ /** The server's public id — the response `aud`. */
261
+ serverId: string;
262
+ /** The connect `auth_token` the client presented (our `oge_` bearer), if any. */
263
+ authToken: string | undefined;
264
+ /** The connect username, if any (unused today; present for completeness). */
265
+ user: string | undefined;
266
+ }
267
+
268
+ /**
269
+ * Decode the authorization-request JWT payload (the middle base64url segment). The
270
+ * request shape (ADR-26 §2): `nats.user_nkey`, `nats.server_id.id`, and the
271
+ * presented connect options under `nats.connect_opts` (`auth_token` / `user`).
272
+ * Returns null on a malformed token so the caller can deny cleanly.
273
+ */
274
+ export function decodeAuthRequest(token: string): DecodedAuthRequest | null {
275
+ const parts = token.split(".");
276
+ if (parts.length !== 3) {
277
+ return null;
278
+ }
279
+ let payload: unknown;
280
+ try {
281
+ payload = JSON.parse(Buffer.from(parts[1]!, "base64url").toString("utf8"));
282
+ } catch {
283
+ return null;
284
+ }
285
+ if (typeof payload !== "object" || payload === null) {
286
+ return null;
287
+ }
288
+ const nats = (payload as { nats?: unknown }).nats;
289
+ if (typeof nats !== "object" || nats === null) {
290
+ return null;
291
+ }
292
+ const natsObj = nats as {
293
+ user_nkey?: unknown;
294
+ server_id?: { id?: unknown } | unknown;
295
+ connect_opts?: { auth_token?: unknown; user?: unknown } | unknown;
296
+ };
297
+ const userNkey = typeof natsObj.user_nkey === "string" ? natsObj.user_nkey : null;
298
+ if (!userNkey) {
299
+ return null;
300
+ }
301
+ const serverIdRaw =
302
+ typeof natsObj.server_id === "object" && natsObj.server_id !== null
303
+ ? (natsObj.server_id as { id?: unknown }).id
304
+ : undefined;
305
+ const serverId = typeof serverIdRaw === "string" ? serverIdRaw : "";
306
+ const connectOpts =
307
+ typeof natsObj.connect_opts === "object" && natsObj.connect_opts !== null
308
+ ? (natsObj.connect_opts as { auth_token?: unknown; user?: unknown })
309
+ : {};
310
+ const authToken = typeof connectOpts.auth_token === "string" ? connectOpts.auth_token : undefined;
311
+ const user = typeof connectOpts.user === "string" ? connectOpts.user : undefined;
312
+ return { userNkey, serverId, authToken, user };
313
+ }
314
+
315
+ /**
316
+ * Build the workspace-scoped permission set for an agent: it may publish + subscribe
317
+ * ONLY `agent.<workspaceId>.>` (its own RPC/event/hello subtree) and the reply
318
+ * `_INBOX.>` subtree (so request/reply round-trips work). Everything else is
319
+ * implicitly denied (an allow-list with no other entries IS the deny-all-else).
320
+ *
321
+ * THE isolation assertion (§17): with `workspaceId=A`, the returned allow lists name
322
+ * only `agent.A.>` — so a connection bearing this credential is rejected by
323
+ * nats-server the instant it tries to pub/sub `agent.B.>`. This is the per-workspace
324
+ * tenancy boundary, enforced cryptographically by the signed JWT, not by naming.
325
+ */
326
+ export function workspaceAgentPermissions(workspaceId: string): NatsPermissions {
327
+ const agentScope = `agent.${workspaceId}.>`;
328
+ // The reply-inbox subtree must be reachable for request/reply (the control plane
329
+ // requests on agent.<ws>.<id>.rpc with a reply inbox; the agent responds there).
330
+ const inboxScope = "_INBOX.>";
331
+ return {
332
+ pub: { allow: [agentScope, inboxScope] },
333
+ sub: { allow: [agentScope, inboxScope] },
334
+ };
335
+ }