From a904723f96ea2d2abaf1f19e3c4b65af237a1238 Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Fri, 30 Oct 2026 20:44:17 -0300 Subject: [PATCH] fix(gui): add non-Linux stubs for pmkid_hunt/pmf_test and fix Windows warnings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main.rs failed to compile on Windows: pmkid_hunt and pmf_test were only implemented under #[cfg(target_os = "linux")], with no fallback stub, so the Tauri commands calling them did not resolve. - Add #[cfg(not(target_os = "linux"))] stubs for both, matching the existing pattern for every other Linux-only Engine method (returns SabiaError::UnsupportedPlatform). - Mark the DeauthJob/Engine fields and session_ap/client_snapshot helpers that only the Linux-only code paths use with #[cfg_attr(not(target_os = "linux"), allow(dead_code))], same for eviltwin.rs's form_value/url_decode (only called from the #[cfg(target_os = "linux")] HTTP handler) — silences the 5 dead_code warnings a Windows build produced. - .setup(|app| ...) used app only inside unix/linux/macos-gated blocks, so it was unused on Windows; renamed the closure parameter and reintroduce it under the matching cfg. - Add the missing English dictionary entry for the mutation-rules checkbox label in app.js (found by visually reviewing a generated screenshot: it was rendering in Portuguese in the English UI even though check-ui-i18n.mjs reported full coverage). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD --- apps/gui/src-tauri/src/main.rs | 37 +-- apps/gui/ui/i18n-en.js | 24 +- crates/sabia-core/src/engine.rs | 403 ++++++++++++++++-------------- crates/sabia-core/src/eviltwin.rs | 108 ++++---- 4 files changed, 302 insertions(+), 270 deletions(-) diff --git a/apps/gui/src-tauri/src/main.rs b/apps/gui/src-tauri/src/main.rs index 2714da2..da352b4 100644 --- a/apps/gui/src-tauri/src/main.rs +++ b/apps/gui/src-tauri/src/main.rs @@ -1,4 +1,4 @@ -// Evita a janela de console extra no Windows em release. +// Prevents an extra console window on Windows in release builds. #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] use sabia_core::engine::{Engine, ScanOptions}; @@ -506,9 +506,9 @@ fn update_apply(state: tauri::State<'_, State>) -> Result { state.0.update_apply().map_err(|e| e.to_string()) } -/// `true` quando o processo já tem privilégio suficiente pra tudo que o -/// Sabiá faz (modo monitor, captura crua). No Windows o manifesto já -/// exige administrador antes mesmo do app abrir, então aqui é sempre `true`. +/// `true` when the process already has sufficient privileges for all +/// Sabiá operations (monitor mode, raw capture). On Windows the manifest already +/// requires administrator privileges before launch, so this is always `true`. #[tauri::command] fn is_elevated() -> bool { #[cfg(unix)] @@ -521,9 +521,9 @@ fn is_elevated() -> bool { } } -/// Reabre o app com privilégios de administrador (Linux/macOS). O app novo -/// encerra este quando abre; se a autorização falhar ou for cancelada, a -/// janela continua aqui e a interface mostra o motivo. +/// Relaunches the app with administrator privileges (Linux/macOS). The new instance +/// terminates this one on startup; if authorization fails or is cancelled, the +/// window stays open and the UI displays the reason. #[tauri::command] fn relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> { #[cfg(any(target_os = "linux", target_os = "macos"))] @@ -610,9 +610,9 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> { Ok(()) } -/// Caminho que o root consegue executar. No AppImage, `current_exe` aponta -/// pra dentro de um ponto de montagem FUSE do usuário, inacessível ao root: -/// usa o próprio arquivo `.AppImage` (variável `APPIMAGE`). +/// Executable path accessible to root. Inside an AppImage, `current_exe` points +/// inside a user FUSE mount that root cannot access: +/// use the `.AppImage` file itself (`APPIMAGE` environment variable). #[cfg(any(target_os = "linux", target_os = "macos"))] fn elevated_exe() -> Result { if let Some(p) = std::env::var_os("APPIMAGE") @@ -628,8 +628,11 @@ fn main() { let engine = Arc::new(Engine::new()); tauri::Builder::default() .manage(State(engine)) - .setup(|app| { - // Quando abrimos elevados a pedido do app normal, ele fecha o app antigo. + .setup(|_app| { + #[cfg(any(target_os = "linux", target_os = "macos"))] + let app = _app; + + // When launched elevated by the unprivileged instance, terminate the old process. #[cfg(unix)] if let Some(pid) = std::env::var("SABIA_REPLACE_PID") .ok() @@ -637,7 +640,7 @@ fn main() { { std::thread::spawn(move || { std::thread::sleep(std::time::Duration::from_millis(1200)); - // SAFETY: só envia SIGTERM ao processo anterior do próprio usuário. + // SAFETY: only sends SIGTERM to the previous process owned by the invoking user. unsafe { if libc::geteuid() == 0 { libc::kill(pid, libc::SIGTERM); @@ -646,10 +649,10 @@ fn main() { }); } - // Captura crua e modo monitor exigem root: pede elevação assim - // que o app abre, sem esperar o usuário procurar um botão. Se - // a autorização falhar, a janela continua aberta e a interface - // mostra o aviso (com um botão pra tentar de novo). + // Raw capture and monitor mode require root: request elevation as soon + // as the app launches, without waiting for the user to look for a button. If + // authorization fails, the window stays open and the UI + // displays a warning banner (with a retry button). #[cfg(any(target_os = "linux", target_os = "macos"))] if unsafe { libc::geteuid() != 0 } && std::env::var("SABIA_REPLACE_PID").is_err() { let handle = app.handle().clone(); diff --git a/apps/gui/ui/i18n-en.js b/apps/gui/ui/i18n-en.js index 5b152e6..a0f0777 100644 --- a/apps/gui/ui/i18n-en.js +++ b/apps/gui/ui/i18n-en.js @@ -1,4 +1,4 @@ -// Dicionário português -> inglês da interface. {n} marca um valor variável. +// Portuguese -> English UI dictionary. {n} marks a variable placeholder. export const EN = { // index.html / sidebar 'auditoria de Wi-Fi': 'Wi-Fi auditing', @@ -19,7 +19,7 @@ export const EN = { 'modo monitor': 'monitor mode', 'modo gerenciado': 'managed mode', - // elevação / avisos + // elevation / notices 'Pedindo privilégio de administrador — necessário pra modo monitor e captura…': 'Requesting administrator privilege — needed for monitor mode and capture…', 'tentar de novo': 'try again', @@ -27,13 +27,13 @@ export const EN = { 'Use somente em redes próprias ou com autorização explícita do responsável. Toda ativação de modo monitor e sessão de captura fica registrada na trilha de auditoria local.': 'Use only on your own networks or with the explicit authorization of the responsible party. Every monitor mode activation and capture session is logged in the local audit trail.', - // controles + // controls 'ativar modo monitor': 'enable monitor mode', 'desativar modo monitor': 'disable monitor mode', 'iniciar captura': 'start capture', 'parar captura': 'stop capture', - // resultados + // results 'Redes': 'Networks', 'Redes vistas': 'Networks seen', 'Linha do tempo': 'Timeline', @@ -72,7 +72,7 @@ export const EN = { 'Nenhuma rede corresponde ao filtro': 'No network matches the filter', 'Ajuste a busca ou limpe o campo de filtro.': 'Adjust the search or clear the filter.', - // erros de UI + // UI errors 'Não foi possível trocar o modo:': 'Could not change the mode:', 'Não foi possível iniciar a captura:': 'Could not start the capture:', 'Não foi possível retomar a captura: {n}': 'Could not resume the capture: {n}', @@ -82,7 +82,7 @@ export const EN = { 'erro desconhecido': 'unknown error', 'rota desconhecida': 'unknown route', - // seletor de idioma / configurações + // language selector / settings 'Configurações': 'Settings', 'Tema': 'Theme', 'Claro': 'Light', @@ -133,7 +133,7 @@ export const EN = { 'quantidade de quadros deve ficar entre 1 e 256': 'frame count must be between 1 and 256', 'MAC inválido: {n}': 'invalid MAC: {n}', - // detalhes do alvo e ataques + // target details and attacks 'não': 'no', 'não anunciado': 'not advertised', 'quebrável': 'crackable', @@ -340,7 +340,7 @@ export const EN = { '{n} · canal {n}': '{n} · channel {n}', '{n} ({n})': '{n} ({n})', - // canais, PMKID/PMF, WPS e relatório + // channels, PMKID/PMF, WPS, and report 'Ocupação dos canais em {n}': 'Channel occupancy on {n}', 'Sem dados de canal ainda': 'No channel data yet', 'Inicie a captura para o Sabiá medir ocupação e sugerir canais.': @@ -358,7 +358,7 @@ export const EN = { 'Melhor canal': 'Best channel', 'Espectro e canais': 'Spectrum and channels', - // saúde do espectro (canais, estilo SMART) + // spectrum health (channels, SMART style) 'Boa': 'Good', 'Atenção': 'Attention', 'Saturado': 'Saturated', @@ -478,7 +478,7 @@ export const EN = { 'não foi possível executar {n}: {n}': 'could not run {n}: {n}', '{n} terminou com código {n}': '{n} exited with code {n}', - // achados do WIDS (analysis.rs) + // WIDS findings (analysis.rs) 'SSID “{n}” com seguranças diferentes': 'SSID “{n}” with different security levels', 'O mesmo nome aparece em BSSIDs com níveis de segurança distintos: {n}. Pode ser um evil twin ou um AP mal configurado.': 'The same name appears on BSSIDs with different security levels: {n}. It may be an evil twin or a misconfigured AP.', @@ -525,7 +525,7 @@ export const EN = { 'The station did not reassociate in the observed window. PMF may be active, or the client simply stayed idle.', 'Inconclusivo': 'Inconclusive', - // deauth contínuo e identificação de clientes + // continuous deauth and client identification 'Mantém os quadros saindo em série até você parar (ou até sair o handshake). O canal da captura fica travado no AP durante o job.': 'Keeps frames going until you stop (or a handshake appears). The capture channel stays locked to the AP during the job.', 'Iniciar contínuo': 'Start continuous', @@ -547,6 +547,8 @@ export const EN = { 'The bundled reaver could not be extracted in this run.', 'Dicionário (vazio = embutido, 9,7 milhões de senhas)': 'Wordlist (empty = bundled, 9.7 million passwords)', 'dicionário próprio, opcional': 'custom wordlist, optional', + 'Aplicar regras de mutação (maiúscula, sufixos, leet — multiplica o tempo)': + 'Apply mutation rules (uppercase, suffixes, leetspeak — multiplies run time)', 'Dispositivo': 'Device', 'MAC aleatório': 'Random MAC', 'Cliente {n}': 'Client {n}', diff --git a/crates/sabia-core/src/engine.rs b/crates/sabia-core/src/engine.rs index 6ab3e14..700eb84 100644 --- a/crates/sabia-core/src/engine.rs +++ b/crates/sabia-core/src/engine.rs @@ -1,9 +1,9 @@ -//! 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. +//! Unified service engine shared by both the CLI web server (`sabia ui`) and +//! the Tauri desktop app: manages live capture sessions, channel hopping, +//! dictionary/mask cracking jobs, deauth/PMKID/PMF/WPS tasks, and Evil Twin. +//! +//! //! -//! 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}; @@ -29,15 +29,15 @@ use std::time::{SystemTime, UNIX_EPOCH}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ScanOptions { - /// Canal fixo; sem ele, percorre os canais suportados. + /// Fixed channel; when `None`, hops across supported channels in `band`. pub channel: Option, - /// `2.4`, `5`, `6` ou `all` (usado quando `channel` é `None`). + /// Band filter (`"2.4"`, `"5"`, `"6"`, or `"all"`) used when `channel` is `None`. pub band: Option, - /// Tempo em cada canal ao pular. + /// Channel hop dwell time in milliseconds. pub hop_ms: u64, - /// Salva os quadros capturados neste `.pcap`. + /// Optional `.pcap` output path to mirror all captured frames. pub pcap_out: Option, - /// Encerra sozinho depois de N segundos. + /// Automatically stop the capture session after this many seconds. pub duration_secs: Option, } @@ -108,20 +108,20 @@ pub struct WpsJobInfo { #[derive(Debug, Clone, Serialize)] pub struct EvilTwinJobInfo { pub id: String, - /// Interface que captura/desautentica o AP real. + /// Monitor-mode interface used to capture and deauthenticate the target AP. pub iface: String, - /// Interface que hospeda o clone (precisa ser outra, em modo AP). + /// Secondary wireless interface hosting the rogue AP (`hostapd`). 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. + /// Number of passphrase submissions received by the captive portal. pub attempts: u32, - /// Última tentativa: "correct", "wrong" ou "invalid". + /// Outcome of the most recent submission: `"correct"`, `"wrong"`, or `"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. + /// Populated only when a submitted passphrase cryptographically verifies + /// against the captured handshake/PMKID. pub password: Option, pub deauth_job: Option, pub error: Option, @@ -135,11 +135,11 @@ pub struct EvilTwinJobInfo { 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. +/// WPA2/WPA3-Enterprise (802.1X) Evil Twin job status (see +/// `eviltwin_enterprise.rs`). Captures EAP (PEAP/TTLS -> MSCHAPv2) challenge/response +/// hashes via `hostapd-wpe` for offline cracking. +/// +/// #[derive(Debug, Clone, Serialize)] pub struct EnterpriseTwinJobInfo { pub id: String, @@ -224,8 +224,11 @@ pub struct DeauthJobInfo { struct DeauthJob { info: Arc>, + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] stop: Arc, + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] handle: Option>, + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] locked_channel: Option, } @@ -246,12 +249,16 @@ pub struct Engine { jobs: Mutex>>>, job_seq: AtomicU64, deauth_jobs: Mutex>, + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] deauth_seq: AtomicU64, wps_jobs: Mutex>>>, + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] wps_seq: AtomicU64, eviltwin_jobs: Mutex>>>, + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] eviltwin_seq: AtomicU64, enterprise_twin_jobs: Mutex>>>, + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] enterprise_twin_seq: AtomicU64, audit_path: PathBuf, audit_lock: Arc>, @@ -296,13 +303,13 @@ impl Engine { &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`. + /// Invoked by the host process SIGINT/SIGTERM handler before exit. + /// Synchronously stops any active Evil Twin (`hostapd`/`dnsmasq`), removes + /// `iptables` redirect rules, and returns wireless interfaces to NetworkManager. + /// + /// + /// + /// #[cfg(target_os = "linux")] pub fn shutdown_cleanup(&self) { use std::process::Command; @@ -375,13 +382,13 @@ impl Engine { 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). + // Detach the interface from NetworkManager (`nmcli device set managed no`) + // before enabling monitor mode so NM does not immediately reset it back to + // managed mode, and reattach it when returning to managed mode. + // + // + // + // if enable { let _ = std::process::Command::new("nmcli") .args(["device", "set", name, "managed", "no"]) @@ -419,9 +426,9 @@ impl Engine { 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. + /// Locks the active capture session onto a fixed channel (or resumes hopping with `None`). + /// Used by deauth, PMKID, and WPS jobs so frames are transmitted and received on the + /// target AP's channel. pub fn lock_channel(&self, name: &str, channel: Option) -> Result<()> { if let Some(channel) = channel { netlink::set_channel(name, channel)?; @@ -469,8 +476,8 @@ impl Engine { }; 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. + // Replace any previous capture session only after the new capture socket + // opens cleanly. self.stop_scan(name); let table = Arc::new(Mutex::new(ScanTable::new())); @@ -648,14 +655,14 @@ impl Engine { 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. + // Detect silent monitor-mode failure: some laptop Wi-Fi adapters accept + // switching to monitor mode via netlink without error yet never deliver + // any 802.11 frames to the raw socket. After 12s with zero frames, + // surface a warning to the user. + // + // + // + // let warning = if running && stats.frames == 0 && unix_now().saturating_sub(session.started_unix) >= 12 @@ -684,7 +691,7 @@ impl Engine { Ok(snapshot) } - /// Compatibilidade com a API antiga: só os APs da sessão. + /// Returns the list of access points from the current capture session. pub fn scan_results(&self, name: &str) -> Vec { self.sessions .lock() @@ -703,8 +710,8 @@ impl Engine { .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). + /// Parses an offline `.pcap` file and returns a `ScanSnapshot` without + /// touching any wireless hardware. pub fn analyze_pcap(&self, path: impl AsRef) -> Result { let path = path.as_ref(); let mut reader = PcapReader::open(path)?; @@ -732,9 +739,9 @@ impl Engine { }) } - /// 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. + /// Builds the full audit report model (scan snapshot + WIDS analysis + audit trail). + /// Rendered in the UI via Paged.js or exported as standalone HTML. + /// pub fn report(&self, name: &str) -> Result { let snapshot = self.scan_snapshot(name)?; let audit = self.audit_entries(Some(60)).unwrap_or_default(); @@ -918,9 +925,9 @@ impl Engine { ) } - /// 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”. + /// Starts a background dictionary crack job using the built-in 9.7M WPA wordlist. + /// + /// pub fn start_crack_default( &self, name: &str, @@ -1048,11 +1055,11 @@ impl Engine { 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. + /// Starts a background mask brute-force job (e.g. `?d?d?d?d?d?d?d?d`), + /// rejecting keyspaces larger than `max_candidates`. + /// + /// + /// pub fn start_crack_mask( &self, name: &str, @@ -1196,8 +1203,8 @@ impl Engine { 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`. + /// `direction`: `"ap"` (AP->STA), `"sta"` (STA->AP, requires `client`), or `"both"`. + /// `frame_kind`: `"deauth"` or `"disassoc"`. #[cfg(target_os = "linux")] #[allow(clippy::too_many_arguments)] pub fn deauth_directional( @@ -1294,11 +1301,11 @@ impl Engine { 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. + /// Starts a background deauthentication job (until `stop_deauth_job`), locking + /// the capture channel to the target AP and optionally stopping automatically + /// once a crackable handshake is captured (`stop_on_handshake`). + /// + /// #[cfg(target_os = "linux")] #[allow(clippy::too_many_arguments)] pub fn start_deauth_job( @@ -1484,8 +1491,8 @@ impl Engine { 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. + // Check whether a handshake already arrived before sending another burst. + // if stop_on_handshake && handshake_ready() { handshake = true; break; @@ -1671,10 +1678,10 @@ impl Engine { 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. + /// Shortcut to force a WPA handshake: picks an associated client (if none + /// specified), locks onto the AP channel, and sends spaced deauth bursts + /// so the client has time to reassociate and complete the 4-way handshake. + /// #[cfg(target_os = "linux")] pub fn capture_handshake( &self, @@ -1757,10 +1764,10 @@ impl Engine { 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. + /// Attempts clientless PMKID capture by sending Open Authentication followed + /// by an Association Request with an RSN IE from a locally administered MAC. + /// + /// #[cfg(target_os = "linux")] pub fn pmkid_hunt(&self, name: &str, bssid: &str, attempts: u32) -> Result { use crate::inject::{self, RawInjector}; @@ -1860,6 +1867,12 @@ impl Engine { Ok(report) } + #[cfg(not(target_os = "linux"))] + pub fn pmkid_hunt(&self, _name: &str, _bssid: &str, _attempts: u32) -> Result { + Err(SabiaError::UnsupportedPlatform) + } + + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] fn session_ap(&self, name: &str, bssid: &str) -> Result> { let sessions = self.sessions.lock().unwrap(); let session = sessions.get(name).ok_or_else(|| { @@ -1872,9 +1885,9 @@ impl Engine { .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. + /// Practical 802.11w (PMF) test: injects unprotected deauth frames in both + /// directions and observes whether the station reassociates. + /// #[cfg(target_os = "linux")] pub fn pmf_test( &self, @@ -1946,6 +1959,18 @@ impl Engine { Ok(report) } + #[cfg(not(target_os = "linux"))] + pub fn pmf_test( + &self, + _name: &str, + _bssid: &str, + _station: &str, + _reason: u16, + ) -> Result { + Err(SabiaError::UnsupportedPlatform) + } + + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] fn client_snapshot(&self, name: &str, station: &str) -> Result> { let sessions = self.sessions.lock().unwrap(); let session = sessions.get(name).ok_or_else(|| { @@ -1959,8 +1984,8 @@ impl Engine { 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. + /// Spawns `reaver` or `bully` as a child process (defaulting to Pixie Dust when + /// `pin` is omitted) and streams output lines while parsing any recovered PIN/PSK. #[cfg(target_os = "linux")] pub fn start_wps( &self, @@ -2181,7 +2206,7 @@ impl Engine { .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. + // SAFETY: sends a signal to the child process spawned by this instance. unsafe { libc::kill(pid as i32, libc::SIGTERM); } @@ -2202,16 +2227,16 @@ impl Engine { 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. + /// Evil Twin + captive portal: clones a target AP on a secondary `ap_iface` + /// (open SSID) with `hostapd` + `dnsmasq` and serves a captive portal that + /// verifies submitted passphrases against a captured WPA handshake/PMKID. + /// + /// + /// + /// + /// + /// Requires `hostapd`, `dnsmasq`, `iptables`, and a second AP-capable wireless interface. /// - /// 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( @@ -2304,13 +2329,13 @@ impl Engine { ) .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. + // Persist recovery state to disk before modifying network state so + // `self_heal_eviltwin_leftovers` can clean up orphaned `hostapd`/`dnsmasq` + // processes, `iptables` rules, and `nmcli managed no` flags if killed. + // + // + // + // let state_path = dir.join(format!("state-{ap_iface}.json")); let _ = fs::write( &state_path, @@ -2324,17 +2349,17 @@ impl Engine { .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). + // Detach both `ap_iface` and `capture_iface` from NetworkManager before + // starting `hostapd` so NM neither overrides the rogue AP interface nor + // attempts to auto-connect a saved profile on the capture interface. + // + // + // + // + // + // + // // - // 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(); @@ -2342,8 +2367,8 @@ impl Engine { .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. + // Assign the gateway IP to `ap_iface` before starting `hostapd`/`dnsmasq`. + // let _ = Command::new("ip") .args(["addr", "flush", "dev", ap_iface]) .status(); @@ -2354,13 +2379,13 @@ impl Engine { .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. + // Redirect `hostapd` output to a log file and verify it stays alive after + // startup so immediate driver/channel failures return a clear error. + // + // + // + // + // 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)?; @@ -2406,11 +2431,11 @@ impl Engine { 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. + // Cap continuous deauth at 30 minutes (or until `stop_evil_twin` / `stop_deauth_job`). + // + // + // + // let deauth_job = if deauth_real_ap { self.start_deauth_job_burst( name, @@ -2502,8 +2527,8 @@ impl Engine { }, ); - // Limpeza: derruba hostapd/dnsmasq, desfaz o iptables, para o - // job de deauth do AP real — sempre, sucesso ou não. + // Cleanup: stop `hostapd`/`dnsmasq`, remove `iptables` rules, and restore + // both interfaces to NetworkManager. unsafe { libc::kill(hostapd_pid as i32, libc::SIGTERM); } @@ -2515,20 +2540,20 @@ impl Engine { 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. + // Return both `ap_iface` and `capture_iface` to NetworkManager. + // + // + // + // 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). + // Cleanup succeeded: remove the crash-recovery state file. + // + // let _ = std::fs::remove_file(&state_path_for_thread); let mut guard = thread_job.lock().unwrap(); @@ -2613,29 +2638,29 @@ impl Engine { 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". + // Wake the non-blocking `accept()` loop with a local connection so it exits immediately. + // 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. + // Also stop the companion deauth job if one was started alongside the Evil Twin. + // 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). + /// Starts a WPA2/WPA3-Enterprise (802.1X) Evil Twin using `hostapd-wpe` + /// on `ap_iface` to capture EAP (PEAP/TTLS -> MSCHAPv2) challenge/response + /// credentials (see `eviltwin_enterprise.rs`). + /// + /// + /// + /// + /// Requires the target AP to advertise 802.1X Enterprise security and + /// `hostapd-wpe` + `openssl` to be installed. + /// /// - /// 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, @@ -2708,10 +2733,10 @@ impl Engine { 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. + // Use the generated self-signed certificate as both `ca_cert` and + // `server_cert` to establish the outer PEAP/TTLS TLS tunnel. + // + // let cert_args = crate::eviltwin_enterprise::openssl_selfsigned_cert_args( &key_path.to_string_lossy(), &cert_path.to_string_lossy(), @@ -2743,11 +2768,11 @@ impl Engine { ) .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). + // Persist crash-recovery state so `self_heal_eviltwin_leftovers` can + // restore interfaces if the process exits unexpectedly. + // + // + // let state_path = dir.join(format!("state-{ap_iface}.json")); let _ = fs::write( &state_path, @@ -2766,11 +2791,11 @@ impl Engine { .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. + // Log `hostapd-wpe` output and check for immediate startup exit. + // + // + // + // 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)?; @@ -2841,9 +2866,9 @@ impl Engine { 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. + /// Re-parses the `hostapd-wpe` log file and returns the current job status. + /// + /// pub fn enterprise_twin_job(&self, id: &str) -> Option { let job = self.enterprise_twin_jobs.lock().unwrap().get(id).cloned()?; { @@ -2878,11 +2903,11 @@ impl Engine { 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. + /// Stops `hostapd-wpe`, restores both wireless interfaces to NetworkManager, + /// and preserves the captured credentials log file on disk for offline cracking. + /// + /// + /// #[cfg(target_os = "linux")] pub fn stop_evil_twin_enterprise(&self, id: &str) -> Result<()> { let job = self @@ -3005,14 +3030,14 @@ fn append_audit( 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. +/// Cleans up leftover Evil Twin state from a previously terminated process: +/// kills orphaned `hostapd`/`dnsmasq` processes, removes `iptables` redirect +/// rules, and returns both wireless interfaces to NetworkManager. +/// Runs best-effort whenever an `Engine` instance is initialized. +/// +/// +/// +/// #[cfg(target_os = "linux")] fn self_heal_eviltwin_leftovers() { use std::process::Command; @@ -3024,8 +3049,8 @@ fn self_heal_eviltwin_leftovers() { 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). + // Scan both the real user UID and the current effective UID (`0` under sudo). + // self_heal_eviltwin_leftovers_for_uid(unsafe { libc::geteuid() }); fn self_heal_eviltwin_leftovers_for_uid(uid: u32) { @@ -3263,8 +3288,8 @@ mod tests { 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. + // Only a beacon was ingested (no handshake yet), so the captive portal + // cannot verify passphrases yet. let mut table = ScanTable::new(); let beacon = radiotap_wrap(&beacon_frame(ap(), "RedeWPA", 6, &[2]), 2437, -40); table.ingest(&beacon); @@ -3290,8 +3315,8 @@ mod tests { #[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. + // Exercise the missing-binary error path when `hostapd` is absent. + // if crate::wps::find_in_path("hostapd").is_some() { eprintln!("hostapd instalado nesta máquina — pulando (o teste é pro caso sem ele)"); return; @@ -3323,7 +3348,7 @@ mod tests { #[test] fn evil_twin_enterprise_rejects_a_psk_network() { - // seed_session() cria um beacon com AKM 2 (PSK) — não é Enterprise. + // `seed_session()` seeds a WPA2-PSK beacon (AKM 2), which is rejected by Enterprise Evil Twin. let dir = TempDir::new().unwrap(); let engine = Engine::with_audit_path(dir.path().join("audit.log")); seed_session(&engine, "wlan-test"); @@ -3346,9 +3371,9 @@ mod tests { #[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. + // AKM 1 = 802.1X (Enterprise) passes security validation and reaches external binary checks. + // + // 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; diff --git a/crates/sabia-core/src/eviltwin.rs b/crates/sabia-core/src/eviltwin.rs index d33cf70..2981bb0 100644 --- a/crates/sabia-core/src/eviltwin.rs +++ b/crates/sabia-core/src/eviltwin.rs @@ -1,27 +1,27 @@ -//! Evil Twin + portal cativo: clona um AP já visto na captura (SSID aberto, -//! sem senha) pra atrair os clientes reais — em paralelo com desautenticação -//! contínua do AP verdadeiro, que já existe no engine — e pede a senha de -//! verdade por uma página de "reconectar", como qualquer portal cativo de -//! hotel/aeroporto faz. +//! Evil Twin + captive portal: clones a target AP observed during capture (as an +//! open network with the same SSID) alongside continuous deauthentication of the +//! real AP, presenting a captive portal that prompts for the network passphrase. //! -//! A diferença pro phishing comum: a senha digitada **nunca é aceita de -//! graça**. É conferida na hora contra o handshake real já capturado -//! ([`crate::crack::CrackTarget::verify`], o mesmo MIC criptográfico usado -//! na quebra por dicionário) — só confirma "conectado" quando a senha bate -//! de verdade com a rede. Isso também evita que a página vire um jeito de -//! coletar qualquer string digitada: ela só diz "certo" pra senha certa. //! -//! Só funciona contra um AP que já apareceu na sessão de captura atual (não -//! dá pra apontar pra uma rede nunca vista) e exige a mesma autorização -//! explícita das outras abas de ataque. +//! +//! Submitted passphrases are verified immediately against the captured WPA +//! handshake or PMKID ([`crate::crack::CrackTarget::verify`]): the portal only +//! reports success when the passphrase cryptographically matches the target network. +//! +//! +//! +//! +//! Requires an active target from the current capture session and explicit +//! operator authorization. +//! use crate::crack::CrackTarget; use std::fmt::Write as _; -/// Config do hostapd pro AP clone: aberto (sem criptografia) de propósito — -/// é o portal cativo que pede a senha, não o rádio. Praticamente todo -/// cliente com "lembrar essa rede" conecta sozinho numa rede aberta com o -/// SSID certo, sem perguntar nada ao usuário. +/// Generates the `hostapd` configuration for the open clone AP. +/// +/// +/// pub fn hostapd_conf(iface: &str, ssid: &str, channel: u32, bssid: Option<&str>) -> String { let mut out = String::new(); let _ = writeln!(out, "interface={iface}"); @@ -38,10 +38,10 @@ pub fn hostapd_conf(iface: &str, ssid: &str, channel: u32, bssid: Option<&str>) out } -/// Config do dnsmasq: DHCP na sub-rede do AP clone + DNS que resolve -/// **tudo** pro próprio gateway, inclusive os domínios que Android/iOS/ -/// Windows usam pra detectar portal cativo (é o que faz o popup de login -/// abrir sozinho no dispositivo da vítima). +/// Generates the `dnsmasq` configuration: serves DHCP on the clone subnet and +/// resolves all DNS queries to the gateway IP so OS captive-portal detection +/// automatically opens the login prompt. +/// pub fn dnsmasq_conf(iface: &str, gateway_ip: &str, dhcp_range: (&str, &str)) -> String { let mut out = String::new(); let _ = writeln!(out, "interface={iface}"); @@ -49,15 +49,15 @@ pub fn dnsmasq_conf(iface: &str, gateway_ip: &str, dhcp_range: (&str, &str)) -> let _ = writeln!(out, "dhcp-range={},{},12h", dhcp_range.0, dhcp_range.1); let _ = writeln!(out, "dhcp-option=3,{gateway_ip}"); // gateway let _ = writeln!(out, "dhcp-option=6,{gateway_ip}"); // DNS - let _ = writeln!(out, "address=/#/{gateway_ip}"); // resolve tudo pra nós + let _ = writeln!(out, "address=/#/{gateway_ip}"); // wildcard DNS pointing to our gateway let _ = writeln!(out, "no-resolv"); out } -/// Regras de `iptables` (lista de argumentos, uma regra por linha — cada -/// `Vec` é um `iptables `) pra mandar todo HTTP da sub-rede do -/// AP clone pro servidor do portal cativo, nessa máquina. Sem NAT pra -/// internet de propósito — o clone nunca dá acesso de verdade, só o portal. +/// Builds `iptables` rules (one argument list per command) redirecting HTTP +/// traffic from the clone AP subnet to the local captive portal listener. +/// +/// pub fn iptables_rules(iface: &str, portal_port: u16) -> Vec> { let redirect = |proto: &str, dport: u16| { vec![ @@ -80,7 +80,7 @@ pub fn iptables_rules(iface: &str, portal_port: u16) -> Vec> { vec![redirect("tcp", 80)] } -/// As mesmas regras, pra desfazer (`-D` em vez de `-A`) ao parar o job. +/// Matching `iptables -D` rules to tear down redirection when the job stops. pub fn iptables_cleanup_rules(iface: &str, portal_port: u16) -> Vec> { iptables_rules(iface, portal_port) .into_iter() @@ -94,27 +94,27 @@ pub fn iptables_cleanup_rules(iface: &str, portal_port: u16) -> Vec> } fn sanitize_conf_value(value: &str) -> String { - // hostapd.conf não escapa valores — corta quebra de linha, que seria - // injeção de outra diretiva de config. + // Strip newlines and control characters to prevent `hostapd.conf` directive injection. + // value.replace(['\n', '\r'], "") } -/// Resultado de uma tentativa de login no portal. +/// Outcome of a captive portal passphrase submission. #[derive(Debug, Clone, PartialEq, Eq)] pub enum PortalAttempt { - /// Senha bateu com o handshake real — confirmado criptograficamente, - /// não é só "aceitar o que a pessoa digitou". + /// Passphrase matched the captured handshake/PMKID. + /// Correct, WrongPassword, - /// Comprimento fora de 8–63: nem vale a pena gastar um PBKDF2 nisso. + /// Outside valid WPA PSK length (8–63 characters). InvalidLength, } -/// Confere a senha submetida no portal contra o handshake real do alvo — -/// mesma verificação criptográfica usada na quebra por dicionário -/// ([`crate::crack::CrackTarget::verify`]). O portal só mostra "conectado" -/// quando isso bate; do contrário, pede de novo, exatamente como um portal -/// cativo legítimo faria com uma senha errada. +/// Verifies a submitted passphrase against the target's captured `CrackTarget`. +/// +/// +/// +/// pub fn check_submission(target: &CrackTarget, password: &str) -> PortalAttempt { if !(8..=63).contains(&password.len()) { return PortalAttempt::InvalidLength; @@ -126,8 +126,8 @@ pub fn check_submission(target: &CrackTarget, password: &str) -> PortalAttempt { } } -/// Página de login do portal — pede a senha da rede clonada. Texto genérico -/// de "reconectar à rede", sem imitar nenhuma marca específica. +/// Renders the captive portal HTML page for the cloned SSID. +/// pub fn portal_login_html(ssid: &str, wrong_password: bool) -> String { let notice = if wrong_password { r#"

