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<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<Vec<u8>>) -> anyhow::Result<Vec<u8>> {
185 encrypt_with_config(&Argon2Config::strong(), plaintext, passwords)
186}
187
188pub fn encrypt_weak(plaintext: &[u8], passwords: &Vec<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(target_arch = "wasm32")]
250 extern crate wasm_bindgen_test;
251 #[cfg(target_arch = "wasm32")]
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(target_arch = "wasm32")]
257 wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
258
259 #[cfg_attr(not(target_arch = "wasm32"), tokio::test)]
260 #[cfg_attr(target_arch = "wasm32", 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(target_arch = "wasm32"), tokio::test)]
266 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)]
267 async fn test_multiple_encryption_weak() -> anyhow::Result<()> {
268 test_multiple_encryption(encrypt_weak).await
269 }
270
271 async fn test_multiple_encryption(encrypt_fn: fn(&[u8], &Vec<Vec<u8>>) -> anyhow::Result<Vec<u8>>) -> anyhow::Result<()> {
272 let plaintext = "Jimme was here and then some...".as_bytes();
273 let passwords = vec!["alice".to_string().into_bytes(), "bob".to_string().into_bytes(), "charlie".to_string().into_bytes()];
274 let encrypted = encrypt_fn(plaintext, &passwords)?;
275
276 {
277 let decrypted = decrypt(&encrypted, &passwords[0])?;
278 assert_eq!(plaintext, &decrypted);
279 }
280 {
281 let decrypted = decrypt(&encrypted, &passwords[1])?;
282 assert_eq!(plaintext, &decrypted);
283 }
284 {
285 let decrypted = decrypt(&encrypted, &passwords[2])?;
286 assert_eq!(plaintext, &decrypted);
287 }
288 {
289 assert!(decrypt(&encrypted, &"incorrect password".to_string().into_bytes()).is_err());
290 }
291 Ok(())
292 }
293
294 #[cfg_attr(not(target_arch = "wasm32"), tokio::test)]
295 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)]
296 async fn test_encryption_speeds() -> anyhow::Result<()> {
297 let plaintext = "Jimme was here and then some...".as_bytes();
300 let passwords = vec!["alice".to_string().into_bytes(), "bob".to_string().into_bytes(), "charlie".to_string().into_bytes()];
301 let encrypted_strong = encrypt_strong(plaintext, &passwords)?;
302 let encrypted_weak = encrypt_weak(plaintext, &passwords)?;
303 assert_ne!(encrypted_strong, encrypted_weak, "weak and strong encryption should not be identical");
304
305 const ITERATIONS: usize = 128;
306 let time_provider = Arc::new(RealTimeProvider::default());
307
308 let stopwatch_strong = StopWatch::new(time_provider.clone());
309 for _ in 0..ITERATIONS {
310 let decrypted = decrypt(&encrypted_strong, &passwords[0])?;
311 assert_eq!(plaintext, &decrypted);
312 }
313 let elapsed_strong = stopwatch_strong.elapsed_time_millis();
314 info!("Strong encryption took {}", elapsed_strong);
315
316 let stopwatch_weak = StopWatch::new(time_provider.clone());
317 for _ in 0..ITERATIONS {
318 let decrypted = decrypt(&encrypted_weak, &passwords[0])?;
319 assert_eq!(plaintext, &decrypted);
320 }
321 let elapsed_weak = stopwatch_weak.elapsed_time_millis();
322 info!("Weak encryption took {}", elapsed_weak);
323
324 assert!(elapsed_weak < elapsed_strong, "Weak encryption should be faster than strong encryption");
325
326 Ok(())
327 }
328
329 #[cfg_attr(not(target_arch = "wasm32"), tokio::test)]
330 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)]
331 async fn test_zero_recipients_rejected() -> anyhow::Result<()> {
332 let result = encrypt_weak(b"test", &vec![]);
333 assert!(result.is_err());
334 assert!(result.unwrap_err().to_string().contains("at least one password"));
335 Ok(())
336 }
337
338 #[cfg_attr(not(target_arch = "wasm32"), tokio::test)]
339 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)]
340 async fn test_too_many_recipients_rejected() -> anyhow::Result<()> {
341 let passwords: Vec<Vec<u8>> = (0..=MAX_RECIPIENTS).map(|i| format!("password{}", i).into_bytes()).collect();
342 let result = encrypt_weak(b"test", &passwords);
343 assert!(result.is_err());
344 assert!(result.unwrap_err().to_string().contains("too many recipients"));
345 Ok(())
346 }
347
348 #[cfg_attr(not(target_arch = "wasm32"), tokio::test)]
349 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)]
350 async fn test_max_recipients() -> anyhow::Result<()> {
351 let plaintext = b"test";
352 let passwords: Vec<Vec<u8>> = (0..MAX_RECIPIENTS).map(|i| format!("password{}", i).into_bytes()).collect();
353 let encrypted = encrypt_weak(plaintext, &passwords)?;
354
355 for password in &passwords {
357 let decrypted = decrypt(&encrypted, password)?;
358 assert_eq!(plaintext, decrypted.as_slice());
359 }
360 Ok(())
361 }
362
363 #[cfg_attr(not(target_arch = "wasm32"), tokio::test)]
364 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)]
365 async fn test_nonce_uniqueness() -> anyhow::Result<()> {
366 let plaintext = b"same plaintext every time";
367 let passwords = vec![b"key".to_vec()];
368 let body_nonce_start = HEADER_SIZE + 1 + RECIPIENT_SLOT_SIZE;
370
371 let mut seen_nonces = std::collections::HashSet::new();
372 let mut seen_ciphertexts = std::collections::HashSet::new();
373 for _ in 0..256 {
374 let encrypted = encrypt_weak(plaintext, &passwords)?;
375 let nonce = encrypted[body_nonce_start..body_nonce_start + NONCE_SIZE].to_vec();
376 assert!(seen_nonces.insert(nonce), "body nonce was reused");
377 assert!(seen_ciphertexts.insert(encrypted), "ciphertext was reused");
378 }
379 Ok(())
380 }
381
382 #[cfg_attr(not(target_arch = "wasm32"), tokio::test)]
383 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)]
384 async fn test_tamper_detection() -> anyhow::Result<()> {
385 let plaintext = b"tamper me if you dare";
386 let passwords = vec![b"key".to_vec()];
387 let encrypted = encrypt_weak(plaintext, &passwords)?;
388
389 for i in HEADER_SIZE..encrypted.len() {
393 let mut tampered = encrypted.clone();
394 tampered[i] ^= 0xff;
395 assert!(decrypt(&tampered, &passwords[0]).is_err(), "tamper at byte {} was not detected", i);
396 }
397 Ok(())
398 }
399
400 #[cfg_attr(not(target_arch = "wasm32"), tokio::test)]
401 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)]
402 async fn test_dos_rejection() -> anyhow::Result<()> {
403 let mut crafted = vec![0u8; HEADER_SIZE + 1];
406 crafted[0] = HEADER_FORMAT_VERSION;
407 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;
413
414 let result = decrypt(&crafted, b"any");
415 assert!(result.is_err());
416 assert!(result.unwrap_err().to_string().contains("invalid recipient count"));
417
418 Ok(())
419 }
420
421 #[test]
422 fn test_argon2_params_reject_excessive_m_cost() {
423 let mut header = vec![0u8; HEADER_SIZE + 100];
424 header[0] = HEADER_FORMAT_VERSION;
425 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());
429 header[11..15].copy_from_slice(&1u32.to_be_bytes());
430 let result = decrypt(&header, b"any");
431 assert!(result.is_err());
432 assert!(result.unwrap_err().to_string().contains("m_cost too large"));
433 }
434
435 #[test]
436 fn test_argon2_params_reject_excessive_t_cost() {
437 let mut header = vec![0u8; HEADER_SIZE + 100];
438 header[0] = HEADER_FORMAT_VERSION;
439 header[1] = 2;
440 header[2] = 19;
441 header[3..7].copy_from_slice(&(4u32 * 1024).to_be_bytes());
442 header[7..11].copy_from_slice(&1000u32.to_be_bytes()); header[11..15].copy_from_slice(&1u32.to_be_bytes());
444 let result = decrypt(&header, b"any");
445 assert!(result.is_err());
446 assert!(result.unwrap_err().to_string().contains("t_cost too large"));
447 }
448
449 #[test]
450 fn test_argon2_params_reject_excessive_p_cost() {
451 let mut header = vec![0u8; HEADER_SIZE + 100];
452 header[0] = HEADER_FORMAT_VERSION;
453 header[1] = 2;
454 header[2] = 19;
455 header[3..7].copy_from_slice(&(4u32 * 1024).to_be_bytes());
456 header[7..11].copy_from_slice(&2u32.to_be_bytes());
457 header[11..15].copy_from_slice(&100u32.to_be_bytes()); let result = decrypt(&header, b"any");
459 assert!(result.is_err());
460 assert!(result.unwrap_err().to_string().contains("p_cost too large"));
461 }
462}