huddle_core/files/
encryption.rs1use 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}
27
28pub 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
56pub 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.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 assert!(decrypt_file(&ct, &meta, &mut bob, "evil-fp").is_err());
171 }
172}