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 | 556eaa133e9cfe7f4c8e542708119fdfd311357e (patch) | |
| tree | 57ae2a9c5455f335e8c34b293f4f13975cb3d09e /crates | |
| parent | 143880db086c617b843340ca7023a879507761f8 (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')
| -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 | ||||
| -rw-r--r-- | crates/chat-server/Cargo.toml | 46 | ||||
| -rw-r--r-- | crates/chat-server/README.txt | 22 | ||||
| -rw-r--r-- | crates/chat-server/src/http.rs | 160 | ||||
| -rw-r--r-- | crates/chat-server/src/main.rs | 129 | ||||
| -rw-r--r-- | crates/chat-server/src/service.rs | 438 | ||||
| -rw-r--r-- | crates/chat-server/src/store.rs | 169 | ||||
| -rw-r--r-- | crates/chat-server/tests/browser.mjs | 265 | ||||
| -rw-r--r-- | crates/chat-server/web/app.js | 325 | ||||
| -rw-r--r-- | crates/chat-server/web/index.html | 82 | ||||
| -rw-r--r-- | crates/chat-server/web/javascript.html | 28 | ||||
| -rw-r--r-- | crates/chat-server/web/style.css | 49 | ||||
| -rw-r--r-- | crates/chat-server/web/worker.js | 31 |
19 files changed, 2596 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); diff --git a/crates/chat-server/Cargo.toml b/crates/chat-server/Cargo.toml new file mode 100644 index 0000000..eecebf6 --- /dev/null +++ b/crates/chat-server/Cargo.toml @@ -0,0 +1,46 @@ +# 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-server" +version = "0.1.0" +edition = "2024" +license = "AGPL-3.0-or-later" +authors = ["Doan Luu Duc Tai <doan@luuduct.ai>"] +description = "Encrypted private chat storage and authenticated access." +repository = "https://git.doan.luuduct.ai/stack" +readme = "README.txt" +keywords = ["chat", "openpgp", "privacy"] +categories = ["web-programming"] +publish = false + +[dependencies] +anyhow = "1" +axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "json"] } +base64 = "0.22" +clap = { version = "4.6", features = ["derive"] } +dotenvy = "0.15" +hex = "0.4" +openssl = "0.10" +rusqlite = { version = "0.41", default-features = false, features = ["bundled", "backup"] } +serde_json = "1" +stack-chat-crypto = { path = "../chat-crypto", version = "0.1.0" } +tokio = { version = "1", features = ["rt-multi-thread", "net", "macros", "signal", "sync", "fs"] } + +[dev-dependencies] +tempfile = "3" + +[lints] +workspace = true diff --git a/crates/chat-server/README.txt b/crates/chat-server/README.txt new file mode 100644 index 0000000..a7aba5d --- /dev/null +++ b/crates/chat-server/README.txt @@ -0,0 +1,22 @@ +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/>. + + +EXPERIMENTAL PRIVATE CHAT SERVER + + See docs/CHAT-APPLICATION.txt at the workspace root for local setup, + file formats, API/security limits, backups, recovery, source licensing, + and tests. Start with cargo stack chat --init, then cargo stack chat. + Production is intentionally not configured on this feature branch. diff --git a/crates/chat-server/src/http.rs b/crates/chat-server/src/http.rs new file mode 100644 index 0000000..bb618d9 --- /dev/null +++ b/crates/chat-server/src/http.rs @@ -0,0 +1,160 @@ +// 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 crate::service::Service; +use anyhow::Result; +use axum::{ + Json, Router, + extract::{DefaultBodyLimit, State}, + http::{HeaderMap, HeaderValue, StatusCode}, + response::{IntoResponse, Response}, + routing::{get, post}, +}; +use serde_json::{Value, json}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use tokio::sync::Semaphore; + +#[derive(Clone)] +struct App { + service: Arc<Mutex<Service>>, + permits: Arc<Semaphore>, + root: PathBuf, + origin: String, +} + +fn secured(mut response: Response) -> Response { + for (name, value) in [ + ("cache-control", "no-store, max-age=0"), + ( + "content-security-policy", + "default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self'; style-src 'self'; img-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; object-src 'none'", + ), + ("referrer-policy", "no-referrer"), + ("x-content-type-options", "nosniff"), + ("cross-origin-opener-policy", "same-origin"), + ("cross-origin-resource-policy", "same-origin"), + ( + "permissions-policy", + "camera=(), microphone=(), geolocation=(), payment=(), usb=()", + ), + ] { + response + .headers_mut() + .insert(name, HeaderValue::from_static(value)); + } + response +} + +async fn api(State(app): State<App>, headers: HeaderMap, Json(request): Json<Value>) -> Response { + if headers.get("origin").and_then(|value| value.to_str().ok()) != Some(app.origin.as_str()) { + return secured( + ( + StatusCode::FORBIDDEN, + Json(json!({"error":"Origin rejected"})), + ) + .into_response(), + ); + } + let Ok(permit) = Arc::clone(&app.permits).try_acquire_owned() else { + return secured( + ( + StatusCode::TOO_MANY_REQUESTS, + Json(json!({"error":"Server busy"})), + ) + .into_response(), + ); + }; + let result = tokio::task::spawn_blocking(move || { + let _permit = permit; + app.service + .lock() + .map_err(|_| anyhow::anyhow!("Service unavailable"))? + .call(&request) + }) + .await; + match result { + Ok(Ok(value)) => secured(Json(value).into_response()), + _ => secured((StatusCode::BAD_REQUEST,Json(json!({"error":"Request rejected. Unlock again if your session expired; check credentials, message limits, and conversation access."}))).into_response()), + } +} + +async fn asset(State(app): State<App>, uri: axum::http::Uri) -> Response { + let (path, mime) = match uri.path() { + "/" => ( + "crates/chat-server/web/index.html", + "text/html; charset=utf-8", + ), + "/app.js" => ( + "crates/chat-server/web/app.js", + "text/javascript; charset=utf-8", + ), + "/worker.js" => ( + "crates/chat-server/web/worker.js", + "text/javascript; charset=utf-8", + ), + "/wasi.mjs" => ( + "crates/chat-crypto/browser/wasi.mjs", + "text/javascript; charset=utf-8", + ), + "/style.css" => ( + "crates/chat-server/web/style.css", + "text/css; charset=utf-8", + ), + "/crypto.wasm" => ( + "target/wasm32-wasip1/release/stack-chat-crypto.wasm", + "application/wasm", + ), + "/javascript.html" => ( + "crates/chat-server/web/javascript.html", + "text/html; charset=utf-8", + ), + "/SOURCE.tar.gz" => ("var/build/chat-source.tar.gz", "application/gzip"), + "/THIRD-PARTY-LICENSES.html" => { + ("web/THIRD-PARTY-LICENSES.html", "text/html; charset=utf-8") + } + "/LIMITATIONS.txt" => ("docs/CHAT-APPLICATION.txt", "text/plain; charset=utf-8"), + "/LICENSE" => ("LICENSE", "text/plain; charset=utf-8"), + _ => return secured(StatusCode::NOT_FOUND.into_response()), + }; + tokio::fs::read(app.root.join(path)).await.map_or_else( + |_| secured(StatusCode::NOT_FOUND.into_response()), + |bytes| secured(([("content-type", mime)], bytes).into_response()), + ) +} + +pub(crate) async fn serve( + listener: tokio::net::TcpListener, + service: Service, + root: &Path, +) -> Result<()> { + let app = App { + origin: service.origin.clone(), + service: Arc::new(Mutex::new(service)), + permits: Arc::new(Semaphore::new(4)), + root: root.to_path_buf(), + }; + let routes = Router::new() + .route("/api", post(api)) + .fallback(get(asset)) + .layer(DefaultBodyLimit::max(262_144)) + .with_state(app); + axum::serve(listener, routes) + .with_graceful_shutdown(async { + drop(tokio::signal::ctrl_c().await); + }) + .await?; + Ok(()) +} diff --git a/crates/chat-server/src/main.rs b/crates/chat-server/src/main.rs new file mode 100644 index 0000000..6c7f4e1 --- /dev/null +++ b/crates/chat-server/src/main.rs @@ -0,0 +1,129 @@ +// 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 = "Local experimental chat server with ciphertext-only durable storage."] + +mod http; +mod service; +mod store; + +use anyhow::{Context, Result, ensure}; +use clap::Parser; +use std::io::Write; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::process::ExitCode; + +#[derive(Debug, Parser)] +#[command(about = "Run the experimental private chat locally; production is not enabled")] +struct Arguments { + #[arg(long, default_value = ".")] + root: PathBuf, + #[arg(long, default_value = "127.0.0.1:9080")] + bind: SocketAddr, + #[arg(long)] + init: bool, + #[arg(long)] + snapshot: Option<PathBuf>, +} + +fn initialize(root: &std::path::Path) -> Result<()> { + let env = root.join(".env"); + let existing = if env.exists() { + std::fs::read_to_string(&env)? + } else { + String::new() + }; + ensure!( + !existing + .lines() + .any(|line| line.starts_with("CHAT_SETUP_TOKEN=")), + "CHAT_SETUP_TOKEN already exists" + ); + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&env)?; + std::fs::set_permissions(&env, std::os::unix::fs::PermissionsExt::from_mode(0o600))?; + writeln!(file, "\nCHAT_SETUP_TOKEN={}", service::token()?)?; + if !existing + .lines() + .any(|line| line.starts_with("CHAT_ORIGIN=")) + { + writeln!(file, "CHAT_ORIGIN=http://127.0.0.1:9080")?; + } + Ok(()) +} + +async fn run(arguments: Arguments) -> Result<()> { + let root = arguments.root.canonicalize()?; + if arguments.init { + return initialize(&root); + } + let data = root.join("var/data/chat"); + std::fs::create_dir_all(&data)?; + std::fs::set_permissions(&data, std::os::unix::fs::PermissionsExt::from_mode(0o700))?; + let database = data.join("chat.sqlite"); + let store = store::Store::open(&database)?; + std::fs::set_permissions( + &database, + std::os::unix::fs::PermissionsExt::from_mode(0o600), + )?; + if let Some(destination) = arguments.snapshot { + store.snapshot(&destination)?; + std::fs::set_permissions( + destination, + std::os::unix::fs::PermissionsExt::from_mode(0o600), + )?; + return Ok(()); + } + ensure!( + arguments.bind.ip().is_loopback(), + "Experimental server must bind to loopback" + ); + let settings = dotenvy::from_path_iter(root.join(".env"))? + .collect::<std::result::Result<std::collections::HashMap<_, _>, _>>()?; + let origin = settings + .get("CHAT_ORIGIN") + .context("Run --init to configure chat")?; + ensure!( + origin == &format!("http://{}", arguments.bind), + "This development server requires CHAT_ORIGIN to match its loopback bind address" + ); + let setup = settings + .get("CHAT_SETUP_TOKEN") + .context("Missing CHAT_SETUP_TOKEN")?; + let state = service::Service::new(store, origin.clone(), setup.clone()); + let listener = tokio::net::TcpListener::bind(arguments.bind).await?; + writeln!( + std::io::stdout().lock(), + "Experimental chat: {origin} (owner setup: /#setup)" + )?; + http::serve(listener, state, &root).await +} + +#[tokio::main] +async fn main() -> ExitCode { + match run(Arguments::parse()).await { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + drop(writeln!( + std::io::stderr().lock(), + "Chat server failed: {error:#}" + )); + ExitCode::FAILURE + } + } +} diff --git a/crates/chat-server/src/service.rs b/crates/chat-server/src/service.rs new file mode 100644 index 0000000..3f9b6ee --- /dev/null +++ b/crates/chat-server/src/service.rs @@ -0,0 +1,438 @@ +// 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 crate::store::Store; +use anyhow::{Context, Result, ensure}; +use base64::{Engine, prelude::BASE64_STANDARD}; +use serde_json::{Value, json}; +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +struct Challenge { + certificate: String, + text: String, + expiry: Instant, + setup: bool, +} + +struct Session { + identity: String, + expiry: Instant, +} + +pub(crate) struct Service { + pub(crate) store: Store, + pub(crate) origin: String, + setup_token: String, + challenges: HashMap<String, Challenge>, + sessions: HashMap<String, Session>, + window: Instant, + attempts: u16, +} + +pub(crate) fn token() -> Result<String> { + let mut bytes = [0; 32]; + openssl::rand::rand_bytes(&mut bytes)?; + Ok(hex::encode(bytes)) +} + +pub(crate) fn field<'a>(request: &'a Value, key: &str) -> Result<&'a str> { + request + .get(key) + .and_then(Value::as_str) + .context("Missing string field") +} + +fn ciphertext(request: &Value) -> Result<&str> { + let encoded = field(request, "ciphertext")?; + ensure!( + encoded.len() <= 65_536 && BASE64_STANDARD.decode(encoded)?.len() >= 64, + "Invalid ciphertext length" + ); + Ok(encoded) +} + +fn identifier(value: &str) -> Result<()> { + ensure!( + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()), + "Invalid identifier" + ); + Ok(()) +} + +impl Service { + pub(crate) fn new(store: Store, origin: String, setup_token: String) -> Self { + Self { + store, + origin, + setup_token, + challenges: HashMap::new(), + sessions: HashMap::new(), + window: Instant::now(), + attempts: 0, + } + } + + fn owner_id(&self) -> Result<String> { + stack_chat_crypto::public_identity(&self.store.owner()?.context("Owner not initialized")?) + } + + fn challenge(&mut self, request: &Value) -> Result<Value> { + if self.window.elapsed() >= Duration::from_secs(60) { + self.window = Instant::now(); + self.attempts = 0; + } + ensure!(self.attempts < 60, "Authentication rate exceeded"); + self.attempts = self + .attempts + .checked_add(1) + .context("Rate counter overflow")?; + self.challenges + .retain(|_, challenge| challenge.expiry > Instant::now()); + ensure!(self.challenges.len() < 128, "Too many pending challenges"); + let certificate = field(request, "public")?; + let fingerprint = stack_chat_crypto::public_identity(certificate)?; + let setup = self.store.owner()?.is_none(); + if setup { + let supplied = field(request, "setup_token")?; + ensure!( + self.setup_token.len() == 64 + && supplied.len() == 64 + && openssl::memcmp::eq(supplied.as_bytes(), self.setup_token.as_bytes()), + "Invalid setup credential" + ); + } + let nonce = token()?; + let expires = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs() + .checked_add(60) + .context("Clock overflow")?; + let owner = if setup { + fingerprint.clone() + } else { + self.owner_id()? + }; + let text = format!( + "stack-chat-auth/v1\n{}\n{fingerprint}\n{nonce}\n{expires}\n{owner}", + self.origin + ); + self.challenges.insert( + nonce.clone(), + Challenge { + certificate: certificate.into(), + text: text.clone(), + expiry: Instant::now() + .checked_add(Duration::from_secs(60)) + .context("Clock overflow")?, + setup, + }, + ); + Ok(json!({"id":nonce,"challenge":text})) + } + + fn authenticate(&mut self, request: &Value) -> Result<Value> { + let challenge = self + .challenges + .remove(field(request, "id")?) + .context("Missing or consumed challenge")?; + ensure!(challenge.expiry > Instant::now(), "Expired challenge"); + stack_chat_crypto::verify_proof( + &challenge.certificate, + &challenge.text, + field(request, "signature")?, + )?; + let identity = stack_chat_crypto::public_identity(&challenge.certificate)?; + if challenge.setup { + self.store.install_owner(&challenge.certificate)?; + self.setup_token.clear(); + } + let owner = identity == self.owner_id()?; + if !owner { + self.store.register(&identity, &challenge.certificate)?; + } + self.sessions + .retain(|_, session| session.expiry > Instant::now()); + ensure!(self.sessions.len() < 256, "Too many sessions"); + let credential = token()?; + self.sessions.insert( + credential.clone(), + Session { + identity: identity.clone(), + expiry: Instant::now() + .checked_add(Duration::from_mins(30)) + .context("Clock overflow")?, + }, + ); + Ok(json!({"token":credential,"owner":owner,"id":identity,"expires_seconds":1800})) + } + + fn principal(&mut self, request: &Value) -> Result<String> { + self.sessions + .retain(|_, session| session.expiry > Instant::now()); + Ok(self + .sessions + .get(field(request, "token")?) + .context("Missing or expired session")? + .identity + .clone()) + } + + fn authorized(&self, request: &Value, identity: &str) -> Result<String> { + let conversation = field(request, "conversation")?; + identifier(conversation)?; + ensure!( + identity == conversation || identity == self.owner_id()?, + "Forbidden conversation" + ); + self.store.conversation(conversation)?; + Ok(conversation.into()) + } + + pub(crate) fn call(&mut self, request: &Value) -> Result<Value> { + match field(request, "op")? { + "config" => Ok( + json!({"owner":self.store.owner()?,"origin":self.origin,"protocol":"stack-chat/v1"}), + ), + "challenge" => self.challenge(request), + "authenticate" => self.authenticate(request), + _ => self.authenticated(request), + } + } + + fn authenticated(&mut self, request: &Value) -> Result<Value> { + let identity = self.principal(request)?; + let operation = field(request, "op")?; + if operation == "lock" { + self.sessions.remove(field(request, "token")?); + return Ok(json!({"locked":true})); + } + if operation == "inbox" { + ensure!(identity == self.owner_id()?, "Owner only"); + return self.store.inbox(); + } + let conversation = self.authorized(request, &identity)?; + match operation { + "history" => self.store.messages( + &conversation, + request + .get("count") + .and_then(Value::as_u64) + .context("Missing count")?, + request.get("before").and_then(Value::as_u64).unwrap_or(0), + ), + "profile" => { + ensure!(identity == conversation, "Visitor only"); + self.store.profile(&conversation, ciphertext(request)?)?; + Ok(json!({"stored":true})) + } + "send" => { + let id = field(request, "id")?; + identifier(id)?; + self.store + .send(&conversation, id, &identity, ciphertext(request)?) + } + _ => anyhow::bail!("Unknown operation"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + struct Identity { + file: String, + public: String, + fingerprint: String, + } + + fn crypto(request: &Value) -> Result<Value> { + let response = + stack_chat_crypto::dispatch(&request.to_string()).map_err(anyhow::Error::msg)?; + Ok(serde_json::from_str(&response)?) + } + + fn identity(name: &str, owner: &str) -> Result<Identity> { + let created = crypto( + &json!({"op":"create","origin":"http://127.0.0.1:9080","name":name,"password":"test-only-strong-password","owner":owner}), + )?; + let file = field(&created, "file")?; + let opened = crypto( + &json!({"op":"open","origin":"http://127.0.0.1:9080","file":file,"password":"test-only-strong-password"}), + )?; + Ok(Identity { + file: file.into(), + public: field(&opened, "public")?.into(), + fingerprint: field(&opened, "fingerprint")?.into(), + }) + } + + fn authenticate(service: &mut Service, identity: &Identity, setup: &str) -> Result<Value> { + let challenge = service + .call(&json!({"op":"challenge","public":identity.public,"setup_token":setup}))?; + let proof = crypto( + &json!({"op":"proof","file":identity.file,"password":"test-only-strong-password","origin":service.origin,"challenge":field(&challenge,"challenge")?}), + )?; + let request = json!({"op":"authenticate","id":field(&challenge,"id")?,"signature":field(&proof,"signature")?}); + let result = service.call(&request)?; + ensure!(service.call(&request).is_err(), "Challenge was replayed"); + Ok(result) + } + + fn reject_expired_and_failed_proofs(service: &mut Service, identity: &Identity) -> Result<()> { + let challenge = service.call(&json!({"op":"challenge","public":identity.public}))?; + let challenge_id = field(&challenge, "id")?; + service + .challenges + .get_mut(challenge_id) + .context("Challenge")? + .expiry = Instant::now(); + let expired = json!({"op":"authenticate","id":challenge_id,"signature":""}); + ensure!( + service.call(&expired).is_err(), + "Expired challenge accepted" + ); + ensure!( + !service.challenges.contains_key(challenge_id), + "Expired challenge retained" + ); + let invalid = service.call(&json!({"op":"challenge","public":identity.public}))?; + let invalid_id = field(&invalid, "id")?; + ensure!( + service + .call(&json!({"op":"authenticate","id":invalid_id,"signature":""})) + .is_err(), + "Invalid proof accepted" + ); + ensure!( + !service.challenges.contains_key(invalid_id), + "Failed challenge reusable" + ); + service.attempts = 60; + ensure!( + service + .call(&json!({"op":"challenge","public":identity.public})) + .is_err(), + "Challenge rate bound" + ); + ensure!( + service.store.install_owner(&identity.public).is_err(), + "Owner overwritten" + ); + Ok(()) + } + + #[test] + fn authorization_replays_history_and_snapshot_recovery() -> Result<()> { + let directory = TempDir::new()?; + let path = directory.path().join("chat.sqlite"); + let setup = token()?; + let mut service = Service::new( + Store::open(&path)?, + "http://127.0.0.1:9080".into(), + setup.clone(), + ); + let owner = identity("Owner", "")?; + ensure!( + service + .call(&json!({"op":"challenge","public":owner.public,"setup_token":"wrong"})) + .is_err(), + "Setup unauthenticated" + ); + let owner_session = authenticate(&mut service, &owner, &setup)?; + ensure!( + owner_session.get("owner") == Some(&json!(true)), + "Owner role" + ); + let visitor = identity("Visitor", &owner.public)?; + let visitor_session = authenticate(&mut service, &visitor, "")?; + let visitor_token = field(&visitor_session, "token")?; + let owner_token = field(&owner_session, "token")?; + let ciphertext = BASE64_STANDARD.encode([42; 128]); + let message_id = token()?; + let send = json!({"op":"send","conversation":visitor.fingerprint,"token":visitor_token,"id":message_id,"ciphertext":ciphertext}); + service.call(&send)?; + service.call(&send)?; + let conflicting = json!({"op":"send","conversation":visitor.fingerprint,"token":visitor_token,"id":message_id,"ciphertext":BASE64_STANDARD.encode([43;128])}); + ensure!( + service.call(&conflicting).is_err(), + "Conflicting ID accepted" + ); + let empty = service.call(&json!({"op":"history","conversation":visitor.fingerprint,"token":visitor_token,"count":0}))?; + ensure!( + empty + .get("messages") + .and_then(Value::as_array) + .is_some_and(Vec::is_empty), + "N=0" + ); + ensure!( + empty + .get("conversation") + .and_then(|value| value.get("total")) + == Some(&json!(1)), + "Retry duplicated message" + ); + ensure!( + service + .call(&json!({"op":"inbox","token":visitor_token})) + .is_err(), + "Visitor read inbox" + ); + ensure!(service.call(&json!({"op":"history","conversation":owner.fingerprint,"token":visitor_token,"count":1})).is_err(), "Wrong conversation accepted"); + service.call(&json!({"op":"send","conversation":visitor.fingerprint,"token":owner_token,"id":token()?,"ciphertext":ciphertext}))?; + let latest = service.call(&json!({"op":"history","conversation":visitor.fingerprint,"token":visitor_token,"count":1}))?; + let sequence = latest + .get("messages") + .and_then(Value::as_array) + .and_then(|messages| messages.first()) + .and_then(|message| message.get("seq")) + .context("Sequence")?; + let older = service.call(&json!({"op":"history","conversation":visitor.fingerprint,"token":visitor_token,"count":1,"before":sequence}))?; + ensure!( + older + .get("messages") + .and_then(Value::as_array) + .is_some_and(|messages| messages.len() == 1), + "Pagination" + ); + ensure!(service.call(&json!({"op":"history","conversation":visitor.fingerprint,"token":visitor_token,"count":51})).is_err(), "History quota"); + reject_expired_and_failed_proofs(&mut service, &visitor)?; + let snapshot = directory.path().join("snapshot.sqlite"); + service.store.snapshot(&snapshot)?; + let restored = Store::open(&snapshot)?; + ensure!( + restored.owner()? == Some(owner.public), + "Owner missing after restore" + ); + ensure!( + restored.conversation(&visitor.fingerprint)?.get("total") == Some(&json!(2)), + "Restored count" + ); + service.call(&json!({"op":"lock","token":visitor_token}))?; + ensure!(service.call(&json!({"op":"history","conversation":visitor.fingerprint,"token":visitor_token,"count":0})).is_err(), "Locked session accepted"); + let session = service.sessions.get_mut(owner_token).context("Session")?; + session.expiry = Instant::now(); + ensure!( + service + .call(&json!({"op":"inbox","token":owner_token})) + .is_err(), + "Expired session accepted" + ); + Ok(()) + } +} diff --git a/crates/chat-server/src/store.rs b/crates/chat-server/src/store.rs new file mode 100644 index 0000000..fce3bb3 --- /dev/null +++ b/crates/chat-server/src/store.rs @@ -0,0 +1,169 @@ +// 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 rusqlite::{Connection, OptionalExtension, params}; +use serde_json::{Value, json}; +use std::path::Path; + +pub(crate) struct Store { + connection: Connection, +} + +impl Store { + pub(crate) fn open(path: &Path) -> Result<Self> { + let connection = Connection::open(path)?; + connection.execute_batch("PRAGMA foreign_keys=ON; PRAGMA journal_mode=DELETE; PRAGMA synchronous=FULL; PRAGMA secure_delete=ON; CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS conversations (id TEXT PRIMARY KEY, cert TEXT NOT NULL, profile TEXT); CREATE TABLE IF NOT EXISTS messages (seq INTEGER PRIMARY KEY AUTOINCREMENT, conversation TEXT NOT NULL REFERENCES conversations(id), id TEXT NOT NULL UNIQUE, sender TEXT NOT NULL, ciphertext TEXT NOT NULL);")?; + Ok(Self { connection }) + } + + pub(crate) fn owner(&self) -> Result<Option<String>> { + Ok(self + .connection + .query_row("SELECT value FROM metadata WHERE key='owner'", [], |row| { + row.get(0) + }) + .optional()?) + } + + pub(crate) fn install_owner(&self, cert: &str) -> Result<()> { + ensure!(self.owner()?.is_none(), "Already initialized"); + self.connection + .execute("INSERT INTO metadata VALUES ('owner', ?1)", [cert])?; + Ok(()) + } + + pub(crate) fn register(&self, id: &str, cert: &str) -> Result<()> { + if self.connection.query_row( + "SELECT EXISTS(SELECT 1 FROM conversations WHERE id=?1)", + [id], + |row| row.get::<_, bool>(0), + )? { + return Ok(()); + } + let count: i64 = + self.connection + .query_row("SELECT COUNT(*) FROM conversations", [], |row| row.get(0))?; + ensure!(count < 1000, "Conversation limit reached"); + self.connection.execute( + "INSERT INTO conversations (id,cert) VALUES (?1,?2)", + params![id, cert], + )?; + Ok(()) + } + + pub(crate) fn conversation(&self, id: &str) -> Result<Value> { + let (cert, profile): (String, Option<String>) = self.connection.query_row( + "SELECT cert,profile FROM conversations WHERE id=?1", + [id], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + let (total, visitor): (i64, i64) = self.connection.query_row( + "SELECT COUNT(*),COALESCE(SUM(sender=?1),0) FROM messages WHERE conversation=?1", + [id], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + Ok( + json!({"id":id,"public":cert,"profile":profile,"total":total,"visitor_sent":visitor,"owner_sent":total.checked_sub(visitor).context("Counter invariant")?}), + ) + } + + pub(crate) fn inbox(&self) -> Result<Value> { + let mut statement = self + .connection + .prepare("SELECT id FROM conversations ORDER BY id")?; + let ids = statement + .query_map([], |row| row.get::<_, String>(0))? + .collect::<rusqlite::Result<Vec<_>>>()?; + let conversations = ids + .iter() + .map(|id| self.conversation(id)) + .collect::<Result<Vec<_>>>()?; + Ok(json!({"conversations":conversations})) + } + + pub(crate) fn profile(&self, id: &str, ciphertext: &str) -> Result<()> { + self.connection.execute( + "UPDATE conversations SET profile=?2 WHERE id=?1 AND profile IS NULL", + params![id, ciphertext], + )?; + Ok(()) + } + + pub(crate) fn messages(&self, id: &str, count: u64, before: u64) -> Result<Value> { + ensure!( + count <= 50 && before <= i64::MAX.unsigned_abs(), + "Invalid history window" + ); + let mut statement = self.connection.prepare("SELECT seq,id,sender,ciphertext FROM messages WHERE conversation=?1 AND (?3=0 OR seq<?3) ORDER BY seq DESC LIMIT ?2")?; + let messages = statement.query_map(params![id,i64::try_from(count)?,i64::try_from(before)?], |row| Ok(json!({"seq":row.get::<_,i64>(0)?,"id":row.get::<_,String>(1)?,"sender":row.get::<_,String>(2)?,"ciphertext":row.get::<_,String>(3)?})))?.collect::<rusqlite::Result<Vec<_>>>()?; + Ok(json!({"conversation":self.conversation(id)?,"messages":messages})) + } + + pub(crate) fn send( + &mut self, + conversation: &str, + id: &str, + sender: &str, + ciphertext: &str, + ) -> Result<Value> { + let transaction = self.connection.transaction()?; + let existing: Option<(i64, String, String, String)> = transaction + .query_row( + "SELECT seq,conversation,sender,ciphertext FROM messages WHERE id=?1", + [id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .optional()?; + if let Some((seq, previous_conversation, previous_sender, previous_ciphertext)) = existing { + ensure!( + previous_conversation == conversation + && previous_sender == sender + && previous_ciphertext == ciphertext, + "Conflicting message identifier" + ); + transaction.commit()?; + return Ok(json!({"seq":seq,"conversation":self.conversation(conversation)?})); + } + let count: i64 = transaction.query_row( + "SELECT COUNT(*) FROM messages WHERE conversation=?1", + [conversation], + |row| row.get(0), + )?; + let used: i64 = transaction.query_row( + "SELECT COALESCE(SUM(LENGTH(ciphertext)),0) FROM messages", + [], + |row| row.get(0), + )?; + ensure!( + count < 10_000 && used < 1_000_000_000, + "Storage quota reached" + ); + transaction.execute( + "INSERT INTO messages (conversation,id,sender,ciphertext) VALUES (?1,?2,?3,?4)", + params![conversation, id, sender, ciphertext], + )?; + let sequence = transaction.last_insert_rowid(); + transaction.commit()?; + Ok(json!({"seq":sequence,"conversation":self.conversation(conversation)?})) + } + + pub(crate) fn snapshot(&self, destination: &Path) -> Result<()> { + ensure!(!destination.exists(), "Snapshot destination already exists"); + self.connection + .backup("main", destination, None::<fn(rusqlite::backup::Progress)>)?; + Ok(()) + } +} diff --git a/crates/chat-server/tests/browser.mjs b/crates/chat-server/tests/browser.mjs new file mode 100644 index 0000000..a56c7cc --- /dev/null +++ b/crates/chat-server/tests/browser.mjs @@ -0,0 +1,265 @@ +// 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 assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { mkdtemp, readFile, writeFile, symlink, rm, mkdir, rename } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { resolve, join } from 'node:path'; +import { randomBytes } from 'node:crypto'; +import { spawn, execFileSync } from 'node:child_process'; +import { chromium, firefox } from '../../../.tools/browser/node_modules/playwright/index.mjs'; + +const root = await mkdtemp(join(tmpdir(), 'stack-chat-e2e-')); +const token = randomBytes(32).toString('hex'); +const origin = 'http://127.0.0.1:9080'; +const password = 'browser-test-only-unique-passphrase'; +await writeFile(join(root, '.env'), `CHAT_ORIGIN=${origin}\nCHAT_SETUP_TOKEN=${token}\n`, {mode:0o600}); +for (const path of ['crates','target','docs','web','LICENSE']) await symlink(resolve(path), join(root,path)); +await mkdir(join(root,'var/build'), {recursive:true}); +let server; +let serverErrors = ''; +async function launchServer() { + server = spawn(resolve('target/debug/stack-chat-server'), ['--root',root], {stdio:['ignore','pipe','pipe']}); + server.stderr.on('data', bytes => { serverErrors += bytes.toString(); }); + await Promise.race([ + new Promise((ready, reject) => { server.stdout.once('data', ready); server.once('exit', () => reject(new Error(serverErrors || 'Server exited'))); }), + new Promise((_, reject) => setTimeout(() => reject(new Error('Server did not start')), 20000).unref()), + ]); +} +async function stopServer() { + if (server.exitCode !== null || server.signalCode !== null) return; + const stopped = new Promise(done => server.once('exit',done)); + server.kill('SIGINT'); + await stopped; +} +await launchServer(); +const libre = process.env.CHAT_LIBREJS === '1'; +let persistent; +let browser; +if (libre) { + const profile = join(root,'firefox-profile'); + await mkdir(join(profile,'extensions'),{recursive:true}); + await writeFile(join(profile,'extensions','jid1-KtlZuoiikVfFew@jetpack.xpi'),await readFile(resolve('.tools/librejs.xpi'))); + persistent = await firefox.launchPersistentContext(profile, {headless:true,acceptDownloads:true,firefoxUserPrefs:{ + 'extensions.autoDisableScopes':0,'extensions.enabledScopes':15,'extensions.startupScanScopes':15, + 'extensions.update.enabled':false,'app.update.auto':false,'datareporting.healthreport.uploadEnabled':false, + 'browser.sessionstore.resume_from_crash':false, + }}); + browser = { close: () => persistent.close() }; +} else { browser = await chromium.launch({headless:true,channel:'chromium'}); } +const deadline = setTimeout(() => browser.close(), 300000); +const contexts = []; +const external = []; +const errors = []; +const bodies = []; +async function page() { + const context = persistent || await browser.newContext({acceptDownloads:true}); + contexts.push(context); + const page = await context.newPage(); + page.setDefaultTimeout(90000); + page.on('pageerror', error => errors.push(error.message)); + + page.on('request', request => { + if (!request.url().startsWith(origin + '/')) external.push(request.url()); + if (request.url() === origin + '/api') bodies.push(JSON.parse(request.postData())); + }); + return page; +} +async function waitStatus(page, text) { await page.waitForFunction(value => document.getElementById('status').textContent.includes(value), text); } +async function create(page, name, owner = false) { + if (owner) await page.fill('#setup-token', token); else await page.click('#create-choice'); + await page.fill('#name', name); + await page.fill('#new-password', password); + await page.fill('#confirm-password', password); + const downloaded = Promise.race([page.waitForEvent('download'), page.waitForFunction(() => /failed|timed out/i.test(document.getElementById('status').textContent)).then(async () => { throw new Error(await page.locator('#status').textContent()); })]); + await page.click('#create-form button'); + const download = await downloaded; + const bytes = await readFile(await download.path()); + await waitStatus(page,'Reopen your downloaded'); + assert.equal(await page.locator('#chat').isVisible(), false); + assert.equal(bodies.some(body => body.op === 'authenticate'), owner ? false : true); + return {name:'identity.chat',mimeType:'application/octet-stream',buffer:bytes}; +} +async function unlock(page, file, phrase = password) { + await page.setInputFiles('#file', file); + await page.fill('#password', phrase); + await page.click('#unlock-form button'); +} +async function storageEmpty(page) { + assert.equal(await page.evaluate(() => localStorage.length + sessionStorage.length),0); + assert.deepEqual(await page.evaluate(() => indexedDB.databases()),[]); + assert.equal(await page.evaluate(async () => (await navigator.serviceWorker.getRegistrations()).length),0); + assert.deepEqual(await page.context().cookies(),[]); +} +async function verifyLibreJS() { + let executed = false; + const controlServer = createServer((request,response) => { + response.setHeader('Cache-Control','no-store'); + if (request.url === '/control.js') { + response.setHeader('Content-Type','text/javascript'); + response.end("window.unlicensedControl = true; fetch('/executed');"); + } else if (request.url === '/executed') { executed = true; response.end('unexpected'); } + else { response.setHeader('Content-Type','text/html'); response.end('<!doctype html><script src="/control.js"></script><p>Unlicensed external-script rejection control.</p>'); } + }); + await new Promise(done => controlServer.listen(0,'127.0.0.1',done)); + const controlPage = await persistent.newPage(); + try { + await controlPage.goto(`http://127.0.0.1:${controlServer.address().port}/`); + await controlPage.waitForTimeout(1500); + assert.equal(await controlPage.evaluate(() => window.unlicensedControl),undefined,'LibreJS must reject the unlicensed control'); + assert.equal(executed,false); + } finally { await controlPage.close(); await new Promise(done => controlServer.close(done)); } +} +try { + if (libre) await verifyLibreJS(); + const owner = await page(); + const response = await owner.goto(origin + '/#setup'); + assert.match(response.headers()['cache-control'], /no-store/); + assert.match(response.headers()['content-security-policy'], /frame-ancestors 'none'/); + await waitStatus(owner,'Owner setup.'); + const ownerFile = await create(owner,'Doan',true); + await unlock(owner,ownerFile); + await waitStatus(owner,'Unlocked in memory only'); + assert.match(await owner.locator('#who').textContent(), /OWNER/); + const visitor = await page(); + await visitor.goto(origin); + await waitStatus(visitor,'Choose CREATE'); + const visitorFile = await create(visitor,'A friend'); + assert.ok(!visitorFile.buffer.toString().includes('A friend')); + await unlock(visitor,visitorFile,'wrong-but-long-password'); + await waitStatus(visitor,'Cryptographic operation failed'); + assert.equal(await visitor.locator('#chat').isVisible(),false); + await unlock(visitor,visitorFile); + await waitStatus(visitor,'Unlocked in memory only'); + assert.equal(await visitor.locator('#messages li').count(),0); + assert.match(await visitor.locator('#counts').textContent(),/Total: 0/); + const malicious = '<script>window.chatInjection = true</script> hello λ'; + await visitor.fill('#text',malicious); + await visitor.click('#send-form button'); + await waitStatus(visitor,'Encrypted message stored'); + await owner.click('#refresh-inbox'); + await owner.locator('#conversations button').first().click(); + await waitStatus(owner,'Conversation opened'); + assert.match(await owner.locator('#thread-name').textContent(),/A friend/); + assert.equal(await owner.locator('#messages li').count(),0); + await owner.fill('#history-count','1'); + await owner.click('#history-form button'); + await waitStatus(owner,'Selected history decrypted'); + assert.equal(await owner.locator('#messages p').textContent(),malicious); + assert.equal(await owner.evaluate(() => window.chatInjection),undefined); + await owner.fill('#text','Reply from owner'); + await owner.click('#send-form button'); + await waitStatus(owner,'Encrypted message stored'); + await visitor.fill('#history-count','2'); + await visitor.click('#history-form button'); + await waitStatus(visitor,'Selected history decrypted'); + assert.equal(await visitor.locator('#messages li').count(),2); + assert.match(await visitor.locator('#counts').textContent(),/Total: 2 \| Sent: 1 \| Received: 1/); + let interrupted = false; + await visitor.route('**/api',async route => { + const body = route.request().postDataJSON(); + if (body.op === 'send' && !interrupted) { interrupted = true; await route.fetch(); await route.abort(); } + else await route.continue(); + }); + await visitor.fill('#text','Safe retry after a lost response'); + await visitor.click('#send-form button'); + await visitor.locator('#retry').waitFor({state:'visible'}); + await visitor.waitForFunction(() => !document.getElementById('retry').disabled); + await visitor.click('#retry'); + await waitStatus(visitor,'Encrypted message stored'); + assert.match(await visitor.locator('#counts').textContent(),/Total: 3/); + await visitor.reload(); + await waitStatus(visitor,'Choose CREATE'); + assert.equal(await visitor.locator('#messages li').count(),0); + assert.equal(await visitor.locator('#file').inputValue(),''); + assert.equal(await visitor.locator('#password').inputValue(),''); + await visitor.click('#open-choice'); + await unlock(visitor,visitorFile); + await waitStatus(visitor,'Unlocked in memory only'); + assert.match(await visitor.locator('#counts').textContent(),/Total: 3/); + assert.equal(await visitor.locator('#messages li').count(),0); + await visitor.fill('#history-count','1'); + await visitor.click('#history-form button'); + await waitStatus(visitor,'Selected history decrypted'); + assert.match(await visitor.locator('#messages p').textContent(),/Safe retry/); + await visitor.click('#older'); + await waitStatus(visitor,'Selected history decrypted'); + assert.equal(await visitor.locator('#messages li').count(),2); + await visitor.fill('#history-count','0'); + await visitor.click('#history-form button'); + await waitStatus(visitor,'No message history loaded'); + assert.equal(await visitor.locator('#messages li').count(),0); + const snapshot = join(root,'snapshot.sqlite'); + execFileSync(resolve('target/debug/stack-chat-server'),['--root',root,'--snapshot',snapshot],{stdio:'pipe'}); + await stopServer(); + await rm(join(root,'var/data/chat/chat.sqlite')); + await rename(snapshot,join(root,'var/data/chat/chat.sqlite')); + await launchServer(); + await visitor.reload(); + await waitStatus(visitor,'Choose CREATE'); + await visitor.click('#open-choice'); + await unlock(visitor,visitorFile); + await waitStatus(visitor,'Unlocked in memory only'); + assert.match(await visitor.locator('#counts').textContent(),/Total: 3/); + await visitor.fill('#history-count','3'); + await visitor.click('#history-form button'); + await waitStatus(visitor,'Selected history decrypted'); + assert.equal(await visitor.locator('#messages li').count(),3); + assert.ok((await visitor.locator('#messages p').allTextContents()).includes(malicious)); + await owner.reload(); + await waitStatus(owner,'Choose CREATE'); + await owner.click('#open-choice'); + await unlock(owner,ownerFile); + await waitStatus(owner,'Unlocked in memory only'); + assert.match(await owner.locator('#who').textContent(),/OWNER/); + await visitor.fill('#history-count','3'); + await visitor.click('#history-form button'); + await visitor.click('#lock'); + await visitor.waitForTimeout(500); + assert.equal(await visitor.locator('#messages li').count(),0); + await visitor.goto(origin + '/LIMITATIONS.txt'); + await visitor.goBack(); + await visitor.waitForFunction(() => !document.getElementById('entry').hidden); + assert.equal(await visitor.locator('#messages li').count(),0); + assert.equal(await visitor.locator('#password').inputValue(),''); + assert.equal(await visitor.locator('#chat').isVisible(),false); + await storageEmpty(visitor); + await storageEmpty(owner); + assert.deepEqual(external,[]); + assert.deepEqual(errors,[]); + for (const body of bodies) { + for (const forbidden of ['file','password','secret','name','text']) assert.ok(!(forbidden in body),`Leaked ${forbidden}`); + assert.ok(!JSON.stringify(body).includes(malicious)); + } + const database = await readFile(join(root,'var/data/chat/chat.sqlite')); + for (const plaintext of [malicious,'A friend','Reply from owner','Safe retry after a lost response',password]) assert.ok(!database.includes(Buffer.from(plaintext)),`Plaintext persisted: ${plaintext}`); + const blocked = await visitor.request.post(origin + '/api',{data:{op:'config'},headers:{Origin:'https://foreign.invalid'}}); + assert.equal(blocked.status(),403); + assert.equal(serverErrors,''); + if (process.env.CHAT_SCREENSHOT) await visitor.screenshot({path:resolve('.tools/chat-preview.png'),fullPage:true}); + if (libre) { + const extensions = JSON.parse(await readFile(join(root,'firefox-profile','extensions.json'),'utf8')); + const addon = extensions.addons.find(addon => addon.id === 'jid1-KtlZuoiikVfFew@jetpack'); + assert.ok(addon?.active,'LibreJS must actually be active'); + console.log(`LibreJS ${addon.version} is active in Firefox; complete chat workflow succeeded.`); + } + console.log('PASS: real browser/WASI owner setup, protected file recovery, wrong password, visitor/owner encrypted messages, safe text rendering, N=0, counts, pagination, retry deduplication, reload, live SQLite snapshot restoration and both identity recoveries, explicit lock, origin rejection, no browser storage or external requests, no private uploads or database plaintext.'); +} finally { + clearTimeout(deadline); + await browser.close(); + await stopServer(); + await rm(root,{recursive:true,force:true}); +} diff --git a/crates/chat-server/web/app.js b/crates/chat-server/web/app.js new file mode 100644 index 0000000..c06116b --- /dev/null +++ b/crates/chat-server/web/app.js @@ -0,0 +1,325 @@ +// 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 + +const element = id => document.getElementById(id); +const show = (id, visible = true) => { element(id).hidden = !visible; }; +const status = message => { element('status').textContent = message; }; +let config; +let wasm; +let credentials; +let session; +let selected; +let before = 0; +let pendingFile; +let setupToken; +let pendingSend; +let generation = 0; +let busy = false; +const workers = new Map(); +const blobs = new Set(); + +async function api(request) { + const current = generation; + const response = await fetch('/api', { + method: 'POST', credentials: 'omit', cache: 'no-store', redirect: 'error', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...request, token: session?.token }), + signal: AbortSignal.timeout(20000), + }); + const result = await response.json(); + if (current !== generation) throw new Error('Session locked'); + if (!response.ok || result.error) throw new Error(result.error || 'Server request failed'); + return result; +} + +async function cryptoOperation(request) { + const current = generation; + const bytes = await wasm; + if (current !== generation) throw new Error('Session locked'); + return new Promise((resolve, reject) => { + const worker = new Worker('/worker.js'); + + const stop = () => { worker.terminate(); workers.delete(worker); clearTimeout(timer); }; + const timer = setTimeout(() => { stop(); reject(new Error('Cryptographic operation timed out')); }, 90000); + workers.set(worker, () => { stop(); reject(new Error('Session locked')); }); + worker.onerror = () => { stop(); reject(new Error('Cryptographic worker failed')); }; + worker.onmessage = event => { + stop(); + if (current !== generation) { reject(new Error('Session locked')); return; } + if (event.data.error) reject(new Error(event.data.error)); else resolve(event.data.result); + }; + worker.postMessage({ bytes, request }); + }); +} + +function protectRequest(op, fields) { + if (!credentials) throw new Error('Unlock your identity file first'); + return { op, ...credentials, origin: location.origin, ...fields }; +} + +function clearState() { + generation += 1; + busy = false; + for (const button of document.querySelectorAll('button')) button.disabled = false; + for (const cancel of workers.values()) cancel(); + workers.clear(); + for (const url of blobs) URL.revokeObjectURL(url); + blobs.clear(); + credentials = undefined; + session = undefined; + selected = undefined; + pendingFile = undefined; + pendingSend = undefined; + setupToken = undefined; + before = 0; + for (const input of document.querySelectorAll('input,textarea')) { + if (input.id === 'history-count') input.value = '0'; else input.value = ''; + } + for (const id of ['messages', 'conversations', 'who', 'fingerprint', 'counts', 'thread-name', 'conversation-fingerprint']) element(id).replaceChildren(); + for (const id of ['create', 'unlock', 'chat', 'thread', 'inbox', 'older', 'retry']) show(id, false); + show('entry', Boolean(config?.owner)); + status('Locked. Select your file and enter its password to return.'); +} + +function action(id, callback) { + element(id).addEventListener(id.endsWith('-form') ? 'submit' : 'click', async event => { + event.preventDefault(); + if (busy) return; + busy = true; + status('Working locally...'); + const current = generation; + for (const button of document.querySelectorAll('button:not(#lock)')) button.disabled = true; + try { await callback(); } + catch (error) { if (generation === current) status(error.message); } + finally { + if (generation === current) { + busy = false; + for (const button of document.querySelectorAll('button')) button.disabled = false; + } + } + }); +} + +function download(file) { + const url = URL.createObjectURL(new Blob([file], { type: 'application/octet-stream' })); + blobs.add(url); + const link = document.createElement('a'); + link.href = url; + link.download = 'stack-chat-identity.chat'; + link.click(); + setTimeout(() => { URL.revokeObjectURL(url); blobs.delete(url); }, 30000); +} + +async function createIdentity() { + const password = element('new-password').value; + if (password !== element('confirm-password').value) throw new Error('Passwords do not match'); + if (!config.owner && location.hash !== '#setup') throw new Error('Owner setup is required first'); + setupToken = !config.owner ? element('setup-token').value : undefined; + status('Generating your purpose-specific identity and protecting its file...'); + const result = await cryptoOperation({ op: 'create', origin: location.origin, name: element('name').value, password, owner: config.owner || '' }); + pendingFile = result.file; + download(result.file); + element('create-form').reset(); + show('create', false); + show('unlock'); + element('reopen-note').textContent = 'Your file was downloaded. Reopen that exact saved file and enter its password before any message can be sent. Keep a safe backup.'; + status('Reopen your downloaded identity file to verify recovery.'); +} + +async function unlockIdentity() { + const file = element('file').files[0]; + if (!file || file.size > 262144) throw new Error('Choose an identity file no larger than 256 KiB'); + const fileText = await file.text(); + if (pendingFile && fileText !== pendingFile) throw new Error('Reopen the exact identity file you just downloaded'); + const candidate = { file: fileText, password: element('password').value }; + element('unlock-form').reset(); + status('Unlocking locally and verifying your identity...'); + const identity = await cryptoOperation({ op: 'open', ...candidate, origin: location.origin }); + if (config.owner && identity.owner !== config.owner) throw new Error('Pinned owner key differs from this server. Stop and verify the change with Doan.'); + const challenge = await api({ op: 'challenge', public: identity.public, setup_token: setupToken }); + const proof = await cryptoOperation({ op: 'proof', ...candidate, origin: location.origin, challenge: challenge.challenge }); + const authenticated = await api({ op: 'authenticate', id: challenge.id, signature: proof.signature }); + if (authenticated.id !== identity.fingerprint || typeof authenticated.owner !== 'boolean') throw new Error('Authenticated identity mismatch'); + credentials = candidate; + session = { ...identity, ...authenticated }; + pendingFile = undefined; + setupToken = undefined; + config = await api({ op: 'config' }); + show('entry', false); + show('unlock', false); + show('chat'); + element('who').textContent = `${identity.name} [${session.owner === true ? 'OWNER' : 'VISITOR'}]`; + element('fingerprint').textContent = `Identity: ${identity.fingerprint}`; + if (authenticated.owner) { + session.owner = true; + show('inbox'); + await inbox(); + } else { + session.owner = false; + const profile = await cryptoOperation(protectRequest('encrypt', { recipient: config.owner, payload: JSON.stringify({ v: 1, kind: 'profile', origin: location.origin, conversation: session.id, sender: session.id, name: identity.name }) })); + await api({ op: 'profile', conversation: session.id, ciphertext: profile.ciphertext }); + selected = { id: session.id, public: identity.public }; + element('conversation-fingerprint').textContent = `Conversation: ${session.id}`; + show('thread'); + element('thread-name').textContent = 'You <-> Doan'; + await history(0); + } + status('Unlocked in memory only. No history loaded automatically. Lock or leave to forget this session.'); +} + +function checkedPayload(payload, kind, conversation, sender) { + const value = JSON.parse(payload); + if (value.v !== 1 || value.kind !== kind || value.origin !== location.origin || value.conversation !== conversation || value.sender !== sender) throw new Error('Signed message binding mismatch'); + return value; +} + +async function inbox() { + const result = await api({ op: 'inbox' }); + element('conversations').replaceChildren(); + for (const conversation of result.conversations) { + const button = document.createElement('button'); + button.textContent = `${conversation.id.slice(0, 12)} / ${conversation.total} messages`; + button.addEventListener('click', async () => { + if (busy) return; + if (pendingSend) { status('Retry the pending message, or lock before switching conversations.'); return; } + busy = true; + const current = generation; + status('Opening conversation...'); + try { + selected = conversation; + element('conversation-fingerprint').textContent = `Conversation: ${conversation.id}`; + element('thread-name').textContent = `Doan <-> ${conversation.id.slice(0, 12)}`; + pendingSend = undefined; + element('text').value = ''; + show('retry', false); + element('messages').replaceChildren(); + show('thread'); + let name = conversation.id.slice(0, 12); + if (conversation.profile) { + const result = await cryptoOperation(protectRequest('decrypt', { sender: conversation.public, ciphertext: conversation.profile })); + if (result.signer !== conversation.id) throw new Error('Profile signer fingerprint mismatch'); + const profile = checkedPayload(result.payload, 'profile', conversation.id, conversation.id); + if (typeof profile.name !== 'string' || profile.name.length > 80) throw new Error('Invalid encrypted profile'); + name = profile.name; + } + element('thread-name').textContent = `Doan <-> ${name}`; + await history(0); + status('Conversation opened. Choose how many messages to load.'); + } catch (error) { status(error.message); } + finally { if (generation === current) busy = false; } + }); + element('conversations').append(button); + } +} + +function counters(conversation) { + const sent = session.owner ? conversation.owner_sent : conversation.visitor_sent; + const received = session.owner ? conversation.visitor_sent : conversation.owner_sent; + element('counts').textContent = `Total: ${conversation.total} | Sent: ${sent} | Received: ${received}`; +} + +async function history(count, cursor = 0) { + if (!selected) throw new Error('Choose a conversation first'); + if (!Number.isSafeInteger(count) || count < 0 || count > 50) throw new Error('Choose 0 to 50 messages'); + const result = await api({ op: 'history', conversation: selected.id, count, before: cursor }); + counters(result.conversation); + if (!cursor) element('messages').replaceChildren(); + if (result.messages.length) { + status('Decrypting and verifying selected messages locally...'); + const requests = result.messages.map(message => ({ sender: message.sender === selected.id ? selected.public : config.owner, ciphertext: message.ciphertext })); + const decrypted = await cryptoOperation(protectRequest('decrypt_batch', { messages: requests })); + for (let index = 0; index < result.messages.length; index += 1) { + const message = result.messages[index]; + const item = document.createElement('li'); + const label = document.createElement('small'); + label.textContent = `#${message.seq} / ${message.sender === session.id ? 'YOU' : 'THEM'}`; + const text = document.createElement('p'); + try { + const entry = decrypted.messages[index]; + if (entry.signer !== message.sender) throw new Error('Sender fingerprint mismatch'); + if (entry.error) throw new Error('Invalid ciphertext or signature'); + const payload = checkedPayload(entry.payload, 'message', selected.id, message.sender); + if (payload.id !== message.id || typeof payload.text !== 'string' || new TextEncoder().encode(payload.text).length > 8192) throw new Error('Message binding mismatch'); + text.textContent = payload.text; + } catch { text.textContent = '[Message rejected: invalid ciphertext, signature, or conversation binding.]'; } + item.append(label, text); + element('messages').prepend(item); + } + before = Math.min(...result.messages.map(message => message.seq)); + } + show('older', count > 0 && result.messages.length === count); + status(count === 0 ? 'No message history loaded.' : 'Selected history decrypted and verified locally.'); +} + +async function submitPending() { + if (!pendingSend) throw new Error('No pending message'); + const result = await api(pendingSend); + counters(result.conversation); + pendingSend = undefined; + element('text').value = ''; + show('retry', false); + status('Encrypted message stored. Load history to read messages.'); +} + +async function send() { + if (!selected) throw new Error('Choose a conversation first'); + if (pendingSend) throw new Error('Retry or lock before composing another message'); + const text = element('text').value; + if (!text.trim() || new TextEncoder().encode(text).length > 8192) throw new Error('Enter 1 to 8192 UTF-8 bytes of text'); + const id = Array.from(crypto.getRandomValues(new Uint8Array(32)), byte => byte.toString(16).padStart(2, '0')).join(''); + const recipient = session.owner ? selected.public : config.owner; + status('Signing and encrypting for both participants locally...'); + const result = await cryptoOperation(protectRequest('encrypt', { recipient, payload: JSON.stringify({ v: 1, kind: 'message', origin: location.origin, conversation: selected.id, sender: session.id, id, text }) })); + pendingSend = { op: 'send', conversation: selected.id, id, ciphertext: result.ciphertext }; + show('retry'); + await submitPending(); +} + +action('create-choice', async () => { show('entry', false); show('create'); status('Create a purpose-specific protected identity file.'); }); +action('open-choice', async () => { show('entry', false); show('unlock'); status('Select your saved identity file.'); }); +action('create-form', createIdentity); +action('unlock-form', unlockIdentity); +action('history-form', () => history(Number(element('history-count').value))); +action('send-form', send); +action('retry', submitPending); +action('refresh-inbox', inbox); +action('older', () => history(Number(element('history-count').value), before)); +element('lock').addEventListener('click', () => { if (session) api({ op: 'lock' }).catch(() => {}); clearState(); }); +window.addEventListener('pagehide', clearState); +window.addEventListener('pageshow', event => { if (event.persisted) clearState(); }); + +async function start() { + wasm = fetch('/crypto.wasm', { cache: 'no-store', credentials: 'omit' }).then(response => { + if (!response.ok) throw new Error('Build the WASI cryptographic module first'); + return response.arrayBuffer(); + }); + await wasm; + config = await api({ op: 'config' }); + if (config.origin !== location.origin) throw new Error('Server origin mismatch'); + if (!config.owner) { + if (location.hash === '#setup') { + show('create'); + show('setup-label'); + element('create-title').textContent = 'Create the dedicated owner identity'; + status('Owner setup. Keep this identity separate from your canonical OpenPGP key.'); + } else { status('Owner setup is not complete. Please return later.'); } + } else { show('entry'); status('Choose CREATE NEW CHAT or OPEN CHAT FILE.'); } +} + +start().catch(error => status(error.message)); + +// @license-end diff --git a/crates/chat-server/web/index.html b/crates/chat-server/web/index.html new file mode 100644 index 0000000..9783789 --- /dev/null +++ b/crates/chat-server/web/index.html @@ -0,0 +1,82 @@ +<!-- +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/>. +--> +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Message Me | Doan Luu Duc Tai</title> +<link rel="stylesheet" href="/style.css"> +<script src="/app.js" defer></script> +</head> +<body> +<main> +<header><a href="https://doan.luuduct.ai/">~doan</a> / message me <span class="status">EXPERIMENTAL / LOCAL TEST</span></header> +<h1>[ private chat ]</h1> +<p>Text only. A dedicated identity file. Encrypted for you and Doan.</p> +<p class="notice">This is an experimental application, not an audited messenger. <a href="/LIMITATIONS.txt">Read its limitations</a> before use. Keep your file and password safe: there is no account recovery.</p> +<p id="status" role="status" aria-live="polite">Loading cryptographic module...</p> +<section id="entry" hidden> +<button id="create-choice">CREATE NEW CHAT</button> +<button id="open-choice">OPEN CHAT FILE</button> +</section> +<section id="create" hidden> +<h2 id="create-title">Create a new chat</h2> +<form id="create-form" autocomplete="off"> +<label>Display name <input id="name" maxlength="80" required autocomplete="off"></label> +<label>Password <input id="new-password" type="password" minlength="12" maxlength="1024" required autocomplete="off"></label> +<label>Confirm password <input id="confirm-password" type="password" minlength="12" required autocomplete="off"></label> +<label id="setup-label" hidden>Owner setup token from the root .env <input id="setup-token" type="password" autocomplete="off"></label> +<p>Use a strong, unique passphrase. This identity is solely for this chat; do not reuse it elsewhere.</p> +<button type="submit">GENERATE AND DOWNLOAD IDENTITY FILE</button> +</form> +</section> +<section id="unlock" hidden> +<h2>Open your saved identity file</h2> +<p id="reopen-note">Your file is opened locally. The file and password are never uploaded.</p> +<form id="unlock-form" autocomplete="off"> +<label>Identity file <input id="file" type="file" required></label> +<label>Password <input id="password" type="password" required autocomplete="off"></label> +<button type="submit">UNLOCK CHAT</button> +</form> +</section> +<section id="chat" hidden> +<div class="chat-heading"><h2 id="who"></h2><button id="lock">LOCK / FORGET</button></div> +<p id="fingerprint"></p> +<section id="inbox" hidden><h3>Conversations</h3><button id="refresh-inbox">REFRESH INBOX</button><div id="conversations"></div></section> +<section id="thread" hidden> +<h3 id="thread-name"></h3> +<p id="conversation-fingerprint"></p> +<p id="counts"></p> +<form id="history-form"><label>Latest messages to load (0–50) <input id="history-count" type="number" min="0" max="50" step="1" value="0" required></label><button type="submit">LOAD HISTORY</button></form> +<button id="older" hidden>LOAD OLDER</button> +<ol id="messages" aria-label="Messages"></ol> +<form id="send-form" autocomplete="off"><label>Plain text message (up to 8192 UTF-8 bytes) <textarea id="text" rows="5" required maxlength="8192" autocomplete="off"></textarea></label><button type="submit">ENCRYPT AND SEND</button></form> +<button id="retry" hidden>RETRY THE SAME ENCRYPTED MESSAGE</button> +</section> +</section> +<noscript>This chat requires free JavaScript and WebAssembly for local cryptography. The main website remains JavaScript-free.</noscript> +<footer> +<p><a class="badge" href="https://www.gnu.org/software/librejs/" aria-describedby="js-warning">GNU LibreJS-compliant</a><span id="js-warning" role="tooltip">LibreJS compliance concerns software freedom, not a guarantee of privacy or security. Review the source code, dependencies, and scripts delivered to your browser. Server-delivered code can change between visits; free software can still track users or behave maliciously.</span></p> +<a href="https://www.gnu.org/licenses/agpl-3.0.html">LICENSE</a> +<a href="/THIRD-PARTY-LICENSES.html">THIRD PARTY LICENSES</a> +<a href="/SOURCE.tar.gz">CORRESPONDING SOURCE CODE</a> +<a href="/javascript.html" rel="jslicense">JAVASCRIPT LICENSES / RAW SOURCE</a> +</footer> +</main> +</body> +</html> diff --git a/crates/chat-server/web/javascript.html b/crates/chat-server/web/javascript.html new file mode 100644 index 0000000..927a678 --- /dev/null +++ b/crates/chat-server/web/javascript.html @@ -0,0 +1,28 @@ +<!-- +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/>. +--> +<!doctype html> +<html lang="en"><head><meta charset="utf-8"><title>JavaScript licenses</title><link rel="stylesheet" href="/style.css"></head><body><main> +<h1>JavaScript licenses and source</h1> +<p>All first-party scripts are available under the GNU Affero General Public License, version 3 or, at your option, any later version. The table uses the license identifiers recognized by GNU LibreJS.</p> +<table id="jslicense-labels1"><thead><tr><th>Script / raw file</th><th>License</th><th>Corresponding source</th></tr></thead><tbody> +<tr><td><a href="/app.js">app.js</a></td><td><a href="https://www.gnu.org/licenses/agpl-3.0.html">GNU-AGPL-3.0</a></td><td><a href="/SOURCE.tar.gz">Complete corresponding source</a></td></tr> +<tr><td><a href="/worker.js">worker.js</a></td><td><a href="https://www.gnu.org/licenses/agpl-3.0.html">GNU-AGPL-3.0</a></td><td><a href="/SOURCE.tar.gz">Complete corresponding source</a></td></tr> +<tr><td><a href="/wasi.mjs">wasi.mjs</a></td><td><a href="https://www.gnu.org/licenses/agpl-3.0.html">GNU-AGPL-3.0</a></td><td><a href="/SOURCE.tar.gz">Complete corresponding source</a></td></tr> +</tbody></table> +<p>The WebAssembly module is compiled from the Rust workspace and unmodified OpenSSL 3.6.5, under their respective free software licenses. The source archive includes first-party sources, Cargo dependencies, the OpenSSL source archive, build scripts, and notices. General-purpose Rust and WASI SDK tools are documented with verified download locations in CHAT-OPENSSL-MIGRATION.txt.</p> +<p><a href="/THIRD-PARTY-LICENSES.html">Third-party licenses</a> / <a href="/SOURCE.tar.gz">Source archive</a> / <a href="/">Return</a></p> +</main></body></html> diff --git a/crates/chat-server/web/style.css b/crates/chat-server/web/style.css new file mode 100644 index 0000000..0983b2e --- /dev/null +++ b/crates/chat-server/web/style.css @@ -0,0 +1,49 @@ +/* +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/>. +*/ +* { box-sizing: border-box; } +:root { color-scheme: dark; font-family: "Courier New", monospace; background: #131715; color: #d4d8cd; line-height: 1.6; } +body { margin: 0; } +main { max-width: 58rem; margin: 2rem auto; padding: 0 1.25rem; } +header, footer { border-top: 1px solid #667367; padding-top: 1rem; } +header { display: flex; flex-wrap: wrap; gap: .6rem; } +h1 { font-size: 1.8rem; color: #e3e3ce; } +h2, h3 { font-size: 1.05rem; } +a { color: #aec2a5; text-underline-offset: .2rem; } +.status, .badge { color: #e0c46d; } +.status { margin-left: auto; } +.notice { border-left: 2px solid #c5ad68; padding-left: 1rem; } +button, input, textarea { font: inherit; color: inherit; background: #1c231e; border: 1px solid #667367; border-radius: 0; padding: .55rem .7rem; } +button { cursor: pointer; margin: .4rem .6rem .4rem 0; } +button:hover, button:focus-visible { border-color: #d7c884; color: #f0e5b0; } +button:disabled { opacity: .5; cursor: wait; } +input, textarea { display: block; width: 100%; margin: .35rem 0 1rem; } +input[type=number] { width: 8rem; } +label { display: block; } +section { margin: 1.5rem 0; } +[hidden] { display: none !important; } +.chat-heading { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; } +#fingerprint, #conversation-fingerprint { overflow-wrap: anywhere; font-size: .8rem; } +#messages { padding: 0; list-style: none; } +#messages li { border-top: 1px dashed #48564a; padding: 1rem 0; } +#messages p { white-space: pre-wrap; overflow-wrap: anywhere; margin: .4rem 0; } +#messages small { color: #a8b39e; } +#status { min-height: 1.6rem; color: #e0c46d; } +footer { margin-top: 3rem; padding-bottom: 2rem; } +footer > a { display: block; width: fit-content; } +footer p { position: relative; } +#js-warning { display: none; max-width: 48rem; border: 1px solid #c5ad68; background: #1c231e; padding: 1rem; margin: .8rem 0; } +.badge:hover + #js-warning, .badge:focus-visible + #js-warning { display: block; } diff --git a/crates/chat-server/web/worker.js b/crates/chat-server/web/worker.js new file mode 100644 index 0000000..64e0795 --- /dev/null +++ b/crates/chat-server/web/worker.js @@ -0,0 +1,31 @@ +// 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 + +importScripts('/wasi.mjs'); + +self.onmessage = async event => { + try { + const result = await self.stackChatWasi.runCommand(event.data.bytes, event.data.request); + self.postMessage({ result }); + } catch { + self.postMessage({ error: 'Cryptographic operation failed. Check your file, password, origin, and sender.' }); + } finally { + self.close(); + } +}; + +// @license-end |
