toiljs 0.0.27 → 0.0.28

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.
@@ -1,3 +1,4 @@
1
+ import { type CryptoState } from './crypto.js';
1
2
  export declare class WasmAbortError extends Error {
2
3
  constructor(message: string, fileName: string, line: number, column: number);
3
4
  }
@@ -6,6 +7,7 @@ export interface DispatchState {
6
7
  headers: [string, string][];
7
8
  headerBytes: number;
8
9
  sendfile: string | null;
10
+ crypto: CryptoState;
9
11
  }
10
12
  export declare function freshDispatchState(): DispatchState;
11
13
  export interface MemoryRef {
@@ -1,3 +1,4 @@
1
+ import { buildCryptoImports, freshCryptoState } from './crypto.js';
1
2
  const MAX_TOTAL_HEADERS_BYTES = 64 * 1024;
2
3
  const MAX_HEADER_NAME_LEN = 256;
3
4
  const MAX_HEADER_VALUE_LEN = 8192;
@@ -11,7 +12,7 @@ export class WasmAbortError extends Error {
11
12
  }
12
13
  }
13
14
  export function freshDispatchState() {
14
- return { status: null, headers: [], headerBytes: 0, sendfile: null };
15
+ return { status: null, headers: [], headerBytes: 0, sendfile: null, crypto: freshCryptoState() };
15
16
  }
16
17
  function mem(ref) {
17
18
  if (!ref.memory)
@@ -66,6 +67,7 @@ export function buildHostImports(ref, state) {
66
67
  state.sendfile = readBytes(ref, pathPtr, pathLen).toString('utf8');
67
68
  },
68
69
  thread_spawn: (_startArg) => -1,
70
+ ...buildCryptoImports(ref, state.crypto),
69
71
  },
70
72
  };
71
73
  }
@@ -4,7 +4,12 @@ import { buildHostImports, freshDispatchState } from './host.js';
4
4
  export { WasmAbortError } from './host.js';
5
5
  export const UNHANDLED_HEADER = 'x-toil-unhandled';
6
6
  const WASM_PAGE = 65536;
