1use 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#[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 pub content_hash: String,
30}
31
32pub 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 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
72pub 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 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, 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 }
137 }
138
139 #[test]
140 fn round_trip_alice_to_bob() {
141 let db_a = open_db_in_memory().unwrap();
142 let db_b = open_db_in_memory().unwrap();
143 let room_id = "r1";
144 insert_room(&db_a, &make_room(room_id)).unwrap();
145 insert_room(&db_b, &make_room(room_id)).unwrap();
146
147 let mut alice =
148 RoomCrypto::new_for_room(db_a.clone(), room_id.into(), "alice-fp".into(), [0u8; 32])
149 .unwrap();
150 let mut bob =
151 RoomCrypto::new_for_room(db_b.clone(), room_id.into(), "bob-fp".into(), [0u8; 32])
152 .unwrap();
153 bob.add_inbound_session("alice-fp", &alice.our_session_key_b64())
155 .unwrap();
156
157 let plaintext = b"the quick brown fox jumps over the lazy dog. this is a test file.";
158 let (ciphertext, meta) = encrypt_file(plaintext, &mut alice).unwrap();
159 assert_ne!(&ciphertext[..], &plaintext[..]);
160
161 let recovered = decrypt_file(&ciphertext, &meta, &mut bob, "alice-fp").unwrap();
162 assert_eq!(recovered, plaintext);
163 }
164
165 #[test]
166 fn tampered_ciphertext_fails() {
167 let db_a = open_db_in_memory().unwrap();
168 let db_b = open_db_in_memory().unwrap();
169 let room_id = "r1";
170 insert_room(&db_a, &make_room(room_id)).unwrap();
171 insert_room(&db_b, &make_room(room_id)).unwrap();
172
173 let mut alice =
174 RoomCrypto::new_for_room(db_a.clone(), room_id.into(), "alice-fp".into(), [0u8; 32])
175 .unwrap();
176 let mut bob =
177 RoomCrypto::new_for_room(db_b.clone(), room_id.into(), "bob-fp".into(), [0u8; 32])
178 .unwrap();
179 bob.add_inbound_session("alice-fp", &alice.our_session_key_b64())
180 .unwrap();
181
182 let plaintext = b"sensitive content";
183 let (mut ct, meta) = encrypt_file(plaintext, &mut alice).unwrap();
184 ct[0] ^= 0x01;
185 assert!(decrypt_file(&ct, &meta, &mut bob, "alice-fp").is_err());
186 }
187
188 #[test]
189 fn wrong_sender_fingerprint_fails() {
190 let db_a = open_db_in_memory().unwrap();
191 let db_b = open_db_in_memory().unwrap();
192 let room_id = "r1";
193 insert_room(&db_a, &make_room(room_id)).unwrap();
194 insert_room(&db_b, &make_room(room_id)).unwrap();
195
196 let mut alice =
197 RoomCrypto::new_for_room(db_a.clone(), room_id.into(), "alice-fp".into(), [0u8; 32])
198 .unwrap();
199 let mut bob =
200 RoomCrypto::new_for_room(db_b.clone(), room_id.into(), "bob-fp".into(), [0u8; 32])
201 .unwrap();
202 bob.add_inbound_session("alice-fp", &alice.our_session_key_b64())
203 .unwrap();
204
205 let (ct, meta) = encrypt_file(b"hi", &mut alice).unwrap();
206 assert!(decrypt_file(&ct, &meta, &mut bob, "evil-fp").is_err());
208 }
209}