diff options
| author | Doan Luu Duc Tai <doan@luuduct.ai> | 2026-10-31 12:25:30 -0500 |
|---|---|---|
| committer | Doan Luu Duc Tai <doan@luuduct.ai> | 2026-10-31 12:25:30 -0500 |
| commit | 9139398d465f5899cc31bc4cdd54daa7392ca550 (patch) | |
| tree | debf0b15a969e8c64e3b3597f4eb7953b7f047e1 /crates/chat-crypto/browser | |
| parent | ef61fc5aca6eca613a055c507a060b70bd537a37 (diff) | |
Migrate browser code and tests to strict TypeScript
Diffstat (limited to 'crates/chat-crypto/browser')
| -rw-r--r-- | crates/chat-crypto/browser/protocol.d.ts | 57 | ||||
| -rw-r--r-- | crates/chat-crypto/browser/wasi.ts (renamed from crates/chat-crypto/browser/wasi.mjs) | 63 |
2 files changed, 93 insertions, 27 deletions
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 <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/>. + +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<unknown>; + runProbe(bytes: ArrayBuffer): Promise<Execution>; + } +} + +declare var stackChatWasi: Readonly<StackChat.Wasi>; diff --git a/crates/chat-crypto/browser/wasi.mjs b/crates/chat-crypto/browser/wasi.ts index 80d1299..a037519 100644 --- a/crates/chat-crypto/browser/wasi.mjs +++ b/crates/chat-crypto/browser/wasi.ts @@ -15,8 +15,8 @@ // @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-3.0 -async function execute(bytes, input) { - let memory; +async function execute(bytes: ArrayBuffer, input: string): Promise<StackChat.Execution> { + let memory: WebAssembly.Memory | undefined; let output = ''; let errors = ''; const stdoutDecoder = new TextDecoder('utf-8', { fatal: true }); @@ -25,19 +25,24 @@ async function execute(bytes, 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; }; + 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 { - constructor(code) { super(`WASI exit ${code}`); this.code = code; } + readonly code: number; + constructor(code: number) { super(`WASI exit ${code}`); this.code = code; } } - const wasi = { + const wasi: Record<string, WebAssembly.ImportValue> = { 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); + 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))); } @@ -45,20 +50,20 @@ async function execute(bytes, input) { randomBytes += length; return 0; }, - clock_time_get(clock, precision, address) { + 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, address) { data().setBigUint64(address, 1000000n, true); return 0; }, - fd_write(fd, vectors, count, written) { + 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(memory.buffer, address, length), { stream: 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'); @@ -66,36 +71,36 @@ async function execute(bytes, input) { put32(written, total); return 0; }, - fd_read(fd, vectors, count, read) { + 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(memory.buffer, address, length).set(stdin.subarray(position, position + length)); + new Uint8Array(memoryBuffer(), address, length).set(stdin.subarray(position, position + length)); position += length; total += length; } put32(read, total); return 0; }, - fd_fdstat_get(fd, address) { + fd_fdstat_get(fd: number, address: number) { if (fd > 2) return 8; - new Uint8Array(memory.buffer, address, 24).fill(0); + new Uint8Array(memoryBuffer(), address, 24).fill(0); data().setUint8(address, 2); return 0; }, - fd_fdstat_set_flags: fd => fd <= 2 ? 0 : 8, + fd_fdstat_set_flags: (fd: number) => fd <= 2 ? 0 : 8, fd_prestat_get: () => 8, fd_prestat_dir_name: () => 8, - fd_close: fd => fd <= 2 ? 0 : 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) { throw new Exit(code); }, + proc_exit(code: number) { throw new Exit(code); }, }; for (const name of [ 'fd_readdir', 'fd_advise', 'fd_allocate', 'fd_datasync', 'fd_sync', @@ -111,16 +116,19 @@ async function execute(bytes, input) { } } 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}`); } + 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) { +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'); @@ -128,12 +136,13 @@ async function runProbe(bytes) { return result; } -async function runCommand(bytes, request) { +async function runCommand(bytes: ArrayBuffer, request: StackChat.CryptoRequest): Promise<unknown> { 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); + 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; } |
