1use anyhow::anyhow;
41use chacha20poly1305::aead::{Aead, AeadCore, AeadInPlace, KeyInit, OsRng};
42use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
43
44const HEADER_FORMAT_VERSION: u8 = 1;
45const HEADER_SIZE: usize = 15;
46const MAX_RECIPIENTS: usize = 32;
47const NONCE_SIZE: usize = 12;
48const FILE_KEY_SIZE: usize = 32;
49const WRAPPED_KEY_SIZE: usize = FILE_KEY_SIZE + 16; const RECIPIENT_SLOT_SIZE: usize = NONCE_SIZE + WRAPPED_KEY_SIZE; struct Argon2Config {
64 algorithm: u8,
65 version: u8,
66 m_cost: u32,
67 t_cost: u32,
68 p_cost: u32,
69}
70
71impl Argon2Config {
72 fn strong() -> Self {
74 Self { algorithm: 2, version: 19, m_cost: 19 * 1024, t_cost: 2, p_cost: 1 }
75 }
76
77 fn weak() -> Self {
79 Self { algorithm: 2, version: 19, m_cost: 4 * 1024, t_cost: 2, p_cost: 1 }
80 }
81
82 fn to_argon2(&self) -> anyhow::Result<argon2::Argon2<'static>> {
83 let algorithm = match self.algorithm {
84 0 => argon2::Algorithm::Argon2d,
85 1 => argon2::Algorithm::Argon2i,
86 2 => argon2::Algorithm::Argon2id,
87 _ => anyhow::bail!("unknown argon2 algorithm byte: {}", self.algorithm),
88 };
89 let version = match self.version {
90 16 => argon2::Version::V0x10,
91 19 => argon2::Version::V0x13,
92 _ => anyhow::bail!("unknown argon2 version byte: {}", self.version),
93 };
94 let params = argon2::Params::new(self.m_cost, self.t_cost, self.p_cost, None)
95 .map_err(|e| anyhow!("argon2 params error: {}", e))?;
96 Ok(argon2::Argon2::new(algorithm, version, params))
97 }
98
99 fn write_header_to(&self, out: &mut Vec<u8>) {
100 out.push(HEADER_FORMAT_VERSION);
101 out.push(self.algorithm);
102 out.push(self.version);
103 out.extend_from_slice(&self.m_cost.to_be_bytes());
104 out.extend_from_slice(&self.t_cost.to_be_bytes());
105 out.extend_from_slice(&self.p_cost.to_be_bytes());
106 }
107
108 fn from_header_bytes(bytes: &[u8]) -> anyhow::Result<Self> {
109 if bytes.len() < HEADER_SIZE {
110 anyhow::bail!("ciphertext too short to contain argon2 header: {} < {}", bytes.len(), HEADER_SIZE);
111 }
112 if bytes[0] != HEADER_FORMAT_VERSION {
113 anyhow::bail!("unknown argon2 header format version: {}", bytes[0]);
114 }
115 let m_cost = u32::from_be_bytes(bytes[3..7].try_into().map_err(|_| anyhow!("m_cost slice error"))?);
116 let t_cost = u32::from_be_bytes(bytes[7..11].try_into().map_err(|_| anyhow!("t_cost slice error"))?);
117 let p_cost = u32::from_be_bytes(bytes[11..15].try_into().map_err(|_| anyhow!("p_cost slice error"))?);
118
119 if m_cost > 256 * 1024 {
122 anyhow::bail!("argon2 m_cost too large: {} (max 256 MiB)", m_cost);
123 }
124 if t_cost > 16 {
125 anyhow::bail!("argon2 t_cost too large: {} (max 16)", t_cost);
126 }
127 if p_cost > 4 {
128 anyhow::bail!("argon2 p_cost too large: {} (max 4)", p_cost);
129 }
130
131 Ok(Self { algorithm: bytes[1], version: bytes[2], m_cost, t_cost, p_cost })
132 }
133}
134
135fn derive_wrap_key(argon2: &argon2::Argon2, password: &[u8]) -> anyhow::Result<Key> {
137 let mut key_bytes = [0u8; 32];
138 argon2
139 .hash_password_into(password, b"hashiverse-key-wrap", &mut key_bytes)
140 .map_err(|e| anyhow!("key derivation error: {}", e))?;
141 Ok(*Key::from_slice(&key_bytes))
142}
143
144fn encrypt_with_config(config: &Argon2Config, plaintext: &[u8], passwords: &[Vec<u8>]) -> anyhow::Result<Vec<u8>> {
145 if passwords.is_empty() {
146 anyhow::bail!("at least one password required");
147 }
148 if passwords.len() > MAX_RECIPIENTS {
149 anyhow::bail!("too many recipients: {} > {}", passwords.len(), MAX_RECIPIENTS);
150 }
151
152 let argon2 = config.to_argon2()?;
153 let file_key = ChaCha20Poly1305::generate_key(&mut OsRng);
154
155 let mut out = Vec::new();
156 config.write_header_to(&mut out);
157 out.push(passwords.len() as u8);
158
159 for password in passwords {
161 let wrap_key = derive_wrap_key(&argon2, password)?;
162 let wrap_nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng);
163 let wrapped = ChaCha20Poly1305::new(&wrap_key)
164 .encrypt(&wrap_nonce, file_key.as_slice())
165 .map_err(|_| anyhow!("key wrap failed"))?;
166 out.extend_from_slice(&wrap_nonce);
167 out.extend_from_slice(&wrapped);
168 }
169
170 let body_nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng);
172 out.extend_from_slice(&body_nonce);
173 let plaintext_start = out.len();
174 out.extend_from_slice(plaintext);
175 let tag = ChaCha20Poly1305::new(&file_key)
176 .encrypt_in_place_detached(&body_nonce, b"", &mut out[plaintext_start..])
177 .map_err(|_| anyhow!("body encryption failed"))?;
178 out.extend_from_slice(&tag);
179
180 Ok(out)
181}
182
183pub fn encrypt_strong(plaintext: &[u8], passwords: &[Vec<u8>]) -> anyhow::Result<Vec<u8>> {
185 encrypt_with_config(&Argon2Config::strong(), plaintext, passwords)
186}
187
188pub fn encrypt_weak(plaintext: &[u8], passwords: &[Vec<u8>]) -> anyhow::Result<Vec<u8>> {
190 encrypt_with_config(&Argon2Config::weak(), plaintext, passwords)
191}
192
193pub fn decrypt(ciphertext: &[u8], password: &[u8]) -> anyhow::Result<Vec<u8>> {
197 let config = Argon2Config::from_header_bytes(ciphertext)?;
198 let argon2 = config.to_argon2()?;
199
200 let mut pos = HEADER_SIZE;
201
202 if ciphertext.len() <= pos {
203 anyhow::bail!("ciphertext too short: missing recipient count");
204 }
205 let num_recipients = ciphertext[pos] as usize;
206 pos += 1;
207
208 if num_recipients == 0 || num_recipients > MAX_RECIPIENTS {
209 anyhow::bail!("invalid recipient count: {}", num_recipients);
210 }
211
212 let recipients_end = pos + num_recipients * RECIPIENT_SLOT_SIZE;
213 if ciphertext.len() < recipients_end + NONCE_SIZE + 16 {
214 anyhow::bail!("ciphertext too short for claimed recipient count");
215 }
216
217 let wrap_key = derive_wrap_key(&argon2, password)?;
219 let wrap_cipher = ChaCha20Poly1305::new(&wrap_key);
220
221 let mut file_key: Option<Key> = None;
222 for i in 0..num_recipients {
223 let slot = pos + i * RECIPIENT_SLOT_SIZE;
224 let nonce = Nonce::from_slice(&ciphertext[slot..slot + NONCE_SIZE]);
225 let wrapped = &ciphertext[slot + NONCE_SIZE..slot + RECIPIENT_SLOT_SIZE];
226 if let Ok(key_bytes) = wrap_cipher.decrypt(nonce, wrapped) {
227 file_key = Some(*Key::from_slice(&key_bytes));
228 break;
229 }
230 }
231
232 let file_key = file_key.ok_or_else(|| anyhow!("password did not match any recipient"))?;
233
234 let body_nonce = Nonce::from_slice(&ciphertext[recipients_end..recipients_end + NONCE_SIZE]);
235 let body_ciphertext = &ciphertext[recipients_end + NONCE_SIZE..];
236
237 ChaCha20Poly1305::new(&file_key)
238 .decrypt(body_nonce, body_ciphertext)
239 .map_err(|_| anyhow!("body decryption failed"))
240}
241
242
243#[cfg(test)]
244mod tests {
245 use std::sync::Arc;
246 use log::info;
247 use crate::tools::encryption::*;
248
249 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
250 extern crate wasm_bindgen_test;
251 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
252 use wasm_bindgen_test::*;
253 use crate::tools::time_provider::stop_watch::StopWatch;
254 use crate::tools::time_provider::time_provider::RealTimeProvider;
255
256 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
257 wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
258
259 #[cfg_attr(not(all(target_arch = "wasm32", target_os = "unknown")), tokio::test)]
260 #[cfg_attr(all(target_arch = "wasm32", target_os = "unknown"), wasm_bindgen_test)]
261 async fn test_multiple_encryption_strong() -> anyhow::Result<()> {
262 test_multiple_encryption(encrypt_strong).await
263 }
264
265 #[cfg_attr(not(all(target_arch = "wasm32", target_os = "unknown")), tokio::test)]
266 #[cfg_attr(all(target_arch = "wasm32", target_os = "unknown"), wasm_bindgen_test)]
267 async fn test_multiple_encryption_weak() -> anyhow::Result<()> {
268 test_multiple_encryption(encrypt_weak).await
269 }
270
271 type EncryptFn = fn(&[u8], &[Vec<u8>]) -> anyhow::Result<Vec<u8>>;
272
273 async fn test_multiple_encryption(encrypt_fn: EncryptFn) -> anyhow::Result<()> {
274 let plaintext = "Jimme was here and then some...".as_bytes();
275 let passwords = vec!["alice".to_string().into_bytes(), "bob".to_string().into_bytes(), "charlie".to_string().into_bytes()];
276 let encrypted = encrypt_fn(plaintext, &passwords)?;
277
278 {
279 let decrypted = decrypt(&encrypted, &passwords[0])?;
280 assert_eq!(plaintext, &decrypted);
281 }
282 {
283 let decrypted = decrypt(&encrypted, &passwords[1])?;
284 assert_eq!(plaintext, &decrypted);
285 }
286 {
287 let decrypted = decrypt(&encrypted, &passwords[2])?;
288 assert_eq!(plaintext, &decrypted);
289 }
290 {
291 assert!(decrypt(&encrypted, &"incorrect password".to_string().into_bytes()).is_err());
292 }
293 Ok(())
294 }
295
296 #[cfg_attr(not(all(target_arch = "wasm32", target_os = "unknown")), tokio::test)]
297 #[cfg_attr(all(target_arch = "wasm32", target_os = "unknown"), wasm_bindgen_test)]
298 async fn test_encryption_speeds() -> anyhow::Result<()> {
299 let plaintext = "Jimme was here and then some...".as_bytes();
302 let passwords = vec!["alice".to_string().into_bytes(), "bob".to_string().into_bytes(), "charlie".to_string().into_bytes()];
303 let encrypted_strong = encrypt_strong(plaintext, &passwords)?;
304 let encrypted_weak = encrypt_weak(plaintext, &passwords)?;
305 assert_ne!(encrypted_strong, encrypted_weak, "weak and strong encryption should not be identical");
306
307 const ITERATIONS: usize = 128;
308 let time_provider = Arc::new(RealTimeProvider);
309
310 let stopwatch_strong = StopWatch::new(time_provider.clone());
311 for _ in 0..ITERATIONS {
312 let decrypted = decrypt(&encrypted_strong, &passwords[0])?;
313 assert_eq!(plaintext, &decrypted);
314 }
315 let elapsed_strong = stopwatch_strong.elapsed_time_millis();
316 info!("Strong encryption took {}", elapsed_strong);
317
318 let stopwatch_weak = StopWatch::new(time_provider.clone());
319 for _ in 0..ITERATIONS {
320 let decrypted = decrypt(&encrypted_weak, &passwords[0])?;
321 assert_eq!(plaintext, &decrypted);
322 }
323 let elapsed_weak = stopwatch_weak.elapsed_time_millis();
324 info!("Weak encryption took {}", elapsed_weak);
325
326 assert!(elapsed_weak < elapsed_strong, "Weak encryption should be faster than strong encryption");
327
328 Ok(())
329 }
330
331 #[cfg_attr(not(all(target_arch = "wasm32", target_os = "unknown")), tokio::test)]
332 #[cfg_attr(all(target_arch = "wasm32", target_os = "unknown"), wasm_bindgen_test)]
333 async fn test_zero_recipients_rejected() -> anyhow::Result<()> {
334 let result = encrypt_weak(b"test", &[]);
335 assert!(result.is_err());
336 assert!(result.unwrap_err().to_string().contains("at least one password"));
337 Ok(())
338 }
339
340 #[cfg_attr(not(all(target_arch = "wasm32", target_os = "unknown")), tokio::test)]
341 #[cfg_attr(all(target_arch = "wasm32", target_os = "unknown"), wasm_bindgen_test)]
342 async fn test_too_many_recipients_rejected() -> anyhow::Result<()> {
343 let passwords: Vec<Vec<u8>> = (0..=MAX_RECIPIENTS).map(|i| format!("password{}", i).into_bytes()).collect();
344 let result = encrypt_weak(b"test", &passwords);
345 assert!(result.is_err());
346 assert!(result.unwrap_err().to_string().contains("too many recipients"));
347 Ok(())
348 }
349
350 #[cfg_attr(not(all(target_arch = "wasm32", target_os = "unknown")), tokio::test)]
351 #[cfg_attr(all(target_arch = "wasm32", target_os = "unknown"), wasm_bindgen_test)]
352 async fn test_max_recipients() -> anyhow::Result<()> {
353 let plaintext = b"test";
354 let passwords: Vec<Vec<u8>> = (0..MAX_RECIPIENTS).map(|i| format!("password{}", i).into_bytes()).collect();
355 let encrypted = encrypt_weak(plaintext, &passwords)?;
356
357 for password in &passwords {
359 let decrypted = decrypt(&encrypted, password)?;
360 assert_eq!(plaintext, decrypted.as_slice());
361 }
362 Ok(())
363 }
364
365 #[cfg_attr(not(all(target_arch = "wasm32", target_os = "unknown")), tokio::test)]
366 #[cfg_attr(all(target_arch = "wasm32", target_os = "unknown"), wasm_bindgen_test)]
367 async fn test_nonce_uniqueness() -> anyhow::Result<()> {
368 let plaintext = b"same plaintext every time";
369 let passwords = vec![b"key".to_vec()];
370 let body_nonce_start = HEADER_SIZE + 1 + RECIPIENT_SLOT_SIZE;
372
373 let mut seen_nonces = std::collections::HashSet::new();
374 let mut seen_ciphertexts = std::collections::HashSet::new();
375 for _ in 0..256 {
376 let encrypted = encrypt_weak(plaintext, &passwords)?;
377 let nonce = encrypted[body_nonce_start..body_nonce_start + NONCE_SIZE].to_vec();
378 assert!(seen_nonces.insert(nonce), "body nonce was reused");
379 assert!(seen_ciphertexts.insert(encrypted), "ciphertext was reused");
380 }
381 Ok(())
382 }
383
384 #[cfg_attr(not(all(target_arch = "wasm32", target_os = "unknown")), tokio::test)]
385 #[cfg_attr(all(target_arch = "wasm32", target_os = "unknown"), wasm_bindgen_test)]
386 async fn test_tamper_detection() -> anyhow::Result<()> {
387 let plaintext = b"tamper me if you dare";
388 let passwords = vec![b"key".to_vec()];
389 let encrypted = encrypt_weak(plaintext, &passwords)?;
390
391 for i in HEADER_SIZE..encrypted.len() {
395 let mut tampered = encrypted.clone();
396 tampered[i] ^= 0xff;
397 assert!(decrypt(&tampered, &passwords[0]).is_err(), "tamper at byte {} was not detected", i);
398 }
399 Ok(())
400 }
401
402 #[cfg_attr(not(all(target_arch = "wasm32", target_os = "unknown")), tokio::test)]
403 #[cfg_attr(all(target_arch = "wasm32", target_os = "unknown"), wasm_bindgen_test)]
404 async fn test_dos_rejection() -> anyhow::Result<()> {
405 let mut crafted = vec![0u8; HEADER_SIZE + 1];
408 crafted[0] = HEADER_FORMAT_VERSION;
409 crafted[1] = 2; crafted[2] = 19; crafted[3..7].copy_from_slice(&(4u32 * 1024).to_be_bytes()); crafted[7..11].copy_from_slice(&2u32.to_be_bytes()); crafted[11..15].copy_from_slice(&1u32.to_be_bytes()); crafted[HEADER_SIZE] = (MAX_RECIPIENTS + 1) as u8;
415
416 let result = decrypt(&crafted, b"any");
417 assert!(result.is_err());
418 assert!(result.unwrap_err().to_string().contains("invalid recipient count"));
419
420 Ok(())
421 }
422
423 #[test]
424 fn test_argon2_params_reject_excessive_m_cost() {
425 let mut header = vec![0u8; HEADER_SIZE + 100];
426 header[0] = HEADER_FORMAT_VERSION;
427 header[1] = 2; header[2] = 19; header[3..7].copy_from_slice(&(u32::MAX).to_be_bytes()); header[7..11].copy_from_slice(&2u32.to_be_bytes());
431 header[11..15].copy_from_slice(&1u32.to_be_bytes());
432 let result = decrypt(&header, b"any");
433 assert!(result.is_err());
434 assert!(result.unwrap_err().to_string().contains("m_cost too large"));
435 }
436
437 #[test]
438 fn test_argon2_params_reject_excessive_t_cost() {
439 let mut header = vec![0u8; HEADER_SIZE + 100];
440 header[0] = HEADER_FORMAT_VERSION;
441 header[1] = 2;
442 header[2] = 19;
443 header[3..7].copy_from_slice(&(4u32 * 1024).to_be_bytes());
444 header[7..11].copy_from_slice(&1000u32.to_be_bytes()); header[11..15].copy_from_slice(&1u32.to_be_bytes());
446 let result = decrypt(&header, b"any");
447 assert!(result.is_err());
448 assert!(result.unwrap_err().to_string().contains("t_cost too large"));
449 }
450
451 #[test]
452 fn test_argon2_params_reject_excessive_p_cost() {
453 let mut header = vec![0u8; HEADER_SIZE + 100];
454 header[0] = HEADER_FORMAT_VERSION;
455 header[1] = 2;
456 header[2] = 19;
457 header[3..7].copy_from_slice(&(4u32 * 1024).to_be_bytes());
458 header[7..11].copy_from_slice(&2u32.to_be_bytes());
459 header[11..15].copy_from_slice(&100u32.to_be_bytes()); let result = decrypt(&header, b"any");
461 assert!(result.is_err());
462 assert!(result.unwrap_err().to_string().contains("p_cost too large"));
463 }
464}