Skip to main content

vector_core/crypto/
stream.rs

1//! Attachment AES-256-GCM over a file, a chunk at a time: the cipher of
2//! [`super::decrypt_data`] (16-byte nonce, 16-byte tag appended) without the file
3//! ever sitting whole in memory.
4//!
5//! GCM is CTR encryption plus a GHASH over the ciphertext, and both run
6//! incrementally. The catch is that the tag only speaks at the end, so plaintext
7//! is written to a file the caller keeps out of sight until this returns Ok.
8
9use std::io::{Read, Write};
10use std::path::Path;
11
12use super::gcm;
13use zeroize::Zeroize;
14use super::sha256::Sha256;
15
16use super::hex;
17
18/// Read size for every streamed pass. A multiple of the 16-byte GHASH block, so
19/// only the final read can leave a partial block.
20const CHUNK: usize = 1 << 20;
21
22/// What one pass over a downloaded file learned.
23#[derive(Debug)]
24pub struct StreamedFile {
25    /// SHA-256 of the file as downloaded: a Blossom URL's content address.
26    pub source_sha256: String,
27    /// SHA-256 of what was written out: the attachment's identity.
28    pub output_sha256: String,
29    pub output_len: u64,
30}
31
32/// What a pass stopped by its caller returns.
33pub const CANCELLED: &str = "Upload cancelled";
34
35/// Tell `progress` when `done` of `total` reaches a new whole percentage; false when
36/// it asks the pass to stop.
37fn 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
50/// Decrypt `src` (ciphertext || tag) into `dst`, hashing both sides on the way.
51/// `dst` holds unauthenticated plaintext until this returns: on any error it is
52/// removed, and a tag mismatch is reported as a decryption failure.
53pub 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
57/// [`decrypt_file`], telling `progress` each new whole percentage of the file opened.
58/// `progress` answering false stops the pass, as for [`encrypt_file_with_progress`].
59pub 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
67/// Encrypt `src` into `dst` (ciphertext || tag), the counterpart of [`decrypt_file`].
68/// Returns the SHA-256 and length of what was written: the blob a server will hold.
69pub 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
73/// [`encrypt_file`], telling `progress` each new whole percentage of the file sealed.
74/// `progress` answering false stops the pass: nothing is written and the error says
75/// it was cancelled.
76pub 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
84/// GCM's limit for one message (2^36 - 32 bytes): past it the 32-bit counter wraps and
85/// the keystream repeats.
86const 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        // Fill the buffer before encrypting: GHASH may only pad the final block.
107        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
193/// SHA-256 of a file, read a chunk at a time.
194pub 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
210/// Put a verified download into the download directory under `name`: an identical
211/// file already there (same name, size and hash) is reused and `src` dropped;
212/// otherwise `src` moves to a free spelling of the name. Returns where it landed.
213pub 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    // One plain component, or nothing: the name and extension come from the sender.
218    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    // A rename can't cross filesystems (app storage to shared storage on Android):
235    // copy beside the destination, then rename that into place.
236    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, &params).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, &params.key, &params.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, &params.key, &params.nonce).unwrap();
304            let blob = std::fs::read(&sealed).unwrap();
305            assert_eq!(blob, crate::crypto::encrypt_data(&plain, &params).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, &params.key, &params.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, &params.key, &params.nonce).unwrap();
322        let err = decrypt_file_with_progress(&sealed, &opened, &params.key, &params.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], &params).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, &params.key, &params.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", &params).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, &params.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}