1use std::{
4 fs::File,
5 io::{Read, Write},
6 path::{Path, PathBuf},
7};
8
9use aes::{
10 Aes256,
11 cipher::{Block, BlockEncrypt, KeyInit, KeyIvInit, StreamCipher},
12};
13use ctr::Ctr32BE;
14use ghash::{GHash, universal_hash::UniversalHash};
15use sha2::{Digest, Sha256};
16use subtle::ConstantTimeEq;
17
18use crate::{
19 CodecError,
20 encryption::{
21 self, AES_GCM_NONCE_SIZE, AES_GCM_TAG_SIZE, EncryptionKeys, HKDF_SALT_SIZE, SECRET_SIZE,
22 },
23};
24
25const FILE_CHUNK_BYTES: usize = 64 * 1024;
26const MAX_GCM_BYTES: u64 = (1u64 << 36) - 32;
28
29struct GcmStream {
30 ctr: Ctr32BE<Aes256>,
31 ghash: GHash,
32 mask: Block<Aes256>,
33}
34
35impl GcmStream {
36 fn new(secret: &[u8], salt: &[u8], nonce: &[u8]) -> Result<Self, CodecError> {
37 if salt.len() != HKDF_SALT_SIZE || nonce.len() != AES_GCM_NONCE_SIZE {
38 return Err(CodecError::Decode(
39 "invalid file encryption salt or nonce".into(),
40 ));
41 }
42 let key = encryption::derive_key(secret, salt).map_err(CodecError::Decode)?;
43 let aes = Aes256::new_from_slice(&key)
44 .map_err(|e| CodecError::Decode(format!("invalid AES key: {e}")))?;
45 let mut h = Block::<Aes256>::default();
46 aes.encrypt_block(&mut h);
47 let ghash = GHash::new(ghash::Key::from_slice(&h));
48 let mut mask = Block::<Aes256>::default();
49 mask[..AES_GCM_NONCE_SIZE].copy_from_slice(nonce);
50 mask[15] = 1;
51 aes.encrypt_block(&mut mask);
52 let mut iv = Block::<Aes256>::default();
53 iv[..AES_GCM_NONCE_SIZE].copy_from_slice(nonce);
54 iv[15] = 2;
55 let ctr = Ctr32BE::<Aes256>::new((&key).into(), &iv);
56 Ok(Self { ctr, ghash, mask })
57 }
58
59 fn encrypt_chunk(&mut self, chunk: &mut [u8]) {
60 self.ctr.apply_keystream(chunk);
61 self.ghash.update_padded(chunk);
62 }
63
64 fn decrypt_chunk(&mut self, chunk: &mut [u8]) {
65 self.ghash.update_padded(chunk);
66 self.ctr.apply_keystream(chunk);
67 }
68
69 fn finish(mut self, ciphertext_len: u64) -> [u8; AES_GCM_TAG_SIZE] {
70 let mut lengths = ghash::Block::default();
71 lengths[8..].copy_from_slice(&(ciphertext_len * 8).to_be_bytes());
72 self.ghash.update(&[lengths]);
73 let mut tag = [0u8; AES_GCM_TAG_SIZE];
74 tag.copy_from_slice(&self.ghash.finalize());
75 for (byte, mask) in tag.iter_mut().zip(self.mask.iter()) {
76 *byte ^= mask;
77 }
78 tag
79 }
80}
81
82fn output_parent(output: &Path) -> PathBuf {
83 output
84 .parent()
85 .filter(|parent| !parent.as_os_str().is_empty())
86 .unwrap_or_else(|| Path::new("."))
87 .to_path_buf()
88}
89
90fn encrypt_stream(
91 input: &mut impl Read,
92 output: &mut impl Write,
93 secret: &[u8],
94 salt: &[u8],
95 nonce: &[u8],
96) -> Result<(String, u64), CodecError> {
97 let mut gcm = GcmStream::new(secret, salt, nonce)?;
98 let mut digest = Sha256::new();
99 let mut chunk = [0u8; FILE_CHUNK_BYTES];
100 let mut length = 0u64;
101 loop {
102 let mut read = 0;
103 while read < FILE_CHUNK_BYTES {
104 let count = input
105 .read(&mut chunk[read..])
106 .map_err(|e| CodecError::Encode(e.to_string()))?;
107 if count == 0 {
108 break;
109 }
110 read += count;
111 }
112 if read == 0 {
113 break;
114 }
115 length = length
116 .checked_add(read as u64)
117 .filter(|value| *value <= MAX_GCM_BYTES)
118 .ok_or_else(|| CodecError::Encode("file exceeds AES-GCM size limit".into()))?;
119 gcm.encrypt_chunk(&mut chunk[..read]);
120 output
121 .write_all(&chunk[..read])
122 .map_err(|e| CodecError::Encode(e.to_string()))?;
123 digest.update(&chunk[..read]);
124 }
125 let tag = gcm.finish(length);
126 output
127 .write_all(&tag)
128 .map_err(|e| CodecError::Encode(e.to_string()))?;
129 digest.update(tag);
130 Ok((
131 hex::encode(digest.finalize()),
132 length + AES_GCM_TAG_SIZE as u64,
133 ))
134}
135
136pub fn encrypt_file(input: &Path, output: &Path) -> Result<EncryptionKeys, CodecError> {
138 let mut source = File::open(input).map_err(|e| CodecError::Encode(e.to_string()))?;
139 let mut target = tempfile::NamedTempFile::new_in(output_parent(output))
140 .map_err(|e| CodecError::Encode(e.to_string()))?;
141 let secret: [u8; SECRET_SIZE] = xmtp_common::rand_array();
142 let salt: [u8; HKDF_SALT_SIZE] = xmtp_common::rand_array();
143 let nonce: [u8; AES_GCM_NONCE_SIZE] = xmtp_common::rand_array();
144 let (digest, length) = encrypt_stream(&mut source, &mut target, &secret, &salt, &nonce)?;
145 target
146 .persist(output)
147 .map_err(|e| CodecError::Encode(e.to_string()))?;
148 Ok(EncryptionKeys {
149 secret: secret.to_vec(),
150 salt: salt.to_vec(),
151 nonce: nonce.to_vec(),
152 digest,
153 length,
154 })
155}
156
157pub fn decrypt_file(input: &Path, output: &Path, keys: &EncryptionKeys) -> Result<(), CodecError> {
159 let mut source = File::open(input).map_err(|e| CodecError::Decode(e.to_string()))?;
160 let total_len = source
161 .metadata()
162 .map_err(|e| CodecError::Decode(e.to_string()))?
163 .len();
164 if total_len != keys.length
165 || total_len < AES_GCM_TAG_SIZE as u64
166 || total_len - AES_GCM_TAG_SIZE as u64 > MAX_GCM_BYTES
167 {
168 return Err(CodecError::Decode("invalid encrypted file length".into()));
169 }
170 let mut target = tempfile::NamedTempFile::new_in(output_parent(output))
171 .map_err(|e| CodecError::Decode(e.to_string()))?;
172 let mut gcm = GcmStream::new(&keys.secret, &keys.salt, &keys.nonce)?;
173 let mut digest = Sha256::new();
174 let mut remaining = total_len - AES_GCM_TAG_SIZE as u64;
175 let mut chunk = [0u8; FILE_CHUNK_BYTES];
176 while remaining > 0 {
177 let size = remaining.min(FILE_CHUNK_BYTES as u64) as usize;
178 source
179 .read_exact(&mut chunk[..size])
180 .map_err(|e| CodecError::Decode(e.to_string()))?;
181 digest.update(&chunk[..size]);
182 gcm.decrypt_chunk(&mut chunk[..size]);
183 target
184 .write_all(&chunk[..size])
185 .map_err(|e| CodecError::Decode(e.to_string()))?;
186 remaining -= size as u64;
187 }
188 let mut tag = [0u8; AES_GCM_TAG_SIZE];
189 source
190 .read_exact(&mut tag)
191 .map_err(|e| CodecError::Decode(e.to_string()))?;
192 digest.update(tag);
193 if hex::encode(digest.finalize()) != keys.digest {
194 return Err(CodecError::Decode("content digest mismatch".into()));
195 }
196 let expected_tag = gcm.finish(total_len - AES_GCM_TAG_SIZE as u64);
197 if !bool::from(expected_tag.ct_eq(&tag)) {
198 return Err(CodecError::Decode("AES-GCM authentication failed".into()));
199 }
200 target
201 .persist(output)
202 .map_err(|e| CodecError::Decode(e.to_string()))?;
203 Ok(())
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209 use crate::{
210 ContentCodec,
211 attachment::{Attachment, AttachmentCodec},
212 remote_attachment::{RemoteAttachment, decrypt_attachment},
213 };
214 use prost::Message;
215
216 #[xmtp_common::test(unwrap_try = true)]
218 async fn decrypt_file_wrong_key_leaves_no_output() {
219 let dir = tempfile::tempdir()?;
220 let source = dir.path().join("plain");
221 let encrypted = dir.path().join("encrypted");
222 let output = dir.path().join("decrypted");
223 std::fs::write(&source, b"secret file")?;
224 let mut keys = encrypt_file(&source, &encrypted)?;
225 keys.secret[0] ^= 1;
226 assert!(decrypt_file(&encrypted, &output, &keys).is_err());
227 assert!(!output.exists());
228 assert_eq!(std::fs::read_dir(dir.path())?.count(), 2);
229 let mut keys = encrypt_file(&source, &encrypted)?;
230 keys.digest = "bad digest".into();
231 assert!(decrypt_file(&encrypted, &output, &keys).is_err());
232 assert!(!output.exists());
233 }
234
235 struct BoundedReader<'a> {
236 bytes: &'a [u8],
237 largest: usize,
238 }
239 impl Read for BoundedReader<'_> {
240 fn read(&mut self, output: &mut [u8]) -> std::io::Result<usize> {
241 self.largest = self.largest.max(output.len());
242 self.bytes.read(output)
243 }
244 }
245
246 #[xmtp_common::test(unwrap_try = true)]
247 async fn file_encryption_reads_bounded_chunks() {
248 let bytes = vec![42u8; FILE_CHUNK_BYTES * 2 + 19];
249 let mut reader = BoundedReader {
250 bytes: &bytes,
251 largest: 0,
252 };
253 let secret: [u8; SECRET_SIZE] = xmtp_common::rand_array();
254 let salt: [u8; HKDF_SALT_SIZE] = xmtp_common::rand_array();
255 let nonce: [u8; AES_GCM_NONCE_SIZE] = xmtp_common::rand_array();
256 let mut ciphertext = Vec::new();
257 let (digest, length) =
258 encrypt_stream(&mut reader, &mut ciphertext, &secret, &salt, &nonce)?;
259 assert_eq!(reader.largest, FILE_CHUNK_BYTES);
260 let keys = EncryptionKeys {
261 secret: secret.to_vec(),
262 salt: salt.to_vec(),
263 nonce: nonce.to_vec(),
264 digest,
265 length,
266 };
267 assert_eq!(encryption::decrypt_bytes(&ciphertext, &keys)?, bytes);
268
269 let dir = tempfile::tempdir()?;
270 let source = dir.path().join("plain");
271 let encrypted = dir.path().join("encrypted");
272 let output = dir.path().join("decrypted");
273 let content = AttachmentCodec::encode(Attachment {
274 filename: None,
275 mime_type: "application/octet-stream".into(),
276 content: bytes,
277 })?;
278 std::fs::write(&source, content.encode_to_vec())?;
279 let keys = encrypt_file(&source, &encrypted)?;
280 decrypt_file(&encrypted, &output, &keys)?;
281 assert_eq!(std::fs::read(&source)?, std::fs::read(&output)?);
282 let remote = RemoteAttachment {
283 filename: None,
284 content_length: Some(keys.length as u32),
285 url: String::new(),
286 content_digest: keys.digest,
287 secret: keys.secret,
288 salt: keys.salt,
289 nonce: keys.nonce,
290 scheme: String::new(),
291 };
292 assert_eq!(
293 decrypt_attachment(&std::fs::read(encrypted)?, &remote)?
294 .content
295 .len(),
296 FILE_CHUNK_BYTES * 2 + 19
297 );
298 }
299}