Skip to main content

shadow_crypt_core/v3/
stream.rs

1//! Chunked (streaming) content encryption for the v3 format.
2//!
3//! The content is a sequence of AEAD chunks of `chunk_size` plaintext bytes
4//! (the final chunk may be shorter, including empty). Each chunk's 24-byte
5//! nonce is `nonce_prefix (16) || counter (7, little endian) || final flag
6//! (1)`, and every chunk authenticates the header binding under the content
7//! domain. The counter makes reordering fail authentication, and the final
8//! flag makes truncation at a chunk boundary fail: the last present chunk
9//! was not sealed as final, so opening it as final does not authenticate.
10//!
11//! An empty file is one final chunk with empty plaintext, so every content
12//! stream contains at least one chunk.
13
14use chacha20poly1305::{
15    KeyInit, XChaCha20Poly1305,
16    aead::{Aead, Payload},
17};
18
19use crate::{
20    errors::{CryptError, FileError},
21    file::FileMetadata,
22    memory::{SecureBytes, SecureKey},
23    v3::{
24        crypt,
25        header::{AadPurpose, FileHeader, HeaderBinding},
26        key::KeyDerivationParams,
27    },
28};
29
30/// Plaintext bytes per chunk written by this implementation. Reading accepts
31/// any chunk size the header declares, within the parse-time bound.
32pub const CHUNK_SIZE: u32 = 1024 * 1024; // 1 MiB
33
34/// AEAD authentication tag length appended to every chunk.
35pub const TAG_SIZE: usize = 16;
36
37/// Highest chunk counter value that fits the 7-byte nonce field.
38const MAX_COUNTER: u64 = (1 << 56) - 1;
39
40fn chunk_nonce(prefix: &[u8; 16], counter: u64, is_last: bool) -> [u8; 24] {
41    let mut nonce = [0u8; 24];
42    nonce[..16].copy_from_slice(prefix);
43    nonce[16..23].copy_from_slice(&counter.to_le_bytes()[..7]);
44    nonce[23] = u8::from(is_last);
45    nonce
46}
47
48/// Incremental encryption of one file's content stream.
49pub struct StreamSealer {
50    cipher: XChaCha20Poly1305,
51    nonce_prefix: [u8; 16],
52    chunk_size: usize,
53    aad: Vec<u8>,
54    counter: u64,
55    finished: bool,
56}
57
58impl StreamSealer {
59    /// Starts sealing a new v3 file: encrypts the metadata envelope, builds
60    /// the header, and returns it together with the sealer for the content
61    /// chunks. The caller supplies the derived key and fresh random
62    /// salt/nonces, keeping this deterministic.
63    pub fn begin(
64        metadata: &FileMetadata,
65        key: &SecureKey,
66        kdf_params: KeyDerivationParams,
67        salt: [u8; 16],
68        nonce_prefix: [u8; 16],
69        metadata_nonce: [u8; 24],
70    ) -> Result<(FileHeader, StreamSealer), FileError> {
71        Self::begin_with_chunk_size(
72            metadata,
73            key,
74            kdf_params,
75            salt,
76            nonce_prefix,
77            metadata_nonce,
78            CHUNK_SIZE,
79        )
80    }
81
82    /// [`StreamSealer::begin`] with an explicit chunk size, for callers with
83    /// unusual chunking needs. The size must satisfy the same bounds the
84    /// header enforces.
85    pub fn begin_with_chunk_size(
86        metadata: &FileMetadata,
87        key: &SecureKey,
88        kdf_params: KeyDerivationParams,
89        salt: [u8; 16],
90        nonce_prefix: [u8; 16],
91        metadata_nonce: [u8; 24],
92        chunk_size: u32,
93    ) -> Result<(FileHeader, StreamSealer), FileError> {
94        let envelope = crate::v3::metadata::serialize(metadata)?;
95
96        let binding = HeaderBinding::new(
97            &salt,
98            &kdf_params,
99            &nonce_prefix,
100            chunk_size,
101            &metadata_nonce,
102        );
103        let (metadata_ciphertext, _) = crypt::encrypt_bytes(
104            envelope.as_slice(),
105            key.as_bytes(),
106            &metadata_nonce,
107            &binding.aad(AadPurpose::Metadata),
108        )?;
109        let content_aad = binding.aad(AadPurpose::Content);
110
111        let header = FileHeader::new(
112            salt,
113            kdf_params,
114            nonce_prefix,
115            chunk_size,
116            metadata_nonce,
117            metadata_ciphertext,
118        )?;
119
120        let sealer = StreamSealer {
121            cipher: XChaCha20Poly1305::new(key.as_bytes().into()),
122            nonce_prefix,
123            chunk_size: chunk_size as usize,
124            aad: content_aad,
125            counter: 0,
126            finished: false,
127        };
128        Ok((header, sealer))
129    }
130
131    /// Plaintext bytes to feed per [`StreamSealer::seal_chunk`] call; only
132    /// the final chunk may be shorter.
133    pub fn chunk_plaintext_len(&self) -> usize {
134        self.chunk_size
135    }
136
137    /// Seals the next chunk. Every chunk except the last must be exactly
138    /// `chunk_plaintext_len` bytes; the last may be shorter (or empty).
139    pub fn seal_chunk(&mut self, plaintext: &[u8], is_last: bool) -> Result<Vec<u8>, FileError> {
140        if self.finished {
141            return Err(stream_error("chunk sealed after the final chunk"));
142        }
143        if !is_last && plaintext.len() != self.chunk_size {
144            return Err(stream_error("non-final chunk must be exactly chunk-sized"));
145        }
146        if plaintext.len() > self.chunk_size {
147            return Err(stream_error("chunk larger than the declared chunk size"));
148        }
149        if self.counter > MAX_COUNTER {
150            return Err(stream_error("chunk counter overflow"));
151        }
152
153        let nonce = chunk_nonce(&self.nonce_prefix, self.counter, is_last);
154        let ciphertext = self
155            .cipher
156            .encrypt(
157                (&nonce).into(),
158                Payload {
159                    msg: plaintext,
160                    aad: &self.aad,
161                },
162            )
163            .map_err(|e| {
164                FileError::Crypt(CryptError::EncryptionError(format!(
165                    "Encryption failed: {}",
166                    e
167                )))
168            })?;
169
170        self.counter += 1;
171        self.finished = is_last;
172        Ok(ciphertext)
173    }
174
175    /// True once the final chunk has been sealed.
176    pub fn finished(&self) -> bool {
177        self.finished
178    }
179}
180
181/// Incremental decryption of one file's content stream.
182pub struct StreamOpener {
183    cipher: XChaCha20Poly1305,
184    nonce_prefix: [u8; 16],
185    chunk_size: usize,
186    aad: Vec<u8>,
187    counter: u64,
188    finished: bool,
189}
190
191impl StreamOpener {
192    pub fn new(header: &FileHeader, key: &SecureKey) -> StreamOpener {
193        StreamOpener {
194            cipher: XChaCha20Poly1305::new(key.as_bytes().into()),
195            nonce_prefix: *header.nonce_prefix(),
196            chunk_size: header.chunk_size() as usize,
197            aad: header.binding().aad(AadPurpose::Content),
198            counter: 0,
199            finished: false,
200        }
201    }
202
203    /// Ciphertext bytes to feed per [`StreamOpener::open_chunk`] call; only
204    /// the final chunk may be shorter.
205    pub fn chunk_ciphertext_len(&self) -> usize {
206        self.chunk_size + TAG_SIZE
207    }
208
209    /// Opens the next chunk. `is_last` marks that no more ciphertext
210    /// follows; sealing and opening must agree on which chunk is final or
211    /// authentication fails (this is what detects truncation).
212    pub fn open_chunk(
213        &mut self,
214        ciphertext: &[u8],
215        is_last: bool,
216    ) -> Result<SecureBytes, FileError> {
217        if self.finished {
218            return Err(stream_error("data present after the final chunk"));
219        }
220        if !is_last && ciphertext.len() != self.chunk_ciphertext_len() {
221            return Err(stream_error("non-final chunk has the wrong length"));
222        }
223        if ciphertext.len() < TAG_SIZE || ciphertext.len() > self.chunk_ciphertext_len() {
224            return Err(stream_error("chunk has an impossible length"));
225        }
226        if self.counter > MAX_COUNTER {
227            return Err(stream_error("chunk counter overflow"));
228        }
229
230        let nonce = chunk_nonce(&self.nonce_prefix, self.counter, is_last);
231        let plaintext = self
232            .cipher
233            .decrypt(
234                (&nonce).into(),
235                Payload {
236                    msg: ciphertext,
237                    aad: &self.aad,
238                },
239            )
240            .map_err(|_| {
241                FileError::Crypt(CryptError::DecryptionError(
242                    "authentication failed (wrong password, or the file is corrupted)".to_string(),
243                ))
244            })?;
245
246        self.counter += 1;
247        self.finished = is_last;
248        Ok(SecureBytes::new(plaintext))
249    }
250
251    /// True once the final chunk has been opened. A stream that ends without
252    /// this being true was truncated.
253    pub fn finished(&self) -> bool {
254        self.finished
255    }
256}
257
258fn stream_error(msg: &str) -> FileError {
259    FileError::Crypt(CryptError::DecryptionError(format!(
260        "invalid content stream: {}",
261        msg
262    )))
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use crate::memory::SecureString;
269
270    fn test_setup(chunk_size: u32) -> (FileHeader, StreamSealer, SecureKey) {
271        let key = SecureKey::new([9u8; 32]);
272        let metadata = FileMetadata::new(SecureString::new("a.txt".to_string()), None, None);
273        let (header, sealer) = StreamSealer::begin_with_chunk_size(
274            &metadata,
275            &key,
276            KeyDerivationParams::test_defaults(),
277            [1u8; 16],
278            [2u8; 16],
279            [3u8; 24],
280            chunk_size,
281        )
282        .unwrap();
283        (header, sealer, key)
284    }
285
286    /// Chunks like the shell's reader: fixed-size pieces, the last piece is
287    /// final (a full-sized final piece on exact multiples, one empty piece
288    /// for empty content).
289    fn seal_all(sealer: &mut StreamSealer, content: &[u8], chunk_size: usize) -> Vec<Vec<u8>> {
290        let pieces: Vec<&[u8]> = if content.is_empty() {
291            vec![&[][..]]
292        } else {
293            content.chunks(chunk_size).collect()
294        };
295        pieces
296            .iter()
297            .enumerate()
298            .map(|(i, piece)| sealer.seal_chunk(piece, i == pieces.len() - 1).unwrap())
299            .collect()
300    }
301
302    fn open_all(
303        header: &FileHeader,
304        key: &SecureKey,
305        chunks: &[Vec<u8>],
306    ) -> Result<Vec<u8>, FileError> {
307        let mut opener = StreamOpener::new(header, key);
308        let mut out = Vec::new();
309        for (i, chunk) in chunks.iter().enumerate() {
310            let is_last = i == chunks.len() - 1;
311            out.extend_from_slice(opener.open_chunk(chunk, is_last)?.as_slice());
312        }
313        assert!(opener.finished());
314        Ok(out)
315    }
316
317    #[test]
318    fn multi_chunk_round_trip() {
319        let (header, mut sealer, key) = test_setup(8);
320        let content = b"this content spans multiple chunks".to_vec();
321
322        let chunks = seal_all(&mut sealer, &content, 8);
323        assert!(chunks.len() > 2);
324        assert_eq!(open_all(&header, &key, &chunks).unwrap(), content);
325    }
326
327    #[test]
328    fn empty_content_round_trip() {
329        let (header, mut sealer, key) = test_setup(8);
330        let chunks = seal_all(&mut sealer, b"", 8);
331        assert_eq!(chunks.len(), 1);
332        assert_eq!(open_all(&header, &key, &chunks).unwrap(), b"");
333    }
334
335    #[test]
336    fn exact_multiple_round_trip() {
337        let (header, mut sealer, key) = test_setup(8);
338        let content = b"0123456789abcdef".to_vec(); // exactly two chunks
339
340        let chunks = seal_all(&mut sealer, &content, 8);
341        assert_eq!(chunks.len(), 2); // the final chunk is full-sized
342        assert_eq!(open_all(&header, &key, &chunks).unwrap(), content);
343    }
344
345    #[test]
346    fn truncation_is_detected() {
347        let (header, mut sealer, key) = test_setup(8);
348        let mut chunks = seal_all(&mut sealer, b"0123456789abcdefgh", 8);
349
350        // Drop the final chunk: the new last chunk was not sealed as final.
351        chunks.pop();
352        assert!(open_all(&header, &key, &chunks).is_err());
353    }
354
355    #[test]
356    fn reordering_is_detected() {
357        let (header, mut sealer, key) = test_setup(8);
358        let mut chunks = seal_all(&mut sealer, b"0123456789abcdefgh", 8);
359        chunks.swap(0, 1);
360        assert!(open_all(&header, &key, &chunks).is_err());
361    }
362
363    #[test]
364    fn corruption_is_detected() {
365        let (header, mut sealer, key) = test_setup(8);
366        let mut chunks = seal_all(&mut sealer, b"0123456789abcdefgh", 8);
367        chunks[1][0] ^= 1;
368        assert!(open_all(&header, &key, &chunks).is_err());
369    }
370
371    #[test]
372    fn sealing_after_final_chunk_fails() {
373        let (_, mut sealer, _) = test_setup(8);
374        sealer.seal_chunk(b"tail", true).unwrap();
375        assert!(sealer.seal_chunk(b"more", true).is_err());
376    }
377
378    #[test]
379    fn short_non_final_chunk_fails() {
380        let (_, mut sealer, _) = test_setup(8);
381        assert!(sealer.seal_chunk(b"tiny", false).is_err());
382    }
383
384    #[test]
385    fn wrong_key_fails() {
386        let (header, mut sealer, key) = test_setup(8);
387        let chunks = seal_all(&mut sealer, b"content", 8);
388
389        let wrong = SecureKey::new([1u8; 32]);
390        assert!(open_all(&header, &wrong, &chunks).is_err());
391        assert!(open_all(&header, &key, &chunks).is_ok());
392    }
393}