summaryrefslogtreecommitdiff
path: root/crates/chat-crypto
diff options
context:
space:
mode:
Diffstat (limited to 'crates/chat-crypto')
-rw-r--r--crates/chat-crypto/README.txt7
-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
-rw-r--r--crates/chat-crypto/src/application.rs128
-rw-r--r--crates/chat-crypto/tests/browser.ts (renamed from crates/chat-crypto/tests/browser.mjs)41
5 files changed, 238 insertions, 67 deletions
diff --git a/crates/chat-crypto/README.txt b/crates/chat-crypto/README.txt
index 2ee3f01..114544d 100644
--- a/crates/chat-crypto/README.txt
+++ b/crates/chat-crypto/README.txt
@@ -32,7 +32,7 @@ PRIVATE CHAT CRYPTOGRAPHY
See ../../docs/CHAT-APPLICATION.txt for implemented formats, local setup,
tests, backups, and limitations. See ../../docs/CHAT-PRODUCTION.txt for
- packaged HTTPS integration. Work remains experimental on feature/private-chat;
+ packaged HTTPS integration. Work remains experimental;
compatibility tests are not an application security audit.
../../docs/CHAT-OPENSSL-MIGRATION.txt records the complete backend decision,
@@ -45,11 +45,12 @@ CHECKS FROM THE WORKSPACE ROOT
cargo test --locked -p stack-chat-crypto
cargo run --locked -p stack-chat-crypto
sh infra/chat/build-openssl-wasi.sh
- PLAYWRIGHT_BROWSERS_PATH="$PWD/.tools/browsers" node crates/chat-crypto/tests/browser.mjs
+ PLAYWRIGHT_BROWSERS_PATH="$PWD/.tools/browsers" npm run test:crypto
cargo stack check
Native builds require OpenSSL >=3.5 development files and libclang.
The cross-build script supports an x86_64 GNU/Linux development host.
- Browser tooling is excluded from the public source tree and releases.
+ Installed browser tooling is excluded from source archives and releases.
+ The TypeScript tests and locked toolchain declarations are public.
The probe uses public fixture credentials and creates temporary identities
in memory. It does not open the root .env or use the canonical RSA key.
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
diff --git a/crates/chat-crypto/src/application.rs b/crates/chat-crypto/src/application.rs
index 98e8a20..c13f4f4 100644
--- a/crates/chat-crypto/src/application.rs
+++ b/crates/chat-crypto/src/application.rs
@@ -32,11 +32,12 @@ use std::io::{Read, Write};
use zeroize::Zeroizing;
const PURPOSE: &str = "Stack private chat identity only; do not reuse";
-const FORMAT: &str = "stack-chat-file/v1;argon2id-m65536-t3-p1;aes256-gcm";
+const LEGACY_FORMAT: &str = "stack-chat-file/v1;argon2id-m65536-t3-p1;aes256-gcm";
+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,
@@ -45,17 +46,29 @@ struct Identity {
}
#[derive(Deserialize, JsonSerialize)]
-#[serde(deny_unknown_fields)]
+#[serde(crate = "serde", deny_unknown_fields)]
struct Envelope {
format: String,
salt: String,
nonce: String,
- ciphertext: String,
+ ciphertext: FileCiphertext,
tag: String,
}
+#[derive(Deserialize, JsonSerialize)]
+#[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,
@@ -97,7 +110,7 @@ enum Request {
}
#[derive(Deserialize)]
-#[serde(deny_unknown_fields)]
+#[serde(crate = "serde", deny_unknown_fields)]
struct EncryptedMessage {
sender: String,
ciphertext: String,
@@ -201,11 +214,17 @@ fn protect(identity: &Identity, password: &str) -> Result<String> {
&plaintext,
&mut tag,
)?;
- Ok(serde_json::to_string(&Envelope {
+ let encoded = BASE64_STANDARD.encode(ciphertext);
+ let chunks = encoded
+ .as_bytes()
+ .chunks(80)
+ .map(|chunk| std::str::from_utf8(chunk).map(str::to_owned))
+ .collect::<std::result::Result<Vec<_>, _>>()?;
+ Ok(serde_json::to_string_pretty(&Envelope {
format: FORMAT.into(),
salt: BASE64_STANDARD.encode(salt),
nonce: BASE64_STANDARD.encode(nonce),
- ciphertext: BASE64_STANDARD.encode(ciphertext),
+ ciphertext: FileCiphertext::Wrapped(chunks),
tag: BASE64_STANDARD.encode(tag),
})?)
}
@@ -213,10 +232,20 @@ fn protect(identity: &Identity, password: &str) -> Result<String> {
fn unlock(file: &str, password: &str, origin: &str) -> Result<(Identity, Cert)> {
ensure!(file.len() <= LIMIT, "Identity file exceeds its limit");
let envelope: Envelope = serde_json::from_str(file)?;
- ensure!(
- envelope.format == FORMAT,
- "Unsupported identity file format"
- );
+ let encoded = match (envelope.format.as_str(), &envelope.ciphertext) {
+ (LEGACY_FORMAT, FileCiphertext::Compact(value)) => value.clone(),
+ (FORMAT, FileCiphertext::Wrapped(chunks)) => {
+ ensure!(
+ !chunks.is_empty()
+ && chunks
+ .iter()
+ .all(|chunk| !chunk.is_empty() && chunk.len() <= 80),
+ "Invalid ciphertext wrapping"
+ );
+ chunks.concat()
+ }
+ _ => anyhow::bail!("Unsupported identity file format"),
+ };
let salt = decode(&envelope.salt)?;
let nonce = decode(&envelope.nonce)?;
let tag = decode(&envelope.tag)?;
@@ -224,13 +253,13 @@ fn unlock(file: &str, password: &str, origin: &str) -> Result<(Identity, Cert)>
salt.len() == 16 && nonce.len() == 12 && tag.len() == 16,
"Malformed envelope"
);
- let ciphertext = decode(&envelope.ciphertext)?;
+ let ciphertext = decode(&encoded)?;
let key = derive(password, &salt)?;
let plaintext = Zeroizing::new(decrypt_aead(
Cipher::aes_256_gcm(),
key.as_ref(),
Some(&nonce),
- FORMAT.as_bytes(),
+ envelope.format.as_bytes(),
&ciphertext,
&tag,
)?);
@@ -643,7 +672,7 @@ mod tests {
format: FORMAT.into(),
salt: BASE64_STANDARD.encode([0; 15]),
nonce: BASE64_STANDARD.encode([0; 12]),
- ciphertext: String::new(),
+ ciphertext: FileCiphertext::Wrapped(Vec::new()),
tag: BASE64_STANDARD.encode([0; 16]),
})?;
ensure!(
@@ -657,6 +686,74 @@ mod tests {
Ok(())
}
+ fn check_file_formats(
+ file: &str,
+ identity: &Identity,
+ password: &str,
+ origin: &str,
+ ) -> Result<()> {
+ ensure!(
+ file.starts_with("{\n \"format\""),
+ "Envelope is not indented"
+ );
+ let envelope: Envelope = serde_json::from_str(file)?;
+ let FileCiphertext::Wrapped(chunks) = &envelope.ciphertext else {
+ anyhow::bail!("Ciphertext is not wrapped");
+ };
+ let (last, complete) = chunks.split_last().context("Missing ciphertext")?;
+ ensure!(
+ complete.iter().all(|chunk| chunk.len() == 80) && (1..=80).contains(&last.len()),
+ "Wrong ciphertext width"
+ );
+ let salt = random::<16>()?;
+ let nonce = random::<12>()?;
+ let key = derive(password, &salt)?;
+ let mut tag = [0; 16];
+ let plaintext = Zeroizing::new(serde_json::to_vec(identity)?);
+ let ciphertext = encrypt_aead(
+ Cipher::aes_256_gcm(),
+ key.as_ref(),
+ Some(&nonce),
+ LEGACY_FORMAT.as_bytes(),
+ &plaintext,
+ &mut tag,
+ )?;
+ let legacy = Envelope {
+ format: LEGACY_FORMAT.into(),
+ salt: BASE64_STANDARD.encode(salt),
+ nonce: BASE64_STANDARD.encode(nonce),
+ ciphertext: FileCiphertext::Compact(BASE64_STANDARD.encode(ciphertext)),
+ tag: BASE64_STANDARD.encode(tag),
+ };
+ for historical in [
+ serde_json::to_string(&legacy)?,
+ serde_json::to_string_pretty(&legacy)?,
+ ] {
+ let (restored, _certificate) = unlock(&historical, password, origin)?;
+ ensure!(
+ restored.secret.as_str() == identity.secret.as_str(),
+ "Legacy identity changed"
+ );
+ ensure!(
+ unlock(&historical, "wrong-passphrase", origin).is_err(),
+ "Legacy password protection failed"
+ );
+ }
+ let mut malformed = serde_json::to_value(&envelope)?;
+ *malformed.get_mut("ciphertext").context("Ciphertext")? = json!(["A".repeat(81)]);
+ ensure!(
+ unlock(&malformed.to_string(), password, origin).is_err(),
+ "Overlong ciphertext chunk accepted"
+ );
+ *malformed.get_mut("format").context("Format")? = json!(LEGACY_FORMAT);
+ *malformed.get_mut("ciphertext").context("Ciphertext")? = json!(chunks.concat());
+ ensure!(
+ unlock(&malformed.to_string(), password, origin).is_err(),
+ "Unauthenticated file-version downgrade accepted"
+ );
+ Ok(())
+ }
+
#[test]
fn protected_file_and_authenticated_messages() -> Result<()> {
let origin = "http://127.0.0.1:9080";
@@ -668,6 +765,7 @@ mod tests {
.as_str()
.context("File")?;
let (owner_identity, owner_cert) = unlock(owner_file, password, origin)?;
+ check_file_formats(owner_file, &owner_identity, password, origin)?;
let owner_public = public(&owner_cert)?;
let visitor = create(origin.into(), "Visitor".into(), password, owner_public)?;
let visitor_file = visitor
diff --git a/crates/chat-crypto/tests/browser.mjs b/crates/chat-crypto/tests/browser.ts
index 4fd2ee5..fd16529 100644
--- a/crates/chat-crypto/tests/browser.mjs
+++ b/crates/chat-crypto/tests/browser.ts
@@ -13,58 +13,59 @@
// 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 '../../../.tools/browser/node_modules/playwright/index.mjs';
+import { chromium } from 'playwright';
+import { asyncServer, closeBrowser, serverOrigin } from '../../../tests/browser-support.js';
-const assets = new Map([
- ['/wasi.mjs', [resolve('crates/chat-crypto/browser/wasi.mjs'), 'text/javascript']],
+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(resolveReady => server.listen(0, '127.0.0.1', resolveReady));
-const origin = `http://127.0.0.1:${server.address().port}`;
+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 = [];
+ const external: string[] = [];
page.on('request', request => {
if (!request.url().startsWith(origin + '/')) external.push(request.url());
});
await page.goto(origin);
const result = await page.evaluate(async () => {
- await import('/wasi.mjs');
- const { runProbe } = globalThis.stackChatWasi;
+ const host = '/wasi.mjs';
+ await import(host);
+ 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'); };
- try { await runProbe(bytes); } catch (error) { rejected = error.message.includes("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));
const pattern = new TextEncoder().encode('random_get');
- const offset = modified.findIndex((value, index) => pattern.every((byte, position) => modified[index + position] === byte));
+ const offset = modified.findIndex((_value, index) => pattern.every((byte, position) => modified[index + position] === byte));
if (offset < 0) throw new Error('Expected entropy import was not found');
modified.set(new TextEncoder().encode('reject_get'), offset);
let unknownImportRejected = false;
- try { await runProbe(modified); }
- catch (error) { unknownImportRejected = error.message.includes('Unsupported WASI import: wasi_snapshot_preview1.reject_get'); }
+ try { await runProbe(modified.buffer); }
+ catch (error) { unknownImportRejected = (error instanceof Error ? error.message : String(error)).includes('Unsupported WASI import: wasi_snapshot_preview1.reject_get'); }
return { valid, again, rejected, unknownImportRejected };
});
assert.match(result.valid.output, /^PASS: backend=OpenSSL 3\.6\.5;/);
@@ -78,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);