Skip to main content

wpa_next/
crypto.rs

1// =============================================================================
2// crypto.rs — WPA-Next Cryptographic Stack
3// =============================================================================
4//
5// Implements a hybrid classical + post-quantum key exchange:
6//
7//   1. Classical Layer  : X25519 ECDH  (via `ring`)
8//   2. Post-Quantum Layer: ML-KEM-768  (FIPS 203, via `libcrux-ml-kem`)
9//   3. Hybrid Combiner  : HKDF-SHA-384 merging both shared secrets
10//
11// Security rationale
12// ------------------
13//   The hybrid design ensures that an adversary must break *both* X25519 (hard
14//   for classical computers today) AND ML-KEM-768 (hard for quantum computers)
15//   to recover the session key. Neither primitive alone is sufficient.
16//
17//   HKDF is used as a KDF combiner per the pattern recommended in
18//   draft-ietf-tls-hybrid-design: both secrets are fed as IKM with a
19//   protocol-specific info string so that cross-protocol attacks are prevented.
20// =============================================================================
21
22use hkdf::Hkdf;
23use hmac::{Hmac, Mac};
24use rand_core::RngCore;
25use ring::agreement::{self, EphemeralPrivateKey, UnparsedPublicKey, X25519};
26use ring::rand::SystemRandom;
27use sha2::Sha384;
28use subtle::ConstantTimeEq;
29use zeroize::{Zeroize, ZeroizeOnDrop};
30
31// ── ML-KEM-768 (FIPS 203) ────────────────────────────────────────────────────
32// Correct API for libcrux-ml-kem 0.0.2:
33//   generate_key_pair(seed)         -> MlKem768KeyPair
34//   key_pair.public_key()           -> &MlKem768PublicKey
35//   key_pair.private_key()          -> &MlKem768PrivateKey  (opaque wrapper)
36//   encapsulate(pk, randomness)     -> (MlKem768Ciphertext, MlKemSharedSecret)
37//   decapsulate(private_key(), &ct) -> MlKemSharedSecret
38use libcrux_ml_kem::mlkem768::{self, MlKem768Ciphertext, MlKem768KeyPair, MlKem768PublicKey};
39
40// ── Constants ─────────────────────────────────────────────────────────────────
41
42/// Length of the final WPA-Next session key (256-bit AES-GCM key)
43pub const SESSION_KEY_LEN: usize = 32;
44
45/// ML-KEM-768 public key size (bytes) — dictated by FIPS 203 parameter set
46pub const MLKEM_PK_LEN: usize = 1184;
47
48/// ML-KEM-768 ciphertext size (encapsulated key) in bytes
49pub const MLKEM_CT_LEN: usize = 1088;
50
51/// X25519 public key size (bytes)
52pub const X25519_PK_LEN: usize = 32;
53
54/// HMAC-SHA384 output length used for cookie challenges
55pub const HMAC_LEN: usize = 48;
56
57// ── Zeroizing Key Wrappers ───────────────────────────────────────────────────
58
59/// A session key that is automatically zeroed when dropped.
60/// This prevents secrets from lingering in heap / stack memory after use.
61#[derive(Zeroize, ZeroizeOnDrop)]
62pub struct SessionKey(pub [u8; SESSION_KEY_LEN]);
63
64impl SessionKey {
65    /// Constant-time equality check — never branches on secret data.
66    ///
67    /// Use this instead of `==` whenever comparing session keys to prevent
68    /// timing side-channel attacks.
69    #[allow(dead_code)]
70    pub fn ct_eq(&self, other: &SessionKey) -> bool {
71        self.0.ct_eq(&other.0).into()
72    }
73}
74
75impl std::fmt::Debug for SessionKey {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        // Never print the raw key; show a safe placeholder in debug output.
78        write!(f, "SessionKey([REDACTED])")
79    }
80}
81
82/// Zeroizing wrapper for any heap-allocated secret bytes.
83#[derive(Zeroize, ZeroizeOnDrop)]
84pub struct SecretBytes(pub Vec<u8>);
85
86// ── X25519 (Classical ECDH Layer) ────────────────────────────────────────────
87
88/// Holds an ephemeral X25519 key pair for one handshake leg.
89pub struct X25519KeyPair {
90    /// The private key — consumed (moved) on use to enforce single-use semantics.
91    private_key: Option<EphemeralPrivateKey>,
92    /// 32-byte public key bytes, safe to transmit over the air.
93    pub public_key_bytes: [u8; X25519_PK_LEN],
94}
95
96impl X25519KeyPair {
97    /// Generate a fresh ephemeral X25519 key pair.
98    pub fn generate() -> Result<Self, CryptoError> {
99        let rng = SystemRandom::new();
100        let private_key =
101            EphemeralPrivateKey::generate(&X25519, &rng).map_err(|_| CryptoError::KeyGen)?;
102        let public_key = private_key.compute_public_key().map_err(|_| CryptoError::KeyGen)?;
103        let mut pk_bytes = [0u8; X25519_PK_LEN];
104        pk_bytes.copy_from_slice(public_key.as_ref());
105        Ok(X25519KeyPair {
106            private_key: Some(private_key),
107            public_key_bytes: pk_bytes,
108        })
109    }
110
111    /// Consume the private key and perform ECDH with the peer's public key.
112    /// Returns the 32-byte shared secret wrapped in a zeroizing container.
113    pub fn diffie_hellman(
114        mut self,
115        peer_public_key_bytes: &[u8; X25519_PK_LEN],
116    ) -> Result<SecretBytes, CryptoError> {
117        let private_key = self.private_key.take().ok_or(CryptoError::AlreadyUsed)?;
118        let peer_pk = UnparsedPublicKey::new(&X25519, peer_public_key_bytes.as_ref());
119        let shared = agreement::agree_ephemeral(private_key, &peer_pk, |ss| {
120            Ok::<SecretBytes, CryptoError>(SecretBytes(ss.to_vec()))
121        })
122        .map_err(|_| CryptoError::Ecdh)??;
123        Ok(shared)
124    }
125}
126
127// ── ML-KEM-768 (Post-Quantum Layer, FIPS 203) ────────────────────────────────
128
129/// ML-KEM-768 key pair for the Access Point's initiating encapsulation.
130pub struct MlKemKeyPair {
131    inner: MlKem768KeyPair,
132}
133
134impl MlKemKeyPair {
135    /// Generate an ML-KEM-768 key pair using OS entropy.
136    pub fn generate() -> Result<Self, CryptoError> {
137        // libcrux requires a 64-byte seed drawn from a CSPRNG.
138        let mut seed = [0u8; 64];
139        rand_core::OsRng.fill_bytes(&mut seed);
140        let kp = mlkem768::generate_key_pair(seed);
141        Ok(MlKemKeyPair { inner: kp })
142    }
143
144    /// Returns the 1184-byte public key (to be fragmented across the air).
145    pub fn public_key_bytes(&self) -> Vec<u8> {
146        self.inner.public_key().as_ref().to_vec()
147    }
148
149    /// Decapsulate an incoming ciphertext, recovering the shared secret.
150    /// The secret is wrapped in `SecretBytes` and zeroed on drop.
151    pub fn decapsulate(&self, ciphertext: &[u8]) -> Result<SecretBytes, CryptoError> {
152        if ciphertext.len() != MLKEM_CT_LEN {
153            return Err(CryptoError::InvalidCiphertext);
154        }
155        let ct_arr: [u8; MLKEM_CT_LEN] = ciphertext.try_into().unwrap();
156        let ct = MlKem768Ciphertext::from(ct_arr);
157        let ss = mlkem768::decapsulate(self.inner.private_key(), &ct);
158        Ok(SecretBytes(ss.as_ref().to_vec()))
159    }
160}
161
162/// Encapsulate against a peer's ML-KEM-768 public key.
163/// Returns (ciphertext to send, shared secret to keep).
164pub fn mlkem_encapsulate(
165    public_key_bytes: &[u8],
166) -> Result<(Vec<u8>, SecretBytes), CryptoError> {
167    if public_key_bytes.len() != MLKEM_PK_LEN {
168        return Err(CryptoError::InvalidPublicKey);
169    }
170    let pk_arr: [u8; MLKEM_PK_LEN] = public_key_bytes.try_into().unwrap();
171    let pk = MlKem768PublicKey::from(pk_arr);
172
173    let mut rand_bytes = [0u8; 32];
174    rand_core::OsRng.fill_bytes(&mut rand_bytes);
175
176    let (ct, ss) = mlkem768::encapsulate(&pk, rand_bytes);
177    Ok((ct.as_ref().to_vec(), SecretBytes(ss.as_ref().to_vec())))
178}
179
180// ── Hybrid Combiner: HKDF-SHA-384 ────────────────────────────────────────────
181//
182// Protocol: WPA-Next Hybrid Combiner v1
183//
184// IKM  = classical_ss || pq_ss
185// Salt = "WPA-Next-v1-hybrid-salt"  (static, protocol-versioned)
186// Info = "WPA-Next-v1-session-key"  (binds output to this protocol + purpose)
187//
188// This construction follows the concatenation-based hybrid KDF pattern
189// recommended in NIST SP 800-227 (draft) and draft-ietf-tls-hybrid-design.
190// Feeding both secrets as IKM means that if either is strong, the output
191// is computationally indistinguishable from random.
192
193const HYBRID_SALT: &[u8] = b"WPA-Next-v1-hybrid-salt";
194const HYBRID_INFO: &[u8] = b"WPA-Next-v1-session-key";
195
196/// Derive the final session key from the classical and PQ shared secrets.
197pub fn derive_session_key(
198    classical_ss: &SecretBytes,
199    pq_ss: &SecretBytes,
200) -> Result<SessionKey, CryptoError> {
201    // Concatenate both shared secrets as IKM (order is protocol-defined,
202    // must be the same on both ends).
203    let mut ikm = Vec::with_capacity(classical_ss.0.len() + pq_ss.0.len());
204    ikm.extend_from_slice(&classical_ss.0);
205    ikm.extend_from_slice(&pq_ss.0);
206
207    let hk = Hkdf::<Sha384>::new(Some(HYBRID_SALT), &ikm);
208    let mut okm = [0u8; SESSION_KEY_LEN];
209    hk.expand(HYBRID_INFO, &mut okm)
210        .map_err(|_| CryptoError::Hkdf)?;
211
212    // Zeroize the intermediate concatenated IKM immediately.
213    let mut ikm_zeroize = ikm;
214    ikm_zeroize.zeroize();
215
216    Ok(SessionKey(okm))
217}
218
219// ── Cookie / DoS-Mitigation Challenge ────────────────────────────────────────
220//
221// Before allocating reassembly state for a fragmented PQ frame, the AP issues
222// a cheap HMAC challenge. Only a responder that demonstrates knowledge of the
223// cookie can trigger buffer allocation, preventing fragment-flooding DoS.
224
225type HmacSha384 = Hmac<Sha384>;
226
227/// Compute a DoS-mitigation cookie: HMAC-SHA384(ap_secret, peer_addr || seq_id)
228pub fn compute_cookie(
229    ap_secret: &[u8; 32],
230    peer_addr: &[u8; 6],
231    sequence_id: u32,
232) -> [u8; HMAC_LEN] {
233    let mut mac = <HmacSha384 as Mac>::new_from_slice(ap_secret)
234        .expect("HMAC accepts any key length");
235    mac.update(peer_addr);
236    mac.update(&sequence_id.to_be_bytes());
237    mac.update(b"WPA-Next-cookie-v1");
238    let result = mac.finalize().into_bytes();
239    let mut out = [0u8; HMAC_LEN];
240    out.copy_from_slice(&result);
241    out
242}
243
244/// Constant-time cookie verification.
245pub fn verify_cookie(
246    ap_secret: &[u8; 32],
247    peer_addr: &[u8; 6],
248    sequence_id: u32,
249    candidate: &[u8; HMAC_LEN],
250) -> bool {
251    let expected = compute_cookie(ap_secret, peer_addr, sequence_id);
252    // subtle::ConstantTimeEq: never short-circuits, thwarting timing attacks.
253    expected.ct_eq(candidate).into()
254}
255
256// ── Error Types ───────────────────────────────────────────────────────────────
257
258/// Errors that can occur during WPA-Next cryptographic operations.
259#[derive(Debug, thiserror::Error)]
260pub enum CryptoError {
261    /// Key generation failed due to insufficient entropy or an RNG error.
262    #[error("Key generation failed")]
263    KeyGen,
264    /// X25519 ECDH key agreement failed (invalid peer public key).
265    #[error("X25519 ECDH agreement failed")]
266    Ecdh,
267    /// HKDF-SHA384 expansion failed (output length too large).
268    #[error("HKDF expansion failed (output too long)")]
269    Hkdf,
270    /// The ephemeral private key was already consumed — each key pair is single-use.
271    #[error("Private key already consumed (single-use ephemeral)")]
272    AlreadyUsed,
273    /// ML-KEM ciphertext was not exactly [`MLKEM_CT_LEN`] bytes.
274    #[error("Invalid ML-KEM ciphertext length")]
275    InvalidCiphertext,
276    /// ML-KEM public key was not exactly [`MLKEM_PK_LEN`] bytes.
277    #[error("Invalid ML-KEM public key length")]
278    InvalidPublicKey,
279}