chore(i18n): finish translating code comments and scripts to English
CI / Format and clippy (push) Successful in 1m13s
CI / Dependency audit (push) Successful in 13s
CI / Tests (Linux) (push) Successful in 1m35s
Release / Create release (push) Successful in 7s
CI / Check desktop app (Linux) (push) Successful in 3m19s
Release / CLI (linux-aarch64) (push) Successful in 1m3s
Release / CLI (linux-x86_64) (push) Successful in 1m28s
Release / Desktop app (linux-aarch64) (push) Successful in 7m16s
Release / Desktop app (linux-x86_64) (push) Successful in 10m17s
Release / Desktop app (windows) (push) Successful in 4m54s

Completes the English-only standardization for code, comments, scripts
and CI templates (UI/CLI-facing strings stay bilingual pt-BR/en,
untouched). Covers the remaining crates/, apps/gui/ui/ and
apps/gui/src-tauri/ comments, .gitea/ workflow and template comments,
.githooks/ hooks, and the scripts/ source comments for the files
already renamed in a prior commit (check.sh, generate-logo.py,
install-hooks.sh).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
This commit is contained in:
Lucas RayanandClaude Sonnet 5 committed 2026-10-30 20:44:39 -03:00
1 parent bf059fd0bc
commit 55e19a8e5b
59 files changed
+935 -879

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "sabia-cli"
description = "sabia: CLI de auditoria e monitoramento de redes Wi-Fi"
description = "Sabiá: Wi-Fi network auditing and monitoring CLI"
version.workspace = true
edition.workspace = true
license.workspace = true
+27 -27
View File
@@ -16,7 +16,7 @@ use std::time::Duration;
version
)]
struct Cli {
/// Trilha de auditoria (padrão: .sabia-audit.log).
/// Audit log file path (default: `.sabia-audit.log`).
#[arg(long, global = true, default_value = ".sabia-audit.log")]
audit: PathBuf,
@@ -26,95 +26,95 @@ struct Cli {
#[derive(Subcommand)]
enum Cmd {
/// Lista interfaces, canais e controla o modo monitor.
/// List interfaces, channels, and toggle monitor mode.
Iface {
#[command(subcommand)]
action: IfaceAction,
},
/// Captura o ar e mostra redes, clientes e handshakes.
/// Capture live traffic and display networks, clients, and handshakes.
Scan {
iface: String,
/// Canal fixo; sem isso, percorre os canais suportados.
/// Fixed channel; when omitted, hops across supported channels.
#[arg(long)]
channel: Option<u32>,
/// Banda ao percorrer canais: 2.4, 5, 6 ou all.
/// Frequency band when hopping channels: `2.4`, `5`, `6`, or `all`.
#[arg(long)]
band: Option<String>,
/// Tempo em cada canal (ms).
/// Channel dwell time in milliseconds.
#[arg(long, default_value_t = 350)]
hop_ms: u64,
/// Para a varredura depois de N segundos (padrão: até Ctrl+C).
/// Stop scanning after N seconds (default: run until Ctrl+C).
#[arg(long)]
seconds: Option<u64>,
/// Salva os quadros capturados em um .pcap.
/// Save captured frames to a `.pcap` file.
#[arg(long)]
out: Option<PathBuf>,
/// Depois de capturar, tenta quebrar o primeiro handshake.
/// After capturing, attempt a wordlist crack on the first handshake.
#[arg(long, value_name = "DICIONARIO")]
crack: Option<PathBuf>,
/// Mostra apenas o JSON final.
/// Output only the final JSON summary.
#[arg(long)]
json: bool,
},
/// Analisa um .pcap offline (redes, clientes e handshakes).
/// Analyze a `.pcap` file offline (networks, clients, and handshakes).
Analyze {
pcap: PathBuf,
#[arg(long)]
json: bool,
},
/// Quebra um handshake de um .pcap por dicionário.
/// Run a dictionary attack against a handshake in a `.pcap` file.
Crack {
pcap: PathBuf,
/// Dicionário com uma senha por linha.
/// Wordlist file (one candidate passphrase per line).
#[arg(short, long)]
wordlist: PathBuf,
#[arg(long)]
bssid: Option<String>,
#[arg(long)]
station: Option<String>,
/// Aplica mutações comuns a cada palavra (maiúscula, sufixos
/// numéricos, leet simples) — multiplica o tempo de verdade.
/// Apply common mutation rules to each word (capitalization, numeric
/// suffixes, simple leetspeak) — multiplies total runtime.
#[arg(long)]
rules: bool,
#[arg(long)]
json: bool,
},
/// Envia desautenticação para um AP/estação (teste autorizado).
/// Send deauthentication frames to an AP/station (authorized testing only).
Deauth {
iface: String,
#[arg(long)]
bssid: String,
/// Sem isso, o destino é broadcast.
/// Target client MAC; defaults to broadcast when omitted.
#[arg(long)]
station: Option<String>,
#[arg(long, default_value_t = 5)]
count: u32,
#[arg(long, default_value_t = 7)]
reason: u16,
/// Confirma que você tem autorização para testar esta rede.
/// Confirm explicit authorization to test this target network.
#[arg(long)]
yes: bool,
},
/// Diagnóstico de privilégios, ferramentas e interfaces.
/// Diagnose privileges, external tools, and wireless interfaces.
Doctor {
#[arg(long)]
json: bool,
},
/// Inspeciona a trilha de auditoria.
/// Inspect and verify the hash-chained audit trail.
Audit {
#[command(subcommand)]
action: AuditAction,
},
/// Verifica e aplica atualizações do próprio sabia.
/// Check for and apply `sabia` CLI updates.
Update {
#[arg(long)]
apply: bool,
},
/// Abre a interface gráfica (servidor local + navegador).
/// Launch the web UI (local HTTP server + browser).
Ui {
#[arg(long, default_value = "4337")]
port: u16,
/// Não tenta abrir o navegador automaticamente.
/// Do not open the browser automatically.
#[arg(long)]
no_open: bool,
},
@@ -123,13 +123,13 @@ enum Cmd {
#[derive(Subcommand)]
enum IfaceAction {
List,
/// Mostra os canais suportados pelo rádio.
/// List channels supported by the wireless phy.
Channels {
iface: String,
#[arg(long)]
json: bool,
},
/// Liga/desliga modo monitor numa interface Wi-Fi.
/// Enable or disable monitor mode on a Wi-Fi interface.
Monitor {
iface: String,
#[arg(value_enum)]
@@ -139,9 +139,9 @@ enum IfaceAction {
#[derive(Subcommand)]
enum AuditAction {
/// Confere a integridade da cadeia.
/// Verify the cryptographic hash chain integrity.
Verify { path: Option<PathBuf> },
/// Mostra as últimas entradas.
/// Show the last N entries.
Show {
#[arg(long, default_value_t = 20)]
limit: usize,
+9 -9
View File
@@ -1,7 +1,7 @@
//! Servidor local da interface: uma única página HTML/JS servida em
//! `127.0.0.1`, protegida por um token gerado na hora, falando com o
//! núcleo por uma API JSON simples. A mesma página em `apps/gui/ui` é o
//! que o app desktop (Tauri) carrega — só muda o transporte.
//! Local web UI server: serves the single-page HTML/JS app on
//! `127.0.0.1`, protected by a per-session random token, communicating with
//! the core over JSON endpoints. The exact same frontend in `apps/gui/ui`
//! powers the Tauri desktop app.
use sabia_core::engine::{Engine, ScanOptions};
use serde::Deserialize;
@@ -25,11 +25,11 @@ pub fn serve(port: u16, audit_path: std::path::PathBuf, open: bool) -> sabia_cor
let listener = TcpListener::bind(("127.0.0.1", port))?;
let engine = Arc::new(Engine::with_audit_path(audit_path));
// Sem isso, Ctrl+C (ou um `kill` normal) durante um Evil Twin ativo
// mata o processo sem rodar a limpeza: hostapd/dnsmasq ficam órfãos
// e as duas interfaces ficam presas em `managed no` pro
// NetworkManager — o painel de Wi-Fi do sistema some por completo até
// o próximo início do engine se auto-curar.
// Without a signal handler, Ctrl+C (or `SIGTERM`) during an active Evil Twin
// exits before cleanup runs: `hostapd`/`dnsmasq` are left orphaned
// and both interfaces remain stuck in `managed no` under
// NetworkManager until the next engine startup runs self-healing.
//
{
let engine = engine.clone();
let _ = ctrlc::set_handler(move || {
+3 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "sabia-core"
description = "Sabiá: núcleo de captura, análise e auditoria de redes Wi-Fi"
description = "Sabiá: Wi-Fi network capture, analysis, and auditing core"
version.workspace = true
edition.workspace = true
license.workspace = true
@@ -25,5 +25,5 @@ flate2 = { version = "1", default-features = false, features = ["rust_backend"]
tempfile = "3"
[target.'cfg(not(target_os = "linux"))'.dependencies]
# captura crua (AF_PACKET) só existe no Linux; outras plataformas compilam
# a crate mas os módulos de captura ficam indisponíveis em tempo de execução.
# Raw capture (AF_PACKET) is Linux-only; other platforms compile the crate
# while live capture modules remain unavailable at runtime.
+6 -6
View File
@@ -1,8 +1,8 @@
//! Análise de canais, detecção de anomalias (WIDS) e leitura de WPA3.
//! Channel utilization analysis, wireless anomaly detection (WIDS), and WPA3 indicators.
//!
//! Everything here is derived passively from the captured scan table without injecting frames.
//! Both the live UI and the standalone audit report consume this model.
//!
//! Tudo aqui é derivado da tabela já capturada — nada injeta quadros. A
//! interface usa o resultado para desenhar o gráfico de canais e os alertas;
//! o relatório usa o mesmo modelo para não ter duas verdades.
use crate::radiotap::{self, Band};
use crate::scan::AccessPoint;
@@ -280,8 +280,8 @@ pub fn analyze(aps: &[AccessPoint], deauth_60s: u32, now: u64) -> Analysis {
})
.collect();
// SSID anunciado com seguranças diferentes é o sinal clássico de evil
// twin — legítimo costuma manter o mesmo conjunto em todos os rádios.
// Same SSID advertised with mixed security suites (e.g. WPA2 alongside Open)
// is a classic Evil Twin indicator.
let mut by_ssid: HashMap<String, Vec<&AccessPoint>> = HashMap::new();
for ap in aps {
if let Some(ssid) = ap.ssid.as_deref().filter(|s| !s.is_empty()) {
+11 -11
View File
@@ -1,12 +1,12 @@
//! Trilha de auditoria encadeada por hash de toda ação sensível (ligar
//! modo monitor, iniciar captura, trocar canal, etc.) — mesmo princípio do
//! módulo `audit` do Guará: cada evento guarda o hash do anterior, então
//! qualquer edição retroativa do log quebra a cadeia e é detectável.
//! Hash-chained audit trail of every sensitive operation (enabling
//! monitor mode, starting capture, changing channels, active attacks):
//! each entry commits to the SHA-256 of the previous entry so any
//! retroactive tampering breaks the chain.
//!
//! Provides verifiable engagement evidence of what was executed, when,
//! and against which target/interface.
//!
//!
//! Isso existe para que o uso do Sabiá em um engajamento de pentest tenha
//! evidência de o quê foi feito, quando e contra qual rede/interface —
//! não para decidir sozinho se uma ação é autorizada (isso é sempre do
//! operador).
use crate::error::Result;
use serde::{Deserialize, Serialize};
@@ -85,7 +85,7 @@ impl AuditLog {
Ok(entry)
}
/// Lê as entradas da trilha, opcionalmente só as `limit` mais recentes.
/// Reads entries from the audit log, optionally returning only the last `limit` entries.
pub fn read_entries(path: impl AsRef<Path>, limit: Option<usize>) -> Result<Vec<AuditEntry>> {
let file = OpenOptions::new().read(true).open(path)?;
let reader = BufReader::new(file);
@@ -105,8 +105,8 @@ impl AuditLog {
Ok(entries)
}
/// Verifica a integridade de toda a cadeia; retorna o índice da
/// primeira entrada inválida, se houver.
/// Verifies the integrity of the entire hash chain; returns the index of
/// the first invalid entry if tampered.
pub fn verify(path: impl AsRef<Path>) -> Result<Option<usize>> {
let file = OpenOptions::new().read(true).open(path)?;
let reader = BufReader::new(file);
+12 -12
View File
@@ -1,9 +1,9 @@
//! Captura crua de quadros 802.11 via `AF_PACKET`, sem depender de libpcap.
//! Raw 802.11 frame capture via Linux `AF_PACKET` sockets without requiring `libpcap`.
//!
//! The interface must already be in monitor mode (see [`crate::netlink`]).
//! Uses the same kernel packet socket interface that `libpcap` uses on Linux
//! without adding an external system library dependency.
//!
//! A interface já precisa estar em modo monitor (ver [`crate::netlink`]).
//! Isso só funciona em Linux — é exatamente o que o libpcap faz por baixo
//! quando usado em modo monitor, então reimplementar aqui é pouco código e
//! remove uma dependência de sistema.
#![cfg(target_os = "linux")]
@@ -21,8 +21,8 @@ pub struct RawCapture {
}
impl RawCapture {
/// Abre um socket raw ligado à interface `iface` (já em modo monitor).
/// Exige `CAP_NET_RAW` (tipicamente root).
/// Opens a raw `AF_PACKET` socket bound to `iface` (must already be in monitor mode).
/// Requires `CAP_NET_RAW` (typically root).
pub fn open(iface: &str) -> Result<Self> {
let fd = unsafe { libc::socket(libc::AF_PACKET, libc::SOCK_RAW, ETH_P_ALL as i32) };
if fd < 0 {
@@ -57,8 +57,8 @@ impl RawCapture {
Ok(Self { fd })
}
/// Define um timeout de recepção, para o loop de captura poder checar
/// periodicamente um sinal de parada sem travar para sempre em `recv`.
/// Sets `SO_RCVTIMEO` so the capture loop can periodically check its stop flag
/// without blocking indefinitely in `recv`.
pub fn set_read_timeout(&self, timeout: Duration) -> Result<()> {
let tv = libc::timeval {
tv_sec: timeout.as_secs() as libc::time_t,
@@ -79,7 +79,7 @@ impl RawCapture {
Ok(())
}
/// Lê um quadro. Retorna `Ok(None)` em timeout (ver `set_read_timeout`).
/// Reads a single frame. Returns `Ok(None)` on receive timeout (see `set_read_timeout`).
pub fn recv(&self, buf: &mut [u8]) -> Result<Option<usize>> {
let n = unsafe { libc::recv(self.fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len(), 0) };
if n < 0 {
@@ -114,8 +114,8 @@ fn if_index(name: &str) -> Result<i32> {
Ok(idx as i32)
}
/// Garante, de forma best-effort, que o processo tem privilégio para abrir
/// sockets raw — útil para dar um erro claro antes de tentar o resto.
/// Best-effort check that the process has privileges to open raw packet sockets.
///
pub fn check_capabilities() -> bool {
let euid = unsafe { libc::geteuid() };
euid == 0
+39 -39
View File
@@ -1,31 +1,31 @@
//! Decriptação CCMP (WPA2-AES) de quadros de dados, usando a TK já derivada
//! de um handshake quebrado.
//! WPA2 CCMP (AES-CTR) data frame decryption using the Temporal Key (TK) derived
//! from a cracked 4-way handshake.
//!
//! Used passively to inspect cleartext upper-layer protocols (DHCP, mDNS, ARP, HTTP)
//! of a station whose own 4-way handshake was captured and cracked, allowing
//! hostname/device identification. Because each association derives an ephemeral
//! PTK, the TK is scoped to a specific `(BSSID, station)` pair.
//!
//!
//!
//!
//! Implements AES-CTR payload decryption (skipping 8-byte CBC-MAC MIC verification
//! since we only extract passive identity hints from known-key traffic).
//!
//!
//!
//! Isso não serve pra forjar nem reinjetar nada — só pra enxergar o conteúdo
//! em claro (DHCP, mDNS) de um cliente cuja senha da rede já foi recuperada
//! por dicionário, e assim extrair hostname/fabricante de verdade em vez de
//! só o MAC. A chave é par (BSSID, estação): só decifra tráfego do cliente
//! cujo próprio handshake foi capturado e quebrado, nunca de terceiros —
//! cada associação tem sua PTK efêmera, então a senha da rede sozinha não
//! abre o tráfego de quem a gente não viu entrar.
//!
//! Implementa só o modo CTR (a parte que decifra o conteúdo). O MIC
//! (CBC-MAC) não é conferido: a intenção aqui é mostrar o payload de um
//! tráfego que a gente já pode decifrar porque a senha é conhecida, não
//! provar integridade de um quadro adulterado — e implementar o AAD/CBC-MAC
//! certinho exigiria replicar o mascaramento do cabeçalho 802.11 inteiro
//! sem ganho prático pro que isso serve aqui.
use aes::cipher::{BlockEncrypt, KeyInit};
use aes::Aes128;
/// Decifra o corpo de um quadro de dados CCMP, a partir do cabeçalho CCMP
/// (8 bytes) em diante — ou seja, `body` é `frame.body[frame.header_len..]`.
/// `transmitter` é o endereço 2 (A2) do quadro — o rádio que o enviou, não o
/// BSSID/estação lógicos. `qos_tid` é o TID do QoS Control (0 se não-QoS).
/// Decrypts a CCMP-protected data frame payload starting at the 8-byte CCMP header
/// (`frame.body[frame.header_len..]`).
/// `transmitter` is Address 2 (`A2`), and `qos_tid` is the QoS TID (`0` for non-QoS).
///
///
/// Returns the decrypted LLC/SNAP + payload (excluding the trailing 8-byte MIC).
///
/// Retorna o LLC/SNAP + payload em claro (o mesmo formato que um quadro não
/// protegido teria), sem os 8 bytes de MIC cifrado no final.
pub fn decrypt_data(
tk: &[u8; 16],
transmitter: &[u8; 6],
@@ -40,8 +40,8 @@ pub fn decrypt_data(
let pn0 = body[0];
let pn1 = body[1];
let key_id_octet = body[3];
// ExtIV (bit 5) precisa estar ligado — senão não é um cabeçalho CCMP de
// verdade (podia ser WEP de 4 bytes por coincidência de layout).
// ExtIV (bit 5) must be set for a valid 8-byte CCMP IV header.
//
if key_id_octet & 0x20 == 0 {
return None;
}
@@ -53,8 +53,8 @@ pub fn decrypt_data(
let ciphertext_and_mic = &body[HEADER_LEN..];
let ciphertext = &ciphertext_and_mic[..ciphertext_and_mic.len() - MIC_LEN];
// Nonce CCMP (13 bytes): flags/prioridade (1) || A2 (6) || PN, do byte
// mais significativo (PN5) ao menos significativo (PN0).
// 13-byte CCMP nonce: `flags/priority (1) || A2 (6) || PN5..PN0 (6)`.
//
let mut nonce = [0u8; 13];
nonce[0] = qos_tid & 0x0f;
nonce[1..7].copy_from_slice(transmitter);
@@ -67,10 +67,10 @@ pub fn decrypt_data(
let cipher = Aes128::new(tk.into());
let mut out = Vec::with_capacity(ciphertext.len());
let mut counter: u16 = 1; // o bloco de contador 0 cifra só o MIC, não dado.
let mut counter: u16 = 1; // counter block 0 encrypts the MIC; payload blocks start at counter 1.
for chunk in ciphertext.chunks(16) {
let mut block = [0u8; 16];
block[0] = 0x01; // flag octet do CCMP counter mode (L=1, sem AAD aqui)
block[0] = 0x01; // CCMP counter mode flags octet (`L = 1`)
block[1..14].copy_from_slice(&nonce);
block[14..16].copy_from_slice(&counter.to_be_bytes());
let mut keystream = block.into();
@@ -87,9 +87,9 @@ pub fn decrypt_data(
mod tests {
use super::*;
/// AES-128 do FIPS-197 (vetor de referência oficial do NIST): confirma
/// que a cifra de bloco usada embaixo está correta antes de confiar no
/// contador CTR por cima dela.
/// NIST FIPS-197 Appendix B AES-128 test vector.
///
///
#[test]
fn aes128_matches_fips197_vector() {
let key: [u8; 16] = [
@@ -110,19 +110,19 @@ mod tests {
assert_eq!(block.as_slice(), &expected);
}
/// Round-trip de auto-consistência: decifrar um texto que a própria
/// função "cifrou" (mesmo algoritmo, usado nos dois sentidos — CTR é
/// simétrico) tem que devolver o original. Isso pega bug de indexação
/// no nonce/contador mesmo sem um vetor de teste oficial do CCMP à mão.
/// CTR symmetry round-trip: encrypting and decrypting with the same CCMP
/// keystream recovers the original LLC/SNAP payload.
///
///
#[test]
fn ctr_round_trip_recovers_plaintext() {
let tk = [0x42u8; 16];
let transmitter = [0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff];
let plaintext = b"AA AA 03 00 00 00 08 00 payload de teste bem maior que um bloco de 16 bytes para cobrir mais de uma rodada do contador";
// Monta um "quadro" CCMP cifrando manualmente com a mesma lógica de
// keystream (ela é simétrica: XOR com o keystream cifra e decifra).
let header = [0x11u8, 0x22, 0x00, 0x20, 0x33, 0x44, 0x55, 0x66]; // ExtIV ligado
// Build a synthetic CCMP payload using the symmetric CTR keystream.
//
let header = [0x11u8, 0x22, 0x00, 0x20, 0x33, 0x44, 0x55, 0x66]; // ExtIV bit set
let mut nonce = [0u8; 13];
nonce[0] = 3; // TID
nonce[1..7].copy_from_slice(&transmitter);
@@ -147,7 +147,7 @@ mod tests {
}
counter = counter.wrapping_add(1);
}
ciphertext.extend_from_slice(&[0u8; 8]); // MIC fictício, ignorado na decriptação
ciphertext.extend_from_slice(&[0u8; 8]); // dummy 8-byte MIC (stripped during decryption)
let mut body = Vec::new();
body.extend_from_slice(&header);
@@ -160,7 +160,7 @@ mod tests {
#[test]
fn rejects_body_without_extiv_bit() {
let tk = [0u8; 16];
let body = [0u8; 20]; // key_id_octet (índice 3) sem o bit ExtIV
let body = [0u8; 20]; // key_id_octet (index 3) without the ExtIV bit set
assert!(decrypt_data(&tk, &[0; 6], 0, &body).is_none());
}
+67 -67
View File
@@ -1,15 +1,15 @@
//! Quebra de WPA/WPA2-PSK por dicionário, em Rust puro.
//! Pure-Rust WPA/WPA2-PSK dictionary and mask cracker.
//!
//! Duas provas são aceitas, como no aircrack-ng/hashcat:
//! Supports both verification modes used by `aircrack-ng` and `hashcat`:
//!
//! * **EAPOL (4-way handshake):** deriva a PMK com PBKDF2-HMAC-SHA1
//! (4096 iterações), expande a PTK com o PRF do 802.11i e recalcula o MIC da
//! mensagem 2.
//! * **PMKID:** recalcula `HMAC-SHA1(PMK, "PMK Name" || AP || STA)` direto da
//! mensagem 1 — não precisa do handshake completo.
//! * **EAPOL (4-way handshake):** derives the PMK via PBKDF2-HMAC-SHA1
//! (4096 iterations), expands the PTK via IEEE 802.11i PRF, and verifies the
//! message 2 MIC.
//! * **PMKID:** computes `HMAC-SHA1(PMK, "PMK Name" || AP || STA)` directly from
//! message 1 without requiring a full 4-way handshake.
//!
//! É CPU puro, sem GPU: suficiente para a v0.1.0 e para dicionários comuns,
//! mas não substitui o hashcat em ataques de força bruta grandes.
//! Runs multi-threaded on the CPU; for large GPU-accelerated attacks, export
//! `.hc22000` via [`crate::hashcat`].
use crate::dot11::parse_mac;
use crate::error::{Result, SabiaError};
@@ -33,9 +33,9 @@ type HmacMd5 = Hmac<Md5>;
const WPA_ITERATIONS: u32 = 4096;
/// `.gz` do dicionário embutido — descomprimido só na primeira vez que é
/// usado (ver [`default_wordlist`]). Cru são ~101MB; comprimido, ~39MB no
/// binário em vez de ~101MB.
/// Gzip-compressed built-in wordlist (`~39 MB` compressed -> `~101 MB` raw),
/// decompressed lazily on first use (see [`default_wordlist`]).
///
const DEFAULT_WORDLIST_GZ: &[u8] = include_bytes!("../assets/wordlists/wpa-full.txt.gz");
pub const DEFAULT_WORDLIST_SOURCE: &str =
@@ -49,17 +49,17 @@ static DEFAULT_WORDLIST_CACHE: LazyLock<String> = LazyLock::new(|| {
out
});
/// Lista embutida de senhas prováveis de Wi-Fi, usada quando o usuário não
/// escolhe um dicionário próprio. Não depende de arquivo externo: é a lista
/// mais completa que o Sabiá consegue embutir sem exigir download —
/// 9.703.831 senhas (203.806 do Probable-Wordlists, ranqueadas por
/// probabilidade, seguidas pelo rockyou.txt inteiro) filtradas pro
/// comprimento válido de WPA (8–63) e pra ASCII puro (WPA-PSK exige um
/// octeto válido; a imensa maioria dos roteadores só aceita ASCII mesmo, e
/// isso evita o pouco de lixo binário que o rockyou.txt original carrega),
/// sem duplicatas e na ordem de prioridade: as mais prováveis primeiro,
/// então a quebra tende a achar a senha cedo, não só no fim da lista. Ver
/// `assets/wordlists/LICENSE.wpa-full` pra atribuição completa.
/// Built-in WPA wordlist (9,703,831 deduplicated 8–63 char ASCII passwords
/// combining Probable-Wordlists and `rockyou.txt` ordered by probability).
/// See `crates/sabia-core/assets/wordlists/LICENSE.wpa-full` for attribution.
///
///
///
///
///
///
///
///
pub fn default_wordlist() -> &'static str {
&DEFAULT_WORDLIST_CACHE
}
@@ -142,7 +142,7 @@ impl CrackTarget {
}
}
/// Deriva a PMK (256 bits) de uma frase-senha WPA e do SSID.
/// Derives the 256-bit WPA PMK from an ASCII passphrase and SSID (`PBKDF2-HMAC-SHA1`, 4096 rounds).
pub fn derive_pmk(passphrase: &str, ssid: &str) -> [u8; 32] {
let dk = pbkdf2_sha1(passphrase.as_bytes(), ssid.as_bytes(), WPA_ITERATIONS, 32);
let mut pmk = [0u8; 32];
@@ -150,10 +150,10 @@ pub fn derive_pmk(passphrase: &str, ssid: &str) -> [u8; 32] {
pmk
}
/// Aceita uma frase-senha (8–63 caracteres) ou uma PMK crua em hexadecimal
/// (64 caracteres, como no hashcat/aircrack). `pub(crate)` porque
/// `handshake.rs` reusa exatamente essa regra ao derivar a TK de uma senha
/// já quebrada (o campo `password` aceita as duas formas, igual aqui).
/// Resolves either an 8–63 character WPA passphrase or a raw 64-hex-character PMK.
/// Shared with `handshake.rs` when deriving the CCMP Temporal Key (TK) from a known password.
///
///
pub(crate) fn pmk_from_candidate(candidate: &str, ssid: &[u8]) -> Option<[u8; 32]> {
if candidate.len() == 64 && candidate.bytes().all(|b| b.is_ascii_hexdigit()) {
return hex_to_32(candidate.as_bytes());
@@ -170,10 +170,10 @@ pub(crate) fn pmk_from_candidate(candidate: &str, ssid: &[u8]) -> Option<[u8; 32
pub fn pbkdf2_sha1(password: &[u8], salt: &[u8], iterations: u32, out_len: usize) -> Vec<u8> {
let iterations = iterations.max(1);
let mut out = Vec::with_capacity(out_len);
// O par ipad/opad depende só da senha — monta uma vez e clona a cada
// rodada (clone é só copiar os dois blocos já prontos) em vez de montar
// o HMAC do zero em cada uma das milhares de iterações. Em 4096
// iterações por bloco isso é a maior parte do custo fora do SHA1 em si.
// Precompute the HMAC inner/outer SHA-1 states once per passphrase and clone
// across all 4096 PBKDF2 rounds to avoid re-hashing the key block each iteration.
//
//
let base = HmacSha1::new_from_slice(password).expect("HMAC aceita qualquer chave");
let mut block: u32 = 1;
while out.len() < out_len {
@@ -213,7 +213,7 @@ fn prf_sha1(key: &[u8], label: &[u8], data: &[u8], out_len: usize) -> Vec<u8> {
out
}
/// PTK de 64 bytes: KCK (16) + KEK (16) + TK (16) + 16 de reserva.
/// Derives the 64-byte PTK: `KCK (16) || KEK (16) || TK (16) || MIC keys (16)`.
pub fn compute_ptk(
pmk: &[u8; 32],
bssid: &[u8; 6],
@@ -314,14 +314,14 @@ fn valid_candidate(candidate: &str) -> bool {
|| (candidate.len() == 64 && candidate.bytes().all(|b| b.is_ascii_hexdigit()))
}
/// Tenta cada linha do dicionário contra o alvo, dividindo o trabalho entre
/// as threads disponíveis (PBKDF2-HMAC-SHA1 de 4096 iterações é CPU puro e
/// cada candidato é independente — ganho quase linear com o número de
/// núcleos). `progress` recebe o número de candidatos já processados, ~8x
/// por segundo enquanto roda. Com `apply_rules`, cada linha vira até
/// [`rules::VARIANTS_PER_WORD`] candidatos (maiúscula, sufixos comuns, leet
/// simples) em vez de só a palavra crua — multiplica o tempo de verdade,
/// por isso é opt-in.
/// Tests wordlist candidates against `target` across all available CPU threads.
/// Invokes `progress` (~8x/s) with the number of tested candidates. When
/// `apply_rules` is enabled, expands each base word via [`rules::expand`].
///
///
///
///
///
pub fn crack_reader<R: BufRead, F: FnMut(u64) + Send>(
target: &CrackTarget,
mut wordlist: R,
@@ -330,9 +330,9 @@ pub fn crack_reader<R: BufRead, F: FnMut(u64) + Send>(
) -> CrackReport {
let start = Instant::now();
// Carrega a lista inteira antes de começar: dá pra repartir em pedaços
// contíguos por thread (melhor localidade de cache que round-robin) e o
// próprio dicionário embutido cabe de sobra em memória.
// Load candidates upfront and partition into contiguous per-thread slices.
//
//
let mut candidates: Vec<String> = Vec::new();
let mut line = String::new();
loop {
@@ -384,8 +384,8 @@ pub fn crack_reader<R: BufRead, F: FnMut(u64) + Send>(
});
}
// Thread "repórter": roda dentro do mesmo scope só pra poder
// emprestar `progress` (FnMut, chamado de um único lugar).
// Progress reporter thread scoped to borrow `progress` (`FnMut`) safely.
//
scope.spawn(|| loop {
std::thread::sleep(std::time::Duration::from_millis(120));
let n = tried.load(Ordering::Relaxed);
@@ -426,7 +426,7 @@ pub fn crack_handshake<F: FnMut(u64) + Send>(
))
}
/// Quebra usando a lista embutida.
/// Runs a dictionary crack using the built-in wordlist.
pub fn crack_handshake_default<F: FnMut(u64) + Send>(
hs: &Handshake,
apply_rules: bool,
@@ -449,7 +449,7 @@ fn target_for(hs: &Handshake) -> Result<CrackTarget> {
})
}
/// Lê um `.pcap`, monta os handshakes e tenta quebrar o primeiro alvo útil.
/// Parses a `.pcap` file, assembles handshakes, and cracks the first crackable target.
pub fn crack_pcap<F: FnMut(u64) + Send>(
pcap: impl AsRef<Path>,
wordlist: impl AsRef<Path>,
@@ -474,11 +474,11 @@ pub fn crack_pcap<F: FnMut(u64) + Send>(
crack_handshake(hs, wordlist, apply_rules, progress)
}
/// Força bruta por máscara (estilo hashcat `-a 3`): gera e testa cada
/// combinação possível, sem depender de dicionário. Mesma paralelização por
/// threads do `crack_reader`, mas gerando candidatos por índice (base
/// mista) em vez de carregar tudo em memória — é o que permite máscaras com
/// milhões/bilhões de combinações sem estourar RAM.
/// Mask brute-force attack (hashcat `-a 3` style): generates candidates on the fly
/// by mixed-radix index across all CPU threads without materializing the keyspace in RAM.
///
///
///
pub fn crack_mask<F: FnMut(u64) + Send>(
target: &CrackTarget,
mask: &Mask,
@@ -546,7 +546,7 @@ pub fn crack_mask<F: FnMut(u64) + Send>(
}
}
/// Quebra por máscara a partir de um handshake já montado.
/// Runs a mask brute-force attack against an assembled `Handshake`.
pub fn crack_handshake_mask<F: FnMut(u64) + Send>(
hs: &Handshake,
mask: &Mask,
@@ -562,8 +562,8 @@ mod tests {
use crate::testutil::{handshake_frames, synthetic_handshake};
use std::io::Cursor;
/// Mede só o tempo de descomprimir o dicionário embutido (gzip, ~39MB -> ~101MB),
/// que acontece uma vez, na primeira chamada a `default_wordlist()`.
/// Benchmarks one-time decompression of the built-in gzip wordlist (`~39 MB -> ~101 MB`).
///
#[test]
#[ignore]
fn bench_default_wordlist_decompress() {
@@ -577,17 +577,17 @@ mod tests {
);
}
/// Não é teste de verdade — mede candidatos/segundo pra guiar otimização
/// de performance. `cargo test --release -p sabia-core -- --ignored
/// --nocapture bench_crack_throughput`.
/// Throughput benchmark (`cargo test --release -p sabia-core -- --ignored --nocapture bench_crack_throughput`).
///
///
#[test]
#[ignore]
fn bench_crack_throughput() {
let (m1, m2) = handshake_frames("IEEE", "senha-que-nao-esta-na-lista", 2, false);
let hs = synthetic_handshake("IEEE", "00:11:22:33:44:55", "66:77:88:99:aa:bb", &m1, &m2);
let target = CrackTarget::from_handshake(&hs).expect("alvo");
// Só uma fatia: a lista inteira (9,7 milhões) levaria minutos — o
// objetivo aqui é medir candidatos/s, não o tempo total.
// Benchmark a 20,000-password slice to measure candidates/sec quickly.
//
let sample: String = default_wordlist()
.lines()
.take(500_000)
@@ -677,8 +677,8 @@ mod tests {
#[test]
fn rules_find_a_mutated_variant_not_present_verbatim() {
// A senha de verdade é "wifi12345" — não está na lista, mas é um
// sufixo numérico de "wifi" que `rules::expand` gera.
// True password `"wifi12345"` is derived from `"wifi"` via `rules::expand`.
//
let (m1, m2) = handshake_frames("IEEE", "wifi12345", 2, false);
let hs = synthetic_handshake("IEEE", "00:11:22:33:44:55", "66:77:88:99:aa:bb", &m1, &m2);
let target = CrackTarget::from_handshake(&hs).expect("alvo");
@@ -692,10 +692,10 @@ mod tests {
#[test]
fn mask_crack_finds_a_numeric_password() {
// Máscara minúscula de propósito (só 10 combinações): cada candidato
// é um PBKDF2 de 4096 iterações, pesado até em paralelo — um brute
// force de verdade (ex.: ?d×8, 100 milhões) é coisa pra uso real,
// não pra um teste unitário que roda em `cargo test` toda hora.
// Small 10-candidate mask (`wifi123?d`) to keep unit tests fast.
//
//
//
let (m1, m2) = handshake_frames("IEEE", "wifiabc7", 2, false);
let hs = synthetic_handshake("IEEE", "00:11:22:33:44:55", "66:77:88:99:aa:bb", &m1, &m2);
let target = CrackTarget::from_handshake(&hs).expect("alvo");
+2 -2
View File
@@ -1,5 +1,5 @@
//! Diagnóstico do ambiente: privilégios, ferramentas externas, interfaces,
//! suporte a modo monitor e integridade da trilha de auditoria.
//! Environment diagnostics: privileges, external helper tools, wireless interfaces,
//! monitor mode support, and audit trail integrity.
use crate::audit::AuditLog;
use crate::iface::{self, Interface};
+30 -30
View File
@@ -1,7 +1,7 @@
//! Parser de quadros 802.11 — gerenciamento (beacon, probe, associação,
//! desautenticação) e dados — com leitura dos *information elements* que
//! interessam para auditoria: SSID, canal, segurança (RSN/WPA), WPS, PMF e
//! a localização do payload EAPOL dos handshakes WPA/WPA2.
//! IEEE 802.11 frame parser — management (beacon, probe, association,
//! deauthentication) and data frames — extracting security-relevant Information
//! Elements (SSID, channel, RSN/WPA, WPS, PMF) and locating EAPOL handshake
//! payloads and cleartext client identity hints.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FrameKind {
@@ -94,9 +94,9 @@ pub struct ManagementInfo {
pub wps_serial: Option<String>,
}
/// Pistas de identidade tiradas de payload em claro (DHCP, mDNS/DNS, SSDP,
/// HTTP e ARP). Redes criptografadas não expõem nada disso; nesse caso os
/// campos ficam vazios e a UI mostra só MAC, OUI e probes.
/// Client identity hints extracted from cleartext upper-layer payloads (DHCP, mDNS/DNS,
/// SSDP, HTTP, ARP). Empty on encrypted networks unless the station's CCMP
/// session key has been derived from a cracked handshake.
#[derive(Debug, Clone, Default)]
pub struct PayloadInfo {
pub ip: Option<String>,
@@ -172,7 +172,7 @@ pub fn is_broadcast(mac: &[u8; 6]) -> bool {
*mac == [0xff; 6]
}
/// Quadro 802.11 já sem o cabeçalho radiotap.
/// Parsed 802.11 frame (after stripping the leading Radiotap header).
pub struct Frame<'a> {
pub kind: FrameKind,
pub fc: u16,
@@ -327,7 +327,7 @@ impl<'a> Frame<'a> {
self.dst.map(|m| mac_to_string(&m))
}
/// Slice dos information elements, quando o quadro tem um.
/// Returns the Information Elements (IE) byte slice for management frames that carry one.
pub fn ies(&self) -> Option<&'a [u8]> {
if self.ie_offset == 0 || self.ie_offset >= self.body.len() {
None
@@ -336,8 +336,8 @@ impl<'a> Frame<'a> {
}
}
/// Faz parsing dos information elements (TLV), extraindo SSID, canal,
/// cifra/AKM e indicadores de WPS e PMF.
/// Parses TLV Information Elements, extracting SSID, channel, cipher/AKM suites,
/// WPS state, and PMF (802.11w) flags.
pub fn management_info(&self) -> ManagementInfo {
let mut info = ManagementInfo::default();
let Some(ies) = self.ies() else {
@@ -449,8 +449,8 @@ impl<'a> Frame<'a> {
}
}
/// Payload EAPOL (handshake WPA) de um quadro de dados não protegido.
/// O slice começa no cabeçalho EAPOL (versão, tipo, tamanho).
/// Extracts the EAPOL payload slice (`0x888E`) from an unprotected data frame.
/// The returned slice begins at the 4-byte EAPOL header.
pub fn eapol(&self) -> Option<&'a [u8]> {
if self.protected || !self.kind.is_data() {
return None;
@@ -469,10 +469,10 @@ impl<'a> Frame<'a> {
self.body.get(start..end)
}
/// Tenta identificar a estação por payload não criptografado. Só olha
/// dados em claro; em WPA2/WPA3 o conteúdo é opaco e retorna vazio — a
/// menos que a senha já tenha sido quebrada (ver [`Self::ccmp_payload`]
/// + [`payload_info_from_llc`]).
/// Extracts passive client identity hints from an unprotected data frame.
/// Returns empty `PayloadInfo` for protected frames (use [`Self::ccmp_payload`]
/// + [`payload_info_from_llc`] when the station's TK is known).
///
pub fn payload_info(&self) -> PayloadInfo {
if self.protected || !self.kind.is_data() {
return PayloadInfo::default();
@@ -483,8 +483,8 @@ impl<'a> Frame<'a> {
payload_info_from_llc(payload)
}
/// TID do campo QoS Control, quando o quadro é QoS Data. `None` pra
/// dados não-QoS (o nonce do CCMP usa TID 0 nesse caso).
/// Extracts the QoS Control TID (`0..15`) for QoS Data frames, or `None` for non-QoS.
///
pub fn qos_tid(&self) -> Option<u8> {
if !self.kind.is_data() {
return None;
@@ -500,10 +500,10 @@ impl<'a> Frame<'a> {
self.body.get(offset).map(|b| b & 0x0f)
}
/// Decifra um quadro de dados CCMP com a TK já derivada (handshake
/// quebrado) e devolve o LLC/SNAP + payload em claro, no mesmo formato
/// que um quadro não protegido teria. `None` se o quadro não for CCMP
/// protegido ou os bytes não baterem com um cabeçalho CCMP válido.
/// Decrypts a CCMP-protected data frame using the station's 16-byte Temporal Key (TK)
/// and returns the cleartext LLC/SNAP + upper-layer payload.
///
///
pub fn ccmp_payload(&self, tk: &[u8; 16]) -> Option<Vec<u8>> {
if !self.protected || !self.kind.is_data() {
return None;
@@ -515,9 +515,9 @@ impl<'a> Frame<'a> {
}
}
/// LLC/SNAP (AA AA 03 00 00 00 + ethertype) seguido do payload em claro —
/// compartilhado entre quadros nunca cifrados e quadros CCMP já decifrados
/// com a TK conhecida.
/// Parses cleartext LLC/SNAP (`AA AA 03 00 00 00 + EtherType`) payloads for client
/// identity hints (shared by unencrypted frames and CCMP-decrypted frames).
///
pub fn payload_info_from_llc(payload: &[u8]) -> PayloadInfo {
let mut info = PayloadInfo::default();
let Some(llc) = payload.get(..8) else {
@@ -1062,7 +1062,7 @@ fn manufacturer_from_text(text: &str) -> Option<String> {
Some(name.to_string())
}
/// Mantido para chamadores antigos: há um EAPOL neste quadro?
/// Returns `true` if this frame carries an EAPOL payload.
pub fn is_eapol(body: &[u8]) -> bool {
parse(body).and_then(|f| f.eapol().map(|_| ())).is_some()
}
@@ -1102,8 +1102,8 @@ fn add_phy(phy: &mut Vec<String>, value: &str) {
}
}
/// Information Element de WPS (vendor 00:50:f2 tipo 4). O conteúdo começa
/// com a versão do protocolo e segue em atributos TLV de 16 bits.
/// Parses a Microsoft WPS vendor-specific IE (`00:50:f2:04`, big-endian 16-bit TLVs).
///
fn parse_wps(val: &[u8], info: &mut ManagementInfo) {
if val.len() < 6 {
return;
@@ -1366,7 +1366,7 @@ mod tests {
f.extend_from_slice(&0x0108u16.to_le_bytes()); // data, ToDS
f.extend_from_slice(&0u16.to_le_bytes());
f.extend_from_slice(&mac(0x11)); // addr1 = AP
f.extend_from_slice(&mac(0x22)); // addr2 = cliente
f.extend_from_slice(&mac(0x22)); // addr2 = client
f.extend_from_slice(&mac(0x11)); // addr3 = AP
f.extend_from_slice(&[0u8; 2]);
f.extend_from_slice(&[0xAA, 0xAA, 0x03, 0, 0, 0]);
+30 -30
View File
@@ -1,8 +1,8 @@
//! Parser do handshake WPA/WPA2 (EAPOL-Key, IEEE 802.1X).
//! WPA/WPA2 EAPOL-Key (IEEE 802.1X) 4-way handshake parser.
//!
//! Identifies messages 1–4 of the 4-way handshake and extracts nonces, MIC,
//! key descriptor version, and PMKID for [`crate::crack`].
//!
//! Lê as quatro mensagens do 4-way handshake, identifica o número de cada
//! uma, extrai nonces, MIC, versão de chave e PMKID — matéria-prima para
//! montar um alvo de quebra por dicionário em [`crate::crack`].
pub const EAPOL_TYPE_KEY: u8 = 3;
@@ -12,8 +12,8 @@ const KEY_INFO_ACK: u16 = 0x0080;
const KEY_INFO_MIC: u16 = 0x0100;
const KEY_INFO_SECURE: u16 = 0x0200;
/// Deslocamento absoluto do campo MIC dentro do quadro EAPOL (já incluindo
/// o cabeçalho de 4 bytes).
/// Byte offset of the 16-byte MIC field within the full EAPOL frame (including
/// the 4-byte 802.1X header).
pub const MIC_OFFSET: usize = 81;
pub const KEY_DATA_OFFSET: usize = 99;
pub const MIN_KEY_FRAME_LEN: usize = 99;
@@ -22,10 +22,10 @@ pub const MIN_KEY_FRAME_LEN: usize = 99;
pub struct EapolKey {
pub version: u8,
pub descriptor_type: u8,
/// 1, 2, 3 ou 4; 0 quando não é uma das mensagens do 4-way handshake.
/// Handshake message number (`1..=4`, or `0` if not a 4-way handshake frame).
pub message: u8,
pub key_info: u16,
/// Versão do descritor: 1 = TKIP, 2 = CCMP, 3 = AES-CMAC.
/// Key descriptor version: `1` = HMAC-MD5/RC4 (TKIP), `2` = HMAC-SHA1/AES (CCMP), `3` = AES-128-CMAC.
pub key_version: u8,
pub pairwise: bool,
pub replay_counter: u64,
@@ -33,8 +33,8 @@ pub struct EapolKey {
pub mic: [u8; 16],
pub key_data: Vec<u8>,
pub pmkid: Option<[u8; 16]>,
/// Quadro EAPOL completo (cabeçalho + descritor + dados), com o MIC no
/// lugar — usado para recalcular o MIC durante a quebra.
/// Full raw EAPOL frame (header + key descriptor + key data), used to
/// zero the MIC field and recompute it during password verification.
pub frame: Vec<u8>,
}
@@ -51,17 +51,17 @@ pub fn parse(buf: &[u8]) -> Option<EapolKey> {
let frame = buf[..total].to_vec();
let descriptor_type = frame[4];
// 2 = RSN (WPA2/WPA3), 254 = WPA1. Outros descritores não interessam.
// 2 = RSN (WPA2/WPA3), 254 = legacy WPA1.
if descriptor_type != 2 && descriptor_type != 254 {
return None;
}
// Todo campo multi-byte do descritor EAPOL-Key (Key Information, Key
// Length, Replay Counter, Key Data Length) é big-endian — é 802.1X,
// segue a mesma ordem de rede do cabeçalho EAPOL em si. Ler como
// little-endian (bug anterior) embaralhava os bits de ack/mic/secure e
// fazia praticamente todo handshake real cair em message=0, descartado
// silenciosamente — só não aparecia nos testes porque o gerador de
// quadro sintético cometia o mesmo engano, simetricamente.
// All multi-byte fields in the EAPOL-Key descriptor (Key Information, Key
// Length, Replay Counter, Key Data Length) are network byte order (big-endian).
//
//
//
//
//
let key_info = u16::from_be_bytes([frame[5], frame[6]]);
let key_version = (key_info & 0x0007) as u8;
let pairwise = key_info & KEY_INFO_PAIRWISE != 0;
@@ -111,8 +111,8 @@ pub fn parse(buf: &[u8]) -> Option<EapolKey> {
})
}
/// PMKID KDE: `dd 14 00 0f ac 04` + 16 bytes. Pode vir em qualquer posição
/// dos dados da chave (normalmente na mensagem 1).
/// Extracts the 16-byte PMKID from an RSN PMKID KDE (`dd 14 00 0f ac 04 + 16 bytes`)
/// inside EAPOL-Key data (typically present in message 1).
pub fn parse_pmkid_kde(data: &[u8]) -> Option<[u8; 16]> {
let mut i = 0usize;
while i + 2 <= data.len() {
@@ -183,24 +183,24 @@ mod tests {
}
}
/// Byte a byte, independente do helper `key_frame` (que usa a mesma
/// codificação do parser): confere que um quadro Mensagem 1 real — key
/// info 0x008a (Ack=1, versão CCMP=2, pairwise), big-endian como é no
/// fio de verdade — é lido como message=1/key_version=2, não message=0.
/// É exatamente o valor citado pela documentação de ferramentas de
/// quebra de WPA pra mensagem 1 com CCMP.
/// Verifies against a raw wire-format EAPOL Message 1 frame (`key_info = 0x008a`
/// big-endian: Ack=1, Pairwise=1, Version=2 CCMP).
///
///
///
///
#[test]
fn parses_real_wire_message1_key_info_as_big_endian() {
let mut frame = vec![0u8; MIN_KEY_FRAME_LEN];
frame[0] = 2; // versão EAPOL
frame[0] = 2; // EAPOL version
frame[1] = EAPOL_TYPE_KEY;
frame[2..4].copy_from_slice(&((MIN_KEY_FRAME_LEN - 4) as u16).to_be_bytes());
frame[4] = 2; // descritor RSN
frame[5..7].copy_from_slice(&0x008au16.to_be_bytes()); // Ack=1, versão=2(CCMP), pairwise=1
frame[4] = 2; // RSN key descriptor
frame[5..7].copy_from_slice(&0x008au16.to_be_bytes()); // Ack=1, version=2 (CCMP), pairwise=1
frame[7..9].copy_from_slice(&16u16.to_be_bytes()); // key length
frame[9..17].copy_from_slice(&1u64.to_be_bytes()); // replay counter
frame[17..49].fill(0xaa); // nonce
frame[97..99].copy_from_slice(&0u16.to_be_bytes()); // sem key data
frame[97..99].copy_from_slice(&0u16.to_be_bytes()); // key data length = 0
let parsed = parse(&frame).expect("mensagem 1 real deveria parsear");
assert_eq!(parsed.message, 1);
+46 -46
View File
@@ -1,36 +1,36 @@
//! Evil Twin pra redes WPA2/WPA3-Enterprise (802.1X — usuário+senha via
//! RADIUS, não uma senha única como o PSK comum): clona o AP numa segunda
//! interface anunciando o mesmo SSID e aceita o handshake EAP (PEAP/EAP-TTLS
//! com MSCHAPv2) de qualquer cliente que tente autenticar nele. Diferente do
//! Evil Twin de PSK (`eviltwin.rs`), aqui o dispositivo manda a credencial
//! **automaticamente**, como parte do próprio protocolo 802.1X — não precisa
//! de portal cativo nem de nenhuma ação humana, porque o cliente já tenta
//! autenticar sozinho em qualquer AP que anuncie o SSID que ele reconhece.
//! WPA2/WPA3-Enterprise (802.1X) Evil Twin: clones an Enterprise AP on a
//! secondary interface using `hostapd-wpe` to accept EAP (PEAP/EAP-TTLS with
//! inner MSCHAPv2) authentication attempts and log NetNTLMv1 challenge/response
//! hashes for offline cracking with `john` or `hashcat -m 5500`.
//!
//!
//!
//!
//!
//! Note: because the server certificate is self-signed, clients configured to
//! strictly validate the RADIUS CA certificate chain will reject the connection;
//! clients without strict CA pinning will complete the inner MSCHAPv2 exchange.
//!
//!
//!
//!
//!
//!
//!
//!
//!
//!
//!
//!
//!
//! Isso só existe pra Enterprise porque só nela a senha (ou algo derivado
//! dela) de fato viaja pela rede como parte da autenticação — no PSK a
//! senha nunca é transmitida (só confere um MIC), por isso o Evil Twin de
//! PSK precisa enganar um humano a digitá-la de novo.
//!
//! Orquestra o `hostapd-wpe` (não vem com o Sabiá — precisa estar instalado
//! separadamente: não é um binário pequeno vendorizável como o reaver, é um
//! patch sobre a árvore de build do próprio hostapd). O Sabiá só gera a
//! config, o `eap_user` genérico e o certificado autoassinado, e depois lê o
//! log de credenciais capturadas.
//!
//! Limitação honesta: o certificado é autoassinado. Clientes que validam a
//! cadeia de certificado da rede (comum em dispositivos atualizados,
//! principalmente com perfil MDM corporativo) vão recusar a conexão ou
//! avisar o usuário — exatamente a defesa que deveria existir contra esse
//! ataque. Funciona de verdade contra dispositivos configurados sem essa
//! validação (ainda comum em redes legadas/mal configuradas), que é o caso
//! que esse ataque sempre existiu para expor.
use std::fmt::Write as _;
/// Config do hostapd-wpe pro AP clone: mesmo SSID/canal do AP real,
/// anunciando WPA2-Enterprise e aceitando qualquer EAP (PEAP/TTLS) — é o
/// `eap_user_file` genérico que faz ele aceitar qualquer identidade.
/// Generates the `hostapd-wpe` configuration for the Enterprise clone AP.
///
///
#[allow(clippy::too_many_arguments)]
pub fn hostapd_wpe_conf(
iface: &str,
@@ -64,20 +64,20 @@ pub fn hostapd_wpe_conf(
out
}
/// `eap_user` genérico: aceita qualquer identidade externa pra PEAP/TTLS e
/// força MSCHAPv2 por dentro — é o que faz o hostapd-wpe logar usuário +
/// challenge/response de qualquer cliente que tentar autenticar, sem
/// precisar saber credenciais reais de antemão.
/// Wildcard `eap_user` table: accepts any outer identity for PEAP/TTLS and
/// negotiates inner MSCHAPv2 so `hostapd-wpe` logs the challenge/response pair.
///
///
pub const DEFAULT_EAP_USER_FILE: &str = concat!(
"# gerado pelo Sabiá — aceita qualquer identidade, qualquer EAP externo\n",
"* PEAP,TTLS\n",
"\"anonymous\" TTLS-MSCHAPV2,MSCHAPV2 \"senha\" [2]\n",
);
/// Argumentos do `openssl req` pra gerar o par certificado+chave
/// autoassinado que o hostapd-wpe precisa pra abrir TLS (PEAP/TTLS sempre
/// envolve um túnel TLS, mesmo sendo um AP falso). Efêmero: 1 dia de validade
/// é de sobra pra uma sessão de teste.
/// Builds `openssl req` arguments to generate a 1-day ephemeral self-signed
/// RSA certificate and private key for the outer EAP-TLS/PEAP/TTLS tunnel.
///
///
pub fn openssl_selfsigned_cert_args(key_path: &str, cert_path: &str) -> Vec<String> {
[
"req",
@@ -103,23 +103,23 @@ fn sanitize_conf_value(value: &str) -> String {
value.replace(['\n', '\r'], "")
}
/// Uma credencial capturada: identidade + a linha pronta pra crackear
/// (formato `netntlm` do John the Ripper / compatível com `hashcat -m 5500`),
/// se o hostapd-wpe conseguiu capturar os dois.
/// A captured Enterprise credential: username plus NetNTLMv1 hash string
/// (compatible with `john --format=netntlm` and `hashcat -m 5500`).
///
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CapturedCredential {
pub username: String,
pub netntlm: Option<String>,
}
/// Lê o `wpe_logfile` e extrai as credenciais capturadas. Parser
/// best-effort: o formato do hostapd-wpe é documentado (blocos
/// `username:`/`challenge:`/`response:`/`jtr NETNTLM:`), mas não dá pra
/// validar contra uma instalação de verdade nesta máquina (não é um
/// binário que o Sabiá vendoriza). Pega a linha `jtr NETNTLM:` quando
/// presente — já vem pronta pra `john --format=netntlm` ou pra reformatar
/// pro `hashcat -m 5500` — e nunca falha "silenciosamente demais": o job
/// sempre expõe o arquivo de log cru também, pra conferência manual.
/// Parses `hostapd-wpe` log output and extracts captured MSCHAPv2 / NetNTLMv1 credentials.
///
///
///
///
///
///
///
pub fn captured_identities(log: &str) -> Vec<CapturedCredential> {
let mut out: Vec<CapturedCredential> = Vec::new();
let mut current_username: Option<String> = None;
+29 -29
View File
@@ -1,9 +1,9 @@
//! Montagem dos handshakes WPA/WPA2 a partir dos quadros EAPOL vistos no ar.
//! Assembles WPA/WPA2 4-way handshakes and PMKIDs from captured EAPOL frames.
//!
//! Keyed by `(BSSID, station)`. Tracks messages 1–4, PMKID from message 1,
//! and the raw radiotap+802.11 frames required to export `.pcap` or `.hc22000`.
//!
//!
//! Um handshake é identificado pelo par (BSSID, estação). Guardamos as quatro
//! mensagens quando aparecem, o PMKID da mensagem 1 e os quadros crus
//! necessários para exportar um `.pcap` que o `aircrack-ng`/hashcat também
//! entendam.
use crate::dot11;
use crate::eapol::{self, EapolKey};
@@ -24,20 +24,20 @@ pub struct Handshake {
pub snonce: Option<[u8; 32]>,
pub pmkid: Option<[u8; 16]>,
pub key_version: u8,
/// Bitmask das mensagens vistas (bit 0 = mensagem 1).
/// Bitmask of observed handshake messages (bit 0 = message 1).
pub messages: u8,
/// Quadro EAPOL da mensagem 2, com o MIC — necessário para conferir a
/// senha candidata.
/// Full EAPOL frame from message 2 (containing the MIC) used to verify
/// candidate passphrases.
pub eapol_msg2: Option<Vec<u8>>,
pub raw: Vec<RawFrame>,
pub first_seen_unix: u64,
pub last_seen_unix: u64,
pub password: Option<String>,
/// Temporal Key (CCMP/AES) derivada quando `password` é preenchido.
/// Só pra TKIP (key_version 1) fica None: a decriptação aqui só cobre
/// CCMP. Serve pra enxergar o tráfego em claro desse par (BSSID,
/// estação) específico — não abre o tráfego de outros clientes, já que
/// a PTK é efêmera por associação.
/// 16-byte CCMP Temporal Key (TK) derived once `password` is known.
/// `None` for TKIP (`key_version == 1`) or PMKID-only handshakes. Scoped
/// to this specific `(BSSID, station)` association.
///
///
pub tk: Option<[u8; 16]>,
}
@@ -111,7 +111,7 @@ impl Handshake {
}
}
/// Quadros crus (radiotap + 802.11) das mensagens vistas, em ordem.
/// Raw radiotap + 802.11 frames of observed messages, in arrival order.
pub fn raw_frames(&self) -> Vec<Vec<u8>> {
let mut v = self.raw.clone();
v.sort_by_key(|f| f.message);
@@ -140,8 +140,8 @@ impl HandshakeTracker {
Self::default()
}
/// Processa um quadro 802.11. Retorna `true` quando o quadro era EAPOL e
/// atualizou (ou criou) um handshake.
/// Processes a parsed 802.11 frame. Returns `true` if it contained an EAPOL-Key
/// message that created or updated a handshake entry.
pub fn ingest(
&mut self,
frame: &dot11::Frame<'_>,
@@ -159,8 +159,8 @@ impl HandshakeTracker {
return false;
}
// Em data frames com DS, o BSSID está em `frame.bssid`; a estação é
// quem recebe (1/3, AP -> STA) ou quem transmite (2/4, STA -> AP).
// In DS data frames, `frame.bssid` holds the AP MAC; the station is
// `destination` on downlink (M1/M3) or `transmitter` on uplink (M2/M4).
let bssid = frame.bssid_str();
let station = match key.message {
1 | 3 => frame.addr1.map(|m| dot11::mac_to_string(&m)),
@@ -198,8 +198,8 @@ impl HandshakeTracker {
true
}
/// Atualiza o SSID de handshakes já vistos (quando o beacon chega depois
/// do EAPOL, o que acontece em capturas começando no meio do handshake).
/// Backfills the SSID on existing handshakes when a beacon arrives after
/// the EAPOL exchange.
pub fn update_ssid(&mut self, bssid: &str, ssid: &str) {
if ssid.is_empty() {
return;
@@ -233,8 +233,8 @@ impl HandshakeTracker {
self.all().into_iter().map(Handshake::info).collect()
}
/// Primeiro handshake que pode ser quebrado; filtros opcionais por BSSID
/// e estação.
/// Returns the first crackable handshake matching the optional `bssid` and
/// `station` filters.
pub fn first_crackable(
&self,
bssid: Option<&str>,
@@ -254,9 +254,9 @@ impl HandshakeTracker {
}
}
/// TK já derivada pra esse par (BSSID, estação), se a senha da rede já
/// foi quebrada e o handshake tinha os dois nonces (EAPOL completo — um
/// handshake só de PMKID não dá pra derivar PTK/TK, só confirma a PSK).
/// Returns the derived 16-byte CCMP Temporal Key (TK) for `(bssid, station)`
/// if the network password has been cracked and both nonces were captured.
///
pub fn tk_for(&self, bssid: &str, station: &str) -> Option<[u8; 16]> {
self.get(bssid, station).and_then(|hs| hs.tk)
}
@@ -270,10 +270,10 @@ impl HandshakeTracker {
}
}
/// Deriva a TK (bytes 32..48 da PTK) a partir da senha quebrada, pra poder
/// decifrar CCMP do tráfego desse cliente. Só funciona com EAPOL/CCMP
/// completo (key_version 2); PMKID sozinho não tem os dois nonces pra
/// derivar PTK, e TKIP (key_version 1) usa RC4 + Michael, não implementado.
/// Derives the 16-byte CCMP Temporal Key (`PTK[32..48]`) from a cracked passphrase.
/// Requires a full EAPOL CCMP handshake (`key_version == 2`) with both nonces.
///
///
fn derive_tk(hs: &Handshake, password: &str) -> Option<[u8; 16]> {
if hs.key_version != 2 {
return None;
+5 -5
View File
@@ -1,12 +1,12 @@
//! Exportação no formato `.hc22000` do hashcat.
//! Exports captured WPA/WPA2 material in hashcat `.hc22000` format (`-m 22000`).
//!
//! Duas linhas possíveis, como no hcxtools:
//! Emits both record types supported by `hcxtools` / `hashcat`:
//!
//! * `WPA*01*PMKID*AP*STA*SSID***`
//! * `WPA*02*MIC*AP*STA*SSID*ANONCE*EAPOL*MESSAGEPAIR`
//!
//! O EAPOL exportado é o quadro da mensagem 2 completo (cabeçalho 802.1X +
//! descritor), que é o que o hashcat espera no campo `EAPOL_CLIENT`.
//! The exported EAPOL field contains the full message 2 frame (802.1X header +
//! key descriptor) expected by hashcat's `EAPOL_CLIENT` field.
use crate::eapol::MIC_OFFSET;
use crate::handshake::Handshake;
@@ -55,7 +55,7 @@ pub fn hashcat_line(hs: &Handshake) -> Option<String> {
))
}
/// Linhas únicas, uma por handshake que tenha material suficiente.
/// Formats deduplicated `.hc22000` lines for all crackable handshakes.
pub fn export<'a>(handshakes: impl IntoIterator<Item = &'a Handshake>) -> Vec<String> {
let mut lines: Vec<String> = handshakes.into_iter().filter_map(hashcat_line).collect();
lines.sort();
+12 -12
View File
@@ -1,7 +1,7 @@
//! Descoberta de interfaces de rede e identificação das que são Wi-Fi.
//! Network interface enumeration and 802.11 wireless phy identification.
//!
//! Não depende de `iw`: lê direto de `/sys/class/net`, do mesmo jeito que o
//! próprio `iw`/`ip` fazem por baixo.
//! Reads directly from `/sys/class/net` on Linux, falling back to `iw` for
//! detailed interface mode inspection when available.
use crate::error::{Result, SabiaError};
use std::fs;
@@ -11,7 +11,7 @@ use std::path::{Path, PathBuf};
pub struct Interface {
pub name: String,
pub mac: String,
/// Caminho do phy (ex.: `phy0`), presente só em interfaces 802.11.
/// Wireless `wiphy` name (e.g. `"phy0"`), present only on 802.11 interfaces.
pub phy: Option<String>,
pub mode: Mode,
pub up: bool,
@@ -28,7 +28,7 @@ fn net_root() -> &'static Path {
Path::new("/sys/class/net")
}
/// Lista todas as interfaces de rede, Wi-Fi ou não (chamador filtra por `phy`).
/// Lists all network interfaces on the system (wireless interfaces have `phy.is_some()`).
pub fn list_all() -> Result<Vec<Interface>> {
let mut out = Vec::new();
for entry in fs::read_dir(net_root())? {
@@ -43,7 +43,7 @@ pub fn list_all() -> Result<Vec<Interface>> {
Ok(out)
}
/// Lista apenas interfaces com um `phy80211` (ou seja, rádios Wi-Fi).
/// Lists only 802.11 wireless interfaces (`phy80211` present).
pub fn list_wireless() -> Result<Vec<Interface>> {
Ok(list_all()?
.into_iter()
@@ -79,8 +79,8 @@ fn read_interface(name: &str) -> Result<Interface> {
.ok()
.and_then(|p| p.file_name().map(|f| f.to_string_lossy().into_owned()))
.or_else(|| {
// em alguns kernels phy80211 é um diretório real, não symlink;
// o nome do phy então vem do subdiretório "phyNN" dentro dele.
// On some kernels `phy80211` is a directory rather than a symlink;
// read the `"phyNN"` subdirectory inside it.
fs::read_dir(&phy80211)
.ok()
.and_then(|mut rd| rd.next())
@@ -106,10 +106,10 @@ fn read_interface(name: &str) -> Result<Interface> {
})
}
/// Lê o modo (`managed`/`monitor`) via `/sys/class/net/<if>/type` + `uevent`
/// não é suficiente (type é sempre ARPHRD_ETHER para Wi-Fi); a forma estável
/// sem libnl é delegar a leitura real ao `iw dev <if> info` quando disponível,
/// mantendo aqui um melhor-esforço local para não depender de root.
/// Reads the interface operating mode (`managed`/`monitor`) via `/sys/class/net/<if>/type`
/// and `iw dev <if> info` when available.
///
///
fn read_wireless_mode(name: &str) -> Mode {
match crate::netlink::iw_dev_type(name) {
Some(Mode::Monitor) => Mode::Monitor,
+31 -31
View File
@@ -1,17 +1,17 @@
//! Construção e envio de quadros de gerenciamento (desautenticação e
//! desassociação) para testes autorizados de robustez de redes Wi-Fi.
//! Construction and raw socket injection of 802.11 management frames
//! (deauthentication, disassociation, open auth, association) for authorized testing.
//!
//! Frame builders are platform-independent; raw transmission uses Linux `AF_PACKET`.
//! Rate limits, counts, and audit logging are enforced by [`crate::engine::Engine`].
//!
//! A construção do quadro é independente de plataforma e testável; o envio
//! usa `AF_PACKET` e só existe no Linux. O chamador (engine) impõe limites de
//! quantidade e intervalo e registra a ação na trilha de auditoria.
#[cfg(target_os = "linux")]
use crate::error::{Result, SabiaError};
pub const BROADCAST: [u8; 6] = [0xff; 6];
/// Cabeçalho radiotap mínimo (8 bytes, sem campos). É aceito por mac80211 e
/// deixa o driver escolher a taxa; suficiente para quadros de gerenciamento.
/// Minimal 8-byte Radiotap header (no present fields), accepted by `mac80211`
/// for management frame injection.
fn radiotap_min() -> [u8; 8] {
[0, 0, 8, 0, 0, 0, 0, 0]
}
@@ -20,9 +20,9 @@ fn mgmt_frame(subtype: u8, bssid: [u8; 6], destination: [u8; 6], reason: u16) ->
mgmt_frame_from(subtype, bssid, bssid, destination, reason)
}
/// Quadro de gerenciamento com transmissor explícito. Permite forjar tanto
/// AP→STA (`transmitter = BSSID`) quanto STA→AP (`transmitter = estação`),
/// usado no teste bidirecional de PMF.
/// Builds a deauth/disassoc management frame with an explicit transmitter MAC,
/// supporting both AP->STA (`transmitter = BSSID`) and STA->AP (`transmitter = client`).
///
fn mgmt_frame_from(
subtype: u8,
transmitter: [u8; 6],
@@ -35,21 +35,21 @@ fn mgmt_frame_from(
f.extend_from_slice(&radiotap_min());
f.extend_from_slice(&fc.to_le_bytes());
f.extend_from_slice(&0u16.to_le_bytes());
f.extend_from_slice(&destination); // addr1: destino
f.extend_from_slice(&transmitter); // addr2: transmissor
f.extend_from_slice(&destination); // addr1: destination
f.extend_from_slice(&transmitter); // addr2: transmitter
f.extend_from_slice(&bssid); // addr3: BSSID
f.extend_from_slice(&[0u8; 2]); // número de sequência
f.extend_from_slice(&[0u8; 2]); // sequence control
f.extend_from_slice(&reason.to_le_bytes());
f
}
/// Quadro de desautenticação (subtipo 12). `destination` pode ser uma
/// estação específica ou [`BROADCAST`].
/// Builds an AP->STA deauthentication frame (subtype 12). `destination` may be
/// a unicast client MAC or [`BROADCAST`].
pub fn deauth_frame(bssid: [u8; 6], destination: [u8; 6], reason: u16) -> Vec<u8> {
mgmt_frame(12, bssid, destination, reason)
}
/// Desautenticação forjada com transmissor arbitrário (STA→AP).
/// Builds a deauthentication frame with an explicit transmitter MAC.
pub fn deauth_frame_from(
transmitter: [u8; 6],
bssid: [u8; 6],
@@ -59,7 +59,7 @@ pub fn deauth_frame_from(
mgmt_frame_from(12, transmitter, bssid, destination, reason)
}
/// Desassociação forjada com transmissor arbitrário (STA→AP).
/// Builds a disassociation frame with an explicit transmitter MAC.
pub fn disassoc_frame_from(
transmitter: [u8; 6],
bssid: [u8; 6],
@@ -69,19 +69,19 @@ pub fn disassoc_frame_from(
mgmt_frame_from(10, transmitter, bssid, destination, reason)
}
/// Quadro de desassociação (subtipo 10).
/// Builds an AP->STA disassociation frame (subtype 10).
pub fn disassoc_frame(bssid: [u8; 6], destination: [u8; 6], reason: u16) -> Vec<u8> {
mgmt_frame(10, bssid, destination, reason)
}
/// Offset do campo "Sequence Control" dentro de um quadro de gerenciamento
/// construído por [`mgmt_frame_from`] (8 de radiotap + 22 até o campo).
/// Byte offset of the 802.11 Sequence Control field inside a frame built by
/// [`mgmt_frame_from`] (8-byte Radiotap + 22-byte MAC header offset).
const SEQ_CTRL_OFFSET: usize = 30;
/// Grava um número de sequência (0–4095) no quadro, deixando o número de
/// fragmento (4 bits baixos) zerado. Sem isso, todo quadro injetado sai com
/// sequência 0 e o driver do alvo descarta as repetições como duplicata,
/// fazendo a desautenticação não ter efeito nenhum apesar de "enviada".
/// Writes a 12-bit 802.11 sequence number (`0..4096`) into `frame` (with fragment
/// number `0`) so target drivers do not drop repeated frames as duplicates.
///
///
pub fn set_seq(frame: &mut [u8], seq: u16) {
if frame.len() < SEQ_CTRL_OFFSET + 2 {
return;
@@ -99,8 +99,8 @@ fn ie(tag: u8, value: &[u8]) -> Vec<u8> {
out
}
/// Autenticação aberta (algoritmo 0, sequência 1). Usada antes do association
/// request no teste de PMKID.
/// Builds an Open System Authentication request frame (algorithm 0, seq 1) used
/// prior to Association Request when probing for clientless PMKID.
pub fn auth_frame(bssid: [u8; 6], station: [u8; 6]) -> Vec<u8> {
let mut f = Vec::with_capacity(8 + 24 + 6);
f.extend_from_slice(&radiotap_min());
@@ -110,8 +110,8 @@ pub fn auth_frame(bssid: [u8; 6], station: [u8; 6]) -> Vec<u8> {
f.extend_from_slice(&station);
f.extend_from_slice(&bssid);
f.extend_from_slice(&[0u8; 2]);
f.extend_from_slice(&0u16.to_le_bytes()); // algoritmo aberto
f.extend_from_slice(&1u16.to_le_bytes()); // sequência
f.extend_from_slice(&0u16.to_le_bytes()); // Open System algorithm (0)
f.extend_from_slice(&1u16.to_le_bytes()); // auth sequence (1)
f.extend_from_slice(&0u16.to_le_bytes()); // status
f
}
@@ -119,7 +119,7 @@ pub fn auth_frame(bssid: [u8; 6], station: [u8; 6]) -> Vec<u8> {
fn rsn_ie(akms: &[u8], pmf: bool, pmf_required: bool, pmkid: &[u8; 16]) -> Vec<u8> {
let mut val = Vec::new();
val.extend_from_slice(&1u16.to_le_bytes());
val.extend_from_slice(&[0x00, 0x0f, 0xac, 4]); // grupo CCMP
val.extend_from_slice(&[0x00, 0x0f, 0xac, 4]); // group cipher suite: CCMP
val.extend_from_slice(&1u16.to_le_bytes());
val.extend_from_slice(&[0x00, 0x0f, 0xac, 4]); // pairwise CCMP
val.extend_from_slice(&(akms.len() as u16).to_le_bytes());
@@ -139,8 +139,8 @@ fn rsn_ie(akms: &[u8], pmf: bool, pmf_required: bool, pmkid: &[u8; 16]) -> Vec<u
ie(48, &val)
}
/// Association request com RSN/PMKID. O AP pode responder com a mensagem 1
/// do 4-way handshake contendo o PMKID — é isso que o teste tenta provocar.
/// Builds an Association Request carrying an RSN IE to elicit EAPOL Message 1
/// (and its optional PMKID) from the target AP.
pub fn assoc_request_frame(
bssid: [u8; 6],
station: [u8; 6],
+4 -4
View File
@@ -1,7 +1,7 @@
//! `sabia-core`: tudo que fala com o rádio Wi-Fi, interpreta quadros 802.11,
//! mantém a trilha de auditoria e quebra handshakes. A CLI e a interface
//! gráfica são só consumidoras disto — a parte complexa (radiotap, 802.11,
//! EAPOL, captura e injeção) fica inteira aqui dentro.
//! `sabia-core`: core 802.11 wireless auditing library (Radiotap/802.11/EAPOL
//! parsers, raw capture and injection, WIDS channel analysis, WPA2/PMKID cracking,
//! WPS, Evil Twin, reporting, and hash-chained audit logging).
//!
pub mod analysis;
pub mod audit;
+11 -11
View File
@@ -1,11 +1,11 @@
//! Controle de modo (managed/monitor), canal e leitura das capacidades do
//! rádio via `iw`.
//! Wireless interface mode control (`managed`/`monitor`), channel switching,
//! and supported channel enumeration via `iw` and `ip`.
//!
//!
//!
//!
//!
//!
//! É uma ponte deliberada: o protocolo real é nl80211 por netlink, e trocar
//! este módulo por uma implementação nativa (sem depender dos binários `iw` e
//! `ip`) é o próximo passo natural. Por ora `iw`/`ip` são universais em
//! Linux, testados, e evitam reimplementar genl/nl80211 antes de validar o
//! pipeline com hardware real.
use crate::error::{Result, SabiaError};
use crate::iface::Mode;
@@ -98,7 +98,7 @@ pub fn phy_for_iface(iface: &str) -> Result<String> {
})
}
/// Lista canais suportados pelo phy da interface.
/// Lists all 802.11 channels supported by the interface's `wiphy`.
pub fn channels(iface: &str) -> Result<Vec<ChannelInfo>> {
let phy = phy_for_iface(iface)?;
let info = run("iw", &["phy", &phy, "info"])?;
@@ -113,7 +113,7 @@ pub fn supported_channels(iface: &str) -> Result<Vec<u32>> {
.collect())
}
/// Canais a percorrer. `band` aceita `"2.4"`, `"5"`, `"6"`, `"all"`/`None`.
/// Filters supported channels by band (`"2.4"`, `"5"`, `"6"`, or `"all"`/`None`).
pub fn channel_plan(iface: &str, band: Option<&str>) -> Result<Vec<u32>> {
let want = match band.map(|b| b.trim().to_ascii_lowercase()) {
None => None,
@@ -159,7 +159,7 @@ pub fn driver_name(iface: &str) -> Option<String> {
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
}
/// Põe a interface em modo monitor. Requer `CAP_NET_ADMIN` (normalmente root).
/// Toggles monitor or managed mode on `iface` (requires `CAP_NET_ADMIN` / root).
pub fn set_monitor_mode(iface: &str, enable: bool) -> Result<()> {
run("ip", &["link", "set", iface, "down"])?;
let target = if enable { "monitor" } else { "managed" };
@@ -168,7 +168,7 @@ pub fn set_monitor_mode(iface: &str, enable: bool) -> Result<()> {
Ok(())
}
/// Troca o canal atual da interface (precisa já estar em modo monitor).
/// Switches `iface` to `channel` (the interface must already be in monitor mode).
pub fn set_channel(iface: &str, channel: u32) -> Result<()> {
run(
"iw",
+1 -1
View File
@@ -1,4 +1,4 @@
//! Identificação de fabricante pelo prefixo OUI do MAC.
//! IEEE OUI vendor lookup by 3-byte MAC prefix.
const OUIS: &[(&str, &str)] = &[
("00000c", "Cisco"),
+6 -6
View File
@@ -1,6 +1,6 @@
//! Leitor do formato `.pcap` clássico, usado para analisar capturas feitas
//! pelo próprio Sabiá (link-type 127, radiotap) e pelo aircrack-ng/tcpdump
//! (link-type 105, 802.11 cru).
//! Classic libpcap (`.pcap`) reader supporting both `LINKTYPE_IEEE802_11_RADIOTAP` (127)
//! and raw `LINKTYPE_IEEE802_11` (105) captures.
//!
use crate::error::{Result, SabiaError};
use std::fs::File;
@@ -75,7 +75,7 @@ impl PcapReader {
self.linktype
}
/// Próximo quadro; `None` no fim do arquivo.
/// Reads the next frame, returning `Ok(None)` at EOF.
pub fn next_frame(&mut self) -> Result<Option<PcapFrame>> {
let mut rec = [0u8; 16];
match self.input.read_exact(&mut rec) {
@@ -102,8 +102,8 @@ impl PcapReader {
.read_exact(&mut data)
.map_err(|e| SabiaError::InvalidPcap(format!("quadro truncado: {e}")))?;
// Capturas sem radiotap (link-type 105) recebem um cabeçalho mínimo
// para o mesmo pipeline de parsing funcionar.
// Prepend a minimal 8-byte Radiotap header to link-type 105 (raw 802.11)
// frames so the downstream parser receives a uniform format.
if self.linktype == LINKTYPE_IEEE802_11 {
let mut with_radiotap = vec![0u8, 0, 8, 0, 0, 0, 0, 0];
with_radiotap.extend_from_slice(&data);
+6 -6
View File
@@ -1,6 +1,6 @@
//! Escritor de arquivos `.pcap` (formato clássico, link-type 127 =
//! IEEE802_11_RADIOTAP) para exportar capturas — compatível com
//! Wireshark, aircrack-ng e qualquer outra ferramenta do ecossistema.
//! Classic libpcap (`.pcap`) writer (`LINKTYPE_IEEE802_11_RADIOTAP` = 127),
//! compatible with Wireshark, `aircrack-ng`, and `hcxpcapngtool`.
//!
use crate::error::Result;
use std::fs::File;
@@ -18,7 +18,7 @@ impl PcapWriter {
pub fn create(path: impl AsRef<Path>) -> Result<Self> {
let file = File::create(path)?;
let mut out = BufWriter::new(file);
// Cabeçalho global do pcap clássico.
// 24-byte classic libpcap global header.
out.write_all(&0xa1b2c3d4u32.to_le_bytes())?; // magic
out.write_all(&2u16.to_le_bytes())?; // version major
out.write_all(&4u16.to_le_bytes())?; // version minor
@@ -29,7 +29,7 @@ impl PcapWriter {
Ok(Self { out })
}
/// Grava um quadro crú (radiotap + 802.11), com timestamp de agora.
/// Writes a raw Radiotap + 802.11 frame stamped with the current system time.
pub fn write_frame(&mut self, raw: &[u8]) -> Result<()> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -37,7 +37,7 @@ impl PcapWriter {
self.out.write_all(&(now.as_secs() as u32).to_le_bytes())?;
self.out.write_all(&(now.subsec_micros()).to_le_bytes())?;
self.out.write_all(&(raw.len() as u32).to_le_bytes())?; // caplen
self.out.write_all(&(raw.len() as u32).to_le_bytes())?; // len original
self.out.write_all(&(raw.len() as u32).to_le_bytes())?; // original wire length
self.out.write_all(raw)?;
Ok(())
}
+16 -16
View File
@@ -1,9 +1,9 @@
//! Parser de cabeçalho Radiotap.
//! IEEE 802.11 Radiotap header parser (<https://www.radiotap.org/>).
//!
//! Walks present flags in specification order with proper field alignment to
//! extract channel/frequency and antenna signal strength (dBm).
//!
//!
//! Só extraímos o que o restante do núcleo usa — frequência/canal e força do
//! sinal — mas avançamos pelos campos na ordem da especificação, respeitando
//! alinhamento, para não depender do formato exato que cada driver emite.
//! Especificação: <https://www.radiotap.org/>
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum Band {
@@ -36,7 +36,7 @@ pub struct RadiotapInfo {
pub signal_dbm: Option<i8>,
pub noise_dbm: Option<i8>,
pub header_len: usize,
/// Bitmap "present" da primeira palavra (campos padrão).
/// Standard Radiotap `it_present` bit positions.
pub present: u32,
}
@@ -48,20 +48,20 @@ const BIT_FHSS: u32 = 1 << 4;
const BIT_ANTSIGNAL: u32 = 1 << 5;
const BIT_ANTNOISE: u32 = 1 << 6;
/// Retorna as infos extraídas e quantos bytes o cabeçalho ocupa (para o
/// chamador saber onde o frame 802.11 propriamente começa).
/// Parses the Radiotap header at the start of `buf`, returning `(RadiotapInfo, header_len)`.
///
pub fn parse(buf: &[u8]) -> Option<RadiotapInfo> {
if buf.len() < 8 || buf[0] != 0 {
return None; // version deve ser 0
return None; // Radiotap `it_version` must be 0
}
let header_len = u16::from_le_bytes([buf[2], buf[3]]) as usize;
if header_len < 8 || header_len > buf.len() {
return None;
}
// Pode haver múltiplas palavras de "present" (bit 31 = continua). Só a
// primeira descreve os campos padrão; as seguintes são namespaces de
// extensão que ficam depois dos campos que usamos.
// Advance past any chained `it_present` extension words (bit 31 = more present words).
//
//
let mut present = 0u32;
let mut offset = 4usize;
let mut first = true;
@@ -139,7 +139,7 @@ pub fn freq_to_channel(freq_mhz: u16) -> Option<u32> {
match freq_mhz {
2412..=2472 => Some(((freq_mhz as u32 - 2412) / 5) + 1),
2484 => Some(14),
// 4.9 GHz / segurança pública (canais 182–196).
// 4.9 GHz public safety band (channels 182–196).
4910 => Some(182),
4920 => Some(184),
4940 => Some(188),
@@ -193,13 +193,13 @@ mod tests {
let present: u32 = BIT_TSFT | BIT_FLAGS | BIT_RATE | BIT_CHANNEL | BIT_ANTSIGNAL;
let mut buf = Vec::new();
buf.extend_from_slice(&[0, 0]); // version, pad
buf.extend_from_slice(&0u16.to_le_bytes()); // len (preenchido depois)
buf.extend_from_slice(&0u16.to_le_bytes()); // header length (backfilled below)
buf.extend_from_slice(&present.to_le_bytes());
// present termina em 8; TSFT alinhado em 8
// `it_present` ends at offset 8; TSFT is 8-byte aligned
buf.extend_from_slice(&0x1122_3344_5566_7788u64.to_le_bytes());
buf.push(0x10); // flags
buf.push(0x02); // rate 1 Mb/s
// channel alinhado em 2 (cursor 18 já é par)
// Channel field is 2-byte aligned (offset 18)
buf.extend_from_slice(&freq.to_le_bytes());
buf.extend_from_slice(&0xa0u16.to_le_bytes());
buf.push(signal as u8);
+12 -12
View File
@@ -1,9 +1,9 @@
//! Relatório de auditoria em HTML paginado com Paged.js.
//! Paginated HTML/PDF wireless security audit report generator (powered by Paged.js).
//!
//! Shared between the live in-app report preview and standalone offline HTML export.
//!
//!
//!
//! O mesmo modelo alimenta a pré-visualização na interface e o arquivo
//! standalone. A paginação é feita no navegador (A4, margens, cabeçalho e
//! rodapé), então o PDF sai pelo diálogo de impressão sem depender de
//! Chromium no servidor.
use crate::analysis::Analysis;
use crate::audit::AuditEntry;
@@ -28,8 +28,8 @@ pub struct ReportModel {
#[derive(Debug, Clone, Copy, Default)]
pub struct ReportOptions {
/// Troca o `<script src>` por um marcador para o servidor embutir o
/// polyfill e gerar um HTML que funciona offline.
/// Placeholder replaced with the inline `paged.polyfill.min.js` script in
/// standalone offline HTML exports.
pub inline_pagedjs: bool,
}
@@ -37,11 +37,11 @@ const PAGED_MARKER: &str = "<!--SABIA_PAGEDJS-->";
const LOGO_PNG: &[u8] = include_bytes!("../../../logo.png");
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
/// Embute o logo como `data:` URI — sem isso o relatório standalone (modo
/// offline, pensado pra abrir sem servidor nenhum) teria um `<img
/// src="/logo.png">` que nunca resolve fora do `sabia ui`. Pequeno o
/// bastante (poucos KB) pra não valer a pena puxar uma crate de base64 só
/// por isso.
/// Encodes the Sabiá logo as a `data:image/png;base64,...` URI so standalone
/// exported reports render offline without an HTTP server.
///
///
///
fn base64(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
+31 -31
View File
@@ -1,14 +1,14 @@
//! Regras de mutação sobre uma palavra do dicionário (estilo hashcat `-r`,
//! bem mais simples) e força bruta por máscara (estilo hashcat `-a 3`).
//! Wordlist mutation rules (lightweight hashcat `-r` style) and mask brute-force
//! generator (hashcat `-a 3` style).
//!
//! Because each WPA-PSK candidate requires 4096 rounds of PBKDF2-HMAC-SHA1,
//! mutation rules focus on high-yield patterns (capitalization, common numeric
//! suffixes, single-character leetspeak) to keep the multiplier bounded.
//!
//!
//! Em quebra de hash normal, regras são "de graça" — um hash SHA256 custa
//! nanossegundos. Aqui não: cada candidato é um PBKDF2-HMAC-SHA1 de 4096
//! iterações, então cada variante multiplica o tempo de verdade. As regras
//! abaixo cobrem os padrões mais comuns (maiúscula inicial, sufixo numérico,
//! leet simples) sem explodir pra centenas de variantes por palavra.
/// Quantas variantes [`expand`] gera por palavra base (pra calcular o
/// multiplicador de tempo antes de rodar).
/// Upper bound on variants produced by [`expand`] per base word.
///
pub const VARIANTS_PER_WORD: usize = 1 + 1 + 1 + SUFFIXES.len() + LEET_PAIRS.len();
const SUFFIXES: &[&str] = &[
@@ -16,12 +16,12 @@ const SUFFIXES: &[&str] = &[
"@", "#",
];
/// (char original, substituto) — leet simples, uma troca por vez, não
/// combinada, senão a combinatória explode rápido demais.
/// `(source_char, leet_replacement)` single-substitution pairs.
///
const LEET_PAIRS: &[(char, char)] = &[('a', '@'), ('e', '3'), ('i', '1'), ('o', '0'), ('s', '$')];
/// Gera as variantes de `word` (incluindo a palavra original) que respeitam
/// o comprimento válido de WPA (8–63), sem duplicatas.
/// Generates deduplicated WPA-valid (8–63 char) mutations of `word` (including `word` itself).
///
pub fn expand(word: &str) -> Vec<String> {
let mut out = Vec::with_capacity(VARIANTS_PER_WORD);
let mut push = |s: String| {
@@ -52,9 +52,9 @@ pub fn expand(word: &str) -> Vec<String> {
out
}
/// Classe de caractere de um token `?x` de máscara (sintaxe hashcat: `?d`
/// dígito, `?l` minúscula, `?u` maiúscula, `?s` símbolo comum, `?a` os
/// quatro juntos). `??` escapa um `?` literal.
/// Character class for a mask token (`?d` digits, `?l` lowercase, `?u` uppercase,
/// `?s` symbols, `?a` all printable ASCII, `??` literal `?`).
///
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CharClass {
Digit,
@@ -83,17 +83,17 @@ impl CharClass {
}
}
/// Máscara compilada: uma lista de alfabetos, um por posição. Gera
/// candidatos por índice (contagem em base mista) sem materializar a lista
/// inteira — essencial, já que `?d?d?d?d?d?d?d?d` sozinho são 100 milhões.
/// Compiled brute-force mask (one alphabet per character position), supporting
/// O(1) mixed-radix candidate generation by index.
///
#[derive(Debug, Clone)]
pub struct Mask {
alphabets: Vec<Vec<char>>,
}
impl Mask {
/// Compila uma máscara tipo `wifi?d?d?d?d` (literal "wifi" + 4 dígitos).
/// `??` é um `?` literal; `?` seguido de letra desconhecida é erro.
/// Compiles a hashcat-style mask pattern such as `"wifi?d?d?d?d"`.
/// `??` escapes a literal `?`; unknown `?x` tokens return an error.
pub fn parse(pattern: &str) -> Result<Self, String> {
let mut alphabets = Vec::new();
let mut chars = pattern.chars().peekable();
@@ -123,8 +123,8 @@ impl Mask {
Ok(Self { alphabets })
}
/// Total de candidatos. `None` se não couber num `u64` (máscara longa
/// demais pra ser levada a sério de qualquer forma).
/// Total keyspace size, or `None` if it overflows `u64`.
///
pub fn total(&self) -> Option<u64> {
self.alphabets
.iter()
@@ -139,9 +139,9 @@ impl Mask {
self.alphabets.is_empty()
}
/// Candidato de índice `index` (contagem em base mista, como um
/// odômetro: a última posição gira mais rápido). `None` se `index` >=
/// `total()`.
/// Materializes the candidate at `index` (mixed-radix odometer order), or
/// `None` if `index >= total()`.
///
pub fn candidate(&self, mut index: u64) -> Option<String> {
let total = self.total()?;
if index >= total {
@@ -165,8 +165,8 @@ mod tests {
#[test]
fn expand_respects_min_length() {
let variants = expand("senha");
// "senha" tem 5 chars; sozinha não é candidata WPA válida (precisa
// de 8+), mas com sufixo (ex.: "senha123", 8) sim.
// `"senha"` is 5 chars (too short for WPA alone), but `"senha123"` (8 chars) is kept.
//
assert!(!variants.iter().any(|v| v == "senha"));
assert!(variants.contains(&"senha123".to_string()));
}
@@ -183,7 +183,7 @@ mod tests {
assert!(variants.contains(&"Abacaxi1".to_string()));
assert!(variants.contains(&"ABACAXI1".to_string()));
assert!(variants.contains(&"abacaxi1123".to_string()));
// leet troca TODAS as ocorrências de 'a': as três em "abacaxi1".
// Leetspeak substitution replaces all occurrences of `'a'` in `"abacaxi1"`.
assert!(variants.contains(&"@b@c@xi1".to_string()));
}
@@ -208,8 +208,8 @@ mod tests {
#[test]
fn mask_literal_question_mark_and_unknown_class() {
// "??" vira um '?' literal; os dois primeiros chars são consumidos
// juntos, então "???d" é: '?' literal + "?d" (classe dígito).
// `"??"` escapes a literal `'?'`, so `"???d"` parses as literal `'?'` + `?d`.
//
assert_eq!(
Mask::parse("???d").unwrap().candidate(0).as_deref(),
Some("?0")
+29 -29
View File
@@ -1,5 +1,5 @@
//! Agregação de quadros capturados em tabelas de redes, clientes e
//! handshakes — o coração do que a CLI e a interface mostram.
//! Aggregates captured 802.11 frames into access point, client station, and
//! handshake tables.
use crate::dot11::{self, FrameKind, Security};
use crate::handshake::{Handshake, HandshakeInfo, HandshakeTracker};
@@ -59,7 +59,7 @@ pub struct ClientEvent {
#[derive(Debug, Clone, serde::Serialize)]
pub struct Client {
pub mac: String,
/// Nome curto para exibir: hostname, nome WPS, modelo ou fabricante.
/// Best human-readable label for a station: DHCP/mDNS hostname, WPS device name, model, or OUI vendor.
pub display_name: Option<String>,
pub vendor: Option<String>,
pub manufacturer: Option<String>,
@@ -78,10 +78,10 @@ pub struct Client {
pub associated: bool,
pub first_seen_unix: u64,
pub last_seen_unix: u64,
/// Amostras de sinal (mais antiga primeiro), limitadas para não crescer
/// sem fim numa captura longa.
/// Recent RSSI history (oldest first, bounded ring) for sparkline rendering.
///
pub signal_history: Vec<SignalSample>,
/// Linha do tempo curta: primeiro visto, probe, associação, desautenticação.
/// Per-client event timeline (first seen, probe, association, deauthentication, etc.).
pub events: Vec<ClientEvent>,
}
@@ -102,15 +102,15 @@ pub struct ScanSnapshot {
pub clients: Vec<Client>,
pub handshakes: Vec<HandshakeInfo>,
pub analysis: crate::analysis::Analysis,
/// Preenchido quando a captura morreu sozinha (erro de I/O, adaptador
/// caiu da USB, etc.) — `running` já vem `false` nesse caso, mas sem
/// isso a UI não tinha como dizer *por quê* parou.
/// Populated when the capture loop terminated due to an I/O error (e.g. unplugged
/// USB adapter).
///
pub error: Option<String>,
/// Preenchido quando a captura está rodando mas não recebeu nenhum
/// quadro depois de um tempo razoável saltando canais — sintoma de uma
/// placa que aceita ativar o modo monitor (o netlink não recusa) mas não
/// entrega quadro nenhum de verdade. Comum em placas Wi-Fi internas de
/// notebook; diferente de `error`, a sessão continua `running`.
/// Populated when capture has been running for >12s across channels with zero
/// received frames (typical of drivers/firmware that silently ignore monitor mode).
///
///
///
pub warning: Option<String>,
}
@@ -140,8 +140,8 @@ impl ScanTable {
Self::default()
}
/// Processa um quadro radiotap+802.11 crú. Retorna `true` se o quadro
/// continha EAPOL (mantido para chamadores antigos).
/// Ingests a raw Radiotap + 802.11 frame. Returns `true` if the frame carried
/// an EAPOL handshake message.
pub fn ingest(&mut self, raw: &[u8]) -> bool {
let now = unix_now();
self.ingest_at(raw, now).eapol
@@ -317,9 +317,9 @@ impl ScanTable {
};
if let (Some(client), Some(bssid)) = (client.clone(), bssid.clone()) {
if client != bssid {
// Calculado antes do touch_client: a TK vem de self.handshakes, e
// o borrow checker não deixa consultar isso com `c` (borrow de
// self.clients) vivo ao mesmo tempo.
// Look up any derived CCMP Temporal Key (TK) before borrowing `self.clients`.
//
//
let tk = (frame.protected && frame.to_ds && !frame.from_ds)
.then(|| self.handshakes.tk_for(&bssid, &client))
.flatten();
@@ -327,9 +327,9 @@ impl ScanTable {
c.packets += 1;
if frame.to_ds && !frame.from_ds {
if frame.protected {
// Só decifra se já quebramos a senha e vimos o handshake
// DESSE cliente — a PTK é por associação, então a senha da
// rede sozinha não abre tráfego de quem não foi capturado.
// Decrypt CCMP data frames when we have derived this station's association TK.
//
//
if let Some(plain) = tk.and_then(|tk| frame.ccmp_payload(&tk)) {
let payload = dot11::payload_info_from_llc(&plain);
apply_payload_identity(c, &payload);
@@ -339,9 +339,9 @@ impl ScanTable {
apply_payload_identity(c, &payload);
}
}
// Sempre recalcula o nome de exibição, mesmo em quadros AP→estação
// (downlink): sem isso, um cliente só visto recebendo dados nunca
// ganhava o fallback para o fabricante (OUI), ficando só com o MAC.
// Refresh `display_name` on both uplink and downlink frames so OUI vendor
// fallback is populated even for receive-only stations.
//
refresh_identity(c);
let was = c.associated;
c.bssid = Some(bssid.clone());
@@ -462,9 +462,9 @@ impl ScanTable {
}
}
}
// Já dá o fallback pro fabricante (OUI) desde o primeiro avistamento,
// antes de qualquer quadro de gerenciamento/dados chegar a chamar
// refresh_identity de novo.
// Populate `display_name` from the OUI vendor immediately on first sighting.
//
//
if c.display_name.is_none() {
refresh_identity(c);
}
@@ -507,7 +507,7 @@ impl ScanTable {
self.handshakes.get(bssid, station)
}
/// Handshakes completos (com quadros crus) para exportação.
/// Returns all tracked handshakes (including raw frames for `.pcap`/`.hc22000` export).
pub fn handshake_entries(&self) -> Vec<Handshake> {
self.handshakes.all().into_iter().cloned().collect()
}
+8 -8
View File
@@ -1,5 +1,5 @@
//! Helpers de teste: quadros 802.11/EAPOL sintéticos, radiotap e handshakes
//! válidos gerados a partir de uma senha conhecida. Só existe em `cfg(test)`.
//! Unit-test helpers: synthetic Radiotap/802.11/EAPOL frame builders and
//! valid 4-way handshake generators for a known passphrase (`#[cfg(test)]`).
use crate::crack::{compute_mic, compute_pmkid, compute_ptk, derive_pmk};
use crate::dot11;
@@ -46,11 +46,11 @@ pub fn frame_with_eapol(
f.extend_from_slice(&fc.to_le_bytes());
f.extend_from_slice(&0u16.to_le_bytes());
if from_ap {
f.extend_from_slice(&sta); // addr1 = destino
f.extend_from_slice(&sta); // addr1 = destination
f.extend_from_slice(&ap); // addr2 = BSSID
} else {
f.extend_from_slice(&ap); // addr1 = BSSID
f.extend_from_slice(&sta); // addr2 = origem
f.extend_from_slice(&sta); // addr2 = source
}
f.extend_from_slice(&da);
f.extend_from_slice(&[0u8; 2]);
@@ -148,7 +148,7 @@ pub fn data_frame(
f
}
/// Gera (msg1, msg2) válidas para a senha informada, com MIC correto.
/// Generates a valid `(msg1, msg2)` EAPOL-Key pair with a verified MIC for `passphrase`.
pub fn handshake_frames(
ssid: &str,
password: &str,
@@ -172,8 +172,8 @@ pub fn handshake_frames(
(m1, m2)
}
/// Monta um `Handshake` diretamente dos quadros EAPOL, para testes de quebra
/// sem depender do pipeline de captura.
/// Assembles a `Handshake` directly from synthetic EAPOL frames for cracking tests.
///
pub fn synthetic_handshake(
ssid: &str,
bssid: &str,
@@ -207,7 +207,7 @@ pub fn synthetic_handshake(
password: None,
tk: None,
};
// Exercita o parser de MAC do alvo.
// Exercises MAC address parsing.
assert!(dot11::parse_mac(&hs.bssid).is_some());
assert!(dot11::parse_mac(&hs.station).is_some());
hs
+28 -28
View File
@@ -1,9 +1,9 @@
//! Auto-atualização simples: verifica a última release no Gitea, baixa o
//! binário certo para a plataforma atual, confere o checksum e troca o
//! executável em uso — sem o usuário precisar baixar nada manualmente.
//! CLI self-updater: queries the latest Gitea Release, downloads the matching
//! platform archive, verifies its SHA-256 checksum, and atomically replaces the
//! running `sabia` binary.
//!
//! Usa `curl` (quase universal em Linux/macOS) em vez de trazer uma stack
//! HTTP/TLS inteira como dependência só para isso.
//! Uses the system `curl` and `tar` binaries to avoid pulling a full TLS stack
//! into `sabia-core`.
use crate::error::{Result, SabiaError};
use serde::Deserialize;
@@ -12,12 +12,12 @@ use std::fs;
use std::path::PathBuf;
use std::process::Command;
/// A API do Gitea devolve o mesmo formato de `releases/latest` que o GitHub devolvia
/// (`tag_name`, `assets[].name`, `assets[].browser_download_url`).
/// Gitea `releases/latest` API endpoint (`tag_name`, `assets[].name`, `assets[].browser_download_url`).
///
const LATEST_RELEASE_URL: &str =
"https://git.lucasrguerra.dev.br/api/v1/repos/lucasrguerra/sabia/releases/latest";
/// Página de downloads, para quem usa o app desktop.
/// Human-facing Gitea Releases page for desktop GUI users.
const RELEASES_PAGE_URL: &str = "https://git.lucasrguerra.dev.br/lucasrguerra/sabia/releases";
#[derive(Debug, Deserialize)]
@@ -75,9 +75,9 @@ fn curl_download(url: &str, dest: &PathBuf) -> Result<()> {
Ok(())
}
/// Sufixo que identifica o pacote certo entre os assets da release — o
/// nome completo inclui a tag (ex.: `sabia-v0.1.0-linux-x86_64.tar.gz`),
/// então casamos pelo fim em vez do nome inteiro.
/// Returns the expected release asset suffix for the current OS/architecture
/// (e.g. `"-linux-x86_64.tar.gz"`).
///
fn asset_suffix_for_platform() -> String {
let os = if cfg!(target_os = "linux") {
"linux"
@@ -95,15 +95,15 @@ fn asset_suffix_for_platform() -> String {
format!("-{os}-{arch}.{ext}")
}
/// Consulta a última release publicada e compara com a versão atual
/// (`CARGO_PKG_VERSION` do binário chamador).
/// Queries the latest published release and compares it against the running
/// `CARGO_PKG_VERSION`.
pub fn check(current_version: &str) -> Result<UpdateStatus> {
let url = LATEST_RELEASE_URL;
let body = match curl_text(url) {
Ok(b) => b,
Err(_) => {
// Sem rede, ou repositório ainda sem releases publicadas: não é
// um erro fatal, só significa "sem atualização disponível".
// Offline or no published releases yet: treat as no update available.
//
return Ok(UpdateStatus {
current_version: current_version.to_string(),
latest_version: None,
@@ -121,12 +121,12 @@ pub fn check(current_version: &str) -> Result<UpdateStatus> {
})
}
/// Baixa e aplica a atualização mais recente, substituindo o binário atual.
/// Retorna a versão instalada.
/// Downloads, verifies, and installs the latest CLI release in place.
/// Returns the newly installed version string.
pub fn apply(current_exe: &PathBuf) -> Result<String> {
// O pacote da release contém o CLI (`sabia`). Trocar por ele um executável que
// não seja o CLI, como o `sabia-gui`, o deixaria como um CLI e quebraria o app
// na próxima abertura. O app desktop se atualiza baixando o instalador.
// Guard against replacing `sabia-gui` with the CLI binary (`sabia`);
// desktop GUI updates use the Tauri updater / installer instead.
//
let is_cli = current_exe
.file_stem()
.map(|stem| stem == "sabia")
@@ -183,10 +183,10 @@ pub fn apply(current_exe: &PathBuf) -> Result<String> {
}
}
// Extrai o pacote (tar.gz no Linux/macOS, zip no Windows — `tar xf` sem
// flag de compressão detecta os dois formatos nos `tar` modernos,
// incluindo o bsdtar que o Windows já traz por padrão) e substitui o
// binário atual atomicamente.
// Extract the archive (`tar xf` auto-detects `.tar.gz` and `.zip` on modern
// libarchive/bsdtar) and atomically replace the current executable.
//
//
let extract_dir = tmp_dir.join("extracted");
let _ = fs::remove_dir_all(&extract_dir);
fs::create_dir_all(&extract_dir)?;
@@ -219,9 +219,9 @@ pub fn apply(current_exe: &PathBuf) -> Result<String> {
Ok(release.tag_name)
}
/// O binário vem dentro de uma subpasta (o CI empacota
/// `sabia-vX.Y.Z-<plataforma>/sabia`), então procura até um nível de
/// profundidade abaixo do diretório extraído.
/// Locates `name` at the archive root or one directory level below
/// (`sabia-vX.Y.Z-<platform>/sabia`).
///
fn find_binary(dir: &PathBuf) -> Result<PathBuf> {
fn is_binary(name: &std::ffi::OsStr) -> bool {
name == "sabia" || name == "sabia.exe"
@@ -252,7 +252,7 @@ mod tests {
#[test]
fn apply_refuses_to_overwrite_the_desktop_app() {
// Falha antes de qualquer acesso à rede: o nome do executável já não é o do CLI.
// Rejects non-CLI binary names before making any network requests.
let err = apply(&PathBuf::from("/opt/sabia/sabia-gui")).unwrap_err();
assert!(err.to_string().contains("só para o CLI"), "{err}");
let err = apply(&PathBuf::from("C:/Program Files/Sabia/sabia-gui.exe")).unwrap_err();
+14 -14
View File
@@ -1,10 +1,10 @@
//! Ataque de PIN WPS com o `reaver` embutido.
//! WPS PIN / Pixie Dust auditing via bundled `reaver` (or system `bully`).
//!
//! Extracts the bundled `reaver` binary at runtime (see
//! `crates/sabia-core/assets/tools/LICENSE.reaver`) or uses a system
//! `reaver`/`bully` installation when present.
//!
//!
//! O protocolo EAP-WSC é grande demais para reimplementar aqui; o Sabiá
//! carrega uma cópia do reaver (GPL-2, veja `crates/sabia-core/assets/tools/LICENSE.reaver`),
//! extrai em tempo de execução e mostra a saída ao vivo. Assim o WPS já
//! funciona sem instalar nada. Se o usuário tiver um `bully` no PATH, ele
//! também aparece como alternativa.
use serde::Serialize;
use std::path::PathBuf;
@@ -19,9 +19,9 @@ pub struct WpsTool {
#[cfg(target_os = "linux")]
const BUNDLED_REAVER: &[u8] = include_bytes!("../assets/tools/reaver");
/// Procura um binário no `PATH` — usado pelo WPS (reaver/bully) e, fora
/// deste módulo, pelo Evil Twin (hostapd/dnsmasq), que precisa da mesma
/// checagem sem puxar uma dependência nova só pra isso.
/// Searches `PATH` (plus standard sbin directories) for an executable `name`.
///
///
pub(crate) fn find_in_path(binary: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
@@ -72,8 +72,8 @@ fn bundled_reaver() -> Option<PathBuf> {
use std::fs;
use std::os::unix::fs::PermissionsExt;
// `temp_dir` é compartilhado; usa um diretório por UID e confere que ele
// não é um symlink antes de gravar, para não seguir link de outro usuário.
// Extract into a per-UID private directory and verify it is not a symlink.
//
let uid = unsafe { libc::geteuid() };
let dir = std::env::temp_dir().join(format!("sabia-tools-{uid}"));
if let Ok(meta) = fs::symlink_metadata(&dir) {
@@ -104,8 +104,8 @@ fn bundled_reaver() -> Option<PathBuf> {
None
}
/// Argumentos para uma execução. Sem PIN, usa Pixie Dust (`-K 1`) no reaver
/// e o ataque padrão no bully.
/// Builds CLI arguments for `reaver` or `bully` (defaults to Pixie Dust `-K 1` when `pin` is `None`).
///
pub fn args(tool: &str, iface: &str, bssid: &str, channel: u32, pin: Option<&str>) -> Vec<String> {
let channel = channel.to_string();
match tool {
@@ -151,7 +151,7 @@ pub fn args(tool: &str, iface: &str, bssid: &str, channel: u32, pin: Option<&str
}
}
/// Extrai PIN/PSK de uma linha de saída do reaver/bully.
/// Parses a recovered WPS PIN or WPA PSK from a `reaver`/`bully` output line.
pub fn parse_line(line: &str) -> (Option<String>, Option<String>) {
let lower = line.to_ascii_lowercase();
let grab = |needle: &str| -> Option<String> {
+7 -7
View File
@@ -1,6 +1,6 @@
//! Integração: trilha de auditoria encadeada por hash — grava entradas,
//! confirma que a cadeia valida, depois adultera um registro no disco e
//! confirma que a verificação detecta a falha.
//! Integration test: hash-chained audit trail — appends entries, verifies
//! chain integrity, then tampers with a middle record on disk and confirms
//! verification pinpoints the corrupted entry.
mod common;
@@ -26,7 +26,7 @@ fn chain_validates_after_recording_entries() {
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].action, "monitor-on");
assert_eq!(entries[2].action, "deauth");
// cadeia encadeada corretamente
// each entry's `prev_hash` matches the preceding entry's `hash`
assert_eq!(entries[1].prev_hash, entries[0].hash);
assert_eq!(entries[2].prev_hash, entries[1].hash);
@@ -52,11 +52,11 @@ fn tampering_with_an_entry_breaks_verification() {
.expect("record 3");
}
// Confirma que, intacta, a cadeia passa.
// Intact chain verifies cleanly.
assert_eq!(AuditLog::verify(&path).expect("verify ok"), None);
// Adultera o "detail" da segunda entrada sem recalcular o hash,
// simulando uma edição retroativa do log.
// Tamper with the second entry's `detail` field without recomputing its hash.
//
let contents = std::fs::read_to_string(&path).expect("read");
let mut lines: Vec<String> = contents.lines().map(|l| l.to_string()).collect();
assert_eq!(lines.len(), 3);
+13 -13
View File
@@ -1,10 +1,10 @@
#![allow(dead_code)]
//! Helpers de teste de integração: quadros 802.11/EAPOL sintéticos,
//! radiotap e handshakes válidos gerados a partir de uma senha conhecida.
//! Integration-test helpers: synthetic Radiotap/802.11/EAPOL frames and
//! valid 4-way handshakes generated from a known passphrase.
//!
//!
//!
//!
//! Porta a lógica de `crates/sabia-core/src/testutil.rs` (que só existe em
//! `#[cfg(test)]` e por isso não fica visível aqui), usando só a API
//! pública de `sabia_core`.
use sabia_core::crack::{compute_mic, compute_pmkid, compute_ptk, derive_pmk};
use sabia_core::dot11;
@@ -58,11 +58,11 @@ pub fn frame_with_eapol(
f.extend_from_slice(&fc.to_le_bytes());
f.extend_from_slice(&0u16.to_le_bytes());
if from_ap {
f.extend_from_slice(&sta); // addr1 = destino
f.extend_from_slice(&sta); // addr1 = destination
f.extend_from_slice(&ap); // addr2 = BSSID
} else {
f.extend_from_slice(&ap); // addr1 = BSSID
f.extend_from_slice(&sta); // addr2 = origem
f.extend_from_slice(&sta); // addr2 = source
}
f.extend_from_slice(&da);
f.extend_from_slice(&[0u8; 2]);
@@ -160,7 +160,7 @@ pub fn data_frame(
f
}
/// Gera (msg1, msg2) válidas para a senha informada, com MIC correto.
/// Generates a valid `(msg1, msg2)` EAPOL-Key pair with a verified MIC for `passphrase`.
pub fn handshake_frames(
ssid: &str,
password: &str,
@@ -184,8 +184,8 @@ pub fn handshake_frames(
(m1, m2)
}
/// Monta quadros radiotap+802.11 completos (prontos para `ScanTable::ingest`)
/// com o 4-way handshake entre `AP` e `STA` para o SSID/senha dados.
/// Builds complete Radiotap + 802.11 frames (ready for `ScanTable::ingest`)
/// carrying the 4-way handshake between `AP` and `STA` for `ssid`/`passphrase`.
pub fn handshake_radiotap_frames(
ssid: &str,
password: &str,
@@ -198,8 +198,8 @@ pub fn handshake_radiotap_frames(
(radiotap_wrap(&f1, 2437, -40), radiotap_wrap(&f2, 2437, -42))
}
/// Monta um `Handshake` diretamente dos quadros EAPOL, para testes de quebra
/// sem depender do pipeline de captura.
/// Assembles a `Handshake` directly from synthetic EAPOL frames for cracking tests.
///
pub fn synthetic_handshake(
ssid: &str,
bssid: &str,
@@ -233,7 +233,7 @@ pub fn synthetic_handshake(
password: None,
tk: None,
};
// Exercita o parser de MAC do alvo.
// Exercises MAC address parsing.
assert!(dot11::parse_mac(&hs.bssid).is_some());
assert!(dot11::parse_mac(&hs.station).is_some());
hs
+8 -8
View File
@@ -1,6 +1,6 @@
//! Integração: handshake sintético (gerado com senha conhecida) -> montagem
//! de `CrackTarget` -> `verify()`, para os dois métodos (EAPOL 4-way e
//! PMKID), cobrindo senha certa e errada.
//! Integration test: synthetic handshake -> `CrackTarget` -> `verify()` and
//! dictionary cracking across both EAPOL 4-way and PMKID modes.
//!
mod common;
@@ -45,7 +45,7 @@ fn tkip_keyversion_roundtrip_verifies_password() {
#[test]
fn incomplete_handshake_without_pmkid_or_msg2_has_no_target() {
let (m1, _m2) = common::handshake_frames("MinhaRede", "senha-correta", 2, false);
// Sem msg2 e sem PMKID: não há prova suficiente para quebrar.
// Without message 2 or a PMKID, the handshake is not yet crackable.
let hs = common::synthetic_handshake("MinhaRede", BSSID, STATION, &m1, &[]);
assert!(CrackTarget::from_handshake(&hs).is_none());
}
@@ -60,17 +60,17 @@ fn derive_pmk_compute_ptk_and_mic_are_consistent() {
let pmk = derive_pmk(password, ssid);
let ptk = compute_ptk(&pmk, &common::ap(), &common::sta(), &anonce, &snonce);
// MIC calculado sobre um quadro msg2 qualquer, zerando o campo MIC antes.
// Compute the MIC over a message 2 frame with its MIC field zeroed.
let mut m2 = common::key_frame(2, 2, snonce, &[]);
let mic = compute_mic(2, &ptk[..16], &m2, sabia_core::eapol::MIC_OFFSET).expect("mic");
m2[sabia_core::eapol::MIC_OFFSET..sabia_core::eapol::MIC_OFFSET + 16].copy_from_slice(&mic);
// Recalculando o MIC sobre o quadro já preenchido deve bater de novo
// (compute_mic zera o campo internamente antes de recalcular).
// Recomputing the MIC over the already-signed frame must yield the same digest
// (`compute_mic` zeroes the MIC slice before hashing).
let mic_again = compute_mic(2, &ptk[..16], &m2, sabia_core::eapol::MIC_OFFSET).expect("mic");
assert_eq!(mic, mic_again);
// PMKID direto também deve ser determinístico e usável no verify().
// Direct PMKID computation is deterministic and verifies via `CrackTarget::Pmkid`.
let pmkid = compute_pmkid(&pmk, &common::ap(), &common::sta());
assert_eq!(
pmkid,
+3 -3
View File
@@ -1,6 +1,6 @@
//! Integração: grava quadros com `PcapWriter`, lê de volta com
//! `PcapReader` e confere que batem byte a byte; depois confirma que
//! `Engine::analyze_pcap` encontra redes e handshakes no arquivo recém-escrito.
//! Integration test: writes frames with `PcapWriter`, reads them back with
//! `PcapReader`, and verifies `Engine::analyze_pcap` reconstructs the AP and handshake.
//!
mod common;
+23 -23
View File
@@ -1,6 +1,6 @@
//! Integração: beacon + probe + handshake completo passando pelo caminho
//! público de agregação (`ScanTable`), verificando que AP, cliente e
//! handshake são detectados de ponta a ponta.
//! End-to-end integration test: ingests beacon, probe, and 4-way handshake
//! frames into `ScanTable` and verifies AP, client, handshake, and live CCMP
//! data frame decryption.
mod common;
@@ -10,7 +10,7 @@ use sabia_core::scan::ScanTable;
fn detects_ap_client_and_handshake_end_to_end() {
let mut table = ScanTable::new();
// RSN com AKM PSK (2) para que a rede apareça como WPA2-PSK.
// Include an RSN IE with PSK AKM (2) so the AP is classified as WPA2-PSK.
let beacon = common::beacon_frame(common::ap(), "RedeTeste", 6, &[2]);
table.ingest(&common::radiotap_wrap(&beacon, 2437, -35));
@@ -58,7 +58,7 @@ fn detects_ap_client_and_handshake_end_to_end() {
fn hidden_ssid_beacon_is_marked_hidden() {
let mut table = ScanTable::new();
// SSID vazio = rede oculta.
// Empty 0-length SSID IE = hidden network.
let beacon = common::beacon_frame(common::ap(), "", 11, &[2]);
table.ingest(&common::radiotap_wrap(&beacon, 2462, -60));
@@ -67,11 +67,11 @@ fn hidden_ssid_beacon_is_marked_hidden() {
assert!(aps[0].hidden);
}
/// Depois que a senha de uma rede WPA2/CCMP é conhecida (quebrada), o
/// tráfego de dados do cliente cujo handshake foi capturado passa a ser
/// decifrado em tempo real pelo pipeline de ingestão — e a identidade
/// (aqui, o IP de origem, que exercita o parser de IPv4 por trás do LLC)
/// aparece mesmo com o bit "Protected" ligado.
/// Once a WPA2/CCMP handshake has been cracked, subsequent protected QoS Data
/// frames from that station are decrypted on the fly using its derived TK to
/// populate client identity fields (such as its IPv4 address).
///
///
#[test]
fn decrypts_ccmp_traffic_once_password_is_known_and_fills_client_identity() {
use sabia_core::crack::{compute_ptk, derive_pmk};
@@ -91,8 +91,8 @@ fn decrypts_ccmp_traffic_once_password_is_known_and_fills_client_identity() {
let station_str = sabia_core::dot11::mac_to_string(&common::sta());
table.set_password(&bssid_str, &station_str, password);
// A TK derivada pelo pipeline tem que bater com a calculada à parte, com
// os mesmos nonces fixos (0xaa/0xbb) que handshake_radiotap_frames usa.
// The pipeline-derived TK must match the TK computed directly from the same nonces.
//
let anonce = [0xaa; 32];
let snonce = [0xbb; 32];
let pmk = derive_pmk(password, ssid);
@@ -107,16 +107,16 @@ fn decrypts_ccmp_traffic_once_password_is_known_and_fills_client_identity() {
"TK deveria ser derivada ao quebrar a senha"
);
// Monta um quadro QoS Data, STA->AP, protegido, com CCMP header (PN=1,
// ExtIV ligado) e cifra um payload LLC/SNAP+IPv4 "na mão" — o CTR do
// CCMP é simétrico, então usar decrypt_data pra cifrar é válido aqui.
// Build a protected STA->AP QoS Data frame (CCMP PN=1, ExtIV=1) carrying an
// encrypted LLC/SNAP + IPv4 packet.
//
let llc_ipv4 = {
let mut v = vec![0xAA, 0xAA, 0x03, 0x00, 0x00, 0x00, 0x08, 0x00];
let mut ip = vec![
0x45, 0x00, 0x00, 20, 0x00, 0x00, 0x00, 0x00, 64, 17, 0x00, 0x00,
];
ip.extend_from_slice(&[192, 168, 1, 50]); // origem privada
ip.extend_from_slice(&[192, 168, 1, 1]); // destino
ip.extend_from_slice(&[192, 168, 1, 50]); // private source IPv4
ip.extend_from_slice(&[192, 168, 1, 1]); // destination IPv4
v.extend_from_slice(&ip);
v
};
@@ -124,22 +124,22 @@ fn decrypts_ccmp_traffic_once_password_is_known_and_fills_client_identity() {
let mut to_cipher = Vec::new();
to_cipher.extend_from_slice(&ccmp_header);
to_cipher.extend_from_slice(&llc_ipv4);
to_cipher.extend_from_slice(&[0u8; 8]); // placeholder pro MIC, ignorado
to_cipher.extend_from_slice(&[0u8; 8]); // 8-byte MIC placeholder
let ciphertext = sabia_core::ccmp::decrypt_data(&expected_tk, &common::sta(), 0, &to_cipher)
.expect("cifra via CTR simétrico");
let mut body = Vec::new();
body.extend_from_slice(&ccmp_header);
body.extend_from_slice(&ciphertext);
body.extend_from_slice(&[0u8; 8]); // MIC cifrado — não conferido na decriptação
body.extend_from_slice(&[0u8; 8]); // trailing 8-byte MIC
let mut frame = Vec::new();
let fc: u16 = 0x0088 | 0x0100 | 0x4000; // QoS Data, to_ds, protected
frame.extend_from_slice(&fc.to_le_bytes());
frame.extend_from_slice(&0u16.to_le_bytes()); // duration
frame.extend_from_slice(&common::ap()); // addr1 = BSSID
frame.extend_from_slice(&common::sta()); // addr2 = transmissor (estação)
frame.extend_from_slice(&common::ap()); // addr3 = destino (irrelevante aqui)
frame.extend_from_slice(&common::sta()); // addr2 = transmitter (station)
frame.extend_from_slice(&common::ap()); // addr3 = destination
frame.extend_from_slice(&[0u8; 2]); // sequence control
frame.extend_from_slice(&[0u8; 2]); // QoS control, TID 0
frame.extend_from_slice(&body);
@@ -163,11 +163,11 @@ fn decrypts_ccmp_traffic_once_password_is_known_and_fills_client_identity() {
fn pmkid_only_handshake_is_detected_and_crackable() {
let mut table = ScanTable::new();
// Beacon com SSID visível, para que o handshake herde o SSID via hint.
// Beacon with visible SSID so the PMKID handshake inherits the SSID hint.
let beacon = common::beacon_frame(common::ap(), "RedePmkid", 11, &[2]);
table.ingest_at(&common::radiotap_wrap(&beacon, 2462, -60), 1);
// Handshake via PMKID (só msg1, sem msg2/snonce).
// PMKID-only handshake (message 1 with PMKID KDE, no message 2).
let (m1, _m2) = common::handshake_frames("RedePmkid", "outra-senha", 2, true);
let f1 = common::frame_with_eapol(true, common::ap(), common::sta(), common::sta(), &m1);
let ev = table.ingest_at(&common::radiotap_wrap(&f1, 2462, -55), 20);