Skip to main content

hashiverse_lib/tools/
encryption.rs

1//! # Password-based authenticated encryption with multi-recipient key wrapping
2//!
3//! Symmetric encryption for data that must be readable by one *or more* passwords. Used
4//! chiefly for at-rest protection of sensitive material (key lockers, encrypted
5//! persistence) and for payloads where a small group of recipients needs shared access
6//! without each receiving a copy.
7//!
8//! ## Construction
9//!
10//! 1. A random 32-byte *file key* is generated per encryption.
11//! 2. The body (plaintext) is encrypted in-place with `ChaCha20Poly1305` using that file key
12//!    and a fresh nonce — one authentication tag covers the whole body.
13//! 3. For each recipient password, an Argon2id KDF produces a wrap key; the file key is
14//!    then wrapped (AEAD-encrypted) under that wrap key with its own nonce and tag and
15//!    appended to the header as a "slot".
16//!
17//! On decrypt, the caller's password is Argon2id-derived once and trial-decrypted against
18//! each slot until one authenticates; the recovered file key unwraps the body. Tampering
19//! anywhere after the Argon2 header is caught by one of the two AEAD tag layers.
20//!
21//! ## Strong vs weak
22//!
23//! Both flavours use Argon2id / ChaCha20Poly1305 — they differ only in the memory cost of
24//! the KDF (encoded into the header):
25//!
26//! - [`encrypt_strong`] — `m_cost = 19 MiB`. For private, sensitive-at-rest data. Expensive
27//!   enough to resist offline brute-force of weak passphrases.
28//! - [`encrypt_weak`] — `m_cost = 4 MiB`. For public-at-rest data where throughput matters
29//!   more than passphrase resistance (the secret itself is typically high-entropy).
30//!
31//! [`decrypt`] reads the algorithm parameters from the header, so a single function
32//! decrypts either flavour.
33//!
34//! ## DoS hardening
35//!
36//! The header parser rejects absurd Argon2 parameters (`m_cost > 256 MiB`, `t_cost > 16`,
37//! `p_cost > 4`) and absurd recipient counts (`> MAX_RECIPIENTS`) *before* running the KDF,
38//! so a crafted ciphertext can't make a peer burn gigabytes of RAM trying to validate it.
39
40use 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; // 32 key bytes + 16 poly1305 tag
50const RECIPIENT_SLOT_SIZE: usize = NONCE_SIZE + WRAPPED_KEY_SIZE; // 60 bytes per slot
51
52/// Serialisable description of the Argon2 parameters used during encryption.
53/// Written as a fixed 15-byte prefix so any future `decrypt` call can reconstruct the
54/// exact hasher that was used.
55///
56/// Layout (all numeric fields big-endian):
57///   [0]      format version  (u8)  – always 1
58///   [1]      algorithm       (u8)  – 0=Argon2d, 1=Argon2i, 2=Argon2id
59///   [2]      argon2 version  (u8)  – 16=V0x10,  19=V0x13
60///   [3..7]   m_cost          (u32)
61///   [7..11]  t_cost          (u32)
62///  [11..15]  p_cost          (u32)
63struct Argon2Config {
64    algorithm: u8,
65    version: u8,
66    m_cost: u32,
67    t_cost: u32,
68    p_cost: u32,
69}
70
71impl Argon2Config {
72    /// Strong encryption – for private / sensitive data.
73    fn strong() -> Self {
74        Self { algorithm: 2, version: 19, m_cost: 19 * 1024, t_cost: 2, p_cost: 1 }
75    }
76
77    /// Weak encryption – for public-at-rest data where speed matters more.
78    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        // Reject unreasonable parameters that could cause OOM or CPU exhaustion.
120        // Strong config uses m_cost=19456, t_cost=2, p_cost=1 — allow generous headroom.
121        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
135/// Derive a 32-byte ChaCha20Poly1305 key from `password` using the given argon2 instance.
136fn 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    // Wrap the file key for each recipient
160    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    // Copy plaintext into out then encrypt in-place — no intermediate buffer
171    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
183/// Strong encrypt data with one or more passwords – for private / sensitive data.
184pub fn encrypt_strong(plaintext: &[u8], passwords: &Vec<Vec<u8>>) -> anyhow::Result<Vec<u8>> {
185    encrypt_with_config(&Argon2Config::strong(), plaintext, passwords)
186}
187
188/// Weak encrypt data with one or more passwords - for public-at-rest data where speed matters more.
189pub fn encrypt_weak(plaintext: &[u8], passwords: &Vec<Vec<u8>>) -> anyhow::Result<Vec<u8>> {
190    encrypt_with_config(&Argon2Config::weak(), plaintext, passwords)
191}
192
193/// Decrypt ciphertext produced by `encrypt_strong` or `encrypt_weak` (or any future variant) using the associated password.
194///
195/// The argon2 parameters and recipient count are read from the prepended header.
196pub 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    // Derive this password's wrap key and try each slot
218    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        // configure_logging();
298
299        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        // Every password should decrypt successfully, including from the last slot
356        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        // Body nonce sits after: argon2 header (15) + num_recipients byte (1) + one recipient slot (60)
369        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        // Flip every byte after the argon2 header and verify decryption always fails.
390        // Recipient slots are protected by their own ChaCha20Poly1305 tag;
391        // the body is protected by a second tag. Any single-byte corruption must be detected.
392        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        // A crafted ciphertext claiming too many recipients must be rejected
404        // before any expensive crypto is attempted
405        let mut crafted = vec![0u8; HEADER_SIZE + 1];
406        crafted[0] = HEADER_FORMAT_VERSION;
407        crafted[1] = 2; // Argon2id
408        crafted[2] = 19; // V0x13
409        crafted[3..7].copy_from_slice(&(4u32 * 1024).to_be_bytes()); // m_cost
410        crafted[7..11].copy_from_slice(&2u32.to_be_bytes());          // t_cost
411        crafted[11..15].copy_from_slice(&1u32.to_be_bytes());         // p_cost
412        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; // Argon2id
426        header[2] = 19; // V0x13
427        header[3..7].copy_from_slice(&(u32::MAX).to_be_bytes()); // absurd m_cost
428        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()); // absurd t_cost
443        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()); // absurd p_cost
458        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}