Skip to main content

huddle_protocol/crypto/
passphrase.rs

1//! Passphrase-derived key wrapping for Megolm session keys.
2//!
3//! Argon2id derives a 32-byte key from a user passphrase + per-room salt.
4//! ChaCha20-Poly1305 then wraps the Megolm session key for transmission.
5//! Anyone in possession of the passphrase + salt can unwrap and join the room.
6//!
7//! huddle 0.7.11: derived keys are returned in a `Zeroizing<[u8;32]>`
8//! wrapper that overwrites the byte slice when the value is dropped.
9//! That doesn't fix every secret-in-memory exposure (the bytes can
10//! still be copied), but it prevents the local owner from leaking
11//! into swap or a stale heap page after the key is no longer in use.
12
13use argon2::{Algorithm, Argon2, Params, Version};
14use base64::Engine;
15use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng};
16use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
17use rand::RngCore;
18use zeroize::Zeroizing;
19
20use crate::error::{ProtocolError, Result};
21
22pub const SALT_LEN: usize = 16;
23pub const KEY_LEN: usize = 32;
24pub const NONCE_LEN: usize = 12;
25
26/// Generate a random salt for a new encrypted room.
27pub fn random_salt() -> [u8; SALT_LEN] {
28    let mut salt = [0u8; SALT_LEN];
29    OsRng.fill_bytes(&mut salt);
30    salt
31}
32
33/// huddle 2.1.3: the single source of truth for huddle's Argon2id cost
34/// parameters — the strong RFC 9106 / OWASP profile (64 MiB memory, 3 iterations,
35/// 4 lanes). BOTH the room-passphrase KDF (`derive_key_zeroizing`) and the
36/// master-key KDF (`storage::keychain::derive_master_key`) build their `Params`
37/// here, so the two can never silently drift — a desync (e.g. a future memory-cost
38/// bump applied to only one) would brick at-rest decryption + room-key unwrap.
39/// `out_len` is the desired derived-key length in bytes.
40pub fn argon2id_params(out_len: usize) -> Result<Params> {
41    Params::new(65_536, 3, 4, Some(out_len))
42        .map_err(|e| ProtocolError::Session(format!("argon2 params: {e}")))
43}
44
45/// Derive a 32-byte symmetric key from a passphrase and salt using
46/// Argon2id. Parameters follow the strong RFC 9106 / OWASP profile
47/// (64 MiB memory, 3 iterations, 4 lanes) and must stay in sync with the
48/// master-key KDF in `storage::keychain::derive_master_key`.
49pub fn derive_key(passphrase: &str, salt: &[u8]) -> Result<[u8; KEY_LEN]> {
50    let zeroizing = derive_key_zeroizing(passphrase, salt)?;
51    // Copy out into a plain array for back-compat with all the call
52    // sites that already accept `&[u8; KEY_LEN]`. The local `zeroizing`
53    // is wiped when it goes out of scope at the end of this function.
54    Ok(*zeroizing)
55}
56
57/// huddle 0.7.11: same as `derive_key` but returns the key in a
58/// zeroize-on-drop wrapper. Callers that want defense-in-depth against
59/// heap-residency leaks should prefer this over `derive_key`.
60pub fn derive_key_zeroizing(passphrase: &str, salt: &[u8]) -> Result<Zeroizing<[u8; KEY_LEN]>> {
61    let params = argon2id_params(KEY_LEN)?;
62    let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
63    let mut out = Zeroizing::new([0u8; KEY_LEN]);
64    argon
65        .hash_password_into(passphrase.as_bytes(), salt, out.as_mut_slice())
66        .map_err(|e| ProtocolError::Session(format!("argon2 derive: {e}")))?;
67    Ok(out)
68}
69
70/// Wrap arbitrary plaintext (typically a Megolm SessionKey) under the passphrase key.
71/// Returns nonce || ciphertext, base64-encoded for transmission.
72pub fn wrap(plaintext: &[u8], passphrase_key: &[u8; KEY_LEN]) -> Result<String> {
73    let cipher = ChaCha20Poly1305::new(Key::from_slice(passphrase_key));
74    let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng);
75    let ciphertext = cipher
76        .encrypt(&nonce, plaintext)
77        .map_err(|e| ProtocolError::Session(format!("wrap failed: {e}")))?;
78    let mut combined = Vec::with_capacity(NONCE_LEN + ciphertext.len());
79    combined.extend_from_slice(&nonce);
80    combined.extend_from_slice(&ciphertext);
81    Ok(base64::engine::general_purpose::STANDARD.encode(&combined))
82}
83
84/// Unwrap base64-encoded (nonce || ciphertext) under the passphrase key.
85pub fn unwrap(encoded: &str, passphrase_key: &[u8; KEY_LEN]) -> Result<Vec<u8>> {
86    let bytes = base64::engine::general_purpose::STANDARD
87        .decode(encoded)
88        .map_err(|e| ProtocolError::Session(format!("bad base64: {e}")))?;
89    if bytes.len() < NONCE_LEN + 16 {
90        return Err(ProtocolError::Session("wrapped key too short".into()));
91    }
92    let (nonce_bytes, ciphertext) = bytes.split_at(NONCE_LEN);
93    let cipher = ChaCha20Poly1305::new(Key::from_slice(passphrase_key));
94    let nonce = Nonce::from_slice(nonce_bytes);
95    cipher
96        .decrypt(nonce, ciphertext)
97        .map_err(|e| ProtocolError::Session(format!("unwrap failed (wrong passphrase?): {e}")))
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn derive_is_deterministic() {
106        let salt = [42u8; SALT_LEN];
107        let k1 = derive_key("hunter2", &salt).unwrap();
108        let k2 = derive_key("hunter2", &salt).unwrap();
109        assert_eq!(k1, k2);
110    }
111
112    #[test]
113    fn different_passphrases_different_keys() {
114        let salt = [42u8; SALT_LEN];
115        let k1 = derive_key("hunter2", &salt).unwrap();
116        let k2 = derive_key("hunter3", &salt).unwrap();
117        assert_ne!(k1, k2);
118    }
119
120    #[test]
121    fn different_salts_different_keys() {
122        let k1 = derive_key("same", &[1u8; SALT_LEN]).unwrap();
123        let k2 = derive_key("same", &[2u8; SALT_LEN]).unwrap();
124        assert_ne!(k1, k2);
125    }
126
127    #[test]
128    fn wrap_unwrap_round_trip() {
129        let salt = random_salt();
130        let key = derive_key("hunter2", &salt).unwrap();
131        let secret = b"this is a megolm session key";
132        let wrapped = wrap(secret, &key).unwrap();
133        let recovered = unwrap(&wrapped, &key).unwrap();
134        assert_eq!(recovered, secret);
135    }
136
137    #[test]
138    fn wrong_passphrase_fails_unwrap() {
139        let salt = random_salt();
140        let right_key = derive_key("hunter2", &salt).unwrap();
141        let wrong_key = derive_key("hunter3", &salt).unwrap();
142        let wrapped = wrap(b"secret", &right_key).unwrap();
143        assert!(unwrap(&wrapped, &wrong_key).is_err());
144    }
145
146    #[test]
147    fn wrapped_output_is_nondeterministic() {
148        let salt = random_salt();
149        let key = derive_key("hunter2", &salt).unwrap();
150        let w1 = wrap(b"hello", &key).unwrap();
151        let w2 = wrap(b"hello", &key).unwrap();
152        assert_ne!(w1, w2, "nonce should differ each time");
153    }
154}