summaryrefslogtreecommitdiff
path: root/crates/chat-crypto/src/application.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/chat-crypto/src/application.rs')
-rw-r--r--crates/chat-crypto/src/application.rs115
1 files changed, 104 insertions, 11 deletions
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