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:
1 parent
df27974359
commit
791538c9d9
112 files changed
+15620
-494
No files matched your search
@@ -15,5 +15,6 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
sabia-core = { path = "../sabia-core" }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
ctrlc = "3"
|
||||
+395
-104
@@ -1,9 +1,9 @@
|
||||
mod web;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use sabia_core::{
|
||||
audit::AuditLog, capture::RawCapture, iface, netlink, pcapwriter::PcapWriter, scan::ScanTable,
|
||||
};
|
||||
use sabia_core::engine::{Engine, ScanOptions};
|
||||
use sabia_core::iface;
|
||||
use std::io::IsTerminal;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
@@ -16,47 +16,115 @@ use std::time::Duration;
|
||||
version
|
||||
)]
|
||||
struct Cli {
|
||||
/// Trilha de auditoria (padrão: .sabia-audit.log).
|
||||
#[arg(long, global = true, default_value = ".sabia-audit.log")]
|
||||
audit: PathBuf,
|
||||
|
||||
#[command(subcommand)]
|
||||
cmd: Cmd,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Lista interfaces de rede e mostra quais são Wi-Fi.
|
||||
/// Lista interfaces, canais e controla o modo monitor.
|
||||
Iface {
|
||||
#[command(subcommand)]
|
||||
action: IfaceAction,
|
||||
},
|
||||
/// Varre o ar na interface (já em modo monitor) e mostra APs/clientes vistos.
|
||||
/// Captura o ar e mostra redes, clientes e handshakes.
|
||||
Scan {
|
||||
iface: String,
|
||||
/// Canal fixo; sem isso, pula entre todos os canais suportados.
|
||||
/// Canal fixo; sem isso, percorre os canais suportados.
|
||||
#[arg(long)]
|
||||
channel: Option<u32>,
|
||||
/// Banda ao percorrer canais: 2.4, 5, 6 ou all.
|
||||
#[arg(long)]
|
||||
band: Option<String>,
|
||||
/// Tempo em cada canal (ms).
|
||||
#[arg(long, default_value_t = 350)]
|
||||
hop_ms: u64,
|
||||
/// Para a varredura depois de N segundos (padrão: até Ctrl+C).
|
||||
#[arg(long)]
|
||||
seconds: Option<u64>,
|
||||
/// Salva tudo que foi capturado em um .pcap (inclui handshakes).
|
||||
/// Salva os quadros capturados em um .pcap.
|
||||
#[arg(long)]
|
||||
out: Option<PathBuf>,
|
||||
/// Depois de capturar, tenta quebrar o primeiro handshake.
|
||||
#[arg(long, value_name = "DICIONARIO")]
|
||||
crack: Option<PathBuf>,
|
||||
/// Mostra apenas o JSON final.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Analisa um .pcap offline (redes, clientes e handshakes).
|
||||
Analyze {
|
||||
pcap: PathBuf,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Quebra um handshake de um .pcap por dicionário.
|
||||
Crack {
|
||||
pcap: PathBuf,
|
||||
/// Dicionário com uma senha por linha.
|
||||
#[arg(short, long)]
|
||||
wordlist: PathBuf,
|
||||
#[arg(long)]
|
||||
bssid: Option<String>,
|
||||
#[arg(long)]
|
||||
station: Option<String>,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Envia desautenticação para um AP/estação (teste autorizado).
|
||||
Deauth {
|
||||
iface: String,
|
||||
#[arg(long)]
|
||||
bssid: String,
|
||||
/// Sem isso, o destino é broadcast.
|
||||
#[arg(long)]
|
||||
station: Option<String>,
|
||||
#[arg(long, default_value_t = 5)]
|
||||
count: u32,
|
||||
#[arg(long, default_value_t = 7)]
|
||||
reason: u16,
|
||||
/// Confirma que você tem autorização para testar esta rede.
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
},
|
||||
/// Diagnóstico de privilégios, ferramentas e interfaces.
|
||||
Doctor {
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Inspeciona a trilha de auditoria.
|
||||
Audit {
|
||||
#[command(subcommand)]
|
||||
action: AuditAction,
|
||||
},
|
||||
/// Verifica e aplica atualizações do próprio sabia.
|
||||
Update {
|
||||
#[arg(long)]
|
||||
apply: bool,
|
||||
},
|
||||
/// Confere a integridade da trilha de auditoria.
|
||||
AuditVerify { path: PathBuf },
|
||||
/// Abre a interface gráfica (servidor local + navegador).
|
||||
Ui {
|
||||
#[arg(long, default_value = "4337")]
|
||||
port: u16,
|
||||
/// Não tenta abrir o navegador automaticamente.
|
||||
#[arg(long)]
|
||||
no_open: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum IfaceAction {
|
||||
List,
|
||||
/// Mostra os canais suportados pelo rádio.
|
||||
Channels {
|
||||
iface: String,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Liga/desliga modo monitor numa interface Wi-Fi.
|
||||
Monitor {
|
||||
iface: String,
|
||||
@@ -65,16 +133,23 @@ enum IfaceAction {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum AuditAction {
|
||||
/// Confere a integridade da cadeia.
|
||||
Verify { path: Option<PathBuf> },
|
||||
/// Mostra as últimas entradas.
|
||||
Show {
|
||||
#[arg(long, default_value_t = 20)]
|
||||
limit: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, clap::ValueEnum)]
|
||||
enum OnOff {
|
||||
On,
|
||||
Off,
|
||||
}
|
||||
|
||||
fn audit_path() -> PathBuf {
|
||||
PathBuf::from(".sabia-audit.log")
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let cli = Cli::parse();
|
||||
if let Err(e) = run(cli) {
|
||||
@@ -84,6 +159,9 @@ fn main() {
|
||||
}
|
||||
|
||||
fn run(cli: Cli) -> sabia_core::Result<()> {
|
||||
let audit_path = cli.audit.clone();
|
||||
let engine = Engine::with_audit_path(&audit_path);
|
||||
|
||||
match cli.cmd {
|
||||
Cmd::Iface { action } => match action {
|
||||
IfaceAction::List => {
|
||||
@@ -94,17 +172,33 @@ fn run(cli: Cli) -> sabia_core::Result<()> {
|
||||
i.name,
|
||||
i.mac,
|
||||
tag,
|
||||
format!("{:?}", i.mode),
|
||||
format!("{:?}", i.mode).to_lowercase(),
|
||||
if i.up { "up" } else { "down" }
|
||||
);
|
||||
}
|
||||
}
|
||||
IfaceAction::Channels { iface: name, json } => {
|
||||
let channels = engine.channels(&name)?;
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&channels)?);
|
||||
} else {
|
||||
for c in channels {
|
||||
println!(
|
||||
"canal {:<4} {:>5} MHz {:<6} {}{}",
|
||||
c.channel,
|
||||
c.freq_mhz,
|
||||
c.band.label(),
|
||||
if c.disabled { "desabilitado" } else { "ok" },
|
||||
c.max_power_dbm
|
||||
.map(|p| format!(" até {p:.1} dBm"))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
IfaceAction::Monitor { iface: name, state } => {
|
||||
let enable = matches!(state, OnOff::On);
|
||||
iface::get(&name)?; // valida que é Wi-Fi antes de tocar nela
|
||||
netlink::set_monitor_mode(&name, enable)?;
|
||||
let mut log = AuditLog::open(audit_path())?;
|
||||
log.record("monitor_mode", &name, &format!("{{\"enable\": {enable}}}"))?;
|
||||
engine.set_monitor(&name, enable)?;
|
||||
println!(
|
||||
"{name}: modo monitor {}",
|
||||
if enable { "ativado" } else { "desativado" }
|
||||
@@ -115,104 +209,167 @@ fn run(cli: Cli) -> sabia_core::Result<()> {
|
||||
Cmd::Scan {
|
||||
iface: name,
|
||||
channel,
|
||||
band,
|
||||
hop_ms,
|
||||
seconds,
|
||||
out,
|
||||
crack,
|
||||
json,
|
||||
} => {
|
||||
let info = iface::get(&name)?;
|
||||
if info.mode != iface::Mode::Monitor {
|
||||
eprintln!("aviso: {name} não está em modo monitor (sabia iface monitor {name} on)");
|
||||
}
|
||||
|
||||
let mut log = AuditLog::open(audit_path())?;
|
||||
log.record(
|
||||
"scan_start",
|
||||
&name,
|
||||
&format!("{{\"channel\": {channel:?}}}"),
|
||||
)?;
|
||||
|
||||
let cap = RawCapture::open(&name)?;
|
||||
cap.set_read_timeout(Duration::from_millis(500))?;
|
||||
let options = ScanOptions {
|
||||
channel,
|
||||
band,
|
||||
hop_ms,
|
||||
pcap_out: out,
|
||||
duration_secs: seconds,
|
||||
};
|
||||
engine.start_scan_with(&name, options)?;
|
||||
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
let r = running.clone();
|
||||
ctrlc::set_handler(move || r.store(false, Ordering::SeqCst))
|
||||
.expect("falha ao instalar handler de Ctrl+C");
|
||||
let _ = ctrlc::set_handler(move || r.store(false, Ordering::SeqCst));
|
||||
|
||||
let mut table = ScanTable::new();
|
||||
let mut writer = match &out {
|
||||
Some(p) => Some(PcapWriter::create(p)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let channels: Vec<u32> = match channel {
|
||||
Some(c) => vec![c],
|
||||
None => netlink::supported_channels(&name).unwrap_or_default(),
|
||||
};
|
||||
let mut chan_idx = 0usize;
|
||||
let mut last_hop = std::time::Instant::now();
|
||||
|
||||
let deadline = seconds.map(|s| std::time::Instant::now() + Duration::from_secs(s));
|
||||
let mut buf = [0u8; 65535];
|
||||
|
||||
println!("capturando em {name}... (Ctrl+C para parar)");
|
||||
let interactive = std::io::stdout().is_terminal() && !json;
|
||||
while running.load(Ordering::SeqCst) {
|
||||
if let Some(d) = deadline {
|
||||
if std::time::Instant::now() >= d {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
let snapshot = match engine.scan_snapshot(&name) {
|
||||
Ok(s) => s,
|
||||
Err(_) => break,
|
||||
};
|
||||
if !snapshot.running {
|
||||
break;
|
||||
}
|
||||
if channel.is_none()
|
||||
&& !channels.is_empty()
|
||||
&& last_hop.elapsed() > Duration::from_millis(300)
|
||||
{
|
||||
chan_idx = (chan_idx + 1) % channels.len();
|
||||
let _ = netlink::set_channel(&name, channels[chan_idx]);
|
||||
last_hop = std::time::Instant::now();
|
||||
}
|
||||
|
||||
match cap.recv(&mut buf)? {
|
||||
Some(n) => {
|
||||
let frame = &buf[..n];
|
||||
let had_eapol = table.ingest(frame);
|
||||
if had_eapol {
|
||||
eprintln!("[!] handshake EAPOL visto");
|
||||
}
|
||||
if let Some(w) = writer.as_mut() {
|
||||
w.write_frame(frame)?;
|
||||
}
|
||||
}
|
||||
None => continue, // timeout, sem quadro
|
||||
if interactive {
|
||||
print!("\x1b[2J\x1b[H");
|
||||
print_snapshot(&snapshot);
|
||||
}
|
||||
}
|
||||
if let Some(w) = writer.as_mut() {
|
||||
w.flush()?;
|
||||
|
||||
engine.stop_scan(&name);
|
||||
let snapshot = engine.scan_snapshot(&name)?;
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&snapshot)?);
|
||||
} else {
|
||||
if interactive {
|
||||
print!("\x1b[2J\x1b[H");
|
||||
}
|
||||
print_snapshot(&snapshot);
|
||||
}
|
||||
|
||||
log.record(
|
||||
"scan_stop",
|
||||
&name,
|
||||
&format!("{{\"aps_found\": {}}}", table.access_points().len()),
|
||||
if let Some(wordlist) = crack {
|
||||
crack_first(&engine, &name, &wordlist)?;
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::Analyze { pcap, json } => {
|
||||
let snapshot = engine.analyze_pcap(&pcap)?;
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&snapshot)?);
|
||||
} else {
|
||||
print_snapshot(&snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::Crack {
|
||||
pcap,
|
||||
wordlist,
|
||||
bssid,
|
||||
station,
|
||||
json,
|
||||
} => {
|
||||
let mut last = 0u64;
|
||||
let report = sabia_core::crack::crack_pcap(
|
||||
&pcap,
|
||||
&wordlist,
|
||||
bssid.as_deref(),
|
||||
station.as_deref(),
|
||||
|tried| {
|
||||
if tried.saturating_sub(last) >= 4096 {
|
||||
eprint!("\r{tried} senhas testadas…");
|
||||
last = tried;
|
||||
}
|
||||
},
|
||||
)?;
|
||||
|
||||
println!(
|
||||
"\n{:<18} {:<24} {:<8} {:<8} {:<6} CLIENTES",
|
||||
"BSSID", "SSID", "CANAL", "SINAL", "SEG"
|
||||
);
|
||||
for ap in table.access_points() {
|
||||
if !json {
|
||||
eprintln!();
|
||||
}
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||
} else if let Some(password) = &report.password {
|
||||
println!(
|
||||
"{:<18} {:<24} {:<8} {:<8} {:<6} {}",
|
||||
ap.bssid,
|
||||
ap.ssid.unwrap_or_default(),
|
||||
ap.channel.map(|c| c.to_string()).unwrap_or_default(),
|
||||
ap.last_signal_dbm
|
||||
.map(|s| format!("{s}dBm"))
|
||||
.unwrap_or_default(),
|
||||
ap.security.unwrap_or_default(),
|
||||
ap.clients.len()
|
||||
"SENHA ENCONTRADA: {password}\n{} tentativas em {} ms ({})",
|
||||
report.tried,
|
||||
report.elapsed_ms,
|
||||
report.method.as_deref().unwrap_or("?")
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"senha não encontrada ({} tentativas em {} ms)",
|
||||
report.tried, report.elapsed_ms
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::Deauth {
|
||||
iface: name,
|
||||
bssid,
|
||||
station,
|
||||
count,
|
||||
reason,
|
||||
yes,
|
||||
} => {
|
||||
if !yes {
|
||||
return Err(sabia_core::SabiaError::InvalidInput(
|
||||
"desautenticação derruba clientes; confirme com --yes apenas em rede própria ou com autorização".into(),
|
||||
));
|
||||
}
|
||||
let report = engine.deauth(&name, &bssid, station.as_deref(), count, reason)?;
|
||||
println!(
|
||||
"{} quadros enviados para {} via {} (motivo {})",
|
||||
report.frames_sent, report.station, report.iface, report.reason
|
||||
);
|
||||
}
|
||||
|
||||
Cmd::Doctor { json } => {
|
||||
let report = engine.doctor();
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||
} else {
|
||||
print_doctor(&report);
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::Audit { action } => match action {
|
||||
AuditAction::Verify { path } => {
|
||||
let path = path.unwrap_or_else(|| audit_path.clone());
|
||||
if !path.exists() {
|
||||
println!(
|
||||
"{} não existe (nenhuma ação registrada ainda)",
|
||||
path.display()
|
||||
);
|
||||
} else {
|
||||
match sabia_core::audit::AuditLog::verify(&path)? {
|
||||
None => println!("cadeia de auditoria íntegra ({})", path.display()),
|
||||
Some(idx) => {
|
||||
println!("cadeia ROMPIDA na entrada {idx} ({})", path.display())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AuditAction::Show { limit } => {
|
||||
let entries = sabia_core::audit::AuditLog::read_entries(&audit_path, Some(limit))?;
|
||||
if entries.is_empty() {
|
||||
println!("nenhuma entrada em {}", audit_path.display());
|
||||
}
|
||||
for e in entries {
|
||||
println!(
|
||||
"{} {:<18} {:<18} {}",
|
||||
e.timestamp, e.action, e.target, e.detail
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Cmd::Update { apply } => {
|
||||
let current = env!("CARGO_PKG_VERSION");
|
||||
if apply {
|
||||
@@ -233,14 +390,148 @@ fn run(cli: Cli) -> sabia_core::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::AuditVerify { path } => match sabia_core::audit::AuditLog::verify(&path)? {
|
||||
None => println!("cadeia de auditoria íntegra"),
|
||||
Some(idx) => println!("cadeia ROMPIDA na entrada {idx}"),
|
||||
},
|
||||
|
||||
Cmd::Ui { port } => {
|
||||
web::serve(port)?;
|
||||
Cmd::Ui { port, no_open } => {
|
||||
web::serve(port, audit_path, !no_open)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn crack_first(engine: &Engine, iface: &str, wordlist: &PathBuf) -> sabia_core::Result<()> {
|
||||
let Some(hs) = engine
|
||||
.handshakes(Some(iface))
|
||||
.into_iter()
|
||||
.find(|h| h.crackable)
|
||||
else {
|
||||
println!("\nnenhum handshake quebrável capturado");
|
||||
return Ok(());
|
||||
};
|
||||
println!(
|
||||
"\nquebrando handshake de {} ({}) com {}…",
|
||||
hs.ssid.as_deref().unwrap_or("?"),
|
||||
hs.bssid,
|
||||
wordlist.display()
|
||||
);
|
||||
let id = engine.start_crack(iface, Some(&hs.bssid), Some(&hs.station), wordlist)?;
|
||||
loop {
|
||||
let job = engine.crack_job(&id).expect("job");
|
||||
if job.status == "running" {
|
||||
eprint!("\r{} senhas testadas…", job.tried);
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
continue;
|
||||
}
|
||||
eprintln!();
|
||||
match job.report {
|
||||
Some(report) => match report.password {
|
||||
Some(password) => println!(
|
||||
"SENHA ENCONTRADA: {password}\n{} tentativas em {} ms",
|
||||
report.tried, report.elapsed_ms
|
||||
),
|
||||
None => println!(
|
||||
"senha não encontrada ({} tentativas em {} ms)",
|
||||
report.tried, report.elapsed_ms
|
||||
),
|
||||
},
|
||||
None => println!("falha: {}", job.error.unwrap_or_default()),
|
||||
}
|
||||
break;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_snapshot(snapshot: &sabia_core::scan::ScanSnapshot) {
|
||||
println!(
|
||||
"{} {}{} {} quadros · {} APs · {} clientes · {} handshakes",
|
||||
snapshot.iface,
|
||||
if snapshot.running { "● " } else { "" },
|
||||
snapshot
|
||||
.channel
|
||||
.map(|c| format!("canal {c}"))
|
||||
.unwrap_or_default(),
|
||||
snapshot.stats.frames,
|
||||
snapshot.stats.aps,
|
||||
snapshot.stats.clients,
|
||||
snapshot.stats.handshakes
|
||||
);
|
||||
if !snapshot.access_points.is_empty() {
|
||||
println!(
|
||||
"\n{:<18} {:<24} {:<6} {:<8} {:<12} CLIENTES",
|
||||
"BSSID", "SSID", "CANAL", "SINAL", "SEGURANÇA"
|
||||
);
|
||||
for ap in &snapshot.access_points {
|
||||
println!(
|
||||
"{:<18} {:<24} {:<6} {:<8} {:<12} {}",
|
||||
ap.bssid,
|
||||
ap.ssid.clone().unwrap_or_else(|| "<oculta>".into()),
|
||||
ap.channel.map(|c| c.to_string()).unwrap_or_default(),
|
||||
ap.signal_dbm
|
||||
.map(|s| format!("{s} dBm"))
|
||||
.unwrap_or_default(),
|
||||
ap.security.clone().unwrap_or_default(),
|
||||
ap.clients.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
if !snapshot.handshakes.is_empty() {
|
||||
println!("\nHANDSHAKES");
|
||||
for hs in &snapshot.handshakes {
|
||||
println!(
|
||||
"{:<18} {:<18} {:<16} msgs {:<8} {}{}",
|
||||
hs.bssid,
|
||||
hs.station,
|
||||
hs.ssid.clone().unwrap_or_default(),
|
||||
hs.messages,
|
||||
hs.kind,
|
||||
if hs.crackable { " · quebrável" } else { "" }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn print_doctor(report: &sabia_core::doctor::DoctorReport) {
|
||||
println!("Sabiá {} ({})", report.version, report.platform);
|
||||
println!(
|
||||
"root: {} · iw: {} · ip: {} · curl: {} · captura: {}",
|
||||
yes_no(report.root),
|
||||
yes_no(report.iw),
|
||||
yes_no(report.ip),
|
||||
yes_no(report.curl),
|
||||
report.capture_backend
|
||||
);
|
||||
println!(
|
||||
"auditoria: {} ({})",
|
||||
report.audit_path,
|
||||
if report.audit_ok {
|
||||
"íntegra"
|
||||
} else {
|
||||
"corrompida"
|
||||
}
|
||||
);
|
||||
if !report.interfaces.is_empty() {
|
||||
println!("\nINTERFACES");
|
||||
for i in &report.interfaces {
|
||||
println!(
|
||||
"{:<16} {:<18} {:<10} monitor: {:<3} driver: {}",
|
||||
i.name,
|
||||
i.mac,
|
||||
i.mode,
|
||||
yes_no(i.monitor_supported),
|
||||
i.driver.as_deref().unwrap_or("?")
|
||||
);
|
||||
}
|
||||
}
|
||||
if !report.notes.is_empty() {
|
||||
println!("\nAVISOS");
|
||||
for note in &report.notes {
|
||||
println!("- {note}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn yes_no(value: bool) -> &'static str {
|
||||
if value {
|
||||
"sim"
|
||||
} else {
|
||||
"não"
|
||||
}
|
||||
}
|
||||
+350
-77
@@ -3,31 +3,32 @@
|
||||
//! núcleo por uma API JSON simples. A mesma página em `apps/gui/ui` é o
|
||||
//! que o app desktop (Tauri) carrega — só muda o transporte.
|
||||
|
||||
use sabia_core::engine::Engine;
|
||||
use sabia_core::engine::{Engine, ScanOptions};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
const INDEX_HTML: &str = include_str!("../../../../apps/gui/ui/index.html");
|
||||
const APP_CSS: &str = include_str!("../../../../apps/gui/ui/app.css");
|
||||
const APP_JS: &str = include_str!("../../../../apps/gui/ui/app.js");
|
||||
const LOGO_SVG: &str = include_str!("../../../../apps/gui/ui/sabia-logo.svg");
|
||||
const LOGO_PNG: &[u8] = include_bytes!("../../../../apps/gui/ui/sabia.png");
|
||||
|
||||
pub fn serve(port: u16) -> sabia_core::Result<()> {
|
||||
pub fn serve(port: u16, audit_path: std::path::PathBuf, open: bool) -> sabia_core::Result<()> {
|
||||
let token = gen_token();
|
||||
let listener = TcpListener::bind(("127.0.0.1", port))?;
|
||||
let engine = Arc::new(Engine::new());
|
||||
let engine = Arc::new(Engine::with_audit_path(audit_path));
|
||||
|
||||
println!("Sabiá UI em http://127.0.0.1:{port}/?token={token}");
|
||||
let _ = open_browser(&format!("http://127.0.0.1:{port}/?token={token}"));
|
||||
if open {
|
||||
let _ = open_browser(&format!("http://127.0.0.1:{port}/?token={token}"));
|
||||
}
|
||||
|
||||
for stream in listener.incoming() {
|
||||
let stream = match stream {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let Ok(stream) = stream else { continue };
|
||||
let engine = engine.clone();
|
||||
let token = token.clone();
|
||||
thread::spawn(move || {
|
||||
@@ -38,36 +39,35 @@ pub fn serve(port: u16) -> sabia_core::Result<()> {
|
||||
}
|
||||
|
||||
fn gen_token() -> String {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
format!("{nanos:x}")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn open_browser(url: &str) -> std::io::Result<()> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
std::process::Command::new("xdg-open")
|
||||
.arg(url)
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
std::process::Command::new("open")
|
||||
.arg(url)
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
std::process::Command::new("cmd")
|
||||
.args(["/C", "start", "", url])
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
}
|
||||
std::process::Command::new("xdg-open")
|
||||
.arg(url)
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn open_browser(url: &str) -> std::io::Result<()> {
|
||||
std::process::Command::new("open")
|
||||
.arg(url)
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn open_browser(url: &str) -> std::io::Result<()> {
|
||||
std::process::Command::new("cmd")
|
||||
.args(["/C", "start", "", url])
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
struct Request {
|
||||
@@ -75,6 +75,44 @@ struct Request {
|
||||
path: String,
|
||||
query: HashMap<String, String>,
|
||||
token: Option<String>,
|
||||
body: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Request {
|
||||
fn text(&self, key: &str) -> Option<String> {
|
||||
if !self.body.is_empty() {
|
||||
if let Ok(serde_json::Value::Object(map)) =
|
||||
serde_json::from_slice::<serde_json::Value>(&self.body)
|
||||
{
|
||||
if let Some(value) = map.get(key) {
|
||||
return match value {
|
||||
serde_json::Value::String(s) => Some(s.clone()),
|
||||
serde_json::Value::Null => None,
|
||||
other => Some(other.to_string()),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
self.query.get(key).cloned()
|
||||
}
|
||||
|
||||
fn bool(&self, key: &str) -> bool {
|
||||
self.text(key)
|
||||
.map(|v| v == "true" || v == "1" || v == "on")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn u64(&self, key: &str) -> Option<u64> {
|
||||
self.text(key).and_then(|v| v.parse().ok())
|
||||
}
|
||||
|
||||
fn u32(&self, key: &str) -> Option<u32> {
|
||||
self.text(key).and_then(|v| v.parse().ok())
|
||||
}
|
||||
|
||||
fn u16(&self, key: &str) -> Option<u16> {
|
||||
self.text(key).and_then(|v| v.parse().ok())
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_request(stream: &mut TcpStream) -> std::io::Result<Request> {
|
||||
@@ -85,37 +123,87 @@ fn parse_request(stream: &mut TcpStream) -> std::io::Result<Request> {
|
||||
let method = parts.next().unwrap_or("GET").to_string();
|
||||
let full_path = parts.next().unwrap_or("/").to_string();
|
||||
|
||||
let mut content_length = 0usize;
|
||||
let mut header_token = None;
|
||||
loop {
|
||||
let mut l = String::new();
|
||||
if reader.read_line(&mut l)? == 0 || l == "\r\n" {
|
||||
if reader.read_line(&mut l)? == 0 || l == "\r\n" || l == "\n" {
|
||||
break;
|
||||
}
|
||||
if let Some((name, value)) = l.split_once(':') {
|
||||
let name = name.trim().to_ascii_lowercase();
|
||||
let value = value.trim();
|
||||
match name.as_str() {
|
||||
"content-length" => content_length = value.parse().unwrap_or(0),
|
||||
"x-sabia-token" => header_token = Some(value.to_string()),
|
||||
"authorization" => {
|
||||
header_token = value
|
||||
.strip_prefix("Bearer ")
|
||||
.map(|v| v.trim().to_string())
|
||||
.or_else(|| Some(value.to_string()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (path, query_str) = match full_path.split_once('?') {
|
||||
Some((p, q)) => (p.to_string(), q.to_string()),
|
||||
None => (full_path, String::new()),
|
||||
};
|
||||
let (path, query_str) = full_path
|
||||
.split_once('?')
|
||||
.map(|(p, q)| (p.to_string(), q.to_string()))
|
||||
.unwrap_or((full_path, String::new()));
|
||||
let mut query = HashMap::new();
|
||||
for pair in query_str.split('&') {
|
||||
if let Some((k, v)) = pair.split_once('=') {
|
||||
query.insert(urldecode(k), urldecode(v));
|
||||
} else if !pair.is_empty() {
|
||||
query.insert(urldecode(pair), String::new());
|
||||
}
|
||||
}
|
||||
let token = query.get("token").cloned();
|
||||
let token = query.get("token").cloned().or(header_token);
|
||||
|
||||
let mut body = vec![0u8; content_length.min(4 * 1024 * 1024)];
|
||||
if !body.is_empty() {
|
||||
reader.read_exact(&mut body)?;
|
||||
}
|
||||
|
||||
Ok(Request {
|
||||
method,
|
||||
path,
|
||||
query,
|
||||
token,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
fn urldecode(s: &str) -> String {
|
||||
s.replace('+', " ")
|
||||
.replace("%3A", ":")
|
||||
.replace("%2F", "/")
|
||||
.replace("%20", " ")
|
||||
let bytes = s.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
match bytes[i] {
|
||||
b'+' => out.push(b' '),
|
||||
b'%' if i + 2 < bytes.len() => {
|
||||
if let (Some(hi), Some(lo)) = (hexval(bytes[i + 1]), hexval(bytes[i + 2])) {
|
||||
out.push((hi << 4) | lo);
|
||||
i += 2;
|
||||
} else {
|
||||
out.push(b'%');
|
||||
}
|
||||
}
|
||||
b => out.push(b),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
fn hexval(b: u8) -> Option<u8> {
|
||||
match b {
|
||||
b'0'..=b'9' => Some(b - b'0'),
|
||||
b'a'..=b'f' => Some(b - b'a' + 10),
|
||||
b'A'..=b'F' => Some(b - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle(
|
||||
@@ -137,7 +225,6 @@ fn handle(
|
||||
);
|
||||
}
|
||||
|
||||
// Assets estáticos: não carregam dado nenhum, então não exigem token.
|
||||
match req.path.as_str() {
|
||||
"/app.css" => return respond(&mut stream, 200, "text/css", APP_CSS.as_bytes()),
|
||||
"/app.js" => {
|
||||
@@ -148,9 +235,7 @@ fn handle(
|
||||
APP_JS.as_bytes(),
|
||||
)
|
||||
}
|
||||
"/sabia-logo.svg" => {
|
||||
return respond(&mut stream, 200, "image/svg+xml", LOGO_SVG.as_bytes())
|
||||
}
|
||||
"/sabia.png" => return respond(&mut stream, 200, "image/png", LOGO_PNG),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -162,65 +247,253 @@ fn handle(
|
||||
&mut stream,
|
||||
401,
|
||||
"application/json",
|
||||
b"{\"error\":\"token invalido\"}",
|
||||
b"{\"ok\":false,\"error\":\"token invalido\"}",
|
||||
);
|
||||
}
|
||||
|
||||
let body = api(engine, &req);
|
||||
respond(&mut stream, 200, "application/json", body.as_bytes())
|
||||
let (code, body) = api(engine, &req);
|
||||
respond(&mut stream, code, "application/json", body.as_bytes())
|
||||
}
|
||||
|
||||
fn respond(stream: &mut TcpStream, code: u16, ctype: &str, body: &[u8]) -> std::io::Result<()> {
|
||||
let status = match code {
|
||||
200 => "OK",
|
||||
400 => "Bad Request",
|
||||
401 => "Unauthorized",
|
||||
404 => "Not Found",
|
||||
500 => "Internal Server Error",
|
||||
_ => "Error",
|
||||
};
|
||||
write!(stream, "HTTP/1.1 {code} {status}\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len())?;
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 {code} {status}\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
)?;
|
||||
stream.write_all(body)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn api(engine: &Arc<Engine>, req: &Request) -> String {
|
||||
fn ok<T: serde::Serialize>(value: &T) -> (u16, String) {
|
||||
match serde_json::to_string(value) {
|
||||
Ok(body) => (200, body),
|
||||
Err(e) => err(500, e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn ok_json(value: serde_json::Value) -> (u16, String) {
|
||||
(200, value.to_string())
|
||||
}
|
||||
|
||||
fn err(code: u16, message: impl ToString) -> (u16, String) {
|
||||
(
|
||||
code,
|
||||
serde_json::json!({ "ok": false, "error": message.to_string() }).to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AnalyzeBody {
|
||||
pcap: String,
|
||||
}
|
||||
|
||||
fn api(engine: &Arc<Engine>, req: &Request) -> (u16, String) {
|
||||
match (req.method.as_str(), req.path.as_str()) {
|
||||
("GET", "/api/version") => format!("{{\"version\":{:?}}}", engine.version()),
|
||||
("GET", "/api/version") => ok_json(serde_json::json!({ "version": engine.version() })),
|
||||
("GET", "/api/doctor") => ok(&engine.doctor()),
|
||||
("GET", "/api/update/check") => match engine.update_check() {
|
||||
Ok(status) => serde_json::to_string(&status).unwrap_or_else(|_| "{}".into()),
|
||||
Err(e) => format!("{{\"error\":{:?}}}", e.to_string()),
|
||||
Ok(status) => ok(&status),
|
||||
Err(e) => err(400, e),
|
||||
},
|
||||
("GET", "/api/ifaces") => {
|
||||
let list = engine.list_ifaces().unwrap_or_default();
|
||||
serde_json::to_string(&list).unwrap_or_else(|_| "[]".into())
|
||||
("POST", "/api/update/apply") => match engine.update_apply() {
|
||||
Ok(version) => ok_json(serde_json::json!({ "ok": true, "version": version })),
|
||||
Err(e) => err(400, e),
|
||||
},
|
||||
("GET", "/api/ifaces") => match engine.list_ifaces() {
|
||||
Ok(list) => ok(&list),
|
||||
Err(e) => err(400, e),
|
||||
},
|
||||
("GET", "/api/sessions") => ok(&engine.list_sessions()),
|
||||
("GET", "/api/channels") => {
|
||||
let Some(iface) = req.text("iface") else {
|
||||
return err(400, "iface obrigatória");
|
||||
};
|
||||
match engine.channels(&iface) {
|
||||
Ok(list) => ok(&list),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/monitor") => {
|
||||
let name = req.query.get("iface").cloned().unwrap_or_default();
|
||||
let enable = req
|
||||
.query
|
||||
.get("enable")
|
||||
.map(|v| v == "true")
|
||||
.unwrap_or(false);
|
||||
match engine.set_monitor(&name, enable) {
|
||||
Ok(()) => "{\"ok\":true}".into(),
|
||||
Err(e) => format!("{{\"ok\":false,\"error\":{:?}}}", e.to_string()),
|
||||
let Some(iface) = req.text("iface") else {
|
||||
return err(400, "iface obrigatória");
|
||||
};
|
||||
match engine.set_monitor(&iface, req.bool("enable")) {
|
||||
Ok(()) => ok_json(serde_json::json!({ "ok": true })),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/channel") => {
|
||||
let (Some(iface), Some(channel)) = (req.text("iface"), req.u32("channel")) else {
|
||||
return err(400, "iface e channel obrigatórios");
|
||||
};
|
||||
match engine.set_channel(&iface, channel) {
|
||||
Ok(()) => ok_json(serde_json::json!({ "ok": true })),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/scan/start") => {
|
||||
let name = req.query.get("iface").cloned().unwrap_or_default();
|
||||
match engine.start_scan(&name) {
|
||||
Ok(()) => "{\"ok\":true}".into(),
|
||||
Err(e) => format!("{{\"ok\":false,\"error\":{:?}}}", e.to_string()),
|
||||
let Some(iface) = req.text("iface") else {
|
||||
return err(400, "iface obrigatória");
|
||||
};
|
||||
let options = ScanOptions {
|
||||
channel: req.u32("channel"),
|
||||
band: req.text("band"),
|
||||
hop_ms: req.u64("hop_ms").unwrap_or(350),
|
||||
pcap_out: req.text("pcap_out").map(Into::into),
|
||||
duration_secs: req.u64("seconds"),
|
||||
};
|
||||
match engine.start_scan_with(&iface, options) {
|
||||
Ok(()) => ok_json(serde_json::json!({ "ok": true })),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/scan/stop") => {
|
||||
let name = req.query.get("iface").cloned().unwrap_or_default();
|
||||
engine.stop_scan(&name);
|
||||
"{\"ok\":true}".into()
|
||||
let Some(iface) = req.text("iface") else {
|
||||
return err(400, "iface obrigatória");
|
||||
};
|
||||
engine.stop_scan(&iface);
|
||||
ok_json(serde_json::json!({ "ok": true }))
|
||||
}
|
||||
("GET", "/api/scan/snapshot") => {
|
||||
let Some(iface) = req.text("iface") else {
|
||||
return err(400, "iface obrigatória");
|
||||
};
|
||||
match engine.scan_snapshot(&iface) {
|
||||
Ok(snapshot) => ok(&snapshot),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("GET", "/api/scan") => {
|
||||
let name = req.query.get("iface").cloned().unwrap_or_default();
|
||||
serde_json::to_string(&engine.scan_results(&name)).unwrap_or_else(|_| "[]".into())
|
||||
let Some(iface) = req.text("iface") else {
|
||||
return err(400, "iface obrigatória");
|
||||
};
|
||||
ok(&engine.scan_results(&iface))
|
||||
}
|
||||
_ => "{\"error\":\"rota desconhecida\"}".into(),
|
||||
("GET", "/api/scan/clients") => {
|
||||
let Some(iface) = req.text("iface") else {
|
||||
return err(400, "iface obrigatória");
|
||||
};
|
||||
ok(&engine.scan_clients(&iface))
|
||||
}
|
||||
("GET", "/api/handshakes") => ok(&engine.handshakes(req.text("iface").as_deref())),
|
||||
("POST", "/api/handshakes/export") => {
|
||||
let (Some(iface), Some(bssid), Some(station), Some(out)) = (
|
||||
req.text("iface"),
|
||||
req.text("bssid"),
|
||||
req.text("station"),
|
||||
req.text("out"),
|
||||
) else {
|
||||
return err(400, "iface, bssid, station e out são obrigatórios");
|
||||
};
|
||||
match engine.export_handshake(&iface, &bssid, &station, &out) {
|
||||
Ok(report) => ok(&report),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/handshakes/export-all") => {
|
||||
let (Some(iface), Some(out)) = (req.text("iface"), req.text("out")) else {
|
||||
return err(400, "iface e out são obrigatórios");
|
||||
};
|
||||
match engine.export_all_handshakes(&iface, &out) {
|
||||
Ok(report) => ok(&report),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/crack") => {
|
||||
let (Some(iface), Some(wordlist)) = (req.text("iface"), req.text("wordlist")) else {
|
||||
return err(400, "iface e wordlist são obrigatórios");
|
||||
};
|
||||
let bssid = req.text("bssid");
|
||||
let station = req.text("station");
|
||||
match engine.start_crack(&iface, bssid.as_deref(), station.as_deref(), &wordlist) {
|
||||
Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("GET", "/api/crack") => {
|
||||
let Some(id) = req.text("id") else {
|
||||
return err(400, "id obrigatório");
|
||||
};
|
||||
match engine.crack_job(&id) {
|
||||
Some(job) => ok(&job),
|
||||
None => err(404, "job não encontrado"),
|
||||
}
|
||||
}
|
||||
("GET", "/api/cracks") => ok(&engine.crack_jobs()),
|
||||
("POST", "/api/deauth") => {
|
||||
if !req.bool("confirm") {
|
||||
return err(
|
||||
400,
|
||||
"confirme a autorização do teste (confirm=true) antes de desautenticar",
|
||||
);
|
||||
}
|
||||
let (Some(iface), Some(bssid)) = (req.text("iface"), req.text("bssid")) else {
|
||||
return err(400, "iface e bssid são obrigatórios");
|
||||
};
|
||||
let count = req.u32("count").unwrap_or(5);
|
||||
let reason = req.u16("reason").unwrap_or(7);
|
||||
match engine.deauth(
|
||||
&iface,
|
||||
&bssid,
|
||||
req.text("station").as_deref(),
|
||||
count,
|
||||
reason,
|
||||
) {
|
||||
Ok(report) => ok(&report),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/analyze") => {
|
||||
let pcap = if !req.body.is_empty() {
|
||||
serde_json::from_slice::<AnalyzeBody>(&req.body)
|
||||
.ok()
|
||||
.map(|b| b.pcap)
|
||||
.or_else(|| req.text("pcap"))
|
||||
} else {
|
||||
req.text("pcap")
|
||||
};
|
||||
let Some(pcap) = pcap else {
|
||||
return err(400, "pcap obrigatório");
|
||||
};
|
||||
match engine.analyze_pcap(&pcap) {
|
||||
Ok(snapshot) => ok(&snapshot),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("GET", "/api/audit/verify") => match engine.audit_verify() {
|
||||
Ok(None) => ok_json(serde_json::json!({ "ok": true, "broken_at": null })),
|
||||
Ok(Some(idx)) => ok_json(serde_json::json!({ "ok": false, "broken_at": idx })),
|
||||
Err(e) => err(400, e),
|
||||
},
|
||||
("GET", "/api/audit") => {
|
||||
let limit = req.u64("limit").map(|v| v as usize).or(Some(50));
|
||||
match engine.audit_entries(limit) {
|
||||
Ok(entries) => ok(&entries),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
_ => err(404, "rota desconhecida"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn decodes_url_components() {
|
||||
assert_eq!(urldecode("a%20b%2Fc+d"), "a b/c d");
|
||||
assert_eq!(urldecode("100%25"), "100%");
|
||||
assert_eq!(urldecode("caf%C3%A9"), "café");
|
||||
assert_eq!(urldecode("bad%2"), "bad%2");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)"
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user