wpa_next/network.rs
1// =============================================================================
2// network.rs — WPA-Next Frame Structures, Fragmentation & Protocol State Machine
3// =============================================================================
4//
5// WHY FRAGMENTATION IS NECESSARY
6// --------------------------------
7// 802.11 management frames (Probe Requests, Authentication frames, Association
8// Requests) are constrained by the MPDU size limit. In practice the usable
9// body of a management frame is ~2304 bytes, but vendor interoperability issues
10// and regulatory body information elements leave far less room — commonly only
11// 300–500 bytes of usable payload in an authentication exchange.
12//
13// ML-KEM-768 public keys are 1184 bytes and ciphertexts are 1088 bytes.
14// Neither fits in a single standard management frame alongside 802.11 headers,
15// RSN information elements, and other mandatory fields.
16//
17// WPA-Next SOLUTION: Layer 2 Fragmentation
18// ------------------------------------------
19// Instead of relying on IP-layer fragmentation (which doesn't exist pre-
20// association), WPA-Next defines its own application-layer fragmentation
21// protocol that operates entirely within the 802.11 management frame body.
22// Each fragment carries:
23// • A 4-byte Sequence_ID — ties all fragments of one logical message together
24// • A 1-byte Frag_Index — 0-based fragment number (0, 1, 2)
25// • A 1-byte Frag_Total — total number of fragments in this message
26// • A 2-byte Payload_Len — length of this fragment's payload slice
27// • A 48-byte Cookie — DoS-mitigation HMAC (only in Frag_Index == 0)
28// • Up to 400 bytes of payload
29//
30// This keeps every fragment well under 512 bytes — safe for any 802.11
31// management frame implementation. Three fragments reassemble the 1184-byte
32// ML-KEM-768 public key on the receiver.
33//
34// Fragmentation also lets the AP remain STATELESS until a valid cookie is
35// presented in the first fragment, preventing state-exhaustion DoS attacks.
36// =============================================================================
37
38use crate::crypto::{
39 compute_cookie, derive_session_key, mlkem_encapsulate, verify_cookie, CryptoError, MlKemKeyPair,
40 SecretBytes, SessionKey, X25519KeyPair, HMAC_LEN, MLKEM_CT_LEN, X25519_PK_LEN,
41};
42use rand_core::RngCore;
43use serde::{Deserialize, Serialize};
44use std::collections::HashMap;
45
46// ── Frame Constants ───────────────────────────────────────────────────────────
47
48/// Maximum payload bytes per fragment.
49/// Chosen so that full wire frame (header + cookie + payload) stays < 512 bytes.
50///
51/// Fragmentation math:
52/// MLKEM_PK_LEN (1184) / 3 = ~395 bytes → round down to 400 for alignment.
53/// Fragment 0: 400 bytes (bytes 0 – 399)
54/// Fragment 1: 400 bytes (bytes 400 – 799)
55/// Fragment 2: 384 bytes (bytes 800 – 1183)
56pub const FRAG_PAYLOAD_MAX: usize = 400;
57
58/// Number of fragments needed to carry one ML-KEM-768 public key.
59pub const MLKEM_PK_FRAG_COUNT: u8 = 3;
60
61// ── Stage 1: FastLinkFrame (Discovery) ───────────────────────────────────────
62//
63// Sent by a Station during the initial probe/discovery phase.
64// Carries only the 32-byte X25519 public key — fits easily in a single frame.
65// This is the "classical" arm of the hybrid handshake.
66
67/// WPA-Next Stage 1 — Discovery frame.
68/// Transmitted by the Station; received by the Access Point.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct FastLinkFrame {
71 /// Protocol magic bytes: b"WPAN" — validates frame type on receipt.
72 pub magic: [u8; 4],
73
74 /// Protocol version (currently 1).
75 pub version: u8,
76
77 /// Frame type discriminant: 0x01 = FastLinkFrame.
78 pub frame_type: u8,
79
80 /// Station's ephemeral X25519 public key (32 bytes).
81 /// Safe to transmit in plaintext — this is a Diffie-Hellman public value.
82 pub x25519_public_key: [u8; X25519_PK_LEN],
83
84 /// Station's MAC address (used as cookie input to bind the handshake).
85 pub station_mac: [u8; 6],
86}
87
88impl FastLinkFrame {
89 /// Protocol magic bytes identifying a WPA-Next frame.
90 pub const MAGIC: [u8; 4] = *b"WPAN";
91 /// Frame type discriminant for [`FastLinkFrame`] (Stage 1 Discovery).
92 pub const FRAME_TYPE: u8 = 0x01;
93
94 /// Create a new [`FastLinkFrame`] with the given X25519 public key and station MAC.
95 pub fn new(x25519_pk: [u8; X25519_PK_LEN], station_mac: [u8; 6]) -> Self {
96 FastLinkFrame {
97 magic: Self::MAGIC,
98 version: 1,
99 frame_type: Self::FRAME_TYPE,
100 x25519_public_key: x25519_pk,
101 station_mac,
102 }
103 }
104
105 /// Returns `true` if the magic, version, and frame_type fields are valid.
106 pub fn is_valid(&self) -> bool {
107 self.magic == Self::MAGIC && self.version == 1 && self.frame_type == Self::FRAME_TYPE
108 }
109}
110
111// ── Stage 2: FragmentedPQFrame (Quantization Phase) ──────────────────────────
112//
113// Carries the ML-KEM-768 public key (1184 bytes) split into 3 fragments.
114// Each fragment is a self-contained wire frame with its own header.
115// Fragments are sent sequentially; the receiver reassembles before processing.
116
117/// Header present on EVERY fragment of a fragmented PQ payload.
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct FragmentHeader {
120 /// Protocol magic (same as FastLinkFrame for quick demux).
121 pub magic: [u8; 4],
122
123 /// Frame type: 0x02 = FragmentedPQFrame.
124 pub frame_type: u8,
125
126 /// Ties all fragments of the same logical message together.
127 /// Generated fresh for each new ML-KEM public key transmission.
128 pub sequence_id: u32,
129
130 /// Zero-based fragment index (0, 1, or 2 for a 3-fragment message).
131 pub frag_index: u8,
132
133 /// Total number of fragments in this message (3 for ML-KEM-768 PK).
134 pub frag_total: u8,
135
136 /// Length of the payload slice carried by this specific fragment.
137 pub payload_len: u16,
138}
139
140/// A single fragment of a fragmented PQ payload — this is what goes over the air.
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct FragmentedPQFrame {
143 /// Frame header carrying sequence ID, fragment index, and length metadata.
144 pub header: FragmentHeader,
145
146 /// DoS-mitigation cookie: only populated on frag_index == 0.
147 /// HMAC-SHA384(ap_secret, station_mac || sequence_id) — 48 bytes.
148 /// Subsequent fragments carry all zeros here (cookie already validated).
149 /// Stored as Vec<u8> (always HMAC_LEN bytes) — serde supports Vec but not [u8; 48].
150 pub cookie: Vec<u8>,
151
152 /// The actual payload slice (≤ FRAG_PAYLOAD_MAX bytes).
153 pub payload: Vec<u8>,
154}
155
156impl FragmentedPQFrame {
157 /// Frame type discriminant for [`FragmentedPQFrame`] (Stage 2 Quantization).
158 pub const FRAME_TYPE: u8 = 0x02;
159}
160
161/// Fragment a large byte blob (e.g., ML-KEM public key) into wire frames.
162///
163/// The `cookie` parameter must be pre-computed by the AP and is embedded only
164/// in fragment 0. This lets the Station prove it has the cookie before the AP
165/// allocates reassembly state for fragments 1 and 2.
166pub fn fragment_payload(
167 payload: &[u8],
168 sequence_id: u32,
169 cookie: &[u8; HMAC_LEN],
170) -> Vec<FragmentedPQFrame> {
171 let chunks: Vec<&[u8]> = payload.chunks(FRAG_PAYLOAD_MAX).collect();
172 let frag_total = chunks.len() as u8;
173 let mut frames = Vec::with_capacity(chunks.len());
174
175 for (idx, chunk) in chunks.iter().enumerate() {
176 let frag_index = idx as u8;
177 // Cookie only in first fragment — AP validates before allocating state.
178 let frame_cookie = if frag_index == 0 { cookie.to_vec() } else { vec![0u8; HMAC_LEN] };
179
180 frames.push(FragmentedPQFrame {
181 header: FragmentHeader {
182 magic: FastLinkFrame::MAGIC,
183 frame_type: FragmentedPQFrame::FRAME_TYPE,
184 sequence_id,
185 frag_index,
186 frag_total,
187 payload_len: chunk.len() as u16,
188 },
189 cookie: frame_cookie,
190 payload: chunk.to_vec(),
191 });
192 }
193 frames
194}
195
196/// Reassemble fragments into the original payload.
197/// Returns `None` if fragments are incomplete, out-of-order, or inconsistent.
198pub fn reassemble_fragments(frames: &[FragmentedPQFrame]) -> Option<Vec<u8>> {
199 if frames.is_empty() {
200 return None;
201 }
202 let frag_total = frames[0].header.frag_total as usize;
203 if frames.len() != frag_total {
204 return None; // Not all fragments received yet.
205 }
206
207 // Sort by frag_index to handle out-of-order delivery.
208 let mut sorted = frames.to_vec();
209 sorted.sort_by_key(|f| f.header.frag_index);
210
211 // Validate contiguity and shared sequence_id.
212 let seq_id = sorted[0].header.sequence_id;
213 for (expected_idx, frame) in sorted.iter().enumerate() {
214 if frame.header.frag_index as usize != expected_idx {
215 return None; // Gap or duplicate.
216 }
217 if frame.header.sequence_id != seq_id {
218 return None; // Mixed sequence IDs — discard.
219 }
220 }
221
222 let mut reassembled = Vec::new();
223 for frame in &sorted {
224 reassembled.extend_from_slice(&frame.payload[..frame.header.payload_len as usize]);
225 }
226 Some(reassembled)
227}
228
229// ── Access Point State Machine ────────────────────────────────────────────────
230//
231// The AP starts STATELESS. It only allocates per-station state after receiving
232// a fragment-0 frame with a valid DoS cookie. This prevents state-exhaustion
233// attacks where an adversary floods the AP with fragment-1/2 frames to fill
234// reassembly buffers.
235
236/// Per-station reassembly state held by the AP.
237#[derive(Debug)]
238struct StationHandshakeState {
239 /// Station's X25519 public key from the FastLinkFrame.
240 x25519_pk: [u8; X25519_PK_LEN],
241 /// Partially reassembled ML-KEM ciphertext fragments.
242 fragments: Vec<FragmentedPQFrame>,
243 /// How many fragments we expect for this sequence.
244 frag_total: u8,
245}
246
247/// Access Point — starts stateless, accepts connections.
248pub struct AccessPoint {
249 /// MAC address of this AP (informational; used in cookie binding).
250 #[allow(dead_code)]
251 pub mac: [u8; 6],
252
253 /// AP's ML-KEM-768 key pair. The public key is distributed to stations.
254 mlkem_kp: MlKemKeyPair,
255
256 /// AP's ephemeral X25519 key pair. Regenerated per station in a real impl.
257 x25519_kp: Option<X25519KeyPair>,
258
259 /// AP's secret used for DoS-mitigation cookie generation.
260 /// Rotated periodically; never leaves the AP.
261 cookie_secret: [u8; 32],
262
263 /// Per-station reassembly state. Only populated after valid cookie is seen.
264 /// Key = station MAC address as a u64 (6 bytes, zero-padded).
265 station_state: HashMap<u64, StationHandshakeState>,
266}
267
268impl AccessPoint {
269 /// Create a new AccessPoint with fresh cryptographic material.
270 pub fn new(mac: [u8; 6]) -> Result<Self, NetworkError> {
271 let mut secret = [0u8; 32];
272 rand_core::OsRng.fill_bytes(&mut secret);
273
274 Ok(AccessPoint {
275 mac,
276 mlkem_kp: MlKemKeyPair::generate().map_err(NetworkError::Crypto)?,
277 x25519_kp: Some(X25519KeyPair::generate().map_err(NetworkError::Crypto)?),
278 cookie_secret: secret,
279 station_state: HashMap::new(),
280 })
281 }
282
283 /// Returns the AP's ML-KEM-768 public key bytes (1184 bytes).
284 /// This will be fragmented by `build_pk_fragments` before transmission.
285 pub fn mlkem_public_key_bytes(&self) -> Vec<u8> {
286 self.mlkem_kp.public_key_bytes()
287 }
288
289 /// Returns the AP's X25519 public key bytes (32 bytes).
290 pub fn x25519_public_key_bytes(&self) -> Option<[u8; X25519_PK_LEN]> {
291 self.x25519_kp.as_ref().map(|kp| kp.public_key_bytes)
292 }
293
294 /// Build the cookie for a given station, to be embedded in fragment-0.
295 pub fn build_cookie(&self, station_mac: &[u8; 6], sequence_id: u32) -> [u8; HMAC_LEN] {
296 compute_cookie(&self.cookie_secret, station_mac, sequence_id)
297 }
298
299 /// Process a Stage-1 FastLinkFrame from a Station.
300 ///
301 /// STATELESS at this point — no per-station memory allocated.
302 /// The AP records the station's X25519 key and issues a cookie challenge.
303 /// In a real AP, the cookie would be returned in a management frame;
304 /// here we return it directly so the Station can embed it in fragment-0.
305 pub fn process_fast_link_frame(
306 &mut self,
307 frame: &FastLinkFrame,
308 sequence_id: u32,
309 ) -> Result<[u8; HMAC_LEN], NetworkError> {
310 if !frame.is_valid() {
311 return Err(NetworkError::InvalidFrame("FastLinkFrame magic/version check failed"));
312 }
313
314 println!(
315 "[AP] Received FastLinkFrame from station {:02X?} — issuing cookie challenge",
316 frame.station_mac
317 );
318
319 // Issue cookie challenge — cheap, stateless.
320 let cookie = self.build_cookie(&frame.station_mac, sequence_id);
321 Ok(cookie)
322 }
323
324 /// Process an incoming fragment. Allocates state only after valid cookie.
325 ///
326 /// Returns `Some(SessionKey)` when all fragments have been reassembled,
327 /// the ML-KEM ciphertext has been decapsulated, and the session key derived.
328 pub fn process_fragment(
329 &mut self,
330 frame: &FragmentedPQFrame,
331 station_mac: &[u8; 6],
332 station_x25519_pk: &[u8; X25519_PK_LEN],
333 ) -> Result<Option<SessionKey>, NetworkError> {
334 let station_id = mac_to_u64(station_mac);
335 let seq_id = frame.header.sequence_id;
336
337 if frame.header.frag_index == 0 {
338 // ── Fragment 0: validate cookie BEFORE allocating state ──────────
339 // This is the critical DoS-mitigation step. An attacker without the
340 // cookie cannot force the AP to allocate reassembly buffers.
341 let cookie_arr: &[u8; HMAC_LEN] = frame.cookie.as_slice().try_into()
342 .map_err(|_| NetworkError::InvalidCookie)?;
343 if !verify_cookie(&self.cookie_secret, station_mac, seq_id, cookie_arr) {
344 println!("[AP] Cookie verification FAILED for station {:02X?} — dropping", station_mac);
345 return Err(NetworkError::InvalidCookie);
346 }
347 println!("[AP] Cookie verified for station {:02X?} — allocating reassembly state", station_mac);
348
349 // Safe to allocate state now.
350 self.station_state.insert(
351 station_id,
352 StationHandshakeState {
353 x25519_pk: *station_x25519_pk,
354 fragments: vec![frame.clone()],
355 frag_total: frame.header.frag_total,
356 },
357 );
358 return Ok(None); // Wait for more fragments.
359 }
360
361 // ── Fragment 1 / 2: state must already exist ─────────────────────────
362 let state = self
363 .station_state
364 .get_mut(&station_id)
365 .ok_or(NetworkError::UnknownStation)?;
366
367 state.fragments.push(frame.clone());
368
369 if state.fragments.len() < state.frag_total as usize {
370 println!(
371 "[AP] Fragment {}/{} received for station {:02X?}",
372 state.fragments.len(),
373 state.frag_total,
374 station_mac
375 );
376 return Ok(None); // Still waiting.
377 }
378
379 // ── All fragments received — reassemble and complete handshake ────────
380 println!("[AP] All {} fragments received — reassembling ML-KEM ciphertext", state.frag_total);
381
382 let ciphertext = reassemble_fragments(&state.fragments)
383 .ok_or(NetworkError::ReassemblyFailed)?;
384
385 if ciphertext.len() != MLKEM_CT_LEN {
386 return Err(NetworkError::InvalidFrame("Reassembled payload length mismatch"));
387 }
388
389 // ML-KEM decapsulation → PQ shared secret
390 let pq_ss = self
391 .mlkem_kp
392 .decapsulate(&ciphertext)
393 .map_err(NetworkError::Crypto)?;
394 println!("[AP] ML-KEM-768 decapsulation successful");
395
396 // X25519 ECDH → classical shared secret
397 let x25519_kp = self
398 .x25519_kp
399 .take()
400 .ok_or(NetworkError::X25519Consumed)?;
401 let classical_ss = x25519_kp
402 .diffie_hellman(&state.x25519_pk)
403 .map_err(NetworkError::Crypto)?;
404 println!("[AP] X25519 ECDH successful");
405
406 // Hybrid combine via HKDF-SHA384
407 let session_key = derive_session_key(&classical_ss, &pq_ss)
408 .map_err(NetworkError::Crypto)?;
409 println!("[AP] Session key derived via HKDF-SHA384 hybrid combiner");
410
411 // Clean up station state.
412 self.station_state.remove(&station_id);
413
414 Ok(Some(session_key))
415 }
416}
417
418// ── Station (Client) State Machine ───────────────────────────────────────────
419
420/// Station (Wi-Fi client) — initiates the WPA-Next handshake.
421pub struct Station {
422 /// MAC address of this station.
423 pub mac: [u8; 6],
424
425 /// Station's ephemeral X25519 key pair.
426 x25519_kp: Option<X25519KeyPair>,
427}
428
429impl Station {
430 /// Create a new [`Station`] with a fresh ephemeral X25519 key pair.
431 pub fn new(mac: [u8; 6]) -> Result<Self, NetworkError> {
432 Ok(Station {
433 mac,
434 x25519_kp: Some(X25519KeyPair::generate().map_err(NetworkError::Crypto)?),
435 })
436 }
437
438 /// Build Stage 1: FastLinkFrame containing the station's X25519 public key.
439 pub fn build_fast_link_frame(&self) -> Result<FastLinkFrame, NetworkError> {
440 let pk = self
441 .x25519_kp
442 .as_ref()
443 .ok_or(NetworkError::X25519Consumed)?
444 .public_key_bytes;
445 Ok(FastLinkFrame::new(pk, self.mac))
446 }
447
448 /// Returns the station's X25519 public key (needed by AP for ECDH).
449 pub fn x25519_public_key_bytes(&self) -> Result<[u8; X25519_PK_LEN], NetworkError> {
450 self.x25519_kp
451 .as_ref()
452 .map(|kp| kp.public_key_bytes)
453 .ok_or(NetworkError::X25519Consumed)
454 }
455
456 /// Build Stage 2: fragment the AP's ML-KEM public key, encapsulate,
457 /// and produce a Vec of `FragmentedPQFrame`s ready for transmission.
458 ///
459 /// Returns the frames AND the PQ shared secret (kept locally, not sent).
460 pub fn build_pq_fragments(
461 &self,
462 ap_mlkem_pk: &[u8],
463 sequence_id: u32,
464 cookie: &[u8; HMAC_LEN],
465 ) -> Result<(Vec<FragmentedPQFrame>, SecretBytes), NetworkError> {
466 // Encapsulate against AP's ML-KEM public key.
467 let (ciphertext, pq_ss) =
468 mlkem_encapsulate(ap_mlkem_pk).map_err(NetworkError::Crypto)?;
469
470 println!(
471 "[Station] ML-KEM-768 encapsulation successful — ciphertext {} bytes",
472 ciphertext.len()
473 );
474
475 // Fragment the ciphertext (1088 bytes → 3 frames of ≤400 bytes each).
476 let frames = fragment_payload(&ciphertext, sequence_id, cookie);
477 println!(
478 "[Station] Ciphertext split into {} fragments (max {} bytes each)",
479 frames.len(),
480 FRAG_PAYLOAD_MAX
481 );
482
483 Ok((frames, pq_ss))
484 }
485
486 /// Complete the Station side of the handshake:
487 /// Perform X25519 ECDH with the AP's public key and derive the session key.
488 pub fn complete_handshake(
489 mut self,
490 ap_x25519_pk: &[u8; X25519_PK_LEN],
491 pq_ss: SecretBytes,
492 ) -> Result<SessionKey, NetworkError> {
493 let x25519_kp = self.x25519_kp.take().ok_or(NetworkError::X25519Consumed)?;
494 let classical_ss = x25519_kp
495 .diffie_hellman(ap_x25519_pk)
496 .map_err(NetworkError::Crypto)?;
497 println!("[Station] X25519 ECDH successful");
498
499 let session_key = derive_session_key(&classical_ss, &pq_ss)
500 .map_err(NetworkError::Crypto)?;
501 println!("[Station] Session key derived via HKDF-SHA384 hybrid combiner");
502
503 Ok(session_key)
504 }
505}
506
507// ── Utilities ─────────────────────────────────────────────────────────────────
508
509fn mac_to_u64(mac: &[u8; 6]) -> u64 {
510 let mut buf = [0u8; 8];
511 buf[2..8].copy_from_slice(mac);
512 u64::from_be_bytes(buf)
513}
514
515// ── Error Types ───────────────────────────────────────────────────────────────
516
517/// Errors that can occur during WPA-Next network / protocol operations.
518#[derive(Debug, thiserror::Error)]
519pub enum NetworkError {
520 /// An underlying cryptographic primitive failed.
521 #[error("Cryptographic operation failed: {0}")]
522 Crypto(#[from] CryptoError),
523
524 /// A received frame had an invalid magic, version, or type field.
525 #[error("Invalid frame: {0}")]
526 InvalidFrame(&'static str),
527
528 /// The DoS-mitigation cookie in a fragment-0 frame did not verify.
529 #[error("DoS cookie verification failed")]
530 InvalidCookie,
531
532 /// Fragment reassembly failed — fragments were incomplete, out-of-range, or had mismatched sequence IDs.
533 #[error("Fragment reassembly failed — incomplete or inconsistent fragments")]
534 ReassemblyFailed,
535
536 /// A non-initial fragment arrived with no prior reassembly state (fragment 0 was never received or cookie failed).
537 #[error("Unknown station — received non-initial fragment without prior state")]
538 UnknownStation,
539
540 /// The X25519 key pair was already consumed — each ephemeral key pair is single-use.
541 #[error("X25519 key pair already consumed (single-use)")]
542 X25519Consumed,
543}