Skip to main content

phantom_protocol/crypto/
hybrid_kem.rs

1//! Hybrid KEM: classical ECDH + ML-KEM-768 (FIPS 203, post-quantum).
2//!
3//! Phase 5.1 — switched the PQ half from `pqcrypto-kyber`'s C reference
4//! implementation of NIST PQC round-3 Kyber768 to the RustCrypto pure-Rust
5//! `ml-kem` crate's FIPS-203 ML-KEM-768. Same algorithm at the math level,
6//! but the byte encoding follows FIPS 203.
7//!
8//! Under `--features fips`, the classical half swaps from X25519
9//! to ECDH-P-256 via `aws-lc-rs`. The classical public-key length on
10//! the wire grows from 32 bytes (X25519) to 65 bytes (uncompressed
11//! SEC1 P-256). Cross-mode interop (fips ↔ non-fips) is **not
12//! supported** — both peers MUST be compiled with matching feature
13//! flags, and the `PROTOCOL_VARIANT` handshake constant
14//! (`transport::handshake::PROTOCOL_VARIANT`) is baked into the
15//! signed transcript so a mixed-mode attempt fails on the client's
16//! signature check rather than producing a silently-wrong shared
17//! secret.
18//!
19//! Both KEM halves contribute 32 bytes of shared secret, combined via
20//! `HKDF-SHA-256` with the label `"HybridKEM_X25519_Kyber768"` on the
21//! default build and `"HybridKEM_P256_Kyber768"` under fips. The label
22//! divergence is intentional defense-in-depth: even if `PROTOCOL_VARIANT`
23//! were stripped, the derived traffic secret would differ.
24
25use 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/// Classical KEM public-key byte length on the wire.
48///
49/// - Default build: X25519 → 32 bytes (RFC 7748).
50/// - `--features fips`: ECDH-P-256 uncompressed SEC1 → 65 bytes.
51#[cfg(not(feature = "fips"))]
52pub const CLASSICAL_PK_BYTES: usize = 32;
53#[cfg(feature = "fips")]
54pub const CLASSICAL_PK_BYTES: usize = 65;
55
56/// Combined-secret HKDF label. The default build keeps the V1/V2 label
57/// verbatim so the protocol's KDF-label inventory stays stable; the fips
58/// build uses a distinct label because the classical primitive is
59/// different.
60#[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/// Hybrid secret key. Holds the classical long-term secret (X25519 by
66/// default, ECDH-P-256 under fips) and the ML-KEM-768 decapsulation
67/// key. Both halves are zeroized on drop — `ml_kem`'s `DecapsulationKey`
68/// implements `Zeroize` natively, and the classical side either uses
69/// `x25519_dalek`'s `Zeroize` impl (default) or aws-lc-rs's internal
70/// Drop, which frees the underlying key material.
71///
72/// `ml_kem_dk` is `Box`-ed so the (~2.4 KiB) decapsulation key lives on
73/// the heap; constructing several `HybridSecretKey`s in a deep call
74/// chain (as happens during the handshake) would otherwise stress
75/// tokio's default test thread stack.
76#[derive(ZeroizeOnDrop)]
77pub struct HybridSecretKey {
78    /// Classical long-lived secret. Type depends on the active backend:
79    /// `x25519_dalek::StaticSecret` (default) or
80    /// `aws_lc_rs::agreement::PrivateKey` (`--features fips`, ECDH-P-256).
81    #[cfg(not(feature = "fips"))]
82    pub classical_sk: StaticSecret,
83    #[cfg(feature = "fips")]
84    #[zeroize(skip)] // aws-lc-rs frees the inner key on Drop
85    pub classical_sk: PrivateKey,
86
87    /// ML-KEM-768 decapsulation key (FIPS 203). Boxed to keep stack
88    /// pressure down — the structure is ~2.4 KiB.
89    #[zeroize(skip)] // Box's Drop calls T::Drop which zeroes the inner key
90    pub ml_kem_dk: Box<MlKem768DecapKey>,
91}
92
93impl HybridSecretKey {
94    pub fn generate() -> (Self, HybridKeyPackage) {
95        let mut rng = OsRng;
96
97        // Classical (X25519 or ECDH-P-256) key generation + public key
98        // derivation. Branch is fully cfg-gated; the build pulls in
99        // exactly one path.
100        #[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            // PANIC-SAFETY: `PrivateKey::generate` only fails when the
109            // underlying AWS-LC random source is broken — same failure
110            // mode as `getrandom` on the default build, where we also
111            // panic via `OsRng`. `compute_public_key` derives a
112            // P-256 public from a fresh, just-generated valid private,
113            // which cannot fail. A failure here means the FIPS module
114            // is in a non-recoverable state; loud panic is the correct
115            // surface for the embedder.
116            #[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        // ML-KEM-768 (post-quantum, FIPS 203). Box the decap key so the
129        // ~2.4 KiB structure never lives on the stack.
130        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        // 1. Classical ECDH.
145        #[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            // aws-lc-rs's `agree` returns `Result<R, E>` where the
155            // closure is `FnOnce(&[u8]) -> Result<R, E>`. The
156            // `error_value` arg is the E returned when peer-key parse
157            // fails before the closure runs.
158            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                    // ECDH-P-256 shared secret is the 32-byte X coordinate.
164                    let mut out = [0u8; 32];
165                    out.copy_from_slice(km);
166                    Ok(out)
167                },
168            )?
169        };
170
171        // 2. ML-KEM-768 decapsulation.
172        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        // 3. Our own classical public key — bound into the combiner (T4.2). Derived
180        // from the long-lived classical secret on each build path.
181        #[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        // 4. Combine the two 32-byte secrets via HKDF, binding the classical ciphertext
196        // (the sender's ephemeral pk) + the recipient classical pk (T4.2).
197        Self::combine_secrets(
198            &classical_shared,
199            ml_kem_shared.as_slice(),
200            &ciphertext.classical_pk,
201            &our_classical_pk,
202        )
203    }
204
205    /// Combine the classical + ML-KEM shared secrets into the 32-byte session secret.
206    ///
207    /// T4.2 (X-Wing / draft-ietf-tls-hybrid-design): the IKM binds, in addition to the
208    /// two raw shared secrets, the **classical ciphertext** (`classical_ct` — the
209    /// sender's ephemeral classical pubkey, carried in [`HybridCiphertext::classical_pk`])
210    /// and the **recipient classical pubkey** (`classical_pk`). This makes the combined
211    /// secret commit to the full classical transcript so the combiner's security does not
212    /// rest on the handshake signature alone. The ML-KEM half is implicitly committed via
213    /// its shared secret (ML-KEM is IND-CCA / binds its ciphertext); only the classical
214    /// half needs the explicit ct/pk binding here.
215    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        // CRYPTO-3: the combined IKM holds both raw classical and ML-KEM shared
222        // secrets — wipe it on every exit path rather than leaving it in freed
223        // memory.
224        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    /// Classical public key. Encoded as raw bytes; semantics depend on
246    /// the build (X25519 32-byte key by default, P-256 uncompressed
247    /// SEC1 65-byte key under fips).
248    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        // 1. Classical ECDH: fresh ephemeral on the sender side.
257        #[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        // 2. ML-KEM-768 encapsulation against the peer's encap key.
290        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        // 3. Combine via HKDF, binding the classical ciphertext (this ephemeral pk)
298        // + the recipient classical pk (T4.2).
299        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    /// Sender's ephemeral classical public key. Encoding matches
317    /// [`HybridKeyPackage::classical_pk`].
318    pub classical_pk: [u8; CLASSICAL_PK_BYTES],
319    /// ML-KEM-768 ciphertext bytes (FIPS-203 encoded).
320    pub ml_kem_ct: Vec<u8>,
321}
322
323// ─── Encoding helpers ─────────────────────────────────────────────────────
324//
325// `ml-kem` stores its byte-encoded keys and ciphertexts as `Encoded<T>`,
326// a `GenericArray<u8, N>` from the `hybrid-array` crate. We carry them on
327// the wire as `Vec<u8>` (borsh-friendly) and round-trip via these
328// helpers. Length mismatches return `None` so callers can map them to a
329// proper handshake / KEM error.
330
331fn 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    /// T4.2 (X-Wing / draft-ietf-tls-hybrid-design): the hybrid combiner must bind the
357    /// classical ciphertext (the sender's ephemeral pubkey) and the recipient classical
358    /// pubkey into the IKM, not just the two raw shared secrets — so the combined secret
359    /// commits to the full classical transcript and the construction's security does not
360    /// rest on the handshake signature alone. Changing either the ct or the pk while
361    /// holding both shared secrets fixed must change the combined secret.
362    #[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        // Same recipient, different sender ephemeral classical + different
391        // ML-KEM randomness → different shared secrets.
392        assert_ne!(ss1, ss2);
393    }
394
395    #[test]
396    fn ml_kem_ciphertext_size_matches_fips_203() {
397        // FIPS-203 ML-KEM-768 ciphertext is 1088 bytes.
398        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        // FIPS-203 ML-KEM-768 encap key is 1184 bytes.
406        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        // The recipient's decap yields the same secret as the sender's encap1.
417        assert_eq!(pt1, ss1);
418    }
419
420    /// Classical public key length matches the active backend.
421    #[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    /// fips-only: P-256 SEC1 uncompressed encoding starts with 0x04.
435    #[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}