1use borsh::{BorshDeserialize, BorshSerialize};
26use hkdf::Hkdf;
27use ml_kem::array::Array;
28use ml_kem::kem::{Decapsulate, Encapsulate};
29use ml_kem::{Encoded, EncodedSizeUser, KemCore, MlKem768};
30use rand::rngs::OsRng;
31use sha2::Sha256;
32use std::fmt;
33use zeroize::ZeroizeOnDrop;
34
35#[cfg(not(feature = "fips"))]
36use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret};
37
38#[cfg(feature = "fips")]
39use aws_lc_rs::{
40 agreement::{self, agree, EphemeralPrivateKey, PrivateKey, UnparsedPublicKey, ECDH_P256},
41 rand::SystemRandom,
42};
43
44type MlKem768DecapKey = <MlKem768 as KemCore>::DecapsulationKey;
45type MlKem768EncapKey = <MlKem768 as KemCore>::EncapsulationKey;
46
47#[cfg(not(feature = "fips"))]
52pub const CLASSICAL_PK_BYTES: usize = 32;
53#[cfg(feature = "fips")]
54pub const CLASSICAL_PK_BYTES: usize = 65;
55
56#[cfg(not(feature = "fips"))]
61const COMBINE_LABEL: &[u8] = b"HybridKEM_X25519_Kyber768";
62#[cfg(feature = "fips")]
63const COMBINE_LABEL: &[u8] = b"HybridKEM_P256_Kyber768";
64
65#[derive(ZeroizeOnDrop)]
77pub struct HybridSecretKey {
78 #[cfg(not(feature = "fips"))]
82 pub classical_sk: StaticSecret,
83 #[cfg(feature = "fips")]
84 #[zeroize(skip)] pub classical_sk: PrivateKey,
86
87 #[zeroize(skip)] pub ml_kem_dk: Box<MlKem768DecapKey>,
91}
92
93impl HybridSecretKey {
94 pub fn generate() -> (Self, HybridKeyPackage) {
95 let mut rng = OsRng;
96
97 #[cfg(not(feature = "fips"))]
101 let (classical_sk, classical_pk_bytes) = {
102 let sk = StaticSecret::random_from_rng(rng);
103 let pk = X25519PublicKey::from(&sk);
104 (sk, *pk.as_bytes())
105 };
106 #[cfg(feature = "fips")]
107 let (classical_sk, classical_pk_bytes) = {
108 #[allow(clippy::expect_used)]
117 let sk = PrivateKey::generate(&ECDH_P256)
118 .expect("aws-lc-rs ECDH-P-256 generate must succeed");
119 #[allow(clippy::expect_used)]
120 let pk = sk
121 .compute_public_key()
122 .expect("aws-lc-rs ECDH-P-256 compute_public_key must succeed");
123 let mut bytes = [0u8; CLASSICAL_PK_BYTES];
124 bytes.copy_from_slice(pk.as_ref());
125 (sk, bytes)
126 };
127
128 let (dk, ek) = MlKem768::generate(&mut rng);
131
132 let secret_key = HybridSecretKey {
133 classical_sk,
134 ml_kem_dk: Box::new(dk),
135 };
136 let key_package = HybridKeyPackage {
137 classical_pk: classical_pk_bytes,
138 ml_kem_pk: ek.as_bytes().to_vec(),
139 };
140 (secret_key, key_package)
141 }
142
143 pub fn decapsulate(&self, ciphertext: &HybridCiphertext) -> Result<[u8; 32], anyhow::Error> {
144 #[cfg(not(feature = "fips"))]
146 let classical_shared: [u8; 32] = {
147 let peer = X25519PublicKey::from(ciphertext.classical_pk);
148 let s = self.classical_sk.diffie_hellman(&peer);
149 *s.as_bytes()
150 };
151 #[cfg(feature = "fips")]
152 let classical_shared: [u8; 32] = {
153 let peer = UnparsedPublicKey::new(&ECDH_P256, &ciphertext.classical_pk[..]);
154 agree(
159 &self.classical_sk,
160 peer,
161 anyhow::anyhow!("aws-lc-rs ECDH-P-256 agree failed (peer key parse)"),
162 |km| -> Result<[u8; 32], anyhow::Error> {
163 let mut out = [0u8; 32];
165 out.copy_from_slice(km);
166 Ok(out)
167 },
168 )?
169 };
170
171 let ct_array = decode_ml_kem_ciphertext(&ciphertext.ml_kem_ct)
173 .ok_or_else(|| anyhow::anyhow!("invalid ML-KEM-768 ciphertext length"))?;
174 let ml_kem_shared = self
175 .ml_kem_dk
176 .decapsulate(&ct_array)
177 .map_err(|e| anyhow::anyhow!("ML-KEM decapsulation failed: {:?}", e))?;
178
179 #[cfg(not(feature = "fips"))]
182 let our_classical_pk: [u8; CLASSICAL_PK_BYTES] =
183 *X25519PublicKey::from(&self.classical_sk).as_bytes();
184 #[cfg(feature = "fips")]
185 let our_classical_pk: [u8; CLASSICAL_PK_BYTES] = {
186 let pk = self
187 .classical_sk
188 .compute_public_key()
189 .map_err(|e| anyhow::anyhow!("aws-lc-rs P-256 compute_public_key: {:?}", e))?;
190 let mut b = [0u8; CLASSICAL_PK_BYTES];
191 b.copy_from_slice(pk.as_ref());
192 b
193 };
194
195 Self::combine_secrets(
198 &classical_shared,
199 ml_kem_shared.as_slice(),
200 &ciphertext.classical_pk,
201 &our_classical_pk,
202 )
203 }
204
205 pub(crate) fn combine_secrets(
216 ecc_secret: &[u8],
217 pq_secret: &[u8],
218 classical_ct: &[u8],
219 classical_pk: &[u8],
220 ) -> Result<[u8; 32], anyhow::Error> {
221 let ikm =
225 zeroize::Zeroizing::new([ecc_secret, pq_secret, classical_ct, classical_pk].concat());
226 let hkdf = Hkdf::<Sha256>::new(None, &ikm);
227 let mut okm = [0u8; 32];
228 hkdf.expand(COMBINE_LABEL, &mut okm)
229 .map_err(|_| anyhow::anyhow!("HKDF expansion failed"))?;
230 Ok(okm)
231 }
232}
233
234impl fmt::Debug for HybridSecretKey {
235 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
236 f.debug_struct("HybridSecretKey")
237 .field("classical_sk", &"REDACTED")
238 .field("ml_kem_dk", &"REDACTED")
239 .finish()
240 }
241}
242
243#[derive(BorshSerialize, BorshDeserialize, Debug, Clone)]
244pub struct HybridKeyPackage {
245 pub classical_pk: [u8; CLASSICAL_PK_BYTES],
249 pub ml_kem_pk: Vec<u8>,
250}
251
252impl HybridKeyPackage {
253 pub fn encapsulate(&self) -> Result<([u8; 32], HybridCiphertext), anyhow::Error> {
254 let mut rng = OsRng;
255
256 #[cfg(not(feature = "fips"))]
258 let (eph_pk_bytes, classical_shared) = {
259 let eph_sk = StaticSecret::random_from_rng(rng);
260 let eph_pk = X25519PublicKey::from(&eph_sk);
261 let peer = X25519PublicKey::from(self.classical_pk);
262 let shared = eph_sk.diffie_hellman(&peer);
263 (*eph_pk.as_bytes(), *shared.as_bytes())
264 };
265 #[cfg(feature = "fips")]
266 let (eph_pk_bytes, classical_shared): ([u8; CLASSICAL_PK_BYTES], [u8; 32]) = {
267 let aws_rng = SystemRandom::new();
268 let eph_sk = EphemeralPrivateKey::generate(&ECDH_P256, &aws_rng)
269 .map_err(|e| anyhow::anyhow!("aws-lc-rs ECDH-P-256 ephemeral generate: {:?}", e))?;
270 let eph_pk = eph_sk
271 .compute_public_key()
272 .map_err(|e| anyhow::anyhow!("compute_public_key: {:?}", e))?;
273 let mut pk_bytes = [0u8; CLASSICAL_PK_BYTES];
274 pk_bytes.copy_from_slice(eph_pk.as_ref());
275 let peer = UnparsedPublicKey::new(&ECDH_P256, &self.classical_pk[..]);
276 let shared = agreement::agree_ephemeral(
277 eph_sk,
278 peer,
279 anyhow::anyhow!("aws-lc-rs ECDH-P-256 agree_ephemeral failed (peer parse)"),
280 |km| -> Result<[u8; 32], anyhow::Error> {
281 let mut o = [0u8; 32];
282 o.copy_from_slice(km);
283 Ok(o)
284 },
285 )?;
286 (pk_bytes, shared)
287 };
288
289 let ek_array = decode_ml_kem_encap_key(&self.ml_kem_pk)
291 .ok_or_else(|| anyhow::anyhow!("invalid ML-KEM-768 public key length"))?;
292 let ek = MlKem768EncapKey::from_bytes(&ek_array);
293 let (ct, ml_kem_shared) = ek
294 .encapsulate(&mut rng)
295 .map_err(|e| anyhow::anyhow!("ML-KEM encapsulation failed: {:?}", e))?;
296
297 let shared_secret = HybridSecretKey::combine_secrets(
300 &classical_shared,
301 ml_kem_shared.as_slice(),
302 &eph_pk_bytes,
303 &self.classical_pk,
304 )?;
305
306 let ciphertext = HybridCiphertext {
307 classical_pk: eph_pk_bytes,
308 ml_kem_ct: ct.as_slice().to_vec(),
309 };
310 Ok((shared_secret, ciphertext))
311 }
312}
313
314#[derive(BorshSerialize, BorshDeserialize, Debug, Clone)]
315pub struct HybridCiphertext {
316 pub classical_pk: [u8; CLASSICAL_PK_BYTES],
319 pub ml_kem_ct: Vec<u8>,
321}
322
323fn decode_ml_kem_encap_key(bytes: &[u8]) -> Option<Encoded<MlKem768EncapKey>> {
332 Encoded::<MlKem768EncapKey>::try_from(bytes).ok()
333}
334
335fn decode_ml_kem_ciphertext(
336 bytes: &[u8],
337) -> Option<Array<u8, <MlKem768 as KemCore>::CiphertextSize>> {
338 Array::<u8, <MlKem768 as KemCore>::CiphertextSize>::try_from(bytes).ok()
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344
345 #[test]
346 fn hybrid_kem_round_trip() {
347 let (sk, pk) = HybridSecretKey::generate();
348 let (ss_send, ct) = pk.encapsulate().expect("encap");
349 let ss_recv = sk.decapsulate(&ct).expect("decap");
350 assert_eq!(
351 ss_send, ss_recv,
352 "encap/decap must agree on the shared secret"
353 );
354 }
355
356 #[test]
363 fn combiner_binds_classical_ct_and_pk() {
364 let ecc = [7u8; 32];
365 let pq = [9u8; 32];
366 let ct1 = [1u8; CLASSICAL_PK_BYTES];
367 let ct2 = [2u8; CLASSICAL_PK_BYTES];
368 let pk1 = [3u8; CLASSICAL_PK_BYTES];
369 let pk2 = [4u8; CLASSICAL_PK_BYTES];
370
371 let base = HybridSecretKey::combine_secrets(&ecc, &pq, &ct1, &pk1).expect("combine");
372 let diff_ct = HybridSecretKey::combine_secrets(&ecc, &pq, &ct2, &pk1).expect("combine");
373 let diff_pk = HybridSecretKey::combine_secrets(&ecc, &pq, &ct1, &pk2).expect("combine");
374
375 assert_ne!(
376 base, diff_ct,
377 "combined secret must depend on the classical ciphertext (sender ephemeral pk)"
378 );
379 assert_ne!(
380 base, diff_pk,
381 "combined secret must depend on the recipient classical pubkey"
382 );
383 }
384
385 #[test]
386 fn hybrid_kem_two_handshakes_yield_distinct_secrets() {
387 let (_sk, pk) = HybridSecretKey::generate();
388 let (ss1, _ct1) = pk.encapsulate().expect("first encap");
389 let (ss2, _ct2) = pk.encapsulate().expect("second encap");
390 assert_ne!(ss1, ss2);
393 }
394
395 #[test]
396 fn ml_kem_ciphertext_size_matches_fips_203() {
397 let (_sk, pk) = HybridSecretKey::generate();
399 let (_ss, ct) = pk.encapsulate().expect("encap");
400 assert_eq!(ct.ml_kem_ct.len(), 1088);
401 }
402
403 #[test]
404 fn ml_kem_public_key_size_matches_fips_203() {
405 let (_sk, pk) = HybridSecretKey::generate();
407 assert_eq!(pk.ml_kem_pk.len(), 1184);
408 }
409
410 #[test]
411 fn hybrid_kem_two_secrets_distinct_under_same_recipient_key() {
412 let (sk, pk) = HybridSecretKey::generate();
413 let (ss1, ct1) = pk.encapsulate().expect("encap1");
414 let (_ss2, _ct2) = pk.encapsulate().expect("encap2");
415 let pt1 = sk.decapsulate(&ct1).expect("decap1");
416 assert_eq!(pt1, ss1);
418 }
419
420 #[test]
422 fn classical_public_key_size_matches_backend() {
423 let (_sk, pk) = HybridSecretKey::generate();
424 assert_eq!(pk.classical_pk.len(), CLASSICAL_PK_BYTES);
425 #[cfg(not(feature = "fips"))]
426 assert_eq!(CLASSICAL_PK_BYTES, 32, "X25519 public key is 32 bytes");
427 #[cfg(feature = "fips")]
428 assert_eq!(
429 CLASSICAL_PK_BYTES, 65,
430 "ECDH-P-256 uncompressed SEC1 public key is 65 bytes"
431 );
432 }
433
434 #[cfg(feature = "fips")]
436 #[test]
437 fn fips_classical_public_key_is_uncompressed_sec1() {
438 let (_sk, pk) = HybridSecretKey::generate();
439 assert_eq!(
440 pk.classical_pk[0], 0x04,
441 "uncompressed SEC1 P-256 key must lead with 0x04"
442 );
443 }
444}