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}