diff options
Diffstat (limited to 'crates/chat-crypto/src')
| -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 |
3 files changed, 773 insertions, 4 deletions
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}") } |
