summaryrefslogtreecommitdiff
path: root/crates/chat-crypto/browser
diff options
context:
space:
mode:
Diffstat (limited to 'crates/chat-crypto/browser')
-rw-r--r--crates/chat-crypto/browser/protocol.d.ts57
-rw-r--r--crates/chat-crypto/browser/wasi.ts (renamed from crates/chat-crypto/browser/wasi.mjs)72
2 files changed, 100 insertions, 29 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..30b5bf8
--- /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 {
+ readonly runCommand: (bytes: ArrayBuffer, request: CryptoRequest) => Promise<unknown>;
+ readonly runProbe: (bytes: ArrayBuffer) => Promise<Execution>;
+ }
+}
+
+declare const stackChatWasi: Readonly<StackChat.Wasi>;
diff --git a/crates/chat-crypto/browser/wasi.mjs b/crates/chat-crypto/browser/wasi.ts
index 80d1299..f2d9290 100644
--- a/crates/chat-crypto/browser/wasi.mjs
+++ 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
-async function execute(bytes, input) {
- let memory;
+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> {
+ const instanceState: { memory: WebAssembly.Memory | undefined } = { memory: undefined };
let output = '';
let errors = '';
const stdoutDecoder = new TextDecoder('utf-8', { fatal: true });
@@ -25,19 +29,25 @@ 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 => {
+ 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): 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 {
- constructor(code) { super(`WASI exit ${code}`); this.code = code; }
+ readonly code: number;
+ constructor(code: number) { super(`WASI exit ${String(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 +55,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 +76,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',
@@ -103,7 +113,7 @@ async function execute(bytes, input) {
'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') {
@@ -111,16 +121,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) || !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}`, { cause: error }); }
output += stdoutDecoder.decode();
errors += stderrDecoder.decode();
stdin.fill(0);
return { output, randomCalls, randomBytes };
}
-async function runProbe(bytes) {
+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');
@@ -128,15 +141,16 @@ 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;
}
-globalThis.stackChatWasi = Object.freeze({ runCommand, runProbe });
+Object.defineProperty(globalThis, 'stackChatWasi', { value: Object.freeze({ runCommand, runProbe }), writable: false, configurable: false });
// @license-end