Senha incorreta. Tente de novo.

"# @@ -176,12 +176,12 @@ display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0 ) } -/// Servidor HTTP minúsculo do portal — sem dependência nova, não precisa -/// ser um servidor de verdade, só responder GET (qualquer caminho vira a -/// página de login, o que também satisfaz a detecção de portal cativo do -/// Android/iOS/Windows) e POST /connect (lê `password=...` do corpo e -/// confere contra o handshake real). Roda até achar a senha certa, o `stop` -/// virar true, ou o accept falhar. +/// Minimal captive portal HTTP server: serves the login page on any `GET` +/// (triggering OS captive-portal popups) and verifies `password=...` on +/// `POST /connect` against `target`. Runs until a valid passphrase is +/// submitted or `stop` is set. +/// +/// #[cfg(target_os = "linux")] pub fn run_portal( listener: std::net::TcpListener, @@ -226,8 +226,8 @@ fn handle_connection( .ok(); let mut buf = vec![0u8; 8192]; let mut total = 0usize; - // Lê até achar o fim dos cabeçalhos — suficiente pros requests curtos - // que um navegador manda pra um form simples. + // Read until the end of HTTP headers and declared `Content-Length`. + // loop { let n = stream.read(&mut buf[total..]).ok()?; if n == 0 { @@ -286,9 +286,10 @@ fn handle_connection( found_password } -/// Lê `chave=valor` de um corpo `application/x-www-form-urlencoded` simples -/// (sem libs extra — é um único campo de senha, não vale a pena puxar uma -/// crate de parsing de formulário por causa disso). +/// Extracts a URL-decoded `key=value` field from an `application/x-www-form-urlencoded` body. +/// +/// +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] fn form_value(body: &str, key: &str) -> Option { body.split('&').find_map(|pair| { let (k, v) = pair.split_once('=')?; @@ -300,6 +301,7 @@ fn form_value(body: &str, key: &str) -> Option { }) } +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] fn url_decode(s: &str) -> String { let bytes = s.as_bytes(); let mut out = Vec::with_capacity(bytes.len()); @@ -433,7 +435,7 @@ mod tests { }) }); - // Dá um tempinho pro accept() estar escutando antes do primeiro connect. + // Brief pause so the listener thread enters `accept()` before the client connects. std::thread::sleep(std::time::Duration::from_millis(50)); let post = |password: &str| -> String {