Skip to main content

oc_crypto/
stream.rs

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