use crate::{
crypto::{CipherSuite, Hash, MlsHash},
MlsError, Result,
};
use zeroize::Zeroizing;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum KdfFamily {
Sha3_256,
Sha3_512,
}
impl KdfFamily {
fn for_suite(suite: CipherSuite) -> Self {
match suite.hash() {
MlsHash::Sha256 | MlsHash::Sha3_256 | MlsHash::Blake3 => KdfFamily::Sha3_256,
MlsHash::Sha384 | MlsHash::Sha512 | MlsHash::Sha3_512 => KdfFamily::Sha3_512,
}
}
fn nh(self) -> usize {
match self {
KdfFamily::Sha3_256 => 32,
KdfFamily::Sha3_512 => 64,
}
}
}
fn hmac(suite: CipherSuite, key: &[u8], data: &[u8]) -> Result<Vec<u8>> {
use saorsa_pqc::api::hmac::{HmacSha3_256, HmacSha3_512};
use saorsa_pqc::api::traits::Mac;
match KdfFamily::for_suite(suite) {
KdfFamily::Sha3_256 => {
let mut mac = HmacSha3_256::new(key)
.map_err(|e| MlsError::CryptoError(format!("HMAC key error: {e:?}")))?;
mac.update(data);
Ok(mac.finalize().as_ref().to_vec())
}
KdfFamily::Sha3_512 => {
let mut mac = HmacSha3_512::new(key)
.map_err(|e| MlsError::CryptoError(format!("HMAC key error: {e:?}")))?;
mac.update(data);
Ok(mac.finalize().as_ref().to_vec())
}
}
}
fn extract(suite: CipherSuite, salt: &[u8], ikm: &[u8]) -> Result<Vec<u8>> {
hmac(suite, salt, ikm)
}
fn expand(suite: CipherSuite, prk: &[u8], info: &[u8], len: usize) -> Result<Vec<u8>> {
if len == 0 {
return Ok(Vec::new());
}
let nh = KdfFamily::for_suite(suite).nh();
let n_blocks = len.div_ceil(nh);
if n_blocks > 255 {
return Err(MlsError::CryptoError(format!(
"expand length {len} exceeds 255*Nh"
)));
}
let mut okm: Zeroizing<Vec<u8>> = Zeroizing::new(Vec::with_capacity(n_blocks * nh));
let mut prev: Zeroizing<Vec<u8>> = Zeroizing::new(Vec::new());
for i in 1..=n_blocks {
let mut data: Zeroizing<Vec<u8>> =
Zeroizing::new(Vec::with_capacity(prev.len() + info.len() + 1));
data.extend_from_slice(&prev);
data.extend_from_slice(info);
data.push(i as u8);
prev = Zeroizing::new(hmac(suite, prk, &data)?);
okm.extend_from_slice(&prev);
}
Ok(okm[..len].to_vec())
}
fn expand_with_label(
suite: CipherSuite,
secret: &[u8],
label: &str,
context: &[u8],
length: usize,
) -> Result<Vec<u8>> {
let mut full_label = Vec::with_capacity(8 + label.len());
full_label.extend_from_slice(b"MLS 1.0 ");
full_label.extend_from_slice(label.as_bytes());
let len_u16 = u16::try_from(length)
.map_err(|_| MlsError::CryptoError("label length too large".into()))?;
let label_len = u16::try_from(full_label.len())
.map_err(|_| MlsError::CryptoError("label too long".into()))?;
let ctx_len = u16::try_from(context.len())
.map_err(|_| MlsError::CryptoError("context too long".into()))?;
let mut info = Vec::new();
info.extend_from_slice(&len_u16.to_be_bytes());
info.extend_from_slice(&label_len.to_be_bytes());
info.extend_from_slice(&full_label);
info.extend_from_slice(&ctx_len.to_be_bytes());
info.extend_from_slice(context);
expand(suite, secret, &info, length)
}
fn derive_secret(suite: CipherSuite, secret: &[u8], label: &str) -> Result<Zeroizing<Vec<u8>>> {
let nh = KdfFamily::for_suite(suite).nh();
Ok(Zeroizing::new(expand_with_label(
suite,
secret,
label,
&[],
nh,
)?))
}
#[derive(Clone)]
pub struct EpochSecrets {
suite: CipherSuite,
init_secret: Zeroizing<Vec<u8>>,
sender_data_secret: Zeroizing<Vec<u8>>,
encryption_secret: Zeroizing<Vec<u8>>,
exporter_secret: Zeroizing<Vec<u8>>,
external_secret: Zeroizing<Vec<u8>>,
confirmation_key: Zeroizing<Vec<u8>>,
membership_key: Zeroizing<Vec<u8>>,
resumption_psk: Zeroizing<Vec<u8>>,
epoch_authenticator: Zeroizing<Vec<u8>>,
}
impl std::fmt::Debug for EpochSecrets {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EpochSecrets")
.field("suite", &self.suite)
.field("secrets", &"<redacted>")
.finish()
}
}
impl EpochSecrets {
#[must_use]
pub fn secret_len(suite: CipherSuite) -> usize {
KdfFamily::for_suite(suite).nh()
}
pub fn derive(
suite: CipherSuite,
prev_init_secret: &[u8],
commit_secret: &[u8],
group_context: &[u8],
) -> Result<Self> {
let joiner = Self::joiner_secret(suite, prev_init_secret, commit_secret)?;
Self::from_joiner(suite, &joiner, group_context)
}
pub fn joiner_secret(
suite: CipherSuite,
prev_init_secret: &[u8],
commit_secret: &[u8],
) -> Result<Zeroizing<Vec<u8>>> {
Ok(Zeroizing::new(extract(
suite,
prev_init_secret,
commit_secret,
)?))
}
pub fn from_joiner(
suite: CipherSuite,
joiner_secret: &[u8],
group_context: &[u8],
) -> Result<Self> {
let nh = KdfFamily::for_suite(suite).nh();
let epoch_secret = Zeroizing::new(expand_with_label(
suite,
joiner_secret,
"epoch",
group_context,
nh,
)?);
Ok(Self {
suite,
sender_data_secret: derive_secret(suite, &epoch_secret, "sender data")?,
encryption_secret: derive_secret(suite, &epoch_secret, "encryption")?,
exporter_secret: derive_secret(suite, &epoch_secret, "exporter")?,
external_secret: derive_secret(suite, &epoch_secret, "external")?,
confirmation_key: derive_secret(suite, &epoch_secret, "confirm")?,
membership_key: derive_secret(suite, &epoch_secret, "membership")?,
resumption_psk: derive_secret(suite, &epoch_secret, "resumption")?,
epoch_authenticator: derive_secret(suite, &epoch_secret, "authentication")?,
init_secret: derive_secret(suite, &epoch_secret, "init")?,
})
}
pub fn next(&self, commit_secret: &[u8], group_context: &[u8]) -> Result<Self> {
Self::derive(self.suite, &self.init_secret, commit_secret, group_context)
}
#[must_use]
pub fn init_secret(&self) -> &[u8] {
&self.init_secret
}
#[must_use]
pub fn encryption_secret(&self) -> &[u8] {
&self.encryption_secret
}
#[must_use]
pub fn sender_data_secret(&self) -> &[u8] {
&self.sender_data_secret
}
#[must_use]
pub fn membership_key(&self) -> &[u8] {
&self.membership_key
}
#[must_use]
pub fn confirmation_key(&self) -> &[u8] {
&self.confirmation_key
}
#[must_use]
pub fn external_secret(&self) -> &[u8] {
&self.external_secret
}
#[must_use]
pub fn resumption_psk(&self) -> &[u8] {
&self.resumption_psk
}
#[must_use]
pub fn epoch_authenticator(&self) -> &[u8] {
&self.epoch_authenticator
}
pub fn export(&self, label: &str, context: &[u8], length: usize) -> Result<Vec<u8>> {
let derived = derive_secret(self.suite, &self.exporter_secret, label)?;
let context_hash = Hash::new(self.suite).hash(context);
expand_with_label(self.suite, &derived, "exported", &context_hash, length)
}
pub fn application_key_and_nonce(
&self,
sender_leaf: u32,
generation: u32,
) -> Result<(Zeroizing<Vec<u8>>, Vec<u8>)> {
let mut context = Vec::with_capacity(8);
context.extend_from_slice(&sender_leaf.to_be_bytes());
context.extend_from_slice(&generation.to_be_bytes());
let key = Zeroizing::new(expand_with_label(
self.suite,
&self.encryption_secret,
"key",
&context,
self.suite.key_size(),
)?);
let nonce = expand_with_label(
self.suite,
&self.encryption_secret,
"nonce",
&context,
self.suite.nonce_size(),
)?;
Ok((key, nonce))
}
#[must_use]
pub fn snapshot(&self) -> EpochSecretsSnapshot {
EpochSecretsSnapshot {
suite: self.suite,
init_secret: self.init_secret.to_vec(),
sender_data_secret: self.sender_data_secret.to_vec(),
encryption_secret: self.encryption_secret.to_vec(),
exporter_secret: self.exporter_secret.to_vec(),
external_secret: self.external_secret.to_vec(),
confirmation_key: self.confirmation_key.to_vec(),
membership_key: self.membership_key.to_vec(),
resumption_psk: self.resumption_psk.to_vec(),
epoch_authenticator: self.epoch_authenticator.to_vec(),
}
}
#[must_use]
pub fn from_snapshot(snapshot: EpochSecretsSnapshot) -> Self {
Self {
suite: snapshot.suite,
init_secret: Zeroizing::new(snapshot.init_secret),
sender_data_secret: Zeroizing::new(snapshot.sender_data_secret),
encryption_secret: Zeroizing::new(snapshot.encryption_secret),
exporter_secret: Zeroizing::new(snapshot.exporter_secret),
external_secret: Zeroizing::new(snapshot.external_secret),
confirmation_key: Zeroizing::new(snapshot.confirmation_key),
membership_key: Zeroizing::new(snapshot.membership_key),
resumption_psk: Zeroizing::new(snapshot.resumption_psk),
epoch_authenticator: Zeroizing::new(snapshot.epoch_authenticator),
}
}
}
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct EpochSecretsSnapshot {
suite: CipherSuite,
init_secret: Vec<u8>,
sender_data_secret: Vec<u8>,
encryption_secret: Vec<u8>,
exporter_secret: Vec<u8>,
external_secret: Vec<u8>,
confirmation_key: Vec<u8>,
membership_key: Vec<u8>,
resumption_psk: Vec<u8>,
epoch_authenticator: Vec<u8>,
}
impl std::fmt::Debug for EpochSecretsSnapshot {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EpochSecretsSnapshot")
.field("suite", &self.suite)
.field("secrets", &"<redacted>")
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::CipherSuiteId;
fn suite_256() -> CipherSuite {
CipherSuite::default() }
fn suite_512() -> CipherSuite {
CipherSuite::from_id(CipherSuiteId::SPEC2_MLS_256_MLKEM1024_CHACHA20POLY1305_SHA512_MLDSA87)
.unwrap()
}
#[test]
fn test_hash_agility_secret_sizes() {
assert_eq!(EpochSecrets::secret_len(suite_256()), 32);
assert_eq!(EpochSecrets::secret_len(suite_512()), 64);
let es256 = EpochSecrets::derive(suite_256(), &[1u8; 32], &[2u8; 32], b"ctx").unwrap();
assert_eq!(es256.encryption_secret().len(), 32);
let es512 = EpochSecrets::derive(suite_512(), &[1u8; 64], &[2u8; 64], b"ctx").unwrap();
assert_eq!(
es512.encryption_secret().len(),
64,
"SHA-512 suite must derive 64-byte secrets, not the legacy 32"
);
}
#[test]
fn test_derive_is_deterministic() {
let a = EpochSecrets::derive(suite_256(), &[7u8; 32], &[9u8; 32], b"group-ctx").unwrap();
let b = EpochSecrets::derive(suite_256(), &[7u8; 32], &[9u8; 32], b"group-ctx").unwrap();
assert_eq!(a.encryption_secret(), b.encryption_secret());
assert_eq!(a.init_secret(), b.init_secret());
assert_eq!(a.epoch_authenticator(), b.epoch_authenticator());
}
#[test]
fn test_distinct_labels_distinct_secrets() {
let es = EpochSecrets::derive(suite_256(), &[1u8; 32], &[2u8; 32], b"ctx").unwrap();
let all = [
es.encryption_secret(),
es.sender_data_secret(),
es.exporter_secret.as_slice(),
es.external_secret(),
es.confirmation_key(),
es.membership_key(),
es.resumption_psk(),
es.epoch_authenticator(),
es.init_secret(),
];
for i in 0..all.len() {
for j in (i + 1)..all.len() {
assert_ne!(all[i], all[j], "labelled secrets {i} and {j} collide");
}
}
}
#[test]
fn test_pcs_epoch_depends_on_commit_secret() {
let init = [3u8; 32];
let e1 = EpochSecrets::derive(suite_256(), &init, &[10u8; 32], b"ctx").unwrap();
let e2 = EpochSecrets::derive(suite_256(), &init, &[11u8; 32], b"ctx").unwrap();
assert_ne!(e1.encryption_secret(), e2.encryption_secret());
assert_ne!(e1.init_secret(), e2.init_secret());
}
#[test]
fn test_fs_init_chaining_one_way() {
let prev_init = [4u8; 32];
let commit = [5u8; 32];
let e_n = EpochSecrets::derive(suite_256(), &prev_init, &commit, b"ctx").unwrap();
assert_ne!(e_n.init_secret(), &prev_init[..], "init must advance");
assert_ne!(e_n.init_secret(), &commit[..]);
let e_n1 = e_n.next(&[6u8; 32], b"ctx").unwrap();
assert_ne!(e_n1.init_secret(), e_n.init_secret());
assert_ne!(e_n1.encryption_secret(), e_n.encryption_secret());
}
#[test]
fn test_group_context_binding() {
let a = EpochSecrets::derive(suite_256(), &[1u8; 32], &[2u8; 32], b"group-A").unwrap();
let b = EpochSecrets::derive(suite_256(), &[1u8; 32], &[2u8; 32], b"group-B").unwrap();
assert_ne!(
a.encryption_secret(),
b.encryption_secret(),
"epoch secrets must be bound to the group context"
);
}
#[test]
fn test_exporter_label_and_context_separation() {
let es = EpochSecrets::derive(suite_256(), &[1u8; 32], &[2u8; 32], b"ctx").unwrap();
let a = es.export("label-a", b"ctx", 32).unwrap();
let b = es.export("label-b", b"ctx", 32).unwrap();
let c = es.export("label-a", b"ctx2", 32).unwrap();
assert_ne!(a, b, "different labels must separate");
assert_ne!(a, c, "different contexts must separate");
assert_eq!(a.len(), 32);
assert_eq!(a, es.export("label-a", b"ctx", 32).unwrap());
}
#[test]
fn test_expand_multi_block_lengths() {
let out = expand_with_label(suite_256(), &[0u8; 32], "x", b"", 100).unwrap();
assert_eq!(out.len(), 100); let out512 = expand_with_label(suite_512(), &[0u8; 64], "x", b"", 200).unwrap();
assert_eq!(out512.len(), 200);
let long = expand(suite_256(), &[1u8; 32], b"info", 100).unwrap();
let short = expand(suite_256(), &[1u8; 32], b"info", 32).unwrap();
assert_eq!(&long[..32], &short[..], "HKDF-Expand must be prefix-stable");
assert!(expand_with_label(suite_256(), &[0u8; 32], "x", b"", 255 * 32 + 1).is_err());
}
#[test]
fn test_sha512_family_determinism() {
let a = EpochSecrets::derive(suite_512(), &[7u8; 64], &[9u8; 64], b"ctx").unwrap();
let b = EpochSecrets::derive(suite_512(), &[7u8; 64], &[9u8; 64], b"ctx").unwrap();
assert_eq!(a.encryption_secret(), b.encryption_secret());
assert_eq!(a.init_secret(), b.init_secret());
assert_eq!(a.epoch_authenticator(), b.epoch_authenticator());
assert_eq!(
a.export("x", b"ctx", 100).unwrap(),
b.export("x", b"ctx", 100).unwrap()
);
}
#[test]
fn test_commit_secret_drives_converged_epoch_secrets() {
use crate::member::{MemberId, MemberIdentity};
use crate::treekem::RatchetTree;
let suite = suite_256();
let alice = MemberIdentity::generate(MemberId::generate()).unwrap();
let bob = MemberIdentity::generate(MemberId::generate()).unwrap();
let mut tree_a = RatchetTree::new(alice.key_package.clone(), suite).unwrap();
tree_a
.attach_owner(0, alice.kem_secret().unwrap().clone())
.unwrap();
let bob_leaf = tree_a.add_leaf(bob.key_package.clone()).unwrap();
let mut tree_b = tree_a.clone();
tree_b
.attach_owner(bob_leaf, bob.kem_secret().unwrap().clone())
.unwrap();
let group_context = b"group-id|epoch=1";
let (update_path, cs_alice) = tree_a.generate_update_path(group_context, None).unwrap();
let cs_bob = tree_b
.process_update_path(&update_path, group_context)
.unwrap();
assert_eq!(&*cs_alice, &*cs_bob, "tree-level commit secrets must match");
let prev_init = vec![0u8; EpochSecrets::secret_len(suite)];
let epoch_a = EpochSecrets::derive(suite, &prev_init, &cs_alice, group_context).unwrap();
let epoch_b = EpochSecrets::derive(suite, &prev_init, &cs_bob, group_context).unwrap();
assert_eq!(
epoch_a.encryption_secret(),
epoch_b.encryption_secret(),
"both members must reach the same epoch encryption secret"
);
assert_eq!(epoch_a.init_secret(), epoch_b.init_secret());
assert_eq!(epoch_a.epoch_authenticator(), epoch_b.epoch_authenticator());
assert_eq!(
epoch_a.export("app", b"ctx", 32).unwrap(),
epoch_b.export("app", b"ctx", 32).unwrap()
);
}
}