aboutsummaryrefslogtreecommitdiff
path: root/crates
diff options
context:
space:
mode:
authorDoan Luu Duc Tai <doan@luuduct.ai>2026-10-30 17:24:45 -0500
committerDoan Luu Duc Tai <doan@luuduct.ai>2026-10-30 17:24:45 -0500
commit7a0ce2805258e8ac061202904379692d53d5dc95 (patch)
treea495aee0cbdc111cd9773f7d2d02753cd1d778d5 /crates
parent436e217c7858888702d2b6d9e655597ccadb7281 (diff)
Prove OpenSSL PQ crypto in browser WASI and document the migrationfeature/private-chat
Diffstat (limited to 'crates')
-rw-r--r--crates/chat-crypto/Cargo.toml8
-rw-r--r--crates/chat-crypto/README.txt63
-rw-r--r--crates/chat-crypto/browser/wasi.mjs106
-rw-r--r--crates/chat-crypto/src/lib.rs11
-rw-r--r--crates/chat-crypto/src/main.rs23
-rw-r--r--crates/chat-crypto/tests/browser.mjs36
6 files changed, 194 insertions, 53 deletions
diff --git a/crates/chat-crypto/Cargo.toml b/crates/chat-crypto/Cargo.toml
index b5bc0c7..eaada21 100644
--- a/crates/chat-crypto/Cargo.toml
+++ b/crates/chat-crypto/Cargo.toml
@@ -27,15 +27,11 @@ categories = ["cryptography"]
publish = false
[lib]
-crate-type = ["cdylib", "rlib"]
+crate-type = ["rlib"]
[dependencies]
anyhow = "1"
-sequoia-openpgp = { version = "=2.4.2", default-features = false, features = ["crypto-rust", "allow-experimental-crypto", "allow-variable-time-crypto"] }
-wasm-bindgen = "0.2"
-
-[target.'cfg(target_arch = "wasm32")'.dependencies]
-getrandom = { version = "0.4", features = ["wasm_js"] }
+sequoia-openpgp = { version = "=2.4.2", default-features = false, features = ["crypto-openssl"] }
[lints]
workspace = true
diff --git a/crates/chat-crypto/README.txt b/crates/chat-crypto/README.txt
index ba78cea..d09ff82 100644
--- a/crates/chat-crypto/README.txt
+++ b/crates/chat-crypto/README.txt
@@ -23,44 +23,35 @@ CHAT CRYPTOGRAPHY COMPATIBILITY PROBE
Fixed encryption: RFC 9980 ML-KEM-1024 + X448, OpenPGP v6.
Tested signing: ML-DSA-87 + Ed448, OpenPGP v6.
- No algorithm substitution or security-advisory ignore is permitted.
+ Backend: Sequoia 2.4.2 with OpenSSL 3.6.5. No algorithm substitution or
+ vulnerability-advisory exception was used. The first checkpoint's Rust
+ backend is historical and no longer selected.
- See ../../docs/CHAT-SECURITY-REVIEW.txt for the blocking dependency review,
- and ../../docs/CHAT-DESIGN.txt for the intended application and limitations.
+ See ../../docs/CHAT-OPENSSL-MIGRATION.txt for the complete decision record,
+ pinned source hashes, build flags, capability boundaries, dependency-policy
+ exceptions, observed results, limitations, and reproduction commands.
+ ../../docs/CHAT-SECURITY-REVIEW.txt records the original dependency findings.
+ ../../docs/CHAT-DESIGN.txt describes the planned application.
-REPRODUCE THE NATIVE TEST
+QUICK CHECKS FROM THE STACK ROOT
cargo test --locked -p stack-chat-crypto
- cargo clippy --locked -p stack-chat-crypto --all-targets
-
-REPRODUCE THE BROWSER TEST
-
- From the stack root, install development-only tools under .tools:
-
- rustup target add wasm32-unknown-unknown
- cargo install --locked --version 0.2.130 wasm-bindgen-cli --root .tools
- npm install --prefix .tools/browser --save-exact playwright@1.64.1
- PLAYWRIGHT_BROWSERS_PATH="$PWD/.tools/browsers" .tools/browser/node_modules/.bin/playwright install chromium
-
- Build and bind the Rust module, then run it in actual Chromium:
-
- cargo build --locked --release -p stack-chat-crypto --target wasm32-unknown-unknown
- .tools/bin/wasm-bindgen --target web --out-dir .tools/chat-proof target/wasm32-unknown-unknown/release/stack_chat_crypto.wasm
+ 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
-
- The harness serves only a blank test document and the generated JS/WASM on
- an ephemeral loopback port. It checks the actual browser cryptographic
- result and verifies that the probe creates no cookies, web storage, or
- IndexedDB databases and sends no requests to external hosts.
-
- This does not establish the storage or network behavior of a future chat
- application, which has not been built yet. LibreJS acceptance has not been
- tested; there is no public application or compliance badge for this probe.
- The test password is a public fixture, not a real configured secret.
-
-AUDIT
-
- .tools/bin/cargo-deny --workspace --all-features check
-
- This audit is intentionally blocking. Consult the security review rather
- than trusting a functional round trip as evidence of production security.
+ cargo stack check
+
+ Native checks require OpenSSL >=3.5 development files and libclang.
+ The cross-build script currently supports Linux x86_64 development hosts.
+ Browser tooling installation is documented in the migration record.
+
+ The harness serves only a blank document, a readable AGPL JavaScript WASI
+ bridge, and the new WASI binary on an ephemeral loopback port. It verifies
+ the actual OpenSSL backend, cryptographic round trips, tampering and invalid
+ credential rejection, repeated fresh instances, and failure without CSPRNG.
+ It checks for external requests and persistent browser application state.
+
+ The application itself, private chat-file envelope, and session workflow
+ remain unimplemented. LibreJS acceptance has not been tested; there is no
+ public chat page or compliance badge. The fixture password is public test
+ data, not a configured secret. No .env file or user private key is needed.
diff --git a/crates/chat-crypto/browser/wasi.mjs b/crates/chat-crypto/browser/wasi.mjs
new file mode 100644
index 0000000..d27fe4b
--- /dev/null
+++ b/crates/chat-crypto/browser/wasi.mjs
@@ -0,0 +1,106 @@
+// 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/>.
+
+// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-3.0
+
+export async function runProbe(bytes) {
+ let memory;
+ let output = '';
+ let errors = '';
+ 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 = new TextDecoder().decode(new Uint8Array(memory.buffer, address, length));
+ if (fd === 1) output += text; else errors += text;
+ total += length;
+ if (output.length + errors.length > 65536) throw new Error('Probe output exceeded its limit');
+ }
+ put32(written, total);
+ return 0;
+ },
+ fd_read(fd, vectors, count, read) { if (fd !== 0) return 8; put32(read, 0); 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}`); }
+ if (!output.startsWith('PASS: backend=OpenSSL ') || randomCalls === 0) {
+ throw new Error(`OpenSSL probe did not pass: ${errors || output}`);
+ }
+ return { output, randomCalls, randomBytes };
+}
+
+// @license-end
diff --git a/crates/chat-crypto/src/lib.rs b/crates/chat-crypto/src/lib.rs
index 93f6674..62319e2 100644
--- a/crates/chat-crypto/src/lib.rs
+++ b/crates/chat-crypto/src/lib.rs
@@ -33,7 +33,6 @@ use pgp::serialize::{
use pgp::types::{KeyFlags, SymmetricAlgorithm};
use sequoia_openpgp as pgp;
use std::io::Write;
-use wasm_bindgen::prelude::*;
const PASSWORD: &str = "compatibility-test-only-not-a-real-secret";
const TEXT: &[u8] = "stack-chat/v1: UTF-8 message — doan ↔ visitor".as_bytes();
@@ -41,7 +40,6 @@ const TEXT: &[u8] = "stack-chat/v1: UTF-8 message — doan ↔ visitor".as_bytes
#[doc = "Exercises v6 PQ generation, protected-key import, signing and two-recipient encryption."]
#[doc = "# Errors"]
#[doc = "Returns a diagnostic if a required operation or integrity check fails."]
-#[wasm_bindgen]
pub fn compatibility_probe() -> std::result::Result<String, String> {
probe().map_err(|error| format!("{error:#}"))
}
@@ -165,6 +163,11 @@ fn decrypt(ciphertext: &[u8], recipient: &Cert, sender: &Cert, password: &str) -
}
fn probe() -> Result<String> {
+ let backend = pgp::crypto::backend();
+ ensure!(
+ backend.starts_with("OpenSSL "),
+ "Expected OpenSSL backend, got {backend}"
+ );
let sender = identity()?;
let visitor = identity()?;
ensure!(
@@ -205,7 +208,9 @@ fn probe() -> Result<String> {
decrypt(&altered, &visitor, &sender, PASSWORD).is_err(),
"Tampered ciphertext accepted"
);
- Ok("PASS: RFC 9980 ML-KEM-1024+X448, ML-DSA-87+Ed448, v6 keys, protected key import, both recipients, wrong password, wrong signer, tampering".to_owned())
+ Ok(format!(
+ "PASS: backend={backend}; RFC 9980 ML-KEM-1024+X448, ML-DSA-87+Ed448, v6 keys, protected key import, both recipients, wrong password, wrong signer, tampering"
+ ))
}
#[cfg(test)]
diff --git a/crates/chat-crypto/src/main.rs b/crates/chat-crypto/src/main.rs
new file mode 100644
index 0000000..b6749cf
--- /dev/null
+++ b/crates/chat-crypto/src/main.rs
@@ -0,0 +1,23 @@
+// 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/>.
+
+#![doc = "Command entry point for the isolated cryptographic compatibility probe."]
+
+use std::io::{self, Write};
+
+fn main() -> io::Result<()> {
+ let result = stack_chat_crypto::compatibility_probe().map_err(io::Error::other)?;
+ writeln!(io::stdout().lock(), "{result}")
+}
diff --git a/crates/chat-crypto/tests/browser.mjs b/crates/chat-crypto/tests/browser.mjs
index c6aae93..8d2a9bb 100644
--- a/crates/chat-crypto/tests/browser.mjs
+++ b/crates/chat-crypto/tests/browser.mjs
@@ -20,8 +20,8 @@ import assert from 'node:assert/strict';
import { chromium } from '../../../.tools/browser/node_modules/playwright/index.mjs';
const assets = new Map([
- ['/crypto.js', ['stack_chat_crypto.js', 'text/javascript']],
- ['/stack_chat_crypto_bg.wasm', ['stack_chat_crypto_bg.wasm', 'application/wasm']],
+ ['/wasi.mjs', [resolve('crates/chat-crypto/browser/wasi.mjs'), 'text/javascript']],
+ ['/probe.wasm', [resolve('target/wasm32-wasip1/release/stack-chat-crypto.wasm'), 'application/wasm']],
]);
const server = createServer(async (request, response) => {
response.setHeader('Cache-Control', 'no-store');
@@ -33,7 +33,7 @@ const server = createServer(async (request, response) => {
const asset = assets.get(request.url);
if (!asset) { response.writeHead(404); response.end(); return; }
response.setHeader('Content-Type', asset[1]);
- response.end(await readFile(resolve('.tools/chat-proof', asset[0])));
+ 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}`;
@@ -47,17 +47,37 @@ try {
});
await page.goto(origin);
const result = await page.evaluate(async () => {
- const module = await import('/crypto.js');
- await module.default();
- return module.compatibility_probe();
+ const { runProbe } = await import('/wasi.mjs');
+ 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;
+ let rejected = false;
+ crypto.getRandomValues = () => { throw new Error('Entropy unavailable'); };
+ try { await runProbe(bytes); } catch (error) { rejected = error.message.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));
+ 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'); }
+ return { valid, again, rejected, unknownImportRejected };
});
- assert.match(result, /^PASS:/);
+ assert.match(result.valid.output, /^PASS: backend=OpenSSL 3\.6\.5;/);
+ assert.match(result.again.output, /^PASS: backend=OpenSSL 3\.6\.5;/);
+ assert.ok(result.valid.randomCalls > 0 && result.valid.randomBytes > 0);
+ assert.ok(result.rejected, 'Unavailable browser entropy must fail closed');
+ assert.ok(result.unknownImportRejected, 'Unknown host imports must fail closed');
assert.deepEqual(external, []);
assert.deepEqual(await page.context().cookies(), []);
assert.equal(await page.evaluate(() => localStorage.length + sessionStorage.length), 0);
assert.deepEqual(await page.evaluate(async () => indexedDB.databases()), []);
assert.equal(await page.evaluate(async () => (await navigator.serviceWorker.getRegistrations()).length), 0);
- console.log(result);
+ 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('PASS: actual Chromium WASM execution; no external requests, cookies, web storage, or IndexedDB.');
} finally {
clearTimeout(deadline);