diff options
Diffstat (limited to 'crates/chat-crypto/tests/browser.ts')
| -rw-r--r-- | crates/chat-crypto/tests/browser.ts | 89 |
1 files changed, 89 insertions, 0 deletions
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 <doan@luuduct.ai> +// +// 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 <https://www.gnu.org/licenses/>. + +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<string, [string, string]>([ + ['/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('<!doctype html><title>Local cryptographic compatibility test</title>'); + 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<void>(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)); +} |
