From 55e19a8e5b21180db065dd196f5881816e5e4e62 Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Fri, 30 Oct 2026 20:44:39 -0300 Subject: [PATCH] chore(i18n): finish translating code comments and scripts to English Completes the English-only standardization for code, comments, scripts and CI templates (UI/CLI-facing strings stay bilingual pt-BR/en, untouched). Covers the remaining crates/, apps/gui/ui/ and apps/gui/src-tauri/ comments, .gitea/ workflow and template comments, .githooks/ hooks, and the scripts/ source comments for the files already renamed in a prior commit (check.sh, generate-logo.py, install-hooks.sh). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD --- .gitea/ISSUE_TEMPLATE/bug.yml | 18 +-- .gitea/PULL_REQUEST_TEMPLATE.md | 11 +- .gitea/workflows/ci.yml | 28 ++-- .gitea/workflows/release.yml | 42 +++--- .githooks/commit-msg | 42 ++++++ .githooks/pre-push | 4 +- .gitignore | 14 +- CONTRIBUTING.md | 15 +- CONTRIBUTING.pt-BR.md | 15 +- apps/gui/src-tauri/Cargo.toml | 6 +- apps/gui/src-tauri/build.rs | 6 +- apps/gui/src-tauri/capabilities/default.json | 2 +- .../src-tauri/gen/schemas/capabilities.json | 2 +- apps/gui/ui/app.css | 40 +++--- apps/gui/ui/app.js | 132 +++++++++-------- apps/gui/ui/i18n.js | 16 +-- apps/gui/ui/vendor/README.md | 12 +- crates/sabia-cli/Cargo.toml | 2 +- crates/sabia-cli/src/main.rs | 54 +++---- crates/sabia-cli/src/web/mod.rs | 18 +-- crates/sabia-core/Cargo.toml | 6 +- crates/sabia-core/src/analysis.rs | 12 +- crates/sabia-core/src/audit.rs | 22 +-- crates/sabia-core/src/capture.rs | 24 ++-- crates/sabia-core/src/ccmp.rs | 78 +++++----- crates/sabia-core/src/crack.rs | 134 +++++++++--------- crates/sabia-core/src/doctor.rs | 4 +- crates/sabia-core/src/dot11.rs | 60 ++++---- crates/sabia-core/src/eapol.rs | 60 ++++---- crates/sabia-core/src/eviltwin_enterprise.rs | 92 ++++++------ crates/sabia-core/src/handshake.rs | 58 ++++---- crates/sabia-core/src/hashcat.rs | 10 +- crates/sabia-core/src/iface.rs | 24 ++-- crates/sabia-core/src/inject.rs | 62 ++++---- crates/sabia-core/src/lib.rs | 8 +- crates/sabia-core/src/netlink.rs | 22 +-- crates/sabia-core/src/oui.rs | 2 +- crates/sabia-core/src/pcapreader.rs | 12 +- crates/sabia-core/src/pcapwriter.rs | 12 +- crates/sabia-core/src/radiotap.rs | 32 ++--- crates/sabia-core/src/report.rs | 24 ++-- crates/sabia-core/src/rules.rs | 62 ++++---- crates/sabia-core/src/scan.rs | 58 ++++---- crates/sabia-core/src/testutil.rs | 16 +-- crates/sabia-core/src/update.rs | 56 ++++---- crates/sabia-core/src/wps.rs | 28 ++-- crates/sabia-core/tests/audit_trail.rs | 14 +- crates/sabia-core/tests/common/mod.rs | 26 ++-- crates/sabia-core/tests/crack_roundtrip.rs | 16 +-- crates/sabia-core/tests/pcap_roundtrip.rs | 6 +- crates/sabia-core/tests/scan_e2e.rs | 46 +++--- scripts/apt-install.sh | 31 ++-- scripts/check-ui-i18n.mjs | 43 +++--- scripts/check.sh | 73 +++++----- scripts/generate-logo.py | 58 ++++---- scripts/install-hooks.sh | 4 +- scripts/package-cli.sh | 16 +-- scripts/release-notes.sh | 8 +- scripts/release-upload.sh | 16 +-- 59 files changed, 935 insertions(+), 879 deletions(-) create mode 100755 .githooks/commit-msg diff --git a/.gitea/ISSUE_TEMPLATE/bug.yml b/.gitea/ISSUE_TEMPLATE/bug.yml index 6fd9e21..f66b3ab 100644 --- a/.gitea/ISSUE_TEMPLATE/bug.yml +++ b/.gitea/ISSUE_TEMPLATE/bug.yml @@ -1,17 +1,17 @@ -name: Relatar um problema -description: Algo não funcionou como esperado +name: Report a bug +description: Something did not work as expected labels: [bug] body: - type: textarea - id: o-que - attributes: { label: "O que aconteceu?", description: "Passos, o que esperava e o que viu." } + id: what-happened + attributes: { label: "What happened?", description: "Steps to reproduce, expected behavior, and actual behavior." } validations: { required: true } - type: input - id: sistema - attributes: { label: "Sistema operacional e versão do Sabiá", placeholder: "Ubuntu 24.04 / sabia 0.1.0" } + id: system + attributes: { label: "Operating system and Sabiá version", placeholder: "Ubuntu 24.04 / sabia 1.0.3" } - type: input - id: adaptador - attributes: { label: "Adaptador Wi-Fi e modo", placeholder: "wlan0 (Intel AX200), modo monitor" } + id: adapter + attributes: { label: "Wi-Fi adapter and mode", placeholder: "wlan0 (Intel AX200), monitor mode" } - type: textarea id: log - attributes: { label: "Saída do terminal (sabia scan / ui)", render: shell } + attributes: { label: "Terminal output (sabia scan / ui)", render: shell } diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md index f72a185..b93927b 100644 --- a/.gitea/PULL_REQUEST_TEMPLATE.md +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -1,7 +1,8 @@ -## O que muda e por quê +## What changes and why ## 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) +- [ ] `cargo fmt --all` and `cargo clippy --workspace --all-targets` pass without warnings +- [ ] `cargo test --workspace` passes +- [ ] Nothing in this PR transmits captured data outside the local machine +- [ ] Commits follow Conventional Commits in English and code/comments are in English +- [ ] CHANGELOG updated (if there is a user-visible change) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 2b12ccb..234f1c2 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -1,7 +1,7 @@ name: CI on: - # Mudança só de documentação ou estado de ferramentas não precisa dos jobs de CI. + # Documentation-only or local agent state changes do not trigger CI jobs. push: branches: [main] paths-ignore: ['**.md', 'docs/**', 'LICENSE*', '.gitignore', '.githooks/**', '.wolf/**', '.claude/**', '.opencode/**', '.codex/**', '.cursor/**', '.superpowers/**', '.superset/**', '.gitea/ISSUE_TEMPLATE/**', 'scripts/release-notes.sh', 'scripts/release-upload.sh', '.gitea/workflows/release.yml'] @@ -15,17 +15,17 @@ concurrency: env: CARGO_TERM_COLOR: always RUSTFLAGS: -D warnings - # O runner mantém /opt/hostedtoolcache num volume que sobrevive entre jobs: - # a toolchain do rustup, o registry do cargo, caches de npm/apt e o diretório - # target ficam ali, então nada disso é baixado ou recompilado do zero. Cada job - # usa o seu próprio target (flags diferentes invalidariam o de outro job). + # The runner persists /opt/hostedtoolcache across jobs: rustup toolchains, + # cargo registry, npm/apt caches, and target directories live there so nothing + # is re-downloaded or rebuilt from scratch on every run. Each job uses its own + # target directory so differing RUSTFLAGS do not invalidate another job's cache. CARGO_HOME: /opt/hostedtoolcache/cargo RUSTUP_HOME: /opt/hostedtoolcache/rustup NPM_CONFIG_CACHE: /opt/hostedtoolcache/npm-cache jobs: lint: - name: Formatação e clippy + name: Format and clippy runs-on: ubuntu-latest env: CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/sabia-lint @@ -37,14 +37,14 @@ jobs: - uses: actions/setup-node@v7 with: node-version: 22 - - name: Cobertura da tradução da interface + - name: UI translation coverage run: node scripts/check-ui-i18n.mjs - run: cargo fmt --all -- --check - run: cargo clippy --workspace --all-targets - # Windows e macOS saem: o Gitea aqui tem um único runner, Linux arm64. + # Linux-only runner (ARM64). test: - name: Testes (Linux) + name: Tests (Linux) runs-on: ubuntu-latest env: CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/sabia-test @@ -54,21 +54,21 @@ jobs: - run: cargo test --workspace audit: - name: Auditoria de dependências + name: Dependency audit runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable # rustsec/audit-check depends on GitHub's checks API. cargo-audit itself # needs nothing but the advisory database, and installing it once into the - # persistent CARGO_HOME makes every later run skip the compile. + # persistent CARGO_HOME makes every later run skip compilation. - name: cargo audit run: | command -v cargo-audit >/dev/null || cargo install cargo-audit --locked cargo audit gui: - name: Compila o app desktop (Linux) + name: Check desktop app (Linux) runs-on: ubuntu-latest env: CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/sabia-gui @@ -78,10 +78,10 @@ jobs: - uses: actions/setup-node@v7 with: node-version: 22 - - name: Dependências do Tauri + - name: Tauri system dependencies timeout-minutes: 25 run: scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-native libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf - - name: Gera os ícones + - name: Generate icons 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 env: diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index df18ca5..9b0b023 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -1,22 +1,20 @@ name: Release -# Quando uma tag v* é enviada: cria a entrada em "Releases" com as notas do -# CHANGELOG.md e anexa o CLI compilado (scripts/package-cli.sh), com um .sha256 ao -# lado de cada pacote. É o que o atualizador (`sabia update`) baixa. +# Triggered when a v* tag is pushed: creates the Gitea Release entry with notes +# from CHANGELOG.md and uploads the compiled CLI (scripts/package-cli.sh) with +# a .sha256 checksum alongside each archive, plus the desktop bundles (Linux +# aarch64, Linux x86_64, and Windows via cargo-xwin). # -# O CLI é só para Linux (x86_64 e aarch64), compilado no runner Linux; o x86_64 -# por compilação cruzada. Windows e macOS têm só o app desktop. -# -# Assinatura do atualizador (opcional): SABIA_TAURI_SIGNING_PRIVATE_KEY e -# SABIA_TAURI_SIGNING_PRIVATE_KEY_PASSWORD, com os nomes antigos sem prefixo como -# fallback. Sem chave, o build continua e apenas não gera .sig. +# Updater signing (optional): SABIA_TAURI_SIGNING_PRIVATE_KEY and +# SABIA_TAURI_SIGNING_PRIVATE_KEY_PASSWORD, with unprefixed names as fallback. +# Without a key, the build succeeds without generating .sig files. on: push: tags: ['v*'] env: CARGO_TERM_COLOR: always - # Toolchain, caches e registry do cargo ficam no volume que sobrevive entre jobs. + # Rust toolchains, cargo registry, and caches live on the persistent runner volume. CARGO_HOME: /opt/hostedtoolcache/cargo RUSTUP_HOME: /opt/hostedtoolcache/rustup NPM_CONFIG_CACHE: /opt/hostedtoolcache/npm-cache @@ -24,7 +22,7 @@ env: jobs: release: - name: Cria a release + name: Create release runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -36,9 +34,9 @@ jobs: TOKEN: ${{ gitea.token }} API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }} run: | - # Uma execução repetida não recria a release. + # Re-running the workflow does not recreate an existing release. if curl -fsS -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" >/dev/null 2>&1; then - echo "a release $TAG já existe"; exit 0 + echo "release $TAG already exists"; exit 0 fi scripts/release-notes.sh "$TAG" > notes.md pre=false; case "$TAG" in *-*) pre=true ;; esac @@ -64,19 +62,19 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} - - name: Linker e ferramentas desta plataforma + - name: Platform cross-linker if: ${{ matrix.apt != '' }} run: scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/cli-x86_64 ${{ matrix.apt }} - - name: Compila e empacota + - name: Build and package run: scripts/package-cli.sh "${{ gitea.ref_name }}" "${{ matrix.platform }}" "${{ matrix.target }}" | tee packages.txt - - name: Anexa à release + - name: Upload to release env: TOKEN: ${{ gitea.token }} API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }} run: scripts/release-upload.sh "${{ gitea.ref_name }}" $(cat packages.txt) gui: - name: App desktop (${{ matrix.platform }}) + name: Desktop app (${{ matrix.platform }}) needs: release runs-on: ubuntu-latest env: @@ -93,11 +91,11 @@ jobs: - uses: actions/setup-node@v7 with: node-version: 22 - - name: Dependências do Tauri (Linux aarch64) + - name: Tauri dependencies (Linux aarch64) if: matrix.platform == 'linux-aarch64' timeout-minutes: 25 run: scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-native libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf - - name: Dependências do Tauri (Linux x86_64) + - name: Tauri dependencies (Linux x86_64) if: matrix.platform == 'linux-x86_64' timeout-minutes: 25 run: | @@ -117,14 +115,14 @@ jobs: else scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-native libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf fi - - name: Ferramentas para compilação cruzada (Windows) + - name: Cross-compilation tools (Windows) if: matrix.platform == 'windows' run: | scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-win nsis lld llvm clang command -v cargo-xwin >/dev/null || cargo install cargo-xwin --locked - - name: Compila e empacota + - name: Build and package run: scripts/build-gui.sh "${{ matrix.platform }}" - - name: Anexa à release + - name: Upload to release env: TOKEN: ${{ gitea.token }} API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }} diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..074da6c --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Enforces Conventional Commits 1.0.0 and English-only commit messages. +set -euo pipefail + +msg_file="${1:?usage: commit-msg }" +python3 - "$msg_file" <<'PY' +import re, sys +from pathlib import Path + +raw = Path(sys.argv[1]).read_text(encoding="utf-8") +lines = [l for l in raw.splitlines() if not l.startswith("#")] +while lines and not lines[0].strip(): + lines.pop(0) +if not lines: + sys.exit("commit-msg: empty commit message") + +subject = lines[0].strip() +if subject.startswith(("Merge ", "Revert \"")): + sys.exit(0) + +conv_re = re.compile( + r"^(feat|fix|chore|ci|docs|refactor|test|perf|build|style|revert)(\([a-z0-9_./-]+\))?!?: .+$" +) +if not conv_re.match(subject): + sys.exit( + f"commit-msg: invalid Conventional Commit header: {subject!r}\n" + "Expected: [optional scope]: \n" + "Allowed types: feat, fix, chore, ci, docs, refactor, test, perf, build, style, revert" + ) + +body = "\n".join(lines) +cleaned = re.sub(r"\b(Guará|Sabiá|Acauã|Ciberfauna)\b", "", body) +pt_accents = re.search(r"[áàâãéêíóôõúçÁÀÂÃÉÊÍÓÔÕÚÇ]", cleaned) +pt_words = re.search( + r"\b(não|para|como|quando|então|pelos|pelas|pelo|pela|estão|está|também|versão|" + r"corrige|adiciona|atualiza|remove|padroniza|implementa|ajusta|arquivo|arquivos)\b", + cleaned, + re.I, +) +if pt_accents or pt_words: + sys.exit("commit-msg: commit messages must be written 100% in English.") +PY diff --git a/.githooks/pre-push b/.githooks/pre-push index a9b68e1..c7faa7c 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -1,3 +1,3 @@ #!/usr/bin/env bash -# Antes de cada push: a verificação rápida. Ignore com `git push --no-verify` (use com parcimônia). -exec "$(git rev-parse --show-toplevel)/scripts/verificar.sh" +set -euo pipefail +exec "$(git rev-parse --show-toplevel)/scripts/check.sh" diff --git a/.gitignore b/.gitignore index dbeb29a..aba945d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -# Build +# Build artifacts target/ apps/gui/src-tauri/target/ node_modules/ @@ -7,15 +7,15 @@ node_modules/ .DS_Store **/*.rs.bk -# Capturas e estado local do Sabiá +# Sabiá local captures and audit log *.pcap *.cap .sabia-audit.log -# Logo copiada para a interface pelo build.rs +# Logo copied to the UI directory by build.rs /apps/gui/ui/logo.png -# Configuração e memória local de agentes de IA — não faz parte do produto +# Local AI agent configuration and state — not part of the product .claude/ .wolf/ .opencode/ @@ -27,16 +27,16 @@ CLAUDE.md AGENTS.md GEMINI.md -# Variáveis de ambiente locais (o setup do Superset copia da raiz) +# Local environment files .env .env.* -# Lixo local +# Local scratch files *.eml __pycache__/ *.pyc -# Pacotes gerados por scripts/package-cli.sh +# Packages generated by scripts/package-cli.sh and scripts/build-gui.sh /guara-v* /sabia-v* /dist-gui diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9acf9aa..f1d8d72 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,20 +7,23 @@ 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. +4. **100% English codebase and comments.** All source code, comments, scripts, CI workflows, and commit messages must be written in English, while the GUI/CLI remains bilingual (PT-BR and EN via `i18n-en.js`). ## Workflow ```bash -scripts/instalar-hooks.sh # once: runs the quick check before every push -scripts/verificar.sh # fast (seconds): fmt, clippy as in CI, i18n, scripts, workflows -scripts/verificar.sh --completo # adds cargo test --workspace and cargo audit +scripts/install-hooks.sh # once: enables commit-msg and pre-push Git hooks +scripts/check.sh # fast (seconds): fmt, clippy as in CI, i18n, scripts, workflows, semver +scripts/check.sh --full # adds cargo test --workspace and cargo audit ``` CI runs on Linux; run the check before pushing: almost everything that fails CI shows up here in seconds. Documentation-only changes (`*.md`, `docs/`) skip CI. Live capture exists only on Linux (`AF_PACKET` + netlink); the code behind Windows and macOS paths still compiles and is covered by `cargo check`. -## Releases +## Releases and Versioning + +Versions strictly follow **Semantic Versioning 2.0.0** (`MAJOR.MINOR.PATCH`), synchronized across `Cargo.toml`, `apps/gui/src-tauri/Cargo.toml`, and `apps/gui/src-tauri/tauri.conf.json` (enforced by `scripts/check.sh`). Pushing a `v*` tag makes the Gitea CI create the release (notes come from `CHANGELOG.md`, so update it first) and attach the Linux CLI (x86_64 and aarch64) and the desktop app for Linux (x86_64 and aarch64) and Windows. @@ -43,6 +46,10 @@ The source text of the interface is **Portuguese**; English comes from `apps/gui Keep the pt/en pair: every `*.md` and `docs/*` file has a Portuguese version (`*.pt-BR.md`, `docs/pt-BR/`) and an English one. +## Commits + +Commits must follow **Conventional Commits 1.0.0** in English (`feat:`, `fix:`, `chore:`, `ci:`, `docs:`, `refactor:`, `test:`, `perf:`, `build:`), e.g. `fix(eapol): validate replay counter on message 2`. One logical change per PR. Enforced automatically by `.githooks/commit-msg`. + ## License By contributing, you agree to license under [MIT](LICENSE-MIT) **or** [Apache-2.0](LICENSE-APACHE), like the rest of the project. diff --git a/CONTRIBUTING.pt-BR.md b/CONTRIBUTING.pt-BR.md index e90f180..58826f5 100644 --- a/CONTRIBUTING.pt-BR.md +++ b/CONTRIBUTING.pt-BR.md @@ -7,20 +7,23 @@ 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. +4. **Código, comentários e commits 100% em inglês.** Todo código-fonte, comentários, scripts, workflows de CI e mensagens de commit devem ser escritos em inglês, enquanto a interface gráfica/CLI permanece bilíngue (PT-BR e EN via `i18n-en.js`). ## Fluxo ```bash -scripts/instalar-hooks.sh # uma vez: roda a verificação rápida antes de cada push -scripts/verificar.sh # rápido (segundos): fmt, clippy como no CI, tradução, scripts, workflows -scripts/verificar.sh --completo # inclui cargo test --workspace e cargo audit +scripts/install-hooks.sh # uma vez: ativa os hooks commit-msg e pre-push +scripts/check.sh # rápido (segundos): fmt, clippy como no CI, tradução, scripts, workflows, semver +scripts/check.sh --full # inclui cargo test --workspace e cargo audit ``` O CI roda em Linux; rode a verificação antes do push: quase tudo que faz o CI falhar aparece aqui em segundos. Mudanças só de documentação (`*.md`, `docs/`) pulam o CI. A captura ao vivo só existe no Linux (`AF_PACKET` + netlink); o código dos caminhos de Windows e macOS continua compilando e é coberto pelo `cargo check`. -## Releases +## Releases e Versionamento + +As versões seguem estritamente o **Versionamento Semântico 2.0.0** (`MAJOR.MINOR.PATCH`), sincronizadas entre `Cargo.toml`, `apps/gui/src-tauri/Cargo.toml` e `apps/gui/src-tauri/tauri.conf.json` (validado por `scripts/check.sh`). Enviar uma tag `v*` faz o CI do Gitea criar a release (as notas vêm do `CHANGELOG.md`, então atualize-o antes) e anexar o CLI para Linux (x86_64 e aarch64) e o app desktop para Linux (x86_64 e aarch64) e Windows. @@ -43,6 +46,10 @@ O texto-fonte da interface é em **português**; o inglês vem de `apps/gui/ui/i 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. +## Commits + +Os commits devem seguir **Conventional Commits 1.0.0** em inglês (`feat:`, `fix:`, `chore:`, `ci:`, `docs:`, `refactor:`, `test:`, `perf:`, `build:`), ex.: `fix(eapol): validate replay counter on message 2`. Uma mudança lógica por PR. Validado automaticamente por `.githooks/commit-msg`. + ## Licença Ao contribuir, você concorda em licenciar sob [MIT](LICENSE-MIT) **ou** [Apache-2.0](LICENSE-APACHE), como o resto do projeto. diff --git a/apps/gui/src-tauri/Cargo.toml b/apps/gui/src-tauri/Cargo.toml index 9eddd8f..8b64ca3 100644 --- a/apps/gui/src-tauri/Cargo.toml +++ b/apps/gui/src-tauri/Cargo.toml @@ -1,8 +1,8 @@ -# App desktop (Tauri 2). Fica fora do workspace principal porque exige -# as bibliotecas de WebView do sistema (WebKitGTK no Linux). +# Desktop application (Tauri 2). Kept outside the main workspace because it +# requires system WebView libraries (WebKitGTK on Linux). [package] name = "sabia-gui" -description = "Sabiá: interface gráfica de auditoria de Wi-Fi" +description = "Sabiá: graphical Wi-Fi security auditing interface" version = "1.0.3" edition = "2021" license = "MIT OR Apache-2.0" diff --git a/apps/gui/src-tauri/build.rs b/apps/gui/src-tauri/build.rs index e83e9f6..9421c2b 100644 --- a/apps/gui/src-tauri/build.rs +++ b/apps/gui/src-tauri/build.rs @@ -3,12 +3,12 @@ fn main() { let _ = std::fs::copy("../../../logo.png", "../ui/logo.png"); #[cfg(windows)] { - // Modo monitor e captura crua exigem administrador no Windows: o - // manifesto pede elevação (UAC) assim que o app abre. + // Monitor mode and raw frame capture require Administrator privileges on Windows: + // embed UAC elevation manifest at startup. let manifest = include_str!("windows-app-manifest.xml"); let attrs = tauri_build::Attributes::new() .windows_attributes(tauri_build::WindowsAttributes::new().app_manifest(manifest)); - tauri_build::try_build(attrs).expect("falha no tauri-build"); + tauri_build::try_build(attrs).expect("tauri-build failed"); } #[cfg(not(windows))] tauri_build::build(); diff --git a/apps/gui/src-tauri/capabilities/default.json b/apps/gui/src-tauri/capabilities/default.json index 36d5a5a..b5e8009 100644 --- a/apps/gui/src-tauri/capabilities/default.json +++ b/apps/gui/src-tauri/capabilities/default.json @@ -1,7 +1,7 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "Permissões da janela principal", + "description": "Main window permissions", "windows": ["main"], "permissions": ["core:default", "core:window:allow-destroy", "core:window:allow-close"] } diff --git a/apps/gui/src-tauri/gen/schemas/capabilities.json b/apps/gui/src-tauri/gen/schemas/capabilities.json index 88c0908..aea3e72 100644 --- a/apps/gui/src-tauri/gen/schemas/capabilities.json +++ b/apps/gui/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Permissões da janela principal","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-destroy","core:window:allow-close"]}} \ No newline at end of file +{"default":{"identifier":"default","description":"Main window permissions","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-destroy","core:window:allow-close"]}} \ No newline at end of file diff --git a/apps/gui/ui/app.css b/apps/gui/ui/app.css index 8b5e4b5..ab9ea22 100644 --- a/apps/gui/ui/app.css +++ b/apps/gui/ui/app.css @@ -8,11 +8,11 @@ --text-3: #8e8e93; --line: rgba(0, 0, 0, 0.09); - /* "canto" — cobre quente, o peito do sabiá-laranjeira */ + /* "song" — warm copper, the rufous-bellied thrush breast */ --accent: #b45309; --accent-ink: #ffffff; --accent-soft: rgba(180, 83, 9, 0.11); - /* "sinal" — ciano, a onda que o rádio enxerga */ + /* "signal" — cyan, the radio wave */ --signal: #0891b2; --signal-soft: rgba(8, 145, 178, 0.12); @@ -131,7 +131,7 @@ code, .mono { font-family: var(--mono); font-variant-numeric: tabular-nums; } .page { max-width: 1040px; margin: 0 auto; padding: 30px 36px 100px; animation: rise .35s cubic-bezier(.2, .8, .2, 1); } @keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } } -/* -------------------------------------------------------- topo + linha de sinal */ +/* -------------------------------------------------------- topbar + signal line */ .topbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; } .topbar h1 { font-size: 21px; } .topbar .sub { color: var(--text-2); font-size: 13px; margin-top: 2px; } @@ -141,14 +141,14 @@ code, .mono { font-family: var(--mono); font-variant-numeric: tabular-nums; } .wave.scanning path.live { opacity: 1; } .wave.scanning path.flat { opacity: 0; } -/* ------------------------------------------------------------ aviso */ +/* ------------------------------------------------------------ notice banner */ .notice { display: flex; gap: 10px; background: var(--surface); border-radius: 12px; padding: 12px 14px; box-shadow: var(--shadow); font-size: 13px; color: var(--text-2); margin-bottom: 20px; align-items: flex-start; } .notice svg { flex: none; margin-top: 2px; color: var(--warn); } .notice.warn { background: var(--warn-soft); } .notice.warn svg { color: var(--warn); } .notice.bad { background: var(--bad-soft); } .notice.bad svg { color: var(--bad); } .notice b { color: var(--text); } -/* ------------------------------------------------------------ controles */ +/* ------------------------------------------------------------ controls */ .controls { background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow); padding: 16px 18px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 22px; } .controls .group { display: flex; align-items: center; gap: 8px; } .pill { display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; background: color-mix(in srgb, var(--text) 8%, transparent); color: var(--text-2); } @@ -166,7 +166,7 @@ code, .mono { font-family: var(--mono); font-variant-numeric: tabular-nums; } .btn.live { background: var(--bad); color: #fff; } .btn.live:hover { filter: brightness(1.06); } -/* ------------------------------------------------------------ tabela */ +/* ------------------------------------------------------------ table */ .result-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin: 4px 2px 10px; } .result-summary { display: flex; align-items: baseline; gap: 10px; min-width: 0; } .result-head strong { font-size: 15px; } @@ -186,7 +186,7 @@ tbody tr:hover { background: var(--surface-2); } td.ssid { font-weight: 600; } td.ssid em { color: var(--text-3); font-weight: 400; font-style: normal; } -/* rede agrupada por ESSID; cada BSSID vira uma linha expansível */ +/* network grouped by ESSID; each BSSID renders as an expandable row */ .caret-cell { width: 34px; padding-left: 10px; padding-right: 0; } .expander { width: 26px; height: 26px; border: 0; border-radius: 8px; background: transparent; color: var(--text-3); cursor: pointer; display: inline-grid; place-items: center; padding: 0; } .expander:hover { background: color-mix(in srgb, var(--text) 8%, transparent); color: var(--text); } @@ -227,7 +227,7 @@ td.ssid em { color: var(--text-3); font-weight: 400; font-style: normal; } .empty-state h2 { font-size: 17px; color: var(--text); margin-bottom: 6px; } .empty-state p { font-size: 13.5px; max-width: 360px; margin: 0 auto; } -/* ------------------------------------------------------------ configurações */ +/* ------------------------------------------------------------ settings */ dialog { border: 0; border-radius: 20px; padding: 24px; width: min(420px, calc(100vw - 32px)); background: var(--surface); color: var(--text); box-shadow: 0 30px 80px rgba(0, 0, 0, .35); } dialog::backdrop { background: rgba(0, 0, 0, .35); backdrop-filter: blur(6px); } dialog h2 { font-size: 20px; margin-bottom: 16px; } @@ -238,7 +238,7 @@ dialog menu { display: flex; gap: 10px; justify-content: flex-end; padding: 0; m .select option { background-color: var(--surface); color: var(--text); } -/* ------------------------------------------------------------ detalhes do alvo */ +/* ------------------------------------------------------------ target details */ .ssid-btn { display: block; border: 0; padding: 0; background: transparent; color: inherit; font: inherit; font-weight: 600; text-align: left; cursor: pointer; line-height: 1.25; } .ssid-btn:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; } .bssid-item { cursor: pointer; } @@ -300,13 +300,11 @@ dialog menu { display: flex; gap: 10px; justify-content: flex-end; padding: 0; m .empty-inline p { margin: 0; font-size: 13px; } .auth-check { display: flex; align-items: center; gap: 8px; font-size: 12.5px; font-weight: 600; margin: 4px 0 14px; } .auth-check input { width: 16px; height: 16px; accent-color: var(--accent); } -/* Colunas CSS em vez de grid: os cards de ataque têm alturas muito - diferentes entre si (Deauth/Handshake são enormes, PMF/WPS são curtos). - Num grid de 2 colunas comum, o par mais curto de uma linha deixava um - vão vazio enorme embaixo (a altura da linha é a do item mais alto, e - align-items:start não estica o curto pra preencher). Colunas fazem os - cards fluírem como alvenaria: cada um só ocupa a altura que precisa, o - próximo entra logo abaixo, sem buracos. */ +/* CSS multi-column layout instead of grid: attack cards have very different + heights (Deauth/Handshake are tall, PMF/WPS are short). In a standard + 2-column grid, the shorter card in a row left a large empty gap underneath. + Multi-column masonry flow lets each card take only the height it needs + with the next card stacking right below. */ .attack-grid { column-count: 2; column-gap: 12px; } .attack-card { background: var(--surface-2); border: 1px solid var(--line); border-radius: 14px; padding: 14px 16px; min-width: 0; break-inside: avoid; margin: 0 0 12px; display: inline-block; width: 100%; } .attack-deauth, .attack-wide { column-span: all; } @@ -336,7 +334,7 @@ dialog menu { display: flex; gap: 10px; justify-content: flex-end; padding: 0; m .crack-bar i { display: block; width: 35%; height: 100%; border-radius: 999px; background: var(--accent); animation: crack-slide 1.1s ease-in-out infinite; } @keyframes crack-slide { 0% { margin-left: -35%; } 100% { margin-left: 100%; } } -/* ------------------------------------------------------------ responsivo */ +/* ------------------------------------------------------------ responsive */ @media (max-width: 760px) { .app { grid-template-columns: 1fr; grid-template-rows: auto 1fr; } .sidebar { border-right: 0; border-bottom: 1px solid var(--line); flex-direction: row; align-items: center; overflow-x: auto; } @@ -359,7 +357,7 @@ dialog menu { display: flex; gap: 10px; justify-content: flex-end; padding: 0; m .detail-card, .attack-card { padding: 12px; } } -/* --------------------------------------------------- análise de canais */ +/* --------------------------------------------------- channel analysis */ .top-actions { display: flex; align-items: center; gap: 10px; } .view-switch { display: inline-flex; padding: 2px; border-radius: 999px; background: color-mix(in srgb, var(--text) 7%, transparent); } .view-switch button { border: 0; border-radius: 999px; padding: 5px 12px; font-size: 12px; font-weight: 600; color: var(--text-2); background: transparent; cursor: pointer; } @@ -401,7 +399,7 @@ dialog menu { display: flex; gap: 10px; justify-content: flex-end; padding: 0; m .chan-aps { color: var(--text-3); font-size: 11px; white-space: nowrap; } .chan-best .chan-n, .chan-best .chan-aps { color: var(--signal); } -/* ------------------------------------------------- saúde do espectro (SMART) */ +/* ------------------------------------------------- spectrum health (SMART) */ .chan-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); gap: 10px; margin: 2px 0 16px; } .chan-tile { background: var(--surface-2); border-radius: 14px; padding: 12px 14px; display: flex; flex-direction: column; gap: 2px; box-shadow: 0 0 0 1px var(--line); } .chan-tile small { color: var(--text-3); font-size: 12px; } @@ -424,7 +422,7 @@ dialog menu { display: flex; gap: 10px; justify-content: flex-end; padding: 0; m .chan-table tr.chan-best-row td { color: var(--signal); font-weight: 600; } .mini-tag { font-size: 8.5px; padding: 1px 4px; border-radius: 4px; background: var(--warn-soft); color: var(--warn); } -/* ------------------------------------------------ clientes e linha do tempo */ +/* ------------------------------------------------ clients and timeline */ .spark { display: inline-block; width: 62px; height: 16px; vertical-align: middle; margin-left: 6px; } .spark path { fill: none; stroke: var(--signal); stroke-width: 1.4; } .spark-big { display: block; width: 100%; height: 70px; margin: 10px 0 0; } @@ -439,7 +437,7 @@ dialog menu { display: flex; gap: 10px; justify-content: flex-end; padding: 0; m .tl-probe { color: var(--signal); } .tl-detail { color: var(--text-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -/* ------------------------------------------------------------- WPS e relatório */ +/* ------------------------------------------------------------- WPS and report */ .term { margin: 10px 0 0; padding: 10px 12px; border-radius: 10px; background: color-mix(in srgb, var(--text) 6%, transparent); color: var(--text-2); font: 11.5px/1.5 var(--mono); white-space: pre-wrap; max-height: 180px; overflow: auto; } .report-dlg { width: min(1060px, 96vw); height: 92vh; max-height: 92vh; padding: 0; display: flex; flex-direction: column; overflow: hidden; } .report-dlg:not([open]) { display: none; } diff --git a/apps/gui/ui/app.js b/apps/gui/ui/app.js index 7bd671c..5c98c4c 100644 --- a/apps/gui/ui/app.js +++ b/apps/gui/ui/app.js @@ -53,11 +53,11 @@ function applyTheme(t) { function updateThemePref(t) { applyTheme(t); - try { localStorage.setItem('sabia-theme', t); } catch { /* sem armazenamento */ } + try { localStorage.setItem('sabia-theme', t); } catch { /* storage unavailable */ } } let themePref = 'auto'; -try { themePref = localStorage.getItem('sabia-theme') || 'auto'; } catch { /* sem armazenamento */ } +try { themePref = localStorage.getItem('sabia-theme') || 'auto'; } catch { /* storage unavailable */ } applyTheme(themePref); document.querySelectorAll('[data-theme-pick]').forEach((b) => { @@ -67,7 +67,7 @@ document.querySelectorAll('[data-theme-pick]').forEach((b) => { try { const sort = localStorage.getItem('sabia-sort'); if (sort && [...els.sortSel.options].some((o) => o.value === sort)) els.sortSel.value = sort; -} catch { /* sem armazenamento */ } +} catch { /* storage unavailable */ } els.settingsBtn.addEventListener('click', () => { if (!els.settingsDlg.open) els.settingsDlg.showModal(); @@ -75,14 +75,14 @@ els.settingsBtn.addEventListener('click', () => { function applyLangPref() { let pref = 'auto'; - try { pref = localStorage.getItem('sabia-lang') || 'auto'; } catch { /* ignora */ } + try { pref = localStorage.getItem('sabia-lang') || 'auto'; } catch { /* ignore */ } 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 */ } + try { localStorage.setItem('sabia-lang', pref); } catch { /* ignore */ } setLang(pref === 'auto' ? undefined : pref); }); @@ -90,12 +90,12 @@ let ifaces = []; let current = null; let scanning = false; -// Ordem de chegada estável por rede/BSSID: sem isso, cada poll reordenava a -// lista pelo sinal instantâneo e as linhas ficavam "flutuando" de posição a -// cada atualização, tornando impossível clicar no alvo certo. A ordem de -// chegada serve de critério de desempate (e de ordenação padrão), então um -// item só muda de posição se o usuário trocar o modo de ordenação de -// verdade — não por uma flutuação de -1 dBm entre um poll e outro. +// Stable arrival order per network/BSSID: without this, each poll resorted the +// list by instantaneous RSSI and rows jumped around on every +// update, making it hard to click a target. Arrival order +// serves as the tiebreaker (and default sort), so an +// item only moves when the user explicitly changes the sort mode +// rather than on a 1 dBm RSSI fluctuation between polls. const firstSeenOrder = new Map(); let firstSeenSeq = 0; function orderOf(key) { @@ -115,8 +115,8 @@ async function api(path, opts) { return r.json(); } -// No app desktop não existe servidor HTTP: as mesmas rotas viram chamadas -// IPC diretas pro núcleo, pelos comandos registrados em src-tauri/src/main.rs. +// In the desktop app there is no HTTP server: the same routes map to direct +// IPC calls into the core via the Tauri commands in src-tauri/src/main.rs. async function tauriApi(path, opts = {}) { const [p, qs] = path.split('?'); const q = new URLSearchParams(qs || ''); @@ -280,8 +280,8 @@ function renderEmptyResult(message, hint) { els.resultArea.innerHTML = `
${ICON_EMPTY}