7
- const PROVIDED_IMPORTS = new Set(['abort', 'set_status', 'set_header', 'respond_file', 'thread_spawn']);
7
+ const PROVIDED_IMPORTS = new Set([
8
+ 'abort', 'set_status', 'set_header', 'respond_file', 'thread_spawn',
9
+ 'crypto.fill_random', 'crypto.random_uuid', 'crypto.take_result', 'crypto.digest',
10
+ 'crypto.import_key', 'crypto.export_key', 'crypto.encrypt', 'crypto.decrypt',
11
+ 'crypto.sign', 'crypto.verify', 'crypto.derive_bits',
12
+ ]);
8
13
  export class WasmServerModule {
9
14
  wasmPath;
10
15
  module = null;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "toiljs",
3
3
  "type": "module",
4
- "version": "0.0.27",
4
+ "version": "0.0.28",
5
5
  "author": "Dacely",
6
6
  "description": "The modern React framework: a file-based React frontend and a ToilScript-compiled WebAssembly backend.",
7
7
  "repository": {
@@ -127,7 +127,7 @@
127
127
  "eslint-plugin-react-refresh": "^0.5.2",
128
128
  "picocolors": "^1.1.1",
129
129
  "sharp": "^0.34.5",
130
- "toilscript": "^0.1.16",
130
+ "toilscript": "^0.1.17",
131
131
  "typescript-eslint": "^8.60.0",
132
132
  "vite": "^8.0.14",
133
133
  "vite-imagetools": "^10.0.0",
@@ -0,0 +1,421 @@
1
+ /**
2
+ * Dev-server mock of the `env.crypto.*` host functions, mirroring the
3
+ * production edge (`toil-backend/src/wasm/host/import_functions/crypto`) and the
4
+ * toilscript std ABI (`std/assembly/crypto/algorithms.ts`). Backed by Node's
5
+ * `crypto`. The dev server intentionally skips the edge's metering, so these
6
+ * charge nothing; results must still be byte-identical to the edge for the
7
+ * common algorithms.
8
+ *
9
+ * Variable-length results use the same two-step pull as the edge: the op
10
+ * returns the length and stashes the bytes; the guest then calls `take_result`.
11
+ *
12
+ * Dev-only limitations: raw-format import of asymmetric keys (EC/Ed25519/
13
+ * X25519) returns the "unsupported" code (-3) here because Node can't import a
14
+ * bare key without DER; use pkcs8/spki in the dev server. The production edge
15
+ * supports raw too. These are catchable guest-side errors, never crashes.
16
+ */
17
+
18
+ import * as nodeCrypto from 'node:crypto';
19
+
20
+ import type { MemoryRef } from './host.js';
21
+
22
+ // --- ABI id tables (must match the std + Rust backend) ----------------------
23
+ const ALG = {
24
+ SHA1: 1, SHA256: 2, SHA384: 3, SHA512: 4, SHA3_256: 5, SHA3_384: 6, SHA3_512: 7,
25
+ AES_GCM: 10, AES_CBC: 11, AES_CTR: 12, AES_KW: 13, HMAC: 20,
26
+ ECDSA: 32, ED25519: 33, ECDH: 50, X25519: 51, HKDF: 52, PBKDF2: 53,
27
+ } as const;
28
+ const FMT = { RAW: 0, PKCS8: 1, SPKI: 2, JWK: 3 } as const;
29
+
30
+ // Recoverable error codes (negative returns).
31
+ const ERR_GENERIC = -1;
32
+ const ERR_UNSUPPORTED = -3;
33
+ const ERR_INVALID_PARAMS = -4;
34
+ const ERR_OPERATION_FAILED = -5;
35
+ const ERR_NOT_EXTRACTABLE = -7;
36
+
37
+ const MAX_OUTPUT = 1024 * 1024;
38
+
39
+ interface KeyEntry {
40
+ /** Raw bytes for symmetric/MAC/KDF keys. */
41
+ raw: Buffer | null;
42
+ /** Node KeyObject for asymmetric keys. */
43
+ keyObject: nodeCrypto.KeyObject | null;
44
+ alg: number;
45
+ hash: number;
46
+ extractable: boolean;
47
+ isPrivate: boolean;
48
+ }
49
+
50
+ export interface CryptoState {
51
+ keys: Map<number, KeyEntry>;
52
+ nextHandle: number;
53
+ lastResult: Buffer | null;
54
+ }
55
+
56
+ export function freshCryptoState(): CryptoState {
57
+ return { keys: new Map(), nextHandle: 1, lastResult: null };
58
+ }
59
+
60
+ function memBuf(ref: MemoryRef): Buffer {
61
+ if (!ref.memory) throw new Error('crypto host import called before memory was bound');
62
+ return Buffer.from(ref.memory.buffer);
63
+ }
64
+
65
+ function readBytes(ref: MemoryRef, ptr: number, len: number): Buffer {
66
+ const m = memBuf(ref);
67
+ if (ptr < 0 || len < 0 || ptr + len > m.length)
68
+ throw new Error(`crypto read out of bounds: ptr=${String(ptr)} len=${String(len)}`);
69
+ // Copy out so later writes/grows can't alias the input.
70
+ return Buffer.from(m.subarray(ptr, ptr + len));
71
+ }
72
+
73
+ function writeBytes(ref: MemoryRef, ptr: number, bytes: Buffer | Uint8Array): void {
74
+ const m = memBuf(ref);
75
+ if (ptr < 0 || ptr + bytes.length > m.length)
76
+ throw new Error(`crypto write out of bounds: ptr=${String(ptr)} len=${String(bytes.length)}`);
77
+ m.set(bytes, ptr);
78
+ }
79
+
80
+ /** Little-endian reader over a packed params buffer (mirrors the Rust ParamReader). */
81
+ class ParamReader {
82
+ private pos = 0;
83
+ constructor(private readonly buf: Buffer) {}
84
+ /** Bounds-check before a read so a malformed buffer throws a controlled
85
+ * error (trap-equivalent, caught by the dispatcher) rather than a raw
86
+ * Node RangeError. */
87
+ private need(n: number): void {
88
+ if (n < 0 || this.pos + n > this.buf.length)
89
+ throw new Error('crypto: malformed params buffer (truncated)');
90
+ }
91
+ readI32(): number {
92
+ this.need(4);
93
+ const v = this.buf.readInt32LE(this.pos);
94
+ this.pos += 4;
95
+ return v;
96
+ }
97
+ readU32(): number {
98
+ this.need(4);
99
+ const v = this.buf.readUInt32LE(this.pos);
100
+ this.pos += 4;
101
+ return v;
102
+ }
103
+ readBlob(): Buffer {
104
+ const n = this.readU32();
105
+ this.need(n);
106
+ const s = Buffer.from(this.buf.subarray(this.pos, this.pos + n));
107
+ this.pos += n;
108
+ return s;
109
+ }
110
+ }
111
+
112
+ function hashName(id: number): string {
113
+ switch (id) {
114
+ case ALG.SHA1: return 'sha1';
115
+ case ALG.SHA256: return 'sha256';
116
+ case ALG.SHA384: return 'sha384';
117
+ case ALG.SHA512: return 'sha512';
118
+ case ALG.SHA3_256: return 'sha3-256';
119
+ case ALG.SHA3_384: return 'sha3-384';
120
+ case ALG.SHA3_512: return 'sha3-512';
121
+ default: throw new Error(`crypto: bad hash id ${String(id)}`);
122
+ }
123
+ }
124
+
125
+ function stash(state: CryptoState, bytes: Buffer): number {
126
+ state.lastResult = bytes;
127
+ return bytes.length;
128
+ }
129
+
130
+ /**
131
+ * Build the `env.crypto.*` import functions. `state.crypto` holds the per-
132
+ * dispatch keystore + result scratch.
133
+ */
134
+ export function buildCryptoImports(
135
+ ref: MemoryRef,
136
+ cs: CryptoState,
137
+ ): Record<string, (...args: number[]) => number | void> {
138
+ return {
139
+ 'crypto.fill_random': (outPtr: number, len: number): void => {
140
+ if (len < 0 || len > MAX_OUTPUT) throw new Error('crypto.fill_random: bad length');
141
+ writeBytes(ref, outPtr, nodeCrypto.randomBytes(len));
142
+ },
143
+
144
+ 'crypto.random_uuid': (outPtr: number): void => {
145
+ writeBytes(ref, outPtr, nodeCrypto.randomBytes(16));
146
+ },
147
+
148
+ 'crypto.take_result': (outPtr: number, outLen: number): number => {
149
+ const r = cs.lastResult;
150
+ if (!r || r.length !== outLen)
151
+ throw new Error('crypto.take_result: length mismatch');
152
+ writeBytes(ref, outPtr, r);
153
+ cs.lastResult = null;
154
+ return r.length;
155
+ },
156
+
157
+ 'crypto.digest': (alg: number, dataPtr: number, dataLen: number): number => {
158
+ const data = readBytes(ref, dataPtr, dataLen);
159
+ try {
160
+ return stash(cs, nodeCrypto.createHash(hashName(alg)).update(data).digest());
161
+ } catch {
162
+ return ERR_UNSUPPORTED;
163
+ }
164
+ },
165
+
166
+ 'crypto.import_key': (
167
+ format: number,
168
+ keyPtr: number,
169
+ keyLen: number,
170
+ paramsPtr: number,
171
+ paramsLen: number,
172
+ extractable: number,
173
+ _usages: number,
174
+ ): number => {
175
+ const key = readBytes(ref, keyPtr, keyLen);
176
+ const pr = new ParamReader(readBytes(ref, paramsPtr, paramsLen));
177
+ const alg = pr.readI32();
178
+ const hash = pr.readI32();
179
+ return importKey(cs, format, alg, hash, pr, key, extractable !== 0);
180
+ },
181
+
182
+ 'crypto.export_key': (format: number, handle: number): number => {
183
+ const e = cs.keys.get(handle);
184
+ if (!e) throw new Error('crypto.export_key: invalid handle');
185
+ if (!e.extractable) return ERR_NOT_EXTRACTABLE;
186
+ try {
187
+ if (e.raw && format === FMT.RAW) return stash(cs, e.raw);
188
+ if (e.keyObject) {
189
+ if (format === FMT.PKCS8 && e.isPrivate)
190
+ return stash(cs, e.keyObject.export({ format: 'der', type: 'pkcs8' }) as Buffer);
191
+ if (format === FMT.SPKI && !e.isPrivate)
192
+ return stash(cs, e.keyObject.export({ format: 'der', type: 'spki' }) as Buffer);
193
+ }
194
+ return ERR_UNSUPPORTED;
195
+ } catch {
196
+ return ERR_OPERATION_FAILED;
197
+ }
198
+ },
199
+
200
+ 'crypto.encrypt': (h: number, pp: number, pl: number, dp: number, dl: number): number =>
201
+ aesOp(cs, ref, true, h, pp, pl, dp, dl),
202
+ 'crypto.decrypt': (h: number, pp: number, pl: number, dp: number, dl: number): number =>
203
+ aesOp(cs, ref, false, h, pp, pl, dp, dl),
204
+
205
+ 'crypto.sign': (h: number, pp: number, pl: number, dp: number, dl: number): number =>
206
+ signOp(cs, ref, h, pp, pl, dp, dl),
207
+
208
+ 'crypto.verify': (
209
+ h: number, pp: number, pl: number, sp: number, sl: number, dp: number, dl: number,
210
+ ): number => verifyOp(cs, ref, h, pp, pl, sp, sl, dp, dl),
211
+
212
+ 'crypto.derive_bits': (h: number, pp: number, pl: number, lengthBits: number): number =>
213
+ deriveBitsOp(cs, ref, h, pp, pl, lengthBits),
214
+ };
215
+ }
216
+
217
+ function importKey(
218
+ cs: CryptoState,
219
+ format: number,
220
+ alg: number,
221
+ hash: number,
222
+ pr: ParamReader,
223
+ key: Buffer,
224
+ extractable: boolean,
225
+ ): number {
226
+ const newEntry = (e: Omit<KeyEntry, 'extractable'>): number => {
227
+ const handle = cs.nextHandle++;
228
+ cs.keys.set(handle, { ...e, extractable });
229
+ return handle;
230
+ };
231
+
232
+ try {
233
+ // Symmetric / MAC / KDF: raw bytes.
234
+ if (
235
+ alg === ALG.AES_GCM || alg === ALG.AES_CBC || alg === ALG.AES_CTR ||
236
+ alg === ALG.AES_KW || alg === ALG.HMAC || alg === ALG.PBKDF2 || alg === ALG.HKDF
237
+ ) {
238
+ if (format !== FMT.RAW) return ERR_UNSUPPORTED;
239
+ return newEntry({ raw: key, keyObject: null, alg, hash, isPrivate: false });
240
+ }
241
+
242
+ // Asymmetric: pkcs8 (private) / spki (public). raw not supported in dev.
243
+ const isPrivate = format === FMT.PKCS8;
244
+ if (format === FMT.PKCS8) {
245
+ const ko = nodeCrypto.createPrivateKey({ key, format: 'der', type: 'pkcs8' });
246
+ return newEntry({ raw: null, keyObject: ko, alg, hash, isPrivate });
247
+ }
248
+ if (format === FMT.SPKI) {
249
+ const ko = nodeCrypto.createPublicKey({ key, format: 'der', type: 'spki' });
250
+ return newEntry({ raw: null, keyObject: ko, alg, hash, isPrivate });
251
+ }
252
+ return ERR_UNSUPPORTED;
253
+ } catch {
254
+ return ERR_INVALID_PARAMS;
255
+ }
256
+ }
257
+
258
+ function aesAlgName(keyLen: number, mode: 'gcm' | 'cbc' | 'ctr'): string {
259
+ const bits = keyLen === 16 ? 128 : keyLen === 32 ? 256 : 0;
260
+ if (!bits) throw new Error('crypto: bad AES key length');
261
+ return `aes-${String(bits)}-${mode}`;
262
+ }
263
+
264
+ function aesOp(
265
+ cs: CryptoState, ref: MemoryRef, encrypt: boolean,
266
+ handle: number, pp: number, pl: number, dp: number, dl: number,
267
+ ): number {
268
+ const e = cs.keys.get(handle);
269
+ if (!e || !e.raw) throw new Error('crypto: invalid AES key handle');
270
+ const pr = new ParamReader(readBytes(ref, pp, pl));
271
+ const alg = pr.readI32();
272
+ pr.readI32(); // hash (unused)
273
+ const data = readBytes(ref, dp, dl);
274
+ try {
275
+ if (alg === ALG.AES_GCM) {
276
+ const iv = pr.readBlob();
277
+ const tagBits = pr.readI32();
278
+ const aad = pr.readBlob();
279
+ if (tagBits !== 0 && tagBits !== 128) return ERR_INVALID_PARAMS;
280
+ if (encrypt) {
281
+ const c = nodeCrypto.createCipheriv(
282
+ aesAlgName(e.raw.length, 'gcm'), e.raw, iv,
283
+ ) as nodeCrypto.CipherGCM;
284
+ if (aad.length) c.setAAD(aad);
285
+ const ct = Buffer.concat([c.update(data), c.final()]);
286
+ return stash(cs, Buffer.concat([ct, c.getAuthTag()]));
287
+ }
288
+ // Ciphertext must be at least the 16-byte tag; shorter input can
289
+ // never authenticate.
290
+ if (data.length < 16) return ERR_OPERATION_FAILED;
291
+ const d = nodeCrypto.createDecipheriv(
292
+ aesAlgName(e.raw.length, 'gcm'), e.raw, iv,
293
+ ) as nodeCrypto.DecipherGCM;
294
+ if (aad.length) d.setAAD(aad);
295
+ const tag = data.subarray(data.length - 16);
296
+ const ct = data.subarray(0, data.length - 16);
297
+ d.setAuthTag(tag);
298
+ return stash(cs, Buffer.concat([d.update(ct), d.final()]));
299
+ }
300
+ if (alg === ALG.AES_CBC) {
301
+ const iv = pr.readBlob();
302
+ const c = encrypt
303
+ ? nodeCrypto.createCipheriv(aesAlgName(e.raw.length, 'cbc'), e.raw, iv)
304
+ : nodeCrypto.createDecipheriv(aesAlgName(e.raw.length, 'cbc'), e.raw, iv);
305
+ return stash(cs, Buffer.concat([c.update(data), c.final()]));
306
+ }
307
+ if (alg === ALG.AES_CTR) {
308
+ const counter = pr.readBlob();
309
+ const c = encrypt
310
+ ? nodeCrypto.createCipheriv(aesAlgName(e.raw.length, 'ctr'), e.raw, counter)
311
+ : nodeCrypto.createDecipheriv(aesAlgName(e.raw.length, 'ctr'), e.raw, counter);
312
+ return stash(cs, Buffer.concat([c.update(data), c.final()]));
313
+ }
314
+ return ERR_UNSUPPORTED;
315
+ } catch {
316
+ return ERR_OPERATION_FAILED;
317
+ }
318
+ }
319
+
320
+ function signOp(
321
+ cs: CryptoState, ref: MemoryRef,
322
+ handle: number, pp: number, pl: number, dp: number, dl: number,
323
+ ): number {
324
+ const e = cs.keys.get(handle);
325
+ if (!e) throw new Error('crypto.sign: invalid handle');
326
+ const pr = new ParamReader(readBytes(ref, pp, pl));
327
+ const alg = pr.readI32();
328
+ const hash = pr.readI32();
329
+ const data = readBytes(ref, dp, dl);
330
+ try {
331
+ if (e.alg === ALG.HMAC && e.raw) {
332
+ return stash(cs, nodeCrypto.createHmac(hashName(e.hash), e.raw).update(data).digest());
333
+ }
334
+ if (e.alg === ALG.ECDSA && e.keyObject) {
335
+ const sig = nodeCrypto.sign(hashName(hash), data, {
336
+ key: e.keyObject,
337
+ dsaEncoding: 'ieee-p1363',
338
+ });
339
+ return stash(cs, sig);
340
+ }
341
+ if (e.alg === ALG.ED25519 && e.keyObject) {
342
+ return stash(cs, nodeCrypto.sign(null, data, e.keyObject));
343
+ }
344
+ void alg;
345
+ return ERR_UNSUPPORTED;
346
+ } catch {
347
+ return ERR_OPERATION_FAILED;
348
+ }
349
+ }
350
+
351
+ function verifyOp(
352
+ cs: CryptoState, ref: MemoryRef,
353
+ handle: number, pp: number, pl: number, sp: number, sl: number, dp: number, dl: number,
354
+ ): number {
355
+ const e = cs.keys.get(handle);
356
+ if (!e) throw new Error('crypto.verify: invalid handle');
357
+ const pr = new ParamReader(readBytes(ref, pp, pl));
358
+ pr.readI32(); // alg
359
+ const hash = pr.readI32();
360
+ const sig = readBytes(ref, sp, sl);
361
+ const data = readBytes(ref, dp, dl);
362
+ try {
363
+ if (e.alg === ALG.HMAC && e.raw) {
364
+ const mac = nodeCrypto.createHmac(hashName(e.hash), e.raw).update(data).digest();
365
+ return mac.length === sig.length && nodeCrypto.timingSafeEqual(mac, sig) ? 1 : 0;
366
+ }
367
+ if (e.alg === ALG.ECDSA && e.keyObject) {
368
+ const ok = nodeCrypto.verify(hashName(hash), data, {
369
+ key: e.keyObject,
370
+ dsaEncoding: 'ieee-p1363',
371
+ }, sig);
372
+ return ok ? 1 : 0;
373
+ }
374
+ if (e.alg === ALG.ED25519 && e.keyObject) {
375
+ return nodeCrypto.verify(null, data, e.keyObject, sig) ? 1 : 0;
376
+ }
377
+ return ERR_UNSUPPORTED;
378
+ } catch {
379
+ return ERR_GENERIC;
380
+ }
381
+ }
382
+
383
+ function deriveBitsOp(
384
+ cs: CryptoState, ref: MemoryRef,
385
+ handle: number, pp: number, pl: number, lengthBits: number,
386
+ ): number {
387
+ if (lengthBits < 0 || lengthBits % 8 !== 0) return ERR_INVALID_PARAMS;
388
+ const outLen = lengthBits / 8;
389
+ if (outLen > MAX_OUTPUT) return ERR_INVALID_PARAMS;
390
+ const e = cs.keys.get(handle);
391
+ if (!e) throw new Error('crypto.derive_bits: invalid handle');
392
+ const pr = new ParamReader(readBytes(ref, pp, pl));
393
+ const alg = pr.readI32();
394
+ const hash = pr.readI32();
395
+ try {
396
+ if (alg === ALG.PBKDF2 && e.raw) {
397
+ const iterations = pr.readU32();
398
+ const salt = pr.readBlob();
399
+ return stash(cs, nodeCrypto.pbkdf2Sync(e.raw, salt, iterations, outLen, hashName(hash)));
400
+ }
401
+ if (alg === ALG.HKDF && e.raw) {
402
+ const salt = pr.readBlob();
403
+ const info = pr.readBlob();
404
+ const okm = nodeCrypto.hkdfSync(hashName(hash), e.raw, salt, info, outLen);
405
+ return stash(cs, Buffer.from(okm));
406
+ }
407
+ if ((alg === ALG.ECDH || alg === ALG.X25519) && e.keyObject) {
408
+ const peerHandle = pr.readI32();
409
+ const peer = cs.keys.get(peerHandle);
410
+ if (!peer || !peer.keyObject) throw new Error('crypto.derive_bits: invalid peer handle');
411
+ const shared = nodeCrypto.diffieHellman({
412
+ privateKey: e.keyObject,
413
+ publicKey: peer.keyObject,
414
+ });
415
+ return stash(cs, Buffer.from(shared.subarray(0, Math.min(outLen, shared.length))));
416
+ }
417
+ return ERR_UNSUPPORTED;
418
+ } catch {
419
+ return ERR_OPERATION_FAILED;
420
+ }
421
+ }
@@ -17,6 +17,8 @@
17
17
  * `WebAssembly.Instance`, so offering the full surface costs nothing.
18
18
  */
19
19
 
20
+ import { buildCryptoImports, freshCryptoState, type CryptoState } from './crypto.js';
21
+
20
22
  /** Limits identical to the edge's `set_header` / `respond_file` bounds. */
21
23
  const MAX_TOTAL_HEADERS_BYTES = 64 * 1024;
22
24
  const MAX_HEADER_NAME_LEN = 256;
@@ -47,11 +49,13 @@ export interface DispatchState {
47
49
  headerBytes: number;
48
50
  /** File path from `respond_file`, or `null`; when set, the envelope body is ignored. */
49
51
  sendfile: string | null;
52
+ /** Per-dispatch Web Crypto keystore + result scratch (mirrors the edge). */
53
+ crypto: CryptoState;
50
54
  }
51
55
 
52
56
  /** A fresh, zeroed per-dispatch state (the edge resets the same way before each request). */
53
57
  export function freshDispatchState(): DispatchState {
54
- return { status: null, headers: [], headerBytes: 0, sendfile: null };
58
+ return { status: null, headers: [], headerBytes: 0, sendfile: null, crypto: freshCryptoState() };
55
59
  }
56
60
 
57
61
  /**
@@ -132,6 +136,10 @@ export function buildHostImports(ref: MemoryRef, state: DispatchState): WebAssem
132
136
  },
133
137
 
134
138
  thread_spawn: (_startArg: number): number => -1,
139
+
140
+ // Web Crypto host functions (`env.crypto.*`), backed by Node's
141
+ // `crypto`. The dev server skips metering, so these charge nothing.
142
+ ...buildCryptoImports(ref, state.crypto),
135
143
  },
136
144
  };
137
145
  }
@@ -51,7 +51,13 @@ interface HandleExports {
51
51
  }
52
52
 
53
53
  /** Host functions the dev server provides under `env` (see `host.ts`). */
54
- const PROVIDED_IMPORTS = new Set(['abort', 'set_status', 'set_header', 'respond_file', 'thread_spawn']);
54
+ const PROVIDED_IMPORTS = new Set([
55
+ 'abort', 'set_status', 'set_header', 'respond_file', 'thread_spawn',
56
+ // Web Crypto host functions (see ./crypto.ts).
57
+ 'crypto.fill_random', 'crypto.random_uuid', 'crypto.take_result', 'crypto.digest',
58
+ 'crypto.import_key', 'crypto.export_key', 'crypto.encrypt', 'crypto.decrypt',
59
+ 'crypto.sign', 'crypto.verify', 'crypto.derive_bits',
60
+ ]);
55
61
 
56
62
  export class WasmServerModule {
57
63
  private module: WebAssembly.Module | null = null;