Skip to main content

quantum_shield/
stream.rs

1//! Streaming authenticated encryption (`QST2`) for payloads too large to hold
2//! in memory or to seal in one shot (over [`MAX_PLAINTEXT_LEN`]).
3//!
4//! One hybrid KEM run (X25519 + ML-KEM-1024) derives a single AES-256-GCM key;
5//! the payload is then encrypted in fixed-size chunks using the STREAM
6//! construction (Rogaway/Hoang online authenticated encryption):
7//!
8//! - The 12-byte per-chunk nonce is `prefix (7) || u32 chunk index || last (1)`.
9//! - Each chunk's associated data is `stream_header || u32 index || last`,
10//!   binding the chunk to its position and to the one-time header, so
11//!   reordering, duplicating, dropping, or truncating chunks fails.
12//! - The final chunk sets the last-flag to 1. A stream that never presents a
13//!   last chunk is [`Error::StreamTruncated`] at [`StreamOpener::finish`].
14//!
15//! Chunks are [`STREAM_CHUNK_SIZE`] (64 KiB) of plaintext each; the `u32`
16//! counter allows up to 2^32 chunks (256 TiB) before rejection.
17//!
18//! ```
19//! use quantum_shield::{HybridCrypto, StreamSealer};
20//! # fn run() -> quantum_shield::Result<()> {
21//! let bob = HybridCrypto::generate()?;
22//! let (mut sealer, header) = StreamSealer::new(bob.public_keys())?;
23//! let c1 = sealer.seal_chunk(b"first part ", false)?;
24//! let c2 = sealer.seal_chunk(b"second part", true)?;
25//!
26//! let mut opener = bob.stream_opener(&header)?;
27//! let mut out = Vec::new();
28//! out.extend(opener.open_chunk(&c1)?.0);
29//! out.extend(opener.open_chunk(&c2)?.0);
30//! opener.finish()?;
31//! assert_eq!(out, b"first part second part");
32//! # Ok(()) }
33//! ```
34
35use crate::constants::*;
36use crate::error::{Error, Result};
37use crate::hybrid_kem::{self, KemCiphertext};
38use crate::keys::{KeyPair, PublicKeyBundle};
39use crate::wire::{read_header, take, write_header};
40use aes_gcm::aead::{Aead, Payload};
41use aes_gcm::{Aes256Gcm, KeyInit};
42use alloc::vec::Vec;
43use zeroize::Zeroizing;
44
45/// Assemble the 12-byte chunk nonce: `prefix || u32 index || last-flag`.
46fn chunk_nonce(prefix: &[u8; STREAM_NONCE_PREFIX_LEN], index: u32, last: bool) -> [u8; NONCE_LEN] {
47    let mut nonce = [0u8; NONCE_LEN];
48    nonce[..STREAM_NONCE_PREFIX_LEN].copy_from_slice(prefix);
49    nonce[STREAM_NONCE_PREFIX_LEN..STREAM_NONCE_PREFIX_LEN + 4]
50        .copy_from_slice(&index.to_be_bytes());
51    nonce[NONCE_LEN - 1] = last as u8;
52    nonce
53}
54
55/// Per-chunk associated data: `stream_header || u32 index || last-flag`.
56fn chunk_aad(header: &[u8], index: u32, last: bool) -> Vec<u8> {
57    let mut aad = Vec::with_capacity(header.len() + 5);
58    aad.extend_from_slice(header);
59    aad.extend_from_slice(&index.to_be_bytes());
60    aad.push(last as u8);
61    aad
62}
63
64/// Encrypts a payload as a sequence of independently authenticated chunks.
65pub struct StreamSealer {
66    cipher: Aes256Gcm,
67    nonce_prefix: [u8; STREAM_NONCE_PREFIX_LEN],
68    header: Vec<u8>,
69    index: u32,
70    finished: bool,
71}
72
73impl StreamSealer {
74    /// Begin a stream to `recipient`. Returns the sealer and the header bytes
75    /// (`QST2`) that must be written before the chunks.
76    ///
77    /// # Errors
78    ///
79    /// [`Error::RandomnessUnavailable`] if the OS RNG fails.
80    pub fn new(recipient: &PublicKeyBundle) -> Result<(Self, Vec<u8>)> {
81        let (kem_ct, ss) = hybrid_kem::encapsulate(recipient)?;
82        let mut nonce_prefix = [0u8; STREAM_NONCE_PREFIX_LEN];
83        getrandom::fill(&mut nonce_prefix).map_err(|_| Error::RandomnessUnavailable)?;
84
85        let mut header = Vec::with_capacity(STREAM_HEADER_LEN);
86        write_header(&mut header, MAGIC_STREAM);
87        header.extend_from_slice(&kem_ct.epk_x25519);
88        header.extend_from_slice(kem_ct.ct_mlkem.as_ref());
89        header.extend_from_slice(&nonce_prefix);
90
91        let cipher = Aes256Gcm::new((&*ss).into());
92        Ok((
93            Self {
94                cipher,
95                nonce_prefix,
96                header: header.clone(),
97                index: 0,
98                finished: false,
99            },
100            header,
101        ))
102    }
103
104    /// Encrypt one chunk. Set `last` on the final chunk. Returns the framed
105    /// chunk bytes to write.
106    ///
107    /// # Errors
108    ///
109    /// [`Error::StreamFinished`] if called after a `last` chunk or once the
110    /// 2^32-chunk limit is reached; [`Error::MessageTooLarge`] if a single
111    /// chunk's ciphertext would exceed the 32-bit frame length.
112    pub fn seal_chunk(&mut self, plaintext: &[u8], last: bool) -> Result<Vec<u8>> {
113        if self.finished {
114            return Err(Error::StreamFinished);
115        }
116        // Refuse a non-final chunk at the maximum index *before* encrypting, so
117        // the next call can never reuse the index/nonce. (A final chunk at the
118        // maximum index is fine — the stream ends there.)
119        if !last && self.index == u32::MAX {
120            self.finished = true;
121            return Err(Error::StreamFinished);
122        }
123        // Bound the per-chunk ciphertext to the 32-bit frame length field.
124        if plaintext.len() > (u32::MAX as usize - TAG_LEN) {
125            return Err(Error::MessageTooLarge {
126                len: plaintext.len(),
127                max: u32::MAX as usize - TAG_LEN,
128            });
129        }
130
131        let nonce = chunk_nonce(&self.nonce_prefix, self.index, last);
132        let aad = chunk_aad(&self.header, self.index, last);
133        let ct = self
134            .cipher
135            .encrypt(
136                (&nonce).into(),
137                Payload {
138                    msg: plaintext,
139                    aad: &aad,
140                },
141            )
142            .map_err(|_| Error::MessageTooLarge {
143                len: plaintext.len(),
144                max: u32::MAX as usize - TAG_LEN,
145            })?;
146
147        // Frame: last(1) || u32_be ct_len || ct.
148        let mut frame = Vec::with_capacity(5 + ct.len());
149        frame.push(last as u8);
150        frame.extend_from_slice(&(ct.len() as u32).to_be_bytes());
151        frame.extend_from_slice(&ct);
152
153        if last {
154            self.finished = true;
155        } else {
156            // Safe: guarded above that index < u32::MAX for non-final chunks.
157            self.index += 1;
158        }
159        Ok(frame)
160    }
161}
162
163/// Decrypts a stream produced by [`StreamSealer`], one chunk at a time.
164///
165/// **You must call [`finish`](StreamOpener::finish) after the last chunk.**
166/// Per-chunk authentication catches reordering, duplication, and corruption,
167/// but *truncation* — an attacker dropping the trailing chunks, including the
168/// final one — is only detected by `finish`, which fails with
169/// [`Error::StreamTruncated`] if it never saw a chunk marked `last`. A consumer
170/// that just loops `open_chunk` until its input is exhausted and skips `finish`
171/// will silently accept a truncated stream.
172pub struct StreamOpener {
173    cipher: Aes256Gcm,
174    nonce_prefix: [u8; STREAM_NONCE_PREFIX_LEN],
175    header: Vec<u8>,
176    index: u32,
177    finished: bool,
178}
179
180impl StreamOpener {
181    /// Begin decrypting from the stream header bytes.
182    ///
183    /// # Errors
184    ///
185    /// [`Error::InvalidEnvelope`] if the header is malformed;
186    /// version/suite errors for other formats.
187    pub fn new(keypair: &KeyPair, header_bytes: &[u8]) -> Result<Self> {
188        let mut rest = read_header(header_bytes, MAGIC_STREAM, Error::InvalidEnvelope)?;
189        let epk_x25519: [u8; X25519_PK_LEN] = take(&mut rest, Error::InvalidEnvelope)?;
190        let ct_mlkem: [u8; MLKEM1024_CT_LEN] = take(&mut rest, Error::InvalidEnvelope)?;
191        let nonce_prefix: [u8; STREAM_NONCE_PREFIX_LEN] = take(&mut rest, Error::InvalidEnvelope)?;
192        if !rest.is_empty() {
193            return Err(Error::InvalidEnvelope);
194        }
195
196        let kem_ct = KemCiphertext {
197            epk_x25519,
198            ct_mlkem: alloc::boxed::Box::new(ct_mlkem),
199        };
200        let ss: Zeroizing<[u8; 32]> = hybrid_kem::decapsulate(keypair, &kem_ct);
201        let cipher = Aes256Gcm::new((&*ss).into());
202
203        Ok(Self {
204            cipher,
205            nonce_prefix,
206            header: header_bytes.to_vec(),
207            index: 0,
208            finished: false,
209        })
210    }
211
212    /// Decrypt one chunk frame. Returns `(plaintext, was_last)`.
213    ///
214    /// # Errors
215    ///
216    /// [`Error::DecryptionFailed`] on any authentication failure (including a
217    /// reordered, duplicated, or spliced chunk); [`Error::StreamFinished`] if
218    /// called after the last chunk.
219    pub fn open_chunk(&mut self, frame: &[u8]) -> Result<(Vec<u8>, bool)> {
220        if self.finished {
221            return Err(Error::StreamFinished);
222        }
223        if frame.len() < 5 {
224            return Err(Error::DecryptionFailed);
225        }
226        let last = match frame[0] {
227            0 => false,
228            1 => true,
229            _ => return Err(Error::DecryptionFailed),
230        };
231        let ct_len = u32::from_be_bytes([frame[1], frame[2], frame[3], frame[4]]) as usize;
232        let ct = &frame[5..];
233        if ct.len() != ct_len || ct_len < TAG_LEN {
234            return Err(Error::DecryptionFailed);
235        }
236
237        let nonce = chunk_nonce(&self.nonce_prefix, self.index, last);
238        let aad = chunk_aad(&self.header, self.index, last);
239        let plaintext = self
240            .cipher
241            .decrypt((&nonce).into(), Payload { msg: ct, aad: &aad })
242            .map_err(|_| Error::DecryptionFailed)?;
243
244        if last {
245            self.finished = true;
246        } else {
247            self.index = self.index.checked_add(1).ok_or(Error::DecryptionFailed)?;
248        }
249        Ok((plaintext, last))
250    }
251
252    /// Confirm the stream ended with a final chunk.
253    ///
254    /// # Errors
255    ///
256    /// [`Error::StreamTruncated`] if no `last` chunk was ever seen.
257    pub fn finish(self) -> Result<()> {
258        if self.finished {
259            Ok(())
260        } else {
261            Err(Error::StreamTruncated)
262        }
263    }
264}