Skip to main content

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}