v0.1.0: logo, i18n pt/en, CI com caches, testes e documentação

- Logo oficial (sabia.png) e conjunto de ícones do app desktop.
- Interface em português e inglês: tradutor por observação do DOM, seletor
  de idioma e checagem de cobertura de tradução no CI.
- CI/CD com caches (rust-cache, .deb do Tauri em cache) e pipeline de release
  multiplataforma (CLI em 5 alvos + app desktop via tauri-action), templates
  de issue/PR.
- Suíte de testes de integração do sabia-core (scan e2e, handshake/crack
  4-way e PMKID, pcap, trilha de auditoria) — 46 testes verdes.
- Documentação completa pt/en: README, ROADMAP, SECURITY, CONTRIBUTING,
  CHANGELOG e docs/ (cli, interface, arquitetura), com enquadramento de uso
  autorizado.
- Correções de clippy (-D warnings) e formatação em toda a árvore.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5.5 committed 2026-10-23 12:46:19 -03:00
1 parent df27974359
commit 791538c9d9
112 files changed
+15620 -494

No files matched your search

+6
View File
@@ -15,6 +15,12 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
time = { version = "0.3", features = ["formatting", "macros"] }
sha2 = "0.10"
sha1 = "0.10"
hmac = "0.12"
md-5 = "0.10"
[dev-dependencies]
tempfile = "3"
[target.'cfg(not(target_os = "linux"))'.dependencies]
# captura crua (AF_PACKET) só existe no Linux; outras plataformas compilam
+20
View File
@@ -85,6 +85,26 @@ impl AuditLog {
Ok(entry)
}
/// Lê as entradas da trilha, opcionalmente só as `limit` mais recentes.
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);
let mut entries = Vec::new();
for line in reader.lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
entries.push(serde_json::from_str::<AuditEntry>(&line)?);
}
if let Some(limit) = limit {
if entries.len() > limit {
entries = entries.split_off(entries.len() - limit);
}
}
Ok(entries)
}
/// Verifica a integridade de toda a cadeia; retorna o índice da
/// primeira entrada inválida, se houver.
pub fn verify(path: impl AsRef<Path>) -> Result<Option<usize>> {
+434
View File
@@ -0,0 +1,434 @@
//! Quebra de WPA/WPA2-PSK por dicionário, em Rust puro.
//!
//! Duas provas são aceitas, como no aircrack-ng/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.
//!
//! É 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.
use crate::dot11::parse_mac;
use crate::error::{Result, SabiaError};
use crate::handshake::Handshake;
use crate::pcapreader::PcapReader;
use crate::scan::ScanTable;
use hmac::{Hmac, Mac};
use md5::Md5;
use sha1::Sha1;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
use std::time::Instant;
type HmacSha1 = Hmac<Sha1>;
type HmacMd5 = Hmac<Md5>;
const WPA_ITERATIONS: u32 = 4096;
#[derive(Debug, Clone)]
pub struct CrackTarget {
pub ssid: String,
pub bssid: [u8; 6],
pub station: [u8; 6],
pub anonce: [u8; 32],
pub snonce: [u8; 32],
pub eapol_msg2: Option<Vec<u8>>,
pub mic_offset: usize,
pub key_version: u8,
pub pmkid: Option<[u8; 16]>,
}
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct CrackReport {
pub password: Option<String>,
pub tried: u64,
pub elapsed_ms: u64,
pub ssid: Option<String>,
pub bssid: Option<String>,
pub station: Option<String>,
pub method: Option<String>,
}
impl CrackTarget {
pub fn from_handshake(hs: &Handshake) -> Option<Self> {
let ssid = hs.ssid.clone().filter(|s| !s.is_empty())?;
let bssid = parse_mac(&hs.bssid)?;
let station = parse_mac(&hs.station)?;
let anonce = hs.anonce?;
let snonce = hs.snonce.unwrap_or([0u8; 32]);
if hs.pmkid.is_none() && (hs.eapol_msg2.is_none() || hs.snonce.is_none()) {
return None;
}
Some(Self {
ssid,
bssid,
station,
anonce,
snonce,
eapol_msg2: hs.eapol_msg2.clone(),
mic_offset: crate::eapol::MIC_OFFSET,
key_version: hs.key_version,
pmkid: hs.pmkid,
})
}
pub fn method(&self) -> &'static str {
if self.pmkid.is_some() {
"PMKID"
} else if self.key_version == 1 {
"EAPOL/TKIP"
} else {
"EAPOL/CCMP"
}
}
pub fn verify(&self, password: &str) -> bool {
let Some(pmk) = pmk_from_candidate(password, self.ssid.as_bytes()) else {
return false;
};
if let Some(expected) = self.pmkid {
return compute_pmkid(&pmk, &self.bssid, &self.station) == expected;
}
let Some(eapol) = self.eapol_msg2.as_deref() else {
return false;
};
let ptk = compute_ptk(&pmk, &self.bssid, &self.station, &self.anonce, &self.snonce);
let Some(mic) = compute_mic(self.key_version, &ptk[0..16], eapol, self.mic_offset) else {
return false;
};
let Some(expected) = eapol.get(self.mic_offset..self.mic_offset + 16) else {
return false;
};
constant_time_eq(&mic, expected)
}
}
/// Deriva a PMK (256 bits) de uma frase-senha WPA e do SSID.
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];
pmk.copy_from_slice(&dk[..32]);
pmk
}
/// Aceita uma frase-senha (8–63 caracteres) ou uma PMK crua em hexadecimal
/// (64 caracteres, como no hashcat/aircrack).
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());
}
if !(8..=63).contains(&candidate.len()) {
return None;
}
let dk = pbkdf2_sha1(candidate.as_bytes(), ssid, WPA_ITERATIONS, 32);
let mut pmk = [0u8; 32];
pmk.copy_from_slice(&dk[..32]);
Some(pmk)
}
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);
let mut block: u32 = 1;
while out.len() < out_len {
let mut mac = HmacSha1::new_from_slice(password).expect("HMAC aceita qualquer chave");
mac.update(salt);
mac.update(&block.to_be_bytes());
let mut u = mac.finalize().into_bytes();
let mut t = u;
for _ in 1..iterations {
let mut mac = HmacSha1::new_from_slice(password).expect("HMAC aceita qualquer chave");
mac.update(&u);
u = mac.finalize().into_bytes();
for (a, b) in t.iter_mut().zip(u.iter()) {
*a ^= *b;
}
}
out.extend_from_slice(&t);
block = block.wrapping_add(1);
}
out.truncate(out_len);
out
}
fn prf_sha1(key: &[u8], label: &[u8], data: &[u8], out_len: usize) -> Vec<u8> {
let mut out = Vec::with_capacity(out_len);
let mut counter: u8 = 0;
while out.len() < out_len {
let mut mac = HmacSha1::new_from_slice(key).expect("HMAC aceita qualquer chave");
mac.update(label);
mac.update(&[0u8]);
mac.update(data);
mac.update(&[counter]);
out.extend_from_slice(&mac.finalize().into_bytes());
counter = counter.wrapping_add(1);
}
out.truncate(out_len);
out
}
/// PTK de 64 bytes: KCK (16) + KEK (16) + TK (16) + 16 de reserva.
pub fn compute_ptk(
pmk: &[u8; 32],
bssid: &[u8; 6],
station: &[u8; 6],
anonce: &[u8; 32],
snonce: &[u8; 32],
) -> [u8; 64] {
let (ap, sta) = if bssid <= station {
(bssid, station)
} else {
(station, bssid)
};
let (nonce_a, nonce_b) = if anonce <= snonce {
(anonce, snonce)
} else {
(snonce, anonce)
};
let mut data = Vec::with_capacity(76);
data.extend_from_slice(ap);
data.extend_from_slice(sta);
data.extend_from_slice(nonce_a);
data.extend_from_slice(nonce_b);
let ptk = prf_sha1(pmk, b"Pairwise key expansion", &data, 64);
let mut out = [0u8; 64];
out.copy_from_slice(&ptk);
out
}
pub fn compute_pmkid(pmk: &[u8; 32], bssid: &[u8; 6], station: &[u8; 6]) -> [u8; 16] {
let mut mac = HmacSha1::new_from_slice(pmk).expect("HMAC aceita qualquer chave");
mac.update(b"PMK Name");
mac.update(bssid);
mac.update(station);
let digest = mac.finalize().into_bytes();
let mut out = [0u8; 16];
out.copy_from_slice(&digest[..16]);
out
}
pub fn compute_mic(
key_version: u8,
kck: &[u8],
eapol: &[u8],
mic_offset: usize,
) -> Option<[u8; 16]> {
if mic_offset + 16 > eapol.len() {
return None;
}
let mut with_zero_mic = eapol.to_vec();
with_zero_mic[mic_offset..mic_offset + 16].fill(0);
match key_version {
1 => {
let mut mac = HmacMd5::new_from_slice(kck).ok()?;
mac.update(&with_zero_mic);
let digest = mac.finalize().into_bytes();
let mut out = [0u8; 16];
out.copy_from_slice(&digest);
Some(out)
}
2 => {
let mut mac = HmacSha1::new_from_slice(kck).ok()?;
mac.update(&with_zero_mic);
let digest = mac.finalize().into_bytes();
let mut out = [0u8; 16];
out.copy_from_slice(&digest[..16]);
Some(out)
}
_ => None,
}
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
fn hex_to_32(hex: &[u8]) -> Option<[u8; 32]> {
if hex.len() != 64 {
return None;
}
let mut out = [0u8; 32];
for (i, byte) in out.iter_mut().enumerate() {
let hi = (hex[i * 2] as char).to_digit(16)?;
let lo = (hex[i * 2 + 1] as char).to_digit(16)?;
*byte = ((hi << 4) | lo) as u8;
}
Some(out)
}
fn valid_candidate(candidate: &str) -> bool {
(8..=63).contains(&candidate.len())
|| (candidate.len() == 64 && candidate.bytes().all(|b| b.is_ascii_hexdigit()))
}
/// Tenta cada linha do dicionário contra o alvo. `progress` recebe o número
/// de candidatos já processados.
pub fn crack_reader<R: BufRead, F: FnMut(u64)>(
target: &CrackTarget,
mut wordlist: R,
mut progress: F,
) -> CrackReport {
let start = Instant::now();
let mut tried = 0u64;
let mut password = None;
let mut line = String::new();
loop {
line.clear();
match wordlist.read_line(&mut line) {
Ok(0) => break,
Ok(_) => {}
Err(_) => break,
}
let candidate = line.trim_end_matches(['\r', '\n']);
if candidate.is_empty() {
continue;
}
tried += 1;
if valid_candidate(candidate) && target.verify(candidate) {
password = Some(candidate.to_string());
break;
}
if tried % 128 == 0 {
progress(tried);
}
}
progress(tried);
CrackReport {
password,
tried,
elapsed_ms: start.elapsed().as_millis() as u64,
ssid: Some(target.ssid.clone()),
bssid: Some(crate::dot11::mac_to_string(&target.bssid)),
station: Some(crate::dot11::mac_to_string(&target.station)),
method: Some(target.method().to_string()),
}
}
pub fn crack_handshake<F: FnMut(u64)>(
hs: &Handshake,
wordlist: impl AsRef<Path>,
progress: F,
) -> Result<CrackReport> {
let target = CrackTarget::from_handshake(hs).ok_or_else(|| {
SabiaError::InvalidInput(
"handshake sem SSID, PMKID ou par de nonces suficiente para quebrar".into(),
)
})?;
let file = File::open(wordlist)?;
Ok(crack_reader(&target, BufReader::new(file), progress))
}
/// Lê um `.pcap`, monta os handshakes e tenta quebrar o primeiro alvo útil.
pub fn crack_pcap<F: FnMut(u64)>(
pcap: impl AsRef<Path>,
wordlist: impl AsRef<Path>,
bssid: Option<&str>,
station: Option<&str>,
progress: F,
) -> Result<CrackReport> {
let mut reader = PcapReader::open(pcap)?;
if !reader.supports_80211() {
return Err(SabiaError::InvalidPcap(
"link-type não é 802.11 nem radiotap".into(),
));
}
let mut table = ScanTable::new();
while let Some(frame) = reader.next_frame()? {
table.ingest(&frame.data);
}
let hs = table
.first_crackable(bssid, station)
.ok_or_else(|| SabiaError::InvalidInput("nenhum handshake quebrável na captura".into()))?;
crack_handshake(hs, wordlist, progress)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::{handshake_frames, synthetic_handshake};
use std::io::Cursor;
#[test]
fn pbkdf2_matches_rfc6070_vector() {
let out = pbkdf2_sha1(b"password", b"salt", 1, 20);
assert_eq!(out, hex_bytes("0c60c80f961f0e71f3a9b524af6012062fe037a6"));
let out = pbkdf2_sha1(b"password", b"salt", 2, 20);
assert_eq!(out, hex_bytes("ea6c014dc72d6f8ccd1ed92ace1d41f0d8de8957"));
}
#[test]
fn derives_known_wpa_pmk() {
let pmk = derive_pmk("password", "IEEE");
assert_eq!(
pmk,
hex_32("f42c6fc52df0ebef9ebb4b90b38a5f902e83fe1b135a70e23aed762e9710a12e")
);
}
#[test]
fn cracks_eapol_handshake_with_dictionary() {
let (m1, m2) = handshake_frames("IEEE", "password", 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");
let mut progress_calls = 0;
let report = crack_reader(
&target,
Cursor::new(b"errada\noutra\npassword\nnunca\n".to_vec()),
|_| progress_calls += 1,
);
assert_eq!(report.password.as_deref(), Some("password"));
assert_eq!(report.tried, 3);
assert_eq!(report.method.as_deref(), Some("EAPOL/CCMP"));
assert!(progress_calls >= 1);
}
#[test]
fn cracks_pmkid_without_full_handshake() {
let (m1, _) = handshake_frames("IEEE", "password", 2, true);
let hs = synthetic_handshake("IEEE", "00:11:22:33:44:55", "66:77:88:99:aa:bb", &m1, &[]);
let target = CrackTarget::from_handshake(&hs).expect("alvo");
assert_eq!(target.method(), "PMKID");
let report = crack_reader(&target, Cursor::new(b"nada\npassword\n".to_vec()), |_| {});
assert_eq!(report.password.as_deref(), Some("password"));
}
#[test]
fn accepts_raw_pmk_hex() {
let pmk = derive_pmk("password", "IEEE");
let hex: String = pmk.iter().map(|b| format!("{b:02x}")).collect();
assert_eq!(pmk_from_candidate(&hex, b"IEEE"), Some(pmk));
assert!(pmk_from_candidate("curta", b"IEEE").is_none());
}
#[test]
fn rejects_wrong_password() {
let (m1, m2) = handshake_frames("IEEE", "senha-correta", 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");
let report = crack_reader(&target, Cursor::new(b"senha-errada\n".to_vec()), |_| {});
assert!(report.password.is_none());
}
fn hex_bytes(s: &str) -> Vec<u8> {
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
.collect()
}
fn hex_32(s: &str) -> [u8; 32] {
hex_bytes(s).try_into().unwrap()
}
}
+135
View File
@@ -0,0 +1,135 @@
//! Diagnóstico do ambiente: privilégios, ferramentas externas, interfaces,
//! suporte a modo monitor e integridade da trilha de auditoria.
use crate::audit::AuditLog;
use crate::iface::{self, Interface};
use crate::netlink::{self, command_exists};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, serde::Serialize)]
pub struct IfaceDiagnosis {
pub name: String,
pub mac: String,
pub mode: String,
pub monitor_supported: bool,
pub driver: Option<String>,
pub up: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct DoctorReport {
pub version: String,
pub platform: String,
pub root: bool,
pub iw: bool,
pub ip: bool,
pub curl: bool,
pub capture_backend: String,
pub audit_path: String,
pub audit_ok: bool,
pub interfaces: Vec<IfaceDiagnosis>,
pub notes: Vec<String>,
}
pub fn diagnose(version: &str, audit_path: impl AsRef<Path>) -> DoctorReport {
let audit_path = audit_path.as_ref().to_path_buf();
let root = netlink::is_root();
let iw = command_exists("iw");
let ip = command_exists("ip");
let curl = command_exists("curl");
let (audit_ok, mut notes) = audit_status(&audit_path);
let all: Vec<Interface> = iface::list_all().unwrap_or_default();
let interfaces = all
.into_iter()
.filter(|i| i.phy.is_some())
.map(|i| IfaceDiagnosis {
monitor_supported: netlink::monitor_supported(&i.name),
driver: netlink::driver_name(&i.name),
name: i.name,
mac: i.mac,
mode: format!("{:?}", i.mode).to_lowercase(),
up: i.up,
})
.collect::<Vec<_>>();
if !root {
notes.push(
"sem root: modo monitor, captura e injeção vão falhar (use sudo ou o app desktop)"
.into(),
);
}
if !iw {
notes.push("`iw` não encontrado no PATH — instale iw/iproute2".into());
}
if !ip {
notes.push("`ip` não encontrado no PATH — instale iproute2".into());
}
if interfaces.is_empty() {
notes.push("nenhuma interface Wi-Fi encontrada".into());
} else if !interfaces.iter().any(|i| i.monitor_supported) {
notes.push("nenhuma interface anuncia suporte a modo monitor".into());
}
if !curl {
notes.push("`curl` não encontrado — a auto-atualização não funciona".into());
}
if !audit_ok {
notes.push("a trilha de auditoria existente está corrompida".into());
}
DoctorReport {
version: version.to_string(),
platform: format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH),
root,
iw,
ip,
curl,
capture_backend: if cfg!(target_os = "linux") {
"AF_PACKET (Linux)".into()
} else {
"indisponível nesta plataforma".into()
},
audit_path: audit_path.display().to_string(),
audit_ok,
interfaces,
notes,
}
}
fn audit_status(path: &PathBuf) -> (bool, Vec<String>) {
let mut notes = Vec::new();
if !path.exists() {
notes.push(format!(
"trilha de auditoria ainda não criada em {}",
path.display()
));
return (true, notes);
}
match AuditLog::verify(path) {
Ok(None) => (true, notes),
Ok(Some(idx)) => {
notes.push(format!("cadeia de auditoria rompida na entrada {idx}"));
(false, notes)
}
Err(e) => {
notes.push(format!("não foi possível ler a trilha de auditoria: {e}"));
(false, notes)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn reports_environment_without_panicking() {
let dir = TempDir::new().unwrap();
let report = diagnose("9.9.9", dir.path().join("nao-existe.log"));
assert_eq!(report.version, "9.9.9");
assert!(report.audit_ok);
assert!(report.platform.contains('-'));
assert!(!report.capture_backend.is_empty());
}
}
+549 -70
View File
@@ -1,5 +1,7 @@
//! Parser mínimo de quadros 802.11 de gerenciamento (beacon, probe
//! request/response) e detecção de EAPOL (handshake WPA/WPA2).
//! 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.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FrameKind {
@@ -7,26 +9,114 @@ pub enum FrameKind {
ProbeRequest,
ProbeResponse,
AssociationRequest,
AssociationResponse,
ReassociationRequest,
Authentication,
Deauth,
Disassoc,
Action,
Data,
Management,
Control,
Other,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
impl FrameKind {
pub fn is_beacon_like(self) -> bool {
matches!(self, Self::Beacon | Self::ProbeResponse)
}
pub fn is_data(self) -> bool {
matches!(self, Self::Data)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum Security {
#[serde(rename = "Open")]
Open,
#[serde(rename = "WEP")]
Wep,
#[serde(rename = "WPA")]
Wpa,
#[serde(rename = "WPA2")]
Wpa2,
#[serde(rename = "WPA2-EAP")]
Wpa2Enterprise,
#[serde(rename = "WPA3")]
Wpa3,
#[serde(rename = "WPA3-EAP")]
Wpa3Enterprise,
#[serde(rename = "OWE")]
Owe,
#[serde(rename = "WPA2/WPA3")]
Wpa2Wpa3,
}
impl Security {
pub fn code(self) -> &'static str {
match self {
Self::Open => "open",
Self::Wep => "wep",
Self::Wpa => "wpa",
Self::Wpa2 => "wpa2",
Self::Wpa2Enterprise => "wpa2-eap",
Self::Wpa3 => "wpa3",
Self::Wpa3Enterprise => "wpa3-eap",
Self::Owe => "owe",
Self::Wpa2Wpa3 => "wpa2-wpa3",
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ManagementInfo {
pub ssid: Option<String>,
pub hidden: bool,
pub channel: Option<u32>,
pub security: Option<Security>,
pub channel_ie: Option<u32>,
pub cipher: Option<String>,
pub akm: Vec<String>,
pub wps: bool,
pub pmf: bool,
pub country: Option<String>,
}
#[derive(Debug, Clone, Copy, Default)]
struct RsnInfo {
cipher: Option<Cipher>,
akm: AkmSet,
caps: u16,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Cipher {
Wep40,
Wep104,
Tkip,
Ccmp,
Gcmp,
Other,
}
impl Cipher {
fn label(self) -> &'static str {
match self {
Self::Wep40 | Self::Wep104 => "WEP",
Self::Tkip => "TKIP",
Self::Ccmp => "CCMP",
Self::Gcmp => "GCMP",
Self::Other => "?",
}
}
}
#[derive(Debug, Clone, Copy, Default)]
struct AkmSet {
psk: bool,
enterprise: bool,
sae: bool,
owe: bool,
}
pub fn mac_to_string(mac: &[u8]) -> String {
@@ -36,15 +126,42 @@ pub fn mac_to_string(mac: &[u8]) -> String {
)
}
pub fn parse_mac(s: &str) -> Option<[u8; 6]> {
let mut out = [0u8; 6];
let mut parts = s.split([':', '-']);
for byte in &mut out {
let part = parts.next()?;
if part.len() != 2 {
return None;
}
*byte = u8::from_str_radix(part, 16).ok()?;
}
if parts.next().is_some() {
return None;
}
Some(out)
}
pub fn is_broadcast(mac: &[u8; 6]) -> bool {
*mac == [0xff; 6]
}
/// Quadro 802.11 já sem o cabeçalho radiotap.
pub struct Frame<'a> {
pub kind: FrameKind,
pub fc: u16,
pub to_ds: bool,
pub from_ds: bool,
pub protected: bool,
pub addr1: Option<[u8; 6]>,
pub addr2: Option<[u8; 6]>,
pub addr3: Option<[u8; 6]>,
pub addr4: Option<[u8; 6]>,
pub bssid: Option<[u8; 6]>,
pub src: Option<[u8; 6]>,
pub dst: Option<[u8; 6]>,
pub body: &'a [u8],
/// offset, dentro de `body` completo original, de onde começam os
/// "information elements" (só preenchido para beacon/probe response).
pub header_len: usize,
ie_offset: usize,
}
@@ -53,52 +170,120 @@ pub fn parse(buf: &[u8]) -> Option<Frame<'_>> {
return None;
}
let fc = u16::from_le_bytes([buf[0], buf[1]]);
let version = fc & 0b11;
if version != 0 {
return None;
}
let frame_type = (fc >> 2) & 0b11;
let subtype = (fc >> 4) & 0b1111;
let to_ds = fc & 0x0100 != 0;
let from_ds = fc & 0x0200 != 0;
let protected = fc & 0x4000 != 0;
let qos = subtype & 0x8 != 0;
let kind = match (frame_type, subtype) {
(0, 8) => FrameKind::Beacon,
(0, 4) => FrameKind::ProbeRequest,
(0, 5) => FrameKind::ProbeResponse,
(0, 0) => FrameKind::AssociationRequest,
(0, 1) => FrameKind::AssociationResponse,
(0, 2) => FrameKind::ReassociationRequest,
(0, 11) => FrameKind::Authentication,
(0, 12) => FrameKind::Deauth,
(0, 10) => FrameKind::Disassoc,
(0, 13) => FrameKind::Action,
(0, _) => FrameKind::Management,
(1, _) => FrameKind::Control,
(2, _) => FrameKind::Data,
_ => FrameKind::Other,
};
if buf.len() < 24 {
// sem addr1/2/3 completos (comum em alguns quadros de controle)
let header_len = match frame_type {
0 => 24,
2 => {
let mut len = 24;
if to_ds && from_ds {
len += 6;
}
if qos {
len += 2;
}
if fc & 0x8000 != 0 {
len += 4; // HT Control
}
len
}
_ => 10,
};
if buf.len() < header_len {
return Some(Frame {
kind,
fc,
to_ds,
from_ds,
protected,
addr1: None,
addr2: None,
addr3: None,
addr4: None,
bssid: None,
src: None,
dst: None,
body: buf,
header_len: buf.len(),
ie_offset: 0,
});
}
let addr1: [u8; 6] = buf[4..10].try_into().ok()?;
let addr2: [u8; 6] = buf[10..16].try_into().ok()?;
let addr3: [u8; 6] = buf[16..22].try_into().ok()?;
let mut addr1 = None;
let mut addr2 = None;
let mut addr3 = None;
let mut addr4 = None;
if header_len >= 24 {
addr1 = Some(buf[4..10].try_into().ok()?);
addr2 = Some(buf[10..16].try_into().ok()?);
addr3 = Some(buf[16..22].try_into().ok()?);
}
if header_len >= 30 && to_ds && from_ds {
addr4 = Some(buf[24..30].try_into().ok()?);
}
let (dst, src, bssid) = (addr1, addr2, addr3);
let (bssid, src, dst) = match frame_type {
0 => (addr3, addr2, addr1),
2 => match (to_ds, from_ds) {
(false, false) => (addr3, addr2, addr1),
(true, false) => (addr1, addr2, addr3),
(false, true) => (addr2, addr3, addr1),
(true, true) => (None, addr4, addr3),
},
_ => (None, None, None),
};
// Para beacon/probe response, corpo fixo de 12 bytes (timestamp +
// interval + capability) antecede os information elements.
let ie_offset = if matches!(kind, FrameKind::Beacon | FrameKind::ProbeResponse) {
24 + 12
} else {
24
let ie_offset = match kind {
FrameKind::Beacon | FrameKind::ProbeResponse => 24 + 12,
FrameKind::ProbeRequest => 24,
FrameKind::AssociationRequest => 24 + 4,
FrameKind::ReassociationRequest => 24 + 10,
FrameKind::AssociationResponse => 24 + 6,
_ => 0,
};
Some(Frame {
kind,
bssid: Some(bssid),
src: Some(src),
dst: Some(dst),
fc,
to_ds,
from_ds,
protected,
addr1,
addr2,
addr3,
addr4,
bssid,
src,
dst,
body: buf,
header_len,
ie_offset,
})
}
@@ -112,29 +297,33 @@ impl<'a> Frame<'a> {
self.src.map(|m| mac_to_string(&m))
}
/// Faz parsing dos "information elements" (TLV) de um beacon/probe
/// response, extraindo SSID, canal e indício de cifragem.
pub fn dst_str(&self) -> Option<String> {
self.dst.map(|m| mac_to_string(&m))
}
/// Slice dos information elements, quando o quadro tem um.
pub fn ies(&self) -> Option<&'a [u8]> {
if self.ie_offset == 0 || self.ie_offset >= self.body.len() {
None
} else {
Some(&self.body[self.ie_offset..])
}
}
/// Faz parsing dos information elements (TLV), extraindo SSID, canal,
/// cifra/AKM e indicadores de WPS e PMF.
pub fn management_info(&self) -> ManagementInfo {
let mut info = ManagementInfo::default();
if self.ie_offset == 0 || self.ie_offset >= self.body.len() {
let Some(ies) = self.ies() else {
return info;
}
let privacy = {
// bit "Privacy" no campo capability, 2 bytes antes do fim do
// cabeçalho fixo (offset 24+10, 2 bytes).
if self.body.len() >= 24 + 12 {
let cap = u16::from_le_bytes([self.body[24 + 10], self.body[24 + 11]]);
cap & 0x0010 != 0
} else {
false
}
};
let privacy = self.privacy_bit();
let mut rsn_present = false;
let mut wpa1_present = false;
let mut rsn = RsnInfo::default();
let ies = &self.body[self.ie_offset..];
let mut i = 0;
let mut i = 0usize;
while i + 2 <= ies.len() {
let tag = ies[i];
let len = ies[i + 1] as usize;
@@ -145,58 +334,348 @@ impl<'a> Frame<'a> {
let val = &ies[start..start + len];
match tag {
0 => {
// SSID; pode ser "oculto" (len 0 ou só zeros)
if len > 0 && val.iter().any(|&b| b != 0) {
info.ssid = Some(String::from_utf8_lossy(val).into_owned());
} else {
let trimmed: Vec<u8> = val.iter().copied().filter(|b| *b != 0).collect();
if trimmed.is_empty() {
info.ssid = Some(String::new());
info.hidden = true;
} else {
info.ssid = Some(String::from_utf8_lossy(&trimmed).into_owned());
}
}
3 => {
if len == 1 {
info.channel_ie = Some(val[0] as u32);
3 if len == 1 => info.channel = Some(val[0] as u32),
7 if len >= 2 => {
info.country = Some(String::from_utf8_lossy(&val[0..2]).into_owned())
}
48 => {
rsn_present = true;
if let Some(parsed) = parse_rsn(val) {
rsn = parsed;
}
}
48 => rsn_present = true, // RSN IE = WPA2/WPA3
// vendor-specific; OUI 00:50:F2 tipo 1 = WPA1
221 if len >= 4 && val[0..3] == [0x00, 0x50, 0xF2] && val[3] == 1 => {
wpa1_present = true;
}
221 if len >= 4 && val[0..3] == [0x00, 0x50, 0xF2] => match val[3] {
1 => wpa1_present = true,
4 => info.wps = true,
_ => {}
},
_ => {}
}
i = start + len;
}
info.security = Some(if rsn_present {
Security::Wpa2
} else if wpa1_present {
Security::Wpa
} else if privacy {
Security::Wep
} else {
Security::Open
});
info.pmf = rsn.caps & 0x00c0 != 0;
if rsn_present {
info.cipher = rsn.cipher.map(|c| c.label().to_string());
info.akm = rsn.akm.labels();
}
info.security = Some(classify(rsn_present, wpa1_present, privacy, rsn));
info
}
fn privacy_bit(&self) -> bool {
match self.kind {
FrameKind::Beacon | FrameKind::ProbeResponse => {
self.body.len() >= 24 + 12
&& u16::from_le_bytes([self.body[24 + 10], self.body[24 + 11]]) & 0x0010 != 0
}
FrameKind::AssociationRequest => {
self.body.len() >= 26
&& u16::from_le_bytes([self.body[24], self.body[25]]) & 0x0010 != 0
}
_ => false,
}
}
/// Payload EAPOL (handshake WPA) de um quadro de dados não protegido.
/// O slice começa no cabeçalho EAPOL (versão, tipo, tamanho).
pub fn eapol(&self) -> Option<&'a [u8]> {
if self.protected || !self.kind.is_data() {
return None;
}
let llc = self.body.get(self.header_len..self.header_len + 8)?;
if llc[0] != 0xAA || llc[1] != 0xAA || llc[2] != 0x03 {
return None;
}
if u16::from_be_bytes([llc[6], llc[7]]) != 0x888E {
return None;
}
let start = self.header_len + 8;
let header = self.body.get(start..start + 4)?;
let len = u16::from_be_bytes([header[2], header[3]]) as usize;
let end = (start + 4 + len).min(self.body.len());
self.body.get(start..end)
}
}
/// Detecta se um quadro de dados carrega EAPOL (handshake 4-way), olhando o
/// cabeçalho LLC/SNAP (ethertype 0x888E) após o cabeçalho 802.11 de dados.
/// Mantido para chamadores antigos: há um EAPOL neste quadro?
pub fn is_eapol(body: &[u8]) -> bool {
// Cabeçalho de dados pode ter 24 ou 26 bytes (QoS); LLC/SNAP tem 8
// bytes (AA AA 03 00 00 00) + ethertype de 2 bytes.
for hdr_len in [24usize, 26] {
if body.len() < hdr_len + 8 {
continue;
parse(body).and_then(|f| f.eapol().map(|_| ())).is_some()
}
fn classify(rsn_present: bool, wpa1_present: bool, privacy: bool, rsn: RsnInfo) -> Security {
if rsn_present {
let akm = rsn.akm;
if akm.owe {
return Security::Owe;
}
let llc = &body[hdr_len..hdr_len + 8];
if llc[0] == 0xAA && llc[1] == 0xAA && llc[2] == 0x03 {
let ethertype = u16::from_be_bytes([llc[6], llc[7]]);
if ethertype == 0x888E {
return true;
if akm.sae && akm.enterprise {
return Security::Wpa3Enterprise;
}
if akm.sae && akm.psk {
return Security::Wpa2Wpa3;
}
if akm.sae {
return Security::Wpa3;
}
if akm.enterprise {
return Security::Wpa2Enterprise;
}
return Security::Wpa2;
}
if wpa1_present {
return Security::Wpa;
}
if privacy {
return Security::Wep;
}
Security::Open
}
fn parse_rsn(val: &[u8]) -> Option<RsnInfo> {
if val.len() < 8 {
return None;
}
if u16::from_le_bytes([val[0], val[1]]) != 1 {
return None;
}
let group = cipher_from_suite(&val[2..6]);
let mut pos = 6usize;
let pairwise_count = u16::from_le_bytes([val[pos], val[pos + 1]]) as usize;
pos += 2;
let mut cipher = group;
for _ in 0..pairwise_count {
if pos + 4 > val.len() {
return None;
}
let suite = cipher_from_suite(&val[pos..pos + 4]);
pos += 4;
if let Some(suite) = suite {
if suite != Cipher::Other && cipher == Some(Cipher::Other) {
cipher = Some(suite);
}
if matches!(suite, Cipher::Ccmp | Cipher::Gcmp) {
cipher = Some(suite);
}
}
}
false
if pos + 2 > val.len() {
return Some(RsnInfo {
cipher,
..Default::default()
});
}
let akm_count = u16::from_le_bytes([val[pos], val[pos + 1]]) as usize;
pos += 2;
let mut akm = AkmSet::default();
for _ in 0..akm_count {
if pos + 4 > val.len() {
break;
}
if val[pos..pos + 3] == [0x00, 0x0f, 0xac] {
match val[pos + 3] {
1 | 3 | 5 | 11 => akm.enterprise = true,
2 | 4 | 6 => akm.psk = true,
8 | 9 | 24 => akm.sae = true,
18 => akm.owe = true,
_ => {}
}
}
pos += 4;
}
let caps = if pos + 2 <= val.len() {
u16::from_le_bytes([val[pos], val[pos + 1]])
} else {
0
};
Some(RsnInfo { cipher, akm, caps })
}
fn cipher_from_suite(suite: &[u8]) -> Option<Cipher> {
if suite.len() < 4 {
return None;
}
if suite[0..3] != [0x00, 0x0f, 0xac] {
return Some(Cipher::Other);
}
Some(match suite[3] {
1 => Cipher::Wep40,
2 => Cipher::Tkip,
4 => Cipher::Ccmp,
5 => Cipher::Wep104,
8 | 9 => Cipher::Gcmp,
_ => Cipher::Other,
})
}
impl AkmSet {
fn labels(self) -> Vec<String> {
let mut out = Vec::new();
if self.psk {
out.push("PSK".into());
}
if self.sae {
out.push("SAE".into());
}
if self.enterprise {
out.push("802.1X".into());
}
if self.owe {
out.push("OWE".into());
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn mac(b: u8) -> [u8; 6] {
[b, b, b, b, b, b]
}
fn beacon_with_ies(ies: &[u8]) -> Vec<u8> {
let mut f = Vec::new();
f.extend_from_slice(&0x0080u16.to_le_bytes());
f.extend_from_slice(&0u16.to_le_bytes());
f.extend_from_slice(&[0xff; 6]);
f.extend_from_slice(&mac(0x11));
f.extend_from_slice(&mac(0x11));
f.extend_from_slice(&[0u8; 2]);
f.extend_from_slice(&[0u8; 8]); // timestamp
f.extend_from_slice(&100u16.to_le_bytes()); // beacon interval
f.extend_from_slice(&0x0031u16.to_le_bytes()); // capability: ESS + privacy
f.extend_from_slice(ies);
f
}
fn ie(tag: u8, val: &[u8]) -> Vec<u8> {
let mut v = vec![tag, val.len() as u8];
v.extend_from_slice(val);
v
}
fn rsn_ie(akms: &[u8]) -> Vec<u8> {
let mut val = Vec::new();
val.extend_from_slice(&1u16.to_le_bytes());
val.extend_from_slice(&[0x00, 0x0f, 0xac, 4]); // group 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());
for akm in akms {
val.extend_from_slice(&[0x00, 0x0f, 0xac, *akm]);
}
val.extend_from_slice(&0x0040u16.to_le_bytes()); // MFPC
ie(48, &val)
}
fn all_ie(ies: &[Vec<u8>]) -> Vec<u8> {
ies.concat()
}
#[test]
fn parses_wpa2_psk_beacon() {
let ies = all_ie(&[ie(0, b"MinhaCasa"), ie(3, &[6]), rsn_ie(&[2])]);
let frame = beacon_with_ies(&ies);
let f = parse(&frame).expect("frame");
assert_eq!(f.kind, FrameKind::Beacon);
assert_eq!(f.bssid_str().as_deref(), Some("11:11:11:11:11:11"));
let info = f.management_info();
assert_eq!(info.ssid.as_deref(), Some("MinhaCasa"));
assert_eq!(info.channel, Some(6));
assert_eq!(info.security, Some(Security::Wpa2));
assert_eq!(info.cipher.as_deref(), Some("CCMP"));
assert_eq!(info.akm, vec!["PSK"]);
assert!(info.pmf);
assert!(!info.wps);
}
#[test]
fn classifies_wpa3_transition_enterprise_and_owe() {
let cases = [
(vec![8], Security::Wpa3),
(vec![2, 8], Security::Wpa2Wpa3),
(vec![1], Security::Wpa2Enterprise),
(vec![18], Security::Owe),
];
for (akms, expected) in cases {
let ies = all_ie(&[ie(0, b"x"), rsn_ie(&akms)]);
let frame = beacon_with_ies(&ies);
let f = parse(&frame).expect("frame");
assert_eq!(f.management_info().security, Some(expected), "{akms:?}");
}
}
#[test]
fn detects_hidden_ssid_and_wps() {
let wps = ie(221, &[0x00, 0x50, 0xF2, 4, 0x10, 0x4a]);
let ies = all_ie(&[ie(0, &[]), wps, rsn_ie(&[2])]);
let frame = beacon_with_ies(&ies);
let f = parse(&frame).expect("frame");
let info = f.management_info();
assert!(info.hidden);
assert_eq!(info.ssid.as_deref(), Some(""));
assert!(info.wps);
}
#[test]
fn parses_directed_data_and_eapol_slice() {
let mut f = Vec::new();
f.extend_from_slice(&0x0108u16.to_le_bytes()); // data, ToDS
f.extend_from_slice(&0u16.to_le_bytes());
f.extend_from_slice(&mac(0x11)); // BSSID
f.extend_from_slice(&mac(0x22)); // station
f.extend_from_slice(&mac(0x33)); // DA
f.extend_from_slice(&[0u8; 2]);
f.extend_from_slice(&[0xAA, 0xAA, 0x03, 0, 0, 0, 0x88, 0x8e]);
let eapol = [0x02u8, 0x03, 0x00, 0x05, 1, 2, 3, 4, 5];
f.extend_from_slice(&eapol);
let parsed = parse(&f).expect("frame");
assert!(parsed.to_ds);
assert_eq!(parsed.header_len, 24);
assert_eq!(parsed.bssid_str().as_deref(), Some("11:11:11:11:11:11"));
assert_eq!(parsed.src_str().as_deref(), Some("22:22:22:22:22:22"));
assert_eq!(parsed.eapol(), Some(&eapol[..]));
}
#[test]
fn qos_and_protected_data_are_handled() {
let mut f = Vec::new();
f.extend_from_slice(&0x4088u16.to_le_bytes()); // QoS data, protected
f.extend_from_slice(&0u16.to_le_bytes());
f.extend_from_slice(&mac(0x11));
f.extend_from_slice(&mac(0x22));
f.extend_from_slice(&mac(0x33));
f.extend_from_slice(&[0u8; 2]);
f.extend_from_slice(&[0u8; 2]); // QoS control
f.extend_from_slice(&[0u8; 32]); // ciphertext
let parsed = parse(&f).expect("frame");
assert!(parsed.protected);
assert_eq!(parsed.header_len, 26);
assert!(parsed.eapol().is_none());
}
#[test]
fn parses_mac_strings() {
assert_eq!(
parse_mac("00:11:22:33:44:55"),
Some([0, 0x11, 0x22, 0x33, 0x44, 0x55])
);
assert_eq!(
parse_mac("00-11-22-33-44-55"),
Some([0, 0x11, 0x22, 0x33, 0x44, 0x55])
);
assert!(parse_mac("00:11:22").is_none());
assert!(parse_mac("zz:11:22:33:44:55").is_none());
}
}
+194
View File
@@ -0,0 +1,194 @@
//! Parser do handshake WPA/WPA2 (EAPOL-Key, IEEE 802.1X).
//!
//! 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;
const KEY_INFO_PAIRWISE: u16 = 0x0008;
const KEY_INFO_INSTALL: u16 = 0x0040;
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).
pub const MIC_OFFSET: usize = 81;
pub const KEY_DATA_OFFSET: usize = 99;
pub const MIN_KEY_FRAME_LEN: usize = 99;
#[derive(Debug, Clone)]
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.
pub message: u8,
pub key_info: u16,
/// Versão do descritor: 1 = TKIP, 2 = CCMP, 3 = AES-CMAC.
pub key_version: u8,
pub pairwise: bool,
pub replay_counter: u64,
pub nonce: [u8; 32],
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.
pub frame: Vec<u8>,
}
pub fn parse(buf: &[u8]) -> Option<EapolKey> {
if buf.len() < 4 || buf[1] != EAPOL_TYPE_KEY {
return None;
}
let version = buf[0];
let declared = u16::from_be_bytes([buf[2], buf[3]]) as usize;
let total = 4 + declared;
if total < MIN_KEY_FRAME_LEN || buf.len() < total {
return None;
}
let frame = buf[..total].to_vec();
let descriptor_type = frame[4];
// 2 = RSN (WPA2/WPA3), 254 = WPA1. Outros descritores não interessam.
if descriptor_type != 2 && descriptor_type != 254 {
return None;
}
let key_info = u16::from_le_bytes([frame[5], frame[6]]);
let key_version = (key_info & 0x0007) as u8;
let pairwise = key_info & KEY_INFO_PAIRWISE != 0;
let ack = key_info & KEY_INFO_ACK != 0;
let mic = key_info & KEY_INFO_MIC != 0;
let install = key_info & KEY_INFO_INSTALL != 0;
let secure = key_info & KEY_INFO_SECURE != 0;
let message = if ack && !mic {
1
} else if !ack && mic && !secure {
2
} else if ack && mic && (install || !secure) {
3
} else if !ack && mic && secure {
4
} else {
0
};
let replay_counter = u64::from_le_bytes(frame[9..17].try_into().ok()?);
let mut nonce = [0u8; 32];
nonce.copy_from_slice(&frame[17..49]);
let mut mic_bytes = [0u8; 16];
mic_bytes.copy_from_slice(&frame[MIC_OFFSET..MIC_OFFSET + 16]);
let key_data_len = u16::from_le_bytes([frame[97], frame[98]]) as usize;
let data_end = KEY_DATA_OFFSET.checked_add(key_data_len)?;
if data_end > frame.len() {
return None;
}
let key_data = frame[KEY_DATA_OFFSET..data_end].to_vec();
let pmkid = parse_pmkid_kde(&key_data);
Some(EapolKey {
version,
descriptor_type,
message,
key_info,
key_version,
pairwise,
replay_counter,
nonce,
mic: mic_bytes,
key_data,
pmkid,
frame,
})
}
/// PMKID KDE: `dd 14 00 0f ac 04` + 16 bytes. Pode vir em qualquer posição
/// dos dados da chave (normalmente na mensagem 1).
pub fn parse_pmkid_kde(data: &[u8]) -> Option<[u8; 16]> {
let mut i = 0usize;
while i + 2 <= data.len() {
let tag = data[i];
let len = data[i + 1] as usize;
let start = i + 2;
if start + len > data.len() {
break;
}
if tag == 0xdd && len >= 20 && data[start..start + 4] == [0x00, 0x0f, 0xac, 0x04] {
let mut pmkid = [0u8; 16];
pmkid.copy_from_slice(&data[start + 4..start + 20]);
return Some(pmkid);
}
i = start + len;
}
None
}
#[cfg(test)]
mod tests {
use super::*;
pub(crate) fn key_frame(message: u8, keyver: u8, nonce: [u8; 32], key_data: &[u8]) -> Vec<u8> {
let mut key_info = keyver as u16 | KEY_INFO_PAIRWISE;
match message {
1 => key_info |= KEY_INFO_ACK,
2 => key_info |= KEY_INFO_MIC,
3 => key_info |= KEY_INFO_ACK | KEY_INFO_MIC | KEY_INFO_INSTALL,
4 => key_info |= KEY_INFO_MIC | KEY_INFO_SECURE,
_ => {}
}
let total = KEY_DATA_OFFSET + key_data.len();
let mut f = vec![0u8; total];
f[0] = 2; // EAPOL version
f[1] = EAPOL_TYPE_KEY;
f[2..4].copy_from_slice(&((total - 4) as u16).to_be_bytes());
f[4] = 2; // RSN descriptor
f[5..7].copy_from_slice(&key_info.to_le_bytes());
f[7..9].copy_from_slice(&16u16.to_le_bytes());
f[9..17].copy_from_slice(&1u64.to_le_bytes());
f[17..49].copy_from_slice(&nonce);
for (i, b) in f[MIC_OFFSET..MIC_OFFSET + 16].iter_mut().enumerate() {
*b = i as u8;
}
f[97..99].copy_from_slice(&(key_data.len() as u16).to_le_bytes());
f[KEY_DATA_OFFSET..].copy_from_slice(key_data);
f
}
#[test]
fn identifies_all_four_messages() {
for msg in 1..=4u8 {
let frame = key_frame(msg, 2, [msg; 32], &[]);
let parsed = parse(&frame).expect("eapol");
assert_eq!(parsed.message, msg);
assert_eq!(parsed.key_version, 2);
assert!(parsed.pairwise);
assert_eq!(parsed.nonce, [msg; 32]);
assert_eq!(parsed.mic, {
let mut m = [0u8; 16];
for (i, b) in m.iter_mut().enumerate() {
*b = i as u8;
}
m
});
assert_eq!(parsed.frame, frame);
}
}
#[test]
fn extracts_pmkid_from_key_data() {
let mut data = vec![0xdd, 20, 0x00, 0x0f, 0xac, 0x04];
data.extend_from_slice(&[0xab; 16]);
let frame = key_frame(1, 2, [0x11; 32], &data);
let parsed = parse(&frame).expect("eapol");
assert_eq!(parsed.pmkid, Some([0xab; 16]));
}
#[test]
fn rejects_non_key_and_truncated_frames() {
assert!(parse(&[2, 0, 0, 0]).is_none());
let frame = key_frame(2, 2, [0; 32], &[]);
assert!(parse(&frame[..frame.len() - 1]).is_none());
}
}
+835 -58
View File
@@ -1,26 +1,116 @@
//! Ponte única entre o núcleo e as duas interfaces (servidor local da CLI e
//! o app desktop Tauri): ambos chamam exatamente os mesmos métodos, então
//! qualquer coisa que funcione em um funciona idêntico no outro.
//!
//! Sessões de captura, jobs de quebra, exportação de handshakes e deauth
//! ficam todos aqui — a CLI e a GUI não reimplementam nada.
use crate::audit::AuditLog;
use crate::error::Result;
use crate::iface::{self, Interface};
use crate::netlink;
use crate::scan::{AccessPoint, ScanTable};
use crate::crack::{self, CrackReport};
use crate::doctor::{self, DoctorReport};
use crate::error::{Result, SabiaError};
use crate::handshake::Handshake;
use crate::iface::{self, Interface, Mode};
use crate::netlink::{self, ChannelInfo};
use crate::pcapreader::PcapReader;
use crate::pcapwriter::PcapWriter;
use crate::scan::{AccessPoint, Client, ScanSnapshot, ScanTable};
use crate::update::{self, UpdateStatus};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScanOptions {
/// Canal fixo; sem ele, percorre os canais suportados.
pub channel: Option<u32>,
/// `2.4`, `5`, `6` ou `all` (usado quando `channel` é `None`).
pub band: Option<String>,
/// Tempo em cada canal ao pular.
pub hop_ms: u64,
/// Salva os quadros capturados neste `.pcap`.
pub pcap_out: Option<PathBuf>,
/// Encerra sozinho depois de N segundos.
pub duration_secs: Option<u64>,
}
impl Default for ScanOptions {
fn default() -> Self {
Self {
channel: None,
band: None,
hop_ms: 350,
pcap_out: None,
duration_secs: None,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct SessionInfo {
pub iface: String,
pub running: bool,
pub channel: Option<u32>,
pub channels: Vec<u32>,
pub frames: u64,
pub aps: usize,
pub clients: usize,
pub handshakes: usize,
pub started_unix: u64,
pub pcap_out: Option<String>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct CrackJobInfo {
pub id: String,
pub iface: String,
pub bssid: Option<String>,
pub station: Option<String>,
pub status: String,
pub tried: u64,
pub started_unix: u64,
pub finished_unix: Option<u64>,
pub report: Option<CrackReport>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ExportReport {
pub path: String,
pub frames: usize,
pub handshakes: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct DeauthReport {
pub iface: String,
pub bssid: String,
pub station: String,
pub reason: u16,
pub frames_sent: usize,
}
struct Session {
table: Arc<Mutex<ScanTable>>,
running: Arc<AtomicBool>,
channel: Arc<Mutex<Option<u32>>>,
channels: Vec<u32>,
started_unix: u64,
pcap_out: Option<PathBuf>,
error: Arc<Mutex<Option<String>>>,
handle: Option<std::thread::JoinHandle<()>>,
}
pub struct Engine {
sessions: Mutex<HashMap<String, Session>>,
jobs: Mutex<HashMap<String, Arc<Mutex<CrackJobInfo>>>>,
job_seq: AtomicU64,
audit_path: PathBuf,
audit_lock: Arc<Mutex<()>>,
}
impl Default for Engine {
@@ -31,9 +121,16 @@ impl Default for Engine {
impl Engine {
pub fn new() -> Self {
Self::with_audit_path(PathBuf::from(".sabia-audit.log"))
}
pub fn with_audit_path(path: impl Into<PathBuf>) -> Self {
Self {
sessions: Mutex::new(HashMap::new()),
audit_path: PathBuf::from(".sabia-audit.log"),
jobs: Mutex::new(HashMap::new()),
job_seq: AtomicU64::new(0),
audit_path: path.into(),
audit_lock: Arc::new(Mutex::new(())),
}
}
@@ -41,91 +138,597 @@ impl Engine {
env!("CARGO_PKG_VERSION")
}
pub fn audit_path(&self) -> &Path {
&self.audit_path
}
pub fn list_ifaces(&self) -> Result<Vec<Interface>> {
iface::list_wireless()
}
pub fn doctor(&self) -> DoctorReport {
doctor::diagnose(self.version(), &self.audit_path)
}
pub fn set_monitor(&self, name: &str, enable: bool) -> Result<()> {
netlink::set_monitor_mode(name, enable)?;
if let Ok(mut log) = AuditLog::open(&self.audit_path) {
let _ = log.record("monitor_mode", name, &format!("{{\"enable\":{enable}}}"));
iface::get(name)?;
if !enable {
self.stop_scan(name);
}
netlink::set_monitor_mode(name, enable)?;
self.log(
"monitor_mode",
name,
serde_json::json!({ "enable": enable }),
);
Ok(())
}
#[cfg(target_os = "linux")]
pub fn start_scan(&self, name: &str) -> Result<()> {
use crate::capture::RawCapture;
use std::thread;
use std::time::Duration;
pub fn channels(&self, name: &str) -> Result<Vec<ChannelInfo>> {
netlink::channels(name)
}
let mut sessions = self.sessions.lock().unwrap();
if sessions.contains_key(name) {
return Ok(());
pub fn set_channel(&self, name: &str, channel: u32) -> Result<()> {
netlink::set_channel(name, channel)?;
if let Some(session) = self.sessions.lock().unwrap().get(name) {
*session.channel.lock().unwrap() = Some(channel);
}
self.log(
"set_channel",
name,
serde_json::json!({ "channel": channel }),
);
Ok(())
}
pub fn start_scan(&self, name: &str) -> Result<()> {
self.start_scan_with(name, ScanOptions::default())
}
#[cfg(target_os = "linux")]
pub fn start_scan_with(&self, name: &str, options: ScanOptions) -> Result<()> {
use crate::capture::RawCapture;
let info = iface::get(name)?;
if info.mode == Mode::Managed {
return Err(SabiaError::InvalidInput(format!(
"{name} está em modo managed; ative o modo monitor antes de capturar"
)));
}
let cap = RawCapture::open(name)?;
cap.set_read_timeout(Duration::from_millis(200))?;
let writer = match options.pcap_out.as_ref() {
Some(path) => Some(PcapWriter::create(path)?),
None => None,
};
let channels = match options.channel {
Some(channel) => vec![channel],
None => netlink::channel_plan(name, options.band.as_deref()).unwrap_or_default(),
};
let hop = Duration::from_millis(options.hop_ms.clamp(100, 5_000));
// Substitui uma sessão anterior (se houver) só depois que a nova
// captura abriu, para não derrubar a antiga se algo falhar.
self.stop_scan(name);
let table = Arc::new(Mutex::new(ScanTable::new()));
let running = Arc::new(AtomicBool::new(true));
let channel = Arc::new(Mutex::new(None));
let error = Arc::new(Mutex::new(None));
let started_unix = unix_now();
let pcap_out = options.pcap_out.clone();
let t = table.clone();
let r = running.clone();
let ch = channel.clone();
let err_slot = error.clone();
let iface_name = name.to_string();
thread::spawn(move || {
let cap = match RawCapture::open(&iface_name) {
Ok(c) => c,
Err(e) => {
eprintln!("falha ao abrir captura em {iface_name}: {e}");
return;
}
};
let _ = cap.set_read_timeout(Duration::from_millis(500));
let channels = netlink::supported_channels(&iface_name).unwrap_or_default();
let audit_path = self.audit_path.clone();
let audit_lock = self.audit_lock.clone();
let deadline = options
.duration_secs
.map(|secs| Instant::now() + Duration::from_secs(secs.max(1)));
let thread_channels = channels.clone();
let handle = std::thread::spawn(move || {
let channels = thread_channels;
let mut writer = writer;
let mut idx = 0usize;
let mut last_hop = std::time::Instant::now();
let mut last_hop = Instant::now();
let mut buf = [0u8; 65535];
while r.load(Ordering::SeqCst) {
if !channels.is_empty() && last_hop.elapsed() > Duration::from_millis(300) {
idx = (idx + 1) % channels.len();
let _ = netlink::set_channel(&iface_name, channels[idx]);
last_hop = std::time::Instant::now();
if !channels.is_empty() {
let first = channels[0];
if netlink::set_channel(&iface_name, first).is_ok() {
*ch.lock().unwrap() = Some(first);
}
if let Ok(Some(n)) = cap.recv(&mut buf) {
let mut table = t.lock().unwrap();
table.ingest(&buf[..n]);
}
while r.load(Ordering::SeqCst) {
if deadline.is_some_and(|d| Instant::now() >= d) {
break;
}
if channels.len() > 1 && last_hop.elapsed() >= hop {
idx = (idx + 1) % channels.len();
let next = channels[idx];
if netlink::set_channel(&iface_name, next).is_ok() {
*ch.lock().unwrap() = Some(next);
}
last_hop = Instant::now();
}
match cap.recv(&mut buf) {
Ok(Some(n)) => {
let frame = &buf[..n];
t.lock().unwrap().ingest(frame);
if let Some(w) = writer.as_mut() {
if let Err(e) = w.write_frame(frame) {
*err_slot.lock().unwrap() = Some(e.to_string());
break;
}
}
}
Ok(None) => {}
Err(e) => {
*err_slot.lock().unwrap() = Some(e.to_string());
break;
}
}
}
if let Some(w) = writer.as_mut() {
let _ = w.flush();
}
r.store(false, Ordering::SeqCst);
let stats = t.lock().unwrap().stats();
append_audit(
&audit_path,
&audit_lock,
"scan_stop",
&iface_name,
&serde_json::json!({
"aps": stats.aps,
"clients": stats.clients,
"handshakes": stats.handshakes,
"frames": stats.frames,
}),
);
});
self.sessions.lock().unwrap().insert(
name.to_string(),
Session {
table,
running,
channel,
channels,
started_unix,
pcap_out,
error,
handle: Some(handle),
},
);
self.log(
"scan_start",
name,
serde_json::json!({ "options": options }),
);
Ok(())
}
#[cfg(not(target_os = "linux"))]
pub fn start_scan_with(&self, _name: &str, _options: ScanOptions) -> Result<()> {
Err(SabiaError::UnsupportedPlatform)
}
pub fn stop_scan(&self, name: &str) {
let handle = {
let mut sessions = self.sessions.lock().unwrap();
match sessions.get_mut(name) {
Some(session) => {
session.running.store(false, Ordering::SeqCst);
session.handle.take()
}
None => None,
}
};
if let Some(handle) = handle {
let _ = handle.join();
}
}
pub fn list_sessions(&self) -> Vec<SessionInfo> {
let sessions = self.sessions.lock().unwrap();
let mut out: Vec<SessionInfo> = sessions
.iter()
.map(|(name, session)| {
let stats = session.table.lock().unwrap().stats();
SessionInfo {
iface: name.clone(),
running: session.running.load(Ordering::SeqCst),
channel: *session.channel.lock().unwrap(),
channels: session.channels.clone(),
frames: stats.frames,
aps: stats.aps,
clients: stats.clients,
handshakes: stats.handshakes,
started_unix: session.started_unix,
pcap_out: session.pcap_out.as_ref().map(|p| p.display().to_string()),
error: session.error.lock().unwrap().clone(),
}
})
.collect();
out.sort_by(|a, b| a.iface.cmp(&b.iface));
out
}
pub fn scan_snapshot(&self, name: &str) -> Result<ScanSnapshot> {
let sessions = self.sessions.lock().unwrap();
let session = sessions.get(name).ok_or_else(|| {
SabiaError::InvalidInput(format!("nenhuma sessão de captura em {name}"))
})?;
let table = session.table.lock().unwrap();
let snapshot = ScanSnapshot {
iface: name.to_string(),
running: session.running.load(Ordering::SeqCst),
channel: *session.channel.lock().unwrap(),
stats: table.stats(),
access_points: table.access_points(),
clients: table.clients(),
handshakes: table.handshakes(),
};
drop(table);
drop(sessions);
Ok(snapshot)
}
/// Compatibilidade com a API antiga: só os APs da sessão.
pub fn scan_results(&self, name: &str) -> Vec<AccessPoint> {
self.sessions
.lock()
.unwrap()
.get(name)
.map(|s| s.table.lock().unwrap().access_points())
.unwrap_or_default()
}
pub fn scan_clients(&self, name: &str) -> Vec<Client> {
self.sessions
.lock()
.unwrap()
.get(name)
.map(|s| s.table.lock().unwrap().clients())
.unwrap_or_default()
}
/// Analisa um `.pcap` offline e devolve o mesmo snapshot da captura ao
/// vivo (sem sessão e sem tocar no rádio).
pub fn analyze_pcap(&self, path: impl AsRef<Path>) -> Result<ScanSnapshot> {
let path = path.as_ref();
let mut reader = PcapReader::open(path)?;
if !reader.supports_80211() {
return Err(SabiaError::InvalidPcap(
"link-type não é 802.11 nem radiotap".into(),
));
}
let mut table = ScanTable::new();
while let Some(frame) = reader.next_frame()? {
table.ingest(&frame.data);
}
Ok(ScanSnapshot {
iface: path.display().to_string(),
running: false,
channel: None,
stats: table.stats(),
access_points: table.access_points(),
clients: table.clients(),
handshakes: table.handshakes(),
})
}
pub fn handshakes(&self, name: Option<&str>) -> Vec<crate::handshake::HandshakeInfo> {
let sessions = self.sessions.lock().unwrap();
let mut out = Vec::new();
for (iface_name, session) in sessions.iter() {
if name.is_some_and(|want| want != iface_name) {
continue;
}
out.extend(session.table.lock().unwrap().handshakes());
}
out.sort_by_key(|h| std::cmp::Reverse(h.last_seen_unix));
out
}
pub fn export_handshake(
&self,
name: &str,
bssid: &str,
station: &str,
out: impl AsRef<Path>,
) -> Result<ExportReport> {
let hs = self
.session_handshake(name, bssid, station)?
.ok_or_else(|| SabiaError::InvalidInput("handshake não encontrado".into()))?;
let frames = hs.raw_frames();
if frames.is_empty() {
return Err(SabiaError::InvalidInput(
"handshake sem quadros crus (captura antiga)".into(),
));
}
let path = out.as_ref();
let mut writer = PcapWriter::create(path)?;
for frame in &frames {
writer.write_frame(frame)?;
}
writer.flush()?;
self.log(
"export_handshake",
name,
serde_json::json!({ "bssid": bssid, "station": station, "path": path, "frames": frames.len() }),
);
Ok(ExportReport {
path: path.display().to_string(),
frames: frames.len(),
handshakes: 1,
})
}
pub fn export_all_handshakes(&self, name: &str, out: impl AsRef<Path>) -> Result<ExportReport> {
let entries = {
let sessions = self.sessions.lock().unwrap();
let session = sessions.get(name).ok_or_else(|| {
SabiaError::InvalidInput(format!("nenhuma sessão de captura em {name}"))
})?;
let entries = session.table.lock().unwrap().handshake_entries();
entries
};
let path = out.as_ref();
let mut writer = PcapWriter::create(path)?;
let mut frames = 0usize;
let mut handshakes = 0usize;
for hs in &entries {
let raw = hs.raw_frames();
if raw.is_empty() {
continue;
}
for frame in &raw {
writer.write_frame(frame)?;
frames += 1;
}
handshakes += 1;
}
writer.flush()?;
if handshakes == 0 {
return Err(SabiaError::InvalidInput(
"nenhum handshake com quadros crus para exportar".into(),
));
}
self.log(
"export_handshakes",
name,
serde_json::json!({ "path": path, "frames": frames, "handshakes": handshakes }),
);
Ok(ExportReport {
path: path.display().to_string(),
frames,
handshakes,
})
}
pub fn start_crack(
&self,
name: &str,
bssid: Option<&str>,
station: Option<&str>,
wordlist: impl AsRef<Path>,
) -> Result<String> {
let wordlist = wordlist.as_ref();
if !wordlist.is_file() {
return Err(SabiaError::InvalidInput(format!(
"dicionário não encontrado: {}",
wordlist.display()
)));
}
let table = {
let sessions = self.sessions.lock().unwrap();
sessions.get(name).map(|s| s.table.clone()).ok_or_else(|| {
SabiaError::InvalidInput(format!("nenhuma sessão de captura em {name}"))
})?
};
let hs = {
let table = table.lock().unwrap();
match (bssid, station) {
(Some(b), Some(s)) => table.handshake(b, s).filter(|h| h.is_crackable()).cloned(),
(Some(b), None) => table.first_crackable(Some(b), None).cloned(),
(None, Some(s)) => table.first_crackable(None, Some(s)).cloned(),
(None, None) => table.first_crackable(None, None).cloned(),
}
}
.ok_or_else(|| {
SabiaError::InvalidInput("nenhum handshake quebrável para os filtros informados".into())
})?;
let id = format!("crack-{}", self.job_seq.fetch_add(1, Ordering::SeqCst) + 1);
let job = Arc::new(Mutex::new(CrackJobInfo {
id: id.clone(),
iface: name.to_string(),
bssid: Some(hs.bssid.clone()),
station: Some(hs.station.clone()),
status: "running".into(),
tried: 0,
started_unix: unix_now(),
finished_unix: None,
report: None,
error: None,
}));
self.jobs.lock().unwrap().insert(id.clone(), job.clone());
let audit_path = self.audit_path.clone();
let audit_lock = self.audit_lock.clone();
let iface_name = name.to_string();
let wordlist_path = wordlist.to_path_buf();
let job_thread = job.clone();
let hs_bssid = hs.bssid.clone();
let hs_station = hs.station.clone();
let thread_wordlist = wordlist_path.clone();
let thread_bssid = hs_bssid.clone();
let thread_station = hs_station.clone();
std::thread::spawn(move || {
let progress_job = job_thread.clone();
let result = crack::crack_handshake(&hs, &thread_wordlist, move |tried| {
if let Ok(mut j) = progress_job.lock() {
j.tried = tried;
}
});
let mut guard = job_thread.lock().unwrap();
guard.finished_unix = Some(unix_now());
match result {
Ok(report) => {
guard.status = "done".into();
guard.tried = report.tried;
if let Some(password) = report.password.as_deref() {
table.lock().unwrap().set_password(
&thread_bssid,
&thread_station,
password,
);
append_audit(
&audit_path,
&audit_lock,
"crack_success",
&iface_name,
&serde_json::json!({
"bssid": thread_bssid,
"station": thread_station,
"tried": report.tried,
"elapsed_ms": report.elapsed_ms,
}),
);
}
guard.report = Some(report);
}
Err(e) => {
guard.status = "error".into();
guard.error = Some(e.to_string());
}
}
});
if let Ok(mut log) = AuditLog::open(&self.audit_path) {
let _ = log.record("scan_start", name, "{}");
self.log(
"crack_start",
name,
serde_json::json!({ "job": id, "bssid": hs_bssid, "station": hs_station, "wordlist": wordlist_path }),
);
Ok(id)
}
pub fn crack_job(&self, id: &str) -> Option<CrackJobInfo> {
self.jobs
.lock()
.unwrap()
.get(id)
.map(|j| j.lock().unwrap().clone())
}
pub fn crack_jobs(&self) -> Vec<CrackJobInfo> {
let jobs = self.jobs.lock().unwrap();
let mut out: Vec<_> = jobs.values().map(|j| j.lock().unwrap().clone()).collect();
out.sort_by(|a, b| {
b.started_unix
.cmp(&a.started_unix)
.then_with(|| a.id.cmp(&b.id))
});
out
}
#[cfg(target_os = "linux")]
pub fn deauth(
&self,
name: &str,
bssid: &str,
station: Option<&str>,
count: u32,
reason: u16,
) -> Result<DeauthReport> {
use crate::inject::{self, RawInjector};
if !(1..=256).contains(&count) {
return Err(SabiaError::InvalidInput(
"quantidade de quadros deve ficar entre 1 e 256".into(),
));
}
sessions.insert(name.to_string(), Session { table, running });
Ok(())
let info = iface::get(name)?;
if info.mode == Mode::Managed {
return Err(SabiaError::InvalidInput(format!(
"{name} está em modo managed; ative o modo monitor antes de injetar"
)));
}
let ap = parse_mac(bssid)?;
let dest = match station {
Some(s) => parse_mac(s)?,
None => inject::BROADCAST,
};
let reason = inject::Reason::from_u16(reason);
let frame = inject::deauth_frame(ap, dest, reason);
let injector = RawInjector::open(name)?;
let mut frames_sent = 0usize;
for _ in 0..count {
injector.send(&frame)?;
frames_sent += 1;
std::thread::sleep(Duration::from_millis(10));
}
let report = DeauthReport {
iface: name.to_string(),
bssid: bssid.to_string(),
station: station.unwrap_or("ff:ff:ff:ff:ff:ff").to_string(),
reason,
frames_sent,
};
self.log(
"deauth",
name,
serde_json::json!({
"bssid": bssid,
"station": report.station,
"reason": reason,
"frames": frames_sent,
}),
);
Ok(report)
}
/// Captura crua (AF_PACKET) só existe no Linux (ver `crate::capture`).
/// Em macOS/Windows o app compila e roda, mas a captura ainda não tem
/// backend nativo — próximo passo natural do projeto.
#[cfg(not(target_os = "linux"))]
pub fn start_scan(&self, _name: &str) -> Result<()> {
Err(crate::error::SabiaError::UnsupportedPlatform)
pub fn deauth(
&self,
_name: &str,
_bssid: &str,
_station: Option<&str>,
_count: u32,
_reason: u16,
) -> Result<DeauthReport> {
Err(SabiaError::UnsupportedPlatform)
}
pub fn stop_scan(&self, name: &str) {
let mut sessions = self.sessions.lock().unwrap();
if let Some(s) = sessions.remove(name) {
s.running.store(false, Ordering::SeqCst);
if let Ok(mut log) = AuditLog::open(&self.audit_path) {
let _ = log.record("scan_stop", name, "{}");
}
pub fn audit_verify(&self) -> Result<Option<usize>> {
if !self.audit_path.exists() {
return Ok(None);
}
AuditLog::verify(&self.audit_path)
}
pub fn scan_results(&self, name: &str) -> Vec<AccessPoint> {
let sessions = self.sessions.lock().unwrap();
match sessions.get(name) {
Some(s) => s.table.lock().unwrap().access_points(),
None => Vec::new(),
pub fn audit_entries(&self, limit: Option<usize>) -> Result<Vec<crate::audit::AuditEntry>> {
if !self.audit_path.exists() {
return Ok(Vec::new());
}
AuditLog::read_entries(&self.audit_path, limit)
}
pub fn update_check(&self) -> Result<UpdateStatus> {
@@ -136,4 +739,178 @@ impl Engine {
let exe = std::env::current_exe()?;
update::apply(&exe)
}
fn session_handshake(
&self,
name: &str,
bssid: &str,
station: &str,
) -> Result<Option<Handshake>> {
let sessions = self.sessions.lock().unwrap();
let session = sessions.get(name).ok_or_else(|| {
SabiaError::InvalidInput(format!("nenhuma sessão de captura em {name}"))
})?;
let table = session.table.lock().unwrap();
Ok(table.handshake(bssid, station).cloned())
}
fn log(&self, action: &str, target: &str, details: serde_json::Value) {
append_audit(&self.audit_path, &self.audit_lock, action, target, &details);
}
}
fn append_audit(
path: &Path,
lock: &Arc<Mutex<()>>,
action: &str,
target: &str,
details: &serde_json::Value,
) {
let _guard = lock.lock().unwrap();
let Ok(mut log) = AuditLog::open(path) else {
return;
};
let text = details.to_string();
let _ = log.record(action, target, &text);
}
fn parse_mac(text: &str) -> Result<[u8; 6]> {
crate::dot11::parse_mac(text)
.ok_or_else(|| SabiaError::InvalidInput(format!("MAC inválido: {text}")))
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::radiotap::Band;
use crate::testutil::{
ap, beacon_frame, frame_with_eapol, handshake_frames, radiotap_wrap, sta,
};
use tempfile::TempDir;
fn seed_session(engine: &Engine, name: &str) -> Arc<Mutex<ScanTable>> {
let mut table = ScanTable::new();
let beacon = radiotap_wrap(&beacon_frame(ap(), "RedeWPA", 6, &[2]), 2437, -40);
table.ingest(&beacon);
let (m1, m2) = handshake_frames("RedeWPA", "password", 2, false);
let f1 = frame_with_eapol(true, ap(), sta(), [0x33; 6], &m1);
let f2 = frame_with_eapol(false, ap(), sta(), [0x33; 6], &m2);
table.ingest(&radiotap_wrap(&f1, 2437, -40));
table.ingest(&radiotap_wrap(&f2, 2437, -41));
assert_eq!(table.stats().handshakes, 1);
let table = Arc::new(Mutex::new(table));
engine.sessions.lock().unwrap().insert(
name.to_string(),
Session {
table: table.clone(),
running: Arc::new(AtomicBool::new(false)),
channel: Arc::new(Mutex::new(Some(6))),
channels: vec![6],
started_unix: 1,
pcap_out: None,
error: Arc::new(Mutex::new(None)),
handle: None,
},
);
table
}
#[test]
fn analyzes_pcap_and_snapshot() {
let dir = TempDir::new().unwrap();
let pcap = dir.path().join("capture.pcap");
let engine = Engine::with_audit_path(dir.path().join("audit.log"));
let table = seed_session(&engine, "wlan-test");
let hs = {
let table = table.lock().unwrap();
table
.handshake("00:11:22:33:44:55", "66:77:88:99:aa:bb")
.cloned()
.unwrap()
};
let mut writer = PcapWriter::create(&pcap).unwrap();
let beacon = radiotap_wrap(&beacon_frame(ap(), "RedeWPA", 6, &[2]), 2437, -40);
writer.write_frame(&beacon).unwrap();
for frame in hs.raw_frames() {
writer.write_frame(&frame).unwrap();
}
writer.flush().unwrap();
let snapshot = engine.analyze_pcap(&pcap).unwrap();
assert_eq!(snapshot.access_points.len(), 1);
assert_eq!(snapshot.access_points[0].ssid.as_deref(), Some("RedeWPA"));
assert_eq!(snapshot.access_points[0].band, Some(Band::B2G4));
assert_eq!(snapshot.handshakes.len(), 1);
assert!(snapshot.handshakes[0].crackable);
assert_eq!(snapshot.stats.frames, 3);
}
#[test]
fn exports_handshake_and_cracks_it() {
let dir = TempDir::new().unwrap();
let engine = Engine::with_audit_path(dir.path().join("audit.log"));
seed_session(&engine, "wlan-test");
let export = dir.path().join("hs.pcap");
let report = engine
.export_handshake(
"wlan-test",
"00:11:22:33:44:55",
"66:77:88:99:aa:bb",
&export,
)
.unwrap();
assert_eq!(report.frames, 2);
assert!(export.is_file());
let wordlist = dir.path().join("words.txt");
std::fs::write(&wordlist, "errada\noutra\npassword\nnunca\n").unwrap();
let id = engine
.start_crack(
"wlan-test",
Some("00:11:22:33:44:55"),
Some("66:77:88:99:aa:bb"),
&wordlist,
)
.unwrap();
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let job = engine.crack_job(&id).unwrap();
if job.status != "running" {
assert_eq!(job.status, "done");
assert_eq!(
job.report.as_ref().and_then(|r| r.password.as_deref()),
Some("password")
);
break;
}
assert!(Instant::now() < deadline, "job não terminou");
std::thread::sleep(Duration::from_millis(20));
}
let hs = engine.handshakes(Some("wlan-test"));
assert_eq!(hs[0].password.as_deref(), Some("password"));
}
#[test]
fn errors_are_clear_for_missing_inputs() {
let dir = TempDir::new().unwrap();
let engine = Engine::with_audit_path(dir.path().join("audit.log"));
assert!(engine.scan_snapshot("nope").is_err());
assert!(engine
.start_crack("nope", None, None, dir.path().join("nada.txt"))
.is_err());
assert!(engine.crack_job("nope").is_none());
assert!(engine.audit_entries(Some(10)).unwrap().is_empty());
}
}
+6
View File
@@ -17,6 +17,12 @@ pub enum SabiaError {
#[error("captura crua só é suportada no Linux (AF_PACKET)")]
UnsupportedPlatform,
#[error("entrada inválida: {0}")]
InvalidInput(String),
#[error("captura .pcap inválida: {0}")]
InvalidPcap(String),
#[error("erro de E/S: {0}")]
Io(#[from] io::Error),
+369
View File
@@ -0,0 +1,369 @@
//! Montagem dos handshakes WPA/WPA2 a partir dos quadros EAPOL vistos no ar.
//!
//! 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};
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct RawFrame {
pub message: u8,
pub raw: Vec<u8>,
}
#[derive(Debug, Clone, Default)]
pub struct Handshake {
pub bssid: String,
pub station: String,
pub ssid: Option<String>,
pub anonce: Option<[u8; 32]>,
pub snonce: Option<[u8; 32]>,
pub pmkid: Option<[u8; 16]>,
pub key_version: u8,
/// Bitmask das mensagens vistas (bit 0 = mensagem 1).
pub messages: u8,
/// Quadro EAPOL da mensagem 2, com o MIC — necessário para conferir a
/// senha candidata.
pub eapol_msg2: Option<Vec<u8>>,
pub raw: Vec<RawFrame>,
pub first_seen_unix: u64,
pub last_seen_unix: u64,
pub password: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct HandshakeInfo {
pub bssid: String,
pub station: String,
pub ssid: Option<String>,
pub kind: String,
pub messages: String,
pub complete: bool,
pub crackable: bool,
pub pmkid: Option<String>,
pub key_version: u8,
pub password: Option<String>,
pub raw_frames: usize,
pub first_seen_unix: u64,
pub last_seen_unix: u64,
}
impl Handshake {
pub fn message_list(&self) -> String {
let mut seen = Vec::new();
for msg in 1..=4 {
if self.messages & (1 << (msg - 1)) != 0 {
seen.push(msg.to_string());
}
}
seen.join(",")
}
pub fn kind(&self) -> &'static str {
match (self.pmkid.is_some(), self.eapol_msg2.is_some()) {
(true, true) => "EAPOL + PMKID",
(true, false) => "PMKID",
(false, _) => "EAPOL",
}
}
pub fn is_complete(&self) -> bool {
self.pmkid.is_some()
|| (self.anonce.is_some() && self.snonce.is_some() && self.eapol_msg2.is_some())
}
pub fn is_crackable(&self) -> bool {
let ssid_ok = self.ssid.as_ref().is_some_and(|s| !s.is_empty());
if !ssid_ok {
return false;
}
if self.pmkid.is_some() {
return true;
}
self.anonce.is_some() && self.eapol_msg2.is_some() && matches!(self.key_version, 1 | 2)
}
pub fn info(&self) -> HandshakeInfo {
HandshakeInfo {
bssid: self.bssid.clone(),
station: self.station.clone(),
ssid: self.ssid.clone(),
kind: self.kind().to_string(),
messages: self.message_list(),
complete: self.is_complete(),
crackable: self.is_crackable(),
pmkid: self.pmkid.map(|p| hex(&p)),
key_version: self.key_version,
password: self.password.clone(),
raw_frames: self.raw.len(),
first_seen_unix: self.first_seen_unix,
last_seen_unix: self.last_seen_unix,
}
}
/// Quadros crus (radiotap + 802.11) das mensagens vistas, em ordem.
pub fn raw_frames(&self) -> Vec<Vec<u8>> {
let mut v = self.raw.clone();
v.sort_by_key(|f| f.message);
v.into_iter().map(|f| f.raw).collect()
}
fn store_raw(&mut self, message: u8, raw: &[u8]) {
if let Some(existing) = self.raw.iter_mut().find(|f| f.message == message) {
existing.raw = raw.to_vec();
} else {
self.raw.push(RawFrame {
message,
raw: raw.to_vec(),
});
}
}
}
#[derive(Debug, Default)]
pub struct HandshakeTracker {
map: HashMap<(String, String), Handshake>,
}
impl HandshakeTracker {
pub fn new() -> Self {
Self::default()
}
/// Processa um quadro 802.11. Retorna `true` quando o quadro era EAPOL e
/// atualizou (ou criou) um handshake.
pub fn ingest(
&mut self,
frame: &dot11::Frame<'_>,
raw: &[u8],
ssid_hint: Option<&str>,
now: u64,
) -> bool {
let Some(payload) = frame.eapol() else {
return false;
};
let Some(key) = eapol::parse(payload) else {
return false;
};
if key.message == 0 || !key.pairwise {
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).
let bssid = frame.bssid_str();
let station = match key.message {
1 | 3 => frame.addr1.map(|m| dot11::mac_to_string(&m)),
_ => frame.addr2.map(|m| dot11::mac_to_string(&m)),
};
let (Some(bssid), Some(station)) = (bssid, station) else {
return false;
};
if bssid == station {
return false;
}
let entry = self
.map
.entry((bssid.clone(), station.clone()))
.or_insert_with(|| Handshake {
bssid,
station,
first_seen_unix: now,
..Default::default()
});
if entry.ssid.is_none() {
if let Some(ssid) = ssid_hint.filter(|s| !s.is_empty()) {
entry.ssid = Some(ssid.to_string());
}
}
if entry.first_seen_unix == 0 {
entry.first_seen_unix = now;
}
entry.last_seen_unix = now;
entry.messages |= 1 << (key.message - 1);
entry.key_version = key.key_version;
entry.store_raw(key.message, raw);
apply_key(entry, &key);
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).
pub fn update_ssid(&mut self, bssid: &str, ssid: &str) {
if ssid.is_empty() {
return;
}
for hs in self.map.values_mut() {
if hs.bssid == bssid && hs.ssid.is_none() {
hs.ssid = Some(ssid.to_string());
}
}
}
pub fn get(&self, bssid: &str, station: &str) -> Option<&Handshake> {
self.map.get(&(bssid.to_string(), station.to_string()))
}
pub fn get_mut(&mut self, bssid: &str, station: &str) -> Option<&mut Handshake> {
self.map.get_mut(&(bssid.to_string(), station.to_string()))
}
pub fn all(&self) -> Vec<&Handshake> {
let mut v: Vec<_> = self.map.values().collect();
v.sort_by(|a, b| {
b.last_seen_unix
.cmp(&a.last_seen_unix)
.then_with(|| a.bssid.cmp(&b.bssid))
});
v
}
pub fn infos(&self) -> Vec<HandshakeInfo> {
self.all().into_iter().map(Handshake::info).collect()
}
/// Primeiro handshake que pode ser quebrado; filtros opcionais por BSSID
/// e estação.
pub fn first_crackable(
&self,
bssid: Option<&str>,
station: Option<&str>,
) -> Option<&Handshake> {
self.all().into_iter().find(|h| {
h.is_crackable()
&& bssid.map_or(true, |b| b.eq_ignore_ascii_case(&h.bssid))
&& station.map_or(true, |s| s.eq_ignore_ascii_case(&h.station))
})
}
pub fn set_password(&mut self, bssid: &str, station: &str, password: &str) {
if let Some(hs) = self.get_mut(bssid, station) {
hs.password = Some(password.to_string());
}
}
pub fn len(&self) -> usize {
self.map.len()
}
pub fn is_empty(&self) -> bool {
self.map.is_empty()
}
}
fn apply_key(hs: &mut Handshake, key: &EapolKey) {
match key.message {
1 => {
hs.anonce = Some(key.nonce);
if key.pmkid.is_some() {
hs.pmkid = key.pmkid;
}
}
2 => {
hs.snonce = Some(key.nonce);
hs.eapol_msg2 = Some(key.frame.clone());
}
3 => hs.anonce = Some(key.nonce),
4 => hs.snonce = Some(key.nonce),
_ => {}
}
}
fn hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
use std::fmt::Write;
let _ = write!(s, "{b:02x}");
}
s
}
#[cfg(test)]
mod tests {
use super::*;
use crate::eapol::KEY_DATA_OFFSET;
use crate::testutil::{frame_with_eapol, key_frame};
fn key_frame_for(message: u8, nonce: [u8; 32], key_data: &[u8]) -> Vec<u8> {
key_frame(message, 2, nonce, key_data)
}
fn ap() -> [u8; 6] {
[0x11; 6]
}
fn sta() -> [u8; 6] {
[0x22; 6]
}
#[test]
fn assembles_complete_handshake() {
let mut tracker = HandshakeTracker::new();
let m1 = key_frame_for(1, [0xaa; 32], &[]);
let f1 = frame_with_eapol(true, ap(), sta(), [0x33; 6], &m1);
let parsed1 = dot11::parse(&f1).expect("frame");
assert!(tracker.ingest(&parsed1, &f1, Some("MinhaRede"), 10));
let m2 = key_frame_for(2, [0xbb; 32], &[]);
let f2 = frame_with_eapol(false, ap(), sta(), [0x33; 6], &m2);
let parsed2 = dot11::parse(&f2).expect("frame");
assert!(tracker.ingest(&parsed2, &f2, Some("MinhaRede"), 11));
let hs = tracker
.get("11:11:11:11:11:11", "22:22:22:22:22:22")
.expect("hs");
assert_eq!(hs.ssid.as_deref(), Some("MinhaRede"));
assert_eq!(hs.messages, 0b11);
assert!(hs.is_complete());
assert!(hs.is_crackable());
assert_eq!(hs.anonce, Some([0xaa; 32]));
assert_eq!(hs.snonce, Some([0xbb; 32]));
assert_eq!(hs.eapol_msg2.as_deref(), Some(&m2[..]));
assert_eq!(hs.raw_frames().len(), 2);
}
#[test]
fn captures_pmkid_from_message_one() {
let mut data = vec![0xdd, 20, 0x00, 0x0f, 0xac, 0x04];
data.extend_from_slice(&[0xcd; 16]);
let m1 = key_frame_for(1, [0xaa; 32], &data);
assert_eq!(m1.len(), KEY_DATA_OFFSET + data.len());
let f1 = frame_with_eapol(true, ap(), sta(), [0x33; 6], &m1);
let mut tracker = HandshakeTracker::new();
let parsed = dot11::parse(&f1).expect("frame");
assert!(tracker.ingest(&parsed, &f1, Some("Rede"), 1));
let hs = tracker
.get("11:11:11:11:11:11", "22:22:22:22:22:22")
.expect("hs");
assert_eq!(hs.pmkid, Some([0xcd; 16]));
assert!(hs.is_complete());
assert!(hs.is_crackable());
assert_eq!(hs.kind(), "PMKID");
}
#[test]
fn late_beacon_fills_ssid() {
let m1 = key_frame_for(1, [1; 32], &[]);
let f1 = frame_with_eapol(true, ap(), sta(), [0x33; 6], &m1);
let mut tracker = HandshakeTracker::new();
let parsed = dot11::parse(&f1).expect("frame");
tracker.ingest(&parsed, &f1, None, 1);
assert!(!tracker
.get("11:11:11:11:11:11", "22:22:22:22:22:22")
.unwrap()
.is_crackable());
tracker.update_ssid("11:11:11:11:11:11", "RedeTardia");
assert!(
tracker
.get("11:11:11:11:11:11", "22:22:22:22:22:22")
.unwrap()
.ssid
.as_deref()
== Some("RedeTardia")
);
}
}
+164
View File
@@ -0,0 +1,164 @@
//! Construção e envio de quadros de gerenciamento (desautenticação e
//! desassociação) para testes autorizados de robustez de redes Wi-Fi.
//!
//! 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.
use crate::error::Result;
#[cfg(target_os = "linux")]
use crate::error::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.
fn radiotap_min() -> [u8; 8] {
[0, 0, 8, 0, 0, 0, 0, 0]
}
fn mgmt_frame(subtype: u8, bssid: [u8; 6], destination: [u8; 6], reason: u16) -> Vec<u8> {
let fc: u16 = (subtype as u16) << 4;
let mut f = Vec::with_capacity(8 + 24 + 2);
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(&bssid); // addr2: BSSID/transmissor
f.extend_from_slice(&bssid); // addr3: BSSID
f.extend_from_slice(&[0u8; 2]); // número de sequência
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`].
pub fn deauth_frame(bssid: [u8; 6], destination: [u8; 6], reason: u16) -> Vec<u8> {
mgmt_frame(12, bssid, destination, reason)
}
/// Quadro de desassociação (subtipo 10).
pub fn disassoc_frame(bssid: [u8; 6], destination: [u8; 6], reason: u16) -> Vec<u8> {
mgmt_frame(10, bssid, destination, reason)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Reason {
Unspecified = 1,
PreviousAuthInvalid = 2,
DeauthLeaving = 3,
Inactivity = 4,
Class2FrameFromNonAuth = 7,
}
impl Reason {
pub fn from_u16(value: u16) -> u16 {
if value == 0 {
1
} else {
value
}
}
}
#[cfg(target_os = "linux")]
pub struct RawInjector {
fd: std::os::fd::RawFd,
}
#[cfg(target_os = "linux")]
impl RawInjector {
pub fn open(iface: &str) -> Result<Self> {
use std::ffi::CString;
use std::io;
use std::mem;
let fd = unsafe { libc::socket(libc::AF_PACKET, libc::SOCK_RAW, 0x0003) };
if fd < 0 {
let err = io::Error::last_os_error();
return Err(if err.raw_os_error() == Some(libc::EPERM) {
SabiaError::PermissionDenied
} else {
SabiaError::Io(err)
});
}
let cname =
CString::new(iface).map_err(|_| SabiaError::InterfaceNotFound(iface.to_string()))?;
let ifindex = unsafe { libc::if_nametoindex(cname.as_ptr()) };
if ifindex == 0 {
unsafe { libc::close(fd) };
return Err(SabiaError::InterfaceNotFound(iface.to_string()));
}
let mut sll: libc::sockaddr_ll = unsafe { mem::zeroed() };
sll.sll_family = libc::AF_PACKET as u16;
sll.sll_protocol = 0x0003u16.to_be();
sll.sll_ifindex = ifindex as i32;
let ret = unsafe {
libc::bind(
fd,
&sll as *const libc::sockaddr_ll as *const libc::sockaddr,
mem::size_of::<libc::sockaddr_ll>() as u32,
)
};
if ret < 0 {
let err = io::Error::last_os_error();
unsafe { libc::close(fd) };
return Err(SabiaError::Io(err));
}
Ok(Self { fd })
}
pub fn send(&self, frame: &[u8]) -> Result<usize> {
let n = unsafe {
libc::send(
self.fd,
frame.as_ptr() as *const libc::c_void,
frame.len(),
0,
)
};
if n < 0 {
return Err(SabiaError::Io(std::io::Error::last_os_error()));
}
Ok(n as usize)
}
}
#[cfg(target_os = "linux")]
impl Drop for RawInjector {
fn drop(&mut self) {
unsafe { libc::close(self.fd) };
}
}
#[cfg(test)]
mod tests {
use super::*;
fn bssid() -> [u8; 6] {
[0x00, 0x11, 0x22, 0x33, 0x44, 0x55]
}
fn station() -> [u8; 6] {
[0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb]
}
#[test]
fn builds_deauth_frame() {
let f = deauth_frame(bssid(), station(), 7);
assert_eq!(&f[0..4], &[0, 0, 8, 0]);
assert_eq!(u16::from_le_bytes([f[8], f[9]]), 0x00c0);
assert_eq!(&f[12..18], &station());
assert_eq!(&f[18..24], &bssid());
assert_eq!(&f[24..30], &bssid());
assert_eq!(u16::from_le_bytes([f[32], f[33]]), 7);
}
#[test]
fn builds_disassoc_broadcast_frame() {
let f = disassoc_frame(bssid(), BROADCAST, 1);
assert_eq!(u16::from_le_bytes([f[8], f[9]]), 0x00a0);
assert_eq!(&f[12..18], &BROADCAST);
assert_eq!(f.len(), 8 + 24 + 2);
}
}
+13 -4
View File
@@ -1,14 +1,20 @@
//! `sabia-core`: tudo que fala com o rádio Wi-Fi, interpreta quadros 802.11
//! e mantém a trilha de auditoria. A CLI e a interface gráfica são só
//! consumidoras disto — a parte complexa (netlink, radiotap, parsing de
//! frame) fica inteira aqui dentro.
//! `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.
pub mod audit;
pub mod crack;
pub mod doctor;
pub mod dot11;
pub mod eapol;
pub mod engine;
pub mod error;
pub mod handshake;
pub mod iface;
pub mod inject;
pub mod netlink;
pub mod pcapreader;
pub mod pcapwriter;
pub mod radiotap;
pub mod scan;
@@ -17,4 +23,7 @@ pub mod update;
#[cfg(target_os = "linux")]
pub mod capture;
#[cfg(test)]
pub(crate) mod testutil;
pub use error::{Result, SabiaError};
+179 -28
View File
@@ -1,15 +1,26 @@
//! Controle de modo (managed/monitor) e canal via `iw`.
//! Controle de modo (managed/monitor), canal e leitura das capacidades do
//! rádio via `iw`.
//!
//! Isso é uma ponte deliberada: o protocolo real é nl80211 por netlink, e
//! trocar este módulo por uma implementação nativa (sem depender do binário
//! `iw` instalado) é o próximo passo natural. Por ora, `iw` é amplamente
//! disponível, testado e evita reimplementar genl/nl80211 antes de validar
//! o resto do pipeline com o adaptador real.
//! É 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;
use crate::radiotap::{band_for_freq, Band};
use std::process::Command;
#[derive(Debug, Clone, serde::Serialize)]
pub struct ChannelInfo {
pub channel: u32,
pub freq_mhz: u16,
pub band: Band,
pub disabled: bool,
pub max_power_dbm: Option<f32>,
}
fn run(cmd: &str, args: &[&str]) -> Result<String> {
let out = Command::new(cmd)
.args(args)
@@ -25,7 +36,10 @@ fn run(cmd: &str, args: &[&str]) -> Result<String> {
} else {
detail
};
if detail.contains("Operation not permitted") || detail.contains("nl80211 not found") {
if detail.contains("Operation not permitted")
|| detail.contains("Insufficient privileges")
|| detail.contains("nl80211 not found")
{
return Err(SabiaError::PermissionDenied);
}
return Err(SabiaError::CommandFailed {
@@ -36,6 +50,28 @@ fn run(cmd: &str, args: &[&str]) -> Result<String> {
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
pub fn command_exists(name: &str) -> bool {
std::env::var_os("PATH")
.map(|paths| {
std::env::split_paths(&paths).any(|dir| {
let p = dir.join(name);
p.is_file()
})
})
.unwrap_or(false)
}
pub fn is_root() -> bool {
#[cfg(unix)]
{
unsafe { libc::geteuid() == 0 }
}
#[cfg(not(unix))]
{
false
}
}
pub fn iw_dev_type(iface: &str) -> Option<Mode> {
let out = run("iw", &["dev", iface, "info"]).ok()?;
for line in out.lines() {
@@ -51,35 +87,76 @@ pub fn iw_dev_type(iface: &str) -> Option<Mode> {
None
}
/// Lista (canal, frequência) suportados pelo phy da interface.
pub fn supported_channels(iface: &str) -> Result<Vec<u32>> {
pub fn phy_for_iface(iface: &str) -> Result<String> {
let out = run("iw", &["dev", iface, "info"])?;
let phy = out
.lines()
out.lines()
.find_map(|l| l.trim().strip_prefix("wiphy "))
.map(|n| format!("phy{n}"))
.ok_or_else(|| SabiaError::CommandFailed {
cmd: "iw dev info".into(),
detail: "wiphy não encontrado na saída".into(),
})?;
})
}
/// Lista canais suportados pelo phy da interface.
pub fn channels(iface: &str) -> Result<Vec<ChannelInfo>> {
let phy = phy_for_iface(iface)?;
let info = run("iw", &["phy", &phy, "info"])?;
let mut channels = Vec::new();
for line in info.lines() {
let line = line.trim();
// formato: "* 2412 MHz [1] (20.0 dBm)"
if let Some(rest) = line.strip_prefix("* ") {
if let Some(start) = rest.find('[') {
if let Some(end) = rest[start..].find(']') {
if let Ok(ch) = rest[start + 1..start + end].parse::<u32>() {
channels.push(ch);
}
}
Ok(parse_phy_channels(&info))
}
pub fn supported_channels(iface: &str) -> Result<Vec<u32>> {
Ok(channels(iface)?
.into_iter()
.filter(|c| !c.disabled)
.map(|c| c.channel)
.collect())
}
/// Canais a percorrer. `band` aceita `"2.4"`, `"5"`, `"6"`, `"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,
Some(b) if b.is_empty() || b == "all" || b == "auto" => None,
Some(b) => Some(match b.as_str() {
"2.4" | "2.4ghz" | "2g" => Band::B2G4,
"5" | "5ghz" | "5g" => Band::B5,
"6" | "6ghz" | "6g" => Band::B6,
other => {
return Err(SabiaError::InvalidInput(format!(
"banda `{other}` inválida (use 2.4, 5, 6 ou all)"
)))
}
}
}
channels.sort_unstable();
channels.dedup();
Ok(channels)
}),
};
let mut v: Vec<u32> = channels(iface)?
.into_iter()
.filter(|c| !c.disabled)
.filter(|c| want.map_or(true, |w| c.band == w))
.map(|c| c.channel)
.collect();
v.sort_unstable();
v.dedup();
Ok(v)
}
pub fn monitor_supported(iface: &str) -> bool {
let Ok(phy) = phy_for_iface(iface) else {
return false;
};
let Ok(info) = run("iw", &["phy", &phy, "info"]) else {
return false;
};
info.lines().any(|l| l.trim() == "* monitor")
}
pub fn driver_name(iface: &str) -> Option<String> {
let path = std::path::Path::new("/sys/class/net")
.join(iface)
.join("device/driver");
std::fs::read_link(path)
.ok()
.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).
@@ -99,3 +176,77 @@ pub fn set_channel(iface: &str, channel: u32) -> Result<()> {
)?;
Ok(())
}
pub fn parse_phy_channels(text: &str) -> Vec<ChannelInfo> {
let mut out = Vec::new();
for line in text.lines() {
let line = line.trim();
let Some(rest) = line.strip_prefix("* ") else {
continue;
};
let Some(mhz_at) = rest.find(" MHz") else {
continue;
};
let Ok(freq) = rest[..mhz_at].trim().parse::<f32>() else {
continue;
};
let Some(open) = rest.find('[') else {
continue;
};
let Some(close_rel) = rest[open..].find(']') else {
continue;
};
let Ok(channel) = rest[open + 1..open + close_rel].parse::<u32>() else {
continue;
};
let freq = freq.round() as u16;
let disabled = rest.contains("(disabled)") || rest.contains("no IR");
let max_power_dbm = rest
.rfind('(')
.and_then(|p| rest[p + 1..].strip_suffix(" dBm)"))
.and_then(|v| v.trim().parse::<f32>().ok());
out.push(ChannelInfo {
channel,
freq_mhz: freq,
band: band_for_freq(freq),
disabled,
max_power_dbm,
});
}
out
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = "\
Wiphy phy0
\tBand 1:
\t\tFrequencies:
\t\t\t* 2412.0 MHz [1] (20.0 dBm)
\t\t\t* 2437.0 MHz [6] (20.0 dBm)
\t\t\t* 2484.0 MHz [14] (disabled)
\tBand 2:
\t\tFrequencies:
\t\t\t* 5180.0 MHz [36] (23.0 dBm)
\t\t\t* 5200.0 MHz [40] (23.0 dBm)
\tBand 4:
\t\tFrequencies:
\t\t\t* 5955.0 MHz [1] (12.0 dBm)
";
#[test]
fn parses_iw_phy_output() {
let ch = parse_phy_channels(SAMPLE);
assert_eq!(ch.len(), 6);
assert_eq!(ch[0].channel, 1);
assert_eq!(ch[0].freq_mhz, 2412);
assert_eq!(ch[0].band, Band::B2G4);
assert_eq!(ch[0].max_power_dbm, Some(20.0));
assert!(ch[2].disabled);
assert_eq!(ch[3].band, Band::B5);
assert_eq!(ch[5].band, Band::B6);
assert_eq!(ch[5].channel, 1);
}
}
+156
View File
@@ -0,0 +1,156 @@
//! 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).
use crate::error::{Result, SabiaError};
use std::fs::File;
use std::io::{BufReader, Read};
use std::path::Path;
pub const LINKTYPE_ETHERNET: u32 = 1;
pub const LINKTYPE_IEEE802_11: u32 = 105;
pub const LINKTYPE_IEEE802_11_RADIOTAP: u32 = 127;
#[derive(Debug, Clone)]
pub struct PcapFrame {
pub data: Vec<u8>,
pub ts_sec: u32,
pub ts_usec: u32,
}
pub struct PcapReader {
input: BufReader<File>,
little_endian: bool,
nanosecond: bool,
linktype: u32,
}
impl PcapReader {
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
let file = File::open(path)?;
let mut input = BufReader::new(file);
let mut header = [0u8; 24];
input
.read_exact(&mut header)
.map_err(|e| SabiaError::InvalidPcap(format!("cabeçalho incompleto: {e}")))?;
let magic_le = u32::from_le_bytes(header[0..4].try_into().unwrap());
let (little_endian, nanosecond) = match magic_le {
0xa1b2_c3d4 => (true, false),
0xa1b2_3c4d => (true, true),
0xd4c3_b2a1 => (false, false),
0x4d3c_b2a1 => (false, true),
other => {
return Err(SabiaError::InvalidPcap(format!(
"magic 0x{other:08x} não reconhecido"
)))
}
};
let read_u32 = |bytes: &[u8]| {
let arr: [u8; 4] = bytes.try_into().unwrap();
if little_endian {
u32::from_le_bytes(arr)
} else {
u32::from_be_bytes(arr)
}
};
let snaplen = read_u32(&header[16..20]);
let linktype = read_u32(&header[20..24]);
if snaplen == 0 || snaplen > 16 * 1024 * 1024 {
return Err(SabiaError::InvalidPcap(format!(
"snaplen inválido: {snaplen}"
)));
}
Ok(Self {
input,
little_endian,
nanosecond,
linktype,
})
}
pub fn linktype(&self) -> u32 {
self.linktype
}
/// Próximo quadro; `None` no fim do arquivo.
pub fn next_frame(&mut self) -> Result<Option<PcapFrame>> {
let mut rec = [0u8; 16];
match self.input.read_exact(&mut rec) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None),
Err(e) => return Err(e.into()),
}
let read_u32 = |bytes: &[u8]| {
let arr: [u8; 4] = bytes.try_into().unwrap();
if self.little_endian {
u32::from_le_bytes(arr)
} else {
u32::from_be_bytes(arr)
}
};
let ts_sec = read_u32(&rec[0..4]);
let ts_usec = read_u32(&rec[4..8]);
let caplen = read_u32(&rec[8..12]) as usize;
if caplen > 16 * 1024 * 1024 {
return Err(SabiaError::InvalidPcap(format!("caplen absurdo: {caplen}")));
}
let mut data = vec![0u8; caplen];
self.input
.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.
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);
data = with_radiotap;
}
let ts_usec = if self.nanosecond {
ts_usec / 1000
} else {
ts_usec
};
Ok(Some(PcapFrame {
data,
ts_sec,
ts_usec,
}))
}
pub fn supports_80211(&self) -> bool {
matches!(
self.linktype,
LINKTYPE_IEEE802_11 | LINKTYPE_IEEE802_11_RADIOTAP
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pcapwriter::PcapWriter;
use tempfile::NamedTempFile;
#[test]
fn roundtrips_frames_written_by_sabia() {
let file = NamedTempFile::new().unwrap();
{
let mut w = PcapWriter::create(file.path()).unwrap();
w.write_frame(&[0, 0, 8, 0, 0, 0, 0, 0, 0x80, 0]).unwrap();
w.write_frame(&[1, 2, 3, 4]).unwrap();
w.flush().unwrap();
}
let mut r = PcapReader::open(file.path()).unwrap();
assert_eq!(r.linktype(), LINKTYPE_IEEE802_11_RADIOTAP);
let f1 = r.next_frame().unwrap().unwrap();
assert_eq!(f1.data.len(), 10);
let f2 = r.next_frame().unwrap().unwrap();
assert_eq!(f2.data, vec![1, 2, 3, 4]);
assert!(r.next_frame().unwrap().is_none());
}
}
+167 -30
View File
@@ -1,19 +1,52 @@
//! Parser mínimo de cabeçalho Radiotap — só os campos que usamos
//! (canal/frequência e força de sinal). Especificação:
//! <https://www.radiotap.org/>
//! Parser de cabeçalho Radiotap.
//!
//! 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 {
#[serde(rename = "2.4 GHz")]
B2G4,
#[serde(rename = "5 GHz")]
B5,
#[serde(rename = "6 GHz")]
B6,
#[serde(rename = "desconhecida")]
Unknown,
}
impl Band {
pub fn label(self) -> &'static str {
match self {
Band::B2G4 => "2.4 GHz",
Band::B5 => "5 GHz",
Band::B6 => "6 GHz",
Band::Unknown => "desconhecida",
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct RadiotapInfo {
pub freq_mhz: Option<u16>,
pub channel: Option<u32>,
pub band: Option<Band>,
pub signal_dbm: Option<i8>,
pub noise_dbm: Option<i8>,
pub header_len: usize,
/// Bitmap "present" da primeira palavra (campos padrão).
pub present: u32,
}
// Índices de bit no bitmap "present" que nos interessam.
const BIT_TSFT: u32 = 1 << 0;
const BIT_FLAGS: u32 = 1 << 1;
const BIT_RATE: u32 = 1 << 2;
const BIT_CHANNEL: u32 = 1 << 3;
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).
@@ -22,15 +55,18 @@ pub fn parse(buf: &[u8]) -> Option<RadiotapInfo> {
return None; // version deve ser 0
}
let header_len = u16::from_le_bytes([buf[2], buf[3]]) as usize;
if header_len > buf.len() || header_len < 8 {
if header_len < 8 || header_len > buf.len() {
return None;
}
// Pode haver múltiplas palavras de "present" (bit 31 = continua).
let mut present_words = Vec::new();
let mut offset = 4;
// 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.
let mut present = 0u32;
let mut offset = 4usize;
let mut first = true;
loop {
if offset + 4 > buf.len() {
if offset + 4 > header_len {
return None;
}
let word = u32::from_le_bytes([
@@ -39,36 +75,32 @@ pub fn parse(buf: &[u8]) -> Option<RadiotapInfo> {
buf[offset + 2],
buf[offset + 3],
]);
present_words.push(word);
if first {
present = word;
first = false;
}
offset += 4;
if word & (1 << 31) == 0 {
break;
}
}
let present = present_words[0]; // só o primeiro conjunto de campos padrão nos interessa
let mut info = RadiotapInfo {
header_len,
present,
..Default::default()
};
let mut cursor = offset;
// TSFT (u64, align 8) viria antes de Flags; para o nosso subconjunto de
// campos simplificamos assumindo captura via mac80211 (ordem padrão:
// TSFT, Flags, Rate, Channel, ..., AntSignal). Avançamos campo a campo
// respeitando alinhamento, sem travar se algum bit não estiver setado.
macro_rules! align {
($a:expr) => {{
let rem = cursor % $a;
if rem != 0 {
cursor += $a - rem;
}
}};
($a:expr) => {
cursor = (cursor + ($a - 1)) & !($a - 1);
};
}
if present & (1 << 0) != 0 {
if present & BIT_TSFT != 0 {
align!(8);
cursor += 8; // TSFT
cursor += 8;
}
if present & BIT_FLAGS != 0 {
cursor += 1;
@@ -78,19 +110,26 @@ pub fn parse(buf: &[u8]) -> Option<RadiotapInfo> {
}
if present & BIT_CHANNEL != 0 {
align!(2);
if cursor + 4 <= buf.len() {
if cursor + 4 <= header_len {
let freq = u16::from_le_bytes([buf[cursor], buf[cursor + 1]]);
info.freq_mhz = Some(freq);
info.channel = freq_to_channel(freq);
info.band = Some(band_for_freq(freq));
}
cursor += 4;
}
// FHSS (bit 4) — raro, ignorado.
if present & (1 << 4) != 0 {
if present & BIT_FHSS != 0 {
align!(2);
cursor += 2;
}
if present & BIT_ANTSIGNAL != 0 && cursor < buf.len() {
info.signal_dbm = Some(buf[cursor] as i8);
if present & BIT_ANTSIGNAL != 0 {
if cursor < header_len {
info.signal_dbm = Some(buf[cursor] as i8);
}
cursor += 1;
}
if present & BIT_ANTNOISE != 0 && cursor < header_len {
info.noise_dbm = Some(buf[cursor] as i8);
}
Some(info)
@@ -98,9 +137,107 @@ pub fn parse(buf: &[u8]) -> Option<RadiotapInfo> {
pub fn freq_to_channel(freq_mhz: u16) -> Option<u32> {
match freq_mhz {
2412..=2472 => Some(((freq_mhz - 2412) / 5 + 1) as u32),
2412..=2472 => Some(((freq_mhz as u32 - 2412) / 5) + 1),
2484 => Some(14),
5000..=5900 => Some(((freq_mhz - 5000) / 5) as u32),
// 4.9 GHz / segurança pública (canais 182–196).
4910 => Some(182),
4920 => Some(184),
4940 => Some(188),
4960 => Some(192),
4980 => Some(196),
5000..=5895 => Some((freq_mhz as u32 - 5000) / 5),
5955..=7115 => Some((freq_mhz as u32 - 5950) / 5),
_ => None,
}
}
pub fn channel_to_freq(channel: u32, band: Band) -> Option<u16> {
match band {
Band::B2G4 => match channel {
1..=13 => Some((2407 + channel * 5) as u16),
14 => Some(2484),
_ => None,
},
Band::B5 => match channel {
182 => Some(4910),
184 => Some(4920),
188 => Some(4940),
192 => Some(4960),
196 => Some(4980),
32..=177 => Some((5000 + channel * 5) as u16),
_ => None,
},
Band::B6 => match channel {
1..=233 => Some((5950 + channel * 5) as u16),
_ => None,
},
Band::Unknown => None,
}
}
pub fn band_for_freq(freq_mhz: u16) -> Band {
match freq_mhz {
2400..=2500 => Band::B2G4,
4900..=5925 => Band::B5,
5926..=7125 => Band::B6,
_ => Band::Unknown,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn radiotap_with_signal(freq: u16, signal: i8) -> Vec<u8> {
// present: TSFT | Flags | Rate | Channel | AntSignal
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(&present.to_le_bytes());
// present termina em 8; TSFT alinhado em 8
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)
buf.extend_from_slice(&freq.to_le_bytes());
buf.extend_from_slice(&0xa0u16.to_le_bytes());
buf.push(signal as u8);
let len = buf.len() as u16;
buf[2..4].copy_from_slice(&len.to_le_bytes());
buf
}
#[test]
fn parses_signal_and_channel() {
let buf = radiotap_with_signal(2437, -42);
let info = parse(&buf).expect("radiotap válido");
assert_eq!(info.freq_mhz, Some(2437));
assert_eq!(info.channel, Some(6));
assert_eq!(info.band, Some(Band::B2G4));
assert_eq!(info.signal_dbm, Some(-42));
assert_eq!(info.header_len, buf.len());
}
#[test]
fn rejects_invalid_version_and_length() {
assert!(parse(&[1, 0, 8, 0, 0, 0, 0, 0]).is_none());
assert!(parse(&[0, 0, 4, 0, 0, 0, 0, 0]).is_none());
assert!(parse(&[0, 0, 200, 0, 0, 0, 0, 0]).is_none());
}
#[test]
fn maps_frequencies_across_bands() {
assert_eq!(freq_to_channel(2412), Some(1));
assert_eq!(freq_to_channel(2437), Some(6));
assert_eq!(freq_to_channel(2484), Some(14));
assert_eq!(freq_to_channel(5180), Some(36));
assert_eq!(freq_to_channel(5955), Some(1));
assert_eq!(freq_to_channel(6115), Some(33));
assert_eq!(freq_to_channel(7115), Some(233));
assert_eq!(freq_to_channel(1000), None);
assert_eq!(band_for_freq(2437), Band::B2G4);
assert_eq!(band_for_freq(5180), Band::B5);
assert_eq!(band_for_freq(6115), Band::B6);
}
}
+413 -60
View File
@@ -1,9 +1,9 @@
//! Agregação de quadros capturados em uma tabela de APs e clientes vistos,
//! no espírito do `airodump-ng` — mas como uma struct reaproveitável tanto
//! pela CLI quanto pela interface gráfica.
//! Agregação de quadros capturados em tabelas de redes, clientes e
//! handshakes — o coração do que a CLI e a interface mostram.
use crate::dot11::{self, FrameKind, Security};
use crate::radiotap;
use crate::handshake::{Handshake, HandshakeInfo, HandshakeTracker};
use crate::radiotap::{self, Band};
use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};
@@ -11,17 +11,73 @@ use std::time::{SystemTime, UNIX_EPOCH};
pub struct AccessPoint {
pub bssid: String,
pub ssid: Option<String>,
pub hidden: bool,
pub channel: Option<u32>,
pub band: Option<Band>,
pub freq_mhz: Option<u16>,
pub security: Option<String>,
pub last_signal_dbm: Option<i8>,
pub beacons_seen: u64,
pub cipher: Option<String>,
pub akm: Vec<String>,
pub pmf: bool,
pub wps: bool,
pub signal_dbm: Option<i8>,
pub max_signal_dbm: Option<i8>,
pub beacons: u64,
pub data_frames: u64,
pub clients: Vec<String>,
pub first_seen_unix: u64,
pub last_seen_unix: u64,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Client {
pub mac: String,
pub bssid: Option<String>,
pub signal_dbm: Option<i8>,
pub max_signal_dbm: Option<i8>,
pub packets: u64,
pub probes: Vec<String>,
pub associated: bool,
pub first_seen_unix: u64,
pub last_seen_unix: u64,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct IngestEvent {
pub eapol: bool,
pub handshake_updated: bool,
pub deauth: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ScanSnapshot {
pub iface: String,
pub running: bool,
pub channel: Option<u32>,
pub stats: ScanStats,
pub access_points: Vec<AccessPoint>,
pub clients: Vec<Client>,
pub handshakes: Vec<HandshakeInfo>,
}
#[derive(Debug, Clone, Copy, Default, serde::Serialize)]
pub struct ScanStats {
pub frames: u64,
pub eapol_frames: u64,
pub deauth_frames: u64,
pub aps: usize,
pub clients: usize,
pub handshakes: usize,
}
#[derive(Default)]
pub struct ScanTable {
aps: HashMap<String, AccessPoint>,
clients: HashMap<String, Client>,
handshakes: HandshakeTracker,
frames: u64,
eapol_frames: u64,
deauth_frames: u64,
}
impl ScanTable {
@@ -29,91 +85,388 @@ impl ScanTable {
Self::default()
}
/// Processa um quadro radiotap+802.11 crú, atualizando a tabela.
/// Retorna `true` se o quadro continha um pacote EAPOL (indício de
/// handshake WPA em andamento — útil para o chamador decidir salvar em
/// pcap).
/// Processa um quadro radiotap+802.11 crú. Retorna `true` se o quadro
/// continha EAPOL (mantido para chamadores antigos).
pub fn ingest(&mut self, raw: &[u8]) -> bool {
let rt = match radiotap::parse(raw) {
Some(rt) => rt,
None => return false,
let now = unix_now();
self.ingest_at(raw, now).eapol
}
pub fn ingest_at(&mut self, raw: &[u8], now: u64) -> IngestEvent {
let mut event = IngestEvent::default();
let Some(rt) = radiotap::parse(raw) else {
return event;
};
if rt.header_len >= raw.len() {
return false;
return event;
}
let dot11_buf = &raw[rt.header_len..];
let frame = match dot11::parse(dot11_buf) {
Some(f) => f,
None => return false,
let Some(frame) = dot11::parse(&raw[rt.header_len..]) else {
return event;
};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
self.frames += 1;
match frame.kind {
FrameKind::Beacon | FrameKind::ProbeResponse => {
if let Some(bssid) = frame.bssid_str() {
let info = frame.management_info();
let channel = info
.channel_ie
.or_else(|| rt.freq_mhz.and_then(radiotap::freq_to_channel));
let entry = self
.aps
.entry(bssid.clone())
.or_insert_with(|| AccessPoint {
bssid,
ssid: None,
channel: None,
security: None,
last_signal_dbm: None,
beacons_seen: 0,
clients: Vec::new(),
last_seen_unix: now,
});
if let Some(ssid) = info.ssid {
entry.ssid = Some(ssid);
let Some(bssid) = frame.bssid_str() else {
return event;
};
let info = frame.management_info();
let channel = info
.channel
.or_else(|| rt.freq_mhz.and_then(radiotap::freq_to_channel));
let band = rt.freq_mhz.map(radiotap::band_for_freq);
let ap = self
.aps
.entry(bssid.clone())
.or_insert_with(|| AccessPoint {
bssid: bssid.clone(),
ssid: None,
hidden: false,
channel: None,
band: None,
freq_mhz: None,
security: None,
cipher: None,
akm: Vec::new(),
pmf: false,
wps: false,
signal_dbm: None,
max_signal_dbm: None,
beacons: 0,
data_frames: 0,
clients: Vec::new(),
first_seen_unix: now,
last_seen_unix: now,
});
if let Some(ssid) = info.ssid.clone() {
if !ssid.is_empty() || ap.ssid.is_none() {
ap.ssid = Some(ssid);
}
if channel.is_some() {
entry.channel = channel;
}
entry.security = info.security.map(security_label);
entry.last_signal_dbm = rt.signal_dbm;
entry.beacons_seen += 1;
entry.last_seen_unix = now;
ap.hidden = info.hidden;
}
if channel.is_some() {
ap.channel = channel;
}
if rt.freq_mhz.is_some() {
ap.freq_mhz = rt.freq_mhz;
ap.band = band;
}
ap.security = info.security.map(security_label);
ap.cipher = info.cipher.clone();
ap.akm = info.akm.clone();
ap.pmf = info.pmf;
ap.wps = info.wps;
ap.signal_dbm = rt.signal_dbm;
if let Some(sig) = rt.signal_dbm {
ap.max_signal_dbm = Some(ap.max_signal_dbm.map_or(sig, |m| m.max(sig)));
}
ap.beacons += 1;
ap.last_seen_unix = now;
if let Some(ssid) = ap.ssid.clone().filter(|s| !s.is_empty()) {
self.handshakes.update_ssid(&bssid, &ssid);
}
}
FrameKind::Data => {
// Associa o cliente (endereço de origem) ao BSSID observado,
// quando a direção do quadro indicar que vem de uma estação.
if let (Some(bssid), Some(src)) = (frame.bssid_str(), frame.src_str()) {
FrameKind::ProbeRequest => {
let Some(client) = frame.src_str() else {
return event;
};
let info = frame.management_info();
let c = self.touch_client(&client, now, rt.signal_dbm);
c.packets += 1;
if let Some(ssid) = info.ssid.filter(|s| !s.is_empty()) {
if !c.probes.contains(&ssid) {
c.probes.push(ssid);
}
}
}
FrameKind::AssociationRequest => {
let client = frame.src_str();
let bssid = frame
.addr1
.map(|m| dot11::mac_to_string(&m))
.or_else(|| frame.bssid_str());
if let (Some(client), Some(bssid)) = (client, bssid) {
let c = self.touch_client(&client, now, rt.signal_dbm);
c.packets += 1;
c.bssid = Some(bssid.clone());
c.associated = true;
if let Some(ap) = self.aps.get_mut(&bssid) {
if src != bssid && !ap.clients.contains(&src) {
ap.clients.push(src);
if !ap.clients.contains(&client) {
ap.clients.push(client);
}
}
}
return dot11::is_eapol(frame.body);
}
FrameKind::Data => {
let bssid = match (frame.to_ds, frame.from_ds) {
(true, false) => frame.addr1.map(|m| dot11::mac_to_string(&m)),
(false, true) => frame.addr2.map(|m| dot11::mac_to_string(&m)),
_ => frame.bssid_str(),
};
let client = match (frame.to_ds, frame.from_ds) {
(true, false) => frame.src_str(),
(false, true) => frame.dst_str(),
_ => frame.src_str(),
};
if let (Some(client), Some(bssid)) = (client.clone(), bssid.clone()) {
if client != bssid {
let c = self.touch_client(&client, now, rt.signal_dbm);
c.packets += 1;
c.bssid = Some(bssid.clone());
c.associated = true;
if let Some(ap) = self.aps.get_mut(&bssid) {
ap.data_frames += 1;
if !ap.clients.contains(&client) {
ap.clients.push(client.clone());
}
}
}
}
if frame.eapol().is_some() {
event.eapol = true;
self.eapol_frames += 1;
let hint = bssid
.as_deref()
.and_then(|b| self.aps.get(b))
.and_then(|ap| ap.ssid.clone())
.filter(|s| !s.is_empty());
if self.handshakes.ingest(&frame, raw, hint.as_deref(), now) {
event.handshake_updated = true;
}
}
}
FrameKind::Deauth | FrameKind::Disassoc => {
self.deauth_frames += 1;
event.deauth = true;
let a1 = frame.addr1.map(|m| dot11::mac_to_string(&m));
let a2 = frame.addr2.map(|m| dot11::mac_to_string(&m));
let bssid = frame.bssid_str();
let client = match (&a1, &a2, &bssid) {
(Some(a1), Some(_), Some(b)) if a1 != b => Some(a1.clone()),
(Some(_), Some(a2), Some(b)) if a2 != b => Some(a2.clone()),
_ => None,
};
if let Some(client) = client {
if let Some(c) = self.clients.get_mut(&client) {
c.associated = false;
c.last_seen_unix = now;
}
}
}
_ => {}
}
false
event
}
fn touch_client(&mut self, mac: &str, now: u64, signal: Option<i8>) -> &mut Client {
let c = self
.clients
.entry(mac.to_string())
.or_insert_with(|| Client {
mac: mac.to_string(),
bssid: None,
signal_dbm: None,
max_signal_dbm: None,
packets: 0,
probes: Vec::new(),
associated: false,
first_seen_unix: now,
last_seen_unix: now,
});
c.last_seen_unix = now;
c.signal_dbm = signal;
if let Some(sig) = signal {
c.max_signal_dbm = Some(c.max_signal_dbm.map_or(sig, |m| m.max(sig)));
}
c
}
pub fn access_points(&self) -> Vec<AccessPoint> {
let mut v: Vec<_> = self.aps.values().cloned().collect();
v.sort_by_key(|a| std::cmp::Reverse(a.beacons_seen));
v.sort_by(|a, b| {
b.beacons
.cmp(&a.beacons)
.then_with(|| a.ssid.cmp(&b.ssid))
.then_with(|| a.bssid.cmp(&b.bssid))
});
v
}
pub fn clients(&self) -> Vec<Client> {
let mut v: Vec<_> = self.clients.values().cloned().collect();
v.sort_by(|a, b| b.packets.cmp(&a.packets).then_with(|| a.mac.cmp(&b.mac)));
v
}
pub fn handshakes(&self) -> Vec<HandshakeInfo> {
self.handshakes.infos()
}
pub fn handshake(&self, bssid: &str, station: &str) -> Option<&Handshake> {
self.handshakes.get(bssid, station)
}
/// Handshakes completos (com quadros crus) para exportação.
pub fn handshake_entries(&self) -> Vec<Handshake> {
self.handshakes.all().into_iter().cloned().collect()
}
pub fn first_crackable(
&self,
bssid: Option<&str>,
station: Option<&str>,
) -> Option<&Handshake> {
self.handshakes.first_crackable(bssid, station)
}
pub fn set_password(&mut self, bssid: &str, station: &str, password: &str) {
self.handshakes.set_password(bssid, station, password);
}
pub fn stats(&self) -> ScanStats {
ScanStats {
frames: self.frames,
eapol_frames: self.eapol_frames,
deauth_frames: self.deauth_frames,
aps: self.aps.len(),
clients: self.clients.len(),
handshakes: self.handshakes.len(),
}
}
}
fn security_label(s: Security) -> String {
match s {
Security::Open => "Aberta".into(),
Security::Open => "Open".into(),
Security::Wep => "WEP".into(),
Security::Wpa => "WPA".into(),
Security::Wpa2 => "WPA2".into(),
Security::Wpa2Enterprise => "WPA2-EAP".into(),
Security::Wpa3 => "WPA3".into(),
Security::Wpa3Enterprise => "WPA3-EAP".into(),
Security::Owe => "OWE".into(),
Security::Wpa2Wpa3 => "WPA2/WPA3".into(),
}
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::{
ap, beacon_frame, frame_with_eapol, handshake_frames, probe_request, radiotap_wrap, sta,
};
fn beacon_raw(ssid: &str, akms: &[u8]) -> Vec<u8> {
let frame = beacon_frame(ap(), ssid, 6, akms);
radiotap_wrap(&frame, 2437, -40)
}
#[test]
fn builds_ap_table_from_beacon() {
let mut table = ScanTable::new();
table.ingest_at(&beacon_raw("Casa", &[2]), 100);
let aps = table.access_points();
assert_eq!(aps.len(), 1);
let ap = &aps[0];
assert_eq!(ap.ssid.as_deref(), Some("Casa"));
assert_eq!(ap.bssid, "00:11:22:33:44:55");
assert_eq!(ap.channel, Some(6));
assert_eq!(ap.band, Some(Band::B2G4));
assert_eq!(ap.security.as_deref(), Some("WPA2"));
assert_eq!(ap.cipher.as_deref(), Some("CCMP"));
assert_eq!(ap.signal_dbm, Some(-40));
assert_eq!(ap.beacons, 1);
assert_eq!(ap.first_seen_unix, 100);
}
#[test]
fn tracks_probes_and_data_clients() {
let mut table = ScanTable::new();
table.ingest_at(&beacon_raw("Casa", &[2]), 1);
let probe = radiotap_wrap(&probe_request(sta(), "Casa"), 2437, -55);
table.ingest_at(&probe, 2);
let payload = [0xAA, 0xAA, 0x03, 0, 0, 0, 0x08, 0x00, 1, 2, 3];
let data = crate::testutil::data_frame(false, true, sta(), ap(), [0x33; 6], &payload);
let data = radiotap_wrap(&data, 2437, -50);
table.ingest_at(&data, 3);
let clients = table.clients();
assert_eq!(clients.len(), 1);
let c = &clients[0];
assert_eq!(c.mac, "66:77:88:99:aa:bb");
assert_eq!(c.bssid.as_deref(), Some("00:11:22:33:44:55"));
assert!(c.associated);
assert!(c.probes.contains(&"Casa".to_string()));
assert_eq!(c.packets, 2);
assert_eq!(table.access_points()[0].clients.len(), 1);
assert_eq!(table.stats().frames, 3);
}
#[test]
fn captures_handshake_from_raw_frames() {
let mut table = ScanTable::new();
table.ingest_at(&beacon_raw("RedeWPA", &[2]), 1);
let (m1, m2) = handshake_frames("RedeWPA", "password", 2, false);
let f1 = frame_with_eapol(true, ap(), sta(), [0x33; 6], &m1);
let raw1 = radiotap_wrap(&f1, 2437, -40);
let e1 = table.ingest_at(&raw1, 2);
assert!(e1.eapol);
assert!(e1.handshake_updated);
let f2 = frame_with_eapol(false, ap(), sta(), [0x33; 6], &m2);
let raw2 = radiotap_wrap(&f2, 2437, -41);
let e2 = table.ingest_at(&raw2, 3);
assert!(e2.eapol);
let hs = table.handshakes();
assert_eq!(hs.len(), 1);
assert!(hs[0].complete);
assert!(hs[0].crackable);
assert_eq!(hs[0].ssid.as_deref(), Some("RedeWPA"));
assert_eq!(hs[0].messages, "1,2");
assert!(table.first_crackable(None, None).is_some());
assert!(table
.first_crackable(Some("00:11:22:33:44:55"), None)
.is_some());
assert!(table
.first_crackable(Some("ff:ff:ff:ff:ff:ff"), None)
.is_none());
table.set_password("00:11:22:33:44:55", "66:77:88:99:aa:bb", "password");
assert_eq!(table.handshakes()[0].password.as_deref(), Some("password"));
assert_eq!(table.stats().eapol_frames, 2);
}
#[test]
fn counts_deauth_and_marks_client_disconnected() {
let mut table = ScanTable::new();
table.ingest_at(&beacon_raw("Casa", &[2]), 1);
let payload = [0xAA, 0xAA, 0x03, 0, 0, 0, 0x08, 0x00, 1];
let data = crate::testutil::data_frame(false, true, sta(), ap(), [0x33; 6], &payload);
table.ingest_at(&radiotap_wrap(&data, 2437, -50), 2);
let mut deauth = Vec::new();
deauth.extend_from_slice(&0x00c0u16.to_le_bytes());
deauth.extend_from_slice(&0u16.to_le_bytes());
deauth.extend_from_slice(&sta());
deauth.extend_from_slice(&ap());
deauth.extend_from_slice(&ap());
deauth.extend_from_slice(&[0u8; 2]);
deauth.extend_from_slice(&7u16.to_le_bytes());
let event = table.ingest_at(&radiotap_wrap(&deauth, 2437, -50), 3);
assert!(event.deauth);
assert_eq!(table.stats().deauth_frames, 1);
assert!(!table.clients()[0].associated);
}
}
+221
View File
@@ -0,0 +1,221 @@
//! 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)`.
use crate::crack::{compute_mic, compute_pmkid, compute_ptk, derive_pmk};
use crate::dot11;
use crate::eapol::{self, KEY_DATA_OFFSET, MIC_OFFSET};
use crate::handshake::Handshake;
const AP: [u8; 6] = [0x00, 0x11, 0x22, 0x33, 0x44, 0x55];
const STA: [u8; 6] = [0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb];
pub fn key_frame(message: u8, keyver: u8, nonce: [u8; 32], key_data: &[u8]) -> Vec<u8> {
use super::eapol::EAPOL_TYPE_KEY;
let mut key_info = keyver as u16 | 0x0008; // pairwise
match message {
1 => key_info |= 0x0080,
2 => key_info |= 0x0100,
3 => key_info |= 0x0080 | 0x0100 | 0x0040,
4 => key_info |= 0x0100 | 0x0200,
_ => {}
}
let total = KEY_DATA_OFFSET + key_data.len();
let mut f = vec![0u8; total];
f[0] = 2;
f[1] = EAPOL_TYPE_KEY;
f[2..4].copy_from_slice(&((total - 4) as u16).to_be_bytes());
f[4] = 2;
f[5..7].copy_from_slice(&key_info.to_le_bytes());
f[7..9].copy_from_slice(&16u16.to_le_bytes());
f[9..17].copy_from_slice(&1u64.to_le_bytes());
f[17..49].copy_from_slice(&nonce);
f[97..99].copy_from_slice(&(key_data.len() as u16).to_le_bytes());
f[KEY_DATA_OFFSET..].copy_from_slice(key_data);
f
}
pub fn frame_with_eapol(
from_ap: bool,
ap: [u8; 6],
sta: [u8; 6],
da: [u8; 6],
eapol: &[u8],
) -> Vec<u8> {
let mut f = Vec::new();
let fc: u16 = if from_ap { 0x0208 } else { 0x0108 };
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(&ap); // addr2 = BSSID
} else {
f.extend_from_slice(&ap); // addr1 = BSSID
f.extend_from_slice(&sta); // addr2 = origem
}
f.extend_from_slice(&da);
f.extend_from_slice(&[0u8; 2]);
f.extend_from_slice(&[0xAA, 0xAA, 0x03, 0, 0, 0, 0x88, 0x8e]);
f.extend_from_slice(eapol);
f
}
pub fn radiotap_wrap(dot11: &[u8], freq: u16, signal: i8) -> Vec<u8> {
// present: Channel | AntSignal
let present: u32 = (1 << 3) | (1 << 5);
let mut f = vec![0u8, 0, 13, 0];
f.extend_from_slice(&present.to_le_bytes());
f.extend_from_slice(&freq.to_le_bytes());
f.extend_from_slice(&0xa0u16.to_le_bytes());
f.push(signal as u8);
f.extend_from_slice(dot11);
f
}
fn ie(tag: u8, val: &[u8]) -> Vec<u8> {
let mut v = vec![tag, val.len() as u8];
v.extend_from_slice(val);
v
}
fn rsn_ie(akms: &[u8]) -> Vec<u8> {
let mut val = Vec::new();
val.extend_from_slice(&1u16.to_le_bytes());
val.extend_from_slice(&[0x00, 0x0f, 0xac, 4]);
val.extend_from_slice(&1u16.to_le_bytes());
val.extend_from_slice(&[0x00, 0x0f, 0xac, 4]);
val.extend_from_slice(&(akms.len() as u16).to_le_bytes());
for akm in akms {
val.extend_from_slice(&[0x00, 0x0f, 0xac, *akm]);
}
val.extend_from_slice(&0x0040u16.to_le_bytes());
ie(48, &val)
}
pub fn beacon_frame(bssid: [u8; 6], ssid: &str, channel: u8, akms: &[u8]) -> Vec<u8> {
let mut f = Vec::new();
f.extend_from_slice(&0x0080u16.to_le_bytes());
f.extend_from_slice(&0u16.to_le_bytes());
f.extend_from_slice(&[0xff; 6]);
f.extend_from_slice(&bssid);
f.extend_from_slice(&bssid);
f.extend_from_slice(&[0u8; 2]);
f.extend_from_slice(&[0u8; 8]);
f.extend_from_slice(&100u16.to_le_bytes());
f.extend_from_slice(&0x0031u16.to_le_bytes());
f.extend_from_slice(&ie(0, ssid.as_bytes()));
f.extend_from_slice(&ie(3, &[channel]));
if !akms.is_empty() {
f.extend_from_slice(&rsn_ie(akms));
}
f
}
pub fn probe_request(station: [u8; 6], ssid: &str) -> Vec<u8> {
let mut f = Vec::new();
f.extend_from_slice(&0x0040u16.to_le_bytes());
f.extend_from_slice(&0u16.to_le_bytes());
f.extend_from_slice(&[0xff; 6]);
f.extend_from_slice(&station);
f.extend_from_slice(&[0xff; 6]);
f.extend_from_slice(&[0u8; 2]);
f.extend_from_slice(&ie(0, ssid.as_bytes()));
f
}
pub fn data_frame(
to_ds: bool,
from_ds: bool,
addr1: [u8; 6],
addr2: [u8; 6],
addr3: [u8; 6],
payload: &[u8],
) -> Vec<u8> {
let mut fc: u16 = 0x0008;
if to_ds {
fc |= 0x0100;
}
if from_ds {
fc |= 0x0200;
}
let mut f = Vec::new();
f.extend_from_slice(&fc.to_le_bytes());
f.extend_from_slice(&0u16.to_le_bytes());
f.extend_from_slice(&addr1);
f.extend_from_slice(&addr2);
f.extend_from_slice(&addr3);
f.extend_from_slice(&[0u8; 2]);
f.extend_from_slice(payload);
f
}
/// Gera (msg1, msg2) válidas para a senha informada, com MIC correto.
pub fn handshake_frames(
ssid: &str,
password: &str,
keyver: u8,
with_pmkid: bool,
) -> (Vec<u8>, Vec<u8>) {
let anonce = [0xaa; 32];
let snonce = [0xbb; 32];
let pmk = derive_pmk(password, ssid);
let mut m1_key_data = Vec::new();
if with_pmkid {
let pmkid = compute_pmkid(&pmk, &AP, &STA);
m1_key_data.extend_from_slice(&[0xdd, 20, 0x00, 0x0f, 0xac, 0x04]);
m1_key_data.extend_from_slice(&pmkid);
}
let m1 = key_frame(1, keyver, anonce, &m1_key_data);
let mut m2 = key_frame(2, keyver, snonce, &[]);
let ptk = compute_ptk(&pmk, &AP, &STA, &anonce, &snonce);
let mic = compute_mic(keyver, &ptk[..16], &m2, MIC_OFFSET).expect("mic");
m2[MIC_OFFSET..MIC_OFFSET + 16].copy_from_slice(&mic);
(m1, m2)
}
/// Monta um `Handshake` diretamente dos quadros EAPOL, para testes de quebra
/// sem depender do pipeline de captura.
pub fn synthetic_handshake(
ssid: &str,
bssid: &str,
station: &str,
m1: &[u8],
m2: &[u8],
) -> Handshake {
let k1 = eapol::parse(m1).expect("msg1");
let k2 = if m2.is_empty() {
None
} else {
Some(eapol::parse(m2).expect("msg2"))
};
let mut messages = 1u8;
if k2.is_some() {
messages |= 2;
}
let hs = Handshake {
bssid: bssid.to_string(),
station: station.to_string(),
ssid: Some(ssid.to_string()),
anonce: Some(k1.nonce),
snonce: k2.as_ref().map(|k| k.nonce),
pmkid: k1.pmkid,
key_version: k1.key_version,
messages,
eapol_msg2: k2.as_ref().map(|k| k.frame.clone()),
raw: Vec::new(),
first_seen_unix: 1,
last_seen_unix: 2,
password: None,
};
// Exercita o parser de MAC do alvo.
assert!(dot11::parse_mac(&hs.bssid).is_some());
assert!(dot11::parse_mac(&hs.station).is_some());
hs
}
pub fn ap() -> [u8; 6] {
AP
}
pub fn sta() -> [u8; 6] {
STA
}
+77
View File
@@ -0,0 +1,77 @@
//! 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.
mod common;
use sabia_core::audit::AuditLog;
use std::io::Write;
#[test]
fn chain_validates_after_recording_entries() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("audit.log");
{
let mut log = AuditLog::open(&path).expect("open");
log.record("monitor-on", "wlan0", "modo monitor ativado")
.expect("record 1");
log.record("scan-start", "wlan0", "início da captura")
.expect("record 2");
log.record("deauth", "00:11:22:33:44:55", "teste de deautenticação")
.expect("record 3");
}
let entries = AuditLog::read_entries(&path, None).expect("read_entries");
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].action, "monitor-on");
assert_eq!(entries[2].action, "deauth");
// cadeia encadeada corretamente
assert_eq!(entries[1].prev_hash, entries[0].hash);
assert_eq!(entries[2].prev_hash, entries[1].hash);
let result = AuditLog::verify(&path).expect("verify");
assert_eq!(
result, None,
"cadeia íntegra não deve apontar entrada inválida"
);
}
#[test]
fn tampering_with_an_entry_breaks_verification() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("audit.log");
{
let mut log = AuditLog::open(&path).expect("open");
log.record("monitor-on", "wlan0", "modo monitor ativado")
.expect("record 1");
log.record("scan-start", "wlan0", "início da captura")
.expect("record 2");
log.record("scan-stop", "wlan0", "fim da captura")
.expect("record 3");
}
// Confirma que, intacta, a cadeia passa.
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.
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);
assert!(lines[1].contains("início da captura"));
lines[1] = lines[1].replace("início da captura", "evento adulterado");
let mut file = std::fs::File::create(&path).expect("recreate");
for line in &lines {
writeln!(file, "{line}").expect("write tampered line");
}
let result = AuditLog::verify(&path).expect("verify");
assert_eq!(
result,
Some(1),
"verificação deveria apontar a entrada adulterada (índice 1)"
);
}
+239
View File
@@ -0,0 +1,239 @@
#![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.
//!
//! 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;
use sabia_core::eapol::{self, EAPOL_TYPE_KEY, KEY_DATA_OFFSET, MIC_OFFSET};
use sabia_core::handshake::Handshake;
pub const AP: [u8; 6] = [0x00, 0x11, 0x22, 0x33, 0x44, 0x55];
pub const STA: [u8; 6] = [0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb];
pub fn ap() -> [u8; 6] {
AP
}
pub fn sta() -> [u8; 6] {
STA
}
pub fn key_frame(message: u8, keyver: u8, nonce: [u8; 32], key_data: &[u8]) -> Vec<u8> {
let mut key_info = keyver as u16 | 0x0008; // pairwise
match message {
1 => key_info |= 0x0080,
2 => key_info |= 0x0100,
3 => key_info |= 0x0080 | 0x0100 | 0x0040,
4 => key_info |= 0x0100 | 0x0200,
_ => {}
}
let total = KEY_DATA_OFFSET + key_data.len();
let mut f = vec![0u8; total];
f[0] = 2;
f[1] = EAPOL_TYPE_KEY;
f[2..4].copy_from_slice(&((total - 4) as u16).to_be_bytes());
f[4] = 2;
f[5..7].copy_from_slice(&key_info.to_le_bytes());
f[7..9].copy_from_slice(&16u16.to_le_bytes());
f[9..17].copy_from_slice(&1u64.to_le_bytes());
f[17..49].copy_from_slice(&nonce);
f[97..99].copy_from_slice(&(key_data.len() as u16).to_le_bytes());
f[KEY_DATA_OFFSET..].copy_from_slice(key_data);
f
}
pub fn frame_with_eapol(
from_ap: bool,
ap: [u8; 6],
sta: [u8; 6],
da: [u8; 6],
eapol: &[u8],
) -> Vec<u8> {
let mut f = Vec::new();
let fc: u16 = if from_ap { 0x0208 } else { 0x0108 };
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(&ap); // addr2 = BSSID
} else {
f.extend_from_slice(&ap); // addr1 = BSSID
f.extend_from_slice(&sta); // addr2 = origem
}
f.extend_from_slice(&da);
f.extend_from_slice(&[0u8; 2]);
f.extend_from_slice(&[0xAA, 0xAA, 0x03, 0, 0, 0, 0x88, 0x8e]);
f.extend_from_slice(eapol);
f
}
pub fn radiotap_wrap(dot11: &[u8], freq: u16, signal: i8) -> Vec<u8> {
// present: Channel | AntSignal
let present: u32 = (1 << 3) | (1 << 5);
let mut f = vec![0u8, 0, 13, 0];
f.extend_from_slice(&present.to_le_bytes());
f.extend_from_slice(&freq.to_le_bytes());
f.extend_from_slice(&0xa0u16.to_le_bytes());
f.push(signal as u8);
f.extend_from_slice(dot11);
f
}
fn ie(tag: u8, val: &[u8]) -> Vec<u8> {
let mut v = vec![tag, val.len() as u8];
v.extend_from_slice(val);
v
}
fn rsn_ie(akms: &[u8]) -> Vec<u8> {
let mut val = Vec::new();
val.extend_from_slice(&1u16.to_le_bytes());
val.extend_from_slice(&[0x00, 0x0f, 0xac, 4]);
val.extend_from_slice(&1u16.to_le_bytes());
val.extend_from_slice(&[0x00, 0x0f, 0xac, 4]);
val.extend_from_slice(&(akms.len() as u16).to_le_bytes());
for akm in akms {
val.extend_from_slice(&[0x00, 0x0f, 0xac, *akm]);
}
val.extend_from_slice(&0x0040u16.to_le_bytes());
ie(48, &val)
}
pub fn beacon_frame(bssid: [u8; 6], ssid: &str, channel: u8, akms: &[u8]) -> Vec<u8> {
let mut f = Vec::new();
f.extend_from_slice(&0x0080u16.to_le_bytes());
f.extend_from_slice(&0u16.to_le_bytes());
f.extend_from_slice(&[0xff; 6]);
f.extend_from_slice(&bssid);
f.extend_from_slice(&bssid);
f.extend_from_slice(&[0u8; 2]);
f.extend_from_slice(&[0u8; 8]);
f.extend_from_slice(&100u16.to_le_bytes());
f.extend_from_slice(&0x0031u16.to_le_bytes());
f.extend_from_slice(&ie(0, ssid.as_bytes()));
f.extend_from_slice(&ie(3, &[channel]));
if !akms.is_empty() {
f.extend_from_slice(&rsn_ie(akms));
}
f
}
pub fn probe_request(station: [u8; 6], ssid: &str) -> Vec<u8> {
let mut f = Vec::new();
f.extend_from_slice(&0x0040u16.to_le_bytes());
f.extend_from_slice(&0u16.to_le_bytes());
f.extend_from_slice(&[0xff; 6]);
f.extend_from_slice(&station);
f.extend_from_slice(&[0xff; 6]);
f.extend_from_slice(&[0u8; 2]);
f.extend_from_slice(&ie(0, ssid.as_bytes()));
f
}
pub fn data_frame(
to_ds: bool,
from_ds: bool,
addr1: [u8; 6],
addr2: [u8; 6],
addr3: [u8; 6],
payload: &[u8],
) -> Vec<u8> {
let mut fc: u16 = 0x0008;
if to_ds {
fc |= 0x0100;
}
if from_ds {
fc |= 0x0200;
}
let mut f = Vec::new();
f.extend_from_slice(&fc.to_le_bytes());
f.extend_from_slice(&0u16.to_le_bytes());
f.extend_from_slice(&addr1);
f.extend_from_slice(&addr2);
f.extend_from_slice(&addr3);
f.extend_from_slice(&[0u8; 2]);
f.extend_from_slice(payload);
f
}
/// Gera (msg1, msg2) válidas para a senha informada, com MIC correto.
pub fn handshake_frames(
ssid: &str,
password: &str,
keyver: u8,
with_pmkid: bool,
) -> (Vec<u8>, Vec<u8>) {
let anonce = [0xaa; 32];
let snonce = [0xbb; 32];
let pmk = derive_pmk(password, ssid);
let mut m1_key_data = Vec::new();
if with_pmkid {
let pmkid = compute_pmkid(&pmk, &AP, &STA);
m1_key_data.extend_from_slice(&[0xdd, 20, 0x00, 0x0f, 0xac, 0x04]);
m1_key_data.extend_from_slice(&pmkid);
}
let m1 = key_frame(1, keyver, anonce, &m1_key_data);
let mut m2 = key_frame(2, keyver, snonce, &[]);
let ptk = compute_ptk(&pmk, &AP, &STA, &anonce, &snonce);
let mic = compute_mic(keyver, &ptk[..16], &m2, MIC_OFFSET).expect("mic");
m2[MIC_OFFSET..MIC_OFFSET + 16].copy_from_slice(&mic);
(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.
pub fn handshake_radiotap_frames(
ssid: &str,
password: &str,
keyver: u8,
with_pmkid: bool,
) -> (Vec<u8>, Vec<u8>) {
let (m1, m2) = handshake_frames(ssid, password, keyver, with_pmkid);
let f1 = frame_with_eapol(true, AP, STA, STA, &m1);
let f2 = frame_with_eapol(false, AP, STA, AP, &m2);
(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.
pub fn synthetic_handshake(
ssid: &str,
bssid: &str,
station: &str,
m1: &[u8],
m2: &[u8],
) -> Handshake {
let k1 = eapol::parse(m1).expect("msg1");
let k2 = if m2.is_empty() {
None
} else {
Some(eapol::parse(m2).expect("msg2"))
};
let mut messages = 1u8;
if k2.is_some() {
messages |= 2;
}
let hs = Handshake {
bssid: bssid.to_string(),
station: station.to_string(),
ssid: Some(ssid.to_string()),
anonce: Some(k1.nonce),
snonce: k2.as_ref().map(|k| k.nonce),
pmkid: k1.pmkid,
key_version: k1.key_version,
messages,
eapol_msg2: k2.as_ref().map(|k| k.frame.clone()),
raw: Vec::new(),
first_seen_unix: 1,
last_seen_unix: 2,
password: None,
};
// Exercita o parser de MAC do alvo.
assert!(dot11::parse_mac(&hs.bssid).is_some());
assert!(dot11::parse_mac(&hs.station).is_some());
hs
}
@@ -0,0 +1,80 @@
//! 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.
mod common;
use sabia_core::crack::{compute_mic, compute_pmkid, compute_ptk, derive_pmk, CrackTarget};
const BSSID: &str = "00:11:22:33:44:55";
const STATION: &str = "66:77:88:99:aa:bb";
#[test]
fn eapol_4way_roundtrip_verifies_password() {
let (m1, m2) = common::handshake_frames("MinhaRede", "senha-correta", 2, false);
let hs = common::synthetic_handshake("MinhaRede", BSSID, STATION, &m1, &m2);
let target = CrackTarget::from_handshake(&hs).expect("alvo EAPOL válido");
assert_eq!(target.method(), "EAPOL/CCMP");
assert!(target.verify("senha-correta"));
assert!(!target.verify("senha-errada"));
}
#[test]
fn pmkid_roundtrip_verifies_password() {
let (m1, _m2) = common::handshake_frames("MinhaRede", "senha-correta", 2, true);
let hs = common::synthetic_handshake("MinhaRede", BSSID, STATION, &m1, &[]);
let target = CrackTarget::from_handshake(&hs).expect("alvo PMKID válido");
assert_eq!(target.method(), "PMKID");
assert!(target.verify("senha-correta"));
assert!(!target.verify("senha-errada"));
}
#[test]
fn tkip_keyversion_roundtrip_verifies_password() {
let (m1, m2) = common::handshake_frames("RedeAntiga", "outra-senha", 1, false);
let hs = common::synthetic_handshake("RedeAntiga", BSSID, STATION, &m1, &m2);
let target = CrackTarget::from_handshake(&hs).expect("alvo EAPOL/TKIP válido");
assert_eq!(target.method(), "EAPOL/TKIP");
assert!(target.verify("outra-senha"));
assert!(!target.verify("senha-errada"));
}
#[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.
let hs = common::synthetic_handshake("MinhaRede", BSSID, STATION, &m1, &[]);
assert!(CrackTarget::from_handshake(&hs).is_none());
}
#[test]
fn derive_pmk_compute_ptk_and_mic_are_consistent() {
let ssid = "VetorDireto";
let password = "senha-direta";
let anonce = [0xaa; 32];
let snonce = [0xbb; 32];
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.
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).
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().
let pmkid = compute_pmkid(&pmk, &common::ap(), &common::sta());
assert_eq!(
pmkid,
compute_pmkid(&pmk, &common::ap(), &common::sta()),
"PMKID deve ser determinístico"
);
}
+83
View File
@@ -0,0 +1,83 @@
//! 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.
mod common;
use sabia_core::engine::Engine;
use sabia_core::pcapreader::PcapReader;
use sabia_core::pcapwriter::PcapWriter;
#[test]
fn writes_and_reads_back_identical_frames() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("captura.pcap");
let beacon = common::radiotap_wrap(
&common::beacon_frame(common::ap(), "PcapRede", 1, &[2]),
2412,
-30,
);
let probe = common::radiotap_wrap(&common::probe_request(common::sta(), "PcapRede"), 2412, -45);
{
let mut writer = PcapWriter::create(&path).expect("create");
writer.write_frame(&beacon).expect("write beacon");
writer.write_frame(&probe).expect("write probe");
writer.flush().expect("flush");
}
let mut reader = PcapReader::open(&path).expect("open");
assert!(reader.supports_80211());
let f1 = reader
.next_frame()
.expect("read 1")
.expect("frame 1 presente");
assert_eq!(f1.data, beacon);
let f2 = reader
.next_frame()
.expect("read 2")
.expect("frame 2 presente");
assert_eq!(f2.data, probe);
assert!(reader.next_frame().expect("read 3").is_none());
}
#[test]
fn analyze_pcap_finds_networks_and_handshake() {
let dir = tempfile::tempdir().expect("tempdir");
let pcap_path = dir.path().join("sessao.pcap");
let audit_path = dir.path().join("audit.log");
let beacon = common::radiotap_wrap(
&common::beacon_frame(common::ap(), "PcapHandshake", 3, &[2]),
2422,
-38,
);
let (f1, f2) = common::handshake_radiotap_frames("PcapHandshake", "senha-pcap", 2, false);
{
let mut writer = PcapWriter::create(&pcap_path).expect("create");
writer.write_frame(&beacon).expect("write beacon");
writer.write_frame(&f1).expect("write msg1");
writer.write_frame(&f2).expect("write msg2");
writer.flush().expect("flush");
}
let engine = Engine::with_audit_path(&audit_path);
let snapshot = engine.analyze_pcap(&pcap_path).expect("analyze_pcap");
assert_eq!(snapshot.access_points.len(), 1);
assert_eq!(
snapshot.access_points[0].ssid.as_deref(),
Some("PcapHandshake")
);
assert_eq!(snapshot.handshakes.len(), 1);
let hs = &snapshot.handshakes[0];
assert_eq!(hs.ssid.as_deref(), Some("PcapHandshake"));
assert!(hs.complete);
assert!(hs.crackable);
}
+87
View File
@@ -0,0 +1,87 @@
//! 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.
mod common;
use sabia_core::scan::ScanTable;
#[test]
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.
let beacon = common::beacon_frame(common::ap(), "RedeTeste", 6, &[2]);
table.ingest(&common::radiotap_wrap(&beacon, 2437, -35));
let probe = common::probe_request(common::sta(), "RedeTeste");
table.ingest(&common::radiotap_wrap(&probe, 2437, -50));
let (f1, f2) = common::handshake_radiotap_frames("RedeTeste", "senha-correta", 2, false);
let ev1 = table.ingest_at(&f1, 10);
assert!(ev1.eapol, "quadro msg1 deveria ser reconhecido como EAPOL");
let ev2 = table.ingest_at(&f2, 11);
assert!(ev2.eapol, "quadro msg2 deveria ser reconhecido como EAPOL");
assert!(
ev2.handshake_updated,
"msg2 deveria completar/atualizar o handshake"
);
let aps = table.access_points();
assert_eq!(aps.len(), 1);
let ap = &aps[0];
assert_eq!(ap.bssid, sabia_core::dot11::mac_to_string(&common::ap()));
assert_eq!(ap.ssid.as_deref(), Some("RedeTeste"));
assert_eq!(ap.channel, Some(6));
assert_eq!(ap.security.as_deref(), Some("WPA2"));
let clients = table.clients();
assert!(clients
.iter()
.any(|c| c.mac == sabia_core::dot11::mac_to_string(&common::sta())));
let handshakes = table.handshakes();
assert_eq!(handshakes.len(), 1);
let hs = &handshakes[0];
assert_eq!(hs.ssid.as_deref(), Some("RedeTeste"));
assert!(hs.complete, "handshake deveria estar completo (msg1+msg2)");
assert!(hs.crackable, "handshake deveria ser quebrável");
let stats = table.stats();
assert_eq!(stats.aps, 1);
assert_eq!(stats.clients, 1);
assert_eq!(stats.handshakes, 1);
assert!(stats.eapol_frames >= 2);
}
#[test]
fn hidden_ssid_beacon_is_marked_hidden() {
let mut table = ScanTable::new();
// SSID vazio = rede oculta.
let beacon = common::beacon_frame(common::ap(), "", 11, &[2]);
table.ingest(&common::radiotap_wrap(&beacon, 2462, -60));
let aps = table.access_points();
assert_eq!(aps.len(), 1);
assert!(aps[0].hidden);
}
#[test]
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.
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).
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);
assert!(ev.eapol);
let handshakes = table.handshakes();
assert_eq!(handshakes.len(), 1);
assert!(handshakes[0].crackable, "PMKID isolado já é quebrável");
}