use aes_gcm::Aes256Gcm;
use aes_gcm::aead::{Aead, KeyInit, Payload};
use chacha20poly1305::ChaCha20Poly1305;
use hkdf::Hkdf;
use sha2::Sha256;
use zeroize::Zeroizing;
pub const MAGIC: [u8; 6] = *b"BSLSTR";
pub const FORMAT_VERSION: u8 = 1;
pub const FIXED_HEADER_LEN: usize = 61;
const CHUNK_AAD_MAGIC: [u8; 4] = *b"BSLA";
const CEKWRAP_AAD_MAGIC: [u8; 4] = *b"BSLK";
const STREAM_CEK_LABEL: &[u8] = b"basil-stream-cek-v1";
pub const TAG_LEN: usize = 16;
pub const NONCE_LEN: usize = 12;
pub const CEK_LEN: usize = 32;
pub const STREAM_ID_LEN: usize = 16;
pub const STREAM_SALT_LEN: usize = 32;
pub const DEFAULT_CHUNK_SIZE: usize = 64 * 1024;
pub const MAX_CHUNK_SIZE: usize = 1024 * 1024;
pub const SUITE_AES256GCM: u8 = 1;
pub const SUITE_CHACHA20POLY1305: u8 = 2;
pub const SUITE_MLKEM512: u8 = 3;
pub const SUITE_MLKEM768: u8 = 4;
pub const SUITE_MLKEM1024: u8 = 5;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum StreamError {
#[error("stream io error: {0}")]
Io(#[from] std::io::Error),
#[error("bad stream magic")]
BadMagic,
#[error("unsupported stream format version: {0}")]
UnsupportedVersion(u8),
#[error("unsupported stream suite id: {0}")]
UnsupportedSuite(u8),
#[error("reserved header flags must be zero")]
ReservedFlags,
#[error("truncated or malformed stream header")]
ShortHeader,
#[error("invalid chunk size: {0} (must be 1..={max})", max = MAX_CHUNK_SIZE)]
BadChunkSize(usize),
#[error("chunk record too large")]
ChunkTooLarge,
#[error("stream suite mismatch: container suite id {actual} not valid for this operation")]
SuiteMismatch {
actual: u8,
},
#[error("invalid ML-KEM public key")]
BadPublicKey,
#[error("invalid content-encryption key length")]
BadCekLength,
#[error("invalid ML-KEM ciphertext")]
BadKemCiphertext,
#[error("stream truncated: missing final chunk")]
Truncated,
#[error("stream authentication failed")]
AuthFailed,
#[error("key derivation failed")]
KdfFailed,
#[error("seal failed")]
SealFailed,
#[error("kem cek recovery failed: {0}")]
CekRecovery(#[from] crate::error::Error),
}
pub type StreamResult<T> = std::result::Result<T, StreamError>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChunkAead {
Aes256Gcm,
ChaCha20Poly1305,
}
#[derive(Debug, Clone)]
pub struct StreamHeader {
pub suite_id: u8,
pub chunk_size: u32,
pub stream_id: [u8; STREAM_ID_LEN],
pub stream_salt: [u8; STREAM_SALT_LEN],
}
pub const fn chunk_aead_for_suite(suite_id: u8) -> Option<ChunkAead> {
match suite_id {
SUITE_AES256GCM | SUITE_MLKEM512 | SUITE_MLKEM768 | SUITE_MLKEM1024 => {
Some(ChunkAead::Aes256Gcm)
}
SUITE_CHACHA20POLY1305 => Some(ChunkAead::ChaCha20Poly1305),
_ => None,
}
}
pub fn write_fixed_header(
out: &mut Vec<u8>,
suite_id: u8,
chunk_size: u32,
stream_id: &[u8; STREAM_ID_LEN],
stream_salt: &[u8; STREAM_SALT_LEN],
) {
out.extend_from_slice(&MAGIC);
out.push(FORMAT_VERSION);
out.push(suite_id);
out.push(0); out.extend_from_slice(&chunk_size.to_be_bytes());
out.extend_from_slice(stream_id);
out.extend_from_slice(stream_salt);
}
pub fn parse_fixed_header(buf: &[u8]) -> StreamResult<StreamHeader> {
if buf.get(0..6).ok_or(StreamError::ShortHeader)? != MAGIC {
return Err(StreamError::BadMagic);
}
let version = *buf.get(6).ok_or(StreamError::ShortHeader)?;
if version != FORMAT_VERSION {
return Err(StreamError::UnsupportedVersion(version));
}
let suite_id = *buf.get(7).ok_or(StreamError::ShortHeader)?;
if *buf.get(8).ok_or(StreamError::ShortHeader)? != 0 {
return Err(StreamError::ReservedFlags);
}
if chunk_aead_for_suite(suite_id).is_none() {
return Err(StreamError::UnsupportedSuite(suite_id));
}
let chunk_size_bytes: [u8; 4] = buf
.get(9..13)
.ok_or(StreamError::ShortHeader)?
.try_into()
.map_err(|_| StreamError::ShortHeader)?;
let chunk_size = u32::from_be_bytes(chunk_size_bytes);
let in_range = chunk_size >= 1 && (chunk_size as usize) <= MAX_CHUNK_SIZE;
if !in_range {
return Err(StreamError::BadChunkSize(chunk_size as usize));
}
let stream_id: [u8; STREAM_ID_LEN] = buf
.get(13..29)
.ok_or(StreamError::ShortHeader)?
.try_into()
.map_err(|_| StreamError::ShortHeader)?;
let stream_salt: [u8; STREAM_SALT_LEN] = buf
.get(29..61)
.ok_or(StreamError::ShortHeader)?
.try_into()
.map_err(|_| StreamError::ShortHeader)?;
Ok(StreamHeader {
suite_id,
chunk_size,
stream_id,
stream_salt,
})
}
pub fn build_chunk_aad(
suite_id: u8,
stream_id: &[u8; STREAM_ID_LEN],
chunk_index: u64,
is_final: bool,
chunk_plaintext_len: u32,
chunk_size: u32,
) -> Vec<u8> {
let mut aad = Vec::with_capacity(39);
aad.extend_from_slice(&CHUNK_AAD_MAGIC);
aad.push(FORMAT_VERSION);
aad.push(suite_id);
aad.extend_from_slice(stream_id);
aad.extend_from_slice(&chunk_index.to_be_bytes());
aad.push(u8::from(is_final));
aad.extend_from_slice(&chunk_plaintext_len.to_be_bytes());
aad.extend_from_slice(&chunk_size.to_be_bytes());
aad
}
pub fn build_cekwrap_aad(suite_id: u8, stream_id: &[u8; STREAM_ID_LEN]) -> Vec<u8> {
let mut aad = Vec::with_capacity(22);
aad.extend_from_slice(&CEKWRAP_AAD_MAGIC);
aad.push(FORMAT_VERSION);
aad.push(suite_id);
aad.extend_from_slice(stream_id);
aad
}
pub fn chunk_nonce(chunk_index: u64) -> [u8; NONCE_LEN] {
let mut nonce = [0u8; NONCE_LEN];
let index_bytes = chunk_index.to_be_bytes();
let (_zero_prefix, counter) = nonce.split_at_mut(4);
counter.copy_from_slice(&index_bytes);
nonce
}
pub fn derive_message_key(
stream_salt: &[u8; STREAM_SALT_LEN],
cek: &[u8],
suite_id: u8,
stream_id: &[u8; STREAM_ID_LEN],
) -> StreamResult<Zeroizing<[u8; CEK_LEN]>> {
let mut info = Vec::with_capacity(STREAM_CEK_LABEL.len() + 1 + STREAM_ID_LEN);
info.extend_from_slice(STREAM_CEK_LABEL);
info.push(suite_id);
info.extend_from_slice(stream_id);
let hk = Hkdf::<Sha256>::new(Some(stream_salt.as_slice()), cek);
let mut okm = Zeroizing::new([0u8; CEK_LEN]);
hk.expand(&info, okm.as_mut_slice())
.map_err(|_| StreamError::KdfFailed)?;
Ok(okm)
}
pub fn aead_seal(
alg: ChunkAead,
key: &[u8; CEK_LEN],
nonce: &[u8; NONCE_LEN],
plaintext: &[u8],
aad: &[u8],
) -> StreamResult<Vec<u8>> {
match alg {
ChunkAead::Aes256Gcm => {
let cipher = Aes256Gcm::new_from_slice(key).map_err(|_| StreamError::SealFailed)?;
cipher
.encrypt(
&aes_gcm::Nonce::from(*nonce),
Payload {
msg: plaintext,
aad,
},
)
.map_err(|_| StreamError::SealFailed)
}
ChunkAead::ChaCha20Poly1305 => {
let cipher =
ChaCha20Poly1305::new_from_slice(key).map_err(|_| StreamError::SealFailed)?;
cipher
.encrypt(
&chacha20poly1305::Nonce::from(*nonce),
Payload {
msg: plaintext,
aad,
},
)
.map_err(|_| StreamError::SealFailed)
}
}
}
pub fn aead_open(
alg: ChunkAead,
key: &[u8; CEK_LEN],
nonce: &[u8; NONCE_LEN],
ciphertext: &[u8],
aad: &[u8],
) -> StreamResult<Zeroizing<Vec<u8>>> {
let plaintext = match alg {
ChunkAead::Aes256Gcm => {
let cipher = Aes256Gcm::new_from_slice(key).map_err(|_| StreamError::AuthFailed)?;
cipher.decrypt(
&aes_gcm::Nonce::from(*nonce),
Payload {
msg: ciphertext,
aad,
},
)
}
ChunkAead::ChaCha20Poly1305 => {
let cipher =
ChaCha20Poly1305::new_from_slice(key).map_err(|_| StreamError::AuthFailed)?;
cipher.decrypt(
&chacha20poly1305::Nonce::from(*nonce),
Payload {
msg: ciphertext,
aad,
},
)
}
}
.map_err(|_| StreamError::AuthFailed)?;
Ok(Zeroizing::new(plaintext))
}