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    // Затирающий буфер фиксированной ёмкости: обычный вектор уносил бы каждый
81    // прочитанный кусок исходного файла в кучу — и при уничтожении, и при росте
82    // (И-11).
83    let mut plaintext = SecretBuf::with_capacity(capacity);
84    let mut framed = Vec::with_capacity(capacity.saturating_add(TAG_LEN));
85    let mut leaves: Vec<Leaf> = Vec::new();
86    let mut total_len = 0u64;
87    let mut index = 0u32;
88
89    loop {
90        let filled = fill(&mut source, plaintext.as_capacity_mut())?;
91        plaintext.declare_len(filled)?;
92        // Выходим только когда предыдущий чанк был полным: иначе пустой файл не
93        // получил бы ни одного чанка.
94        if filled == 0 && index > 0 {
95            break;
96        }
97
98        let piece = plaintext.as_slice();
99        // Nonce через засев, а не прямо из генератора (решение С-13, доведённое
100        // до всех четырёх nonce сборки пунктом Р-2). Генератор повторяется при
101        // откате снапшота ВМ, клоне образа и восстановлении из копии; повторись
102        // он здесь — повторился бы и ключ полезной нагрузки, потому что `CEK` с
103        // `header_salt` берутся из того же генератора, и два разных документа
104        // получили бы один поток ключей. Открытый текст в засеве это разводит.
105        let mut nonce_seed = Zeroizing::new([0u8; NONCE_LEN]);
106        rand_core::Rng::fill_bytes(rng, nonce_seed.as_mut_slice());
107        let (nonce, leaf) =
108            seal_chunk_hedged(key, alg, file_id, index, &nonce_seed, piece, &mut framed)?;
109
110        sink(&nonce).map_err(StreamError::Host)?;
111        sink(&framed).map_err(StreamError::Host)?;
112
113        leaves.push(leaf);
114        total_len =
115            total_len.checked_add(filled as u64).ok_or(StreamError::TooLarge)?;
116        index = index.checked_add(1).ok_or(StreamError::TooLarge)?;
117
118        if filled < capacity {
119            break;
120        }
121    }
122
123    let tree = MerkleTree::build(&leaves)?;
124    Ok(Sealed { total_len, chunk_count: index, tree_root: tree.root() })
125}
126
127/// Read until the buffer is full or input ends.
128///
129/// Short reads are normal for pipes and network sources; they do NOT
130/// mean end-of-input. Treating them as the end would split chunks at the wrong
131/// boundary, producing different files from pipe input and disk
132/// input. Only a zero-byte read means end of input.
133fn fill<E>(
134    source: &mut impl FnMut(&mut [u8]) -> Result<usize, E>,
135    buf: &mut [u8],
136) -> Result<usize, StreamError<E>> {
137    let mut filled = 0usize;
138    while filled < buf.len() {
139        let Some(rest) = buf.get_mut(filled..) else {
140            break;
141        };
142        let got = source(rest).map_err(StreamError::Host)?;
143        if got == 0 {
144            break;
145        }
146        filled = filled.saturating_add(got);
147    }
148    Ok(filled)
149}
150
151#[cfg(test)]
152#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::indexing_slicing)]
153mod tests {
154    use super::*;
155
156    /// Probe RNG: deterministic because this tests chunking
157    /// DISCIPLINE, not randomness. With a random RNG, two
158    /// runs could not be compared.
159    struct Fixed(u8);
160
161    impl rand_core::TryRng for Fixed {
162        type Error = core::convert::Infallible;
163        fn try_next_u32(&mut self) -> Result<u32, Self::Error> {
164            Ok(u32::from(self.0))
165        }
166        fn try_next_u64(&mut self) -> Result<u64, Self::Error> {
167            Ok(u64::from(self.0))
168        }
169        fn try_fill_bytes(&mut self, dst: &mut [u8]) -> Result<(), Self::Error> {
170            dst.fill(self.0);
171            Ok(())
172        }
173    }
174    impl rand_core::TryCryptoRng for Fixed {}
175
176    fn key() -> PayloadKey {
177        PayloadKey::from_bytes([7u8; 32])
178    }
179
180    /// Process input in pieces no larger than `step` bytes.
181    ///
182    /// `step` models short reads: a pipe returns what
183    /// it currently has, not what was requested.
184    fn run(input: &[u8], chunk_size: u32, step: usize) -> (Sealed, Vec<u8>) {
185        let mut left = input;
186        let mut out = Vec::new();
187        let sealed = seal_chunks(
188            &key(),
189            AeadAlg::XChaCha20Poly1305,
190            &[1u8; 16],
191            chunk_size,
192            &mut Fixed(0x5a),
193            |buf| -> Result<usize, core::convert::Infallible> {
194                let take = left.len().min(buf.len()).min(step);
195                buf.get_mut(..take).unwrap_or_default().copy_from_slice(&left[..take]);
196                left = &left[take..];
197                Ok(take)
198            },
199            |bytes| -> Result<(), core::convert::Infallible> {
200                out.extend_from_slice(bytes);
201                Ok(())
202            },
203        )
204        .expect("прогон не удался");
205        (sealed, out)
206    }
207
208    /// EMPTY INPUT HAS ONE CHUNK, NOT ZERO.
209    ///
210    /// Otherwise the file would have no authentication tag and no tree
211    /// leaf, forcing the parser to introduce a special case, a place
212    /// where the forgery "file without chunks" would look legitimate.
213    #[test]
214    fn an_empty_input_still_gets_exactly_one_chunk() {
215        let (sealed, _) = run(&[], 64, 64);
216        assert_eq!(sealed.chunk_count, 1, "у пустого входа не один чанк");
217        assert_eq!(sealed.total_len, 0);
218    }
219
220    /// SHORT READS CHANGE NO BYTES.
221    ///
222    /// This module's main property and the reason the loop must exist
223    /// only once. Pipes return bytes, disks return whole chunks; if a host split by
224    /// how much it received at a time, the same document supplied in
225    /// two ways would yield DIFFERENT containers. Almost impossible to notice in a live
226    /// file: both open.
227    #[test]
228    fn a_short_read_changes_nothing() {
229        let input: Vec<u8> = (0..300u32).map(|i| u8::try_from(i % 251).unwrap_or(0)).collect();
230        let (whole, bytes_whole) = run(&input, 64, usize::MAX);
231        for step in [1usize, 7, 63, 64, 65, 128] {
232            let (piecemeal, bytes_piecemeal) = run(&input, 64, step);
233            assert_eq!(piecemeal, whole, "шаг {step}: нарезка разошлась");
234            assert_eq!(bytes_piecemeal, bytes_whole, "шаг {step}: байты разошлись");
235        }
236    }
237
238    /// CHUNK COUNT DEPENDS ON SIZE, NOT LUCK.
239    ///
240    /// The "input exactly one chunk long" boundary is checked separately: it is easy
241    /// to create an extra empty chunk there or lose the last one.
242    #[test]
243    fn the_chunk_count_follows_the_size_including_the_edges() {
244        for (len, expected) in [(0usize, 1u32), (1, 1), (63, 1), (64, 1), (65, 2), (128, 2), (129, 3)] {
245            let input = vec![0xa5u8; len];
246            let (sealed, _) = run(&input, 64, usize::MAX);
247            assert_eq!(sealed.chunk_count, expected, "длина {len}");
248            assert_eq!(sealed.total_len, len as u64, "длина {len}");
249        }
250    }
251
252    /// HOST ERRORS REACH THE CALLER WITH THEIR ORIGINAL TYPE.
253    ///
254    /// Not collapsed into our error: one host has `std::io::Error`, another
255    /// JavaScript exceptions, and the original cause must be preserved.
256    #[test]
257    fn a_host_failure_arrives_as_itself() {
258        let err = seal_chunks(
259            &key(),
260            AeadAlg::XChaCha20Poly1305,
261            &[1u8; 16],
262            64,
263            &mut Fixed(1),
264            |_buf| Err("источник отвалился"),
265            |_bytes| Ok(()),
266        )
267        .expect_err("отказ источника не заметили");
268        match err {
269            StreamError::Host(said) => assert_eq!(said, "источник отвалился"),
270            other => panic!("отказ хоста подменён нашим: {other:?}"),
271        }
272    }
273}