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, 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.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 assert!(decrypt_file(&ct, &meta, &mut bob, "evil-fp").is_err());
209 }
210}