diff options
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/chat-crypto/Cargo.toml | 37 | ||||
| -rw-r--r-- | crates/chat-crypto/README.txt | 57 | ||||
| -rw-r--r-- | crates/chat-crypto/browser/wasi.mjs | 106 | ||||
| -rw-r--r-- | crates/chat-crypto/src/lib.rs | 223 | ||||
| -rw-r--r-- | crates/chat-crypto/src/main.rs | 23 | ||||
| -rw-r--r-- | crates/chat-crypto/tests/browser.mjs | 86 |
6 files changed, 532 insertions, 0 deletions
diff --git a/crates/chat-crypto/Cargo.toml b/crates/chat-crypto/Cargo.toml new file mode 100644 index 0000000..eaada21 --- /dev/null +++ b/crates/chat-crypto/Cargo.toml @@ -0,0 +1,37 @@ +# 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/>. + +[package] +name = "stack-chat-crypto" +version = "0.1.0" +edition = "2024" +license = "AGPL-3.0-or-later" +authors = ["Doan Luu Duc Tai <doan@luuduct.ai>"] +description = "Experimental OpenPGP browser compatibility proof for private chat." +repository = "https://git.doan.luuduct.ai/stack" +readme = "README.txt" +keywords = ["openpgp", "wasm", "chat"] +categories = ["cryptography"] +publish = false + +[lib] +crate-type = ["rlib"] + +[dependencies] +anyhow = "1" +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 new file mode 100644 index 0000000..d09ff82 --- /dev/null +++ b/crates/chat-crypto/README.txt @@ -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/>. + + +CHAT CRYPTOGRAPHY COMPATIBILITY PROBE + + Experimental, local-only work on feature/private-chat. This is a + cryptographic compatibility test, not a messaging application. Do not use + it for private conversations or real identities. It creates temporary + identities in memory and discards them; it does not create user chat files. + + Fixed encryption: RFC 9980 ML-KEM-1024 + X448, OpenPGP v6. + Tested signing: ML-DSA-87 + Ed448, OpenPGP v6. + 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-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. + +QUICK CHECKS FROM THE STACK 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 + 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 new file mode 100644 index 0000000..62319e2 --- /dev/null +++ b/crates/chat-crypto/src/lib.rs @@ -0,0 +1,223 @@ +// 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 = "Experimental Sequoia post-quantum browser compatibility probe."] + +use anyhow::{Context, Result, ensure}; +use pgp::Profile; +use pgp::cert::prelude::*; +use pgp::crypto::{Password, SessionKey}; +use pgp::parse::{ + Parse, + stream::{ + DecryptionHelper, DecryptorBuilder, MessageLayer, MessageStructure, VerificationHelper, + }, +}; +use pgp::policy::StandardPolicy; +use pgp::serialize::{ + Serialize, + stream::{Encryptor, LiteralWriter, Message, Signer}, +}; +use pgp::types::{KeyFlags, SymmetricAlgorithm}; +use sequoia_openpgp as pgp; +use std::io::Write; + +const PASSWORD: &str = "compatibility-test-only-not-a-real-secret"; +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."] +pub fn compatibility_probe() -> std::result::Result<String, String> { + probe().map_err(|error| format!("{error:#}")) +} + +fn identity() -> Result<Cert> { + let (cert, _revocation) = CertBuilder::new() + .set_profile(Profile::RFC9580)? + .set_cipher_suite(CipherSuite::MLDSA87_Ed448) + .set_primary_key_flags(KeyFlags::empty().set_certification().set_signing()) + .add_transport_encryption_subkey() + .set_password(Some(Password::from(PASSWORD))) + .generate()?; + let mut encoded = Vec::new(); + cert.as_tsk().serialize(&mut encoded)?; + Cert::from_bytes(&encoded) +} + +fn encrypt(sender: &Cert, other: &Cert) -> Result<Vec<u8>> { + let policy = StandardPolicy::new(); + let recipients = [sender, other].into_iter().flat_map(|cert| { + cert.keys() + .with_policy(&policy, None) + .supported() + .alive() + .revoked(false) + .for_transport_encryption() + }); + let signer = sender + .primary_key() + .key() + .parts_as_secret()? + .clone() + .decrypt_secret(&Password::from(PASSWORD))? + .into_keypair()?; + let mut ciphertext = Vec::new(); + let message = Message::new(&mut ciphertext); + let encrypted = Encryptor::for_recipients(message, recipients) + .symmetric_algo(SymmetricAlgorithm::AES256) + .build()?; + let authenticated_stream = Signer::new(encrypted, signer)?.build()?; + let mut literal = LiteralWriter::new(authenticated_stream).build()?; + literal.write_all(TEXT)?; + literal.finalize()?; + Ok(ciphertext) +} + +struct Helper<'a> { + recipient: &'a Cert, + sender: &'a Cert, + password: &'a str, +} + +impl VerificationHelper for Helper<'_> { + fn get_certs(&mut self, _ids: &[pgp::KeyHandle]) -> Result<Vec<Cert>> { + Ok(vec![self.sender.clone().strip_secret_key_material()]) + } + fn check(&mut self, structure: MessageStructure<'_>) -> Result<()> { + let mut signed = false; + for layer in structure { + if let MessageLayer::SignatureGroup { results } = layer { + ensure!(!results.is_empty(), "Missing signature"); + for result in results { + result.map_err(|error| { + anyhow::anyhow!("Signature verification failed: {error}") + })?; + } + signed = true; + } + } + ensure!(signed, "Unsigned message rejected"); + Ok(()) + } +} + +impl DecryptionHelper for Helper<'_> { + fn decrypt( + &mut self, + pkesks: &[pgp::packet::PKESK], + _skesks: &[pgp::packet::SKESK], + algorithm: Option<SymmetricAlgorithm>, + decrypt: &mut dyn FnMut(Option<SymmetricAlgorithm>, &SessionKey) -> bool, + ) -> Result<Option<Cert>> { + let policy = StandardPolicy::new(); + let key = self + .recipient + .keys() + .secret() + .with_policy(&policy, None) + .for_transport_encryption() + .next() + .context("Missing encryption subkey")? + .key() + .clone(); + let mut pair = key + .decrypt_secret(&Password::from(self.password))? + .into_keypair()?; + for packet in pkesks { + if packet + .decrypt(&mut pair, algorithm) + .is_some_and(|(cipher, key)| decrypt(cipher, &key)) + { + return Ok(Some(self.recipient.clone().strip_secret_key_material())); + } + } + anyhow::bail!("No decryptable recipient packet") + } +} + +fn decrypt(ciphertext: &[u8], recipient: &Cert, sender: &Cert, password: &str) -> Result<Vec<u8>> { + let policy = StandardPolicy::new(); + let helper = Helper { + recipient, + sender, + password, + }; + let mut reader = + DecryptorBuilder::from_bytes(ciphertext)?.with_policy(&policy, None, helper)?; + let mut plaintext = Vec::new(); + std::io::copy(&mut reader, &mut plaintext)?; + Ok(plaintext) +} + +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!( + sender.primary_key().key().pk_algo() == pgp::types::PublicKeyAlgorithm::MLDSA87_Ed448, + "Unexpected signing algorithm" + ); + ensure!( + sender.primary_key().key().version() == 6, + "Expected v6 identity" + ); + ensure!( + sender + .keys() + .any(|key| key.key().pk_algo() == pgp::types::PublicKeyAlgorithm::MLKEM1024_X448), + "Missing ML-KEM-1024+X448" + ); + let ciphertext = encrypt(&sender, &visitor)?; + ensure!( + decrypt(&ciphertext, &sender, &sender, PASSWORD)? == TEXT, + "Sender round-trip mismatch" + ); + ensure!( + decrypt(&ciphertext, &visitor, &sender, PASSWORD)? == TEXT, + "Visitor round-trip mismatch" + ); + ensure!( + decrypt(&ciphertext, &visitor, &sender, "wrong-password").is_err(), + "Wrong password accepted" + ); + ensure!( + decrypt(&ciphertext, &visitor, &visitor, PASSWORD).is_err(), + "Wrong signer accepted" + ); + let mut altered = ciphertext; + let byte = altered.last_mut().context("Empty ciphertext")?; + *byte ^= 1; + ensure!( + decrypt(&altered, &visitor, &sender, PASSWORD).is_err(), + "Tampered ciphertext accepted" + ); + 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)] +mod tests { + #[test] + fn required_crypto_round_trip() -> anyhow::Result<()> { + super::probe()?; + Ok(()) + } +} 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 new file mode 100644 index 0000000..8d2a9bb --- /dev/null +++ b/crates/chat-crypto/tests/browser.mjs @@ -0,0 +1,86 @@ +// 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/>. + +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'; + +const assets = new Map([ + ['/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'); + 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; } + 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}`; +const browser = await chromium.launch({headless: true, channel: "chromium"}); +const deadline = setTimeout(() => browser.close(), 120000); +try { + const page = await browser.newPage(); + const external = []; + page.on('request', request => { + if (!request.url().startsWith(origin + '/')) external.push(request.url()); + }); + await page.goto(origin); + const result = await page.evaluate(async () => { + 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.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.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); + await browser.close(); + await new Promise(resolveClosed => server.close(resolveClosed)); +} |
