@sproutboat/runtime 0.34.2 → 0.34.4

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,333 @@
1
+ import assert from "node:assert/strict";
2
+ import { createHmac } from "node:crypto";
3
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join, resolve } from "node:path";
6
+ import { ensurePorffor } from "../../toolchain/src/acquire";
7
+ import { preludePath, TRANSPORT_MARKER, transportPath, wrapNativeFetchHandler } from "../src/wrap";
8
+
9
+ // The pieces of a JWT check (base64url decode, TextEncoder, HMAC verify) against values that do
10
+ // not come from the runtime under test: RFC 4231 and RFC 7515 vectors, the jwt.io example, and
11
+ // Bun / node:crypto on seeded random inputs.
12
+
13
+ let seed = 0x2545f491;
14
+ const rand = (n: number): number => {
15
+ seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
16
+ return seed % n;
17
+ };
18
+ const randomBytes = (n: number): Uint8Array => Uint8Array.from({ length: n }, () => rand(256));
19
+ const toB64Url = (b: Uint8Array): string => Buffer.from(b).toString("base64url");
20
+
21
+ // atob: valid, padded and unpadded, with whitespace, and the inputs it must reject.
22
+ const atobInputs: string[] = [];
23
+ for (let n = 0; n <= 40; n++) {
24
+ const padded = Buffer.from(randomBytes(n)).toString("base64");
25
+ atobInputs.push(padded, padded.replace(/=+$/, ""), padded.replace(/(.{3})/, "$1 \n"), ` ${padded}\t`);
26
+ }
27
+ atobInputs.push(
28
+ Buffer.from(randomBytes(5000)).toString("base64"),
29
+ Buffer.from(randomBytes(769)).toString("base64"),
30
+ "a",
31
+ "abcde",
32
+ "ab$c",
33
+ "=abc",
34
+ "ab=c",
35
+ "a===",
36
+ "====",
37
+ "==",
38
+ "abc==",
39
+ "YQ=",
40
+ "YQ===",
41
+ "YQ==YQ==",
42
+ " ",
43
+ "\n",
44
+ "",
45
+ "-_",
46
+ "abéd",
47
+ "abŁd",
48
+ "QUJDé",
49
+ "Ł",
50
+ "YWJj\0",
51
+ );
52
+
53
+ // TextEncoder: each length, every kind of unit, lone surrogates in every position.
54
+ const pieces = ["a", "Z", "é", "ÿ", "Ā", "߿", "ࠀ", "东", "🚤", "\ud800", "\udc00", "\udbff", "\udfff", "�", "\0"];
55
+ const encodeInputs: string[] = [];
56
+ for (let n = 0; n <= 60; n++) encodeInputs.push(Array.from({ length: n }, () => pieces[rand(pieces.length)]).join(""));
57
+ encodeInputs.push(
58
+ "\ud83d",
59
+ "\ud83dx",
60
+ "x\udea4",
61
+ "\udea4\ud83d",
62
+ "\ud83d🚤",
63
+ "🚤\udea4",
64
+ "é".repeat(3000),
65
+ "🚤".repeat(5000),
66
+ "x".repeat(70000),
67
+ );
68
+
69
+ // HMAC: every key and message length around the SHA-256 and SHA-512 block sizes, high bytes included.
70
+ const hmacCases: { key: number[]; data: number[]; hash: string }[] = [];
71
+ for (const [klen, dlen] of [
72
+ [0, 0],
73
+ [1, 1],
74
+ [20, 8],
75
+ [32, 55],
76
+ [64, 56],
77
+ [64, 64],
78
+ [65, 119],
79
+ [131, 54],
80
+ [200, 300],
81
+ [16, 1000],
82
+ ]) {
83
+ for (const hash of ["SHA-256", "SHA-384", "SHA-512"]) {
84
+ hmacCases.push({ key: Array.from(randomBytes(klen)), data: Array.from(randomBytes(dlen)), hash });
85
+ }
86
+ }
87
+ const nodeHash = (h: string): string => h.replace("-", "").toLowerCase();
88
+ const hexOf = (b: Uint8Array | Buffer): string => Buffer.from(b).toString("hex");
89
+
90
+ // Published vectors.
91
+ const rfc4231 = [
92
+ {
93
+ key: "0b".repeat(20),
94
+ data: Buffer.from("Hi There").toString("hex"),
95
+ mac: "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7",
96
+ },
97
+ {
98
+ key: Buffer.from("Jefe").toString("hex"),
99
+ data: Buffer.from("what do ya want for nothing?").toString("hex"),
100
+ mac: "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843",
101
+ },
102
+ {
103
+ key: "aa".repeat(131),
104
+ data: Buffer.from("Test Using Larger Than Block-Size Key - Hash Key First").toString("hex"),
105
+ mac: "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54",
106
+ },
107
+ ];
108
+ const rfc7515Key = "AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow";
109
+ const rfc7515 =
110
+ "eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
111
+ const jwtIo =
112
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
113
+ const jwtIoKey = "your-256-bit-secret";
114
+
115
+ const handler = `
116
+ const codes = (s) => Array.from(s, (c) => c.charCodeAt(0));
117
+ const hex = (buffer) => {
118
+ const bytes = new Uint8Array(buffer);
119
+ let out = "";
120
+ for (let i = 0; i < bytes.length; i++) out += (bytes[i] + 0x100).toString(16).slice(1);
121
+ return out;
122
+ };
123
+ const fromHex = (h) => {
124
+ const out = new Uint8Array(h.length / 2);
125
+ for (let i = 0; i < out.length; i++) out[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16);
126
+ return out;
127
+ };
128
+ const b64urlBytes = (s) => {
129
+ let b = s.replace(/-/g, "+").replace(/_/g, "/");
130
+ while (b.length % 4) b += "=";
131
+ const bin = atob(b);
132
+ const out = new Uint8Array(bin.length);
133
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
134
+ return out;
135
+ };
136
+ const hmacKey = (bytes, hash, usages) => crypto.subtle.importKey("raw", bytes, { name: "HMAC", hash }, false, usages || ["sign", "verify"]);
137
+ // The check an API server writes: split, decode the signature, verify over the signing input.
138
+ const verifyJwt = async (token, keyBytes) => {
139
+ const dot = token.lastIndexOf(".");
140
+ const key = await hmacKey(keyBytes, "SHA-256", ["verify"]);
141
+ let sig;
142
+ try { sig = b64urlBytes(token.slice(dot + 1)); } catch (e) { return "bad-base64:" + e.name; }
143
+ return crypto.subtle.verify("HMAC", key, sig, new TextEncoder().encode(token.slice(0, dot)));
144
+ };
145
+ export default {
146
+ async fetch() {
147
+ const input = JSON.parse(${JSON.stringify(JSON.stringify({ atobInputs, encodeInputs, hmacCases, rfc4231, rfc7515Key, rfc7515, jwtIo, jwtIoKey }))});
148
+ const out = {};
149
+ out.atob = input.atobInputs.map((s) => { try { return codes(atob(s)); } catch (e) { return e.name; } });
150
+ // The same strings as UTF-16 storage: built from a JSON escape, so not literals.
151
+ out.atobWide = input.atobInputs.slice(0, 12).map((s) => { const w = JSON.parse(JSON.stringify(s + "\\u0141")); try { return codes(atob(w.slice(0, -1))); } catch (e) { return e.name; } });
152
+ out.encode = input.encodeInputs.map((s) => Array.from(new TextEncoder().encode(s)));
153
+ out.encodeWide = input.encodeInputs.slice(0, 12).map((s) => Array.from(new TextEncoder().encode(JSON.parse(JSON.stringify(s)))));
154
+ out.hmac = [];
155
+ for (const c of input.hmacCases) {
156
+ const key = await hmacKey(Uint8Array.from(c.key), c.hash);
157
+ const mac = await crypto.subtle.sign("HMAC", key, Uint8Array.from(c.data));
158
+ const ok = await crypto.subtle.verify("HMAC", key, mac, Uint8Array.from(c.data));
159
+ const bad = await crypto.subtle.verify("HMAC", key, mac, Uint8Array.from(c.data.concat([0])));
160
+ out.hmac.push({ mac: hex(mac), ok, bad });
161
+ }
162
+ out.rfc4231 = [];
163
+ for (const v of input.rfc4231) {
164
+ const key = await hmacKey(fromHex(v.key), "SHA-256");
165
+ out.rfc4231.push(hex(await crypto.subtle.sign("HMAC", key, fromHex(v.data))));
166
+ }
167
+ const sigOf = (token) => token.slice(token.lastIndexOf(".") + 1);
168
+ const flip = (token, at) => token.slice(0, at) + (token[at] === "A" ? "B" : "A") + token.slice(at + 1);
169
+ const k1 = b64urlBytes(input.rfc7515Key);
170
+ const k2 = new TextEncoder().encode(input.jwtIoKey);
171
+ out.jwt = {
172
+ rfc7515: await verifyJwt(input.rfc7515, k1),
173
+ jwtIo: await verifyJwt(input.jwtIo, k2),
174
+ wrongKey: await verifyJwt(input.jwtIo, new TextEncoder().encode("your-256-bit-secreT")),
175
+ emptyKey: await verifyJwt(input.jwtIo, new Uint8Array(0)),
176
+ tamperedSignature: await verifyJwt(flip(input.jwtIo, input.jwtIo.length - 3), k2),
177
+ tamperedPayload: await verifyJwt(flip(input.jwtIo, 40), k2),
178
+ tamperedHeader: await verifyJwt(flip(input.jwtIo, 5), k2),
179
+ shortSignature: await verifyJwt(input.jwtIo.slice(0, -4), k2),
180
+ longSignature: await verifyJwt(input.jwtIo + "AAAA", k2),
181
+ noSignature: await verifyJwt(input.jwtIo.slice(0, input.jwtIo.lastIndexOf(".") + 1), k2),
182
+ badBase64url: await verifyJwt(input.jwtIo.slice(0, -2) + "\\u0021\\u0021", k2),
183
+ badBase64urlLength: await verifyJwt(input.jwtIo.slice(0, -3), k2),
184
+ };
185
+ // Typed-array windows and ArrayBuffers as verify input, not only whole Uint8Arrays.
186
+ const key = await hmacKey(k2, "SHA-256", ["verify"]);
187
+ const signingInput = new TextEncoder().encode(input.jwtIo.slice(0, input.jwtIo.lastIndexOf(".")));
188
+ const sig = b64urlBytes(sigOf(input.jwtIo));
189
+ const padded = new Uint8Array(signingInput.length + 7);
190
+ padded.set(signingInput, 3);
191
+ const sigPadded = new Uint8Array(sig.length + 5);
192
+ sigPadded.set(sig, 2);
193
+ out.views = {
194
+ arrayBuffer: await crypto.subtle.verify("HMAC", key, sig.buffer, signingInput.buffer),
195
+ window: await crypto.subtle.verify("HMAC", key, sigPadded.subarray(2, 34), padded.subarray(3, 3 + signingInput.length)),
196
+ wrongWindow: await crypto.subtle.verify("HMAC", key, sigPadded.subarray(1, 33), padded.subarray(3, 3 + signingInput.length)),
197
+ string: await crypto.subtle.verify("HMAC", key, sig, input.jwtIo.slice(0, input.jwtIo.lastIndexOf("."))),
198
+ };
199
+ out.keyInfo = { type: key.type, extractable: key.extractable, usages: key.usages, algorithm: key.algorithm };
200
+ return Response.json(out);
201
+ },
202
+ };`;
203
+
204
+ function freePort(): number {
205
+ const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
206
+ const port = listener.port;
207
+ listener.stop(true);
208
+ return port;
209
+ }
210
+
211
+ const codes = (s: string): number[] => Array.from(s, (c) => c.charCodeAt(0));
212
+ const expectedAtob = (s: string): number[] | string => {
213
+ try {
214
+ return codes(atob(s));
215
+ } catch (e) {
216
+ return e instanceof Error ? e.name : "unknown";
217
+ }
218
+ };
219
+ const expected = {
220
+ atob: atobInputs.map(expectedAtob),
221
+ atobWide: atobInputs.slice(0, 12).map((s) => expectedAtob(s)),
222
+ encode: encodeInputs.map((s) => Array.from(new TextEncoder().encode(s))),
223
+ encodeWide: encodeInputs.slice(0, 12).map((s) => Array.from(new TextEncoder().encode(s))),
224
+ hmac: hmacCases.map((c) => ({
225
+ mac: hexOf(createHmac(nodeHash(c.hash), Buffer.from(c.key)).update(Buffer.from(c.data)).digest()),
226
+ ok: true,
227
+ bad: false,
228
+ })),
229
+ rfc4231: rfc4231.map((v) => v.mac),
230
+ jwt: {
231
+ rfc7515: true,
232
+ jwtIo: true,
233
+ wrongKey: false,
234
+ emptyKey: false,
235
+ tamperedSignature: false,
236
+ tamperedPayload: false,
237
+ tamperedHeader: false,
238
+ shortSignature: false,
239
+ longSignature: false,
240
+ noSignature: false,
241
+ badBase64url: "bad-base64:InvalidCharacterError",
242
+ badBase64urlLength: false,
243
+ },
244
+ views: { arrayBuffer: true, window: true, wrongWindow: false, string: true },
245
+ keyInfo: {
246
+ type: "secret",
247
+ extractable: false,
248
+ usages: ["verify"],
249
+ algorithm: { name: "HMAC", hash: { name: "SHA-256" }, length: 152 },
250
+ },
251
+ };
252
+ // The published vectors must hold in Bun too, or the test itself is wrong.
253
+ for (const v of rfc4231)
254
+ assert.equal(
255
+ hexOf(createHmac("sha256", Buffer.from(v.key, "hex")).update(Buffer.from(v.data, "hex")).digest()),
256
+ v.mac,
257
+ );
258
+ assert.equal(
259
+ toB64Url(
260
+ createHmac("sha256", Buffer.from(rfc7515Key, "base64url"))
261
+ .update(rfc7515.slice(0, rfc7515.lastIndexOf(".")))
262
+ .digest(),
263
+ ),
264
+ rfc7515.slice(rfc7515.lastIndexOf(".") + 1),
265
+ );
266
+ assert.equal(
267
+ toB64Url(
268
+ createHmac("sha256", jwtIoKey)
269
+ .update(jwtIo.slice(0, jwtIo.lastIndexOf(".")))
270
+ .digest(),
271
+ ),
272
+ jwtIo.slice(jwtIo.lastIndexOf(".") + 1),
273
+ );
274
+
275
+ const workdir = await mkdtemp(join(tmpdir(), "sb-native-jwt-"));
276
+ let server: ReturnType<typeof Bun.spawn> | undefined;
277
+ try {
278
+ const porffor = await ensurePorffor();
279
+ const node = Bun.which("node");
280
+ if (!node) throw new Error("native jwt test needs node on PATH");
281
+
282
+ const [core, broker] = await Promise.all([readFile(preludePath, "utf8"), readFile(transportPath("broker"), "utf8")]);
283
+ assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
284
+ const prelude = core.replace(TRANSPORT_MARKER, broker);
285
+ const generated = join(workdir, "jwt.generated.js");
286
+ const binary = join(workdir, "jwt-native");
287
+ await writeFile(generated, wrapNativeFetchHandler(handler, prelude));
288
+
289
+ const compile = Bun.spawn(
290
+ [node, resolve(porffor, "runtime/index.js"), "native", generated, "-o", binary, "-s", "-O0"],
291
+ {
292
+ cwd: workdir,
293
+ env: process.env,
294
+ stdout: "pipe",
295
+ stderr: "pipe",
296
+ },
297
+ );
298
+ const [exitCode, stdout, stderr] = await Promise.all([
299
+ compile.exited,
300
+ new Response(compile.stdout).text(),
301
+ new Response(compile.stderr).text(),
302
+ ]);
303
+ assert.equal(exitCode, 0, `Porffor native compile failed:\n${stderr}\n${stdout}`);
304
+
305
+ const port = freePort();
306
+ server = Bun.spawn([binary], {
307
+ cwd: workdir,
308
+ env: { ...process.env, PORT: String(port) },
309
+ stdout: "ignore",
310
+ stderr: "inherit",
311
+ });
312
+ let response: Response | undefined;
313
+ for (let attempt = 0; attempt < 100; attempt++) {
314
+ try {
315
+ response = await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(20000) });
316
+ break;
317
+ } catch {
318
+ if (server.exitCode !== null) break;
319
+ await Bun.sleep(100);
320
+ }
321
+ }
322
+ assert(response, "compiled jwt handler did not start");
323
+ assert.equal(response.status, 200);
324
+ const actual: typeof expected = await response.json();
325
+ assert.deepEqual(actual, expected);
326
+ console.log("Native JWT pieces (atob, TextEncoder, HMAC verify) match RFC 4231, RFC 7515, jwt.io and Bun");
327
+ } finally {
328
+ if (server) {
329
+ server.kill(9);
330
+ await server.exited;
331
+ }
332
+ await rm(workdir, { recursive: true, force: true });
333
+ }