diff options
Diffstat (limited to 'crates/chat-crypto')
| -rw-r--r-- | crates/chat-crypto/browser/protocol.d.ts | 6 | ||||
| -rw-r--r-- | crates/chat-crypto/browser/wasi.ts | 27 | ||||
| -rw-r--r-- | crates/chat-crypto/src/application.rs | 15 | ||||
| -rw-r--r-- | crates/chat-crypto/tests/browser.ts | 19 |
4 files changed, 38 insertions, 29 deletions
diff --git a/crates/chat-crypto/browser/protocol.d.ts b/crates/chat-crypto/browser/protocol.d.ts index 6803c4c..30b5bf8 100644 --- a/crates/chat-crypto/browser/protocol.d.ts +++ b/crates/chat-crypto/browser/protocol.d.ts @@ -49,9 +49,9 @@ declare namespace StackChat { interface WorkerRequest { bytes: ArrayBuffer; request: CryptoRequest } interface Execution { output: string; randomCalls: number; randomBytes: number } interface Wasi { - runCommand(bytes: ArrayBuffer, request: CryptoRequest): Promise<unknown>; - runProbe(bytes: ArrayBuffer): Promise<Execution>; + readonly runCommand: (bytes: ArrayBuffer, request: CryptoRequest) => Promise<unknown>; + readonly runProbe: (bytes: ArrayBuffer) => Promise<Execution>; } } -declare var stackChatWasi: Readonly<StackChat.Wasi>; +declare const stackChatWasi: Readonly<StackChat.Wasi>; diff --git a/crates/chat-crypto/browser/wasi.ts b/crates/chat-crypto/browser/wasi.ts index a037519..f2d9290 100644 --- a/crates/chat-crypto/browser/wasi.ts +++ b/crates/chat-crypto/browser/wasi.ts @@ -15,8 +15,12 @@ // @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-3.0 +function isStart(value: WebAssembly.ExportValue | undefined): value is () => unknown { + return typeof value === 'function' && value.length === 0; +} + async function execute(bytes: ArrayBuffer, input: string): Promise<StackChat.Execution> { - let memory: WebAssembly.Memory | undefined; + const instanceState: { memory: WebAssembly.Memory | undefined } = { memory: undefined }; let output = ''; let errors = ''; const stdoutDecoder = new TextDecoder('utf-8', { fatal: true }); @@ -26,15 +30,16 @@ async function execute(bytes: ArrayBuffer, input: string): Promise<StackChat.Exe let randomCalls = 0; let randomBytes = 0; const memoryBuffer = (): ArrayBuffer => { - if (!memory) throw new Error('WASI memory is unavailable'); + const memory = instanceState.memory; + if (memory === undefined) 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; }; + const put32 = (address: number, value: number): void => { data().setUint32(address, value, true); }; + const noEntries = (count: number, size: number): 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; } + constructor(code: number) { super(`WASI exit ${String(code)}`); this.code = code; } } const wasi: Record<string, WebAssembly.ImportValue> = { args_sizes_get: noEntries, @@ -108,7 +113,7 @@ async function execute(bytes: ArrayBuffer, input: string): Promise<StackChat.Exe '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; + ]) wasi[name] = (): number => 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') { @@ -118,17 +123,17 @@ async function execute(bytes: ArrayBuffer, input: string): Promise<StackChat.Exe 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; + if (!(exportedMemory instanceof WebAssembly.Memory) || !isStart(start)) throw new Error('Invalid WASI exports'); + instanceState.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}`); } + catch (error) { if (!(error instanceof Exit) || error.code !== 0) throw new Error(`${error instanceof Error ? error.message : 'WASI execution failed'}: ${errors}`, { cause: error }); } output += stdoutDecoder.decode(); errors += stderrDecoder.decode(); stdin.fill(0); return { output, randomCalls, randomBytes }; } -async function runProbe(bytes: ArrayBuffer) { +async function runProbe(bytes: ArrayBuffer): Promise<StackChat.Execution> { const result = await execute(bytes, ''); if (!result.output.startsWith('PASS: backend=OpenSSL ') || result.randomCalls === 0) { throw new Error('OpenSSL probe did not pass'); @@ -146,6 +151,6 @@ async function runCommand(bytes: ArrayBuffer, request: StackChat.CryptoRequest): return response; } -globalThis.stackChatWasi = Object.freeze({ runCommand, runProbe }); +Object.defineProperty(globalThis, 'stackChatWasi', { value: Object.freeze({ runCommand, runProbe }), writable: false, configurable: false }); // @license-end diff --git a/crates/chat-crypto/src/application.rs b/crates/chat-crypto/src/application.rs index 963e1de..c13f4f4 100644 --- a/crates/chat-crypto/src/application.rs +++ b/crates/chat-crypto/src/application.rs @@ -37,7 +37,7 @@ const FORMAT: &str = "stack-chat-file/v2;argon2id-m65536-t3-p1;aes256-gcm"; const LIMIT: usize = 262_144; #[derive(Deserialize, JsonSerialize)] -#[serde(deny_unknown_fields)] +#[serde(crate = "serde", deny_unknown_fields)] struct Identity { origin: String, name: String, @@ -46,7 +46,7 @@ struct Identity { } #[derive(Deserialize, JsonSerialize)] -#[serde(deny_unknown_fields)] +#[serde(crate = "serde", deny_unknown_fields)] struct Envelope { format: String, salt: String, @@ -56,14 +56,19 @@ struct Envelope { } #[derive(Deserialize, JsonSerialize)] -#[serde(untagged)] +#[serde(crate = "serde", untagged)] enum FileCiphertext { Compact(String), Wrapped(Vec<String>), } #[derive(Deserialize)] -#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)] +#[serde( + crate = "serde", + tag = "op", + rename_all = "snake_case", + deny_unknown_fields +)] enum Request { Create { origin: String, @@ -105,7 +110,7 @@ enum Request { } #[derive(Deserialize)] -#[serde(deny_unknown_fields)] +#[serde(crate = "serde", deny_unknown_fields)] struct EncryptedMessage { sender: String, ciphertext: String, diff --git a/crates/chat-crypto/tests/browser.ts b/crates/chat-crypto/tests/browser.ts index 75095ce..fd16529 100644 --- a/crates/chat-crypto/tests/browser.ts +++ b/crates/chat-crypto/tests/browser.ts @@ -13,33 +13,32 @@ // 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'; +import { asyncServer, closeBrowser, 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) => { +const server = asyncServer(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; } + const asset = assets.get((request.url ?? '')); + if (asset === undefined) { 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); +const deadline = setTimeout(() => { closeBrowser(browser); }, 120000); try { const page = await browser.newPage(); const external: string[] = []; @@ -50,13 +49,13 @@ try { const result = await page.evaluate(async () => { const host = '/wasi.mjs'; await import(host); - const { runProbe } = globalThis.stackChatWasi; + const { runProbe } = 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; + const getRandomValues = crypto.getRandomValues.bind(crypto); let rejected = false; - crypto.getRandomValues = () => { throw new Error('Entropy unavailable'); }; + crypto.getRandomValues = (): never => { 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)); @@ -80,7 +79,7 @@ try { 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(`Browser CSPRNG: ${String(result.valid.randomCalls)} calls, ${String(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); |
