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    /// SHA-256 of the plaintext, hex-encoded. Bound as AEAD associated
27    /// data so the (key, nonce, ciphertext) triple can't be replayed
28    /// against different content, and verified after decryption.
29    pub content_hash: String,
30}
31
32/// Encrypt `plaintext` with a fresh ChaCha20-Poly1305 key, then Megolm-
33/// wrap that key via the room's outbound session. The returned bytes
34/// are what gets chunked and sent on the wire; the meta travels in the
35/// FileOffer alongside the file_id.
36pub fn encrypt_file(
37    plaintext: &[u8],
38    room_crypto: &mut RoomCrypto,
39) -> Result<(Vec<u8>, EncryptedFileMeta)> {
40    let mut file_key = [0u8; 32];
41    let mut nonce_bytes = [0u8; 12];
42    rand::thread_rng().fill_bytes(&mut file_key);
43    rand::thread_rng().fill_bytes(&mut nonce_bytes);
44
45    // Bind the ciphertext to a commitment of its plaintext via AEAD
46    // associated data, so a room member can't replay this (key, nonce,
47    // ciphertext) triple under a different file_id / name.
48    let content_hash = super::sha256_hex(plaintext);
49
50    let cipher = ChaCha20Poly1305::new(Key::from_slice(&file_key));
51    let nonce = Nonce::from_slice(&nonce_bytes);
52    let ciphertext = cipher
53        .encrypt(
54            nonce,
55            chacha20poly1305::aead::Payload {
56                msg: plaintext,
57                aad: content_hash.as_bytes(),
58            },
59        )
60        .map_err(|e| HuddleError::Other(format!("chacha20 encrypt: {e}")))?;
61
62    let (session_id, wrapped) = room_crypto.encrypt(&file_key)?;
63    let meta = EncryptedFileMeta {
64        megolm_session_id: session_id,
65        wrapped_key_b64: B64.encode(wrapped),
66        nonce_b64: B64.encode(nonce_bytes),
67        content_hash,
68    };
69    Ok((ciphertext, meta))
70}
71
72/// Inverse of `encrypt_file`. The caller supplies the sender's
73/// fingerprint so we know which inbound Megolm session to use.
74pub fn decrypt_file(
75    ciphertext: &[u8],
76    meta: &EncryptedFileMeta,
77    room_crypto: &mut RoomCrypto,
78    sender_fingerprint: &str,
79) -> Result<Vec<u8>> {
80    let wrapped = B64
81        .decode(&meta.wrapped_key_b64)
82        .map_err(|e| HuddleError::Other(format!("bad wrapped_key_b64: {e}")))?;
83    let file_key_bytes = room_crypto.decrypt(sender_fingerprint, &meta.megolm_session_id, &wrapped)?;
84    if file_key_bytes.len() != 32 {
85        return Err(HuddleError::Other(format!(
86            "unwrapped file key is {} bytes, expected 32",
87            file_key_bytes.len()
88        )));
89    }
90    let nonce_bytes = B64
91        .decode(&meta.nonce_b64)
92        .map_err(|e| HuddleError::Other(format!("bad nonce_b64: {e}")))?;
93    if nonce_bytes.len() != 12 {
94        return Err(HuddleError::Other(format!(
95            "nonce is {} bytes, expected 12",
96            nonce_bytes.len()
97        )));
98    }
99    let cipher = ChaCha20Poly1305::new(Key::from_slice(&file_key_bytes));
100    let nonce = Nonce::from_slice(&nonce_bytes);
101    let plaintext = cipher
102        .decrypt(
103            nonce,
104            chacha20poly1305::aead::Payload {
105                msg: ciphertext,
106                aad: meta.content_hash.as_bytes(),
107            },
108        )
109        .map_err(|e| HuddleError::Other(format!("chacha20 decrypt: {e}")))?;
110    // The AEAD tag already binds the ciphertext to the content_hash AAD;
111    // verifying the hash explicitly also catches a sender who announced a
112    // content_hash that doesn't match what they actually encrypted.
113    if super::sha256_hex(&plaintext) != meta.content_hash {
114        return Err(HuddleError::Other(
115            "decrypted file content does not match its announced hash".into(),
116        ));
117    }
118    Ok(plaintext)
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use crate::storage::open_db_in_memory;
125    use crate::storage::repo::{insert_room, RoomKind, StoredRoom};
126
127    fn make_room(id: &str) -> StoredRoom {
128        StoredRoom {
129            id: id.into(),
130            name: "test".into(),
131            creator_fingerprint: "alice-fp".into(),
132            encrypted: true,
133            passphrase_salt: None,
134            created_at: 1,
135            last_active: None,
136            kind: RoomKind::Group,
137        }
138    }
139
140    #[test]
141    fn round_trip_alice_to_bob() {
142        let db_a = open_db_in_memory().unwrap();
143        let db_b = open_db_in_memory().unwrap();
144        let room_id = "r1";
145        insert_room(&db_a, &make_room(room_id)).unwrap();
146        insert_room(&db_b, &make_room(room_id)).unwrap();
147
148        let mut alice =
149            RoomCrypto::new_for_room(db_a.clone(), room_id.into(), "alice-fp".into(), [0u8; 32])
150                .unwrap();
151        let mut bob =
152            RoomCrypto::new_for_room(db_b.clone(), room_id.into(), "bob-fp".into(), [0u8; 32])
153                .unwrap();
154        // Bob must learn Alice's outbound session before decrypting.
155        bob.add_inbound_session("alice-fp", &alice.our_session_key_b64())
156            .unwrap();
157
158        let plaintext = b"the quick brown fox jumps over the lazy dog. this is a test file.";
159        let (ciphertext, meta) = encrypt_file(plaintext, &mut alice).unwrap();
160        assert_ne!(&ciphertext[..], &plaintext[..]);
161
162        let recovered = decrypt_file(&ciphertext, &meta, &mut bob, "alice-fp").unwrap();
163        assert_eq!(recovered, plaintext);
164    }
165
166    #[test]
167    fn tampered_ciphertext_fails() {
168        let db_a = open_db_in_memory().unwrap();
169        let db_b = open_db_in_memory().unwrap();
170        let room_id = "r1";
171        insert_room(&db_a, &make_room(room_id)).unwrap();
172        insert_room(&db_b, &make_room(room_id)).unwrap();
173
174        let mut alice =
175            RoomCrypto::new_for_room(db_a.clone(), room_id.into(), "alice-fp".into(), [0u8; 32])
176                .unwrap();
177        let mut bob =
178            RoomCrypto::new_for_room(db_b.clone(), room_id.into(), "bob-fp".into(), [0u8; 32])
179                .unwrap();
180        bob.add_inbound_session("alice-fp", &alice.our_session_key_b64())
181            .unwrap();
182
183        let plaintext = b"sensitive content";
184        let (mut ct, meta) = encrypt_file(plaintext, &mut alice).unwrap();
185        ct[0] ^= 0x01;
186        assert!(decrypt_file(&ct, &meta, &mut bob, "alice-fp").is_err());
187    }
188
189    #[test]
190    fn wrong_sender_fingerprint_fails() {
191        let db_a = open_db_in_memory().unwrap();
192        let db_b = open_db_in_memory().unwrap();
193        let room_id = "r1";
194        insert_room(&db_a, &make_room(room_id)).unwrap();
195        insert_room(&db_b, &make_room(room_id)).unwrap();
196
197        let mut alice =
198            RoomCrypto::new_for_room(db_a.clone(), room_id.into(), "alice-fp".into(), [0u8; 32])
199                .unwrap();
200        let mut bob =
201            RoomCrypto::new_for_room(db_b.clone(), room_id.into(), "bob-fp".into(), [0u8; 32])
202                .unwrap();
203        bob.add_inbound_session("alice-fp", &alice.our_session_key_b64())
204            .unwrap();
205
206        let (ct, meta) = encrypt_file(b"hi", &mut alice).unwrap();
207        // Bob doesn't have a session keyed by "evil-fp" → must error.
208        assert!(decrypt_file(&ct, &meta, &mut bob, "evil-fp").is_err());
209    }
210}