Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
659701b2d4 | ||
|
|
30a086a808 | ||
|
|
833abc6308 | ||
|
|
3fc3febff3 | ||
|
|
827ef0c2c0 | ||
|
|
c7638a2f4e | ||
|
|
d885d92643 | ||
|
|
0e31d28342 | ||
|
|
9bcdd6c139 | ||
|
|
f62e7fd0a1 | ||
|
|
62ce8b60d5 | ||
|
|
d3ddf86317 | ||
|
|
6e0cd7cf10 | ||
|
|
a3ca73e610 | ||
|
|
7b39c67830 | ||
|
|
91aacba939 | ||
|
|
f83654506e | ||
|
|
5477b8fb03 |
No files matched your search
@@ -0,0 +1,88 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
# Mudança só de documentação ou estado de ferramentas não precisa dos jobs de CI.
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore: ['**.md', 'docs/**', 'LICENSE*', '.gitignore', '.githooks/**', '.wolf/**', '.claude/**', '.opencode/**', '.codex/**', '.cursor/**', '.superpowers/**', '.superset/**', '.gitea/ISSUE_TEMPLATE/**', 'scripts/release-notes.sh', 'scripts/release-upload.sh', '.gitea/workflows/release.yml']
|
||||
pull_request:
|
||||
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']
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: -D warnings
|
||||
# O runner mantém /opt/hostedtoolcache num volume que sobrevive entre jobs:
|
||||
# a toolchain do rustup, o registry do cargo, caches de npm/apt e o diretório
|
||||
# target ficam ali, então nada disso é baixado ou recompilado do zero. Cada job
|
||||
# usa o seu próprio target (flags diferentes invalidariam o de outro job).
|
||||
CARGO_HOME: /opt/hostedtoolcache/cargo
|
||||
RUSTUP_HOME: /opt/hostedtoolcache/rustup
|
||||
NPM_CONFIG_CACHE: /opt/hostedtoolcache/npm-cache
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Formatação e clippy
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/sabia-lint
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Cobertura da tradução da interface
|
||||
run: node scripts/check-ui-i18n.mjs
|
||||
- run: cargo fmt --all -- --check
|
||||
- run: cargo clippy --workspace --all-targets
|
||||
|
||||
# Windows e macOS saem: o Gitea aqui tem um único runner, Linux arm64.
|
||||
test:
|
||||
name: Testes (Linux)
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/sabia-test
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: cargo test --workspace
|
||||
|
||||
audit:
|
||||
name: Auditoria de dependências
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
# rustsec/audit-check depends on GitHub's checks API. cargo-audit itself
|
||||
# needs nothing but the advisory database, and installing it once into the
|
||||
# persistent CARGO_HOME makes every later run skip the compile.
|
||||
- name: cargo audit
|
||||
run: |
|
||||
command -v cargo-audit >/dev/null || cargo install cargo-audit --locked
|
||||
cargo audit
|
||||
|
||||
gui:
|
||||
name: Compila o app desktop (Linux)
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/sabia-gui
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Dependências do Tauri
|
||||
timeout-minutes: 25
|
||||
run: scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-native libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||
- name: Gera os ícones
|
||||
run: npx --yes @tauri-apps/cli@2 icon apps/gui/src-tauri/icons/source.png -o apps/gui/src-tauri/icons
|
||||
- run: cargo check --manifest-path apps/gui/src-tauri/Cargo.toml
|
||||
env:
|
||||
RUSTFLAGS: ""
|
||||
@@ -0,0 +1,131 @@
|
||||
name: Release
|
||||
|
||||
# Quando uma tag v* é enviada: cria a entrada em "Releases" com as notas do
|
||||
# CHANGELOG.md e anexa o CLI compilado (scripts/package-cli.sh), com um .sha256 ao
|
||||
# lado de cada pacote. É o que o atualizador (`sabia update`) baixa.
|
||||
#
|
||||
# O CLI é só para Linux (x86_64 e aarch64), compilado no runner Linux; o x86_64
|
||||
# por compilação cruzada. Windows e macOS têm só o app desktop.
|
||||
#
|
||||
# Assinatura do atualizador (opcional): SABIA_TAURI_SIGNING_PRIVATE_KEY e
|
||||
# SABIA_TAURI_SIGNING_PRIVATE_KEY_PASSWORD, com os nomes antigos sem prefixo como
|
||||
# fallback. Sem chave, o build continua e apenas não gera .sig.
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
# Toolchain, caches e registry do cargo ficam no volume que sobrevive entre jobs.
|
||||
CARGO_HOME: /opt/hostedtoolcache/cargo
|
||||
RUSTUP_HOME: /opt/hostedtoolcache/rustup
|
||||
NPM_CONFIG_CACHE: /opt/hostedtoolcache/npm-cache
|
||||
XWIN_CACHE_DIR: /opt/hostedtoolcache/xwin-cache
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Cria a release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Release
|
||||
env:
|
||||
TAG: ${{ gitea.ref_name }}
|
||||
TOKEN: ${{ gitea.token }}
|
||||
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
|
||||
run: |
|
||||
# Uma execução repetida não recria a release.
|
||||
if curl -fsS -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" >/dev/null 2>&1; then
|
||||
echo "a release $TAG já existe"; exit 0
|
||||
fi
|
||||
scripts/release-notes.sh "$TAG" > notes.md
|
||||
pre=false; case "$TAG" in *-*) pre=true ;; esac
|
||||
jq -n --arg tag "$TAG" --arg name "Sabiá ${TAG#v}" --rawfile body notes.md --argjson pre "$pre" \
|
||||
'{tag_name:$tag, name:$name, body:$body, prerelease:$pre}' > release.json
|
||||
curl -fsS -X POST "$API/releases" -H "Authorization: token $TOKEN" \
|
||||
-H 'Content-Type: application/json' -d @release.json >/dev/null
|
||||
|
||||
cli:
|
||||
name: CLI (${{ matrix.platform }})
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/sabia-release-cli-${{ matrix.platform }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { platform: linux-aarch64, target: aarch64-unknown-linux-gnu, apt: "" }
|
||||
- { platform: linux-x86_64, target: x86_64-unknown-linux-gnu, apt: "gcc-x86-64-linux-gnu" }
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- name: Linker e ferramentas desta plataforma
|
||||
if: ${{ matrix.apt != '' }}
|
||||
run: scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/cli-x86_64 ${{ matrix.apt }}
|
||||
- name: Compila e empacota
|
||||
run: scripts/package-cli.sh "${{ gitea.ref_name }}" "${{ matrix.platform }}" "${{ matrix.target }}" | tee packages.txt
|
||||
- name: Anexa à release
|
||||
env:
|
||||
TOKEN: ${{ gitea.token }}
|
||||
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
|
||||
run: scripts/release-upload.sh "${{ gitea.ref_name }}" $(cat packages.txt)
|
||||
|
||||
gui:
|
||||
name: App desktop (${{ matrix.platform }})
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/sabia-release-gui-${{ matrix.platform }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.SABIA_TAURI_SIGNING_PRIVATE_KEY || secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.SABIA_TAURI_SIGNING_PRIVATE_KEY_PASSWORD || secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform: [linux-aarch64, linux-x86_64, windows]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Dependências do Tauri (Linux aarch64)
|
||||
if: matrix.platform == 'linux-aarch64'
|
||||
timeout-minutes: 25
|
||||
run: scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-native libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||
- name: Dependências do Tauri (Linux x86_64)
|
||||
if: matrix.platform == 'linux-x86_64'
|
||||
timeout-minutes: 25
|
||||
run: |
|
||||
if [ "$(uname -m)" = aarch64 ]; then
|
||||
dpkg --add-architecture amd64
|
||||
. /etc/os-release
|
||||
if [ "${ID:-}" = ubuntu ]; then
|
||||
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
|
||||
grep -q '^Architectures:' /etc/apt/sources.list.d/ubuntu.sources || sed -i '/^Types: deb/a Architectures: arm64' /etc/apt/sources.list.d/ubuntu.sources
|
||||
printf 'Types: deb\nURIs: http://archive.ubuntu.com/ubuntu/\nSuites: %s %s-updates %s-security\nComponents: main universe\nArchitectures: amd64\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n' "$VERSION_CODENAME" "$VERSION_CODENAME" "$VERSION_CODENAME" >/etc/apt/sources.list.d/amd64.sources
|
||||
elif [ -f /etc/apt/sources.list ]; then
|
||||
sed -i 's/^deb http/deb [arch=arm64] http/' /etc/apt/sources.list
|
||||
printf 'deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ %s main universe\ndeb [arch=amd64] http://archive.ubuntu.com/ubuntu/ %s-updates main universe\ndeb [arch=amd64] http://archive.ubuntu.com/ubuntu/ %s-security main universe\n' "$VERSION_CODENAME" "$VERSION_CODENAME" "$VERSION_CODENAME" >/etc/apt/sources.list.d/amd64.list
|
||||
fi
|
||||
fi
|
||||
scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-amd64 gcc-x86-64-linux-gnu g++-x86-64-linux-gnu pkg-config patchelf file squashfs-tools qemu-user libglib2.0-bin libwebkit2gtk-4.1-dev:amd64 libayatana-appindicator3-dev:amd64 librsvg2-dev:amd64
|
||||
else
|
||||
scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-native libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||
fi
|
||||
- name: Ferramentas para compilação cruzada (Windows)
|
||||
if: matrix.platform == 'windows'
|
||||
run: |
|
||||
scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-win nsis lld llvm clang
|
||||
command -v cargo-xwin >/dev/null || cargo install cargo-xwin --locked
|
||||
- name: Compila e empacota
|
||||
run: scripts/build-gui.sh "${{ matrix.platform }}"
|
||||
- name: Anexa à release
|
||||
env:
|
||||
TOKEN: ${{ gitea.token }}
|
||||
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
|
||||
run: scripts/release-upload.sh "${{ gitea.ref_name }}" dist-gui/*
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
# Antes de cada push: a verificação rápida. Ignore com `git push --no-verify` (use com parcimônia).
|
||||
exec "$(git rev-parse --show-toplevel)/scripts/verificar.sh"
|
||||
@@ -1,83 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
# Mudança só de documentação não precisa de 6 jobs em 3 sistemas.
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore: ['**.md', 'docs/**', 'LICENSE*', '.gitignore', '.githooks/**']
|
||||
pull_request:
|
||||
paths-ignore: ['**.md', 'docs/**', 'LICENSE*', '.gitignore', '.githooks/**']
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: -D warnings
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Formatação e clippy
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Cobertura da tradução da interface
|
||||
run: node scripts/check-ui-i18n.mjs
|
||||
- run: cargo fmt --all -- --check
|
||||
- run: cargo clippy --workspace --all-targets
|
||||
|
||||
test:
|
||||
name: Testes (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-24.04, windows-latest, macos-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo test --workspace
|
||||
|
||||
audit:
|
||||
name: Auditoria de dependências
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: rustsec/audit-check@v2
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
gui:
|
||||
name: Compila o app desktop (Linux)
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: apps/gui/src-tauri
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Cache dos pacotes do Tauri (.deb)
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/apt-cache
|
||||
key: apt-tauri-${{ runner.os }}-${{ runner.arch }}-${{ env.ImageVersion }}-${{ hashFiles('scripts/apt-install.sh') }}
|
||||
- name: Dependências do Tauri
|
||||
timeout-minutes: 25
|
||||
run: scripts/apt-install.sh ~/apt-cache libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||
- name: Gera os ícones
|
||||
run: npx --yes @tauri-apps/cli@2 icon apps/gui/src-tauri/icons/source.png -o apps/gui/src-tauri/icons
|
||||
- run: cargo check --manifest-path apps/gui/src-tauri/Cargo.toml
|
||||
env:
|
||||
RUSTFLAGS: ""
|
||||
@@ -1,151 +0,0 @@
|
||||
name: Release
|
||||
|
||||
# Publica uma release pública ao empurrar uma tag vX.Y.Z (ex.: git tag v0.1.0 && git push --tags)
|
||||
on:
|
||||
push:
|
||||
tags: ["v*.*.*"]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
name: Cria a release
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
release_id: ${{ steps.rel.outputs.result }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Notas da versão (do CHANGELOG)
|
||||
id: notes
|
||||
run: |
|
||||
v="${GITHUB_REF_NAME#v}"
|
||||
v="${v%%-*}"
|
||||
awk -v v="$v" '$0 ~ "^## \\[" v "\\]" {f=1; next} /^## \[/ {f=0} f' CHANGELOG.md > notes.md
|
||||
[ -s notes.md ] || echo "Veja o CHANGELOG." > notes.md
|
||||
- uses: actions/github-script@v9
|
||||
id: rel
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const tag = context.ref.replace('refs/tags/', '');
|
||||
const r = await github.rest.repos.createRelease({
|
||||
owner: context.repo.owner, repo: context.repo.repo, tag_name: tag,
|
||||
name: `Sabiá ${tag}`, body: fs.readFileSync('notes.md', 'utf8'),
|
||||
draft: true, prerelease: tag.includes('-'),
|
||||
});
|
||||
return r.data.id;
|
||||
|
||||
cli:
|
||||
name: CLI (${{ matrix.name }})
|
||||
needs: create-release
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { name: linux-x86_64, os: ubuntu-24.04, target: x86_64-unknown-linux-gnu }
|
||||
- { name: linux-aarch64, os: ubuntu-24.04-arm, target: aarch64-unknown-linux-gnu }
|
||||
- { name: macos-aarch64, os: macos-latest, target: aarch64-apple-darwin }
|
||||
- { name: macos-x86_64, os: macos-latest, target: x86_64-apple-darwin }
|
||||
- { name: windows-x86_64, os: windows-latest, target: x86_64-pc-windows-msvc }
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo build --release --locked -p sabia-cli --target ${{ matrix.target }}
|
||||
- name: Empacota
|
||||
shell: bash
|
||||
run: |
|
||||
name="sabia-${GITHUB_REF_NAME}-${{ matrix.name }}"
|
||||
mkdir "$name"
|
||||
if [ "$RUNNER_OS" = "Windows" ]; then
|
||||
cp target/${{ matrix.target }}/release/sabia.exe "$name/"
|
||||
else
|
||||
cp target/${{ matrix.target }}/release/sabia "$name/"
|
||||
fi
|
||||
cp README.md LICENSE-MIT LICENSE-APACHE "$name/"
|
||||
if [ "$RUNNER_OS" = "Windows" ]; then
|
||||
7z a "$name.zip" "$name" >/dev/null; asset="$name.zip"
|
||||
else
|
||||
tar czf "$name.tar.gz" "$name"; asset="$name.tar.gz"
|
||||
fi
|
||||
if command -v sha256sum >/dev/null; then sha256sum "$asset" > "$asset.sha256"; else shasum -a 256 "$asset" > "$asset.sha256"; fi
|
||||
echo "ASSET=$asset" >> "$GITHUB_ENV"
|
||||
- name: Envia para a release
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh release upload "$GITHUB_REF_NAME" "$ASSET" "$ASSET.sha256" --clobber
|
||||
|
||||
gui:
|
||||
name: App desktop (${{ matrix.name }})
|
||||
needs: create-release
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { name: linux-x86_64, os: ubuntu-24.04, args: "" }
|
||||
- { name: macos-aarch64, os: macos-latest, args: "--target aarch64-apple-darwin" }
|
||||
- { name: macos-x86_64, os: macos-latest, args: "--target x86_64-apple-darwin" }
|
||||
- { name: windows-x86_64, os: windows-latest, args: "" }
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ contains(matrix.args, 'apple') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: apps/gui/src-tauri
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Cache dos pacotes do Tauri (.deb)
|
||||
if: runner.os == 'Linux'
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/apt-cache
|
||||
key: apt-tauri-${{ runner.os }}-${{ runner.arch }}-${{ env.ImageVersion }}-${{ hashFiles('scripts/apt-install.sh') }}
|
||||
- name: Dependências do Tauri (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
timeout-minutes: 25
|
||||
run: scripts/apt-install.sh ~/apt-cache libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||
- name: Gera os ícones
|
||||
run: npx --yes @tauri-apps/cli@2 icon apps/gui/src-tauri/icons/source.png -o apps/gui/src-tauri/icons
|
||||
- uses: tauri-apps/tauri-action@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
projectPath: apps/gui/src-tauri
|
||||
releaseId: ${{ needs.create-release.outputs.release_id }}
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
publish:
|
||||
name: Publica a release
|
||||
needs: [create-release, cli, gui]
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Notas da versão (do CHANGELOG)
|
||||
run: |
|
||||
v="${GITHUB_REF_NAME#v}"
|
||||
v="${v%%-*}"
|
||||
awk -v v="$v" '$0 ~ "^## \\[" v "\\]" {f=1; next} /^## \[/ {f=0} f' CHANGELOG.md > notes.md
|
||||
[ -s notes.md ] || echo "Veja o CHANGELOG." > notes.md
|
||||
- uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
await github.rest.repos.updateRelease({
|
||||
owner: context.repo.owner, repo: context.repo.repo,
|
||||
release_id: ${{ needs.create-release.outputs.release_id }},
|
||||
name: `Sabiá ${context.ref.replace('refs/tags/', '')}`,
|
||||
body: fs.readFileSync('notes.md', 'utf8'),
|
||||
draft: false,
|
||||
});
|
||||
@@ -1,13 +1,21 @@
|
||||
/target
|
||||
# Build
|
||||
target/
|
||||
apps/gui/src-tauri/target/
|
||||
node_modules/
|
||||
*.img
|
||||
*.guara-session.json
|
||||
.DS_Store
|
||||
**/*.rs.bk
|
||||
|
||||
# Capturas e estado local do Sabiá
|
||||
*.pcap
|
||||
*.cap
|
||||
.sabia-audit.log
|
||||
node_modules/
|
||||
apps/gui/src-tauri/target
|
||||
|
||||
# Configuração e memória local de agentes de IA — não faz parte do
|
||||
# produto, fica só na máquina de desenvolvimento.
|
||||
# Logo copiada para a interface pelo build.rs
|
||||
/apps/gui/ui/logo.png
|
||||
|
||||
# Configuração e memória local de agentes de IA — não faz parte do produto
|
||||
.claude/
|
||||
.wolf/
|
||||
.opencode/
|
||||
@@ -18,3 +26,17 @@ apps/gui/src-tauri/target
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
GEMINI.md
|
||||
|
||||
# Variáveis de ambiente locais (o setup do Superset copia da raiz)
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Lixo local
|
||||
*.eml
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Pacotes gerados por scripts/package-cli.sh
|
||||
/guara-v*
|
||||
/sabia-v*
|
||||
/dist-gui
|
||||
@@ -4,6 +4,111 @@
|
||||
|
||||
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow SemVer.
|
||||
|
||||
## [1.0.3] - 2026-10-30
|
||||
|
||||
### Fixed
|
||||
- `deauth_directional` had no `#[cfg(target_os = "linux")]` of its own; every
|
||||
non-Linux build failed with a duplicate-definition error and unresolved
|
||||
Linux-only imports. `v1.0.1` and `v1.0.2` never got a working Windows
|
||||
desktop app for this reason, and `v1.0.2`'s Linux package was mislabeled
|
||||
`1.0.1` (the version in `Cargo.toml` was not bumped). Use `v1.0.3`.
|
||||
|
||||
## [1.0.2] - 2026-10-30
|
||||
|
||||
### Fixed
|
||||
- The desktop app build on CI looked for its bundle under `target/`, but the
|
||||
runner's Cargo target directory is elsewhere and persists between runs; the
|
||||
Linux and Windows jobs failed before publishing anything. `v1.0.1`'s desktop
|
||||
app was never published for this reason — use `v1.0.2`.
|
||||
|
||||
## [1.0.1] - 2026-10-30
|
||||
|
||||
The project moved from GitHub to Gitea, and the update check followed it.
|
||||
|
||||
### Changed
|
||||
- **Updates now come from Gitea.** `sabia update` and the desktop app's "Update now"
|
||||
read the latest release from `git.lucasrguerra.dev.br` instead of the GitHub API.
|
||||
Versions up to 1.0.0 still look at GitHub, which is no longer reachable, so they
|
||||
cannot find this release: download 1.0.1 once, and later updates are automatic.
|
||||
- Release packages: the CLI for Linux x86_64 and aarch64 (built by the Gitea CI when
|
||||
a `v*` tag is pushed, with a `.sha256` next to each package), and the desktop app
|
||||
for Linux (`.AppImage`, `.deb`) and Windows (NSIS `.exe` installer, cross-compiled
|
||||
from Linux by `scripts/build-gui.sh`). **There is no Windows CLI and no macOS build
|
||||
any more**: the CLI is Linux-only, and macOS needs Apple's SDK and code signing,
|
||||
which this project's CI cannot provide.
|
||||
|
||||
### Fixed
|
||||
- `sabia update` refuses to run from the desktop app's executable (`sabia-gui`).
|
||||
The release package contains the CLI, and replacing the app with it would have
|
||||
broken the app; the app now points to the downloads page instead.
|
||||
|
||||
## [1.0.0] - 2026-10-24
|
||||
|
||||
First stable release: real-hardware validation across the whole attack surface, a much more complete cracking pipeline, two new attacks (WPS, Evil Twin), a redesigned report, and several reliability fixes found only by testing against live Wi-Fi networks.
|
||||
|
||||
### Added
|
||||
- **Cracking:** default dictionary replaced by a 9.7M-password list (rockyou +
|
||||
Probable-Wordlists, deduplicated, gzip-embedded); hashcat-style **mutation
|
||||
rules** (uppercase, numeric/symbol suffixes, simple leet); **mask brute
|
||||
force** (`?d?l?u?s?a` syntax); **`.hc22000` export** (single or batch) to
|
||||
hand a handshake to hashcat/John.
|
||||
- **WPS attack:** PIN/Pixie Dust via a bundled `reaver` (extracted at
|
||||
runtime), or `bully` when installed on the system.
|
||||
- **Evil Twin:** clones an already-seen AP (open SSID) on a second interface
|
||||
with a captive portal, for WPA/WPA2-PSK — the submitted password is only
|
||||
accepted after being verified against the real captured handshake, never
|
||||
"accepts anything". An **Enterprise (802.1X/EAP)** variant captures
|
||||
credentials automatically via `hostapd-wpe`, no portal or human action
|
||||
needed, since the credential travels in the EAP handshake itself.
|
||||
- **CCMP traffic decryption** for a client once its handshake's password is
|
||||
known (Temporal Key derived from the cracked PMK).
|
||||
- **Channel-interference scoring** per band and **security findings** (open
|
||||
network, missing PMF, WPS exposed, possible evil twin by duplicate SSID).
|
||||
- **Redesigned audit report:** full-page cover with embedded logo, table of
|
||||
contents, running header/footer via `@page` margin boxes (current
|
||||
section, page numbers), KPI and findings cards with severity colors —
|
||||
still paginated by Paged.js in the browser, printable to PDF.
|
||||
- `ScanSnapshot.warning`: tells you when a capture has been running for a
|
||||
while without receiving a single frame — a card that accepts turning
|
||||
monitor mode on without actually delivering capture (common with
|
||||
built-in laptop Wi-Fi) now says so, instead of silently "listening".
|
||||
|
||||
### Fixed
|
||||
- **Handshake capture never completed on real networks:** `eapol.rs` read
|
||||
`key_info`/`replay_counter`/`key_data_len` as little-endian; those
|
||||
802.1X fields are big-endian. Deauth worked, EAPOL frames were seen, but
|
||||
`HandshakeTracker` silently discarded every one — 0 handshakes no matter
|
||||
how much traffic was generated. The project's own synthetic test frames
|
||||
made the same mistake symmetrically, so round-trip tests never caught
|
||||
it; verified live against a real AP after the fix.
|
||||
- **Monitor mode fighting NetworkManager:** `set_monitor()` now releases
|
||||
the interface from NetworkManager before switching modes and returns it
|
||||
afterward — without this, NM reasserted its own connection within
|
||||
seconds and capture died with "is in managed mode".
|
||||
- **Evil Twin left the system half-configured if the process died mid-run**
|
||||
(Ctrl+C, `kill -9`, power loss): both interfaces stuck `managed=no`
|
||||
forever, Wi-Fi gone from the system panel. A state file plus a
|
||||
self-healing pass on every engine start now undoes this automatically;
|
||||
`sabia ui` also handles SIGINT gracefully.
|
||||
- **Repeated deauth/disassoc frames were dropped as duplicates** after the
|
||||
first send — the same Sequence Control was reused on every injection;
|
||||
now increments per frame.
|
||||
- **Network list reordered under the cursor** during capture, as signal-based
|
||||
sorting reacted to every 1-2 dBm fluctuation; order is now stable
|
||||
(arrival order, signal rounded to 4 dBm buckets).
|
||||
- **"Force handshake" failed if capture had been stopped:** it now resumes
|
||||
capture automatically and waits for the target to reappear with a known
|
||||
channel before starting the job, instead of locking onto whatever
|
||||
channel the scanner happened to be hopping through.
|
||||
- **Attacks tab layout left large empty gaps** when two cards of very
|
||||
different heights shared a grid row; switched to a masonry-style column
|
||||
layout.
|
||||
|
||||
### Changed
|
||||
- `crack_reader`/`crack_mask` parallelize across available cores and reuse
|
||||
the HMAC base per iteration instead of reconstructing it — roughly 640x
|
||||
faster in release builds versus the original debug/single-thread path.
|
||||
|
||||
## [0.1.0] - 2026-10-23
|
||||
|
||||
First release: CLI, desktop app and shared core.
|
||||
|
||||
@@ -4,6 +4,117 @@
|
||||
|
||||
Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer.
|
||||
|
||||
## [1.0.3] - 2026-10-30
|
||||
|
||||
### Corrigido
|
||||
- O `deauth_directional` não tinha o próprio `#[cfg(target_os = "linux")]`;
|
||||
todo build fora do Linux falhava com uma definição duplicada e imports só de
|
||||
Linux fora de escopo. Por isso a `v1.0.1` e a `v1.0.2` nunca tiveram um app
|
||||
desktop funcional para Windows, e o pacote Linux da `v1.0.2` saiu rotulado
|
||||
`1.0.1` (a versão no `Cargo.toml` não tinha sido atualizada). Use a `v1.0.3`.
|
||||
|
||||
## [1.0.2] - 2026-10-30
|
||||
|
||||
### Corrigido
|
||||
- O build do app desktop no CI procurava o pacote em `target/`, mas o
|
||||
diretório do Cargo no runner é outro e persiste entre execuções; os jobs de
|
||||
Linux e Windows falhavam antes de publicar qualquer coisa. Por isso o app
|
||||
desktop da `v1.0.1` nunca foi publicado — use a `v1.0.2`.
|
||||
|
||||
## [1.0.1] - 2026-10-30
|
||||
|
||||
O projeto saiu do GitHub para o Gitea, e a verificação de atualização foi junto.
|
||||
|
||||
### Alterado
|
||||
- **As atualizações agora vêm do Gitea.** O `sabia update` e o "Atualizar agora" do app
|
||||
desktop leem a última release em `git.lucasrguerra.dev.br`, e não mais a API do
|
||||
GitHub. As versões até a 1.0.0 ainda olham o GitHub, que não responde mais, e por isso
|
||||
não enxergam esta release: baixe a 1.0.1 uma vez, e as próximas atualizações são automáticas.
|
||||
- Pacotes da release: o CLI para Linux x86_64 e aarch64 (compilado pelo CI do Gitea
|
||||
quando uma tag `v*` é enviada, com um `.sha256` ao lado de cada pacote) e o app desktop
|
||||
para Linux (`.AppImage`, `.deb`) e Windows (instalador NSIS `.exe`, compilado a partir
|
||||
do Linux pelo `scripts/build-gui.sh`). **Não há mais CLI para Windows nem build para
|
||||
macOS**: o CLI é só para Linux, e o macOS exige o SDK e a assinatura da Apple, que o CI
|
||||
deste projeto não tem.
|
||||
|
||||
### Corrigido
|
||||
- O `sabia update` se recusa a rodar a partir do executável do app desktop (`sabia-gui`).
|
||||
O pacote da release contém o CLI, e trocar o app por ele o quebraria; o app passa a
|
||||
apontar para a página de downloads.
|
||||
|
||||
## [1.0.0] - 2026-10-24
|
||||
|
||||
Primeira versão estável: validação em hardware real por toda a superfície de ataque, um pipeline de quebra bem mais completo, dois ataques novos (WPS, Evil Twin), relatório redesenhado e várias correções de confiabilidade que só apareceram testando contra redes Wi-Fi de verdade.
|
||||
|
||||
### Adicionado
|
||||
- **Quebra de senha:** dicionário padrão trocado por uma lista de 9,7 milhões
|
||||
de senhas (rockyou + Probable-Wordlists, deduplicados, comprimida e
|
||||
embutida no binário); **regras de mutação** estilo hashcat (maiúscula,
|
||||
sufixos numéricos/símbolos, leet simples); **força bruta por máscara**
|
||||
(sintaxe `?d?l?u?s?a`); **exportação `.hc22000`** (individual ou em lote)
|
||||
para levar o handshake direto ao hashcat/John.
|
||||
- **Ataque WPS:** PIN/Pixie Dust via `reaver` embutido (extraído em tempo de
|
||||
execução), ou `bully` quando instalado no sistema.
|
||||
- **Evil Twin:** clona um AP já visto na sessão (SSID aberto) numa segunda
|
||||
interface com portal cativo, para WPA/WPA2-PSK — a senha só é aceita
|
||||
depois de conferida de verdade contra o handshake real já capturado,
|
||||
nunca "aceita qualquer coisa". Uma variante **Enterprise (802.1X/EAP)**
|
||||
captura credenciais automaticamente via `hostapd-wpe`, sem portal nem
|
||||
ação humana, já que a credencial viaja no próprio handshake EAP.
|
||||
- **Decriptação de tráfego CCMP** de um cliente assim que a senha do
|
||||
handshake dele é conhecida (Temporal Key derivada da PMK quebrada).
|
||||
- **Pontuação de interferência de canais** por banda e **achados de
|
||||
segurança** (rede aberta, PMF ausente, WPS exposto, possível evil twin
|
||||
por SSID duplicado).
|
||||
- **Relatório de auditoria redesenhado:** capa em página cheia com o logo
|
||||
embutido, sumário com âncoras, cabeçalho/rodapé corridos via margin
|
||||
boxes do `@page` (seção atual, numeração de página), cards de KPI e
|
||||
achados com cor por severidade — a paginação continua feita pelo
|
||||
Paged.js no navegador, imprimível em PDF.
|
||||
- `ScanSnapshot.warning`: avisa quando uma captura está rodando há um
|
||||
tempo sem receber nenhum quadro — uma placa que aceita ativar modo
|
||||
monitor sem realmente entregar captura (comum em Wi-Fi interno de
|
||||
notebook) agora diz isso, em vez de ficar "escutando o ar" caladinha.
|
||||
|
||||
### Corrigido
|
||||
- **Captura de handshake nunca completava em rede real:** `eapol.rs` lia
|
||||
`key_info`/`replay_counter`/`key_data_len` como little-endian; esses
|
||||
campos do 802.1X são big-endian. O deauth funcionava, os quadros EAPOL
|
||||
eram vistos, mas o `HandshakeTracker` descartava todos silenciosamente —
|
||||
0 handshakes não importa quanto tráfego fosse gerado. Os próprios
|
||||
quadros sintéticos de teste do projeto cometiam o mesmo engano
|
||||
simetricamente, então os testes de ida e volta nunca pegaram isso;
|
||||
verificado ao vivo contra um AP real depois da correção.
|
||||
- **Modo monitor brigando com o NetworkManager:** `set_monitor()` agora
|
||||
solta a interface do NetworkManager antes de trocar de modo e devolve
|
||||
depois — sem isso o NM reafirmava a própria conexão em segundos e a
|
||||
captura caía com "está em modo managed".
|
||||
- **Evil Twin deixava o sistema pela metade se o processo morresse no
|
||||
meio** (Ctrl+C, `kill -9`, queda de energia): as duas interfaces ficavam
|
||||
presas em `managed=no` pra sempre, Wi-Fi sumia do painel do sistema. Um
|
||||
arquivo de estado mais uma rotina de autocura a cada início do engine
|
||||
desfazem isso sozinhos agora; o `sabia ui` também trata SIGINT
|
||||
graciosamente.
|
||||
- **Quadros repetidos de deauth/disassoc eram descartados como duplicata**
|
||||
depois do primeiro envio — o mesmo Sequence Control era reusado em toda
|
||||
injeção; agora incrementa por quadro.
|
||||
- **Lista de redes reordenava debaixo do cursor** durante a captura, porque
|
||||
a ordenação por sinal reagia a cada oscilação de 1-2 dBm; a ordem agora
|
||||
é estável (ordem de chegada, sinal arredondado em buckets de 4 dBm).
|
||||
- **"Forçar handshake" falhava se a captura tivesse sido parada:** agora
|
||||
religa a captura sozinho e espera o alvo reaparecer com canal conhecido
|
||||
antes de disparar o job, em vez de travar no canal em que o scanner por
|
||||
acaso estava saltando.
|
||||
- **Grid da aba Ataques deixava buracos vazios grandes** quando dois cards
|
||||
de alturas muito diferentes caíam na mesma linha; trocado por um layout
|
||||
de colunas tipo alvenaria.
|
||||
|
||||
### Alterado
|
||||
- `crack_reader`/`crack_mask` paralelizam entre os núcleos disponíveis e
|
||||
reaproveitam a base do HMAC por iteração em vez de reconstruí-la — cerca
|
||||
de 640x mais rápido em build release comparado ao caminho original
|
||||
debug/single-thread.
|
||||
|
||||
## [0.1.0] - 2026-10-23
|
||||
|
||||
Primeira versão: CLI, app desktop e núcleo compartilhado.
|
||||
|
||||
@@ -11,15 +11,20 @@ Thanks for helping! Golden rules:
|
||||
## Workflow
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo clippy --workspace --all-targets # as in CI (RUSTFLAGS=-D warnings)
|
||||
cargo test --workspace
|
||||
node scripts/check-ui-i18n.mjs # UI translation coverage
|
||||
scripts/instalar-hooks.sh # once: runs the quick check before every push
|
||||
scripts/verificar.sh # fast (seconds): fmt, clippy as in CI, i18n, scripts, workflows
|
||||
scripts/verificar.sh --completo # adds cargo test --workspace and cargo audit
|
||||
```
|
||||
|
||||
CI runs on Linux, Windows and macOS; run these before pushing — almost everything that fails CI shows up here in seconds. Documentation-only changes (`*.md`, `docs/`) are light on CI.
|
||||
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); on Windows and macOS those paths compile but aren't exercised. Parsing and cracking tests run on all three, because they're offline.
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
There is no macOS build: it needs Apple's SDK and code signing, which a Linux runner cannot provide.
|
||||
|
||||
## Where to work
|
||||
|
||||
|
||||
@@ -11,15 +11,20 @@ Obrigado por ajudar! Regras de ouro:
|
||||
## Fluxo
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo clippy --workspace --all-targets # como no CI (RUSTFLAGS=-D warnings)
|
||||
cargo test --workspace
|
||||
node scripts/check-ui-i18n.mjs # cobertura da tradução da interface
|
||||
scripts/instalar-hooks.sh # uma vez: roda a verificação rápida antes de cada push
|
||||
scripts/verificar.sh # rápido (segundos): fmt, clippy como no CI, tradução, scripts, workflows
|
||||
scripts/verificar.sh --completo # inclui cargo test --workspace e cargo audit
|
||||
```
|
||||
|
||||
O CI roda em Linux, Windows e macOS; rode esses comandos antes do push — quase tudo que faz o CI falhar aparece aqui em segundos. Mudanças só de documentação (`*.md`, `docs/`) são leves no CI.
|
||||
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); em Windows e macOS esses caminhos são compilados mas não exercitados. Testes de parsing e de quebra rodam nas três plataformas, porque são offline.
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
Não há build para macOS: ele exige o SDK e a assinatura da Apple, que um runner Linux não tem.
|
||||
|
||||
## Onde mexer
|
||||
|
||||
|
||||
@@ -2,6 +2,23 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
@@ -88,6 +105,16 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.7"
|
||||
@@ -143,6 +170,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
@@ -209,6 +245,16 @@ version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
@@ -245,6 +291,15 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
@@ -285,6 +340,16 @@ version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.31.3"
|
||||
@@ -375,7 +440,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sabia-cli"
|
||||
version = "0.1.0"
|
||||
version = "1.0.3"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"ctrlc",
|
||||
@@ -386,8 +451,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sabia-core"
|
||||
version = "0.1.0"
|
||||
version = "1.0.3"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"flate2",
|
||||
"hmac",
|
||||
"libc",
|
||||
"md-5",
|
||||
@@ -465,6 +532,12 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
|
||||
@@ -3,10 +3,10 @@ resolver = "2"
|
||||
members = ["crates/sabia-core", "crates/sabia-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
version = "1.0.3"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/lucasrguerra/sabia"
|
||||
repository = "https://git.lucasrguerra.dev.br/lucasrguerra/sabia"
|
||||
authors = ["Sabiá contributors"]
|
||||
rust-version = "1.80"
|
||||
|
||||
@@ -15,3 +15,7 @@ lto = "thin"
|
||||
codegen-units = 1
|
||||
opt-level = 3
|
||||
strip = true
|
||||
panic = "abort"
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 3
|
||||
@@ -1,8 +1,8 @@
|
||||
<p align="center"><img src="apps/gui/ui/sabia.png" width="300" alt="Sabiá"></p>
|
||||
<p align="center"><img src="logo.png" width="300" alt="Sabiá"></p>
|
||||
<h1 align="center">Sabiá</h1>
|
||||
<p align="center">Free and open Wi-Fi auditing.<br>Terminal and desktop app · live capture on Linux, offline analysis on Windows, Linux and macOS · authorized use.</p>
|
||||
<p align="center">Free and open Wi-Fi auditing.<br>Terminal and desktop app · live capture on Linux, offline analysis on Windows too · authorized use.</p>
|
||||
|
||||
<p align="center"><a href="docs/cli.md">Terminal</a> · <a href="docs/interface.md">Interface</a> · <a href="docs/architecture.md">Architecture</a> · <a href="ROADMAP.md">Roadmap</a> · <a href="CHANGELOG.md">Changelog</a></p>
|
||||
<p align="center"><a href="docs/cli.md">Terminal</a> · <a href="docs/interface.md">Interface</a> · <a href="docs/architecture.md">Architecture</a> · <a href="docs/support.md">Support</a> · <a href="ROADMAP.md">Roadmap</a> · <a href="CHANGELOG.md">Changelog</a></p>
|
||||
|
||||
<p align="center"><b>English</b> · <a href="README.pt-BR.md">Português</a></p>
|
||||
|
||||
@@ -10,12 +10,14 @@
|
||||
|
||||
Sabiá brings together, in a single sweep, what tools like `airodump-ng`, `hcxdumptool`, `aircrack-ng` and `wifite` do separately — in one Rust binary, with no `libpcap` dependency:
|
||||
|
||||
- **Network discovery:** hops across the **2.4, 5 and 6 GHz** channels and lists access points and associated clients, with SSID, BSSID, channel, signal and security type.
|
||||
- **Monitor mode without scripts:** turns the card's monitor mode on and off straight through netlink/`iw`, and shows which channels the radio supports.
|
||||
- **Handshake capture:** recognizes the **4-way handshake (EAPOL)** and the **PMKID** on the air and keeps only what offline cracking needs.
|
||||
- **Offline dictionary cracking:** recomputes the PMK with **PBKDF2-HMAC-SHA1** and checks the **MIC** (4-way) or the **PMKID** (`HMAC-SHA1(PMK, "PMK Name" …)`) — for WPA/WPA2-PSK.
|
||||
- **Network discovery:** hops across the **2.4, 5 and 6 GHz** channels and lists access points and associated clients, with SSID, BSSID, channel, signal, security type, WPS details and vendor.
|
||||
- **Monitor mode without scripts:** turns the card's monitor mode on and off straight through netlink/`iw`, recovering automatically if NetworkManager tries to reclaim the interface, and shows which channels the radio supports.
|
||||
- **Handshake capture:** recognizes the **4-way handshake (EAPOL)** and the **PMKID** on the air and keeps only what offline cracking needs; decrypts CCMP traffic for a client once its password is known.
|
||||
- **Cracking, three ways:** dictionary (9.7M passwords bundled by default, rockyou + Probable-Wordlists), hashcat-style **mutation rules**, and **mask brute force** (`?d?l?u?s?a`) — recomputes the PMK with **PBKDF2-HMAC-SHA1** and checks the **MIC** (4-way) or the **PMKID** — for WPA/WPA2-PSK. Export to **`.hc22000`** to hand the handshake to hashcat/John instead.
|
||||
- **pcap round-trip:** writes captured frames to `.pcap` and **analyzes a `.pcap` offline** (networks, clients and handshakes), interoperable with the other tools.
|
||||
- **Deauthentication test:** sends _deauth_ to an AP/station to force a reassociation — only with an explicit authorization confirmation (`--yes`).
|
||||
- **Deauthentication and WPS:** directional/continuous _deauth_, PMF probing, clientless PMKID hunting, and a WPS PIN/Pixie Dust attack (bundled reaver, or bully when installed) — all gated behind an explicit authorization confirmation (`--yes` / checkbox).
|
||||
- **Evil Twin:** clones an already-seen AP with a captive portal for WPA/WPA2-PSK — the submitted password is only accepted after it's verified against the real captured handshake, never "accepts anything" — plus an Enterprise (802.1X/EAP) variant that captures credentials automatically, no portal needed.
|
||||
- **Audit report:** a paginated, printable report (cover, table of contents, findings, channel scoring, audit trail) generated locally.
|
||||
- **Audit trail:** every monitor-mode activation and capture session is recorded in a local **hash-chained** trail.
|
||||
- **Diagnostics:** `doctor` checks privileges, tools and interfaces before you start.
|
||||
|
||||
@@ -35,17 +37,18 @@ Download the package for your system from **[Releases](../../releases)**:
|
||||
|
||||
| | CLI | Desktop app |
|
||||
|---|---|---|
|
||||
| Windows | `sabia-*-windows-x86_64.zip` | `.msi` / `.exe` installer |
|
||||
| macOS | `sabia-*-macos-{aarch64,x86_64}.tar.gz` | `.dmg` |
|
||||
| Windows | — | `.exe` installer |
|
||||
| Linux | `sabia-*-linux-{x86_64,aarch64}.tar.gz` | `.AppImage` / `.deb` |
|
||||
|
||||
Each file has a `.sha256` next to it. Build from source: `cargo build --release -p sabia-cli` (Rust 1.80+).
|
||||
The CLI is Linux-only; the desktop app ships for Linux and Windows. Each CLI
|
||||
package has a `.sha256` next to it. Build from source: `cargo build --release
|
||||
-p sabia-cli` (Rust 1.80+, Linux).
|
||||
|
||||
**Updates:** the desktop app checks GitHub on startup; when a new version exists, a button offers **Update now**: it downloads the right binary for your platform, verifies the **SHA-256** and swaps the running executable — no manual download. The check is a single request to the releases API and sends nothing of yours.
|
||||
**Updates:** the desktop app checks Gitea on startup; when a new version exists, a button offers **Update now**. The CLI updates the same way (`sabia update`); it downloads the right binary for your platform, verifies the **SHA-256** and swaps the running executable — no manual download. The check is a single request to the releases API and sends nothing of yours.
|
||||
|
||||
> The binaries are **not signed yet**: Windows SmartScreen and macOS Gatekeeper will warn. Check the SHA-256.
|
||||
> The binaries are **not signed yet**: Windows SmartScreen will warn. Check the SHA-256.
|
||||
|
||||
**Capture and monitor mode require Linux and root** (`CAP_NET_ADMIN`/`CAP_NET_RAW`): raw capture uses `AF_PACKET` and interface control uses netlink. **Offline `.pcap` analysis and cracking** also run on Windows and macOS. The desktop app elevates to administrator on its own at startup.
|
||||
**Capture and monitor mode require Linux and root** — the only platform the CLI ships for (`CAP_NET_ADMIN`/`CAP_NET_RAW`): raw capture uses `AF_PACKET` and interface control uses netlink. **Offline `.pcap` analysis and cracking** also run on Windows and macOS. The desktop app elevates to administrator on its own at startup.
|
||||
|
||||
## Getting started
|
||||
|
||||
@@ -72,11 +75,11 @@ Desktop app (Tauri) with an interface sidebar, a capture panel with an animated
|
||||
|
||||
## Known limits
|
||||
|
||||
- **Capture is Linux-only**, with a card that supports **monitor mode** (and injection, for `deauth`). On Windows and macOS Sabiá only does `.pcap` analysis and offline cracking.
|
||||
- **Capture is Linux-only**, with a card that supports **monitor mode** (and injection, for `deauth`); the CLI is not built for other platforms. On Windows Sabiá's desktop app only does `.pcap` analysis and offline cracking.
|
||||
- **WPA3/SAE** does not fall to handshake dictionaries — cracking covers **WPA/WPA2-PSK** (4-way and PMKID).
|
||||
- **5 and 6 GHz** depend on the card and the regulatory domain; some channels stay passive (no probing).
|
||||
- **Passwords outside the dictionary don't come back:** cracking is dictionary force only, no rules or masks yet.
|
||||
- **Windows and macOS:** the code compiles and is tested in CI, but live capture does not run on those platforms.
|
||||
- **Evil Twin's self-signed certificate:** the Enterprise variant's TLS certificate isn't trusted by clients that validate the chain (common on updated corporate/MDM devices) — that's the correct defense working, not a bug.
|
||||
- **Windows:** the code compiles and is tested in CI, but live capture does not run there; the desktop app does offline analysis only. There is no macOS build: Tauri needs the macOS SDK and code signing, both tied to Apple hardware and an Apple Developer account, which this project's CI (a single Linux runner) does not have.
|
||||
|
||||
## Where it's going
|
||||
|
||||
@@ -84,10 +87,10 @@ Summary of the [ROADMAP](ROADMAP.md):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Done** | Monitor mode via netlink, 2.4/5/6 GHz scanning, 4-way and PMKID capture, `.pcap` read/write, dictionary cracking (WPA/WPA2-PSK), authorized `deauth`, hash-chained audit trail, `doctor`, CLI + desktop app, one-click update |
|
||||
| **Next** | Rules and masks in cracking, GPU acceleration, export to the hashcat format (`22000`) |
|
||||
| **Later** | WPS attack, rogue AP / evil twin for authorized testing, capture on Windows (Npcap) |
|
||||
| **Long term** | WPA3/SAE downgrade, validation on varied hardware |
|
||||
| **Done** | Monitor mode via netlink (with NetworkManager recovery), 2.4/5/6 GHz scanning, 4-way and PMKID capture, CCMP decryption, dictionary/rules/mask cracking (WPA/WPA2-PSK), `.hc22000` export, `.pcap` read/write, authorized `deauth`/PMF/PMKID, WPS attack, Evil Twin (PSK + Enterprise), channel scoring and findings, paginated audit report, hash-chained audit trail, `doctor`, CLI + desktop app, one-click update |
|
||||
| **Next** | GPU acceleration for cracking, capture on Windows (Npcap) |
|
||||
| **Not planned** | A macOS build — see [Known limits](#known-limits) |
|
||||
| **Later** | WPA3/SAE downgrade, validation on varied hardware, signed release binaries |
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<p align="center"><img src="apps/gui/ui/sabia.png" width="300" alt="Sabiá"></p>
|
||||
<p align="center"><img src="logo.png" width="300" alt="Sabiá"></p>
|
||||
<h1 align="center">Sabiá</h1>
|
||||
<p align="center">Auditoria de Wi-Fi livre e gratuita.<br>Terminal e interface gráfica · captura no Linux, análise offline em Windows, Linux e macOS · uso autorizado.</p>
|
||||
<p align="center">Auditoria de Wi-Fi livre e gratuita.<br>Terminal e interface gráfica · captura no Linux, análise offline também em Windows · uso autorizado.</p>
|
||||
|
||||
<p align="center"><a href="docs/pt-BR/cli.md">Terminal</a> · <a href="docs/pt-BR/interface.md">Interface</a> · <a href="docs/pt-BR/arquitetura.md">Arquitetura</a> · <a href="ROADMAP.pt-BR.md">Roadmap</a> · <a href="CHANGELOG.pt-BR.md">Changelog</a></p>
|
||||
<p align="center"><a href="docs/pt-BR/cli.md">Terminal</a> · <a href="docs/pt-BR/interface.md">Interface</a> · <a href="docs/pt-BR/arquitetura.md">Arquitetura</a> · <a href="docs/pt-BR/suporte.md">Suporte</a> · <a href="ROADMAP.pt-BR.md">Roadmap</a> · <a href="CHANGELOG.pt-BR.md">Changelog</a></p>
|
||||
|
||||
<p align="center"><a href="README.md">English</a> · <b>Português</b></p>
|
||||
|
||||
@@ -10,12 +10,14 @@
|
||||
|
||||
O Sabiá junta, numa única varredura, o que ferramentas como `airodump-ng`, `hcxdumptool`, `aircrack-ng` e `wifite` fazem separadamente — em um só binário Rust, sem depender de `libpcap`:
|
||||
|
||||
- **Descoberta de redes:** percorre os canais de **2.4, 5 e 6 GHz** e lista os pontos de acesso e os clientes associados, com SSID, BSSID, canal, sinal e tipo de segurança.
|
||||
- **Modo monitor sem scripts:** liga e desliga o modo monitor da placa direto pelo netlink/`iw`, e mostra os canais que o rádio suporta.
|
||||
- **Captura de handshake:** reconhece o **4-way handshake (EAPOL)** e o **PMKID** no ar e guarda só o que é preciso para a quebra offline.
|
||||
- **Quebra offline por dicionário:** recalcula a PMK com **PBKDF2-HMAC-SHA1** e confere o **MIC** (4-way) ou o **PMKID** (`HMAC-SHA1(PMK, "PMK Name" …)`) — para WPA/WPA2-PSK.
|
||||
- **Descoberta de redes:** percorre os canais de **2.4, 5 e 6 GHz** e lista os pontos de acesso e os clientes associados, com SSID, BSSID, canal, sinal, tipo de segurança, detalhes de WPS e fabricante.
|
||||
- **Modo monitor sem scripts:** liga e desliga o modo monitor da placa direto pelo netlink/`iw`, com recuperação automática se o NetworkManager tentar retomar a interface, e mostra os canais que o rádio suporta.
|
||||
- **Captura de handshake:** reconhece o **4-way handshake (EAPOL)** e o **PMKID** no ar e guarda só o que é preciso para a quebra offline; decifra tráfego CCMP de um cliente assim que a senha dele é conhecida.
|
||||
- **Quebra, de três jeitos:** dicionário (9,7 milhões de senhas por padrão, rockyou + Probable-Wordlists), **regras de mutação** estilo hashcat e **força bruta por máscara** (`?d?l?u?s?a`) — recalcula a PMK com **PBKDF2-HMAC-SHA1** e confere o **MIC** (4-way) ou o **PMKID** — para WPA/WPA2-PSK. Exporta em **`.hc22000`** para levar o handshake direto ao hashcat/John.
|
||||
- **pcap de ida e volta:** grava os quadros capturados em `.pcap` e **analisa um `.pcap` offline** (redes, clientes e handshakes), compatível com as outras ferramentas.
|
||||
- **Teste de desautenticação:** envia _deauth_ a um AP/estação para forçar uma reassociação — só com a confirmação explícita de autorização (`--yes`).
|
||||
- **Desautenticação e WPS:** _deauth_ direcional/contínuo, teste de PMF, caça de PMKID sem cliente, e ataque WPS PIN/Pixie Dust (reaver embutido, ou bully quando instalado) — tudo atrás de confirmação explícita de autorização (`--yes`/checkbox).
|
||||
- **Evil Twin:** clona um AP já visto com portal cativo para WPA/WPA2-PSK — a senha só é aceita depois de conferida contra o handshake real já capturado, nunca "aceita qualquer coisa" — mais uma variante Enterprise (802.1X/EAP) que captura credenciais automaticamente, sem portal.
|
||||
- **Relatório de auditoria:** relatório paginado e imprimível (capa, sumário, achados, pontuação de canais, trilha de auditoria) gerado localmente.
|
||||
- **Trilha de auditoria:** toda ativação de modo monitor e sessão de captura fica registrada numa trilha local **encadeada por hash**.
|
||||
- **Diagnóstico:** o `doctor` confere privilégios, ferramentas e interfaces antes de você começar.
|
||||
|
||||
@@ -35,17 +37,18 @@ Baixe o pacote do seu sistema em **[Releases](../../releases)**:
|
||||
|
||||
| | CLI | App gráfico |
|
||||
|---|---|---|
|
||||
| Windows | `sabia-*-windows-x86_64.zip` | instalador `.msi` / `.exe` |
|
||||
| macOS | `sabia-*-macos-{aarch64,x86_64}.tar.gz` | `.dmg` |
|
||||
| Windows | — | instalador `.exe` |
|
||||
| Linux | `sabia-*-linux-{x86_64,aarch64}.tar.gz` | `.AppImage` / `.deb` |
|
||||
|
||||
Cada arquivo tem um `.sha256` ao lado. Compilar do código: `cargo build --release -p sabia-cli` (Rust 1.80+).
|
||||
O CLI é só para Linux; o app desktop sai para Linux e Windows. Cada pacote do
|
||||
CLI tem um `.sha256` ao lado. Compilar do código: `cargo build --release -p
|
||||
sabia-cli` (Rust 1.80+, Linux).
|
||||
|
||||
**Atualizações:** o app desktop consulta o GitHub ao abrir; havendo versão nova, um botão oferece **Atualizar agora**: ele baixa o binário certo para a plataforma, confere o **SHA-256** e troca o executável em uso — você não precisa baixar nada na mão. A consulta é uma única requisição à API de releases e nada seu é enviado.
|
||||
**Atualizações:** o app desktop consulta o Gitea ao abrir; havendo versão nova, um botão oferece **Atualizar agora**. O CLI atualiza do mesmo jeito (`sabia update`); ele baixa o binário certo para a plataforma, confere o **SHA-256** e troca o executável em uso — você não precisa baixar nada na mão. A consulta é uma única requisição à API de releases e nada seu é enviado.
|
||||
|
||||
> Os binários ainda **não são assinados**: o SmartScreen (Windows) e o Gatekeeper (macOS) vão avisar. Confira o SHA-256.
|
||||
> Os binários ainda **não são assinados**: o SmartScreen do Windows vai avisar. Confira o SHA-256.
|
||||
|
||||
**Captura e modo monitor exigem Linux e privilégio de root** (`CAP_NET_ADMIN`/`CAP_NET_RAW`): a captura crua usa `AF_PACKET` e o controle de interface usa netlink. A **análise de `.pcap` e a quebra offline** rodam também em Windows e macOS. O app desktop pede elevação de administrador sozinho ao abrir.
|
||||
**Captura e modo monitor exigem Linux e privilégio de root** — a única plataforma para a qual o CLI é compilado (`CAP_NET_ADMIN`/`CAP_NET_RAW`): a captura crua usa `AF_PACKET` e o controle de interface usa netlink. A **análise de `.pcap` e a quebra offline** rodam também em Windows e macOS. O app desktop pede elevação de administrador sozinho ao abrir.
|
||||
|
||||
## Começando
|
||||
|
||||
@@ -72,11 +75,11 @@ App desktop (Tauri) com sidebar de interfaces, painel de captura com linha de si
|
||||
|
||||
## Limites conhecidos
|
||||
|
||||
- **Captura só no Linux**, com placa que suporte **modo monitor** (e injeção, para `deauth`). Em Windows e macOS o Sabiá faz só análise de `.pcap` e quebra offline.
|
||||
- **Captura só no Linux**, com placa que suporte **modo monitor** (e injeção, para `deauth`); o CLI não é compilado para outras plataformas. Em Windows o app desktop do Sabiá faz só análise de `.pcap` e quebra offline.
|
||||
- **WPA3/SAE** não cai por dicionário de handshake — a quebra cobre **WPA/WPA2-PSK** (4-way e PMKID).
|
||||
- **5 e 6 GHz** dependem da placa e do _regulatory domain_; alguns canais ficam passivos (sem _probe_).
|
||||
- **Senhas fora do dicionário não voltam:** a quebra é só força de dicionário, sem regras nem máscara ainda.
|
||||
- **Windows e macOS:** o código compila e é testado em CI, mas a captura ao vivo não roda nessas plataformas.
|
||||
- **Certificado autoassinado do Evil Twin Enterprise:** clientes que validam a cadeia de certificado da rede (comum em perfil corporativo/MDM atualizado) não confiam nele — é a defesa correta funcionando, não um bug.
|
||||
- **Windows:** o código compila e é testado em CI, mas a captura ao vivo não roda lá; o app desktop faz só análise offline. Não há build para macOS: o Tauri precisa do SDK do macOS e de assinatura, os dois presos a hardware Apple e a uma conta Apple Developer, que o CI deste projeto (um único runner Linux) não tem.
|
||||
|
||||
## Para onde vai
|
||||
|
||||
@@ -84,10 +87,10 @@ Resumo do [ROADMAP](ROADMAP.pt-BR.md):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Feito** | Modo monitor por netlink, varredura 2.4/5/6 GHz, captura de 4-way e PMKID, escrita e leitura de `.pcap`, quebra por dicionário (WPA/WPA2-PSK), `deauth` autorizado, trilha de auditoria por hash, `doctor`, CLI + app desktop, atualização em um clique |
|
||||
| **Próximo** | Regras e máscaras na quebra, aceleração por GPU, exportação para o formato do hashcat (`22000`) |
|
||||
| **Depois** | Ataque a WPS, _rogue AP_/_evil twin_ para teste autorizado, captura em Windows (Npcap) |
|
||||
| **Longo prazo** | WPA3/SAE _downgrade_, validação em hardware variado |
|
||||
| **Feito** | Modo monitor por netlink (com recuperação do NetworkManager), varredura 2.4/5/6 GHz, captura de 4-way e PMKID, decriptação CCMP, quebra por dicionário/regras/máscara (WPA/WPA2-PSK), exportação `.hc22000`, escrita e leitura de `.pcap`, `deauth`/PMF/PMKID autorizados, ataque WPS, Evil Twin (PSK + Enterprise), pontuação de canais e achados, relatório de auditoria paginado, trilha de auditoria por hash, `doctor`, CLI + app desktop, atualização em um clique |
|
||||
| **Próximo** | Aceleração por GPU na quebra, captura em Windows (Npcap) |
|
||||
| **Fora de plano** | Um build para macOS — veja [Limites conhecidos](#limites-conhecidos) |
|
||||
| **Depois** | WPA3/SAE _downgrade_, validação em hardware variado, binários de release assinados |
|
||||
|
||||
## Desenvolvimento
|
||||
|
||||
|
||||
@@ -2,34 +2,37 @@
|
||||
|
||||
**English** · [Português](ROADMAP.pt-BR.md)
|
||||
|
||||
Sabiá does Wi-Fi auditing: it discovers networks, captures handshakes (4-way and PMKID), cracks WPA/WPA2-PSK by offline dictionary and tests deauthentication with authorization. Always for authorized use, with a local audit trail.
|
||||
Sabiá does Wi-Fi auditing: it discovers networks, captures handshakes (4-way and PMKID), cracks WPA/WPA2-PSK by dictionary/rules/mask, assesses WPS, PMF and evil twin exposure, and tests deauthentication and rogue-AP attacks with authorization. Always for authorized use, with a local audit trail.
|
||||
|
||||
## Done (0.1.0)
|
||||
## Done (1.0.0)
|
||||
|
||||
- **Monitor mode** and channel control via netlink/`iw`.
|
||||
- **Scanning** of 2.4, 5 and 6 GHz: APs and clients, with SSID, BSSID, channel, signal and security.
|
||||
- **Monitor mode** and channel control via netlink/`iw`, with automatic recovery from NetworkManager reclaiming the interface.
|
||||
- **Scanning** of 2.4, 5 and 6 GHz: APs and clients, with SSID, BSSID, channel, signal, security, WPS details, vendor and PHY standard.
|
||||
- Raw 802.11 capture via **AF_PACKET** (no libpcap), **radiotap** and management-frame parsing.
|
||||
- Capture of the **4-way handshake (EAPOL)** and the **PMKID**.
|
||||
- **Offline dictionary cracking** (PBKDF2-HMAC-SHA1, MIC and PMKID) for WPA/WPA2-PSK.
|
||||
- Capture of the **4-way handshake (EAPOL)** and the **PMKID**; **CCMP traffic decryption** once the password is known.
|
||||
- **Offline cracking** (PBKDF2-HMAC-SHA1, MIC and PMKID) for WPA/WPA2-PSK: a 9.7M-password dictionary (rockyou + Probable-Wordlists) by default, hashcat-style **mutation rules** and **mask brute force**.
|
||||
- **`.hc22000` export** to take the handshake straight to hashcat/John.
|
||||
- **`.pcap`** read and write; offline `.pcap` analysis.
|
||||
- Authorized **`deauth`** (`--yes` flag).
|
||||
- Authorized **`deauth`** (`--yes` flag), directional and continuous jobs, PMF probing, clientless PMKID hunting.
|
||||
- **WPS attack** (PIN/Pixie Dust) via a bundled reaver, or bully when installed.
|
||||
- **Evil Twin** with captive portal for WPA/WPA2-PSK (password only accepted after cryptographic verification against the real handshake) and a credential-capture variant for WPA2/WPA3-Enterprise (802.1X/EAP).
|
||||
- Channel-interference scoring and security findings (open network, no PMF, WPS exposed, possible evil twin).
|
||||
- Redesigned, paginated **audit report** (cover, table of contents, running header/footer) that prints to PDF from the browser.
|
||||
- Local hash-chained **audit trail**; `doctor` diagnostics.
|
||||
- **CLI** + **desktop app** (Tauri) + local UI server; **one-click update**.
|
||||
|
||||
## Next
|
||||
|
||||
- **Rules and masks** in cracking (hashcat style), beyond the plain dictionary.
|
||||
- **`22000` export** to take the handshake straight to hashcat/John.
|
||||
- **GPU** acceleration for PMK derivation.
|
||||
|
||||
## Later
|
||||
|
||||
- **WPS attack** (PIN/Pixie Dust) in authorized testing.
|
||||
- **Rogue AP / evil twin** for client assessment, with authorization.
|
||||
- **Capture on Windows** via Npcap.
|
||||
|
||||
## Long term
|
||||
## Later
|
||||
|
||||
- **WPA3/SAE**: detection and downgrade paths where applicable.
|
||||
- Validation on **varied hardware** (chipsets and regulatory domains).
|
||||
- Signing of release binaries (SmartScreen/Gatekeeper).
|
||||
|
||||
## Long term
|
||||
|
||||
- GPU-accelerated cracking backend (hashcat integration beyond export).
|
||||
- Packaged distribution (`apt`/`brew`/winget).
|
||||
@@ -2,34 +2,37 @@
|
||||
|
||||
[English](ROADMAP.md) · **Português**
|
||||
|
||||
O Sabiá faz auditoria de Wi-Fi: descobre redes, captura handshakes (4-way e PMKID), quebra WPA/WPA2-PSK por dicionário offline e testa desautenticação com autorização. Sempre de uso autorizado, com trilha de auditoria local.
|
||||
O Sabiá faz auditoria de Wi-Fi: descobre redes, captura handshakes (4-way e PMKID), quebra WPA/WPA2-PSK por dicionário/regras/máscara, avalia exposição a WPS, PMF e evil twin, e testa desautenticação e ataques de rogue AP com autorização. Sempre de uso autorizado, com trilha de auditoria local.
|
||||
|
||||
## Feito (0.1.0)
|
||||
## Feito (1.0.0)
|
||||
|
||||
- Controle de **modo monitor** e de canal por netlink/`iw`.
|
||||
- **Varredura** de 2.4, 5 e 6 GHz: APs e clientes, com SSID, BSSID, canal, sinal e segurança.
|
||||
- Controle de **modo monitor** e de canal por netlink/`iw`, com recuperação automática quando o NetworkManager tenta retomar a interface.
|
||||
- **Varredura** de 2.4, 5 e 6 GHz: APs e clientes, com SSID, BSSID, canal, sinal, segurança, detalhes de WPS, fabricante e padrão PHY.
|
||||
- Captura crua de 802.11 por **AF_PACKET** (sem libpcap), parsing de **radiotap** e de quadros de gerenciamento.
|
||||
- Captura de **4-way handshake (EAPOL)** e de **PMKID**.
|
||||
- **Quebra offline** por dicionário (PBKDF2-HMAC-SHA1, MIC e PMKID) para WPA/WPA2-PSK.
|
||||
- Captura de **4-way handshake (EAPOL)** e de **PMKID**; **decriptação de tráfego CCMP** quando a senha é conhecida.
|
||||
- **Quebra offline** (PBKDF2-HMAC-SHA1, MIC e PMKID) para WPA/WPA2-PSK: dicionário padrão de 9,7 milhões de senhas (rockyou + Probable-Wordlists), **regras de mutação** estilo hashcat e **força bruta por máscara**.
|
||||
- **Exportação `.hc22000`** para levar o handshake direto ao hashcat/John.
|
||||
- Escrita e leitura de **`.pcap`**; análise de `.pcap` offline.
|
||||
- **`deauth`** autorizado (flag `--yes`).
|
||||
- **`deauth`** autorizado (flag `--yes`), jobs direcionais e contínuos, teste de PMF, caça de PMKID sem cliente.
|
||||
- **Ataque WPS** (PIN/Pixie Dust) via reaver embutido, ou bully quando instalado.
|
||||
- **Evil Twin** com portal cativo para WPA/WPA2-PSK (senha só aceita após verificação criptográfica contra o handshake real) e variante de captura automática de credencial para WPA2/WPA3-Enterprise (802.1X/EAP).
|
||||
- Pontuação de interferência de canais e achados de segurança (rede aberta, sem PMF, WPS exposto, possível evil twin).
|
||||
- **Relatório de auditoria** redesenhado e paginado (capa, sumário, cabeçalho/rodapé corridos) que imprime em PDF pelo navegador.
|
||||
- **Trilha de auditoria** local encadeada por hash; `doctor` de diagnóstico.
|
||||
- **CLI** + **app desktop** (Tauri) + servidor local da UI; **atualização em um clique**.
|
||||
|
||||
## Próximo
|
||||
|
||||
- **Regras e máscaras** na quebra (estilo hashcat), além do dicionário puro.
|
||||
- **Exportação `22000`** para levar o handshake direto ao hashcat/John.
|
||||
- Aceleração por **GPU** na derivação da PMK.
|
||||
|
||||
## Depois
|
||||
|
||||
- **Ataque a WPS** (PIN/Pixie Dust) em teste autorizado.
|
||||
- **Rogue AP / evil twin** para avaliação de clientes, com autorização.
|
||||
- **Captura em Windows** via Npcap.
|
||||
|
||||
## Longo prazo
|
||||
## Depois
|
||||
|
||||
- **WPA3/SAE**: detecção e caminhos de _downgrade_ onde aplicável.
|
||||
- Validação em **hardware variado** (chipsets e _regulatory domains_).
|
||||
- Assinatura dos binários de release (SmartScreen/Gatekeeper).
|
||||
|
||||
## Longo prazo
|
||||
|
||||
- Backend de quebra acelerado por GPU (integração com hashcat além da exportação).
|
||||
- Distribuição empacotada (`apt`/`brew`/winget).
|
||||
@@ -2,6 +2,6 @@
|
||||
|
||||
**English** · [Português](SECURITY.pt-BR.md)
|
||||
|
||||
Sabiá processes untrusted data from the air and from `.pcap` files (802.11 frames and handshakes, possibly forged) and controls network interfaces with root privilege. Vulnerabilities — memory corruption in the radiotap/802.11/EAPOL parsers, path escape when writing `.pcap` or exporting, privilege misuse — should be reported privately through the repository's **Security → Report a vulnerability** tab on GitHub. Do not open a public issue before a fix.
|
||||
Sabiá processes untrusted data from the air and from `.pcap` files (802.11 frames and handshakes, possibly forged) and controls network interfaces with root privilege. Vulnerabilities — memory corruption in the radiotap/802.11/EAPOL parsers, path escape when writing `.pcap` or exporting, privilege misuse — should be reported privately through the private vulnerability reporting feature of the repository on Gitea. Do not open a public issue before a fix.
|
||||
|
||||
Sabiá is a tool for **authorized auditing**. Reports about using it to attack third-party networks are out of the project's security scope — see the **Authorized use** section of the [README](README.md).
|
||||
@@ -2,6 +2,6 @@
|
||||
|
||||
[English](SECURITY.md) · **Português**
|
||||
|
||||
O Sabiá processa dados não confiáveis vindos do ar e de arquivos `.pcap` (quadros 802.11 e handshakes, possivelmente forjados) e controla interfaces de rede com privilégio de root. Vulnerabilidades — corrupção de memória nos parsers de radiotap/802.11/EAPOL, escape de caminho na escrita de `.pcap` ou na exportação, uso indevido de privilégio — devem ser reportadas em privado pela aba **Security → Report a vulnerability** do repositório no GitHub. Não abra issue pública antes da correção.
|
||||
O Sabiá processa dados não confiáveis vindos do ar e de arquivos `.pcap` (quadros 802.11 e handshakes, possivelmente forjados) e controla interfaces de rede com privilégio de root. Vulnerabilidades — corrupção de memória nos parsers de radiotap/802.11/EAPOL, escape de caminho na escrita de `.pcap` ou na exportação, uso indevido de privilégio — devem ser reportadas em privado pelo recurso de relato privado de vulnerabilidade do repositório no Gitea. Não abra issue pública antes da correção.
|
||||
|
||||
O Sabiá é uma ferramenta de **auditoria autorizada**. Relatos sobre uso para atacar redes de terceiros não são escopo de segurança do projeto — veja a seção **Uso autorizado** do [README](README.pt-BR.md).
|
||||
@@ -8,6 +8,17 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.5"
|
||||
@@ -322,6 +333,16 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.9"
|
||||
@@ -1474,6 +1495,15 @@ dependencies = [
|
||||
"cfb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.12.2"
|
||||
@@ -2508,8 +2538,10 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "sabia-core"
|
||||
version = "0.1.0"
|
||||
version = "1.0.3"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"flate2",
|
||||
"hmac",
|
||||
"libc",
|
||||
"md-5",
|
||||
@@ -2523,7 +2555,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sabia-gui"
|
||||
version = "0.1.0"
|
||||
version = "1.0.3"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"sabia-core",
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
[package]
|
||||
name = "sabia-gui"
|
||||
description = "Sabiá: interface gráfica de auditoria de Wi-Fi"
|
||||
version = "0.1.0"
|
||||
version = "1.0.3"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/lucasrguerra/sabia"
|
||||
authors = ["Sabiá contributors"]
|
||||
repository = "https://git.lucasrguerra.dev.br/lucasrguerra/sabia"
|
||||
rust-version = "1.80"
|
||||
|
||||
[workspace]
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=../../../logo.png");
|
||||
let _ = std::fs::copy("../../../logo.png", "../ui/logo.png");
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// Modo monitor e captura crua exigem administrador no Windows: o
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Permissões da janela principal",
|
||||
"windows": ["main"],
|
||||
"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"]}}
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 4.0 KiB |
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 29 KiB |
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 979 B |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 311 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 336 KiB After Width: | Height: | Size: 350 KiB |
@@ -1,7 +1,7 @@
|
||||
// Evita a janela de console extra no Windows em release.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use sabia_core::engine::Engine;
|
||||
use sabia_core::engine::{Engine, ScanOptions};
|
||||
use std::sync::Arc;
|
||||
|
||||
struct State(Arc<Engine>);
|
||||
@@ -19,7 +19,10 @@ fn list_ifaces(state: tauri::State<'_, State>) -> Result<serde_json::Value, Stri
|
||||
|
||||
#[tauri::command]
|
||||
fn set_monitor(state: tauri::State<'_, State>, iface: String, enable: bool) -> Result<(), String> {
|
||||
state.0.set_monitor(&iface, enable).map_err(|e| e.to_string())
|
||||
state
|
||||
.0
|
||||
.set_monitor(&iface, enable)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -33,10 +36,465 @@ fn stop_scan(state: tauri::State<'_, State>, iface: String) {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn scan_results(state: tauri::State<'_, State>, iface: String) -> Result<serde_json::Value, String> {
|
||||
fn scan_results(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
serde_json::to_value(state.0.scan_results(&iface)).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn scan_snapshot(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let snapshot = state.0.scan_snapshot(&iface).map_err(|e| e.to_string())?;
|
||||
serde_json::to_value(snapshot).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn scan_clients(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
serde_json::to_value(state.0.scan_clients(&iface)).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn handshakes(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: Option<String>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
serde_json::to_value(state.0.handshakes(iface.as_deref())).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn export_handshake(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
bssid: String,
|
||||
station: String,
|
||||
out: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let report = state
|
||||
.0
|
||||
.export_handshake(&iface, &bssid, &station, &out)
|
||||
.map_err(|e| e.to_string())?;
|
||||
serde_json::to_value(report).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn export_all_handshakes(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
out: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let report = state
|
||||
.0
|
||||
.export_all_handshakes(&iface, &out)
|
||||
.map_err(|e| e.to_string())?;
|
||||
serde_json::to_value(report).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn start_crack(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
wordlist: Option<String>,
|
||||
bssid: Option<String>,
|
||||
station: Option<String>,
|
||||
rules: Option<bool>,
|
||||
) -> Result<String, String> {
|
||||
let rules = rules.unwrap_or(false);
|
||||
let result = match wordlist.filter(|w| !w.trim().is_empty()) {
|
||||
Some(wordlist) => state.0.start_crack(
|
||||
&iface,
|
||||
bssid.as_deref(),
|
||||
station.as_deref(),
|
||||
&wordlist,
|
||||
rules,
|
||||
),
|
||||
None => state
|
||||
.0
|
||||
.start_crack_default(&iface, bssid.as_deref(), station.as_deref(), rules),
|
||||
};
|
||||
result.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn start_crack_mask(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
mask: String,
|
||||
bssid: Option<String>,
|
||||
station: Option<String>,
|
||||
max_candidates: Option<u64>,
|
||||
) -> Result<String, String> {
|
||||
state
|
||||
.0
|
||||
.start_crack_mask(
|
||||
&iface,
|
||||
bssid.as_deref(),
|
||||
station.as_deref(),
|
||||
&mask,
|
||||
max_candidates.unwrap_or(50_000_000),
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn crack_job(state: tauri::State<'_, State>, id: String) -> Result<serde_json::Value, String> {
|
||||
match state.0.crack_job(&id) {
|
||||
Some(job) => serde_json::to_value(job).map_err(|e| e.to_string()),
|
||||
None => Err("job não encontrado".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn crack_jobs(state: tauri::State<'_, State>) -> Result<serde_json::Value, String> {
|
||||
serde_json::to_value(state.0.crack_jobs()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn deauth(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
bssid: String,
|
||||
station: Option<String>,
|
||||
count: Option<u32>,
|
||||
reason: Option<u16>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let report = state
|
||||
.0
|
||||
.deauth(
|
||||
&iface,
|
||||
&bssid,
|
||||
station.as_deref(),
|
||||
count.unwrap_or(5),
|
||||
reason.unwrap_or(7),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
serde_json::to_value(report).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn deauth_directional(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
bssid: String,
|
||||
station: Option<String>,
|
||||
count: Option<u32>,
|
||||
reason: Option<u16>,
|
||||
direction: Option<String>,
|
||||
frame_kind: Option<String>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let report = state
|
||||
.0
|
||||
.deauth_directional(
|
||||
&iface,
|
||||
&bssid,
|
||||
station.as_deref(),
|
||||
count.unwrap_or(5),
|
||||
reason.unwrap_or(7),
|
||||
direction.as_deref().unwrap_or("ap"),
|
||||
frame_kind.as_deref().unwrap_or("deauth"),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
serde_json::to_value(report).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn start_deauth_job(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
bssid: String,
|
||||
station: Option<String>,
|
||||
reason: Option<u16>,
|
||||
direction: Option<String>,
|
||||
frame_kind: Option<String>,
|
||||
interval_ms: Option<u64>,
|
||||
max_secs: Option<u64>,
|
||||
stop_on_handshake: Option<bool>,
|
||||
) -> Result<String, String> {
|
||||
state
|
||||
.0
|
||||
.start_deauth_job(
|
||||
&iface,
|
||||
&bssid,
|
||||
station.as_deref(),
|
||||
reason.unwrap_or(7),
|
||||
direction.as_deref().unwrap_or("both"),
|
||||
frame_kind.as_deref().unwrap_or("deauth"),
|
||||
interval_ms.unwrap_or(120),
|
||||
max_secs,
|
||||
stop_on_handshake.unwrap_or(false),
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn stop_deauth_job(state: tauri::State<'_, State>, id: String) -> Result<(), String> {
|
||||
state.0.stop_deauth_job(&id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn deauth_job(state: tauri::State<'_, State>, id: String) -> Result<serde_json::Value, String> {
|
||||
match state.0.deauth_job(&id) {
|
||||
Some(job) => serde_json::to_value(job).map_err(|e| e.to_string()),
|
||||
None => Err("job de deauth não encontrado".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn deauth_jobs(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: Option<String>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let jobs = state.0.deauth_jobs();
|
||||
let jobs: Vec<_> = match iface {
|
||||
Some(iface) => jobs
|
||||
.into_iter()
|
||||
.filter(|j| j.iface.eq_ignore_ascii_case(&iface))
|
||||
.collect(),
|
||||
None => jobs,
|
||||
};
|
||||
serde_json::to_value(jobs).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn capture_handshake(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
bssid: String,
|
||||
station: Option<String>,
|
||||
seconds: Option<u64>,
|
||||
) -> Result<String, String> {
|
||||
state
|
||||
.0
|
||||
.capture_handshake(&iface, &bssid, station.as_deref(), seconds)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn report(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let model = state.0.report(&iface).map_err(|e| e.to_string())?;
|
||||
serde_json::to_value(model).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn report_html(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
inline: Option<bool>,
|
||||
) -> Result<String, String> {
|
||||
let model = state.0.report(&iface).map_err(|e| e.to_string())?;
|
||||
let options = sabia_core::report::ReportOptions {
|
||||
inline_pagedjs: inline.unwrap_or(false),
|
||||
..Default::default()
|
||||
};
|
||||
Ok(sabia_core::report::render(&model, options))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn export_hc22000(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
bssid: String,
|
||||
station: String,
|
||||
out: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let report = state
|
||||
.0
|
||||
.export_hc22000(&iface, &bssid, &station, &out)
|
||||
.map_err(|e| e.to_string())?;
|
||||
serde_json::to_value(report).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn export_all_hc22000(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
out: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let report = state
|
||||
.0
|
||||
.export_all_hc22000(&iface, &out)
|
||||
.map_err(|e| e.to_string())?;
|
||||
serde_json::to_value(report).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn pmkid_hunt(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
bssid: String,
|
||||
attempts: Option<u32>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let report = state
|
||||
.0
|
||||
.pmkid_hunt(&iface, &bssid, attempts.unwrap_or(5))
|
||||
.map_err(|e| e.to_string())?;
|
||||
serde_json::to_value(report).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn pmf_test(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
bssid: String,
|
||||
station: String,
|
||||
reason: Option<u16>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let report = state
|
||||
.0
|
||||
.pmf_test(&iface, &bssid, &station, reason.unwrap_or(7))
|
||||
.map_err(|e| e.to_string())?;
|
||||
serde_json::to_value(report).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn wps_tools(state: tauri::State<'_, State>) -> Result<serde_json::Value, String> {
|
||||
serde_json::to_value(state.0.wps_tools()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn start_wps(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
bssid: String,
|
||||
channel: u32,
|
||||
tool: String,
|
||||
pin: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
state
|
||||
.0
|
||||
.start_wps(&iface, &bssid, channel, &tool, pin.as_deref())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn wps_job(state: tauri::State<'_, State>, id: String) -> Result<serde_json::Value, String> {
|
||||
match state.0.wps_job(&id) {
|
||||
Some(job) => serde_json::to_value(job).map_err(|e| e.to_string()),
|
||||
None => Err("job WPS não encontrado".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn wps_jobs(state: tauri::State<'_, State>) -> Result<serde_json::Value, String> {
|
||||
serde_json::to_value(state.0.wps_jobs()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn stop_wps(state: tauri::State<'_, State>, id: String) -> Result<(), String> {
|
||||
state.0.stop_wps(&id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn start_evil_twin(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
ap_iface: String,
|
||||
bssid: String,
|
||||
portal_port: Option<u16>,
|
||||
deauth: Option<bool>,
|
||||
) -> Result<String, String> {
|
||||
state
|
||||
.0
|
||||
.start_evil_twin(
|
||||
&iface,
|
||||
&ap_iface,
|
||||
&bssid,
|
||||
portal_port.unwrap_or(8080),
|
||||
deauth.unwrap_or(false),
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn eviltwin_job(state: tauri::State<'_, State>, id: String) -> Result<serde_json::Value, String> {
|
||||
match state.0.eviltwin_job(&id) {
|
||||
Some(job) => serde_json::to_value(job).map_err(|e| e.to_string()),
|
||||
None => Err("job de Evil Twin não encontrado".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn eviltwin_jobs(state: tauri::State<'_, State>) -> Result<serde_json::Value, String> {
|
||||
serde_json::to_value(state.0.eviltwin_jobs()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn stop_evil_twin(state: tauri::State<'_, State>, id: String) -> Result<(), String> {
|
||||
state.0.stop_evil_twin(&id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn start_evil_twin_enterprise(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
ap_iface: String,
|
||||
bssid: String,
|
||||
) -> Result<String, String> {
|
||||
state
|
||||
.0
|
||||
.start_evil_twin_enterprise(&iface, &ap_iface, &bssid)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn eviltwin_enterprise_job(
|
||||
state: tauri::State<'_, State>,
|
||||
id: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
match state.0.enterprise_twin_job(&id) {
|
||||
Some(job) => serde_json::to_value(job).map_err(|e| e.to_string()),
|
||||
None => Err("job de Evil Twin Enterprise não encontrado".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn eviltwin_enterprise_jobs(state: tauri::State<'_, State>) -> Result<serde_json::Value, String> {
|
||||
serde_json::to_value(state.0.enterprise_twin_jobs()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn stop_evil_twin_enterprise(state: tauri::State<'_, State>, id: String) -> Result<(), String> {
|
||||
state
|
||||
.0
|
||||
.stop_evil_twin_enterprise(&id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn start_scan_opts(
|
||||
state: tauri::State<'_, State>,
|
||||
iface: String,
|
||||
channel: Option<u32>,
|
||||
band: Option<String>,
|
||||
hop_ms: Option<u64>,
|
||||
pcap_out: Option<String>,
|
||||
seconds: Option<u64>,
|
||||
) -> Result<(), String> {
|
||||
let options = ScanOptions {
|
||||
channel,
|
||||
band,
|
||||
hop_ms: hop_ms.unwrap_or(350),
|
||||
pcap_out: pcap_out.map(Into::into),
|
||||
duration_secs: seconds,
|
||||
};
|
||||
state
|
||||
.0
|
||||
.start_scan_with(&iface, options)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn update_check(state: tauri::State<'_, State>) -> Result<serde_json::Value, String> {
|
||||
let status = state.0.update_check().map_err(|e| e.to_string())?;
|
||||
@@ -85,7 +543,10 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
|
||||
let exe = elevated_exe()?;
|
||||
let log = std::env::temp_dir().join(format!("sabia-elevate-{}.log", std::process::id()));
|
||||
let _ = std::fs::write(&log, b"");
|
||||
let out = std::fs::OpenOptions::new().append(true).open(&log).map_err(|e| e.to_string())?;
|
||||
let out = std::fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&log)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let err = out.try_clone().map_err(|e| e.to_string())?;
|
||||
let pid = std::process::id().to_string();
|
||||
|
||||
@@ -93,7 +554,14 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
|
||||
let mut cmd = {
|
||||
let mut c = std::process::Command::new("pkexec");
|
||||
c.arg("/usr/bin/env");
|
||||
for k in ["DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", "GDK_BACKEND"] {
|
||||
for k in [
|
||||
"DISPLAY",
|
||||
"WAYLAND_DISPLAY",
|
||||
"XAUTHORITY",
|
||||
"XDG_RUNTIME_DIR",
|
||||
"DBUS_SESSION_BUS_ADDRESS",
|
||||
"GDK_BACKEND",
|
||||
] {
|
||||
if let Ok(v) = std::env::var(k) {
|
||||
c.arg(format!("{k}={v}"));
|
||||
}
|
||||
@@ -104,14 +572,23 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
let mut cmd = {
|
||||
let shell = format!("SABIA_REPLACE_PID={} '{}' >/dev/null 2>&1 &", pid, exe.display());
|
||||
let script = format!("do shell script \"{}\" with administrator privileges", shell.replace('\\', "\\\\").replace('"', "\\\""));
|
||||
let shell = format!(
|
||||
"SABIA_REPLACE_PID={} '{}' >/dev/null 2>&1 &",
|
||||
pid,
|
||||
exe.display()
|
||||
);
|
||||
let script = format!(
|
||||
"do shell script \"{}\" with administrator privileges",
|
||||
shell.replace('\\', "\\\\").replace('"', "\\\"")
|
||||
);
|
||||
let mut c = std::process::Command::new("osascript");
|
||||
c.args(["-e", &script]);
|
||||
c
|
||||
};
|
||||
cmd.stdout(out).stderr(err);
|
||||
let mut child = cmd.spawn().map_err(|e| format!("não foi possível pedir privilégios: {e}"))?;
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("não foi possível pedir privilégios: {e}"))?;
|
||||
std::thread::spawn(move || {
|
||||
let status = child.wait();
|
||||
if matches!(&status, Ok(s) if s.success()) {
|
||||
@@ -122,7 +599,10 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
|
||||
let msg = match status.ok().and_then(|s| s.code()) {
|
||||
Some(126) => "Autorização cancelada.".to_string(),
|
||||
Some(127) => "Não foi possível autenticar como administrador.".to_string(),
|
||||
_ => format!("Não foi possível reiniciar como administrador. {}", detail.lines().last().unwrap_or("").trim()),
|
||||
_ => format!(
|
||||
"Não foi possível reiniciar como administrador. {}",
|
||||
detail.lines().last().unwrap_or("").trim()
|
||||
),
|
||||
};
|
||||
let _ = app.emit("elevate-failed", msg);
|
||||
let _ = std::fs::remove_file(&log);
|
||||
@@ -135,7 +615,10 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
|
||||
/// usa o próprio arquivo `.AppImage` (variável `APPIMAGE`).
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
fn elevated_exe() -> Result<std::path::PathBuf, String> {
|
||||
if let Some(p) = std::env::var_os("APPIMAGE").map(std::path::PathBuf::from).filter(|p| p.is_file()) {
|
||||
if let Some(p) = std::env::var_os("APPIMAGE")
|
||||
.map(std::path::PathBuf::from)
|
||||
.filter(|p| p.is_file())
|
||||
{
|
||||
return Ok(p);
|
||||
}
|
||||
std::env::current_exe().map_err(|e| e.to_string())
|
||||
@@ -148,7 +631,10 @@ fn main() {
|
||||
.setup(|app| {
|
||||
// Quando abrimos elevados a pedido do app normal, ele fecha o app antigo.
|
||||
#[cfg(unix)]
|
||||
if let Some(pid) = std::env::var("SABIA_REPLACE_PID").ok().and_then(|p| p.parse::<i32>().ok()) {
|
||||
if let Some(pid) = std::env::var("SABIA_REPLACE_PID")
|
||||
.ok()
|
||||
.and_then(|p| p.parse::<i32>().ok())
|
||||
{
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(1200));
|
||||
// SAFETY: só envia SIGTERM ao processo anterior do próprio usuário.
|
||||
@@ -180,6 +666,42 @@ fn main() {
|
||||
start_scan,
|
||||
stop_scan,
|
||||
scan_results,
|
||||
scan_snapshot,
|
||||
scan_clients,
|
||||
handshakes,
|
||||
export_handshake,
|
||||
export_all_handshakes,
|
||||
start_crack,
|
||||
start_crack_mask,
|
||||
crack_job,
|
||||
crack_jobs,
|
||||
deauth,
|
||||
deauth_directional,
|
||||
start_deauth_job,
|
||||
stop_deauth_job,
|
||||
deauth_job,
|
||||
deauth_jobs,
|
||||
capture_handshake,
|
||||
report,
|
||||
report_html,
|
||||
export_hc22000,
|
||||
export_all_hc22000,
|
||||
pmkid_hunt,
|
||||
pmf_test,
|
||||
wps_tools,
|
||||
start_wps,
|
||||
wps_job,
|
||||
wps_jobs,
|
||||
stop_wps,
|
||||
start_evil_twin,
|
||||
eviltwin_job,
|
||||
eviltwin_jobs,
|
||||
stop_evil_twin,
|
||||
start_evil_twin_enterprise,
|
||||
eviltwin_enterprise_job,
|
||||
eviltwin_enterprise_jobs,
|
||||
stop_evil_twin_enterprise,
|
||||
start_scan_opts,
|
||||
update_check,
|
||||
update_apply,
|
||||
is_elevated,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Sabia",
|
||||
"version": "0.1.0",
|
||||
"productName": "Sabiá",
|
||||
"version": "1.0.3",
|
||||
"identifier": "dev.sabia.app",
|
||||
"build": {
|
||||
"frontendDist": "../ui"
|
||||
@@ -19,7 +19,7 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self'; frame-src 'none'; object-src 'none'"
|
||||
"csp": "default-src 'self'; img-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self'; frame-src 'self' data: blob:; object-src 'none'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
:root[data-theme="dark"] { color-scheme: dark; }
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
margin: 0; background: var(--bg); color: var(--text);
|
||||
@@ -104,17 +105,27 @@ code, .mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
||||
padding: 9px 10px; border-radius: 12px; transition: background .15s;
|
||||
}
|
||||
.dev:hover { background: color-mix(in srgb, var(--text) 6%, transparent); }
|
||||
.dev[aria-current="true"] { background: var(--accent-soft); }
|
||||
.dev[aria-current="true"], .dev[aria-pressed="true"] { background: var(--accent-soft); }
|
||||
.dev .ico { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center; background: var(--surface); box-shadow: 0 0 0 1px var(--line); color: var(--text-2); }
|
||||
.dev[aria-current="true"] .ico { color: var(--accent); }
|
||||
.dev[aria-current="true"] .ico, .dev[aria-pressed="true"] .ico { color: var(--accent); }
|
||||
.dev .t { display: block; font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.dev .s { display: block; font-size: 11.5px; color: var(--text-2); font-family: var(--mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.side-foot { margin-top: auto; padding: 14px 10px 4px; font-size: 11.5px; color: var(--text-3); display: flex; justify-content: space-between; gap: 8px; }
|
||||
.side-foot button { background: transparent; border: 0; color: var(--text-3); cursor: pointer; text-decoration: underline; text-underline-offset: 2px; }
|
||||
.iface-pick { display: flex; flex-direction: column; align-items: stretch; gap: 8px; padding: 0 10px 4px; }
|
||||
.iface-pick .select { width: 100%; min-width: 0; }
|
||||
.iface-pick .pill { align-self: flex-start; }
|
||||
|
||||
.side-foot { margin-top: auto; padding: 14px 4px 4px; font-size: 11.5px; color: var(--text-3); display: grid; gap: 8px; }
|
||||
.foot-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.side-foot button { background: transparent; border: 0; color: var(--text-3); cursor: pointer; }
|
||||
.side-foot .foot-row > button:not(.icon-btn) { text-decoration: underline; text-underline-offset: 2px; }
|
||||
.side-foot button:hover { color: var(--text-2); }
|
||||
.lang-sel { background: transparent; border: 0; color: var(--text-3); font-size: 11.5px; cursor: pointer; }
|
||||
.lang-sel:hover { color: var(--text-2); }
|
||||
.icon-btn { width: 30px; height: 30px; border-radius: 9px; display: inline-grid; place-items: center; padding: 0; }
|
||||
.icon-btn:hover { background: color-mix(in srgb, var(--text) 8%, transparent); }
|
||||
|
||||
.theme-switch { display: inline-flex; padding: 2px; border-radius: 999px; background: color-mix(in srgb, var(--text) 8%, transparent); }
|
||||
.theme-switch button { border: 0; background: transparent; cursor: pointer; padding: 4px 9px; border-radius: 999px; font-size: 11.5px; color: var(--text-2); }
|
||||
.theme-switch button[aria-pressed="true"] { background: var(--surface); color: var(--text); box-shadow: 0 1px 3px rgba(0, 0, 0, .18); font-weight: 600; }
|
||||
|
||||
.main { overflow-y: auto; min-width: 0; }
|
||||
.page { max-width: 1040px; margin: 0 auto; padding: 30px 36px 100px; animation: rise .35s cubic-bezier(.2, .8, .2, 1); }
|
||||
@@ -133,6 +144,9 @@ code, .mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
||||
/* ------------------------------------------------------------ aviso */
|
||||
.notice { display: flex; gap: 10px; background: var(--surface); border-radius: 12px; padding: 12px 14px; box-shadow: var(--shadow); font-size: 13px; color: var(--text-2); margin-bottom: 20px; align-items: flex-start; }
|
||||
.notice svg { flex: none; margin-top: 2px; color: var(--warn); }
|
||||
.notice.warn { background: var(--warn-soft); } .notice.warn svg { color: var(--warn); }
|
||||
.notice.bad { background: var(--bad-soft); } .notice.bad svg { color: var(--bad); }
|
||||
.notice b { color: var(--text); }
|
||||
|
||||
/* ------------------------------------------------------------ controles */
|
||||
.controls { 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; }
|
||||
@@ -153,23 +167,57 @@ code, .mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
||||
.btn.live:hover { filter: brightness(1.06); }
|
||||
|
||||
/* ------------------------------------------------------------ tabela */
|
||||
.result-head { display: flex; align-items: baseline; justify-content: space-between; 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-head strong { font-size: 15px; }
|
||||
.result-head span { color: var(--text-3); font-size: 12.5px; }
|
||||
.result-summary span { color: var(--text-3); font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.result-tools { display: flex; align-items: center; gap: 8px; }
|
||||
.result-tools .filter { border: 0; border-radius: 10px; padding: 7px 12px; width: 210px; background: color-mix(in srgb, var(--text) 7%, transparent); color: var(--text); font: inherit; font-size: 12.5px; }
|
||||
.result-tools .filter::placeholder { color: var(--text-3); }
|
||||
.result-tools .select { width: auto; padding: 7px 10px; border-radius: 10px; font-size: 12.5px; }
|
||||
|
||||
.ap-card { background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; }
|
||||
.net-card { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
thead th { text-align: left; padding: 11px 14px; font-size: 10.5px; color: var(--text-3); text-transform: uppercase; letter-spacing: .06em; font-weight: 600; border-bottom: 1px solid var(--line); }
|
||||
tbody td { padding: 10px 14px; font-size: 13px; border-bottom: 1px solid var(--line); vertical-align: middle; }
|
||||
tbody tr:last-child td { border-bottom: 0; }
|
||||
tbody tr:hover { background: var(--surface-2); }
|
||||
td.bssid { font-family: var(--mono); font-size: 12px; color: var(--text-2); }
|
||||
td.ssid { font-weight: 600; }
|
||||
td.ssid em { color: var(--text-3); font-weight: 400; font-style: normal; }
|
||||
.sec-tag { font-size: 11.5px; font-weight: 600; padding: 2px 8px; border-radius: 999px; }
|
||||
.sec-aberta { background: var(--bad-soft); color: var(--bad); }
|
||||
.sec-wep { background: var(--warn-soft); color: var(--warn); }
|
||||
.sec-wpa, .sec-wpa2, .sec-wpa3 { background: var(--ok-soft); color: var(--ok); }
|
||||
|
||||
/* rede agrupada por ESSID; cada BSSID vira uma linha expansível */
|
||||
.caret-cell { width: 34px; padding-left: 10px; padding-right: 0; }
|
||||
.expander { width: 26px; height: 26px; border: 0; border-radius: 8px; background: transparent; color: var(--text-3); cursor: pointer; display: inline-grid; place-items: center; padding: 0; }
|
||||
.expander:hover { background: color-mix(in srgb, var(--text) 8%, transparent); color: var(--text); }
|
||||
.expander svg { transition: transform .18s; }
|
||||
.net-row.open .expander svg { transform: rotate(90deg); }
|
||||
.net-row.open > td { background: color-mix(in srgb, var(--text) 3%, transparent); }
|
||||
.net-name .ssid { display: block; font-weight: 600; line-height: 1.25; }
|
||||
.net-name .ssid em { color: var(--text-3); font-weight: 400; font-style: normal; }
|
||||
.net-sub { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 3px; }
|
||||
.flag { font-size: 10px; font-weight: 700; letter-spacing: .03em; padding: 1px 6px; border-radius: 999px; background: color-mix(in srgb, var(--text) 8%, transparent); color: var(--text-2); white-space: nowrap; }
|
||||
.flag-warn { background: var(--warn-soft); color: var(--warn); }
|
||||
.net-sec .sec-tag + .sec-tag { margin-left: 4px; }
|
||||
.net-chan .chans { font-variant-numeric: tabular-nums; font-weight: 600; }
|
||||
.net-band { display: block; font-size: 10.5px; color: var(--text-3); }
|
||||
.net-signal, .net-clients { white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.net-clients { color: var(--text-2); }
|
||||
|
||||
.detail-row td { padding: 4px 14px 10px 46px; background: var(--surface-2); }
|
||||
.detail-row:hover { background: transparent; }
|
||||
.bssid-list { display: grid; gap: 1px; }
|
||||
.bssid-item { display: grid; grid-template-columns: minmax(150px, 1.4fr) 132px 112px minmax(92px, .8fr) 84px 1fr; gap: 10px; align-items: center; padding: 6px 8px; border-radius: 8px; font-size: 12.5px; }
|
||||
.bssid-item:hover { background: color-mix(in srgb, var(--text) 4%, transparent); }
|
||||
.bssid { color: var(--text-2); font-size: 11.5px; }
|
||||
.bssid-meta, .bssid-clients { color: var(--text-3); font-size: 12px; }
|
||||
.bssid-flags { display: flex; gap: 4px; justify-content: flex-end; flex-wrap: wrap; }
|
||||
|
||||
.sec-tag { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 999px; white-space: nowrap; }
|
||||
.sec-open { background: var(--bad-soft); color: var(--bad); }
|
||||
.sec-wep, .sec-wpa { background: var(--warn-soft); color: var(--warn); }
|
||||
.sec-wpa2, .sec-wpa3, .sec-owe { background: var(--ok-soft); color: var(--ok); }
|
||||
.sec-eap { background: var(--signal-soft); color: var(--signal); }
|
||||
.signal-bars { display: inline-flex; align-items: flex-end; gap: 1.5px; height: 14px; vertical-align: middle; margin-right: 6px; }
|
||||
.signal-bars i { width: 3px; background: var(--line); border-radius: 1px; display: block; }
|
||||
.signal-bars i.on { background: var(--signal); }
|
||||
@@ -179,6 +227,115 @@ td.ssid em { color: var(--text-3); font-weight: 400; font-style: normal; }
|
||||
.empty-state h2 { font-size: 17px; color: var(--text); margin-bottom: 6px; }
|
||||
.empty-state p { font-size: 13.5px; max-width: 360px; margin: 0 auto; }
|
||||
|
||||
/* ------------------------------------------------------------ configurações */
|
||||
dialog { border: 0; border-radius: 20px; padding: 24px; width: min(420px, calc(100vw - 32px)); background: var(--surface); color: var(--text); box-shadow: 0 30px 80px rgba(0, 0, 0, .35); }
|
||||
dialog::backdrop { background: rgba(0, 0, 0, .35); backdrop-filter: blur(6px); }
|
||||
dialog h2 { font-size: 20px; margin-bottom: 16px; }
|
||||
dialog menu { display: flex; gap: 10px; justify-content: flex-end; padding: 0; margin: 22px 0 0; }
|
||||
.field { display: grid; gap: 6px; margin: 12px 0; }
|
||||
.field-label { font-size: 12px; color: var(--text-2); font-weight: 600; }
|
||||
.select { width: 100%; border: 0; border-radius: 12px; padding: 9px 14px; background: color-mix(in srgb, var(--text) 7%, transparent); color: var(--text); font: inherit; cursor: pointer; }
|
||||
.select option { background-color: var(--surface); color: var(--text); }
|
||||
|
||||
|
||||
/* ------------------------------------------------------------ detalhes do alvo */
|
||||
.ssid-btn { display: block; border: 0; padding: 0; background: transparent; color: inherit; font: inherit; font-weight: 600; text-align: left; cursor: pointer; line-height: 1.25; }
|
||||
.ssid-btn:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
|
||||
.bssid-item { cursor: pointer; }
|
||||
.bssid-item:focus-visible { outline: 2px solid var(--signal); outline-offset: -2px; }
|
||||
.target-dlg { width: min(980px, calc(100vw - 32px)); max-height: min(90vh, 900px); padding: 0; overflow: hidden; display: flex; flex-direction: column; }
|
||||
.target-dlg:not([open]) { display: none; }
|
||||
.target-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 22px 24px 12px; }
|
||||
.target-head h2 { font-size: 22px; line-height: 1.2; }
|
||||
.target-sub { display: flex; flex-wrap: wrap; align-items: center; gap: 5px 8px; color: var(--text-2); font-size: 12.5px; margin-top: 5px; }
|
||||
.target-sub .mono { color: var(--text-2); font-size: 12px; }
|
||||
.target-sub .sec-tag { margin-left: 2px; }
|
||||
.dot-sep { color: var(--text-3); }
|
||||
.bssid-select { width: auto; max-width: 320px; padding: 4px 8px; border-radius: 8px; font-size: 11.5px; }
|
||||
.tabs { display: flex; gap: 2px; padding: 0 20px; border-bottom: 1px solid var(--line); }
|
||||
.tabs button { border: 0; background: transparent; color: var(--text-2); padding: 9px 12px 11px; font-size: 13px; font-weight: 600; cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; }
|
||||
.tabs button:hover { color: var(--text); }
|
||||
.tabs button[aria-selected="true"] { color: var(--text); border-bottom-color: var(--accent); }
|
||||
.tab-count { display: inline-block; min-width: 18px; padding: 0 5px; margin-left: 3px; border-radius: 999px; background: color-mix(in srgb, var(--text) 9%, transparent); color: var(--text-2); font-size: 10.5px; line-height: 17px; text-align: center; }
|
||||
.tab-panel { padding: 18px 24px 26px; overflow-y: auto; min-height: 0; }
|
||||
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||
.detail-card { background: var(--surface-2); border: 1px solid var(--line); border-radius: 14px; padding: 14px 16px; }
|
||||
.detail-card h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .06em; color: var(--text-3); margin-bottom: 10px; }
|
||||
.detail-wide { margin-top: 12px; }
|
||||
.kv { display: grid; grid-template-columns: minmax(96px, auto) minmax(0, 1fr); gap: 6px 12px; margin: 0; font-size: 12.5px; }
|
||||
.kv dt { color: var(--text-3); }
|
||||
.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
||||
.detail-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
||||
.btn.small { padding: 6px 12px; font-size: 12px; }
|
||||
.risk { border-radius: 12px; padding: 10px 13px; font-size: 12.5px; margin-bottom: 12px; border: 1px solid transparent; }
|
||||
.risk-bad { background: var(--bad-soft); color: var(--bad); }
|
||||
.risk-warn { background: var(--warn-soft); color: var(--warn); }
|
||||
.risk-info { background: var(--signal-soft); color: var(--signal); }
|
||||
.risk strong { font-weight: 700; }
|
||||
.flag-ok { background: var(--ok-soft); color: var(--ok); }
|
||||
.flag-bad { background: var(--bad-soft); color: var(--bad); }
|
||||
.clients-card { margin-top: 2px; overflow-x: auto; }
|
||||
.mini-table { width: 100%; border-collapse: collapse; }
|
||||
.mini-table .row-actions { text-align: right; white-space: nowrap; }
|
||||
.mini-table th { padding: 9px 12px; font-size: 10.5px; text-transform: uppercase; letter-spacing: .05em; color: var(--text-3); text-align: left; border-bottom: 1px solid var(--line); }
|
||||
.mini-table td { padding: 9px 12px; font-size: 12.5px; border-bottom: 1px solid var(--line); vertical-align: middle; }
|
||||
.mini-table tr:last-child td { border-bottom: 0; }
|
||||
.mini-table .nowrap { white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.mini-table .probes { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-2); }
|
||||
.mini-table .device-cell { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-2); }
|
||||
.clients-card .mini-table { table-layout: fixed; }
|
||||
.clients-card .mini-table th:nth-child(1) { width: 20%; }
|
||||
.clients-card .mini-table th:nth-child(2) { width: 18%; }
|
||||
.clients-card .mini-table th:nth-child(3) { width: 12%; }
|
||||
.clients-card .mini-table th:nth-child(4) { width: 7%; }
|
||||
.clients-card .mini-table th:nth-child(5) { width: 10%; }
|
||||
.clients-card .mini-table th:nth-child(6) { width: 12%; }
|
||||
.clients-card .mini-table th:nth-child(7) { width: 21%; }
|
||||
.clients-card .row-actions .btn { padding: 6px 10px; }
|
||||
.clients-card .client-name, .clients-card .client-sub { max-width: 100%; }
|
||||
.client-name { font-weight: 600; color: var(--text); max-width: 210px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.client-sub { color: var(--text-3); font-size: 10.5px; max-width: 210px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.empty-inline { text-align: center; padding: 46px 20px; color: var(--text-2); background: var(--surface-2); border-radius: 14px; }
|
||||
.empty-inline strong { color: var(--text); display: block; margin-bottom: 4px; }
|
||||
.empty-inline p { margin: 0; font-size: 13px; }
|
||||
.auth-check { display: flex; align-items: center; gap: 8px; font-size: 12.5px; font-weight: 600; margin: 4px 0 14px; }
|
||||
.auth-check input { width: 16px; height: 16px; accent-color: var(--accent); }
|
||||
/* Colunas CSS em vez de grid: os cards de ataque têm alturas muito
|
||||
diferentes entre si (Deauth/Handshake são enormes, PMF/WPS são curtos).
|
||||
Num grid de 2 colunas comum, o par mais curto de uma linha deixava um
|
||||
vão vazio enorme embaixo (a altura da linha é a do item mais alto, e
|
||||
align-items:start não estica o curto pra preencher). Colunas fazem os
|
||||
cards fluírem como alvenaria: cada um só ocupa a altura que precisa, o
|
||||
próximo entra logo abaixo, sem buracos. */
|
||||
.attack-grid { column-count: 2; column-gap: 12px; }
|
||||
.attack-card { background: var(--surface-2); border: 1px solid var(--line); border-radius: 14px; padding: 14px 16px; min-width: 0; break-inside: avoid; margin: 0 0 12px; display: inline-block; width: 100%; }
|
||||
.attack-deauth, .attack-wide { column-span: all; }
|
||||
.attack-deauth .field-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.attack-deauth .detail-actions { align-items: center; }
|
||||
.attack-card h3 { font-size: 14px; margin-bottom: 5px; }
|
||||
.attack-card .hint { margin: 6px 0 10px; }
|
||||
.attack-card .btn { margin-top: 2px; }
|
||||
.field-row { display: grid; grid-template-columns: 1fr 1.4fr; gap: 10px; }
|
||||
.field { display: grid; gap: 5px; margin: 9px 0; }
|
||||
.field-label { font-size: 11.5px; color: var(--text-2); font-weight: 600; }
|
||||
.input { width: 100%; border: 1px solid transparent; border-radius: 10px; padding: 8px 11px; background: color-mix(in srgb, var(--text) 7%, transparent); color: var(--text); font: inherit; font-size: 12.5px; }
|
||||
.input:focus { outline: 2px solid var(--signal); outline-offset: 1px; }
|
||||
.hint { color: var(--text-3); font-size: 12px; margin: 0; line-height: 1.4; }
|
||||
.hint.bad { color: var(--bad); }
|
||||
.hint code { background: color-mix(in srgb, var(--text) 7%, transparent); padding: 1px 5px; border-radius: 5px; font-size: 11.5px; }
|
||||
.hs-list { display: grid; gap: 6px; margin-bottom: 8px; }
|
||||
.hs-item { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 8px 10px; font-size: 12px; }
|
||||
.hs-main { min-width: 0; display: grid; gap: 2px; }
|
||||
.hs-meta { color: var(--text-3); font-size: 11px; }
|
||||
.hs-flags { display: flex; flex-wrap: wrap; gap: 4px; justify-content: flex-end; }
|
||||
.crack-box { margin-top: 10px; padding: 9px 11px; border-radius: 10px; background: var(--surface); border: 1px solid var(--line); font-size: 12px; display: flex; flex-wrap: wrap; align-items: center; gap: 7px; }
|
||||
.crack-ok { border-color: color-mix(in srgb, var(--ok) 40%, transparent); }
|
||||
.crack-box .mono { margin-right: 4px; }
|
||||
.crack-head { display: flex; align-items: center; gap: 8px; width: 100%; color: var(--text-2); }
|
||||
.crack-bar { width: 100%; height: 4px; border-radius: 999px; background: var(--line); overflow: hidden; }
|
||||
.crack-bar i { display: block; width: 35%; height: 100%; border-radius: 999px; background: var(--accent); animation: crack-slide 1.1s ease-in-out infinite; }
|
||||
@keyframes crack-slide { 0% { margin-left: -35%; } 100% { margin-left: 100%; } }
|
||||
|
||||
/* ------------------------------------------------------------ responsivo */
|
||||
@media (max-width: 760px) {
|
||||
.app { grid-template-columns: 1fr; grid-template-rows: auto 1fr; }
|
||||
@@ -186,4 +343,119 @@ td.ssid em { color: var(--text-3); font-weight: 400; font-style: normal; }
|
||||
.brand { padding: 4px 10px; }
|
||||
.side-title, .side-foot { display: none; }
|
||||
.list { display: flex; }
|
||||
.result-tools { width: 100%; }
|
||||
.result-tools .filter { flex: 1; width: auto; }
|
||||
.net-table { min-width: 560px; }
|
||||
.bssid-item { grid-template-columns: 1fr auto; }
|
||||
.bssid-meta, .bssid-sig, .bssid-flags { grid-column: 1 / -1; }
|
||||
.bssid-flags { justify-content: flex-start; }
|
||||
.target-dlg { width: calc(100vw - 16px); max-height: 94vh; }
|
||||
.target-head { padding: 16px 16px 10px; }
|
||||
.tabs { padding: 0 10px; overflow-x: auto; }
|
||||
.tab-panel { padding: 14px 14px 20px; }
|
||||
.detail-grid { grid-template-columns: 1fr; }
|
||||
.attack-grid { column-count: 1; }
|
||||
.field-row { grid-template-columns: 1fr; }
|
||||
.detail-card, .attack-card { padding: 12px; }
|
||||
}
|
||||
|
||||
/* --------------------------------------------------- análise de canais */
|
||||
.top-actions { display: flex; align-items: center; gap: 10px; }
|
||||
.view-switch { display: inline-flex; padding: 2px; border-radius: 999px; background: color-mix(in srgb, var(--text) 7%, transparent); }
|
||||
.view-switch button { border: 0; border-radius: 999px; padding: 5px 12px; font-size: 12px; font-weight: 600; color: var(--text-2); background: transparent; cursor: pointer; }
|
||||
.view-switch button[aria-pressed="true"] { background: var(--surface); color: var(--text); box-shadow: 0 1px 3px rgba(0, 0, 0, .12); }
|
||||
.alert-strip { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin: 0 2px 10px; }
|
||||
.alert-item { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--line); background: var(--surface); border-radius: 999px; padding: 5px 11px; font-size: 12px; cursor: pointer; }
|
||||
.alert-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--warn); }
|
||||
.alert-high .alert-dot { background: var(--bad); }
|
||||
.alert-low .alert-dot { background: var(--text-3); }
|
||||
.alert-title { max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.alert-more { color: var(--text-3); font-size: 11.5px; padding: 0 4px; }
|
||||
.channel-card { padding: 16px; }
|
||||
.channel-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||
.band-switch { display: inline-flex; gap: 4px; }
|
||||
.band-switch button { border: 1px solid transparent; background: color-mix(in srgb, var(--text) 6%, transparent); color: var(--text-2); border-radius: 999px; padding: 5px 12px; font-size: 12px; font-weight: 600; cursor: pointer; }
|
||||
.band-switch button[aria-pressed="true"] { background: var(--signal-soft); color: var(--signal); border-color: color-mix(in srgb, var(--signal) 35%, transparent); }
|
||||
.sp-legend { display: flex; flex-wrap: wrap; gap: 10px; color: var(--text-3); font-size: 11px; }
|
||||
.sp-legend span { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.sp-legend i { width: 9px; height: 9px; border-radius: 2px; display: inline-block; }
|
||||
.spectrum { width: 100%; height: auto; display: block; background: color-mix(in srgb, var(--text) 3%, transparent); border-radius: 12px; }
|
||||
.sp-grid { stroke: var(--line); stroke-width: 1; }
|
||||
.sp-axis { stroke: color-mix(in srgb, var(--text) 22%, transparent); stroke-width: 1; }
|
||||
.sp-db, .sp-chan { fill: var(--text-3); font-size: 9px; font-family: var(--mono); }
|
||||
.sp-name { fill: var(--text-2); font-size: 9px; font-weight: 600; }
|
||||
.sp-lobe path { transition: fill-opacity .15s; }
|
||||
.sp-lobe:hover path { fill-opacity: .38; }
|
||||
.channel-grid { display: grid; grid-template-columns: 190px 1fr; gap: 18px; margin-top: 14px; }
|
||||
.channel-grid h3 { font-size: 12px; color: var(--text-2); margin-bottom: 8px; }
|
||||
.chan-best-num { font-size: 44px; font-weight: 700; line-height: 1; color: var(--signal); margin: 0 0 6px; }
|
||||
.chan-list { display: grid; gap: 6px; }
|
||||
.chan-list h3 { margin: 0 0 2px; }
|
||||
.chan-row { display: grid; grid-template-columns: 46px 1fr 48px 128px; align-items: center; gap: 8px; font-size: 12px; }
|
||||
.chan-row .chan-n { font-weight: 600; display: inline-flex; align-items: center; gap: 4px; }
|
||||
.chan-bar { height: 7px; border-radius: 999px; background: var(--line); overflow: hidden; }
|
||||
.chan-bar i { display: block; height: 100%; border-radius: 999px; background: var(--ok); }
|
||||
.chan-bar i.diff-mid { background: var(--warn); }
|
||||
.chan-bar i.diff-high { background: var(--bad); }
|
||||
.chan-score { font-family: var(--mono); font-size: 11px; color: var(--text-2); text-align: right; }
|
||||
.chan-aps { color: var(--text-3); font-size: 11px; white-space: nowrap; }
|
||||
.chan-best .chan-n, .chan-best .chan-aps { color: var(--signal); }
|
||||
|
||||
/* ------------------------------------------------- saúde do espectro (SMART) */
|
||||
.chan-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); gap: 10px; margin: 2px 0 16px; }
|
||||
.chan-tile { background: var(--surface-2); border-radius: 14px; padding: 12px 14px; display: flex; flex-direction: column; gap: 2px; box-shadow: 0 0 0 1px var(--line); }
|
||||
.chan-tile small { color: var(--text-3); font-size: 12px; }
|
||||
.chan-tile b { font-size: 18px; font-variant-numeric: tabular-nums; }
|
||||
.chan-tile.status.ok { background: var(--ok-soft); } .chan-tile.status.ok b { color: var(--ok); }
|
||||
.chan-tile.status.warn { background: var(--warn-soft); } .chan-tile.status.warn b { color: var(--warn); }
|
||||
.chan-tile.status.bad { background: var(--bad-soft); } .chan-tile.status.bad b { color: var(--bad); }
|
||||
.chan-tile b span { font-weight: 500; opacity: .8; font-size: 13px; margin-left: 4px; }
|
||||
|
||||
.chan-dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; background: var(--ok); margin-right: 5px; flex: none; }
|
||||
.chan-dot.warn { background: var(--warn); }
|
||||
.chan-dot.bad { background: var(--bad); }
|
||||
|
||||
.chan-attrs { margin-top: 4px; }
|
||||
.chan-attrs summary { cursor: pointer; font-size: 12.5px; font-weight: 600; color: var(--text-2); padding: 6px 0; list-style: none; }
|
||||
.chan-attrs summary::-webkit-details-marker { display: none; }
|
||||
.chan-attrs summary::before { content: '▸ '; display: inline-block; width: 12px; }
|
||||
.chan-attrs[open] summary::before { content: '▾ '; }
|
||||
.chan-table .dim { color: var(--text-3); font-size: 11.5px; }
|
||||
.chan-table tr.chan-best-row td { color: var(--signal); font-weight: 600; }
|
||||
.mini-tag { font-size: 8.5px; padding: 1px 4px; border-radius: 4px; background: var(--warn-soft); color: var(--warn); }
|
||||
|
||||
/* ------------------------------------------------ clientes e linha do tempo */
|
||||
.spark { display: inline-block; width: 62px; height: 16px; vertical-align: middle; margin-left: 6px; }
|
||||
.spark path { fill: none; stroke: var(--signal); stroke-width: 1.4; }
|
||||
.spark-big { display: block; width: 100%; height: 70px; margin: 10px 0 0; }
|
||||
.timeline-card { margin-top: 12px; }
|
||||
.timeline-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 6px; }
|
||||
.timeline { list-style: none; padding: 0; margin: 8px 0; display: grid; gap: 5px; max-height: 260px; overflow: auto; }
|
||||
.timeline li { display: grid; grid-template-columns: 84px 112px 1fr; align-items: baseline; gap: 8px; font-size: 12px; border-bottom: 1px solid var(--line); padding: 5px 2px; }
|
||||
.timeline li > .mono:first-child { white-space: nowrap; }
|
||||
.tl-kind { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .03em; color: var(--text-3); }
|
||||
.tl-deauth { color: var(--bad); }
|
||||
.tl-associate { color: var(--ok); }
|
||||
.tl-probe { color: var(--signal); }
|
||||
.tl-detail { color: var(--text-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* ------------------------------------------------------------- WPS e relatório */
|
||||
.term { margin: 10px 0 0; padding: 10px 12px; border-radius: 10px; background: color-mix(in srgb, var(--text) 6%, transparent); color: var(--text-2); font: 11.5px/1.5 var(--mono); white-space: pre-wrap; max-height: 180px; overflow: auto; }
|
||||
.report-dlg { width: min(1060px, 96vw); height: 92vh; max-height: 92vh; padding: 0; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.report-dlg:not([open]) { display: none; }
|
||||
.report-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; padding: 16px 18px 12px; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
|
||||
.report-head h2 { font-size: 17px; margin: 0 0 2px; }
|
||||
.report-tools { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.report-tools .mono { font-size: 11.5px; color: var(--text-2); min-width: 38px; text-align: center; }
|
||||
.report-view { flex: 1; overflow: auto; background: color-mix(in srgb, var(--text) 8%, var(--bg)); padding: 16px; }
|
||||
.report-canvas { margin: 0 auto; transform-origin: top center; background: #fff; box-shadow: 0 6px 30px rgba(0, 0, 0, .18); }
|
||||
.report-canvas iframe { border: 0; display: block; background: #fff; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.top-actions { gap: 6px; }
|
||||
.channel-grid { grid-template-columns: 1fr; }
|
||||
.chan-row { grid-template-columns: 42px 1fr 44px; }
|
||||
.chan-aps { grid-column: 2 / -1; }
|
||||
.report-dlg { width: 100vw; height: 100vh; max-height: 100vh; border-radius: 0; }
|
||||
.report-view { padding: 8px; }
|
||||
}
|
||||
@@ -2,8 +2,12 @@
|
||||
export const EN = {
|
||||
// index.html / sidebar
|
||||
'auditoria de Wi-Fi': 'Wi-Fi auditing',
|
||||
'Interface': 'Interface',
|
||||
'Interface Wi-Fi': 'Wi-Fi interface',
|
||||
'Interfaces': 'Interfaces',
|
||||
'nenhuma interface Wi-Fi encontrada': 'no Wi-Fi interface found',
|
||||
'APs, clientes e ataques': 'APs, clients and attacks',
|
||||
'espectro e saúde': 'spectrum and health',
|
||||
'checar atualização': 'check for update',
|
||||
'checando…': 'checking…',
|
||||
'atualizar para {n}': 'update to {n}',
|
||||
@@ -30,7 +34,12 @@ export const EN = {
|
||||
'parar captura': 'stop capture',
|
||||
|
||||
// resultados
|
||||
'Redes': 'Networks',
|
||||
'Redes vistas': 'Networks seen',
|
||||
'Linha do tempo': 'Timeline',
|
||||
'Linha do tempo ·': 'Timeline ·',
|
||||
'fechar': 'close',
|
||||
'motivo {n}': 'reason {n}',
|
||||
'Pronto pra capturar': 'Ready to capture',
|
||||
'Ative o modo monitor e inicie a captura pra ver as redes no ar.': 'Enable monitor mode and start capturing to see the networks on the air.',
|
||||
'Nenhuma interface Wi-Fi': 'No Wi-Fi interface',
|
||||
@@ -41,16 +50,44 @@ export const EN = {
|
||||
'oculta': 'hidden',
|
||||
'Rede': 'Network',
|
||||
'Canal': 'Channel',
|
||||
'Canais': 'Channels',
|
||||
'Banda': 'Band',
|
||||
'Bandas': 'Bands',
|
||||
'Nome': 'Name',
|
||||
'Sinal': 'Signal',
|
||||
'Segurança': 'Security',
|
||||
'Clientes': 'Clients',
|
||||
'filtrar por SSID ou BSSID': 'filter by SSID or BSSID',
|
||||
'Filtrar redes': 'Filter networks',
|
||||
'Ordenar por': 'Sort by',
|
||||
'expandir': 'expand',
|
||||
'recolher': 'collapse',
|
||||
'PMF parcial': 'partial PMF',
|
||||
'canal {n}': 'channel {n}',
|
||||
'canal {n} · {n}': 'channel {n} · {n}',
|
||||
'1 cliente': '1 client',
|
||||
'{n} clientes': '{n} clients',
|
||||
'{n} de {n} redes': '{n} of {n} networks',
|
||||
'{n} redes · {n} BSSIDs · {n} clientes': '{n} networks · {n} BSSIDs · {n} clients',
|
||||
'Nenhuma rede corresponde ao filtro': 'No network matches the filter',
|
||||
'Ajuste a busca ou limpe o campo de filtro.': 'Adjust the search or clear the filter.',
|
||||
|
||||
// erros de UI
|
||||
'Não foi possível trocar o modo:': 'Could not change the mode:',
|
||||
'Não foi possível iniciar a captura:': 'Could not start the capture:',
|
||||
'Não foi possível retomar a captura: {n}': 'Could not resume the capture: {n}',
|
||||
'Retomando a captura e localizando a rede…': 'Resuming the capture and locating the network…',
|
||||
'A rede não reapareceu na varredura a tempo. Tente de novo.': 'The network did not reappear in the scan in time. Try again.',
|
||||
'A captura parou:': 'The capture stopped:',
|
||||
'erro desconhecido': 'unknown error',
|
||||
'rota desconhecida': 'unknown route',
|
||||
|
||||
// seletor de idioma
|
||||
// seletor de idioma / configurações
|
||||
'Configurações': 'Settings',
|
||||
'Tema': 'Theme',
|
||||
'Claro': 'Light',
|
||||
'Escuro': 'Dark',
|
||||
'Fechar': 'Close',
|
||||
'Idioma': 'Language',
|
||||
'Auto': 'Auto',
|
||||
'Português': 'Portuguese',
|
||||
@@ -95,4 +132,434 @@ export const EN = {
|
||||
'nenhum handshake quebrável para os filtros informados': 'no crackable handshake for the given filters',
|
||||
'quantidade de quadros deve ficar entre 1 e 256': 'frame count must be between 1 and 256',
|
||||
'MAC inválido: {n}': 'invalid MAC: {n}',
|
||||
|
||||
// detalhes do alvo e ataques
|
||||
'não': 'no',
|
||||
'não anunciado': 'not advertised',
|
||||
'quebrável': 'crackable',
|
||||
'Nenhum handshake capturado para este BSSID.': 'No handshake captured for this BSSID.',
|
||||
'Rede aberta.': 'Open network.',
|
||||
'O tráfego não é cifrado pelo Wi-Fi; qualquer cliente pode se associar.': 'Traffic is not encrypted over Wi-Fi; any client can associate.',
|
||||
'A cifra é quebrável; capture IVs na aba Ataques.': 'The cipher is breakable; capture IVs in the Attacks tab.',
|
||||
'O AP anuncia configuração travada.': 'The AP advertises configuration locked.',
|
||||
'PIN pode estar acessível; verifique o lock.': 'The PIN may be accessible; check the lock.',
|
||||
'Desautenticação e captura de handshake podem ser ignoradas pelos clientes.': 'Deauthentication and handshake capture may be ignored by clients.',
|
||||
'Sem PMF obrigatório.': 'No mandatory PMF.',
|
||||
'Desautenticação tende a funcionar e facilita a captura do handshake.': 'Deauthentication tends to work and makes handshake capture easier.',
|
||||
'País': 'Country',
|
||||
'Último visto': 'Last seen',
|
||||
'Primeiro visto': 'First seen',
|
||||
'Frequência': 'Frequency',
|
||||
'Largura': 'Width',
|
||||
'Padrão': 'Standard',
|
||||
'Sinal máximo': 'Max signal',
|
||||
'Intervalo de beacon': 'Beacon interval',
|
||||
'Quadros de dados': 'Data frames',
|
||||
'Versão WPS': 'WPS version',
|
||||
'Vistos na rede': 'Seen on network',
|
||||
'Quebráveis': 'Crackable',
|
||||
'Número do modelo': 'Model number',
|
||||
'Nome do dispositivo': 'Device name',
|
||||
'Serial': 'Serial',
|
||||
'Identificação': 'Identity',
|
||||
'Rádio': 'Radio',
|
||||
'sim': 'yes',
|
||||
'Nome exibido': 'Display name',
|
||||
'Hostname': 'Hostname',
|
||||
'Sistema': 'OS',
|
||||
'IP': 'IP',
|
||||
'Associado a': 'Associated to',
|
||||
'Atual': 'Current',
|
||||
'Máximo': 'Maximum',
|
||||
'Sem amostras de sinal suficientes ainda.': 'Not enough signal samples yet.',
|
||||
'Linha do tempo · eventos mais recentes primeiro.': 'Timeline · most recent events first.',
|
||||
'Cliente · {n}': 'Client · {n}',
|
||||
'A captura parou sozinha:': 'The capture stopped on its own:',
|
||||
'A placa não está capturando nada': 'The adapter is not capturing anything',
|
||||
'Nenhum quadro recebido depois de alguns segundos saltando canais — esta placa pode aceitar ativar o modo monitor sem conseguir capturar de verdade (comum em Wi-Fi interna de notebook). Tente um adaptador USB dedicado a modo monitor/injeção.':
|
||||
'No frame received after several seconds of channel hopping — this adapter may accept turning monitor mode on without actually being able to capture (common with a laptop\'s built-in Wi-Fi). Try a USB adapter dedicated to monitor mode/injection.',
|
||||
'motivo desconhecido — confira se o adaptador ainda está conectado.':
|
||||
'unknown reason — check whether the adapter is still connected.',
|
||||
'Clientes e handshake': 'Clients and handshake',
|
||||
'Nenhum cliente visto.': 'No client seen.',
|
||||
'Associe ou gere tráfego na rede para que o Sabiá identifique estações.': 'Associate or generate traffic on the network so Sabiá can identify stations.',
|
||||
'senha encontrada': 'password found',
|
||||
'concluído': 'done',
|
||||
'quebrando': 'cracking',
|
||||
'senhas testadas': 'passwords tried',
|
||||
'{n} senhas testadas': '{n} passwords tried',
|
||||
'{n} tentativas · {n} ms': '{n} attempts · {n} ms',
|
||||
'Senha não encontrada no dicionário ({n} tentativas).': 'Password not found in the wordlist ({n} attempts).',
|
||||
'· quebrável': '· crackable',
|
||||
'não especificado': 'unspecified',
|
||||
'autenticação expirada': 'authentication expired',
|
||||
'saindo': 'leaving',
|
||||
'inativo': 'inactive',
|
||||
'AP sem recursos': 'AP out of resources',
|
||||
'classe 2': 'class 2',
|
||||
'classe 3': 'class 3',
|
||||
'{n} handshake(s) visto(s); {n} quebrável(is).': '{n} handshake(s) seen; {n} crackable.',
|
||||
'Nenhum handshake capturado ainda. Desautentique um cliente para estimular a reassociação.': 'No handshake captured yet. Deauthenticate a client to trigger reassociation.',
|
||||
'Captura WEP': 'WEP capture',
|
||||
'WEP não tem handshake: o ataque coleta IVs em um .pcap. Reinicie a captura gravando o arquivo e gere tráfego com desautenticação. A captura atual é reiniciada e a tabela começa vazia.': 'WEP has no handshake: the attack collects IVs into a .pcap. Restart the capture writing the file and generate traffic with deauthentication. The current capture is restarted and the table starts empty.',
|
||||
'Arquivo pcap': 'pcap file',
|
||||
'Reiniciar captura com pcap': 'Restart capture to pcap',
|
||||
'Gerar tráfego': 'Generate traffic',
|
||||
'Copiar comando': 'Copy command',
|
||||
'Esta rede não usa WEP.': 'This network does not use WEP.',
|
||||
'Somente em redes próprias ou com autorização escrita.': 'Only on your own networks or with written authorization.',
|
||||
'Cada ação fica registrada na trilha de auditoria local, com interface, BSSID e estação.': 'Every action is logged in the local audit trail with interface, BSSID and station.',
|
||||
'Tenho autorização para testar esta rede': 'I have authorization to test this network',
|
||||
'Desautenticação': 'Deauthentication',
|
||||
'Expulsa uma estação (ou todas) para forçar reassociação e captura de handshake.': 'Kicks a station (or all) to force reassociation and handshake capture.',
|
||||
'Enviar desautenticação': 'Send deauthentication',
|
||||
'Forçar handshake': 'Force handshake',
|
||||
'Dicionário': 'Wordlist',
|
||||
'Rede oculta': 'Hidden network',
|
||||
'job não encontrado': 'job not found',
|
||||
'{n} quadro(s) enviados para {n} (motivo {n}).': '{n} frame(s) sent to {n} (reason {n}).',
|
||||
'Selecione um handshake.': 'Select a handshake.',
|
||||
'Informe o dicionário.': 'Enter the wordlist.',
|
||||
'Informe o caminho do .pcap.': 'Enter the .pcap path.',
|
||||
'Força bruta por máscara — sem dicionário, testa toda combinação do padrão.':
|
||||
'Mask brute force — no wordlist, tests every combination of the pattern.',
|
||||
'dígito,': 'digit,',
|
||||
'minúscula,': 'lowercase,',
|
||||
'maiúscula,': 'uppercase,',
|
||||
'símbolo,': 'symbol,',
|
||||
'qualquer um; o resto é literal.': 'any of those; the rest is literal.',
|
||||
'Máscara': 'Mask',
|
||||
'Força bruta por máscara': 'Mask brute force',
|
||||
'Informe uma máscara (ex.: ?d?d?d?d?d?d?d?d).': 'Enter a mask (e.g. ?d?d?d?d?d?d?d?d).',
|
||||
'job não iniciado': 'job not started',
|
||||
'Captura gravando em {n}. Gere tráfego e aguarde IVs suficientes.': 'Capture writing to {n}. Generate traffic and wait for enough IVs.',
|
||||
'Visão geral': 'Overview',
|
||||
'Ver detalhes': 'View details',
|
||||
'Ver clientes': 'View clients',
|
||||
'Abrir ataques': 'Open attacks',
|
||||
'Todos (broadcast)': 'All (broadcast)',
|
||||
'associado': 'associated',
|
||||
'sondando': 'probing',
|
||||
'visto': 'seen',
|
||||
'Alvo': 'Target',
|
||||
'Handshake': 'Handshake',
|
||||
'Salvar pcap em': 'Save pcap to',
|
||||
'Exportar selecionado': 'Export selected',
|
||||
'Exportar todos': 'Export all',
|
||||
'Quebrar senha': 'Crack password',
|
||||
'completo': 'complete',
|
||||
'Ataques': 'Attacks',
|
||||
'Cliente alvo': 'Target client',
|
||||
'Ações': 'Actions',
|
||||
'Atacar': 'Attack',
|
||||
'Cliente não associado a este AP.': 'Client not associated with this AP.',
|
||||
'Somente o cliente selecionado será desautenticado.': 'Only the selected client will be deauthenticated.',
|
||||
'Todos os clientes associados (broadcast).': 'All associated clients (broadcast).',
|
||||
'Fabricante': 'Manufacturer',
|
||||
'Modelo': 'Model',
|
||||
'Oculta': 'Hidden',
|
||||
'Modo': 'Mode',
|
||||
'Cifra': 'Cipher',
|
||||
'Privacidade': 'Privacy',
|
||||
'WPS travado': 'WPS locked',
|
||||
'Associados': 'Associated',
|
||||
'Senha': 'Password',
|
||||
'Quadro': 'Frame',
|
||||
'Quadros': 'Frames',
|
||||
'ambas': 'both',
|
||||
'Tentativas': 'Attempts',
|
||||
'Testar PMF': 'Test PMF',
|
||||
'em execução': 'running',
|
||||
'iniciando': 'starting',
|
||||
'erro': 'error',
|
||||
'parado': 'stopped',
|
||||
'Status: {n}': 'Status: {n}',
|
||||
'tamanho inválido': 'invalid length',
|
||||
'· última: {n}': '· last: {n}',
|
||||
'Clona o AP (aberto) numa segunda interface e sobe um portal pedindo a senha de novo. A senha só é aceita se bater de verdade com o handshake já capturado — não é um "aceita qualquer coisa".':
|
||||
'Clones the AP (open) on a second interface and shows a portal asking for the password again. The password is only accepted if it truly matches the already-captured handshake — it is not an "accept anything" page.',
|
||||
'Exige um handshake quebrável já capturado deste AP (aba Handshake). Precisa de':
|
||||
'Requires a crackable handshake already captured for this AP (Handshake tab). Needs',
|
||||
'instalado e de uma': 'installed and a',
|
||||
'interface Wi-Fi em modo AP, diferente da que está capturando.':
|
||||
'Wi-Fi interface in AP mode, different from the one capturing.',
|
||||
'Iniciar Evil Twin': 'Start Evil Twin',
|
||||
'Informe a interface do clone (uma segunda placa).':
|
||||
'Enter the clone interface (a second adapter).',
|
||||
'a interface do clone precisa ser diferente da que está capturando':
|
||||
'the clone interface must be different from the one capturing',
|
||||
'esse BSSID ainda não apareceu nesta sessão de captura':
|
||||
'that BSSID has not appeared in this capture session yet',
|
||||
'rede oculta: sem SSID não dá pra clonar': 'hidden network: no SSID, cannot clone',
|
||||
'handshake sem dados suficientes pra conferir senha':
|
||||
'handshake does not have enough data to verify a password',
|
||||
'hostapd não está instalado (sudo apt install hostapd) — necessário pro Evil Twin':
|
||||
'hostapd is not installed (sudo apt install hostapd) — needed for Evil Twin',
|
||||
'{n} · {n} credencial(is) capturada(s){n}': '{n} · {n} credential(s) captured{n}',
|
||||
'Clona o AP numa segunda interface e deixa o hostapd-wpe aceitar a autenticação EAP de qualquer cliente. A credencial sai sozinha, como parte do próprio 802.1X — sem portal, sem ação humana.':
|
||||
'Clones the AP on a second interface and lets hostapd-wpe accept the EAP authentication from any client. The credential comes out on its own, as part of 802.1X itself — no portal, no human action.',
|
||||
'Rede Enterprise detectada (AKM 802.1X) — aqui a credencial é enviada automaticamente pelo cliente durante o handshake EAP, não precisa de portal. Exige':
|
||||
'Enterprise network detected (AKM 802.1X) — here the credential is sent automatically by the client during the EAP handshake, no portal needed. Requires',
|
||||
'instalado (não vem com o Sabiá: é um patch no build do hostapd —':
|
||||
'installed (not bundled with Sabiá: it is a patch on the hostapd build —',
|
||||
') e uma': ') and a',
|
||||
'interface Wi-Fi em modo AP.': 'Wi-Fi interface in AP mode.',
|
||||
'Iniciar Evil Twin Enterprise': 'Start Enterprise Evil Twin',
|
||||
'Limitação honesta: o certificado TLS do clone é autoassinado. Dispositivo que valida a cadeia de certificado da rede (comum em perfil corporativo/MDM atualizado) vai recusar ou avisar o usuário — essa é a defesa que deveria existir. Funciona contra configuração sem essa validação.':
|
||||
'Honest limitation: the clone\'s TLS certificate is self-signed. A device that validates the network\'s certificate chain (common on an updated corporate/MDM profile) will refuse or warn the user — that is the defense that is supposed to exist. It works against configurations without that validation.',
|
||||
'essa rede não é WPA2/WPA3-Enterprise (802.1X) — pra rede com senha única (PSK), use o Evil Twin com portal cativo':
|
||||
'this network is not WPA2/WPA3-Enterprise (802.1X) — for a single-password (PSK) network, use the Evil Twin with captive portal',
|
||||
'hostapd-wpe não está instalado — não vem com o Sabiá (é um patch sobre o build do hostapd, não um binário pequeno); instale pelo repositório do Kali ou compile https://github.com/aircrack-ng/hostapd-wpe':
|
||||
'hostapd-wpe is not installed — not bundled with Sabiá (it is a patch on the hostapd build, not a small binary); install it from the Kali repository or build https://github.com/aircrack-ng/hostapd-wpe',
|
||||
'{n} está num modo que o hostapd-wpe não consegue usar':
|
||||
'{n} is in a mode hostapd-wpe cannot use',
|
||||
'não foi possível gerar o certificado TLS (openssl) pro hostapd-wpe':
|
||||
'could not generate the TLS certificate (openssl) for hostapd-wpe',
|
||||
'hostapd-wpe saiu sozinho ({n}) logo ao subir em {n} — a placa pode não suportar de verdade o canal/banda do AP real. Saída: {n}':
|
||||
'hostapd-wpe exited on its own ({n}) right after starting on {n} — the adapter may not actually support the real AP\'s channel/band. Output: {n}',
|
||||
'job de Evil Twin Enterprise não encontrado': 'Enterprise Evil Twin job not found',
|
||||
'job de Evil Twin não encontrado': 'Evil Twin job not found',
|
||||
'hostapd saiu sozinho ({n}) logo ao subir em {n} — a placa pode não suportar de verdade o canal/banda do AP real. Saída: {n}':
|
||||
'hostapd exited on its own ({n}) right after starting on {n} — the adapter may not actually support the real AP\'s channel/band. Output: {n}',
|
||||
'{n} está num modo que o hostapd não consegue usar': '{n} is in a mode hostapd cannot use',
|
||||
'{n} handshake(s), {n} quadro(s) em {n}': '{n} handshake(s), {n} frame(s) in {n}',
|
||||
'Motivo': 'Reason',
|
||||
'Pacotes': 'Packets',
|
||||
'Estado': 'State',
|
||||
'Probes': 'Probes',
|
||||
'Cliente': 'Client',
|
||||
'WPS ativo': 'WPS active',
|
||||
'WPS ativo.': 'WPS active.',
|
||||
'WPS ativo ({n}).': 'WPS active ({n}).',
|
||||
'PMF exigido.': 'PMF required.',
|
||||
'Depois de coletar IVs suficientes:': 'After collecting enough IVs:',
|
||||
'mensagens {n}': 'messages {n}',
|
||||
'{n} · mensagens {n}': '{n} · messages {n}',
|
||||
'senha: {n}': 'password: {n}',
|
||||
'{n} · quebrável': '{n} · crackable',
|
||||
'{n} · canal {n}': '{n} · channel {n}',
|
||||
'{n} ({n})': '{n} ({n})',
|
||||
|
||||
// canais, PMKID/PMF, WPS e relatório
|
||||
'Ocupação dos canais em {n}': 'Channel occupancy on {n}',
|
||||
'Sem dados de canal ainda': 'No channel data yet',
|
||||
'Inicie a captura para o Sabiá medir ocupação e sugerir canais.':
|
||||
'Start capturing so Sabiá can measure occupancy and suggest channels.',
|
||||
'{n} APs · {n} canais ocupados': '{n} APs · {n} busy channels',
|
||||
'{n} AP(s) · recomendado': '{n} AP(s) · recommended',
|
||||
'· recomendado': '· recommended',
|
||||
'aberta/WEP': 'open/WEP',
|
||||
'{n} rede(s) WPA3 analisada(s) — detalhes na aba do alvo e no relatório.':
|
||||
'{n} WPA3 network(s) analyzed — details in the target tab and report.',
|
||||
'Restrito a 1, 6 e 11 para evitar sobreposição.':
|
||||
'Limited to 1, 6 and 11 to avoid overlap.',
|
||||
'Menor pontuação de interferência entre os canais observados.':
|
||||
'Lowest interference score among observed channels.',
|
||||
'Melhor canal': 'Best channel',
|
||||
'Espectro e canais': 'Spectrum and channels',
|
||||
|
||||
// saúde do espectro (canais, estilo SMART)
|
||||
'Boa': 'Good',
|
||||
'Atenção': 'Attention',
|
||||
'Saturado': 'Saturated',
|
||||
'Boa condição': 'Good condition',
|
||||
'Sem canal livre nessa banda': 'No free channel on this band',
|
||||
'Até o melhor canal está saturado': 'Even the best channel is saturated',
|
||||
'Recomendado é DFS — pode cair com radar por perto': 'The recommended channel is DFS — it can drop off if radar is detected nearby',
|
||||
'Espectro congestionado': 'Congested spectrum',
|
||||
'Canal recomendado': 'Recommended channel',
|
||||
'Canais ocupados': 'Busy channels',
|
||||
'APs na banda': 'APs on this band',
|
||||
'Pior canal': 'Worst channel',
|
||||
'Canais DFS': 'DFS channels',
|
||||
'Nessa banda, prefira sempre 1, 6 ou 11 — canais no meio sobrepõem vizinhos.':
|
||||
'On this band, always prefer 1, 6 or 11 — channels in between overlap neighbors.',
|
||||
'Canais DFS liberam mais espaço, mas o rádio é obrigado a sair do ar se detectar radar — tenha um canal não-DFS de reserva.':
|
||||
'DFS channels free up more room, but the radio must go off the air if it detects radar — keep a non-DFS channel as backup.',
|
||||
'Considere mudar o AP para o canal recomendado ou mover pra outra banda.':
|
||||
'Consider moving the AP to the recommended channel or to another band.',
|
||||
'Canais detalhados ({n})': 'Detailed channels ({n})',
|
||||
'Pontuação': 'Score',
|
||||
'APs': 'APs',
|
||||
'{n} AP(s)': '{n} AP(s)',
|
||||
'Recomendado': 'Recommended',
|
||||
'Ainda sem eventos além do primeiro avistamento.':
|
||||
'No events beyond the first sighting yet.',
|
||||
'{n} amostra(s) de sinal. Eventos mais recentes primeiro.':
|
||||
'{n} signal sample(s). Most recent events first.',
|
||||
'nenhuma ferramenta instalada': 'no tool installed',
|
||||
'Senha encontrada: {n}{n}': 'Password found: {n}{n}',
|
||||
'Sem PIN, o reaver usa Pixie Dust; o bully usa o ataque padrão.':
|
||||
'Without a PIN, reaver uses Pixie Dust; bully uses its default attack.',
|
||||
'Instale reaver ou bully para habilitar. Ex.: sudo apt install reaver bully':
|
||||
'Install reaver or bully to enable. E.g.: sudo apt install reaver bully',
|
||||
'A saída do reaver/bully aparece aqui.': 'reaver/bully output appears here.',
|
||||
'Orquestra reaver/bully na interface monitor. O canal do AP é usado automaticamente; o processo pode ser interrompido a qualquer momento.':
|
||||
'Orchestrates reaver/bully on the monitor interface. The AP channel is used automatically; the process can be stopped at any time.',
|
||||
'Ferramenta': 'Tool',
|
||||
'PIN conhecido (opcional)': 'Known PIN (optional)',
|
||||
'Exportar .hc22000': 'Export .hc22000',
|
||||
'Todos .hc22000': 'Export all .hc22000',
|
||||
'Iniciar WPS': 'Start WPS',
|
||||
'Parar job': 'Stop job',
|
||||
'8 dígitos': '8 digits',
|
||||
'nenhum cliente visto': 'no client seen',
|
||||
'Direção': 'Direction',
|
||||
'AP → cliente': 'AP → client',
|
||||
'cliente → AP': 'client → AP',
|
||||
'Dispara deauth nas duas direções e observa se a estação reassocia. Resultado prático, não prova criptográfica.':
|
||||
'Sends deauth in both directions and watches whether the station reassociates. Practical result, not cryptographic proof.',
|
||||
'Estação': 'Station',
|
||||
'Forja um MAC local e envia association request com PMKID para tentar a mensagem 1. Muitos APs só expõem PMKID em roaming real.':
|
||||
'Forge a local MAC and send an association request with PMKID to try for message 1. Many APs only expose PMKID during real roaming.',
|
||||
'Caçar PMKID': 'Hunt PMKID',
|
||||
'Do cliente para o AP (exige estação selecionada).':
|
||||
'From client to AP (requires a selected station).',
|
||||
'Nas duas direções (exige estação selecionada).':
|
||||
'In both directions (requires a selected station).',
|
||||
'{n} quadro(s) {n} em {n} para {n} (motivo {n}).':
|
||||
'{n} {n} frame(s) in {n} to {n} (reason {n}).',
|
||||
'Aguardando saída do reaver/bully…': 'Waiting for reaver/bully output…',
|
||||
'Enviando associação com PMKID…': 'Sending association with PMKID…',
|
||||
'{n} quadro(s) enviados; PMKID não apareceu. {n}':
|
||||
'{n} frame(s) sent; PMKID did not appear. {n}',
|
||||
'Nenhuma estação disponível.': 'No station available.',
|
||||
'Testando por ~4 s…': 'Testing for ~4 s…',
|
||||
'PMKID capturado: {n}': 'PMKID captured: {n}',
|
||||
'PMF não obrigatório': 'PMF not required',
|
||||
'PMF não obrigatório · {n}': 'PMF not required · {n}',
|
||||
'Inconclusivo · {n}': 'Inconclusive · {n}',
|
||||
'Instale reaver ou bully.': 'Install reaver or bully.',
|
||||
'Canal do AP desconhecido.': 'Unknown AP channel.',
|
||||
'Montando o relatório…': 'Building the report…',
|
||||
'não foi possível gerar o relatório': 'could not generate the report',
|
||||
'Pré-visualização A4 · {n} página(s).': 'A4 preview · {n} page(s).',
|
||||
'Pré-visualização A4.': 'A4 preview.',
|
||||
'Paginando o relatório…': 'Paginating the report…',
|
||||
'Não foi possível abrir a impressão; baixe o HTML e abra no navegador.':
|
||||
'Could not open printing; download the HTML and open it in the browser.',
|
||||
'não foi possível gerar o HTML': 'could not generate the HTML',
|
||||
'Relatório': 'Report',
|
||||
'Relatório de auditoria': 'Audit report',
|
||||
'Imprimir / PDF': 'Print / PDF',
|
||||
'Baixar HTML': 'Download HTML',
|
||||
'Pré-visualização paginada em A4.': 'Paginated A4 preview.',
|
||||
'Visualização': 'View',
|
||||
'Relatório paginado': 'Paginated report',
|
||||
'handshake sem SSID, PMKID ou mensagem 2 para exportar':
|
||||
'handshake without SSID, PMKID or message 2 to export',
|
||||
'nenhum handshake com material para .hc22000':
|
||||
'no handshake with material for .hc22000',
|
||||
'direção inválida: use ap, sta ou both': 'invalid direction: use ap, sta or both',
|
||||
'máscara grande demais pra calcular o total': 'mask too large to calculate the total',
|
||||
'máscara tem {n} combinações, acima do limite de {n} — restrinja a máscara (ex.: menos posições ?a/?l) antes de rodar':
|
||||
'mask has {n} combinations, above the limit of {n} — narrow the mask (e.g. fewer ?a/?l positions) before running',
|
||||
'quadro inválido: use deauth ou disassoc': 'invalid frame: use deauth or disassoc',
|
||||
'direção sta/both exige a estação alvo': 'sta/both direction requires the target station',
|
||||
'inicie a captura nesta interface antes do teste PMKID':
|
||||
'start capturing on this interface before the PMKID test',
|
||||
'BSSID ainda não visto nesta captura': 'BSSID not seen in this capture yet',
|
||||
'SSID oculto: impossível montar o association request':
|
||||
'hidden SSID: cannot build the association request',
|
||||
'PMKID observado; exporte em .hc22000 e quebre com hashcat ou pelo Sabiá.':
|
||||
'PMKID observed; export to .hc22000 and crack with hashcat or Sabiá.',
|
||||
'Nenhum PMKID apareceu. O AP pode não usar cache de PMKID ou só responder a roaming real; tente também desautenticar um cliente associado.':
|
||||
'No PMKID appeared. The AP may not cache PMKID or may only answer real roaming; also try deauthenticating an associated client.',
|
||||
'PIN WPS deve ter 8 dígitos': 'WPS PIN must have 8 digits',
|
||||
'a ferramenta terminou sem informar PSK': 'the tool finished without reporting a PSK',
|
||||
'job WPS não encontrado': 'WPS job not found',
|
||||
'Auditoria Wi-Fi · {n}': 'Wi-Fi audit · {n}',
|
||||
'{n} está em modo managed; ative o modo monitor antes do teste PMKID':
|
||||
'{n} is in managed mode; enable monitor mode before the PMKID test',
|
||||
'{n} não encontrado no PATH; instale reaver ou bully':
|
||||
'{n} not found in PATH; install reaver or bully',
|
||||
'{n} está em modo managed; ative o modo monitor antes do ataque WPS':
|
||||
'{n} is in managed mode; enable monitor mode before the WPS attack',
|
||||
'não foi possível executar {n}: {n}': 'could not run {n}: {n}',
|
||||
'{n} terminou com código {n}': '{n} exited with code {n}',
|
||||
|
||||
// achados do WIDS (analysis.rs)
|
||||
'SSID “{n}” com seguranças diferentes': 'SSID “{n}” with different security levels',
|
||||
'O mesmo nome aparece em BSSIDs com níveis de segurança distintos: {n}. Pode ser um evil twin ou um AP mal configurado.':
|
||||
'The same name appears on BSSIDs with different security levels: {n}. It may be an evil twin or a misconfigured AP.',
|
||||
'Rajada de desautenticação': 'Deauthentication burst',
|
||||
'{n} quadros de desautenticação/desassociação no último minuto. Pode ser ataque de desautenticação ou um AP instável.':
|
||||
'{n} deauthentication/disassociation frames in the last minute. It may be a deauth attack or an unstable AP.',
|
||||
'Muitos BSSIDs novos': 'Many new BSSIDs',
|
||||
'{n} BSSIDs apareceram no último minuto — possível beacon flood ou ambiente muito denso.':
|
||||
'{n} BSSIDs appeared in the last minute — possible beacon flood or a very dense environment.',
|
||||
'Rede aberta: {n}': 'Open network: {n}',
|
||||
'Sem cifra no Wi-Fi; o tráfego fica exposto a qualquer estação no alcance.':
|
||||
'No Wi-Fi encryption; traffic is exposed to any station in range.',
|
||||
'WEP em {n}': 'WEP on {n}',
|
||||
'WEP é quebrável em minutos; trate a rede como aberta.':
|
||||
'WEP can be cracked in minutes; treat the network as open.',
|
||||
'WPA1 (TKIP) em {n}': 'WPA1 (TKIP) on {n}',
|
||||
'WPA1/TKIP tem fraquezas conhecidas; migre para WPA2/CCMP ou WPA3.':
|
||||
'WPA1/TKIP has known weaknesses; migrate to WPA2/CCMP or WPA3.',
|
||||
'WPS ativo em {n}': 'WPS active on {n}',
|
||||
'O AP anuncia WPS com configuração travada.': 'The AP advertises WPS with configuration locked.',
|
||||
'WPS sem lock explícito; PIN pode ser testado com reaver/bully.':
|
||||
'WPS without an explicit lock; the PIN can be tested with reaver/bully.',
|
||||
'Sem PMF em {n}': 'No PMF on {n}',
|
||||
'Desautenticação e desassociação não são protegidas; clientes podem ser expulsos para facilitar a captura de handshake.':
|
||||
'Deauthentication and disassociation are unprotected; clients can be expelled to make handshake capture easier.',
|
||||
'PMF inconsistente em {n}': 'Inconsistent PMF on {n}',
|
||||
'O AP anuncia WPA3, mas não anuncia PMF; verifique a configuração.':
|
||||
'The AP advertises WPA3 but not PMF; check the configuration.',
|
||||
'WPA3 com WPS em {n}': 'WPA3 with WPS on {n}',
|
||||
'WPS não faz parte do WPA3 e abre uma via mais fraca de associação.':
|
||||
'WPS is not part of WPA3 and opens a weaker association path.',
|
||||
'primeiro visto': 'first seen',
|
||||
'associou': 'associated',
|
||||
'desautenticado': 'deauthenticated',
|
||||
|
||||
'A estação voltou a se associar.': 'The station reassociated.',
|
||||
'O AP pode não usar cache de PMKID.': 'The AP may not cache PMKID.',
|
||||
|
||||
'A estação voltou a se associar após a desautenticação: PMF não está obrigatório (ou o cliente não o usa).':
|
||||
'The station reassociated after deauthentication: PMF is not required (or the client does not use it).',
|
||||
'A estação continuou trocando quadros depois da desautenticação; o quadro de gerenciamento provavelmente foi aceito.':
|
||||
'The station kept exchanging frames after deauthentication; the management frame was probably accepted.',
|
||||
'A estação não voltou a associar na janela observada. PMF pode estar ativo, ou o cliente simplesmente ficou inativo.':
|
||||
'The station did not reassociate in the observed window. PMF may be active, or the client simply stayed idle.',
|
||||
'Inconclusivo': 'Inconclusive',
|
||||
|
||||
// deauth contínuo e identificação de clientes
|
||||
'Mantém os quadros saindo em série até você parar (ou até sair o handshake). O canal da captura fica travado no AP durante o job.':
|
||||
'Keeps frames going until you stop (or a handshake appears). The capture channel stays locked to the AP during the job.',
|
||||
'Iniciar contínuo': 'Start continuous',
|
||||
'Parar': 'Stop',
|
||||
'Rajada ({n})': 'Burst ({n})',
|
||||
'Pronto. O job contínuo trava a captura no canal do AP e para quando você mandar.':
|
||||
'Ready. The continuous job locks capture to the AP channel and stops when you say so.',
|
||||
'Job {n} · {n} quadro(s){n}{n}': 'Job {n} · {n} frame(s){n}{n}',
|
||||
'Handshake capturado em {n} quadro(s){n}.': 'Handshake captured in {n} frame(s){n}.',
|
||||
'Job {n} {n} · {n} quadro(s){n}.': 'Job {n} {n} · {n} frame(s){n}.',
|
||||
'Escolha um cliente para desautenticar nas direções sta/both.':
|
||||
'Choose a client to deauthenticate in the sta/both directions.',
|
||||
'Forçando reassociação e handshake…': 'Forcing reassociation and handshake…',
|
||||
'Desautenticação contínua iniciada.': 'Continuous deauthentication started.',
|
||||
'Nenhuma ferramenta WPS disponível.': 'No WPS tool available.',
|
||||
'O reaver embutido usa Pixie Dust sem PIN. O bully aparece se estiver instalado no sistema.':
|
||||
'The bundled reaver uses Pixie Dust without a PIN. bully appears if it is installed on the system.',
|
||||
'O reaver embutido não pôde ser extraído nesta execução.':
|
||||
'The bundled reaver could not be extracted in this run.',
|
||||
'Dicionário (vazio = embutido, 9,7 milhões de senhas)': 'Wordlist (empty = bundled, 9.7 million passwords)',
|
||||
'dicionário próprio, opcional': 'custom wordlist, optional',
|
||||
'Dispositivo': 'Device',
|
||||
'MAC aleatório': 'Random MAC',
|
||||
'Cliente {n}': 'Client {n}',
|
||||
'Linha do tempo · {n}': 'Timeline · {n}',
|
||||
'nenhuma ferramenta disponível': 'no tool available',
|
||||
'· canal {n}': '· channel {n}',
|
||||
'· para sozinho no handshake': '· stops on handshake',
|
||||
'job de deauth não encontrado': 'deauth job not found',
|
||||
'já existe um job de desautenticação em execução em {n}':
|
||||
'a deauthentication job is already running on {n}',
|
||||
'inicie a captura em {n} antes de iniciar o job':
|
||||
'start capture on {n} before starting the job',
|
||||
'a captura em {n} não está ativa': 'capture on {n} is not active',
|
||||
'{n} não está disponível; o reaver integrado não pôde ser extraído':
|
||||
'{n} is not available; the bundled reaver could not be extracted',
|
||||
};
|
||||
@@ -4,31 +4,53 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sabiá</title>
|
||||
<link rel="icon" href="/sabia.png">
|
||||
<link rel="icon" href="/logo.png">
|
||||
<link rel="stylesheet" href="/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<img class="logo" src="/sabia.png" alt="">
|
||||
<img class="logo" src="/logo.png" alt="">
|
||||
<div>
|
||||
<strong>Sabiá</strong>
|
||||
<small>auditoria de Wi-Fi</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="side-title">Interfaces</div>
|
||||
<ul class="list" id="ifaceList" data-empty="nenhuma interface Wi-Fi encontrada"></ul>
|
||||
<div class="side-title">Interface</div>
|
||||
<div class="iface-pick">
|
||||
<select id="ifaceSelect" class="select" aria-label="Interface Wi-Fi" data-empty="nenhuma interface Wi-Fi encontrada"></select>
|
||||
<span id="modePill" class="pill"><span class="dot"></span><span id="modeLabel">—</span></span>
|
||||
</div>
|
||||
|
||||
<div class="side-title">Visualização</div>
|
||||
<ul class="list" id="viewNav" role="group" aria-label="Visualização">
|
||||
<li><button type="button" class="dev" data-view="nets" aria-pressed="true">
|
||||
<span class="ico"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1"/></svg></span>
|
||||
<span><span class="t">Redes</span><span class="s">APs, clientes e ataques</span></span>
|
||||
</button></li>
|
||||
<li><button type="button" class="dev" data-view="channels" aria-pressed="false">
|
||||
<span class="ico"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="19" x2="5" y2="10"/><line x1="12" y1="19" x2="12" y2="5"/><line x1="19" y1="19" x2="19" y2="13"/></svg></span>
|
||||
<span><span class="t">Canais</span><span class="s">espectro e saúde</span></span>
|
||||
</button></li>
|
||||
</ul>
|
||||
|
||||
<div class="side-foot">
|
||||
<span id="verInfo" class="mono">—</span>
|
||||
<select id="langSel" class="lang-sel" aria-label="Idioma" title="Idioma">
|
||||
<option value="auto">Auto</option>
|
||||
<option value="pt">Português</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
<button id="btnUpdate" type="button">checar atualização</button>
|
||||
<div class="foot-row">
|
||||
<button id="settingsBtn" class="icon-btn" type="button" title="Configurações" aria-label="Configurações">
|
||||
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg>
|
||||
</button>
|
||||
<div class="theme-switch" role="group" aria-label="Tema">
|
||||
<button type="button" data-theme-pick="auto" aria-pressed="true">Auto</button>
|
||||
<button type="button" data-theme-pick="light" aria-pressed="false">Claro</button>
|
||||
<button type="button" data-theme-pick="dark" aria-pressed="false">Escuro</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="foot-row">
|
||||
<span id="verInfo" class="mono">—</span>
|
||||
<button id="btnUpdate" type="button">checar atualização</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -39,7 +61,9 @@
|
||||
<h1>Painel de captura</h1>
|
||||
<div class="sub" id="ifaceSub">selecione uma interface</div>
|
||||
</div>
|
||||
<span id="modePill" class="pill"><span class="dot"></span><span id="modeLabel">—</span></span>
|
||||
<div class="top-actions">
|
||||
<button id="btnReport" class="btn" type="button" disabled>Relatório</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<svg class="wave" id="wave" viewBox="0 0 600 34" preserveAspectRatio="none">
|
||||
@@ -68,14 +92,79 @@
|
||||
</div>
|
||||
|
||||
<div class="result-head">
|
||||
<strong>Redes vistas</strong>
|
||||
<span id="count">0</span>
|
||||
<div class="result-summary">
|
||||
<strong id="resultTitle">Redes vistas</strong>
|
||||
<span id="count">0</span>
|
||||
</div>
|
||||
<div class="result-tools">
|
||||
<input id="searchInput" class="filter" type="search" placeholder="filtrar por SSID ou BSSID" aria-label="Filtrar redes" autocomplete="off" spellcheck="false">
|
||||
<select id="sortSel" class="select compact" aria-label="Ordenar por">
|
||||
<option value="signal">Sinal</option>
|
||||
<option value="security">Segurança</option>
|
||||
<option value="clients">Clientes</option>
|
||||
<option value="name">Nome</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="resultArea"></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<dialog id="targetDlg" class="target-dlg" aria-labelledby="targetTitle">
|
||||
<div class="target-head">
|
||||
<div class="target-id">
|
||||
<h2 id="targetTitle">—</h2>
|
||||
<div class="target-sub" id="targetSub"></div>
|
||||
</div>
|
||||
<button id="targetClose" class="icon-btn" type="button" aria-label="Fechar" title="Fechar">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="tabs" role="tablist" aria-label="Detalhes do alvo">
|
||||
<button type="button" role="tab" data-tab="overview" aria-selected="true">Visão geral</button>
|
||||
<button type="button" role="tab" data-tab="clients" aria-selected="false">Clientes <span class="tab-count" id="tabClientsCount">0</span></button>
|
||||
<button type="button" role="tab" data-tab="attacks" aria-selected="false">Ataques</button>
|
||||
</div>
|
||||
<div class="tab-panel" id="tabPanel"></div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="reportDlg" class="report-dlg" aria-labelledby="reportTitle">
|
||||
<div class="report-head">
|
||||
<div>
|
||||
<h2 id="reportTitle">Relatório de auditoria</h2>
|
||||
<p class="hint" id="reportStatus">Pré-visualização paginada em A4.</p>
|
||||
</div>
|
||||
<div class="report-tools">
|
||||
<button type="button" class="icon-btn" id="reportZoomOut" title="Diminuir zoom" aria-label="Diminuir zoom">−</button>
|
||||
<span class="mono" id="reportZoomLabel">100%</span>
|
||||
<button type="button" class="icon-btn" id="reportZoomIn" title="Aumentar zoom" aria-label="Aumentar zoom">+</button>
|
||||
<button type="button" class="btn" id="reportPrint">Imprimir / PDF</button>
|
||||
<button type="button" class="btn" id="reportDownload">Baixar HTML</button>
|
||||
<button type="button" class="icon-btn" id="reportClose" title="Fechar" aria-label="Fechar">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="report-view" id="reportView"><div class="report-canvas" id="reportCanvas"><iframe id="reportFrame" title="Relatório paginado"></iframe></div></div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="settingsDlg" aria-labelledby="settingsTitle">
|
||||
<form method="dialog">
|
||||
<h2 id="settingsTitle">Configurações</h2>
|
||||
<div class="field">
|
||||
<span class="field-label">Idioma</span>
|
||||
<select id="langSel" class="select" name="lang" aria-label="Idioma">
|
||||
<option value="auto">Auto</option>
|
||||
<option value="pt">Português</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
<menu><button class="btn primary" value="close">Fechar</button></menu>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script type="module" src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017-2024 Paged.js contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,10 @@
|
||||
# Paged.js
|
||||
|
||||
`paged.polyfill.min.js` é o polyfill do [Paged.js](https://pagedjs.org/), versão
|
||||
0.4.3, usado para paginar os relatórios em A4 no navegador.
|
||||
|
||||
- Projeto: https://gitlab.coko.foundation/pagedjs/pagedjs
|
||||
- Licença: MIT (texto em `PAGEDJS-LICENSE`)
|
||||
- Arquivo: `paged.polyfill.min.js` (build de distribuição, não modificado)
|
||||
|
||||
O arquivo é vendorizado para que o relatório funcione offline, sem CDN.
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* @license Paged.js v0.4.3 | MIT | https://gitlab.coko.foundation/pagedjs/pagedjs
|
||||
*/
|
||||
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).PagedPolyfill=t()}(this,(function(){"use strict";function e(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var t,n,r={exports:{}},a={exports:{}};var i,o,s,l,d=function(){},p=d(),c=function(e){return e!==p&&null!==e};function u(){return l||(l=1,s=(n?t:(n=1,t=function(){try{return Object.keys("primitive"),!0}catch(e){return!1}}))()?Object.keys:function(){if(o)return i;o=1;var e=c,t=Object.keys;return i=function(n){return t(e(n)?Object(n):n)}}()),s}var m,h,g=c,f=function(e){if(!g(e))throw new TypeError("Cannot use null or undefined");return e};var y,b,S=function(){var e,t=Object.assign;return"function"==typeof t&&(t(e={foo:"raz"},{bar:"dwa"},{trzy:"trzy"}),e.foo+e.bar+e.trzy==="razdwatrzy")}()?Object.assign:function(){if(h)return m;h=1;var e=u(),t=f,n=Math.max;return m=function(r,a){var i,o,s,l=n(arguments.length,2);for(r=Object(t(r)),s=function(e){try{r[e]=a[e]}catch(e){i||(i=e)}},o=1;o<l;++o)e(a=arguments[o]).forEach(s);if(void 0!==i)throw i;return r},m}(),v=c,x=Array.prototype.forEach,k=Object.create,w="razdwatrzy";var C,T="function"==typeof w.contains&&!0===w.contains("dwa")&&!1===w.contains("foo")?String.prototype.contains:function(){if(b)return y;b=1;var e=String.prototype.indexOf;return y=function(t){return e.call(this,t,arguments[1])>-1},y}(),_=S,z=function(e){var t=k(null);return x.call(arguments,(function(e){v(e)&&function(e,t){var n;for(n in e)t[n]=e[n]}(Object(e),t)})),t},O=function(e){return"function"==typeof e},A=T;C=a.exports=function(e,t){var n,r,a,i,o;return arguments.length<2||"string"!=typeof e?(i=t,t=e,e=null):i=arguments[2],null==e?(n=a=!0,r=!1):(n=A.call(e,"c"),r=A.call(e,"e"),a=A.call(e,"w")),o={value:t,configurable:n,enumerable:r,writable:a},i?_(z(i),o):o},C.gs=function(e,t,n){var r,a,i,o;return"string"!=typeof e?(i=n,n=t,t=e,e=null):i=arguments[3],null==t?t=void 0:O(t)?null==n?n=void 0:O(n)||(i=n,n=void 0):(i=t,t=n=void 0),null==e?(r=!0,a=!1):(r=A.call(e,"c"),a=A.call(e,"e")),o={get:t,set:n,configurable:r,enumerable:a},i?_(z(i),o):o};var E=a.exports,j=function(e){if("function"!=typeof e)throw new TypeError(e+" is not a function");return e};!function(e,t){var n,r,a,i,o,s,l,d=E,p=j,c=Function.prototype.apply,u=Function.prototype.call,m=Object.create,h=Object.defineProperty,g=Object.defineProperties,f=Object.prototype.hasOwnProperty,y={configurable:!0,enumerable:!1,writable:!0};n=function(e,t){var n;return p(t),f.call(this,"__ee__")?n=this.__ee__:(n=y.value=m(null),h(this,"__ee__",y),y.value=null),n[e]?"object"==typeof n[e]?n[e].push(t):n[e]=[n[e],t]:n[e]=t,this},r=function(e,t){var r,i;return p(t),i=this,n.call(this,e,r=function(){a.call(i,e,r),c.call(t,this,arguments)}),r.__eeOnceListener__=t,this},a=function(e,t){var n,r,a,i;if(p(t),!f.call(this,"__ee__"))return this;if(!(n=this.__ee__)[e])return this;if("object"==typeof(r=n[e]))for(i=0;a=r[i];++i)a!==t&&a.__eeOnceListener__!==t||(2===r.length?n[e]=r[i?0:1]:r.splice(i,1));else r!==t&&r.__eeOnceListener__!==t||delete n[e];return this},i=function(e){var t,n,r,a,i;if(f.call(this,"__ee__")&&(a=this.__ee__[e]))if("object"==typeof a){for(n=arguments.length,i=new Array(n-1),t=1;t<n;++t)i[t-1]=arguments[t];for(a=a.slice(),t=0;r=a[t];++t)c.call(r,this,i)}else switch(arguments.length){case 1:u.call(a,this);break;case 2:u.call(a,this,arguments[1]);break;case 3:u.call(a,this,arguments[1],arguments[2]);break;default:for(n=arguments.length,i=new Array(n-1),t=1;t<n;++t)i[t-1]=arguments[t];c.call(a,this,i)}},o={on:n,once:r,off:a,emit:i},s={on:d(n),once:d(r),off:d(a),emit:d(i)},l=g({},s),e.exports=t=function(e){return null==e?m(l):g(Object(e),s)},t.methods=o}(r,r.exports);var W=r.exports,L=e(W);class B{constructor(e){this.context=e||this,this.hooks=[]}register(){for(var e=0;e<arguments.length;++e)if("function"==typeof arguments[e])this.hooks.push(arguments[e]);else for(var t=0;t<arguments[e].length;++t)this.hooks.push(arguments[e][t])}trigger(){var e=arguments,t=this.context,n=[];return this.hooks.forEach((function(r){var a=r.apply(t,e);a&&"function"==typeof a.then?n.push(a):n.push(new Promise(((e,t)=>{e(a)})))})),Promise.all(n)}triggerSync(){var e=arguments,t=this.context,n=[];return this.hooks.forEach((function(r){var a=r.apply(t,e);n.push(a)})),n}list(){return this.hooks}clear(){return this.hooks=[]}}function P(e){if(!e)return;let t;if(void 0!==e.getBoundingClientRect)t=e.getBoundingClientRect();else{let n=document.createRange();n.selectNode(e),t=n.getBoundingClientRect()}return t}function q(e){if(!e)return;let t;if(void 0!==e.getClientRects)t=e.getClientRects();else{let n=document.createRange();n.selectNode(e),t=n.getClientRects()}return t}function M(){var e=(new Date).getTime();return"undefined"!=typeof performance&&"function"==typeof performance.now&&(e+=performance.now()),"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replaceLine truncated
|
||||
@@ -72,6 +72,10 @@ enum Cmd {
|
||||
bssid: Option<String>,
|
||||
#[arg(long)]
|
||||
station: Option<String>,
|
||||
/// Aplica mutações comuns a cada palavra (maiúscula, sufixos
|
||||
/// numéricos, leet simples) — multiplica o tempo de verdade.
|
||||
#[arg(long)]
|
||||
rules: bool,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
@@ -275,6 +279,7 @@ fn run(cli: Cli) -> sabia_core::Result<()> {
|
||||
wordlist,
|
||||
bssid,
|
||||
station,
|
||||
rules,
|
||||
json,
|
||||
} => {
|
||||
let mut last = 0u64;
|
||||
@@ -283,6 +288,7 @@ fn run(cli: Cli) -> sabia_core::Result<()> {
|
||||
&wordlist,
|
||||
bssid.as_deref(),
|
||||
station.as_deref(),
|
||||
rules,
|
||||
|tried| {
|
||||
if tried.saturating_sub(last) >= 4096 {
|
||||
eprint!("\r{tried} senhas testadas…");
|
||||
@@ -412,7 +418,7 @@ fn crack_first(engine: &Engine, iface: &str, wordlist: &PathBuf) -> sabia_core::
|
||||
hs.bssid,
|
||||
wordlist.display()
|
||||
);
|
||||
let id = engine.start_crack(iface, Some(&hs.bssid), Some(&hs.station), wordlist)?;
|
||||
let id = engine.start_crack(iface, Some(&hs.bssid), Some(&hs.station), wordlist, false)?;
|
||||
loop {
|
||||
let job = engine.crack_job(&id).expect("job");
|
||||
if job.status == "running" {
|
||||
|
||||
@@ -15,13 +15,29 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
const INDEX_HTML: &str = include_str!("../../../../apps/gui/ui/index.html");
|
||||
const APP_CSS: &str = include_str!("../../../../apps/gui/ui/app.css");
|
||||
const APP_JS: &str = include_str!("../../../../apps/gui/ui/app.js");
|
||||
const LOGO_PNG: &[u8] = include_bytes!("../../../../apps/gui/ui/sabia.png");
|
||||
const I18N_JS: &str = include_str!("../../../../apps/gui/ui/i18n.js");
|
||||
const I18N_EN_JS: &str = include_str!("../../../../apps/gui/ui/i18n-en.js");
|
||||
const LOGO_PNG: &[u8] = include_bytes!("../../../../logo.png");
|
||||
const PAGED_JS: &str = include_str!("../../../../apps/gui/ui/vendor/paged.polyfill.min.js");
|
||||
|
||||
pub fn serve(port: u16, audit_path: std::path::PathBuf, open: bool) -> sabia_core::Result<()> {
|
||||
let token = gen_token();
|
||||
let listener = TcpListener::bind(("127.0.0.1", port))?;
|
||||
let engine = Arc::new(Engine::with_audit_path(audit_path));
|
||||
|
||||
// Sem isso, Ctrl+C (ou um `kill` normal) durante um Evil Twin ativo
|
||||
// mata o processo sem rodar a limpeza: hostapd/dnsmasq ficam órfãos
|
||||
// e as duas interfaces ficam presas em `managed no` pro
|
||||
// NetworkManager — o painel de Wi-Fi do sistema some por completo até
|
||||
// o próximo início do engine se auto-curar.
|
||||
{
|
||||
let engine = engine.clone();
|
||||
let _ = ctrlc::set_handler(move || {
|
||||
engine.shutdown_cleanup();
|
||||
std::process::exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
println!("Sabiá UI em http://127.0.0.1:{port}/?token={token}");
|
||||
if open {
|
||||
let _ = open_browser(&format!("http://127.0.0.1:{port}/?token={token}"));
|
||||
@@ -225,7 +241,48 @@ fn handle(
|
||||
);
|
||||
}
|
||||
|
||||
if req.path == "/api/report.html" {
|
||||
if req.token.as_deref() != Some(expected_token) {
|
||||
return respond(&mut stream, 401, "text/plain", b"token invalido");
|
||||
}
|
||||
let Some(iface) = req.text("iface") else {
|
||||
return respond(&mut stream, 400, "text/plain", b"iface obrigatoria");
|
||||
};
|
||||
return match engine.report(&iface) {
|
||||
Ok(model) => {
|
||||
let download = req.bool("download");
|
||||
let mut html = sabia_core::report::render(
|
||||
&model,
|
||||
sabia_core::report::ReportOptions {
|
||||
inline_pagedjs: download,
|
||||
},
|
||||
);
|
||||
if download {
|
||||
html = html.replace(
|
||||
"<!--SABIA_PAGEDJS-->",
|
||||
&format!("<script>{PAGED_JS}</script>"),
|
||||
);
|
||||
}
|
||||
respond(
|
||||
&mut stream,
|
||||
200,
|
||||
"text/html; charset=utf-8",
|
||||
html.as_bytes(),
|
||||
)
|
||||
}
|
||||
Err(e) => respond(&mut stream, 400, "text/plain", e.to_string().as_bytes()),
|
||||
};
|
||||
}
|
||||
|
||||
match req.path.as_str() {
|
||||
"/vendor/paged.polyfill.min.js" | "/paged.polyfill.min.js" => {
|
||||
return respond(
|
||||
&mut stream,
|
||||
200,
|
||||
"application/javascript",
|
||||
PAGED_JS.as_bytes(),
|
||||
)
|
||||
}
|
||||
"/app.css" => return respond(&mut stream, 200, "text/css", APP_CSS.as_bytes()),
|
||||
"/app.js" => {
|
||||
return respond(
|
||||
@@ -235,7 +292,23 @@ fn handle(
|
||||
APP_JS.as_bytes(),
|
||||
)
|
||||
}
|
||||
"/sabia.png" => return respond(&mut stream, 200, "image/png", LOGO_PNG),
|
||||
"/i18n.js" => {
|
||||
return respond(
|
||||
&mut stream,
|
||||
200,
|
||||
"application/javascript",
|
||||
I18N_JS.as_bytes(),
|
||||
)
|
||||
}
|
||||
"/i18n-en.js" => {
|
||||
return respond(
|
||||
&mut stream,
|
||||
200,
|
||||
"application/javascript",
|
||||
I18N_EN_JS.as_bytes(),
|
||||
)
|
||||
}
|
||||
"/logo.png" => return respond(&mut stream, 200, "image/png", LOGO_PNG),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -408,13 +481,210 @@ fn api(engine: &Arc<Engine>, req: &Request) -> (u16, String) {
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/handshakes/export-hc22000") => {
|
||||
let (Some(iface), Some(bssid), Some(station), Some(out)) = (
|
||||
req.text("iface"),
|
||||
req.text("bssid"),
|
||||
req.text("station"),
|
||||
req.text("out"),
|
||||
) else {
|
||||
return err(400, "iface, bssid, station e out são obrigatórios");
|
||||
};
|
||||
match engine.export_hc22000(&iface, &bssid, &station, &out) {
|
||||
Ok(report) => ok(&report),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/handshakes/export-all-hc22000") => {
|
||||
let (Some(iface), Some(out)) = (req.text("iface"), req.text("out")) else {
|
||||
return err(400, "iface e out são obrigatórios");
|
||||
};
|
||||
match engine.export_all_hc22000(&iface, &out) {
|
||||
Ok(report) => ok(&report),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("GET", "/api/report") => {
|
||||
let Some(iface) = req.text("iface") else {
|
||||
return err(400, "iface obrigatória");
|
||||
};
|
||||
match engine.report(&iface) {
|
||||
Ok(model) => ok(&model),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("GET", "/api/wps/tools") => ok(&engine.wps_tools()),
|
||||
("POST", "/api/wps") => {
|
||||
if !req.bool("confirm") {
|
||||
return err(400, "confirme a autorização do teste (confirm=true)");
|
||||
}
|
||||
let (Some(iface), Some(bssid)) = (req.text("iface"), req.text("bssid")) else {
|
||||
return err(400, "iface e bssid são obrigatórios");
|
||||
};
|
||||
let tool = req.text("tool").unwrap_or_else(|| "reaver".into());
|
||||
let channel = req.u32("channel").unwrap_or(0);
|
||||
let pin = req.text("pin");
|
||||
match engine.start_wps(&iface, &bssid, channel, &tool, pin.as_deref()) {
|
||||
Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("GET", "/api/wps") => {
|
||||
let Some(id) = req.text("id") else {
|
||||
return err(400, "id obrigatório");
|
||||
};
|
||||
match engine.wps_job(&id) {
|
||||
Some(job) => ok(&job),
|
||||
None => err(404, "job não encontrado"),
|
||||
}
|
||||
}
|
||||
("GET", "/api/wps/jobs") => ok(&engine.wps_jobs()),
|
||||
("POST", "/api/wps/stop") => {
|
||||
let Some(id) = req.text("id") else {
|
||||
return err(400, "id obrigatório");
|
||||
};
|
||||
match engine.stop_wps(&id) {
|
||||
Ok(()) => ok_json(serde_json::json!({ "ok": true })),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/eviltwin") => {
|
||||
if !req.bool("confirm") {
|
||||
return err(400, "confirme a autorização do teste (confirm=true)");
|
||||
}
|
||||
let (Some(iface), Some(ap_iface), Some(bssid)) =
|
||||
(req.text("iface"), req.text("ap_iface"), req.text("bssid"))
|
||||
else {
|
||||
return err(400, "iface, ap_iface e bssid são obrigatórios");
|
||||
};
|
||||
let portal_port = req.u16("portal_port").unwrap_or(8080);
|
||||
let deauth = req.bool("deauth");
|
||||
match engine.start_evil_twin(&iface, &ap_iface, &bssid, portal_port, deauth) {
|
||||
Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("GET", "/api/eviltwin") => {
|
||||
let Some(id) = req.text("id") else {
|
||||
return err(400, "id obrigatório");
|
||||
};
|
||||
match engine.eviltwin_job(&id) {
|
||||
Some(job) => ok(&job),
|
||||
None => err(404, "job não encontrado"),
|
||||
}
|
||||
}
|
||||
("GET", "/api/eviltwins") => ok(&engine.eviltwin_jobs()),
|
||||
("POST", "/api/eviltwin/stop") => {
|
||||
let Some(id) = req.text("id") else {
|
||||
return err(400, "id obrigatório");
|
||||
};
|
||||
match engine.stop_evil_twin(&id) {
|
||||
Ok(()) => ok_json(serde_json::json!({ "ok": true })),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/eviltwin-enterprise") => {
|
||||
if !req.bool("confirm") {
|
||||
return err(400, "confirme a autorização do teste (confirm=true)");
|
||||
}
|
||||
let (Some(iface), Some(ap_iface), Some(bssid)) =
|
||||
(req.text("iface"), req.text("ap_iface"), req.text("bssid"))
|
||||
else {
|
||||
return err(400, "iface, ap_iface e bssid são obrigatórios");
|
||||
};
|
||||
match engine.start_evil_twin_enterprise(&iface, &ap_iface, &bssid) {
|
||||
Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("GET", "/api/eviltwin-enterprise") => {
|
||||
let Some(id) = req.text("id") else {
|
||||
return err(400, "id obrigatório");
|
||||
};
|
||||
match engine.enterprise_twin_job(&id) {
|
||||
Some(job) => ok(&job),
|
||||
None => err(404, "job não encontrado"),
|
||||
}
|
||||
}
|
||||
("GET", "/api/eviltwins-enterprise") => ok(&engine.enterprise_twin_jobs()),
|
||||
("POST", "/api/eviltwin-enterprise/stop") => {
|
||||
let Some(id) = req.text("id") else {
|
||||
return err(400, "id obrigatório");
|
||||
};
|
||||
match engine.stop_evil_twin_enterprise(&id) {
|
||||
Ok(()) => ok_json(serde_json::json!({ "ok": true })),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/pmkid") => {
|
||||
if !req.bool("confirm") {
|
||||
return err(400, "confirme a autorização do teste (confirm=true)");
|
||||
}
|
||||
let (Some(iface), Some(bssid)) = (req.text("iface"), req.text("bssid")) else {
|
||||
return err(400, "iface e bssid são obrigatórios");
|
||||
};
|
||||
let attempts = req.u32("attempts").unwrap_or(5);
|
||||
match engine.pmkid_hunt(&iface, &bssid, attempts) {
|
||||
Ok(report) => ok(&report),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/pmf") => {
|
||||
if !req.bool("confirm") {
|
||||
return err(400, "confirme a autorização do teste (confirm=true)");
|
||||
}
|
||||
let (Some(iface), Some(bssid), Some(station)) =
|
||||
(req.text("iface"), req.text("bssid"), req.text("station"))
|
||||
else {
|
||||
return err(400, "iface, bssid e station são obrigatórios");
|
||||
};
|
||||
let reason = req.u16("reason").unwrap_or(7);
|
||||
match engine.pmf_test(&iface, &bssid, &station, reason) {
|
||||
Ok(report) => ok(&report),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/crack") => {
|
||||
let (Some(iface), Some(wordlist)) = (req.text("iface"), req.text("wordlist")) else {
|
||||
return err(400, "iface e wordlist são obrigatórios");
|
||||
let Some(iface) = req.text("iface") else {
|
||||
return err(400, "iface é obrigatória");
|
||||
};
|
||||
let bssid = req.text("bssid");
|
||||
let station = req.text("station");
|
||||
match engine.start_crack(&iface, bssid.as_deref(), station.as_deref(), &wordlist) {
|
||||
let rules = req.bool("rules");
|
||||
let result = match req.text("wordlist") {
|
||||
Some(wordlist) if !wordlist.trim().is_empty() => engine.start_crack(
|
||||
&iface,
|
||||
bssid.as_deref(),
|
||||
station.as_deref(),
|
||||
&wordlist,
|
||||
rules,
|
||||
),
|
||||
_ => {
|
||||
engine.start_crack_default(&iface, bssid.as_deref(), station.as_deref(), rules)
|
||||
}
|
||||
};
|
||||
match result {
|
||||
Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/crack/mask") => {
|
||||
let Some(iface) = req.text("iface") else {
|
||||
return err(400, "iface é obrigatória");
|
||||
};
|
||||
let Some(mask) = req.text("mask").filter(|m| !m.trim().is_empty()) else {
|
||||
return err(400, "máscara é obrigatória");
|
||||
};
|
||||
let bssid = req.text("bssid");
|
||||
let station = req.text("station");
|
||||
let max_candidates = req.u64("max_candidates").unwrap_or(50_000_000);
|
||||
match engine.start_crack_mask(
|
||||
&iface,
|
||||
bssid.as_deref(),
|
||||
station.as_deref(),
|
||||
&mask,
|
||||
max_candidates,
|
||||
) {
|
||||
Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
@@ -441,17 +711,94 @@ fn api(engine: &Arc<Engine>, req: &Request) -> (u16, String) {
|
||||
};
|
||||
let count = req.u32("count").unwrap_or(5);
|
||||
let reason = req.u16("reason").unwrap_or(7);
|
||||
match engine.deauth(
|
||||
let direction = req.text("direction").unwrap_or_else(|| "ap".into());
|
||||
let frame_kind = req.text("frame").unwrap_or_else(|| "deauth".into());
|
||||
match engine.deauth_directional(
|
||||
&iface,
|
||||
&bssid,
|
||||
req.text("station").as_deref(),
|
||||
count,
|
||||
reason,
|
||||
&direction,
|
||||
&frame_kind,
|
||||
) {
|
||||
Ok(report) => ok(&report),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/deauth/start") => {
|
||||
if !req.bool("confirm") {
|
||||
return err(
|
||||
400,
|
||||
"confirme a autorização do teste (confirm=true) antes de desautenticar",
|
||||
);
|
||||
}
|
||||
let (Some(iface), Some(bssid)) = (req.text("iface"), req.text("bssid")) else {
|
||||
return err(400, "iface e bssid são obrigatórios");
|
||||
};
|
||||
let reason = req.u16("reason").unwrap_or(7);
|
||||
let direction = req.text("direction").unwrap_or_else(|| "both".into());
|
||||
let frame_kind = req.text("frame").unwrap_or_else(|| "deauth".into());
|
||||
let interval_ms = req.u64("interval_ms").unwrap_or(120);
|
||||
let max_secs = req.u64("max_secs");
|
||||
let stop_on_handshake = req.bool("stop_on_handshake");
|
||||
match engine.start_deauth_job(
|
||||
&iface,
|
||||
&bssid,
|
||||
req.text("station").as_deref(),
|
||||
reason,
|
||||
&direction,
|
||||
&frame_kind,
|
||||
interval_ms,
|
||||
max_secs,
|
||||
stop_on_handshake,
|
||||
) {
|
||||
Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/deauth/stop") => {
|
||||
let Some(id) = req.text("id") else {
|
||||
return err(400, "id obrigatório");
|
||||
};
|
||||
match engine.stop_deauth_job(&id) {
|
||||
Ok(()) => ok_json(serde_json::json!({ "ok": true })),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("GET", "/api/deauth/job") => {
|
||||
let Some(id) = req.text("id") else {
|
||||
return err(400, "id obrigatório");
|
||||
};
|
||||
match engine.deauth_job(&id) {
|
||||
Some(job) => ok(&job),
|
||||
None => err(404, "job não encontrado"),
|
||||
}
|
||||
}
|
||||
("GET", "/api/deauth/jobs") => {
|
||||
let jobs = engine.deauth_jobs();
|
||||
match req.text("iface") {
|
||||
Some(iface) => ok(&jobs
|
||||
.into_iter()
|
||||
.filter(|j| j.iface.eq_ignore_ascii_case(&iface))
|
||||
.collect::<Vec<_>>()),
|
||||
None => ok(&jobs),
|
||||
}
|
||||
}
|
||||
("POST", "/api/handshakes/capture") => {
|
||||
if !req.bool("confirm") {
|
||||
return err(400, "confirme a autorização do teste (confirm=true)");
|
||||
}
|
||||
let (Some(iface), Some(bssid)) = (req.text("iface"), req.text("bssid")) else {
|
||||
return err(400, "iface e bssid são obrigatórios");
|
||||
};
|
||||
let seconds = req.u64("seconds");
|
||||
match engine.capture_handshake(&iface, &bssid, req.text("station").as_deref(), seconds)
|
||||
{
|
||||
Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/analyze") => {
|
||||
let pcap = if !req.body.is_empty() {
|
||||
serde_json::from_slice::<AnalyzeBody>(&req.body)
|
||||
|
||||
@@ -18,6 +18,8 @@ sha2 = "0.10"
|
||||
sha1 = "0.10"
|
||||
hmac = "0.12"
|
||||
md-5 = "0.10"
|
||||
aes = "0.8"
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||
Upstream-Name: reaver-wps-fork-t6x
|
||||
Source: https://github.com/t6x/reaver-wps-fork-t6x
|
||||
|
||||
Files: *
|
||||
Copyright: 2011, Tactical Network Solutions, Craig Heffner <cheffner@tacnetsol.com>
|
||||
License: GPL-2
|
||||
|
||||
Files: src/common/*
|
||||
Copyright: 2002-2010, Jouni Malinen <j@w1.fi>
|
||||
License: GPL-2
|
||||
|
||||
Files: src/crypto/*
|
||||
Copyright: 2002-2010, Jouni Malinen <j@w1.fi> and contributors
|
||||
License: GPL-2
|
||||
|
||||
Files: src/tls/*
|
||||
Copyright: 2002-2010, Jouni Malinen <j@w1.fi>
|
||||
License: GPL-2
|
||||
|
||||
Files: src/tls/libtommath.c
|
||||
Copyright: Tom St Denis
|
||||
License: public-domain
|
||||
|
||||
Files: src/utils/*
|
||||
Copyright: 2002-2010, Jouni Malinen <j@w1.fi>
|
||||
License: GPL-2
|
||||
|
||||
Files: src/utils/radiotap.h
|
||||
Copyright: 2003-2004 David Young
|
||||
License: BSD-3-clause
|
||||
|
||||
Files: src/wps/*
|
||||
Copyright: 2007-2009, Jouni Malinen <j@w1.fi>
|
||||
License: GPL-2
|
||||
|
||||
Files: src/wps/wps_ufd.c
|
||||
Copyright: 2009, Masashi Honma <honma@ictec.co.jp>
|
||||
License: GPL-2
|
||||
|
||||
Files: src/lwe/*
|
||||
Copyright: 1997-2007, Jean Tourrilhes <jt@hpl.hp.com>
|
||||
License: GPL-2
|
||||
|
||||
Files: debian/*
|
||||
Copyright: 2011-2018 Bartosz Fenski <fenio@debian.org>
|
||||
2023 Leandro Cunha <leandrocunha016@gmail.com>
|
||||
License: GPL-2
|
||||
|
||||
License: GPL-2
|
||||
This package is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 2 of the License.
|
||||
.
|
||||
This package is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
.
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this package; if not, write to the Free Software
|
||||
Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
.
|
||||
On Debian systems, the complete text of the GNU General
|
||||
Public License can be found in `/usr/share/common-licenses/GPL-2'.
|
||||
|
||||
License: BSD-3-clause
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
.
|
||||
1. Redistributions of source code must retain the above copyright notice,
|
||||
this list of conditions and the following disclaimer.
|
||||
.
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
.
|
||||
3. Neither the name of the ORGANIZATION nor the names of its contributors
|
||||
may be used to endorse or promote products derived from this software
|
||||
without specific prior written permission.
|
||||
.
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
License: public-domain
|
||||
The files tagged with this license contains the following paragraphs:
|
||||
.
|
||||
No copyright is claimed. This code is in the public domain; do with
|
||||
it what you wish.
|
||||
.
|
||||
Written by Karel Zak <kzak@redhat.com>
|
||||
@@ -0,0 +1,31 @@
|
||||
wpa-full.txt.gz
|
||||
|
||||
Dois dicionários concatenados, com as duplicatas removidas e a ordem de
|
||||
prioridade preservada (as entradas mais prováveis ficam primeiro, então a
|
||||
quebra por dicionário tende a achar a senha mais cedo em vez de só no fim):
|
||||
|
||||
1. Probable-Wordlists (berzerk0), Real-Passwords/WPA-Length/
|
||||
Top204Thousand-WPA-probable-v2.txt — 203.806 senhas reais vazadas, já
|
||||
filtradas pro comprimento válido de WPA (8–63) e ordenadas por frequência
|
||||
observada no mundo real.
|
||||
https://github.com/berzerk0/Probable-Wordlists
|
||||
Licença: Creative Commons Attribution-ShareAlike 4.0 International
|
||||
(CC BY-SA 4.0) — https://creativecommons.org/licenses/by-sa/4.0/
|
||||
|
||||
2. rockyou.txt — o dump de senhas em texto puro do vazamento da RockYou de
|
||||
2009 (~32 milhões de contas), aqui filtrado pro mesmo comprimento válido
|
||||
de WPA. É provavelmente o dicionário de senhas mais usado em segurança
|
||||
ofensiva — vem embutido por padrão até no Kali Linux
|
||||
(/usr/share/wordlists/rockyou.txt.gz). Redistribuído aqui via SecLists:
|
||||
https://github.com/danielmiessler/SecLists (repositório MIT; o conteúdo do
|
||||
próprio rockyou.txt é dado de vazamento, sem titular de direitos autorais
|
||||
que o licencie formalmente — é tratado como de uso livre pra pesquisa de
|
||||
segurança em toda a indústria, mesmo padrão que o SecLists e as
|
||||
distribuições Linux de pentest adotam).
|
||||
|
||||
Ao redistribuir este arquivo (ou um derivado dele), mantenha a atribuição ao
|
||||
Probable-Wordlists acima — é a parte que tem uma licença formal que exige
|
||||
isso (CC BY-SA 4.0).
|
||||
|
||||
Isso é atribuição e aviso de licença, não os termos legais completos da
|
||||
CC BY-SA 4.0.
|
||||
@@ -0,0 +1,486 @@
|
||||
//! Análise de canais, detecção de anomalias (WIDS) e leitura de WPA3.
|
||||
//!
|
||||
//! Tudo aqui é derivado da tabela já capturada — nada injeta quadros. A
|
||||
//! interface usa o resultado para desenhar o gráfico de canais e os alertas;
|
||||
//! o relatório usa o mesmo modelo para não ter duas verdades.
|
||||
|
||||
use crate::radiotap::{self, Band};
|
||||
use crate::scan::AccessPoint;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ChannelCandidate {
|
||||
pub channel: u32,
|
||||
pub freq_mhz: u16,
|
||||
pub band: String,
|
||||
pub score: f32,
|
||||
pub aps: usize,
|
||||
pub dfs: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct BandReport {
|
||||
pub band: String,
|
||||
pub candidates: Vec<ChannelCandidate>,
|
||||
pub recommended: Option<u32>,
|
||||
pub used_channels: Vec<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Finding {
|
||||
pub id: String,
|
||||
pub severity: String,
|
||||
pub title: String,
|
||||
pub detail: String,
|
||||
pub bssid: Option<String>,
|
||||
pub ssid: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Wpa3Info {
|
||||
pub bssid: String,
|
||||
pub ssid: Option<String>,
|
||||
pub mode: String,
|
||||
pub pmf: bool,
|
||||
pub pmf_required: bool,
|
||||
pub transition: bool,
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Default)]
|
||||
pub struct Analysis {
|
||||
pub bands: Vec<BandReport>,
|
||||
pub findings: Vec<Finding>,
|
||||
pub wpa3: Vec<Wpa3Info>,
|
||||
pub deauth_60s: u32,
|
||||
}
|
||||
|
||||
const BAND_24: &str = "2.4 GHz";
|
||||
const BAND_5: &str = "5 GHz";
|
||||
const BAND_6: &str = "6 GHz";
|
||||
|
||||
fn band_label(band: Band) -> &'static str {
|
||||
match band {
|
||||
Band::B2G4 => BAND_24,
|
||||
Band::B5 => BAND_5,
|
||||
Band::B6 => BAND_6,
|
||||
Band::Unknown => BAND_24,
|
||||
}
|
||||
}
|
||||
|
||||
fn ap_freq(ap: &AccessPoint) -> Option<(u16, Band)> {
|
||||
if let Some(freq) = ap.freq_mhz {
|
||||
return Some((freq, radiotap::band_for_freq(freq)));
|
||||
}
|
||||
let band = ap.band?;
|
||||
let channel = ap.channel?;
|
||||
radiotap::channel_to_freq(channel, band).map(|freq| (freq, band))
|
||||
}
|
||||
|
||||
fn ap_width(ap: &AccessPoint) -> u16 {
|
||||
ap.width_mhz.filter(|w| *w >= 20).unwrap_or(20)
|
||||
}
|
||||
|
||||
fn candidates_for(band: Band) -> Vec<u32> {
|
||||
match band {
|
||||
Band::B2G4 => (1..=14).collect(),
|
||||
Band::B5 => {
|
||||
let mut chans: Vec<u32> = (36..=64).step_by(4).collect();
|
||||
chans.extend((100..=144).step_by(4));
|
||||
chans.extend((149..=177).step_by(4));
|
||||
chans.extend([182, 184, 188, 192, 196]);
|
||||
chans
|
||||
}
|
||||
Band::B6 => (1..=233).step_by(4).collect(),
|
||||
Band::Unknown => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_dfs(channel: u32) -> bool {
|
||||
(52..=64).contains(&channel) || (100..=144).contains(&channel)
|
||||
}
|
||||
|
||||
fn signal_weight(dbm: Option<i8>) -> f32 {
|
||||
let dbm = dbm.unwrap_or(-75) as f32;
|
||||
(1.0 - ((-dbm - 30.0) / 70.0)).clamp(0.15, 1.0)
|
||||
}
|
||||
|
||||
fn overlap_score(candidate_freq: u16, aps: &[(u16, u16, f32)]) -> (f32, usize) {
|
||||
let half = 10.0f32;
|
||||
let mut score = 0.0f32;
|
||||
let mut count = 0usize;
|
||||
for (freq, width, weight) in aps {
|
||||
let ap_half = *width as f32 / 2.0;
|
||||
let lo = (candidate_freq as f32 - half).max(*freq as f32 - ap_half);
|
||||
let hi = (candidate_freq as f32 + half).min(*freq as f32 + ap_half);
|
||||
let overlap = (hi - lo).max(0.0) / (half * 2.0);
|
||||
if overlap > 0.0 {
|
||||
score += overlap * weight;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
(score, count)
|
||||
}
|
||||
|
||||
fn band_report(band: Band, aps: &[&AccessPoint]) -> BandReport {
|
||||
let mut occupied: Vec<(u16, u16, f32)> = Vec::new();
|
||||
let mut used = Vec::new();
|
||||
for ap in aps {
|
||||
let Some((freq, _)) = ap_freq(ap) else {
|
||||
continue;
|
||||
};
|
||||
occupied.push((
|
||||
freq,
|
||||
ap_width(ap),
|
||||
signal_weight(ap.signal_dbm.or(ap.max_signal_dbm)),
|
||||
));
|
||||
if let Some(ch) = ap.channel {
|
||||
used.push(ch);
|
||||
}
|
||||
}
|
||||
used.sort_unstable();
|
||||
used.dedup();
|
||||
|
||||
let mut candidates: Vec<ChannelCandidate> = candidates_for(band)
|
||||
.into_iter()
|
||||
.filter_map(|channel| {
|
||||
radiotap::channel_to_freq(channel, band).map(|freq| {
|
||||
let (score, count) = overlap_score(freq, &occupied);
|
||||
ChannelCandidate {
|
||||
channel,
|
||||
freq_mhz: freq,
|
||||
band: band_label(band).to_string(),
|
||||
score: (score * 1000.0).round() / 1000.0,
|
||||
aps: count,
|
||||
dfs: is_dfs(channel),
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let preferred: Vec<u32> = match band {
|
||||
Band::B2G4 => vec![1, 6, 11],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let recommended = if preferred.is_empty() {
|
||||
candidates
|
||||
.iter()
|
||||
.filter(|c| !c.dfs)
|
||||
.min_by(|a, b| a.score.total_cmp(&b.score))
|
||||
.or_else(|| candidates.iter().min_by(|a, b| a.score.total_cmp(&b.score)))
|
||||
.map(|c| c.channel)
|
||||
} else {
|
||||
preferred
|
||||
.into_iter()
|
||||
.filter_map(|ch| candidates.iter().find(|c| c.channel == ch))
|
||||
.min_by(|a, b| a.score.total_cmp(&b.score))
|
||||
.map(|c| c.channel)
|
||||
};
|
||||
|
||||
candidates.sort_by(|a, b| {
|
||||
a.score
|
||||
.total_cmp(&b.score)
|
||||
.then_with(|| a.channel.cmp(&b.channel))
|
||||
});
|
||||
candidates.truncate(8);
|
||||
BandReport {
|
||||
band: band_label(band).to_string(),
|
||||
candidates,
|
||||
recommended,
|
||||
used_channels: used,
|
||||
}
|
||||
}
|
||||
|
||||
fn finding(
|
||||
id: &str,
|
||||
severity: &str,
|
||||
title: impl Into<String>,
|
||||
detail: impl Into<String>,
|
||||
ap: Option<&AccessPoint>,
|
||||
) -> Finding {
|
||||
Finding {
|
||||
id: id.into(),
|
||||
severity: severity.into(),
|
||||
title: title.into(),
|
||||
detail: detail.into(),
|
||||
bssid: ap.map(|a| a.bssid.clone()),
|
||||
ssid: ap.and_then(|a| a.ssid.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn security_rank(security: Option<&str>) -> u8 {
|
||||
match security {
|
||||
Some("Open") => 0,
|
||||
Some("WEP") => 1,
|
||||
Some("WPA") => 2,
|
||||
Some("WPA2-EAP") => 3,
|
||||
Some("WPA2") => 4,
|
||||
Some("WPA2/WPA3") => 5,
|
||||
Some("WPA3-EAP") => 6,
|
||||
Some("WPA3") => 7,
|
||||
Some("OWE") => 4,
|
||||
_ => 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn wpa3_info(ap: &AccessPoint) -> Option<Wpa3Info> {
|
||||
let sec = ap.security.as_deref()?;
|
||||
let is_wpa3 = sec.contains("WPA3") || sec == "OWE";
|
||||
if !is_wpa3 {
|
||||
return None;
|
||||
}
|
||||
let transition = sec == "WPA2/WPA3";
|
||||
let mut notes = Vec::new();
|
||||
if transition {
|
||||
notes.push(
|
||||
"Modo de transição aceita WPA2: um cliente pode ser induzido a usar o modo mais fraco."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if !ap.pmf {
|
||||
notes
|
||||
.push("PMF não anunciado; WPA3 pressupõe proteção de quadros de gerenciamento.".into());
|
||||
} else if !ap.pmf_required && !transition {
|
||||
notes.push("PMF suportado, mas não obrigatório.".into());
|
||||
}
|
||||
if ap.wps {
|
||||
notes.push("WPS ativo em rede WPA3 — combinação incomum e enfraquece o conjunto.".into());
|
||||
}
|
||||
if ap.wps_locked == Some(false) {
|
||||
notes.push("WPS destravado: PIN pode ser testado.".into());
|
||||
}
|
||||
Some(Wpa3Info {
|
||||
bssid: ap.bssid.clone(),
|
||||
ssid: ap.ssid.clone(),
|
||||
mode: sec.to_string(),
|
||||
pmf: ap.pmf,
|
||||
pmf_required: ap.pmf_required,
|
||||
transition,
|
||||
notes,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn analyze(aps: &[AccessPoint], deauth_60s: u32, now: u64) -> Analysis {
|
||||
let mut findings = Vec::new();
|
||||
let mut wpa3 = Vec::new();
|
||||
|
||||
let bands = [Band::B2G4, Band::B5, Band::B6]
|
||||
.into_iter()
|
||||
.filter_map(|band| {
|
||||
let filtered: Vec<&AccessPoint> = aps
|
||||
.iter()
|
||||
.filter(|ap| ap_freq(ap).is_some_and(|(_, b)| b == band))
|
||||
.collect();
|
||||
if filtered.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(band_report(band, &filtered))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// SSID anunciado com seguranças diferentes é o sinal clássico de evil
|
||||
// twin — legítimo costuma manter o mesmo conjunto em todos os rádios.
|
||||
let mut by_ssid: HashMap<String, Vec<&AccessPoint>> = HashMap::new();
|
||||
for ap in aps {
|
||||
if let Some(ssid) = ap.ssid.as_deref().filter(|s| !s.is_empty()) {
|
||||
by_ssid.entry(ssid.to_string()).or_default().push(ap);
|
||||
}
|
||||
}
|
||||
for (ssid, group) in &by_ssid {
|
||||
if group.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let mut ranks: Vec<u8> = group
|
||||
.iter()
|
||||
.map(|a| security_rank(a.security.as_deref()))
|
||||
.collect();
|
||||
ranks.sort_unstable();
|
||||
ranks.dedup();
|
||||
if ranks.len() > 1 {
|
||||
let detail = group
|
||||
.iter()
|
||||
.map(|a| format!("{} ({})", a.bssid, a.security.as_deref().unwrap_or("—")))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
findings.push(finding(
|
||||
"evil_twin_security",
|
||||
"bad",
|
||||
format!("SSID “{ssid}” com seguranças diferentes"),
|
||||
format!("O mesmo nome aparece em BSSIDs com níveis de segurança distintos: {detail}. Pode ser um evil twin ou um AP mal configurado."),
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if deauth_60s >= 40 {
|
||||
findings.push(finding(
|
||||
"deauth_flood",
|
||||
"bad",
|
||||
"Rajada de desautenticação",
|
||||
format!("{deauth_60s} quadros de desautenticação/desassociação no último minuto. Pode ser ataque de desautenticação ou um AP instável."),
|
||||
None,
|
||||
));
|
||||
}
|
||||
let recent_aps = aps
|
||||
.iter()
|
||||
.filter(|a| now.saturating_sub(a.first_seen_unix) <= 60)
|
||||
.count();
|
||||
if recent_aps >= 60 {
|
||||
findings.push(finding(
|
||||
"beacon_flood",
|
||||
"warn",
|
||||
"Muitos BSSIDs novos",
|
||||
format!("{recent_aps} BSSIDs apareceram no último minuto — possível beacon flood ou ambiente muito denso."),
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
for ap in aps {
|
||||
let label = ap
|
||||
.ssid
|
||||
.clone()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| ap.bssid.clone());
|
||||
match ap.security.as_deref() {
|
||||
Some("Open") => findings.push(finding(
|
||||
"open",
|
||||
"bad",
|
||||
format!("Rede aberta: {label}"),
|
||||
"Sem cifra no Wi-Fi; o tráfego fica exposto a qualquer estação no alcance.",
|
||||
Some(ap),
|
||||
)),
|
||||
Some("WEP") => findings.push(finding(
|
||||
"wep",
|
||||
"bad",
|
||||
format!("WEP em {label}"),
|
||||
"WEP é quebrável em minutos; trate a rede como aberta.",
|
||||
Some(ap),
|
||||
)),
|
||||
Some("WPA") => findings.push(finding(
|
||||
"wpa1",
|
||||
"warn",
|
||||
format!("WPA1 (TKIP) em {label}"),
|
||||
"WPA1/TKIP tem fraquezas conhecidas; migre para WPA2/CCMP ou WPA3.",
|
||||
Some(ap),
|
||||
)),
|
||||
_ => {}
|
||||
}
|
||||
if ap.wps {
|
||||
findings.push(finding(
|
||||
"wps",
|
||||
if ap.wps_locked == Some(true) {
|
||||
"info"
|
||||
} else {
|
||||
"warn"
|
||||
},
|
||||
format!("WPS ativo em {label}"),
|
||||
if ap.wps_locked == Some(true) {
|
||||
"O AP anuncia WPS com configuração travada."
|
||||
} else {
|
||||
"WPS sem lock explícito; PIN pode ser testado com reaver/bully."
|
||||
},
|
||||
Some(ap),
|
||||
));
|
||||
}
|
||||
if !ap.pmf
|
||||
&& matches!(
|
||||
ap.security.as_deref(),
|
||||
Some("WPA2") | Some("WPA2-EAP") | Some("WPA2/WPA3")
|
||||
)
|
||||
{
|
||||
findings.push(finding(
|
||||
"no_pmf",
|
||||
"warn",
|
||||
format!("Sem PMF em {label}"),
|
||||
"Desautenticação e desassociação não são protegidas; clientes podem ser expulsos para facilitar a captura de handshake.",
|
||||
Some(ap),
|
||||
));
|
||||
}
|
||||
if ap.pmf_required && ap.security.as_deref().is_some_and(|s| s.contains("WPA3")) && !ap.pmf
|
||||
{
|
||||
findings.push(finding(
|
||||
"pmf_inconsistent",
|
||||
"warn",
|
||||
format!("PMF inconsistente em {label}"),
|
||||
"O AP anuncia WPA3, mas não anuncia PMF; verifique a configuração.",
|
||||
Some(ap),
|
||||
));
|
||||
}
|
||||
if ap.wps && ap.security.as_deref().is_some_and(|s| s.contains("WPA3")) {
|
||||
findings.push(finding(
|
||||
"wpa3_wps",
|
||||
"warn",
|
||||
format!("WPA3 com WPS em {label}"),
|
||||
"WPS não faz parte do WPA3 e abre uma via mais fraca de associação.",
|
||||
Some(ap),
|
||||
));
|
||||
}
|
||||
if let Some(info) = wpa3_info(ap) {
|
||||
wpa3.push(info);
|
||||
}
|
||||
}
|
||||
|
||||
findings.sort_by_key(|f| match f.severity.as_str() {
|
||||
"bad" => 0,
|
||||
"warn" => 1,
|
||||
_ => 2,
|
||||
});
|
||||
Analysis {
|
||||
bands,
|
||||
findings,
|
||||
wpa3,
|
||||
deauth_60s,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::scan::ScanTable;
|
||||
use crate::testutil::{ap, beacon_frame, radiotap_wrap};
|
||||
|
||||
fn ingest(table: &mut ScanTable, ssid: &str, channel: u8, signal: i8, akms: &[u8]) {
|
||||
let frame = beacon_frame(ap(), ssid, channel, akms);
|
||||
table.ingest_at(
|
||||
&radiotap_wrap(&frame, 2407 + channel as u16 * 5, signal),
|
||||
100,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recommends_16_or_11_in_24() {
|
||||
let mut table = ScanTable::new();
|
||||
ingest(&mut table, "Casa", 6, -40, &[2]);
|
||||
let result = table.analysis();
|
||||
let band = result
|
||||
.bands
|
||||
.iter()
|
||||
.find(|b| b.band == BAND_24)
|
||||
.expect("2.4");
|
||||
assert!(matches!(band.recommended, Some(1 | 6 | 11)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flags_same_ssid_with_different_security() {
|
||||
let mut table = ScanTable::new();
|
||||
ingest(&mut table, "Loja", 6, -40, &[2]);
|
||||
let twin = beacon_frame([0x02, 0x11, 0x22, 0x33, 0x44, 0x55], "Loja", 11, &[]);
|
||||
table.ingest_at(&radiotap_wrap(&twin, 2462, -50), 100);
|
||||
let result = table.analysis();
|
||||
assert!(result.findings.iter().any(|f| f.id == "evil_twin_security"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flags_wps_and_weak_networks() {
|
||||
let mut table = ScanTable::new();
|
||||
let frame = beacon_frame(ap(), "Antiga", 1, &[]);
|
||||
table.ingest_at(&radiotap_wrap(&frame, 2412, -60), 100);
|
||||
let result = table.analysis();
|
||||
assert!(result
|
||||
.findings
|
||||
.iter()
|
||||
.any(|f| f.id == "wep" || f.id == "open"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Decriptação CCMP (WPA2-AES) de quadros de dados, usando a TK já derivada
|
||||
//! de um handshake quebrado.
|
||||
//!
|
||||
//! Isso não serve pra forjar nem reinjetar nada — só pra enxergar o conteúdo
|
||||
//! em claro (DHCP, mDNS) de um cliente cuja senha da rede já foi recuperada
|
||||
//! por dicionário, e assim extrair hostname/fabricante de verdade em vez de
|
||||
//! só o MAC. A chave é par (BSSID, estação): só decifra tráfego do cliente
|
||||
//! cujo próprio handshake foi capturado e quebrado, nunca de terceiros —
|
||||
//! cada associação tem sua PTK efêmera, então a senha da rede sozinha não
|
||||
//! abre o tráfego de quem a gente não viu entrar.
|
||||
//!
|
||||
//! Implementa só o modo CTR (a parte que decifra o conteúdo). O MIC
|
||||
//! (CBC-MAC) não é conferido: a intenção aqui é mostrar o payload de um
|
||||
//! tráfego que a gente já pode decifrar porque a senha é conhecida, não
|
||||
//! provar integridade de um quadro adulterado — e implementar o AAD/CBC-MAC
|
||||
//! certinho exigiria replicar o mascaramento do cabeçalho 802.11 inteiro
|
||||
//! sem ganho prático pro que isso serve aqui.
|
||||
|
||||
use aes::cipher::{BlockEncrypt, KeyInit};
|
||||
use aes::Aes128;
|
||||
|
||||
/// Decifra o corpo de um quadro de dados CCMP, a partir do cabeçalho CCMP
|
||||
/// (8 bytes) em diante — ou seja, `body` é `frame.body[frame.header_len..]`.
|
||||
/// `transmitter` é o endereço 2 (A2) do quadro — o rádio que o enviou, não o
|
||||
/// BSSID/estação lógicos. `qos_tid` é o TID do QoS Control (0 se não-QoS).
|
||||
///
|
||||
/// Retorna o LLC/SNAP + payload em claro (o mesmo formato que um quadro não
|
||||
/// protegido teria), sem os 8 bytes de MIC cifrado no final.
|
||||
pub fn decrypt_data(
|
||||
tk: &[u8; 16],
|
||||
transmitter: &[u8; 6],
|
||||
qos_tid: u8,
|
||||
body: &[u8],
|
||||
) -> Option<Vec<u8>> {
|
||||
const HEADER_LEN: usize = 8;
|
||||
const MIC_LEN: usize = 8;
|
||||
if body.len() < HEADER_LEN + MIC_LEN {
|
||||
return None;
|
||||
}
|
||||
let pn0 = body[0];
|
||||
let pn1 = body[1];
|
||||
let key_id_octet = body[3];
|
||||
// ExtIV (bit 5) precisa estar ligado — senão não é um cabeçalho CCMP de
|
||||
// verdade (podia ser WEP de 4 bytes por coincidência de layout).
|
||||
if key_id_octet & 0x20 == 0 {
|
||||
return None;
|
||||
}
|
||||
let pn2 = body[4];
|
||||
let pn3 = body[5];
|
||||
let pn4 = body[6];
|
||||
let pn5 = body[7];
|
||||
|
||||
let ciphertext_and_mic = &body[HEADER_LEN..];
|
||||
let ciphertext = &ciphertext_and_mic[..ciphertext_and_mic.len() - MIC_LEN];
|
||||
|
||||
// Nonce CCMP (13 bytes): flags/prioridade (1) || A2 (6) || PN, do byte
|
||||
// mais significativo (PN5) ao menos significativo (PN0).
|
||||
let mut nonce = [0u8; 13];
|
||||
nonce[0] = qos_tid & 0x0f;
|
||||
nonce[1..7].copy_from_slice(transmitter);
|
||||
nonce[7] = pn5;
|
||||
nonce[8] = pn4;
|
||||
nonce[9] = pn3;
|
||||
nonce[10] = pn2;
|
||||
nonce[11] = pn1;
|
||||
nonce[12] = pn0;
|
||||
|
||||
let cipher = Aes128::new(tk.into());
|
||||
let mut out = Vec::with_capacity(ciphertext.len());
|
||||
let mut counter: u16 = 1; // o bloco de contador 0 cifra só o MIC, não dado.
|
||||
for chunk in ciphertext.chunks(16) {
|
||||
let mut block = [0u8; 16];
|
||||
block[0] = 0x01; // flag octet do CCMP counter mode (L=1, sem AAD aqui)
|
||||
block[1..14].copy_from_slice(&nonce);
|
||||
block[14..16].copy_from_slice(&counter.to_be_bytes());
|
||||
let mut keystream = block.into();
|
||||
cipher.encrypt_block(&mut keystream);
|
||||
for (c, k) in chunk.iter().zip(keystream.iter()) {
|
||||
out.push(c ^ k);
|
||||
}
|
||||
counter = counter.wrapping_add(1);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// AES-128 do FIPS-197 (vetor de referência oficial do NIST): confirma
|
||||
/// que a cifra de bloco usada embaixo está correta antes de confiar no
|
||||
/// contador CTR por cima dela.
|
||||
#[test]
|
||||
fn aes128_matches_fips197_vector() {
|
||||
let key: [u8; 16] = [
|
||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
|
||||
0x0e, 0x0f,
|
||||
];
|
||||
let plaintext: [u8; 16] = [
|
||||
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
|
||||
0xee, 0xff,
|
||||
];
|
||||
let expected: [u8; 16] = [
|
||||
0x69, 0xc4, 0xe0, 0xd8, 0x6a, 0x7b, 0x04, 0x30, 0xd8, 0xcd, 0xb7, 0x80, 0x70, 0xb4,
|
||||
0xc5, 0x5a,
|
||||
];
|
||||
let cipher = Aes128::new(&key.into());
|
||||
let mut block = plaintext.into();
|
||||
cipher.encrypt_block(&mut block);
|
||||
assert_eq!(block.as_slice(), &expected);
|
||||
}
|
||||
|
||||
/// Round-trip de auto-consistência: decifrar um texto que a própria
|
||||
/// função "cifrou" (mesmo algoritmo, usado nos dois sentidos — CTR é
|
||||
/// simétrico) tem que devolver o original. Isso pega bug de indexação
|
||||
/// no nonce/contador mesmo sem um vetor de teste oficial do CCMP à mão.
|
||||
#[test]
|
||||
fn ctr_round_trip_recovers_plaintext() {
|
||||
let tk = [0x42u8; 16];
|
||||
let transmitter = [0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff];
|
||||
let plaintext = b"AA AA 03 00 00 00 08 00 payload de teste bem maior que um bloco de 16 bytes para cobrir mais de uma rodada do contador";
|
||||
|
||||
// Monta um "quadro" CCMP cifrando manualmente com a mesma lógica de
|
||||
// keystream (ela é simétrica: XOR com o keystream cifra e decifra).
|
||||
let header = [0x11u8, 0x22, 0x00, 0x20, 0x33, 0x44, 0x55, 0x66]; // ExtIV ligado
|
||||
let mut nonce = [0u8; 13];
|
||||
nonce[0] = 3; // TID
|
||||
nonce[1..7].copy_from_slice(&transmitter);
|
||||
nonce[7] = header[7];
|
||||
nonce[8] = header[6];
|
||||
nonce[9] = header[5];
|
||||
nonce[10] = header[4];
|
||||
nonce[11] = header[1];
|
||||
nonce[12] = header[0];
|
||||
let cipher = Aes128::new(&tk.into());
|
||||
let mut ciphertext = Vec::with_capacity(plaintext.len());
|
||||
let mut counter: u16 = 1;
|
||||
for chunk in plaintext.chunks(16) {
|
||||
let mut block = [0u8; 16];
|
||||
block[0] = 0x01;
|
||||
block[1..14].copy_from_slice(&nonce);
|
||||
block[14..16].copy_from_slice(&counter.to_be_bytes());
|
||||
let mut keystream = block.into();
|
||||
cipher.encrypt_block(&mut keystream);
|
||||
for (p, k) in chunk.iter().zip(keystream.iter()) {
|
||||
ciphertext.push(p ^ k);
|
||||
}
|
||||
counter = counter.wrapping_add(1);
|
||||
}
|
||||
ciphertext.extend_from_slice(&[0u8; 8]); // MIC fictício, ignorado na decriptação
|
||||
|
||||
let mut body = Vec::new();
|
||||
body.extend_from_slice(&header);
|
||||
body.extend_from_slice(&ciphertext);
|
||||
|
||||
let out = decrypt_data(&tk, &transmitter, 3, &body).expect("decifra");
|
||||
assert_eq!(out, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_body_without_extiv_bit() {
|
||||
let tk = [0u8; 16];
|
||||
let body = [0u8; 20]; // key_id_octet (índice 3) sem o bit ExtIV
|
||||
assert!(decrypt_data(&tk, &[0; 6], 0, &body).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_body_too_short() {
|
||||
let tk = [0u8; 16];
|
||||
let body = [0u8; 10];
|
||||
assert!(decrypt_data(&tk, &[0; 6], 0, &body).is_none());
|
||||
}
|
||||
}
|
||||
@@ -15,13 +15,17 @@ use crate::dot11::parse_mac;
|
||||
use crate::error::{Result, SabiaError};
|
||||
use crate::handshake::Handshake;
|
||||
use crate::pcapreader::PcapReader;
|
||||
use crate::rules::Mask;
|
||||
use crate::scan::ScanTable;
|
||||
use flate2::read::GzDecoder;
|
||||
use hmac::{Hmac, Mac};
|
||||
use md5::Md5;
|
||||
use sha1::Sha1;
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::io::{BufRead, BufReader, Read};
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
type HmacSha1 = Hmac<Sha1>;
|
||||
@@ -29,6 +33,37 @@ type HmacMd5 = Hmac<Md5>;
|
||||
|
||||
const WPA_ITERATIONS: u32 = 4096;
|
||||
|
||||
/// `.gz` do dicionário embutido — descomprimido só na primeira vez que é
|
||||
/// usado (ver [`default_wordlist`]). Cru são ~101MB; comprimido, ~39MB no
|
||||
/// binário em vez de ~101MB.
|
||||
const DEFAULT_WORDLIST_GZ: &[u8] = include_bytes!("../assets/wordlists/wpa-full.txt.gz");
|
||||
|
||||
pub const DEFAULT_WORDLIST_SOURCE: &str =
|
||||
"Probable-Wordlists (berzerk0) WPA-Length top204k (CC BY-SA 4.0) + rockyou.txt via SecLists — ver assets/wordlists/LICENSE.wpa-full";
|
||||
|
||||
static DEFAULT_WORDLIST_CACHE: LazyLock<String> = LazyLock::new(|| {
|
||||
let mut out = String::new();
|
||||
GzDecoder::new(DEFAULT_WORDLIST_GZ)
|
||||
.read_to_string(&mut out)
|
||||
.expect("dicionário embutido corrompido");
|
||||
out
|
||||
});
|
||||
|
||||
/// Lista embutida de senhas prováveis de Wi-Fi, usada quando o usuário não
|
||||
/// escolhe um dicionário próprio. Não depende de arquivo externo: é a lista
|
||||
/// mais completa que o Sabiá consegue embutir sem exigir download —
|
||||
/// 9.703.831 senhas (203.806 do Probable-Wordlists, ranqueadas por
|
||||
/// probabilidade, seguidas pelo rockyou.txt inteiro) filtradas pro
|
||||
/// comprimento válido de WPA (8–63) e pra ASCII puro (WPA-PSK exige um
|
||||
/// octeto válido; a imensa maioria dos roteadores só aceita ASCII mesmo, e
|
||||
/// isso evita o pouco de lixo binário que o rockyou.txt original carrega),
|
||||
/// sem duplicatas e na ordem de prioridade: as mais prováveis primeiro,
|
||||
/// então a quebra tende a achar a senha cedo, não só no fim da lista. Ver
|
||||
/// `assets/wordlists/LICENSE.wpa-full` pra atribuição completa.
|
||||
pub fn default_wordlist() -> &'static str {
|
||||
&DEFAULT_WORDLIST_CACHE
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CrackTarget {
|
||||
pub ssid: String,
|
||||
@@ -116,8 +151,10 @@ pub fn derive_pmk(passphrase: &str, ssid: &str) -> [u8; 32] {
|
||||
}
|
||||
|
||||
/// Aceita uma frase-senha (8–63 caracteres) ou uma PMK crua em hexadecimal
|
||||
/// (64 caracteres, como no hashcat/aircrack).
|
||||
fn pmk_from_candidate(candidate: &str, ssid: &[u8]) -> Option<[u8; 32]> {
|
||||
/// (64 caracteres, como no hashcat/aircrack). `pub(crate)` porque
|
||||
/// `handshake.rs` reusa exatamente essa regra ao derivar a TK de uma senha
|
||||
/// já quebrada (o campo `password` aceita as duas formas, igual aqui).
|
||||
pub(crate) fn pmk_from_candidate(candidate: &str, ssid: &[u8]) -> Option<[u8; 32]> {
|
||||
if candidate.len() == 64 && candidate.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return hex_to_32(candidate.as_bytes());
|
||||
}
|
||||
@@ -133,15 +170,20 @@ 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> {
|
||||
let iterations = iterations.max(1);
|
||||
let mut out = Vec::with_capacity(out_len);
|
||||
// O par ipad/opad depende só da senha — monta uma vez e clona a cada
|
||||
// rodada (clone é só copiar os dois blocos já prontos) em vez de montar
|
||||
// o HMAC do zero em cada uma das milhares de iterações. Em 4096
|
||||
// iterações por bloco isso é a maior parte do custo fora do SHA1 em si.
|
||||
let base = HmacSha1::new_from_slice(password).expect("HMAC aceita qualquer chave");
|
||||
let mut block: u32 = 1;
|
||||
while out.len() < out_len {
|
||||
let mut mac = HmacSha1::new_from_slice(password).expect("HMAC aceita qualquer chave");
|
||||
let mut mac = base.clone();
|
||||
mac.update(salt);
|
||||
mac.update(&block.to_be_bytes());
|
||||
let mut u = mac.finalize().into_bytes();
|
||||
let mut t = u;
|
||||
for _ in 1..iterations {
|
||||
let mut mac = HmacSha1::new_from_slice(password).expect("HMAC aceita qualquer chave");
|
||||
let mut mac = base.clone();
|
||||
mac.update(&u);
|
||||
u = mac.finalize().into_bytes();
|
||||
for (a, b) in t.iter_mut().zip(u.iter()) {
|
||||
@@ -272,16 +314,26 @@ fn valid_candidate(candidate: &str) -> bool {
|
||||
|| (candidate.len() == 64 && candidate.bytes().all(|b| b.is_ascii_hexdigit()))
|
||||
}
|
||||
|
||||
/// Tenta cada linha do dicionário contra o alvo. `progress` recebe o número
|
||||
/// de candidatos já processados.
|
||||
pub fn crack_reader<R: BufRead, F: FnMut(u64)>(
|
||||
/// Tenta cada linha do dicionário contra o alvo, dividindo o trabalho entre
|
||||
/// as threads disponíveis (PBKDF2-HMAC-SHA1 de 4096 iterações é CPU puro e
|
||||
/// cada candidato é independente — ganho quase linear com o número de
|
||||
/// núcleos). `progress` recebe o número de candidatos já processados, ~8x
|
||||
/// por segundo enquanto roda. Com `apply_rules`, cada linha vira até
|
||||
/// [`rules::VARIANTS_PER_WORD`] candidatos (maiúscula, sufixos comuns, leet
|
||||
/// simples) em vez de só a palavra crua — multiplica o tempo de verdade,
|
||||
/// por isso é opt-in.
|
||||
pub fn crack_reader<R: BufRead, F: FnMut(u64) + Send>(
|
||||
target: &CrackTarget,
|
||||
mut wordlist: R,
|
||||
apply_rules: bool,
|
||||
mut progress: F,
|
||||
) -> CrackReport {
|
||||
let start = Instant::now();
|
||||
let mut tried = 0u64;
|
||||
let mut password = None;
|
||||
|
||||
// Carrega a lista inteira antes de começar: dá pra repartir em pedaços
|
||||
// contíguos por thread (melhor localidade de cache que round-robin) e o
|
||||
// próprio dicionário embutido cabe de sobra em memória.
|
||||
let mut candidates: Vec<String> = Vec::new();
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
@@ -294,20 +346,62 @@ pub fn crack_reader<R: BufRead, F: FnMut(u64)>(
|
||||
if candidate.is_empty() {
|
||||
continue;
|
||||
}
|
||||
tried += 1;
|
||||
if valid_candidate(candidate) && target.verify(candidate) {
|
||||
password = Some(candidate.to_string());
|
||||
break;
|
||||
}
|
||||
if tried % 128 == 0 {
|
||||
progress(tried);
|
||||
if apply_rules {
|
||||
candidates.extend(crate::rules::expand(candidate));
|
||||
} else {
|
||||
candidates.push(candidate.to_string());
|
||||
}
|
||||
}
|
||||
progress(tried);
|
||||
|
||||
let total = candidates.len();
|
||||
let workers = std::thread::available_parallelism()
|
||||
.map(std::num::NonZeroUsize::get)
|
||||
.unwrap_or(1)
|
||||
.min(total.max(1));
|
||||
let chunk = total.div_ceil(workers.max(1)).max(1);
|
||||
|
||||
let tried = AtomicU64::new(0);
|
||||
let stop = AtomicBool::new(false);
|
||||
let found: Mutex<Option<String>> = Mutex::new(None);
|
||||
|
||||
std::thread::scope(|scope| {
|
||||
for part in candidates.chunks(chunk) {
|
||||
let tried = &tried;
|
||||
let stop = &stop;
|
||||
let found = &found;
|
||||
scope.spawn(move || {
|
||||
for candidate in part {
|
||||
if stop.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
if valid_candidate(candidate) && target.verify(candidate) {
|
||||
*found.lock().unwrap() = Some(candidate.clone());
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
tried.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Thread "repórter": roda dentro do mesmo scope só pra poder
|
||||
// emprestar `progress` (FnMut, chamado de um único lugar).
|
||||
scope.spawn(|| loop {
|
||||
std::thread::sleep(std::time::Duration::from_millis(120));
|
||||
let n = tried.load(Ordering::Relaxed);
|
||||
progress(n);
|
||||
if stop.load(Ordering::Relaxed) || n as usize >= total {
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let final_tried = tried.load(Ordering::Relaxed);
|
||||
progress(final_tried);
|
||||
|
||||
CrackReport {
|
||||
password,
|
||||
tried,
|
||||
password: found.into_inner().unwrap(),
|
||||
tried: final_tried,
|
||||
elapsed_ms: start.elapsed().as_millis() as u64,
|
||||
ssid: Some(target.ssid.clone()),
|
||||
bssid: Some(crate::dot11::mac_to_string(&target.bssid)),
|
||||
@@ -316,26 +410,52 @@ pub fn crack_reader<R: BufRead, F: FnMut(u64)>(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn crack_handshake<F: FnMut(u64)>(
|
||||
pub fn crack_handshake<F: FnMut(u64) + Send>(
|
||||
hs: &Handshake,
|
||||
wordlist: impl AsRef<Path>,
|
||||
apply_rules: bool,
|
||||
progress: F,
|
||||
) -> Result<CrackReport> {
|
||||
let target = CrackTarget::from_handshake(hs).ok_or_else(|| {
|
||||
let target = target_for(hs)?;
|
||||
let file = File::open(wordlist)?;
|
||||
Ok(crack_reader(
|
||||
&target,
|
||||
BufReader::new(file),
|
||||
apply_rules,
|
||||
progress,
|
||||
))
|
||||
}
|
||||
|
||||
/// Quebra usando a lista embutida.
|
||||
pub fn crack_handshake_default<F: FnMut(u64) + Send>(
|
||||
hs: &Handshake,
|
||||
apply_rules: bool,
|
||||
progress: F,
|
||||
) -> Result<CrackReport> {
|
||||
let target = target_for(hs)?;
|
||||
Ok(crack_reader(
|
||||
&target,
|
||||
std::io::Cursor::new(default_wordlist().as_bytes()),
|
||||
apply_rules,
|
||||
progress,
|
||||
))
|
||||
}
|
||||
|
||||
fn target_for(hs: &Handshake) -> Result<CrackTarget> {
|
||||
CrackTarget::from_handshake(hs).ok_or_else(|| {
|
||||
SabiaError::InvalidInput(
|
||||
"handshake sem SSID, PMKID ou par de nonces suficiente para quebrar".into(),
|
||||
)
|
||||
})?;
|
||||
let file = File::open(wordlist)?;
|
||||
Ok(crack_reader(&target, BufReader::new(file), progress))
|
||||
})
|
||||
}
|
||||
|
||||
/// Lê um `.pcap`, monta os handshakes e tenta quebrar o primeiro alvo útil.
|
||||
pub fn crack_pcap<F: FnMut(u64)>(
|
||||
pub fn crack_pcap<F: FnMut(u64) + Send>(
|
||||
pcap: impl AsRef<Path>,
|
||||
wordlist: impl AsRef<Path>,
|
||||
bssid: Option<&str>,
|
||||
station: Option<&str>,
|
||||
apply_rules: bool,
|
||||
progress: F,
|
||||
) -> Result<CrackReport> {
|
||||
let mut reader = PcapReader::open(pcap)?;
|
||||
@@ -351,7 +471,89 @@ pub fn crack_pcap<F: FnMut(u64)>(
|
||||
let hs = table
|
||||
.first_crackable(bssid, station)
|
||||
.ok_or_else(|| SabiaError::InvalidInput("nenhum handshake quebrável na captura".into()))?;
|
||||
crack_handshake(hs, wordlist, progress)
|
||||
crack_handshake(hs, wordlist, apply_rules, progress)
|
||||
}
|
||||
|
||||
/// Força bruta por máscara (estilo hashcat `-a 3`): gera e testa cada
|
||||
/// combinação possível, sem depender de dicionário. Mesma paralelização por
|
||||
/// threads do `crack_reader`, mas gerando candidatos por índice (base
|
||||
/// mista) em vez de carregar tudo em memória — é o que permite máscaras com
|
||||
/// milhões/bilhões de combinações sem estourar RAM.
|
||||
pub fn crack_mask<F: FnMut(u64) + Send>(
|
||||
target: &CrackTarget,
|
||||
mask: &Mask,
|
||||
mut progress: F,
|
||||
) -> CrackReport {
|
||||
let start = Instant::now();
|
||||
let total = mask.total().unwrap_or(0);
|
||||
|
||||
let workers = std::thread::available_parallelism()
|
||||
.map(std::num::NonZeroUsize::get)
|
||||
.unwrap_or(1)
|
||||
.min(total.max(1) as usize) as u64;
|
||||
let chunk = total.div_ceil(workers.max(1)).max(1);
|
||||
|
||||
let tried = AtomicU64::new(0);
|
||||
let stop = AtomicBool::new(false);
|
||||
let found: Mutex<Option<String>> = Mutex::new(None);
|
||||
|
||||
std::thread::scope(|scope| {
|
||||
let mut from = 0u64;
|
||||
while from < total {
|
||||
let to = (from + chunk).min(total);
|
||||
let tried = &tried;
|
||||
let stop = &stop;
|
||||
let found = &found;
|
||||
scope.spawn(move || {
|
||||
for i in from..to {
|
||||
if stop.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
if let Some(candidate) = mask.candidate(i) {
|
||||
if valid_candidate(&candidate) && target.verify(&candidate) {
|
||||
*found.lock().unwrap() = Some(candidate);
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
}
|
||||
tried.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
});
|
||||
from = to;
|
||||
}
|
||||
|
||||
scope.spawn(|| loop {
|
||||
std::thread::sleep(std::time::Duration::from_millis(120));
|
||||
let n = tried.load(Ordering::Relaxed);
|
||||
progress(n);
|
||||
if stop.load(Ordering::Relaxed) || n >= total {
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let final_tried = tried.load(Ordering::Relaxed);
|
||||
progress(final_tried);
|
||||
|
||||
CrackReport {
|
||||
password: found.into_inner().unwrap(),
|
||||
tried: final_tried,
|
||||
elapsed_ms: start.elapsed().as_millis() as u64,
|
||||
ssid: Some(target.ssid.clone()),
|
||||
bssid: Some(crate::dot11::mac_to_string(&target.bssid)),
|
||||
station: Some(crate::dot11::mac_to_string(&target.station)),
|
||||
method: Some(target.method().to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Quebra por máscara a partir de um handshake já montado.
|
||||
pub fn crack_handshake_mask<F: FnMut(u64) + Send>(
|
||||
hs: &Handshake,
|
||||
mask: &Mask,
|
||||
progress: F,
|
||||
) -> Result<CrackReport> {
|
||||
let target = target_for(hs)?;
|
||||
Ok(crack_mask(&target, mask, progress))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -360,6 +562,47 @@ mod tests {
|
||||
use crate::testutil::{handshake_frames, synthetic_handshake};
|
||||
use std::io::Cursor;
|
||||
|
||||
/// Mede só o tempo de descomprimir o dicionário embutido (gzip, ~39MB -> ~101MB),
|
||||
/// que acontece uma vez, na primeira chamada a `default_wordlist()`.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn bench_default_wordlist_decompress() {
|
||||
let start = Instant::now();
|
||||
let text = default_wordlist();
|
||||
let elapsed = start.elapsed();
|
||||
eprintln!(
|
||||
"descomprimiu {} linhas em {:.2?}",
|
||||
text.lines().count(),
|
||||
elapsed
|
||||
);
|
||||
}
|
||||
|
||||
/// Não é teste de verdade — mede candidatos/segundo pra guiar otimização
|
||||
/// de performance. `cargo test --release -p sabia-core -- --ignored
|
||||
/// --nocapture bench_crack_throughput`.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn bench_crack_throughput() {
|
||||
let (m1, m2) = handshake_frames("IEEE", "senha-que-nao-esta-na-lista", 2, false);
|
||||
let hs = synthetic_handshake("IEEE", "00:11:22:33:44:55", "66:77:88:99:aa:bb", &m1, &m2);
|
||||
let target = CrackTarget::from_handshake(&hs).expect("alvo");
|
||||
// Só uma fatia: a lista inteira (9,7 milhões) levaria minutos — o
|
||||
// objetivo aqui é medir candidatos/s, não o tempo total.
|
||||
let sample: String = default_wordlist()
|
||||
.lines()
|
||||
.take(500_000)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let start = Instant::now();
|
||||
let report = crack_reader(&target, Cursor::new(sample.as_bytes()), false, |_| {});
|
||||
let elapsed = start.elapsed();
|
||||
let per_sec = report.tried as f64 / elapsed.as_secs_f64();
|
||||
eprintln!(
|
||||
"{} candidatos em {:.2?} = {:.1}/s",
|
||||
report.tried, elapsed, per_sec
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pbkdf2_matches_rfc6070_vector() {
|
||||
let out = pbkdf2_sha1(b"password", b"salt", 1, 20);
|
||||
@@ -386,6 +629,7 @@ mod tests {
|
||||
let report = crack_reader(
|
||||
&target,
|
||||
Cursor::new(b"errada\noutra\npassword\nnunca\n".to_vec()),
|
||||
false,
|
||||
|_| progress_calls += 1,
|
||||
);
|
||||
assert_eq!(report.password.as_deref(), Some("password"));
|
||||
@@ -400,7 +644,12 @@ mod tests {
|
||||
let hs = synthetic_handshake("IEEE", "00:11:22:33:44:55", "66:77:88:99:aa:bb", &m1, &[]);
|
||||
let target = CrackTarget::from_handshake(&hs).expect("alvo");
|
||||
assert_eq!(target.method(), "PMKID");
|
||||
let report = crack_reader(&target, Cursor::new(b"nada\npassword\n".to_vec()), |_| {});
|
||||
let report = crack_reader(
|
||||
&target,
|
||||
Cursor::new(b"nada\npassword\n".to_vec()),
|
||||
false,
|
||||
|_| {},
|
||||
);
|
||||
assert_eq!(report.password.as_deref(), Some("password"));
|
||||
}
|
||||
|
||||
@@ -417,10 +666,46 @@ mod tests {
|
||||
let (m1, m2) = handshake_frames("IEEE", "senha-correta", 2, false);
|
||||
let hs = synthetic_handshake("IEEE", "00:11:22:33:44:55", "66:77:88:99:aa:bb", &m1, &m2);
|
||||
let target = CrackTarget::from_handshake(&hs).expect("alvo");
|
||||
let report = crack_reader(&target, Cursor::new(b"senha-errada\n".to_vec()), |_| {});
|
||||
let report = crack_reader(
|
||||
&target,
|
||||
Cursor::new(b"senha-errada\n".to_vec()),
|
||||
false,
|
||||
|_| {},
|
||||
);
|
||||
assert!(report.password.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rules_find_a_mutated_variant_not_present_verbatim() {
|
||||
// A senha de verdade é "wifi12345" — não está na lista, mas é um
|
||||
// sufixo numérico de "wifi" que `rules::expand` gera.
|
||||
let (m1, m2) = handshake_frames("IEEE", "wifi12345", 2, false);
|
||||
let hs = synthetic_handshake("IEEE", "00:11:22:33:44:55", "66:77:88:99:aa:bb", &m1, &m2);
|
||||
let target = CrackTarget::from_handshake(&hs).expect("alvo");
|
||||
|
||||
let without_rules = crack_reader(&target, Cursor::new(b"wifi\n".to_vec()), false, |_| {});
|
||||
assert!(without_rules.password.is_none());
|
||||
|
||||
let with_rules = crack_reader(&target, Cursor::new(b"wifi\n".to_vec()), true, |_| {});
|
||||
assert_eq!(with_rules.password.as_deref(), Some("wifi12345"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_crack_finds_a_numeric_password() {
|
||||
// Máscara minúscula de propósito (só 10 combinações): cada candidato
|
||||
// é um PBKDF2 de 4096 iterações, pesado até em paralelo — um brute
|
||||
// force de verdade (ex.: ?d×8, 100 milhões) é coisa pra uso real,
|
||||
// não pra um teste unitário que roda em `cargo test` toda hora.
|
||||
let (m1, m2) = handshake_frames("IEEE", "wifiabc7", 2, false);
|
||||
let hs = synthetic_handshake("IEEE", "00:11:22:33:44:55", "66:77:88:99:aa:bb", &m1, &m2);
|
||||
let target = CrackTarget::from_handshake(&hs).expect("alvo");
|
||||
let mask = Mask::parse("wifiabc?d").expect("máscara válida");
|
||||
assert_eq!(mask.total(), Some(10));
|
||||
let report = crack_mask(&target, &mask, |_| {});
|
||||
assert_eq!(report.password.as_deref(), Some("wifiabc7"));
|
||||
assert!(report.tried > 0);
|
||||
}
|
||||
|
||||
fn hex_bytes(s: &str) -> Vec<u8> {
|
||||
(0..s.len())
|
||||
.step_by(2)
|
||||
|
||||
@@ -79,7 +79,33 @@ pub struct ManagementInfo {
|
||||
pub akm: Vec<String>,
|
||||
pub wps: bool,
|
||||
pub pmf: bool,
|
||||
pub pmf_required: bool,
|
||||
pub country: Option<String>,
|
||||
pub beacon_interval: Option<u16>,
|
||||
pub privacy: bool,
|
||||
pub phy: Vec<String>,
|
||||
pub width_mhz: Option<u16>,
|
||||
pub wps_locked: Option<bool>,
|
||||
pub wps_version: Option<String>,
|
||||
pub wps_manufacturer: Option<String>,
|
||||
pub wps_model: Option<String>,
|
||||
pub wps_model_number: Option<String>,
|
||||
pub wps_device_name: Option<String>,
|
||||
pub wps_serial: Option<String>,
|
||||
}
|
||||
|
||||
/// Pistas de identidade tiradas de payload em claro (DHCP, mDNS/DNS, SSDP,
|
||||
/// HTTP e ARP). Redes criptografadas não expõem nada disso; nesse caso os
|
||||
/// campos ficam vazios e a UI mostra só MAC, OUI e probes.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PayloadInfo {
|
||||
pub ip: Option<String>,
|
||||
pub hostname: Option<String>,
|
||||
pub os: Option<String>,
|
||||
pub device: Option<String>,
|
||||
pub manufacturer: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub service: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
@@ -323,6 +349,13 @@ impl<'a> Frame<'a> {
|
||||
let mut wpa1_present = false;
|
||||
let mut rsn = RsnInfo::default();
|
||||
|
||||
if matches!(self.kind, FrameKind::Beacon | FrameKind::ProbeResponse)
|
||||
&& self.body.len() >= 24 + 12
|
||||
{
|
||||
info.beacon_interval = Some(u16::from_le_bytes([self.body[24 + 8], self.body[24 + 9]]));
|
||||
}
|
||||
info.privacy = privacy;
|
||||
|
||||
let mut i = 0usize;
|
||||
while i + 2 <= ies.len() {
|
||||
let tag = ies[i];
|
||||
@@ -346,15 +379,44 @@ impl<'a> Frame<'a> {
|
||||
7 if len >= 2 => {
|
||||
info.country = Some(String::from_utf8_lossy(&val[0..2]).into_owned())
|
||||
}
|
||||
45 => {
|
||||
add_phy(&mut info.phy, "n");
|
||||
if info.width_mhz.is_none() {
|
||||
info.width_mhz = Some(20);
|
||||
}
|
||||
}
|
||||
48 => {
|
||||
rsn_present = true;
|
||||
if let Some(parsed) = parse_rsn(val) {
|
||||
rsn = parsed;
|
||||
}
|
||||
}
|
||||
61 if len >= 2 => {
|
||||
if info.width_mhz.is_none() || info.width_mhz == Some(20) {
|
||||
info.width_mhz = Some(if val[1] & 0x04 != 0 { 40 } else { 20 });
|
||||
}
|
||||
}
|
||||
191 => add_phy(&mut info.phy, "ac"),
|
||||
192 if !val.is_empty() => {
|
||||
add_phy(&mut info.phy, "ac");
|
||||
info.width_mhz = Some(match val[0] {
|
||||
1 => 80,
|
||||
2 => 160,
|
||||
3 => 80,
|
||||
_ => info.width_mhz.unwrap_or(20),
|
||||
});
|
||||
}
|
||||
221 if len >= 4 && val[0..3] == [0x00, 0x50, 0xF2] => match val[3] {
|
||||
1 => wpa1_present = true,
|
||||
4 => info.wps = true,
|
||||
4 => {
|
||||
info.wps = true;
|
||||
parse_wps(val, &mut info);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
255 if !val.is_empty() => match val[0] {
|
||||
35 | 36 => add_phy(&mut info.phy, "ax"),
|
||||
106 | 108 => add_phy(&mut info.phy, "be"),
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
@@ -363,6 +425,7 @@ impl<'a> Frame<'a> {
|
||||
}
|
||||
|
||||
info.pmf = rsn.caps & 0x00c0 != 0;
|
||||
info.pmf_required = rsn.caps & 0x0040 != 0;
|
||||
if rsn_present {
|
||||
info.cipher = rsn.cipher.map(|c| c.label().to_string());
|
||||
info.akm = rsn.akm.labels();
|
||||
@@ -405,6 +468,598 @@ impl<'a> Frame<'a> {
|
||||
let end = (start + 4 + len).min(self.body.len());
|
||||
self.body.get(start..end)
|
||||
}
|
||||
|
||||
/// Tenta identificar a estação por payload não criptografado. Só olha
|
||||
/// dados em claro; em WPA2/WPA3 o conteúdo é opaco e retorna vazio — a
|
||||
/// menos que a senha já tenha sido quebrada (ver [`Self::ccmp_payload`]
|
||||
/// + [`payload_info_from_llc`]).
|
||||
pub fn payload_info(&self) -> PayloadInfo {
|
||||
if self.protected || !self.kind.is_data() {
|
||||
return PayloadInfo::default();
|
||||
}
|
||||
let Some(payload) = self.body.get(self.header_len..) else {
|
||||
return PayloadInfo::default();
|
||||
};
|
||||
payload_info_from_llc(payload)
|
||||
}
|
||||
|
||||
/// TID do campo QoS Control, quando o quadro é QoS Data. `None` pra
|
||||
/// dados não-QoS (o nonce do CCMP usa TID 0 nesse caso).
|
||||
pub fn qos_tid(&self) -> Option<u8> {
|
||||
if !self.kind.is_data() {
|
||||
return None;
|
||||
}
|
||||
let subtype = (self.fc >> 4) & 0b1111;
|
||||
if subtype & 0x8 == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut offset = 24;
|
||||
if self.to_ds && self.from_ds {
|
||||
offset += 6;
|
||||
}
|
||||
self.body.get(offset).map(|b| b & 0x0f)
|
||||
}
|
||||
|
||||
/// Decifra um quadro de dados CCMP com a TK já derivada (handshake
|
||||
/// quebrado) e devolve o LLC/SNAP + payload em claro, no mesmo formato
|
||||
/// que um quadro não protegido teria. `None` se o quadro não for CCMP
|
||||
/// protegido ou os bytes não baterem com um cabeçalho CCMP válido.
|
||||
pub fn ccmp_payload(&self, tk: &[u8; 16]) -> Option<Vec<u8>> {
|
||||
if !self.protected || !self.kind.is_data() {
|
||||
return None;
|
||||
}
|
||||
let transmitter = self.addr2?;
|
||||
let tid = self.qos_tid().unwrap_or(0);
|
||||
let body = self.body.get(self.header_len..)?;
|
||||
crate::ccmp::decrypt_data(tk, &transmitter, tid, body)
|
||||
}
|
||||
}
|
||||
|
||||
/// LLC/SNAP (AA AA 03 00 00 00 + ethertype) seguido do payload em claro —
|
||||
/// compartilhado entre quadros nunca cifrados e quadros CCMP já decifrados
|
||||
/// com a TK conhecida.
|
||||
pub fn payload_info_from_llc(payload: &[u8]) -> PayloadInfo {
|
||||
let mut info = PayloadInfo::default();
|
||||
let Some(llc) = payload.get(..8) else {
|
||||
return info;
|
||||
};
|
||||
if llc[0] != 0xAA || llc[1] != 0xAA || llc[2] != 0x03 {
|
||||
return info;
|
||||
}
|
||||
let ethertype = u16::from_be_bytes([llc[6], llc[7]]);
|
||||
let Some(rest) = payload.get(8..) else {
|
||||
return info;
|
||||
};
|
||||
match ethertype {
|
||||
0x0806 => parse_arp(rest, &mut info),
|
||||
0x0800 => parse_ipv4(rest, &mut info),
|
||||
_ => {}
|
||||
}
|
||||
info
|
||||
}
|
||||
|
||||
fn ipv4_string(bytes: &[u8]) -> Option<String> {
|
||||
(bytes.len() >= 4).then(|| format!("{}.{}.{}.{}", bytes[0], bytes[1], bytes[2], bytes[3]))
|
||||
}
|
||||
|
||||
fn private_ip(ip: &str) -> bool {
|
||||
let mut parts = ip.split('.');
|
||||
let (Some(a), Some(b)) = (
|
||||
parts.next().and_then(|p| p.parse::<u8>().ok()),
|
||||
parts.next().and_then(|p| p.parse::<u8>().ok()),
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
a == 10
|
||||
|| (a == 172 && (16..=31).contains(&b))
|
||||
|| (a == 192 && b == 168)
|
||||
|| (a == 169 && b == 254)
|
||||
|| (a == 100 && (64..=127).contains(&b))
|
||||
}
|
||||
|
||||
fn parse_arp(payload: &[u8], info: &mut PayloadInfo) {
|
||||
if payload.len() < 28
|
||||
|| u16::from_be_bytes([payload[0], payload[1]]) != 1
|
||||
|| u16::from_be_bytes([payload[2], payload[3]]) != 0x0800
|
||||
|| payload[4] != 6
|
||||
|| payload[5] != 4
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Some(ip) = ipv4_string(&payload[14..18]) {
|
||||
if ip != "0.0.0.0" {
|
||||
info.ip = Some(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_ipv4(payload: &[u8], info: &mut PayloadInfo) {
|
||||
if payload.len() < 20 || payload[0] >> 4 != 4 {
|
||||
return;
|
||||
}
|
||||
let ihl = ((payload[0] & 0x0f) as usize) * 4;
|
||||
if ihl < 20 || payload.len() < ihl {
|
||||
return;
|
||||
}
|
||||
let protocol = payload[9];
|
||||
let src = ipv4_string(&payload[12..16]);
|
||||
let dst = ipv4_string(&payload[16..20]);
|
||||
if let Some(ip) = &src {
|
||||
if private_ip(ip) {
|
||||
info.ip = Some(ip.clone());
|
||||
}
|
||||
}
|
||||
|
||||
match protocol {
|
||||
17 => {
|
||||
let udp = &payload[ihl..];
|
||||
if udp.len() < 8 {
|
||||
return;
|
||||
}
|
||||
let sport = u16::from_be_bytes([udp[0], udp[1]]);
|
||||
let dport = u16::from_be_bytes([udp[2], udp[3]]);
|
||||
let data = &udp[8..];
|
||||
if sport == 68 || dport == 67 {
|
||||
parse_dhcp(data, info);
|
||||
} else if sport == 5353 || dport == 5353 {
|
||||
parse_dns(data, info, true);
|
||||
} else if sport == 53 || dport == 53 {
|
||||
parse_dns(data, info, false);
|
||||
} else if sport == 1900 || dport == 1900 {
|
||||
parse_ssdp(data, info);
|
||||
} else if let Some(ip) = &dst {
|
||||
if info.ip.is_none() && private_ip(ip) {
|
||||
info.ip = Some(ip.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
6 => {
|
||||
let tcp = &payload[ihl..];
|
||||
if tcp.len() < 20 {
|
||||
return;
|
||||
}
|
||||
let data_offset = ((tcp[12] >> 4) as usize) * 4;
|
||||
let sport = u16::from_be_bytes([tcp[0], tcp[1]]);
|
||||
let dport = u16::from_be_bytes([tcp[2], tcp[3]]);
|
||||
if data_offset >= 20 && tcp.len() > data_offset {
|
||||
let data = &tcp[data_offset..];
|
||||
if matches!(sport, 80 | 8080 | 8000 | 8888)
|
||||
|| matches!(dport, 80 | 8080 | 8000 | 8888)
|
||||
{
|
||||
parse_http(data, info);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_dhcp(data: &[u8], info: &mut PayloadInfo) {
|
||||
if data.len() < 240 || data[236..240] != [0x63, 0x82, 0x53, 0x63] {
|
||||
return;
|
||||
}
|
||||
let mut requested_ip = None;
|
||||
let mut message_type = None;
|
||||
let mut pos = 240usize;
|
||||
while pos < data.len() {
|
||||
let code = data[pos];
|
||||
if code == 255 {
|
||||
break;
|
||||
}
|
||||
if code == 0 {
|
||||
pos += 1;
|
||||
continue;
|
||||
}
|
||||
if pos + 1 >= data.len() {
|
||||
break;
|
||||
}
|
||||
let len = data[pos + 1] as usize;
|
||||
let start = pos + 2;
|
||||
if start + len > data.len() {
|
||||
break;
|
||||
}
|
||||
let val = &data[start..start + len];
|
||||
match code {
|
||||
12 => info.hostname = Some(String::from_utf8_lossy(val).trim().to_string()),
|
||||
50 if len >= 4 => requested_ip = ipv4_string(val),
|
||||
53 if len == 1 => message_type = Some(val[0]),
|
||||
60 => {
|
||||
let vendor = String::from_utf8_lossy(val).trim().to_string();
|
||||
if let Some(os) = os_from_text(&vendor) {
|
||||
info.os = Some(os);
|
||||
}
|
||||
if let Some(device) = device_from_text(&vendor) {
|
||||
info.device = Some(device);
|
||||
}
|
||||
}
|
||||
81 => {
|
||||
let name = String::from_utf8_lossy(val).trim_matches('\0').to_string();
|
||||
if !name.is_empty() {
|
||||
info.hostname = Some(name.split('\0').next().unwrap_or(&name).to_string());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
pos = start + len;
|
||||
}
|
||||
if message_type == Some(3) {
|
||||
if let Some(ip) = requested_ip {
|
||||
if ip != "0.0.0.0" {
|
||||
info.ip = Some(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
if info.hostname.as_deref() == Some("") {
|
||||
info.hostname = None;
|
||||
}
|
||||
if let Some(hostname) = info.hostname.as_deref() {
|
||||
if info.device.is_none() {
|
||||
info.device = device_from_text(hostname);
|
||||
}
|
||||
if info.os.is_none() {
|
||||
info.os = os_from_text(hostname);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dns_name(data: &[u8], mut pos: usize) -> Option<(String, usize)> {
|
||||
let mut labels = Vec::new();
|
||||
let mut jumps = 0usize;
|
||||
let mut end = pos;
|
||||
let mut jumped = false;
|
||||
while pos < data.len() && jumps < 16 {
|
||||
let len = data[pos];
|
||||
if len == 0 {
|
||||
if !jumped {
|
||||
end = pos + 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if len & 0xc0 == 0xc0 {
|
||||
if pos + 1 >= data.len() {
|
||||
return None;
|
||||
}
|
||||
let ptr = (((len & 0x3f) as usize) << 8) | data[pos + 1] as usize;
|
||||
if !jumped {
|
||||
end = pos + 2;
|
||||
}
|
||||
pos = ptr;
|
||||
jumps += 1;
|
||||
jumped = true;
|
||||
continue;
|
||||
}
|
||||
let len = len as usize;
|
||||
if pos + 1 + len > data.len() {
|
||||
return None;
|
||||
}
|
||||
labels.push(String::from_utf8_lossy(&data[pos + 1..pos + 1 + len]).into_owned());
|
||||
pos += 1 + len;
|
||||
if !jumped {
|
||||
end = pos;
|
||||
}
|
||||
}
|
||||
if labels.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some((labels.join("."), end))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_dns(data: &[u8], info: &mut PayloadInfo, mdns: bool) {
|
||||
if data.len() < 12 {
|
||||
return;
|
||||
}
|
||||
let qd = u16::from_be_bytes([data[4], data[5]]) as usize;
|
||||
let an = u16::from_be_bytes([data[6], data[7]]) as usize;
|
||||
let mut pos = 12usize;
|
||||
for _ in 0..qd {
|
||||
let Some((name, next)) = dns_name(data, pos) else {
|
||||
return;
|
||||
};
|
||||
pos = next + 4;
|
||||
apply_dns_name(&name, info, mdns);
|
||||
}
|
||||
for _ in 0..an.min(24) {
|
||||
let Some((name, next)) = dns_name(data, pos) else {
|
||||
return;
|
||||
};
|
||||
if next + 10 > data.len() {
|
||||
return;
|
||||
}
|
||||
let kind = u16::from_be_bytes([data[next], data[next + 1]]);
|
||||
let rdlen = u16::from_be_bytes([data[next + 8], data[next + 9]]) as usize;
|
||||
let rdata = next + 10;
|
||||
if rdata + rdlen > data.len() {
|
||||
return;
|
||||
}
|
||||
if kind == 1 && rdlen == 4 {
|
||||
if let Some(ip) = ipv4_string(&data[rdata..rdata + 4]) {
|
||||
if private_ip(&ip) && (info.ip.is_none() || info.hostname.is_none()) {
|
||||
info.ip = Some(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
if kind == 16 {
|
||||
parse_txt(&data[rdata..rdata + rdlen], info);
|
||||
}
|
||||
apply_dns_name(&name, info, mdns);
|
||||
pos = rdata + rdlen;
|
||||
}
|
||||
if let Some(ip) = &info.ip {
|
||||
if !private_ip(ip) {
|
||||
info.ip = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_dns_name(name: &str, info: &mut PayloadInfo, mdns: bool) {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
if let Some(service) = lower.strip_prefix('_') {
|
||||
info.service = Some(service.split('.').next().unwrap_or(service).to_string());
|
||||
if let Some(device) = device_from_service(&lower) {
|
||||
info.device = Some(device);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if mdns && lower.ends_with(".local") {
|
||||
let host = name.split('.').next().unwrap_or(name);
|
||||
if !host.is_empty() {
|
||||
info.hostname = Some(host.to_string());
|
||||
if let Some(device) = device_from_text(host) {
|
||||
info.device = Some(device);
|
||||
}
|
||||
if let Some(os) = os_from_text(host) {
|
||||
info.os = Some(os);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_txt(data: &[u8], info: &mut PayloadInfo) {
|
||||
let mut pos = 0usize;
|
||||
while pos < data.len() {
|
||||
let len = data[pos] as usize;
|
||||
pos += 1;
|
||||
if pos + len > data.len() {
|
||||
break;
|
||||
}
|
||||
let txt = String::from_utf8_lossy(&data[pos..pos + len]);
|
||||
pos += len;
|
||||
let mut parts = txt.splitn(2, '=');
|
||||
let key = parts.next().unwrap_or("").to_ascii_lowercase();
|
||||
let value = parts.next().unwrap_or("");
|
||||
match key.as_str() {
|
||||
"model" | "md" => info.model = Some(value.to_string()),
|
||||
"os" => info.os = Some(value.to_string()),
|
||||
"device" | "product" => info.device = Some(value.to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_ssdp(data: &[u8], info: &mut PayloadInfo) {
|
||||
let text = String::from_utf8_lossy(data);
|
||||
for line in text.lines() {
|
||||
let Some((key, value)) = line.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let key = key.trim().to_ascii_uppercase();
|
||||
let value = value.trim();
|
||||
match key.as_str() {
|
||||
"SERVER" | "USER-AGENT" => {
|
||||
if let Some(os) = os_from_text(value) {
|
||||
info.os = Some(os);
|
||||
}
|
||||
if let Some(device) = device_from_text(value) {
|
||||
info.device = Some(device);
|
||||
}
|
||||
if let Some(manufacturer) = manufacturer_from_text(value) {
|
||||
info.manufacturer = Some(manufacturer);
|
||||
}
|
||||
info.model = info.model.take().or_else(|| {
|
||||
value
|
||||
.split_whitespace()
|
||||
.find(|part| part.contains('/') && !part.contains("UPnP"))
|
||||
.map(str::to_string)
|
||||
});
|
||||
}
|
||||
"ST" | "NT" => {
|
||||
let lower = value.to_ascii_lowercase();
|
||||
if let Some(device) = device_from_service(&lower) {
|
||||
info.device = Some(device);
|
||||
}
|
||||
info.service = info
|
||||
.service
|
||||
.take()
|
||||
.or_else(|| Some(value.rsplit(':').next().unwrap_or(value).to_string()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_http(data: &[u8], info: &mut PayloadInfo) {
|
||||
let text = String::from_utf8_lossy(data);
|
||||
if !(text.starts_with("GET ") || text.starts_with("POST ") || text.starts_with("HEAD ")) {
|
||||
return;
|
||||
}
|
||||
for line in text.lines().take(64) {
|
||||
let Some((key, value)) = line.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let key = key.trim().to_ascii_lowercase();
|
||||
let value = value.trim();
|
||||
match key.as_str() {
|
||||
"user-agent" | "server" => {
|
||||
if let Some(os) = os_from_text(value) {
|
||||
info.os = Some(os);
|
||||
}
|
||||
if let Some(device) = device_from_text(value) {
|
||||
info.device = Some(device);
|
||||
}
|
||||
}
|
||||
"host" if info.hostname.is_none() => {
|
||||
info.hostname = Some(value.split(':').next().unwrap_or(value).to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn os_from_text(text: &str) -> Option<String> {
|
||||
let s = text.to_ascii_lowercase();
|
||||
let os = if s.contains("windows nt 10") || s.contains("windows 10") || s.contains("windows 11")
|
||||
{
|
||||
"Windows 10/11"
|
||||
} else if s.contains("windows") || s.contains("microsoft") {
|
||||
"Windows"
|
||||
} else if s.contains("ipados") || s.contains("ipad") {
|
||||
"iPadOS"
|
||||
} else if s.contains("iphone")
|
||||
|| s.contains("ios")
|
||||
|| s.contains("darwin") && s.contains("mobile")
|
||||
{
|
||||
"iOS"
|
||||
} else if s.contains("mac os x") || s.contains("macintosh") || s.contains("macos") {
|
||||
"macOS"
|
||||
} else if s.contains("android") {
|
||||
"Android"
|
||||
} else if s.contains("cros") || s.contains("chromeos") {
|
||||
"ChromeOS"
|
||||
} else if s.contains("tizen") {
|
||||
"Tizen"
|
||||
} else if s.contains("webos") {
|
||||
"webOS"
|
||||
} else if s.contains("roku") {
|
||||
"Roku OS"
|
||||
} else if s.contains("linux")
|
||||
|| s.contains("ubuntu")
|
||||
|| s.contains("debian")
|
||||
|| s.contains("raspbian")
|
||||
{
|
||||
"Linux"
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
Some(os.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn device_from_text(text: &str) -> Option<String> {
|
||||
let s = text.to_ascii_lowercase();
|
||||
let device = if s.contains("iphone")
|
||||
|| s.contains("galaxy")
|
||||
|| s.contains("pixel")
|
||||
|| s.contains("redmi")
|
||||
|| s.contains("smartphone")
|
||||
|| s.contains("phone")
|
||||
|| s.contains("moto ")
|
||||
{
|
||||
"smartphone"
|
||||
} else if s.contains("ipad") || s.contains("tablet") {
|
||||
"tablet"
|
||||
} else if s.contains("macbook")
|
||||
|| s.contains("thinkpad")
|
||||
|| s.contains("laptop")
|
||||
|| s.contains("notebook")
|
||||
{
|
||||
"laptop"
|
||||
} else if s.contains("printer")
|
||||
|| s.contains("laserjet")
|
||||
|| s.contains("officejet")
|
||||
|| s.contains("mfc-")
|
||||
{
|
||||
"impressora"
|
||||
} else if s.contains("chromecast")
|
||||
|| s.contains("appletv")
|
||||
|| s.contains("fire tv")
|
||||
|| s.contains("firetv")
|
||||
|| s.contains("roku")
|
||||
|| s.contains("smart tv")
|
||||
|| s.contains("bravia")
|
||||
|| s.contains("tv")
|
||||
{
|
||||
"TV/streaming"
|
||||
} else if s.contains("camera") || s.contains("câmera") || s.contains("ipc") {
|
||||
"câmera"
|
||||
} else if s.contains("watch") {
|
||||
"wearable"
|
||||
} else if s.contains("echo")
|
||||
|| s.contains("alexa")
|
||||
|| s.contains("google home")
|
||||
|| s.contains("nest")
|
||||
{
|
||||
"assistente"
|
||||
} else if s.contains("raspberry") {
|
||||
"SBC"
|
||||
} else if s.contains("esp32")
|
||||
|| s.contains("esp8266")
|
||||
|| s.contains("shelly")
|
||||
|| s.contains("sonoff")
|
||||
|| s.contains("tuya")
|
||||
{
|
||||
"IoT"
|
||||
} else if s.contains("desktop")
|
||||
|| s.contains("laptop")
|
||||
|| s.contains("windows")
|
||||
|| s.contains("pc")
|
||||
{
|
||||
"computador"
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
Some(device.to_string())
|
||||
}
|
||||
|
||||
fn device_from_service(service: &str) -> Option<String> {
|
||||
let s = service.to_ascii_lowercase();
|
||||
let device = if s.contains("_airplay")
|
||||
|| s.contains("_raop")
|
||||
|| s.contains("_googlecast")
|
||||
|| s.contains("_spotify-connect")
|
||||
{
|
||||
"TV/streaming"
|
||||
} else if s.contains("_ipp") || s.contains("_printer") || s.contains("_pdl-datastream") {
|
||||
"impressora"
|
||||
} else if s.contains("_smb") || s.contains("_workstation") || s.contains("_device-info") {
|
||||
"computador"
|
||||
} else if s.contains("_homekit") || s.contains("_hap") {
|
||||
"IoT"
|
||||
} else if s.contains("_ssh") || s.contains("_sftp-ssh") {
|
||||
"servidor"
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
Some(device.to_string())
|
||||
}
|
||||
|
||||
fn manufacturer_from_text(text: &str) -> Option<String> {
|
||||
let s = text.to_ascii_lowercase();
|
||||
let name = if s.contains("apple") {
|
||||
"Apple"
|
||||
} else if s.contains("samsung") {
|
||||
"Samsung"
|
||||
} else if s.contains("microsoft") || s.contains("windows") {
|
||||
"Microsoft"
|
||||
} else if s.contains("google") {
|
||||
"Google"
|
||||
} else if s.contains("sony") {
|
||||
"Sony"
|
||||
} else if s.contains("lg electronics") || s.starts_with("lg ") {
|
||||
"LG"
|
||||
} else if s.contains("roku") {
|
||||
"Roku"
|
||||
} else if s.contains("huawei") {
|
||||
"Huawei"
|
||||
} else if s.contains("xiaomi") {
|
||||
"Xiaomi"
|
||||
} else if s.contains("synology") {
|
||||
"Synology"
|
||||
} else if s.contains("raspberry") {
|
||||
"Raspberry Pi"
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
Some(name.to_string())
|
||||
}
|
||||
|
||||
/// Mantido para chamadores antigos: há um EAPOL neste quadro?
|
||||
@@ -441,6 +1096,47 @@ fn classify(rsn_present: bool, wpa1_present: bool, privacy: bool, rsn: RsnInfo)
|
||||
Security::Open
|
||||
}
|
||||
|
||||
fn add_phy(phy: &mut Vec<String>, value: &str) {
|
||||
if !phy.iter().any(|p| p == value) {
|
||||
phy.push(value.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Information Element de WPS (vendor 00:50:f2 tipo 4). O conteúdo começa
|
||||
/// com a versão do protocolo e segue em atributos TLV de 16 bits.
|
||||
fn parse_wps(val: &[u8], info: &mut ManagementInfo) {
|
||||
if val.len() < 6 {
|
||||
return;
|
||||
}
|
||||
let version = u16::from_be_bytes([val[4], val[5]]);
|
||||
info.wps_version = Some(match version {
|
||||
0x10 => "1.0".into(),
|
||||
0x11 => "2.0".into(),
|
||||
other => format!("0x{other:04x}"),
|
||||
});
|
||||
let mut pos = 6usize;
|
||||
while pos + 4 <= val.len() {
|
||||
let attr = u16::from_be_bytes([val[pos], val[pos + 1]]);
|
||||
let len = u16::from_be_bytes([val[pos + 2], val[pos + 3]]) as usize;
|
||||
let start = pos + 4;
|
||||
if start + len > val.len() {
|
||||
break;
|
||||
}
|
||||
let data = &val[start..start + len];
|
||||
let text = || String::from_utf8_lossy(data).trim().to_string();
|
||||
match attr {
|
||||
0x1011 => info.wps_device_name = Some(text()),
|
||||
0x1021 => info.wps_manufacturer = Some(text()),
|
||||
0x1023 => info.wps_model = Some(text()),
|
||||
0x1024 => info.wps_model_number = Some(text()),
|
||||
0x1042 => info.wps_serial = Some(text()),
|
||||
0x1057 if len == 1 => info.wps_locked = Some(data[0] != 0),
|
||||
_ => {}
|
||||
}
|
||||
pos = start + len;
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_rsn(val: &[u8]) -> Option<RsnInfo> {
|
||||
if val.len() < 8 {
|
||||
return None;
|
||||
@@ -665,6 +1361,116 @@ mod tests {
|
||||
assert!(parsed.eapol().is_none());
|
||||
}
|
||||
|
||||
fn data_with_ethertype(ethertype: u16, payload: &[u8]) -> Vec<u8> {
|
||||
let mut f = Vec::new();
|
||||
f.extend_from_slice(&0x0108u16.to_le_bytes()); // data, ToDS
|
||||
f.extend_from_slice(&0u16.to_le_bytes());
|
||||
f.extend_from_slice(&mac(0x11)); // addr1 = AP
|
||||
f.extend_from_slice(&mac(0x22)); // addr2 = cliente
|
||||
f.extend_from_slice(&mac(0x11)); // addr3 = AP
|
||||
f.extend_from_slice(&[0u8; 2]);
|
||||
f.extend_from_slice(&[0xAA, 0xAA, 0x03, 0, 0, 0]);
|
||||
f.extend_from_slice(ðertype.to_be_bytes());
|
||||
f.extend_from_slice(payload);
|
||||
f
|
||||
}
|
||||
|
||||
fn data_with_payload(payload: &[u8]) -> Vec<u8> {
|
||||
data_with_ethertype(0x0800, payload)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_ip_from_arp() {
|
||||
let mut arp = Vec::new();
|
||||
arp.extend_from_slice(&1u16.to_be_bytes());
|
||||
arp.extend_from_slice(&0x0800u16.to_be_bytes());
|
||||
arp.push(6);
|
||||
arp.push(4);
|
||||
arp.extend_from_slice(&1u16.to_be_bytes());
|
||||
arp.extend_from_slice(&mac(0x22));
|
||||
arp.extend_from_slice(&[192, 168, 1, 42]);
|
||||
arp.extend_from_slice(&[0u8; 6]);
|
||||
arp.extend_from_slice(&[192, 168, 1, 1]);
|
||||
let frame = data_with_ethertype(0x0806, &arp);
|
||||
let parsed = parse(&frame).expect("frame");
|
||||
assert_eq!(parsed.payload_info().ip.as_deref(), Some("192.168.1.42"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_hostname_and_os_from_mdns() {
|
||||
let mut dns = vec![0u8; 12];
|
||||
dns[5] = 1; // qdcount
|
||||
for label in ["Lucass-iPhone", "local"] {
|
||||
dns.push(label.len() as u8);
|
||||
dns.extend_from_slice(label.as_bytes());
|
||||
}
|
||||
dns.push(0);
|
||||
dns.extend_from_slice(&1u16.to_be_bytes());
|
||||
dns.extend_from_slice(&1u16.to_be_bytes());
|
||||
|
||||
let mut udp = Vec::new();
|
||||
udp.extend_from_slice(&5353u16.to_be_bytes());
|
||||
udp.extend_from_slice(&5353u16.to_be_bytes());
|
||||
udp.extend_from_slice(&((8 + dns.len()) as u16).to_be_bytes());
|
||||
udp.extend_from_slice(&[0u8; 2]);
|
||||
udp.extend_from_slice(&dns);
|
||||
|
||||
let mut ip = vec![0x45, 0];
|
||||
ip.extend_from_slice(&((20 + udp.len()) as u16).to_be_bytes());
|
||||
ip.extend_from_slice(&[0u8; 5]);
|
||||
ip.push(17);
|
||||
ip.extend_from_slice(&[0u8; 2]);
|
||||
ip.extend_from_slice(&[192, 168, 1, 7]);
|
||||
ip.extend_from_slice(&[224, 0, 0, 251]);
|
||||
ip.extend_from_slice(&udp);
|
||||
|
||||
let frame = data_with_payload(&ip);
|
||||
let parsed = parse(&frame).expect("frame");
|
||||
let info = parsed.payload_info();
|
||||
assert_eq!(info.hostname.as_deref(), Some("Lucass-iPhone"));
|
||||
assert_eq!(info.os.as_deref(), Some("iOS"));
|
||||
assert_eq!(info.device.as_deref(), Some("smartphone"));
|
||||
assert_eq!(info.ip.as_deref(), Some("192.168.1.7"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_hostname_and_requested_ip_from_dhcp() {
|
||||
let mut bootp = vec![0u8; 240];
|
||||
bootp[0] = 1;
|
||||
bootp[236..240].copy_from_slice(&[0x63, 0x82, 0x53, 0x63]);
|
||||
bootp.extend_from_slice(&[53, 1, 3]);
|
||||
bootp.extend_from_slice(&[50, 4, 192, 168, 1, 50]);
|
||||
bootp.extend_from_slice(&[12, 7]);
|
||||
bootp.extend_from_slice(b"pixel-7");
|
||||
bootp.extend_from_slice(&[60, 15]);
|
||||
bootp.extend_from_slice(b"android-dhcp-13");
|
||||
bootp.push(255);
|
||||
|
||||
let mut udp = Vec::new();
|
||||
udp.extend_from_slice(&68u16.to_be_bytes());
|
||||
udp.extend_from_slice(&67u16.to_be_bytes());
|
||||
udp.extend_from_slice(&((8 + bootp.len()) as u16).to_be_bytes());
|
||||
udp.extend_from_slice(&[0u8; 2]);
|
||||
udp.extend_from_slice(&bootp);
|
||||
|
||||
let mut ip = vec![0x45, 0];
|
||||
ip.extend_from_slice(&((20 + udp.len()) as u16).to_be_bytes());
|
||||
ip.extend_from_slice(&[0u8; 5]);
|
||||
ip.push(17);
|
||||
ip.extend_from_slice(&[0u8; 2]);
|
||||
ip.extend_from_slice(&[0, 0, 0, 0]);
|
||||
ip.extend_from_slice(&[255, 255, 255, 255]);
|
||||
ip.extend_from_slice(&udp);
|
||||
|
||||
let frame = data_with_payload(&ip);
|
||||
let parsed = parse(&frame).expect("frame");
|
||||
let info = parsed.payload_info();
|
||||
assert_eq!(info.hostname.as_deref(), Some("pixel-7"));
|
||||
assert_eq!(info.os.as_deref(), Some("Android"));
|
||||
assert_eq!(info.device.as_deref(), Some("smartphone"));
|
||||
assert_eq!(info.ip.as_deref(), Some("192.168.1.50"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_mac_strings() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -55,7 +55,14 @@ pub fn parse(buf: &[u8]) -> Option<EapolKey> {
|
||||
if descriptor_type != 2 && descriptor_type != 254 {
|
||||
return None;
|
||||
}
|
||||
let key_info = u16::from_le_bytes([frame[5], frame[6]]);
|
||||
// Todo campo multi-byte do descritor EAPOL-Key (Key Information, Key
|
||||
// Length, Replay Counter, Key Data Length) é big-endian — é 802.1X,
|
||||
// segue a mesma ordem de rede do cabeçalho EAPOL em si. Ler como
|
||||
// little-endian (bug anterior) embaralhava os bits de ack/mic/secure e
|
||||
// fazia praticamente todo handshake real cair em message=0, descartado
|
||||
// silenciosamente — só não aparecia nos testes porque o gerador de
|
||||
// quadro sintético cometia o mesmo engano, simetricamente.
|
||||
let key_info = u16::from_be_bytes([frame[5], frame[6]]);
|
||||
let key_version = (key_info & 0x0007) as u8;
|
||||
let pairwise = key_info & KEY_INFO_PAIRWISE != 0;
|
||||
let ack = key_info & KEY_INFO_ACK != 0;
|
||||
@@ -75,12 +82,12 @@ pub fn parse(buf: &[u8]) -> Option<EapolKey> {
|
||||
0
|
||||
};
|
||||
|
||||
let replay_counter = u64::from_le_bytes(frame[9..17].try_into().ok()?);
|
||||
let replay_counter = u64::from_be_bytes(frame[9..17].try_into().ok()?);
|
||||
let mut nonce = [0u8; 32];
|
||||
nonce.copy_from_slice(&frame[17..49]);
|
||||
let mut mic_bytes = [0u8; 16];
|
||||
mic_bytes.copy_from_slice(&frame[MIC_OFFSET..MIC_OFFSET + 16]);
|
||||
let key_data_len = u16::from_le_bytes([frame[97], frame[98]]) as usize;
|
||||
let key_data_len = u16::from_be_bytes([frame[97], frame[98]]) as usize;
|
||||
let data_end = KEY_DATA_OFFSET.checked_add(key_data_len)?;
|
||||
if data_end > frame.len() {
|
||||
return None;
|
||||
@@ -144,14 +151,14 @@ mod tests {
|
||||
f[1] = EAPOL_TYPE_KEY;
|
||||
f[2..4].copy_from_slice(&((total - 4) as u16).to_be_bytes());
|
||||
f[4] = 2; // RSN descriptor
|
||||
f[5..7].copy_from_slice(&key_info.to_le_bytes());
|
||||
f[7..9].copy_from_slice(&16u16.to_le_bytes());
|
||||
f[9..17].copy_from_slice(&1u64.to_le_bytes());
|
||||
f[5..7].copy_from_slice(&key_info.to_be_bytes());
|
||||
f[7..9].copy_from_slice(&16u16.to_be_bytes());
|
||||
f[9..17].copy_from_slice(&1u64.to_be_bytes());
|
||||
f[17..49].copy_from_slice(&nonce);
|
||||
for (i, b) in f[MIC_OFFSET..MIC_OFFSET + 16].iter_mut().enumerate() {
|
||||
*b = i as u8;
|
||||
}
|
||||
f[97..99].copy_from_slice(&(key_data.len() as u16).to_le_bytes());
|
||||
f[97..99].copy_from_slice(&(key_data.len() as u16).to_be_bytes());
|
||||
f[KEY_DATA_OFFSET..].copy_from_slice(key_data);
|
||||
f
|
||||
}
|
||||
@@ -176,6 +183,32 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Byte a byte, independente do helper `key_frame` (que usa a mesma
|
||||
/// codificação do parser): confere que um quadro Mensagem 1 real — key
|
||||
/// info 0x008a (Ack=1, versão CCMP=2, pairwise), big-endian como é no
|
||||
/// fio de verdade — é lido como message=1/key_version=2, não message=0.
|
||||
/// É exatamente o valor citado pela documentação de ferramentas de
|
||||
/// quebra de WPA pra mensagem 1 com CCMP.
|
||||
#[test]
|
||||
fn parses_real_wire_message1_key_info_as_big_endian() {
|
||||
let mut frame = vec![0u8; MIN_KEY_FRAME_LEN];
|
||||
frame[0] = 2; // versão EAPOL
|
||||
frame[1] = EAPOL_TYPE_KEY;
|
||||
frame[2..4].copy_from_slice(&((MIN_KEY_FRAME_LEN - 4) as u16).to_be_bytes());
|
||||
frame[4] = 2; // descritor RSN
|
||||
frame[5..7].copy_from_slice(&0x008au16.to_be_bytes()); // Ack=1, versão=2(CCMP), pairwise=1
|
||||
frame[7..9].copy_from_slice(&16u16.to_be_bytes()); // key length
|
||||
frame[9..17].copy_from_slice(&1u64.to_be_bytes()); // replay counter
|
||||
frame[17..49].fill(0xaa); // nonce
|
||||
frame[97..99].copy_from_slice(&0u16.to_be_bytes()); // sem key data
|
||||
|
||||
let parsed = parse(&frame).expect("mensagem 1 real deveria parsear");
|
||||
assert_eq!(parsed.message, 1);
|
||||
assert_eq!(parsed.key_version, 2);
|
||||
assert!(parsed.pairwise);
|
||||
assert_eq!(parsed.replay_counter, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_pmkid_from_key_data() {
|
||||
let mut data = vec![0xdd, 20, 0x00, 0x0f, 0xac, 0x04];
|
||||
|
||||