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>
No files matched your search
@@ -0,0 +1,17 @@
|
||||
name: Relatar um problema
|
||||
description: Algo não funcionou como esperado
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: textarea
|
||||
id: o-que
|
||||
attributes: { label: "O que aconteceu?", description: "Passos, o que esperava e o que viu." }
|
||||
validations: { required: true }
|
||||
- type: input
|
||||
id: sistema
|
||||
attributes: { label: "Sistema operacional e versão do Sabiá", placeholder: "Ubuntu 24.04 / sabia 0.1.0" }
|
||||
- type: input
|
||||
id: adaptador
|
||||
attributes: { label: "Adaptador Wi-Fi e modo", placeholder: "wlan0 (Intel AX200), modo monitor" }
|
||||
- type: textarea
|
||||
id: log
|
||||
attributes: { label: "Saída do terminal (sabia scan / ui)", render: shell }
|
||||
@@ -0,0 +1,7 @@
|
||||
## O que muda e por quê
|
||||
|
||||
## Checklist
|
||||
- [ ] `cargo fmt --all` e `cargo clippy --workspace --all-targets` sem avisos
|
||||
- [ ] `cargo test --workspace` passa
|
||||
- [ ] Nada neste PR transmite ou envia dados capturados para fora da máquina
|
||||
- [ ] CHANGELOG atualizado (se houver mudança visível)
|
||||
@@ -1,9 +1,12 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
# Mudança só de documentação não precisa de 6 jobs em 3 sistemas.
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore: ['**.md', 'docs/**', 'LICENSE*', '.gitignore', '.githooks/**']
|
||||
pull_request:
|
||||
paths-ignore: ['**.md', 'docs/**', 'LICENSE*', '.gitignore', '.githooks/**']
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
@@ -23,6 +26,11 @@ jobs:
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Cobertura da tradução da interface
|
||||
run: node scripts/check-ui-i18n.mjs
|
||||
- run: cargo fmt --all -- --check
|
||||
- run: cargo clippy --workspace --all-targets
|
||||
|
||||
@@ -60,10 +68,14 @@ jobs:
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Cache dos pacotes do Tauri (.deb)
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/apt-cache
|
||||
key: apt-tauri-${{ runner.os }}-${{ runner.arch }}-${{ env.ImageVersion }}-${{ hashFiles('scripts/apt-install.sh') }}
|
||||
- name: Dependências do Tauri
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||
timeout-minutes: 25
|
||||
run: scripts/apt-install.sh ~/apt-cache libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||
- name: Gera os ícones
|
||||
run: npx --yes @tauri-apps/cli@2 icon apps/gui/src-tauri/icons/source.png -o apps/gui/src-tauri/icons
|
||||
- run: cargo check --manifest-path apps/gui/src-tauri/Cargo.toml
|
||||
|
||||
@@ -106,11 +106,16 @@ jobs:
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Cache dos pacotes do Tauri (.deb)
|
||||
if: runner.os == 'Linux'
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/apt-cache
|
||||
key: apt-tauri-${{ runner.os }}-${{ runner.arch }}-${{ env.ImageVersion }}-${{ hashFiles('scripts/apt-install.sh') }}
|
||||
- name: Dependências do Tauri (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||
timeout-minutes: 25
|
||||
run: scripts/apt-install.sh ~/apt-cache libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||
- name: Gera os ícones
|
||||
run: npx --yes @tauri-apps/cli@2 icon apps/gui/src-tauri/icons/source.png -o apps/gui/src-tauri/icons
|
||||
- uses: tauri-apps/tauri-action@v1
|
||||
|
||||
@@ -1,23 +1,31 @@
|
||||
# Changelog
|
||||
|
||||
**English** · [Português](CHANGELOG.pt-BR.md)
|
||||
|
||||
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow SemVer.
|
||||
|
||||
## [0.1.0] - 2026-10-23
|
||||
|
||||
Primeira versão: CLI, app desktop e núcleo compartilhado.
|
||||
First release: CLI, desktop app and shared core.
|
||||
|
||||
### Added
|
||||
- `sabia-core`: descoberta de interfaces Wi-Fi, controle de modo monitor e
|
||||
canal (via `iw`/`ip`), captura crua de quadros 802.11 por AF_PACKET (sem
|
||||
libpcap), parsing de radiotap e de quadros de gerenciamento (beacon,
|
||||
probe, EAPOL), agregação de APs e clientes vistos, escrita de `.pcap`,
|
||||
trilha de auditoria local encadeada por hash, auto-atualização por
|
||||
releases do GitHub.
|
||||
- `sabia`: CLI com `iface`, `scan`, `update`, `audit-verify` e `ui`
|
||||
(servidor local da interface gráfica).
|
||||
- App desktop (Tauri) com a mesma interface servida pelo `sabia ui`,
|
||||
pedindo elevação de administrador automaticamente ao abrir — modo
|
||||
monitor e captura crua exigem root/admin.
|
||||
- Interface gráfica: sidebar de interfaces, painel de captura com linha
|
||||
de sinal animada, tabela de redes com barras de sinal e badges de
|
||||
segurança por cor.
|
||||
- `sabia-core`: Wi-Fi interface discovery, **monitor mode** and channel control
|
||||
(via netlink/`iw`), **scanning** of 2.4, 5 and 6 GHz, raw 802.11 frame capture
|
||||
via **AF_PACKET** (no libpcap), **radiotap** and management-frame parsing
|
||||
(beacon, probe, EAPOL), aggregation of APs and clients, capture of the
|
||||
**4-way handshake** and **PMKID**, **offline dictionary cracking**
|
||||
(PBKDF2-HMAC-SHA1, MIC and PMKID) for WPA/WPA2-PSK, `.pcap` read and write,
|
||||
frame injection, a local hash-chained **audit trail**, and **self-update**
|
||||
from GitHub releases.
|
||||
- `sabia`: CLI with `iface` (list/channels/monitor), `scan`, `analyze`, `crack`,
|
||||
`deauth` (with `--yes`), `doctor`, `audit` (verify/show), `update` and `ui`
|
||||
(local interface server).
|
||||
- Desktop app (Tauri) with the same interface served by `sabia ui`, elevating to
|
||||
administrator automatically on startup — monitor mode and raw capture require
|
||||
root/admin.
|
||||
- Interface: interface sidebar, capture panel with an animated signal line,
|
||||
networks table with signal bars and color-coded security badges, an
|
||||
**authorized-use** notice, a one-click **update** button, Auto/Light/Dark
|
||||
themes and **Portuguese and English** language.
|
||||
- CI with caches (`rust-cache`, cached Tauri `.deb`) and a cross-platform release
|
||||
pipeline (CLI on 5 targets and desktop app via `tauri-action`).
|
||||
@@ -0,0 +1,31 @@
|
||||
# Changelog
|
||||
|
||||
[English](CHANGELOG.md) · **Português**
|
||||
|
||||
Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer.
|
||||
|
||||
## [0.1.0] - 2026-10-23
|
||||
|
||||
Primeira versão: CLI, app desktop e núcleo compartilhado.
|
||||
|
||||
### Adicionado
|
||||
- `sabia-core`: descoberta de interfaces Wi-Fi, controle de **modo monitor** e
|
||||
canal (via netlink/`iw`), **varredura** de 2.4, 5 e 6 GHz, captura crua de
|
||||
quadros 802.11 por **AF_PACKET** (sem libpcap), parsing de **radiotap** e de
|
||||
quadros de gerenciamento (beacon, probe, EAPOL), agregação de APs e clientes,
|
||||
captura de **4-way handshake** e **PMKID**, **quebra offline** por dicionário
|
||||
(PBKDF2-HMAC-SHA1, MIC e PMKID) para WPA/WPA2-PSK, escrita e leitura de
|
||||
**`.pcap`**, injeção de quadros, trilha de **auditoria** local encadeada por
|
||||
hash, **auto-atualização** por releases do GitHub.
|
||||
- `sabia`: CLI com `iface` (list/channels/monitor), `scan`, `analyze`, `crack`,
|
||||
`deauth` (com `--yes`), `doctor`, `audit` (verify/show), `update` e `ui`
|
||||
(servidor local da interface).
|
||||
- App desktop (Tauri) com a mesma interface servida pelo `sabia ui`, pedindo
|
||||
elevação de administrador automaticamente ao abrir — modo monitor e captura
|
||||
crua exigem root/admin.
|
||||
- Interface gráfica: sidebar de interfaces, painel de captura com linha de sinal
|
||||
animada, tabela de redes com barras de sinal e selos de segurança por cor,
|
||||
aviso de **uso autorizado**, botão de **atualização** em um clique, temas Auto/
|
||||
Claro/Escuro e idioma **português e inglês**.
|
||||
- CI com caches (`rust-cache`, `.deb` do Tauri em cache) e pipeline de release
|
||||
multiplataforma (CLI em 5 alvos e app desktop via `tauri-action`).
|
||||
@@ -0,0 +1,43 @@
|
||||
# Contributing to Sabiá
|
||||
|
||||
**English** · [Português](CONTRIBUTING.pt-BR.md)
|
||||
|
||||
Thanks for helping! Golden rules:
|
||||
|
||||
1. **Authorized auditing only.** Every new feature is for testing your own networks or networks you're authorized to test. Sensitive operations (deauthentication) require explicit confirmation and are recorded in the audit trail. PRs that hide activity from the trail or remove the confirmations are rejected.
|
||||
2. **Test the parsers with real data.** New readers (radiotap, 802.11, EAPOL, pcap) need tests against known frames — see `crates/sabia-core/tests/` and the helpers in `crates/sabia-core/src/testutil.rs`. No parser without a test.
|
||||
3. **Be honest about what works.** If a feature is Linux-only, or only covers WPA2-PSK, say so in `--help`, in the README and in the test.
|
||||
|
||||
## Workflow
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo clippy --workspace --all-targets # as in CI (RUSTFLAGS=-D warnings)
|
||||
cargo test --workspace
|
||||
node scripts/check-ui-i18n.mjs # UI translation coverage
|
||||
```
|
||||
|
||||
CI runs on Linux, Windows and macOS; run these before pushing — almost everything that fails CI shows up here in seconds. Documentation-only changes (`*.md`, `docs/`) are light on CI.
|
||||
|
||||
Live capture exists only on Linux (`AF_PACKET` + netlink); on Windows and macOS those paths compile but aren't exercised. Parsing and cracking tests run on all three, because they're offline.
|
||||
|
||||
## Where to work
|
||||
|
||||
Module map in [docs/architecture.md](docs/architecture.md). What's missing and welcome is in the [ROADMAP](ROADMAP.md).
|
||||
|
||||
- **Frame parsing:** `radiotap.rs`, `dot11.rs`, `eapol.rs`.
|
||||
- **Radio and system:** `iface.rs`, `netlink.rs`, `scan.rs`, `capture.rs`, `inject.rs`.
|
||||
- **Handshake and cracking:** `handshake.rs`, `crack.rs`.
|
||||
- **App and API:** `crates/sabia-cli/src/` and `apps/gui/`.
|
||||
|
||||
## Interface and translation
|
||||
|
||||
The source text of the interface is **Portuguese**; English comes from `apps/gui/ui/i18n-en.js`, applied at runtime by `i18n.js`. When adding or changing visible text, run `node scripts/check-ui-i18n.mjs` and include the translation — CI rejects Portuguese without an English pair. Don't translate user data (SSID, BSSID, values) or technical terms (WPA, EAPOL, pcap…).
|
||||
|
||||
## Documentation
|
||||
|
||||
Keep the pt/en pair: every `*.md` and `docs/*` file has a Portuguese version (`*.pt-BR.md`, `docs/pt-BR/`) and an English one.
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree to license under [MIT](LICENSE-MIT) **or** [Apache-2.0](LICENSE-APACHE), like the rest of the project.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Contribuindo com o Sabiá
|
||||
|
||||
[English](CONTRIBUTING.md) · **Português**
|
||||
|
||||
Obrigado por ajudar! Regras de ouro:
|
||||
|
||||
1. **Só auditoria autorizada.** Todo recurso novo é para testar redes próprias ou com autorização. Operações sensíveis (desautenticação) exigem confirmação explícita e ficam na trilha de auditoria. PRs que escondem atividade da trilha ou removem as confirmações são recusados.
|
||||
2. **Teste os parsers com dados reais.** Novos leitores (radiotap, 802.11, EAPOL, pcap) precisam de teste com quadros conhecidos — veja `crates/sabia-core/tests/` e os ajudantes em `crates/sabia-core/src/testutil.rs`. Nada de parser sem teste.
|
||||
3. **Seja honesto sobre o que funciona.** Se um recurso só roda no Linux, ou só cobre WPA2-PSK, diga isso no `--help`, no README e no teste.
|
||||
|
||||
## Fluxo
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo clippy --workspace --all-targets # como no CI (RUSTFLAGS=-D warnings)
|
||||
cargo test --workspace
|
||||
node scripts/check-ui-i18n.mjs # cobertura da tradução da interface
|
||||
```
|
||||
|
||||
O CI roda em Linux, Windows e macOS; rode esses comandos antes do push — quase tudo que faz o CI falhar aparece aqui em segundos. Mudanças só de documentação (`*.md`, `docs/`) são leves no CI.
|
||||
|
||||
A captura ao vivo só existe no Linux (`AF_PACKET` + netlink); em Windows e macOS esses caminhos são compilados mas não exercitados. Testes de parsing e de quebra rodam nas três plataformas, porque são offline.
|
||||
|
||||
## Onde mexer
|
||||
|
||||
Mapa dos módulos em [docs/pt-BR/arquitetura.md](docs/pt-BR/arquitetura.md). O que falta e o que é bem-vindo está no [ROADMAP](ROADMAP.pt-BR.md).
|
||||
|
||||
- **Parsing de quadros:** `radiotap.rs`, `dot11.rs`, `eapol.rs`.
|
||||
- **Rádio e sistema:** `iface.rs`, `netlink.rs`, `scan.rs`, `capture.rs`, `inject.rs`.
|
||||
- **Handshake e quebra:** `handshake.rs`, `crack.rs`.
|
||||
- **App e API:** `crates/sabia-cli/src/` e `apps/gui/`.
|
||||
|
||||
## Interface e tradução
|
||||
|
||||
O texto-fonte da interface é em **português**; o inglês vem de `apps/gui/ui/i18n-en.js`, aplicado em tempo de execução por `i18n.js`. Ao adicionar ou mudar texto visível, rode `node scripts/check-ui-i18n.mjs` e inclua a tradução — o CI recusa português sem par em inglês. Não traduza dados do usuário (SSID, BSSID, valores) nem termos técnicos (WPA, EAPOL, pcap…).
|
||||
|
||||
## Documentação
|
||||
|
||||
Mantenha o par pt/en: todo arquivo `*.md` e `docs/*` tem a versão em português (`*.pt-BR.md`, `docs/pt-BR/`) e em inglês.
|
||||
|
||||
## Licença
|
||||
|
||||
Ao contribuir, você concorda em licenciar sob [MIT](LICENSE-MIT) **ou** [Apache-2.0](LICENSE-APACHE), como o resto do projeto.
|
||||
@@ -178,6 +178,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -192,6 +193,22 @@ dependencies = [
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
@@ -202,12 +219,32 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
@@ -226,6 +263,22 @@ version = "0.2.191"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
@@ -265,6 +318,12 @@ version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
@@ -295,6 +354,25 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sabia-cli"
|
||||
version = "0.1.0"
|
||||
@@ -302,6 +380,7 @@ dependencies = [
|
||||
"clap",
|
||||
"ctrlc",
|
||||
"sabia-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
@@ -309,10 +388,14 @@ dependencies = [
|
||||
name = "sabia-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"hmac",
|
||||
"libc",
|
||||
"md-5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"time",
|
||||
]
|
||||
@@ -360,6 +443,17 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
@@ -377,6 +471,12 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
@@ -399,6 +499,19 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
|
||||
@@ -1,51 +1,112 @@
|
||||
# Sabiá
|
||||
<p align="center"><img src="apps/gui/ui/sabia.png" width="300" alt="Sabiá"></p>
|
||||
<h1 align="center">Sabiá</h1>
|
||||
<p align="center">Free and open Wi-Fi auditing.<br>Terminal and desktop app · live capture on Linux, offline analysis on Windows, Linux and macOS · authorized use.</p>
|
||||
|
||||
Auditoria, monitoramento e pentest de redes Wi-Fi — terminal e interface
|
||||
gráfica sobre o mesmo núcleo, com foco na interface gráfica e uso
|
||||
intuitivo: a parte complexa (netlink, radiotap, parsing de quadro 802.11)
|
||||
fica escondida em `sabia-core`; CLI e GUI só consomem.
|
||||
<p align="center"><a href="docs/cli.md">Terminal</a> · <a href="docs/interface.md">Interface</a> · <a href="docs/architecture.md">Architecture</a> · <a href="ROADMAP.md">Roadmap</a> · <a href="CHANGELOG.md">Changelog</a></p>
|
||||
|
||||
> Use apenas em redes próprias ou com autorização explícita do responsável
|
||||
> pela rede (engajamento de pentest formal). Toda ativação de modo monitor
|
||||
> e sessão de captura é registrada numa trilha de auditoria local
|
||||
> encadeada por hash (`.sabia-audit.log`).
|
||||
<p align="center"><b>English</b> · <a href="README.pt-BR.md">Português</a></p>
|
||||
|
||||
## Estrutura
|
||||
---
|
||||
|
||||
```
|
||||
crates/sabia-core biblioteca: netlink (iw), radiotap, parsing 802.11,
|
||||
captura crua (AF_PACKET), auditoria, auto-atualização
|
||||
crates/sabia-cli binário `sabia`: comandos de terminal + servidor
|
||||
local da interface (`sabia ui`)
|
||||
apps/gui/ui mesma página HTML/JS servida pelo `sabia ui` e pelo app desktop
|
||||
apps/gui/src-tauri app desktop (Tauri) — pede administrador/root ao abrir
|
||||
Sabiá brings together, in a single sweep, what tools like `airodump-ng`, `hcxdumptool`, `aircrack-ng` and `wifite` do separately — in one Rust binary, with no `libpcap` dependency:
|
||||
|
||||
- **Network discovery:** hops across the **2.4, 5 and 6 GHz** channels and lists access points and associated clients, with SSID, BSSID, channel, signal and security type.
|
||||
- **Monitor mode without scripts:** turns the card's monitor mode on and off straight through netlink/`iw`, and shows which channels the radio supports.
|
||||
- **Handshake capture:** recognizes the **4-way handshake (EAPOL)** and the **PMKID** on the air and keeps only what offline cracking needs.
|
||||
- **Offline dictionary cracking:** recomputes the PMK with **PBKDF2-HMAC-SHA1** and checks the **MIC** (4-way) or the **PMKID** (`HMAC-SHA1(PMK, "PMK Name" …)`) — for WPA/WPA2-PSK.
|
||||
- **pcap round-trip:** writes captured frames to `.pcap` and **analyzes a `.pcap` offline** (networks, clients and handshakes), interoperable with the other tools.
|
||||
- **Deauthentication test:** sends _deauth_ to an AP/station to force a reassociation — only with an explicit authorization confirmation (`--yes`).
|
||||
- **Audit trail:** every monitor-mode activation and capture session is recorded in a local **hash-chained** trail.
|
||||
- **Diagnostics:** `doctor` checks privileges, tools and interfaces before you start.
|
||||
|
||||
## Authorized use
|
||||
|
||||
Sabiá is an **auditing** tool — for testing the security of networks you **own** or have **explicit authorization** to assess.
|
||||
|
||||
- Capturing handshakes, sending deauthentication or cracking passwords on third-party networks **without permission is illegal** in most countries. The responsibility is the operator's.
|
||||
- Deauthentication (`deauth`) requires the `--yes` flag, an assertion that you are authorized to test that network.
|
||||
- Every monitor-mode activation and capture session is **recorded in the local audit trail** (`sabia audit show`), hash-chained to detect tampering.
|
||||
- **Nothing of yours leaves the machine.** The only network access by default is the update check on startup (which can be disabled).
|
||||
- Dictionary passwords and handshakes stay on your machine.
|
||||
|
||||
## Install
|
||||
|
||||
Download the package for your system from **[Releases](../../releases)**:
|
||||
|
||||
| | CLI | Desktop app |
|
||||
|---|---|---|
|
||||
| Windows | `sabia-*-windows-x86_64.zip` | `.msi` / `.exe` installer |
|
||||
| macOS | `sabia-*-macos-{aarch64,x86_64}.tar.gz` | `.dmg` |
|
||||
| Linux | `sabia-*-linux-{x86_64,aarch64}.tar.gz` | `.AppImage` / `.deb` |
|
||||
|
||||
Each file has a `.sha256` next to it. Build from source: `cargo build --release -p sabia-cli` (Rust 1.80+).
|
||||
|
||||
**Updates:** the desktop app checks GitHub on startup; when a new version exists, a button offers **Update now**: it downloads the right binary for your platform, verifies the **SHA-256** and swaps the running executable — no manual download. The check is a single request to the releases API and sends nothing of yours.
|
||||
|
||||
> The binaries are **not signed yet**: Windows SmartScreen and macOS Gatekeeper will warn. Check the SHA-256.
|
||||
|
||||
**Capture and monitor mode require Linux and root** (`CAP_NET_ADMIN`/`CAP_NET_RAW`): raw capture uses `AF_PACKET` and interface control uses netlink. **Offline `.pcap` analysis and cracking** also run on Windows and macOS. The desktop app elevates to administrator on its own at startup.
|
||||
|
||||
## Getting started
|
||||
|
||||
```bash
|
||||
sabia doctor # privileges, tools and interfaces
|
||||
sabia iface list # Wi-Fi interfaces and their modes
|
||||
sudo sabia iface monitor wlan0 on # enable monitor mode
|
||||
|
||||
sudo sabia scan wlan0 --band all --out air.pcap # hop channels and record the air
|
||||
sabia analyze air.pcap # read a .pcap offline
|
||||
sabia crack air.pcap --wordlist list.txt # crack the handshake by dictionary
|
||||
```
|
||||
|
||||
## Uso
|
||||
Authorized deauthentication testing and all other commands (`audit`, `update`, `ui`) are in **[docs/cli.md](docs/cli.md)**.
|
||||
|
||||
## Desktop app
|
||||
|
||||
Desktop app (Tauri) with an interface sidebar, a capture panel with an animated signal line, and a networks table with signal bars and color-coded security badges. The same front-end runs in the browser with `sabia ui`. More in **[docs/interface.md](docs/interface.md)**.
|
||||
|
||||
- Pick the interface, toggle **monitor mode** and start/stop **capture** by button.
|
||||
- Live **networks seen** table, with signal, channel and security.
|
||||
- An **authorized-use** notice always in view and automatic privilege elevation on startup.
|
||||
- One-click **update** button, **Auto, Light and Dark** themes and interface in **Portuguese and English**.
|
||||
|
||||
## Known limits
|
||||
|
||||
- **Capture is Linux-only**, with a card that supports **monitor mode** (and injection, for `deauth`). On Windows and macOS Sabiá only does `.pcap` analysis and offline cracking.
|
||||
- **WPA3/SAE** does not fall to handshake dictionaries — cracking covers **WPA/WPA2-PSK** (4-way and PMKID).
|
||||
- **5 and 6 GHz** depend on the card and the regulatory domain; some channels stay passive (no probing).
|
||||
- **Passwords outside the dictionary don't come back:** cracking is dictionary force only, no rules or masks yet.
|
||||
- **Windows and macOS:** the code compiles and is tested in CI, but live capture does not run on those platforms.
|
||||
|
||||
## Where it's going
|
||||
|
||||
Summary of the [ROADMAP](ROADMAP.md):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Done** | Monitor mode via netlink, 2.4/5/6 GHz scanning, 4-way and PMKID capture, `.pcap` read/write, dictionary cracking (WPA/WPA2-PSK), authorized `deauth`, hash-chained audit trail, `doctor`, CLI + desktop app, one-click update |
|
||||
| **Next** | Rules and masks in cracking, GPU acceleration, export to the hashcat format (`22000`) |
|
||||
| **Later** | WPS attack, rogue AP / evil twin for authorized testing, capture on Windows (Npcap) |
|
||||
| **Long term** | WPA3/SAE downgrade, validation on varied hardware |
|
||||
|
||||
## Development
|
||||
|
||||
```
|
||||
sabia iface list # lista interfaces, marca as Wi-Fi
|
||||
sabia iface monitor <if> on # liga modo monitor (precisa root)
|
||||
sabia scan <if> --out captura.pcap # varre canais, mostra APs/clientes
|
||||
sabia ui # abre a interface gráfica local
|
||||
sabia update # verifica atualização
|
||||
sabia update --apply # baixa e aplica a atualização
|
||||
crates/sabia-core core: interfaces, netlink, radiotap, 802.11, EAPOL, handshake, capture, crack, audit, update
|
||||
crates/sabia-cli the `sabia` binary (CLI + local UI server)
|
||||
apps/gui interface (ui/ = HTML, CSS and JS; src-tauri/ = desktop app)
|
||||
scripts/ CI helpers (cached dependency install, i18n coverage check)
|
||||
docs/ terminal, interface and architecture
|
||||
```
|
||||
|
||||
Ou baixe o app desktop em [Releases](https://github.com/lucasrguerra/sabia/releases)
|
||||
— ele pede elevação de administrador/root automaticamente ao abrir, já que
|
||||
modo monitor e captura crua exigem esse privilégio em qualquer sistema.
|
||||
```bash
|
||||
cargo test --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
node scripts/check-ui-i18n.mjs # UI translation coverage
|
||||
```
|
||||
|
||||
Pela CLI, capturar quadros 802.11 exige `CAP_NET_RAW`/`CAP_NET_ADMIN` —
|
||||
normalmente root (`sudo sabia ...`).
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) and [docs/architecture.md](docs/architecture.md). Versions follow [SemVer](https://semver.org/); changes in [CHANGELOG.md](CHANGELOG.md). To release: `git tag vX.Y.Z && git push --tags` (the `release` workflow builds and publishes everything). Vulnerabilities: [SECURITY.md](SECURITY.md).
|
||||
|
||||
## Dependências externas
|
||||
## License
|
||||
|
||||
`iw` e `ip` (iproute2) para controlar modo monitor e canal — é uma ponte
|
||||
deliberada até existir uma implementação nl80211 nativa. A captura em si
|
||||
(AF_PACKET) e todo o parsing de quadro são Rust puro, sem libpcap.
|
||||
|
||||
## Hardware testado
|
||||
|
||||
Adaptador USB RTL8812AU (driver `rtw88_8812au`), suporta modo monitor e
|
||||
injeção.
|
||||
[MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), your choice.
|
||||
@@ -0,0 +1,112 @@
|
||||
<p align="center"><img src="apps/gui/ui/sabia.png" width="300" alt="Sabiá"></p>
|
||||
<h1 align="center">Sabiá</h1>
|
||||
<p align="center">Auditoria de Wi-Fi livre e gratuita.<br>Terminal e interface gráfica · captura no Linux, análise offline em Windows, Linux e macOS · uso autorizado.</p>
|
||||
|
||||
<p align="center"><a href="docs/pt-BR/cli.md">Terminal</a> · <a href="docs/pt-BR/interface.md">Interface</a> · <a href="docs/pt-BR/arquitetura.md">Arquitetura</a> · <a href="ROADMAP.pt-BR.md">Roadmap</a> · <a href="CHANGELOG.pt-BR.md">Changelog</a></p>
|
||||
|
||||
<p align="center"><a href="README.md">English</a> · <b>Português</b></p>
|
||||
|
||||
---
|
||||
|
||||
O Sabiá junta, numa única varredura, o que ferramentas como `airodump-ng`, `hcxdumptool`, `aircrack-ng` e `wifite` fazem separadamente — em um só binário Rust, sem depender de `libpcap`:
|
||||
|
||||
- **Descoberta de redes:** percorre os canais de **2.4, 5 e 6 GHz** e lista os pontos de acesso e os clientes associados, com SSID, BSSID, canal, sinal e tipo de segurança.
|
||||
- **Modo monitor sem scripts:** liga e desliga o modo monitor da placa direto pelo netlink/`iw`, e mostra os canais que o rádio suporta.
|
||||
- **Captura de handshake:** reconhece o **4-way handshake (EAPOL)** e o **PMKID** no ar e guarda só o que é preciso para a quebra offline.
|
||||
- **Quebra offline por dicionário:** recalcula a PMK com **PBKDF2-HMAC-SHA1** e confere o **MIC** (4-way) ou o **PMKID** (`HMAC-SHA1(PMK, "PMK Name" …)`) — para WPA/WPA2-PSK.
|
||||
- **pcap de ida e volta:** grava os quadros capturados em `.pcap` e **analisa um `.pcap` offline** (redes, clientes e handshakes), compatível com as outras ferramentas.
|
||||
- **Teste de desautenticação:** envia _deauth_ a um AP/estação para forçar uma reassociação — só com a confirmação explícita de autorização (`--yes`).
|
||||
- **Trilha de auditoria:** toda ativação de modo monitor e sessão de captura fica registrada numa trilha local **encadeada por hash**.
|
||||
- **Diagnóstico:** o `doctor` confere privilégios, ferramentas e interfaces antes de você começar.
|
||||
|
||||
## Uso autorizado
|
||||
|
||||
O Sabiá é uma ferramenta de **auditoria** — para testar a segurança de redes **suas** ou de redes que você tem **autorização explícita** para avaliar.
|
||||
|
||||
- Capturar handshakes, enviar desautenticação ou quebrar senhas de redes de terceiros **sem permissão é ilegal** na maioria dos países. A responsabilidade é de quem usa.
|
||||
- A desautenticação (`deauth`) exige a flag `--yes`, uma afirmação de que você tem autorização para testar aquela rede.
|
||||
- Toda ativação de modo monitor e sessão de captura é **registrada na trilha de auditoria local** (`sabia audit show`), com encadeamento por hash para detectar adulteração.
|
||||
- **Nada seu é enviado para fora.** O único acesso à rede por padrão é a verificação de atualização ao abrir o app (desligável).
|
||||
- Senhas de dicionário e handshakes ficam só na sua máquina.
|
||||
|
||||
## Instalação
|
||||
|
||||
Baixe o pacote do seu sistema em **[Releases](../../releases)**:
|
||||
|
||||
| | CLI | App gráfico |
|
||||
|---|---|---|
|
||||
| Windows | `sabia-*-windows-x86_64.zip` | instalador `.msi` / `.exe` |
|
||||
| macOS | `sabia-*-macos-{aarch64,x86_64}.tar.gz` | `.dmg` |
|
||||
| Linux | `sabia-*-linux-{x86_64,aarch64}.tar.gz` | `.AppImage` / `.deb` |
|
||||
|
||||
Cada arquivo tem um `.sha256` ao lado. Compilar do código: `cargo build --release -p sabia-cli` (Rust 1.80+).
|
||||
|
||||
**Atualizações:** o app desktop consulta o GitHub ao abrir; havendo versão nova, um botão oferece **Atualizar agora**: ele baixa o binário certo para a plataforma, confere o **SHA-256** e troca o executável em uso — você não precisa baixar nada na mão. A consulta é uma única requisição à API de releases e nada seu é enviado.
|
||||
|
||||
> Os binários ainda **não são assinados**: o SmartScreen (Windows) e o Gatekeeper (macOS) vão avisar. Confira o SHA-256.
|
||||
|
||||
**Captura e modo monitor exigem Linux e privilégio de root** (`CAP_NET_ADMIN`/`CAP_NET_RAW`): a captura crua usa `AF_PACKET` e o controle de interface usa netlink. A **análise de `.pcap` e a quebra offline** rodam também em Windows e macOS. O app desktop pede elevação de administrador sozinho ao abrir.
|
||||
|
||||
## Começando
|
||||
|
||||
```bash
|
||||
sabia doctor # privilégios, ferramentas e interfaces
|
||||
sabia iface list # interfaces Wi-Fi e seus modos
|
||||
sudo sabia iface monitor wlan0 on # liga o modo monitor
|
||||
|
||||
sudo sabia scan wlan0 --band all --out ar.pcap # percorre os canais e grava o ar
|
||||
sabia analyze ar.pcap # lê um .pcap offline
|
||||
sabia crack ar.pcap --wordlist lista.txt # quebra o handshake por dicionário
|
||||
```
|
||||
|
||||
Teste autorizado de desautenticação e todos os outros comandos (`audit`, `update`, `ui`) estão em **[docs/pt-BR/cli.md](docs/pt-BR/cli.md)**.
|
||||
|
||||
## Interface gráfica
|
||||
|
||||
App desktop (Tauri) com sidebar de interfaces, painel de captura com linha de sinal animada e uma tabela de redes com barras de sinal e selos de segurança por cor. O mesmo front-end roda no navegador com `sabia ui`. Mais detalhes em **[docs/pt-BR/interface.md](docs/pt-BR/interface.md)**.
|
||||
|
||||
- Seleção da interface, ativação de **modo monitor** e início/parada de **captura** por botão.
|
||||
- Tabela de **redes vistas** em tempo real, com sinal, canal e segurança.
|
||||
- Aviso de **uso autorizado** sempre à vista e elevação automática de privilégio ao abrir.
|
||||
- Botão de **atualização** em um clique, temas **Auto, Claro e Escuro** e interface em **português e inglês**.
|
||||
|
||||
## Limites conhecidos
|
||||
|
||||
- **Captura só no Linux**, com placa que suporte **modo monitor** (e injeção, para `deauth`). Em Windows e macOS o Sabiá faz só análise de `.pcap` e quebra offline.
|
||||
- **WPA3/SAE** não cai por dicionário de handshake — a quebra cobre **WPA/WPA2-PSK** (4-way e PMKID).
|
||||
- **5 e 6 GHz** dependem da placa e do _regulatory domain_; alguns canais ficam passivos (sem _probe_).
|
||||
- **Senhas fora do dicionário não voltam:** a quebra é só força de dicionário, sem regras nem máscara ainda.
|
||||
- **Windows e macOS:** o código compila e é testado em CI, mas a captura ao vivo não roda nessas plataformas.
|
||||
|
||||
## Para onde vai
|
||||
|
||||
Resumo do [ROADMAP](ROADMAP.pt-BR.md):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Feito** | Modo monitor por netlink, varredura 2.4/5/6 GHz, captura de 4-way e PMKID, escrita e leitura de `.pcap`, quebra por dicionário (WPA/WPA2-PSK), `deauth` autorizado, trilha de auditoria por hash, `doctor`, CLI + app desktop, atualização em um clique |
|
||||
| **Próximo** | Regras e máscaras na quebra, aceleração por GPU, exportação para o formato do hashcat (`22000`) |
|
||||
| **Depois** | Ataque a WPS, _rogue AP_/_evil twin_ para teste autorizado, captura em Windows (Npcap) |
|
||||
| **Longo prazo** | WPA3/SAE _downgrade_, validação em hardware variado |
|
||||
|
||||
## Desenvolvimento
|
||||
|
||||
```
|
||||
crates/sabia-core núcleo: interfaces, netlink, radiotap, 802.11, EAPOL, handshake, captura, crack, auditoria, update
|
||||
crates/sabia-cli binário `sabia` (CLI + servidor local da UI)
|
||||
apps/gui interface (ui/ = HTML, CSS e JS; src-tauri/ = app desktop)
|
||||
scripts/ apoio de CI (instalação de dependências em cache, checagem de i18n)
|
||||
docs/ terminal, interface e arquitetura
|
||||
```
|
||||
|
||||
```bash
|
||||
cargo test --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
node scripts/check-ui-i18n.mjs # cobertura da tradução da interface
|
||||
```
|
||||
|
||||
Veja [CONTRIBUTING.pt-BR.md](CONTRIBUTING.pt-BR.md) e [docs/pt-BR/arquitetura.md](docs/pt-BR/arquitetura.md). Versões seguem [SemVer](https://semver.org/lang/pt-BR/); mudanças em [CHANGELOG.pt-BR.md](CHANGELOG.pt-BR.md). Para publicar: `git tag vX.Y.Z && git push --tags` (o workflow `release` compila e publica tudo). Vulnerabilidades: [SECURITY.pt-BR.md](SECURITY.pt-BR.md).
|
||||
|
||||
## Licença
|
||||
|
||||
[MIT](LICENSE-MIT) ou [Apache-2.0](LICENSE-APACHE), à sua escolha.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Roadmap
|
||||
|
||||
**English** · [Português](ROADMAP.pt-BR.md)
|
||||
|
||||
Sabiá does Wi-Fi auditing: it discovers networks, captures handshakes (4-way and PMKID), cracks WPA/WPA2-PSK by offline dictionary and tests deauthentication with authorization. Always for authorized use, with a local audit trail.
|
||||
|
||||
## Done (0.1.0)
|
||||
|
||||
- **Monitor mode** and channel control via netlink/`iw`.
|
||||
- **Scanning** of 2.4, 5 and 6 GHz: APs and clients, with SSID, BSSID, channel, signal and security.
|
||||
- Raw 802.11 capture via **AF_PACKET** (no libpcap), **radiotap** and management-frame parsing.
|
||||
- Capture of the **4-way handshake (EAPOL)** and the **PMKID**.
|
||||
- **Offline dictionary cracking** (PBKDF2-HMAC-SHA1, MIC and PMKID) for WPA/WPA2-PSK.
|
||||
- **`.pcap`** read and write; offline `.pcap` analysis.
|
||||
- Authorized **`deauth`** (`--yes` flag).
|
||||
- Local hash-chained **audit trail**; `doctor` diagnostics.
|
||||
- **CLI** + **desktop app** (Tauri) + local UI server; **one-click update**.
|
||||
|
||||
## Next
|
||||
|
||||
- **Rules and masks** in cracking (hashcat style), beyond the plain dictionary.
|
||||
- **`22000` export** to take the handshake straight to hashcat/John.
|
||||
- **GPU** acceleration for PMK derivation.
|
||||
|
||||
## Later
|
||||
|
||||
- **WPS attack** (PIN/Pixie Dust) in authorized testing.
|
||||
- **Rogue AP / evil twin** for client assessment, with authorization.
|
||||
- **Capture on Windows** via Npcap.
|
||||
|
||||
## Long term
|
||||
|
||||
- **WPA3/SAE**: detection and downgrade paths where applicable.
|
||||
- Validation on **varied hardware** (chipsets and regulatory domains).
|
||||
- Signing of release binaries (SmartScreen/Gatekeeper).
|
||||
@@ -0,0 +1,35 @@
|
||||
# Roadmap
|
||||
|
||||
[English](ROADMAP.md) · **Português**
|
||||
|
||||
O Sabiá faz auditoria de Wi-Fi: descobre redes, captura handshakes (4-way e PMKID), quebra WPA/WPA2-PSK por dicionário offline e testa desautenticação com autorização. Sempre de uso autorizado, com trilha de auditoria local.
|
||||
|
||||
## Feito (0.1.0)
|
||||
|
||||
- Controle de **modo monitor** e de canal por netlink/`iw`.
|
||||
- **Varredura** de 2.4, 5 e 6 GHz: APs e clientes, com SSID, BSSID, canal, sinal e segurança.
|
||||
- Captura crua de 802.11 por **AF_PACKET** (sem libpcap), parsing de **radiotap** e de quadros de gerenciamento.
|
||||
- Captura de **4-way handshake (EAPOL)** e de **PMKID**.
|
||||
- **Quebra offline** por dicionário (PBKDF2-HMAC-SHA1, MIC e PMKID) para WPA/WPA2-PSK.
|
||||
- Escrita e leitura de **`.pcap`**; análise de `.pcap` offline.
|
||||
- **`deauth`** autorizado (flag `--yes`).
|
||||
- **Trilha de auditoria** local encadeada por hash; `doctor` de diagnóstico.
|
||||
- **CLI** + **app desktop** (Tauri) + servidor local da UI; **atualização em um clique**.
|
||||
|
||||
## Próximo
|
||||
|
||||
- **Regras e máscaras** na quebra (estilo hashcat), além do dicionário puro.
|
||||
- **Exportação `22000`** para levar o handshake direto ao hashcat/John.
|
||||
- Aceleração por **GPU** na derivação da PMK.
|
||||
|
||||
## Depois
|
||||
|
||||
- **Ataque a WPS** (PIN/Pixie Dust) em teste autorizado.
|
||||
- **Rogue AP / evil twin** para avaliação de clientes, com autorização.
|
||||
- **Captura em Windows** via Npcap.
|
||||
|
||||
## Longo prazo
|
||||
|
||||
- **WPA3/SAE**: detecção e caminhos de _downgrade_ onde aplicável.
|
||||
- Validação em **hardware variado** (chipsets e _regulatory domains_).
|
||||
- Assinatura dos binários de release (SmartScreen/Gatekeeper).
|
||||
@@ -0,0 +1,7 @@
|
||||
# Security
|
||||
|
||||
**English** · [Português](SECURITY.pt-BR.md)
|
||||
|
||||
Sabiá processes untrusted data from the air and from `.pcap` files (802.11 frames and handshakes, possibly forged) and controls network interfaces with root privilege. Vulnerabilities — memory corruption in the radiotap/802.11/EAPOL parsers, path escape when writing `.pcap` or exporting, privilege misuse — should be reported privately through the repository's **Security → Report a vulnerability** tab on GitHub. Do not open a public issue before a fix.
|
||||
|
||||
Sabiá is a tool for **authorized auditing**. Reports about using it to attack third-party networks are out of the project's security scope — see the **Authorized use** section of the [README](README.md).
|
||||
@@ -0,0 +1,7 @@
|
||||
# Segurança
|
||||
|
||||
[English](SECURITY.md) · **Português**
|
||||
|
||||
O Sabiá processa dados não confiáveis vindos do ar e de arquivos `.pcap` (quadros 802.11 e handshakes, possivelmente forjados) e controla interfaces de rede com privilégio de root. Vulnerabilidades — corrupção de memória nos parsers de radiotap/802.11/EAPOL, escape de caminho na escrita de `.pcap` ou na exportação, uso indevido de privilégio — devem ser reportadas em privado pela aba **Security → Report a vulnerability** do repositório no GitHub. Não abra issue pública antes da correção.
|
||||
|
||||
O Sabiá é uma ferramenta de **auditoria autorizada**. Relatos sobre uso para atacar redes de terceiros não são escopo de segurança do projeto — veja a seção **Uso autorizado** do [README](README.pt-BR.md).
|
||||
@@ -0,0 +1 @@
|
||||
{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-exit":{"identifier":"allow-exit","description":"Enables the exit command without any pre-configured scope.","commands":{"allow":["exit"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-exit":{"identifier":"deny-exit","description":"Denies the exit command without any pre-configured scope.","commands":{"allow":[],"deny":["exit"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"DenLine truncated
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 979 B |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 311 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 336 KiB |
@@ -113,6 +113,8 @@ code, .mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
||||
.side-foot { margin-top: auto; padding: 14px 10px 4px; font-size: 11.5px; color: var(--text-3); display: flex; justify-content: space-between; gap: 8px; }
|
||||
.side-foot button { background: transparent; border: 0; color: var(--text-3); cursor: pointer; text-decoration: underline; text-underline-offset: 2px; }
|
||||
.side-foot button:hover { color: var(--text-2); }
|
||||
.lang-sel { background: transparent; border: 0; color: var(--text-3); font-size: 11.5px; cursor: pointer; }
|
||||
.lang-sel:hover { color: var(--text-2); }
|
||||
|
||||
.main { overflow-y: auto; min-width: 0; }
|
||||
.page { max-width: 1040px; margin: 0 auto; padding: 30px 36px 100px; animation: rise .35s cubic-bezier(.2, .8, .2, 1); }
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { setLang, currentLang } from './i18n.js';
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const token = params.get('token');
|
||||
const tauri = window.__TAURI__;
|
||||
@@ -14,8 +16,22 @@ const els = {
|
||||
wave: document.getElementById('wave'),
|
||||
verInfo: document.getElementById('verInfo'),
|
||||
btnUpdate: document.getElementById('btnUpdate'),
|
||||
langSel: document.getElementById('langSel'),
|
||||
};
|
||||
|
||||
function applyLangPref() {
|
||||
let pref = 'auto';
|
||||
try { pref = localStorage.getItem('sabia-lang') || 'auto'; } catch { /* ignora */ }
|
||||
els.langSel.value = pref;
|
||||
setLang(pref === 'auto' ? undefined : pref);
|
||||
}
|
||||
|
||||
els.langSel.addEventListener('change', () => {
|
||||
const pref = els.langSel.value;
|
||||
try { localStorage.setItem('sabia-lang', pref); } catch { /* ignora */ }
|
||||
setLang(pref === 'auto' ? undefined : pref);
|
||||
});
|
||||
|
||||
let ifaces = [];
|
||||
let current = null;
|
||||
let scanning = false;
|
||||
@@ -248,6 +264,7 @@ async function checkElevation() {
|
||||
}
|
||||
|
||||
(async function init() {
|
||||
applyLangPref();
|
||||
checkElevation();
|
||||
const status = await api('/api/version');
|
||||
els.verInfo.textContent = `v${status.version}`;
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// Dicionário português -> inglês da interface. {n} marca um valor variável.
|
||||
export const EN = {
|
||||
// index.html / sidebar
|
||||
'auditoria de Wi-Fi': 'Wi-Fi auditing',
|
||||
'Interfaces': 'Interfaces',
|
||||
'nenhuma interface Wi-Fi encontrada': 'no Wi-Fi interface found',
|
||||
'checar atualização': 'check for update',
|
||||
'checando…': 'checking…',
|
||||
'atualizar para {n}': 'update to {n}',
|
||||
'já atualizado': 'already up to date',
|
||||
|
||||
// topbar
|
||||
'Painel de captura': 'Capture dashboard',
|
||||
'selecione uma interface': 'select an interface',
|
||||
'modo monitor': 'monitor mode',
|
||||
'modo gerenciado': 'managed mode',
|
||||
|
||||
// elevação / avisos
|
||||
'Pedindo privilégio de administrador — necessário pra modo monitor e captura…':
|
||||
'Requesting administrator privilege — needed for monitor mode and capture…',
|
||||
'tentar de novo': 'try again',
|
||||
'Não foi possível obter privilégio de administrador.': 'Could not get administrator privilege.',
|
||||
'Use somente em redes próprias ou com autorização explícita do responsável. Toda ativação de modo monitor e sessão de captura fica registrada na trilha de auditoria local.':
|
||||
'Use only on your own networks or with the explicit authorization of the responsible party. Every monitor mode activation and capture session is logged in the local audit trail.',
|
||||
|
||||
// controles
|
||||
'ativar modo monitor': 'enable monitor mode',
|
||||
'desativar modo monitor': 'disable monitor mode',
|
||||
'iniciar captura': 'start capture',
|
||||
'parar captura': 'stop capture',
|
||||
|
||||
// resultados
|
||||
'Redes vistas': 'Networks seen',
|
||||
'Pronto pra capturar': 'Ready to capture',
|
||||
'Ative o modo monitor e inicie a captura pra ver as redes no ar.': 'Enable monitor mode and start capturing to see the networks on the air.',
|
||||
'Nenhuma interface Wi-Fi': 'No Wi-Fi interface',
|
||||
'Conecte um adaptador compatível com modo monitor e recarregue.': 'Connect an adapter that supports monitor mode and reload.',
|
||||
'Escutando o ar…': 'Listening to the air…',
|
||||
'Nenhuma rede vista ainda. Isso pode levar alguns segundos enquanto os canais são varridos.':
|
||||
'No network seen yet. This can take a few seconds while the channels are scanned.',
|
||||
'oculta': 'hidden',
|
||||
'Rede': 'Network',
|
||||
'Canal': 'Channel',
|
||||
'Sinal': 'Signal',
|
||||
'Segurança': 'Security',
|
||||
'Clientes': 'Clients',
|
||||
|
||||
// erros de UI
|
||||
'Não foi possível trocar o modo:': 'Could not change the mode:',
|
||||
'erro desconhecido': 'unknown error',
|
||||
'rota desconhecida': 'unknown route',
|
||||
|
||||
// seletor de idioma
|
||||
'Idioma': 'Language',
|
||||
'Auto': 'Auto',
|
||||
'Português': 'Portuguese',
|
||||
'English': 'English',
|
||||
|
||||
// crates/sabia-core/src/error.rs
|
||||
'interface `{n}` não encontrada': 'interface `{n}` not found',
|
||||
'interface `{n}` não é uma interface Wi-Fi (sem phy80211)': 'interface `{n}` is not a Wi-Fi interface (no phy80211)',
|
||||
'falha ao executar `{n}`: {n}': 'failed to run `{n}`: {n}',
|
||||
'permissão insuficiente — operações de captura e modo monitor exigem root (CAP_NET_ADMIN/CAP_NET_RAW)':
|
||||
'insufficient permission — capture and monitor mode operations require root (CAP_NET_ADMIN/CAP_NET_RAW)',
|
||||
'captura crua só é suportada no Linux (AF_PACKET)': 'raw capture is only supported on Linux (AF_PACKET)',
|
||||
'entrada inválida: {n}': 'invalid input: {n}',
|
||||
'captura .pcap inválida: {n}': 'invalid .pcap capture: {n}',
|
||||
'erro de E/S: {n}': 'I/O error: {n}',
|
||||
'erro ao (de)serializar: {n}': '(de)serialization error: {n}',
|
||||
|
||||
// crates/sabia-core/src/doctor.rs
|
||||
'sem root: modo monitor, captura e injeção vão falhar (use sudo ou o app desktop)':
|
||||
'no root: monitor mode, capture and injection will fail (use sudo or the desktop app)',
|
||||
'`iw` não encontrado no PATH — instale iw/iproute2': '`iw` not found in PATH — install iw/iproute2',
|
||||
'`ip` não encontrado no PATH — instale iproute2': '`ip` not found in PATH — install iproute2',
|
||||
'nenhuma interface anuncia suporte a modo monitor': 'no interface advertises monitor mode support',
|
||||
'`curl` não encontrado — a auto-atualização não funciona': '`curl` not found — auto-update does not work',
|
||||
'a trilha de auditoria existente está corrompida': 'the existing audit trail is corrupted',
|
||||
'trilha de auditoria ainda não criada em {n}': 'audit trail not created yet at {n}',
|
||||
'cadeia de auditoria rompida na entrada {n}': 'audit chain broken at entry {n}',
|
||||
'não foi possível ler a trilha de auditoria: {n}': 'could not read the audit trail: {n}',
|
||||
'indisponível nesta plataforma': 'unavailable on this platform',
|
||||
|
||||
// crates/sabia-core/src/engine.rs
|
||||
'{n} está em modo managed; ative o modo monitor antes de capturar':
|
||||
'{n} is in managed mode; enable monitor mode before capturing',
|
||||
'{n} está em modo managed; ative o modo monitor antes de injetar':
|
||||
'{n} is in managed mode; enable monitor mode before injecting',
|
||||
'nenhuma sessão de captura em {n}': 'no capture session on {n}',
|
||||
'link-type não é 802.11 nem radiotap': 'link-type is neither 802.11 nor radiotap',
|
||||
'handshake não encontrado': 'handshake not found',
|
||||
'handshake sem quadros crus (captura antiga)': 'handshake with no raw frames (old capture)',
|
||||
'nenhum handshake com quadros crus para exportar': 'no handshake with raw frames to export',
|
||||
'dicionário não encontrado: {n}': 'wordlist not found: {n}',
|
||||
'nenhum handshake quebrável para os filtros informados': 'no crackable handshake for the given filters',
|
||||
'quantidade de quadros deve ficar entre 1 e 256': 'frame count must be between 1 and 256',
|
||||
'MAC inválido: {n}': 'invalid MAC: {n}',
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
// Idioma da interface. O texto-fonte é em português; no modo inglês um tradutor
|
||||
// troca os textos exibidos (nós de texto e atributos) usando um dicionário, e
|
||||
// continua observando a página para traduzir o que for desenhado depois.
|
||||
// Textos com valores variáveis usam {n} no dicionário (ex.: "{n} redes" -> "{n} networks").
|
||||
|
||||
import { EN } from './i18n-en.js';
|
||||
|
||||
let lang = 'pt';
|
||||
export const isEn = () => lang === 'en';
|
||||
export const currentLang = () => lang;
|
||||
export const LOC = () => (lang === 'en' ? 'en-US' : 'pt-BR');
|
||||
|
||||
export function resolveLang(pref) {
|
||||
if (pref === 'pt' || pref === 'en') return pref;
|
||||
return (navigator.language || 'pt').toLowerCase().startsWith('pt') ? 'pt' : 'en';
|
||||
}
|
||||
|
||||
const ATTRS = ['placeholder', 'title', 'aria-label', 'alt', 'data-empty'];
|
||||
// Dados do usuário (SSID, BSSID, MAC, valores mono) nunca são traduzidos.
|
||||
const SKIP = '.ssid, .bssid, .mono, pre, code, input, textarea, script, style';
|
||||
|
||||
const exact = new Map(Object.entries(EN).filter(([k]) => !k.includes('{n}')));
|
||||
const patterns = Object.entries(EN)
|
||||
.filter(([k]) => k.includes('{n}'))
|
||||
.map(([k, v]) => {
|
||||
const parts = k.split('{n}').map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||||
return { re: new RegExp('^' + parts.join('(.+?)') + '$', 's'), to: v };
|
||||
})
|
||||
// padrões mais específicos (mais texto fixo) primeiro
|
||||
.sort((a, b) => b.re.source.length - a.re.source.length);
|
||||
|
||||
const cache = new Map();
|
||||
export function tr(text) {
|
||||
if (!isEn()) return text;
|
||||
const lead = text.match(/^\s*/)[0];
|
||||
const trail = text.match(/\s*$/)[0];
|
||||
const core = text.trim();
|
||||
if (!core) return text;
|
||||
if (cache.has(core)) return lead + cache.get(core) + trail;
|
||||
let out = exact.get(core);
|
||||
if (out === undefined) {
|
||||
for (const p of patterns) {
|
||||
const m = core.match(p.re);
|
||||
if (m) {
|
||||
let i = 0;
|
||||
// os valores capturados também podem ter partes traduzíveis (ex.: estados)
|
||||
out = p.to.replace(/\{n\}/g, () => tr(m[++i] ?? ''));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (out === undefined) out = core;
|
||||
if (cache.size > 5000) cache.clear();
|
||||
cache.set(core, out);
|
||||
return lead + out + trail;
|
||||
}
|
||||
|
||||
const orig = new WeakMap(); // nó -> texto original em português
|
||||
let observer = null;
|
||||
|
||||
function translateNode(n) {
|
||||
if (n.nodeType === 3) {
|
||||
if (n.parentElement?.closest(SKIP)) return;
|
||||
const o = orig.get(n) ?? n.nodeValue;
|
||||
const t = tr(o);
|
||||
if (t !== n.nodeValue) {
|
||||
if (!orig.has(n)) orig.set(n, o);
|
||||
n.nodeValue = t;
|
||||
}
|
||||
} else if (n.nodeType === 1) {
|
||||
if (n.matches?.(SKIP) && n.tagName !== 'INPUT') return;
|
||||
for (const a of ATTRS) {
|
||||
if (n.hasAttribute?.(a)) {
|
||||
const key = `i18n-${a}`;
|
||||
const o = n[key] ?? n.getAttribute(a);
|
||||
const t = tr(o);
|
||||
if (t !== n.getAttribute(a)) { n[key] = o; n.setAttribute(a, t); }
|
||||
}
|
||||
}
|
||||
for (const c of n.childNodes) translateNode(c);
|
||||
}
|
||||
}
|
||||
|
||||
function restoreAll(root) {
|
||||
const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
let n;
|
||||
while ((n = w.nextNode())) { if (orig.has(n)) { n.nodeValue = orig.get(n); orig.delete(n); } }
|
||||
root.querySelectorAll('*').forEach((el) => {
|
||||
for (const a of ATTRS) { const key = `i18n-${a}`; if (el[key] !== undefined) { el.setAttribute(a, el[key]); delete el[key]; } }
|
||||
});
|
||||
}
|
||||
|
||||
function start() {
|
||||
translateNode(document.body);
|
||||
document.title = tr(document.title);
|
||||
if (observer) return;
|
||||
observer = new MutationObserver((muts) => {
|
||||
observer.disconnect();
|
||||
for (const m of muts) {
|
||||
if (m.type === 'childList') m.addedNodes.forEach(translateNode);
|
||||
else if (m.type === 'characterData') { orig.delete(m.target); translateNode(m.target); }
|
||||
else if (m.type === 'attributes') { delete m.target[`i18n-${m.attributeName}`]; translateNode(m.target); }
|
||||
}
|
||||
observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ATTRS });
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ATTRS });
|
||||
}
|
||||
|
||||
function stop() {
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
restoreAll(document.body);
|
||||
}
|
||||
|
||||
export function setLang(pref) {
|
||||
const next = resolveLang(pref);
|
||||
const changed = next !== lang;
|
||||
lang = next;
|
||||
document.documentElement.lang = lang === 'en' ? 'en' : 'pt-BR';
|
||||
if (lang === 'en') start();
|
||||
else if (changed || observer) stop();
|
||||
}
|
||||
@@ -4,14 +4,14 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sabiá</title>
|
||||
<link rel="icon" href="/sabia-logo.svg">
|
||||
<link rel="icon" href="/sabia.png">
|
||||
<link rel="stylesheet" href="/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<img src="/sabia-logo.svg" alt="">
|
||||
<img class="logo" src="/sabia.png" alt="">
|
||||
<div>
|
||||
<strong>Sabiá</strong>
|
||||
<small>auditoria de Wi-Fi</small>
|
||||
@@ -23,6 +23,11 @@
|
||||
|
||||
<div class="side-foot">
|
||||
<span id="verInfo" class="mono">—</span>
|
||||
<select id="langSel" class="lang-sel" aria-label="Idioma" title="Idioma">
|
||||
<option value="auto">Auto</option>
|
||||
<option value="pt">Português</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
<button id="btnUpdate" type="button">checar atualização</button>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -71,6 +76,6 @@
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/app.js"></script>
|
||||
<script type="module" src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" role="img" aria-label="Sabiá"><style>.a{stroke:#b45309}.b{stroke:#0891b2}.t{fill:#1d1d1f}@media (prefers-color-scheme:dark){.a{stroke:#f59e0b}.b{stroke:#22d3ee}.t{fill:#f5f5f7}}</style><defs><clipPath id="s-bird"><path d="M78 14 C70 8 60 6 52 10 C46 6 38 6 32 10 C20 16 12 28 12 42 C12 54 18 64 28 70 C26 76 28 82 34 85 C33 80 35 76 39 74 C46 78 55 79 63 76 C73 72 80 62 81 50 C88 48 93 42 94 34 C87 36 81 34 77 29 C81 25 83 19 78 14 Z"/></clipPath></defs><g clip-path="url(#s-bird)" fill="none" stroke-width="3.6"><path class="a" d="M78 14A6 6 0 0 1 78 26"/><path class="b" d="M78 14A6 6 0 0 0 78 26" stroke-dasharray="3 3"/><path class="a" d="M78 7A13 13 0 0 1 78 33"/><path class="b" d="M78 7A13 13 0 0 0 78 33" stroke-dasharray="6 4" stroke-dashoffset="3"/><path class="a" d="M78 0A20 20 0 0 1 78 40"/><path class="b" d="M78 0A20 20 0 0 0 78 40" stroke-dasharray="9 5" stroke-dashoffset="6"/><path class="a" d="M78 -7A27 27 0 0 1 78 47"/><path class="b" d="M78 -7A27 27 0 0 0 78 47" stroke-dasharray="3 6" stroke-dashoffset="9"/><path class="a" d="M78 -14A34 34 0 0 1 78 54"/><path class="b" d="M78 -14A34 34 0 0 0 78 54" stroke-dasharray="6 3" stroke-dashoffset="12"/><path class="a" d="M78 -21A41 41 0 0 1 78 61"/><path class="b" d="M78 -21A41 41 0 0 0 78 61" stroke-dasharray="9 4" stroke-dashoffset="15"/><path class="a" d="M78 -28A48 48 0 0 1 78 68"/><path class="b" d="M78 -28A48 48 0 0 0 78 68" stroke-dasharray="3 5" stroke-dashoffset="18"/><path class="a" d="M78 -35A55 55 0 0 1 78 75"/><path class="b" d="M78 -35A55 55 0 0 0 78 75" stroke-dasharray="6 6" stroke-dashoffset="21"/><path class="a" d="M78 -42A62 62 0 0 1 78 82"/><path class="b" d="M78 -42A62 62 0 0 0 78 82" stroke-dasharray="9 3" stroke-dashoffset="24"/><path class="a" d="M78 -49A69 69 0 0 1 78 89"/><path class="b" d="M78 -49A69 69 0 0 0 78 89" stroke-dasharray="3 4" stroke-dashoffset="27"/><path class="a" d="M78 -56A76 76 0 0 1 78 96"/><path class="b" d="M78 -56A76 76 0 0 0 78 96" stroke-dasharray="6 5" stroke-dashoffset="30"/><path class="a" d="M78 -63A83 83 0 0 1 78 103"/><path class="b" d="M78 -63A83 83 0 0 0 78 103" stroke-dasharray="9 6" stroke-dashoffset="33"/><path class="a" d="M78 -70A90 90 0 0 1 78 110"/><path class="b" d="M78 -70A90 90 0 0 0 78 110" stroke-dasharray="3 3" stroke-dashoffset="36"/><rect class="t" stroke="none" x="20" y="60" width="64" height="40"/></g><circle class="t" cx="40" cy="36" r="2.6"/></svg>
|
||||
|
Before Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 675 KiB |
@@ -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"
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
);
|
||||
}
|
||||