Skip to main content

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}