Skip to main content

basil/stream/
format.rs

1// SPDX-FileCopyrightText: 2026 OpenBasil Contributors
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! On-the-wire container format and AEAD primitives for streaming encryption.
6//!
7//! This module owns the byte layout of the Basil streaming container (magic,
8//! header, per-chunk AEAD records), the per-stream key derivation, and the
9//! per-chunk nonce derivation. The exact format is specified in
10//! `docs/specs/streaming-encryption-format.md`; the Go client re-implements the
11//! same bytes for cross-language interop, so every constant and byte ordering
12//! here is load-bearing.
13
14use aes_gcm::Aes256Gcm;
15use aes_gcm::aead::{Aead, KeyInit, Payload};
16use chacha20poly1305::ChaCha20Poly1305;
17use hkdf::Hkdf;
18use sha2::Sha256;
19use zeroize::Zeroizing;
20
21/// Container magic: ASCII `"BSLSTR"` (Basil stream).
22pub const MAGIC: [u8; 6] = *b"BSLSTR";
23/// Container format version. Bump on any breaking byte-layout change.
24pub const FORMAT_VERSION: u8 = 1;
25/// Length of the fixed (suite-independent) header prefix in bytes.
26pub const FIXED_HEADER_LEN: usize = 61;
27
28/// Domain-separation tag prefixed to every per-chunk AEAD AAD.
29const CHUNK_AAD_MAGIC: [u8; 4] = *b"BSLA";
30/// Domain-separation tag prefixed to the KEM-wrapped-CEK AAD.
31const CEKWRAP_AAD_MAGIC: [u8; 4] = *b"BSLK";
32
33/// HKDF info label binding the per-stream message key derivation.
34const STREAM_CEK_LABEL: &[u8] = b"basil-stream-cek-v1";
35
36/// AEAD authentication-tag length (AES-256-GCM and ChaCha20-Poly1305 both 16).
37pub const TAG_LEN: usize = 16;
38/// AEAD nonce length (both suites use a 96-bit nonce).
39pub const NONCE_LEN: usize = 12;
40/// Content-encryption-key length (256-bit).
41pub const CEK_LEN: usize = 32;
42/// Per-stream random stream identifier length.
43pub const STREAM_ID_LEN: usize = 16;
44/// Per-stream random HKDF salt length.
45pub const STREAM_SALT_LEN: usize = 32;
46
47/// Default plaintext chunk size: 64 KiB.
48pub const DEFAULT_CHUNK_SIZE: usize = 64 * 1024;
49/// Maximum permitted plaintext chunk size: 1 MiB. Bounds per-record buffering on
50/// decrypt so a malicious length prefix cannot trigger unbounded allocation.
51pub const MAX_CHUNK_SIZE: usize = 1024 * 1024;
52
53/// Suite id for the symmetric AES-256-GCM stream.
54pub const SUITE_AES256GCM: u8 = 1;
55/// Suite id for the symmetric ChaCha20-Poly1305 stream.
56pub const SUITE_CHACHA20POLY1305: u8 = 2;
57/// Suite id for the ML-KEM-512 + AES-256-GCM stream.
58pub const SUITE_MLKEM512: u8 = 3;
59/// Suite id for the ML-KEM-768 + AES-256-GCM stream.
60pub const SUITE_MLKEM768: u8 = 4;
61/// Suite id for the ML-KEM-1024 + AES-256-GCM stream.
62pub const SUITE_MLKEM1024: u8 = 5;
63
64/// Errors produced by the streaming encrypt/decrypt API.
65///
66/// All authentication failures collapse to [`StreamError::AuthFailed`] so the
67/// caller learns nothing beyond "this stream did not verify".
68#[derive(Debug, thiserror::Error)]
69#[non_exhaustive]
70pub enum StreamError {
71    /// Underlying reader or writer I/O failed.
72    #[error("stream io error: {0}")]
73    Io(#[from] std::io::Error),
74
75    /// The container magic bytes did not match.
76    #[error("bad stream magic")]
77    BadMagic,
78
79    /// The container declared an unsupported format version.
80    #[error("unsupported stream format version: {0}")]
81    UnsupportedVersion(u8),
82
83    /// The container declared an unknown algorithm suite.
84    #[error("unsupported stream suite id: {0}")]
85    UnsupportedSuite(u8),
86
87    /// The reserved header flags byte was non-zero.
88    #[error("reserved header flags must be zero")]
89    ReservedFlags,
90
91    /// The header was shorter than the format requires.
92    #[error("truncated or malformed stream header")]
93    ShortHeader,
94
95    /// The requested or declared chunk size was zero or above [`MAX_CHUNK_SIZE`].
96    #[error("invalid chunk size: {0} (must be 1..={max})", max = MAX_CHUNK_SIZE)]
97    BadChunkSize(usize),
98
99    /// A record's length prefix implied a plaintext chunk above the limit.
100    #[error("chunk record too large")]
101    ChunkTooLarge,
102
103    /// The decrypt entry point was called for a different suite family than the
104    /// container actually uses (e.g. AEAD decrypt on an ML-KEM stream).
105    #[error("stream suite mismatch: container suite id {actual} not valid for this operation")]
106    SuiteMismatch {
107        /// The suite id found in the container header.
108        actual: u8,
109    },
110
111    /// A supplied ML-KEM public encapsulation key was malformed.
112    #[error("invalid ML-KEM public key")]
113    BadPublicKey,
114
115    /// A supplied content-encryption key was not [`CEK_LEN`] bytes.
116    #[error("invalid content-encryption key length")]
117    BadCekLength,
118
119    /// The ML-KEM ciphertext / encapsulated key was malformed.
120    #[error("invalid ML-KEM ciphertext")]
121    BadKemCiphertext,
122
123    /// The stream ended before a final-marked chunk was authenticated.
124    #[error("stream truncated: missing final chunk")]
125    Truncated,
126
127    /// AEAD authentication failed: wrong key, tampered data/AAD, reordered or
128    /// truncated chunk, or a downgraded header.
129    #[error("stream authentication failed")]
130    AuthFailed,
131
132    /// HKDF key derivation failed.
133    #[error("key derivation failed")]
134    KdfFailed,
135
136    /// AEAD sealing failed.
137    #[error("seal failed")]
138    SealFailed,
139
140    /// Recovering the content-encryption key from the broker failed.
141    #[error("kem cek recovery failed: {0}")]
142    CekRecovery(#[from] crate::error::Error),
143}
144
145/// Result alias for streaming operations.
146pub type StreamResult<T> = std::result::Result<T, StreamError>;
147
148/// The two AEAD suites used to seal individual chunks.
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub enum ChunkAead {
151    /// AES-256-GCM.
152    Aes256Gcm,
153    /// ChaCha20-Poly1305.
154    ChaCha20Poly1305,
155}
156
157/// Parsed fixed header fields shared by every suite.
158#[derive(Debug, Clone)]
159pub struct StreamHeader {
160    /// Algorithm suite id.
161    pub suite_id: u8,
162    /// Declared plaintext chunk size.
163    pub chunk_size: u32,
164    /// Per-stream random identifier.
165    pub stream_id: [u8; STREAM_ID_LEN],
166    /// Per-stream HKDF salt.
167    pub stream_salt: [u8; STREAM_SALT_LEN],
168}
169
170/// Resolve the chunk AEAD used by a suite id, if the suite is known.
171pub 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
181/// Serialize the fixed header prefix into `out`.
182pub 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); // reserved flags
193    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
198/// Parse and validate the fixed header prefix from a [`FIXED_HEADER_LEN`] buffer.
199///
200/// # Errors
201///
202/// Returns [`StreamError::ShortHeader`] for a short buffer,
203/// [`StreamError::BadMagic`]/[`StreamError::UnsupportedVersion`]/
204/// [`StreamError::ReservedFlags`]/[`StreamError::UnsupportedSuite`] for a
205/// malformed or downgraded header, and [`StreamError::BadChunkSize`] for an
206/// out-of-range chunk size.
207pub 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
250/// Build the per-chunk AEAD additional-authenticated-data.
251///
252/// Layout (39 bytes, big-endian integers):
253/// `"BSLA" | version | suite_id | stream_id[16] | chunk_index[8] | final_flag |
254/// chunk_plaintext_len[4] | chunk_size[4]`.
255pub 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
275/// Build the AAD that binds a KEM-wrapped CEK to its stream.
276///
277/// Layout (22 bytes): `"BSLK" | version | suite_id | stream_id[16]`.
278pub 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
287/// Per-chunk nonce: 4 zero bytes followed by the 64-bit big-endian chunk index.
288///
289/// The per-stream message key is unique per stream (derived from a fresh random
290/// salt), so a counter nonce is sufficient for `(key, nonce)` uniqueness.
291pub 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    // nonce[4..12] = index_bytes; written without indexing for the no-panic gate.
295    let (_zero_prefix, counter) = nonce.split_at_mut(4);
296    counter.copy_from_slice(&index_bytes);
297    nonce
298}
299
300/// Derive the per-stream message key `K_msg = HKDF-SHA256(salt=stream_salt,
301/// ikm=cek, info="basil-stream-cek-v1" | suite_id | stream_id)`.
302///
303/// # Errors
304///
305/// Returns [`StreamError::KdfFailed`] if HKDF expansion fails.
306pub 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
324/// Seal one chunk under the per-stream message key.
325///
326/// # Errors
327///
328/// Returns [`StreamError::SealFailed`] if AEAD sealing fails.
329pub 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
365/// Open one chunk under the per-stream message key.
366///
367/// # Errors
368///
369/// Returns [`StreamError::AuthFailed`] for any authentication failure (wrong
370/// key, tampered ciphertext/AAD, reorder, truncation, or downgrade).
371pub 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}