Skip to content

Commit f8209d9

Browse files
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 <noreply@anthropic.com>
1 parent 1a60b08 commit f8209d9

2 files changed

Lines changed: 67 additions & 3 deletions

File tree

Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ pg-curve = { version = "0.2.0", features = [
2424
subtle = { version = "2.4.1", default-features = false }
2525
tiny-keccak = { version = "2.0.2", features = ["sha3", "shake"] }
2626
aes-gcm = { version = "0.10", optional = true }
27+
hkdf = { version = "0.12", default-features = false, optional = true }
28+
sha2 = { version = "0.10", default-features = false, optional = true }
2729

2830
[target.wasm32-unknown-unknown.dependencies]
2931
getrandom = { version = "0.2", features = ["js"] }
@@ -41,7 +43,7 @@ cgwkv = []
4143
kv1 = []
4244
waters = []
4345
waters_naccache = []
44-
mkem = ["aes-gcm"]
46+
mkem = ["aes-gcm", "hkdf", "sha2"]
4547

4648
[lib]
4749
bench = false

src/kem/mkem.rs

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ use subtle::CtOption;
4242

4343
use aes_gcm::aead::{Nonce, Tag};
4444
use aes_gcm::{AeadInPlace, Aes128Gcm, KeyInit};
45+
use hkdf::Hkdf;
46+
use sha2::Sha256;
4547

4648
#[cfg(feature = "cgwfo")]
4749
use crate::kem::cgw_fo::CGWFO;
@@ -56,6 +58,27 @@ const TAG_SIZE: usize = 16;
5658
const NONCE_SIZE: usize = 12;
5759
const KEY_SIZE: usize = 16;
5860

61+
/// Domain-separation label for the HKDF-SHA256 expansion that turns the KEM
62+
/// shared secret into the AES-128-GCM key.
63+
const HKDF_INFO: &[u8] = b"ibe-mkem-aes128gcm";
64+
65+
/// Derives the AES-128-GCM key bytes from a KEM shared secret using
66+
/// HKDF-SHA256 with domain separation, rather than raw truncation of the
67+
/// shared secret.
68+
fn derive_aead_key(kek: &SharedSecret) -> [u8; KEY_SIZE] {
69+
let mut aes_key = [0u8; KEY_SIZE];
70+
Hkdf::<Sha256>::new(None, &kek.0)
71+
.expand(HKDF_INFO, &mut aes_key)
72+
.expect("KEY_SIZE is a valid HKDF-SHA256 output length");
73+
74+
aes_key
75+
}
76+
77+
/// Builds the AES-128-GCM instance keyed with the HKDF-derived key.
78+
fn derive_aead(kek: &SharedSecret) -> Aes128Gcm {
79+
Aes128Gcm::new_from_slice(&derive_aead_key(kek)).expect("aes_key has the correct length")
80+
}
81+
5982
impl SharedSecret {
6083
/// Sample random shared secret.
6184
fn random<R: Rng + CryptoRng>(r: &mut R) -> Self {
@@ -96,7 +119,7 @@ where
96119

97120
let (ct_asymm, kek) = <K as IBKEM>::encaps(self.pk, id, self.rng);
98121

99-
let aead = Aes128Gcm::new_from_slice(&kek.0[..KEY_SIZE]).unwrap();
122+
let aead = derive_aead(&kek);
100123
let nonce_bytes = self.rng.gen::<[u8; NONCE_SIZE]>();
101124
let nonce = Nonce::<Aes128Gcm>::from_slice(&nonce_bytes);
102125

@@ -147,7 +170,7 @@ pub trait MultiRecipient: IBKEM {
147170
ct: &Ciphertext<Self>,
148171
) -> Result<SharedSecret, Error> {
149172
let kek = <Self as IBKEM>::decaps(mpk, usk, &ct.ct_asymm)?;
150-
let aead = Aes128Gcm::new_from_slice(&kek.0[..KEY_SIZE]).unwrap();
173+
let aead = derive_aead(&kek);
151174
let mut shared_key = ct.ct_symm;
152175
aead.decrypt_in_place_detached(&ct.nonce, b"", &mut shared_key, &ct.tag)
153176
.map_err(|_e| Error)?;
@@ -206,3 +229,42 @@ impl_mkemct_compress!(CGWFO);
206229

207230
#[cfg(feature = "kv1")]
208231
impl_mkemct_compress!(KV1);
232+
233+
#[cfg(test)]
234+
mod tests {
235+
use super::*;
236+
237+
#[test]
238+
fn derive_aead_key_is_deterministic() {
239+
let kek = SharedSecret([7u8; SS_BYTES]);
240+
assert_eq!(derive_aead_key(&kek), derive_aead_key(&kek));
241+
}
242+
243+
#[test]
244+
fn derive_aead_key_differs_from_raw_truncation() {
245+
// The vulnerability being fixed: the key used to be `&kek.0[..KEY_SIZE]`.
246+
// HKDF-SHA256 must mix the whole secret, so the derived key should not
247+
// equal the raw prefix of the shared secret.
248+
let kek = SharedSecret([0xABu8; SS_BYTES]);
249+
assert_ne!(&derive_aead_key(&kek)[..], &kek.0[..KEY_SIZE]);
250+
}
251+
252+
#[test]
253+
fn derive_aead_key_depends_on_full_secret() {
254+
// Two secrets sharing the same first KEY_SIZE bytes but differing in the
255+
// tail would collide under raw truncation; HKDF must keep them distinct.
256+
let mut a = [0u8; SS_BYTES];
257+
let mut b = [0u8; SS_BYTES];
258+
for (i, (x, y)) in a.iter_mut().zip(b.iter_mut()).enumerate() {
259+
*x = i as u8;
260+
*y = i as u8;
261+
}
262+
b[SS_BYTES - 1] ^= 0xFF;
263+
264+
assert_eq!(&a[..KEY_SIZE], &b[..KEY_SIZE]);
265+
assert_ne!(
266+
derive_aead_key(&SharedSecret(a)),
267+
derive_aead_key(&SharedSecret(b))
268+
);
269+
}
270+
}

0 commit comments

Comments
 (0)