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}