dstu_core/crypto_secretstream.rs
1//! `crypto_secretstream` equivalent (`docs/dstu-crypto-project.md` "Mapping onto the libsodium
2//! API", `docs/TASKS.md` T-40/T-70, roadmap Step 5 item 1 - `docs/DECISIONS.md` D-68) - a chunked/streaming
3//! AEAD construction so a large message never needs to fit in memory all at once, unlike
4//! [`crate::crypto_secretbox`] (whose underlying AEAD tag needs the whole plaintext/ciphertext up
5//! front).
6//!
7//! # From-scratch construction - no DSTU standard, no oracle vector, ever
8//!
9//! No DSTU standard defines a streaming/chunked AEAD mode. Per `docs/DECISIONS.md` D-47's tie-breaker
10//! rule (no citation exists, so: TLS 1.3/modern-AEAD lessons, then libsodium's own API shape), this
11//! follows libsodium's `crypto_secretstream_xchacha20poly1305` shape - tag-per-chunk framing with a
12//! `FINAL` tag whose absence before end-of-input signals truncation - built on this crate's own
13//! primitives ([`crate::hazmat::kalyna_gcm::Kalyna256_256Gcm`] for chunk encryption,
14//! [`crate::hazmat::kupyna_kmac::Kupyna256Kmac`] for subkey derivation) instead of
15//! `ChaCha20-Poly1305`. Same posture as [`crate::hazmat::kupyna_kdf`] (D-45): verified by property test only,
16//! never citable as vector-verified.
17//!
18//! # Construction
19//!
20//! [`PushState::init`] draws a random 32-byte `header` and derives the stream's initial subkey as
21//! `Kupyna256Kmac::mac(key = master_key, message = header)`. This is the nonce/IV-coverage rule
22//! (see [`crate::crypto_secretbox`]'s D-63 precedent) applied at stream setup instead of per-chunk
23//! AAD: the subkey itself is a function of the header, so a tampered header derives the wrong
24//! subkey and the very first chunk's tag fails closed - no separate binding needed.
25//!
26//! Each chunk is encrypted with [`Kalyna256_256Gcm`](crate::hazmat::kalyna_gcm::Kalyna256_256Gcm)
27//! under a 32-byte IV that is all-zero except its low 8 bytes, which hold a `u64` counter -
28//! monotonically increasing per chunk, tracked identically on both sides, **never transmitted and
29//! never reset** (including across a [`Tag::Rekey`], the simplest safe choice). The chunk's
30//! [`Tag`] byte and the counter are passed together as `kalyna_gcm`'s `aad` parameter
31//! (`counter.to_le_bytes() || [tag_byte]`) - the same "bind out-of-band data into the tag via
32//! AEAD's own AAD mechanism" pattern D-63 established for `crypto_secretbox`'s nonce. Binding the
33//! counter into the AAD, rather than trusting a transmitted position, is what defeats reordering,
34//! interior chunk drops, and splicing a chunk from a different stream: a receiver always verifies
35//! against *its own* expected counter, so anything that isn't exactly next-in-sequence fails its
36//! tag check. Splicing from a different stream under the same master key fails for a second,
37//! independent reason too: that stream has its own random header, hence its own derived subkey.
38//!
39//! Tampering the transmitted `tag_byte` itself (e.g. flipping [`Tag::Final`] to [`Tag::Message`]
40//! to hide truncation) is caught the same way: [`PullState::pull`] uses the wire-read `tag_byte`
41//! directly as part of the AAD it verifies against, so a flipped byte changes the AAD and fails the
42//! tag check before the (wrong) [`Tag`] is ever trusted or returned to the caller.
43//!
44//! # Tags
45//!
46//! Byte values match libsodium's own encoding (`MESSAGE=0x00`, `PUSH=0x01`, `REKEY=0x02`,
47//! `FINAL=0x03`) - no DSTU reason to diverge, purely familiarity:
48//! - [`Tag::Message`] - an ordinary chunk, no special handling.
49//! - [`Tag::Push`] - marks a logical sub-message boundary within one continuous stream, for a
50//! caller multiplexing more than one logical message into a single stream.
51//! - [`Tag::Rekey`] - after this chunk, both sides derive a fresh subkey:
52//! `new_subkey = Kupyna256Kmac::mac(key = current_subkey, message = b"DSTU-secretstream-rekey")`.
53//! One-way (KMAC), so a compromised later subkey does not recover earlier chunks' key - the
54//! forward-secrecy property libsodium's own rekey exists for.
55//! - [`Tag::Final`] - after this chunk, the state is marked finalized
56//! ([`PushState::is_finalized`]/[`PullState::is_finalized`]); any further [`PushState::push`]/
57//! [`PullState::pull`] call on that state returns [`SecretstreamError::StreamFinalized`]. This is
58//! what makes truncation detectable: a caller who reaches end-of-input without ever having seen
59//! `Final` knows the stream was cut short - the check itself lives in the caller's I/O loop
60//! ([`PullState::is_finalized`] is the primitive this module provides for it), since only the
61//! caller knows when its input is exhausted.
62//!
63//! # `no_std` posture
64//!
65//! Per-item `std` gating (the [`crate::crypto_auth`]/[`crate::crypto_kdf`] pattern, not
66//! [`crate::crypto_stream`]'s whole-module gating): only [`PushState::init`] (needs
67//! [`crate::randombytes::randombytes_buf`] for the header) is `#[cfg(feature = "std")]`.
68//! [`PullState::init`], [`PushState::push`], and [`PullState::pull`] are unconditional - caller-
69//! supplied buffers mean no `Vec`/`alloc` is needed anywhere in the actual push/pull path. The
70//! `push`/`pull` step machinery itself is a stricter `no_std` fit than any other high-level
71//! `crypto_*` module's equivalent step, **but `PushState::init` is `PushState`'s only
72//! constructor**, so under `no_std` a caller can build a [`PullState`] but has no way to start a
73//! new stream - this module is decrypt-only without `std` (D-09's "`hazmat` never generates its
74//! own randomness" reasoning, unchanged, but worth stating plainly rather than implying the whole
75//! module is symmetric under `no_std`).
76//!
77//! # Example
78//!
79//! A real caller processes a large file one bounded-size chunk at a time (see `uacrypt`'s own
80//! `encrypt`/`decrypt` commands for that shape); this example uses one chunk for clarity. Each
81//! chunk is authenticated individually - a tampered chunk, a dropped/reordered chunk, or one
82//! spliced from a different stream all fail closed at [`PullState::pull`], never producing wrong
83//! plaintext silently.
84//!
85//! ```rust
86//! use dstu_core::crypto_secretstream::{Key, PushState, PullState, Tag};
87//!
88//! let key = Key::generate().expect("OS CSPRNG should not fail");
89//! let plaintext = b"a whole file, conceptually split into chunks";
90//!
91//! // Sender side: one chunk, marked Final since it's the only (and therefore last) one.
92//! let (mut push, header) = PushState::init(&key).expect("OS CSPRNG should not fail");
93//! let mut ciphertext = vec![0u8; plaintext.len()];
94//! let tag = push
95//! .push(Tag::Final, plaintext, &mut ciphertext)
96//! .expect("push before finalization");
97//!
98//! // Receiver side: needs the key and the transmitted header, ciphertext, and tag.
99//! let mut pull = PullState::init(&key, &header);
100//! let mut decrypted = vec![0u8; ciphertext.len()];
101//! let read_tag = pull
102//! .pull(Tag::Final.to_byte(), &ciphertext, &tag, &mut decrypted)
103//! .expect("authentic chunk");
104//! assert_eq!(read_tag, Tag::Final);
105//! assert_eq!(decrypted, plaintext);
106//!
107//! // A tampered ciphertext byte is rejected, not silently decrypted into garbage.
108//! let mut tampered = ciphertext.clone();
109//! tampered[0] ^= 1;
110//! let mut pull2 = PullState::init(&key, &header);
111//! let mut out = vec![0u8; tampered.len()];
112//! assert!(pull2
113//! .pull(Tag::Final.to_byte(), &tampered, &tag, &mut out)
114//! .is_err());
115//! ```
116
117use crate::hazmat::kalyna_gcm::{GcmError, Kalyna256_256Gcm};
118use crate::hazmat::kupyna_kmac::Kupyna256Kmac;
119use core::fmt;
120use zeroize::Zeroize;
121
122const TAG_LEN: usize = 16;
123const REKEY_CONTEXT: &[u8] = b"DSTU-secretstream-rekey";
124
125/// A `crypto_secretstream` master key. Always exactly 32 bytes - [`Kupyna256Kmac`]'s fixed
126/// key/MAC length (see the module doc).
127pub struct Key([u8; 32]);
128
129impl Drop for Key {
130 fn drop(&mut self) {
131 self.0.zeroize();
132 }
133}
134
135impl Key {
136 /// Generates a fresh key from the OS CSPRNG - libsodium's `crypto_secretstream_keygen`
137 /// equivalent.
138 ///
139 /// # Errors
140 ///
141 /// Returns [`crate::randombytes::RandomError`] if the OS CSPRNG fails.
142 #[cfg(any(feature = "std", feature = "getrandom"))]
143 pub fn generate() -> Result<Self, crate::randombytes::RandomError> {
144 let mut bytes = [0u8; 32];
145 crate::randombytes::randombytes_buf(&mut bytes)?;
146 Ok(Key(bytes))
147 }
148
149 #[must_use]
150 pub fn from_bytes(bytes: [u8; 32]) -> Self {
151 Key(bytes)
152 }
153
154 #[must_use]
155 pub fn as_bytes(&self) -> &[u8; 32] {
156 &self.0
157 }
158}
159
160/// A chunk's role in the stream - see the module doc's "Tags" section.
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub enum Tag {
163 Message,
164 Push,
165 Rekey,
166 Final,
167}
168
169impl Tag {
170 #[must_use]
171 pub fn to_byte(self) -> u8 {
172 match self {
173 Tag::Message => 0x00,
174 Tag::Push => 0x01,
175 Tag::Rekey => 0x02,
176 Tag::Final => 0x03,
177 }
178 }
179
180 #[must_use]
181 pub fn from_byte(byte: u8) -> Option<Self> {
182 match byte {
183 0x00 => Some(Tag::Message),
184 0x01 => Some(Tag::Push),
185 0x02 => Some(Tag::Rekey),
186 0x03 => Some(Tag::Final),
187 _ => None,
188 }
189 }
190}
191
192#[derive(Debug)]
193pub enum SecretstreamError {
194 /// `ciphertext_out.len() != plaintext.len()` (or the `plaintext_out`/`ciphertext` equivalent
195 /// on [`PullState::pull`]).
196 InvalidLength,
197 /// Authentication failed: wrong key, wrong header, tampered ciphertext/tag/tag-byte, or a
198 /// chunk out of sequence (wrong counter) - reordered, dropped, or spliced from another stream.
199 TagMismatch,
200 /// `tag_byte` passed to [`PullState::pull`] was not one of the four values [`Tag::to_byte`]
201 /// produces.
202 UnknownTag,
203 /// [`PushState::push`]/[`PullState::pull`] called again after a [`Tag::Final`] chunk already
204 /// closed this state.
205 StreamFinalized,
206 /// [`PushState::init`]'s OS CSPRNG call failed while generating a header.
207 #[cfg(any(feature = "std", feature = "getrandom"))]
208 Random(crate::randombytes::RandomError),
209}
210
211impl fmt::Display for SecretstreamError {
212 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
213 match self {
214 SecretstreamError::InvalidLength => write!(f, "buffer length mismatch"),
215 SecretstreamError::TagMismatch => write!(f, "authentication failed"),
216 SecretstreamError::UnknownTag => write!(f, "unrecognized chunk tag byte"),
217 SecretstreamError::StreamFinalized => {
218 write!(f, "stream already finalized, no more chunks accepted")
219 }
220 #[cfg(any(feature = "std", feature = "getrandom"))]
221 SecretstreamError::Random(e) => write!(f, "{e}"),
222 }
223 }
224}
225
226impl core::error::Error for SecretstreamError {}
227
228#[cfg(any(feature = "std", feature = "getrandom"))]
229impl From<crate::randombytes::RandomError> for SecretstreamError {
230 fn from(e: crate::randombytes::RandomError) -> Self {
231 SecretstreamError::Random(e)
232 }
233}
234
235fn rekey(subkey: &mut [u8; 32]) {
236 let Ok(new_subkey) = Kupyna256Kmac::mac(subkey, REKEY_CONTEXT) else {
237 unreachable!("subkey is always exactly 32 bytes, Kupyna256Kmac's own mac_len")
238 };
239 subkey.zeroize();
240 *subkey = new_subkey;
241}
242
243fn chunk_iv(counter: u64) -> [u8; 32] {
244 let mut iv = [0u8; 32];
245 iv[..8].copy_from_slice(&counter.to_le_bytes());
246 iv
247}
248
249fn chunk_aad(counter: u64, tag_byte: u8) -> [u8; 9] {
250 let mut aad = [0u8; 9];
251 aad[..8].copy_from_slice(&counter.to_le_bytes());
252 aad[8] = tag_byte;
253 aad
254}
255
256/// Encrypting half of a `crypto_secretstream` session - produced by [`PushState::init`], driven
257/// one chunk at a time by [`PushState::push`].
258pub struct PushState {
259 subkey: [u8; 32],
260 counter: u64,
261 finalized: bool,
262}
263
264impl Drop for PushState {
265 fn drop(&mut self) {
266 self.subkey.zeroize();
267 }
268}
269
270impl PushState {
271 /// Starts a new stream under `key`, drawing a fresh random header from the OS CSPRNG. The
272 /// returned header must be transmitted/stored alongside the chunks - [`PullState::init`]
273 /// needs it to re-derive the same initial subkey.
274 ///
275 /// # Errors
276 ///
277 /// Returns [`SecretstreamError::Random`] if the OS CSPRNG fails.
278 #[cfg(any(feature = "std", feature = "getrandom"))]
279 pub fn init(key: &Key) -> Result<(Self, [u8; 32]), SecretstreamError> {
280 let mut header = [0u8; 32];
281 crate::randombytes::randombytes_buf(&mut header)?;
282 let Ok(subkey) = Kupyna256Kmac::mac(key.as_bytes(), &header) else {
283 unreachable!("Key::as_bytes() is always exactly 32 bytes, Kupyna256Kmac's own mac_len")
284 };
285 Ok((
286 PushState {
287 subkey,
288 counter: 0,
289 finalized: false,
290 },
291 header,
292 ))
293 }
294
295 #[must_use]
296 pub fn is_finalized(&self) -> bool {
297 self.finalized
298 }
299
300 /// Encrypts `plaintext` into `ciphertext_out` (same length) and returns the 16-byte
301 /// authentication tag for this chunk. `tag` becomes part of this chunk's AAD (see the module
302 /// doc) - the caller must transmit both the ciphertext, the returned tag, *and* `tag.to_byte()`
303 /// for [`PullState::pull`] to recover the plaintext.
304 ///
305 /// # Errors
306 ///
307 /// Returns [`SecretstreamError::InvalidLength`] if `ciphertext_out.len() != plaintext.len()`,
308 /// or [`SecretstreamError::StreamFinalized`] if a previous chunk already used [`Tag::Final`].
309 pub fn push(
310 &mut self,
311 tag: Tag,
312 plaintext: &[u8],
313 ciphertext_out: &mut [u8],
314 ) -> Result<[u8; TAG_LEN], SecretstreamError> {
315 if self.finalized {
316 return Err(SecretstreamError::StreamFinalized);
317 }
318 if ciphertext_out.len() != plaintext.len() {
319 return Err(SecretstreamError::InvalidLength);
320 }
321
322 let cipher = Kalyna256_256Gcm::new(&self.subkey);
323 let iv = chunk_iv(self.counter);
324 let aad = chunk_aad(self.counter, tag.to_byte());
325 let Ok(full_tag) = cipher.encrypt(&iv, &aad, plaintext, ciphertext_out) else {
326 unreachable!("ciphertext_out.len() == plaintext.len() checked above")
327 };
328
329 self.counter += 1;
330 match tag {
331 Tag::Rekey => rekey(&mut self.subkey),
332 Tag::Final => self.finalized = true,
333 Tag::Message | Tag::Push => {}
334 }
335
336 let mut out = [0u8; TAG_LEN];
337 out.copy_from_slice(&full_tag[..TAG_LEN]);
338 Ok(out)
339 }
340}
341
342/// Decrypting half of a `crypto_secretstream` session - built from the master key and the header
343/// [`PushState::init`] produced, driven one chunk at a time by [`PullState::pull`].
344pub struct PullState {
345 subkey: [u8; 32],
346 counter: u64,
347 finalized: bool,
348}
349
350impl Drop for PullState {
351 fn drop(&mut self) {
352 self.subkey.zeroize();
353 }
354}
355
356impl PullState {
357 /// Re-derives the stream's initial subkey from `key` and `header` (as produced by
358 /// [`PushState::init`]). Infallible - deriving the wrong subkey from a tampered `header` is
359 /// not detected here, only once the first chunk's tag fails to verify (see the module doc).
360 #[must_use]
361 pub fn init(key: &Key, header: &[u8; 32]) -> Self {
362 let Ok(subkey) = Kupyna256Kmac::mac(key.as_bytes(), header) else {
363 unreachable!("Key::as_bytes() is always exactly 32 bytes, Kupyna256Kmac's own mac_len")
364 };
365 PullState {
366 subkey,
367 counter: 0,
368 finalized: false,
369 }
370 }
371
372 #[must_use]
373 pub fn is_finalized(&self) -> bool {
374 self.finalized
375 }
376
377 /// Verifies and decrypts one chunk. `tag_byte` is untrusted wire input - it is folded into the
378 /// AAD verified against `auth_tag`, so a value that doesn't match what [`PushState::push`]
379 /// actually used fails the tag check (see the module doc). Returns the authenticated [`Tag`]
380 /// on success.
381 ///
382 /// # Errors
383 ///
384 /// Returns [`SecretstreamError::UnknownTag`] if `tag_byte` isn't a value [`Tag::to_byte`]
385 /// produces, [`SecretstreamError::InvalidLength`] if `plaintext_out.len() != ciphertext.len()`,
386 /// [`SecretstreamError::StreamFinalized`] if a previous chunk already used [`Tag::Final`], or
387 /// [`SecretstreamError::TagMismatch`] if authentication fails - `plaintext_out` is left
388 /// all-zero on any authentication failure, never unverified plaintext.
389 pub fn pull(
390 &mut self,
391 tag_byte: u8,
392 ciphertext: &[u8],
393 auth_tag: &[u8],
394 plaintext_out: &mut [u8],
395 ) -> Result<Tag, SecretstreamError> {
396 if self.finalized {
397 return Err(SecretstreamError::StreamFinalized);
398 }
399 let Some(tag) = Tag::from_byte(tag_byte) else {
400 return Err(SecretstreamError::UnknownTag);
401 };
402 if plaintext_out.len() != ciphertext.len() {
403 return Err(SecretstreamError::InvalidLength);
404 }
405
406 let cipher = Kalyna256_256Gcm::new(&self.subkey);
407 let iv = chunk_iv(self.counter);
408 let aad = chunk_aad(self.counter, tag_byte);
409
410 // `Kalyna256_256Gcm::decrypt` already uses `subtle::ConstantTimeEq` internally (D-56) and
411 // validates `auth_tag.len()` itself (8..=32) - no separate check needed here.
412 match cipher.decrypt(&iv, &aad, ciphertext, auth_tag, plaintext_out) {
413 Ok(()) => {}
414 Err(GcmError::TagMismatch) => return Err(SecretstreamError::TagMismatch),
415 Err(GcmError::InvalidLength) => return Err(SecretstreamError::InvalidLength),
416 }
417
418 self.counter += 1;
419 match tag {
420 Tag::Rekey => rekey(&mut self.subkey),
421 Tag::Final => self.finalized = true,
422 Tag::Message | Tag::Push => {}
423 }
424
425 Ok(tag)
426 }
427}