1use aes_gcm::Aes256Gcm;
15use aes_gcm::aead::{Aead, KeyInit, Payload};
16use chacha20poly1305::ChaCha20Poly1305;
17use hkdf::Hkdf;
18use sha2::Sha256;
19use zeroize::Zeroizing;
20
21pub const MAGIC: [u8; 6] = *b"BSLSTR";
23pub const FORMAT_VERSION: u8 = 1;
25pub const FIXED_HEADER_LEN: usize = 61;
27
28const CHUNK_AAD_MAGIC: [u8; 4] = *b"BSLA";
30const CEKWRAP_AAD_MAGIC: [u8; 4] = *b"BSLK";
32
33const STREAM_CEK_LABEL: &[u8] = b"basil-stream-cek-v1";
35
36pub const TAG_LEN: usize = 16;
38pub const NONCE_LEN: usize = 12;
40pub const CEK_LEN: usize = 32;
42pub const STREAM_ID_LEN: usize = 16;
44pub const STREAM_SALT_LEN: usize = 32;
46
47pub const DEFAULT_CHUNK_SIZE: usize = 64 * 1024;
49pub const MAX_CHUNK_SIZE: usize = 1024 * 1024;
52
53pub const SUITE_AES256GCM: u8 = 1;
55pub const SUITE_CHACHA20POLY1305: u8 = 2;
57pub const SUITE_MLKEM512: u8 = 3;
59pub const SUITE_MLKEM768: u8 = 4;
61pub const SUITE_MLKEM1024: u8 = 5;
63
64#[derive(Debug, thiserror::Error)]
69#[non_exhaustive]
70pub enum StreamError {
71 #[error("stream io error: {0}")]
73 Io(#[from] std::io::Error),
74
75 #[error("bad stream magic")]
77 BadMagic,
78
79 #[error("unsupported stream format version: {0}")]
81 UnsupportedVersion(u8),
82
83 #[error("unsupported stream suite id: {0}")]
85 UnsupportedSuite(u8),
86
87 #[error("reserved header flags must be zero")]
89 ReservedFlags,
90
91 #[error("truncated or malformed stream header")]
93 ShortHeader,
94
95 #[error("invalid chunk size: {0} (must be 1..={max})", max = MAX_CHUNK_SIZE)]
97 BadChunkSize(usize),
98
99 #[error("chunk record too large")]
101 ChunkTooLarge,
102
103 #[error("stream suite mismatch: container suite id {actual} not valid for this operation")]
106 SuiteMismatch {
107 actual: u8,
109 },
110
111 #[error("invalid ML-KEM public key")]
113 BadPublicKey,
114
115 #[error("invalid content-encryption key length")]
117 BadCekLength,
118
119 #[error("invalid ML-KEM ciphertext")]
121 BadKemCiphertext,
122
123 #[error("stream truncated: missing final chunk")]
125 Truncated,
126
127 #[error("stream authentication failed")]
130 AuthFailed,
131
132 #[error("key derivation failed")]
134 KdfFailed,
135
136 #[error("seal failed")]
138 SealFailed,
139
140 #[error("kem cek recovery failed: {0}")]
142 CekRecovery(#[from] crate::error::Error),
143}
144
145pub type StreamResult<T> = std::result::Result<T, StreamError>;
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub enum ChunkAead {
151 Aes256Gcm,
153 ChaCha20Poly1305,
155}
156
157#[derive(Debug, Clone)]
159pub struct StreamHeader {
160 pub suite_id: u8,
162 pub chunk_size: u32,
164 pub stream_id: [u8; STREAM_ID_LEN],
166 pub stream_salt: [u8; STREAM_SALT_LEN],
168}
169
170pub const fn chunk_aead_for_suite(suite_id: u8) -> Option<ChunkAead> {
172 match suite_id {
173 SUITE_AES256GCM | SUITE_MLKEM512 | SUITE_MLKEM768 | SUITE_MLKEM1024 => {
174 Some(ChunkAead::Aes256Gcm)
175 }
176 SUITE_CHACHA20POLY1305 => Some(ChunkAead::ChaCha20Poly1305),
177 _ => None,
178 }
179}
180
181pub fn write_fixed_header(
183 out: &mut Vec<u8>,
184 suite_id: u8,
185 chunk_size: u32,
186 stream_id: &[u8; STREAM_ID_LEN],
187 stream_salt: &[u8; STREAM_SALT_LEN],
188) {
189 out.extend_from_slice(&MAGIC);
190 out.push(FORMAT_VERSION);
191 out.push(suite_id);
192 out.push(0); out.extend_from_slice(&chunk_size.to_be_bytes());
194 out.extend_from_slice(stream_id);
195 out.extend_from_slice(stream_salt);
196}
197
198pub fn parse_fixed_header(buf: &[u8]) -> StreamResult<StreamHeader> {
208 if buf.get(0..6).ok_or(StreamError::ShortHeader)? != MAGIC {
209 return Err(StreamError::BadMagic);
210 }
211 let version = *buf.get(6).ok_or(StreamError::ShortHeader)?;
212 if version != FORMAT_VERSION {
213 return Err(StreamError::UnsupportedVersion(version));
214 }
215 let suite_id = *buf.get(7).ok_or(StreamError::ShortHeader)?;
216 if *buf.get(8).ok_or(StreamError::ShortHeader)? != 0 {
217 return Err(StreamError::ReservedFlags);
218 }
219 if chunk_aead_for_suite(suite_id).is_none() {
220 return Err(StreamError::UnsupportedSuite(suite_id));
221 }
222 let chunk_size_bytes: [u8; 4] = buf
223 .get(9..13)
224 .ok_or(StreamError::ShortHeader)?
225 .try_into()
226 .map_err(|_| StreamError::ShortHeader)?;
227 let chunk_size = u32::from_be_bytes(chunk_size_bytes);
228 let in_range = chunk_size >= 1 && (chunk_size as usize) <= MAX_CHUNK_SIZE;
229 if !in_range {
230 return Err(StreamError::BadChunkSize(chunk_size as usize));
231 }
232 let stream_id: [u8; STREAM_ID_LEN] = buf
233 .get(13..29)
234 .ok_or(StreamError::ShortHeader)?
235 .try_into()
236 .map_err(|_| StreamError::ShortHeader)?;
237 let stream_salt: [u8; STREAM_SALT_LEN] = buf
238 .get(29..61)
239 .ok_or(StreamError::ShortHeader)?
240 .try_into()
241 .map_err(|_| StreamError::ShortHeader)?;
242 Ok(StreamHeader {
243 suite_id,
244 chunk_size,
245 stream_id,
246 stream_salt,
247 })
248}
249
250pub fn build_chunk_aad(
256 suite_id: u8,
257 stream_id: &[u8; STREAM_ID_LEN],
258 chunk_index: u64,
259 is_final: bool,
260 chunk_plaintext_len: u32,
261 chunk_size: u32,
262) -> Vec<u8> {
263 let mut aad = Vec::with_capacity(39);
264 aad.extend_from_slice(&CHUNK_AAD_MAGIC);
265 aad.push(FORMAT_VERSION);
266 aad.push(suite_id);
267 aad.extend_from_slice(stream_id);
268 aad.extend_from_slice(&chunk_index.to_be_bytes());
269 aad.push(u8::from(is_final));
270 aad.extend_from_slice(&chunk_plaintext_len.to_be_bytes());
271 aad.extend_from_slice(&chunk_size.to_be_bytes());
272 aad
273}
274
275pub fn build_cekwrap_aad(suite_id: u8, stream_id: &[u8; STREAM_ID_LEN]) -> Vec<u8> {
279 let mut aad = Vec::with_capacity(22);
280 aad.extend_from_slice(&CEKWRAP_AAD_MAGIC);
281 aad.push(FORMAT_VERSION);
282 aad.push(suite_id);
283 aad.extend_from_slice(stream_id);
284 aad
285}
286
287pub fn chunk_nonce(chunk_index: u64) -> [u8; NONCE_LEN] {
292 let mut nonce = [0u8; NONCE_LEN];
293 let index_bytes = chunk_index.to_be_bytes();
294 let (_zero_prefix, counter) = nonce.split_at_mut(4);
296 counter.copy_from_slice(&index_bytes);
297 nonce
298}
299
300pub fn derive_message_key(
307 stream_salt: &[u8; STREAM_SALT_LEN],
308 cek: &[u8],
309 suite_id: u8,
310 stream_id: &[u8; STREAM_ID_LEN],
311) -> StreamResult<Zeroizing<[u8; CEK_LEN]>> {
312 let mut info = Vec::with_capacity(STREAM_CEK_LABEL.len() + 1 + STREAM_ID_LEN);
313 info.extend_from_slice(STREAM_CEK_LABEL);
314 info.push(suite_id);
315 info.extend_from_slice(stream_id);
316
317 let hk = Hkdf::<Sha256>::new(Some(stream_salt.as_slice()), cek);
318 let mut okm = Zeroizing::new([0u8; CEK_LEN]);
319 hk.expand(&info, okm.as_mut_slice())
320 .map_err(|_| StreamError::KdfFailed)?;
321 Ok(okm)
322}
323
324pub fn aead_seal(
330 alg: ChunkAead,
331 key: &[u8; CEK_LEN],
332 nonce: &[u8; NONCE_LEN],
333 plaintext: &[u8],
334 aad: &[u8],
335) -> StreamResult<Vec<u8>> {
336 match alg {
337 ChunkAead::Aes256Gcm => {
338 let cipher = Aes256Gcm::new_from_slice(key).map_err(|_| StreamError::SealFailed)?;
339 cipher
340 .encrypt(
341 &aes_gcm::Nonce::from(*nonce),
342 Payload {
343 msg: plaintext,
344 aad,
345 },
346 )
347 .map_err(|_| StreamError::SealFailed)
348 }
349 ChunkAead::ChaCha20Poly1305 => {
350 let cipher =
351 ChaCha20Poly1305::new_from_slice(key).map_err(|_| StreamError::SealFailed)?;
352 cipher
353 .encrypt(
354 &chacha20poly1305::Nonce::from(*nonce),
355 Payload {
356 msg: plaintext,
357 aad,
358 },
359 )
360 .map_err(|_| StreamError::SealFailed)
361 }
362 }
363}
364
365pub fn aead_open(
372 alg: ChunkAead,
373 key: &[u8; CEK_LEN],
374 nonce: &[u8; NONCE_LEN],
375 ciphertext: &[u8],
376 aad: &[u8],
377) -> StreamResult<Zeroizing<Vec<u8>>> {
378 let plaintext = match alg {
379 ChunkAead::Aes256Gcm => {
380 let cipher = Aes256Gcm::new_from_slice(key).map_err(|_| StreamError::AuthFailed)?;
381 cipher.decrypt(
382 &aes_gcm::Nonce::from(*nonce),
383 Payload {
384 msg: ciphertext,
385 aad,
386 },
387 )
388 }
389 ChunkAead::ChaCha20Poly1305 => {
390 let cipher =
391 ChaCha20Poly1305::new_from_slice(key).map_err(|_| StreamError::AuthFailed)?;
392 cipher.decrypt(
393 &chacha20poly1305::Nonce::from(*nonce),
394 Payload {
395 msg: ciphertext,
396 aad,
397 },
398 )
399 }
400 }
401 .map_err(|_| StreamError::AuthFailed)?;
402 Ok(Zeroizing::new(plaintext))
403}