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}