From b7b163a4d34f486663e72950625ad03fbf022f73 Mon Sep 17 00:00:00 2001 From: Doan Luu Duc Tai Date: Sat, 31 Oct 2026 08:58:04 -0500 Subject: Export readable wrapped chat files with legacy compatibility --- crates/chat-crypto/src/application.rs | 115 ++++++++++++++++++++++++++++++---- 1 file changed, 104 insertions(+), 11 deletions(-) (limited to 'crates/chat-crypto/src') diff --git a/crates/chat-crypto/src/application.rs b/crates/chat-crypto/src/application.rs index 98e8a20..963e1de 100644 --- a/crates/chat-crypto/src/application.rs +++ b/crates/chat-crypto/src/application.rs @@ -32,7 +32,8 @@ 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 LEGACY_FORMAT: &str = "stack-chat-file/v1;argon2id-m65536-t3-p1;aes256-gcm"; +const FORMAT: &str = "stack-chat-file/v2;argon2id-m65536-t3-p1;aes256-gcm"; const LIMIT: usize = 262_144; #[derive(Deserialize, JsonSerialize)] @@ -50,10 +51,17 @@ struct Envelope { format: String, salt: String, nonce: String, - ciphertext: String, + ciphertext: FileCiphertext, tag: String, } +#[derive(Deserialize, JsonSerialize)] +#[serde(untagged)] +enum FileCiphertext { + Compact(String), + Wrapped(Vec), +} + #[derive(Deserialize)] #[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)] enum Request { @@ -201,11 +209,17 @@ fn protect(identity: &Identity, password: &str) -> Result { &plaintext, &mut tag, )?; - Ok(serde_json::to_string(&Envelope { + let encoded = BASE64_STANDARD.encode(ciphertext); + let chunks = encoded + .as_bytes() + .chunks(80) + .map(|chunk| std::str::from_utf8(chunk).map(str::to_owned)) + .collect::, _>>()?; + Ok(serde_json::to_string_pretty(&Envelope { format: FORMAT.into(), salt: BASE64_STANDARD.encode(salt), nonce: BASE64_STANDARD.encode(nonce), - ciphertext: BASE64_STANDARD.encode(ciphertext), + ciphertext: FileCiphertext::Wrapped(chunks), tag: BASE64_STANDARD.encode(tag), })?) } @@ -213,10 +227,20 @@ fn protect(identity: &Identity, password: &str) -> Result { 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 encoded = match (envelope.format.as_str(), &envelope.ciphertext) { + (LEGACY_FORMAT, FileCiphertext::Compact(value)) => value.clone(), + (FORMAT, FileCiphertext::Wrapped(chunks)) => { + ensure!( + !chunks.is_empty() + && chunks + .iter() + .all(|chunk| !chunk.is_empty() && chunk.len() <= 80), + "Invalid ciphertext wrapping" + ); + chunks.concat() + } + _ => anyhow::bail!("Unsupported identity file format"), + }; let salt = decode(&envelope.salt)?; let nonce = decode(&envelope.nonce)?; let tag = decode(&envelope.tag)?; @@ -224,13 +248,13 @@ fn unlock(file: &str, password: &str, origin: &str) -> Result<(Identity, Cert)> salt.len() == 16 && nonce.len() == 12 && tag.len() == 16, "Malformed envelope" ); - let ciphertext = decode(&envelope.ciphertext)?; + let ciphertext = decode(&encoded)?; let key = derive(password, &salt)?; let plaintext = Zeroizing::new(decrypt_aead( Cipher::aes_256_gcm(), key.as_ref(), Some(&nonce), - FORMAT.as_bytes(), + envelope.format.as_bytes(), &ciphertext, &tag, )?); @@ -643,7 +667,7 @@ mod tests { format: FORMAT.into(), salt: BASE64_STANDARD.encode([0; 15]), nonce: BASE64_STANDARD.encode([0; 12]), - ciphertext: String::new(), + ciphertext: FileCiphertext::Wrapped(Vec::new()), tag: BASE64_STANDARD.encode([0; 16]), })?; ensure!( @@ -657,6 +681,74 @@ mod tests { Ok(()) } + fn check_file_formats( + file: &str, + identity: &Identity, + password: &str, + origin: &str, + ) -> Result<()> { + ensure!( + file.starts_with("{\n \"format\""), + "Envelope is not indented" + ); + let envelope: Envelope = serde_json::from_str(file)?; + let FileCiphertext::Wrapped(chunks) = &envelope.ciphertext else { + anyhow::bail!("Ciphertext is not wrapped"); + }; + let (last, complete) = chunks.split_last().context("Missing ciphertext")?; + ensure!( + complete.iter().all(|chunk| chunk.len() == 80) && (1..=80).contains(&last.len()), + "Wrong ciphertext width" + ); + let salt = random::<16>()?; + let nonce = random::<12>()?; + let key = derive(password, &salt)?; + let mut tag = [0; 16]; + let plaintext = Zeroizing::new(serde_json::to_vec(identity)?); + let ciphertext = encrypt_aead( + Cipher::aes_256_gcm(), + key.as_ref(), + Some(&nonce), + LEGACY_FORMAT.as_bytes(), + &plaintext, + &mut tag, + )?; + let legacy = Envelope { + format: LEGACY_FORMAT.into(), + salt: BASE64_STANDARD.encode(salt), + nonce: BASE64_STANDARD.encode(nonce), + ciphertext: FileCiphertext::Compact(BASE64_STANDARD.encode(ciphertext)), + tag: BASE64_STANDARD.encode(tag), + }; + for historical in [ + serde_json::to_string(&legacy)?, + serde_json::to_string_pretty(&legacy)?, + ] { + let (restored, _certificate) = unlock(&historical, password, origin)?; + ensure!( + restored.secret.as_str() == identity.secret.as_str(), + "Legacy identity changed" + ); + ensure!( + unlock(&historical, "wrong-passphrase", origin).is_err(), + "Legacy password protection failed" + ); + } + let mut malformed = serde_json::to_value(&envelope)?; + *malformed.get_mut("ciphertext").context("Ciphertext")? = json!(["A".repeat(81)]); + ensure!( + unlock(&malformed.to_string(), password, origin).is_err(), + "Overlong ciphertext chunk accepted" + ); + *malformed.get_mut("format").context("Format")? = json!(LEGACY_FORMAT); + *malformed.get_mut("ciphertext").context("Ciphertext")? = json!(chunks.concat()); + ensure!( + unlock(&malformed.to_string(), password, origin).is_err(), + "Unauthenticated file-version downgrade accepted" + ); + Ok(()) + } + #[test] fn protected_file_and_authenticated_messages() -> Result<()> { let origin = "http://127.0.0.1:9080"; @@ -668,6 +760,7 @@ mod tests { .as_str() .context("File")?; let (owner_identity, owner_cert) = unlock(owner_file, password, origin)?; + check_file_formats(owner_file, &owner_identity, password, origin)?; let owner_public = public(&owner_cert)?; let visitor = create(origin.into(), "Visitor".into(), password, owner_public)?; let visitor_file = visitor -- cgit v1.2.3