1use std::io::{Read, Write};
10use std::path::Path;
11
12use super::gcm;
13use zeroize::Zeroize;
14use super::sha256::Sha256;
15
16use super::hex;
17
18const CHUNK: usize = 1 << 20;
21
22#[derive(Debug)]
24pub struct StreamedFile {
25 pub source_sha256: String,
27 pub output_sha256: String,
29 pub output_len: u64,
30}
31
32pub const CANCELLED: &str = "Upload cancelled";
34
35fn report(done: u64, total: u64, reported: &mut u8, progress: &mut dyn FnMut(u8) -> bool) -> bool {
38 let pct = done.saturating_mul(100).checked_div(total).map_or(100, |p| p.min(100) as u8);
39 if pct > *reported {
40 *reported = pct;
41 return progress(pct);
42 }
43 true
44}
45
46fn hex32(bytes: &[u8]) -> String {
47 hex::encode(bytes)
48}
49
50pub fn decrypt_file(src: &Path, dst: &Path, key_hex: &str, nonce_hex: &str) -> Result<StreamedFile, String> {
54 decrypt_file_with_progress(src, dst, key_hex, nonce_hex, &mut |_| true)
55}
56
57pub fn decrypt_file_with_progress(src: &Path, dst: &Path, key_hex: &str, nonce_hex: &str, progress: &mut dyn FnMut(u8) -> bool) -> Result<StreamedFile, String> {
60 let result = decrypt_file_inner(src, dst, key_hex, nonce_hex, progress);
61 if result.is_err() {
62 let _ = std::fs::remove_file(dst);
63 }
64 result
65}
66
67pub fn encrypt_file(src: &Path, dst: &Path, key_hex: &str, nonce_hex: &str) -> Result<(String, u64), String> {
70 encrypt_file_with_progress(src, dst, key_hex, nonce_hex, &mut |_| true)
71}
72
73pub fn encrypt_file_with_progress(src: &Path, dst: &Path, key_hex: &str, nonce_hex: &str, progress: &mut dyn FnMut(u8) -> bool) -> Result<(String, u64), String> {
77 let result = encrypt_file_inner(src, dst, key_hex, nonce_hex, progress);
78 if result.is_err() {
79 let _ = std::fs::remove_file(dst);
80 }
81 result
82}
83
84const GCM_MAX_PLAINTEXT: u64 = (1 << 36) - 32;
87
88fn encrypt_file_inner(src: &Path, dst: &Path, key_hex: &str, nonce_hex: &str, progress: &mut dyn FnMut(u8) -> bool) -> Result<(String, u64), String> {
89 let (mut key, nonce) = super::attachment_params(key_hex, nonce_hex, "Invalid decryption key")?;
90 let mut input = std::fs::File::open(src).map_err(|e| format!("open file: {e}"))?;
91 let total = input.metadata().map(|m| m.len()).unwrap_or(0);
92 if total > GCM_MAX_PLAINTEXT {
93 return Err("File too large to encrypt as one attachment (over 64 GiB)".to_string());
94 }
95 let mut gcm = gcm::Stream::new(&key, &nonce);
96 key.zeroize();
97 let mut reported = 0u8;
98 let mut output = std::io::BufWriter::with_capacity(
99 CHUNK,
100 std::fs::File::create(dst).map_err(|e| format!("create ciphertext: {e}"))?,
101 );
102 let mut blob_hash = Sha256::new();
103 let mut buf = vec![0u8; CHUNK];
104 let mut len = 0u64;
105 loop {
106 let mut n = 0;
108 while n < CHUNK {
109 let got = input.read(&mut buf[n..]).map_err(|e| format!("read file: {e}"))?;
110 if got == 0 {
111 break;
112 }
113 n += got;
114 }
115 if n == 0 {
116 break;
117 }
118 let chunk = &mut buf[..n];
119 gcm.encrypt(chunk);
120 blob_hash.update(&*chunk);
121 output.write_all(chunk).map_err(|e| format!("write ciphertext: {e}"))?;
122 len += n as u64;
123 if !report(len, total, &mut reported, progress) {
124 return Err(CANCELLED.to_string());
125 }
126 if n < CHUNK {
127 break;
128 }
129 }
130 let tag = gcm.finish();
131 blob_hash.update(tag);
132 output.write_all(&tag).map_err(|e| format!("write ciphertext: {e}"))?;
133 let file = output.into_inner().map_err(|e| format!("write ciphertext: {e}"))?;
134 file.sync_all().map_err(|e| format!("sync ciphertext: {e}"))?;
135 Ok((hex32(&blob_hash.finalize()), len + 16))
136}
137
138fn decrypt_file_inner(src: &Path, dst: &Path, key_hex: &str, nonce_hex: &str, progress: &mut dyn FnMut(u8) -> bool) -> Result<StreamedFile, String> {
139 let (mut key, nonce) = super::attachment_params(key_hex, nonce_hex, "Invalid decryption key")?;
140 let mut input = std::fs::File::open(src).map_err(|e| format!("open download: {e}"))?;
141 let total = input.metadata().map_err(|e| format!("stat download: {e}"))?.len();
142 if total < 16 {
143 return Err(format!("Invalid Input: encrypted data too small ({} bytes, minimum 16 bytes required for authentication tag)", total));
144 }
145 let ciphertext_len = total - 16;
146 if ciphertext_len > GCM_MAX_PLAINTEXT {
147 return Err("Invalid Input: encrypted data exceeds the AES-GCM limit".to_string());
148 }
149 let mut gcm = gcm::Stream::new(&key, &nonce);
150 key.zeroize();
151 let mut reported = 0u8;
152
153 let mut source_hash = Sha256::new();
154 let mut output_hash = Sha256::new();
155 let mut output = std::io::BufWriter::with_capacity(
156 CHUNK,
157 std::fs::File::create(dst).map_err(|e| format!("create output: {e}"))?,
158 );
159
160 let mut buf = vec![0u8; CHUNK];
161 let mut remaining = ciphertext_len;
162 while remaining > 0 {
163 let n = remaining.min(CHUNK as u64) as usize;
164 let chunk = &mut buf[..n];
165 input.read_exact(chunk).map_err(|e| format!("read download: {e}"))?;
166 source_hash.update(&*chunk);
167 gcm.decrypt(chunk);
168 output_hash.update(&*chunk);
169 output.write_all(chunk).map_err(|e| format!("write output: {e}"))?;
170 remaining -= n as u64;
171 if !report(ciphertext_len - remaining, ciphertext_len, &mut reported, progress) {
172 return Err(CANCELLED.to_string());
173 }
174 }
175
176 let mut tag = [0u8; 16];
177 input.read_exact(&mut tag).map_err(|e| format!("read download: {e}"))?;
178 source_hash.update(tag);
179
180 if !gcm::tags_equal(&gcm.finish(), &tag) {
181 return Err("Decryption failed: aead::Error (authentication tag mismatch)".to_string());
182 }
183
184 let file = output.into_inner().map_err(|e| format!("write output: {e}"))?;
185 file.sync_all().map_err(|e| format!("sync output: {e}"))?;
186 Ok(StreamedFile {
187 source_sha256: hex32(&source_hash.finalize()),
188 output_sha256: hex32(&output_hash.finalize()),
189 output_len: ciphertext_len,
190 })
191}
192
193pub fn hash_file(path: &Path) -> Result<(String, u64), String> {
195 let mut file = std::fs::File::open(path).map_err(|e| format!("open: {e}"))?;
196 let mut hasher = Sha256::new();
197 let mut buf = vec![0u8; CHUNK];
198 let mut len = 0u64;
199 loop {
200 let n = file.read(&mut buf).map_err(|e| format!("read: {e}"))?;
201 if n == 0 {
202 break;
203 }
204 hasher.update(&buf[..n]);
205 len += n as u64;
206 }
207 Ok((hex32(&hasher.finalize()), len))
208}
209
210pub fn place_download(src: &Path, file_hash: &str, len: u64, name: &str, extension: &str) -> Result<std::path::PathBuf, String> {
214 let dir = crate::db::get_download_dir();
215 std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create directory: {}", e))?;
216 let target_name = if name.is_empty() { format!("{}.{}", file_hash, extension) } else { name.to_string() };
217 let mut parts = Path::new(&target_name).components();
219 if !matches!((parts.next(), parts.next()), (Some(std::path::Component::Normal(_)), None))
220 || target_name.contains(['/', '\\'])
221 {
222 return Err("Refusing a download name outside the download folder".to_string());
223 }
224
225 let candidate = dir.join(&target_name);
226 let identical = std::fs::metadata(&candidate).map(|m| m.len() == len).unwrap_or(false)
227 && hash_file(&candidate).map(|(h, _)| h == file_hash).unwrap_or(false);
228 if identical {
229 let _ = std::fs::remove_file(src);
230 return Ok(candidate);
231 }
232
233 let dest = crate::crypto::resolve_unique_filename(&dir, &target_name);
234 if std::fs::rename(src, &dest).is_err() {
237 let staging = dir.join(format!(".{}.{}.tmp", file_hash, extension));
238 let copied = std::fs::copy(src, &staging)
239 .map_err(|e| format!("Failed to write file: {}", e))
240 .and_then(|_| std::fs::File::open(&staging).and_then(|f| f.sync_all()).map_err(|e| format!("Failed to write file: {}", e)))
241 .and_then(|_| std::fs::rename(&staging, &dest).map_err(|e| format!("Failed to rename file: {}", e)));
242 if let Err(e) = copied {
243 let _ = std::fs::remove_file(&staging);
244 return Err(e);
245 }
246 let _ = std::fs::remove_file(src);
247 }
248 Ok(dest)
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 fn scratch(tag: &str) -> std::path::PathBuf {
256 let dir = std::env::temp_dir().join(format!("vector-stream-{tag}-{}", std::process::id()));
257 let _ = std::fs::remove_dir_all(&dir);
258 std::fs::create_dir_all(&dir).unwrap();
259 dir
260 }
261
262 #[test]
263 fn a_download_never_lands_outside_its_folder() {
264 let dir = scratch("escape");
265 let src = dir.join("plain");
266 std::fs::write(&src, b"x").unwrap();
267 for bad in ["..\\..\\evil.bat", "../evil", "sub/evil", ".."] {
268 assert!(place_download(&src, "abc", 1, bad, "bin").is_err(), "{bad}");
269 }
270 assert!(place_download(&src, "abc", 1, "", "..\\evil").is_err());
271 }
272
273 fn roundtrip(len: usize) {
274 let dir = scratch(&format!("rt{len}"));
275 let plain: Vec<u8> = (0..len).map(|i| (i * 31 % 251) as u8).collect();
276 let params = crate::crypto::generate_encryption_params();
277 let sealed = crate::crypto::encrypt_data(&plain, ¶ms).unwrap();
278 let (src, dst) = (dir.join("in"), dir.join("out"));
279 std::fs::write(&src, &sealed).unwrap();
280
281 let got = decrypt_file(&src, &dst, ¶ms.key, ¶ms.nonce).unwrap();
282 assert_eq!(std::fs::read(&dst).unwrap(), plain, "same plaintext as the one-shot cipher at {len} bytes");
283 assert_eq!(got.output_sha256, crate::crypto::sha256_hex(&plain));
284 assert_eq!(got.source_sha256, crate::crypto::sha256_hex(&sealed));
285 assert_eq!(got.output_len, len as u64);
286 }
287
288 #[test]
289 fn it_matches_the_one_shot_cipher_across_chunk_and_block_edges() {
290 for len in [0, 1, 15, 16, 17, CHUNK - 1, CHUNK, CHUNK + 1, 2 * CHUNK + 7] {
291 roundtrip(len);
292 }
293 }
294
295 #[test]
296 fn a_streamed_seal_opens_with_the_one_shot_cipher() {
297 for len in [0usize, 1, 16, 17, CHUNK - 1, CHUNK, CHUNK + 1, 2 * CHUNK + 7] {
298 let dir = scratch(&format!("enc{len}"));
299 let plain: Vec<u8> = (0..len).map(|i| (i * 7 % 253) as u8).collect();
300 let (src, sealed) = (dir.join("plain"), dir.join("sealed"));
301 std::fs::write(&src, &plain).unwrap();
302 let params = crate::crypto::generate_encryption_params();
303 let (hash, sealed_len) = encrypt_file(&src, &sealed, ¶ms.key, ¶ms.nonce).unwrap();
304 let blob = std::fs::read(&sealed).unwrap();
305 assert_eq!(blob, crate::crypto::encrypt_data(&plain, ¶ms).unwrap(), "byte for byte at {len} bytes");
306 assert_eq!((hash, sealed_len), (crate::crypto::sha256_hex(&blob), blob.len() as u64));
307 }
308 }
309
310 #[test]
311 fn a_stopped_pass_ends_early_and_leaves_nothing() {
312 let dir = scratch("stop");
313 let (src, sealed, opened) = (dir.join("plain"), dir.join("sealed"), dir.join("opened"));
314 std::fs::write(&src, vec![3u8; 4 * CHUNK]).unwrap();
315 let params = crate::crypto::generate_encryption_params();
316 let mut seen = 0;
317 let err = encrypt_file_with_progress(&src, &sealed, ¶ms.key, ¶ms.nonce, &mut |pct| { seen = pct; pct < 25 }).unwrap_err();
318 assert_eq!(err, CANCELLED);
319 assert!(seen < 100 && !sealed.exists(), "stopped at {seen}%, nothing left behind");
320
321 encrypt_file(&src, &sealed, ¶ms.key, ¶ms.nonce).unwrap();
322 let err = decrypt_file_with_progress(&sealed, &opened, ¶ms.key, ¶ms.nonce, &mut |pct| pct < 50).unwrap_err();
323 assert_eq!(err, CANCELLED);
324 assert!(!opened.exists(), "no half-opened plaintext");
325 }
326
327 #[test]
328 fn a_flipped_byte_fails_and_leaves_no_plaintext() {
329 let dir = scratch("tamper");
330 let params = crate::crypto::generate_encryption_params();
331 let mut sealed = crate::crypto::encrypt_data(&vec![7u8; 5000], ¶ms).unwrap();
332 sealed[2500] ^= 1;
333 let (src, dst) = (dir.join("in"), dir.join("out"));
334 std::fs::write(&src, &sealed).unwrap();
335 let err = decrypt_file(&src, &dst, ¶ms.key, ¶ms.nonce).unwrap_err();
336 assert!(err.contains("aead"), "reads as a decryption failure: {err}");
337 assert!(!dst.exists(), "unauthenticated plaintext must not survive");
338 }
339
340 #[test]
341 fn a_wrong_key_fails() {
342 let dir = scratch("key");
343 let params = crate::crypto::generate_encryption_params();
344 let sealed = crate::crypto::encrypt_data(b"hello", ¶ms).unwrap();
345 let (src, dst) = (dir.join("in"), dir.join("out"));
346 std::fs::write(&src, &sealed).unwrap();
347 let other = crate::crypto::generate_encryption_params();
348 assert!(decrypt_file(&src, &dst, &other.key, ¶ms.nonce).is_err());
349 }
350
351 #[test]
352 fn hash_file_matches_the_in_memory_hash() {
353 let dir = scratch("hash");
354 let data: Vec<u8> = (0..(CHUNK + 5)).map(|i| i as u8).collect();
355 std::fs::write(dir.join("f"), &data).unwrap();
356 assert_eq!(hash_file(&dir.join("f")).unwrap(), (crate::crypto::sha256_hex(&data), data.len() as u64));
357 }
358}