diff --git a/docs/features/plugin-system.md b/docs/features/plugin-system.md index a4e4f94a7..f3352d2f3 100644 --- a/docs/features/plugin-system.md +++ b/docs/features/plugin-system.md @@ -315,6 +315,7 @@ Inside the admin window, plugin React surfaces (panels, app pages, canvas overla - **`console.{log, info, warn, error, debug, trace}`** — routes to `api.plugin.log`. - **`fetch(url, init)`** — opt-in: requires `network.outbound` permission AND the URL host on the `networkAllowedHosts` allowlist. Byte-safe: `arrayBuffer()` returns exact bytes; request bodies accept `string | ArrayBuffer | TypedArray/DataView`. - **`crypto.subtle`** — pure computation bridge: `digest(...)`, `importKey('raw', ..., { name: 'HMAC', hash })`, and `sign('HMAC', ...)`. These map to ungated `crypto.digest` / `crypto.signHmac` RPC targets because they do no I/O. +- **`crypto.getRandomValues(view)` / `crypto.randomUUID()`** — CSPRNG entropy from the host, for tokens, nonces, invitation codes and one-time links. Unlike the digest/HMAC pair these do **not** use the `__hostCall` RPC bridge, because that returns a Promise and `getRandomValues` is synchronous by spec; they call the dedicated synchronous `__hostRandomBytes` host function instead. Also ungated (no I/O, nothing to escalate). `getRandomValues` accepts integer-typed views only, throwing `TypeMismatchError` for float or non-view arguments, and caps a single call at 65536 bytes with `QuotaExceededError` above it — the WebCrypto quota, enforced in both the shim and the host function. `randomUUID` returns an RFC 9562 version-4 UUID. ### What's denied @@ -364,7 +365,7 @@ VM budgets live in `server/plugins/quickjs/limits.ts`; the host-side RPC timeout Before any plugin code runs, the host evaluates a **bootstrap** program inside the VM: Web-Platform polyfills (URL, TextEncoder, console, AbortController, timers, -crypto.subtle, fetch) plus the SDK factory `__buildApi()` and the `__run*` +crypto.subtle, crypto.getRandomValues, fetch) plus the SDK factory `__buildApi()` and the `__run*` dispatchers the host calls to drive plugin code. QuickJS has no module loader, so this bootstrap must reach the VM as a single source **string** — but that string is a build artifact, not the authoring surface. diff --git a/server/plugins/quickjs/bootstrap/crypto.ts b/server/plugins/quickjs/bootstrap/crypto.ts index 636859763..940b2b081 100644 --- a/server/plugins/quickjs/bootstrap/crypto.ts +++ b/server/plugins/quickjs/bootstrap/crypto.ts @@ -1,12 +1,21 @@ /** - * WebCrypto-compatible crypto.subtle shim evaluated inside every plugin - * QuickJS VM. + * WebCrypto-compatible crypto shim evaluated inside every plugin QuickJS VM. * * Exposed surface: crypto.subtle.digest, crypto.subtle.importKey (raw HMAC), - * and crypto.subtle.sign (HMAC). Bytes cross the host bridge as base64 - * strings via __hostCall('crypto.digest') / __hostCall('crypto.signHmac'). + * crypto.subtle.sign (HMAC), plus crypto.getRandomValues and + * crypto.randomUUID. Digest/HMAC bytes cross the host bridge as base64 + * strings via __hostCall('crypto.digest') / __hostCall('crypto.signHmac'); + * entropy uses the synchronous __hostRandomBytes bridge instead, because + * getRandomValues is synchronous by spec and __hostCall returns a Promise. */ +/** + * Per-call entropy ceiling, shared by the VM shim and the host function so + * both agree on one bound. Matches the WebCrypto quota for + * `crypto.getRandomValues`, which throws QuotaExceededError above 65536 bytes. + */ +export const CRYPTO_RANDOM_BYTES_MAX = 65536 + export const CRYPTO_SUBTLE_SHIM = `// ------- crypto.subtle — WebCrypto-compatible shim -------------------------- // Storage / auth plugins need SHA-256 + HMAC-SHA256 (AWS Sigv4, JWT signing, // OAuth, presigned URLs). Without a host bridge they'd have to vendor a @@ -140,3 +149,86 @@ globalThis.crypto.subtle = { }; ` + +/** + * CSPRNG shim — `crypto.getRandomValues` and `crypto.randomUUID`. + * + * Must be evaluated after BASE64_SHIM (uses `__base64ToBytes`) and it augments + * whatever `globalThis.crypto` already exists rather than replacing it, so the + * ordering against CRYPTO_SUBTLE_SHIM does not matter. + * + * Without this, `Math.random` and `Date` were the only entropy in the sandbox, + * so a plugin minting a bearer token, nonce, invitation code or one-time link + * had no safe way to do it on the server. + */ +export const CRYPTO_RANDOM_SHIM = `// ------- crypto.getRandomValues / crypto.randomUUID ------------------------- +// Entropy comes from the host's CSPRNG through the SYNCHRONOUS +// __hostRandomBytes bridge (base64 in, bytes out). getRandomValues is +// synchronous by spec, so it cannot use the Promise-returning __hostCall the +// digest/HMAC paths use. +var __CRYPTO_RANDOM_MAX = ${CRYPTO_RANDOM_BYTES_MAX}; + +// QuickJS has no DOMException, so carry the spec's error \`name\` on a plain +// Error. Plugins that branch on err.name still behave the same. +function __cryptoNamedError(name, message) { + var err = new Error(message); + err.name = name; + return err; +} + +// getRandomValues accepts only integer-typed views. Float and non-typed views +// are a TypeMismatchError per spec. Named rather than instanceof-checked so a +// missing BigInt64Array in the engine degrades to "unsupported", not a crash. +var __CRYPTO_INTEGER_VIEWS = [ + 'Int8Array', 'Uint8Array', 'Uint8ClampedArray', + 'Int16Array', 'Uint16Array', + 'Int32Array', 'Uint32Array', + 'BigInt64Array', 'BigUint64Array', +]; + +function __cryptoRandomBytes(count) { + if (count <= 0) return new Uint8Array(0); + return __base64ToBytes(__hostRandomBytes(count)); +} + +globalThis.crypto = globalThis.crypto || {}; + +globalThis.crypto.getRandomValues = function getRandomValues(array) { + if (!array || typeof array !== 'object' || !ArrayBuffer.isView(array)) { + throw __cryptoNamedError('TypeMismatchError', 'getRandomValues expects an integer-typed TypedArray.'); + } + var kind = array.constructor && array.constructor.name; + if (__CRYPTO_INTEGER_VIEWS.indexOf(kind) < 0) { + throw __cryptoNamedError('TypeMismatchError', 'getRandomValues does not support ' + String(kind) + '.'); + } + if (array.byteLength > __CRYPTO_RANDOM_MAX) { + throw __cryptoNamedError( + 'QuotaExceededError', + 'getRandomValues supports at most ' + __CRYPTO_RANDOM_MAX + ' bytes per call.', + ); + } + if (array.byteLength === 0) return array; + // Fill through a byte view so the element width of the caller's array is + // irrelevant — the spec fills the underlying bytes. + var bytes = __cryptoRandomBytes(array.byteLength); + new Uint8Array(array.buffer, array.byteOffset, array.byteLength).set(bytes); + return array; +}; + +var __CRYPTO_HEX = '0123456789abcdef'; + +globalThis.crypto.randomUUID = function randomUUID() { + var b = __cryptoRandomBytes(16); + // RFC 9562 §5.4: version 4 in the high nibble of octet 6, variant 10 in the + // top two bits of octet 8. + b[6] = (b[6] & 0x0f) | 0x40; + b[8] = (b[8] & 0x3f) | 0x80; + var out = ''; + for (var i = 0; i < 16; i++) { + if (i === 4 || i === 6 || i === 8 || i === 10) out += '-'; + out += __CRYPTO_HEX[b[i] >> 4] + __CRYPTO_HEX[b[i] & 0x0f]; + } + return out; +}; + +` diff --git a/server/plugins/quickjs/bootstrap/index.ts b/server/plugins/quickjs/bootstrap/index.ts index ed05f94e5..63b868d30 100644 --- a/server/plugins/quickjs/bootstrap/index.ts +++ b/server/plugins/quickjs/bootstrap/index.ts @@ -12,7 +12,8 @@ * Execution order matters: polyfills must be defined before the API layer * references them (URL, TextEncoder, AbortController, crypto.subtle, fetch), * and the shared base64 codec must precede crypto, fetch, and the bundled - * runtime — all three move binary payloads through it. + * runtime — all four move binary payloads through it (the CSPRNG shim decodes + * host entropy with `__base64ToBytes`). * The leading `'use strict';` makes the entire evaluated program — including * the bundled IIFE — strict. */ @@ -20,7 +21,7 @@ import { URL_POLYFILL, TEXT_CODEC_POLYFILL, CONSOLE_POLYFILL, ABORT_CONTROLLER_POLYFILL } from './polyfills' import { TIMERS_SOURCE } from './timers' import { BASE64_SHIM } from './base64' -import { CRYPTO_SUBTLE_SHIM } from './crypto' +import { CRYPTO_SUBTLE_SHIM, CRYPTO_RANDOM_SHIM } from './crypto' import { FETCH_SHIM } from './fetch' import { PLUGIN_BOOTSTRAP_SOURCE } from './generated/pluginBootstrap' @@ -33,5 +34,6 @@ export const BOOTSTRAP_SOURCE = ABORT_CONTROLLER_POLYFILL + BASE64_SHIM + CRYPTO_SUBTLE_SHIM + + CRYPTO_RANDOM_SHIM + FETCH_SHIM + PLUGIN_BOOTSTRAP_SOURCE diff --git a/server/plugins/quickjs/vm.ts b/server/plugins/quickjs/vm.ts index a27db81d5..a050fa4c5 100644 --- a/server/plugins/quickjs/vm.ts +++ b/server/plugins/quickjs/vm.ts @@ -32,6 +32,8 @@ import { getQuickJS, type QuickJSContext, type QuickJSHandle, type QuickJSWASMMo import { BOOTSTRAP_SOURCE } from './bootstrap/index' import { DEFAULT_EVAL_TIMEOUT_MS, DEFAULT_MEMORY_LIMIT_BYTES, DEFAULT_STACK_SIZE_BYTES } from './limits' import { jsToHandle } from './marshal' +import { bytesToBase64 } from '../protocol/bodyEncoding' +import { CRYPTO_RANDOM_BYTES_MAX } from './bootstrap/crypto' import { callString, callVoid, evalJson, withSyncDeadline } from './eval' import type { PluginVm, PluginVmEnv } from './types' @@ -277,6 +279,27 @@ export async function createPluginVm(args: { ctx.setProp(ctx.global, '__log', logHandle) hostFunctionHandles.push(logHandle) + // 2b. Wire __hostRandomBytes — CSPRNG entropy, returned SYNCHRONOUSLY as + // base64. Deliberately not routed through __hostCall: that returns a + // VM-side Promise, and `crypto.getRandomValues` is synchronous by + // spec, so a plugin could not await it. Pure computation with no I/O + // and no privilege to escalate, so it needs no permission gate — the + // same reasoning the crypto.digest / crypto.signHmac handlers document. + // Capped at the WebCrypto quota so a plugin cannot ask the host for an + // unbounded allocation; the VM-side shim enforces the same bound and + // throws the spec's QuotaExceededError before ever calling in. + const hostRandomBytesHandle = ctx.newFunction('__hostRandomBytes', (countHandle) => { + const requested = ctx.getNumber(countHandle) + const count = Number.isFinite(requested) ? Math.floor(requested) : 0 + if (count <= 0) return ctx.newString('') + if (count > CRYPTO_RANDOM_BYTES_MAX) { + return { error: ctx.newError(`__hostRandomBytes: at most ${CRYPTO_RANDOM_BYTES_MAX} bytes`) } + } + return ctx.newString(bytesToBase64(crypto.getRandomValues(new Uint8Array(count)))) + }) + ctx.setProp(ctx.global, '__hostRandomBytes', hostRandomBytesHandle) + hostFunctionHandles.push(hostRandomBytesHandle) + // 3. Wire meta + settings as VM globals. // // `grantedPermissions` is the AUTHORITATIVE set the operator approved at diff --git a/src/__tests__/server/pluginSandboxCsprng.test.ts b/src/__tests__/server/pluginSandboxCsprng.test.ts new file mode 100644 index 000000000..3326cfdfb --- /dev/null +++ b/src/__tests__/server/pluginSandboxCsprng.test.ts @@ -0,0 +1,242 @@ +/** + * Plugin sandbox CSPRNG — `crypto.getRandomValues` and `crypto.randomUUID` + * inside the QuickJS-WASM VM. + * + * Before this existed the sandbox exposed only `crypto.subtle` (digest + + * HMAC), so `Math.random` and `Date` were the only entropy available and a + * plugin minting a bearer token, nonce or one-time link had no safe way to do + * it server-side (#387). + * + * The load-bearing property is that `getRandomValues` is **synchronous**. The + * digest/HMAC paths go through `__hostCall`, which returns a VM-side Promise, + * so entropy needed its own synchronous bridge. Several tests below call it + * without `await` on purpose to pin that. + * + * The plugin has no ambient way to report back except `__hostCall`, so each + * test records observations through a `test.record` recorder and asserts on + * the host side. + */ +import { describe, expect, it } from 'bun:test' +import { createPluginVm, type PluginVmEnv } from '../../../server/plugins/quickjs/vm' +import { CRYPTO_RANDOM_BYTES_MAX } from '../../../server/plugins/quickjs/bootstrap/crypto' + +interface RecorderEntry { + target: string + args: unknown[] +} + +function makeRecorderEnv(): { env: PluginVmEnv; recorder: RecorderEntry[] } { + const recorder: RecorderEntry[] = [] + const env: PluginVmEnv = { + pluginId: 'acme.csprng', + manifestVersion: '1.0.0', + grantedPermissions: [], + assetBasePath: '/uploads/plugins/acme.csprng/1.0.0', + settings: {}, + hostCall: async (target, args) => { + recorder.push({ target, args }) + return null + }, + log: () => { /* swallow */ }, + } + return { env, recorder } +} + +/** Run a plugin body in a VM and return whatever it recorded. */ +async function record(body: string): Promise { + const { env, recorder } = makeRecorderEnv() + const vm = await createPluginVm({ + env, + pluginSource: ` + ;(function () { + const __plugin_exports = (globalThis.__plugin_exports = {}); + __plugin_exports.activate = async function activate() { + ${body} + }; + })(); + `, + }) + try { + await vm.runLifecycle('activate') + return recorder.filter((e) => e.target === 'test.record').map((e) => e.args[0]) + } finally { + vm.dispose() + } +} + +describe('plugin sandbox: crypto.getRandomValues', () => { + it('is present and synchronous — no await needed', async () => { + const [observed] = await record(` + const a = new Uint8Array(8); + const returned = crypto.getRandomValues(a); + __hostCall('test.record', [{ + type: typeof crypto.getRandomValues, + // A Promise here would mean it went through __hostCall. + returnsInputSynchronously: returned === a, + length: a.length, + }]); + `) + expect(observed).toEqual({ type: 'function', returnsInputSynchronously: true, length: 8 }) + }) + + it('actually fills the array with varied bytes', async () => { + const [observed] = await record(` + const a = new Uint8Array(64); + crypto.getRandomValues(a); + let distinct = {}; + let nonZero = 0; + for (let i = 0; i < a.length; i++) { + distinct[a[i]] = true; + if (a[i] !== 0) nonZero++; + } + __hostCall('test.record', [{ + distinctCount: Object.keys(distinct).length, + nonZero: nonZero, + }]); + `) + const stats = observed as { distinctCount: number; nonZero: number } + // 64 CSPRNG bytes essentially never collapse to a handful of values, and + // an all-zero fill is the signature of a bridge that silently no-ops. + expect(stats.nonZero).toBeGreaterThan(50) + expect(stats.distinctCount).toBeGreaterThan(20) + }) + + it('produces different bytes on successive calls', async () => { + const [observed] = await record(` + const a = new Uint8Array(32); + const b = new Uint8Array(32); + crypto.getRandomValues(a); + crypto.getRandomValues(b); + let same = true; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) { same = false; break; } + __hostCall('test.record', [{ identical: same }]); + `) + expect(observed).toEqual({ identical: false }) + }) + + it('fills wider element types across their full byte length', async () => { + const [observed] = await record(` + const u32 = new Uint32Array(8); + crypto.getRandomValues(u32); + let nonZero = 0; + for (let i = 0; i < u32.length; i++) if (u32[i] !== 0) nonZero++; + __hostCall('test.record', [{ byteLength: u32.byteLength, nonZero: nonZero }]); + `) + const stats = observed as { byteLength: number; nonZero: number } + expect(stats.byteLength).toBe(32) + expect(stats.nonZero).toBeGreaterThan(6) + }) + + it('respects byteOffset when handed a view over a larger buffer', async () => { + const [observed] = await record(` + const buf = new ArrayBuffer(16); + const whole = new Uint8Array(buf); + const middle = new Uint8Array(buf, 4, 8); + crypto.getRandomValues(middle); + let headTouched = false, tailTouched = false; + for (let i = 0; i < 4; i++) if (whole[i] !== 0) headTouched = true; + for (let i = 12; i < 16; i++) if (whole[i] !== 0) tailTouched = true; + let filledNonZero = 0; + for (let i = 4; i < 12; i++) if (whole[i] !== 0) filledNonZero++; + __hostCall('test.record', [{ headTouched, tailTouched, filledNonZero }]); + `) + const stats = observed as { headTouched: boolean; tailTouched: boolean; filledNonZero: number } + // Writing outside the view would corrupt neighbouring data. + expect(stats.headTouched).toBe(false) + expect(stats.tailTouched).toBe(false) + expect(stats.filledNonZero).toBeGreaterThan(4) + }) + + it('returns a zero-length array untouched', async () => { + const [observed] = await record(` + const a = new Uint8Array(0); + const returned = crypto.getRandomValues(a); + __hostCall('test.record', [{ same: returned === a, length: a.length }]); + `) + expect(observed).toEqual({ same: true, length: 0 }) + }) + + it('throws QuotaExceededError above the WebCrypto ceiling', async () => { + const [observed] = await record(` + let name = null; + try { + crypto.getRandomValues(new Uint8Array(${CRYPTO_RANDOM_BYTES_MAX} + 1)); + } catch (err) { + name = err.name; + } + __hostCall('test.record', [{ name: name }]); + `) + expect(observed).toEqual({ name: 'QuotaExceededError' }) + }) + + it('accepts exactly the ceiling', async () => { + const [observed] = await record(` + let ok = false; + try { + const a = new Uint8Array(${CRYPTO_RANDOM_BYTES_MAX}); + crypto.getRandomValues(a); + ok = a[0] !== undefined; + } catch (err) { ok = false; } + __hostCall('test.record', [{ ok: ok }]); + `) + expect(observed).toEqual({ ok: true }) + }) + + it('throws TypeMismatchError for float views and non-views', async () => { + const [observed] = await record(` + function nameOf(fn) { + try { fn(); return null; } catch (err) { return err.name; } + } + __hostCall('test.record', [{ + float32: nameOf(function () { crypto.getRandomValues(new Float32Array(4)); }), + float64: nameOf(function () { crypto.getRandomValues(new Float64Array(4)); }), + plainArray: nameOf(function () { crypto.getRandomValues([1, 2, 3]); }), + nothing: nameOf(function () { crypto.getRandomValues(); }), + }]); + `) + expect(observed).toEqual({ + float32: 'TypeMismatchError', + float64: 'TypeMismatchError', + plainArray: 'TypeMismatchError', + nothing: 'TypeMismatchError', + }) + }) + + it('does not disturb the existing crypto.subtle surface', async () => { + const [observed] = await record(` + __hostCall('test.record', [{ + digest: typeof crypto.subtle.digest, + importKey: typeof crypto.subtle.importKey, + sign: typeof crypto.subtle.sign, + }]); + `) + expect(observed).toEqual({ digest: 'function', importKey: 'function', sign: 'function' }) + }) +}) + +describe('plugin sandbox: crypto.randomUUID', () => { + it('returns a well-formed v4 UUID synchronously', async () => { + const [observed] = await record(` + const id = crypto.randomUUID(); + __hostCall('test.record', [{ id: id, type: typeof id }]); + `) + const { id, type } = observed as { id: string; type: string } + expect(type).toBe('string') + // Version 4, RFC 9562 variant 10xx. + expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/) + }) + + it('does not repeat across many calls', async () => { + const [observed] = await record(` + const seen = {}; + let collisions = 0; + for (let i = 0; i < 200; i++) { + const id = crypto.randomUUID(); + if (seen[id]) collisions++; + seen[id] = true; + } + __hostCall('test.record', [{ collisions: collisions, unique: Object.keys(seen).length }]); + `) + expect(observed).toEqual({ collisions: 0, unique: 200 }) + }) +})