// Copyright (C) 2026 Doan Luu Duc Tai // // 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 . 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, } #[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, owner: String, }, Open { file: String, password: Zeroizing, origin: String, }, Proof { file: String, password: Zeroizing, origin: String, challenge: String, }, Encrypt { file: String, password: Zeroizing, origin: String, recipient: String, payload: String, }, DecryptBatch { file: String, password: Zeroizing, origin: String, messages: Vec, }, Decrypt { file: String, password: Zeroizing, origin: String, sender: String, ciphertext: String, }, } #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct EncryptedMessage { sender: String, ciphertext: String, } fn decode(value: &str) -> Result> { ensure!(value.len() <= LIMIT, "Input exceeds its limit"); Ok(BASE64_STANDARD.decode(value)?) } fn random() -> Result<[u8; N]> { let mut bytes = [0; N]; pgp::crypto::random(&mut bytes)?; Ok(bytes) } fn certificate(encoded: &str) -> Result { 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 { 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 { 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> { 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 { 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 { 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> { 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, decrypt: &mut dyn FnMut(Option, &SessionKey) -> bool, ) -> Result> { 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 { 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::>(); 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::()?; 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 { 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 { 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 { 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 { 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 { public_identity(&message.sender)?; decrypt(&cert, &certificate(&message.sender)?, &message.ciphertext) })(); result.unwrap_or_else(|_| json!({"error":"Message rejected"})) }) .collect::>(); 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 { let result = (|| -> Result { 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 ")?; 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 ", "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(()) } }