Skip to main content

netget/server/eapol/
mod.rs

1//! EAPOL / 802.1X authenticator — the switch-port role.
2//!
3//! `codec.rs` is the pure wire format; this file is the transport and the session loop that
4//! sits on top of it, and — most importantly — the guarantee that **no decision means
5//! denial**. See `src/server/eapol/CLAUDE.md` for the design rationale, the exact list of
6//! things this server deliberately does not implement, and how it pairs with `radius`.
7//!
8//! # Two transports, one handler
9//!
10//! `transport: "raw"` (default) captures and injects EtherType 0x888E frames through libpcap
11//! and needs packet-capture privilege. `transport: "udp"` carries the identical EAPOL frames
12//! over a UDP socket, each datagram prefixed with the supplicant's six-octet MAC address, so
13//! the whole event -> LLM -> action -> frame path can be exercised unprivileged. Both funnel
14//! into [`EapolServer::handle_eapol`]; there is no second copy of the state machine.
15//!
16//! # The fail-closed rule
17//!
18//! `EAP-Success` is an admission decision. [`EapolServer::decide`] is the only place a reply
19//! is chosen, and it can produce Success bytes in exactly one way: by *recognising* them in
20//! output the action executor already built from an explicit `send_eap_success`. Every other
21//! exit — an LLM error, an empty action list, an action that failed to encode, a Success on a
22//! session with no identity — calls [`codec::eapol_eap_failure_frame`] directly.
23
24pub mod actions;
25pub mod codec;
26
27pub use actions::EapolProtocol;
28
29use crate::llm::action_helper::call_llm;
30use crate::logging::emit::Log;
31use crate::protocol::{Event, SpawnContext};
32use crate::server::connection::ConnectionId;
33use crate::state::app_state::AppState;
34use crate::state::ServerId;
35use anyhow::{Context, Result};
36use std::collections::HashMap;
37use std::net::{IpAddr, Ipv4Addr, SocketAddr};
38use std::sync::{Arc, Mutex};
39use tokio::net::UdpSocket;
40use tokio::sync::mpsc;
41use tracing::{debug, error, trace, warn};
42
43use actions::{
44    EAPOL_IDENTITY_RESPONSE_EVENT, EAPOL_LOGOFF_EVENT, EAPOL_METHOD_RESPONSE_EVENT,
45    EAPOL_START_EVENT,
46};
47
48/// Largest datagram the UDP transport will read. An EAPOL frame is tiny; this is generous
49/// enough for a fragmented EAP-TLS record and small enough that a sender cannot make the
50/// server allocate on demand.
51pub const MAX_FRAME_LEN: usize = 4096;
52
53// ---------------------------------------------------------------------------
54// Decisions
55// ---------------------------------------------------------------------------
56
57/// How a reply was arrived at.
58///
59/// This exists so the log can never conflate "the model denied" with "the model said
60/// nothing" — the OAuth2 failure mode, where those two collapsed into one another and silence
61/// became approval. On the wire both are an EAP-Failure, which is correct; the distinction
62/// belongs here.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum Decision {
65    /// The model returned `send_eap_success`: the port is authorized.
66    ModelAdmit,
67    /// The model returned `send_eap_failure`: the port stays unauthorized.
68    ModelReject,
69    /// The model asked for an identity, a method or a notification. Nothing is granted.
70    ModelChallenge,
71    /// The model returned no usable action. The server denies.
72    FailClosedNoAction,
73    /// The LLM call itself failed. The server denies.
74    FailClosedLlmError,
75    /// The model's action could not be encoded. The server denies.
76    FailClosedActionError,
77    /// The model asked to admit a session that has produced no identity. The server denies.
78    ///
79    /// `execute_action` already refuses this, so reaching it means a second line of defence
80    /// fired and something upstream is wrong. It gets its own token so that shows up in the
81    /// log rather than hiding inside `fail_closed_action_error`.
82    FailClosedNoIdentity,
83}
84
85impl Decision {
86    /// Stable, grep-able token. One per path, never reused.
87    pub fn as_str(&self) -> &'static str {
88        match self {
89            Decision::ModelAdmit => "model_admit",
90            Decision::ModelReject => "model_reject",
91            Decision::ModelChallenge => "model_challenge",
92            Decision::FailClosedNoAction => "fail_closed_no_action",
93            Decision::FailClosedLlmError => "fail_closed_llm_error",
94            Decision::FailClosedActionError => "fail_closed_action_error",
95            Decision::FailClosedNoIdentity => "fail_closed_no_identity",
96        }
97    }
98
99    /// True when the server, not the model, chose to deny.
100    pub fn is_fail_closed(&self) -> bool {
101        matches!(
102            self,
103            Decision::FailClosedNoAction
104                | Decision::FailClosedLlmError
105                | Decision::FailClosedActionError
106                | Decision::FailClosedNoIdentity
107        )
108    }
109}
110
111/// Classify a frame the executor produced, by the EAP code inside it.
112///
113/// Returns `None` for anything that is not a well-formed EAPOL-wrapped EAP packet, which is
114/// treated as "no usable answer" and therefore denies.
115pub fn classify_frame(frame: &[u8]) -> Option<Decision> {
116    let eapol = codec::EapolFrame::decode(frame).ok()?;
117    if eapol.packet_type != codec::EAPOL_TYPE_EAP_PACKET {
118        return None;
119    }
120    let eap = codec::EapPacket::decode(&eapol.body).ok()?;
121    match eap.code {
122        codec::EAP_CODE_SUCCESS => Some(Decision::ModelAdmit),
123        codec::EAP_CODE_FAILURE => Some(Decision::ModelReject),
124        codec::EAP_CODE_REQUEST => Some(Decision::ModelChallenge),
125        _ => None,
126    }
127}
128
129// ---------------------------------------------------------------------------
130// Sessions
131// ---------------------------------------------------------------------------
132
133/// An MD5-Challenge NetGet issued and is waiting on.
134#[derive(Clone, Debug)]
135struct PendingMd5 {
136    /// EAP identifier the challenge went out with; the response must echo it.
137    identifier: u8,
138    /// The challenge NetGet generated. Never chosen by the model.
139    challenge: Vec<u8>,
140    /// Password the model said this identity should hold, if it supplied one. `None` means
141    /// nothing can be verified, and the event says so in as many words.
142    expected_password: Option<String>,
143}
144
145/// Per-supplicant state, keyed by source MAC.
146#[derive(Debug)]
147struct Session {
148    connection_id: ConnectionId,
149    /// Identity claimed by an `EAP-Response/Identity`, if one has arrived.
150    identity: Option<String>,
151    /// Identifier of the last EAP-Request NetGet sent. RFC 3748 §4.1: a Response that does
152    /// not echo it is silently discarded.
153    last_request_identifier: Option<u8>,
154    /// Next identifier to allocate for an outbound Request.
155    next_identifier: u8,
156    pending_md5: Option<PendingMd5>,
157}
158
159type Sessions = Arc<Mutex<HashMap<[u8; 6], Session>>>;
160
161/// Lock helper that survives a poisoned mutex.
162///
163/// A panic in one supplicant's task must not take the authenticator down, and the state
164/// behind this lock is per-supplicant bookkeeping rather than a security invariant: nothing
165/// here can turn a denial into an admission, because that decision lives entirely in
166/// [`EapolServer::decide`].
167///
168/// Note the type: this is a `std::sync::Mutex`, so its guard is `!Send` and holding one
169/// across an `.await` in a spawned task is a compile error rather than a review finding.
170fn sessions_lock(sessions: &Sessions) -> std::sync::MutexGuard<'_, HashMap<[u8; 6], Session>> {
171    sessions.lock().unwrap_or_else(|e| e.into_inner())
172}
173
174// ---------------------------------------------------------------------------
175// Transport
176// ---------------------------------------------------------------------------
177
178/// How a reply reaches the supplicant. Cloned into every per-frame task.
179#[derive(Clone)]
180enum Transmit {
181    /// Real EAPOL: build an Ethernet frame and hand it to the pcap injection thread.
182    Raw {
183        frames: mpsc::UnboundedSender<Vec<u8>>,
184        local_mac: [u8; 6],
185    },
186    /// Test transport: `[supplicant MAC (6)][EAPOL frame]` back to the datagram's sender.
187    Udp {
188        socket: Arc<UdpSocket>,
189        peer: SocketAddr,
190    },
191}
192
193impl Transmit {
194    async fn send(&self, supplicant_mac: [u8; 6], eapol_frame: &[u8]) -> Result<usize> {
195        match self {
196            Transmit::Raw { frames, local_mac } => {
197                let ethernet = codec::build_ethernet_frame(
198                    supplicant_mac,
199                    *local_mac,
200                    codec::ETHERTYPE_EAPOL,
201                    eapol_frame,
202                );
203                let len = ethernet.len();
204                frames
205                    .send(ethernet)
206                    .map_err(|_| anyhow::anyhow!("EAPOL injection thread has exited"))?;
207                Ok(len)
208            }
209            Transmit::Udp { socket, peer } => {
210                let mut datagram = Vec::with_capacity(6 + eapol_frame.len());
211                datagram.extend_from_slice(&supplicant_mac);
212                datagram.extend_from_slice(eapol_frame);
213                Ok(socket.send_to(&datagram, peer).await?)
214            }
215        }
216    }
217}
218
219/// Everything a per-frame task needs that is not the frame itself.
220#[derive(Clone)]
221struct ServerCx {
222    llm_client: crate::llm::ollama_client::OllamaClient,
223    state: Arc<AppState>,
224    status_tx: mpsc::UnboundedSender<String>,
225    server_id: ServerId,
226    sessions: Sessions,
227    local_addr: SocketAddr,
228    /// Version forced by the `eapol_version` startup parameter, if any. Without it, replies
229    /// echo whatever version the supplicant used.
230    forced_version: Option<u8>,
231}
232
233/// The result of classifying one received frame: which event it raises, what the model is
234/// told, and everything the reply needs. Owned — no lock is held across the LLM call.
235struct Prepared {
236    event_id: &'static str,
237    event_data: serde_json::Value,
238    request: actions::RequestContext,
239    identity: Option<String>,
240    /// True only for EAPOL-Logoff, where the port is already de-authorized and there is
241    /// nothing left to deny.
242    silence_is_safe: bool,
243    /// True when the session ends with this frame regardless of the answer.
244    terminal: bool,
245}
246
247/// What [`EapolServer::decide`] concluded: the decision, the frame to send, and the one field
248/// of the model's action that never reaches the wire.
249struct Answer {
250    decision: Decision,
251    frame: Option<Vec<u8>>,
252    /// `expected_password` from the model's own `send_eap_request_method`, if it supplied
253    /// one.
254    ///
255    /// This has to come from the action JSON rather than from the encoded frame, because it
256    /// is deliberately the only part of an MD5 challenge that is *not* transmitted: the
257    /// challenge goes to the supplicant, the password stays here so NetGet can do the
258    /// comparison itself. Reading it back off the wire is therefore impossible by design.
259    expected_password: Option<String>,
260}
261
262// ---------------------------------------------------------------------------
263// Server
264// ---------------------------------------------------------------------------
265
266pub struct EapolServer;
267
268impl EapolServer {
269    /// Start the authenticator on whichever transport was asked for.
270    ///
271    /// Returns `Err` — so `server_startup` sets `ServerStatus::Error` — for an unknown
272    /// transport, an out-of-range version, a missing capture privilege, an interface that
273    /// does not exist, or a socket that will not bind. Nothing here is fire-and-forget: the
274    /// raw path hands its open result back over a oneshot and only returns `Ok` once the
275    /// capture is genuinely live.
276    pub async fn spawn_with_llm_actions(ctx: SpawnContext) -> Result<SocketAddr> {
277        let legacy_addr = ctx.legacy_listen_addr();
278        let interface = ctx.interface().map(|s| s.to_string());
279        let socket_addr = ctx.socket_addr();
280
281        let SpawnContext {
282            llm_client,
283            state,
284            status_tx,
285            server_id,
286            startup_params,
287            ..
288        } = ctx;
289
290        // Both parameters are optional, but a supplied value that makes no sense must refuse
291        // the start rather than be silently replaced by a default. Propagated with `?`, never
292        // unwrapped: an undeclared key or a wrong type over MCP used to panic the per-request
293        // task and hang the caller with no error at all.
294        let (transport, forced_version) = match &startup_params {
295            Some(params) => {
296                let transport = params
297                    .get_optional_string("transport")?
298                    .unwrap_or_else(|| actions::TRANSPORT_RAW.to_string())
299                    .to_ascii_lowercase();
300                let version = match params.get_optional_u64("eapol_version")? {
301                    Some(v) if (1..=3).contains(&v) => Some(v as u8),
302                    Some(v) => {
303                        return Err(anyhow::anyhow!(
304                            "eapol_version must be 1 (802.1X-2001), 2 (802.1X-2004) or 3 \
305                             (802.1X-2010); got {}",
306                            v
307                        ))
308                    }
309                    None => None,
310                };
311                (transport, version)
312            }
313            None => (actions::TRANSPORT_RAW.to_string(), None),
314        };
315
316        let sessions: Sessions = Arc::new(Mutex::new(HashMap::new()));
317
318        match transport.as_str() {
319            actions::TRANSPORT_UDP => {
320                let bind_addr = socket_addr.unwrap_or(legacy_addr);
321                Self::spawn_udp(
322                    bind_addr,
323                    ServerCx {
324                        llm_client,
325                        state,
326                        status_tx,
327                        server_id,
328                        sessions,
329                        local_addr: bind_addr,
330                        forced_version,
331                    },
332                )
333                .await
334            }
335            actions::TRANSPORT_RAW => {
336                let interface = interface.context(
337                    "EAPOL with transport 'raw' requires a network interface to capture on",
338                )?;
339                Self::spawn_raw(
340                    interface,
341                    ServerCx {
342                        llm_client,
343                        state,
344                        status_tx,
345                        server_id,
346                        sessions,
347                        local_addr: legacy_addr,
348                        forced_version,
349                    },
350                )
351                .await?;
352                Ok(legacy_addr)
353            }
354            other => Err(anyhow::anyhow!(
355                "Unknown EAPOL transport '{}'. Use 'raw' (real EAPOL on EtherType 0x888E, \
356                 needs packet-capture privilege) or 'udp' (the unprivileged test transport).",
357                other
358            )),
359        }
360    }
361
362    // -----------------------------------------------------------------------
363    // UDP test transport
364    // -----------------------------------------------------------------------
365
366    /// The unprivileged transport. Each datagram is `[supplicant MAC (6)][EAPOL frame]`, in
367    /// both directions — the six octets are always the *supplicant's* address, which is the
368    /// one piece of Ethernet framing the events actually carry.
369    async fn spawn_udp(bind_addr: SocketAddr, cx: ServerCx) -> Result<SocketAddr> {
370        let socket = Arc::new(
371            UdpSocket::bind(bind_addr)
372                .await
373                .with_context(|| format!("EAPOL failed to bind {}", bind_addr))?,
374        );
375        let local_addr = socket.local_addr()?;
376
377        // Two lines, not one: the test harness reads the bound port back out of a
378        // "listening on ADDR:PORT" line with `rfind("on ")`, so anything trailing the address
379        // risks being parsed as the port.
380        let log = Log::new(Some(&cx.status_tx));
381        log.info(format!("EAPOL authenticator listening on {}", local_addr));
382        log.info("EAPOL transport=udp: each datagram is [supplicant MAC (6 octets)][EAPOL frame]");
383
384        let mut cx = cx;
385        cx.local_addr = local_addr;
386
387        let registrar = cx.state.clone();
388        let server_id = cx.server_id;
389
390        let handle = tokio::spawn(async move {
391            let mut buffer = vec![0u8; MAX_FRAME_LEN];
392            loop {
393                let (n, peer) = match socket.recv_from(&mut buffer).await {
394                    Ok(pair) => pair,
395                    Err(e) => {
396                        Log::new(Some(&cx.status_tx)).error(format!("EAPOL receive error: {}", e));
397                        break;
398                    }
399                };
400
401                if n < 6 {
402                    Log::new(Some(&cx.status_tx)).warn(format!(
403                        "EAPOL dropped a {}-octet datagram from {}: the udp transport prefixes \
404                         every frame with the supplicant's 6-octet MAC address",
405                        n, peer
406                    ));
407                    continue;
408                }
409
410                let mut supplicant_mac = [0u8; 6];
411                supplicant_mac.copy_from_slice(&buffer[..6]);
412                let payload = buffer[6..n].to_vec();
413
414                let transmit = Transmit::Udp {
415                    socket: socket.clone(),
416                    peer,
417                };
418                let task_cx = cx.clone();
419                // Tracked, not detached: stop_server must abort this task too.
420                let task_owner = cx.state.clone();
421                task_owner
422                    .spawn_server_task(cx.server_id, async move {
423                        Self::handle_eapol(
424                            supplicant_mac,
425                            codec::PAE_GROUP_ADDRESS,
426                            payload,
427                            transmit,
428                            peer,
429                            task_cx,
430                        )
431                        .await;
432                    })
433                    .await;
434            }
435        });
436
437        registrar.register_server_task(server_id, handle).await;
438        Ok(local_addr)
439    }
440
441    // -----------------------------------------------------------------------
442    // Raw Ethernet transport
443    // -----------------------------------------------------------------------
444
445    /// Real EAPOL over libpcap.
446    ///
447    /// **This code path has never been executed.** It needs packet-capture privilege, which
448    /// the environment it was written in does not have, and no third-party supplicant has
449    /// ever spoken to it. `metadata().notes` and the module CLAUDE.md both say so, and the
450    /// path from here to Beta is written down there rather than assumed.
451    async fn spawn_raw(interface: String, cx: ServerCx) -> Result<()> {
452        use pcap::{Capture, Device};
453
454        // The privilege gate lives here rather than in `metadata()`, and the comment on
455        // `privilege_requirement` in actions.rs explains why: a static PacketCapture
456        // declaration would also refuse `transport: "udp"`, which needs nothing at all.
457        let caps = cx.state.get_system_capabilities().await;
458        if !caps.has_packet_capture_access {
459            return Err(anyhow::anyhow!(
460                "EAPOL transport 'raw' needs layer-2 capture and injection on '{}': root, or \
461                 read/write access to /dev/bpf* on macOS/BSD, or CAP_NET_RAW on Linux. \
462                 Current capabilities: {}. Use startup_params {{\"transport\": \"udp\"}} to \
463                 exercise the authenticator without privilege.",
464                interface,
465                caps.description()
466            ));
467        }
468
469        // pnet supplies the interface's own MAC, which becomes the source address of every
470        // frame this authenticator emits. libpcap does not expose it.
471        let mac = pnet::datalink::interfaces()
472            .into_iter()
473            .find(|i| i.name == interface)
474            .with_context(|| format!("no such network interface '{}'", interface))?
475            .mac
476            .with_context(|| {
477                format!(
478                    "interface '{}' has no MAC address, so it has no Ethernet link layer for \
479                     EAPOL to live on. Point this server at a real Ethernet or Wi-Fi interface.",
480                    interface
481                )
482            })?;
483        let local_mac: [u8; 6] = [mac.0, mac.1, mac.2, mac.3, mac.4, mac.5];
484
485        let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<Result<()>>();
486        let (frames_tx, mut frames_rx) = mpsc::unbounded_channel::<Vec<u8>>();
487
488        // `JoinHandle::abort()` cannot interrupt a thread parked in `next_packet()`, so the
489        // capture loop stops cooperatively, exactly as `arp` does.
490        let stop = crate::utils::StopSignal::new();
491        let stop_in_loop = stop.clone();
492
493        let registrar = cx.state.clone();
494        let server_id = cx.server_id;
495        let status_after = cx.status_tx.clone();
496        let interface_for_task = interface.clone();
497        let status_for_task = cx.status_tx.clone();
498
499        tokio::task::spawn_blocking(move || {
500            let open = || -> Result<(Capture<pcap::Active>, Capture<pcap::Active>)> {
501                let device = Device::list()
502                    .context("failed to enumerate capture devices")?
503                    .into_iter()
504                    .find(|d| d.name == interface_for_task)
505                    .with_context(|| format!("no such capture device '{}'", interface_for_task))?;
506
507                let mut rx = Capture::from_device(device.clone())
508                    .map(|c| c.promisc(true).snaplen(65535).timeout(1000))
509                    .and_then(|c| c.open())
510                    .with_context(|| {
511                        format!(
512                            "failed to open pcap capture on '{}' (needs root, or read access \
513                             to /dev/bpf* on macOS/BSD, or CAP_NET_RAW on Linux)",
514                            interface_for_task
515                        )
516                    })?;
517
518                // Without the filter every frame on the segment reaches the LLM, so a failure
519                // here refuses the start rather than falling through. `ether proto` is an
520                // Ethernet-only keyword: on loopback, a tunnel or a raw-IP device libpcap
521                // rejects it outright, which is the same trap `arp` and `isis` document.
522                rx.filter("ether proto 0x888e", true).with_context(|| {
523                    format!(
524                        "failed to apply the EAPOL BPF filter on '{}'. EAPOL is an \
525                         Ethernet-only protocol, so this fails on any interface with no \
526                         Ethernet link layer — loopback (lo/lo0), tunnels and raw-IP devices \
527                         carry no EAPOL. Point this server at a real Ethernet or Wi-Fi \
528                         interface, or use startup_params {{\"transport\": \"udp\"}}.",
529                        interface_for_task
530                    )
531                })?;
532
533                let tx = Capture::from_device(device)
534                    .map(|c| c.promisc(true).snaplen(65535).timeout(1000))
535                    .and_then(|c| c.open())
536                    .with_context(|| {
537                        format!(
538                            "failed to open pcap injection handle on '{}'",
539                            interface_for_task
540                        )
541                    })?;
542
543                Ok((rx, tx))
544            };
545
546            let (mut rx, mut tx) = match open() {
547                Ok(handles) => {
548                    let _ = ready_tx.send(Ok(()));
549                    handles
550                }
551                Err(e) => {
552                    Log::new(Some(&status_for_task))
553                        .error(format!("EAPOL capture startup failed: {:#}", e));
554                    let _ = ready_tx.send(Err(e));
555                    return;
556                }
557            };
558
559            // Injection runs on its own thread: sendpacket blocks, and the capture loop must
560            // not miss frames while it does.
561            std::thread::spawn(move || {
562                while let Some(frame) = frames_rx.blocking_recv() {
563                    if let Err(e) = tx.sendpacket(frame) {
564                        error!("EAPOL failed to inject frame: {}", e);
565                    }
566                }
567            });
568
569            let runtime = tokio::runtime::Handle::current();
570            // Raw Ethernet has no peer socket address; the dashboard row wants one anyway.
571            let peer_placeholder = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0);
572
573            loop {
574                if stop_in_loop.is_stopped() {
575                    debug!("EAPOL capture on '{}' stopping", interface_for_task);
576                    break;
577                }
578                match rx.next_packet() {
579                    Ok(packet) => {
580                        let ethernet = match codec::parse_ethernet_frame(packet.data) {
581                            Ok(f) => f,
582                            Err(e) => {
583                                warn!("EAPOL dropped an unparseable frame: {}", e);
584                                continue;
585                            }
586                        };
587                        if ethernet.ethertype != codec::ETHERTYPE_EAPOL {
588                            continue;
589                        }
590                        // Our own injected frames come back through the capture handle.
591                        if ethernet.source == local_mac {
592                            continue;
593                        }
594
595                        let transmit = Transmit::Raw {
596                            frames: frames_tx.clone(),
597                            local_mac,
598                        };
599                        let task_cx = cx.clone();
600                        runtime.spawn(async move {
601                            Self::handle_eapol(
602                                ethernet.source,
603                                ethernet.destination,
604                                ethernet.payload,
605                                transmit,
606                                peer_placeholder,
607                                task_cx,
608                            )
609                            .await;
610                        });
611                    }
612                    Err(pcap::Error::TimeoutExpired) => continue,
613                    Err(e) => {
614                        error!("EAPOL capture error on '{}': {}", interface_for_task, e);
615                        break;
616                    }
617                }
618            }
619        });
620
621        // Only report success once the capture is genuinely live. A server that sits in
622        // `Running` having captured nothing is worse than one that refuses to start.
623        match ready_rx.await {
624            Ok(Ok(())) => {}
625            Ok(Err(e)) => return Err(e),
626            Err(_) => {
627                return Err(anyhow::anyhow!(
628                    "EAPOL capture task on '{}' exited before signalling readiness",
629                    interface
630                ))
631            }
632        }
633
634        Log::new(Some(&status_after)).info(format!(
635            "EAPOL authenticator capturing on '{}' (EtherType 0x888E, local MAC {})",
636            interface,
637            codec::format_mac(&local_mac)
638        ));
639
640        registrar
641            .register_server_task(server_id, stop.park_task())
642            .await;
643        Ok(())
644    }
645
646    // -----------------------------------------------------------------------
647    // One received frame
648    // -----------------------------------------------------------------------
649
650    /// Decode one EAPOL frame, raise the matching event, apply the fail-closed rule and write
651    /// the answer. Shared by both transports.
652    async fn handle_eapol(
653        supplicant_mac: [u8; 6],
654        destination_mac: [u8; 6],
655        payload: Vec<u8>,
656        transmit: Transmit,
657        peer: SocketAddr,
658        cx: ServerCx,
659    ) {
660        let log = Log::new(Some(&cx.status_tx));
661        let mac_text = codec::format_mac(&supplicant_mac);
662
663        trace!(
664            "EAPOL {} octets from {}: {}",
665            payload.len(),
666            mac_text,
667            hex::encode(&payload)
668        );
669
670        let frame = match codec::EapolFrame::decode(&payload) {
671            Ok(f) => f,
672            Err(e) => {
673                log.warn(format!("EAPOL dropped a frame from {}: {}", mac_text, e));
674                return;
675            }
676        };
677
678        let version = cx.forced_version.unwrap_or(frame.version);
679
680        // Register (or find) the session, and with it the connection row the dashboard draws.
681        let connection_id = Self::ensure_session(&cx, supplicant_mac, peer, payload.len()).await;
682
683        // Classify the frame into an event. `prepare` takes and releases the session lock
684        // internally and returns owned data, so no guard is alive across the LLM call.
685        let Some(prepared) = Self::prepare(
686            &cx,
687            supplicant_mac,
688            &frame,
689            version,
690            &mac_text,
691            &destination_mac,
692        ) else {
693            return;
694        };
695
696        debug!(
697            "EAPOL {} from {} raising {}",
698            codec::eapol_packet_type_name(frame.packet_type),
699            mac_text,
700            prepared.event_id
701        );
702
703        let protocol = EapolProtocol::for_request(prepared.request.clone());
704        let event = match prepared.event_id {
705            "eapol_start" => Event::new(&EAPOL_START_EVENT, prepared.event_data.clone()),
706            "eapol_identity_response" => {
707                Event::new(&EAPOL_IDENTITY_RESPONSE_EVENT, prepared.event_data.clone())
708            }
709            "eapol_method_response" => {
710                Event::new(&EAPOL_METHOD_RESPONSE_EVENT, prepared.event_data.clone())
711            }
712            _ => Event::new(&EAPOL_LOGOFF_EVENT, prepared.event_data.clone()),
713        };
714
715        let outcome = call_llm(
716            &cx.llm_client,
717            &cx.state,
718            cx.server_id,
719            Some(connection_id),
720            &event,
721            &protocol,
722        )
723        .await;
724
725        let answer = Self::decide(
726            outcome,
727            &prepared.request,
728            prepared.silence_is_safe,
729            &cx.status_tx,
730            &mac_text,
731        );
732        let decision = answer.decision;
733
734        // Log the decision before writing it, and make every fail-closed path loud. The token
735        // is stable so an operator can grep `decision=fail_closed_` and find every supplicant
736        // the model did not actually answer for.
737        let summary = format!(
738            "EAPOL {} mac={} identity={} decision={}",
739            prepared.event_id,
740            mac_text,
741            prepared.identity.as_deref().unwrap_or("-"),
742            decision.as_str()
743        );
744        if decision.is_fail_closed() {
745            log.error(format!(
746                "{} (denied because no usable admission decision was produced)",
747                summary
748            ));
749        } else {
750            log.info(&summary);
751        }
752
753        let Some(reply) = answer.frame else {
754            debug!("EAPOL sending nothing to {}", mac_text);
755            if prepared.terminal {
756                Self::end_session(&cx, supplicant_mac).await;
757            }
758            return;
759        };
760
761        let outcome_class = classify_frame(&reply);
762
763        // Bookkeeping for whatever we are about to send. The identifier and any challenge are
764        // read back out of the encoded frame — the bytes on the wire are the authority — and
765        // only the expected password comes from the action, because it is the one field the
766        // wire deliberately never carries.
767        Self::record_outbound(&cx, supplicant_mac, &reply, &answer.expected_password);
768
769        match transmit.send(supplicant_mac, &reply).await {
770            Ok(sent) => {
771                trace!(
772                    "EAPOL sent {} octets to {}: {}",
773                    sent,
774                    mac_text,
775                    hex::encode(&reply)
776                );
777                cx.state
778                    .update_connection_stats(
779                        cx.server_id,
780                        connection_id,
781                        None,
782                        Some(sent as u64),
783                        None,
784                        Some(1),
785                    )
786                    .await;
787            }
788            Err(e) => {
789                log.error(format!("EAPOL failed to reply to {}: {}", mac_text, e));
790            }
791        }
792
793        // A Success or a Failure ends the exchange either way.
794        let terminal_reply = matches!(
795            outcome_class,
796            Some(Decision::ModelAdmit) | Some(Decision::ModelReject)
797        );
798        if terminal_reply || prepared.terminal {
799            let authorized = matches!(outcome_class, Some(Decision::ModelAdmit));
800            log.info(format!(
801                "EAPOL port for {} is now {} (identity={}, decision={})",
802                mac_text,
803                if authorized {
804                    "AUTHORIZED"
805                } else {
806                    "unauthorized"
807                },
808                prepared.identity.as_deref().unwrap_or("-"),
809                decision.as_str()
810            ));
811            Self::end_session(&cx, supplicant_mac).await;
812        }
813    }
814
815    // -----------------------------------------------------------------------
816    // The fail-closed rule
817    // -----------------------------------------------------------------------
818
819    /// **The fail-closed rule.** Returns the decision taken and the frame to send, if any.
820    ///
821    /// - A usable model action is used verbatim.
822    /// - No usable action, an unencodable action, or an LLM error produces a synthesised
823    ///   **EAP-Failure** and a `fail_closed_*` decision. It is never reported as
824    ///   `model_reject`: a model that denies and a model that is unreachable must remain
825    ///   distinguishable, which is exactly what OAuth2 lost.
826    /// - An `EAP-Success` is used only when the session has an established identity. The
827    ///   executor already refuses otherwise; this is the second gate, and it has its own
828    ///   token so a first-gate regression is visible in the log rather than silent.
829    /// - `silence_is_safe` is set only for EAPOL-Logoff, where the port was already
830    ///   de-authorized before the model was asked, so there is nothing left to deny.
831    ///
832    /// There is no argument to this function, and no branch inside it, that can produce
833    /// `EAP-Success` bytes. Success can only arrive here already encoded, from
834    /// `execute_action`'s `send_eap_success` arm.
835    fn decide(
836        llm_outcome: Result<crate::llm::ExecutionResult>,
837        request: &actions::RequestContext,
838        silence_is_safe: bool,
839        status_tx: &mpsc::UnboundedSender<String>,
840        mac_text: &str,
841    ) -> Answer {
842        let log = Log::new(Some(status_tx));
843
844        // Every failure exit below carries `expected_password: None`. That is not an
845        // oversight: a challenge that was never issued has no password to remember.
846        let denied = |decision: Decision| Answer {
847            decision,
848            frame: Self::synthesised_failure(request, silence_is_safe),
849            expected_password: None,
850        };
851
852        let execution = match llm_outcome {
853            Ok(result) => {
854                for message in &result.messages {
855                    log.info(message);
856                }
857                result
858            }
859            Err(e) => {
860                // EAPOL has exactly one way to say no, so an overloaded backend and a dead
861                // one both produce the same EAP-Failure on the wire. The distinction is still
862                // worth having, so it goes in the log next to the decision token — and the
863                // error itself never reaches the supplicant.
864                let category = match crate::utils::wire_failure::WireFailure::classify(&e) {
865                    crate::utils::wire_failure::WireFailure::Overloaded => "overloaded",
866                    crate::utils::wire_failure::WireFailure::Unavailable => "unavailable",
867                };
868                log.error(format!(
869                    "EAPOL LLM call failed for {} (category={}): {}",
870                    mac_text, category, e
871                ));
872                return denied(Decision::FailClosedLlmError);
873            }
874        };
875
876        // The password the model wants an MD5 response compared against. Taken from the
877        // action rather than the frame, for the reason `Answer::expected_password` records.
878        let expected_password = execution
879            .raw_actions
880            .iter()
881            .filter(|a| a.get("type").and_then(|v| v.as_str()) == Some("send_eap_request_method"))
882            .find_map(|a| {
883                a.get("expected_password")
884                    .and_then(|v| v.as_str())
885                    .map(|s| s.to_string())
886            });
887
888        // Take the first output that decodes as an EAPOL reply. Extra outputs are a model
889        // error, not a licence to send several frames for one received packet.
890        let mut chosen: Option<(Decision, Vec<u8>)> = None;
891        let mut extra = 0usize;
892        for protocol_result in &execution.protocol_results {
893            for output in protocol_result.get_all_output() {
894                match classify_frame(&output) {
895                    Some(decision) if chosen.is_none() => chosen = Some((decision, output)),
896                    _ => extra += 1,
897                }
898            }
899        }
900        if extra > 0 {
901            warn!(
902                "EAPOL ignored {} extra frame(s) for {}; one received packet gets one reply",
903                extra, mac_text
904            );
905        }
906
907        if let Some((decision, bytes)) = chosen {
908            // Second gate on admission. `execute_action` refuses a Success without an
909            // established identity, so this cannot fire today — which is the point: if it
910            // ever does, an authentication bypass is reported in the log instead of served
911            // on the wire.
912            if decision == Decision::ModelAdmit && !request.identity_established {
913                log.error(format!(
914                    "EAPOL refusing an EAP-Success for {}: this session has produced no \
915                     identity. Denying instead. This should be unreachable — the action \
916                     executor refuses it first — so treat it as a defect report.",
917                    mac_text
918                ));
919                return Answer {
920                    decision: Decision::FailClosedNoIdentity,
921                    // Never silent: an admission that had to be refused is a denial, even on
922                    // the one event where saying nothing would otherwise be safe.
923                    frame: Self::synthesised_failure(request, false),
924                    expected_password: None,
925                };
926            }
927            return Answer {
928                decision,
929                frame: Some(bytes),
930                expected_password,
931            };
932        }
933
934        // Nothing usable came back. If the model produced actions at all, they failed to
935        // encode; if it produced none, it stayed silent. Both deny, and both are recorded as
936        // the server's decision rather than the model's.
937        denied(if execution.raw_actions.is_empty() {
938            Decision::FailClosedNoAction
939        } else {
940            Decision::FailClosedActionError
941        })
942    }
943
944    /// The EAP-Failure the server sends when the model did not decide.
945    ///
946    /// Built by calling [`codec::eapol_eap_failure_frame`] directly — it never goes near the
947    /// action executor, so no model output and no error path can steer it.
948    fn synthesised_failure(
949        request: &actions::RequestContext,
950        silence_is_safe: bool,
951    ) -> Option<Vec<u8>> {
952        if silence_is_safe {
953            // EAPOL-Logoff: the session was destroyed before the model was asked, so there is
954            // no access left to deny and a fabricated frame would say nothing true.
955            return None;
956        }
957        Some(codec::eapol_eap_failure_frame(
958            request.eapol_version,
959            request.result_identifier,
960        ))
961    }
962
963    // -----------------------------------------------------------------------
964    // Session bookkeeping
965    // -----------------------------------------------------------------------
966
967    /// Find or create the session for a supplicant, and the connection row that represents it.
968    async fn ensure_session(
969        cx: &ServerCx,
970        mac: [u8; 6],
971        peer: SocketAddr,
972        bytes: usize,
973    ) -> ConnectionId {
974        // Fast path: an existing session. The guard is dropped before the await.
975        let existing = {
976            let sessions = sessions_lock(&cx.sessions);
977            sessions.get(&mac).map(|s| s.connection_id)
978        };
979        if let Some(id) = existing {
980            cx.state
981                .update_connection_stats(cx.server_id, id, Some(bytes as u64), None, Some(1), None)
982                .await;
983            return id;
984        }
985
986        let connection_id = ConnectionId::new(cx.state.get_next_unified_id().await);
987        {
988            let mut sessions = sessions_lock(&cx.sessions);
989            sessions.entry(mac).or_insert(Session {
990                connection_id,
991                identity: None,
992                last_request_identifier: None,
993                next_identifier: 1,
994                pending_md5: None,
995            });
996        }
997
998        use crate::state::server::{
999            ConnectionState as ServerConnectionState, ConnectionStatus, ProtocolConnectionInfo,
1000        };
1001        let now = crate::utils::clock::Instant::now();
1002        cx.state
1003            .add_connection_to_server(
1004                cx.server_id,
1005                ServerConnectionState {
1006                    id: connection_id,
1007                    remote_addr: peer,
1008                    local_addr: cx.local_addr,
1009                    bytes_sent: 0,
1010                    bytes_received: bytes as u64,
1011                    packets_sent: 0,
1012                    packets_received: 1,
1013                    last_activity: now,
1014                    status: ConnectionStatus::Active,
1015                    status_changed_at: now,
1016                    protocol_info: ProtocolConnectionInfo::new(serde_json::json!({
1017                        "supplicant_mac": codec::format_mac(&mac),
1018                    })),
1019                },
1020            )
1021            .await;
1022        let _ = cx.status_tx.send("__UPDATE_UI__".to_string());
1023        connection_id
1024    }
1025
1026    /// Drop a session and close its dashboard row.
1027    ///
1028    /// EAPOL sessions are removed explicitly here rather than by the 10-second idle sweep:
1029    /// `metadata()` deliberately does not set `connectionless()`, because an exchange parked
1030    /// on a manual handler waiting for a human routinely outlives 10 seconds, and the sweep
1031    /// would draw it `(closed)` while it was still live.
1032    async fn end_session(cx: &ServerCx, mac: [u8; 6]) {
1033        let connection_id = {
1034            let mut sessions = sessions_lock(&cx.sessions);
1035            sessions.remove(&mac).map(|s| s.connection_id)
1036        };
1037        if let Some(id) = connection_id {
1038            cx.state.close_connection_on_server(cx.server_id, id).await;
1039            let _ = cx.status_tx.send("__UPDATE_UI__".to_string());
1040        }
1041    }
1042
1043    /// Record what an outbound frame implies for the session: the identifier a Response must
1044    /// echo, and any MD5 challenge now outstanding.
1045    ///
1046    /// Read from the encoded frame, not from the model's JSON — `expected_password` is the
1047    /// only thing the action carries that the wire does not.
1048    fn record_outbound(
1049        cx: &ServerCx,
1050        mac: [u8; 6],
1051        frame: &[u8],
1052        expected_password: &Option<String>,
1053    ) {
1054        let Ok(eapol) = codec::EapolFrame::decode(frame) else {
1055            return;
1056        };
1057        if eapol.packet_type != codec::EAPOL_TYPE_EAP_PACKET {
1058            return;
1059        }
1060        let Ok(eap) = codec::EapPacket::decode(&eapol.body) else {
1061            return;
1062        };
1063        if eap.code != codec::EAP_CODE_REQUEST {
1064            return;
1065        }
1066
1067        let pending = if eap.eap_type == Some(codec::EAP_TYPE_MD5_CHALLENGE) {
1068            codec::decode_md5_value(&eap.type_data)
1069                .ok()
1070                .map(|(challenge, _name)| PendingMd5 {
1071                    identifier: eap.identifier,
1072                    challenge,
1073                    expected_password: expected_password.clone(),
1074                })
1075        } else {
1076            None
1077        };
1078
1079        let mut sessions = sessions_lock(&cx.sessions);
1080        if let Some(session) = sessions.get_mut(&mac) {
1081            session.last_request_identifier = Some(eap.identifier);
1082            session.next_identifier = eap.identifier.wrapping_add(1);
1083            if pending.is_some() {
1084                session.pending_md5 = pending;
1085            }
1086        }
1087    }
1088
1089    // -----------------------------------------------------------------------
1090    // Event construction
1091    // -----------------------------------------------------------------------
1092
1093    /// Classify a received frame into an event, and build the reply context for it.
1094    ///
1095    /// Returns `None` for anything this server must not answer — a Request or a
1096    /// Success/Failure sent *by* a supplicant, a Response whose identifier does not echo
1097    /// ours, an EAPOL-Key, or a malformed EAP packet. Dropping is what RFC 3748 §4.1
1098    /// prescribes.
1099    fn prepare(
1100        cx: &ServerCx,
1101        mac: [u8; 6],
1102        frame: &codec::EapolFrame,
1103        version: u8,
1104        mac_text: &str,
1105        destination_mac: &[u8; 6],
1106    ) -> Option<Prepared> {
1107        let log = Log::new(Some(&cx.status_tx));
1108
1109        match frame.packet_type {
1110            codec::EAPOL_TYPE_START => {
1111                let request_identifier = {
1112                    let mut sessions = sessions_lock(&cx.sessions);
1113                    let session = sessions.get_mut(&mac)?;
1114                    // An EAPOL-Start restarts the exchange: whatever was claimed before is no
1115                    // longer evidence of anything.
1116                    session.identity = None;
1117                    session.pending_md5 = None;
1118                    session.next_identifier
1119                };
1120                Some(Prepared {
1121                    event_id: "eapol_start",
1122                    event_data: serde_json::json!({
1123                        "source_mac": mac_text,
1124                        "destination_mac": codec::format_mac(destination_mac),
1125                        "eapol_version": version,
1126                    }),
1127                    request: actions::RequestContext {
1128                        eapol_version: version,
1129                        request_identifier,
1130                        // Nothing has been received to echo, so a denial carries the
1131                        // identifier the next Request would have used.
1132                        result_identifier: request_identifier,
1133                        identity_established: false,
1134                        md5_challenge: Self::fresh_challenge(),
1135                    },
1136                    identity: None,
1137                    silence_is_safe: false,
1138                    terminal: false,
1139                })
1140            }
1141
1142            codec::EAPOL_TYPE_LOGOFF => {
1143                // De-authorize first, then ask. Ending access is not the model's decision to
1144                // make, and a backend outage must not be able to keep a port open.
1145                let (identity, request_identifier) = {
1146                    let mut sessions = sessions_lock(&cx.sessions);
1147                    let session = sessions.get_mut(&mac)?;
1148                    session.pending_md5 = None;
1149                    let identity = session.identity.take();
1150                    (identity, session.next_identifier)
1151                };
1152                Some(Prepared {
1153                    event_id: "eapol_logoff",
1154                    event_data: serde_json::json!({
1155                        "source_mac": mac_text,
1156                        "identity": identity,
1157                        "eapol_version": version,
1158                    }),
1159                    request: actions::RequestContext {
1160                        eapol_version: version,
1161                        request_identifier,
1162                        result_identifier: request_identifier,
1163                        identity_established: false,
1164                        md5_challenge: Self::fresh_challenge(),
1165                    },
1166                    identity,
1167                    silence_is_safe: true,
1168                    terminal: true,
1169                })
1170            }
1171
1172            codec::EAPOL_TYPE_EAP_PACKET => {
1173                let eap = match codec::EapPacket::decode(&frame.body) {
1174                    Ok(p) => p,
1175                    Err(e) => {
1176                        log.warn(format!(
1177                            "EAPOL dropped an EAP packet from {}: {}",
1178                            mac_text, e
1179                        ));
1180                        return None;
1181                    }
1182                };
1183
1184                if eap.code != codec::EAP_CODE_RESPONSE {
1185                    // An authenticator answers Responses. A Request, Success or Failure sent
1186                    // by a supplicant is either a protocol violation or an attempt to talk
1187                    // the server into agreeing with itself.
1188                    log.warn(format!(
1189                        "EAPOL ignoring {} from {}: an authenticator answers Responses only",
1190                        codec::eap_code_name(eap.code),
1191                        mac_text
1192                    ));
1193                    return None;
1194                }
1195
1196                let (identity, pending, expected_identifier) = {
1197                    let sessions = sessions_lock(&cx.sessions);
1198                    let session = sessions.get(&mac)?;
1199                    (
1200                        session.identity.clone(),
1201                        session.pending_md5.clone(),
1202                        session.last_request_identifier,
1203                    )
1204                };
1205
1206                // RFC 3748 §4.1: a Response that does not echo the outstanding Request's
1207                // identifier is silently discarded. Getting this wrong looks exactly like a
1208                // hang, which is why it is checked rather than assumed.
1209                match expected_identifier {
1210                    Some(expected) if eap.identifier != expected => {
1211                        log.warn(format!(
1212                            "EAPOL discarding a Response from {}: identifier {} does not echo \
1213                             the outstanding Request's {}",
1214                            mac_text, eap.identifier, expected
1215                        ));
1216                        return None;
1217                    }
1218                    Some(_) => {}
1219                    None => debug!(
1220                        "EAPOL accepting an unsolicited Response from {} (no Request was \
1221                         outstanding)",
1222                        mac_text
1223                    ),
1224                }
1225
1226                let eap_type = eap.eap_type.unwrap_or(0);
1227
1228                if eap_type == codec::EAP_TYPE_IDENTITY {
1229                    let claimed = String::from_utf8_lossy(&eap.type_data).into_owned();
1230                    {
1231                        let mut sessions = sessions_lock(&cx.sessions);
1232                        if let Some(session) = sessions.get_mut(&mac) {
1233                            session.identity = Some(claimed.clone());
1234                            session.pending_md5 = None;
1235                        }
1236                    }
1237                    return Some(Prepared {
1238                        event_id: "eapol_identity_response",
1239                        event_data: serde_json::json!({
1240                            "identity": claimed,
1241                            "source_mac": mac_text,
1242                            "eap_identifier": eap.identifier,
1243                            "eapol_version": version,
1244                        }),
1245                        request: actions::RequestContext {
1246                            eapol_version: version,
1247                            request_identifier: eap.identifier.wrapping_add(1),
1248                            // RFC 3748 §4.2: a Success or Failure echoes the Response it
1249                            // answers. A mismatch is silently discarded by the supplicant,
1250                            // which looks exactly like a hang.
1251                            result_identifier: eap.identifier,
1252                            identity_established: true,
1253                            md5_challenge: Self::fresh_challenge(),
1254                        },
1255                        identity: Some(claimed),
1256                        silence_is_safe: false,
1257                        terminal: false,
1258                    });
1259                }
1260
1261                // A method response. NetGet does the cryptography; the model gets a verdict.
1262                let mut data = serde_json::json!({
1263                    "eap_type": codec::eap_type_name(eap_type),
1264                    "eap_type_number": eap_type,
1265                    "identity": identity,
1266                    "source_mac": mac_text,
1267                    "eap_identifier": eap.identifier,
1268                    "eapol_version": version,
1269                    "type_data_length": eap.type_data.len(),
1270                });
1271
1272                if eap_type == codec::EAP_TYPE_MD5_CHALLENGE {
1273                    let (verification, verified) =
1274                        Self::verify_md5(&pending, eap.identifier, &eap.type_data);
1275                    data["md5_verification"] = serde_json::json!(verification);
1276                    data["md5_verified"] = serde_json::json!(verified);
1277                } else if eap_type == codec::EAP_TYPE_NAK {
1278                    let desired: Vec<String> = eap
1279                        .type_data
1280                        .iter()
1281                        .map(|t| codec::eap_type_name(*t).to_string())
1282                        .collect();
1283                    data["desired_eap_types"] = serde_json::json!(desired);
1284                } else if eap_type == codec::EAP_TYPE_TLS || eap_type == codec::EAP_TYPE_PEAP {
1285                    if let Some((length_included, more, start)) = codec::tls_flags(&eap.type_data) {
1286                        data["tls_length_included"] = serde_json::json!(length_included);
1287                        data["tls_more_fragments"] = serde_json::json!(more);
1288                        data["tls_start"] = serde_json::json!(start);
1289                    }
1290                }
1291
1292                Some(Prepared {
1293                    event_id: "eapol_method_response",
1294                    event_data: data,
1295                    request: actions::RequestContext {
1296                        eapol_version: version,
1297                        request_identifier: eap.identifier.wrapping_add(1),
1298                        result_identifier: eap.identifier,
1299                        // A method response can only admit a session that already claimed an
1300                        // identity. Without one there is nothing to admit, whatever the
1301                        // digest says.
1302                        identity_established: identity.is_some(),
1303                        md5_challenge: Self::fresh_challenge(),
1304                    },
1305                    identity,
1306                    silence_is_safe: false,
1307                    terminal: false,
1308                })
1309            }
1310
1311            other => {
1312                // EAPOL-Key (MKA) and Encapsulated-ASF-Alert are not implemented, and no
1313                // event is declared for them. Declaring one that nothing could answer would
1314                // be worse than saying so here.
1315                log.warn(format!(
1316                    "EAPOL ignoring {} from {}: not implemented by this authenticator",
1317                    codec::eapol_packet_type_name(other),
1318                    mac_text
1319                ));
1320                None
1321            }
1322        }
1323    }
1324
1325    /// Compare a supplicant's MD5-Challenge response against the outstanding challenge.
1326    ///
1327    /// Every branch that is not a genuine match reports `false`. In particular, "the model
1328    /// never told us a password" reports `not_checked_no_expected_password`, not `verified` —
1329    /// the OAuth2 shape, where the absence of information became a positive assertion.
1330    fn verify_md5(
1331        pending: &Option<PendingMd5>,
1332        identifier: u8,
1333        type_data: &[u8],
1334    ) -> (&'static str, bool) {
1335        let Some(pending) = pending else {
1336            return ("unsolicited", false);
1337        };
1338        if pending.identifier != identifier {
1339            return ("unsolicited", false);
1340        }
1341        let Ok((value, _name)) = codec::decode_md5_value(type_data) else {
1342            return ("malformed", false);
1343        };
1344        let Some(password) = pending.expected_password.as_deref() else {
1345            return ("not_checked_no_expected_password", false);
1346        };
1347        if codec::md5_response_matches(identifier, password, &pending.challenge, &value) {
1348            ("verified", true)
1349        } else {
1350            ("mismatch", false)
1351        }
1352    }
1353
1354    /// A fresh MD5 challenge. Generated here, never by the model: RFC 1994 §2.2 requires it
1355    /// to be unpredictable, and a model cannot produce randomness.
1356    fn fresh_challenge() -> Vec<u8> {
1357        use rand::Rng;
1358        let mut challenge = vec![0u8; codec::MD5_CHALLENGE_LEN];
1359        rand::thread_rng().fill(&mut challenge[..]);
1360        challenge
1361    }
1362}