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}