mod format;
mod kem;
use rand::RngCore;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use zeroize::Zeroizing;
use basil_proto::broker::v1 as pb;
pub use format::{DEFAULT_CHUNK_SIZE, MAX_CHUNK_SIZE, StreamError, StreamResult};
pub use kem::{BrokerCekRecovery, CekRecovery, LocalSeedCekRecovery, StreamKemEnvelope};
use format::{
CEK_LEN, ChunkAead, FIXED_HEADER_LEN, NONCE_LEN, STREAM_ID_LEN, STREAM_SALT_LEN,
SUITE_AES256GCM, SUITE_CHACHA20POLY1305, SUITE_MLKEM512, SUITE_MLKEM768, SUITE_MLKEM1024,
StreamHeader, TAG_LEN, aead_open, aead_seal, build_cekwrap_aad, build_chunk_aad, chunk_nonce,
derive_message_key, parse_fixed_header, write_fixed_header,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum AeadSuite {
Aes256Gcm,
ChaCha20Poly1305,
}
impl AeadSuite {
pub(crate) const fn suite_id(self) -> u8 {
match self {
Self::Aes256Gcm => SUITE_AES256GCM,
Self::ChaCha20Poly1305 => SUITE_CHACHA20POLY1305,
}
}
pub(crate) const fn chunk_aead(self) -> ChunkAead {
match self {
Self::Aes256Gcm => ChunkAead::Aes256Gcm,
Self::ChaCha20Poly1305 => ChunkAead::ChaCha20Poly1305,
}
}
pub(crate) const fn from_suite_id(suite_id: u8) -> Option<Self> {
match suite_id {
SUITE_AES256GCM => Some(Self::Aes256Gcm),
SUITE_CHACHA20POLY1305 => Some(Self::ChaCha20Poly1305),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum MlKemSuite {
MlKem512,
MlKem768,
MlKem1024,
}
impl MlKemSuite {
pub(crate) const fn suite_id(self) -> u8 {
match self {
Self::MlKem512 => SUITE_MLKEM512,
Self::MlKem768 => SUITE_MLKEM768,
Self::MlKem1024 => SUITE_MLKEM1024,
}
}
pub(crate) const fn kem_token(self) -> &'static str {
match self {
Self::MlKem512 => "ml-kem-512",
Self::MlKem768 => "ml-kem-768",
Self::MlKem1024 => "ml-kem-1024",
}
}
pub(crate) const fn ciphertext_len(self) -> usize {
match self {
Self::MlKem512 => 768,
Self::MlKem768 => 1088,
Self::MlKem1024 => 1568,
}
}
pub(crate) const fn proto_kem_algorithm(self) -> pb::KemAlgorithm {
match self {
Self::MlKem512 => pb::KemAlgorithm::MlKem512,
Self::MlKem768 => pb::KemAlgorithm::MlKem768,
Self::MlKem1024 => pb::KemAlgorithm::MlKem1024,
}
}
pub(crate) const fn from_suite_id(suite_id: u8) -> Option<Self> {
match suite_id {
SUITE_MLKEM512 => Some(Self::MlKem512),
SUITE_MLKEM768 => Some(Self::MlKem768),
SUITE_MLKEM1024 => Some(Self::MlKem1024),
_ => None,
}
}
}
#[non_exhaustive]
pub enum CekSource {
Generate,
Provided(Zeroizing<[u8; CEK_LEN]>),
}
pub(crate) fn fill_random(buf: &mut [u8]) {
let mut rng = rand::rngs::OsRng;
rng.fill_bytes(buf);
}
struct ChunkCrypto<'a> {
suite_id: u8,
chunk_aead: ChunkAead,
chunk_size: usize,
stream_id: &'a [u8; STREAM_ID_LEN],
message_key: &'a [u8; CEK_LEN],
}
pub async fn encrypt_aead<R, W>(
mut reader: R,
mut writer: W,
suite: AeadSuite,
cek: CekSource,
chunk_size: usize,
) -> StreamResult<Zeroizing<[u8; CEK_LEN]>>
where
R: AsyncRead + Unpin,
W: AsyncWrite + Unpin,
{
let chunk_size = validate_chunk_size(chunk_size)?;
let cek = match cek {
CekSource::Generate => {
let mut key = Zeroizing::new([0u8; CEK_LEN]);
fill_random(key.as_mut_slice());
key
}
CekSource::Provided(key) => key,
};
let mut stream_id = [0u8; STREAM_ID_LEN];
let mut stream_salt = [0u8; STREAM_SALT_LEN];
fill_random(&mut stream_id);
fill_random(&mut stream_salt);
let suite_id = suite.suite_id();
let chunk_size_u32 =
u32::try_from(chunk_size).map_err(|_| StreamError::BadChunkSize(chunk_size))?;
let mut header = Vec::with_capacity(FIXED_HEADER_LEN);
write_fixed_header(
&mut header,
suite_id,
chunk_size_u32,
&stream_id,
&stream_salt,
);
let message_key = derive_message_key(&stream_salt, cek.as_slice(), suite_id, &stream_id)?;
let crypto = ChunkCrypto {
suite_id,
chunk_aead: suite.chunk_aead(),
chunk_size,
stream_id: &stream_id,
message_key: &message_key,
};
write_chunks(&mut reader, &mut writer, header, &crypto).await?;
Ok(cek)
}
pub async fn decrypt_aead<R, W>(
mut reader: R,
mut writer: W,
cek: &[u8; CEK_LEN],
) -> StreamResult<()>
where
R: AsyncRead + Unpin,
W: AsyncWrite + Unpin,
{
let header = read_fixed_header(&mut reader).await?;
let suite = AeadSuite::from_suite_id(header.suite_id).ok_or(StreamError::SuiteMismatch {
actual: header.suite_id,
})?;
let message_key =
derive_message_key(&header.stream_salt, cek, header.suite_id, &header.stream_id)?;
let crypto = ChunkCrypto {
suite_id: header.suite_id,
chunk_aead: suite.chunk_aead(),
chunk_size: header.chunk_size as usize,
stream_id: &header.stream_id,
message_key: &message_key,
};
read_chunks(&mut reader, &mut writer, &crypto).await
}
pub async fn encrypt_ml_kem<R, W>(
mut reader: R,
mut writer: W,
suite: MlKemSuite,
public_key: &[u8],
chunk_size: usize,
) -> StreamResult<()>
where
R: AsyncRead + Unpin,
W: AsyncWrite + Unpin,
{
let chunk_size = validate_chunk_size(chunk_size)?;
let mut cek = Zeroizing::new([0u8; CEK_LEN]);
fill_random(cek.as_mut_slice());
let mut stream_id = [0u8; STREAM_ID_LEN];
let mut stream_salt = [0u8; STREAM_SALT_LEN];
fill_random(&mut stream_id);
fill_random(&mut stream_salt);
let suite_id = suite.suite_id();
let cekwrap_aad = build_cekwrap_aad(suite_id, &stream_id);
let envelope = kem::wrap_cek(public_key, suite, cek.as_slice(), &cekwrap_aad)?;
let chunk_size_u32 =
u32::try_from(chunk_size).map_err(|_| StreamError::BadChunkSize(chunk_size))?;
let mut header = Vec::new();
write_fixed_header(
&mut header,
suite_id,
chunk_size_u32,
&stream_id,
&stream_salt,
);
header.extend_from_slice(&kem::serialize_kem_header(&envelope)?);
let message_key = derive_message_key(&stream_salt, cek.as_slice(), suite_id, &stream_id)?;
let crypto = ChunkCrypto {
suite_id,
chunk_aead: ChunkAead::Aes256Gcm,
chunk_size,
stream_id: &stream_id,
message_key: &message_key,
};
write_chunks(&mut reader, &mut writer, header, &crypto).await
}
#[allow(clippy::future_not_send)]
pub async fn decrypt_ml_kem<R, W, C>(mut reader: R, mut writer: W, recovery: &C) -> StreamResult<()>
where
R: AsyncRead + Unpin,
W: AsyncWrite + Unpin,
C: CekRecovery,
{
let header = read_fixed_header(&mut reader).await?;
let suite = MlKemSuite::from_suite_id(header.suite_id).ok_or(StreamError::SuiteMismatch {
actual: header.suite_id,
})?;
let envelope = read_kem_envelope(&mut reader, suite).await?;
let cekwrap_aad = build_cekwrap_aad(header.suite_id, &header.stream_id);
let cek = recovery.recover_cek(&envelope, &cekwrap_aad).await?;
if cek.len() != CEK_LEN {
return Err(StreamError::BadCekLength);
}
let message_key = derive_message_key(
&header.stream_salt,
&cek,
header.suite_id,
&header.stream_id,
)?;
let crypto = ChunkCrypto {
suite_id: header.suite_id,
chunk_aead: ChunkAead::Aes256Gcm,
chunk_size: header.chunk_size as usize,
stream_id: &header.stream_id,
message_key: &message_key,
};
read_chunks(&mut reader, &mut writer, &crypto).await
}
const fn validate_chunk_size(chunk_size: usize) -> StreamResult<usize> {
if chunk_size == 0 || chunk_size > MAX_CHUNK_SIZE {
return Err(StreamError::BadChunkSize(chunk_size));
}
Ok(chunk_size)
}
async fn write_chunks<R, W>(
reader: &mut R,
writer: &mut W,
header: Vec<u8>,
crypto: &ChunkCrypto<'_>,
) -> StreamResult<()>
where
R: AsyncRead + Unpin,
W: AsyncWrite + Unpin,
{
writer.write_all(&header).await?;
let chunk_size_u32 = u32::try_from(crypto.chunk_size)
.map_err(|_| StreamError::BadChunkSize(crypto.chunk_size))?;
let mut current = vec![0u8; crypto.chunk_size];
let mut have = read_full(reader, &mut current).await?;
let mut index: u64 = 0;
loop {
let mut next = vec![0u8; crypto.chunk_size];
let next_have = read_full(reader, &mut next).await?;
let is_final = next_have == 0;
let plaintext_len = u32::try_from(have).map_err(|_| StreamError::SealFailed)?;
let aad = build_chunk_aad(
crypto.suite_id,
crypto.stream_id,
index,
is_final,
plaintext_len,
chunk_size_u32,
);
let nonce = chunk_nonce(index);
let plaintext = current.get(..have).ok_or(StreamError::SealFailed)?;
let record = aead_seal(
crypto.chunk_aead,
crypto.message_key,
&nonce,
plaintext,
&aad,
)?;
let record_len = u32::try_from(record.len()).map_err(|_| StreamError::SealFailed)?;
writer.write_all(&record_len.to_be_bytes()).await?;
writer.write_all(&record).await?;
index = index.checked_add(1).ok_or(StreamError::SealFailed)?;
if is_final {
break;
}
current = next;
have = next_have;
}
writer.flush().await?;
Ok(())
}
async fn read_chunks<R, W>(
reader: &mut R,
writer: &mut W,
crypto: &ChunkCrypto<'_>,
) -> StreamResult<()>
where
R: AsyncRead + Unpin,
W: AsyncWrite + Unpin,
{
let max_record = crypto
.chunk_size
.checked_add(TAG_LEN)
.ok_or(StreamError::ChunkTooLarge)?;
let chunk_size_u32 = u32::try_from(crypto.chunk_size)
.map_err(|_| StreamError::BadChunkSize(crypto.chunk_size))?;
let Some(first_len) = read_len_prefix(reader).await? else {
return Err(StreamError::Truncated);
};
let mut current = read_record(reader, first_len, max_record).await?;
let mut index: u64 = 0;
loop {
let next_len = read_len_prefix(reader).await?;
let is_final = next_len.is_none();
let plaintext_len = current
.len()
.checked_sub(TAG_LEN)
.ok_or(StreamError::AuthFailed)?;
let plaintext_len_u32 =
u32::try_from(plaintext_len).map_err(|_| StreamError::ChunkTooLarge)?;
let aad = build_chunk_aad(
crypto.suite_id,
crypto.stream_id,
index,
is_final,
plaintext_len_u32,
chunk_size_u32,
);
let nonce = chunk_nonce(index);
let plaintext = aead_open(
crypto.chunk_aead,
crypto.message_key,
&nonce,
¤t,
&aad,
)?;
if !is_final && plaintext_len != crypto.chunk_size {
return Err(StreamError::AuthFailed);
}
writer.write_all(plaintext.as_slice()).await?;
if is_final {
break;
}
index = index.checked_add(1).ok_or(StreamError::Truncated)?;
let next_len = next_len.ok_or(StreamError::Truncated)?;
current = read_record(reader, next_len, max_record).await?;
}
writer.flush().await?;
Ok(())
}
async fn read_fixed_header<R>(reader: &mut R) -> StreamResult<StreamHeader>
where
R: AsyncRead + Unpin,
{
let mut buf = [0u8; FIXED_HEADER_LEN];
let got = read_full(reader, &mut buf).await?;
if got != FIXED_HEADER_LEN {
return Err(StreamError::ShortHeader);
}
parse_fixed_header(&buf)
}
async fn read_kem_envelope<R>(reader: &mut R, suite: MlKemSuite) -> StreamResult<StreamKemEnvelope>
where
R: AsyncRead + Unpin,
{
let kem_ct_len = read_len_prefix(reader)
.await?
.ok_or(StreamError::ShortHeader)?;
let expected = suite.ciphertext_len();
if kem_ct_len as usize != expected {
return Err(StreamError::BadKemCiphertext);
}
let encapsulated_key = read_record_exact(reader, expected).await?;
let nonce_bytes = read_record_exact(reader, NONCE_LEN).await?;
let nonce: [u8; NONCE_LEN] = nonce_bytes
.as_slice()
.try_into()
.map_err(|_| StreamError::ShortHeader)?;
let wrapped_len = read_len_prefix(reader)
.await?
.ok_or(StreamError::ShortHeader)?;
let wrapped = CEK_LEN
.checked_add(TAG_LEN)
.ok_or(StreamError::BadKemCiphertext)?;
if wrapped_len as usize != wrapped {
return Err(StreamError::BadKemCiphertext);
}
let ciphertext = read_record_exact(reader, wrapped).await?;
Ok(StreamKemEnvelope {
encapsulated_key,
nonce,
ciphertext,
})
}
async fn read_record<R>(reader: &mut R, len: u32, max_record: usize) -> StreamResult<Vec<u8>>
where
R: AsyncRead + Unpin,
{
let len = len as usize;
if len > max_record {
return Err(StreamError::ChunkTooLarge);
}
read_record_exact(reader, len).await
}
async fn read_record_exact<R>(reader: &mut R, n: usize) -> StreamResult<Vec<u8>>
where
R: AsyncRead + Unpin,
{
let mut buf = vec![0u8; n];
let got = read_full(reader, &mut buf).await?;
if got != n {
return Err(StreamError::Truncated);
}
Ok(buf)
}
async fn read_len_prefix<R>(reader: &mut R) -> StreamResult<Option<u32>>
where
R: AsyncRead + Unpin,
{
let mut buf = [0u8; 4];
let got = read_full(reader, &mut buf).await?;
match got {
0 => Ok(None),
4 => Ok(Some(u32::from_be_bytes(buf))),
_ => Err(StreamError::Truncated),
}
}
async fn read_full<R>(reader: &mut R, buf: &mut [u8]) -> std::io::Result<usize>
where
R: AsyncRead + Unpin,
{
let mut filled = 0;
while filled < buf.len() {
let Some(dst) = buf.get_mut(filled..) else {
break;
};
let n = reader.read(dst).await?;
if n == 0 {
break;
}
filled += n;
}
Ok(filled)
}
#[cfg(test)]
mod tests;