diff options
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/chat-crypto/README.txt | 2 | ||||
| -rw-r--r-- | crates/chat-crypto/src/application.rs | 115 | ||||
| -rw-r--r-- | crates/chat-server/tests/browser.mjs | 7 |
3 files changed, 112 insertions, 12 deletions
diff --git a/crates/chat-crypto/README.txt b/crates/chat-crypto/README.txt index 2ee3f01..a08d850 100644 --- a/crates/chat-crypto/README.txt +++ b/crates/chat-crypto/README.txt @@ -32,7 +32,7 @@ PRIVATE CHAT CRYPTOGRAPHY See ../../docs/CHAT-APPLICATION.txt for implemented formats, local setup, tests, backups, and limitations. See ../../docs/CHAT-PRODUCTION.txt for - packaged HTTPS integration. Work remains experimental on feature/private-chat; + packaged HTTPS integration. Work remains experimental; compatibility tests are not an application security audit. ../../docs/CHAT-OPENSSL-MIGRATION.txt records the complete backend decision, 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<String>), +} + #[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<String> { &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::<std::result::Result<Vec<_>, _>>()?; + 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<String> { 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 diff --git a/crates/chat-server/tests/browser.mjs b/crates/chat-server/tests/browser.mjs index 7a359e7..b0a9041 100644 --- a/crates/chat-server/tests/browser.mjs +++ b/crates/chat-server/tests/browser.mjs @@ -163,6 +163,13 @@ async function create(page, name, owner = false) { await page.click('#create-form button'); const download = await downloaded; const bytes = await readFile(await download.path()); + const envelope = JSON.parse(bytes.toString()); + assert.ok(envelope.format.startsWith('stack-chat-file/v2;')); + assert.ok(bytes.toString().startsWith('{\n "format"')); + assert.ok(Array.isArray(envelope.ciphertext)); + assert.ok(envelope.ciphertext.length > 1); + assert.ok(envelope.ciphertext.slice(0,-1).every(chunk => chunk.length === 80)); + assert.ok(envelope.ciphertext.at(-1).length > 0 && envelope.ciphertext.at(-1).length <= 80); await waitStatus(page,'Reopen your downloaded'); assert.equal(await page.locator('#chat').isVisible(), false); assert.equal(bodies.filter(body => body.op === 'authenticate').length,previousAuthentications); |
