From 9139398d465f5899cc31bc4cdd54daa7392ca550 Mon Sep 17 00:00:00 2001 From: Doan Luu Duc Tai Date: Sat, 31 Oct 2026 12:25:30 -0500 Subject: Migrate browser code and tests to strict TypeScript --- crates/chat-crypto/README.txt | 5 +- crates/chat-crypto/browser/protocol.d.ts | 57 ++++++++++++ crates/chat-crypto/browser/wasi.mjs | 142 ----------------------------- crates/chat-crypto/browser/wasi.ts | 151 +++++++++++++++++++++++++++++++ crates/chat-crypto/tests/browser.mjs | 87 ------------------ crates/chat-crypto/tests/browser.ts | 89 ++++++++++++++++++ 6 files changed, 300 insertions(+), 231 deletions(-) create mode 100644 crates/chat-crypto/browser/protocol.d.ts delete mode 100644 crates/chat-crypto/browser/wasi.mjs create mode 100644 crates/chat-crypto/browser/wasi.ts delete mode 100644 crates/chat-crypto/tests/browser.mjs create mode 100644 crates/chat-crypto/tests/browser.ts (limited to 'crates/chat-crypto') diff --git a/crates/chat-crypto/README.txt b/crates/chat-crypto/README.txt index a08d850..114544d 100644 --- a/crates/chat-crypto/README.txt +++ b/crates/chat-crypto/README.txt @@ -45,11 +45,12 @@ CHECKS FROM THE WORKSPACE ROOT cargo test --locked -p stack-chat-crypto cargo run --locked -p stack-chat-crypto sh infra/chat/build-openssl-wasi.sh - PLAYWRIGHT_BROWSERS_PATH="$PWD/.tools/browsers" node crates/chat-crypto/tests/browser.mjs + PLAYWRIGHT_BROWSERS_PATH="$PWD/.tools/browsers" npm run test:crypto cargo stack check Native builds require OpenSSL >=3.5 development files and libclang. The cross-build script supports an x86_64 GNU/Linux development host. - Browser tooling is excluded from the public source tree and releases. + Installed browser tooling is excluded from source archives and releases. + The TypeScript tests and locked toolchain declarations are public. The probe uses public fixture credentials and creates temporary identities in memory. It does not open the root .env or use the canonical RSA key. diff --git a/crates/chat-crypto/browser/protocol.d.ts b/crates/chat-crypto/browser/protocol.d.ts new file mode 100644 index 0000000..6803c4c --- /dev/null +++ b/crates/chat-crypto/browser/protocol.d.ts @@ -0,0 +1,57 @@ +// Copyright (C) 2026 Doan Luu Duc Tai +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +declare namespace StackChat { + interface Credentials { file: string; password: string } + interface Identity { public: string; fingerprint: string; owner: string; name: string } + interface Session { public: string; fingerprint: string; name: string; id: string; token: string; owner: boolean; expires_seconds: number } + interface Configuration { owner: string | null; origin: string; protocol: string } + interface Conversation { id: string; public: string; profile?: string | null; total: number; visitor_sent: number; owner_sent: number } + interface Message { seq: number; id: string; sender: string; ciphertext: string } + interface SendRequest { op: 'send'; conversation: string; id: string; ciphertext: string } + interface Api { + config: { request: { op: 'config' }; response: Configuration }; + challenge: { request: { op: 'challenge'; public: string; setup_token?: string | undefined }; response: { id: string; challenge: string } }; + authenticate: { request: { op: 'authenticate'; id: string; signature: string }; response: { token: string; id: string; owner: boolean; expires_seconds: number } }; + profile: { request: { op: 'profile'; conversation: string; ciphertext: string }; response: { stored: boolean } }; + inbox: { request: { op: 'inbox' }; response: { conversations: Conversation[] } }; + history: { request: { op: 'history'; conversation: string; count: number; before: number }; response: { conversation: Conversation; messages: Message[] } }; + send: { request: SendRequest; response: { seq: number; conversation: Conversation } }; + lock: { request: { op: 'lock' }; response: { locked: boolean } }; + } + interface Protected extends Credentials { origin: string } + interface Decryption { payload: string; signer: string } + type DecryptionEntry = Decryption | { error: string }; + interface Crypto { + create: { request: { op: 'create'; origin: string; name: string; password: string; owner: string }; response: { file: string } }; + open: { request: Protected & { op: 'open' }; response: Identity }; + proof: { request: Protected & { op: 'proof'; challenge: string }; response: { signature: string } }; + encrypt: { request: Protected & { op: 'encrypt'; recipient: string; payload: string }; response: { ciphertext: string } }; + decrypt: { request: Protected & { op: 'decrypt'; sender: string; ciphertext: string }; response: Decryption }; + decrypt_batch: { request: Protected & { op: 'decrypt_batch'; messages: { sender: string; ciphertext: string }[] }; response: { messages: DecryptionEntry[] } }; + } + type ApiRequest = Api[keyof Api]['request']; + type CryptoRequest = Crypto[keyof Crypto]['request']; + type CryptoResult = Crypto[keyof Crypto]['response']; + type WorkerResponse = { result: CryptoResult } | { error: string }; + interface WorkerRequest { bytes: ArrayBuffer; request: CryptoRequest } + interface Execution { output: string; randomCalls: number; randomBytes: number } + interface Wasi { + runCommand(bytes: ArrayBuffer, request: CryptoRequest): Promise; + runProbe(bytes: ArrayBuffer): Promise; + } +} + +declare var stackChatWasi: Readonly; diff --git a/crates/chat-crypto/browser/wasi.mjs b/crates/chat-crypto/browser/wasi.mjs deleted file mode 100644 index 80d1299..0000000 --- a/crates/chat-crypto/browser/wasi.mjs +++ /dev/null @@ -1,142 +0,0 @@ -// Copyright (C) 2026 Doan Luu Duc Tai -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as -// published by the Free Software Foundation, either version 3 of the -// License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-3.0 - -async function execute(bytes, input) { - let memory; - let output = ''; - let errors = ''; - const stdoutDecoder = new TextDecoder('utf-8', { fatal: true }); - const stderrDecoder = new TextDecoder('utf-8', { fatal: true }); - const stdin = new TextEncoder().encode(input); - let position = 0; - let randomCalls = 0; - let randomBytes = 0; - const data = () => new DataView(memory.buffer); - const put32 = (address, value) => data().setUint32(address, value, true); - const noEntries = (count, size) => { put32(count, 0); put32(size, 0); return 0; }; - class Exit extends Error { - constructor(code) { super(`WASI exit ${code}`); this.code = code; } - } - const wasi = { - args_sizes_get: noEntries, - environ_sizes_get: noEntries, - args_get: () => 0, - environ_get: () => 0, - random_get(address, length) { - const buffer = new Uint8Array(memory.buffer, address, length); - for (let offset = 0; offset < length; offset += 65536) { - crypto.getRandomValues(buffer.subarray(offset, Math.min(offset + 65536, length))); - } - randomCalls += 1; - randomBytes += length; - return 0; - }, - clock_time_get(clock, precision, address) { - if (clock !== 0 && clock !== 1) return 52; - const milliseconds = clock === 0 ? Date.now() : performance.now(); - data().setBigUint64(address, BigInt(Math.floor(milliseconds * 1000000)), true); - return 0; - }, - clock_res_get(clock, address) { data().setBigUint64(address, 1000000n, true); return 0; }, - fd_write(fd, vectors, count, written) { - if (fd !== 1 && fd !== 2) return 8; - let total = 0; - for (let index = 0; index < count; index += 1) { - const address = data().getUint32(vectors + index * 8, true); - const length = data().getUint32(vectors + index * 8 + 4, true); - const text = (fd === 1 ? stdoutDecoder : stderrDecoder).decode(new Uint8Array(memory.buffer, address, length), { stream: true }); - if (fd === 1) output += text; else errors += text; - total += length; - if (output.length + errors.length > 1048576) throw new Error('Probe output exceeded its limit'); - } - put32(written, total); - return 0; - }, - fd_read(fd, vectors, count, read) { - if (fd !== 0) return 8; - let total = 0; - for (let index = 0; index < count && position < stdin.length; index += 1) { - const address = data().getUint32(vectors + index * 8, true); - const capacity = data().getUint32(vectors + index * 8 + 4, true); - const length = Math.min(capacity, stdin.length - position); - new Uint8Array(memory.buffer, address, length).set(stdin.subarray(position, position + length)); - position += length; - total += length; - } - put32(read, total); - return 0; - }, - fd_fdstat_get(fd, address) { - if (fd > 2) return 8; - new Uint8Array(memory.buffer, address, 24).fill(0); - data().setUint8(address, 2); - return 0; - }, - fd_fdstat_set_flags: fd => fd <= 2 ? 0 : 8, - fd_prestat_get: () => 8, - fd_prestat_dir_name: () => 8, - fd_close: fd => fd <= 2 ? 0 : 8, - fd_seek: () => 70, - fd_tell: () => 70, - fd_filestat_get: () => 8, - path_open: () => 76, - sched_yield: () => 0, - proc_exit(code) { throw new Exit(code); }, - }; - for (const name of [ - 'fd_readdir', 'fd_advise', 'fd_allocate', 'fd_datasync', 'fd_sync', - 'fd_filestat_set_size', 'fd_filestat_set_times', 'fd_fdstat_set_rights', - 'path_filestat_get', 'path_filestat_set_times', 'path_readlink', 'path_unlink_file', - 'path_rename', 'path_create_directory', 'path_remove_directory', 'path_link', 'path_symlink', - 'sock_accept', 'sock_recv', 'sock_send', 'sock_shutdown', 'poll_oneoff', - ]) wasi[name] = () => 76; - const module = await WebAssembly.compile(bytes); - for (const entry of WebAssembly.Module.imports(module)) { - if (entry.module !== 'wasi_snapshot_preview1' || typeof wasi[entry.name] !== 'function') { - throw new Error(`Unsupported WASI import: ${entry.module}.${entry.name}`); - } - } - const instance = await WebAssembly.instantiate(module, { wasi_snapshot_preview1: wasi }); - memory = instance.exports.memory; - try { instance.exports._start(); } - catch (error) { if (!(error instanceof Exit) || error.code !== 0) throw new Error(`${error.message}: ${errors}`); } - output += stdoutDecoder.decode(); - errors += stderrDecoder.decode(); - stdin.fill(0); - return { output, randomCalls, randomBytes }; -} - -async function runProbe(bytes) { - const result = await execute(bytes, ''); - if (!result.output.startsWith('PASS: backend=OpenSSL ') || result.randomCalls === 0) { - throw new Error('OpenSSL probe did not pass'); - } - return result; -} - -async function runCommand(bytes, request) { - const input = JSON.stringify(request); - if (input.length > 8388608) throw new Error('Crypto request exceeds its limit'); - const result = await execute(bytes, input); - const response = JSON.parse(result.output); - if (response.error) throw new Error(response.error); - return response; -} - -globalThis.stackChatWasi = Object.freeze({ runCommand, runProbe }); - -// @license-end diff --git a/crates/chat-crypto/browser/wasi.ts b/crates/chat-crypto/browser/wasi.ts new file mode 100644 index 0000000..a037519 --- /dev/null +++ b/crates/chat-crypto/browser/wasi.ts @@ -0,0 +1,151 @@ +// Copyright (C) 2026 Doan Luu Duc Tai +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-3.0 + +async function execute(bytes: ArrayBuffer, input: string): Promise { + let memory: WebAssembly.Memory | undefined; + let output = ''; + let errors = ''; + const stdoutDecoder = new TextDecoder('utf-8', { fatal: true }); + const stderrDecoder = new TextDecoder('utf-8', { fatal: true }); + const stdin = new TextEncoder().encode(input); + let position = 0; + let randomCalls = 0; + let randomBytes = 0; + const memoryBuffer = (): ArrayBuffer => { + if (!memory) throw new Error('WASI memory is unavailable'); + return memory.buffer; + }; + const data = (): DataView => new DataView(memoryBuffer()); + const put32 = (address: number, value: number) => data().setUint32(address, value, true); + const noEntries = (count: number, size: number) => { put32(count, 0); put32(size, 0); return 0; }; + class Exit extends Error { + readonly code: number; + constructor(code: number) { super(`WASI exit ${code}`); this.code = code; } + } + const wasi: Record = { + args_sizes_get: noEntries, + environ_sizes_get: noEntries, + args_get: () => 0, + environ_get: () => 0, + random_get(address: number, length: number) { + const buffer = new Uint8Array(memoryBuffer(), address, length); + for (let offset = 0; offset < length; offset += 65536) { + crypto.getRandomValues(buffer.subarray(offset, Math.min(offset + 65536, length))); + } + randomCalls += 1; + randomBytes += length; + return 0; + }, + clock_time_get(clock: number, _precision: bigint, address: number) { + if (clock !== 0 && clock !== 1) return 52; + const milliseconds = clock === 0 ? Date.now() : performance.now(); + data().setBigUint64(address, BigInt(Math.floor(milliseconds * 1000000)), true); + return 0; + }, + clock_res_get(_clock: number, address: number) { data().setBigUint64(address, 1000000n, true); return 0; }, + fd_write(fd: number, vectors: number, count: number, written: number) { + if (fd !== 1 && fd !== 2) return 8; + let total = 0; + for (let index = 0; index < count; index += 1) { + const address = data().getUint32(vectors + index * 8, true); + const length = data().getUint32(vectors + index * 8 + 4, true); + const text = (fd === 1 ? stdoutDecoder : stderrDecoder).decode(new Uint8Array(memoryBuffer(), address, length), { stream: true }); + if (fd === 1) output += text; else errors += text; + total += length; + if (output.length + errors.length > 1048576) throw new Error('Probe output exceeded its limit'); + } + put32(written, total); + return 0; + }, + fd_read(fd: number, vectors: number, count: number, read: number) { + if (fd !== 0) return 8; + let total = 0; + for (let index = 0; index < count && position < stdin.length; index += 1) { + const address = data().getUint32(vectors + index * 8, true); + const capacity = data().getUint32(vectors + index * 8 + 4, true); + const length = Math.min(capacity, stdin.length - position); + new Uint8Array(memoryBuffer(), address, length).set(stdin.subarray(position, position + length)); + position += length; + total += length; + } + put32(read, total); + return 0; + }, + fd_fdstat_get(fd: number, address: number) { + if (fd > 2) return 8; + new Uint8Array(memoryBuffer(), address, 24).fill(0); + data().setUint8(address, 2); + return 0; + }, + fd_fdstat_set_flags: (fd: number) => fd <= 2 ? 0 : 8, + fd_prestat_get: () => 8, + fd_prestat_dir_name: () => 8, + fd_close: (fd: number) => fd <= 2 ? 0 : 8, + fd_seek: () => 70, + fd_tell: () => 70, + fd_filestat_get: () => 8, + path_open: () => 76, + sched_yield: () => 0, + proc_exit(code: number) { throw new Exit(code); }, + }; + for (const name of [ + 'fd_readdir', 'fd_advise', 'fd_allocate', 'fd_datasync', 'fd_sync', + 'fd_filestat_set_size', 'fd_filestat_set_times', 'fd_fdstat_set_rights', + 'path_filestat_get', 'path_filestat_set_times', 'path_readlink', 'path_unlink_file', + 'path_rename', 'path_create_directory', 'path_remove_directory', 'path_link', 'path_symlink', + 'sock_accept', 'sock_recv', 'sock_send', 'sock_shutdown', 'poll_oneoff', + ]) wasi[name] = () => 76; + const module = await WebAssembly.compile(bytes); + for (const entry of WebAssembly.Module.imports(module)) { + if (entry.module !== 'wasi_snapshot_preview1' || typeof wasi[entry.name] !== 'function') { + throw new Error(`Unsupported WASI import: ${entry.module}.${entry.name}`); + } + } + const instance = await WebAssembly.instantiate(module, { wasi_snapshot_preview1: wasi }); + const exportedMemory = instance.exports['memory']; + const start = instance.exports['_start']; + if (!(exportedMemory instanceof WebAssembly.Memory) || typeof start !== 'function') throw new Error('Invalid WASI exports'); + memory = exportedMemory; + try { start(); } + catch (error) { if (!(error instanceof Exit) || error.code !== 0) throw new Error(`${error instanceof Error ? error.message : 'WASI execution failed'}: ${errors}`); } + output += stdoutDecoder.decode(); + errors += stderrDecoder.decode(); + stdin.fill(0); + return { output, randomCalls, randomBytes }; +} + +async function runProbe(bytes: ArrayBuffer) { + const result = await execute(bytes, ''); + if (!result.output.startsWith('PASS: backend=OpenSSL ') || result.randomCalls === 0) { + throw new Error('OpenSSL probe did not pass'); + } + return result; +} + +async function runCommand(bytes: ArrayBuffer, request: StackChat.CryptoRequest): Promise { + const input = JSON.stringify(request); + if (input.length > 8388608) throw new Error('Crypto request exceeds its limit'); + const result = await execute(bytes, input); + const response: unknown = JSON.parse(result.output); + if (typeof response !== 'object' || response === null || Array.isArray(response)) throw new Error('Invalid crypto response'); + if ('error' in response) throw new Error(typeof response.error === 'string' ? response.error : 'Cryptographic operation failed'); + return response; +} + +globalThis.stackChatWasi = Object.freeze({ runCommand, runProbe }); + +// @license-end diff --git a/crates/chat-crypto/tests/browser.mjs b/crates/chat-crypto/tests/browser.mjs deleted file mode 100644 index 4fd2ee5..0000000 --- a/crates/chat-crypto/tests/browser.mjs +++ /dev/null @@ -1,87 +0,0 @@ -// Copyright (C) 2026 Doan Luu Duc Tai -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as -// published by the Free Software Foundation, either version 3 of the -// License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -import { createServer } from 'node:http'; -import { readFile } from 'node:fs/promises'; -import { resolve } from 'node:path'; -import assert from 'node:assert/strict'; -import { chromium } from '../../../.tools/browser/node_modules/playwright/index.mjs'; - -const assets = new Map([ - ['/wasi.mjs', [resolve('crates/chat-crypto/browser/wasi.mjs'), 'text/javascript']], - ['/probe.wasm', [resolve('target/wasm32-wasip1/release/stack-chat-crypto.wasm'), 'application/wasm']], -]); -const server = createServer(async (request, response) => { - response.setHeader('Cache-Control', 'no-store'); - if (request.url === '/') { - response.setHeader('Content-Type', 'text/html'); - response.end('Local cryptographic compatibility test'); - return; - } - const asset = assets.get(request.url); - if (!asset) { response.writeHead(404); response.end(); return; } - response.setHeader('Content-Type', asset[1]); - response.end(await readFile(asset[0])); -}); -await new Promise(resolveReady => server.listen(0, '127.0.0.1', resolveReady)); -const origin = `http://127.0.0.1:${server.address().port}`; -const browser = await chromium.launch({headless: true, channel: "chromium"}); -const deadline = setTimeout(() => browser.close(), 120000); -try { - const page = await browser.newPage(); - const external = []; - page.on('request', request => { - if (!request.url().startsWith(origin + '/')) external.push(request.url()); - }); - await page.goto(origin); - const result = await page.evaluate(async () => { - await import('/wasi.mjs'); - const { runProbe } = globalThis.stackChatWasi; - const bytes = await (await fetch('/probe.wasm', { cache: 'no-store' })).arrayBuffer(); - const valid = await runProbe(bytes); - const again = await runProbe(bytes); - const getRandomValues = crypto.getRandomValues; - let rejected = false; - crypto.getRandomValues = () => { throw new Error('Entropy unavailable'); }; - try { await runProbe(bytes); } catch (error) { rejected = error.message.includes("Entropy unavailable"); } - finally { crypto.getRandomValues = getRandomValues; } - const modified = new Uint8Array(bytes.slice(0)); - const pattern = new TextEncoder().encode('random_get'); - const offset = modified.findIndex((value, index) => pattern.every((byte, position) => modified[index + position] === byte)); - if (offset < 0) throw new Error('Expected entropy import was not found'); - modified.set(new TextEncoder().encode('reject_get'), offset); - let unknownImportRejected = false; - try { await runProbe(modified); } - catch (error) { unknownImportRejected = error.message.includes('Unsupported WASI import: wasi_snapshot_preview1.reject_get'); } - return { valid, again, rejected, unknownImportRejected }; - }); - assert.match(result.valid.output, /^PASS: backend=OpenSSL 3\.6\.5;/); - assert.match(result.again.output, /^PASS: backend=OpenSSL 3\.6\.5;/); - assert.ok(result.valid.randomCalls > 0 && result.valid.randomBytes > 0); - assert.ok(result.rejected, 'Unavailable browser entropy must fail closed'); - assert.ok(result.unknownImportRejected, 'Unknown host imports must fail closed'); - assert.deepEqual(external, []); - assert.deepEqual(await page.context().cookies(), []); - assert.equal(await page.evaluate(() => localStorage.length + sessionStorage.length), 0); - assert.deepEqual(await page.evaluate(async () => indexedDB.databases()), []); - assert.equal(await page.evaluate(async () => (await navigator.serviceWorker.getRegistrations()).length), 0); - console.log(result.valid.output.trim()); - console.log(`Browser CSPRNG: ${result.valid.randomCalls} calls, ${result.valid.randomBytes} bytes; missing entropy and unknown imports rejected.`); - console.log('PASS: actual Chromium WASM execution; no external requests, cookies, web storage, or IndexedDB.'); -} finally { - clearTimeout(deadline); - await browser.close(); - await new Promise(resolveClosed => server.close(resolveClosed)); -} diff --git a/crates/chat-crypto/tests/browser.ts b/crates/chat-crypto/tests/browser.ts new file mode 100644 index 0000000..75095ce --- /dev/null +++ b/crates/chat-crypto/tests/browser.ts @@ -0,0 +1,89 @@ +// Copyright (C) 2026 Doan Luu Duc Tai +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { createServer } from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import assert from 'node:assert/strict'; +import { chromium } from 'playwright'; +import { serverOrigin } from '../../../tests/browser-support.js'; + +const assets = new Map([ + ['/wasi.mjs', [resolve('var/build/typescript/crates/chat-crypto/browser/wasi.js'), 'text/javascript']], + ['/probe.wasm', [resolve('target/wasm32-wasip1/release/stack-chat-crypto.wasm'), 'application/wasm']], +]); +const server = createServer(async (request, response) => { + response.setHeader('Cache-Control', 'no-store'); + if (request.url === '/') { + response.setHeader('Content-Type', 'text/html'); + response.end('Local cryptographic compatibility test'); + return; + } + const asset = assets.get(request.url || ''); + if (!asset) { response.writeHead(404); response.end(); return; } + response.setHeader('Content-Type', asset[1]); + response.end(await readFile(asset[0])); +}); +await new Promise(resolveReady => server.listen(0, '127.0.0.1', resolveReady)); +const origin = serverOrigin(server); +const browser = await chromium.launch({headless: true, channel: "chromium"}); +const deadline = setTimeout(() => browser.close(), 120000); +try { + const page = await browser.newPage(); + const external: string[] = []; + page.on('request', request => { + if (!request.url().startsWith(origin + '/')) external.push(request.url()); + }); + await page.goto(origin); + const result = await page.evaluate(async () => { + const host = '/wasi.mjs'; + await import(host); + const { runProbe } = globalThis.stackChatWasi; + const bytes = await (await fetch('/probe.wasm', { cache: 'no-store' })).arrayBuffer(); + const valid = await runProbe(bytes); + const again = await runProbe(bytes); + const getRandomValues = crypto.getRandomValues; + let rejected = false; + crypto.getRandomValues = () => { throw new Error('Entropy unavailable'); }; + try { await runProbe(bytes); } catch (error) { rejected = (error instanceof Error ? error.message : String(error)).includes("Entropy unavailable"); } + finally { crypto.getRandomValues = getRandomValues; } + const modified = new Uint8Array(bytes.slice(0)); + const pattern = new TextEncoder().encode('random_get'); + const offset = modified.findIndex((_value, index) => pattern.every((byte, position) => modified[index + position] === byte)); + if (offset < 0) throw new Error('Expected entropy import was not found'); + modified.set(new TextEncoder().encode('reject_get'), offset); + let unknownImportRejected = false; + try { await runProbe(modified.buffer); } + catch (error) { unknownImportRejected = (error instanceof Error ? error.message : String(error)).includes('Unsupported WASI import: wasi_snapshot_preview1.reject_get'); } + return { valid, again, rejected, unknownImportRejected }; + }); + assert.match(result.valid.output, /^PASS: backend=OpenSSL 3\.6\.5;/); + assert.match(result.again.output, /^PASS: backend=OpenSSL 3\.6\.5;/); + assert.ok(result.valid.randomCalls > 0 && result.valid.randomBytes > 0); + assert.ok(result.rejected, 'Unavailable browser entropy must fail closed'); + assert.ok(result.unknownImportRejected, 'Unknown host imports must fail closed'); + assert.deepEqual(external, []); + assert.deepEqual(await page.context().cookies(), []); + assert.equal(await page.evaluate(() => localStorage.length + sessionStorage.length), 0); + assert.deepEqual(await page.evaluate(async () => indexedDB.databases()), []); + assert.equal(await page.evaluate(async () => (await navigator.serviceWorker.getRegistrations()).length), 0); + console.log(result.valid.output.trim()); + console.log(`Browser CSPRNG: ${result.valid.randomCalls} calls, ${result.valid.randomBytes} bytes; missing entropy and unknown imports rejected.`); + console.log('PASS: actual Chromium WASM execution; no external requests, cookies, web storage, or IndexedDB.'); +} finally { + clearTimeout(deadline); + await browser.close(); + await new Promise(resolveClosed => server.close(resolveClosed)); +} -- cgit v1.2.3