//! Aggregates captured 802.11 frames into access point, client station, and //! handshake tables. use crate::dot11::{self, FrameKind, Security}; use crate::handshake::{Handshake, HandshakeInfo, HandshakeTracker}; use crate::radiotap::{self, Band}; use std::collections::{HashMap, VecDeque}; use std::time::{SystemTime, UNIX_EPOCH}; #[derive(Debug, Clone, serde::Serialize)] pub struct AccessPoint { pub bssid: String, pub ssid: Option, pub hidden: bool, pub channel: Option, pub band: Option, pub freq_mhz: Option, pub security: Option, pub cipher: Option, pub akm: Vec, pub pmf: bool, pub pmf_required: bool, pub wps: bool, pub wps_locked: Option, pub wps_version: Option, pub wps_manufacturer: Option, pub wps_model: Option, pub wps_model_number: Option, pub wps_device_name: Option, pub wps_serial: Option, pub vendor: Option, pub country: Option, pub phy: Vec, pub width_mhz: Option, pub beacon_interval: Option, pub privacy: bool, pub signal_dbm: Option, pub max_signal_dbm: Option, pub beacons: u64, pub data_frames: u64, pub clients: Vec, pub first_seen_unix: u64, pub last_seen_unix: u64, } #[derive(Debug, Clone, serde::Serialize)] pub struct SignalSample { pub t: u64, pub dbm: i8, } #[derive(Debug, Clone, serde::Serialize)] pub struct ClientEvent { pub t: u64, pub kind: String, pub detail: Option, } #[derive(Debug, Clone, serde::Serialize)] pub struct Client { pub mac: String, /// Best human-readable label for a station: DHCP/mDNS hostname, WPS device name, model, or OUI vendor. pub display_name: Option, pub vendor: Option, pub manufacturer: Option, pub model: Option, pub device_name: Option, pub hostname: Option, pub ip: Option, pub os: Option, pub device: Option, pub random_mac: bool, pub bssid: Option, pub signal_dbm: Option, pub max_signal_dbm: Option, pub packets: u64, pub probes: Vec, pub associated: bool, pub first_seen_unix: u64, pub last_seen_unix: u64, /// Recent RSSI history (oldest first, bounded ring) for sparkline rendering. /// pub signal_history: Vec, /// Per-client event timeline (first seen, probe, association, deauthentication, etc.). pub events: Vec, } #[derive(Debug, Clone, Copy, Default)] pub struct IngestEvent { pub eapol: bool, pub handshake_updated: bool, pub deauth: bool, } #[derive(Debug, Clone, serde::Serialize)] pub struct ScanSnapshot { pub iface: String, pub running: bool, pub channel: Option, pub stats: ScanStats, pub access_points: Vec, pub clients: Vec, pub handshakes: Vec, pub analysis: crate::analysis::Analysis, /// Populated when the capture loop terminated due to an I/O error (e.g. unplugged /// USB adapter). /// pub error: Option, /// Populated when capture has been running for >12s across channels with zero /// received frames (typical of drivers/firmware that silently ignore monitor mode). /// /// /// pub warning: Option, } #[derive(Debug, Clone, Copy, Default, serde::Serialize)] pub struct ScanStats { pub frames: u64, pub eapol_frames: u64, pub deauth_frames: u64, pub aps: usize, pub clients: usize, pub handshakes: usize, } #[derive(Default)] pub struct ScanTable { aps: HashMap, clients: HashMap, handshakes: HandshakeTracker, frames: u64, eapol_frames: u64, deauth_frames: u64, deauth_times: VecDeque, } impl ScanTable { pub fn new() -> Self { Self::default() } /// Ingests a raw Radiotap + 802.11 frame. Returns `true` if the frame carried /// an EAPOL handshake message. pub fn ingest(&mut self, raw: &[u8]) -> bool { let now = unix_now(); self.ingest_at(raw, now).eapol } pub fn ingest_at(&mut self, raw: &[u8], now: u64) -> IngestEvent { let mut event = IngestEvent::default(); let Some(rt) = radiotap::parse(raw) else { return event; }; if rt.header_len >= raw.len() { return event; } let Some(frame) = dot11::parse(&raw[rt.header_len..]) else { return event; }; self.frames += 1; match frame.kind { FrameKind::Beacon | FrameKind::ProbeResponse => { let Some(bssid) = frame.bssid_str() else { return event; }; let info = frame.management_info(); let channel = info .channel .or_else(|| rt.freq_mhz.and_then(radiotap::freq_to_channel)); let band = rt.freq_mhz.map(radiotap::band_for_freq); let ap = self .aps .entry(bssid.clone()) .or_insert_with(|| AccessPoint { bssid: bssid.clone(), ssid: None, hidden: false, channel: None, band: None, freq_mhz: None, security: None, cipher: None, akm: Vec::new(), pmf: false, pmf_required: false, wps: false, wps_locked: None, wps_version: None, wps_manufacturer: None, wps_model: None, wps_model_number: None, wps_device_name: None, wps_serial: None, vendor: crate::oui::vendor(&bssid).map(str::to_string), country: None, phy: Vec::new(), width_mhz: None, beacon_interval: None, privacy: false, signal_dbm: None, max_signal_dbm: None, beacons: 0, data_frames: 0, clients: Vec::new(), first_seen_unix: now, last_seen_unix: now, }); if let Some(ssid) = info.ssid.clone() { if !ssid.is_empty() || ap.ssid.is_none() { ap.ssid = Some(ssid); } ap.hidden = info.hidden; } if channel.is_some() { ap.channel = channel; } if rt.freq_mhz.is_some() { ap.freq_mhz = rt.freq_mhz; ap.band = band; } ap.security = info.security.map(security_label); ap.cipher = info.cipher.clone(); ap.akm = info.akm.clone(); ap.pmf = info.pmf; ap.pmf_required = info.pmf_required; ap.wps = info.wps; if info.wps { ap.wps_locked = info.wps_locked; ap.wps_version = info.wps_version.clone(); ap.wps_manufacturer = info.wps_manufacturer.clone(); ap.wps_model = info.wps_model.clone(); ap.wps_model_number = info.wps_model_number.clone(); ap.wps_device_name = info.wps_device_name.clone(); ap.wps_serial = info.wps_serial.clone(); } ap.country = info.country.clone().or(ap.country.take()); if !info.phy.is_empty() { ap.phy = info.phy.clone(); } ap.width_mhz = info.width_mhz.or(ap.width_mhz); ap.beacon_interval = info.beacon_interval.or(ap.beacon_interval); ap.privacy = info.privacy; ap.signal_dbm = rt.signal_dbm; if let Some(sig) = rt.signal_dbm { ap.max_signal_dbm = Some(ap.max_signal_dbm.map_or(sig, |m| m.max(sig))); } ap.beacons += 1; ap.last_seen_unix = now; if let Some(ssid) = ap.ssid.clone().filter(|s| !s.is_empty()) { self.handshakes.update_ssid(&bssid, &ssid); } } FrameKind::ProbeRequest => { let Some(client) = frame.src_str() else { return event; }; let info = frame.management_info(); let c = self.touch_client(&client, now, rt.signal_dbm); c.packets += 1; apply_management_identity(c, &info); if let Some(ssid) = info.ssid.filter(|s| !s.is_empty()) { if !c.probes.contains(&ssid) { c.probes.push(ssid.clone()); c.events.push(ClientEvent { t: now, kind: "probe".into(), detail: Some(ssid), }); trim_events(c); } } refresh_identity(c); } FrameKind::AssociationRequest => { let client = frame.src_str(); let bssid = frame .addr1 .map(|m| dot11::mac_to_string(&m)) .or_else(|| frame.bssid_str()); if let (Some(client), Some(bssid)) = (client, bssid) { let info = frame.management_info(); let c = self.touch_client(&client, now, rt.signal_dbm); c.packets += 1; apply_management_identity(c, &info); refresh_identity(c); let was = c.associated; c.bssid = Some(bssid.clone()); c.associated = true; if !was { c.events.push(ClientEvent { t: now, kind: "associate".into(), detail: Some(bssid.clone()), }); trim_events(c); } if let Some(ap) = self.aps.get_mut(&bssid) { if !ap.clients.contains(&client) { ap.clients.push(client); } } } } FrameKind::Data => { let bssid = match (frame.to_ds, frame.from_ds) { (true, false) => frame.addr1.map(|m| dot11::mac_to_string(&m)), (false, true) => frame.addr2.map(|m| dot11::mac_to_string(&m)), _ => frame.bssid_str(), }; let client = match (frame.to_ds, frame.from_ds) { (true, false) => frame.src_str(), (false, true) => frame.dst_str(), _ => frame.src_str(), }; if let (Some(client), Some(bssid)) = (client.clone(), bssid.clone()) { if client != bssid { // Look up any derived CCMP Temporal Key (TK) before borrowing `self.clients`. // // let tk = (frame.protected && frame.to_ds && !frame.from_ds) .then(|| self.handshakes.tk_for(&bssid, &client)) .flatten(); let c = self.touch_client(&client, now, rt.signal_dbm); c.packets += 1; if frame.to_ds && !frame.from_ds { if frame.protected { // Decrypt CCMP data frames when we have derived this station's association TK. // // if let Some(plain) = tk.and_then(|tk| frame.ccmp_payload(&tk)) { let payload = dot11::payload_info_from_llc(&plain); apply_payload_identity(c, &payload); } } else { let payload = frame.payload_info(); apply_payload_identity(c, &payload); } } // Refresh `display_name` on both uplink and downlink frames so OUI vendor // fallback is populated even for receive-only stations. // refresh_identity(c); let was = c.associated; c.bssid = Some(bssid.clone()); c.associated = true; if !was { c.events.push(ClientEvent { t: now, kind: "associate".into(), detail: Some(bssid.clone()), }); trim_events(c); } if let Some(ap) = self.aps.get_mut(&bssid) { ap.data_frames += 1; if !ap.clients.contains(&client) { ap.clients.push(client.clone()); } } } } if frame.eapol().is_some() { event.eapol = true; self.eapol_frames += 1; let hint = bssid .as_deref() .and_then(|b| self.aps.get(b)) .and_then(|ap| ap.ssid.clone()) .filter(|s| !s.is_empty()); if self.handshakes.ingest(&frame, raw, hint.as_deref(), now) { event.handshake_updated = true; } } } FrameKind::Deauth | FrameKind::Disassoc => { self.deauth_frames += 1; self.deauth_times.push_back(now); while self .deauth_times .front() .is_some_and(|t| now.saturating_sub(*t) > 60) { self.deauth_times.pop_front(); } event.deauth = true; let a1 = frame.addr1.map(|m| dot11::mac_to_string(&m)); let a2 = frame.addr2.map(|m| dot11::mac_to_string(&m)); let bssid = frame.bssid_str(); let client = match (&a1, &a2, &bssid) { (Some(a1), Some(_), Some(b)) if a1 != b => Some(a1.clone()), (Some(_), Some(a2), Some(b)) if a2 != b => Some(a2.clone()), _ => None, }; if let Some(client) = client { if let Some(c) = self.clients.get_mut(&client) { if c.associated { c.events.push(ClientEvent { t: now, kind: "deauth".into(), detail: bssid.clone(), }); trim_events(c); } c.associated = false; c.last_seen_unix = now; } } } _ => {} } event } fn touch_client(&mut self, mac: &str, now: u64, signal: Option) -> &mut Client { let c = self .clients .entry(mac.to_string()) .or_insert_with(|| Client { mac: mac.to_string(), display_name: None, vendor: crate::oui::vendor(mac).map(str::to_string), manufacturer: None, model: None, device_name: None, hostname: None, ip: None, os: None, device: None, random_mac: is_random_mac(mac), bssid: None, signal_dbm: None, max_signal_dbm: None, packets: 0, probes: Vec::new(), associated: false, first_seen_unix: now, last_seen_unix: now, signal_history: Vec::new(), events: vec![ClientEvent { t: now, kind: "first_seen".into(), detail: None, }], }); c.last_seen_unix = now; c.signal_dbm = signal; if let Some(sig) = signal { c.max_signal_dbm = Some(c.max_signal_dbm.map_or(sig, |m| m.max(sig))); let should_push = c .signal_history .last() .map_or(true, |s| s.t != now || s.dbm != sig); if should_push { c.signal_history.push(SignalSample { t: now, dbm: sig }); if c.signal_history.len() > 60 { let drop = c.signal_history.len() - 60; c.signal_history.drain(0..drop); } } } // Populate `display_name` from the OUI vendor immediately on first sighting. // // if c.display_name.is_none() { refresh_identity(c); } c } pub fn analysis(&self) -> crate::analysis::Analysis { let now = unix_now(); let aps = self.access_points(); let deauth = self.deauth_times.len() as u32; crate::analysis::analyze(&aps, deauth, now) } pub fn deauth_60s(&self) -> u32 { self.deauth_times.len() as u32 } pub fn access_points(&self) -> Vec { let mut v: Vec<_> = self.aps.values().cloned().collect(); v.sort_by(|a, b| { b.beacons .cmp(&a.beacons) .then_with(|| a.ssid.cmp(&b.ssid)) .then_with(|| a.bssid.cmp(&b.bssid)) }); v } pub fn clients(&self) -> Vec { let mut v: Vec<_> = self.clients.values().cloned().collect(); v.sort_by(|a, b| b.packets.cmp(&a.packets).then_with(|| a.mac.cmp(&b.mac))); v } pub fn handshakes(&self) -> Vec { self.handshakes.infos() } pub fn handshake(&self, bssid: &str, station: &str) -> Option<&Handshake> { self.handshakes.get(bssid, station) } /// Returns all tracked handshakes (including raw frames for `.pcap`/`.hc22000` export). pub fn handshake_entries(&self) -> Vec { self.handshakes.all().into_iter().cloned().collect() } pub fn first_crackable( &self, bssid: Option<&str>, station: Option<&str>, ) -> Option<&Handshake> { self.handshakes.first_crackable(bssid, station) } pub fn set_password(&mut self, bssid: &str, station: &str, password: &str) { self.handshakes.set_password(bssid, station, password); } pub fn stats(&self) -> ScanStats { ScanStats { frames: self.frames, eapol_frames: self.eapol_frames, deauth_frames: self.deauth_frames, aps: self.aps.len(), clients: self.clients.len(), handshakes: self.handshakes.len(), } } } fn is_random_mac(mac: &str) -> bool { let Some(first) = mac.split(':').next() else { return false; }; u8::from_str_radix(first, 16) .map(|b| b & 0x02 != 0 && b & 0x01 == 0) .unwrap_or(false) } fn apply_management_identity(c: &mut Client, info: &dot11::ManagementInfo) { if let Some(name) = info.wps_device_name.as_deref().filter(|s| !s.is_empty()) { c.device_name = Some(name.to_string()); } if let Some(manufacturer) = info.wps_manufacturer.as_deref().filter(|s| !s.is_empty()) { c.manufacturer = Some(manufacturer.to_string()); } if let Some(model) = info.wps_model.as_deref().filter(|s| !s.is_empty()) { c.model = Some(model.to_string()); } if let Some(hint) = c.device_name.as_deref().or(c.model.as_deref()) { if let Some(device) = dot11::device_from_text(hint) { c.device = Some(device); } if let Some(os) = dot11::os_from_text(hint) { c.os = Some(os); } } } fn apply_payload_identity(c: &mut Client, info: &dot11::PayloadInfo) { if let Some(ip) = &info.ip { c.ip = Some(ip.clone()); } if let Some(hostname) = &info.hostname { if !hostname.is_empty() { c.hostname = Some(hostname.clone()); } } if let Some(manufacturer) = &info.manufacturer { c.manufacturer = c.manufacturer.take().or_else(|| Some(manufacturer.clone())); } if let Some(model) = &info.model { c.model = Some(model.clone()); } if let Some(os) = &info.os { c.os = Some(os.clone()); } if let Some(device) = &info.device { c.device = Some(device.clone()); } if let Some(hint) = c.hostname.as_deref().or(c.device_name.as_deref()) { if c.os.is_none() { c.os = dot11::os_from_text(hint); } if c.device.is_none() { c.device = dot11::device_from_text(hint); } } } fn refresh_identity(c: &mut Client) { if c.manufacturer.is_none() { c.manufacturer = c.vendor.clone(); } if c.os.is_none() { c.os = c .hostname .as_deref() .or(c.device_name.as_deref()) .or(c.model.as_deref()) .and_then(dot11::os_from_text); } if c.device.is_none() { c.device = c .hostname .as_deref() .or(c.device_name.as_deref()) .or(c.model.as_deref()) .and_then(dot11::device_from_text); } c.display_name = c .hostname .clone() .or_else(|| c.device_name.clone()) .or_else(|| c.model.clone()) .or_else(|| c.manufacturer.clone()); } fn trim_events(c: &mut Client) { const MAX: usize = 40; if c.events.len() > MAX { let drop = c.events.len() - MAX; c.events.drain(0..drop); } } fn security_label(s: Security) -> String { match s { Security::Open => "Open".into(), Security::Wep => "WEP".into(), Security::Wpa => "WPA".into(), Security::Wpa2 => "WPA2".into(), Security::Wpa2Enterprise => "WPA2-EAP".into(), Security::Wpa3 => "WPA3".into(), Security::Wpa3Enterprise => "WPA3-EAP".into(), Security::Owe => "OWE".into(), Security::Wpa2Wpa3 => "WPA2/WPA3".into(), } } fn unix_now() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0) } #[cfg(test)] mod tests { use super::*; use crate::testutil::{ ap, beacon_frame, frame_with_eapol, handshake_frames, probe_request, radiotap_wrap, sta, }; fn beacon_raw(ssid: &str, akms: &[u8]) -> Vec { let frame = beacon_frame(ap(), ssid, 6, akms); radiotap_wrap(&frame, 2437, -40) } #[test] fn builds_ap_table_from_beacon() { let mut table = ScanTable::new(); table.ingest_at(&beacon_raw("Casa", &[2]), 100); let aps = table.access_points(); assert_eq!(aps.len(), 1); let ap = &aps[0]; assert_eq!(ap.ssid.as_deref(), Some("Casa")); assert_eq!(ap.bssid, "00:11:22:33:44:55"); assert_eq!(ap.channel, Some(6)); assert_eq!(ap.band, Some(Band::B2G4)); assert_eq!(ap.security.as_deref(), Some("WPA2")); assert_eq!(ap.cipher.as_deref(), Some("CCMP")); assert_eq!(ap.signal_dbm, Some(-40)); assert_eq!(ap.beacons, 1); assert_eq!(ap.first_seen_unix, 100); } #[test] fn tracks_probes_and_data_clients() { let mut table = ScanTable::new(); table.ingest_at(&beacon_raw("Casa", &[2]), 1); let probe = radiotap_wrap(&probe_request(sta(), "Casa"), 2437, -55); table.ingest_at(&probe, 2); let payload = [0xAA, 0xAA, 0x03, 0, 0, 0, 0x08, 0x00, 1, 2, 3]; let data = crate::testutil::data_frame(false, true, sta(), ap(), [0x33; 6], &payload); let data = radiotap_wrap(&data, 2437, -50); table.ingest_at(&data, 3); let clients = table.clients(); assert_eq!(clients.len(), 1); let c = &clients[0]; assert_eq!(c.mac, "66:77:88:99:aa:bb"); assert_eq!(c.bssid.as_deref(), Some("00:11:22:33:44:55")); assert!(c.associated); assert!(c.probes.contains(&"Casa".to_string())); assert_eq!(c.packets, 2); assert_eq!(table.access_points()[0].clients.len(), 1); assert_eq!(table.stats().frames, 3); } #[test] fn captures_handshake_from_raw_frames() { let mut table = ScanTable::new(); table.ingest_at(&beacon_raw("RedeWPA", &[2]), 1); let (m1, m2) = handshake_frames("RedeWPA", "password", 2, false); let f1 = frame_with_eapol(true, ap(), sta(), [0x33; 6], &m1); let raw1 = radiotap_wrap(&f1, 2437, -40); let e1 = table.ingest_at(&raw1, 2); assert!(e1.eapol); assert!(e1.handshake_updated); let f2 = frame_with_eapol(false, ap(), sta(), [0x33; 6], &m2); let raw2 = radiotap_wrap(&f2, 2437, -41); let e2 = table.ingest_at(&raw2, 3); assert!(e2.eapol); let hs = table.handshakes(); assert_eq!(hs.len(), 1); assert!(hs[0].complete); assert!(hs[0].crackable); assert_eq!(hs[0].ssid.as_deref(), Some("RedeWPA")); assert_eq!(hs[0].messages, "1,2"); assert!(table.first_crackable(None, None).is_some()); assert!(table .first_crackable(Some("00:11:22:33:44:55"), None) .is_some()); assert!(table .first_crackable(Some("ff:ff:ff:ff:ff:ff"), None) .is_none()); table.set_password("00:11:22:33:44:55", "66:77:88:99:aa:bb", "password"); assert_eq!(table.handshakes()[0].password.as_deref(), Some("password")); assert_eq!(table.stats().eapol_frames, 2); } #[test] fn counts_deauth_and_marks_client_disconnected() { let mut table = ScanTable::new(); table.ingest_at(&beacon_raw("Casa", &[2]), 1); let payload = [0xAA, 0xAA, 0x03, 0, 0, 0, 0x08, 0x00, 1]; let data = crate::testutil::data_frame(false, true, sta(), ap(), [0x33; 6], &payload); table.ingest_at(&radiotap_wrap(&data, 2437, -50), 2); let mut deauth = Vec::new(); deauth.extend_from_slice(&0x00c0u16.to_le_bytes()); deauth.extend_from_slice(&0u16.to_le_bytes()); deauth.extend_from_slice(&sta()); deauth.extend_from_slice(&ap()); deauth.extend_from_slice(&ap()); deauth.extend_from_slice(&[0u8; 2]); deauth.extend_from_slice(&7u16.to_le_bytes()); let event = table.ingest_at(&radiotap_wrap(&deauth, 2437, -50), 3); assert!(event.deauth); assert_eq!(table.stats().deauth_frames, 1); assert!(!table.clients()[0].associated); } #[test] fn flags_locally_administered_macs() { assert!(is_random_mac("5a:0d:93:04:cf:90")); assert!(is_random_mac("02:11:22:33:44:55")); assert!(!is_random_mac("00:11:22:33:44:55")); assert!(!is_random_mac("ff:ff:ff:ff:ff:ff")); } }