Skip to main content

netget/server/eapol/
codec.rs

1//! Pure EAPOL / EAP codec — IEEE 802.1X-2004 §11.3 and RFC 3748.
2//!
3//! No I/O, no locks, no async, no state. Everything here is a total function over bytes, so
4//! `tests/server/eapol/codec_test.rs` can pin it against literal specification bytes without
5//! a socket, an interface or a privilege. That split is the point: the transport in `mod.rs`
6//! is a thin shell over these functions, and the transport is the part that has never run.
7//!
8//! # The one rule this file exists to enforce
9//!
10//! **An `EAP-Success` frame is an admission decision**, so there is exactly one function in
11//! this crate that can produce those bytes — [`eapol_eap_success_frame`] — and it shares no
12//! code with [`eapol_eap_failure_frame`]. Neither takes a code, a boolean, or anything else
13//! that could select the other. `grep -rn EAP_CODE_SUCCESS src/ --include='*.rs'` finds five
14//! code sites and exactly one of them writes the octet: the constant, that one builder, the
15//! `EapPacket::decode` arm, `eap_code_name`, and `classify_frame` in `mod.rs`. The other four
16//! only read. A sixth site, or a second one that constructs a frame, wants careful reading.
17//!
18//! A generic `encode_eap_result(code, id)` would be shorter and is precisely what must not
19//! exist: one wrong argument, one inverted condition, and an authentication bypass is a
20//! single character wide.
21
22use md5::{Digest, Md5};
23use std::fmt;
24
25// ---------------------------------------------------------------------------
26// Wire constants
27// ---------------------------------------------------------------------------
28
29/// EtherType carrying EAPOL, IEEE 802.1X-2004 §11.3.1.
30pub const ETHERTYPE_EAPOL: u16 = 0x888E;
31
32/// The PAE group address supplicants send to, IEEE 802.1X-2004 §7.8 Table 7-2.
33pub const PAE_GROUP_ADDRESS: [u8; 6] = [0x01, 0x80, 0xC2, 0x00, 0x00, 0x03];
34
35/// Destination MAC (6) + source MAC (6) + EtherType (2).
36pub const ETHERNET_HEADER_LEN: usize = 14;
37
38/// Protocol version (1) + packet type (1) + body length (2).
39pub const EAPOL_HEADER_LEN: usize = 4;
40
41/// Code (1) + identifier (1) + length (2).
42pub const EAP_HEADER_LEN: usize = 4;
43
44/// An Ethernet frame is padded out to 60 octets before the FCS. EAPOL frames are far
45/// shorter than that, so trailing padding is the normal case and must be ignored rather
46/// than parsed.
47pub const ETHERNET_MIN_FRAME_LEN: usize = 60;
48
49// EAPOL packet types (IEEE 802.1X-2004 Table 11-3).
50pub const EAPOL_TYPE_EAP_PACKET: u8 = 0;
51pub const EAPOL_TYPE_START: u8 = 1;
52pub const EAPOL_TYPE_LOGOFF: u8 = 2;
53pub const EAPOL_TYPE_KEY: u8 = 3;
54pub const EAPOL_TYPE_ASF_ALERT: u8 = 4;
55
56/// 802.1X-2001. Still what many supplicants put on the wire.
57pub const EAPOL_VERSION_2001: u8 = 1;
58/// 802.1X-2004. NetGet's default.
59pub const EAPOL_VERSION_2004: u8 = 2;
60/// 802.1X-2010.
61pub const EAPOL_VERSION_2010: u8 = 3;
62
63// EAP codes (RFC 3748 §4).
64pub const EAP_CODE_REQUEST: u8 = 1;
65pub const EAP_CODE_RESPONSE: u8 = 2;
66pub const EAP_CODE_SUCCESS: u8 = 3;
67pub const EAP_CODE_FAILURE: u8 = 4;
68
69// EAP method types (RFC 3748 §5, and the IANA EAP Method Type registry).
70pub const EAP_TYPE_IDENTITY: u8 = 1;
71pub const EAP_TYPE_NOTIFICATION: u8 = 2;
72pub const EAP_TYPE_NAK: u8 = 3;
73pub const EAP_TYPE_MD5_CHALLENGE: u8 = 4;
74pub const EAP_TYPE_TLS: u8 = 13;
75pub const EAP_TYPE_PEAP: u8 = 25;
76pub const EAP_TYPE_MSCHAPV2: u8 = 26;
77
78/// EAP-TLS flag bits, RFC 5216 §3.1. Shared by PEAP (RFC 7170 §3.1 uses the same layout).
79pub const TLS_FLAG_LENGTH_INCLUDED: u8 = 0x80;
80pub const TLS_FLAG_MORE_FRAGMENTS: u8 = 0x40;
81pub const TLS_FLAG_START: u8 = 0x20;
82
83/// RFC 1994 §2.2 recommends a challenge of at least the digest length.
84pub const MD5_CHALLENGE_LEN: usize = 16;
85
86// ---------------------------------------------------------------------------
87// Errors
88// ---------------------------------------------------------------------------
89
90/// Everything that can be wrong with a frame. Deliberately specific: an authenticator that
91/// logs "bad packet" tells an operator nothing about which of six layers failed.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub enum CodecError {
94    /// Fewer bytes than the layer's fixed header needs.
95    TooShort {
96        layer: &'static str,
97        needed: usize,
98        got: usize,
99    },
100    /// EAPOL protocol version outside 1..=3.
101    UnsupportedVersion(u8),
102    /// A declared length that the buffer cannot satisfy, or that is below the fixed header.
103    BadLength {
104        layer: &'static str,
105        declared: usize,
106        available: usize,
107    },
108    /// A Request or Response with no type octet, or a Success/Failure that is not 4 octets.
109    MalformedEap(&'static str),
110    /// Not the EtherType this server serves.
111    NotEapol(u16),
112    /// A MAC address that is not six colon- or dash-separated hex octets.
113    BadMac(String),
114    /// A field that will not fit the octet count the wire format gives it.
115    FieldTooLong {
116        field: &'static str,
117        max: usize,
118        got: usize,
119    },
120}
121
122impl fmt::Display for CodecError {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        match self {
125            CodecError::TooShort { layer, needed, got } => {
126                write!(f, "{} needs at least {} octets, got {}", layer, needed, got)
127            }
128            CodecError::UnsupportedVersion(v) => write!(
129                f,
130                "EAPOL protocol version {} is not 1 (802.1X-2001), 2 (802.1X-2004) or 3 \
131                 (802.1X-2010)",
132                v
133            ),
134            CodecError::BadLength {
135                layer,
136                declared,
137                available,
138            } => write!(
139                f,
140                "{} declares a length of {} octets but only {} are present",
141                layer, declared, available
142            ),
143            CodecError::MalformedEap(detail) => write!(f, "malformed EAP packet: {}", detail),
144            CodecError::NotEapol(ethertype) => write!(
145                f,
146                "EtherType 0x{:04x} is not EAPOL (0x{:04x})",
147                ethertype, ETHERTYPE_EAPOL
148            ),
149            CodecError::BadMac(s) => write!(
150                f,
151                "'{}' is not a MAC address; expected six hex octets like 02:00:00:00:00:01",
152                s
153            ),
154            CodecError::FieldTooLong { field, max, got } => write!(
155                f,
156                "{} is {} octets; the wire format allows at most {}",
157                field, got, max
158            ),
159        }
160    }
161}
162
163impl std::error::Error for CodecError {}
164
165pub type CodecResult<T> = Result<T, CodecError>;
166
167// ---------------------------------------------------------------------------
168// Ethernet
169// ---------------------------------------------------------------------------
170
171/// A parsed Ethernet II frame. `payload` has any trailing pad removed only insofar as the
172/// EAPOL layer declares its own length — Ethernet itself cannot tell pad from data.
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct EthernetFrame {
175    pub destination: [u8; 6],
176    pub source: [u8; 6],
177    pub ethertype: u16,
178    pub payload: Vec<u8>,
179}
180
181pub fn parse_ethernet_frame(bytes: &[u8]) -> CodecResult<EthernetFrame> {
182    if bytes.len() < ETHERNET_HEADER_LEN {
183        return Err(CodecError::TooShort {
184            layer: "Ethernet header",
185            needed: ETHERNET_HEADER_LEN,
186            got: bytes.len(),
187        });
188    }
189    let mut destination = [0u8; 6];
190    let mut source = [0u8; 6];
191    destination.copy_from_slice(&bytes[0..6]);
192    source.copy_from_slice(&bytes[6..12]);
193    Ok(EthernetFrame {
194        destination,
195        source,
196        ethertype: u16::from_be_bytes([bytes[12], bytes[13]]),
197        payload: bytes[ETHERNET_HEADER_LEN..].to_vec(),
198    })
199}
200
201/// Build an Ethernet II frame, padding the tail to the 60-octet minimum the MAC layer
202/// requires. Pad octets are zero, and the EAPOL body-length field is what lets the receiver
203/// ignore them.
204pub fn build_ethernet_frame(
205    destination: [u8; 6],
206    source: [u8; 6],
207    ethertype: u16,
208    payload: &[u8],
209) -> Vec<u8> {
210    let mut out =
211        Vec::with_capacity(ETHERNET_MIN_FRAME_LEN.max(ETHERNET_HEADER_LEN + payload.len()));
212    out.extend_from_slice(&destination);
213    out.extend_from_slice(&source);
214    out.extend_from_slice(&ethertype.to_be_bytes());
215    out.extend_from_slice(payload);
216    if out.len() < ETHERNET_MIN_FRAME_LEN {
217        out.resize(ETHERNET_MIN_FRAME_LEN, 0);
218    }
219    out
220}
221
222/// Lower-case colon-separated form, which is what every event field carries.
223pub fn format_mac(mac: &[u8; 6]) -> String {
224    format!(
225        "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
226        mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]
227    )
228}
229
230/// Accepts `aa:bb:cc:dd:ee:ff`, `aa-bb-cc-dd-ee-ff` and `aabbccddeeff`, any case.
231pub fn parse_mac(s: &str) -> CodecResult<[u8; 6]> {
232    let cleaned: String = s
233        .chars()
234        .filter(|c| !matches!(c, ':' | '-' | '.' | ' '))
235        .collect();
236    if cleaned.len() != 12 || !cleaned.chars().all(|c| c.is_ascii_hexdigit()) {
237        return Err(CodecError::BadMac(s.to_string()));
238    }
239    let mut mac = [0u8; 6];
240    for (i, slot) in mac.iter_mut().enumerate() {
241        *slot = u8::from_str_radix(&cleaned[i * 2..i * 2 + 2], 16)
242            .map_err(|_| CodecError::BadMac(s.to_string()))?;
243    }
244    Ok(mac)
245}
246
247// ---------------------------------------------------------------------------
248// EAPOL
249// ---------------------------------------------------------------------------
250
251/// An EAPOL PDU: protocol version, packet type, and the body the length field delimits.
252#[derive(Debug, Clone, PartialEq, Eq)]
253pub struct EapolFrame {
254    pub version: u8,
255    pub packet_type: u8,
256    pub body: Vec<u8>,
257}
258
259impl EapolFrame {
260    /// Decode an EAPOL PDU from the Ethernet payload.
261    ///
262    /// Trailing octets beyond the declared body length are **ignored**, not rejected: an
263    /// EAPOL-Start is 4 octets and every Ethernet frame carrying one is padded to 60, so a
264    /// strict length equality check would reject every real frame on the wire.
265    pub fn decode(bytes: &[u8]) -> CodecResult<Self> {
266        if bytes.len() < EAPOL_HEADER_LEN {
267            return Err(CodecError::TooShort {
268                layer: "EAPOL header",
269                needed: EAPOL_HEADER_LEN,
270                got: bytes.len(),
271            });
272        }
273        let version = bytes[0];
274        if !(EAPOL_VERSION_2001..=EAPOL_VERSION_2010).contains(&version) {
275            return Err(CodecError::UnsupportedVersion(version));
276        }
277        let declared = u16::from_be_bytes([bytes[2], bytes[3]]) as usize;
278        let available = bytes.len() - EAPOL_HEADER_LEN;
279        if declared > available {
280            return Err(CodecError::BadLength {
281                layer: "EAPOL body",
282                declared,
283                available,
284            });
285        }
286        Ok(EapolFrame {
287            version,
288            packet_type: bytes[1],
289            body: bytes[EAPOL_HEADER_LEN..EAPOL_HEADER_LEN + declared].to_vec(),
290        })
291    }
292
293    /// Encode the PDU, refusing a body the two-octet Packet Body Length field cannot describe.
294    ///
295    /// `self.body.len() as u16` was the `m3ua` defect in miniature and worse than an oversize
296    /// frame: 65536 octets narrow to a declared length of **0**, so the receiver reads an empty
297    /// body out of a frame that carries 64 KiB, and a cast does not overflow-check in release.
298    /// Nothing on an Ethernet segment is that large and no caller in this tree can reach it —
299    /// `encode_eap_typed` already refuses past `u16::MAX`, so the wrapped EAP packet is always
300    /// smaller — but this is a `pub fn` on a `pub` struct with a `pub` body, so the bound
301    /// belongs to the function rather than to an argument about who calls it.
302    pub fn encode(&self) -> CodecResult<Vec<u8>> {
303        let declared = u16::try_from(self.body.len()).map_err(|_| CodecError::FieldTooLong {
304            field: "EAPOL body",
305            max: u16::MAX as usize,
306            got: self.body.len(),
307        })?;
308        let mut out = Vec::with_capacity(EAPOL_HEADER_LEN + self.body.len());
309        out.push(self.version);
310        out.push(self.packet_type);
311        out.extend_from_slice(&declared.to_be_bytes());
312        out.extend_from_slice(&self.body);
313        Ok(out)
314    }
315}
316
317pub fn eapol_packet_type_name(packet_type: u8) -> &'static str {
318    match packet_type {
319        EAPOL_TYPE_EAP_PACKET => "EAP-Packet",
320        EAPOL_TYPE_START => "EAPOL-Start",
321        EAPOL_TYPE_LOGOFF => "EAPOL-Logoff",
322        EAPOL_TYPE_KEY => "EAPOL-Key",
323        EAPOL_TYPE_ASF_ALERT => "EAPOL-Encapsulated-ASF-Alert",
324        _ => "Unknown-EAPOL-Type",
325    }
326}
327
328/// Wrap an already-encoded EAP packet in an EAPOL header.
329///
330/// This takes an opaque body and cannot choose an EAP code, which is why it is safe for both
331/// directions. The Success and Failure builders below deliberately do **not** call it.
332pub fn eapol_wrap_eap(version: u8, eap: &[u8]) -> CodecResult<Vec<u8>> {
333    EapolFrame {
334        version,
335        packet_type: EAPOL_TYPE_EAP_PACKET,
336        body: eap.to_vec(),
337    }
338    .encode()
339}
340
341/// An EAPOL-Start, used by the supplicant role and by tests playing one.
342pub fn eapol_start_frame(version: u8) -> Vec<u8> {
343    vec![version, EAPOL_TYPE_START, 0x00, 0x00]
344}
345
346/// An EAPOL-Logoff.
347pub fn eapol_logoff_frame(version: u8) -> Vec<u8> {
348    vec![version, EAPOL_TYPE_LOGOFF, 0x00, 0x00]
349}
350
351// ---------------------------------------------------------------------------
352// EAP
353// ---------------------------------------------------------------------------
354
355/// A decoded EAP packet. `eap_type` is present only for Request and Response (RFC 3748 §4.1);
356/// Success and Failure have no type octet and are exactly four octets long (§4.2).
357#[derive(Debug, Clone, PartialEq, Eq)]
358pub struct EapPacket {
359    pub code: u8,
360    pub identifier: u8,
361    pub eap_type: Option<u8>,
362    pub type_data: Vec<u8>,
363}
364
365impl EapPacket {
366    pub fn decode(bytes: &[u8]) -> CodecResult<Self> {
367        if bytes.len() < EAP_HEADER_LEN {
368            return Err(CodecError::TooShort {
369                layer: "EAP header",
370                needed: EAP_HEADER_LEN,
371                got: bytes.len(),
372            });
373        }
374        let code = bytes[0];
375        let identifier = bytes[1];
376        let declared = u16::from_be_bytes([bytes[2], bytes[3]]) as usize;
377        if declared < EAP_HEADER_LEN {
378            return Err(CodecError::BadLength {
379                layer: "EAP packet",
380                declared,
381                available: bytes.len(),
382            });
383        }
384        if declared > bytes.len() {
385            return Err(CodecError::BadLength {
386                layer: "EAP packet",
387                declared,
388                available: bytes.len(),
389            });
390        }
391        let body = &bytes[..declared];
392
393        match code {
394            EAP_CODE_REQUEST | EAP_CODE_RESPONSE => {
395                if declared < EAP_HEADER_LEN + 1 {
396                    return Err(CodecError::MalformedEap(
397                        "a Request or Response must carry a type octet (RFC 3748 §4.1)",
398                    ));
399                }
400                Ok(EapPacket {
401                    code,
402                    identifier,
403                    eap_type: Some(body[4]),
404                    type_data: body[5..].to_vec(),
405                })
406            }
407            EAP_CODE_SUCCESS | EAP_CODE_FAILURE => {
408                if declared != EAP_HEADER_LEN {
409                    return Err(CodecError::MalformedEap(
410                        "a Success or Failure is exactly four octets (RFC 3748 §4.2)",
411                    ));
412                }
413                Ok(EapPacket {
414                    code,
415                    identifier,
416                    eap_type: None,
417                    type_data: Vec::new(),
418                })
419            }
420            _ => Err(CodecError::MalformedEap(
421                "EAP code must be 1 (Request), 2 (Response), 3 (Success) or 4 (Failure)",
422            )),
423        }
424    }
425}
426
427pub fn eap_code_name(code: u8) -> &'static str {
428    match code {
429        EAP_CODE_REQUEST => "EAP-Request",
430        EAP_CODE_RESPONSE => "EAP-Response",
431        EAP_CODE_SUCCESS => "EAP-Success",
432        EAP_CODE_FAILURE => "EAP-Failure",
433        _ => "Unknown-EAP-Code",
434    }
435}
436
437pub fn eap_type_name(eap_type: u8) -> &'static str {
438    match eap_type {
439        EAP_TYPE_IDENTITY => "identity",
440        EAP_TYPE_NOTIFICATION => "notification",
441        EAP_TYPE_NAK => "nak",
442        EAP_TYPE_MD5_CHALLENGE => "md5-challenge",
443        EAP_TYPE_TLS => "tls",
444        EAP_TYPE_PEAP => "peap",
445        EAP_TYPE_MSCHAPV2 => "mschapv2",
446        _ => "unknown",
447    }
448}
449
450/// Encode a Request or Response body. Private: every caller goes through a named builder, so
451/// no call site can pass an arbitrary code.
452fn encode_eap_typed(
453    code: u8,
454    identifier: u8,
455    eap_type: u8,
456    type_data: &[u8],
457) -> CodecResult<Vec<u8>> {
458    let length = EAP_HEADER_LEN + 1 + type_data.len();
459    if length > u16::MAX as usize {
460        return Err(CodecError::FieldTooLong {
461            field: "EAP type-data",
462            max: u16::MAX as usize - EAP_HEADER_LEN - 1,
463            got: type_data.len(),
464        });
465    }
466    let mut out = Vec::with_capacity(length);
467    out.push(code);
468    out.push(identifier);
469    out.extend_from_slice(&(length as u16).to_be_bytes());
470    out.push(eap_type);
471    out.extend_from_slice(type_data);
472    Ok(out)
473}
474
475/// `EAP-Request/Identity` — the first thing an authenticator says. Carries no prompt.
476pub fn eap_request_identity(identifier: u8) -> Vec<u8> {
477    // 4-octet header + 1 type octet; cannot exceed u16, so the Result is not interesting.
478    vec![EAP_CODE_REQUEST, identifier, 0x00, 0x05, EAP_TYPE_IDENTITY]
479}
480
481/// `EAP-Request/Notification` — a displayable message, RFC 3748 §5.2. Not a decision.
482pub fn eap_request_notification(identifier: u8, text: &str) -> CodecResult<Vec<u8>> {
483    encode_eap_typed(
484        EAP_CODE_REQUEST,
485        identifier,
486        EAP_TYPE_NOTIFICATION,
487        text.as_bytes(),
488    )
489}
490
491/// `EAP-Request/MD5-Challenge` — RFC 3748 §5.4, whose value field follows RFC 1994 §4.1:
492/// `Value-Size (1) | Value | Name`.
493pub fn eap_request_md5_challenge(
494    identifier: u8,
495    challenge: &[u8],
496    name: &str,
497) -> CodecResult<Vec<u8>> {
498    let value = encode_md5_value(challenge, name)?;
499    encode_eap_typed(EAP_CODE_REQUEST, identifier, EAP_TYPE_MD5_CHALLENGE, &value)
500}
501
502/// `EAP-Request/TLS` or `/PEAP` carrying only the Start flag, RFC 5216 §3.2.
503///
504/// NetGet sends the Start and surfaces whatever the supplicant answers; it does not carry a
505/// TLS handshake. `metadata()` and the module's CLAUDE.md both say so.
506pub fn eap_request_tls_start(identifier: u8, eap_type: u8) -> CodecResult<Vec<u8>> {
507    encode_eap_typed(EAP_CODE_REQUEST, identifier, eap_type, &[TLS_FLAG_START])
508}
509
510/// Encode a Response body. Used by the supplicant role and by tests playing a supplicant.
511pub fn eap_response(identifier: u8, eap_type: u8, type_data: &[u8]) -> CodecResult<Vec<u8>> {
512    encode_eap_typed(EAP_CODE_RESPONSE, identifier, eap_type, type_data)
513}
514
515/// `EAP-Response/Identity` carrying a claimed identity.
516pub fn eap_response_identity(identifier: u8, identity: &str) -> CodecResult<Vec<u8>> {
517    encode_eap_typed(
518        EAP_CODE_RESPONSE,
519        identifier,
520        EAP_TYPE_IDENTITY,
521        identity.as_bytes(),
522    )
523}
524
525// ---------------------------------------------------------------------------
526// The two terminal frames. Read the module header before touching either.
527// ---------------------------------------------------------------------------
528
529/// **The only function in NetGet that can produce an `EAP-Success`.**
530///
531/// It takes a version and an identifier and nothing else. There is no code parameter, no
532/// boolean, and no shared helper with [`eapol_eap_failure_frame`] — the eight octets are
533/// literals here and literals there. `EapolServer::decide` is the single caller, reached
534/// only from the `send_eap_success` arm of `execute_action` on a protocol instance that
535/// carries an established identity.
536///
537/// Layout (802.1X-2004 §11.3 + RFC 3748 §4.2):
538/// `version | 0x00 EAP-Packet | 0x0004 body length | 0x03 Success | id | 0x0004 length`
539pub fn eapol_eap_success_frame(version: u8, identifier: u8) -> Vec<u8> {
540    vec![
541        version,
542        EAPOL_TYPE_EAP_PACKET,
543        0x00,
544        0x04,
545        EAP_CODE_SUCCESS,
546        identifier,
547        0x00,
548        0x04,
549    ]
550}
551
552/// The denial. Every fail-closed path in `mod.rs` calls this directly, bypassing the action
553/// executor entirely, so no model output and no error path can reach the function above.
554///
555/// Layout is identical to [`eapol_eap_success_frame`] but for the code octet, and the two are
556/// written out separately on purpose: a shared body parameterised by the code is one wrong
557/// argument away from an authentication bypass.
558pub fn eapol_eap_failure_frame(version: u8, identifier: u8) -> Vec<u8> {
559    vec![
560        version,
561        EAPOL_TYPE_EAP_PACKET,
562        0x00,
563        0x04,
564        EAP_CODE_FAILURE,
565        identifier,
566        0x00,
567        0x04,
568    ]
569}
570
571// ---------------------------------------------------------------------------
572// Method payloads
573// ---------------------------------------------------------------------------
574
575/// `Value-Size (1) | Value | Name` — RFC 1994 §4.1, reused by EAP-MD5-Challenge.
576pub fn encode_md5_value(value: &[u8], name: &str) -> CodecResult<Vec<u8>> {
577    if value.len() > u8::MAX as usize {
578        return Err(CodecError::FieldTooLong {
579            field: "MD5-Challenge value",
580            max: u8::MAX as usize,
581            got: value.len(),
582        });
583    }
584    let mut out = Vec::with_capacity(1 + value.len() + name.len());
585    out.push(value.len() as u8);
586    out.extend_from_slice(value);
587    out.extend_from_slice(name.as_bytes());
588    Ok(out)
589}
590
591/// Split an MD5-Challenge type-data field into its value and its trailing name.
592pub fn decode_md5_value(type_data: &[u8]) -> CodecResult<(Vec<u8>, String)> {
593    if type_data.is_empty() {
594        return Err(CodecError::TooShort {
595            layer: "MD5-Challenge value",
596            needed: 1,
597            got: 0,
598        });
599    }
600    let size = type_data[0] as usize;
601    if type_data.len() < 1 + size {
602        return Err(CodecError::BadLength {
603            layer: "MD5-Challenge value",
604            declared: size,
605            available: type_data.len() - 1,
606        });
607    }
608    Ok((
609        type_data[1..1 + size].to_vec(),
610        String::from_utf8_lossy(&type_data[1 + size..]).into_owned(),
611    ))
612}
613
614/// Flags on an EAP-TLS/PEAP fragment, RFC 5216 §3.1, as a structured triple. Returns `None`
615/// when the fragment carries no flags octet at all.
616pub fn tls_flags(type_data: &[u8]) -> Option<(bool, bool, bool)> {
617    type_data.first().map(|f| {
618        (
619            f & TLS_FLAG_LENGTH_INCLUDED != 0,
620            f & TLS_FLAG_MORE_FRAGMENTS != 0,
621            f & TLS_FLAG_START != 0,
622        )
623    })
624}
625
626// ---------------------------------------------------------------------------
627// MD5 (RFC 1321)
628// ---------------------------------------------------------------------------
629//
630// The `md-5` crate, same as `src/server/radius/packet.rs` — the two agree deliberately, so
631// there is one MD5 in the tree and one place for it to be wrong.
632//
633// This was hand-rolled in the first version of this file, because `md-5` was gated on the
634// `radius` feature. Pinning it to the RFC 1321 vectors was not enough of a defence: the
635// vectors prove the happy path, not the edge cases, and the next person to touch it would not
636// have had the context. The `eapol` feature now carries `dep:md-5` and the vector tests were
637// kept — they no longer test an implementation, they test that this file drives the crate
638// correctly, which is still the thing that can break.
639
640/// RFC 1321 MD5 of a single buffer.
641///
642/// A thin wrapper over the `md-5` crate, kept as a named function because the RFC 1321 §A.5
643/// vector tests exercise it directly.
644pub fn md5(data: &[u8]) -> [u8; 16] {
645    let mut hasher = Md5::new();
646    hasher.update(data);
647    hasher.finalize().into()
648}
649
650/// RFC 1994 §2.2: `MD5(Identifier || Secret || Challenge)`.
651///
652/// The order matters and is the classic implementation mistake — a digest computed over
653/// `Secret || Identifier || Challenge` is self-consistent and rejects every real supplicant.
654pub fn md5_challenge_digest(identifier: u8, secret: &str, challenge: &[u8]) -> [u8; 16] {
655    // Fed to the hasher in three updates rather than assembled into a Vec first, the way
656    // `radius`'s authenticators do it: the concatenation is the specification, and writing it
657    // as three ordered `update` calls makes the order the thing a reader checks.
658    let mut hasher = Md5::new();
659    hasher.update([identifier]);
660    hasher.update(secret.as_bytes());
661    hasher.update(challenge);
662    hasher.finalize().into()
663}
664
665/// Whether a supplicant's MD5-Challenge response matches the digest of the expected secret.
666///
667/// Comparison is constant-time in the length-equal case, as `radius` does for its
668/// Accounting-Request authenticator: a timing side channel on an authentication comparison
669/// is a real, if slow, oracle.
670pub fn md5_response_matches(
671    identifier: u8,
672    secret: &str,
673    challenge: &[u8],
674    response_value: &[u8],
675) -> bool {
676    let expected = md5_challenge_digest(identifier, secret, challenge);
677    if response_value.len() != expected.len() {
678        return false;
679    }
680    let mut diff = 0u8;
681    for (a, b) in expected.iter().zip(response_value.iter()) {
682        diff |= a ^ b;
683    }
684    diff == 0
685}