diff options
| author | Doan Luu Duc Tai <doan@luuduct.ai> | 2026-10-30 21:39:57 -0500 |
|---|---|---|
| committer | Doan Luu Duc Tai <doan@luuduct.ai> | 2026-10-30 21:39:57 -0500 |
| commit | e6b350a1d48ca0490b7a3447bb8d0a4235514b76 (patch) | |
| tree | 57ae2a9c5455f335e8c34b293f4f13975cb3d09e /crates/chat-crypto | |
| parent | 0a4da3b86eb69f0f09b802a55e36fa9cf1bbf38c (diff) | |
Implement experimental file-based encrypted private chat
Add fixed-suite Rust/WASI identity files, signed challenge authentication, ciphertext-only SQLite storage, a minimal LibreJS-compatible interface, and consistent backup snapshots. Keep production unchanged and document the local workflow, limits, source distribution, and browser recovery tests.
Diffstat (limited to 'crates/chat-crypto')
| -rw-r--r-- | crates/chat-crypto/Cargo.toml | 8 | ||||
| -rw-r--r-- | crates/chat-crypto/README.txt | 56 | ||||
| -rw-r--r-- | crates/chat-crypto/browser/wasi.mjs | 50 | ||||
| -rw-r--r-- | crates/chat-crypto/src/application.rs | 753 | ||||
| -rw-r--r-- | crates/chat-crypto/src/lib.rs | 6 | ||||
| -rw-r--r-- | crates/chat-crypto/src/main.rs | 18 | ||||
| -rw-r--r-- | crates/chat-crypto/tests/browser.mjs | 3 |
7 files changed, 852 insertions, 42 deletions
diff --git a/crates/chat-crypto/Cargo.toml b/crates/chat-crypto/Cargo.toml index eaada21..738b539 100644 --- a/crates/chat-crypto/Cargo.toml +++ b/crates/chat-crypto/Cargo.toml @@ -19,7 +19,7 @@ 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." +description = "Purpose-specific OpenPGP identity files and encrypted private chat." repository = "https://git.doan.luuduct.ai/stack" readme = "README.txt" keywords = ["openpgp", "wasm", "chat"] @@ -31,6 +31,12 @@ crate-type = ["rlib"] [dependencies] anyhow = "1" +argon2 = "0.5" +base64 = "0.22" +openssl = "0.10" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +zeroize = { version = "1", features = ["serde"] } sequoia-openpgp = { version = "=2.4.2", default-features = false, features = ["crypto-openssl"] } [lints] diff --git a/crates/chat-crypto/README.txt b/crates/chat-crypto/README.txt index d09ff82..e04246c 100644 --- a/crates/chat-crypto/README.txt +++ b/crates/chat-crypto/README.txt @@ -14,26 +14,33 @@ 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. +PRIVATE CHAT CRYPTOGRAPHY Fixed encryption: RFC 9980 ML-KEM-1024 + X448, OpenPGP v6. - Tested signing: ML-DSA-87 + Ed448, OpenPGP v6. + 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. + vulnerability-advisory exception was used. The original RustCrypto backend + is historical and no longer selected. + + This crate implements purpose-specific identity creation, Argon2id/AES-256-GCM + protected files, detached authentication proofs, signed two-recipient messages, + bounded verified history batches, and the original compatibility probe. + Native Rust and browser WASI share the implementation. The standalone command + accepts one JSON request on stdin; empty input runs the compatibility probe. + Its browser WASI host grants no filesystem or network access. Private request + data must remain in local memory and must never be submitted to the server. + + See ../../docs/CHAT-APPLICATION.txt for implemented formats, local setup, + tests, backups, and limitations. Work remains experimental and local on + feature/private-chat. Production remains unchanged. Do not mistake the + compatibility probe for an application security audit. - 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. + ../../docs/CHAT-OPENSSL-MIGRATION.txt records the complete backend decision, + pinned source hashes, build flags, FFI/capability limitations, and reviewed + dependency-policy exceptions. ../../docs/CHAT-SECURITY-REVIEW.txt records + the superseded RustCrypto findings without advisory ignores. -QUICK CHECKS FROM THE STACK ROOT +CHECKS FROM THE WORKSPACE ROOT cargo test --locked -p stack-chat-crypto cargo run --locked -p stack-chat-crypto @@ -41,17 +48,8 @@ QUICK CHECKS FROM THE STACK ROOT 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. + 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. + 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/wasi.mjs b/crates/chat-crypto/browser/wasi.mjs index d27fe4b..80d1299 100644 --- a/crates/chat-crypto/browser/wasi.mjs +++ b/crates/chat-crypto/browser/wasi.mjs @@ -15,10 +15,14 @@ // @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-3.0 -export async function runProbe(bytes) { +async function execute(bytes, input) { let memory; let output = ''; let errors = ''; + const stdoutDecoder = new TextDecoder('utf-8', { fatal: true }); + const stderrDecoder = new TextDecoder('utf-8', { fatal: true }); + const stdin = new TextEncoder().encode(input); + let position = 0; let randomCalls = 0; let randomBytes = 0; const data = () => new DataView(memory.buffer); @@ -54,15 +58,28 @@ export async function runProbe(bytes) { 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)); + const text = (fd === 1 ? stdoutDecoder : stderrDecoder).decode(new Uint8Array(memory.buffer, address, length), { stream: true }); if (fd === 1) output += text; else errors += text; total += length; - if (output.length + errors.length > 65536) throw new Error('Probe output exceeded its limit'); + if (output.length + errors.length > 1048576) 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_read(fd, vectors, count, read) { + 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)); + position += length; + total += length; + } + put32(read, total); + return 0; + }, fd_fdstat_get(fd, address) { if (fd > 2) return 8; new Uint8Array(memory.buffer, address, 24).fill(0); @@ -97,10 +114,29 @@ export async function runProbe(bytes) { 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}`); - } + output += stdoutDecoder.decode(); + errors += stderrDecoder.decode(); + stdin.fill(0); return { output, randomCalls, randomBytes }; } +async function runProbe(bytes) { + const result = await execute(bytes, ''); + if (!result.output.startsWith('PASS: backend=OpenSSL ') || result.randomCalls === 0) { + throw new Error('OpenSSL probe did not pass'); + } + return result; +} + +async function runCommand(bytes, request) { + 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); + return response; +} + +globalThis.stackChatWasi = Object.freeze({ runCommand, runProbe }); + // @license-end diff --git a/crates/chat-crypto/src/application.rs b/crates/chat-crypto/src/application.rs new file mode 100644 index 0000000..98e8a20 --- /dev/null +++ b/crates/chat-crypto/src/application.rs @@ -0,0 +1,753 @@ +// 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/>. + +use anyhow::{Context, Result, ensure}; +use argon2::{Algorithm, Argon2, Params, Version}; +use base64::{Engine, prelude::BASE64_STANDARD}; +use openssl::symm::{Cipher, decrypt_aead, encrypt_aead}; +use pgp::cert::prelude::*; +use pgp::crypto::SessionKey; +use pgp::parse::{Parse, stream::DetachedVerifierBuilder}; +use pgp::serialize::{ + Serialize, + stream::{Encryptor, LiteralWriter, Message, Signer}, +}; +use pgp::types::{KeyFlags, PublicKeyAlgorithm, SymmetricAlgorithm}; +use sequoia_openpgp as pgp; +use serde::{Deserialize, Serialize as JsonSerialize}; +use serde_json::{Value, json}; +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 LIMIT: usize = 262_144; + +#[derive(Deserialize, JsonSerialize)] +#[serde(deny_unknown_fields)] +struct Identity { + origin: String, + name: String, + owner: String, + secret: Zeroizing<String>, +} + +#[derive(Deserialize, JsonSerialize)] +#[serde(deny_unknown_fields)] +struct Envelope { + format: String, + salt: String, + nonce: String, + ciphertext: String, + tag: String, +} + +#[derive(Deserialize)] +#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)] +enum Request { + Create { + origin: String, + name: String, + password: Zeroizing<String>, + owner: String, + }, + Open { + file: String, + password: Zeroizing<String>, + origin: String, + }, + Proof { + file: String, + password: Zeroizing<String>, + origin: String, + challenge: String, + }, + Encrypt { + file: String, + password: Zeroizing<String>, + origin: String, + recipient: String, + payload: String, + }, + DecryptBatch { + file: String, + password: Zeroizing<String>, + origin: String, + messages: Vec<EncryptedMessage>, + }, + Decrypt { + file: String, + password: Zeroizing<String>, + origin: String, + sender: String, + ciphertext: String, + }, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct EncryptedMessage { + sender: String, + ciphertext: String, +} + +fn decode(value: &str) -> Result<Vec<u8>> { + ensure!(value.len() <= LIMIT, "Input exceeds its limit"); + Ok(BASE64_STANDARD.decode(value)?) +} + +fn random<const N: usize>() -> Result<[u8; N]> { + let mut bytes = [0; N]; + pgp::crypto::random(&mut bytes)?; + Ok(bytes) +} + +fn certificate(encoded: &str) -> Result<Cert> { + ensure!(encoded.len() <= 65_536, "Certificate exceeds its limit"); + let cert = Cert::from_bytes(&decode(encoded)?)?; + let policy = pgp::policy::StandardPolicy::new(); + ensure!(cert.keys().count() == 2, "Unexpected number of keys"); + ensure!( + cert.primary_key().key().version() == 6 + && cert.primary_key().key().pk_algo() == PublicKeyAlgorithm::MLDSA87_Ed448, + "Wrong signing suite" + ); + ensure!( + cert.keys().subkeys().all(|key| key.key().version() == 6 + && key.key().pk_algo() == PublicKeyAlgorithm::MLKEM1024_X448), + "Wrong encryption suite" + ); + ensure!( + cert.userids().count() == 1 + && cert + .userids() + .any(|uid| uid.userid().value() == PURPOSE.as_bytes()), + "Identity is not purpose-specific" + ); + cert.with_policy(&policy, None)?.alive()?; + ensure!( + !matches!( + cert.revocation_status(&policy, None), + pgp::types::RevocationStatus::Revoked(_) + ), + "Revoked identity" + ); + ensure!( + cert.keys() + .with_policy(&policy, None) + .alive() + .revoked(false) + .for_transport_encryption() + .count() + == 1, + "Missing valid encryption subkey" + ); + Ok(cert) +} + +fn public(cert: &Cert) -> Result<String> { + let mut encoded = Vec::new(); + cert.clone() + .strip_secret_key_material() + .serialize(&mut encoded)?; + Ok(BASE64_STANDARD.encode(encoded)) +} + +#[doc = "Validates the fixed-suite public identity and returns its full fingerprint."] +#[doc = "# Errors"] +#[doc = "Rejects secret material, invalid certificates, and every alternative key suite."] +pub fn public_identity(encoded: &str) -> Result<String> { + let cert = certificate(encoded)?; + ensure!(!cert.is_tsk(), "Secret material is forbidden on the server"); + Ok(cert.fingerprint().to_hex()) +} + +fn derive(password: &str, salt: &[u8]) -> Result<Zeroizing<[u8; 32]>> { + ensure!( + (12..=1024).contains(&password.len()), + "Use a password of 12 to 1024 UTF-8 bytes" + ); + let params = Params::new(65_536, 3, 1, Some(32)).map_err(|error| anyhow::anyhow!("{error}"))?; + let mut key = Zeroizing::new([0; 32]); + Argon2::new(Algorithm::Argon2id, Version::V0x13, params) + .hash_password_into(password.as_bytes(), salt, key.as_mut()) + .map_err(|error| anyhow::anyhow!("{error}"))?; + Ok(key) +} + +fn protect(identity: &Identity, password: &str) -> Result<String> { + let plaintext = Zeroizing::new(serde_json::to_vec(identity)?); + let salt = random::<16>()?; + let nonce = random::<12>()?; + let key = derive(password, &salt)?; + let mut tag = [0; 16]; + let ciphertext = encrypt_aead( + Cipher::aes_256_gcm(), + key.as_ref(), + Some(&nonce), + FORMAT.as_bytes(), + &plaintext, + &mut tag, + )?; + Ok(serde_json::to_string(&Envelope { + format: FORMAT.into(), + salt: BASE64_STANDARD.encode(salt), + nonce: BASE64_STANDARD.encode(nonce), + ciphertext: BASE64_STANDARD.encode(ciphertext), + tag: BASE64_STANDARD.encode(tag), + })?) +} + +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 salt = decode(&envelope.salt)?; + let nonce = decode(&envelope.nonce)?; + let tag = decode(&envelope.tag)?; + ensure!( + salt.len() == 16 && nonce.len() == 12 && tag.len() == 16, + "Malformed envelope" + ); + let ciphertext = decode(&envelope.ciphertext)?; + let key = derive(password, &salt)?; + let plaintext = Zeroizing::new(decrypt_aead( + Cipher::aes_256_gcm(), + key.as_ref(), + Some(&nonce), + FORMAT.as_bytes(), + &ciphertext, + &tag, + )?); + let identity: Identity = serde_json::from_slice(&plaintext)?; + ensure!( + identity.origin == origin, + "Identity file belongs to another origin" + ); + let cert = certificate(&identity.secret)?; + ensure!(cert.is_tsk(), "Missing private identity"); + public_identity(&identity.owner)?; + Ok((identity, cert)) +} + +fn create(origin: String, name: String, password: &str, owner: String) -> Result<Value> { + ensure!( + origin.len() <= 256 + && (origin.starts_with("https://") || origin.starts_with("http://127.0.0.1:")), + "Invalid origin" + ); + ensure!( + !name.trim().is_empty() && name.len() <= 80 && !name.chars().any(char::is_control), + "Use a display name of 1 to 80 UTF-8 bytes" + ); + let (cert, _revocation) = CertBuilder::new() + .set_profile(pgp::Profile::RFC9580)? + .set_cipher_suite(CipherSuite::MLDSA87_Ed448) + .set_primary_key_flags(KeyFlags::empty().set_certification().set_signing()) + .add_userid(PURPOSE) + .add_transport_encryption_subkey() + .generate()?; + let mut secret = Zeroizing::new(Vec::new()); + cert.as_tsk().serialize(&mut *secret)?; + let owner_cert = if owner.is_empty() { + public(&cert)? + } else { + public_identity(&owner)?; + owner + }; + let identity = Identity { + origin, + name, + owner: owner_cert, + secret: Zeroizing::new(BASE64_STANDARD.encode(&secret)), + }; + Ok(json!({"file":protect(&identity, password)?})) +} + +struct MessageHelper<'a> { + recipient: &'a Cert, + sender: &'a Cert, +} + +impl pgp::parse::stream::VerificationHelper for MessageHelper<'_> { + 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: pgp::parse::stream::MessageStructure<'_>) -> Result<()> { + let mut count = 0_u8; + for layer in structure { + match layer { + pgp::parse::stream::MessageLayer::SignatureGroup { results } => { + ensure!(results.len() == 1, "Exactly one signature is required"); + for result in results { + result.map_err(|_| anyhow::anyhow!("Invalid signature"))?; + } + count = count.checked_add(1).context("Too many signatures")?; + } + pgp::parse::stream::MessageLayer::Compression { .. } => { + anyhow::bail!("Compression is forbidden") + } + pgp::parse::stream::MessageLayer::Encryption { sym_algo, .. } => ensure!( + sym_algo == SymmetricAlgorithm::AES256, + "Wrong content cipher" + ), + } + } + ensure!(count == 1, "Exactly one signature group is required"); + Ok(()) + } +} + +impl pgp::parse::stream::DecryptionHelper for MessageHelper<'_> { + fn decrypt( + &mut self, + packets: &[pgp::packet::PKESK], + passwords: &[pgp::packet::SKESK], + algorithm: Option<SymmetricAlgorithm>, + decrypt: &mut dyn FnMut(Option<SymmetricAlgorithm>, &SessionKey) -> bool, + ) -> Result<Option<Cert>> { + ensure!( + passwords.is_empty() && packets.len() == 2, + "Expected two public-key recipients" + ); + let policy = pgp::policy::StandardPolicy::new(); + let key = self + .recipient + .keys() + .secret() + .with_policy(&policy, None) + .alive() + .revoked(false) + .for_transport_encryption() + .next() + .context("No encryption key")? + .key() + .clone(); + let mut pair = key.into_keypair()?; + for packet in packets { + 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!("Not a recipient") + } +} + +#[doc = "Verifies a detached fixed-suite authentication proof for exactly these challenge bytes."] +#[doc = "# Errors"] +#[doc = "Rejects invalid certificates, signatures, and mismatched challenges."] +pub fn verify_proof(encoded: &str, challenge: &str, signature: &str) -> Result<()> { + public_identity(encoded)?; + ensure!(challenge.len() <= 1024, "Challenge too long"); + let cert = certificate(encoded)?; + let helper = MessageHelper { + recipient: &cert, + sender: &cert, + }; + let policy = pgp::policy::StandardPolicy::new(); + let signature_bytes = decode(signature)?; + let mut verifier = DetachedVerifierBuilder::from_bytes(&signature_bytes)? + .with_policy(&policy, None, helper)?; + verifier.verify_bytes(challenge.as_bytes())?; + Ok(()) +} + +fn proof(cert: &Cert, owner: &str, origin: &str, challenge: &str) -> Result<Value> { + let prefix = format!( + "stack-chat-auth/v1\n{origin}\n{}\n", + cert.fingerprint().to_hex() + ); + ensure!( + challenge.starts_with(&prefix) && challenge.len() <= 1024, + "Challenge binding mismatch" + ); + let parts = challenge.lines().collect::<Vec<_>>(); + ensure!(parts.len() == 6, "Malformed challenge"); + let nonce = parts.get(3).context("Missing nonce")?; + ensure!( + nonce.len() == 64 && nonce.bytes().all(|byte| byte.is_ascii_hexdigit()), + "Malformed nonce" + ); + ensure!( + parts.get(5).context("Missing owner")? == &public_identity(owner)?, + "Owner binding mismatch" + ); + let expiry = parts.get(4).context("Missing expiry")?.parse::<u64>()?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs(); + ensure!( + expiry >= now.saturating_sub(5) && expiry <= now.saturating_add(65), + "Invalid challenge expiry" + ); + let signer = cert + .primary_key() + .key() + .parts_as_secret()? + .clone() + .into_keypair()?; + let mut signature = Vec::new(); + let mut writer = Signer::new(Message::new(&mut signature), signer)? + .detached() + .build()?; + writer.write_all(challenge.as_bytes())?; + writer.finalize()?; + Ok(json!({"signature":BASE64_STANDARD.encode(signature)})) +} + +fn encrypt(cert: &Cert, other: &Cert, payload: &str) -> Result<Value> { + ensure!(payload.len() <= 16_384, "Payload exceeds its limit"); + let policy = pgp::policy::StandardPolicy::new(); + let recipients = [cert, other].into_iter().flat_map(|identity| { + identity + .keys() + .with_policy(&policy, None) + .alive() + .revoked(false) + .for_transport_encryption() + }); + let signer = cert + .primary_key() + .key() + .parts_as_secret()? + .clone() + .into_keypair()?; + let mut ciphertext = Vec::new(); + let encrypted = Encryptor::for_recipients(Message::new(&mut ciphertext), recipients) + .symmetric_algo(SymmetricAlgorithm::AES256) + .build()?; + let authenticated = Signer::new(encrypted, signer)?.build()?; + let mut writer = LiteralWriter::new(authenticated).build()?; + writer.write_all(payload.as_bytes())?; + writer.finalize()?; + Ok(json!({"ciphertext":BASE64_STANDARD.encode(ciphertext)})) +} + +fn decrypt(cert: &Cert, sender: &Cert, encoded: &str) -> Result<Value> { + let policy = pgp::policy::StandardPolicy::new(); + let helper = MessageHelper { + recipient: cert, + sender, + }; + let ciphertext_bytes = decode(encoded)?; + let mut reader = pgp::parse::stream::DecryptorBuilder::from_bytes(&ciphertext_bytes)? + .with_policy(&policy, None, helper)? + .take(16_385); + let mut plaintext = Zeroizing::new(String::new()); + reader.read_to_string(&mut plaintext)?; + ensure!(plaintext.len() <= 16_384, "Payload exceeds its limit"); + Ok(json!({"payload":*plaintext,"signer":sender.fingerprint().to_hex()})) +} + +fn encrypt_bound( + identity: &Identity, + cert: &Cert, + recipient: &str, + payload: &str, +) -> Result<Value> { + let peer = public_identity(recipient)?; + let owner = public_identity(&identity.owner)?; + let sender = cert.fingerprint().to_hex(); + let conversation = if sender == owner { + peer.as_str() + } else { + sender.as_str() + }; + ensure!( + sender == owner || peer == owner, + "Recipient differs from pinned owner" + ); + let value: Value = serde_json::from_str(payload)?; + ensure!( + value.get("v") == Some(&json!(1)) + && value.get("origin").and_then(Value::as_str) == Some(identity.origin.as_str()), + "Invalid protocol binding" + ); + ensure!( + value.get("conversation").and_then(Value::as_str) == Some(conversation) + && value.get("sender").and_then(Value::as_str) == Some(sender.as_str()), + "Invalid identity binding" + ); + match value.get("kind").and_then(Value::as_str) { + Some("profile") => ensure!( + sender != owner + && value.get("name").and_then(Value::as_str) == Some(identity.name.as_str()), + "Invalid profile binding" + ), + Some("message") => { + let id = value + .get("id") + .and_then(Value::as_str) + .context("Missing message ID")?; + let text = value + .get("text") + .and_then(Value::as_str) + .context("Missing text")?; + ensure!( + id.len() == 64 && id.bytes().all(|byte| byte.is_ascii_hexdigit()), + "Invalid message ID" + ); + ensure!( + !text.trim().is_empty() && text.len() <= 8192, + "Invalid message length" + ); + } + _ => anyhow::bail!("Invalid payload kind"), + } + encrypt(cert, &certificate(recipient)?, payload) +} + +fn handle(request: Request) -> Result<Value> { + match request { + Request::Create { + origin, + name, + password, + owner, + } => create(origin, name, &password, owner), + Request::Open { + file, + password, + origin, + } => { + let (identity, cert) = unlock(&file, &password, &origin)?; + Ok( + json!({"public":public(&cert)?, "fingerprint":cert.fingerprint().to_hex(), "owner":identity.owner, "name":identity.name}), + ) + } + Request::Proof { + file, + password, + origin, + challenge, + } => { + let (identity, cert) = unlock(&file, &password, &origin)?; + proof(&cert, &identity.owner, &origin, &challenge) + } + Request::Encrypt { + file, + password, + origin, + recipient, + payload, + } => { + let (identity, cert) = unlock(&file, &password, &origin)?; + encrypt_bound(&identity, &cert, &recipient, &payload) + } + Request::DecryptBatch { + file, + password, + origin, + messages, + } => { + ensure!(messages.len() <= 50, "Too many messages"); + let (_identity, cert) = unlock(&file, &password, &origin)?; + let results = messages + .iter() + .map(|message| -> Value { + let result = (|| -> Result<Value> { + public_identity(&message.sender)?; + decrypt(&cert, &certificate(&message.sender)?, &message.ciphertext) + })(); + result.unwrap_or_else(|_| json!({"error":"Message rejected"})) + }) + .collect::<Vec<_>>(); + Ok(json!({"messages":results})) + } + Request::Decrypt { + file, + password, + origin, + sender, + ciphertext, + } => { + let (_identity, cert) = unlock(&file, &password, &origin)?; + public_identity(&sender)?; + decrypt(&cert, &certificate(&sender)?, &ciphertext) + } + } +} + +#[doc = "Runs one bounded browser crypto request, without filesystem or network access."] +#[doc = "# Errors"] +#[doc = "Returns a generic failure without including credentials or plaintext."] +pub fn dispatch(request: &str) -> std::result::Result<String, String> { + let result = (|| -> Result<String> { + ensure!(request.len() <= 8_388_608, "Request exceeds its limit"); + Ok(serde_json::to_string(&handle(serde_json::from_str( + request, + )?)?)?) + })(); + result.map_err(|_| { + "Cryptographic operation failed. Check the file, password, origin, and sender.".into() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn reject_forged_bindings(identity: &Identity, cert: &Cert, owner: &Cert) -> Result<()> { + let fingerprint = cert.fingerprint().to_hex(); + let mut payload = json!({"v":1,"kind":"message","origin":identity.origin,"conversation":fingerprint,"sender":fingerprint,"id":"b".repeat(64),"text":"Bound message"}); + let owner_public = public(owner)?; + encrypt_bound(identity, cert, &owner_public, &payload.to_string())?; + ensure!( + encrypt_bound(identity, cert, &public(cert)?, &payload.to_string()).is_err(), + "Unpinned recipient accepted" + ); + *payload.get_mut("sender").context("Sender")? = json!(owner.fingerprint().to_hex()); + ensure!( + encrypt_bound(identity, cert, &owner_public, &payload.to_string()).is_err(), + "Forged sender accepted" + ); + *payload.get_mut("sender").context("Sender")? = json!(fingerprint); + *payload.get_mut("conversation").context("Conversation")? = + json!(owner.fingerprint().to_hex()); + ensure!( + encrypt_bound(identity, cert, &owner_public, &payload.to_string()).is_err(), + "Wrong conversation accepted" + ); + Ok(()) + } + + #[test] + fn alternative_suites_and_malformed_envelopes_are_rejected() -> Result<()> { + let (legacy, _revocation) = CertBuilder::new() + .add_userid(PURPOSE) + .add_transport_encryption_subkey() + .generate()?; + ensure!( + public_identity(&public(&legacy)?).is_err(), + "Classical fallback accepted" + ); + let invalid = serde_json::to_string(&Envelope { + format: FORMAT.into(), + salt: BASE64_STANDARD.encode([0; 15]), + nonce: BASE64_STANDARD.encode([0; 12]), + ciphertext: String::new(), + tag: BASE64_STANDARD.encode([0; 16]), + })?; + ensure!( + unlock(&invalid, "test-password-long", "http://127.0.0.1:9080").is_err(), + "Malformed salt accepted" + ); + ensure!( + unlock("{}", "test-password-long", "http://127.0.0.1:9080").is_err(), + "Malformed file accepted" + ); + Ok(()) + } + + #[test] + fn protected_file_and_authenticated_messages() -> Result<()> { + let origin = "http://127.0.0.1:9080"; + let password = "test-only-long-passphrase"; + let owner = create(origin.into(), "Owner".into(), password, String::new())?; + let owner_file = owner + .get("file") + .context("Missing field")? + .as_str() + .context("File")?; + let (owner_identity, owner_cert) = unlock(owner_file, password, origin)?; + let owner_public = public(&owner_cert)?; + let visitor = create(origin.into(), "Visitor".into(), password, owner_public)?; + let visitor_file = visitor + .get("file") + .context("Missing field")? + .as_str() + .context("File")?; + let (visitor_identity, visitor_cert) = unlock(visitor_file, password, origin)?; + ensure!( + !owner_file.contains("Owner") && !owner_file.contains(owner_identity.secret.as_str()), + "Leaking envelope" + ); + ensure!( + unlock(visitor_file, "wrong-passphrase", origin).is_err(), + "Wrong password accepted" + ); + ensure!( + unlock(visitor_file, password, "https://elsewhere.invalid").is_err(), + "Wrong origin accepted" + ); + reject_forged_bindings(&visitor_identity, &visitor_cert, &owner_cert)?; + let ciphertext = encrypt(&visitor_cert, &owner_cert, "plain text <script>λ</script>")?; + let encoded = ciphertext + .get("ciphertext") + .context("Missing field")? + .as_str() + .context("Ciphertext")?; + ensure!( + decrypt(&owner_cert, &visitor_cert, encoded)? + .get("payload") + .context("Missing payload")? + == "plain text <script>λ</script>", + "Round trip" + ); + ensure!( + decrypt(&visitor_cert, &visitor_cert, encoded).is_ok(), + "Own history" + ); + ensure!( + decrypt(&owner_cert, &owner_cert, encoded).is_err(), + "Wrong sender accepted" + ); + let expiry = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs() + .checked_add(60) + .context("Clock")?; + let nonce = "a".repeat(64); + let challenge = format!( + "stack-chat-auth/v1\n{origin}\n{}\n{nonce}\n{expiry}\n{}", + visitor_cert.fingerprint().to_hex(), + owner_cert.fingerprint().to_hex() + ); + let signed = proof(&visitor_cert, &public(&owner_cert)?, origin, &challenge)?; + verify_proof( + &public(&visitor_cert)?, + &challenge, + signed + .get("signature") + .context("Missing field")? + .as_str() + .context("Proof")?, + )?; + ensure!( + verify_proof( + &public(&visitor_cert)?, + "altered", + signed + .get("signature") + .context("Missing field")? + .as_str() + .context("Proof")? + ) + .is_err(), + "Proof binding" + ); + ensure!( + public_identity(&owner_identity.secret).is_err(), + "Server accepted secrets" + ); + Ok(()) + } +} diff --git a/crates/chat-crypto/src/lib.rs b/crates/chat-crypto/src/lib.rs index 62319e2..7274c43 100644 --- a/crates/chat-crypto/src/lib.rs +++ b/crates/chat-crypto/src/lib.rs @@ -13,7 +13,11 @@ // 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."] +#![doc = "Fixed-suite `OpenPGP` operations for purpose-specific private chat."] + +mod application; + +pub use application::{dispatch, public_identity, verify_proof}; use anyhow::{Context, Result, ensure}; use pgp::Profile; diff --git a/crates/chat-crypto/src/main.rs b/crates/chat-crypto/src/main.rs index b6749cf..9ecfe22 100644 --- a/crates/chat-crypto/src/main.rs +++ b/crates/chat-crypto/src/main.rs @@ -13,11 +13,23 @@ // 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."] +#![doc = "Isolated WASI command entry point for chat cryptography."] -use std::io::{self, Write}; +use std::io::{self, Read, Write}; fn main() -> io::Result<()> { - let result = stack_chat_crypto::compatibility_probe().map_err(io::Error::other)?; + let mut request = zeroize::Zeroizing::new(String::new()); + io::stdin() + .lock() + .take(8_388_609) + .read_to_string(&mut request)?; + let result = if request.is_empty() { + stack_chat_crypto::compatibility_probe().map_err(io::Error::other)? + } else { + match stack_chat_crypto::dispatch(&request) { + Ok(output) => output, + Err(error) => serde_json::to_string(&serde_json::json!({"error":error}))?, + } + }; writeln!(io::stdout().lock(), "{result}") } diff --git a/crates/chat-crypto/tests/browser.mjs b/crates/chat-crypto/tests/browser.mjs index 8d2a9bb..4fd2ee5 100644 --- a/crates/chat-crypto/tests/browser.mjs +++ b/crates/chat-crypto/tests/browser.mjs @@ -47,7 +47,8 @@ try { }); await page.goto(origin); const result = await page.evaluate(async () => { - const { runProbe } = await import('/wasi.mjs'); + await import('/wasi.mjs'); + const { runProbe } = globalThis.stackChatWasi; const bytes = await (await fetch('/probe.wasm', { cache: 'no-store' })).arrayBuffer(); const valid = await runProbe(bytes); const again = await runProbe(bytes); |
