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/browser/protocol.d.ts | 57 ++++++++++++ crates/chat-crypto/browser/wasi.mjs | 142 ----------------------------- crates/chat-crypto/browser/wasi.ts | 151 +++++++++++++++++++++++++++++++ 3 files changed, 208 insertions(+), 142 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 (limited to 'crates/chat-crypto/browser') 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 -- cgit v1.2.3