From f8209d927d880c036ee03bb144ef36d35f70fa17 Mon Sep 17 00:00:00 2001 From: "dobby-yivi-agent[bot]" <275734547+dobby-yivi-agent[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 03:04:10 +0000 Subject: [PATCH 1/2] fix: derive mkem AES key via HKDF-SHA256 instead of raw truncation The mkem layer derived its AES-128-GCM key by truncating the KEM shared secret (`&kek.0[..KEY_SIZE]`) rather than applying a KDF. Replace this with an HKDF-SHA256 expansion using a domain-separation label (`ibe-mkem-aes128gcm`) at both the encapsulation and decapsulation sites, via a shared `derive_aead` helper. Adds `hkdf` and `sha2` as optional dependencies gated behind the `mkem` feature. Includes unit tests covering determinism, divergence from raw truncation, and dependence on the full shared secret. Refs GHSA-236p-m8qr-cmjg, closes #44 Co-Authored-By: Claude Opus 4.8 --- Cargo.toml | 4 ++- src/kem/mkem.rs | 66 +++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index fff5522..2619ac1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,8 @@ pg-curve = { version = "0.2.0", features = [ subtle = { version = "2.4.1", default-features = false } tiny-keccak = { version = "2.0.2", features = ["sha3", "shake"] } aes-gcm = { version = "0.10", optional = true } +hkdf = { version = "0.12", default-features = false, optional = true } +sha2 = { version = "0.10", default-features = false, optional = true } [target.wasm32-unknown-unknown.dependencies] getrandom = { version = "0.2", features = ["js"] } @@ -41,7 +43,7 @@ cgwkv = [] kv1 = [] waters = [] waters_naccache = [] -mkem = ["aes-gcm"] +mkem = ["aes-gcm", "hkdf", "sha2"] [lib] bench = false diff --git a/src/kem/mkem.rs b/src/kem/mkem.rs index a1daeb2..7b0ceb1 100644 --- a/src/kem/mkem.rs +++ b/src/kem/mkem.rs @@ -42,6 +42,8 @@ use subtle::CtOption; use aes_gcm::aead::{Nonce, Tag}; use aes_gcm::{AeadInPlace, Aes128Gcm, KeyInit}; +use hkdf::Hkdf; +use sha2::Sha256; #[cfg(feature = "cgwfo")] use crate::kem::cgw_fo::CGWFO; @@ -56,6 +58,27 @@ const TAG_SIZE: usize = 16; const NONCE_SIZE: usize = 12; const KEY_SIZE: usize = 16; +/// Domain-separation label for the HKDF-SHA256 expansion that turns the KEM +/// shared secret into the AES-128-GCM key. +const HKDF_INFO: &[u8] = b"ibe-mkem-aes128gcm"; + +/// Derives the AES-128-GCM key bytes from a KEM shared secret using +/// HKDF-SHA256 with domain separation, rather than raw truncation of the +/// shared secret. +fn derive_aead_key(kek: &SharedSecret) -> [u8; KEY_SIZE] { + let mut aes_key = [0u8; KEY_SIZE]; + Hkdf::::new(None, &kek.0) + .expand(HKDF_INFO, &mut aes_key) + .expect("KEY_SIZE is a valid HKDF-SHA256 output length"); + + aes_key +} + +/// Builds the AES-128-GCM instance keyed with the HKDF-derived key. +fn derive_aead(kek: &SharedSecret) -> Aes128Gcm { + Aes128Gcm::new_from_slice(&derive_aead_key(kek)).expect("aes_key has the correct length") +} + impl SharedSecret { /// Sample random shared secret. fn random(r: &mut R) -> Self { @@ -96,7 +119,7 @@ where let (ct_asymm, kek) = ::encaps(self.pk, id, self.rng); - let aead = Aes128Gcm::new_from_slice(&kek.0[..KEY_SIZE]).unwrap(); + let aead = derive_aead(&kek); let nonce_bytes = self.rng.gen::<[u8; NONCE_SIZE]>(); let nonce = Nonce::::from_slice(&nonce_bytes); @@ -147,7 +170,7 @@ pub trait MultiRecipient: IBKEM { ct: &Ciphertext, ) -> Result { let kek = ::decaps(mpk, usk, &ct.ct_asymm)?; - let aead = Aes128Gcm::new_from_slice(&kek.0[..KEY_SIZE]).unwrap(); + let aead = derive_aead(&kek); let mut shared_key = ct.ct_symm; aead.decrypt_in_place_detached(&ct.nonce, b"", &mut shared_key, &ct.tag) .map_err(|_e| Error)?; @@ -206,3 +229,42 @@ impl_mkemct_compress!(CGWFO); #[cfg(feature = "kv1")] impl_mkemct_compress!(KV1); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn derive_aead_key_is_deterministic() { + let kek = SharedSecret([7u8; SS_BYTES]); + assert_eq!(derive_aead_key(&kek), derive_aead_key(&kek)); + } + + #[test] + fn derive_aead_key_differs_from_raw_truncation() { + // The vulnerability being fixed: the key used to be `&kek.0[..KEY_SIZE]`. + // HKDF-SHA256 must mix the whole secret, so the derived key should not + // equal the raw prefix of the shared secret. + let kek = SharedSecret([0xABu8; SS_BYTES]); + assert_ne!(&derive_aead_key(&kek)[..], &kek.0[..KEY_SIZE]); + } + + #[test] + fn derive_aead_key_depends_on_full_secret() { + // Two secrets sharing the same first KEY_SIZE bytes but differing in the + // tail would collide under raw truncation; HKDF must keep them distinct. + let mut a = [0u8; SS_BYTES]; + let mut b = [0u8; SS_BYTES]; + for (i, (x, y)) in a.iter_mut().zip(b.iter_mut()).enumerate() { + *x = i as u8; + *y = i as u8; + } + b[SS_BYTES - 1] ^= 0xFF; + + assert_eq!(&a[..KEY_SIZE], &b[..KEY_SIZE]); + assert_ne!( + derive_aead_key(&SharedSecret(a)), + derive_aead_key(&SharedSecret(b)) + ); + } +} From 3b7c41065c598c14b8985e89015555ac8fb41249 Mon Sep 17 00:00:00 2001 From: "dobby-yivi-agent[bot]" <275734547+dobby-yivi-agent[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 03:57:07 +0000 Subject: [PATCH 2/2] docs: record mkem HKDF-SHA256 key derivation change in CHANGELOG The switch from raw truncation to HKDF-SHA256 for the mkem AES-128-GCM key is a wire-incompatible cryptographic behaviour change; document it so downstream (PostGuard) users know pre/post-fix peers cannot decrypt each other's MKEM ciphertexts. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 957bb38..697909d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Changed multi-user key encapsulation, see [this PR](https://github.com/encryption4all/ibe/pull/10). - Changed `irmaseal-curve 0.1.4` to `pg-curve 0.2.0` (includes `bls12_381 0.8`). +- Derive the `mkem` AES-128-GCM key from the KEM shared secret via HKDF-SHA256 with domain separation instead of raw truncation, see [this PR](https://github.com/encryption4all/ibe/pull/47). This is a wire-incompatible change: pre-fix and post-fix peers can no longer decrypt each other's MKEM ciphertexts (session keys are ephemeral, so there is no stored-ciphertext migration). ## 0.2.3