${message}

${hint}

`; } -// Só troca o HTML quando muda de verdade: o poll de 1 s não deve -// reconstruir a tabela (e perder foco/hover) à toa. +// Only update innerHTML when content actually changes: the 1s poll must not +// rebuild the DOM table (and drop focus/hover) unnecessarily. function setResultsHtml(html) { if (els.resultArea._h === html) return; els.resultArea._h = html; @@ -352,9 +352,9 @@ els.btnScan.addEventListener('click', async () => { if (!scanning) { const res = await api(`/api/scan/start?iface=${encodeURIComponent(current)}`, { method: 'POST' }); if (res && res.ok === false) { - // Sem isso, uma falha ao abrir a captura (permissão, interface saiu do - // modo monitor, etc.) ficava muda: o botão virava "parar captura" e a - // tela mostrava "escutando o ar" pra sempre, sem nenhuma rede chegar. + // Without this, a failure to start capture (permissions, interface left + // monitor mode, etc.) was silent: the button flipped to "stop capture" and + // the screen showed "listening" forever without any networks arriving. alert('Não foi possível iniciar a captura: ' + (res.error || 'erro desconhecido')); return; } @@ -398,7 +398,7 @@ function signalOf(ap) { } function signalBars(dbm) { - // dBm tipicamente entre -30 (ótimo) e -90 (péssimo) + // dBm typically ranges from -30 (excellent) to -90 (weak) let level = 0; if (dbm != null) { if (dbm >= -55) level = 4; @@ -414,9 +414,9 @@ function signalBars(dbm) { return `${bars}`; } -// Agrupa por ESSID: uma rede pode ter vários BSSIDs (rádios) e todos -// pertencem ao mesmo alvo. Redes ocultas ficam separadas, pois sem SSID -// não há como afirmar que dois BSSIDs são a mesma rede. +// Group by ESSID: a network may have multiple BSSIDs (radios) belonging +// to the same target. Hidden networks stay separate since without an SSID +// two BSSIDs cannot be assumed to belong to the same network. function buildGroups(aps) { const map = new Map(); for (const ap of aps) { @@ -460,9 +460,9 @@ function filterGroups(groups) { g.aps.some((a) => a.bssid.toLowerCase().includes(q))); } -// Dessensibiliza o sinal antes de comparar: arredondar pro m\u00faltiplo de 4 -// dBm mais pr\u00f3ximo evita que uma oscila\u00e7\u00e3o de 1-2 dBm entre dois polls -// inverta a ordem de dois alvos com sinal parecido a cada atualiza\u00e7\u00e3o. +// Quantize RSSI before comparing: rounding to the nearest multiple of 4 +// dBm prevents a 1-2 dBm fluctuation between polls from +// flipping the order of two targets with similar signal strength. function signalBucket(v) { return v == null ? -1000 : Math.round(v / 4) * 4; } @@ -535,7 +535,7 @@ function renderGroup(g) { let lastAps = []; const openGroups = new Set(); let viewMode = 'nets'; -try { viewMode = localStorage.getItem('sabia-view') || 'nets'; } catch { /* sem armazenamento */ } +try { viewMode = localStorage.getItem('sabia-view') || 'nets'; } catch { /* storage unavailable */ } let channelBand = null; let reportState = { iface: null, pages: 1, zoom: 1, url: null }; let reportMeasureTimer = null; @@ -632,8 +632,8 @@ function spectrumSvg(band) { const CHAN_TONE_WORD = { ok: 'Boa', warn: 'Atenção', bad: 'Saturado' }; -/** Estado geral da banda: mesma lógica de "saúde" usada nos tons por canal, - * só que aplicada ao canal recomendado — é o que decide o tom do card inteiro. */ +/** Overall band status: applies the per-channel health logic to the + * recommended channel to determine the summary card tone. */ function bandHealth(band, rec) { if (!rec) return { tone: 'bad', label: 'Sem canal livre nessa banda' }; if (rec.score >= 1.5) return { tone: 'bad', label: 'Até o melhor canal está saturado' }; @@ -752,7 +752,7 @@ function renderResults(aps) { function setView(mode) { viewMode = mode === 'channels' ? 'channels' : 'nets'; - try { localStorage.setItem('sabia-view', viewMode); } catch { /* ignora */ } + try { localStorage.setItem('sabia-view', viewMode); } catch { /* ignore */ } document.querySelectorAll('#viewNav [data-view]').forEach((b) => { b.setAttribute('aria-pressed', String(b.dataset.view === viewMode)); }); @@ -779,7 +779,7 @@ els.resultArea.addEventListener('click', (e) => { els.searchInput.addEventListener('input', () => renderResults(lastAps)); els.sortSel.addEventListener('change', () => { - try { localStorage.setItem('sabia-sort', els.sortSel.value); } catch { /* sem armazenamento */ } + try { localStorage.setItem('sabia-sort', els.sortSel.value); } catch { /* storage unavailable */ } renderResults(lastAps); }); els.resultArea.addEventListener('click', (e) => { @@ -803,7 +803,7 @@ els.resultArea.addEventListener('keydown', (e) => { openTarget(target.dataset.bssid); }); -// ---------------------------------------------------------------- alvo +// ---------------------------------------------------------------- target let lastSnapshot = null; let selectedBssid = null; let activeTab = 'overview'; @@ -1056,8 +1056,8 @@ const EVENT_LABEL = { deauth: 'desautenticado', }; -/** Sparkline em tamanho real (não o mini de 62px da tabela), pra caber bem - * sozinho dentro do card de sinal do detalhe do cliente. */ +/** Full-size RSSI sparkline (wider than the 62px table mini-chart) for the + * client detail signal card. */ function bigSparkline(history) { const points = (history || []).slice(-60); if (points.length < 2) return '

Sem amostras de sinal suficientes ainda.

'; @@ -1077,9 +1077,8 @@ function bigSparkline(history) { return ``; } -/** Visão completa de um cliente: tudo que o Sabiá já capturou sobre ele — - * identificação, rede, sinal e a linha do tempo de eventos — num painel só, - * em vez de ter que adivinhar olhando a linha compacta da tabela. */ +/** Detailed client view: consolidates everything captured about a station — + * identity hints, associated AP, signal history, and event timeline — in one modal. */ function renderClientDetail(clients) { const client = clients.find((c) => c.mac === attack.timelineMac); if (!client) return ''; @@ -1522,11 +1521,11 @@ async function loadSnapshot() { } } -// Enquanto startDeauthJob está religando a captura e esperando o AP -// reaparecer na varredura, a sessão nova responde vazia por alguns ciclos -// de poll — sem essa trava, renderTarget() interpreta "AP não visto ainda" -// como "AP não existe mais" e fecha o diálogo debaixo do usuário no meio -// da própria ação que ele acabou de pedir. +// While startDeauthJob is restarting capture and waiting for the AP +// to reappear in the scan, the fresh session returns empty for a few poll +// cycles — without this guard, renderTarget() treats "AP not seen yet" +// as "AP gone" and closes the modal underneath the user mid-action. +// let resumingForBssid = null; function renderTarget(force) { @@ -1671,13 +1670,13 @@ async function waitForBssidChannel(bssid, timeoutMs) { } async function startDeauthJob(handshake) { - // Fixa o alvo no início: o resto desta função faz esperas longas - // (religar a captura, aguardar a rede reaparecer na varredura), e nesse - // meio tempo o poll global pode não achar mais o AP na primeira resposta - // vazia da sessão recém-reiniciada e fechar o diálogo, zerando - // `selectedBssid`. Sem essa cópia local, o job acabava sendo disparado - // com bssid=null ("MAC inválido: null") mesmo depois de localizar a rede - // com sucesso. + // Snapshot the target BSSID upfront: the rest of this function awaits + // capture restart and AP rediscovery, during which the global poll could + // see an empty initial snapshot and close the modal, clearing + // `selectedBssid`. + // + // + // const bssid = selectedBssid; const station = document.getElementById('deauthStation')?.value || ''; const direction = document.getElementById('deauthDirection')?.value || attack.direction; @@ -1688,10 +1687,10 @@ async function startDeauthJob(handshake) { setResult('deauthResult', 'Escolha um cliente para desautenticar nas direções sta/both.', true); return; } - // Essa ação exige uma sessão de captura ativa (é o que guarda o handshake - // montado); sem isso o backend recusa com "inicie a captura antes do - // job". Em vez de obrigar o usuário a voltar e religar manualmente, retoma - // a captura aqui — do jeito que ele descreveu querer. + // This action requires an active capture session (which stores the assembled + // handshake); rather than forcing the user to manually restart capture, + // resume capture automatically here. + // if (!scanning) { const scanRes = await api(`/api/scan/start?iface=${encodeURIComponent(current)}`, { method: 'POST' }); if (scanRes && scanRes.ok === false) { @@ -1702,12 +1701,12 @@ async function startDeauthJob(handshake) { els.btnScan.textContent = 'parar captura'; els.btnScan.classList.add('live'); els.wave.classList.add('scanning'); - // A captura acabou de reiniciar: a sessão ainda não sabe em qual canal - // este BSSID está, e o job travaria o canal errado (o canal em que o - // scanner por acaso já estava saltando) em vez do canal real do AP — - // os quadros de deauth saem no vácuo e o job termina sem efeito. Espera - // o AP reaparecer no snapshot (ele já esteve no ar, é questão de - // segundos de varredura) antes de disparar o job. + // Capture just restarted: the session does not yet know which channel + // this BSSID is on, so starting the job immediately would lock the wrong + // channel. Wait briefly for the AP to reappear in the scan snapshot + // before launching the job. + // + // setResult('deauthResult', 'Retomando a captura e localizando a rede…'); resumingForBssid = bssid; let found; @@ -2370,10 +2369,9 @@ async function poll() { if (scanning && current) { const snap = await api(`/api/scan/snapshot?iface=${encodeURIComponent(current)}`); if (snap?.access_points && snap.running === false) { - // A sessão existe e respondeu normal (200), mas o próprio engine - // marcou running=false: a thread de captura morreu sozinha (erro de - // I/O, adaptador caiu da USB, etc.). Sem isso, a UI continuava achando - // que ainda estava capturando e nunca contava o motivo real. + // The session exists and returned HTTP 200, but the engine marked + // running=false because the capture thread died on its own (I/O error, + // USB adapter unplugged, etc.). Surface the actual reason to the UI. scanning = false; els.btnScan.textContent = 'iniciar captura'; els.btnScan.classList.remove('live'); @@ -2386,10 +2384,10 @@ async function poll() { renderResults(snap.access_points); if (selectedBssid) renderTarget(false); } else if (snap && snap.ok === false) { - // A captura morreu no meio (interface saiu do monitor, processo externo - // tomou o adaptador, etc.). Antes disso caía no fallback de baixo, que - // sempre responde 200 com lista vazia — a UI continuava "escutando o - // ar" indefinidamente sem nenhuma rede aparecer e sem avisar por quê. + // Capture stopped mid-flight (interface left monitor mode, external + // process claimed the adapter, etc.). Without this branch, the fallback + // below returns 200 with an empty list and the UI keeps listening + // indefinitely without any network appearing or explaining why. scanning = false; els.btnScan.textContent = 'iniciar captura'; els.btnScan.classList.remove('live'); @@ -2405,7 +2403,7 @@ async function poll() { } async function checkElevation() { - if (!tauri) return; // via `sabia ui` a elevação é responsabilidade do terminal (sudo) + if (!tauri) return; // in `sabia ui` mode privilege elevation is handled by the terminal (sudo) const notice = document.getElementById('elevateNotice'); const msg = document.getElementById('elevateMsg'); const retry = document.getElementById('btnElevateRetry'); diff --git a/apps/gui/ui/i18n.js b/apps/gui/ui/i18n.js index 67270b7..ca382a1 100644 --- a/apps/gui/ui/i18n.js +++ b/apps/gui/ui/i18n.js @@ -1,7 +1,7 @@ -// 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"). +// UI language. Source strings are in Portuguese; in English mode a translator +// replaces displayed strings (text nodes and attributes) using a dictionary, and +// keeps observing the page to translate dynamically rendered elements. +// Variable placeholders use {n} in the dictionary (e.g. "{n} networks"). import { EN } from './i18n-en.js'; @@ -16,7 +16,7 @@ export function resolveLang(pref) { } const ATTRS = ['placeholder', 'title', 'aria-label', 'alt', 'data-empty']; -// Dados do usuário (SSID, BSSID, MAC, valores mono) nunca são traduzidos. +// User data (SSID, BSSID, MAC, monospace values) is never translated. const SKIP = '.ssid, .bssid, .mono, pre, code, input, textarea, script, style'; const exact = new Map(Object.entries(EN).filter(([k]) => !k.includes('{n}'))); @@ -26,7 +26,7 @@ const patterns = Object.entries(EN) 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 + // more specific patterns (more fixed text) first .sort((a, b) => b.re.source.length - a.re.source.length); const cache = new Map(); @@ -43,7 +43,7 @@ export function tr(text) { const m = core.match(p.re); if (m) { let i = 0; - // os valores capturados também podem ter partes traduzíveis (ex.: estados) + // captured values may also contain translatable parts (e.g. status labels) out = p.to.replace(/\{n\}/g, () => tr(m[++i] ?? '')); break; } @@ -55,7 +55,7 @@ export function tr(text) { return lead + out + trail; } -const orig = new WeakMap(); // nó -> texto original em português +const orig = new WeakMap(); // node -> original source text let observer = null; function translateNode(n) { diff --git a/apps/gui/ui/vendor/README.md b/apps/gui/ui/vendor/README.md index 301e2f7..bc0cb2c 100644 --- a/apps/gui/ui/vendor/README.md +++ b/apps/gui/ui/vendor/README.md @@ -1,10 +1,10 @@ # Paged.js -`paged.polyfill.min.js` é o polyfill do [Paged.js](https://pagedjs.org/), versão -0.4.3, usado para paginar os relatórios em A4 no navegador. +`paged.polyfill.min.js` is the [Paged.js](https://pagedjs.org/) polyfill, version +0.4.3, used to paginate A4 reports in the browser. -- Projeto: https://gitlab.coko.foundation/pagedjs/pagedjs -- Licença: MIT (texto em `PAGEDJS-LICENSE`) -- Arquivo: `paged.polyfill.min.js` (build de distribuição, não modificado) +- Project: https://gitlab.coko.foundation/pagedjs/pagedjs +- License: MIT (text in `PAGEDJS-LICENSE`) +- File: `paged.polyfill.min.js` (unmodified distribution build) -O arquivo é vendorizado para que o relatório funcione offline, sem CDN. +Vendorized so report generation works completely offline without external CDNs. diff --git a/crates/sabia-cli/Cargo.toml b/crates/sabia-cli/Cargo.toml index e2c9779..175238f 100644 --- a/crates/sabia-cli/Cargo.toml +++ b/crates/sabia-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sabia-cli" -description = "sabia: CLI de auditoria e monitoramento de redes Wi-Fi" +description = "Sabiá: Wi-Fi network auditing and monitoring CLI" version.workspace = true edition.workspace = true license.workspace = true diff --git a/crates/sabia-cli/src/main.rs b/crates/sabia-cli/src/main.rs index 124f0dc..1b6c5b0 100644 --- a/crates/sabia-cli/src/main.rs +++ b/crates/sabia-cli/src/main.rs @@ -16,7 +16,7 @@ use std::time::Duration; version )] struct Cli { - /// Trilha de auditoria (padrão: .sabia-audit.log). + /// Audit log file path (default: `.sabia-audit.log`). #[arg(long, global = true, default_value = ".sabia-audit.log")] audit: PathBuf, @@ -26,95 +26,95 @@ struct Cli { #[derive(Subcommand)] enum Cmd { - /// Lista interfaces, canais e controla o modo monitor. + /// List interfaces, channels, and toggle monitor mode. Iface { #[command(subcommand)] action: IfaceAction, }, - /// Captura o ar e mostra redes, clientes e handshakes. + /// Capture live traffic and display networks, clients, and handshakes. Scan { iface: String, - /// Canal fixo; sem isso, percorre os canais suportados. + /// Fixed channel; when omitted, hops across supported channels. #[arg(long)] channel: Option, - /// Banda ao percorrer canais: 2.4, 5, 6 ou all. + /// Frequency band when hopping channels: `2.4`, `5`, `6`, or `all`. #[arg(long)] band: Option, - /// Tempo em cada canal (ms). + /// Channel dwell time in milliseconds. #[arg(long, default_value_t = 350)] hop_ms: u64, - /// Para a varredura depois de N segundos (padrão: até Ctrl+C). + /// Stop scanning after N seconds (default: run until Ctrl+C). #[arg(long)] seconds: Option, - /// Salva os quadros capturados em um .pcap. + /// Save captured frames to a `.pcap` file. #[arg(long)] out: Option, - /// Depois de capturar, tenta quebrar o primeiro handshake. + /// After capturing, attempt a wordlist crack on the first handshake. #[arg(long, value_name = "DICIONARIO")] crack: Option, - /// Mostra apenas o JSON final. + /// Output only the final JSON summary. #[arg(long)] json: bool, }, - /// Analisa um .pcap offline (redes, clientes e handshakes). + /// Analyze a `.pcap` file offline (networks, clients, and handshakes). Analyze { pcap: PathBuf, #[arg(long)] json: bool, }, - /// Quebra um handshake de um .pcap por dicionário. + /// Run a dictionary attack against a handshake in a `.pcap` file. Crack { pcap: PathBuf, - /// Dicionário com uma senha por linha. + /// Wordlist file (one candidate passphrase per line). #[arg(short, long)] wordlist: PathBuf, #[arg(long)] bssid: Option, #[arg(long)] station: Option, - /// Aplica mutações comuns a cada palavra (maiúscula, sufixos - /// numéricos, leet simples) — multiplica o tempo de verdade. + /// Apply common mutation rules to each word (capitalization, numeric + /// suffixes, simple leetspeak) — multiplies total runtime. #[arg(long)] rules: bool, #[arg(long)] json: bool, }, - /// Envia desautenticação para um AP/estação (teste autorizado). + /// Send deauthentication frames to an AP/station (authorized testing only). Deauth { iface: String, #[arg(long)] bssid: String, - /// Sem isso, o destino é broadcast. + /// Target client MAC; defaults to broadcast when omitted. #[arg(long)] station: Option, #[arg(long, default_value_t = 5)] count: u32, #[arg(long, default_value_t = 7)] reason: u16, - /// Confirma que você tem autorização para testar esta rede. + /// Confirm explicit authorization to test this target network. #[arg(long)] yes: bool, }, - /// Diagnóstico de privilégios, ferramentas e interfaces. + /// Diagnose privileges, external tools, and wireless interfaces. Doctor { #[arg(long)] json: bool, }, - /// Inspeciona a trilha de auditoria. + /// Inspect and verify the hash-chained audit trail. Audit { #[command(subcommand)] action: AuditAction, }, - /// Verifica e aplica atualizações do próprio sabia. + /// Check for and apply `sabia` CLI updates. Update { #[arg(long)] apply: bool, }, - /// Abre a interface gráfica (servidor local + navegador). + /// Launch the web UI (local HTTP server + browser). Ui { #[arg(long, default_value = "4337")] port: u16, - /// Não tenta abrir o navegador automaticamente. + /// Do not open the browser automatically. #[arg(long)] no_open: bool, }, @@ -123,13 +123,13 @@ enum Cmd { #[derive(Subcommand)] enum IfaceAction { List, - /// Mostra os canais suportados pelo rádio. + /// List channels supported by the wireless phy. Channels { iface: String, #[arg(long)] json: bool, }, - /// Liga/desliga modo monitor numa interface Wi-Fi. + /// Enable or disable monitor mode on a Wi-Fi interface. Monitor { iface: String, #[arg(value_enum)] @@ -139,9 +139,9 @@ enum IfaceAction { #[derive(Subcommand)] enum AuditAction { - /// Confere a integridade da cadeia. + /// Verify the cryptographic hash chain integrity. Verify { path: Option }, - /// Mostra as últimas entradas. + /// Show the last N entries. Show { #[arg(long, default_value_t = 20)] limit: usize, diff --git a/crates/sabia-cli/src/web/mod.rs b/crates/sabia-cli/src/web/mod.rs index 9866e6e..a577746 100644 --- a/crates/sabia-cli/src/web/mod.rs +++ b/crates/sabia-cli/src/web/mod.rs @@ -1,7 +1,7 @@ -//! Servidor local da interface: uma única página HTML/JS servida em -//! `127.0.0.1`, protegida por um token gerado na hora, falando com o -//! núcleo por uma API JSON simples. A mesma página em `apps/gui/ui` é o -//! que o app desktop (Tauri) carrega — só muda o transporte. +//! Local web UI server: serves the single-page HTML/JS app on +//! `127.0.0.1`, protected by a per-session random token, communicating with +//! the core over JSON endpoints. The exact same frontend in `apps/gui/ui` +//! powers the Tauri desktop app. use sabia_core::engine::{Engine, ScanOptions}; use serde::Deserialize; @@ -25,11 +25,11 @@ pub fn serve(port: u16, audit_path: std::path::PathBuf, open: bool) -> sabia_cor let listener = TcpListener::bind(("127.0.0.1", port))?; let engine = Arc::new(Engine::with_audit_path(audit_path)); - // Sem isso, Ctrl+C (ou um `kill` normal) durante um Evil Twin ativo - // mata o processo sem rodar a limpeza: hostapd/dnsmasq ficam órfãos - // e as duas interfaces ficam presas em `managed no` pro - // NetworkManager — o painel de Wi-Fi do sistema some por completo até - // o próximo início do engine se auto-curar. + // Without a signal handler, Ctrl+C (or `SIGTERM`) during an active Evil Twin + // exits before cleanup runs: `hostapd`/`dnsmasq` are left orphaned + // and both interfaces remain stuck in `managed no` under + // NetworkManager until the next engine startup runs self-healing. + // { let engine = engine.clone(); let _ = ctrlc::set_handler(move || { diff --git a/crates/sabia-core/Cargo.toml b/crates/sabia-core/Cargo.toml index af1c7e7..b5a1f30 100644 --- a/crates/sabia-core/Cargo.toml +++ b/crates/sabia-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sabia-core" -description = "Sabiá: núcleo de captura, análise e auditoria de redes Wi-Fi" +description = "Sabiá: Wi-Fi network capture, analysis, and auditing core" version.workspace = true edition.workspace = true license.workspace = true @@ -25,5 +25,5 @@ flate2 = { version = "1", default-features = false, features = ["rust_backend"] tempfile = "3" [target.'cfg(not(target_os = "linux"))'.dependencies] -# captura crua (AF_PACKET) só existe no Linux; outras plataformas compilam -# a crate mas os módulos de captura ficam indisponíveis em tempo de execução. +# Raw capture (AF_PACKET) is Linux-only; other platforms compile the crate +# while live capture modules remain unavailable at runtime. diff --git a/crates/sabia-core/src/analysis.rs b/crates/sabia-core/src/analysis.rs index 272dbd1..c7a21a3 100644 --- a/crates/sabia-core/src/analysis.rs +++ b/crates/sabia-core/src/analysis.rs @@ -1,8 +1,8 @@ -//! Análise de canais, detecção de anomalias (WIDS) e leitura de WPA3. +//! Channel utilization analysis, wireless anomaly detection (WIDS), and WPA3 indicators. +//! +//! Everything here is derived passively from the captured scan table without injecting frames. +//! Both the live UI and the standalone audit report consume this model. //! -//! Tudo aqui é derivado da tabela já capturada — nada injeta quadros. A -//! interface usa o resultado para desenhar o gráfico de canais e os alertas; -//! o relatório usa o mesmo modelo para não ter duas verdades. use crate::radiotap::{self, Band}; use crate::scan::AccessPoint; @@ -280,8 +280,8 @@ pub fn analyze(aps: &[AccessPoint], deauth_60s: u32, now: u64) -> Analysis { }) .collect(); - // SSID anunciado com seguranças diferentes é o sinal clássico de evil - // twin — legítimo costuma manter o mesmo conjunto em todos os rádios. + // Same SSID advertised with mixed security suites (e.g. WPA2 alongside Open) + // is a classic Evil Twin indicator. let mut by_ssid: HashMap> = HashMap::new(); for ap in aps { if let Some(ssid) = ap.ssid.as_deref().filter(|s| !s.is_empty()) { diff --git a/crates/sabia-core/src/audit.rs b/crates/sabia-core/src/audit.rs index decaf20..128b1dd 100644 --- a/crates/sabia-core/src/audit.rs +++ b/crates/sabia-core/src/audit.rs @@ -1,12 +1,12 @@ -//! Trilha de auditoria encadeada por hash de toda ação sensível (ligar -//! modo monitor, iniciar captura, trocar canal, etc.) — mesmo princípio do -//! módulo `audit` do Guará: cada evento guarda o hash do anterior, então -//! qualquer edição retroativa do log quebra a cadeia e é detectável. +//! Hash-chained audit trail of every sensitive operation (enabling +//! monitor mode, starting capture, changing channels, active attacks): +//! each entry commits to the SHA-256 of the previous entry so any +//! retroactive tampering breaks the chain. +//! +//! Provides verifiable engagement evidence of what was executed, when, +//! and against which target/interface. +//! //! -//! Isso existe para que o uso do Sabiá em um engajamento de pentest tenha -//! evidência de o quê foi feito, quando e contra qual rede/interface — -//! não para decidir sozinho se uma ação é autorizada (isso é sempre do -//! operador). use crate::error::Result; use serde::{Deserialize, Serialize}; @@ -85,7 +85,7 @@ impl AuditLog { Ok(entry) } - /// Lê as entradas da trilha, opcionalmente só as `limit` mais recentes. + /// Reads entries from the audit log, optionally returning only the last `limit` entries. pub fn read_entries(path: impl AsRef, limit: Option) -> Result> { let file = OpenOptions::new().read(true).open(path)?; let reader = BufReader::new(file); @@ -105,8 +105,8 @@ impl AuditLog { Ok(entries) } - /// Verifica a integridade de toda a cadeia; retorna o índice da - /// primeira entrada inválida, se houver. + /// Verifies the integrity of the entire hash chain; returns the index of + /// the first invalid entry if tampered. pub fn verify(path: impl AsRef) -> Result> { let file = OpenOptions::new().read(true).open(path)?; let reader = BufReader::new(file); diff --git a/crates/sabia-core/src/capture.rs b/crates/sabia-core/src/capture.rs index 41a0fda..e5d0d90 100644 --- a/crates/sabia-core/src/capture.rs +++ b/crates/sabia-core/src/capture.rs @@ -1,9 +1,9 @@ -//! Captura crua de quadros 802.11 via `AF_PACKET`, sem depender de libpcap. +//! Raw 802.11 frame capture via Linux `AF_PACKET` sockets without requiring `libpcap`. +//! +//! The interface must already be in monitor mode (see [`crate::netlink`]). +//! Uses the same kernel packet socket interface that `libpcap` uses on Linux +//! without adding an external system library dependency. //! -//! A interface já precisa estar em modo monitor (ver [`crate::netlink`]). -//! Isso só funciona em Linux — é exatamente o que o libpcap faz por baixo -//! quando usado em modo monitor, então reimplementar aqui é pouco código e -//! remove uma dependência de sistema. #![cfg(target_os = "linux")] @@ -21,8 +21,8 @@ pub struct RawCapture { } impl RawCapture { - /// Abre um socket raw ligado à interface `iface` (já em modo monitor). - /// Exige `CAP_NET_RAW` (tipicamente root). + /// Opens a raw `AF_PACKET` socket bound to `iface` (must already be in monitor mode). + /// Requires `CAP_NET_RAW` (typically root). pub fn open(iface: &str) -> Result { let fd = unsafe { libc::socket(libc::AF_PACKET, libc::SOCK_RAW, ETH_P_ALL as i32) }; if fd < 0 { @@ -57,8 +57,8 @@ impl RawCapture { Ok(Self { fd }) } - /// Define um timeout de recepção, para o loop de captura poder checar - /// periodicamente um sinal de parada sem travar para sempre em `recv`. + /// Sets `SO_RCVTIMEO` so the capture loop can periodically check its stop flag + /// without blocking indefinitely in `recv`. pub fn set_read_timeout(&self, timeout: Duration) -> Result<()> { let tv = libc::timeval { tv_sec: timeout.as_secs() as libc::time_t, @@ -79,7 +79,7 @@ impl RawCapture { Ok(()) } - /// Lê um quadro. Retorna `Ok(None)` em timeout (ver `set_read_timeout`). + /// Reads a single frame. Returns `Ok(None)` on receive timeout (see `set_read_timeout`). pub fn recv(&self, buf: &mut [u8]) -> Result> { let n = unsafe { libc::recv(self.fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len(), 0) }; if n < 0 { @@ -114,8 +114,8 @@ fn if_index(name: &str) -> Result { Ok(idx as i32) } -/// Garante, de forma best-effort, que o processo tem privilégio para abrir -/// sockets raw — útil para dar um erro claro antes de tentar o resto. +/// Best-effort check that the process has privileges to open raw packet sockets. +/// pub fn check_capabilities() -> bool { let euid = unsafe { libc::geteuid() }; euid == 0 diff --git a/crates/sabia-core/src/ccmp.rs b/crates/sabia-core/src/ccmp.rs index c796fb8..98a3d0c 100644 --- a/crates/sabia-core/src/ccmp.rs +++ b/crates/sabia-core/src/ccmp.rs @@ -1,31 +1,31 @@ -//! Decriptação CCMP (WPA2-AES) de quadros de dados, usando a TK já derivada -//! de um handshake quebrado. +//! WPA2 CCMP (AES-CTR) data frame decryption using the Temporal Key (TK) derived +//! from a cracked 4-way handshake. +//! +//! Used passively to inspect cleartext upper-layer protocols (DHCP, mDNS, ARP, HTTP) +//! of a station whose own 4-way handshake was captured and cracked, allowing +//! hostname/device identification. Because each association derives an ephemeral +//! PTK, the TK is scoped to a specific `(BSSID, station)` pair. +//! +//! +//! +//! +//! Implements AES-CTR payload decryption (skipping 8-byte CBC-MAC MIC verification +//! since we only extract passive identity hints from known-key traffic). +//! +//! //! -//! Isso não serve pra forjar nem reinjetar nada — só pra enxergar o conteúdo -//! em claro (DHCP, mDNS) de um cliente cuja senha da rede já foi recuperada -//! por dicionário, e assim extrair hostname/fabricante de verdade em vez de -//! só o MAC. A chave é par (BSSID, estação): só decifra tráfego do cliente -//! cujo próprio handshake foi capturado e quebrado, nunca de terceiros — -//! cada associação tem sua PTK efêmera, então a senha da rede sozinha não -//! abre o tráfego de quem a gente não viu entrar. //! -//! Implementa só o modo CTR (a parte que decifra o conteúdo). O MIC -//! (CBC-MAC) não é conferido: a intenção aqui é mostrar o payload de um -//! tráfego que a gente já pode decifrar porque a senha é conhecida, não -//! provar integridade de um quadro adulterado — e implementar o AAD/CBC-MAC -//! certinho exigiria replicar o mascaramento do cabeçalho 802.11 inteiro -//! sem ganho prático pro que isso serve aqui. use aes::cipher::{BlockEncrypt, KeyInit}; use aes::Aes128; -/// Decifra o corpo de um quadro de dados CCMP, a partir do cabeçalho CCMP -/// (8 bytes) em diante — ou seja, `body` é `frame.body[frame.header_len..]`. -/// `transmitter` é o endereço 2 (A2) do quadro — o rádio que o enviou, não o -/// BSSID/estação lógicos. `qos_tid` é o TID do QoS Control (0 se não-QoS). +/// Decrypts a CCMP-protected data frame payload starting at the 8-byte CCMP header +/// (`frame.body[frame.header_len..]`). +/// `transmitter` is Address 2 (`A2`), and `qos_tid` is the QoS TID (`0` for non-QoS). +/// +/// +/// Returns the decrypted LLC/SNAP + payload (excluding the trailing 8-byte MIC). /// -/// Retorna o LLC/SNAP + payload em claro (o mesmo formato que um quadro não -/// protegido teria), sem os 8 bytes de MIC cifrado no final. pub fn decrypt_data( tk: &[u8; 16], transmitter: &[u8; 6], @@ -40,8 +40,8 @@ pub fn decrypt_data( let pn0 = body[0]; let pn1 = body[1]; let key_id_octet = body[3]; - // ExtIV (bit 5) precisa estar ligado — senão não é um cabeçalho CCMP de - // verdade (podia ser WEP de 4 bytes por coincidência de layout). + // ExtIV (bit 5) must be set for a valid 8-byte CCMP IV header. + // if key_id_octet & 0x20 == 0 { return None; } @@ -53,8 +53,8 @@ pub fn decrypt_data( let ciphertext_and_mic = &body[HEADER_LEN..]; let ciphertext = &ciphertext_and_mic[..ciphertext_and_mic.len() - MIC_LEN]; - // Nonce CCMP (13 bytes): flags/prioridade (1) || A2 (6) || PN, do byte - // mais significativo (PN5) ao menos significativo (PN0). + // 13-byte CCMP nonce: `flags/priority (1) || A2 (6) || PN5..PN0 (6)`. + // let mut nonce = [0u8; 13]; nonce[0] = qos_tid & 0x0f; nonce[1..7].copy_from_slice(transmitter); @@ -67,10 +67,10 @@ pub fn decrypt_data( let cipher = Aes128::new(tk.into()); let mut out = Vec::with_capacity(ciphertext.len()); - let mut counter: u16 = 1; // o bloco de contador 0 cifra só o MIC, não dado. + let mut counter: u16 = 1; // counter block 0 encrypts the MIC; payload blocks start at counter 1. for chunk in ciphertext.chunks(16) { let mut block = [0u8; 16]; - block[0] = 0x01; // flag octet do CCMP counter mode (L=1, sem AAD aqui) + block[0] = 0x01; // CCMP counter mode flags octet (`L = 1`) block[1..14].copy_from_slice(&nonce); block[14..16].copy_from_slice(&counter.to_be_bytes()); let mut keystream = block.into(); @@ -87,9 +87,9 @@ pub fn decrypt_data( mod tests { use super::*; - /// AES-128 do FIPS-197 (vetor de referência oficial do NIST): confirma - /// que a cifra de bloco usada embaixo está correta antes de confiar no - /// contador CTR por cima dela. + /// NIST FIPS-197 Appendix B AES-128 test vector. + /// + /// #[test] fn aes128_matches_fips197_vector() { let key: [u8; 16] = [ @@ -110,19 +110,19 @@ mod tests { assert_eq!(block.as_slice(), &expected); } - /// Round-trip de auto-consistência: decifrar um texto que a própria - /// função "cifrou" (mesmo algoritmo, usado nos dois sentidos — CTR é - /// simétrico) tem que devolver o original. Isso pega bug de indexação - /// no nonce/contador mesmo sem um vetor de teste oficial do CCMP à mão. + /// CTR symmetry round-trip: encrypting and decrypting with the same CCMP + /// keystream recovers the original LLC/SNAP payload. + /// + /// #[test] fn ctr_round_trip_recovers_plaintext() { let tk = [0x42u8; 16]; let transmitter = [0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff]; let plaintext = b"AA AA 03 00 00 00 08 00 payload de teste bem maior que um bloco de 16 bytes para cobrir mais de uma rodada do contador"; - // Monta um "quadro" CCMP cifrando manualmente com a mesma lógica de - // keystream (ela é simétrica: XOR com o keystream cifra e decifra). - let header = [0x11u8, 0x22, 0x00, 0x20, 0x33, 0x44, 0x55, 0x66]; // ExtIV ligado + // Build a synthetic CCMP payload using the symmetric CTR keystream. + // + let header = [0x11u8, 0x22, 0x00, 0x20, 0x33, 0x44, 0x55, 0x66]; // ExtIV bit set let mut nonce = [0u8; 13]; nonce[0] = 3; // TID nonce[1..7].copy_from_slice(&transmitter); @@ -147,7 +147,7 @@ mod tests { } counter = counter.wrapping_add(1); } - ciphertext.extend_from_slice(&[0u8; 8]); // MIC fictício, ignorado na decriptação + ciphertext.extend_from_slice(&[0u8; 8]); // dummy 8-byte MIC (stripped during decryption) let mut body = Vec::new(); body.extend_from_slice(&header); @@ -160,7 +160,7 @@ mod tests { #[test] fn rejects_body_without_extiv_bit() { let tk = [0u8; 16]; - let body = [0u8; 20]; // key_id_octet (índice 3) sem o bit ExtIV + let body = [0u8; 20]; // key_id_octet (index 3) without the ExtIV bit set assert!(decrypt_data(&tk, &[0; 6], 0, &body).is_none()); } diff --git a/crates/sabia-core/src/crack.rs b/crates/sabia-core/src/crack.rs index 722f1e9..2c5fa4b 100644 --- a/crates/sabia-core/src/crack.rs +++ b/crates/sabia-core/src/crack.rs @@ -1,15 +1,15 @@ -//! Quebra de WPA/WPA2-PSK por dicionário, em Rust puro. +//! Pure-Rust WPA/WPA2-PSK dictionary and mask cracker. //! -//! Duas provas são aceitas, como no aircrack-ng/hashcat: +//! Supports both verification modes used by `aircrack-ng` and `hashcat`: //! -//! * **EAPOL (4-way handshake):** deriva a PMK com PBKDF2-HMAC-SHA1 -//! (4096 iterações), expande a PTK com o PRF do 802.11i e recalcula o MIC da -//! mensagem 2. -//! * **PMKID:** recalcula `HMAC-SHA1(PMK, "PMK Name" || AP || STA)` direto da -//! mensagem 1 — não precisa do handshake completo. +//! * **EAPOL (4-way handshake):** derives the PMK via PBKDF2-HMAC-SHA1 +//! (4096 iterations), expands the PTK via IEEE 802.11i PRF, and verifies the +//! message 2 MIC. +//! * **PMKID:** computes `HMAC-SHA1(PMK, "PMK Name" || AP || STA)` directly from +//! message 1 without requiring a full 4-way handshake. //! -//! É CPU puro, sem GPU: suficiente para a v0.1.0 e para dicionários comuns, -//! mas não substitui o hashcat em ataques de força bruta grandes. +//! Runs multi-threaded on the CPU; for large GPU-accelerated attacks, export +//! `.hc22000` via [`crate::hashcat`]. use crate::dot11::parse_mac; use crate::error::{Result, SabiaError}; @@ -33,9 +33,9 @@ type HmacMd5 = Hmac; const WPA_ITERATIONS: u32 = 4096; -/// `.gz` do dicionário embutido — descomprimido só na primeira vez que é -/// usado (ver [`default_wordlist`]). Cru são ~101MB; comprimido, ~39MB no -/// binário em vez de ~101MB. +/// Gzip-compressed built-in wordlist (`~39 MB` compressed -> `~101 MB` raw), +/// decompressed lazily on first use (see [`default_wordlist`]). +/// const DEFAULT_WORDLIST_GZ: &[u8] = include_bytes!("../assets/wordlists/wpa-full.txt.gz"); pub const DEFAULT_WORDLIST_SOURCE: &str = @@ -49,17 +49,17 @@ static DEFAULT_WORDLIST_CACHE: LazyLock = LazyLock::new(|| { out }); -/// Lista embutida de senhas prováveis de Wi-Fi, usada quando o usuário não -/// escolhe um dicionário próprio. Não depende de arquivo externo: é a lista -/// mais completa que o Sabiá consegue embutir sem exigir download — -/// 9.703.831 senhas (203.806 do Probable-Wordlists, ranqueadas por -/// probabilidade, seguidas pelo rockyou.txt inteiro) filtradas pro -/// comprimento válido de WPA (8–63) e pra ASCII puro (WPA-PSK exige um -/// octeto válido; a imensa maioria dos roteadores só aceita ASCII mesmo, e -/// isso evita o pouco de lixo binário que o rockyou.txt original carrega), -/// sem duplicatas e na ordem de prioridade: as mais prováveis primeiro, -/// então a quebra tende a achar a senha cedo, não só no fim da lista. Ver -/// `assets/wordlists/LICENSE.wpa-full` pra atribuição completa. +/// Built-in WPA wordlist (9,703,831 deduplicated 8–63 char ASCII passwords +/// combining Probable-Wordlists and `rockyou.txt` ordered by probability). +/// See `crates/sabia-core/assets/wordlists/LICENSE.wpa-full` for attribution. +/// +/// +/// +/// +/// +/// +/// +/// pub fn default_wordlist() -> &'static str { &DEFAULT_WORDLIST_CACHE } @@ -142,7 +142,7 @@ impl CrackTarget { } } -/// Deriva a PMK (256 bits) de uma frase-senha WPA e do SSID. +/// Derives the 256-bit WPA PMK from an ASCII passphrase and SSID (`PBKDF2-HMAC-SHA1`, 4096 rounds). pub fn derive_pmk(passphrase: &str, ssid: &str) -> [u8; 32] { let dk = pbkdf2_sha1(passphrase.as_bytes(), ssid.as_bytes(), WPA_ITERATIONS, 32); let mut pmk = [0u8; 32]; @@ -150,10 +150,10 @@ pub fn derive_pmk(passphrase: &str, ssid: &str) -> [u8; 32] { pmk } -/// Aceita uma frase-senha (8–63 caracteres) ou uma PMK crua em hexadecimal -/// (64 caracteres, como no hashcat/aircrack). `pub(crate)` porque -/// `handshake.rs` reusa exatamente essa regra ao derivar a TK de uma senha -/// já quebrada (o campo `password` aceita as duas formas, igual aqui). +/// Resolves either an 8–63 character WPA passphrase or a raw 64-hex-character PMK. +/// Shared with `handshake.rs` when deriving the CCMP Temporal Key (TK) from a known password. +/// +/// pub(crate) fn pmk_from_candidate(candidate: &str, ssid: &[u8]) -> Option<[u8; 32]> { if candidate.len() == 64 && candidate.bytes().all(|b| b.is_ascii_hexdigit()) { return hex_to_32(candidate.as_bytes()); @@ -170,10 +170,10 @@ pub(crate) fn pmk_from_candidate(candidate: &str, ssid: &[u8]) -> Option<[u8; 32 pub fn pbkdf2_sha1(password: &[u8], salt: &[u8], iterations: u32, out_len: usize) -> Vec { let iterations = iterations.max(1); let mut out = Vec::with_capacity(out_len); - // O par ipad/opad depende só da senha — monta uma vez e clona a cada - // rodada (clone é só copiar os dois blocos já prontos) em vez de montar - // o HMAC do zero em cada uma das milhares de iterações. Em 4096 - // iterações por bloco isso é a maior parte do custo fora do SHA1 em si. + // Precompute the HMAC inner/outer SHA-1 states once per passphrase and clone + // across all 4096 PBKDF2 rounds to avoid re-hashing the key block each iteration. + // + // let base = HmacSha1::new_from_slice(password).expect("HMAC aceita qualquer chave"); let mut block: u32 = 1; while out.len() < out_len { @@ -213,7 +213,7 @@ fn prf_sha1(key: &[u8], label: &[u8], data: &[u8], out_len: usize) -> Vec { out } -/// PTK de 64 bytes: KCK (16) + KEK (16) + TK (16) + 16 de reserva. +/// Derives the 64-byte PTK: `KCK (16) || KEK (16) || TK (16) || MIC keys (16)`. pub fn compute_ptk( pmk: &[u8; 32], bssid: &[u8; 6], @@ -314,14 +314,14 @@ fn valid_candidate(candidate: &str) -> bool { || (candidate.len() == 64 && candidate.bytes().all(|b| b.is_ascii_hexdigit())) } -/// Tenta cada linha do dicionário contra o alvo, dividindo o trabalho entre -/// as threads disponíveis (PBKDF2-HMAC-SHA1 de 4096 iterações é CPU puro e -/// cada candidato é independente — ganho quase linear com o número de -/// núcleos). `progress` recebe o número de candidatos já processados, ~8x -/// por segundo enquanto roda. Com `apply_rules`, cada linha vira até -/// [`rules::VARIANTS_PER_WORD`] candidatos (maiúscula, sufixos comuns, leet -/// simples) em vez de só a palavra crua — multiplica o tempo de verdade, -/// por isso é opt-in. +/// Tests wordlist candidates against `target` across all available CPU threads. +/// Invokes `progress` (~8x/s) with the number of tested candidates. When +/// `apply_rules` is enabled, expands each base word via [`rules::expand`]. +/// +/// +/// +/// +/// pub fn crack_reader( target: &CrackTarget, mut wordlist: R, @@ -330,9 +330,9 @@ pub fn crack_reader( ) -> CrackReport { let start = Instant::now(); - // Carrega a lista inteira antes de começar: dá pra repartir em pedaços - // contíguos por thread (melhor localidade de cache que round-robin) e o - // próprio dicionário embutido cabe de sobra em memória. + // Load candidates upfront and partition into contiguous per-thread slices. + // + // let mut candidates: Vec = Vec::new(); let mut line = String::new(); loop { @@ -384,8 +384,8 @@ pub fn crack_reader( }); } - // Thread "repórter": roda dentro do mesmo scope só pra poder - // emprestar `progress` (FnMut, chamado de um único lugar). + // Progress reporter thread scoped to borrow `progress` (`FnMut`) safely. + // scope.spawn(|| loop { std::thread::sleep(std::time::Duration::from_millis(120)); let n = tried.load(Ordering::Relaxed); @@ -426,7 +426,7 @@ pub fn crack_handshake( )) } -/// Quebra usando a lista embutida. +/// Runs a dictionary crack using the built-in wordlist. pub fn crack_handshake_default( hs: &Handshake, apply_rules: bool, @@ -449,7 +449,7 @@ fn target_for(hs: &Handshake) -> Result { }) } -/// Lê um `.pcap`, monta os handshakes e tenta quebrar o primeiro alvo útil. +/// Parses a `.pcap` file, assembles handshakes, and cracks the first crackable target. pub fn crack_pcap( pcap: impl AsRef, wordlist: impl AsRef, @@ -474,11 +474,11 @@ pub fn crack_pcap( crack_handshake(hs, wordlist, apply_rules, progress) } -/// Força bruta por máscara (estilo hashcat `-a 3`): gera e testa cada -/// combinação possível, sem depender de dicionário. Mesma paralelização por -/// threads do `crack_reader`, mas gerando candidatos por índice (base -/// mista) em vez de carregar tudo em memória — é o que permite máscaras com -/// milhões/bilhões de combinações sem estourar RAM. +/// Mask brute-force attack (hashcat `-a 3` style): generates candidates on the fly +/// by mixed-radix index across all CPU threads without materializing the keyspace in RAM. +/// +/// +/// pub fn crack_mask( target: &CrackTarget, mask: &Mask, @@ -546,7 +546,7 @@ pub fn crack_mask( } } -/// Quebra por máscara a partir de um handshake já montado. +/// Runs a mask brute-force attack against an assembled `Handshake`. pub fn crack_handshake_mask( hs: &Handshake, mask: &Mask, @@ -562,8 +562,8 @@ mod tests { use crate::testutil::{handshake_frames, synthetic_handshake}; use std::io::Cursor; - /// Mede só o tempo de descomprimir o dicionário embutido (gzip, ~39MB -> ~101MB), - /// que acontece uma vez, na primeira chamada a `default_wordlist()`. + /// Benchmarks one-time decompression of the built-in gzip wordlist (`~39 MB -> ~101 MB`). + /// #[test] #[ignore] fn bench_default_wordlist_decompress() { @@ -577,17 +577,17 @@ mod tests { ); } - /// Não é teste de verdade — mede candidatos/segundo pra guiar otimização - /// de performance. `cargo test --release -p sabia-core -- --ignored - /// --nocapture bench_crack_throughput`. + /// Throughput benchmark (`cargo test --release -p sabia-core -- --ignored --nocapture bench_crack_throughput`). + /// + /// #[test] #[ignore] fn bench_crack_throughput() { let (m1, m2) = handshake_frames("IEEE", "senha-que-nao-esta-na-lista", 2, false); let hs = synthetic_handshake("IEEE", "00:11:22:33:44:55", "66:77:88:99:aa:bb", &m1, &m2); let target = CrackTarget::from_handshake(&hs).expect("alvo"); - // Só uma fatia: a lista inteira (9,7 milhões) levaria minutos — o - // objetivo aqui é medir candidatos/s, não o tempo total. + // Benchmark a 20,000-password slice to measure candidates/sec quickly. + // let sample: String = default_wordlist() .lines() .take(500_000) @@ -677,8 +677,8 @@ mod tests { #[test] fn rules_find_a_mutated_variant_not_present_verbatim() { - // A senha de verdade é "wifi12345" — não está na lista, mas é um - // sufixo numérico de "wifi" que `rules::expand` gera. + // True password `"wifi12345"` is derived from `"wifi"` via `rules::expand`. + // let (m1, m2) = handshake_frames("IEEE", "wifi12345", 2, false); let hs = synthetic_handshake("IEEE", "00:11:22:33:44:55", "66:77:88:99:aa:bb", &m1, &m2); let target = CrackTarget::from_handshake(&hs).expect("alvo"); @@ -692,10 +692,10 @@ mod tests { #[test] fn mask_crack_finds_a_numeric_password() { - // Máscara minúscula de propósito (só 10 combinações): cada candidato - // é um PBKDF2 de 4096 iterações, pesado até em paralelo — um brute - // force de verdade (ex.: ?d×8, 100 milhões) é coisa pra uso real, - // não pra um teste unitário que roda em `cargo test` toda hora. + // Small 10-candidate mask (`wifi123?d`) to keep unit tests fast. + // + // + // let (m1, m2) = handshake_frames("IEEE", "wifiabc7", 2, false); let hs = synthetic_handshake("IEEE", "00:11:22:33:44:55", "66:77:88:99:aa:bb", &m1, &m2); let target = CrackTarget::from_handshake(&hs).expect("alvo"); diff --git a/crates/sabia-core/src/doctor.rs b/crates/sabia-core/src/doctor.rs index 7b8313c..be1ac36 100644 --- a/crates/sabia-core/src/doctor.rs +++ b/crates/sabia-core/src/doctor.rs @@ -1,5 +1,5 @@ -//! Diagnóstico do ambiente: privilégios, ferramentas externas, interfaces, -//! suporte a modo monitor e integridade da trilha de auditoria. +//! Environment diagnostics: privileges, external helper tools, wireless interfaces, +//! monitor mode support, and audit trail integrity. use crate::audit::AuditLog; use crate::iface::{self, Interface}; diff --git a/crates/sabia-core/src/dot11.rs b/crates/sabia-core/src/dot11.rs index 6a5b2f7..4bd7cf7 100644 --- a/crates/sabia-core/src/dot11.rs +++ b/crates/sabia-core/src/dot11.rs @@ -1,7 +1,7 @@ -//! Parser de quadros 802.11 — gerenciamento (beacon, probe, associação, -//! desautenticação) e dados — com leitura dos *information elements* que -//! interessam para auditoria: SSID, canal, segurança (RSN/WPA), WPS, PMF e -//! a localização do payload EAPOL dos handshakes WPA/WPA2. +//! IEEE 802.11 frame parser — management (beacon, probe, association, +//! deauthentication) and data frames — extracting security-relevant Information +//! Elements (SSID, channel, RSN/WPA, WPS, PMF) and locating EAPOL handshake +//! payloads and cleartext client identity hints. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FrameKind { @@ -94,9 +94,9 @@ pub struct ManagementInfo { pub wps_serial: Option, } -/// Pistas de identidade tiradas de payload em claro (DHCP, mDNS/DNS, SSDP, -/// HTTP e ARP). Redes criptografadas não expõem nada disso; nesse caso os -/// campos ficam vazios e a UI mostra só MAC, OUI e probes. +/// Client identity hints extracted from cleartext upper-layer payloads (DHCP, mDNS/DNS, +/// SSDP, HTTP, ARP). Empty on encrypted networks unless the station's CCMP +/// session key has been derived from a cracked handshake. #[derive(Debug, Clone, Default)] pub struct PayloadInfo { pub ip: Option, @@ -172,7 +172,7 @@ pub fn is_broadcast(mac: &[u8; 6]) -> bool { *mac == [0xff; 6] } -/// Quadro 802.11 já sem o cabeçalho radiotap. +/// Parsed 802.11 frame (after stripping the leading Radiotap header). pub struct Frame<'a> { pub kind: FrameKind, pub fc: u16, @@ -327,7 +327,7 @@ impl<'a> Frame<'a> { self.dst.map(|m| mac_to_string(&m)) } - /// Slice dos information elements, quando o quadro tem um. + /// Returns the Information Elements (IE) byte slice for management frames that carry one. pub fn ies(&self) -> Option<&'a [u8]> { if self.ie_offset == 0 || self.ie_offset >= self.body.len() { None @@ -336,8 +336,8 @@ impl<'a> Frame<'a> { } } - /// Faz parsing dos information elements (TLV), extraindo SSID, canal, - /// cifra/AKM e indicadores de WPS e PMF. + /// Parses TLV Information Elements, extracting SSID, channel, cipher/AKM suites, + /// WPS state, and PMF (802.11w) flags. pub fn management_info(&self) -> ManagementInfo { let mut info = ManagementInfo::default(); let Some(ies) = self.ies() else { @@ -449,8 +449,8 @@ impl<'a> Frame<'a> { } } - /// Payload EAPOL (handshake WPA) de um quadro de dados não protegido. - /// O slice começa no cabeçalho EAPOL (versão, tipo, tamanho). + /// Extracts the EAPOL payload slice (`0x888E`) from an unprotected data frame. + /// The returned slice begins at the 4-byte EAPOL header. pub fn eapol(&self) -> Option<&'a [u8]> { if self.protected || !self.kind.is_data() { return None; @@ -469,10 +469,10 @@ impl<'a> Frame<'a> { self.body.get(start..end) } - /// Tenta identificar a estação por payload não criptografado. Só olha - /// dados em claro; em WPA2/WPA3 o conteúdo é opaco e retorna vazio — a - /// menos que a senha já tenha sido quebrada (ver [`Self::ccmp_payload`] - /// + [`payload_info_from_llc`]). + /// Extracts passive client identity hints from an unprotected data frame. + /// Returns empty `PayloadInfo` for protected frames (use [`Self::ccmp_payload`] + /// + [`payload_info_from_llc`] when the station's TK is known). + /// pub fn payload_info(&self) -> PayloadInfo { if self.protected || !self.kind.is_data() { return PayloadInfo::default(); @@ -483,8 +483,8 @@ impl<'a> Frame<'a> { payload_info_from_llc(payload) } - /// TID do campo QoS Control, quando o quadro é QoS Data. `None` pra - /// dados não-QoS (o nonce do CCMP usa TID 0 nesse caso). + /// Extracts the QoS Control TID (`0..15`) for QoS Data frames, or `None` for non-QoS. + /// pub fn qos_tid(&self) -> Option { if !self.kind.is_data() { return None; @@ -500,10 +500,10 @@ impl<'a> Frame<'a> { self.body.get(offset).map(|b| b & 0x0f) } - /// Decifra um quadro de dados CCMP com a TK já derivada (handshake - /// quebrado) e devolve o LLC/SNAP + payload em claro, no mesmo formato - /// que um quadro não protegido teria. `None` se o quadro não for CCMP - /// protegido ou os bytes não baterem com um cabeçalho CCMP válido. + /// Decrypts a CCMP-protected data frame using the station's 16-byte Temporal Key (TK) + /// and returns the cleartext LLC/SNAP + upper-layer payload. + /// + /// pub fn ccmp_payload(&self, tk: &[u8; 16]) -> Option> { if !self.protected || !self.kind.is_data() { return None; @@ -515,9 +515,9 @@ impl<'a> Frame<'a> { } } -/// LLC/SNAP (AA AA 03 00 00 00 + ethertype) seguido do payload em claro — -/// compartilhado entre quadros nunca cifrados e quadros CCMP já decifrados -/// com a TK conhecida. +/// Parses cleartext LLC/SNAP (`AA AA 03 00 00 00 + EtherType`) payloads for client +/// identity hints (shared by unencrypted frames and CCMP-decrypted frames). +/// pub fn payload_info_from_llc(payload: &[u8]) -> PayloadInfo { let mut info = PayloadInfo::default(); let Some(llc) = payload.get(..8) else { @@ -1062,7 +1062,7 @@ fn manufacturer_from_text(text: &str) -> Option { Some(name.to_string()) } -/// Mantido para chamadores antigos: há um EAPOL neste quadro? +/// Returns `true` if this frame carries an EAPOL payload. pub fn is_eapol(body: &[u8]) -> bool { parse(body).and_then(|f| f.eapol().map(|_| ())).is_some() } @@ -1102,8 +1102,8 @@ fn add_phy(phy: &mut Vec, value: &str) { } } -/// Information Element de WPS (vendor 00:50:f2 tipo 4). O conteúdo começa -/// com a versão do protocolo e segue em atributos TLV de 16 bits. +/// Parses a Microsoft WPS vendor-specific IE (`00:50:f2:04`, big-endian 16-bit TLVs). +/// fn parse_wps(val: &[u8], info: &mut ManagementInfo) { if val.len() < 6 { return; @@ -1366,7 +1366,7 @@ mod tests { f.extend_from_slice(&0x0108u16.to_le_bytes()); // data, ToDS f.extend_from_slice(&0u16.to_le_bytes()); f.extend_from_slice(&mac(0x11)); // addr1 = AP - f.extend_from_slice(&mac(0x22)); // addr2 = cliente + f.extend_from_slice(&mac(0x22)); // addr2 = client f.extend_from_slice(&mac(0x11)); // addr3 = AP f.extend_from_slice(&[0u8; 2]); f.extend_from_slice(&[0xAA, 0xAA, 0x03, 0, 0, 0]); diff --git a/crates/sabia-core/src/eapol.rs b/crates/sabia-core/src/eapol.rs index 6b4afcd..8f966d3 100644 --- a/crates/sabia-core/src/eapol.rs +++ b/crates/sabia-core/src/eapol.rs @@ -1,8 +1,8 @@ -//! Parser do handshake WPA/WPA2 (EAPOL-Key, IEEE 802.1X). +//! WPA/WPA2 EAPOL-Key (IEEE 802.1X) 4-way handshake parser. +//! +//! Identifies messages 1–4 of the 4-way handshake and extracts nonces, MIC, +//! key descriptor version, and PMKID for [`crate::crack`]. //! -//! Lê as quatro mensagens do 4-way handshake, identifica o número de cada -//! uma, extrai nonces, MIC, versão de chave e PMKID — matéria-prima para -//! montar um alvo de quebra por dicionário em [`crate::crack`]. pub const EAPOL_TYPE_KEY: u8 = 3; @@ -12,8 +12,8 @@ const KEY_INFO_ACK: u16 = 0x0080; const KEY_INFO_MIC: u16 = 0x0100; const KEY_INFO_SECURE: u16 = 0x0200; -/// Deslocamento absoluto do campo MIC dentro do quadro EAPOL (já incluindo -/// o cabeçalho de 4 bytes). +/// Byte offset of the 16-byte MIC field within the full EAPOL frame (including +/// the 4-byte 802.1X header). pub const MIC_OFFSET: usize = 81; pub const KEY_DATA_OFFSET: usize = 99; pub const MIN_KEY_FRAME_LEN: usize = 99; @@ -22,10 +22,10 @@ pub const MIN_KEY_FRAME_LEN: usize = 99; pub struct EapolKey { pub version: u8, pub descriptor_type: u8, - /// 1, 2, 3 ou 4; 0 quando não é uma das mensagens do 4-way handshake. + /// Handshake message number (`1..=4`, or `0` if not a 4-way handshake frame). pub message: u8, pub key_info: u16, - /// Versão do descritor: 1 = TKIP, 2 = CCMP, 3 = AES-CMAC. + /// Key descriptor version: `1` = HMAC-MD5/RC4 (TKIP), `2` = HMAC-SHA1/AES (CCMP), `3` = AES-128-CMAC. pub key_version: u8, pub pairwise: bool, pub replay_counter: u64, @@ -33,8 +33,8 @@ pub struct EapolKey { pub mic: [u8; 16], pub key_data: Vec, pub pmkid: Option<[u8; 16]>, - /// Quadro EAPOL completo (cabeçalho + descritor + dados), com o MIC no - /// lugar — usado para recalcular o MIC durante a quebra. + /// Full raw EAPOL frame (header + key descriptor + key data), used to + /// zero the MIC field and recompute it during password verification. pub frame: Vec, } @@ -51,17 +51,17 @@ pub fn parse(buf: &[u8]) -> Option { let frame = buf[..total].to_vec(); let descriptor_type = frame[4]; - // 2 = RSN (WPA2/WPA3), 254 = WPA1. Outros descritores não interessam. + // 2 = RSN (WPA2/WPA3), 254 = legacy WPA1. if descriptor_type != 2 && descriptor_type != 254 { return None; } - // Todo campo multi-byte do descritor EAPOL-Key (Key Information, Key - // Length, Replay Counter, Key Data Length) é big-endian — é 802.1X, - // segue a mesma ordem de rede do cabeçalho EAPOL em si. Ler como - // little-endian (bug anterior) embaralhava os bits de ack/mic/secure e - // fazia praticamente todo handshake real cair em message=0, descartado - // silenciosamente — só não aparecia nos testes porque o gerador de - // quadro sintético cometia o mesmo engano, simetricamente. + // All multi-byte fields in the EAPOL-Key descriptor (Key Information, Key + // Length, Replay Counter, Key Data Length) are network byte order (big-endian). + // + // + // + // + // let key_info = u16::from_be_bytes([frame[5], frame[6]]); let key_version = (key_info & 0x0007) as u8; let pairwise = key_info & KEY_INFO_PAIRWISE != 0; @@ -111,8 +111,8 @@ pub fn parse(buf: &[u8]) -> Option { }) } -/// PMKID KDE: `dd 14 00 0f ac 04` + 16 bytes. Pode vir em qualquer posição -/// dos dados da chave (normalmente na mensagem 1). +/// Extracts the 16-byte PMKID from an RSN PMKID KDE (`dd 14 00 0f ac 04 + 16 bytes`) +/// inside EAPOL-Key data (typically present in message 1). pub fn parse_pmkid_kde(data: &[u8]) -> Option<[u8; 16]> { let mut i = 0usize; while i + 2 <= data.len() { @@ -183,24 +183,24 @@ mod tests { } } - /// Byte a byte, independente do helper `key_frame` (que usa a mesma - /// codificação do parser): confere que um quadro Mensagem 1 real — key - /// info 0x008a (Ack=1, versão CCMP=2, pairwise), big-endian como é no - /// fio de verdade — é lido como message=1/key_version=2, não message=0. - /// É exatamente o valor citado pela documentação de ferramentas de - /// quebra de WPA pra mensagem 1 com CCMP. + /// Verifies against a raw wire-format EAPOL Message 1 frame (`key_info = 0x008a` + /// big-endian: Ack=1, Pairwise=1, Version=2 CCMP). + /// + /// + /// + /// #[test] fn parses_real_wire_message1_key_info_as_big_endian() { let mut frame = vec![0u8; MIN_KEY_FRAME_LEN]; - frame[0] = 2; // versão EAPOL + frame[0] = 2; // EAPOL version frame[1] = EAPOL_TYPE_KEY; frame[2..4].copy_from_slice(&((MIN_KEY_FRAME_LEN - 4) as u16).to_be_bytes()); - frame[4] = 2; // descritor RSN - frame[5..7].copy_from_slice(&0x008au16.to_be_bytes()); // Ack=1, versão=2(CCMP), pairwise=1 + frame[4] = 2; // RSN key descriptor + frame[5..7].copy_from_slice(&0x008au16.to_be_bytes()); // Ack=1, version=2 (CCMP), pairwise=1 frame[7..9].copy_from_slice(&16u16.to_be_bytes()); // key length frame[9..17].copy_from_slice(&1u64.to_be_bytes()); // replay counter frame[17..49].fill(0xaa); // nonce - frame[97..99].copy_from_slice(&0u16.to_be_bytes()); // sem key data + frame[97..99].copy_from_slice(&0u16.to_be_bytes()); // key data length = 0 let parsed = parse(&frame).expect("mensagem 1 real deveria parsear"); assert_eq!(parsed.message, 1); diff --git a/crates/sabia-core/src/eviltwin_enterprise.rs b/crates/sabia-core/src/eviltwin_enterprise.rs index 9ef52ae..ab32f25 100644 --- a/crates/sabia-core/src/eviltwin_enterprise.rs +++ b/crates/sabia-core/src/eviltwin_enterprise.rs @@ -1,36 +1,36 @@ -//! Evil Twin pra redes WPA2/WPA3-Enterprise (802.1X — usuário+senha via -//! RADIUS, não uma senha única como o PSK comum): clona o AP numa segunda -//! interface anunciando o mesmo SSID e aceita o handshake EAP (PEAP/EAP-TTLS -//! com MSCHAPv2) de qualquer cliente que tente autenticar nele. Diferente do -//! Evil Twin de PSK (`eviltwin.rs`), aqui o dispositivo manda a credencial -//! **automaticamente**, como parte do próprio protocolo 802.1X — não precisa -//! de portal cativo nem de nenhuma ação humana, porque o cliente já tenta -//! autenticar sozinho em qualquer AP que anuncie o SSID que ele reconhece. +//! WPA2/WPA3-Enterprise (802.1X) Evil Twin: clones an Enterprise AP on a +//! secondary interface using `hostapd-wpe` to accept EAP (PEAP/EAP-TTLS with +//! inner MSCHAPv2) authentication attempts and log NetNTLMv1 challenge/response +//! hashes for offline cracking with `john` or `hashcat -m 5500`. +//! +//! +//! +//! +//! +//! Note: because the server certificate is self-signed, clients configured to +//! strictly validate the RADIUS CA certificate chain will reject the connection; +//! clients without strict CA pinning will complete the inner MSCHAPv2 exchange. +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! +//! //! -//! Isso só existe pra Enterprise porque só nela a senha (ou algo derivado -//! dela) de fato viaja pela rede como parte da autenticação — no PSK a -//! senha nunca é transmitida (só confere um MIC), por isso o Evil Twin de -//! PSK precisa enganar um humano a digitá-la de novo. //! -//! Orquestra o `hostapd-wpe` (não vem com o Sabiá — precisa estar instalado -//! separadamente: não é um binário pequeno vendorizável como o reaver, é um -//! patch sobre a árvore de build do próprio hostapd). O Sabiá só gera a -//! config, o `eap_user` genérico e o certificado autoassinado, e depois lê o -//! log de credenciais capturadas. //! -//! Limitação honesta: o certificado é autoassinado. Clientes que validam a -//! cadeia de certificado da rede (comum em dispositivos atualizados, -//! principalmente com perfil MDM corporativo) vão recusar a conexão ou -//! avisar o usuário — exatamente a defesa que deveria existir contra esse -//! ataque. Funciona de verdade contra dispositivos configurados sem essa -//! validação (ainda comum em redes legadas/mal configuradas), que é o caso -//! que esse ataque sempre existiu para expor. use std::fmt::Write as _; -/// Config do hostapd-wpe pro AP clone: mesmo SSID/canal do AP real, -/// anunciando WPA2-Enterprise e aceitando qualquer EAP (PEAP/TTLS) — é o -/// `eap_user_file` genérico que faz ele aceitar qualquer identidade. +/// Generates the `hostapd-wpe` configuration for the Enterprise clone AP. +/// +/// #[allow(clippy::too_many_arguments)] pub fn hostapd_wpe_conf( iface: &str, @@ -64,20 +64,20 @@ pub fn hostapd_wpe_conf( out } -/// `eap_user` genérico: aceita qualquer identidade externa pra PEAP/TTLS e -/// força MSCHAPv2 por dentro — é o que faz o hostapd-wpe logar usuário + -/// challenge/response de qualquer cliente que tentar autenticar, sem -/// precisar saber credenciais reais de antemão. +/// Wildcard `eap_user` table: accepts any outer identity for PEAP/TTLS and +/// negotiates inner MSCHAPv2 so `hostapd-wpe` logs the challenge/response pair. +/// +/// pub const DEFAULT_EAP_USER_FILE: &str = concat!( "# gerado pelo Sabiá — aceita qualquer identidade, qualquer EAP externo\n", "* PEAP,TTLS\n", "\"anonymous\" TTLS-MSCHAPV2,MSCHAPV2 \"senha\" [2]\n", ); -/// Argumentos do `openssl req` pra gerar o par certificado+chave -/// autoassinado que o hostapd-wpe precisa pra abrir TLS (PEAP/TTLS sempre -/// envolve um túnel TLS, mesmo sendo um AP falso). Efêmero: 1 dia de validade -/// é de sobra pra uma sessão de teste. +/// Builds `openssl req` arguments to generate a 1-day ephemeral self-signed +/// RSA certificate and private key for the outer EAP-TLS/PEAP/TTLS tunnel. +/// +/// pub fn openssl_selfsigned_cert_args(key_path: &str, cert_path: &str) -> Vec { [ "req", @@ -103,23 +103,23 @@ fn sanitize_conf_value(value: &str) -> String { value.replace(['\n', '\r'], "") } -/// Uma credencial capturada: identidade + a linha pronta pra crackear -/// (formato `netntlm` do John the Ripper / compatível com `hashcat -m 5500`), -/// se o hostapd-wpe conseguiu capturar os dois. +/// A captured Enterprise credential: username plus NetNTLMv1 hash string +/// (compatible with `john --format=netntlm` and `hashcat -m 5500`). +/// #[derive(Debug, Clone, PartialEq, Eq)] pub struct CapturedCredential { pub username: String, pub netntlm: Option, } -/// Lê o `wpe_logfile` e extrai as credenciais capturadas. Parser -/// best-effort: o formato do hostapd-wpe é documentado (blocos -/// `username:`/`challenge:`/`response:`/`jtr NETNTLM:`), mas não dá pra -/// validar contra uma instalação de verdade nesta máquina (não é um -/// binário que o Sabiá vendoriza). Pega a linha `jtr NETNTLM:` quando -/// presente — já vem pronta pra `john --format=netntlm` ou pra reformatar -/// pro `hashcat -m 5500` — e nunca falha "silenciosamente demais": o job -/// sempre expõe o arquivo de log cru também, pra conferência manual. +/// Parses `hostapd-wpe` log output and extracts captured MSCHAPv2 / NetNTLMv1 credentials. +/// +/// +/// +/// +/// +/// +/// pub fn captured_identities(log: &str) -> Vec { let mut out: Vec = Vec::new(); let mut current_username: Option = None; diff --git a/crates/sabia-core/src/handshake.rs b/crates/sabia-core/src/handshake.rs index 4971f9a..2c17e41 100644 --- a/crates/sabia-core/src/handshake.rs +++ b/crates/sabia-core/src/handshake.rs @@ -1,9 +1,9 @@ -//! Montagem dos handshakes WPA/WPA2 a partir dos quadros EAPOL vistos no ar. +//! Assembles WPA/WPA2 4-way handshakes and PMKIDs from captured EAPOL frames. +//! +//! Keyed by `(BSSID, station)`. Tracks messages 1–4, PMKID from message 1, +//! and the raw radiotap+802.11 frames required to export `.pcap` or `.hc22000`. +//! //! -//! Um handshake é identificado pelo par (BSSID, estação). Guardamos as quatro -//! mensagens quando aparecem, o PMKID da mensagem 1 e os quadros crus -//! necessários para exportar um `.pcap` que o `aircrack-ng`/hashcat também -//! entendam. use crate::dot11; use crate::eapol::{self, EapolKey}; @@ -24,20 +24,20 @@ pub struct Handshake { pub snonce: Option<[u8; 32]>, pub pmkid: Option<[u8; 16]>, pub key_version: u8, - /// Bitmask das mensagens vistas (bit 0 = mensagem 1). + /// Bitmask of observed handshake messages (bit 0 = message 1). pub messages: u8, - /// Quadro EAPOL da mensagem 2, com o MIC — necessário para conferir a - /// senha candidata. + /// Full EAPOL frame from message 2 (containing the MIC) used to verify + /// candidate passphrases. pub eapol_msg2: Option>, pub raw: Vec, pub first_seen_unix: u64, pub last_seen_unix: u64, pub password: Option, - /// Temporal Key (CCMP/AES) derivada quando `password` é preenchido. - /// Só pra TKIP (key_version 1) fica None: a decriptação aqui só cobre - /// CCMP. Serve pra enxergar o tráfego em claro desse par (BSSID, - /// estação) específico — não abre o tráfego de outros clientes, já que - /// a PTK é efêmera por associação. + /// 16-byte CCMP Temporal Key (TK) derived once `password` is known. + /// `None` for TKIP (`key_version == 1`) or PMKID-only handshakes. Scoped + /// to this specific `(BSSID, station)` association. + /// + /// pub tk: Option<[u8; 16]>, } @@ -111,7 +111,7 @@ impl Handshake { } } - /// Quadros crus (radiotap + 802.11) das mensagens vistas, em ordem. + /// Raw radiotap + 802.11 frames of observed messages, in arrival order. pub fn raw_frames(&self) -> Vec> { let mut v = self.raw.clone(); v.sort_by_key(|f| f.message); @@ -140,8 +140,8 @@ impl HandshakeTracker { Self::default() } - /// Processa um quadro 802.11. Retorna `true` quando o quadro era EAPOL e - /// atualizou (ou criou) um handshake. + /// Processes a parsed 802.11 frame. Returns `true` if it contained an EAPOL-Key + /// message that created or updated a handshake entry. pub fn ingest( &mut self, frame: &dot11::Frame<'_>, @@ -159,8 +159,8 @@ impl HandshakeTracker { return false; } - // Em data frames com DS, o BSSID está em `frame.bssid`; a estação é - // quem recebe (1/3, AP -> STA) ou quem transmite (2/4, STA -> AP). + // In DS data frames, `frame.bssid` holds the AP MAC; the station is + // `destination` on downlink (M1/M3) or `transmitter` on uplink (M2/M4). let bssid = frame.bssid_str(); let station = match key.message { 1 | 3 => frame.addr1.map(|m| dot11::mac_to_string(&m)), @@ -198,8 +198,8 @@ impl HandshakeTracker { true } - /// Atualiza o SSID de handshakes já vistos (quando o beacon chega depois - /// do EAPOL, o que acontece em capturas começando no meio do handshake). + /// Backfills the SSID on existing handshakes when a beacon arrives after + /// the EAPOL exchange. pub fn update_ssid(&mut self, bssid: &str, ssid: &str) { if ssid.is_empty() { return; @@ -233,8 +233,8 @@ impl HandshakeTracker { self.all().into_iter().map(Handshake::info).collect() } - /// Primeiro handshake que pode ser quebrado; filtros opcionais por BSSID - /// e estação. + /// Returns the first crackable handshake matching the optional `bssid` and + /// `station` filters. pub fn first_crackable( &self, bssid: Option<&str>, @@ -254,9 +254,9 @@ impl HandshakeTracker { } } - /// TK já derivada pra esse par (BSSID, estação), se a senha da rede já - /// foi quebrada e o handshake tinha os dois nonces (EAPOL completo — um - /// handshake só de PMKID não dá pra derivar PTK/TK, só confirma a PSK). + /// Returns the derived 16-byte CCMP Temporal Key (TK) for `(bssid, station)` + /// if the network password has been cracked and both nonces were captured. + /// pub fn tk_for(&self, bssid: &str, station: &str) -> Option<[u8; 16]> { self.get(bssid, station).and_then(|hs| hs.tk) } @@ -270,10 +270,10 @@ impl HandshakeTracker { } } -/// Deriva a TK (bytes 32..48 da PTK) a partir da senha quebrada, pra poder -/// decifrar CCMP do tráfego desse cliente. Só funciona com EAPOL/CCMP -/// completo (key_version 2); PMKID sozinho não tem os dois nonces pra -/// derivar PTK, e TKIP (key_version 1) usa RC4 + Michael, não implementado. +/// Derives the 16-byte CCMP Temporal Key (`PTK[32..48]`) from a cracked passphrase. +/// Requires a full EAPOL CCMP handshake (`key_version == 2`) with both nonces. +/// +/// fn derive_tk(hs: &Handshake, password: &str) -> Option<[u8; 16]> { if hs.key_version != 2 { return None; diff --git a/crates/sabia-core/src/hashcat.rs b/crates/sabia-core/src/hashcat.rs index 033d063..5ea4681 100644 --- a/crates/sabia-core/src/hashcat.rs +++ b/crates/sabia-core/src/hashcat.rs @@ -1,12 +1,12 @@ -//! Exportação no formato `.hc22000` do hashcat. +//! Exports captured WPA/WPA2 material in hashcat `.hc22000` format (`-m 22000`). //! -//! Duas linhas possíveis, como no hcxtools: +//! Emits both record types supported by `hcxtools` / `hashcat`: //! //! * `WPA*01*PMKID*AP*STA*SSID***` //! * `WPA*02*MIC*AP*STA*SSID*ANONCE*EAPOL*MESSAGEPAIR` //! -//! O EAPOL exportado é o quadro da mensagem 2 completo (cabeçalho 802.1X + -//! descritor), que é o que o hashcat espera no campo `EAPOL_CLIENT`. +//! The exported EAPOL field contains the full message 2 frame (802.1X header + +//! key descriptor) expected by hashcat's `EAPOL_CLIENT` field. use crate::eapol::MIC_OFFSET; use crate::handshake::Handshake; @@ -55,7 +55,7 @@ pub fn hashcat_line(hs: &Handshake) -> Option { )) } -/// Linhas únicas, uma por handshake que tenha material suficiente. +/// Formats deduplicated `.hc22000` lines for all crackable handshakes. pub fn export<'a>(handshakes: impl IntoIterator) -> Vec { let mut lines: Vec = handshakes.into_iter().filter_map(hashcat_line).collect(); lines.sort(); diff --git a/crates/sabia-core/src/iface.rs b/crates/sabia-core/src/iface.rs index e6ccc74..56f2240 100644 --- a/crates/sabia-core/src/iface.rs +++ b/crates/sabia-core/src/iface.rs @@ -1,7 +1,7 @@ -//! Descoberta de interfaces de rede e identificação das que são Wi-Fi. +//! Network interface enumeration and 802.11 wireless phy identification. //! -//! Não depende de `iw`: lê direto de `/sys/class/net`, do mesmo jeito que o -//! próprio `iw`/`ip` fazem por baixo. +//! Reads directly from `/sys/class/net` on Linux, falling back to `iw` for +//! detailed interface mode inspection when available. use crate::error::{Result, SabiaError}; use std::fs; @@ -11,7 +11,7 @@ use std::path::{Path, PathBuf}; pub struct Interface { pub name: String, pub mac: String, - /// Caminho do phy (ex.: `phy0`), presente só em interfaces 802.11. + /// Wireless `wiphy` name (e.g. `"phy0"`), present only on 802.11 interfaces. pub phy: Option, pub mode: Mode, pub up: bool, @@ -28,7 +28,7 @@ fn net_root() -> &'static Path { Path::new("/sys/class/net") } -/// Lista todas as interfaces de rede, Wi-Fi ou não (chamador filtra por `phy`). +/// Lists all network interfaces on the system (wireless interfaces have `phy.is_some()`). pub fn list_all() -> Result> { let mut out = Vec::new(); for entry in fs::read_dir(net_root())? { @@ -43,7 +43,7 @@ pub fn list_all() -> Result> { Ok(out) } -/// Lista apenas interfaces com um `phy80211` (ou seja, rádios Wi-Fi). +/// Lists only 802.11 wireless interfaces (`phy80211` present). pub fn list_wireless() -> Result> { Ok(list_all()? .into_iter() @@ -79,8 +79,8 @@ fn read_interface(name: &str) -> Result { .ok() .and_then(|p| p.file_name().map(|f| f.to_string_lossy().into_owned())) .or_else(|| { - // em alguns kernels phy80211 é um diretório real, não symlink; - // o nome do phy então vem do subdiretório "phyNN" dentro dele. + // On some kernels `phy80211` is a directory rather than a symlink; + // read the `"phyNN"` subdirectory inside it. fs::read_dir(&phy80211) .ok() .and_then(|mut rd| rd.next()) @@ -106,10 +106,10 @@ fn read_interface(name: &str) -> Result { }) } -/// Lê o modo (`managed`/`monitor`) via `/sys/class/net//type` + `uevent` -/// não é suficiente (type é sempre ARPHRD_ETHER para Wi-Fi); a forma estável -/// sem libnl é delegar a leitura real ao `iw dev info` quando disponível, -/// mantendo aqui um melhor-esforço local para não depender de root. +/// Reads the interface operating mode (`managed`/`monitor`) via `/sys/class/net//type` +/// and `iw dev info` when available. +/// +/// fn read_wireless_mode(name: &str) -> Mode { match crate::netlink::iw_dev_type(name) { Some(Mode::Monitor) => Mode::Monitor, diff --git a/crates/sabia-core/src/inject.rs b/crates/sabia-core/src/inject.rs index 7384423..8aee6f2 100644 --- a/crates/sabia-core/src/inject.rs +++ b/crates/sabia-core/src/inject.rs @@ -1,17 +1,17 @@ -//! Construção e envio de quadros de gerenciamento (desautenticação e -//! desassociação) para testes autorizados de robustez de redes Wi-Fi. +//! Construction and raw socket injection of 802.11 management frames +//! (deauthentication, disassociation, open auth, association) for authorized testing. +//! +//! Frame builders are platform-independent; raw transmission uses Linux `AF_PACKET`. +//! Rate limits, counts, and audit logging are enforced by [`crate::engine::Engine`]. //! -//! A construção do quadro é independente de plataforma e testável; o envio -//! usa `AF_PACKET` e só existe no Linux. O chamador (engine) impõe limites de -//! quantidade e intervalo e registra a ação na trilha de auditoria. #[cfg(target_os = "linux")] use crate::error::{Result, SabiaError}; pub const BROADCAST: [u8; 6] = [0xff; 6]; -/// Cabeçalho radiotap mínimo (8 bytes, sem campos). É aceito por mac80211 e -/// deixa o driver escolher a taxa; suficiente para quadros de gerenciamento. +/// Minimal 8-byte Radiotap header (no present fields), accepted by `mac80211` +/// for management frame injection. fn radiotap_min() -> [u8; 8] { [0, 0, 8, 0, 0, 0, 0, 0] } @@ -20,9 +20,9 @@ fn mgmt_frame(subtype: u8, bssid: [u8; 6], destination: [u8; 6], reason: u16) -> mgmt_frame_from(subtype, bssid, bssid, destination, reason) } -/// Quadro de gerenciamento com transmissor explícito. Permite forjar tanto -/// AP→STA (`transmitter = BSSID`) quanto STA→AP (`transmitter = estação`), -/// usado no teste bidirecional de PMF. +/// Builds a deauth/disassoc management frame with an explicit transmitter MAC, +/// supporting both AP->STA (`transmitter = BSSID`) and STA->AP (`transmitter = client`). +/// fn mgmt_frame_from( subtype: u8, transmitter: [u8; 6], @@ -35,21 +35,21 @@ fn mgmt_frame_from( f.extend_from_slice(&radiotap_min()); f.extend_from_slice(&fc.to_le_bytes()); f.extend_from_slice(&0u16.to_le_bytes()); - f.extend_from_slice(&destination); // addr1: destino - f.extend_from_slice(&transmitter); // addr2: transmissor + f.extend_from_slice(&destination); // addr1: destination + f.extend_from_slice(&transmitter); // addr2: transmitter f.extend_from_slice(&bssid); // addr3: BSSID - f.extend_from_slice(&[0u8; 2]); // número de sequência + f.extend_from_slice(&[0u8; 2]); // sequence control f.extend_from_slice(&reason.to_le_bytes()); f } -/// Quadro de desautenticação (subtipo 12). `destination` pode ser uma -/// estação específica ou [`BROADCAST`]. +/// Builds an AP->STA deauthentication frame (subtype 12). `destination` may be +/// a unicast client MAC or [`BROADCAST`]. pub fn deauth_frame(bssid: [u8; 6], destination: [u8; 6], reason: u16) -> Vec { mgmt_frame(12, bssid, destination, reason) } -/// Desautenticação forjada com transmissor arbitrário (STA→AP). +/// Builds a deauthentication frame with an explicit transmitter MAC. pub fn deauth_frame_from( transmitter: [u8; 6], bssid: [u8; 6], @@ -59,7 +59,7 @@ pub fn deauth_frame_from( mgmt_frame_from(12, transmitter, bssid, destination, reason) } -/// Desassociação forjada com transmissor arbitrário (STA→AP). +/// Builds a disassociation frame with an explicit transmitter MAC. pub fn disassoc_frame_from( transmitter: [u8; 6], bssid: [u8; 6], @@ -69,19 +69,19 @@ pub fn disassoc_frame_from( mgmt_frame_from(10, transmitter, bssid, destination, reason) } -/// Quadro de desassociação (subtipo 10). +/// Builds an AP->STA disassociation frame (subtype 10). pub fn disassoc_frame(bssid: [u8; 6], destination: [u8; 6], reason: u16) -> Vec { mgmt_frame(10, bssid, destination, reason) } -/// Offset do campo "Sequence Control" dentro de um quadro de gerenciamento -/// construído por [`mgmt_frame_from`] (8 de radiotap + 22 até o campo). +/// Byte offset of the 802.11 Sequence Control field inside a frame built by +/// [`mgmt_frame_from`] (8-byte Radiotap + 22-byte MAC header offset). const SEQ_CTRL_OFFSET: usize = 30; -/// Grava um número de sequência (0–4095) no quadro, deixando o número de -/// fragmento (4 bits baixos) zerado. Sem isso, todo quadro injetado sai com -/// sequência 0 e o driver do alvo descarta as repetições como duplicata, -/// fazendo a desautenticação não ter efeito nenhum apesar de "enviada". +/// Writes a 12-bit 802.11 sequence number (`0..4096`) into `frame` (with fragment +/// number `0`) so target drivers do not drop repeated frames as duplicates. +/// +/// pub fn set_seq(frame: &mut [u8], seq: u16) { if frame.len() < SEQ_CTRL_OFFSET + 2 { return; @@ -99,8 +99,8 @@ fn ie(tag: u8, value: &[u8]) -> Vec { out } -/// Autenticação aberta (algoritmo 0, sequência 1). Usada antes do association -/// request no teste de PMKID. +/// Builds an Open System Authentication request frame (algorithm 0, seq 1) used +/// prior to Association Request when probing for clientless PMKID. pub fn auth_frame(bssid: [u8; 6], station: [u8; 6]) -> Vec { let mut f = Vec::with_capacity(8 + 24 + 6); f.extend_from_slice(&radiotap_min()); @@ -110,8 +110,8 @@ pub fn auth_frame(bssid: [u8; 6], station: [u8; 6]) -> Vec { f.extend_from_slice(&station); f.extend_from_slice(&bssid); f.extend_from_slice(&[0u8; 2]); - f.extend_from_slice(&0u16.to_le_bytes()); // algoritmo aberto - f.extend_from_slice(&1u16.to_le_bytes()); // sequência + f.extend_from_slice(&0u16.to_le_bytes()); // Open System algorithm (0) + f.extend_from_slice(&1u16.to_le_bytes()); // auth sequence (1) f.extend_from_slice(&0u16.to_le_bytes()); // status f } @@ -119,7 +119,7 @@ pub fn auth_frame(bssid: [u8; 6], station: [u8; 6]) -> Vec { fn rsn_ie(akms: &[u8], pmf: bool, pmf_required: bool, pmkid: &[u8; 16]) -> Vec { let mut val = Vec::new(); val.extend_from_slice(&1u16.to_le_bytes()); - val.extend_from_slice(&[0x00, 0x0f, 0xac, 4]); // grupo CCMP + val.extend_from_slice(&[0x00, 0x0f, 0xac, 4]); // group cipher suite: CCMP val.extend_from_slice(&1u16.to_le_bytes()); val.extend_from_slice(&[0x00, 0x0f, 0xac, 4]); // pairwise CCMP val.extend_from_slice(&(akms.len() as u16).to_le_bytes()); @@ -139,8 +139,8 @@ fn rsn_ie(akms: &[u8], pmf: bool, pmf_required: bool, pmkid: &[u8; 16]) -> Vec Result { }) } -/// Lista canais suportados pelo phy da interface. +/// Lists all 802.11 channels supported by the interface's `wiphy`. pub fn channels(iface: &str) -> Result> { let phy = phy_for_iface(iface)?; let info = run("iw", &["phy", &phy, "info"])?; @@ -113,7 +113,7 @@ pub fn supported_channels(iface: &str) -> Result> { .collect()) } -/// Canais a percorrer. `band` aceita `"2.4"`, `"5"`, `"6"`, `"all"`/`None`. +/// Filters supported channels by band (`"2.4"`, `"5"`, `"6"`, or `"all"`/`None`). pub fn channel_plan(iface: &str, band: Option<&str>) -> Result> { let want = match band.map(|b| b.trim().to_ascii_lowercase()) { None => None, @@ -159,7 +159,7 @@ pub fn driver_name(iface: &str) -> Option { .and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned())) } -/// Põe a interface em modo monitor. Requer `CAP_NET_ADMIN` (normalmente root). +/// Toggles monitor or managed mode on `iface` (requires `CAP_NET_ADMIN` / root). pub fn set_monitor_mode(iface: &str, enable: bool) -> Result<()> { run("ip", &["link", "set", iface, "down"])?; let target = if enable { "monitor" } else { "managed" }; @@ -168,7 +168,7 @@ pub fn set_monitor_mode(iface: &str, enable: bool) -> Result<()> { Ok(()) } -/// Troca o canal atual da interface (precisa já estar em modo monitor). +/// Switches `iface` to `channel` (the interface must already be in monitor mode). pub fn set_channel(iface: &str, channel: u32) -> Result<()> { run( "iw", diff --git a/crates/sabia-core/src/oui.rs b/crates/sabia-core/src/oui.rs index 0deffb1..142622d 100644 --- a/crates/sabia-core/src/oui.rs +++ b/crates/sabia-core/src/oui.rs @@ -1,4 +1,4 @@ -//! Identificação de fabricante pelo prefixo OUI do MAC. +//! IEEE OUI vendor lookup by 3-byte MAC prefix. const OUIS: &[(&str, &str)] = &[ ("00000c", "Cisco"), diff --git a/crates/sabia-core/src/pcapreader.rs b/crates/sabia-core/src/pcapreader.rs index 79abe74..9f9e86f 100644 --- a/crates/sabia-core/src/pcapreader.rs +++ b/crates/sabia-core/src/pcapreader.rs @@ -1,6 +1,6 @@ -//! Leitor do formato `.pcap` clássico, usado para analisar capturas feitas -//! pelo próprio Sabiá (link-type 127, radiotap) e pelo aircrack-ng/tcpdump -//! (link-type 105, 802.11 cru). +//! Classic libpcap (`.pcap`) reader supporting both `LINKTYPE_IEEE802_11_RADIOTAP` (127) +//! and raw `LINKTYPE_IEEE802_11` (105) captures. +//! use crate::error::{Result, SabiaError}; use std::fs::File; @@ -75,7 +75,7 @@ impl PcapReader { self.linktype } - /// Próximo quadro; `None` no fim do arquivo. + /// Reads the next frame, returning `Ok(None)` at EOF. pub fn next_frame(&mut self) -> Result> { let mut rec = [0u8; 16]; match self.input.read_exact(&mut rec) { @@ -102,8 +102,8 @@ impl PcapReader { .read_exact(&mut data) .map_err(|e| SabiaError::InvalidPcap(format!("quadro truncado: {e}")))?; - // Capturas sem radiotap (link-type 105) recebem um cabeçalho mínimo - // para o mesmo pipeline de parsing funcionar. + // Prepend a minimal 8-byte Radiotap header to link-type 105 (raw 802.11) + // frames so the downstream parser receives a uniform format. if self.linktype == LINKTYPE_IEEE802_11 { let mut with_radiotap = vec![0u8, 0, 8, 0, 0, 0, 0, 0]; with_radiotap.extend_from_slice(&data); diff --git a/crates/sabia-core/src/pcapwriter.rs b/crates/sabia-core/src/pcapwriter.rs index 2d8c54d..d0d7d80 100644 --- a/crates/sabia-core/src/pcapwriter.rs +++ b/crates/sabia-core/src/pcapwriter.rs @@ -1,6 +1,6 @@ -//! Escritor de arquivos `.pcap` (formato clássico, link-type 127 = -//! IEEE802_11_RADIOTAP) para exportar capturas — compatível com -//! Wireshark, aircrack-ng e qualquer outra ferramenta do ecossistema. +//! Classic libpcap (`.pcap`) writer (`LINKTYPE_IEEE802_11_RADIOTAP` = 127), +//! compatible with Wireshark, `aircrack-ng`, and `hcxpcapngtool`. +//! use crate::error::Result; use std::fs::File; @@ -18,7 +18,7 @@ impl PcapWriter { pub fn create(path: impl AsRef) -> Result { let file = File::create(path)?; let mut out = BufWriter::new(file); - // Cabeçalho global do pcap clássico. + // 24-byte classic libpcap global header. out.write_all(&0xa1b2c3d4u32.to_le_bytes())?; // magic out.write_all(&2u16.to_le_bytes())?; // version major out.write_all(&4u16.to_le_bytes())?; // version minor @@ -29,7 +29,7 @@ impl PcapWriter { Ok(Self { out }) } - /// Grava um quadro crú (radiotap + 802.11), com timestamp de agora. + /// Writes a raw Radiotap + 802.11 frame stamped with the current system time. pub fn write_frame(&mut self, raw: &[u8]) -> Result<()> { let now = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -37,7 +37,7 @@ impl PcapWriter { self.out.write_all(&(now.as_secs() as u32).to_le_bytes())?; self.out.write_all(&(now.subsec_micros()).to_le_bytes())?; self.out.write_all(&(raw.len() as u32).to_le_bytes())?; // caplen - self.out.write_all(&(raw.len() as u32).to_le_bytes())?; // len original + self.out.write_all(&(raw.len() as u32).to_le_bytes())?; // original wire length self.out.write_all(raw)?; Ok(()) } diff --git a/crates/sabia-core/src/radiotap.rs b/crates/sabia-core/src/radiotap.rs index ec8fbb3..24528bc 100644 --- a/crates/sabia-core/src/radiotap.rs +++ b/crates/sabia-core/src/radiotap.rs @@ -1,9 +1,9 @@ -//! Parser de cabeçalho Radiotap. +//! IEEE 802.11 Radiotap header parser (). +//! +//! Walks present flags in specification order with proper field alignment to +//! extract channel/frequency and antenna signal strength (dBm). +//! //! -//! Só extraímos o que o restante do núcleo usa — frequência/canal e força do -//! sinal — mas avançamos pelos campos na ordem da especificação, respeitando -//! alinhamento, para não depender do formato exato que cada driver emite. -//! Especificação: #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] pub enum Band { @@ -36,7 +36,7 @@ pub struct RadiotapInfo { pub signal_dbm: Option, pub noise_dbm: Option, pub header_len: usize, - /// Bitmap "present" da primeira palavra (campos padrão). + /// Standard Radiotap `it_present` bit positions. pub present: u32, } @@ -48,20 +48,20 @@ const BIT_FHSS: u32 = 1 << 4; const BIT_ANTSIGNAL: u32 = 1 << 5; const BIT_ANTNOISE: u32 = 1 << 6; -/// Retorna as infos extraídas e quantos bytes o cabeçalho ocupa (para o -/// chamador saber onde o frame 802.11 propriamente começa). +/// Parses the Radiotap header at the start of `buf`, returning `(RadiotapInfo, header_len)`. +/// pub fn parse(buf: &[u8]) -> Option { if buf.len() < 8 || buf[0] != 0 { - return None; // version deve ser 0 + return None; // Radiotap `it_version` must be 0 } let header_len = u16::from_le_bytes([buf[2], buf[3]]) as usize; if header_len < 8 || header_len > buf.len() { return None; } - // Pode haver múltiplas palavras de "present" (bit 31 = continua). Só a - // primeira descreve os campos padrão; as seguintes são namespaces de - // extensão que ficam depois dos campos que usamos. + // Advance past any chained `it_present` extension words (bit 31 = more present words). + // + // let mut present = 0u32; let mut offset = 4usize; let mut first = true; @@ -139,7 +139,7 @@ pub fn freq_to_channel(freq_mhz: u16) -> Option { match freq_mhz { 2412..=2472 => Some(((freq_mhz as u32 - 2412) / 5) + 1), 2484 => Some(14), - // 4.9 GHz / segurança pública (canais 182–196). + // 4.9 GHz public safety band (channels 182–196). 4910 => Some(182), 4920 => Some(184), 4940 => Some(188), @@ -193,13 +193,13 @@ mod tests { let present: u32 = BIT_TSFT | BIT_FLAGS | BIT_RATE | BIT_CHANNEL | BIT_ANTSIGNAL; let mut buf = Vec::new(); buf.extend_from_slice(&[0, 0]); // version, pad - buf.extend_from_slice(&0u16.to_le_bytes()); // len (preenchido depois) + buf.extend_from_slice(&0u16.to_le_bytes()); // header length (backfilled below) buf.extend_from_slice(&present.to_le_bytes()); - // present termina em 8; TSFT alinhado em 8 + // `it_present` ends at offset 8; TSFT is 8-byte aligned buf.extend_from_slice(&0x1122_3344_5566_7788u64.to_le_bytes()); buf.push(0x10); // flags buf.push(0x02); // rate 1 Mb/s - // channel alinhado em 2 (cursor 18 já é par) + // Channel field is 2-byte aligned (offset 18) buf.extend_from_slice(&freq.to_le_bytes()); buf.extend_from_slice(&0xa0u16.to_le_bytes()); buf.push(signal as u8); diff --git a/crates/sabia-core/src/report.rs b/crates/sabia-core/src/report.rs index 483895c..3646059 100644 --- a/crates/sabia-core/src/report.rs +++ b/crates/sabia-core/src/report.rs @@ -1,9 +1,9 @@ -//! Relatório de auditoria em HTML paginado com Paged.js. +//! Paginated HTML/PDF wireless security audit report generator (powered by Paged.js). +//! +//! Shared between the live in-app report preview and standalone offline HTML export. +//! +//! //! -//! O mesmo modelo alimenta a pré-visualização na interface e o arquivo -//! standalone. A paginação é feita no navegador (A4, margens, cabeçalho e -//! rodapé), então o PDF sai pelo diálogo de impressão sem depender de -//! Chromium no servidor. use crate::analysis::Analysis; use crate::audit::AuditEntry; @@ -28,8 +28,8 @@ pub struct ReportModel { #[derive(Debug, Clone, Copy, Default)] pub struct ReportOptions { - /// Troca o `