aboutsummaryrefslogtreecommitdiff
path: root/crates/chat-crypto/src
diff options
context:
space:
mode:
Diffstat (limited to 'crates/chat-crypto/src')
-rw-r--r--crates/chat-crypto/src/application.rs753
-rw-r--r--crates/chat-crypto/src/lib.rs6
-rw-r--r--crates/chat-crypto/src/main.rs18
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}")
}