Skip to main content

huddle_core/files/
encryption.rs

1//! File encryption for room attachments.
2//!
3//! Megolm advances its ratchet on every encrypted message. Chunk-wise
4//! Megolm would burn through key material; instead we encrypt each
5//! file body with a fresh ChaCha20-Poly1305 key, then Megolm-wrap that
6//! key once. The wrapped key + nonce travel inside the FileOffer.
7
8use base64::engine::general_purpose::STANDARD as B64;
9use base64::Engine;
10use chacha20poly1305::aead::{Aead, KeyInit};
11use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
12use rand::RngCore;
13use serde::{Deserialize, Serialize};
14
15use crate::crypto::RoomCrypto;
16use crate::error::{HuddleError, Result};
17
18/// Metadata that lets the receiver decrypt an encrypted file: the
19/// Megolm session id used to wrap the file key, the wrapped file key
20/// itself, and the ChaCha20-Poly1305 nonce. All bytes base64-encoded.
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22pub struct EncryptedFileMeta {
23    pub megolm_session_id: String,
24    pub wrapped_key_b64: String,
25    pub nonce_b64: String,
26}
27
28/// Encrypt `plaintext` with a fresh ChaCha20-Poly1305 key, then Megolm-
29/// wrap that key via the room's outbound session. The returned bytes
30/// are what gets chunked and sent on the wire; the meta travels in the
31/// FileOffer alongside the file_id.
32pub fn encrypt_file(
33    plaintext: &[u8],
34    room_crypto: &mut RoomCrypto,
35) -> Result<(Vec<u8>, EncryptedFileMeta)> {
36    let mut file_key = [0u8; 32];
37    let mut nonce_bytes = [0u8; 12];
38    rand::thread_rng().fill_bytes(&mut file_key);
39    rand::thread_rng().fill_bytes(&mut nonce_bytes);
40
41    let cipher = ChaCha20Poly1305::new(Key::from_slice(&file_key));
42    let nonce = Nonce::from_slice(&nonce_bytes);
43    let ciphertext = cipher
44        .encrypt(nonce, plaintext)
45        .map_err(|e| HuddleError::Other(format!("chacha20 encrypt: {e}")))?;
46
47    let (session_id, wrapped) = room_crypto.encrypt(&file_key)?;
48    let meta = EncryptedFileMeta {
49        megolm_session_id: session_id,
50        wrapped_key_b64: B64.encode(wrapped),
51        nonce_b64: B64.encode(nonce_bytes),
52    };
53    Ok((ciphertext, meta))
54}
55
56/// Inverse of `encrypt_file`. The caller supplies the sender's
57/// fingerprint so we know which inbound Megolm session to use.
58pub fn decrypt_file(
59    ciphertext: &[u8],
60    meta: &EncryptedFileMeta,
61    room_crypto: &mut RoomCrypto,
62    sender_fingerprint: &str,
63) -> Result<Vec<u8>> {
64    let wrapped = B64
65        .decode(&meta.wrapped_key_b64)
66        .map_err(|e| HuddleError::Other(format!("bad wrapped_key_b64: {e}")))?;
67    let file_key_bytes = room_crypto.decrypt(sender_fingerprint, &meta.megolm_session_id, &wrapped)?;
68    if file_key_bytes.len() != 32 {
69        return Err(HuddleError::Other(format!(
70            "unwrapped file key is {} bytes, expected 32",
71            file_key_bytes.len()
72        )));
73    }
74    let nonce_bytes = B64
75        .decode(&meta.nonce_b64)
76        .map_err(|e| HuddleError::Other(format!("bad nonce_b64: {e}")))?;
77    if nonce_bytes.len() != 12 {
78        return Err(HuddleError::Other(format!(
79            "nonce is {} bytes, expected 12",
80            nonce_bytes.len()
81        )));
82    }
83    let cipher = ChaCha20Poly1305::new(Key::from_slice(&file_key_bytes));
84    let nonce = Nonce::from_slice(&nonce_bytes);
85    cipher
86        .decrypt(nonce, ciphertext)
87        .map_err(|e| HuddleError::Other(format!("chacha20 decrypt: {e}")))
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use crate::storage::open_db_in_memory;
94    use crate::storage::repo::{insert_room, StoredRoom};
95
96    fn make_room(id: &str) -> StoredRoom {
97        StoredRoom {
98            id: id.into(),
99            name: "test".into(),
100            creator_fingerprint: "alice-fp".into(),
101            encrypted: true,
102            passphrase_salt: None,
103            created_at: 1,
104            last_active: None,
105        }
106    }
107
108    #[test]
109    fn round_trip_alice_to_bob() {
110        let db_a = open_db_in_memory().unwrap();
111        let db_b = open_db_in_memory().unwrap();
112        let room_id = "r1";
113        insert_room(&db_a, &make_room(room_id)).unwrap();
114        insert_room(&db_b, &make_room(room_id)).unwrap();
115
116        let mut alice =
117            RoomCrypto::new_for_room(db_a.clone(), room_id.into(), "alice-fp".into()).unwrap();
118        let mut bob =
119            RoomCrypto::new_for_room(db_b.clone(), room_id.into(), "bob-fp".into()).unwrap();
120        // Bob must learn Alice's outbound session before decrypting.
121        bob.add_inbound_session("alice-fp", &alice.our_session_key_b64())
122            .unwrap();
123
124        let plaintext = b"the quick brown fox jumps over the lazy dog. this is a test file.";
125        let (ciphertext, meta) = encrypt_file(plaintext, &mut alice).unwrap();
126        assert_ne!(&ciphertext[..], &plaintext[..]);
127
128        let recovered = decrypt_file(&ciphertext, &meta, &mut bob, "alice-fp").unwrap();
129        assert_eq!(recovered, plaintext);
130    }
131
132    #[test]
133    fn tampered_ciphertext_fails() {
134        let db_a = open_db_in_memory().unwrap();
135        let db_b = open_db_in_memory().unwrap();
136        let room_id = "r1";
137        insert_room(&db_a, &make_room(room_id)).unwrap();
138        insert_room(&db_b, &make_room(room_id)).unwrap();
139
140        let mut alice =
141            RoomCrypto::new_for_room(db_a.clone(), room_id.into(), "alice-fp".into()).unwrap();
142        let mut bob =
143            RoomCrypto::new_for_room(db_b.clone(), room_id.into(), "bob-fp".into()).unwrap();
144        bob.add_inbound_session("alice-fp", &alice.our_session_key_b64())
145            .unwrap();
146
147        let plaintext = b"sensitive content";
148        let (mut ct, meta) = encrypt_file(plaintext, &mut alice).unwrap();
149        ct[0] ^= 0x01;
150        assert!(decrypt_file(&ct, &meta, &mut bob, "alice-fp").is_err());
151    }
152
153    #[test]
154    fn wrong_sender_fingerprint_fails() {
155        let db_a = open_db_in_memory().unwrap();
156        let db_b = open_db_in_memory().unwrap();
157        let room_id = "r1";
158        insert_room(&db_a, &make_room(room_id)).unwrap();
159        insert_room(&db_b, &make_room(room_id)).unwrap();
160
161        let mut alice =
162            RoomCrypto::new_for_room(db_a.clone(), room_id.into(), "alice-fp".into()).unwrap();
163        let mut bob =
164            RoomCrypto::new_for_room(db_b.clone(), room_id.into(), "bob-fp".into()).unwrap();
165        bob.add_inbound_session("alice-fp", &alice.our_session_key_b64())
166            .unwrap();
167
168        let (ct, meta) = encrypt_file(b"hi", &mut alice).unwrap();
169        // Bob doesn't have a session keyed by "evil-fp" → must error.
170        assert!(decrypt_file(&ct, &meta, &mut bob, "evil-fp").is_err());
171    }
172}