chore(i18n): finish translating code comments and scripts to English
CI / Format and clippy (push) Successful in 1m13s
CI / Dependency audit (push) Successful in 13s
CI / Tests (Linux) (push) Successful in 1m35s
Release / Create release (push) Successful in 7s
CI / Check desktop app (Linux) (push) Successful in 3m19s
Release / CLI (linux-aarch64) (push) Successful in 1m3s
Release / CLI (linux-x86_64) (push) Successful in 1m28s
Release / Desktop app (linux-aarch64) (push) Successful in 7m16s
Release / Desktop app (linux-x86_64) (push) Successful in 10m17s
Release / Desktop app (windows) (push) Successful in 4m54s
CI / Format and clippy (push) Successful in 1m13s
CI / Dependency audit (push) Successful in 13s
CI / Tests (Linux) (push) Successful in 1m35s
Release / Create release (push) Successful in 7s
CI / Check desktop app (Linux) (push) Successful in 3m19s
Release / CLI (linux-aarch64) (push) Successful in 1m3s
Release / CLI (linux-x86_64) (push) Successful in 1m28s
Release / Desktop app (linux-aarch64) (push) Successful in 7m16s
Release / Desktop app (linux-x86_64) (push) Successful in 10m17s
Release / Desktop app (windows) (push) Successful in 4m54s
Completes the English-only standardization for code, comments, scripts and CI templates (UI/CLI-facing strings stay bilingual pt-BR/en, untouched). Covers the remaining crates/, apps/gui/ui/ and apps/gui/src-tauri/ comments, .gitea/ workflow and template comments, .githooks/ hooks, and the scripts/ source comments for the files already renamed in a prior commit (check.sh, generate-logo.py, install-hooks.sh). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
This commit is contained in:
1 parent
bf059fd0bc
commit
55e19a8e5b
59 files changed
+935
-879
No files matched your search
@@ -1,17 +1,17 @@
|
|||||||
name: Relatar um problema
|
name: Report a bug
|
||||||
description: Algo não funcionou como esperado
|
description: Something did not work as expected
|
||||||
labels: [bug]
|
labels: [bug]
|
||||||
body:
|
body:
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: o-que
|
id: what-happened
|
||||||
attributes: { label: "O que aconteceu?", description: "Passos, o que esperava e o que viu." }
|
attributes: { label: "What happened?", description: "Steps to reproduce, expected behavior, and actual behavior." }
|
||||||
validations: { required: true }
|
validations: { required: true }
|
||||||
- type: input
|
- type: input
|
||||||
id: sistema
|
id: system
|
||||||
attributes: { label: "Sistema operacional e versão do Sabiá", placeholder: "Ubuntu 24.04 / sabia 0.1.0" }
|
attributes: { label: "Operating system and Sabiá version", placeholder: "Ubuntu 24.04 / sabia 1.0.3" }
|
||||||
- type: input
|
- type: input
|
||||||
id: adaptador
|
id: adapter
|
||||||
attributes: { label: "Adaptador Wi-Fi e modo", placeholder: "wlan0 (Intel AX200), modo monitor" }
|
attributes: { label: "Wi-Fi adapter and mode", placeholder: "wlan0 (Intel AX200), monitor mode" }
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: log
|
id: log
|
||||||
attributes: { label: "Saída do terminal (sabia scan / ui)", render: shell }
|
attributes: { label: "Terminal output (sabia scan / ui)", render: shell }
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
## O que muda e por quê
|
## What changes and why
|
||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
- [ ] `cargo fmt --all` e `cargo clippy --workspace --all-targets` sem avisos
|
- [ ] `cargo fmt --all` and `cargo clippy --workspace --all-targets` pass without warnings
|
||||||
- [ ] `cargo test --workspace` passa
|
- [ ] `cargo test --workspace` passes
|
||||||
- [ ] Nada neste PR transmite ou envia dados capturados para fora da máquina
|
- [ ] Nothing in this PR transmits captured data outside the local machine
|
||||||
- [ ] CHANGELOG atualizado (se houver mudança visível)
|
- [ ] Commits follow Conventional Commits in English and code/comments are in English
|
||||||
|
- [ ] CHANGELOG updated (if there is a user-visible change)
|
||||||
+14
-14
@@ -1,7 +1,7 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
on:
|
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:
|
push:
|
||||||
branches: [main]
|
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']
|
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:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
RUSTFLAGS: -D warnings
|
RUSTFLAGS: -D warnings
|
||||||
# O runner mantém /opt/hostedtoolcache num volume que sobrevive entre jobs:
|
# The runner persists /opt/hostedtoolcache across jobs: rustup toolchains,
|
||||||
# a toolchain do rustup, o registry do cargo, caches de npm/apt e o diretório
|
# cargo registry, npm/apt caches, and target directories live there so nothing
|
||||||
# target ficam ali, então nada disso é baixado ou recompilado do zero. Cada job
|
# is re-downloaded or rebuilt from scratch on every run. Each job uses its own
|
||||||
# usa o seu próprio target (flags diferentes invalidariam o de outro job).
|
# target directory so differing RUSTFLAGS do not invalidate another job's cache.
|
||||||
CARGO_HOME: /opt/hostedtoolcache/cargo
|
CARGO_HOME: /opt/hostedtoolcache/cargo
|
||||||
RUSTUP_HOME: /opt/hostedtoolcache/rustup
|
RUSTUP_HOME: /opt/hostedtoolcache/rustup
|
||||||
NPM_CONFIG_CACHE: /opt/hostedtoolcache/npm-cache
|
NPM_CONFIG_CACHE: /opt/hostedtoolcache/npm-cache
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
name: Formatação e clippy
|
name: Format and clippy
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/sabia-lint
|
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/sabia-lint
|
||||||
@@ -37,14 +37,14 @@ jobs:
|
|||||||
- uses: actions/setup-node@v7
|
- uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
- name: Cobertura da tradução da interface
|
- name: UI translation coverage
|
||||||
run: node scripts/check-ui-i18n.mjs
|
run: node scripts/check-ui-i18n.mjs
|
||||||
- run: cargo fmt --all -- --check
|
- run: cargo fmt --all -- --check
|
||||||
- run: cargo clippy --workspace --all-targets
|
- run: cargo clippy --workspace --all-targets
|
||||||
|
|
||||||
# Windows e macOS saem: o Gitea aqui tem um único runner, Linux arm64.
|
# Linux-only runner (ARM64).
|
||||||
test:
|
test:
|
||||||
name: Testes (Linux)
|
name: Tests (Linux)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/sabia-test
|
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/sabia-test
|
||||||
@@ -54,21 +54,21 @@ jobs:
|
|||||||
- run: cargo test --workspace
|
- run: cargo test --workspace
|
||||||
|
|
||||||
audit:
|
audit:
|
||||||
name: Auditoria de dependências
|
name: Dependency audit
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
# rustsec/audit-check depends on GitHub's checks API. cargo-audit itself
|
# rustsec/audit-check depends on GitHub's checks API. cargo-audit itself
|
||||||
# needs nothing but the advisory database, and installing it once into the
|
# 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
|
- name: cargo audit
|
||||||
run: |
|
run: |
|
||||||
command -v cargo-audit >/dev/null || cargo install cargo-audit --locked
|
command -v cargo-audit >/dev/null || cargo install cargo-audit --locked
|
||||||
cargo audit
|
cargo audit
|
||||||
|
|
||||||
gui:
|
gui:
|
||||||
name: Compila o app desktop (Linux)
|
name: Check desktop app (Linux)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/sabia-gui
|
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/sabia-gui
|
||||||
@@ -78,10 +78,10 @@ jobs:
|
|||||||
- uses: actions/setup-node@v7
|
- uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
- name: Dependências do Tauri
|
- name: Tauri system dependencies
|
||||||
timeout-minutes: 25
|
timeout-minutes: 25
|
||||||
run: scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-native libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
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: 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
|
- run: cargo check --manifest-path apps/gui/src-tauri/Cargo.toml
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
# Quando uma tag v* é enviada: cria a entrada em "Releases" com as notas do
|
# Triggered when a v* tag is pushed: creates the Gitea Release entry with notes
|
||||||
# CHANGELOG.md e anexa o CLI compilado (scripts/package-cli.sh), com um .sha256 ao
|
# from CHANGELOG.md and uploads the compiled CLI (scripts/package-cli.sh) with
|
||||||
# lado de cada pacote. É o que o atualizador (`sabia update`) baixa.
|
# 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
|
# Updater signing (optional): SABIA_TAURI_SIGNING_PRIVATE_KEY and
|
||||||
# por compilação cruzada. Windows e macOS têm só o app desktop.
|
# SABIA_TAURI_SIGNING_PRIVATE_KEY_PASSWORD, with unprefixed names as fallback.
|
||||||
#
|
# Without a key, the build succeeds without generating .sig files.
|
||||||
# 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.
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags: ['v*']
|
tags: ['v*']
|
||||||
|
|
||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
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
|
CARGO_HOME: /opt/hostedtoolcache/cargo
|
||||||
RUSTUP_HOME: /opt/hostedtoolcache/rustup
|
RUSTUP_HOME: /opt/hostedtoolcache/rustup
|
||||||
NPM_CONFIG_CACHE: /opt/hostedtoolcache/npm-cache
|
NPM_CONFIG_CACHE: /opt/hostedtoolcache/npm-cache
|
||||||
@@ -24,7 +22,7 @@ env:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
name: Cria a release
|
name: Create release
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
@@ -36,9 +34,9 @@ jobs:
|
|||||||
TOKEN: ${{ gitea.token }}
|
TOKEN: ${{ gitea.token }}
|
||||||
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
|
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
|
||||||
run: |
|
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
|
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
|
fi
|
||||||
scripts/release-notes.sh "$TAG" > notes.md
|
scripts/release-notes.sh "$TAG" > notes.md
|
||||||
pre=false; case "$TAG" in *-*) pre=true ;; esac
|
pre=false; case "$TAG" in *-*) pre=true ;; esac
|
||||||
@@ -64,19 +62,19 @@ jobs:
|
|||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
targets: ${{ matrix.target }}
|
targets: ${{ matrix.target }}
|
||||||
- name: Linker e ferramentas desta plataforma
|
- name: Platform cross-linker
|
||||||
if: ${{ matrix.apt != '' }}
|
if: ${{ matrix.apt != '' }}
|
||||||
run: scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/cli-x86_64 ${{ 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
|
run: scripts/package-cli.sh "${{ gitea.ref_name }}" "${{ matrix.platform }}" "${{ matrix.target }}" | tee packages.txt
|
||||||
- name: Anexa à release
|
- name: Upload to release
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ gitea.token }}
|
TOKEN: ${{ gitea.token }}
|
||||||
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
|
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
|
||||||
run: scripts/release-upload.sh "${{ gitea.ref_name }}" $(cat packages.txt)
|
run: scripts/release-upload.sh "${{ gitea.ref_name }}" $(cat packages.txt)
|
||||||
|
|
||||||
gui:
|
gui:
|
||||||
name: App desktop (${{ matrix.platform }})
|
name: Desktop app (${{ matrix.platform }})
|
||||||
needs: release
|
needs: release
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
@@ -93,11 +91,11 @@ jobs:
|
|||||||
- uses: actions/setup-node@v7
|
- uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
- name: Dependências do Tauri (Linux aarch64)
|
- name: Tauri dependencies (Linux aarch64)
|
||||||
if: matrix.platform == 'linux-aarch64'
|
if: matrix.platform == 'linux-aarch64'
|
||||||
timeout-minutes: 25
|
timeout-minutes: 25
|
||||||
run: scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-native libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
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'
|
if: matrix.platform == 'linux-x86_64'
|
||||||
timeout-minutes: 25
|
timeout-minutes: 25
|
||||||
run: |
|
run: |
|
||||||
@@ -117,14 +115,14 @@ jobs:
|
|||||||
else
|
else
|
||||||
scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-native libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-native libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||||
fi
|
fi
|
||||||
- name: Ferramentas para compilação cruzada (Windows)
|
- name: Cross-compilation tools (Windows)
|
||||||
if: matrix.platform == 'windows'
|
if: matrix.platform == 'windows'
|
||||||
run: |
|
run: |
|
||||||
scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-win nsis lld llvm clang
|
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
|
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 }}"
|
run: scripts/build-gui.sh "${{ matrix.platform }}"
|
||||||
- name: Anexa à release
|
- name: Upload to release
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ gitea.token }}
|
TOKEN: ${{ gitea.token }}
|
||||||
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
|
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
|
||||||
|
|||||||
Executable
+42
@@ -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 <file>}"
|
||||||
|
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: <type>[optional scope]: <description in English>\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
|
||||||
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Antes de cada push: a verificação rápida. Ignore com `git push --no-verify` (use com parcimônia).
|
set -euo pipefail
|
||||||
exec "$(git rev-parse --show-toplevel)/scripts/verificar.sh"
|
exec "$(git rev-parse --show-toplevel)/scripts/check.sh"
|
||||||
+7
-7
@@ -1,4 +1,4 @@
|
|||||||
# Build
|
# Build artifacts
|
||||||
target/
|
target/
|
||||||
apps/gui/src-tauri/target/
|
apps/gui/src-tauri/target/
|
||||||
node_modules/
|
node_modules/
|
||||||
@@ -7,15 +7,15 @@ node_modules/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
**/*.rs.bk
|
**/*.rs.bk
|
||||||
|
|
||||||
# Capturas e estado local do Sabiá
|
# Sabiá local captures and audit log
|
||||||
*.pcap
|
*.pcap
|
||||||
*.cap
|
*.cap
|
||||||
.sabia-audit.log
|
.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
|
/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/
|
.claude/
|
||||||
.wolf/
|
.wolf/
|
||||||
.opencode/
|
.opencode/
|
||||||
@@ -27,16 +27,16 @@ CLAUDE.md
|
|||||||
AGENTS.md
|
AGENTS.md
|
||||||
GEMINI.md
|
GEMINI.md
|
||||||
|
|
||||||
# Variáveis de ambiente locais (o setup do Superset copia da raiz)
|
# Local environment files
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
|
||||||
# Lixo local
|
# Local scratch files
|
||||||
*.eml
|
*.eml
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|
||||||
# Pacotes gerados por scripts/package-cli.sh
|
# Packages generated by scripts/package-cli.sh and scripts/build-gui.sh
|
||||||
/guara-v*
|
/guara-v*
|
||||||
/sabia-v*
|
/sabia-v*
|
||||||
/dist-gui
|
/dist-gui
|
||||||
+11
-4
@@ -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.
|
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.
|
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.
|
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
|
## Workflow
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
scripts/instalar-hooks.sh # once: runs the quick check before every push
|
scripts/install-hooks.sh # once: enables commit-msg and pre-push Git hooks
|
||||||
scripts/verificar.sh # fast (seconds): fmt, clippy as in CI, i18n, scripts, workflows
|
scripts/check.sh # fast (seconds): fmt, clippy as in CI, i18n, scripts, workflows, semver
|
||||||
scripts/verificar.sh --completo # adds cargo test --workspace and cargo audit
|
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.
|
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`.
|
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.
|
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.
|
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
|
## License
|
||||||
|
|
||||||
By contributing, you agree to license under [MIT](LICENSE-MIT) **or** [Apache-2.0](LICENSE-APACHE), like the rest of the project.
|
By contributing, you agree to license under [MIT](LICENSE-MIT) **or** [Apache-2.0](LICENSE-APACHE), like the rest of the project.
|
||||||
+11
-4
@@ -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.
|
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.
|
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.
|
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
|
## Fluxo
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
scripts/instalar-hooks.sh # uma vez: roda a verificação rápida antes de cada push
|
scripts/install-hooks.sh # uma vez: ativa os hooks commit-msg e pre-push
|
||||||
scripts/verificar.sh # rápido (segundos): fmt, clippy como no CI, tradução, scripts, workflows
|
scripts/check.sh # rápido (segundos): fmt, clippy como no CI, tradução, scripts, workflows, semver
|
||||||
scripts/verificar.sh --completo # inclui cargo test --workspace e cargo audit
|
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.
|
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`.
|
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.
|
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.
|
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
|
## Licença
|
||||||
|
|
||||||
Ao contribuir, você concorda em licenciar sob [MIT](LICENSE-MIT) **ou** [Apache-2.0](LICENSE-APACHE), como o resto do projeto.
|
Ao contribuir, você concorda em licenciar sob [MIT](LICENSE-MIT) **ou** [Apache-2.0](LICENSE-APACHE), como o resto do projeto.
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
# App desktop (Tauri 2). Fica fora do workspace principal porque exige
|
# Desktop application (Tauri 2). Kept outside the main workspace because it
|
||||||
# as bibliotecas de WebView do sistema (WebKitGTK no Linux).
|
# requires system WebView libraries (WebKitGTK on Linux).
|
||||||
[package]
|
[package]
|
||||||
name = "sabia-gui"
|
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"
|
version = "1.0.3"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ fn main() {
|
|||||||
let _ = std::fs::copy("../../../logo.png", "../ui/logo.png");
|
let _ = std::fs::copy("../../../logo.png", "../ui/logo.png");
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
// Modo monitor e captura crua exigem administrador no Windows: o
|
// Monitor mode and raw frame capture require Administrator privileges on Windows:
|
||||||
// manifesto pede elevação (UAC) assim que o app abre.
|
// embed UAC elevation manifest at startup.
|
||||||
let manifest = include_str!("windows-app-manifest.xml");
|
let manifest = include_str!("windows-app-manifest.xml");
|
||||||
let attrs = tauri_build::Attributes::new()
|
let attrs = tauri_build::Attributes::new()
|
||||||
.windows_attributes(tauri_build::WindowsAttributes::new().app_manifest(manifest));
|
.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))]
|
#[cfg(not(windows))]
|
||||||
tauri_build::build();
|
tauri_build::build();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Permissões da janela principal",
|
"description": "Main window permissions",
|
||||||
"windows": ["main"],
|
"windows": ["main"],
|
||||||
"permissions": ["core:default", "core:window:allow-destroy", "core:window:allow-close"]
|
"permissions": ["core:default", "core:window:allow-destroy", "core:window:allow-close"]
|
||||||
}
|
}
|
||||||
@@ -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"]}}
|
{"default":{"identifier":"default","description":"Main window permissions","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-destroy","core:window:allow-close"]}}
|
||||||
+19
-21
@@ -8,11 +8,11 @@
|
|||||||
--text-3: #8e8e93;
|
--text-3: #8e8e93;
|
||||||
--line: rgba(0, 0, 0, 0.09);
|
--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: #b45309;
|
||||||
--accent-ink: #ffffff;
|
--accent-ink: #ffffff;
|
||||||
--accent-soft: rgba(180, 83, 9, 0.11);
|
--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: #0891b2;
|
||||||
--signal-soft: rgba(8, 145, 178, 0.12);
|
--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); }
|
.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; } }
|
@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 { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||||
.topbar h1 { font-size: 21px; }
|
.topbar h1 { font-size: 21px; }
|
||||||
.topbar .sub { color: var(--text-2); font-size: 13px; margin-top: 2px; }
|
.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.live { opacity: 1; }
|
||||||
.wave.scanning path.flat { opacity: 0; }
|
.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 { 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 svg { flex: none; margin-top: 2px; color: var(--warn); }
|
||||||
.notice.warn { background: var(--warn-soft); } .notice.warn svg { 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.bad { background: var(--bad-soft); } .notice.bad svg { color: var(--bad); }
|
||||||
.notice b { color: var(--text); }
|
.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 { 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; }
|
.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); }
|
.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 { background: var(--bad); color: #fff; }
|
||||||
.btn.live:hover { filter: brightness(1.06); }
|
.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-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-summary { display: flex; align-items: baseline; gap: 10px; min-width: 0; }
|
||||||
.result-head strong { font-size: 15px; }
|
.result-head strong { font-size: 15px; }
|
||||||
@@ -186,7 +186,7 @@ tbody tr:hover { background: var(--surface-2); }
|
|||||||
td.ssid { font-weight: 600; }
|
td.ssid { font-weight: 600; }
|
||||||
td.ssid em { color: var(--text-3); font-weight: 400; font-style: normal; }
|
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; }
|
.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 { 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); }
|
.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 h2 { font-size: 17px; color: var(--text); margin-bottom: 6px; }
|
||||||
.empty-state p { font-size: 13.5px; max-width: 360px; margin: 0 auto; }
|
.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 { 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::backdrop { background: rgba(0, 0, 0, .35); backdrop-filter: blur(6px); }
|
||||||
dialog h2 { font-size: 20px; margin-bottom: 16px; }
|
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); }
|
.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 { 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; }
|
.ssid-btn:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
|
||||||
.bssid-item { cursor: pointer; }
|
.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; }
|
.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 { 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); }
|
.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
|
/* CSS multi-column layout instead of grid: attack cards have very different
|
||||||
diferentes entre si (Deauth/Handshake são enormes, PMF/WPS são curtos).
|
heights (Deauth/Handshake are tall, PMF/WPS are short). In a standard
|
||||||
Num grid de 2 colunas comum, o par mais curto de uma linha deixava um
|
2-column grid, the shorter card in a row left a large empty gap underneath.
|
||||||
vão vazio enorme embaixo (a altura da linha é a do item mais alto, e
|
Multi-column masonry flow lets each card take only the height it needs
|
||||||
align-items:start não estica o curto pra preencher). Colunas fazem os
|
with the next card stacking right below. */
|
||||||
cards fluírem como alvenaria: cada um só ocupa a altura que precisa, o
|
|
||||||
próximo entra logo abaixo, sem buracos. */
|
|
||||||
.attack-grid { column-count: 2; column-gap: 12px; }
|
.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-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; }
|
.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; }
|
.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%; } }
|
@keyframes crack-slide { 0% { margin-left: -35%; } 100% { margin-left: 100%; } }
|
||||||
|
|
||||||
/* ------------------------------------------------------------ responsivo */
|
/* ------------------------------------------------------------ responsive */
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.app { grid-template-columns: 1fr; grid-template-rows: auto 1fr; }
|
.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; }
|
.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; }
|
.detail-card, .attack-card { padding: 12px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------- análise de canais */
|
/* --------------------------------------------------- channel analysis */
|
||||||
.top-actions { display: flex; align-items: center; gap: 10px; }
|
.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 { 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; }
|
.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-aps { color: var(--text-3); font-size: 11px; white-space: nowrap; }
|
||||||
.chan-best .chan-n, .chan-best .chan-aps { color: var(--signal); }
|
.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-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 { 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; }
|
.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; }
|
.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); }
|
.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 { 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 path { fill: none; stroke: var(--signal); stroke-width: 1.4; }
|
||||||
.spark-big { display: block; width: 100%; height: 70px; margin: 10px 0 0; }
|
.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-probe { color: var(--signal); }
|
||||||
.tl-detail { color: var(--text-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.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; }
|
.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 { width: min(1060px, 96vw); height: 92vh; max-height: 92vh; padding: 0; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
.report-dlg:not([open]) { display: none; }
|
.report-dlg:not([open]) { display: none; }
|
||||||
|
|||||||
+65
-67
@@ -53,11 +53,11 @@ function applyTheme(t) {
|
|||||||
|
|
||||||
function updateThemePref(t) {
|
function updateThemePref(t) {
|
||||||
applyTheme(t);
|
applyTheme(t);
|
||||||
try { localStorage.setItem('sabia-theme', t); } catch { /* sem armazenamento */ }
|
try { localStorage.setItem('sabia-theme', t); } catch { /* storage unavailable */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
let themePref = 'auto';
|
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);
|
applyTheme(themePref);
|
||||||
|
|
||||||
document.querySelectorAll('[data-theme-pick]').forEach((b) => {
|
document.querySelectorAll('[data-theme-pick]').forEach((b) => {
|
||||||
@@ -67,7 +67,7 @@ document.querySelectorAll('[data-theme-pick]').forEach((b) => {
|
|||||||
try {
|
try {
|
||||||
const sort = localStorage.getItem('sabia-sort');
|
const sort = localStorage.getItem('sabia-sort');
|
||||||
if (sort && [...els.sortSel.options].some((o) => o.value === sort)) els.sortSel.value = 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', () => {
|
els.settingsBtn.addEventListener('click', () => {
|
||||||
if (!els.settingsDlg.open) els.settingsDlg.showModal();
|
if (!els.settingsDlg.open) els.settingsDlg.showModal();
|
||||||
@@ -75,14 +75,14 @@ els.settingsBtn.addEventListener('click', () => {
|
|||||||
|
|
||||||
function applyLangPref() {
|
function applyLangPref() {
|
||||||
let pref = 'auto';
|
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;
|
els.langSel.value = pref;
|
||||||
setLang(pref === 'auto' ? undefined : pref);
|
setLang(pref === 'auto' ? undefined : pref);
|
||||||
}
|
}
|
||||||
|
|
||||||
els.langSel.addEventListener('change', () => {
|
els.langSel.addEventListener('change', () => {
|
||||||
const pref = els.langSel.value;
|
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);
|
setLang(pref === 'auto' ? undefined : pref);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -90,12 +90,12 @@ let ifaces = [];
|
|||||||
let current = null;
|
let current = null;
|
||||||
let scanning = false;
|
let scanning = false;
|
||||||
|
|
||||||
// Ordem de chegada estável por rede/BSSID: sem isso, cada poll reordenava a
|
// Stable arrival order per network/BSSID: without this, each poll resorted the
|
||||||
// lista pelo sinal instantâneo e as linhas ficavam "flutuando" de posição a
|
// list by instantaneous RSSI and rows jumped around on every
|
||||||
// cada atualização, tornando impossível clicar no alvo certo. A ordem de
|
// update, making it hard to click a target. Arrival order
|
||||||
// chegada serve de critério de desempate (e de ordenação padrão), então um
|
// serves as the tiebreaker (and default sort), so an
|
||||||
// item só muda de posição se o usuário trocar o modo de ordenação de
|
// item only moves when the user explicitly changes the sort mode
|
||||||
// verdade — não por uma flutuação de -1 dBm entre um poll e outro.
|
// rather than on a 1 dBm RSSI fluctuation between polls.
|
||||||
const firstSeenOrder = new Map();
|
const firstSeenOrder = new Map();
|
||||||
let firstSeenSeq = 0;
|
let firstSeenSeq = 0;
|
||||||
function orderOf(key) {
|
function orderOf(key) {
|
||||||
@@ -115,8 +115,8 @@ async function api(path, opts) {
|
|||||||
return r.json();
|
return r.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// No app desktop não existe servidor HTTP: as mesmas rotas viram chamadas
|
// In the desktop app there is no HTTP server: the same routes map to direct
|
||||||
// IPC diretas pro núcleo, pelos comandos registrados em src-tauri/src/main.rs.
|
// IPC calls into the core via the Tauri commands in src-tauri/src/main.rs.
|
||||||
async function tauriApi(path, opts = {}) {
|
async function tauriApi(path, opts = {}) {
|
||||||
const [p, qs] = path.split('?');
|
const [p, qs] = path.split('?');
|
||||||
const q = new URLSearchParams(qs || '');
|
const q = new URLSearchParams(qs || '');
|
||||||
@@ -280,8 +280,8 @@ function renderEmptyResult(message, hint) {
|
|||||||
els.resultArea.innerHTML = `<div class="ap-card empty-state">${ICON_EMPTY}<h2>${message}</h2><p>${hint}</p></div>`;
|
els.resultArea.innerHTML = `<div class="ap-card empty-state">${ICON_EMPTY}<h2>${message}</h2><p>${hint}</p></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Só troca o HTML quando muda de verdade: o poll de 1 s não deve
|
// Only update innerHTML when content actually changes: the 1s poll must not
|
||||||
// reconstruir a tabela (e perder foco/hover) à toa.
|
// rebuild the DOM table (and drop focus/hover) unnecessarily.
|
||||||
function setResultsHtml(html) {
|
function setResultsHtml(html) {
|
||||||
if (els.resultArea._h === html) return;
|
if (els.resultArea._h === html) return;
|
||||||
els.resultArea._h = html;
|
els.resultArea._h = html;
|
||||||
@@ -352,9 +352,9 @@ els.btnScan.addEventListener('click', async () => {
|
|||||||
if (!scanning) {
|
if (!scanning) {
|
||||||
const res = await api(`/api/scan/start?iface=${encodeURIComponent(current)}`, { method: 'POST' });
|
const res = await api(`/api/scan/start?iface=${encodeURIComponent(current)}`, { method: 'POST' });
|
||||||
if (res && res.ok === false) {
|
if (res && res.ok === false) {
|
||||||
// Sem isso, uma falha ao abrir a captura (permissão, interface saiu do
|
// Without this, a failure to start capture (permissions, interface left
|
||||||
// modo monitor, etc.) ficava muda: o botão virava "parar captura" e a
|
// monitor mode, etc.) was silent: the button flipped to "stop capture" and
|
||||||
// tela mostrava "escutando o ar" pra sempre, sem nenhuma rede chegar.
|
// the screen showed "listening" forever without any networks arriving.
|
||||||
alert('Não foi possível iniciar a captura: ' + (res.error || 'erro desconhecido'));
|
alert('Não foi possível iniciar a captura: ' + (res.error || 'erro desconhecido'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -398,7 +398,7 @@ function signalOf(ap) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function signalBars(dbm) {
|
function signalBars(dbm) {
|
||||||
// dBm tipicamente entre -30 (ótimo) e -90 (péssimo)
|
// dBm typically ranges from -30 (excellent) to -90 (weak)
|
||||||
let level = 0;
|
let level = 0;
|
||||||
if (dbm != null) {
|
if (dbm != null) {
|
||||||
if (dbm >= -55) level = 4;
|
if (dbm >= -55) level = 4;
|
||||||
@@ -414,9 +414,9 @@ function signalBars(dbm) {
|
|||||||
return `<span class="signal-bars">${bars}</span>`;
|
return `<span class="signal-bars">${bars}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Agrupa por ESSID: uma rede pode ter vários BSSIDs (rádios) e todos
|
// Group by ESSID: a network may have multiple BSSIDs (radios) belonging
|
||||||
// pertencem ao mesmo alvo. Redes ocultas ficam separadas, pois sem SSID
|
// to the same target. Hidden networks stay separate since without an SSID
|
||||||
// não há como afirmar que dois BSSIDs são a mesma rede.
|
// two BSSIDs cannot be assumed to belong to the same network.
|
||||||
function buildGroups(aps) {
|
function buildGroups(aps) {
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
for (const ap of aps) {
|
for (const ap of aps) {
|
||||||
@@ -460,9 +460,9 @@ function filterGroups(groups) {
|
|||||||
g.aps.some((a) => a.bssid.toLowerCase().includes(q)));
|
g.aps.some((a) => a.bssid.toLowerCase().includes(q)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dessensibiliza o sinal antes de comparar: arredondar pro m\u00faltiplo de 4
|
// Quantize RSSI before comparing: rounding to the nearest multiple of 4
|
||||||
// dBm mais pr\u00f3ximo evita que uma oscila\u00e7\u00e3o de 1-2 dBm entre dois polls
|
// dBm prevents a 1-2 dBm fluctuation between polls from
|
||||||
// inverta a ordem de dois alvos com sinal parecido a cada atualiza\u00e7\u00e3o.
|
// flipping the order of two targets with similar signal strength.
|
||||||
function signalBucket(v) {
|
function signalBucket(v) {
|
||||||
return v == null ? -1000 : Math.round(v / 4) * 4;
|
return v == null ? -1000 : Math.round(v / 4) * 4;
|
||||||
}
|
}
|
||||||
@@ -535,7 +535,7 @@ function renderGroup(g) {
|
|||||||
let lastAps = [];
|
let lastAps = [];
|
||||||
const openGroups = new Set();
|
const openGroups = new Set();
|
||||||
let viewMode = 'nets';
|
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 channelBand = null;
|
||||||
let reportState = { iface: null, pages: 1, zoom: 1, url: null };
|
let reportState = { iface: null, pages: 1, zoom: 1, url: null };
|
||||||
let reportMeasureTimer = null;
|
let reportMeasureTimer = null;
|
||||||
@@ -632,8 +632,8 @@ function spectrumSvg(band) {
|
|||||||
|
|
||||||
const CHAN_TONE_WORD = { ok: 'Boa', warn: 'Atenção', bad: 'Saturado' };
|
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,
|
/** Overall band status: applies the per-channel health logic to the
|
||||||
* só que aplicada ao canal recomendado — é o que decide o tom do card inteiro. */
|
* recommended channel to determine the summary card tone. */
|
||||||
function bandHealth(band, rec) {
|
function bandHealth(band, rec) {
|
||||||
if (!rec) return { tone: 'bad', label: 'Sem canal livre nessa banda' };
|
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' };
|
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) {
|
function setView(mode) {
|
||||||
viewMode = mode === 'channels' ? 'channels' : 'nets';
|
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) => {
|
document.querySelectorAll('#viewNav [data-view]').forEach((b) => {
|
||||||
b.setAttribute('aria-pressed', String(b.dataset.view === viewMode));
|
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.searchInput.addEventListener('input', () => renderResults(lastAps));
|
||||||
els.sortSel.addEventListener('change', () => {
|
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);
|
renderResults(lastAps);
|
||||||
});
|
});
|
||||||
els.resultArea.addEventListener('click', (e) => {
|
els.resultArea.addEventListener('click', (e) => {
|
||||||
@@ -803,7 +803,7 @@ els.resultArea.addEventListener('keydown', (e) => {
|
|||||||
openTarget(target.dataset.bssid);
|
openTarget(target.dataset.bssid);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------- alvo
|
// ---------------------------------------------------------------- target
|
||||||
let lastSnapshot = null;
|
let lastSnapshot = null;
|
||||||
let selectedBssid = null;
|
let selectedBssid = null;
|
||||||
let activeTab = 'overview';
|
let activeTab = 'overview';
|
||||||
@@ -1056,8 +1056,8 @@ const EVENT_LABEL = {
|
|||||||
deauth: 'desautenticado',
|
deauth: 'desautenticado',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Sparkline em tamanho real (não o mini de 62px da tabela), pra caber bem
|
/** Full-size RSSI sparkline (wider than the 62px table mini-chart) for the
|
||||||
* sozinho dentro do card de sinal do detalhe do cliente. */
|
* client detail signal card. */
|
||||||
function bigSparkline(history) {
|
function bigSparkline(history) {
|
||||||
const points = (history || []).slice(-60);
|
const points = (history || []).slice(-60);
|
||||||
if (points.length < 2) return '<p class="hint">Sem amostras de sinal suficientes ainda.</p>';
|
if (points.length < 2) return '<p class="hint">Sem amostras de sinal suficientes ainda.</p>';
|
||||||
@@ -1077,9 +1077,8 @@ function bigSparkline(history) {
|
|||||||
return `<svg class="spark spark-big" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" aria-hidden="true"><path d="${d}"/></svg>`;
|
return `<svg class="spark spark-big" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" aria-hidden="true"><path d="${d}"/></svg>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Visão completa de um cliente: tudo que o Sabiá já capturou sobre ele —
|
/** Detailed client view: consolidates everything captured about a station —
|
||||||
* identificação, rede, sinal e a linha do tempo de eventos — num painel só,
|
* identity hints, associated AP, signal history, and event timeline — in one modal. */
|
||||||
* em vez de ter que adivinhar olhando a linha compacta da tabela. */
|
|
||||||
function renderClientDetail(clients) {
|
function renderClientDetail(clients) {
|
||||||
const client = clients.find((c) => c.mac === attack.timelineMac);
|
const client = clients.find((c) => c.mac === attack.timelineMac);
|
||||||
if (!client) return '';
|
if (!client) return '';
|
||||||
@@ -1522,11 +1521,11 @@ async function loadSnapshot() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enquanto startDeauthJob está religando a captura e esperando o AP
|
// While startDeauthJob is restarting capture and waiting for the AP
|
||||||
// reaparecer na varredura, a sessão nova responde vazia por alguns ciclos
|
// to reappear in the scan, the fresh session returns empty for a few poll
|
||||||
// de poll — sem essa trava, renderTarget() interpreta "AP não visto ainda"
|
// cycles — without this guard, renderTarget() treats "AP not seen yet"
|
||||||
// como "AP não existe mais" e fecha o diálogo debaixo do usuário no meio
|
// as "AP gone" and closes the modal underneath the user mid-action.
|
||||||
// da própria ação que ele acabou de pedir.
|
//
|
||||||
let resumingForBssid = null;
|
let resumingForBssid = null;
|
||||||
|
|
||||||
function renderTarget(force) {
|
function renderTarget(force) {
|
||||||
@@ -1671,13 +1670,13 @@ async function waitForBssidChannel(bssid, timeoutMs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startDeauthJob(handshake) {
|
async function startDeauthJob(handshake) {
|
||||||
// Fixa o alvo no início: o resto desta função faz esperas longas
|
// Snapshot the target BSSID upfront: the rest of this function awaits
|
||||||
// (religar a captura, aguardar a rede reaparecer na varredura), e nesse
|
// capture restart and AP rediscovery, during which the global poll could
|
||||||
// meio tempo o poll global pode não achar mais o AP na primeira resposta
|
// see an empty initial snapshot and close the modal, clearing
|
||||||
// vazia da sessão recém-reiniciada e fechar o diálogo, zerando
|
// `selectedBssid`.
|
||||||
// `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.
|
//
|
||||||
const bssid = selectedBssid;
|
const bssid = selectedBssid;
|
||||||
const station = document.getElementById('deauthStation')?.value || '';
|
const station = document.getElementById('deauthStation')?.value || '';
|
||||||
const direction = document.getElementById('deauthDirection')?.value || attack.direction;
|
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);
|
setResult('deauthResult', 'Escolha um cliente para desautenticar nas direções sta/both.', true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Essa ação exige uma sessão de captura ativa (é o que guarda o handshake
|
// This action requires an active capture session (which stores the assembled
|
||||||
// montado); sem isso o backend recusa com "inicie a captura antes do
|
// handshake); rather than forcing the user to manually restart capture,
|
||||||
// job". Em vez de obrigar o usuário a voltar e religar manualmente, retoma
|
// resume capture automatically here.
|
||||||
// a captura aqui — do jeito que ele descreveu querer.
|
//
|
||||||
if (!scanning) {
|
if (!scanning) {
|
||||||
const scanRes = await api(`/api/scan/start?iface=${encodeURIComponent(current)}`, { method: 'POST' });
|
const scanRes = await api(`/api/scan/start?iface=${encodeURIComponent(current)}`, { method: 'POST' });
|
||||||
if (scanRes && scanRes.ok === false) {
|
if (scanRes && scanRes.ok === false) {
|
||||||
@@ -1702,12 +1701,12 @@ async function startDeauthJob(handshake) {
|
|||||||
els.btnScan.textContent = 'parar captura';
|
els.btnScan.textContent = 'parar captura';
|
||||||
els.btnScan.classList.add('live');
|
els.btnScan.classList.add('live');
|
||||||
els.wave.classList.add('scanning');
|
els.wave.classList.add('scanning');
|
||||||
// A captura acabou de reiniciar: a sessão ainda não sabe em qual canal
|
// Capture just restarted: the session does not yet know which channel
|
||||||
// este BSSID está, e o job travaria o canal errado (o canal em que o
|
// this BSSID is on, so starting the job immediately would lock the wrong
|
||||||
// scanner por acaso já estava saltando) em vez do canal real do AP —
|
// channel. Wait briefly for the AP to reappear in the scan snapshot
|
||||||
// os quadros de deauth saem no vácuo e o job termina sem efeito. Espera
|
// before launching the job.
|
||||||
// o AP reaparecer no snapshot (ele já esteve no ar, é questão de
|
//
|
||||||
// segundos de varredura) antes de disparar o job.
|
//
|
||||||
setResult('deauthResult', 'Retomando a captura e localizando a rede…');
|
setResult('deauthResult', 'Retomando a captura e localizando a rede…');
|
||||||
resumingForBssid = bssid;
|
resumingForBssid = bssid;
|
||||||
let found;
|
let found;
|
||||||
@@ -2370,10 +2369,9 @@ async function poll() {
|
|||||||
if (scanning && current) {
|
if (scanning && current) {
|
||||||
const snap = await api(`/api/scan/snapshot?iface=${encodeURIComponent(current)}`);
|
const snap = await api(`/api/scan/snapshot?iface=${encodeURIComponent(current)}`);
|
||||||
if (snap?.access_points && snap.running === false) {
|
if (snap?.access_points && snap.running === false) {
|
||||||
// A sessão existe e respondeu normal (200), mas o próprio engine
|
// The session exists and returned HTTP 200, but the engine marked
|
||||||
// marcou running=false: a thread de captura morreu sozinha (erro de
|
// running=false because the capture thread died on its own (I/O error,
|
||||||
// I/O, adaptador caiu da USB, etc.). Sem isso, a UI continuava achando
|
// USB adapter unplugged, etc.). Surface the actual reason to the UI.
|
||||||
// que ainda estava capturando e nunca contava o motivo real.
|
|
||||||
scanning = false;
|
scanning = false;
|
||||||
els.btnScan.textContent = 'iniciar captura';
|
els.btnScan.textContent = 'iniciar captura';
|
||||||
els.btnScan.classList.remove('live');
|
els.btnScan.classList.remove('live');
|
||||||
@@ -2386,10 +2384,10 @@ async function poll() {
|
|||||||
renderResults(snap.access_points);
|
renderResults(snap.access_points);
|
||||||
if (selectedBssid) renderTarget(false);
|
if (selectedBssid) renderTarget(false);
|
||||||
} else if (snap && snap.ok === false) {
|
} else if (snap && snap.ok === false) {
|
||||||
// A captura morreu no meio (interface saiu do monitor, processo externo
|
// Capture stopped mid-flight (interface left monitor mode, external
|
||||||
// tomou o adaptador, etc.). Antes disso caía no fallback de baixo, que
|
// process claimed the adapter, etc.). Without this branch, the fallback
|
||||||
// sempre responde 200 com lista vazia — a UI continuava "escutando o
|
// below returns 200 with an empty list and the UI keeps listening
|
||||||
// ar" indefinidamente sem nenhuma rede aparecer e sem avisar por quê.
|
// indefinitely without any network appearing or explaining why.
|
||||||
scanning = false;
|
scanning = false;
|
||||||
els.btnScan.textContent = 'iniciar captura';
|
els.btnScan.textContent = 'iniciar captura';
|
||||||
els.btnScan.classList.remove('live');
|
els.btnScan.classList.remove('live');
|
||||||
@@ -2405,7 +2403,7 @@ async function poll() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function checkElevation() {
|
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 notice = document.getElementById('elevateNotice');
|
||||||
const msg = document.getElementById('elevateMsg');
|
const msg = document.getElementById('elevateMsg');
|
||||||
const retry = document.getElementById('btnElevateRetry');
|
const retry = document.getElementById('btnElevateRetry');
|
||||||
|
|||||||
+8
-8
@@ -1,7 +1,7 @@
|
|||||||
// Idioma da interface. O texto-fonte é em português; no modo inglês um tradutor
|
// UI language. Source strings are in Portuguese; in English mode a translator
|
||||||
// troca os textos exibidos (nós de texto e atributos) usando um dicionário, e
|
// replaces displayed strings (text nodes and attributes) using a dictionary, and
|
||||||
// continua observando a página para traduzir o que for desenhado depois.
|
// keeps observing the page to translate dynamically rendered elements.
|
||||||
// Textos com valores variáveis usam {n} no dicionário (ex.: "{n} redes" -> "{n} networks").
|
// Variable placeholders use {n} in the dictionary (e.g. "{n} networks").
|
||||||
|
|
||||||
import { EN } from './i18n-en.js';
|
import { EN } from './i18n-en.js';
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ export function resolveLang(pref) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ATTRS = ['placeholder', 'title', 'aria-label', 'alt', 'data-empty'];
|
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 SKIP = '.ssid, .bssid, .mono, pre, code, input, textarea, script, style';
|
||||||
|
|
||||||
const exact = new Map(Object.entries(EN).filter(([k]) => !k.includes('{n}')));
|
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, '\\$&'));
|
const parts = k.split('{n}').map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||||||
return { re: new RegExp('^' + parts.join('(.+?)') + '$', 's'), to: v };
|
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);
|
.sort((a, b) => b.re.source.length - a.re.source.length);
|
||||||
|
|
||||||
const cache = new Map();
|
const cache = new Map();
|
||||||
@@ -43,7 +43,7 @@ export function tr(text) {
|
|||||||
const m = core.match(p.re);
|
const m = core.match(p.re);
|
||||||
if (m) {
|
if (m) {
|
||||||
let i = 0;
|
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] ?? ''));
|
out = p.to.replace(/\{n\}/g, () => tr(m[++i] ?? ''));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -55,7 +55,7 @@ export function tr(text) {
|
|||||||
return lead + out + trail;
|
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;
|
let observer = null;
|
||||||
|
|
||||||
function translateNode(n) {
|
function translateNode(n) {
|
||||||
|
|||||||
Vendored
+6
-6
@@ -1,10 +1,10 @@
|
|||||||
# Paged.js
|
# Paged.js
|
||||||
|
|
||||||
`paged.polyfill.min.js` é o polyfill do [Paged.js](https://pagedjs.org/), versão
|
`paged.polyfill.min.js` is the [Paged.js](https://pagedjs.org/) polyfill, version
|
||||||
0.4.3, usado para paginar os relatórios em A4 no navegador.
|
0.4.3, used to paginate A4 reports in the browser.
|
||||||
|
|
||||||
- Projeto: https://gitlab.coko.foundation/pagedjs/pagedjs
|
- Project: https://gitlab.coko.foundation/pagedjs/pagedjs
|
||||||
- Licença: MIT (texto em `PAGEDJS-LICENSE`)
|
- License: MIT (text in `PAGEDJS-LICENSE`)
|
||||||
- Arquivo: `paged.polyfill.min.js` (build de distribuição, não modificado)
|
- 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.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "sabia-cli"
|
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
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use std::time::Duration;
|
|||||||
version
|
version
|
||||||
)]
|
)]
|
||||||
struct Cli {
|
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")]
|
#[arg(long, global = true, default_value = ".sabia-audit.log")]
|
||||||
audit: PathBuf,
|
audit: PathBuf,
|
||||||
|
|
||||||
@@ -26,95 +26,95 @@ struct Cli {
|
|||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum Cmd {
|
enum Cmd {
|
||||||
/// Lista interfaces, canais e controla o modo monitor.
|
/// List interfaces, channels, and toggle monitor mode.
|
||||||
Iface {
|
Iface {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
action: IfaceAction,
|
action: IfaceAction,
|
||||||
},
|
},
|
||||||
/// Captura o ar e mostra redes, clientes e handshakes.
|
/// Capture live traffic and display networks, clients, and handshakes.
|
||||||
Scan {
|
Scan {
|
||||||
iface: String,
|
iface: String,
|
||||||
/// Canal fixo; sem isso, percorre os canais suportados.
|
/// Fixed channel; when omitted, hops across supported channels.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
channel: Option<u32>,
|
channel: Option<u32>,
|
||||||
/// Banda ao percorrer canais: 2.4, 5, 6 ou all.
|
/// Frequency band when hopping channels: `2.4`, `5`, `6`, or `all`.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
band: Option<String>,
|
band: Option<String>,
|
||||||
/// Tempo em cada canal (ms).
|
/// Channel dwell time in milliseconds.
|
||||||
#[arg(long, default_value_t = 350)]
|
#[arg(long, default_value_t = 350)]
|
||||||
hop_ms: u64,
|
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)]
|
#[arg(long)]
|
||||||
seconds: Option<u64>,
|
seconds: Option<u64>,
|
||||||
/// Salva os quadros capturados em um .pcap.
|
/// Save captured frames to a `.pcap` file.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
out: Option<PathBuf>,
|
out: Option<PathBuf>,
|
||||||
/// Depois de capturar, tenta quebrar o primeiro handshake.
|
/// After capturing, attempt a wordlist crack on the first handshake.
|
||||||
#[arg(long, value_name = "DICIONARIO")]
|
#[arg(long, value_name = "DICIONARIO")]
|
||||||
crack: Option<PathBuf>,
|
crack: Option<PathBuf>,
|
||||||
/// Mostra apenas o JSON final.
|
/// Output only the final JSON summary.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
json: bool,
|
json: bool,
|
||||||
},
|
},
|
||||||
/// Analisa um .pcap offline (redes, clientes e handshakes).
|
/// Analyze a `.pcap` file offline (networks, clients, and handshakes).
|
||||||
Analyze {
|
Analyze {
|
||||||
pcap: PathBuf,
|
pcap: PathBuf,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
json: bool,
|
json: bool,
|
||||||
},
|
},
|
||||||
/// Quebra um handshake de um .pcap por dicionário.
|
/// Run a dictionary attack against a handshake in a `.pcap` file.
|
||||||
Crack {
|
Crack {
|
||||||
pcap: PathBuf,
|
pcap: PathBuf,
|
||||||
/// Dicionário com uma senha por linha.
|
/// Wordlist file (one candidate passphrase per line).
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
wordlist: PathBuf,
|
wordlist: PathBuf,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
bssid: Option<String>,
|
bssid: Option<String>,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
station: Option<String>,
|
station: Option<String>,
|
||||||
/// Aplica mutações comuns a cada palavra (maiúscula, sufixos
|
/// Apply common mutation rules to each word (capitalization, numeric
|
||||||
/// numéricos, leet simples) — multiplica o tempo de verdade.
|
/// suffixes, simple leetspeak) — multiplies total runtime.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
rules: bool,
|
rules: bool,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
json: bool,
|
json: bool,
|
||||||
},
|
},
|
||||||
/// Envia desautenticação para um AP/estação (teste autorizado).
|
/// Send deauthentication frames to an AP/station (authorized testing only).
|
||||||
Deauth {
|
Deauth {
|
||||||
iface: String,
|
iface: String,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
bssid: String,
|
bssid: String,
|
||||||
/// Sem isso, o destino é broadcast.
|
/// Target client MAC; defaults to broadcast when omitted.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
station: Option<String>,
|
station: Option<String>,
|
||||||
#[arg(long, default_value_t = 5)]
|
#[arg(long, default_value_t = 5)]
|
||||||
count: u32,
|
count: u32,
|
||||||
#[arg(long, default_value_t = 7)]
|
#[arg(long, default_value_t = 7)]
|
||||||
reason: u16,
|
reason: u16,
|
||||||
/// Confirma que você tem autorização para testar esta rede.
|
/// Confirm explicit authorization to test this target network.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
yes: bool,
|
yes: bool,
|
||||||
},
|
},
|
||||||
/// Diagnóstico de privilégios, ferramentas e interfaces.
|
/// Diagnose privileges, external tools, and wireless interfaces.
|
||||||
Doctor {
|
Doctor {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
json: bool,
|
json: bool,
|
||||||
},
|
},
|
||||||
/// Inspeciona a trilha de auditoria.
|
/// Inspect and verify the hash-chained audit trail.
|
||||||
Audit {
|
Audit {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
action: AuditAction,
|
action: AuditAction,
|
||||||
},
|
},
|
||||||
/// Verifica e aplica atualizações do próprio sabia.
|
/// Check for and apply `sabia` CLI updates.
|
||||||
Update {
|
Update {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
apply: bool,
|
apply: bool,
|
||||||
},
|
},
|
||||||
/// Abre a interface gráfica (servidor local + navegador).
|
/// Launch the web UI (local HTTP server + browser).
|
||||||
Ui {
|
Ui {
|
||||||
#[arg(long, default_value = "4337")]
|
#[arg(long, default_value = "4337")]
|
||||||
port: u16,
|
port: u16,
|
||||||
/// Não tenta abrir o navegador automaticamente.
|
/// Do not open the browser automatically.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
no_open: bool,
|
no_open: bool,
|
||||||
},
|
},
|
||||||
@@ -123,13 +123,13 @@ enum Cmd {
|
|||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum IfaceAction {
|
enum IfaceAction {
|
||||||
List,
|
List,
|
||||||
/// Mostra os canais suportados pelo rádio.
|
/// List channels supported by the wireless phy.
|
||||||
Channels {
|
Channels {
|
||||||
iface: String,
|
iface: String,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
json: bool,
|
json: bool,
|
||||||
},
|
},
|
||||||
/// Liga/desliga modo monitor numa interface Wi-Fi.
|
/// Enable or disable monitor mode on a Wi-Fi interface.
|
||||||
Monitor {
|
Monitor {
|
||||||
iface: String,
|
iface: String,
|
||||||
#[arg(value_enum)]
|
#[arg(value_enum)]
|
||||||
@@ -139,9 +139,9 @@ enum IfaceAction {
|
|||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum AuditAction {
|
enum AuditAction {
|
||||||
/// Confere a integridade da cadeia.
|
/// Verify the cryptographic hash chain integrity.
|
||||||
Verify { path: Option<PathBuf> },
|
Verify { path: Option<PathBuf> },
|
||||||
/// Mostra as últimas entradas.
|
/// Show the last N entries.
|
||||||
Show {
|
Show {
|
||||||
#[arg(long, default_value_t = 20)]
|
#[arg(long, default_value_t = 20)]
|
||||||
limit: usize,
|
limit: usize,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! Servidor local da interface: uma única página HTML/JS servida em
|
//! Local web UI server: serves the single-page HTML/JS app on
|
||||||
//! `127.0.0.1`, protegida por um token gerado na hora, falando com o
|
//! `127.0.0.1`, protected by a per-session random token, communicating with
|
||||||
//! núcleo por uma API JSON simples. A mesma página em `apps/gui/ui` é o
|
//! the core over JSON endpoints. The exact same frontend in `apps/gui/ui`
|
||||||
//! que o app desktop (Tauri) carrega — só muda o transporte.
|
//! powers the Tauri desktop app.
|
||||||
|
|
||||||
use sabia_core::engine::{Engine, ScanOptions};
|
use sabia_core::engine::{Engine, ScanOptions};
|
||||||
use serde::Deserialize;
|
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 listener = TcpListener::bind(("127.0.0.1", port))?;
|
||||||
let engine = Arc::new(Engine::with_audit_path(audit_path));
|
let engine = Arc::new(Engine::with_audit_path(audit_path));
|
||||||
|
|
||||||
// Sem isso, Ctrl+C (ou um `kill` normal) durante um Evil Twin ativo
|
// Without a signal handler, Ctrl+C (or `SIGTERM`) during an active Evil Twin
|
||||||
// mata o processo sem rodar a limpeza: hostapd/dnsmasq ficam órfãos
|
// exits before cleanup runs: `hostapd`/`dnsmasq` are left orphaned
|
||||||
// e as duas interfaces ficam presas em `managed no` pro
|
// and both interfaces remain stuck in `managed no` under
|
||||||
// NetworkManager — o painel de Wi-Fi do sistema some por completo até
|
// NetworkManager until the next engine startup runs self-healing.
|
||||||
// o próximo início do engine se auto-curar.
|
//
|
||||||
{
|
{
|
||||||
let engine = engine.clone();
|
let engine = engine.clone();
|
||||||
let _ = ctrlc::set_handler(move || {
|
let _ = ctrlc::set_handler(move || {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "sabia-core"
|
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
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
@@ -25,5 +25,5 @@ flate2 = { version = "1", default-features = false, features = ["rust_backend"]
|
|||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|
||||||
[target.'cfg(not(target_os = "linux"))'.dependencies]
|
[target.'cfg(not(target_os = "linux"))'.dependencies]
|
||||||
# captura crua (AF_PACKET) só existe no Linux; outras plataformas compilam
|
# Raw capture (AF_PACKET) is Linux-only; other platforms compile the crate
|
||||||
# a crate mas os módulos de captura ficam indisponíveis em tempo de execução.
|
# while live capture modules remain unavailable at runtime.
|
||||||
@@ -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::radiotap::{self, Band};
|
||||||
use crate::scan::AccessPoint;
|
use crate::scan::AccessPoint;
|
||||||
@@ -280,8 +280,8 @@ pub fn analyze(aps: &[AccessPoint], deauth_60s: u32, now: u64) -> Analysis {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// SSID anunciado com seguranças diferentes é o sinal clássico de evil
|
// Same SSID advertised with mixed security suites (e.g. WPA2 alongside Open)
|
||||||
// twin — legítimo costuma manter o mesmo conjunto em todos os rádios.
|
// is a classic Evil Twin indicator.
|
||||||
let mut by_ssid: HashMap<String, Vec<&AccessPoint>> = HashMap::new();
|
let mut by_ssid: HashMap<String, Vec<&AccessPoint>> = HashMap::new();
|
||||||
for ap in aps {
|
for ap in aps {
|
||||||
if let Some(ssid) = ap.ssid.as_deref().filter(|s| !s.is_empty()) {
|
if let Some(ssid) = ap.ssid.as_deref().filter(|s| !s.is_empty()) {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
//! Trilha de auditoria encadeada por hash de toda ação sensível (ligar
|
//! Hash-chained audit trail of every sensitive operation (enabling
|
||||||
//! modo monitor, iniciar captura, trocar canal, etc.) — mesmo princípio do
|
//! monitor mode, starting capture, changing channels, active attacks):
|
||||||
//! módulo `audit` do Guará: cada evento guarda o hash do anterior, então
|
//! each entry commits to the SHA-256 of the previous entry so any
|
||||||
//! qualquer edição retroativa do log quebra a cadeia e é detectável.
|
//! 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 crate::error::Result;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -85,7 +85,7 @@ impl AuditLog {
|
|||||||
Ok(entry)
|
Ok(entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lê as entradas da trilha, opcionalmente só as `limit` mais recentes.
|
/// Reads entries from the audit log, optionally returning only the last `limit` entries.
|
||||||
pub fn read_entries(path: impl AsRef<Path>, limit: Option<usize>) -> Result<Vec<AuditEntry>> {
|
pub fn read_entries(path: impl AsRef<Path>, limit: Option<usize>) -> Result<Vec<AuditEntry>> {
|
||||||
let file = OpenOptions::new().read(true).open(path)?;
|
let file = OpenOptions::new().read(true).open(path)?;
|
||||||
let reader = BufReader::new(file);
|
let reader = BufReader::new(file);
|
||||||
@@ -105,8 +105,8 @@ impl AuditLog {
|
|||||||
Ok(entries)
|
Ok(entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verifica a integridade de toda a cadeia; retorna o índice da
|
/// Verifies the integrity of the entire hash chain; returns the index of
|
||||||
/// primeira entrada inválida, se houver.
|
/// the first invalid entry if tampered.
|
||||||
pub fn verify(path: impl AsRef<Path>) -> Result<Option<usize>> {
|
pub fn verify(path: impl AsRef<Path>) -> Result<Option<usize>> {
|
||||||
let file = OpenOptions::new().read(true).open(path)?;
|
let file = OpenOptions::new().read(true).open(path)?;
|
||||||
let reader = BufReader::new(file);
|
let reader = BufReader::new(file);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
//! Captura crua de quadros 802.11 via `AF_PACKET`, sem depender de libpcap.
|
//! Raw 802.11 frame capture via Linux `AF_PACKET` sockets without requiring `libpcap`.
|
||||||
|
//!
|
||||||
|
//! The interface must already be in monitor mode (see [`crate::netlink`]).
|
||||||
|
//! Uses the same kernel packet socket interface that `libpcap` uses on Linux
|
||||||
|
//! without adding an external system library dependency.
|
||||||
//!
|
//!
|
||||||
//! A interface já precisa estar em modo monitor (ver [`crate::netlink`]).
|
|
||||||
//! Isso só funciona em Linux — é exatamente o que o libpcap faz por baixo
|
|
||||||
//! quando usado em modo monitor, então reimplementar aqui é pouco código e
|
|
||||||
//! remove uma dependência de sistema.
|
|
||||||
|
|
||||||
#![cfg(target_os = "linux")]
|
#![cfg(target_os = "linux")]
|
||||||
|
|
||||||
@@ -21,8 +21,8 @@ pub struct RawCapture {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RawCapture {
|
impl RawCapture {
|
||||||
/// Abre um socket raw ligado à interface `iface` (já em modo monitor).
|
/// Opens a raw `AF_PACKET` socket bound to `iface` (must already be in monitor mode).
|
||||||
/// Exige `CAP_NET_RAW` (tipicamente root).
|
/// Requires `CAP_NET_RAW` (typically root).
|
||||||
pub fn open(iface: &str) -> Result<Self> {
|
pub fn open(iface: &str) -> Result<Self> {
|
||||||
let fd = unsafe { libc::socket(libc::AF_PACKET, libc::SOCK_RAW, ETH_P_ALL as i32) };
|
let fd = unsafe { libc::socket(libc::AF_PACKET, libc::SOCK_RAW, ETH_P_ALL as i32) };
|
||||||
if fd < 0 {
|
if fd < 0 {
|
||||||
@@ -57,8 +57,8 @@ impl RawCapture {
|
|||||||
Ok(Self { fd })
|
Ok(Self { fd })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Define um timeout de recepção, para o loop de captura poder checar
|
/// Sets `SO_RCVTIMEO` so the capture loop can periodically check its stop flag
|
||||||
/// periodicamente um sinal de parada sem travar para sempre em `recv`.
|
/// without blocking indefinitely in `recv`.
|
||||||
pub fn set_read_timeout(&self, timeout: Duration) -> Result<()> {
|
pub fn set_read_timeout(&self, timeout: Duration) -> Result<()> {
|
||||||
let tv = libc::timeval {
|
let tv = libc::timeval {
|
||||||
tv_sec: timeout.as_secs() as libc::time_t,
|
tv_sec: timeout.as_secs() as libc::time_t,
|
||||||
@@ -79,7 +79,7 @@ impl RawCapture {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lê um quadro. Retorna `Ok(None)` em timeout (ver `set_read_timeout`).
|
/// Reads a single frame. Returns `Ok(None)` on receive timeout (see `set_read_timeout`).
|
||||||
pub fn recv(&self, buf: &mut [u8]) -> Result<Option<usize>> {
|
pub fn recv(&self, buf: &mut [u8]) -> Result<Option<usize>> {
|
||||||
let n = unsafe { libc::recv(self.fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len(), 0) };
|
let n = unsafe { libc::recv(self.fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len(), 0) };
|
||||||
if n < 0 {
|
if n < 0 {
|
||||||
@@ -114,8 +114,8 @@ fn if_index(name: &str) -> Result<i32> {
|
|||||||
Ok(idx as i32)
|
Ok(idx as i32)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Garante, de forma best-effort, que o processo tem privilégio para abrir
|
/// Best-effort check that the process has privileges to open raw packet sockets.
|
||||||
/// sockets raw — útil para dar um erro claro antes de tentar o resto.
|
///
|
||||||
pub fn check_capabilities() -> bool {
|
pub fn check_capabilities() -> bool {
|
||||||
let euid = unsafe { libc::geteuid() };
|
let euid = unsafe { libc::geteuid() };
|
||||||
euid == 0
|
euid == 0
|
||||||
|
|||||||
@@ -1,31 +1,31 @@
|
|||||||
//! Decriptação CCMP (WPA2-AES) de quadros de dados, usando a TK já derivada
|
//! WPA2 CCMP (AES-CTR) data frame decryption using the Temporal Key (TK) derived
|
||||||
//! de um handshake quebrado.
|
//! 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::cipher::{BlockEncrypt, KeyInit};
|
||||||
use aes::Aes128;
|
use aes::Aes128;
|
||||||
|
|
||||||
/// Decifra o corpo de um quadro de dados CCMP, a partir do cabeçalho CCMP
|
/// Decrypts a CCMP-protected data frame payload starting at the 8-byte CCMP header
|
||||||
/// (8 bytes) em diante — ou seja, `body` é `frame.body[frame.header_len..]`.
|
/// (`frame.body[frame.header_len..]`).
|
||||||
/// `transmitter` é o endereço 2 (A2) do quadro — o rádio que o enviou, não o
|
/// `transmitter` is Address 2 (`A2`), and `qos_tid` is the QoS TID (`0` for non-QoS).
|
||||||
/// BSSID/estação lógicos. `qos_tid` é o TID do QoS Control (0 se não-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(
|
pub fn decrypt_data(
|
||||||
tk: &[u8; 16],
|
tk: &[u8; 16],
|
||||||
transmitter: &[u8; 6],
|
transmitter: &[u8; 6],
|
||||||
@@ -40,8 +40,8 @@ pub fn decrypt_data(
|
|||||||
let pn0 = body[0];
|
let pn0 = body[0];
|
||||||
let pn1 = body[1];
|
let pn1 = body[1];
|
||||||
let key_id_octet = body[3];
|
let key_id_octet = body[3];
|
||||||
// ExtIV (bit 5) precisa estar ligado — senão não é um cabeçalho CCMP de
|
// ExtIV (bit 5) must be set for a valid 8-byte CCMP IV header.
|
||||||
// verdade (podia ser WEP de 4 bytes por coincidência de layout).
|
//
|
||||||
if key_id_octet & 0x20 == 0 {
|
if key_id_octet & 0x20 == 0 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -53,8 +53,8 @@ pub fn decrypt_data(
|
|||||||
let ciphertext_and_mic = &body[HEADER_LEN..];
|
let ciphertext_and_mic = &body[HEADER_LEN..];
|
||||||
let ciphertext = &ciphertext_and_mic[..ciphertext_and_mic.len() - MIC_LEN];
|
let ciphertext = &ciphertext_and_mic[..ciphertext_and_mic.len() - MIC_LEN];
|
||||||
|
|
||||||
// Nonce CCMP (13 bytes): flags/prioridade (1) || A2 (6) || PN, do byte
|
// 13-byte CCMP nonce: `flags/priority (1) || A2 (6) || PN5..PN0 (6)`.
|
||||||
// mais significativo (PN5) ao menos significativo (PN0).
|
//
|
||||||
let mut nonce = [0u8; 13];
|
let mut nonce = [0u8; 13];
|
||||||
nonce[0] = qos_tid & 0x0f;
|
nonce[0] = qos_tid & 0x0f;
|
||||||
nonce[1..7].copy_from_slice(transmitter);
|
nonce[1..7].copy_from_slice(transmitter);
|
||||||
@@ -67,10 +67,10 @@ pub fn decrypt_data(
|
|||||||
|
|
||||||
let cipher = Aes128::new(tk.into());
|
let cipher = Aes128::new(tk.into());
|
||||||
let mut out = Vec::with_capacity(ciphertext.len());
|
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) {
|
for chunk in ciphertext.chunks(16) {
|
||||||
let mut block = [0u8; 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[1..14].copy_from_slice(&nonce);
|
||||||
block[14..16].copy_from_slice(&counter.to_be_bytes());
|
block[14..16].copy_from_slice(&counter.to_be_bytes());
|
||||||
let mut keystream = block.into();
|
let mut keystream = block.into();
|
||||||
@@ -87,9 +87,9 @@ pub fn decrypt_data(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// AES-128 do FIPS-197 (vetor de referência oficial do NIST): confirma
|
/// NIST FIPS-197 Appendix B AES-128 test vector.
|
||||||
/// que a cifra de bloco usada embaixo está correta antes de confiar no
|
///
|
||||||
/// contador CTR por cima dela.
|
///
|
||||||
#[test]
|
#[test]
|
||||||
fn aes128_matches_fips197_vector() {
|
fn aes128_matches_fips197_vector() {
|
||||||
let key: [u8; 16] = [
|
let key: [u8; 16] = [
|
||||||
@@ -110,19 +110,19 @@ mod tests {
|
|||||||
assert_eq!(block.as_slice(), &expected);
|
assert_eq!(block.as_slice(), &expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Round-trip de auto-consistência: decifrar um texto que a própria
|
/// CTR symmetry round-trip: encrypting and decrypting with the same CCMP
|
||||||
/// função "cifrou" (mesmo algoritmo, usado nos dois sentidos — CTR é
|
/// keystream recovers the original LLC/SNAP payload.
|
||||||
/// 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.
|
///
|
||||||
#[test]
|
#[test]
|
||||||
fn ctr_round_trip_recovers_plaintext() {
|
fn ctr_round_trip_recovers_plaintext() {
|
||||||
let tk = [0x42u8; 16];
|
let tk = [0x42u8; 16];
|
||||||
let transmitter = [0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff];
|
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";
|
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
|
// Build a synthetic CCMP payload using the symmetric CTR keystream.
|
||||||
// keystream (ela é simétrica: XOR com o keystream cifra e decifra).
|
//
|
||||||
let header = [0x11u8, 0x22, 0x00, 0x20, 0x33, 0x44, 0x55, 0x66]; // ExtIV ligado
|
let header = [0x11u8, 0x22, 0x00, 0x20, 0x33, 0x44, 0x55, 0x66]; // ExtIV bit set
|
||||||
let mut nonce = [0u8; 13];
|
let mut nonce = [0u8; 13];
|
||||||
nonce[0] = 3; // TID
|
nonce[0] = 3; // TID
|
||||||
nonce[1..7].copy_from_slice(&transmitter);
|
nonce[1..7].copy_from_slice(&transmitter);
|
||||||
@@ -147,7 +147,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
counter = counter.wrapping_add(1);
|
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();
|
let mut body = Vec::new();
|
||||||
body.extend_from_slice(&header);
|
body.extend_from_slice(&header);
|
||||||
@@ -160,7 +160,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn rejects_body_without_extiv_bit() {
|
fn rejects_body_without_extiv_bit() {
|
||||||
let tk = [0u8; 16];
|
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());
|
assert!(decrypt_data(&tk, &[0; 6], 0, &body).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
//! * **EAPOL (4-way handshake):** derives the PMK via PBKDF2-HMAC-SHA1
|
||||||
//! (4096 iterações), expande a PTK com o PRF do 802.11i e recalcula o MIC da
|
//! (4096 iterations), expands the PTK via IEEE 802.11i PRF, and verifies the
|
||||||
//! mensagem 2.
|
//! message 2 MIC.
|
||||||
//! * **PMKID:** recalcula `HMAC-SHA1(PMK, "PMK Name" || AP || STA)` direto da
|
//! * **PMKID:** computes `HMAC-SHA1(PMK, "PMK Name" || AP || STA)` directly from
|
||||||
//! mensagem 1 — não precisa do handshake completo.
|
//! message 1 without requiring a full 4-way handshake.
|
||||||
//!
|
//!
|
||||||
//! É CPU puro, sem GPU: suficiente para a v0.1.0 e para dicionários comuns,
|
//! Runs multi-threaded on the CPU; for large GPU-accelerated attacks, export
|
||||||
//! mas não substitui o hashcat em ataques de força bruta grandes.
|
//! `.hc22000` via [`crate::hashcat`].
|
||||||
|
|
||||||
use crate::dot11::parse_mac;
|
use crate::dot11::parse_mac;
|
||||||
use crate::error::{Result, SabiaError};
|
use crate::error::{Result, SabiaError};
|
||||||
@@ -33,9 +33,9 @@ type HmacMd5 = Hmac<Md5>;
|
|||||||
|
|
||||||
const WPA_ITERATIONS: u32 = 4096;
|
const WPA_ITERATIONS: u32 = 4096;
|
||||||
|
|
||||||
/// `.gz` do dicionário embutido — descomprimido só na primeira vez que é
|
/// Gzip-compressed built-in wordlist (`~39 MB` compressed -> `~101 MB` raw),
|
||||||
/// usado (ver [`default_wordlist`]). Cru são ~101MB; comprimido, ~39MB no
|
/// decompressed lazily on first use (see [`default_wordlist`]).
|
||||||
/// binário em vez de ~101MB.
|
///
|
||||||
const DEFAULT_WORDLIST_GZ: &[u8] = include_bytes!("../assets/wordlists/wpa-full.txt.gz");
|
const DEFAULT_WORDLIST_GZ: &[u8] = include_bytes!("../assets/wordlists/wpa-full.txt.gz");
|
||||||
|
|
||||||
pub const DEFAULT_WORDLIST_SOURCE: &str =
|
pub const DEFAULT_WORDLIST_SOURCE: &str =
|
||||||
@@ -49,17 +49,17 @@ static DEFAULT_WORDLIST_CACHE: LazyLock<String> = LazyLock::new(|| {
|
|||||||
out
|
out
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Lista embutida de senhas prováveis de Wi-Fi, usada quando o usuário não
|
/// Built-in WPA wordlist (9,703,831 deduplicated 8–63 char ASCII passwords
|
||||||
/// escolhe um dicionário próprio. Não depende de arquivo externo: é a lista
|
/// combining Probable-Wordlists and `rockyou.txt` ordered by probability).
|
||||||
/// mais completa que o Sabiá consegue embutir sem exigir download —
|
/// See `crates/sabia-core/assets/wordlists/LICENSE.wpa-full` for attribution.
|
||||||
/// 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.
|
///
|
||||||
pub fn default_wordlist() -> &'static str {
|
pub fn default_wordlist() -> &'static str {
|
||||||
&DEFAULT_WORDLIST_CACHE
|
&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] {
|
pub fn derive_pmk(passphrase: &str, ssid: &str) -> [u8; 32] {
|
||||||
let dk = pbkdf2_sha1(passphrase.as_bytes(), ssid.as_bytes(), WPA_ITERATIONS, 32);
|
let dk = pbkdf2_sha1(passphrase.as_bytes(), ssid.as_bytes(), WPA_ITERATIONS, 32);
|
||||||
let mut pmk = [0u8; 32];
|
let mut pmk = [0u8; 32];
|
||||||
@@ -150,10 +150,10 @@ pub fn derive_pmk(passphrase: &str, ssid: &str) -> [u8; 32] {
|
|||||||
pmk
|
pmk
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Aceita uma frase-senha (8–63 caracteres) ou uma PMK crua em hexadecimal
|
/// Resolves either an 8–63 character WPA passphrase or a raw 64-hex-character PMK.
|
||||||
/// (64 caracteres, como no hashcat/aircrack). `pub(crate)` porque
|
/// Shared with `handshake.rs` when deriving the CCMP Temporal Key (TK) from a known password.
|
||||||
/// `handshake.rs` reusa exatamente essa regra ao derivar a TK de uma senha
|
///
|
||||||
/// já quebrada (o campo `password` aceita as duas formas, igual aqui).
|
///
|
||||||
pub(crate) fn pmk_from_candidate(candidate: &str, ssid: &[u8]) -> Option<[u8; 32]> {
|
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()) {
|
if candidate.len() == 64 && candidate.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||||
return hex_to_32(candidate.as_bytes());
|
return hex_to_32(candidate.as_bytes());
|
||||||
@@ -170,10 +170,10 @@ pub(crate) fn pmk_from_candidate(candidate: &str, ssid: &[u8]) -> Option<[u8; 32
|
|||||||
pub fn pbkdf2_sha1(password: &[u8], salt: &[u8], iterations: u32, out_len: usize) -> Vec<u8> {
|
pub fn pbkdf2_sha1(password: &[u8], salt: &[u8], iterations: u32, out_len: usize) -> Vec<u8> {
|
||||||
let iterations = iterations.max(1);
|
let iterations = iterations.max(1);
|
||||||
let mut out = Vec::with_capacity(out_len);
|
let mut out = Vec::with_capacity(out_len);
|
||||||
// O par ipad/opad depende só da senha — monta uma vez e clona a cada
|
// Precompute the HMAC inner/outer SHA-1 states once per passphrase and clone
|
||||||
// rodada (clone é só copiar os dois blocos já prontos) em vez de montar
|
// across all 4096 PBKDF2 rounds to avoid re-hashing the key block each iteration.
|
||||||
// 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.
|
//
|
||||||
let base = HmacSha1::new_from_slice(password).expect("HMAC aceita qualquer chave");
|
let base = HmacSha1::new_from_slice(password).expect("HMAC aceita qualquer chave");
|
||||||
let mut block: u32 = 1;
|
let mut block: u32 = 1;
|
||||||
while out.len() < out_len {
|
while out.len() < out_len {
|
||||||
@@ -213,7 +213,7 @@ fn prf_sha1(key: &[u8], label: &[u8], data: &[u8], out_len: usize) -> Vec<u8> {
|
|||||||
out
|
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(
|
pub fn compute_ptk(
|
||||||
pmk: &[u8; 32],
|
pmk: &[u8; 32],
|
||||||
bssid: &[u8; 6],
|
bssid: &[u8; 6],
|
||||||
@@ -314,14 +314,14 @@ fn valid_candidate(candidate: &str) -> bool {
|
|||||||
|| (candidate.len() == 64 && candidate.bytes().all(|b| b.is_ascii_hexdigit()))
|
|| (candidate.len() == 64 && candidate.bytes().all(|b| b.is_ascii_hexdigit()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tenta cada linha do dicionário contra o alvo, dividindo o trabalho entre
|
/// Tests wordlist candidates against `target` across all available CPU threads.
|
||||||
/// as threads disponíveis (PBKDF2-HMAC-SHA1 de 4096 iterações é CPU puro e
|
/// Invokes `progress` (~8x/s) with the number of tested candidates. When
|
||||||
/// cada candidato é independente — ganho quase linear com o número de
|
/// `apply_rules` is enabled, expands each base word via [`rules::expand`].
|
||||||
/// 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.
|
///
|
||||||
pub fn crack_reader<R: BufRead, F: FnMut(u64) + Send>(
|
pub fn crack_reader<R: BufRead, F: FnMut(u64) + Send>(
|
||||||
target: &CrackTarget,
|
target: &CrackTarget,
|
||||||
mut wordlist: R,
|
mut wordlist: R,
|
||||||
@@ -330,9 +330,9 @@ pub fn crack_reader<R: BufRead, F: FnMut(u64) + Send>(
|
|||||||
) -> CrackReport {
|
) -> CrackReport {
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
|
|
||||||
// Carrega a lista inteira antes de começar: dá pra repartir em pedaços
|
// Load candidates upfront and partition into contiguous per-thread slices.
|
||||||
// contíguos por thread (melhor localidade de cache que round-robin) e o
|
//
|
||||||
// próprio dicionário embutido cabe de sobra em memória.
|
//
|
||||||
let mut candidates: Vec<String> = Vec::new();
|
let mut candidates: Vec<String> = Vec::new();
|
||||||
let mut line = String::new();
|
let mut line = String::new();
|
||||||
loop {
|
loop {
|
||||||
@@ -384,8 +384,8 @@ pub fn crack_reader<R: BufRead, F: FnMut(u64) + Send>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Thread "repórter": roda dentro do mesmo scope só pra poder
|
// Progress reporter thread scoped to borrow `progress` (`FnMut`) safely.
|
||||||
// emprestar `progress` (FnMut, chamado de um único lugar).
|
//
|
||||||
scope.spawn(|| loop {
|
scope.spawn(|| loop {
|
||||||
std::thread::sleep(std::time::Duration::from_millis(120));
|
std::thread::sleep(std::time::Duration::from_millis(120));
|
||||||
let n = tried.load(Ordering::Relaxed);
|
let n = tried.load(Ordering::Relaxed);
|
||||||
@@ -426,7 +426,7 @@ pub fn crack_handshake<F: FnMut(u64) + Send>(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Quebra usando a lista embutida.
|
/// Runs a dictionary crack using the built-in wordlist.
|
||||||
pub fn crack_handshake_default<F: FnMut(u64) + Send>(
|
pub fn crack_handshake_default<F: FnMut(u64) + Send>(
|
||||||
hs: &Handshake,
|
hs: &Handshake,
|
||||||
apply_rules: bool,
|
apply_rules: bool,
|
||||||
@@ -449,7 +449,7 @@ fn target_for(hs: &Handshake) -> Result<CrackTarget> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lê um `.pcap`, monta os handshakes e tenta quebrar o primeiro alvo útil.
|
/// Parses a `.pcap` file, assembles handshakes, and cracks the first crackable target.
|
||||||
pub fn crack_pcap<F: FnMut(u64) + Send>(
|
pub fn crack_pcap<F: FnMut(u64) + Send>(
|
||||||
pcap: impl AsRef<Path>,
|
pcap: impl AsRef<Path>,
|
||||||
wordlist: impl AsRef<Path>,
|
wordlist: impl AsRef<Path>,
|
||||||
@@ -474,11 +474,11 @@ pub fn crack_pcap<F: FnMut(u64) + Send>(
|
|||||||
crack_handshake(hs, wordlist, apply_rules, progress)
|
crack_handshake(hs, wordlist, apply_rules, progress)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Força bruta por máscara (estilo hashcat `-a 3`): gera e testa cada
|
/// Mask brute-force attack (hashcat `-a 3` style): generates candidates on the fly
|
||||||
/// combinação possível, sem depender de dicionário. Mesma paralelização por
|
/// by mixed-radix index across all CPU threads without materializing the keyspace in RAM.
|
||||||
/// 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.
|
///
|
||||||
pub fn crack_mask<F: FnMut(u64) + Send>(
|
pub fn crack_mask<F: FnMut(u64) + Send>(
|
||||||
target: &CrackTarget,
|
target: &CrackTarget,
|
||||||
mask: &Mask,
|
mask: &Mask,
|
||||||
@@ -546,7 +546,7 @@ pub fn crack_mask<F: FnMut(u64) + Send>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Quebra por máscara a partir de um handshake já montado.
|
/// Runs a mask brute-force attack against an assembled `Handshake`.
|
||||||
pub fn crack_handshake_mask<F: FnMut(u64) + Send>(
|
pub fn crack_handshake_mask<F: FnMut(u64) + Send>(
|
||||||
hs: &Handshake,
|
hs: &Handshake,
|
||||||
mask: &Mask,
|
mask: &Mask,
|
||||||
@@ -562,8 +562,8 @@ mod tests {
|
|||||||
use crate::testutil::{handshake_frames, synthetic_handshake};
|
use crate::testutil::{handshake_frames, synthetic_handshake};
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
|
|
||||||
/// Mede só o tempo de descomprimir o dicionário embutido (gzip, ~39MB -> ~101MB),
|
/// Benchmarks one-time decompression of the built-in gzip wordlist (`~39 MB -> ~101 MB`).
|
||||||
/// que acontece uma vez, na primeira chamada a `default_wordlist()`.
|
///
|
||||||
#[test]
|
#[test]
|
||||||
#[ignore]
|
#[ignore]
|
||||||
fn bench_default_wordlist_decompress() {
|
fn bench_default_wordlist_decompress() {
|
||||||
@@ -577,17 +577,17 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Não é teste de verdade — mede candidatos/segundo pra guiar otimização
|
/// Throughput benchmark (`cargo test --release -p sabia-core -- --ignored --nocapture bench_crack_throughput`).
|
||||||
/// de performance. `cargo test --release -p sabia-core -- --ignored
|
///
|
||||||
/// --nocapture bench_crack_throughput`.
|
///
|
||||||
#[test]
|
#[test]
|
||||||
#[ignore]
|
#[ignore]
|
||||||
fn bench_crack_throughput() {
|
fn bench_crack_throughput() {
|
||||||
let (m1, m2) = handshake_frames("IEEE", "senha-que-nao-esta-na-lista", 2, false);
|
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 hs = synthetic_handshake("IEEE", "00:11:22:33:44:55", "66:77:88:99:aa:bb", &m1, &m2);
|
||||||
let target = CrackTarget::from_handshake(&hs).expect("alvo");
|
let target = CrackTarget::from_handshake(&hs).expect("alvo");
|
||||||
// Só uma fatia: a lista inteira (9,7 milhões) levaria minutos — o
|
// Benchmark a 20,000-password slice to measure candidates/sec quickly.
|
||||||
// objetivo aqui é medir candidatos/s, não o tempo total.
|
//
|
||||||
let sample: String = default_wordlist()
|
let sample: String = default_wordlist()
|
||||||
.lines()
|
.lines()
|
||||||
.take(500_000)
|
.take(500_000)
|
||||||
@@ -677,8 +677,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rules_find_a_mutated_variant_not_present_verbatim() {
|
fn rules_find_a_mutated_variant_not_present_verbatim() {
|
||||||
// A senha de verdade é "wifi12345" — não está na lista, mas é um
|
// True password `"wifi12345"` is derived from `"wifi"` via `rules::expand`.
|
||||||
// sufixo numérico de "wifi" que `rules::expand` gera.
|
//
|
||||||
let (m1, m2) = handshake_frames("IEEE", "wifi12345", 2, false);
|
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 hs = synthetic_handshake("IEEE", "00:11:22:33:44:55", "66:77:88:99:aa:bb", &m1, &m2);
|
||||||
let target = CrackTarget::from_handshake(&hs).expect("alvo");
|
let target = CrackTarget::from_handshake(&hs).expect("alvo");
|
||||||
@@ -692,10 +692,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mask_crack_finds_a_numeric_password() {
|
fn mask_crack_finds_a_numeric_password() {
|
||||||
// Máscara minúscula de propósito (só 10 combinações): cada candidato
|
// Small 10-candidate mask (`wifi123?d`) to keep unit tests fast.
|
||||||
// é 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.
|
//
|
||||||
let (m1, m2) = handshake_frames("IEEE", "wifiabc7", 2, false);
|
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 hs = synthetic_handshake("IEEE", "00:11:22:33:44:55", "66:77:88:99:aa:bb", &m1, &m2);
|
||||||
let target = CrackTarget::from_handshake(&hs).expect("alvo");
|
let target = CrackTarget::from_handshake(&hs).expect("alvo");
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
//! Diagnóstico do ambiente: privilégios, ferramentas externas, interfaces,
|
//! Environment diagnostics: privileges, external helper tools, wireless interfaces,
|
||||||
//! suporte a modo monitor e integridade da trilha de auditoria.
|
//! monitor mode support, and audit trail integrity.
|
||||||
|
|
||||||
use crate::audit::AuditLog;
|
use crate::audit::AuditLog;
|
||||||
use crate::iface::{self, Interface};
|
use crate::iface::{self, Interface};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! Parser de quadros 802.11 — gerenciamento (beacon, probe, associação,
|
//! IEEE 802.11 frame parser — management (beacon, probe, association,
|
||||||
//! desautenticação) e dados — com leitura dos *information elements* que
|
//! deauthentication) and data frames — extracting security-relevant Information
|
||||||
//! interessam para auditoria: SSID, canal, segurança (RSN/WPA), WPS, PMF e
|
//! Elements (SSID, channel, RSN/WPA, WPS, PMF) and locating EAPOL handshake
|
||||||
//! a localização do payload EAPOL dos handshakes WPA/WPA2.
|
//! payloads and cleartext client identity hints.
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum FrameKind {
|
pub enum FrameKind {
|
||||||
@@ -94,9 +94,9 @@ pub struct ManagementInfo {
|
|||||||
pub wps_serial: Option<String>,
|
pub wps_serial: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pistas de identidade tiradas de payload em claro (DHCP, mDNS/DNS, SSDP,
|
/// Client identity hints extracted from cleartext upper-layer payloads (DHCP, mDNS/DNS,
|
||||||
/// HTTP e ARP). Redes criptografadas não expõem nada disso; nesse caso os
|
/// SSDP, HTTP, ARP). Empty on encrypted networks unless the station's CCMP
|
||||||
/// campos ficam vazios e a UI mostra só MAC, OUI e probes.
|
/// session key has been derived from a cracked handshake.
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct PayloadInfo {
|
pub struct PayloadInfo {
|
||||||
pub ip: Option<String>,
|
pub ip: Option<String>,
|
||||||
@@ -172,7 +172,7 @@ pub fn is_broadcast(mac: &[u8; 6]) -> bool {
|
|||||||
*mac == [0xff; 6]
|
*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 struct Frame<'a> {
|
||||||
pub kind: FrameKind,
|
pub kind: FrameKind,
|
||||||
pub fc: u16,
|
pub fc: u16,
|
||||||
@@ -327,7 +327,7 @@ impl<'a> Frame<'a> {
|
|||||||
self.dst.map(|m| mac_to_string(&m))
|
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]> {
|
pub fn ies(&self) -> Option<&'a [u8]> {
|
||||||
if self.ie_offset == 0 || self.ie_offset >= self.body.len() {
|
if self.ie_offset == 0 || self.ie_offset >= self.body.len() {
|
||||||
None
|
None
|
||||||
@@ -336,8 +336,8 @@ impl<'a> Frame<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Faz parsing dos information elements (TLV), extraindo SSID, canal,
|
/// Parses TLV Information Elements, extracting SSID, channel, cipher/AKM suites,
|
||||||
/// cifra/AKM e indicadores de WPS e PMF.
|
/// WPS state, and PMF (802.11w) flags.
|
||||||
pub fn management_info(&self) -> ManagementInfo {
|
pub fn management_info(&self) -> ManagementInfo {
|
||||||
let mut info = ManagementInfo::default();
|
let mut info = ManagementInfo::default();
|
||||||
let Some(ies) = self.ies() else {
|
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.
|
/// Extracts the EAPOL payload slice (`0x888E`) from an unprotected data frame.
|
||||||
/// O slice começa no cabeçalho EAPOL (versão, tipo, tamanho).
|
/// The returned slice begins at the 4-byte EAPOL header.
|
||||||
pub fn eapol(&self) -> Option<&'a [u8]> {
|
pub fn eapol(&self) -> Option<&'a [u8]> {
|
||||||
if self.protected || !self.kind.is_data() {
|
if self.protected || !self.kind.is_data() {
|
||||||
return None;
|
return None;
|
||||||
@@ -469,10 +469,10 @@ impl<'a> Frame<'a> {
|
|||||||
self.body.get(start..end)
|
self.body.get(start..end)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tenta identificar a estação por payload não criptografado. Só olha
|
/// Extracts passive client identity hints from an unprotected data frame.
|
||||||
/// dados em claro; em WPA2/WPA3 o conteúdo é opaco e retorna vazio — a
|
/// Returns empty `PayloadInfo` for protected frames (use [`Self::ccmp_payload`]
|
||||||
/// menos que a senha já tenha sido quebrada (ver [`Self::ccmp_payload`]
|
/// + [`payload_info_from_llc`] when the station's TK is known).
|
||||||
/// + [`payload_info_from_llc`]).
|
///
|
||||||
pub fn payload_info(&self) -> PayloadInfo {
|
pub fn payload_info(&self) -> PayloadInfo {
|
||||||
if self.protected || !self.kind.is_data() {
|
if self.protected || !self.kind.is_data() {
|
||||||
return PayloadInfo::default();
|
return PayloadInfo::default();
|
||||||
@@ -483,8 +483,8 @@ impl<'a> Frame<'a> {
|
|||||||
payload_info_from_llc(payload)
|
payload_info_from_llc(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TID do campo QoS Control, quando o quadro é QoS Data. `None` pra
|
/// Extracts the QoS Control TID (`0..15`) for QoS Data frames, or `None` for non-QoS.
|
||||||
/// dados não-QoS (o nonce do CCMP usa TID 0 nesse caso).
|
///
|
||||||
pub fn qos_tid(&self) -> Option<u8> {
|
pub fn qos_tid(&self) -> Option<u8> {
|
||||||
if !self.kind.is_data() {
|
if !self.kind.is_data() {
|
||||||
return None;
|
return None;
|
||||||
@@ -500,10 +500,10 @@ impl<'a> Frame<'a> {
|
|||||||
self.body.get(offset).map(|b| b & 0x0f)
|
self.body.get(offset).map(|b| b & 0x0f)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decifra um quadro de dados CCMP com a TK já derivada (handshake
|
/// Decrypts a CCMP-protected data frame using the station's 16-byte Temporal Key (TK)
|
||||||
/// quebrado) e devolve o LLC/SNAP + payload em claro, no mesmo formato
|
/// and returns the cleartext LLC/SNAP + upper-layer payload.
|
||||||
/// 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.
|
///
|
||||||
pub fn ccmp_payload(&self, tk: &[u8; 16]) -> Option<Vec<u8>> {
|
pub fn ccmp_payload(&self, tk: &[u8; 16]) -> Option<Vec<u8>> {
|
||||||
if !self.protected || !self.kind.is_data() {
|
if !self.protected || !self.kind.is_data() {
|
||||||
return None;
|
return None;
|
||||||
@@ -515,9 +515,9 @@ impl<'a> Frame<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// LLC/SNAP (AA AA 03 00 00 00 + ethertype) seguido do payload em claro —
|
/// Parses cleartext LLC/SNAP (`AA AA 03 00 00 00 + EtherType`) payloads for client
|
||||||
/// compartilhado entre quadros nunca cifrados e quadros CCMP já decifrados
|
/// identity hints (shared by unencrypted frames and CCMP-decrypted frames).
|
||||||
/// com a TK conhecida.
|
///
|
||||||
pub fn payload_info_from_llc(payload: &[u8]) -> PayloadInfo {
|
pub fn payload_info_from_llc(payload: &[u8]) -> PayloadInfo {
|
||||||
let mut info = PayloadInfo::default();
|
let mut info = PayloadInfo::default();
|
||||||
let Some(llc) = payload.get(..8) else {
|
let Some(llc) = payload.get(..8) else {
|
||||||
@@ -1062,7 +1062,7 @@ fn manufacturer_from_text(text: &str) -> Option<String> {
|
|||||||
Some(name.to_string())
|
Some(name.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mantido para chamadores antigos: há um EAPOL neste quadro?
|
/// Returns `true` if this frame carries an EAPOL payload.
|
||||||
pub fn is_eapol(body: &[u8]) -> bool {
|
pub fn is_eapol(body: &[u8]) -> bool {
|
||||||
parse(body).and_then(|f| f.eapol().map(|_| ())).is_some()
|
parse(body).and_then(|f| f.eapol().map(|_| ())).is_some()
|
||||||
}
|
}
|
||||||
@@ -1102,8 +1102,8 @@ fn add_phy(phy: &mut Vec<String>, value: &str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Information Element de WPS (vendor 00:50:f2 tipo 4). O conteúdo começa
|
/// Parses a Microsoft WPS vendor-specific IE (`00:50:f2:04`, big-endian 16-bit TLVs).
|
||||||
/// com a versão do protocolo e segue em atributos TLV de 16 bits.
|
///
|
||||||
fn parse_wps(val: &[u8], info: &mut ManagementInfo) {
|
fn parse_wps(val: &[u8], info: &mut ManagementInfo) {
|
||||||
if val.len() < 6 {
|
if val.len() < 6 {
|
||||||
return;
|
return;
|
||||||
@@ -1366,7 +1366,7 @@ mod tests {
|
|||||||
f.extend_from_slice(&0x0108u16.to_le_bytes()); // data, ToDS
|
f.extend_from_slice(&0x0108u16.to_le_bytes()); // data, ToDS
|
||||||
f.extend_from_slice(&0u16.to_le_bytes());
|
f.extend_from_slice(&0u16.to_le_bytes());
|
||||||
f.extend_from_slice(&mac(0x11)); // addr1 = AP
|
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(&mac(0x11)); // addr3 = AP
|
||||||
f.extend_from_slice(&[0u8; 2]);
|
f.extend_from_slice(&[0u8; 2]);
|
||||||
f.extend_from_slice(&[0xAA, 0xAA, 0x03, 0, 0, 0]);
|
f.extend_from_slice(&[0xAA, 0xAA, 0x03, 0, 0, 0]);
|
||||||
|
|||||||
@@ -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;
|
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_MIC: u16 = 0x0100;
|
||||||
const KEY_INFO_SECURE: u16 = 0x0200;
|
const KEY_INFO_SECURE: u16 = 0x0200;
|
||||||
|
|
||||||
/// Deslocamento absoluto do campo MIC dentro do quadro EAPOL (já incluindo
|
/// Byte offset of the 16-byte MIC field within the full EAPOL frame (including
|
||||||
/// o cabeçalho de 4 bytes).
|
/// the 4-byte 802.1X header).
|
||||||
pub const MIC_OFFSET: usize = 81;
|
pub const MIC_OFFSET: usize = 81;
|
||||||
pub const KEY_DATA_OFFSET: usize = 99;
|
pub const KEY_DATA_OFFSET: usize = 99;
|
||||||
pub const MIN_KEY_FRAME_LEN: 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 struct EapolKey {
|
||||||
pub version: u8,
|
pub version: u8,
|
||||||
pub descriptor_type: 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 message: u8,
|
||||||
pub key_info: u16,
|
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 key_version: u8,
|
||||||
pub pairwise: bool,
|
pub pairwise: bool,
|
||||||
pub replay_counter: u64,
|
pub replay_counter: u64,
|
||||||
@@ -33,8 +33,8 @@ pub struct EapolKey {
|
|||||||
pub mic: [u8; 16],
|
pub mic: [u8; 16],
|
||||||
pub key_data: Vec<u8>,
|
pub key_data: Vec<u8>,
|
||||||
pub pmkid: Option<[u8; 16]>,
|
pub pmkid: Option<[u8; 16]>,
|
||||||
/// Quadro EAPOL completo (cabeçalho + descritor + dados), com o MIC no
|
/// Full raw EAPOL frame (header + key descriptor + key data), used to
|
||||||
/// lugar — usado para recalcular o MIC durante a quebra.
|
/// zero the MIC field and recompute it during password verification.
|
||||||
pub frame: Vec<u8>,
|
pub frame: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,17 +51,17 @@ pub fn parse(buf: &[u8]) -> Option<EapolKey> {
|
|||||||
let frame = buf[..total].to_vec();
|
let frame = buf[..total].to_vec();
|
||||||
|
|
||||||
let descriptor_type = frame[4];
|
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 {
|
if descriptor_type != 2 && descriptor_type != 254 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
// Todo campo multi-byte do descritor EAPOL-Key (Key Information, Key
|
// All multi-byte fields in the EAPOL-Key descriptor (Key Information, Key
|
||||||
// Length, Replay Counter, Key Data Length) é big-endian — é 802.1X,
|
// Length, Replay Counter, Key Data Length) are network byte order (big-endian).
|
||||||
// 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.
|
//
|
||||||
let key_info = u16::from_be_bytes([frame[5], frame[6]]);
|
let key_info = u16::from_be_bytes([frame[5], frame[6]]);
|
||||||
let key_version = (key_info & 0x0007) as u8;
|
let key_version = (key_info & 0x0007) as u8;
|
||||||
let pairwise = key_info & KEY_INFO_PAIRWISE != 0;
|
let pairwise = key_info & KEY_INFO_PAIRWISE != 0;
|
||||||
@@ -111,8 +111,8 @@ pub fn parse(buf: &[u8]) -> Option<EapolKey> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// PMKID KDE: `dd 14 00 0f ac 04` + 16 bytes. Pode vir em qualquer posição
|
/// Extracts the 16-byte PMKID from an RSN PMKID KDE (`dd 14 00 0f ac 04 + 16 bytes`)
|
||||||
/// dos dados da chave (normalmente na mensagem 1).
|
/// inside EAPOL-Key data (typically present in message 1).
|
||||||
pub fn parse_pmkid_kde(data: &[u8]) -> Option<[u8; 16]> {
|
pub fn parse_pmkid_kde(data: &[u8]) -> Option<[u8; 16]> {
|
||||||
let mut i = 0usize;
|
let mut i = 0usize;
|
||||||
while i + 2 <= data.len() {
|
while i + 2 <= data.len() {
|
||||||
@@ -183,24 +183,24 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Byte a byte, independente do helper `key_frame` (que usa a mesma
|
/// Verifies against a raw wire-format EAPOL Message 1 frame (`key_info = 0x008a`
|
||||||
/// codificação do parser): confere que um quadro Mensagem 1 real — key
|
/// big-endian: Ack=1, Pairwise=1, Version=2 CCMP).
|
||||||
/// 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.
|
///
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_real_wire_message1_key_info_as_big_endian() {
|
fn parses_real_wire_message1_key_info_as_big_endian() {
|
||||||
let mut frame = vec![0u8; MIN_KEY_FRAME_LEN];
|
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[1] = EAPOL_TYPE_KEY;
|
||||||
frame[2..4].copy_from_slice(&((MIN_KEY_FRAME_LEN - 4) as u16).to_be_bytes());
|
frame[2..4].copy_from_slice(&((MIN_KEY_FRAME_LEN - 4) as u16).to_be_bytes());
|
||||||
frame[4] = 2; // descritor RSN
|
frame[4] = 2; // RSN key descriptor
|
||||||
frame[5..7].copy_from_slice(&0x008au16.to_be_bytes()); // Ack=1, versão=2(CCMP), pairwise=1
|
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[7..9].copy_from_slice(&16u16.to_be_bytes()); // key length
|
||||||
frame[9..17].copy_from_slice(&1u64.to_be_bytes()); // replay counter
|
frame[9..17].copy_from_slice(&1u64.to_be_bytes()); // replay counter
|
||||||
frame[17..49].fill(0xaa); // nonce
|
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");
|
let parsed = parse(&frame).expect("mensagem 1 real deveria parsear");
|
||||||
assert_eq!(parsed.message, 1);
|
assert_eq!(parsed.message, 1);
|
||||||
|
|||||||
@@ -1,36 +1,36 @@
|
|||||||
//! Evil Twin pra redes WPA2/WPA3-Enterprise (802.1X — usuário+senha via
|
//! WPA2/WPA3-Enterprise (802.1X) Evil Twin: clones an Enterprise AP on a
|
||||||
//! RADIUS, não uma senha única como o PSK comum): clona o AP numa segunda
|
//! secondary interface using `hostapd-wpe` to accept EAP (PEAP/EAP-TTLS with
|
||||||
//! interface anunciando o mesmo SSID e aceita o handshake EAP (PEAP/EAP-TTLS
|
//! inner MSCHAPv2) authentication attempts and log NetNTLMv1 challenge/response
|
||||||
//! com MSCHAPv2) de qualquer cliente que tente autenticar nele. Diferente do
|
//! hashes for offline cracking with `john` or `hashcat -m 5500`.
|
||||||
//! 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.
|
//!
|
||||||
|
//!
|
||||||
|
//! 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 _;
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
/// Config do hostapd-wpe pro AP clone: mesmo SSID/canal do AP real,
|
/// Generates the `hostapd-wpe` configuration for the Enterprise clone AP.
|
||||||
/// anunciando WPA2-Enterprise e aceitando qualquer EAP (PEAP/TTLS) — é o
|
///
|
||||||
/// `eap_user_file` genérico que faz ele aceitar qualquer identidade.
|
///
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn hostapd_wpe_conf(
|
pub fn hostapd_wpe_conf(
|
||||||
iface: &str,
|
iface: &str,
|
||||||
@@ -64,20 +64,20 @@ pub fn hostapd_wpe_conf(
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `eap_user` genérico: aceita qualquer identidade externa pra PEAP/TTLS e
|
/// Wildcard `eap_user` table: accepts any outer identity for PEAP/TTLS and
|
||||||
/// força MSCHAPv2 por dentro — é o que faz o hostapd-wpe logar usuário +
|
/// negotiates inner MSCHAPv2 so `hostapd-wpe` logs the challenge/response pair.
|
||||||
/// challenge/response de qualquer cliente que tentar autenticar, sem
|
///
|
||||||
/// precisar saber credenciais reais de antemão.
|
///
|
||||||
pub const DEFAULT_EAP_USER_FILE: &str = concat!(
|
pub const DEFAULT_EAP_USER_FILE: &str = concat!(
|
||||||
"# gerado pelo Sabiá — aceita qualquer identidade, qualquer EAP externo\n",
|
"# gerado pelo Sabiá — aceita qualquer identidade, qualquer EAP externo\n",
|
||||||
"* PEAP,TTLS\n",
|
"* PEAP,TTLS\n",
|
||||||
"\"anonymous\" TTLS-MSCHAPV2,MSCHAPV2 \"senha\" [2]\n",
|
"\"anonymous\" TTLS-MSCHAPV2,MSCHAPV2 \"senha\" [2]\n",
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Argumentos do `openssl req` pra gerar o par certificado+chave
|
/// Builds `openssl req` arguments to generate a 1-day ephemeral self-signed
|
||||||
/// autoassinado que o hostapd-wpe precisa pra abrir TLS (PEAP/TTLS sempre
|
/// RSA certificate and private key for the outer EAP-TLS/PEAP/TTLS tunnel.
|
||||||
/// envolve um túnel TLS, mesmo sendo um AP falso). Efêmero: 1 dia de validade
|
///
|
||||||
/// é de sobra pra uma sessão de teste.
|
///
|
||||||
pub fn openssl_selfsigned_cert_args(key_path: &str, cert_path: &str) -> Vec<String> {
|
pub fn openssl_selfsigned_cert_args(key_path: &str, cert_path: &str) -> Vec<String> {
|
||||||
[
|
[
|
||||||
"req",
|
"req",
|
||||||
@@ -103,23 +103,23 @@ fn sanitize_conf_value(value: &str) -> String {
|
|||||||
value.replace(['\n', '\r'], "")
|
value.replace(['\n', '\r'], "")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Uma credencial capturada: identidade + a linha pronta pra crackear
|
/// A captured Enterprise credential: username plus NetNTLMv1 hash string
|
||||||
/// (formato `netntlm` do John the Ripper / compatível com `hashcat -m 5500`),
|
/// (compatible with `john --format=netntlm` and `hashcat -m 5500`).
|
||||||
/// se o hostapd-wpe conseguiu capturar os dois.
|
///
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct CapturedCredential {
|
pub struct CapturedCredential {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub netntlm: Option<String>,
|
pub netntlm: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lê o `wpe_logfile` e extrai as credenciais capturadas. Parser
|
/// Parses `hostapd-wpe` log output and extracts captured MSCHAPv2 / NetNTLMv1 credentials.
|
||||||
/// 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.
|
///
|
||||||
pub fn captured_identities(log: &str) -> Vec<CapturedCredential> {
|
pub fn captured_identities(log: &str) -> Vec<CapturedCredential> {
|
||||||
let mut out: Vec<CapturedCredential> = Vec::new();
|
let mut out: Vec<CapturedCredential> = Vec::new();
|
||||||
let mut current_username: Option<String> = None;
|
let mut current_username: Option<String> = None;
|
||||||
|
|||||||
@@ -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::dot11;
|
||||||
use crate::eapol::{self, EapolKey};
|
use crate::eapol::{self, EapolKey};
|
||||||
@@ -24,20 +24,20 @@ pub struct Handshake {
|
|||||||
pub snonce: Option<[u8; 32]>,
|
pub snonce: Option<[u8; 32]>,
|
||||||
pub pmkid: Option<[u8; 16]>,
|
pub pmkid: Option<[u8; 16]>,
|
||||||
pub key_version: u8,
|
pub key_version: u8,
|
||||||
/// Bitmask das mensagens vistas (bit 0 = mensagem 1).
|
/// Bitmask of observed handshake messages (bit 0 = message 1).
|
||||||
pub messages: u8,
|
pub messages: u8,
|
||||||
/// Quadro EAPOL da mensagem 2, com o MIC — necessário para conferir a
|
/// Full EAPOL frame from message 2 (containing the MIC) used to verify
|
||||||
/// senha candidata.
|
/// candidate passphrases.
|
||||||
pub eapol_msg2: Option<Vec<u8>>,
|
pub eapol_msg2: Option<Vec<u8>>,
|
||||||
pub raw: Vec<RawFrame>,
|
pub raw: Vec<RawFrame>,
|
||||||
pub first_seen_unix: u64,
|
pub first_seen_unix: u64,
|
||||||
pub last_seen_unix: u64,
|
pub last_seen_unix: u64,
|
||||||
pub password: Option<String>,
|
pub password: Option<String>,
|
||||||
/// Temporal Key (CCMP/AES) derivada quando `password` é preenchido.
|
/// 16-byte CCMP Temporal Key (TK) derived once `password` is known.
|
||||||
/// Só pra TKIP (key_version 1) fica None: a decriptação aqui só cobre
|
/// `None` for TKIP (`key_version == 1`) or PMKID-only handshakes. Scoped
|
||||||
/// CCMP. Serve pra enxergar o tráfego em claro desse par (BSSID,
|
/// to this specific `(BSSID, station)` association.
|
||||||
/// estação) específico — não abre o tráfego de outros clientes, já que
|
///
|
||||||
/// a PTK é efêmera por associação.
|
///
|
||||||
pub tk: Option<[u8; 16]>,
|
pub tk: Option<[u8; 16]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +111,7 @@ impl Handshake {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Quadros crus (radiotap + 802.11) das mensagens vistas, em ordem.
|
/// Raw radiotap + 802.11 frames of observed messages, in arrival order.
|
||||||
pub fn raw_frames(&self) -> Vec<Vec<u8>> {
|
pub fn raw_frames(&self) -> Vec<Vec<u8>> {
|
||||||
let mut v = self.raw.clone();
|
let mut v = self.raw.clone();
|
||||||
v.sort_by_key(|f| f.message);
|
v.sort_by_key(|f| f.message);
|
||||||
@@ -140,8 +140,8 @@ impl HandshakeTracker {
|
|||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Processa um quadro 802.11. Retorna `true` quando o quadro era EAPOL e
|
/// Processes a parsed 802.11 frame. Returns `true` if it contained an EAPOL-Key
|
||||||
/// atualizou (ou criou) um handshake.
|
/// message that created or updated a handshake entry.
|
||||||
pub fn ingest(
|
pub fn ingest(
|
||||||
&mut self,
|
&mut self,
|
||||||
frame: &dot11::Frame<'_>,
|
frame: &dot11::Frame<'_>,
|
||||||
@@ -159,8 +159,8 @@ impl HandshakeTracker {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Em data frames com DS, o BSSID está em `frame.bssid`; a estação é
|
// In DS data frames, `frame.bssid` holds the AP MAC; the station is
|
||||||
// quem recebe (1/3, AP -> STA) ou quem transmite (2/4, STA -> AP).
|
// `destination` on downlink (M1/M3) or `transmitter` on uplink (M2/M4).
|
||||||
let bssid = frame.bssid_str();
|
let bssid = frame.bssid_str();
|
||||||
let station = match key.message {
|
let station = match key.message {
|
||||||
1 | 3 => frame.addr1.map(|m| dot11::mac_to_string(&m)),
|
1 | 3 => frame.addr1.map(|m| dot11::mac_to_string(&m)),
|
||||||
@@ -198,8 +198,8 @@ impl HandshakeTracker {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Atualiza o SSID de handshakes já vistos (quando o beacon chega depois
|
/// Backfills the SSID on existing handshakes when a beacon arrives after
|
||||||
/// do EAPOL, o que acontece em capturas começando no meio do handshake).
|
/// the EAPOL exchange.
|
||||||
pub fn update_ssid(&mut self, bssid: &str, ssid: &str) {
|
pub fn update_ssid(&mut self, bssid: &str, ssid: &str) {
|
||||||
if ssid.is_empty() {
|
if ssid.is_empty() {
|
||||||
return;
|
return;
|
||||||
@@ -233,8 +233,8 @@ impl HandshakeTracker {
|
|||||||
self.all().into_iter().map(Handshake::info).collect()
|
self.all().into_iter().map(Handshake::info).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Primeiro handshake que pode ser quebrado; filtros opcionais por BSSID
|
/// Returns the first crackable handshake matching the optional `bssid` and
|
||||||
/// e estação.
|
/// `station` filters.
|
||||||
pub fn first_crackable(
|
pub fn first_crackable(
|
||||||
&self,
|
&self,
|
||||||
bssid: Option<&str>,
|
bssid: Option<&str>,
|
||||||
@@ -254,9 +254,9 @@ impl HandshakeTracker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TK já derivada pra esse par (BSSID, estação), se a senha da rede já
|
/// Returns the derived 16-byte CCMP Temporal Key (TK) for `(bssid, station)`
|
||||||
/// foi quebrada e o handshake tinha os dois nonces (EAPOL completo — um
|
/// if the network password has been cracked and both nonces were captured.
|
||||||
/// handshake só de PMKID não dá pra derivar PTK/TK, só confirma a PSK).
|
///
|
||||||
pub fn tk_for(&self, bssid: &str, station: &str) -> Option<[u8; 16]> {
|
pub fn tk_for(&self, bssid: &str, station: &str) -> Option<[u8; 16]> {
|
||||||
self.get(bssid, station).and_then(|hs| hs.tk)
|
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
|
/// Derives the 16-byte CCMP Temporal Key (`PTK[32..48]`) from a cracked passphrase.
|
||||||
/// decifrar CCMP do tráfego desse cliente. Só funciona com EAPOL/CCMP
|
/// Requires a full EAPOL CCMP handshake (`key_version == 2`) with both nonces.
|
||||||
/// 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.
|
///
|
||||||
fn derive_tk(hs: &Handshake, password: &str) -> Option<[u8; 16]> {
|
fn derive_tk(hs: &Handshake, password: &str) -> Option<[u8; 16]> {
|
||||||
if hs.key_version != 2 {
|
if hs.key_version != 2 {
|
||||||
return None;
|
return None;
|
||||||
|
|||||||
@@ -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*01*PMKID*AP*STA*SSID***`
|
||||||
//! * `WPA*02*MIC*AP*STA*SSID*ANONCE*EAPOL*MESSAGEPAIR`
|
//! * `WPA*02*MIC*AP*STA*SSID*ANONCE*EAPOL*MESSAGEPAIR`
|
||||||
//!
|
//!
|
||||||
//! O EAPOL exportado é o quadro da mensagem 2 completo (cabeçalho 802.1X +
|
//! The exported EAPOL field contains the full message 2 frame (802.1X header +
|
||||||
//! descritor), que é o que o hashcat espera no campo `EAPOL_CLIENT`.
|
//! key descriptor) expected by hashcat's `EAPOL_CLIENT` field.
|
||||||
|
|
||||||
use crate::eapol::MIC_OFFSET;
|
use crate::eapol::MIC_OFFSET;
|
||||||
use crate::handshake::Handshake;
|
use crate::handshake::Handshake;
|
||||||
@@ -55,7 +55,7 @@ pub fn hashcat_line(hs: &Handshake) -> Option<String> {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Linhas únicas, uma por handshake que tenha material suficiente.
|
/// Formats deduplicated `.hc22000` lines for all crackable handshakes.
|
||||||
pub fn export<'a>(handshakes: impl IntoIterator<Item = &'a Handshake>) -> Vec<String> {
|
pub fn export<'a>(handshakes: impl IntoIterator<Item = &'a Handshake>) -> Vec<String> {
|
||||||
let mut lines: Vec<String> = handshakes.into_iter().filter_map(hashcat_line).collect();
|
let mut lines: Vec<String> = handshakes.into_iter().filter_map(hashcat_line).collect();
|
||||||
lines.sort();
|
lines.sort();
|
||||||
|
|||||||
@@ -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
|
//! Reads directly from `/sys/class/net` on Linux, falling back to `iw` for
|
||||||
//! próprio `iw`/`ip` fazem por baixo.
|
//! detailed interface mode inspection when available.
|
||||||
|
|
||||||
use crate::error::{Result, SabiaError};
|
use crate::error::{Result, SabiaError};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -11,7 +11,7 @@ use std::path::{Path, PathBuf};
|
|||||||
pub struct Interface {
|
pub struct Interface {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub mac: String,
|
pub mac: String,
|
||||||
/// Caminho do phy (ex.: `phy0`), presente só em interfaces 802.11.
|
/// Wireless `wiphy` name (e.g. `"phy0"`), present only on 802.11 interfaces.
|
||||||
pub phy: Option<String>,
|
pub phy: Option<String>,
|
||||||
pub mode: Mode,
|
pub mode: Mode,
|
||||||
pub up: bool,
|
pub up: bool,
|
||||||
@@ -28,7 +28,7 @@ fn net_root() -> &'static Path {
|
|||||||
Path::new("/sys/class/net")
|
Path::new("/sys/class/net")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lista todas as interfaces de rede, Wi-Fi ou não (chamador filtra por `phy`).
|
/// Lists all network interfaces on the system (wireless interfaces have `phy.is_some()`).
|
||||||
pub fn list_all() -> Result<Vec<Interface>> {
|
pub fn list_all() -> Result<Vec<Interface>> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for entry in fs::read_dir(net_root())? {
|
for entry in fs::read_dir(net_root())? {
|
||||||
@@ -43,7 +43,7 @@ pub fn list_all() -> Result<Vec<Interface>> {
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lista apenas interfaces com um `phy80211` (ou seja, rádios Wi-Fi).
|
/// Lists only 802.11 wireless interfaces (`phy80211` present).
|
||||||
pub fn list_wireless() -> Result<Vec<Interface>> {
|
pub fn list_wireless() -> Result<Vec<Interface>> {
|
||||||
Ok(list_all()?
|
Ok(list_all()?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -79,8 +79,8 @@ fn read_interface(name: &str) -> Result<Interface> {
|
|||||||
.ok()
|
.ok()
|
||||||
.and_then(|p| p.file_name().map(|f| f.to_string_lossy().into_owned()))
|
.and_then(|p| p.file_name().map(|f| f.to_string_lossy().into_owned()))
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
// em alguns kernels phy80211 é um diretório real, não symlink;
|
// On some kernels `phy80211` is a directory rather than a symlink;
|
||||||
// o nome do phy então vem do subdiretório "phyNN" dentro dele.
|
// read the `"phyNN"` subdirectory inside it.
|
||||||
fs::read_dir(&phy80211)
|
fs::read_dir(&phy80211)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|mut rd| rd.next())
|
.and_then(|mut rd| rd.next())
|
||||||
@@ -106,10 +106,10 @@ fn read_interface(name: &str) -> Result<Interface> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lê o modo (`managed`/`monitor`) via `/sys/class/net/<if>/type` + `uevent`
|
/// Reads the interface operating mode (`managed`/`monitor`) via `/sys/class/net/<if>/type`
|
||||||
/// não é suficiente (type é sempre ARPHRD_ETHER para Wi-Fi); a forma estável
|
/// and `iw dev <if> info` when available.
|
||||||
/// sem libnl é delegar a leitura real ao `iw dev <if> info` quando disponível,
|
///
|
||||||
/// mantendo aqui um melhor-esforço local para não depender de root.
|
///
|
||||||
fn read_wireless_mode(name: &str) -> Mode {
|
fn read_wireless_mode(name: &str) -> Mode {
|
||||||
match crate::netlink::iw_dev_type(name) {
|
match crate::netlink::iw_dev_type(name) {
|
||||||
Some(Mode::Monitor) => Mode::Monitor,
|
Some(Mode::Monitor) => Mode::Monitor,
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
//! Construção e envio de quadros de gerenciamento (desautenticação e
|
//! Construction and raw socket injection of 802.11 management frames
|
||||||
//! desassociação) para testes autorizados de robustez de redes Wi-Fi.
|
//! (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")]
|
#[cfg(target_os = "linux")]
|
||||||
use crate::error::{Result, SabiaError};
|
use crate::error::{Result, SabiaError};
|
||||||
|
|
||||||
pub const BROADCAST: [u8; 6] = [0xff; 6];
|
pub const BROADCAST: [u8; 6] = [0xff; 6];
|
||||||
|
|
||||||
/// Cabeçalho radiotap mínimo (8 bytes, sem campos). É aceito por mac80211 e
|
/// Minimal 8-byte Radiotap header (no present fields), accepted by `mac80211`
|
||||||
/// deixa o driver escolher a taxa; suficiente para quadros de gerenciamento.
|
/// for management frame injection.
|
||||||
fn radiotap_min() -> [u8; 8] {
|
fn radiotap_min() -> [u8; 8] {
|
||||||
[0, 0, 8, 0, 0, 0, 0, 0]
|
[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)
|
mgmt_frame_from(subtype, bssid, bssid, destination, reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Quadro de gerenciamento com transmissor explícito. Permite forjar tanto
|
/// Builds a deauth/disassoc management frame with an explicit transmitter MAC,
|
||||||
/// AP→STA (`transmitter = BSSID`) quanto STA→AP (`transmitter = estação`),
|
/// supporting both AP->STA (`transmitter = BSSID`) and STA->AP (`transmitter = client`).
|
||||||
/// usado no teste bidirecional de PMF.
|
///
|
||||||
fn mgmt_frame_from(
|
fn mgmt_frame_from(
|
||||||
subtype: u8,
|
subtype: u8,
|
||||||
transmitter: [u8; 6],
|
transmitter: [u8; 6],
|
||||||
@@ -35,21 +35,21 @@ fn mgmt_frame_from(
|
|||||||
f.extend_from_slice(&radiotap_min());
|
f.extend_from_slice(&radiotap_min());
|
||||||
f.extend_from_slice(&fc.to_le_bytes());
|
f.extend_from_slice(&fc.to_le_bytes());
|
||||||
f.extend_from_slice(&0u16.to_le_bytes());
|
f.extend_from_slice(&0u16.to_le_bytes());
|
||||||
f.extend_from_slice(&destination); // addr1: destino
|
f.extend_from_slice(&destination); // addr1: destination
|
||||||
f.extend_from_slice(&transmitter); // addr2: transmissor
|
f.extend_from_slice(&transmitter); // addr2: transmitter
|
||||||
f.extend_from_slice(&bssid); // addr3: BSSID
|
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.extend_from_slice(&reason.to_le_bytes());
|
||||||
f
|
f
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Quadro de desautenticação (subtipo 12). `destination` pode ser uma
|
/// Builds an AP->STA deauthentication frame (subtype 12). `destination` may be
|
||||||
/// estação específica ou [`BROADCAST`].
|
/// a unicast client MAC or [`BROADCAST`].
|
||||||
pub fn deauth_frame(bssid: [u8; 6], destination: [u8; 6], reason: u16) -> Vec<u8> {
|
pub fn deauth_frame(bssid: [u8; 6], destination: [u8; 6], reason: u16) -> Vec<u8> {
|
||||||
mgmt_frame(12, bssid, destination, reason)
|
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(
|
pub fn deauth_frame_from(
|
||||||
transmitter: [u8; 6],
|
transmitter: [u8; 6],
|
||||||
bssid: [u8; 6],
|
bssid: [u8; 6],
|
||||||
@@ -59,7 +59,7 @@ pub fn deauth_frame_from(
|
|||||||
mgmt_frame_from(12, transmitter, bssid, destination, reason)
|
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(
|
pub fn disassoc_frame_from(
|
||||||
transmitter: [u8; 6],
|
transmitter: [u8; 6],
|
||||||
bssid: [u8; 6],
|
bssid: [u8; 6],
|
||||||
@@ -69,19 +69,19 @@ pub fn disassoc_frame_from(
|
|||||||
mgmt_frame_from(10, transmitter, bssid, destination, reason)
|
mgmt_frame_from(10, transmitter, bssid, destination, reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Quadro de desassociação (subtipo 10).
|
/// Builds an AP->STA disassociation frame (subtype 10).
|
||||||
pub fn disassoc_frame(bssid: [u8; 6], destination: [u8; 6], reason: u16) -> Vec<u8> {
|
pub fn disassoc_frame(bssid: [u8; 6], destination: [u8; 6], reason: u16) -> Vec<u8> {
|
||||||
mgmt_frame(10, bssid, destination, reason)
|
mgmt_frame(10, bssid, destination, reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Offset do campo "Sequence Control" dentro de um quadro de gerenciamento
|
/// Byte offset of the 802.11 Sequence Control field inside a frame built by
|
||||||
/// construído por [`mgmt_frame_from`] (8 de radiotap + 22 até o campo).
|
/// [`mgmt_frame_from`] (8-byte Radiotap + 22-byte MAC header offset).
|
||||||
const SEQ_CTRL_OFFSET: usize = 30;
|
const SEQ_CTRL_OFFSET: usize = 30;
|
||||||
|
|
||||||
/// Grava um número de sequência (0–4095) no quadro, deixando o número de
|
/// Writes a 12-bit 802.11 sequence number (`0..4096`) into `frame` (with fragment
|
||||||
/// fragmento (4 bits baixos) zerado. Sem isso, todo quadro injetado sai com
|
/// number `0`) so target drivers do not drop repeated frames as duplicates.
|
||||||
/// 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".
|
///
|
||||||
pub fn set_seq(frame: &mut [u8], seq: u16) {
|
pub fn set_seq(frame: &mut [u8], seq: u16) {
|
||||||
if frame.len() < SEQ_CTRL_OFFSET + 2 {
|
if frame.len() < SEQ_CTRL_OFFSET + 2 {
|
||||||
return;
|
return;
|
||||||
@@ -99,8 +99,8 @@ fn ie(tag: u8, value: &[u8]) -> Vec<u8> {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Autenticação aberta (algoritmo 0, sequência 1). Usada antes do association
|
/// Builds an Open System Authentication request frame (algorithm 0, seq 1) used
|
||||||
/// request no teste de PMKID.
|
/// prior to Association Request when probing for clientless PMKID.
|
||||||
pub fn auth_frame(bssid: [u8; 6], station: [u8; 6]) -> Vec<u8> {
|
pub fn auth_frame(bssid: [u8; 6], station: [u8; 6]) -> Vec<u8> {
|
||||||
let mut f = Vec::with_capacity(8 + 24 + 6);
|
let mut f = Vec::with_capacity(8 + 24 + 6);
|
||||||
f.extend_from_slice(&radiotap_min());
|
f.extend_from_slice(&radiotap_min());
|
||||||
@@ -110,8 +110,8 @@ pub fn auth_frame(bssid: [u8; 6], station: [u8; 6]) -> Vec<u8> {
|
|||||||
f.extend_from_slice(&station);
|
f.extend_from_slice(&station);
|
||||||
f.extend_from_slice(&bssid);
|
f.extend_from_slice(&bssid);
|
||||||
f.extend_from_slice(&[0u8; 2]);
|
f.extend_from_slice(&[0u8; 2]);
|
||||||
f.extend_from_slice(&0u16.to_le_bytes()); // algoritmo aberto
|
f.extend_from_slice(&0u16.to_le_bytes()); // Open System algorithm (0)
|
||||||
f.extend_from_slice(&1u16.to_le_bytes()); // sequência
|
f.extend_from_slice(&1u16.to_le_bytes()); // auth sequence (1)
|
||||||
f.extend_from_slice(&0u16.to_le_bytes()); // status
|
f.extend_from_slice(&0u16.to_le_bytes()); // status
|
||||||
f
|
f
|
||||||
}
|
}
|
||||||
@@ -119,7 +119,7 @@ pub fn auth_frame(bssid: [u8; 6], station: [u8; 6]) -> Vec<u8> {
|
|||||||
fn rsn_ie(akms: &[u8], pmf: bool, pmf_required: bool, pmkid: &[u8; 16]) -> Vec<u8> {
|
fn rsn_ie(akms: &[u8], pmf: bool, pmf_required: bool, pmkid: &[u8; 16]) -> Vec<u8> {
|
||||||
let mut val = Vec::new();
|
let mut val = Vec::new();
|
||||||
val.extend_from_slice(&1u16.to_le_bytes());
|
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(&1u16.to_le_bytes());
|
||||||
val.extend_from_slice(&[0x00, 0x0f, 0xac, 4]); // pairwise CCMP
|
val.extend_from_slice(&[0x00, 0x0f, 0xac, 4]); // pairwise CCMP
|
||||||
val.extend_from_slice(&(akms.len() as u16).to_le_bytes());
|
val.extend_from_slice(&(akms.len() as u16).to_le_bytes());
|
||||||
@@ -139,8 +139,8 @@ fn rsn_ie(akms: &[u8], pmf: bool, pmf_required: bool, pmkid: &[u8; 16]) -> Vec<u
|
|||||||
ie(48, &val)
|
ie(48, &val)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Association request com RSN/PMKID. O AP pode responder com a mensagem 1
|
/// Builds an Association Request carrying an RSN IE to elicit EAPOL Message 1
|
||||||
/// do 4-way handshake contendo o PMKID — é isso que o teste tenta provocar.
|
/// (and its optional PMKID) from the target AP.
|
||||||
pub fn assoc_request_frame(
|
pub fn assoc_request_frame(
|
||||||
bssid: [u8; 6],
|
bssid: [u8; 6],
|
||||||
station: [u8; 6],
|
station: [u8; 6],
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! `sabia-core`: tudo que fala com o rádio Wi-Fi, interpreta quadros 802.11,
|
//! `sabia-core`: core 802.11 wireless auditing library (Radiotap/802.11/EAPOL
|
||||||
//! mantém a trilha de auditoria e quebra handshakes. A CLI e a interface
|
//! parsers, raw capture and injection, WIDS channel analysis, WPA2/PMKID cracking,
|
||||||
//! gráfica são só consumidoras disto — a parte complexa (radiotap, 802.11,
|
//! WPS, Evil Twin, reporting, and hash-chained audit logging).
|
||||||
//! EAPOL, captura e injeção) fica inteira aqui dentro.
|
//!
|
||||||
|
|
||||||
pub mod analysis;
|
pub mod analysis;
|
||||||
pub mod audit;
|
pub mod audit;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
//! Controle de modo (managed/monitor), canal e leitura das capacidades do
|
//! Wireless interface mode control (`managed`/`monitor`), channel switching,
|
||||||
//! rádio via `iw`.
|
//! and supported channel enumeration via `iw` and `ip`.
|
||||||
|
//!
|
||||||
|
//!
|
||||||
|
//!
|
||||||
|
//!
|
||||||
|
//!
|
||||||
//!
|
//!
|
||||||
//! É uma ponte deliberada: o protocolo real é nl80211 por netlink, e trocar
|
|
||||||
//! este módulo por uma implementação nativa (sem depender dos binários `iw` e
|
|
||||||
//! `ip`) é o próximo passo natural. Por ora `iw`/`ip` são universais em
|
|
||||||
//! Linux, testados, e evitam reimplementar genl/nl80211 antes de validar o
|
|
||||||
//! pipeline com hardware real.
|
|
||||||
|
|
||||||
use crate::error::{Result, SabiaError};
|
use crate::error::{Result, SabiaError};
|
||||||
use crate::iface::Mode;
|
use crate::iface::Mode;
|
||||||
@@ -98,7 +98,7 @@ pub fn phy_for_iface(iface: &str) -> Result<String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lista canais suportados pelo phy da interface.
|
/// Lists all 802.11 channels supported by the interface's `wiphy`.
|
||||||
pub fn channels(iface: &str) -> Result<Vec<ChannelInfo>> {
|
pub fn channels(iface: &str) -> Result<Vec<ChannelInfo>> {
|
||||||
let phy = phy_for_iface(iface)?;
|
let phy = phy_for_iface(iface)?;
|
||||||
let info = run("iw", &["phy", &phy, "info"])?;
|
let info = run("iw", &["phy", &phy, "info"])?;
|
||||||
@@ -113,7 +113,7 @@ pub fn supported_channels(iface: &str) -> Result<Vec<u32>> {
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Canais a percorrer. `band` aceita `"2.4"`, `"5"`, `"6"`, `"all"`/`None`.
|
/// Filters supported channels by band (`"2.4"`, `"5"`, `"6"`, or `"all"`/`None`).
|
||||||
pub fn channel_plan(iface: &str, band: Option<&str>) -> Result<Vec<u32>> {
|
pub fn channel_plan(iface: &str, band: Option<&str>) -> Result<Vec<u32>> {
|
||||||
let want = match band.map(|b| b.trim().to_ascii_lowercase()) {
|
let want = match band.map(|b| b.trim().to_ascii_lowercase()) {
|
||||||
None => None,
|
None => None,
|
||||||
@@ -159,7 +159,7 @@ pub fn driver_name(iface: &str) -> Option<String> {
|
|||||||
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
|
.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<()> {
|
pub fn set_monitor_mode(iface: &str, enable: bool) -> Result<()> {
|
||||||
run("ip", &["link", "set", iface, "down"])?;
|
run("ip", &["link", "set", iface, "down"])?;
|
||||||
let target = if enable { "monitor" } else { "managed" };
|
let target = if enable { "monitor" } else { "managed" };
|
||||||
@@ -168,7 +168,7 @@ pub fn set_monitor_mode(iface: &str, enable: bool) -> Result<()> {
|
|||||||
Ok(())
|
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<()> {
|
pub fn set_channel(iface: &str, channel: u32) -> Result<()> {
|
||||||
run(
|
run(
|
||||||
"iw",
|
"iw",
|
||||||
|
|||||||
@@ -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)] = &[
|
const OUIS: &[(&str, &str)] = &[
|
||||||
("00000c", "Cisco"),
|
("00000c", "Cisco"),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Leitor do formato `.pcap` clássico, usado para analisar capturas feitas
|
//! Classic libpcap (`.pcap`) reader supporting both `LINKTYPE_IEEE802_11_RADIOTAP` (127)
|
||||||
//! pelo próprio Sabiá (link-type 127, radiotap) e pelo aircrack-ng/tcpdump
|
//! and raw `LINKTYPE_IEEE802_11` (105) captures.
|
||||||
//! (link-type 105, 802.11 cru).
|
//!
|
||||||
|
|
||||||
use crate::error::{Result, SabiaError};
|
use crate::error::{Result, SabiaError};
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
@@ -75,7 +75,7 @@ impl PcapReader {
|
|||||||
self.linktype
|
self.linktype
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Próximo quadro; `None` no fim do arquivo.
|
/// Reads the next frame, returning `Ok(None)` at EOF.
|
||||||
pub fn next_frame(&mut self) -> Result<Option<PcapFrame>> {
|
pub fn next_frame(&mut self) -> Result<Option<PcapFrame>> {
|
||||||
let mut rec = [0u8; 16];
|
let mut rec = [0u8; 16];
|
||||||
match self.input.read_exact(&mut rec) {
|
match self.input.read_exact(&mut rec) {
|
||||||
@@ -102,8 +102,8 @@ impl PcapReader {
|
|||||||
.read_exact(&mut data)
|
.read_exact(&mut data)
|
||||||
.map_err(|e| SabiaError::InvalidPcap(format!("quadro truncado: {e}")))?;
|
.map_err(|e| SabiaError::InvalidPcap(format!("quadro truncado: {e}")))?;
|
||||||
|
|
||||||
// Capturas sem radiotap (link-type 105) recebem um cabeçalho mínimo
|
// Prepend a minimal 8-byte Radiotap header to link-type 105 (raw 802.11)
|
||||||
// para o mesmo pipeline de parsing funcionar.
|
// frames so the downstream parser receives a uniform format.
|
||||||
if self.linktype == LINKTYPE_IEEE802_11 {
|
if self.linktype == LINKTYPE_IEEE802_11 {
|
||||||
let mut with_radiotap = vec![0u8, 0, 8, 0, 0, 0, 0, 0];
|
let mut with_radiotap = vec![0u8, 0, 8, 0, 0, 0, 0, 0];
|
||||||
with_radiotap.extend_from_slice(&data);
|
with_radiotap.extend_from_slice(&data);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Escritor de arquivos `.pcap` (formato clássico, link-type 127 =
|
//! Classic libpcap (`.pcap`) writer (`LINKTYPE_IEEE802_11_RADIOTAP` = 127),
|
||||||
//! IEEE802_11_RADIOTAP) para exportar capturas — compatível com
|
//! compatible with Wireshark, `aircrack-ng`, and `hcxpcapngtool`.
|
||||||
//! Wireshark, aircrack-ng e qualquer outra ferramenta do ecossistema.
|
//!
|
||||||
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
@@ -18,7 +18,7 @@ impl PcapWriter {
|
|||||||
pub fn create(path: impl AsRef<Path>) -> Result<Self> {
|
pub fn create(path: impl AsRef<Path>) -> Result<Self> {
|
||||||
let file = File::create(path)?;
|
let file = File::create(path)?;
|
||||||
let mut out = BufWriter::new(file);
|
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(&0xa1b2c3d4u32.to_le_bytes())?; // magic
|
||||||
out.write_all(&2u16.to_le_bytes())?; // version major
|
out.write_all(&2u16.to_le_bytes())?; // version major
|
||||||
out.write_all(&4u16.to_le_bytes())?; // version minor
|
out.write_all(&4u16.to_le_bytes())?; // version minor
|
||||||
@@ -29,7 +29,7 @@ impl PcapWriter {
|
|||||||
Ok(Self { out })
|
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<()> {
|
pub fn write_frame(&mut self, raw: &[u8]) -> Result<()> {
|
||||||
let now = SystemTime::now()
|
let now = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.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.as_secs() as u32).to_le_bytes())?;
|
||||||
self.out.write_all(&(now.subsec_micros()).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())?; // 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)?;
|
self.out.write_all(raw)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
//! Parser de cabeçalho Radiotap.
|
//! IEEE 802.11 Radiotap header parser (<https://www.radiotap.org/>).
|
||||||
|
//!
|
||||||
|
//! Walks present flags in specification order with proper field alignment to
|
||||||
|
//! extract channel/frequency and antenna signal strength (dBm).
|
||||||
|
//!
|
||||||
//!
|
//!
|
||||||
//! Só extraímos o que o restante do núcleo usa — frequência/canal e força do
|
|
||||||
//! sinal — mas avançamos pelos campos na ordem da especificação, respeitando
|
|
||||||
//! alinhamento, para não depender do formato exato que cada driver emite.
|
|
||||||
//! Especificação: <https://www.radiotap.org/>
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
||||||
pub enum Band {
|
pub enum Band {
|
||||||
@@ -36,7 +36,7 @@ pub struct RadiotapInfo {
|
|||||||
pub signal_dbm: Option<i8>,
|
pub signal_dbm: Option<i8>,
|
||||||
pub noise_dbm: Option<i8>,
|
pub noise_dbm: Option<i8>,
|
||||||
pub header_len: usize,
|
pub header_len: usize,
|
||||||
/// Bitmap "present" da primeira palavra (campos padrão).
|
/// Standard Radiotap `it_present` bit positions.
|
||||||
pub present: u32,
|
pub present: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,20 +48,20 @@ const BIT_FHSS: u32 = 1 << 4;
|
|||||||
const BIT_ANTSIGNAL: u32 = 1 << 5;
|
const BIT_ANTSIGNAL: u32 = 1 << 5;
|
||||||
const BIT_ANTNOISE: u32 = 1 << 6;
|
const BIT_ANTNOISE: u32 = 1 << 6;
|
||||||
|
|
||||||
/// Retorna as infos extraídas e quantos bytes o cabeçalho ocupa (para o
|
/// Parses the Radiotap header at the start of `buf`, returning `(RadiotapInfo, header_len)`.
|
||||||
/// chamador saber onde o frame 802.11 propriamente começa).
|
///
|
||||||
pub fn parse(buf: &[u8]) -> Option<RadiotapInfo> {
|
pub fn parse(buf: &[u8]) -> Option<RadiotapInfo> {
|
||||||
if buf.len() < 8 || buf[0] != 0 {
|
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;
|
let header_len = u16::from_le_bytes([buf[2], buf[3]]) as usize;
|
||||||
if header_len < 8 || header_len > buf.len() {
|
if header_len < 8 || header_len > buf.len() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pode haver múltiplas palavras de "present" (bit 31 = continua). Só a
|
// Advance past any chained `it_present` extension words (bit 31 = more present words).
|
||||||
// primeira descreve os campos padrão; as seguintes são namespaces de
|
//
|
||||||
// extensão que ficam depois dos campos que usamos.
|
//
|
||||||
let mut present = 0u32;
|
let mut present = 0u32;
|
||||||
let mut offset = 4usize;
|
let mut offset = 4usize;
|
||||||
let mut first = true;
|
let mut first = true;
|
||||||
@@ -139,7 +139,7 @@ pub fn freq_to_channel(freq_mhz: u16) -> Option<u32> {
|
|||||||
match freq_mhz {
|
match freq_mhz {
|
||||||
2412..=2472 => Some(((freq_mhz as u32 - 2412) / 5) + 1),
|
2412..=2472 => Some(((freq_mhz as u32 - 2412) / 5) + 1),
|
||||||
2484 => Some(14),
|
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),
|
4910 => Some(182),
|
||||||
4920 => Some(184),
|
4920 => Some(184),
|
||||||
4940 => Some(188),
|
4940 => Some(188),
|
||||||
@@ -193,13 +193,13 @@ mod tests {
|
|||||||
let present: u32 = BIT_TSFT | BIT_FLAGS | BIT_RATE | BIT_CHANNEL | BIT_ANTSIGNAL;
|
let present: u32 = BIT_TSFT | BIT_FLAGS | BIT_RATE | BIT_CHANNEL | BIT_ANTSIGNAL;
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
buf.extend_from_slice(&[0, 0]); // version, pad
|
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());
|
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.extend_from_slice(&0x1122_3344_5566_7788u64.to_le_bytes());
|
||||||
buf.push(0x10); // flags
|
buf.push(0x10); // flags
|
||||||
buf.push(0x02); // rate 1 Mb/s
|
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(&freq.to_le_bytes());
|
||||||
buf.extend_from_slice(&0xa0u16.to_le_bytes());
|
buf.extend_from_slice(&0xa0u16.to_le_bytes());
|
||||||
buf.push(signal as u8);
|
buf.push(signal as u8);
|
||||||
|
|||||||
@@ -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::analysis::Analysis;
|
||||||
use crate::audit::AuditEntry;
|
use crate::audit::AuditEntry;
|
||||||
@@ -28,8 +28,8 @@ pub struct ReportModel {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Default)]
|
#[derive(Debug, Clone, Copy, Default)]
|
||||||
pub struct ReportOptions {
|
pub struct ReportOptions {
|
||||||
/// Troca o `<script src>` por um marcador para o servidor embutir o
|
/// Placeholder replaced with the inline `paged.polyfill.min.js` script in
|
||||||
/// polyfill e gerar um HTML que funciona offline.
|
/// standalone offline HTML exports.
|
||||||
pub inline_pagedjs: bool,
|
pub inline_pagedjs: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,11 +37,11 @@ const PAGED_MARKER: &str = "<!--SABIA_PAGEDJS-->";
|
|||||||
const LOGO_PNG: &[u8] = include_bytes!("../../../logo.png");
|
const LOGO_PNG: &[u8] = include_bytes!("../../../logo.png");
|
||||||
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
|
||||||
/// Embute o logo como `data:` URI — sem isso o relatório standalone (modo
|
/// Encodes the Sabiá logo as a `data:image/png;base64,...` URI so standalone
|
||||||
/// offline, pensado pra abrir sem servidor nenhum) teria um `<img
|
/// exported reports render offline without an HTTP server.
|
||||||
/// src="/logo.png">` que nunca resolve fora do `sabia ui`. Pequeno o
|
///
|
||||||
/// bastante (poucos KB) pra não valer a pena puxar uma crate de base64 só
|
///
|
||||||
/// por isso.
|
///
|
||||||
fn base64(bytes: &[u8]) -> String {
|
fn base64(bytes: &[u8]) -> String {
|
||||||
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
|
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
|
||||||
for chunk in bytes.chunks(3) {
|
for chunk in bytes.chunks(3) {
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
//! Regras de mutação sobre uma palavra do dicionário (estilo hashcat `-r`,
|
//! Wordlist mutation rules (lightweight hashcat `-r` style) and mask brute-force
|
||||||
//! bem mais simples) e força bruta por máscara (estilo hashcat `-a 3`).
|
//! generator (hashcat `-a 3` style).
|
||||||
|
//!
|
||||||
|
//! Because each WPA-PSK candidate requires 4096 rounds of PBKDF2-HMAC-SHA1,
|
||||||
|
//! mutation rules focus on high-yield patterns (capitalization, common numeric
|
||||||
|
//! suffixes, single-character leetspeak) to keep the multiplier bounded.
|
||||||
|
//!
|
||||||
//!
|
//!
|
||||||
//! Em quebra de hash normal, regras são "de graça" — um hash SHA256 custa
|
|
||||||
//! nanossegundos. Aqui não: cada candidato é um PBKDF2-HMAC-SHA1 de 4096
|
|
||||||
//! iterações, então cada variante multiplica o tempo de verdade. As regras
|
|
||||||
//! abaixo cobrem os padrões mais comuns (maiúscula inicial, sufixo numérico,
|
|
||||||
//! leet simples) sem explodir pra centenas de variantes por palavra.
|
|
||||||
|
|
||||||
/// Quantas variantes [`expand`] gera por palavra base (pra calcular o
|
/// Upper bound on variants produced by [`expand`] per base word.
|
||||||
/// multiplicador de tempo antes de rodar).
|
///
|
||||||
pub const VARIANTS_PER_WORD: usize = 1 + 1 + 1 + SUFFIXES.len() + LEET_PAIRS.len();
|
pub const VARIANTS_PER_WORD: usize = 1 + 1 + 1 + SUFFIXES.len() + LEET_PAIRS.len();
|
||||||
|
|
||||||
const SUFFIXES: &[&str] = &[
|
const SUFFIXES: &[&str] = &[
|
||||||
@@ -16,12 +16,12 @@ const SUFFIXES: &[&str] = &[
|
|||||||
"@", "#",
|
"@", "#",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// (char original, substituto) — leet simples, uma troca por vez, não
|
/// `(source_char, leet_replacement)` single-substitution pairs.
|
||||||
/// combinada, senão a combinatória explode rápido demais.
|
///
|
||||||
const LEET_PAIRS: &[(char, char)] = &[('a', '@'), ('e', '3'), ('i', '1'), ('o', '0'), ('s', '$')];
|
const LEET_PAIRS: &[(char, char)] = &[('a', '@'), ('e', '3'), ('i', '1'), ('o', '0'), ('s', '$')];
|
||||||
|
|
||||||
/// Gera as variantes de `word` (incluindo a palavra original) que respeitam
|
/// Generates deduplicated WPA-valid (8–63 char) mutations of `word` (including `word` itself).
|
||||||
/// o comprimento válido de WPA (8–63), sem duplicatas.
|
///
|
||||||
pub fn expand(word: &str) -> Vec<String> {
|
pub fn expand(word: &str) -> Vec<String> {
|
||||||
let mut out = Vec::with_capacity(VARIANTS_PER_WORD);
|
let mut out = Vec::with_capacity(VARIANTS_PER_WORD);
|
||||||
let mut push = |s: String| {
|
let mut push = |s: String| {
|
||||||
@@ -52,9 +52,9 @@ pub fn expand(word: &str) -> Vec<String> {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Classe de caractere de um token `?x` de máscara (sintaxe hashcat: `?d`
|
/// Character class for a mask token (`?d` digits, `?l` lowercase, `?u` uppercase,
|
||||||
/// dígito, `?l` minúscula, `?u` maiúscula, `?s` símbolo comum, `?a` os
|
/// `?s` symbols, `?a` all printable ASCII, `??` literal `?`).
|
||||||
/// quatro juntos). `??` escapa um `?` literal.
|
///
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
enum CharClass {
|
enum CharClass {
|
||||||
Digit,
|
Digit,
|
||||||
@@ -83,17 +83,17 @@ impl CharClass {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Máscara compilada: uma lista de alfabetos, um por posição. Gera
|
/// Compiled brute-force mask (one alphabet per character position), supporting
|
||||||
/// candidatos por índice (contagem em base mista) sem materializar a lista
|
/// O(1) mixed-radix candidate generation by index.
|
||||||
/// inteira — essencial, já que `?d?d?d?d?d?d?d?d` sozinho são 100 milhões.
|
///
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Mask {
|
pub struct Mask {
|
||||||
alphabets: Vec<Vec<char>>,
|
alphabets: Vec<Vec<char>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Mask {
|
impl Mask {
|
||||||
/// Compila uma máscara tipo `wifi?d?d?d?d` (literal "wifi" + 4 dígitos).
|
/// Compiles a hashcat-style mask pattern such as `"wifi?d?d?d?d"`.
|
||||||
/// `??` é um `?` literal; `?` seguido de letra desconhecida é erro.
|
/// `??` escapes a literal `?`; unknown `?x` tokens return an error.
|
||||||
pub fn parse(pattern: &str) -> Result<Self, String> {
|
pub fn parse(pattern: &str) -> Result<Self, String> {
|
||||||
let mut alphabets = Vec::new();
|
let mut alphabets = Vec::new();
|
||||||
let mut chars = pattern.chars().peekable();
|
let mut chars = pattern.chars().peekable();
|
||||||
@@ -123,8 +123,8 @@ impl Mask {
|
|||||||
Ok(Self { alphabets })
|
Ok(Self { alphabets })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Total de candidatos. `None` se não couber num `u64` (máscara longa
|
/// Total keyspace size, or `None` if it overflows `u64`.
|
||||||
/// demais pra ser levada a sério de qualquer forma).
|
///
|
||||||
pub fn total(&self) -> Option<u64> {
|
pub fn total(&self) -> Option<u64> {
|
||||||
self.alphabets
|
self.alphabets
|
||||||
.iter()
|
.iter()
|
||||||
@@ -139,9 +139,9 @@ impl Mask {
|
|||||||
self.alphabets.is_empty()
|
self.alphabets.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Candidato de índice `index` (contagem em base mista, como um
|
/// Materializes the candidate at `index` (mixed-radix odometer order), or
|
||||||
/// odômetro: a última posição gira mais rápido). `None` se `index` >=
|
/// `None` if `index >= total()`.
|
||||||
/// `total()`.
|
///
|
||||||
pub fn candidate(&self, mut index: u64) -> Option<String> {
|
pub fn candidate(&self, mut index: u64) -> Option<String> {
|
||||||
let total = self.total()?;
|
let total = self.total()?;
|
||||||
if index >= total {
|
if index >= total {
|
||||||
@@ -165,8 +165,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn expand_respects_min_length() {
|
fn expand_respects_min_length() {
|
||||||
let variants = expand("senha");
|
let variants = expand("senha");
|
||||||
// "senha" tem 5 chars; sozinha não é candidata WPA válida (precisa
|
// `"senha"` is 5 chars (too short for WPA alone), but `"senha123"` (8 chars) is kept.
|
||||||
// de 8+), mas com sufixo (ex.: "senha123", 8) sim.
|
//
|
||||||
assert!(!variants.iter().any(|v| v == "senha"));
|
assert!(!variants.iter().any(|v| v == "senha"));
|
||||||
assert!(variants.contains(&"senha123".to_string()));
|
assert!(variants.contains(&"senha123".to_string()));
|
||||||
}
|
}
|
||||||
@@ -183,7 +183,7 @@ mod tests {
|
|||||||
assert!(variants.contains(&"Abacaxi1".to_string()));
|
assert!(variants.contains(&"Abacaxi1".to_string()));
|
||||||
assert!(variants.contains(&"ABACAXI1".to_string()));
|
assert!(variants.contains(&"ABACAXI1".to_string()));
|
||||||
assert!(variants.contains(&"abacaxi1123".to_string()));
|
assert!(variants.contains(&"abacaxi1123".to_string()));
|
||||||
// leet troca TODAS as ocorrências de 'a': as três em "abacaxi1".
|
// Leetspeak substitution replaces all occurrences of `'a'` in `"abacaxi1"`.
|
||||||
assert!(variants.contains(&"@b@c@xi1".to_string()));
|
assert!(variants.contains(&"@b@c@xi1".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,8 +208,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mask_literal_question_mark_and_unknown_class() {
|
fn mask_literal_question_mark_and_unknown_class() {
|
||||||
// "??" vira um '?' literal; os dois primeiros chars são consumidos
|
// `"??"` escapes a literal `'?'`, so `"???d"` parses as literal `'?'` + `?d`.
|
||||||
// juntos, então "???d" é: '?' literal + "?d" (classe dígito).
|
//
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
Mask::parse("???d").unwrap().candidate(0).as_deref(),
|
Mask::parse("???d").unwrap().candidate(0).as_deref(),
|
||||||
Some("?0")
|
Some("?0")
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
//! Agregação de quadros capturados em tabelas de redes, clientes e
|
//! Aggregates captured 802.11 frames into access point, client station, and
|
||||||
//! handshakes — o coração do que a CLI e a interface mostram.
|
//! handshake tables.
|
||||||
|
|
||||||
use crate::dot11::{self, FrameKind, Security};
|
use crate::dot11::{self, FrameKind, Security};
|
||||||
use crate::handshake::{Handshake, HandshakeInfo, HandshakeTracker};
|
use crate::handshake::{Handshake, HandshakeInfo, HandshakeTracker};
|
||||||
@@ -59,7 +59,7 @@ pub struct ClientEvent {
|
|||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
pub struct Client {
|
pub struct Client {
|
||||||
pub mac: String,
|
pub mac: String,
|
||||||
/// Nome curto para exibir: hostname, nome WPS, modelo ou fabricante.
|
/// Best human-readable label for a station: DHCP/mDNS hostname, WPS device name, model, or OUI vendor.
|
||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
pub vendor: Option<String>,
|
pub vendor: Option<String>,
|
||||||
pub manufacturer: Option<String>,
|
pub manufacturer: Option<String>,
|
||||||
@@ -78,10 +78,10 @@ pub struct Client {
|
|||||||
pub associated: bool,
|
pub associated: bool,
|
||||||
pub first_seen_unix: u64,
|
pub first_seen_unix: u64,
|
||||||
pub last_seen_unix: u64,
|
pub last_seen_unix: u64,
|
||||||
/// Amostras de sinal (mais antiga primeiro), limitadas para não crescer
|
/// Recent RSSI history (oldest first, bounded ring) for sparkline rendering.
|
||||||
/// sem fim numa captura longa.
|
///
|
||||||
pub signal_history: Vec<SignalSample>,
|
pub signal_history: Vec<SignalSample>,
|
||||||
/// Linha do tempo curta: primeiro visto, probe, associação, desautenticação.
|
/// Per-client event timeline (first seen, probe, association, deauthentication, etc.).
|
||||||
pub events: Vec<ClientEvent>,
|
pub events: Vec<ClientEvent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,15 +102,15 @@ pub struct ScanSnapshot {
|
|||||||
pub clients: Vec<Client>,
|
pub clients: Vec<Client>,
|
||||||
pub handshakes: Vec<HandshakeInfo>,
|
pub handshakes: Vec<HandshakeInfo>,
|
||||||
pub analysis: crate::analysis::Analysis,
|
pub analysis: crate::analysis::Analysis,
|
||||||
/// Preenchido quando a captura morreu sozinha (erro de I/O, adaptador
|
/// Populated when the capture loop terminated due to an I/O error (e.g. unplugged
|
||||||
/// caiu da USB, etc.) — `running` já vem `false` nesse caso, mas sem
|
/// USB adapter).
|
||||||
/// isso a UI não tinha como dizer *por quê* parou.
|
///
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
/// Preenchido quando a captura está rodando mas não recebeu nenhum
|
/// Populated when capture has been running for >12s across channels with zero
|
||||||
/// quadro depois de um tempo razoável saltando canais — sintoma de uma
|
/// received frames (typical of drivers/firmware that silently ignore monitor mode).
|
||||||
/// placa que aceita ativar o modo monitor (o netlink não recusa) mas não
|
///
|
||||||
/// entrega quadro nenhum de verdade. Comum em placas Wi-Fi internas de
|
///
|
||||||
/// notebook; diferente de `error`, a sessão continua `running`.
|
///
|
||||||
pub warning: Option<String>,
|
pub warning: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,8 +140,8 @@ impl ScanTable {
|
|||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Processa um quadro radiotap+802.11 crú. Retorna `true` se o quadro
|
/// Ingests a raw Radiotap + 802.11 frame. Returns `true` if the frame carried
|
||||||
/// continha EAPOL (mantido para chamadores antigos).
|
/// an EAPOL handshake message.
|
||||||
pub fn ingest(&mut self, raw: &[u8]) -> bool {
|
pub fn ingest(&mut self, raw: &[u8]) -> bool {
|
||||||
let now = unix_now();
|
let now = unix_now();
|
||||||
self.ingest_at(raw, now).eapol
|
self.ingest_at(raw, now).eapol
|
||||||
@@ -317,9 +317,9 @@ impl ScanTable {
|
|||||||
};
|
};
|
||||||
if let (Some(client), Some(bssid)) = (client.clone(), bssid.clone()) {
|
if let (Some(client), Some(bssid)) = (client.clone(), bssid.clone()) {
|
||||||
if client != bssid {
|
if client != bssid {
|
||||||
// Calculado antes do touch_client: a TK vem de self.handshakes, e
|
// Look up any derived CCMP Temporal Key (TK) before borrowing `self.clients`.
|
||||||
// o borrow checker não deixa consultar isso com `c` (borrow de
|
//
|
||||||
// self.clients) vivo ao mesmo tempo.
|
//
|
||||||
let tk = (frame.protected && frame.to_ds && !frame.from_ds)
|
let tk = (frame.protected && frame.to_ds && !frame.from_ds)
|
||||||
.then(|| self.handshakes.tk_for(&bssid, &client))
|
.then(|| self.handshakes.tk_for(&bssid, &client))
|
||||||
.flatten();
|
.flatten();
|
||||||
@@ -327,9 +327,9 @@ impl ScanTable {
|
|||||||
c.packets += 1;
|
c.packets += 1;
|
||||||
if frame.to_ds && !frame.from_ds {
|
if frame.to_ds && !frame.from_ds {
|
||||||
if frame.protected {
|
if frame.protected {
|
||||||
// Só decifra se já quebramos a senha e vimos o handshake
|
// Decrypt CCMP data frames when we have derived this station's association TK.
|
||||||
// DESSE cliente — a PTK é por associação, então a senha da
|
//
|
||||||
// rede sozinha não abre tráfego de quem não foi capturado.
|
//
|
||||||
if let Some(plain) = tk.and_then(|tk| frame.ccmp_payload(&tk)) {
|
if let Some(plain) = tk.and_then(|tk| frame.ccmp_payload(&tk)) {
|
||||||
let payload = dot11::payload_info_from_llc(&plain);
|
let payload = dot11::payload_info_from_llc(&plain);
|
||||||
apply_payload_identity(c, &payload);
|
apply_payload_identity(c, &payload);
|
||||||
@@ -339,9 +339,9 @@ impl ScanTable {
|
|||||||
apply_payload_identity(c, &payload);
|
apply_payload_identity(c, &payload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Sempre recalcula o nome de exibição, mesmo em quadros AP→estação
|
// Refresh `display_name` on both uplink and downlink frames so OUI vendor
|
||||||
// (downlink): sem isso, um cliente só visto recebendo dados nunca
|
// fallback is populated even for receive-only stations.
|
||||||
// ganhava o fallback para o fabricante (OUI), ficando só com o MAC.
|
//
|
||||||
refresh_identity(c);
|
refresh_identity(c);
|
||||||
let was = c.associated;
|
let was = c.associated;
|
||||||
c.bssid = Some(bssid.clone());
|
c.bssid = Some(bssid.clone());
|
||||||
@@ -462,9 +462,9 @@ impl ScanTable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Já dá o fallback pro fabricante (OUI) desde o primeiro avistamento,
|
// Populate `display_name` from the OUI vendor immediately on first sighting.
|
||||||
// antes de qualquer quadro de gerenciamento/dados chegar a chamar
|
//
|
||||||
// refresh_identity de novo.
|
//
|
||||||
if c.display_name.is_none() {
|
if c.display_name.is_none() {
|
||||||
refresh_identity(c);
|
refresh_identity(c);
|
||||||
}
|
}
|
||||||
@@ -507,7 +507,7 @@ impl ScanTable {
|
|||||||
self.handshakes.get(bssid, station)
|
self.handshakes.get(bssid, station)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handshakes completos (com quadros crus) para exportação.
|
/// Returns all tracked handshakes (including raw frames for `.pcap`/`.hc22000` export).
|
||||||
pub fn handshake_entries(&self) -> Vec<Handshake> {
|
pub fn handshake_entries(&self) -> Vec<Handshake> {
|
||||||
self.handshakes.all().into_iter().cloned().collect()
|
self.handshakes.all().into_iter().cloned().collect()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
//! Helpers de teste: quadros 802.11/EAPOL sintéticos, radiotap e handshakes
|
//! Unit-test helpers: synthetic Radiotap/802.11/EAPOL frame builders and
|
||||||
//! válidos gerados a partir de uma senha conhecida. Só existe em `cfg(test)`.
|
//! valid 4-way handshake generators for a known passphrase (`#[cfg(test)]`).
|
||||||
|
|
||||||
use crate::crack::{compute_mic, compute_pmkid, compute_ptk, derive_pmk};
|
use crate::crack::{compute_mic, compute_pmkid, compute_ptk, derive_pmk};
|
||||||
use crate::dot11;
|
use crate::dot11;
|
||||||
@@ -46,11 +46,11 @@ pub fn frame_with_eapol(
|
|||||||
f.extend_from_slice(&fc.to_le_bytes());
|
f.extend_from_slice(&fc.to_le_bytes());
|
||||||
f.extend_from_slice(&0u16.to_le_bytes());
|
f.extend_from_slice(&0u16.to_le_bytes());
|
||||||
if from_ap {
|
if from_ap {
|
||||||
f.extend_from_slice(&sta); // addr1 = destino
|
f.extend_from_slice(&sta); // addr1 = destination
|
||||||
f.extend_from_slice(&ap); // addr2 = BSSID
|
f.extend_from_slice(&ap); // addr2 = BSSID
|
||||||
} else {
|
} else {
|
||||||
f.extend_from_slice(&ap); // addr1 = BSSID
|
f.extend_from_slice(&ap); // addr1 = BSSID
|
||||||
f.extend_from_slice(&sta); // addr2 = origem
|
f.extend_from_slice(&sta); // addr2 = source
|
||||||
}
|
}
|
||||||
f.extend_from_slice(&da);
|
f.extend_from_slice(&da);
|
||||||
f.extend_from_slice(&[0u8; 2]);
|
f.extend_from_slice(&[0u8; 2]);
|
||||||
@@ -148,7 +148,7 @@ pub fn data_frame(
|
|||||||
f
|
f
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gera (msg1, msg2) válidas para a senha informada, com MIC correto.
|
/// Generates a valid `(msg1, msg2)` EAPOL-Key pair with a verified MIC for `passphrase`.
|
||||||
pub fn handshake_frames(
|
pub fn handshake_frames(
|
||||||
ssid: &str,
|
ssid: &str,
|
||||||
password: &str,
|
password: &str,
|
||||||
@@ -172,8 +172,8 @@ pub fn handshake_frames(
|
|||||||
(m1, m2)
|
(m1, m2)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Monta um `Handshake` diretamente dos quadros EAPOL, para testes de quebra
|
/// Assembles a `Handshake` directly from synthetic EAPOL frames for cracking tests.
|
||||||
/// sem depender do pipeline de captura.
|
///
|
||||||
pub fn synthetic_handshake(
|
pub fn synthetic_handshake(
|
||||||
ssid: &str,
|
ssid: &str,
|
||||||
bssid: &str,
|
bssid: &str,
|
||||||
@@ -207,7 +207,7 @@ pub fn synthetic_handshake(
|
|||||||
password: None,
|
password: None,
|
||||||
tk: None,
|
tk: None,
|
||||||
};
|
};
|
||||||
// Exercita o parser de MAC do alvo.
|
// Exercises MAC address parsing.
|
||||||
assert!(dot11::parse_mac(&hs.bssid).is_some());
|
assert!(dot11::parse_mac(&hs.bssid).is_some());
|
||||||
assert!(dot11::parse_mac(&hs.station).is_some());
|
assert!(dot11::parse_mac(&hs.station).is_some());
|
||||||
hs
|
hs
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
//! Auto-atualização simples: verifica a última release no Gitea, baixa o
|
//! CLI self-updater: queries the latest Gitea Release, downloads the matching
|
||||||
//! binário certo para a plataforma atual, confere o checksum e troca o
|
//! platform archive, verifies its SHA-256 checksum, and atomically replaces the
|
||||||
//! executável em uso — sem o usuário precisar baixar nada manualmente.
|
//! running `sabia` binary.
|
||||||
//!
|
//!
|
||||||
//! Usa `curl` (quase universal em Linux/macOS) em vez de trazer uma stack
|
//! Uses the system `curl` and `tar` binaries to avoid pulling a full TLS stack
|
||||||
//! HTTP/TLS inteira como dependência só para isso.
|
//! into `sabia-core`.
|
||||||
|
|
||||||
use crate::error::{Result, SabiaError};
|
use crate::error::{Result, SabiaError};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -12,12 +12,12 @@ use std::fs;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
/// A API do Gitea devolve o mesmo formato de `releases/latest` que o GitHub devolvia
|
/// Gitea `releases/latest` API endpoint (`tag_name`, `assets[].name`, `assets[].browser_download_url`).
|
||||||
/// (`tag_name`, `assets[].name`, `assets[].browser_download_url`).
|
///
|
||||||
const LATEST_RELEASE_URL: &str =
|
const LATEST_RELEASE_URL: &str =
|
||||||
"https://git.lucasrguerra.dev.br/api/v1/repos/lucasrguerra/sabia/releases/latest";
|
"https://git.lucasrguerra.dev.br/api/v1/repos/lucasrguerra/sabia/releases/latest";
|
||||||
|
|
||||||
/// Página de downloads, para quem usa o app desktop.
|
/// Human-facing Gitea Releases page for desktop GUI users.
|
||||||
const RELEASES_PAGE_URL: &str = "https://git.lucasrguerra.dev.br/lucasrguerra/sabia/releases";
|
const RELEASES_PAGE_URL: &str = "https://git.lucasrguerra.dev.br/lucasrguerra/sabia/releases";
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -75,9 +75,9 @@ fn curl_download(url: &str, dest: &PathBuf) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sufixo que identifica o pacote certo entre os assets da release — o
|
/// Returns the expected release asset suffix for the current OS/architecture
|
||||||
/// nome completo inclui a tag (ex.: `sabia-v0.1.0-linux-x86_64.tar.gz`),
|
/// (e.g. `"-linux-x86_64.tar.gz"`).
|
||||||
/// então casamos pelo fim em vez do nome inteiro.
|
///
|
||||||
fn asset_suffix_for_platform() -> String {
|
fn asset_suffix_for_platform() -> String {
|
||||||
let os = if cfg!(target_os = "linux") {
|
let os = if cfg!(target_os = "linux") {
|
||||||
"linux"
|
"linux"
|
||||||
@@ -95,15 +95,15 @@ fn asset_suffix_for_platform() -> String {
|
|||||||
format!("-{os}-{arch}.{ext}")
|
format!("-{os}-{arch}.{ext}")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Consulta a última release publicada e compara com a versão atual
|
/// Queries the latest published release and compares it against the running
|
||||||
/// (`CARGO_PKG_VERSION` do binário chamador).
|
/// `CARGO_PKG_VERSION`.
|
||||||
pub fn check(current_version: &str) -> Result<UpdateStatus> {
|
pub fn check(current_version: &str) -> Result<UpdateStatus> {
|
||||||
let url = LATEST_RELEASE_URL;
|
let url = LATEST_RELEASE_URL;
|
||||||
let body = match curl_text(url) {
|
let body = match curl_text(url) {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
// Sem rede, ou repositório ainda sem releases publicadas: não é
|
// Offline or no published releases yet: treat as no update available.
|
||||||
// um erro fatal, só significa "sem atualização disponível".
|
//
|
||||||
return Ok(UpdateStatus {
|
return Ok(UpdateStatus {
|
||||||
current_version: current_version.to_string(),
|
current_version: current_version.to_string(),
|
||||||
latest_version: None,
|
latest_version: None,
|
||||||
@@ -121,12 +121,12 @@ pub fn check(current_version: &str) -> Result<UpdateStatus> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Baixa e aplica a atualização mais recente, substituindo o binário atual.
|
/// Downloads, verifies, and installs the latest CLI release in place.
|
||||||
/// Retorna a versão instalada.
|
/// Returns the newly installed version string.
|
||||||
pub fn apply(current_exe: &PathBuf) -> Result<String> {
|
pub fn apply(current_exe: &PathBuf) -> Result<String> {
|
||||||
// O pacote da release contém o CLI (`sabia`). Trocar por ele um executável que
|
// Guard against replacing `sabia-gui` with the CLI binary (`sabia`);
|
||||||
// não seja o CLI, como o `sabia-gui`, o deixaria como um CLI e quebraria o app
|
// desktop GUI updates use the Tauri updater / installer instead.
|
||||||
// na próxima abertura. O app desktop se atualiza baixando o instalador.
|
//
|
||||||
let is_cli = current_exe
|
let is_cli = current_exe
|
||||||
.file_stem()
|
.file_stem()
|
||||||
.map(|stem| stem == "sabia")
|
.map(|stem| stem == "sabia")
|
||||||
@@ -183,10 +183,10 @@ pub fn apply(current_exe: &PathBuf) -> Result<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extrai o pacote (tar.gz no Linux/macOS, zip no Windows — `tar xf` sem
|
// Extract the archive (`tar xf` auto-detects `.tar.gz` and `.zip` on modern
|
||||||
// flag de compressão detecta os dois formatos nos `tar` modernos,
|
// libarchive/bsdtar) and atomically replace the current executable.
|
||||||
// incluindo o bsdtar que o Windows já traz por padrão) e substitui o
|
//
|
||||||
// binário atual atomicamente.
|
//
|
||||||
let extract_dir = tmp_dir.join("extracted");
|
let extract_dir = tmp_dir.join("extracted");
|
||||||
let _ = fs::remove_dir_all(&extract_dir);
|
let _ = fs::remove_dir_all(&extract_dir);
|
||||||
fs::create_dir_all(&extract_dir)?;
|
fs::create_dir_all(&extract_dir)?;
|
||||||
@@ -219,9 +219,9 @@ pub fn apply(current_exe: &PathBuf) -> Result<String> {
|
|||||||
Ok(release.tag_name)
|
Ok(release.tag_name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// O binário vem dentro de uma subpasta (o CI empacota
|
/// Locates `name` at the archive root or one directory level below
|
||||||
/// `sabia-vX.Y.Z-<plataforma>/sabia`), então procura até um nível de
|
/// (`sabia-vX.Y.Z-<platform>/sabia`).
|
||||||
/// profundidade abaixo do diretório extraído.
|
///
|
||||||
fn find_binary(dir: &PathBuf) -> Result<PathBuf> {
|
fn find_binary(dir: &PathBuf) -> Result<PathBuf> {
|
||||||
fn is_binary(name: &std::ffi::OsStr) -> bool {
|
fn is_binary(name: &std::ffi::OsStr) -> bool {
|
||||||
name == "sabia" || name == "sabia.exe"
|
name == "sabia" || name == "sabia.exe"
|
||||||
@@ -252,7 +252,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn apply_refuses_to_overwrite_the_desktop_app() {
|
fn apply_refuses_to_overwrite_the_desktop_app() {
|
||||||
// Falha antes de qualquer acesso à rede: o nome do executável já não é o do CLI.
|
// Rejects non-CLI binary names before making any network requests.
|
||||||
let err = apply(&PathBuf::from("/opt/sabia/sabia-gui")).unwrap_err();
|
let err = apply(&PathBuf::from("/opt/sabia/sabia-gui")).unwrap_err();
|
||||||
assert!(err.to_string().contains("só para o CLI"), "{err}");
|
assert!(err.to_string().contains("só para o CLI"), "{err}");
|
||||||
let err = apply(&PathBuf::from("C:/Program Files/Sabia/sabia-gui.exe")).unwrap_err();
|
let err = apply(&PathBuf::from("C:/Program Files/Sabia/sabia-gui.exe")).unwrap_err();
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
//! Ataque de PIN WPS com o `reaver` embutido.
|
//! WPS PIN / Pixie Dust auditing via bundled `reaver` (or system `bully`).
|
||||||
|
//!
|
||||||
|
//! Extracts the bundled `reaver` binary at runtime (see
|
||||||
|
//! `crates/sabia-core/assets/tools/LICENSE.reaver`) or uses a system
|
||||||
|
//! `reaver`/`bully` installation when present.
|
||||||
|
//!
|
||||||
//!
|
//!
|
||||||
//! O protocolo EAP-WSC é grande demais para reimplementar aqui; o Sabiá
|
|
||||||
//! carrega uma cópia do reaver (GPL-2, veja `crates/sabia-core/assets/tools/LICENSE.reaver`),
|
|
||||||
//! extrai em tempo de execução e mostra a saída ao vivo. Assim o WPS já
|
|
||||||
//! funciona sem instalar nada. Se o usuário tiver um `bully` no PATH, ele
|
|
||||||
//! também aparece como alternativa.
|
|
||||||
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -19,9 +19,9 @@ pub struct WpsTool {
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
const BUNDLED_REAVER: &[u8] = include_bytes!("../assets/tools/reaver");
|
const BUNDLED_REAVER: &[u8] = include_bytes!("../assets/tools/reaver");
|
||||||
|
|
||||||
/// Procura um binário no `PATH` — usado pelo WPS (reaver/bully) e, fora
|
/// Searches `PATH` (plus standard sbin directories) for an executable `name`.
|
||||||
/// deste módulo, pelo Evil Twin (hostapd/dnsmasq), que precisa da mesma
|
///
|
||||||
/// checagem sem puxar uma dependência nova só pra isso.
|
///
|
||||||
pub(crate) fn find_in_path(binary: &str) -> Option<PathBuf> {
|
pub(crate) fn find_in_path(binary: &str) -> Option<PathBuf> {
|
||||||
let path = std::env::var_os("PATH")?;
|
let path = std::env::var_os("PATH")?;
|
||||||
for dir in std::env::split_paths(&path) {
|
for dir in std::env::split_paths(&path) {
|
||||||
@@ -72,8 +72,8 @@ fn bundled_reaver() -> Option<PathBuf> {
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
// `temp_dir` é compartilhado; usa um diretório por UID e confere que ele
|
// Extract into a per-UID private directory and verify it is not a symlink.
|
||||||
// não é um symlink antes de gravar, para não seguir link de outro usuário.
|
//
|
||||||
let uid = unsafe { libc::geteuid() };
|
let uid = unsafe { libc::geteuid() };
|
||||||
let dir = std::env::temp_dir().join(format!("sabia-tools-{uid}"));
|
let dir = std::env::temp_dir().join(format!("sabia-tools-{uid}"));
|
||||||
if let Ok(meta) = fs::symlink_metadata(&dir) {
|
if let Ok(meta) = fs::symlink_metadata(&dir) {
|
||||||
@@ -104,8 +104,8 @@ fn bundled_reaver() -> Option<PathBuf> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Argumentos para uma execução. Sem PIN, usa Pixie Dust (`-K 1`) no reaver
|
/// Builds CLI arguments for `reaver` or `bully` (defaults to Pixie Dust `-K 1` when `pin` is `None`).
|
||||||
/// e o ataque padrão no bully.
|
///
|
||||||
pub fn args(tool: &str, iface: &str, bssid: &str, channel: u32, pin: Option<&str>) -> Vec<String> {
|
pub fn args(tool: &str, iface: &str, bssid: &str, channel: u32, pin: Option<&str>) -> Vec<String> {
|
||||||
let channel = channel.to_string();
|
let channel = channel.to_string();
|
||||||
match tool {
|
match tool {
|
||||||
@@ -151,7 +151,7 @@ pub fn args(tool: &str, iface: &str, bssid: &str, channel: u32, pin: Option<&str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extrai PIN/PSK de uma linha de saída do reaver/bully.
|
/// Parses a recovered WPS PIN or WPA PSK from a `reaver`/`bully` output line.
|
||||||
pub fn parse_line(line: &str) -> (Option<String>, Option<String>) {
|
pub fn parse_line(line: &str) -> (Option<String>, Option<String>) {
|
||||||
let lower = line.to_ascii_lowercase();
|
let lower = line.to_ascii_lowercase();
|
||||||
let grab = |needle: &str| -> Option<String> {
|
let grab = |needle: &str| -> Option<String> {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Integração: trilha de auditoria encadeada por hash — grava entradas,
|
//! Integration test: hash-chained audit trail — appends entries, verifies
|
||||||
//! confirma que a cadeia valida, depois adultera um registro no disco e
|
//! chain integrity, then tampers with a middle record on disk and confirms
|
||||||
//! confirma que a verificação detecta a falha.
|
//! verification pinpoints the corrupted entry.
|
||||||
|
|
||||||
mod common;
|
mod common;
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ fn chain_validates_after_recording_entries() {
|
|||||||
assert_eq!(entries.len(), 3);
|
assert_eq!(entries.len(), 3);
|
||||||
assert_eq!(entries[0].action, "monitor-on");
|
assert_eq!(entries[0].action, "monitor-on");
|
||||||
assert_eq!(entries[2].action, "deauth");
|
assert_eq!(entries[2].action, "deauth");
|
||||||
// cadeia encadeada corretamente
|
// each entry's `prev_hash` matches the preceding entry's `hash`
|
||||||
assert_eq!(entries[1].prev_hash, entries[0].hash);
|
assert_eq!(entries[1].prev_hash, entries[0].hash);
|
||||||
assert_eq!(entries[2].prev_hash, entries[1].hash);
|
assert_eq!(entries[2].prev_hash, entries[1].hash);
|
||||||
|
|
||||||
@@ -52,11 +52,11 @@ fn tampering_with_an_entry_breaks_verification() {
|
|||||||
.expect("record 3");
|
.expect("record 3");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Confirma que, intacta, a cadeia passa.
|
// Intact chain verifies cleanly.
|
||||||
assert_eq!(AuditLog::verify(&path).expect("verify ok"), None);
|
assert_eq!(AuditLog::verify(&path).expect("verify ok"), None);
|
||||||
|
|
||||||
// Adultera o "detail" da segunda entrada sem recalcular o hash,
|
// Tamper with the second entry's `detail` field without recomputing its hash.
|
||||||
// simulando uma edição retroativa do log.
|
//
|
||||||
let contents = std::fs::read_to_string(&path).expect("read");
|
let contents = std::fs::read_to_string(&path).expect("read");
|
||||||
let mut lines: Vec<String> = contents.lines().map(|l| l.to_string()).collect();
|
let mut lines: Vec<String> = contents.lines().map(|l| l.to_string()).collect();
|
||||||
assert_eq!(lines.len(), 3);
|
assert_eq!(lines.len(), 3);
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
//! Helpers de teste de integração: quadros 802.11/EAPOL sintéticos,
|
//! Integration-test helpers: synthetic Radiotap/802.11/EAPOL frames and
|
||||||
//! radiotap e handshakes válidos gerados a partir de uma senha conhecida.
|
//! valid 4-way handshakes generated from a known passphrase.
|
||||||
|
//!
|
||||||
|
//!
|
||||||
|
//!
|
||||||
//!
|
//!
|
||||||
//! Porta a lógica de `crates/sabia-core/src/testutil.rs` (que só existe em
|
|
||||||
//! `#[cfg(test)]` e por isso não fica visível aqui), usando só a API
|
|
||||||
//! pública de `sabia_core`.
|
|
||||||
|
|
||||||
use sabia_core::crack::{compute_mic, compute_pmkid, compute_ptk, derive_pmk};
|
use sabia_core::crack::{compute_mic, compute_pmkid, compute_ptk, derive_pmk};
|
||||||
use sabia_core::dot11;
|
use sabia_core::dot11;
|
||||||
@@ -58,11 +58,11 @@ pub fn frame_with_eapol(
|
|||||||
f.extend_from_slice(&fc.to_le_bytes());
|
f.extend_from_slice(&fc.to_le_bytes());
|
||||||
f.extend_from_slice(&0u16.to_le_bytes());
|
f.extend_from_slice(&0u16.to_le_bytes());
|
||||||
if from_ap {
|
if from_ap {
|
||||||
f.extend_from_slice(&sta); // addr1 = destino
|
f.extend_from_slice(&sta); // addr1 = destination
|
||||||
f.extend_from_slice(&ap); // addr2 = BSSID
|
f.extend_from_slice(&ap); // addr2 = BSSID
|
||||||
} else {
|
} else {
|
||||||
f.extend_from_slice(&ap); // addr1 = BSSID
|
f.extend_from_slice(&ap); // addr1 = BSSID
|
||||||
f.extend_from_slice(&sta); // addr2 = origem
|
f.extend_from_slice(&sta); // addr2 = source
|
||||||
}
|
}
|
||||||
f.extend_from_slice(&da);
|
f.extend_from_slice(&da);
|
||||||
f.extend_from_slice(&[0u8; 2]);
|
f.extend_from_slice(&[0u8; 2]);
|
||||||
@@ -160,7 +160,7 @@ pub fn data_frame(
|
|||||||
f
|
f
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gera (msg1, msg2) válidas para a senha informada, com MIC correto.
|
/// Generates a valid `(msg1, msg2)` EAPOL-Key pair with a verified MIC for `passphrase`.
|
||||||
pub fn handshake_frames(
|
pub fn handshake_frames(
|
||||||
ssid: &str,
|
ssid: &str,
|
||||||
password: &str,
|
password: &str,
|
||||||
@@ -184,8 +184,8 @@ pub fn handshake_frames(
|
|||||||
(m1, m2)
|
(m1, m2)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Monta quadros radiotap+802.11 completos (prontos para `ScanTable::ingest`)
|
/// Builds complete Radiotap + 802.11 frames (ready for `ScanTable::ingest`)
|
||||||
/// com o 4-way handshake entre `AP` e `STA` para o SSID/senha dados.
|
/// carrying the 4-way handshake between `AP` and `STA` for `ssid`/`passphrase`.
|
||||||
pub fn handshake_radiotap_frames(
|
pub fn handshake_radiotap_frames(
|
||||||
ssid: &str,
|
ssid: &str,
|
||||||
password: &str,
|
password: &str,
|
||||||
@@ -198,8 +198,8 @@ pub fn handshake_radiotap_frames(
|
|||||||
(radiotap_wrap(&f1, 2437, -40), radiotap_wrap(&f2, 2437, -42))
|
(radiotap_wrap(&f1, 2437, -40), radiotap_wrap(&f2, 2437, -42))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Monta um `Handshake` diretamente dos quadros EAPOL, para testes de quebra
|
/// Assembles a `Handshake` directly from synthetic EAPOL frames for cracking tests.
|
||||||
/// sem depender do pipeline de captura.
|
///
|
||||||
pub fn synthetic_handshake(
|
pub fn synthetic_handshake(
|
||||||
ssid: &str,
|
ssid: &str,
|
||||||
bssid: &str,
|
bssid: &str,
|
||||||
@@ -233,7 +233,7 @@ pub fn synthetic_handshake(
|
|||||||
password: None,
|
password: None,
|
||||||
tk: None,
|
tk: None,
|
||||||
};
|
};
|
||||||
// Exercita o parser de MAC do alvo.
|
// Exercises MAC address parsing.
|
||||||
assert!(dot11::parse_mac(&hs.bssid).is_some());
|
assert!(dot11::parse_mac(&hs.bssid).is_some());
|
||||||
assert!(dot11::parse_mac(&hs.station).is_some());
|
assert!(dot11::parse_mac(&hs.station).is_some());
|
||||||
hs
|
hs
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Integração: handshake sintético (gerado com senha conhecida) -> montagem
|
//! Integration test: synthetic handshake -> `CrackTarget` -> `verify()` and
|
||||||
//! de `CrackTarget` -> `verify()`, para os dois métodos (EAPOL 4-way e
|
//! dictionary cracking across both EAPOL 4-way and PMKID modes.
|
||||||
//! PMKID), cobrindo senha certa e errada.
|
//!
|
||||||
|
|
||||||
mod common;
|
mod common;
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ fn tkip_keyversion_roundtrip_verifies_password() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn incomplete_handshake_without_pmkid_or_msg2_has_no_target() {
|
fn incomplete_handshake_without_pmkid_or_msg2_has_no_target() {
|
||||||
let (m1, _m2) = common::handshake_frames("MinhaRede", "senha-correta", 2, false);
|
let (m1, _m2) = common::handshake_frames("MinhaRede", "senha-correta", 2, false);
|
||||||
// Sem msg2 e sem PMKID: não há prova suficiente para quebrar.
|
// Without message 2 or a PMKID, the handshake is not yet crackable.
|
||||||
let hs = common::synthetic_handshake("MinhaRede", BSSID, STATION, &m1, &[]);
|
let hs = common::synthetic_handshake("MinhaRede", BSSID, STATION, &m1, &[]);
|
||||||
assert!(CrackTarget::from_handshake(&hs).is_none());
|
assert!(CrackTarget::from_handshake(&hs).is_none());
|
||||||
}
|
}
|
||||||
@@ -60,17 +60,17 @@ fn derive_pmk_compute_ptk_and_mic_are_consistent() {
|
|||||||
let pmk = derive_pmk(password, ssid);
|
let pmk = derive_pmk(password, ssid);
|
||||||
let ptk = compute_ptk(&pmk, &common::ap(), &common::sta(), &anonce, &snonce);
|
let ptk = compute_ptk(&pmk, &common::ap(), &common::sta(), &anonce, &snonce);
|
||||||
|
|
||||||
// MIC calculado sobre um quadro msg2 qualquer, zerando o campo MIC antes.
|
// Compute the MIC over a message 2 frame with its MIC field zeroed.
|
||||||
let mut m2 = common::key_frame(2, 2, snonce, &[]);
|
let mut m2 = common::key_frame(2, 2, snonce, &[]);
|
||||||
let mic = compute_mic(2, &ptk[..16], &m2, sabia_core::eapol::MIC_OFFSET).expect("mic");
|
let mic = compute_mic(2, &ptk[..16], &m2, sabia_core::eapol::MIC_OFFSET).expect("mic");
|
||||||
m2[sabia_core::eapol::MIC_OFFSET..sabia_core::eapol::MIC_OFFSET + 16].copy_from_slice(&mic);
|
m2[sabia_core::eapol::MIC_OFFSET..sabia_core::eapol::MIC_OFFSET + 16].copy_from_slice(&mic);
|
||||||
|
|
||||||
// Recalculando o MIC sobre o quadro já preenchido deve bater de novo
|
// Recomputing the MIC over the already-signed frame must yield the same digest
|
||||||
// (compute_mic zera o campo internamente antes de recalcular).
|
// (`compute_mic` zeroes the MIC slice before hashing).
|
||||||
let mic_again = compute_mic(2, &ptk[..16], &m2, sabia_core::eapol::MIC_OFFSET).expect("mic");
|
let mic_again = compute_mic(2, &ptk[..16], &m2, sabia_core::eapol::MIC_OFFSET).expect("mic");
|
||||||
assert_eq!(mic, mic_again);
|
assert_eq!(mic, mic_again);
|
||||||
|
|
||||||
// PMKID direto também deve ser determinístico e usável no verify().
|
// Direct PMKID computation is deterministic and verifies via `CrackTarget::Pmkid`.
|
||||||
let pmkid = compute_pmkid(&pmk, &common::ap(), &common::sta());
|
let pmkid = compute_pmkid(&pmk, &common::ap(), &common::sta());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pmkid,
|
pmkid,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Integração: grava quadros com `PcapWriter`, lê de volta com
|
//! Integration test: writes frames with `PcapWriter`, reads them back with
|
||||||
//! `PcapReader` e confere que batem byte a byte; depois confirma que
|
//! `PcapReader`, and verifies `Engine::analyze_pcap` reconstructs the AP and handshake.
|
||||||
//! `Engine::analyze_pcap` encontra redes e handshakes no arquivo recém-escrito.
|
//!
|
||||||
|
|
||||||
mod common;
|
mod common;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Integração: beacon + probe + handshake completo passando pelo caminho
|
//! End-to-end integration test: ingests beacon, probe, and 4-way handshake
|
||||||
//! público de agregação (`ScanTable`), verificando que AP, cliente e
|
//! frames into `ScanTable` and verifies AP, client, handshake, and live CCMP
|
||||||
//! handshake são detectados de ponta a ponta.
|
//! data frame decryption.
|
||||||
|
|
||||||
mod common;
|
mod common;
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ use sabia_core::scan::ScanTable;
|
|||||||
fn detects_ap_client_and_handshake_end_to_end() {
|
fn detects_ap_client_and_handshake_end_to_end() {
|
||||||
let mut table = ScanTable::new();
|
let mut table = ScanTable::new();
|
||||||
|
|
||||||
// RSN com AKM PSK (2) para que a rede apareça como WPA2-PSK.
|
// Include an RSN IE with PSK AKM (2) so the AP is classified as WPA2-PSK.
|
||||||
let beacon = common::beacon_frame(common::ap(), "RedeTeste", 6, &[2]);
|
let beacon = common::beacon_frame(common::ap(), "RedeTeste", 6, &[2]);
|
||||||
table.ingest(&common::radiotap_wrap(&beacon, 2437, -35));
|
table.ingest(&common::radiotap_wrap(&beacon, 2437, -35));
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ fn detects_ap_client_and_handshake_end_to_end() {
|
|||||||
fn hidden_ssid_beacon_is_marked_hidden() {
|
fn hidden_ssid_beacon_is_marked_hidden() {
|
||||||
let mut table = ScanTable::new();
|
let mut table = ScanTable::new();
|
||||||
|
|
||||||
// SSID vazio = rede oculta.
|
// Empty 0-length SSID IE = hidden network.
|
||||||
let beacon = common::beacon_frame(common::ap(), "", 11, &[2]);
|
let beacon = common::beacon_frame(common::ap(), "", 11, &[2]);
|
||||||
table.ingest(&common::radiotap_wrap(&beacon, 2462, -60));
|
table.ingest(&common::radiotap_wrap(&beacon, 2462, -60));
|
||||||
|
|
||||||
@@ -67,11 +67,11 @@ fn hidden_ssid_beacon_is_marked_hidden() {
|
|||||||
assert!(aps[0].hidden);
|
assert!(aps[0].hidden);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Depois que a senha de uma rede WPA2/CCMP é conhecida (quebrada), o
|
/// Once a WPA2/CCMP handshake has been cracked, subsequent protected QoS Data
|
||||||
/// tráfego de dados do cliente cujo handshake foi capturado passa a ser
|
/// frames from that station are decrypted on the fly using its derived TK to
|
||||||
/// decifrado em tempo real pelo pipeline de ingestão — e a identidade
|
/// populate client identity fields (such as its IPv4 address).
|
||||||
/// (aqui, o IP de origem, que exercita o parser de IPv4 por trás do LLC)
|
///
|
||||||
/// aparece mesmo com o bit "Protected" ligado.
|
///
|
||||||
#[test]
|
#[test]
|
||||||
fn decrypts_ccmp_traffic_once_password_is_known_and_fills_client_identity() {
|
fn decrypts_ccmp_traffic_once_password_is_known_and_fills_client_identity() {
|
||||||
use sabia_core::crack::{compute_ptk, derive_pmk};
|
use sabia_core::crack::{compute_ptk, derive_pmk};
|
||||||
@@ -91,8 +91,8 @@ fn decrypts_ccmp_traffic_once_password_is_known_and_fills_client_identity() {
|
|||||||
let station_str = sabia_core::dot11::mac_to_string(&common::sta());
|
let station_str = sabia_core::dot11::mac_to_string(&common::sta());
|
||||||
table.set_password(&bssid_str, &station_str, password);
|
table.set_password(&bssid_str, &station_str, password);
|
||||||
|
|
||||||
// A TK derivada pelo pipeline tem que bater com a calculada à parte, com
|
// The pipeline-derived TK must match the TK computed directly from the same nonces.
|
||||||
// os mesmos nonces fixos (0xaa/0xbb) que handshake_radiotap_frames usa.
|
//
|
||||||
let anonce = [0xaa; 32];
|
let anonce = [0xaa; 32];
|
||||||
let snonce = [0xbb; 32];
|
let snonce = [0xbb; 32];
|
||||||
let pmk = derive_pmk(password, ssid);
|
let pmk = derive_pmk(password, ssid);
|
||||||
@@ -107,16 +107,16 @@ fn decrypts_ccmp_traffic_once_password_is_known_and_fills_client_identity() {
|
|||||||
"TK deveria ser derivada ao quebrar a senha"
|
"TK deveria ser derivada ao quebrar a senha"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Monta um quadro QoS Data, STA->AP, protegido, com CCMP header (PN=1,
|
// Build a protected STA->AP QoS Data frame (CCMP PN=1, ExtIV=1) carrying an
|
||||||
// ExtIV ligado) e cifra um payload LLC/SNAP+IPv4 "na mão" — o CTR do
|
// encrypted LLC/SNAP + IPv4 packet.
|
||||||
// CCMP é simétrico, então usar decrypt_data pra cifrar é válido aqui.
|
//
|
||||||
let llc_ipv4 = {
|
let llc_ipv4 = {
|
||||||
let mut v = vec![0xAA, 0xAA, 0x03, 0x00, 0x00, 0x00, 0x08, 0x00];
|
let mut v = vec![0xAA, 0xAA, 0x03, 0x00, 0x00, 0x00, 0x08, 0x00];
|
||||||
let mut ip = vec![
|
let mut ip = vec![
|
||||||
0x45, 0x00, 0x00, 20, 0x00, 0x00, 0x00, 0x00, 64, 17, 0x00, 0x00,
|
0x45, 0x00, 0x00, 20, 0x00, 0x00, 0x00, 0x00, 64, 17, 0x00, 0x00,
|
||||||
];
|
];
|
||||||
ip.extend_from_slice(&[192, 168, 1, 50]); // origem privada
|
ip.extend_from_slice(&[192, 168, 1, 50]); // private source IPv4
|
||||||
ip.extend_from_slice(&[192, 168, 1, 1]); // destino
|
ip.extend_from_slice(&[192, 168, 1, 1]); // destination IPv4
|
||||||
v.extend_from_slice(&ip);
|
v.extend_from_slice(&ip);
|
||||||
v
|
v
|
||||||
};
|
};
|
||||||
@@ -124,22 +124,22 @@ fn decrypts_ccmp_traffic_once_password_is_known_and_fills_client_identity() {
|
|||||||
let mut to_cipher = Vec::new();
|
let mut to_cipher = Vec::new();
|
||||||
to_cipher.extend_from_slice(&ccmp_header);
|
to_cipher.extend_from_slice(&ccmp_header);
|
||||||
to_cipher.extend_from_slice(&llc_ipv4);
|
to_cipher.extend_from_slice(&llc_ipv4);
|
||||||
to_cipher.extend_from_slice(&[0u8; 8]); // placeholder pro MIC, ignorado
|
to_cipher.extend_from_slice(&[0u8; 8]); // 8-byte MIC placeholder
|
||||||
let ciphertext = sabia_core::ccmp::decrypt_data(&expected_tk, &common::sta(), 0, &to_cipher)
|
let ciphertext = sabia_core::ccmp::decrypt_data(&expected_tk, &common::sta(), 0, &to_cipher)
|
||||||
.expect("cifra via CTR simétrico");
|
.expect("cifra via CTR simétrico");
|
||||||
|
|
||||||
let mut body = Vec::new();
|
let mut body = Vec::new();
|
||||||
body.extend_from_slice(&ccmp_header);
|
body.extend_from_slice(&ccmp_header);
|
||||||
body.extend_from_slice(&ciphertext);
|
body.extend_from_slice(&ciphertext);
|
||||||
body.extend_from_slice(&[0u8; 8]); // MIC cifrado — não conferido na decriptação
|
body.extend_from_slice(&[0u8; 8]); // trailing 8-byte MIC
|
||||||
|
|
||||||
let mut frame = Vec::new();
|
let mut frame = Vec::new();
|
||||||
let fc: u16 = 0x0088 | 0x0100 | 0x4000; // QoS Data, to_ds, protected
|
let fc: u16 = 0x0088 | 0x0100 | 0x4000; // QoS Data, to_ds, protected
|
||||||
frame.extend_from_slice(&fc.to_le_bytes());
|
frame.extend_from_slice(&fc.to_le_bytes());
|
||||||
frame.extend_from_slice(&0u16.to_le_bytes()); // duration
|
frame.extend_from_slice(&0u16.to_le_bytes()); // duration
|
||||||
frame.extend_from_slice(&common::ap()); // addr1 = BSSID
|
frame.extend_from_slice(&common::ap()); // addr1 = BSSID
|
||||||
frame.extend_from_slice(&common::sta()); // addr2 = transmissor (estação)
|
frame.extend_from_slice(&common::sta()); // addr2 = transmitter (station)
|
||||||
frame.extend_from_slice(&common::ap()); // addr3 = destino (irrelevante aqui)
|
frame.extend_from_slice(&common::ap()); // addr3 = destination
|
||||||
frame.extend_from_slice(&[0u8; 2]); // sequence control
|
frame.extend_from_slice(&[0u8; 2]); // sequence control
|
||||||
frame.extend_from_slice(&[0u8; 2]); // QoS control, TID 0
|
frame.extend_from_slice(&[0u8; 2]); // QoS control, TID 0
|
||||||
frame.extend_from_slice(&body);
|
frame.extend_from_slice(&body);
|
||||||
@@ -163,11 +163,11 @@ fn decrypts_ccmp_traffic_once_password_is_known_and_fills_client_identity() {
|
|||||||
fn pmkid_only_handshake_is_detected_and_crackable() {
|
fn pmkid_only_handshake_is_detected_and_crackable() {
|
||||||
let mut table = ScanTable::new();
|
let mut table = ScanTable::new();
|
||||||
|
|
||||||
// Beacon com SSID visível, para que o handshake herde o SSID via hint.
|
// Beacon with visible SSID so the PMKID handshake inherits the SSID hint.
|
||||||
let beacon = common::beacon_frame(common::ap(), "RedePmkid", 11, &[2]);
|
let beacon = common::beacon_frame(common::ap(), "RedePmkid", 11, &[2]);
|
||||||
table.ingest_at(&common::radiotap_wrap(&beacon, 2462, -60), 1);
|
table.ingest_at(&common::radiotap_wrap(&beacon, 2462, -60), 1);
|
||||||
|
|
||||||
// Handshake via PMKID (só msg1, sem msg2/snonce).
|
// PMKID-only handshake (message 1 with PMKID KDE, no message 2).
|
||||||
let (m1, _m2) = common::handshake_frames("RedePmkid", "outra-senha", 2, true);
|
let (m1, _m2) = common::handshake_frames("RedePmkid", "outra-senha", 2, true);
|
||||||
let f1 = common::frame_with_eapol(true, common::ap(), common::sta(), common::sta(), &m1);
|
let f1 = common::frame_with_eapol(true, common::ap(), common::sta(), common::sta(), &m1);
|
||||||
let ev = table.ingest_at(&common::radiotap_wrap(&f1, 2462, -55), 20);
|
let ev = table.ingest_at(&common::radiotap_wrap(&f1, 2462, -55), 20);
|
||||||
|
|||||||
+14
-17
@@ -1,35 +1,32 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Instala pacotes do apt no CI sem depender do espelho estar rápido: usa os .deb em cache quando
|
# Installs apt packages in CI without depending on fast mirrors: reuses cached .deb
|
||||||
# existem e, se o apt travar ou falhar, tenta de novo com tempo limite por tentativa.
|
# archives when present and retries with per-attempt timeouts if apt stalls or fails.
|
||||||
#
|
#
|
||||||
# Os passos de apt do CI já levaram de 13 a 28 minutos num dia de espelho lento, contra uns 35 s
|
# Usage: scripts/apt-install.sh <cache-dir> <package>...
|
||||||
# no normal. O cache evita o download; o tempo limite evita ficar preso.
|
|
||||||
#
|
|
||||||
# Uso: scripts/apt-install.sh <pasta-do-cache> <pacote>...
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
[ $# -ge 2 ] || { echo "uso: $0 <pasta-do-cache> <pacote>..." >&2; exit 2; }
|
[ $# -ge 2 ] || { echo "usage: $0 <cache-dir> <package>..." >&2; exit 2; }
|
||||||
cache="$1"; shift
|
cache="$1"; shift
|
||||||
pkgs=("$@")
|
pkgs=("$@")
|
||||||
mkdir -p "$cache/partial"
|
mkdir -p "$cache/partial"
|
||||||
# O apt roda como root e deixa arquivos (lock, .deb) que o usuário do runner não lê; sem devolver a
|
# apt runs as root and leaves files (lock, .deb) unreadable by non-root runner users;
|
||||||
# posse, o actions/cache falha ao empacotar a pasta ("tar failed with exit code 2") e nunca grava.
|
# restore ownership on exit so cache directories remain accessible.
|
||||||
trap 'sudo chown -R "$(id -u):$(id -g)" "$cache" 2>/dev/null || true; rm -f "$cache/lock"' EXIT
|
trap 'sudo chown -R "$(id -u):$(id -g)" "$cache" 2>/dev/null || true; rm -f "$cache/lock"' EXIT
|
||||||
opts=(-o "Dir::Cache::archives=$cache" -y)
|
opts=(-o "Dir::Cache::archives=$cache" -y)
|
||||||
|
|
||||||
# 1) com .deb em cache e índice presente: instala direto, sem nem atualizar o índice do apt
|
# 1) With cached .deb files and package index present: install directly without updating apt lists
|
||||||
if [ -z "$(dpkg --print-foreign-architectures)" ] && ls "$cache"/*.deb >/dev/null 2>&1 && ls /var/lib/apt/lists/*Packages* >/dev/null 2>&1 && timeout 300 sudo apt-get "${opts[@]}" install "${pkgs[@]}"; then
|
if [ -z "$(dpkg --print-foreign-architectures)" ] && ls "$cache"/*.deb >/dev/null 2>&1 && ls /var/lib/apt/lists/*Packages* >/dev/null 2>&1 && timeout 300 sudo apt-get "${opts[@]}" install "${pkgs[@]}"; then
|
||||||
echo "instalado a partir do cache"
|
echo "installed from cache"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 2) sem cache (ou multiarch / incompleto): atualiza e instala, até 3 tentativas com tempo limite
|
# 2) Cache miss (or multiarch / incomplete): update and install, up to 3 attempts with timeout
|
||||||
for tentativa in 1 2 3; do
|
for attempt in 1 2 3; do
|
||||||
if timeout 240 sudo apt-get update && { [ -z "$(dpkg --print-foreign-architectures)" ] || timeout 600 sudo apt-get "${opts[@]}" upgrade; } && timeout 600 sudo apt-get "${opts[@]}" install "${pkgs[@]}"; then
|
if timeout 240 sudo apt-get update && { [ -z "$(dpkg --print-foreign-architectures)" ] || timeout 600 sudo apt-get "${opts[@]}" upgrade; } && timeout 600 sudo apt-get "${opts[@]}" install "${pkgs[@]}"; then
|
||||||
echo "instalado (tentativa $tentativa)"
|
echo "installed (attempt $attempt)"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo "apt falhou ou passou do tempo (tentativa $tentativa de 3)" >&2
|
echo "apt failed or timed out (attempt $attempt of 3)" >&2
|
||||||
sleep $((tentativa * 10))
|
sleep $((attempt * 10))
|
||||||
done
|
done
|
||||||
echo "não foi possível instalar: ${pkgs[*]}" >&2
|
echo "failed to install: ${pkgs[*]}" >&2
|
||||||
exit 1
|
exit 1
|
||||||
+21
-22
@@ -1,13 +1,12 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// Confere se o dicionário inglês cobre os textos da interface.
|
// Verifies that the English dictionary covers all user-facing interface strings.
|
||||||
//
|
//
|
||||||
// A varredura é estática e sem dependências: lê os arquivos da interface
|
// Static zero-dependency scanner: reads the UI files (JS and HTML) and the
|
||||||
// (JS e HTML) e as mensagens pt-BR que o núcleo em Rust manda pra tela
|
// pt-BR messages emitted by the Rust core (errors, diagnostics, engine status),
|
||||||
// (erros, diagnóstico, status do engine), e falha quando encontra uma
|
// failing whenever a string looks like Portuguese and lacks a translation. Also
|
||||||
// string que parece português e não tem tradução. Também recusa chaves
|
// rejects duplicate keys in the dictionary (in a JS object literal the last key wins silently).
|
||||||
// duplicadas no dicionário (num objeto JS a última vence em silêncio).
|
|
||||||
//
|
//
|
||||||
// Uso: node scripts/check-ui-i18n.mjs
|
// Usage: node scripts/check-ui-i18n.mjs
|
||||||
|
|
||||||
import { readFileSync, readdirSync } from 'node:fs';
|
import { readFileSync, readdirSync } from 'node:fs';
|
||||||
import { join, relative } from 'node:path';
|
import { join, relative } from 'node:path';
|
||||||
@@ -16,7 +15,7 @@ import { fileURLToPath } from 'node:url';
|
|||||||
const root = join(fileURLToPath(new URL('.', import.meta.url)), '..');
|
const root = join(fileURLToPath(new URL('.', import.meta.url)), '..');
|
||||||
const rel = (p) => relative(root, p).replaceAll('\\', '/');
|
const rel = (p) => relative(root, p).replaceAll('\\', '/');
|
||||||
|
|
||||||
// ---------------------------------------------------------------- dicionário
|
// ---------------------------------------------------------------- dictionary
|
||||||
|
|
||||||
const dictPath = join(root, 'apps/gui/ui/i18n-en.js');
|
const dictPath = join(root, 'apps/gui/ui/i18n-en.js');
|
||||||
const dictSource = readFileSync(dictPath, 'utf8');
|
const dictSource = readFileSync(dictPath, 'utf8');
|
||||||
@@ -33,8 +32,8 @@ while ((m = keyRe.exec(dictSource))) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (duplicates.length) {
|
if (duplicates.length) {
|
||||||
console.error('Chaves duplicadas em apps/gui/ui/i18n-en.js:');
|
console.error('Duplicate keys in apps/gui/ui/i18n-en.js:');
|
||||||
for (const d of duplicates) console.error(` ${JSON.stringify(d.key)} (linhas ${d.first} e ${d.again})`);
|
for (const d of duplicates) console.error(` ${JSON.stringify(d.key)} (lines ${d.first} and ${d.again})`);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +52,7 @@ function translated(text) {
|
|||||||
return patterns.some((re) => re.test(text));
|
return patterns.some((re) => re.test(text));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- heurística
|
// ---------------------------------------------------------------- heuristic
|
||||||
|
|
||||||
const ACCENT = /[áàâãäéèêëíìîïóòôõöúùûüçÁÀÂÃÄÉÈÊËÍÌÎÏÓÒÔÕÖÚÙÛÜÇ]/;
|
const ACCENT = /[áàâãäéèêëíìîïóòôõöúùûüçÁÀÂÃÄÉÈÊËÍÌÎÏÓÒÔÕÖÚÙÛÜÇ]/;
|
||||||
const WORDS = [
|
const WORDS = [
|
||||||
@@ -95,13 +94,13 @@ function suspicious(text) {
|
|||||||
if (ALLOW.has(s) || ALLOW_EXACT.has(s)) return false;
|
if (ALLOW.has(s) || ALLOW_EXACT.has(s)) return false;
|
||||||
if (ALLOW_PREFIX.some((p) => s.startsWith(p))) return false;
|
if (ALLOW_PREFIX.some((p) => s.startsWith(p))) return false;
|
||||||
if (!isPortuguese(s)) return false;
|
if (!isPortuguese(s)) return false;
|
||||||
// técnico: seletores, classes, extensões, expressões
|
// Technical tokens: CSS selectors, classes, extensions, expressions
|
||||||
if (/^[.#\w-]+(\s*[,>+~]\s*[.#\w-]+)+$/.test(s)) return false;
|
if (/^[.#\w-]+(\s*[,>+~]\s*[.#\w-]+)+$/.test(s)) return false;
|
||||||
if (/^[a-z0-9_.:/@%{}$-]+$/i.test(s) && !ACCENT.test(s)) return false;
|
if (/^[a-z0-9_.:/@%{}$-]+$/i.test(s) && !ACCENT.test(s)) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- extratores
|
// ---------------------------------------------------------------- extractors
|
||||||
|
|
||||||
function jsStrings(source, file, baseLine = 1) {
|
function jsStrings(source, file, baseLine = 1) {
|
||||||
const out = [];
|
const out = [];
|
||||||
@@ -215,23 +214,23 @@ function jsStrings(source, file, baseLine = 1) {
|
|||||||
function htmlStrings(source, file, baseLine = 1) {
|
function htmlStrings(source, file, baseLine = 1) {
|
||||||
const out = [];
|
const out = [];
|
||||||
const lineAt = (idx) => baseLine + source.slice(0, idx).split('\n').length - 1;
|
const lineAt = (idx) => baseLine + source.slice(0, idx).split('\n').length - 1;
|
||||||
// todo texto entre tags (inclusive começo e fim) — nós de texto reais
|
// Text between tags (including leading and trailing text nodes)
|
||||||
let last = 0;
|
let last = 0;
|
||||||
for (const m of source.matchAll(/<[^>]*>/g)) {
|
for (const m of source.matchAll(/<[^>]*>/g)) {
|
||||||
out.push({ text: source.slice(last, m.index), file, line: lineAt(last) });
|
out.push({ text: source.slice(last, m.index), file, line: lineAt(last) });
|
||||||
last = m.index + m[0].length;
|
last = m.index + m[0].length;
|
||||||
}
|
}
|
||||||
out.push({ text: source.slice(last), file, line: lineAt(last) });
|
out.push({ text: source.slice(last), file, line: lineAt(last) });
|
||||||
// atributos traduzíveis (com aspas simples ou duplas)
|
// Translatable attributes (single or double quotes)
|
||||||
for (const m of source.matchAll(/\b(placeholder|title|aria-label|alt|data-empty)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) {
|
for (const m of source.matchAll(/\b(placeholder|title|aria-label|alt|data-empty)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) {
|
||||||
out.push({ text: m[2] ?? m[3], file, line: lineAt(m.index) });
|
out.push({ text: m[2] ?? m[3], file, line: lineAt(m.index) });
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extrai mensagens pt-BR do núcleo em Rust: #[error("...")], .push("...".into()),
|
// Extracts pt-BR messages from the Rust core: #[error("...")], .push("...".into()),
|
||||||
// "...".into() e format!("...", ...). Placeholders ({name}, {0}, {} etc.) são
|
// "...".into(), and format!("...", ...). Placeholders ({name}, {0}, {} etc.) are
|
||||||
// normalizados para {n}, igual ao que o dicionário usa.
|
// normalized to {n}, matching the dictionary format.
|
||||||
function rustLabels(source, file) {
|
function rustLabels(source, file) {
|
||||||
const out = [];
|
const out = [];
|
||||||
const lineAt = (idx) => source.slice(0, idx).split('\n').length;
|
const lineAt = (idx) => source.slice(0, idx).split('\n').length;
|
||||||
@@ -247,7 +246,7 @@ function rustLabels(source, file) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- coleta
|
// ---------------------------------------------------------------- collection
|
||||||
|
|
||||||
const uiFiles = readdirSync(join(root, 'apps/gui/ui'))
|
const uiFiles = readdirSync(join(root, 'apps/gui/ui'))
|
||||||
.filter((f) => f.endsWith('.js') && f !== 'i18n-en.js' && f !== 'vendor')
|
.filter((f) => f.endsWith('.js') && f !== 'i18n-en.js' && f !== 'vendor')
|
||||||
@@ -286,12 +285,12 @@ for (const { text, file, line } of found) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (missing.length) {
|
if (missing.length) {
|
||||||
console.error('Textos em português sem tradução em apps/gui/ui/i18n-en.js:\n');
|
console.error('Untranslated Portuguese strings in apps/gui/ui/i18n-en.js:\n');
|
||||||
for (const x of missing) console.error(` ${x.file}:${x.line} ${JSON.stringify(x.text)}`);
|
for (const x of missing) console.error(` ${x.file}:${x.line} ${JSON.stringify(x.text)}`);
|
||||||
console.error(`\n${missing.length} texto(s) sem tradução.`);
|
console.error(`\n${missing.length} untranslated string(s).`);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!process.exitCode) {
|
if (!process.exitCode) {
|
||||||
console.log(`i18n ok: ${found.length} textos verificados, ${Object.keys(EN).length} chaves no dicionário.`);
|
console.log(`i18n ok: ${found.length} strings checked, ${Object.keys(EN).length} dictionary keys.`);
|
||||||
}
|
}
|
||||||
+40
-33
@@ -1,67 +1,74 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Verificação local: roda o mesmo que o CI roda, antes de você dar push.
|
# Local verification: runs the same fast checks as CI before pushing.
|
||||||
#
|
#
|
||||||
# scripts/verificar.sh rápido (~1 min): formatação, clippy, tradução, scripts, workflows
|
# scripts/check.sh fast (~1 min): fmt, clippy, i18n, scripts, workflows, semver
|
||||||
# scripts/verificar.sh --completo inclui os testes do workspace e o cargo audit
|
# scripts/check.sh --full also runs workspace tests and cargo audit
|
||||||
#
|
#
|
||||||
# O CI leva de 5 a 30 minutos por execução e roda em Linux; quase tudo que o faz falhar
|
# CI takes 5 to 30 minutes per run; almost everything that breaks CI is caught
|
||||||
# aparece aqui em segundos. Se isto passa, o CI só deve revelar o que é próprio dele
|
# here in seconds.
|
||||||
# (imagens baixadas, apt do runner).
|
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
completo=0
|
full=0
|
||||||
[ "${1:-}" = "--completo" ] && completo=1
|
[ "${1:-}" = "--full" ] || [ "${1:-}" = "--completo" ] && full=1
|
||||||
falhas=()
|
failures=()
|
||||||
passo() { # passo "nome" comando...
|
step() { # step "name" command...
|
||||||
local nome="$1"; shift
|
local name="$1"; shift
|
||||||
printf '\n\033[1m== %s\033[0m\n' "$nome"
|
printf '\n\033[1m== %s\033[0m\n' "$name"
|
||||||
if "$@"; then printf '\033[32mok\033[0m %s\n' "$nome"; else printf '\033[31mFALHOU\033[0m %s\n' "$nome"; falhas+=("$nome"); fi
|
if "$@"; then printf '\033[32mok\033[0m %s\n' "$name"; else printf '\033[31mFAILED\033[0m %s\n' "$name"; failures+=("$name"); fi
|
||||||
}
|
}
|
||||||
|
|
||||||
passo "formatação (cargo fmt)" cargo fmt --all -- --check
|
step "formatting (cargo fmt)" cargo fmt --all -- --check
|
||||||
# as mesmas flags do CI: avisos viram erro, inclusive nos testes
|
# Same flags as CI: warnings are treated as errors across all targets, including tests
|
||||||
passo "clippy (workspace, todos os alvos)" env RUSTFLAGS="-D warnings" cargo clippy --workspace --all-targets
|
step "clippy (workspace, all targets)" env RUSTFLAGS="-D warnings" cargo clippy --workspace --all-targets
|
||||||
passo "cobertura da tradução da interface" node scripts/check-ui-i18n.mjs
|
step "UI translation coverage" node scripts/check-ui-i18n.mjs
|
||||||
passo "sintaxe dos scripts bash" bash -c 'for f in scripts/*.sh .githooks/*; do bash -n "$f" || exit 1; done'
|
step "bash script syntax" bash -c 'for f in scripts/*.sh .githooks/*; do bash -n "$f" || exit 1; done'
|
||||||
passo "workflows e templates (YAML válido)" python3 -c "
|
step "workflows and templates (valid YAML)" python3 -c "
|
||||||
import glob, yaml
|
import glob, yaml
|
||||||
for f in glob.glob('.gitea/**/*.yml', recursive=True):
|
for f in glob.glob('.gitea/**/*.yml', recursive=True):
|
||||||
yaml.safe_load(open(f))
|
yaml.safe_load(open(f))
|
||||||
"
|
"
|
||||||
passo "versões consistentes" python3 -c "
|
step "consistent Semantic Versioning 2.0.0" python3 -c "
|
||||||
import json, re, sys
|
import json, re, sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
semver_re = re.compile(
|
||||||
|
r'^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)'
|
||||||
|
r'(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?'
|
||||||
|
r'(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$'
|
||||||
|
)
|
||||||
|
|
||||||
def cargo_version(path):
|
def cargo_version(path):
|
||||||
m = re.search(r'^version\s*=\s*\"([^\"]+)\"', Path(path).read_text(), re.M)
|
m = re.search(r'^version\s*=\s*\"([^\"]+)\"', Path(path).read_text(), re.M)
|
||||||
if not m:
|
if not m:
|
||||||
sys.exit(f'versão não encontrada em {path}')
|
sys.exit(f'version not found in {path}')
|
||||||
return m.group(1)
|
return m.group(1)
|
||||||
|
|
||||||
versoes = [
|
versions = [
|
||||||
cargo_version('Cargo.toml'),
|
cargo_version('Cargo.toml'),
|
||||||
cargo_version('apps/gui/src-tauri/Cargo.toml'),
|
cargo_version('apps/gui/src-tauri/Cargo.toml'),
|
||||||
json.load(open('apps/gui/src-tauri/tauri.conf.json'))['version'],
|
json.load(open('apps/gui/src-tauri/tauri.conf.json'))['version'],
|
||||||
]
|
]
|
||||||
if len(set(versoes)) != 1:
|
if len(set(versions)) != 1:
|
||||||
sys.exit(f'versões divergentes: Cargo.toml={versoes[0]}, src-tauri={versoes[1]}, tauri.conf={versoes[2]}')
|
sys.exit(f'version mismatch: Cargo.toml={versions[0]}, src-tauri={versions[1]}, tauri.conf={versions[2]}')
|
||||||
print(versoes[0])
|
if not semver_re.match(versions[0]):
|
||||||
|
sys.exit(f'invalid SemVer 2.0.0 version: {versions[0]}')
|
||||||
|
print(versions[0])
|
||||||
"
|
"
|
||||||
if [ -f apps/gui/src-tauri/Cargo.toml ] && pkg-config --exists webkit2gtk-4.1 2>/dev/null; then
|
if [ -f apps/gui/src-tauri/Cargo.toml ] && pkg-config --exists webkit2gtk-4.1 2>/dev/null; then
|
||||||
passo "app desktop (cargo check)" env RUSTFLAGS="" cargo check --manifest-path apps/gui/src-tauri/Cargo.toml
|
step "desktop app (cargo check)" env RUSTFLAGS="" cargo check --manifest-path apps/gui/src-tauri/Cargo.toml
|
||||||
else
|
else
|
||||||
printf '\n(app desktop: pulado, faltam as bibliotecas do WebKitGTK; o CI compila)\n'
|
printf '\n(desktop app: skipped, WebKitGTK development libraries not installed; built in CI)\n'
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$completo" = 1 ]; then
|
if [ "$full" = 1 ]; then
|
||||||
passo "testes (cargo test --workspace)" cargo test --workspace
|
step "tests (cargo test --workspace)" cargo test --workspace
|
||||||
passo "auditoria de dependências (cargo audit)" cargo audit
|
step "dependency audit (cargo audit)" cargo audit
|
||||||
fi
|
fi
|
||||||
|
|
||||||
printf '\n'
|
printf '\n'
|
||||||
if [ ${#falhas[@]} -eq 0 ]; then
|
if [ ${#failures[@]} -eq 0 ]; then
|
||||||
printf '\033[32mtudo certo\033[0m: pode dar push.\n'
|
printf '\033[32mall checks passed\033[0m: ready to push.\n'
|
||||||
else
|
else
|
||||||
printf '\033[31mfalhou:\033[0m %s\n' "${falhas[*]}"
|
printf '\033[31mfailed:\033[0m %s\n' "${failures[*]}"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
+30
-28
@@ -1,47 +1,49 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Gera, a partir da logo oficial, o ícone base usado pelo Tauri.
|
"""Generates the base Tauri icon from the official project logo.
|
||||||
|
|
||||||
A logo oficial é `logo.png` na raiz do repositório (RGBA, fundo transparente,
|
The official logo is `logo.png` in the repository root (RGBA, transparent
|
||||||
serve aos temas claro e escuro). Ela não é alterada aqui. Saída:
|
background, suitable for both light and dark themes). It is not modified here.
|
||||||
apps/gui/src-tauri/icons/source.png 1024 px, a arte sobre o fundo escuro com cantos arredondados;
|
Output:
|
||||||
o CI e `npx @tauri-apps/cli icon` geram o resto dos ícones.
|
apps/gui/src-tauri/icons/source.png 1024 px artwork centered over a rounded
|
||||||
|
dark background; CI and `npx @tauri-apps/cli icon`
|
||||||
|
derive all remaining icon formats from it.
|
||||||
|
|
||||||
Uso: python3 scripts/gerar-logo.py
|
Usage: python3 scripts/generate-logo.py
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
from PIL import Image, ImageDraw
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
RAIZ = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
|
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
|
||||||
ICONES = os.path.join(RAIZ, "apps/gui/src-tauri/icons")
|
ICONS_DIR = os.path.join(ROOT, "apps/gui/src-tauri/icons")
|
||||||
FUNDO_ICONE = (10, 16, 28) # o azul-noite do fundo da arte original
|
ICON_BG = (10, 16, 28) # dark navy background matching the original artwork
|
||||||
|
|
||||||
|
|
||||||
def recortar_quadrado(im, margem=0.04):
|
def crop_square(im, margin=0.04):
|
||||||
"""Recorta no que é visível (opacidade > 16, ignora resquícios) e centraliza num quadrado."""
|
"""Crops to visible pixels (opacity > 16, ignoring faint artifacts) and centers in a square."""
|
||||||
box = im.getchannel("A").point(lambda v: 255 if v > 16 else 0).getbbox()
|
box = im.getchannel("A").point(lambda v: 255 if v > 16 else 0).getbbox()
|
||||||
im = im.crop(box)
|
im = im.crop(box)
|
||||||
w, h = im.size
|
w, h = im.size
|
||||||
lado = round(max(w, h) * (1 + 2 * margem))
|
side = round(max(w, h) * (1 + 2 * margin))
|
||||||
quadro = Image.new("RGBA", (lado, lado), (0, 0, 0, 0))
|
canvas = Image.new("RGBA", (side, side), (0, 0, 0, 0))
|
||||||
quadro.paste(im, ((lado - w) // 2, (lado - h) // 2), im)
|
canvas.paste(im, ((side - w) // 2, (side - h) // 2), im)
|
||||||
return quadro
|
return canvas
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
logo = recortar_quadrado(Image.open(os.path.join(RAIZ, "logo.png")).convert("RGBA"))
|
logo = crop_square(Image.open(os.path.join(ROOT, "logo.png")).convert("RGBA"))
|
||||||
|
|
||||||
lado = 1024
|
side = 1024
|
||||||
fundo = Image.new("RGBA", (lado, lado), FUNDO_ICONE + (255,))
|
bg = Image.new("RGBA", (side, side), ICON_BG + (255,))
|
||||||
arte = logo.resize((round(lado * 0.82),) * 2, Image.LANCZOS)
|
art = logo.resize((round(side * 0.82),) * 2, Image.LANCZOS)
|
||||||
fundo.paste(arte, ((lado - arte.width) // 2, (lado - arte.height) // 2), arte)
|
bg.paste(art, ((side - art.width) // 2, (side - art.height) // 2), art)
|
||||||
mascara = Image.new("L", (lado, lado), 0)
|
mask = Image.new("L", (side, side), 0)
|
||||||
ImageDraw.Draw(mascara).rounded_rectangle((0, 0, lado - 1, lado - 1), radius=round(lado * 0.2237), fill=255)
|
ImageDraw.Draw(mask).rounded_rectangle((0, 0, side - 1, side - 1), radius=round(side * 0.2237), fill=255)
|
||||||
icone = Image.new("RGBA", (lado, lado), (0, 0, 0, 0))
|
icon = Image.new("RGBA", (side, side), (0, 0, 0, 0))
|
||||||
icone.paste(fundo, (0, 0), mascara)
|
icon.paste(bg, (0, 0), mask)
|
||||||
os.makedirs(ICONES, exist_ok=True)
|
os.makedirs(ICONS_DIR, exist_ok=True)
|
||||||
destino = os.path.join(ICONES, "source.png")
|
dest = os.path.join(ICONS_DIR, "source.png")
|
||||||
icone.save(destino, optimize=True)
|
icon.save(dest, optimize=True)
|
||||||
os.chmod(destino, 0o644)
|
os.chmod(dest, 0o644)
|
||||||
print("icons/source.png")
|
print("icons/source.png")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Liga o hook de pre-push do repositório (roda scripts/verificar.sh antes de cada push).
|
# Enables the repository Git hooks (.githooks/pre-push and .githooks/commit-msg).
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
git config core.hooksPath .githooks
|
git config core.hooksPath .githooks
|
||||||
echo "hook ligado: git push passa a rodar scripts/verificar.sh (desligue com: git config --unset core.hooksPath)"
|
echo "hooks enabled: git commit validates Conventional Commits in English and git push runs scripts/check.sh (disable with: git config --unset core.hooksPath)"
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Compila o CLI para uma plataforma e empacota como o atualizador espera:
|
# Builds the CLI for a target platform and packages it as expected by the updater:
|
||||||
# sabia-<tag>-<plataforma>.tar.gz com um .sha256 ao lado.
|
# sabia-<tag>-<platform>.tar.gz alongside a .sha256 checksum file.
|
||||||
#
|
#
|
||||||
# scripts/package-cli.sh v1.0.3 linux-x86_64 x86_64-unknown-linux-gnu
|
# scripts/package-cli.sh v1.0.3 linux-x86_64 x86_64-unknown-linux-gnu
|
||||||
#
|
#
|
||||||
# Plataformas: linux-x86_64 e linux-aarch64. O alvo diferente da máquina de build
|
# Supported platforms: linux-x86_64 and linux-aarch64. When cross-compiling
|
||||||
# usa o linker cruzado correspondente (gcc-x86-64-linux-gnu). Escreve os arquivos
|
# from a non-x86_64 host, uses gcc-x86-64-linux-gnu. Writes the archives to the
|
||||||
# na raiz do repositório e imprime os nomes.
|
# repository root and prints their filenames.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
tag=${1:?uso: package-cli.sh <tag> <plataforma> <alvo-rust>}
|
tag=${1:?usage: package-cli.sh <tag> <platform> <rust-target>}
|
||||||
platform=${2:?uso: package-cli.sh <tag> <plataforma> <alvo-rust>}
|
platform=${2:?usage: package-cli.sh <tag> <platform> <rust-target>}
|
||||||
target=${3:?uso: package-cli.sh <tag> <plataforma> <alvo-rust>}
|
target=${3:?usage: package-cli.sh <tag> <platform> <rust-target>}
|
||||||
|
|
||||||
case "$target" in
|
case "$target" in
|
||||||
x86_64-unknown-linux-gnu)
|
x86_64-unknown-linux-gnu)
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Imprime as notas de uma release: a seção do CHANGELOG.md daquela versão, ou o
|
# Prints release notes for a given tag: extracts the matching CHANGELOG.md
|
||||||
# log de commits desde a tag anterior quando o changelog não tem a seção.
|
# section, or falls back to the commit log since the previous tag.
|
||||||
#
|
#
|
||||||
# scripts/release-notes.sh v1.2.3
|
# scripts/release-notes.sh v1.2.3
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
tag=${1:?uso: release-notes.sh <tag>}
|
tag=${1:?usage: release-notes.sh <tag>}
|
||||||
version=${tag#v}
|
version=${tag#v}
|
||||||
|
|
||||||
section=""
|
section=""
|
||||||
@@ -22,7 +22,7 @@ if [ -n "$(printf '%s' "$section" | tr -d '[:space:]')" ]; then
|
|||||||
printf '%s\n' "$section"
|
printf '%s\n' "$section"
|
||||||
else
|
else
|
||||||
previous=$(git describe --tags --abbrev=0 "$tag^" 2>/dev/null || true)
|
previous=$(git describe --tags --abbrev=0 "$tag^" 2>/dev/null || true)
|
||||||
echo "## Mudanças${previous:+ desde $previous}"
|
echo "## Changes${previous:+ since $previous}"
|
||||||
echo
|
echo
|
||||||
git log --no-merges --format='- %s (%h)' "${previous:+$previous..}$tag"
|
git log --no-merges --format='- %s (%h)' "${previous:+$previous..}$tag"
|
||||||
fi
|
fi
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Anexa arquivos a uma release do Gitea, trocando os de mesmo nome (assim uma
|
# Uploads assets to a Gitea release, replacing any existing asset with the same
|
||||||
# execução repetida do CI não falha por anexo duplicado).
|
# filename so re-running a CI job never fails on duplicate attachments.
|
||||||
#
|
#
|
||||||
# API=https://git.exemplo.com/api/v1/repos/dono/repo TOKEN=... \
|
# API=https://git.example.com/api/v1/repos/owner/repo TOKEN=... \
|
||||||
# scripts/release-upload.sh v1.2.3 arquivo1 arquivo2
|
# scripts/release-upload.sh v1.2.3 file1 file2
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
: "${API:?defina API (URL do repositório na API do Gitea)}"
|
: "${API:?set API (Gitea repository API URL)}"
|
||||||
: "${TOKEN:?defina TOKEN}"
|
: "${TOKEN:?set TOKEN}"
|
||||||
tag=${1:?uso: release-upload.sh <tag> <arquivo>...}
|
tag=${1:?usage: release-upload.sh <tag> <file>...}
|
||||||
shift
|
shift
|
||||||
|
|
||||||
auth=(-H "Authorization: token $TOKEN")
|
auth=(-H "Authorization: token $TOKEN")
|
||||||
@@ -21,5 +21,5 @@ for file in "$@"; do
|
|||||||
curl -fsS -X DELETE "${auth[@]}" "$API/releases/$id/assets/$old"
|
curl -fsS -X DELETE "${auth[@]}" "$API/releases/$id/assets/$old"
|
||||||
done
|
done
|
||||||
curl -fsS -X POST "${auth[@]}" -F "attachment=@$file" "$API/releases/$id/assets?name=$encoded" >/dev/null
|
curl -fsS -X POST "${auth[@]}" -F "attachment=@$file" "$API/releases/$id/assets?name=$encoded" >/dev/null
|
||||||
echo "enviado: $name"
|
echo "uploaded: $name"
|
||||||
done
|
done
|
||||||
Reference in new issue
Block a user