summaryrefslogtreecommitdiff
path: root/crates/chat-crypto
diff options
context:
space:
mode:
Diffstat (limited to 'crates/chat-crypto')
-rw-r--r--crates/chat-crypto/browser/protocol.d.ts6
-rw-r--r--crates/chat-crypto/browser/wasi.ts27
-rw-r--r--crates/chat-crypto/src/application.rs15
-rw-r--r--crates/chat-crypto/tests/browser.ts19
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);