summaryrefslogtreecommitdiff
path: root/crates/chat-crypto
diff options
context:
space:
mode:
Diffstat (limited to 'crates/chat-crypto')
-rw-r--r--crates/chat-crypto/Cargo.toml41
-rw-r--r--crates/chat-crypto/README.txt66
-rw-r--r--crates/chat-crypto/src/lib.rs218
-rw-r--r--crates/chat-crypto/tests/browser.mjs66
4 files changed, 391 insertions, 0 deletions
diff --git a/crates/chat-crypto/Cargo.toml b/crates/chat-crypto/Cargo.toml
new file mode 100644
index 0000000..b5bc0c7
--- /dev/null
+++ b/crates/chat-crypto/Cargo.toml
@@ -0,0 +1,41 @@
+# 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 = ["cdylib", "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"] }
+
+[lints]
+workspace = true
diff --git a/crates/chat-crypto/README.txt b/crates/chat-crypto/README.txt
new file mode 100644
index 0000000..ba78cea
--- /dev/null
+++ b/crates/chat-crypto/README.txt
@@ -0,0 +1,66 @@
+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.
+ No algorithm substitution or security-advisory ignore is permitted.
+
+ See ../../docs/CHAT-SECURITY-REVIEW.txt for the blocking dependency review,
+ and ../../docs/CHAT-DESIGN.txt for the intended application and limitations.
+
+REPRODUCE THE NATIVE TEST
+
+ 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
+ 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.
diff --git a/crates/chat-crypto/src/lib.rs b/crates/chat-crypto/src/lib.rs
new file mode 100644
index 0000000..93f6674
--- /dev/null
+++ b/crates/chat-crypto/src/lib.rs
@@ -0,0 +1,218 @@
+// 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;
+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();
+
+#[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:#}"))
+}
+
+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 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("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())
+}
+
+#[cfg(test)]
+mod tests {
+ #[test]
+ fn required_crypto_round_trip() -> anyhow::Result<()> {
+ super::probe()?;
+ Ok(())
+ }
+}
diff --git a/crates/chat-crypto/tests/browser.mjs b/crates/chat-crypto/tests/browser.mjs
new file mode 100644
index 0000000..c6aae93
--- /dev/null
+++ b/crates/chat-crypto/tests/browser.mjs
@@ -0,0 +1,66 @@
+// 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([
+ ['/crypto.js', ['stack_chat_crypto.js', 'text/javascript']],
+ ['/stack_chat_crypto_bg.wasm', ['stack_chat_crypto_bg.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(resolve('.tools/chat-proof', 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 module = await import('/crypto.js');
+ await module.default();
+ return module.compatibility_probe();
+ });
+ assert.match(result, /^PASS:/);
+ 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('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));
+}