Skip to main content

oc_crypto/
stream.rs

1// SPDX-License-Identifier: MPL-2.0
2//! Streaming payload encryption shared by native and WASM hosts.
3//!
4//! Source and sink are closures; randomness is supplied by the caller. Empty
5//! input produces one authenticated, zero-length chunk. Nonces use hedging, and
6//! Merkle leaves bind each chunk's index, nonce, tag, and ciphertext in stream order.
7//! Keeping this loop shared preserves the same bytes across host adapters.
8
9use crate::aead::{NONCE_LEN, TAG_LEN, seal_chunk_hedged};
10use crate::merkle::{Leaf, MerkleTree};
11use crate::secret::{PayloadKey, SecretBuf};
12use crate::{AeadAlg, CryptoError};
13use zeroize::Zeroizing;
14
15/// Result of processing a stream.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct Sealed {
18    pub total_len: u64,
19    pub chunk_count: u32,
20    pub tree_root: [u8; 32],
21}
22
23/// Stream failure: ours or the host's.
24///
25/// Host errors deliberately remain distinct from ours: one host has
26/// `std::io::Error`, another JavaScript exceptions; normalizing them
27/// here would discard the cause exactly where it is needed.
28#[derive(Debug)]
29pub enum StreamError<E> {
30    /// Cryptography: key, algorithm, buffer capacity.
31    Crypto(CryptoError),
32    /// Host source or sink.
33    Host(E),
34    /// File exceeds the format's addressable size.
35    TooLarge,
36}
37
38impl<E> From<CryptoError> for StreamError<E> {
39    fn from(err: CryptoError) -> Self {
40        Self::Crypto(err)
41    }
42}
43
44impl<E: core::fmt::Display> core::fmt::Display for StreamError<E> {
45    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
46        match self {
47            Self::Crypto(err) => write!(f, "{err}"),
48            Self::Host(err) => write!(f, "{err}"),
49            Self::TooLarge => f.write_str("файл больше, чем адресуемо форматом"),
50        }
51    }
52}
53
54impl<E: core::fmt::Debug + core::fmt::Display> core::error::Error for StreamError<E> {}
55
56/// Encrypt a stream in chunks and deliver frames to the sink.
57///
58/// `source` fills the buffer and returns the number of bytes written; zero means
59/// end of input. Filling the buffer completely is NOT its responsibility; this loop
60/// does that, or a short read in mid-file would split a chunk at the wrong
61/// boundary and each host would fix it differently.
62///
63/// `sink` receives ready-made frame bytes: first `nonce`, then `tag ‖ ct`.
64///
65/// # Errors
66/// [`StreamError`]: crypto failure, host failure, or an unaddressably large file.
67pub fn seal_chunks<G, E>(
68    key: &PayloadKey,
69    alg: AeadAlg,
70    file_id: &[u8; 16],
71    chunk_size: u32,
72    rng: &mut G,
73    mut source: impl FnMut(&mut [u8]) -> Result<usize, E>,
74    mut sink: impl FnMut(&[u8]) -> Result<(), E>,
75) -> Result<Sealed, StreamError<E>>
76where
77    G: rand_core::CryptoRng + ?Sized,
78{
79    let capacity = usize::try_from(chunk_size).map_err(|_| StreamError::TooLarge)?;
80    // Zero capacity skips the source and would seal a nonempty file as empty.
81    if capacity == 0 {
82        return Err(CryptoError::BadLength.into());
83    }
84    // Затирающий буфер фиксированной ёмкости: обычный вектор уносил бы каждый
85    // прочитанный кусок исходного файла в кучу — и при уничтожении, и при росте
86    // (И-11).
87    let mut plaintext = SecretBuf::with_capacity(capacity);
88    let mut framed = Vec::with_capacity(capacity.saturating_add(TAG_LEN));
89    let mut leaves: Vec<Leaf> = Vec::new();
90    let mut total_len = 0u64;
91    let mut index = 0u32;
92
93    loop {
94        let filled = fill(&mut source, plaintext.as_capacity_mut())?;
95        plaintext.declare_len(filled)?;
96        // Выходим только когда предыдущий чанк был полным: иначе пустой файл не
97        // получил бы ни одного чанка.
98        if filled == 0 && index > 0 {
99            break;
100        }
101
102        let piece = plaintext.as_slice();
103        // Nonce через засев, а не прямо из генератора (решение С-13, доведённое
104        // до всех четырёх nonce сборки пунктом Р-2). Генератор повторяется при
105        // откате снапшота ВМ, клоне образа и восстановлении из копии; повторись
106        // он здесь — повторился бы и ключ полезной нагрузки, потому что `CEK` с
107        // `header_salt` берутся из того же генератора, и два разных документа
108        // получили бы один поток ключей. Открытый текст в засеве это разводит.
109        let mut nonce_seed = Zeroizing::new([0u8; NONCE_LEN]);
110        rand_core::Rng::fill_bytes(rng, nonce_seed.as_mut_slice());
111        let (nonce, leaf) =
112            seal_chunk_hedged(key, alg, file_id, index, &nonce_seed, piece, &mut framed)?;
113
114        sink(&nonce).map_err(StreamError::Host)?;
115        sink(&framed).map_err(StreamError::Host)?;
116
117        leaves.push(leaf);
118        total_len =
119            total_len.checked_add(filled as u64).ok_or(StreamError::TooLarge)?;
120        index = index.checked_add(1).ok_or(StreamError::TooLarge)?;
121
122        if filled < capacity {
123            break;
124        }
125    }
126
127    let tree = MerkleTree::build(&leaves)?;
128    Ok(Sealed { total_len, chunk_count: index, tree_root: tree.root() })
129}
130
131/// Read until the buffer is full or input ends.
132///
133/// Short reads are normal for pipes and network sources; they do NOT
134/// mean end-of-input. Treating them as the end would split chunks at the wrong
135/// boundary, producing different files from pipe input and disk
136/// input. Only a zero-byte read means end of input.
137fn fill<E>(
138    source: &mut impl FnMut(&mut [u8]) -> Result<usize, E>,
139    buf: &mut [u8],
140) -> Result<usize, StreamError<E>> {
141    let mut filled = 0usize;
142    while filled < buf.len() {
143        let Some(rest) = buf.get_mut(filled..) else {
144            break;
145        };
146        let got = source(rest).map_err(StreamError::Host)?;
147        if got == 0 {
148            break;
149        }
150        filled = filled.saturating_add(got);
151    }
152    Ok(filled)
153}
154
155#[cfg(test)]
156#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::indexing_slicing)]
157mod tests {
158    use super::*;
159
160    /// Probe RNG: deterministic because this tests chunking
161    /// DISCIPLINE, not randomness. With a random RNG, two
162    /// runs could not be compared.
163    struct Fixed(u8);
164
165    impl rand_core::TryRng for Fixed {
166        type Error = core::convert::Infallible;
167        fn try_next_u32(&mut self) -> Result<u32, Self::Error> {
168            Ok(u32::from(self.0))
169        }
170        fn try_next_u64(&mut self) -> Result<u64, Self::Error> {
171            Ok(u64::from(self.0))
172        }
173        fn try_fill_bytes(&mut self, dst: &mut [u8]) -> Result<(), Self::Error> {
174            dst.fill(self.0);
175            Ok(())
176        }
177    }
178    impl rand_core::TryCryptoRng for Fixed {}
179
180    fn key() -> PayloadKey {
181        PayloadKey::from_bytes([7u8; 32])
182    }
183
184    /// Process input in pieces no larger than `step` bytes.
185    ///
186    /// `step` models short reads: a pipe returns what
187    /// it currently has, not what was requested.
188    fn run(input: &[u8], chunk_size: u32, step: usize) -> (Sealed, Vec<u8>) {
189        let mut left = input;
190        let mut out = Vec::new();
191        let sealed = seal_chunks(
192            &key(),
193            AeadAlg::XChaCha20Poly1305,
194            &[1u8; 16],
195            chunk_size,
196            &mut Fixed(0x5a),
197            |buf| -> Result<usize, core::convert::Infallible> {
198                let take = left.len().min(buf.len()).min(step);
199                buf.get_mut(..take).unwrap_or_default().copy_from_slice(&left[..take]);
200                left = &left[take..];
201                Ok(take)
202            },
203            |bytes| -> Result<(), core::convert::Infallible> {
204                out.extend_from_slice(bytes);
205                Ok(())
206            },
207        )
208        .expect("прогон не удался");
209        (sealed, out)
210    }
211
212    /// EMPTY INPUT HAS ONE CHUNK, NOT ZERO.
213    ///
214    /// Otherwise the file would have no authentication tag and no tree
215    /// leaf, forcing the parser to introduce a special case, a place
216    /// where the forgery "file without chunks" would look legitimate.
217    #[test]
218    fn an_empty_input_still_gets_exactly_one_chunk() {
219        let (sealed, _) = run(&[], 64, 64);
220        assert_eq!(sealed.chunk_count, 1, "у пустого входа не один чанк");
221        assert_eq!(sealed.total_len, 0);
222    }
223
224    /// SHORT READS CHANGE NO BYTES.
225    ///
226    /// This module's main property and the reason the loop must exist
227    /// only once. Pipes return bytes, disks return whole chunks; if a host split by
228    /// how much it received at a time, the same document supplied in
229    /// two ways would yield DIFFERENT containers. Almost impossible to notice in a live
230    /// file: both open.
231    #[test]
232    fn a_short_read_changes_nothing() {
233        let input: Vec<u8> = (0..300u32).map(|i| u8::try_from(i % 251).unwrap_or(0)).collect();
234        let (whole, bytes_whole) = run(&input, 64, usize::MAX);
235        for step in [1usize, 7, 63, 64, 65, 128] {
236            let (piecemeal, bytes_piecemeal) = run(&input, 64, step);
237            assert_eq!(piecemeal, whole, "шаг {step}: нарезка разошлась");
238            assert_eq!(bytes_piecemeal, bytes_whole, "шаг {step}: байты разошлись");
239        }
240    }
241
242    /// CHUNK COUNT DEPENDS ON SIZE, NOT LUCK.
243    ///
244    /// The "input exactly one chunk long" boundary is checked separately: it is easy
245    /// to create an extra empty chunk there or lose the last one.
246    #[test]
247    fn the_chunk_count_follows_the_size_including_the_edges() {
248        for (len, expected) in [(0usize, 1u32), (1, 1), (63, 1), (64, 1), (65, 2), (128, 2), (129, 3)] {
249            let input = vec![0xa5u8; len];
250            let (sealed, _) = run(&input, 64, usize::MAX);
251            assert_eq!(sealed.chunk_count, expected, "длина {len}");
252            assert_eq!(sealed.total_len, len as u64, "длина {len}");
253        }
254    }
255
256    /// HOST ERRORS REACH THE CALLER WITH THEIR ORIGINAL TYPE.
257    ///
258    /// Not collapsed into our error: one host has `std::io::Error`, another
259    /// JavaScript exceptions, and the original cause must be preserved.
260    #[test]
261    fn a_host_failure_arrives_as_itself() {
262        let err = seal_chunks(
263            &key(),
264            AeadAlg::XChaCha20Poly1305,
265            &[1u8; 16],
266            64,
267            &mut Fixed(1),
268            |_buf| Err("источник отвалился"),
269            |_bytes| Ok(()),
270        )
271        .expect_err("отказ источника не заметили");
272        match err {
273            StreamError::Host(said) => assert_eq!(said, "источник отвалился"),
274            other => panic!("отказ хоста подменён нашим: {other:?}"),
275        }
276    }
277}