From 5acbbb36302349e93eec4c12a37328da2c642fd8 Mon Sep 17 00:00:00 2001 From: snokvist Date: Fri, 2 Oct 2026 13:41:27 +0200 Subject: [PATCH] tests: station client over IRadio + src/sta, and its on-air cell tests/sta_client.cpp is the in-tree caller of the station core and of IRadio::SetStationIdentity: scan, authenticate, associate, the WPA2-PSK four-way and CCMP over src/sta/, a TAP data plane for the host. It arms the station identity only when AdapterCaps::station_mode_ok is true (otherwise it refuses with exit 2; DEVOURER_STA_ARM=0 runs unarmed), arms after StartRxLoop and outside the mutex the RX callback takes, and clears on the way out whenever an arm was attempted, printing whether the clear verified. Unicast requests an ACK and the hardware retry limit defaults to 7 unless DEVOURER_TX_RETRY_LIMIT is set, so DEVOURER_TX_RETRY_LIMIT=0 is how a run asks for the single-shot uplink the library warns about. The data plane uses the core's DupDetector (one transmitter: the joined AP), delivers a non-Key EAPOL packet that on_decrypted_msdu returns unconsumed, refuses a ccmp_encrypted_len overflow, and trims the FCS by RxAtrib.fcs_present. ctest sta_client_headless (StaClientSelftest, Linux + OpenSSL) runs the headless cells in tests/sta_client_selftest.inc against a fixture that plays the authenticator: scan and sweep, re-join policy, key selection by key id, replay and duplicate windows, PTK/GTK rekeys, refusals and the ledger's identities. No device. tests/mt7612u_sta_onair.sh associates the MT7612U against hostapd in a network namespace: cells open, wpa2 (with group and pairwise rekeys), noarm (DEVOURER_STA_ARM=0 control) and retry0 (the tx.retry_limit=0 arm-time warning). Exit 0 pass, 1 fail, 2 inconclusive, 3 interrupted. docs/station-client.md describes the client; docs/station-core.md and src/sta/CLAUDE.md now name it as the core's caller. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- CMakeLists.txt | 28 +- docs/station-client.md | 104 ++ docs/station-core.md | 8 +- examples/common/env_config.cpp | 4 + examples/common/env_config.h | 6 + src/sta/CLAUDE.md | 10 +- tests/mt7612u_sta_lib.sh | 28 +- tests/mt7612u_sta_onair.sh | 533 +++++++++ tests/sta_client.cpp | 1333 ++++++++++++++++++++++ tests/sta_client_selftest.inc | 1947 ++++++++++++++++++++++++++++++++ 10 files changed, 3981 insertions(+), 20 deletions(-) create mode 100644 docs/station-client.md create mode 100755 tests/mt7612u_sta_onair.sh create mode 100644 tests/sta_client.cpp create mode 100644 tests/sta_client_selftest.inc diff --git a/CMakeLists.txt b/CMakeLists.txt index 37821ba3..393d7c7e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1047,6 +1047,27 @@ if(OpenSSL_FOUND) target_link_libraries(CcmpSelftest PRIVATE OpenSSL::Crypto) target_compile_features(CcmpSelftest PRIVATE cxx_std_20) add_test(NAME ccmp_framing COMMAND CcmpSelftest) + + # tests/sta_client.cpp - the station client over IRadio + src/sta: scan, + # join, the WPA2-PSK four-way, CCMP and a TAP data plane. Built under its + # real name for tests/mt7612u_sta_onair.sh; the target name ends in + # "Selftest" so the `selftests` aggregate collects it, and `--self-test` + # runs its headless cells before libusb is touched (no adapter, no root). + # Linux-only: the data plane is a TAP device (). + if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + add_executable(StaClientSelftest + tests/sta_client.cpp + examples/common/env_config.cpp + examples/common/usb_select.cpp) + set_target_properties(StaClientSelftest PROPERTIES OUTPUT_NAME sta_client) + target_link_libraries(StaClientSelftest PUBLIC devourer + PRIVATE OpenSSL::Crypto) + target_include_directories(StaClientSelftest PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src + ${CMAKE_CURRENT_SOURCE_DIR}/tests + ${CMAKE_CURRENT_SOURCE_DIR}/examples/common) + add_test(NAME sta_client_headless COMMAND StaClientSelftest --self-test) + endif() else() # Said out loud: without OpenSSL the station's crypto ACCEPTANCE cells are # not built, and a green ctest here has run none of them. @@ -1054,11 +1075,12 @@ else() message(FATAL_ERROR "OpenSSL not found, and " "DEVOURER_REQUIRE_STA_CRYPTO_TESTS=ON: the station " "crypto selftests (supplicant, station_sm, " - "ccmp_framing) cannot be built") + "ccmp_framing, sta_client_headless) cannot be " + "built") endif() message(WARNING "OpenSSL not found: the station crypto selftests " - "(supplicant, station_sm, ccmp_framing) are NOT built " - "on this host") + "(supplicant, station_sm, ccmp_framing, " + "sta_client_headless) are NOT built on this host") endif() # tests/ccmp_vectors.h is GENERATED by tests/ccmp_gen_vectors.py; --check diff --git a/docs/station-client.md b/docs/station-client.md new file mode 100644 index 00000000..8e7b917b --- /dev/null +++ b/docs/station-client.md @@ -0,0 +1,104 @@ +# The station client (`tests/sta_client.cpp`) + +The in-tree caller of the station core (`docs/station-core.md`) and of +`IRadio::SetStationIdentity`. It joins a WPA2-PSK or open BSS through any +`IRadio`: scan, authenticate, associate, run the four-way as the supplicant, +and carry CCMP-protected traffic to and from the host through a TAP device. +The protocol is `src/sta/`; this file owns what the core leaves to its +integrator - the scanner, the re-join policy and the data plane. + +## The station identity + +- Armed only when `AdapterCaps::station_mode_ok` is true: MT7612U, and the + Realtek 8822C / 8822B arm (`docs/realtek-station-arm.md`). A backend that + reports false is refused at start-up with exit status 2; + `DEVOURER_STA_ARM=0` runs it unarmed instead. +- Armed for the BSSID actually joined, after `StartRxLoop` (IRadio's ordering + rule) and outside the mutex the RX callback takes (IRadio's lock rule). +- Cleared on the way out whenever an arm was attempted; the result is printed + (`station identity clear: restored (verified)` / `NOT VERIFIED`). On + MT7612U the clear is trivially true, since the arm wrote nothing. + +On MT7612U the arm writes no register: it verifies that the station's address +is the adapter's own `MT_MAC_ADDR` and that the auto-responder is enabled +(`docs/mt7612u-station-identity.md`). That is why the station's address always +comes from `GetPermanentMacAddress`. + +## What the station transmits + +The arm covers receive and acknowledgement only. Unicast is sent with an +ACK-requesting radiotap (`DEVOURER_STA_ACK=0` turns that off), and +`tx.retry_limit` defaults to `kStationRetryLimit` (7) unless the library took +a numeric `DEVOURER_TX_RETRY_LIMIT` (an empty or non-numeric value is not +one; `apply_station_retry_limit`). `DEVOURER_TX_RETRY_LIMIT=0` therefore asks +for a single-shot uplink, and the library warns about it at arm time +(`Mt7612uRadio::SetStationIdentity`; `docs/mt7612u-tx-retry.md`). + +## Running it + +``` +sudo DEVOURER_VID=0x0e8d DEVOURER_PID=0x7612 DEVOURER_CHANNEL=6 \ + DEVOURER_STA_SSID=devourerSTA DEVOURER_STA_PSK=devourer123 \ + DEVOURER_STA_TAP=dvsta0 build/sta_client 60 +``` + +Built by the `StaClientSelftest` CMake target (Linux, OpenSSL). Station +variables: `DEVOURER_STA_SSID`, `_PSK` (empty: open), `_TAP`, +`_SCAN_CHANNELS`, `_SCAN_DWELL_MS`, `_RECONNECT`, `_BACKOFF_MS`, `_ARM`, +`_ACK`; plus the library's `DEVOURER_*` (`examples/common/env_config.cpp`). +SIGINT/SIGTERM (handled from the start of `main`, so a stop during bring-up +ends the run once the bring-up returns) leave the BSS, clear the identity and +print the ledger. The ledger is printed at every exit once `sta_client up:` +has printed, and separates "heard nothing", "heard another BSS" and "our AP +refused us". + +Exit status: 0 the run completed; 1 setup failed; 2 refused +(`station_mode_ok` false, or the duration, `DEVOURER_CHANNEL`, +`DEVOURER_STA_SCAN_DWELL_MS` (10..10000, default 250) or +`DEVOURER_STA_BACKOFF_MS` (0..60000, default 1000) is not a valid number in +range); 3 a fault - an exception was caught, the TAP failed mid-run, the RNG +failed, or `ClearStationIdentity` could not verify its rollback. A fault still leaves, clears and prints the ledger, whose +first line then reads `fault=1`. + +## What the tests pin + +- **Headless** - ctest `sta_client_headless` (`build/sta_client --self-test`, + `tests/sta_client_selftest.inc`): the cells play the authenticator and feed + real frames into the real receive path - scan selection and sweep, re-join + policy, key selection by key id, replay and duplicate windows, PTK/GTK + rekeys, plaintext/fragment/A-MSDU refusal, the FCS trim, the ledger's + identities. No device, no root. +- **On air** - `tests/mt7612u_sta_onair.sh` against hostapd in a network + namespace, MT7612U as the station: + + | Cell | Scored | + |---|---| + | `open` | AP associates our address; ping 0% loss over the TAP; ledger plaintext only; armed; the clear ran on exit | + | `wpa2` | four-way, group and pairwise rekeys at the AP; ping before and after; one association; MIC failures <= PTK installs; armed; the clear ran on exit; no `tx.retry_limit=0` warning | + | `noarm` | control, `DEVOURER_STA_ARM=0`: no arm and no clear ran (link outcome reported, not scored) | + | `retry0` | `DEVOURER_TX_RETRY_LIMIT=0`: the arm-time warning; the clear ran on exit (link outcome reported, not scored) | + + The clear's result is printed as information, not scored: on MT7612U + `ClearStationIdentity` is trivially true. + + Exit 0 pass, 1 fail (including a station fault, exit 3, with its cause + named), 2 inconclusive (rig refused, AP not up, route not through the TAP, + the station exited or stalled before `sta_client up:`, station out of + time), 3 interrupted. `FW_DIR` must hold the decompressed MT7612U blobs. The AP's phy must be able to change + network namespace (`iw phy info` lists `set_wiphy_netns`): an + in-kernel cfg80211 driver such as rtw88 or mt76. Out-of-tree drivers such + as rtl88x2cu / 88x2bu cannot, and the cell refuses them. + +## What it does not do + +- The on-air cell takes an MT7612U only (`sta_dut_take`) until a generic + DUT take / hand-back exists. The client itself arms a Realtek 8822C / + 8822B selected with `DEVOURER_VID` / `DEVOURER_PID`; that path is not + covered by an on-air cell here. +- Software CCMP only; no PMF/802.11w, WPA2-PSK/CCMP or open only. +- No fragment reassembly and no A-MSDU: both are refused and counted. +- One BSS at a time, chosen by SSID; no roaming and no background scan while + associated (a retune would lose the association). +- A pairwise rekey can cost one received frame (802.11-2016 12.7.6.5); the + note is at the `ccmp_decrypt` call in `rx_frame()`. +- The host stack owns ARP, IP and DHCP on the TAP. diff --git a/docs/station-core.md b/docs/station-core.md index 271c6021..b90dbd9d 100644 --- a/docs/station-core.md +++ b/docs/station-core.md @@ -66,9 +66,11 @@ functions is `src/sta/CLAUDE.md`. ## What this does not do No device, no hardware crypto offload, no PMF/802.11w, WPA2-PSK with CCMP -only, and no AP-side per-station state. The data plane is the caller's: -`DupDetector` and the MSDU<->Ethernet helpers in `Dot11.h` are tested but -have no in-tree caller, and `StationSm` runs no duplicate cache. Three limits are stated at their +only, and no AP-side per-station state. The data plane is the caller's; +`StationSm` runs no duplicate cache. The in-tree caller that wires this core +to a radio - scan, join policy, data plane, `DupDetector` and the +MSDU<->Ethernet helpers - is the station client (`docs/station-client.md`). +Three limits are stated at their declarations rather than here: - the replay-window width, and why it must grow before HE/EHT use: `CcmpReplay`; - the SNonce policy: `Supplicant::start`; diff --git a/examples/common/env_config.cpp b/examples/common/env_config.cpp index 240c911b..ca169f3a 100644 --- a/examples/common/env_config.cpp +++ b/examples/common/env_config.cpp @@ -101,6 +101,10 @@ devourer::RxMode parse_rx_mode(const char *s) { } // namespace +bool devourer_env_long_strict(const char *name, long *out) { + return env_long_strict(name, out); +} + devourer::DeviceConfig devourer_config_from_env() { devourer::DeviceConfig cfg; long v = 0; diff --git a/examples/common/env_config.h b/examples/common/env_config.h index 6d3d7f78..7c2eb249 100644 --- a/examples/common/env_config.h +++ b/examples/common/env_config.h @@ -17,6 +17,12 @@ * See env_config.cpp for the full mapping table. */ devourer::DeviceConfig devourer_config_from_env(); +/* The strict whole-string integer parse devourer_config_from_env applies to + * DEVOURER_TX_RETRY_LIMIT: true and *out only when the variable is set and is + * one number; a set but non-numeric value warns and returns false. For a demo + * that must know whether the library took the value. */ +bool devourer_env_long_strict(const char *name, long *out); + /* DEVOURER_TX_RATE parsed to a TxMode (unset -> the 6M-legacy default). */ devourer::TxMode devourer_tx_mode_from_env(); diff --git a/src/sta/CLAUDE.md b/src/sta/CLAUDE.md index 87e77166..c225ed13 100644 --- a/src/sta/CLAUDE.md +++ b/src/sta/CLAUDE.md @@ -55,7 +55,7 @@ and the cell, never here. | Received frames: beacons, deauth, auth and (re)assoc responses, no-data subtypes | `StationSm::on_rx`, `on_auth`, `on_assoc_resp` | station_sm: `test_header_only_beacons_do_not_hold_off_loss`, `test_short_deauth_is_malformed`, `test_deauth_during_handshake`, `test_reassoc_resp_does_not_complete_a_join`, `test_truncated_auth_and_assoc_are_malformed`, `test_qos_null_is_ignored_and_alive` | | The handshake deadline | `StationSm::eapol_reply`, `on_eapol` | station_sm: `test_dropped_reply_does_not_move_the_deadline` | | The TX queue: its bound, what `pop_tx` refuses | `StationSm::queue`, `pop_tx`, `kMaxTxQueue` | station_sm: `test_transmit_queue_is_bounded`, `test_join_clears_the_transmit_queue`, `test_pop_tx_refuses_null` | -| Duplicate cache (no in-tree consumer yet) | `DupDetector` | dot11_frames: `test_dup_detector` | +| Duplicate cache (consumer: `tests/sta_client.cpp`) | `DupDetector` | dot11_frames: `test_dup_detector`; sta_client_headless: `test_a_retransmission_is_a_duplicate` | ## Tests @@ -66,6 +66,7 @@ and the cell, never here. | `ccmp_framing` | `tests/ccmp_selftest.cpp` | Ccmp.h + both CCMP vector sets | OpenSSL | | `supplicant` | `tests/supplicant_selftest.cpp` | Eapol.h, Supplicant.h, the hostapd four-way | OpenSSL | | `station_sm` | `tests/station_sm_selftest.cpp` | StationSm.h incl. the group rekey path | OpenSSL | +| `sta_client_headless` | `tests/sta_client.cpp --self-test` (`tests/sta_client_selftest.inc`) | the station client's scan, join/re-join policy and data plane over this core | OpenSSL, Linux | | `ccmp_vectors_generated` | `tests/ccmp_gen_vectors.py --check` | `tests/ccmp_vectors.h` is what the generator emits | Python3 + python-cryptography (else skipped) | The OpenSSL cells are simply not registered without OpenSSL (configure @@ -93,8 +94,9 @@ No device or radio calls; no hardware crypto offload; no PMF/802.11w Replay-window width and why it must change before any HE/EHT use: `CcmpReplay`. -`Dot11.h`'s MSDU<->Ethernet conversion and `DupDetector` have no in-tree -caller yet (`StationSm` does not run the duplicate cache; its contract is -at `DupDetector`), and this tree's AP harnesses (`tests/ap_responder.cpp`, +`Dot11.h`'s MSDU<->Ethernet conversion and `DupDetector` are called by the +station client, `tests/sta_client.cpp` (`StationSm` does not run the +duplicate cache; its contract is at `DupDetector`), and this tree's AP +harnesses (`tests/ap_responder.cpp`, `tests/ap_wpa2.cpp`) carry their own inline builders rather than using this module. diff --git a/tests/mt7612u_sta_lib.sh b/tests/mt7612u_sta_lib.sh index 4e2dcb49..45c8a338 100644 --- a/tests/mt7612u_sta_lib.sh +++ b/tests/mt7612u_sta_lib.sh @@ -1,7 +1,7 @@ # shellcheck shell=sh # mt7612u_sta_lib.sh - shared plumbing for the station harnesses -# (tests/mt7612u_sta_identity.sh, _autoack.sh, _uplink.sh; the generic -# helpers also serve tests/realtek_station_onair.sh). Sourced, not run. +# (tests/mt7612u_sta_identity.sh, _autoack.sh, _uplink.sh, _onair.sh; the +# generic helpers also serve tests/realtek_station_onair.sh). Sourced, not run. # # Four rules these scripts run as root under: # @@ -147,27 +147,35 @@ sta_pid_init() { sta_pid_record() { echo "$2" > "$OUT/.pid_$1"; } -# Signal ($2, default TERM) the process recorded under $1, reap it if it is -# our child, and forget it. A process started inside a command substitution -# is not this shell's child, so `wait` returns at once for it: poll `kill -0` -# for up to 10 s so the caller knows it has really exited (a demo's chip -# de-init runs after the signal). Returns 1, and says so, if it is still -# alive then; 0 otherwise, and silently when nothing is recorded. +# Is PID running? `kill -0` alone also succeeds on an exited but unreaped +# child (a zombie, state Z in /proc/PID/stat after the command name). +sta_pid_alive() { + _sta_st=$(sed 's/^.*) //' "/proc/$1/stat" 2>/dev/null | cut -d' ' -f1) + [ -n "$_sta_st" ] && [ "$_sta_st" != Z ] && [ "$_sta_st" != X ] +} + +# Signal ($2, default TERM) the process recorded under $1, wait up to 10 s +# for it to exit (a demo's chip de-init runs after the signal), reap it if it +# is our child, and forget it. POLLED, never a bare `wait` first: a child +# that ignores the signal - or a background job started with SIGINT ignored, +# as a non-interactive shell starts them - would block that `wait` for good. +# Returns 1, and says so, if it is still alive then (unreaped, so the caller +# can escalate by PID); 0 otherwise, and silently when nothing is recorded. sta_pid_kill() { [ -f "$OUT/.pid_$1" ] || return 0 _sta_pid=$(cat "$OUT/.pid_$1" 2>/dev/null) rm -f "$OUT/.pid_$1" case "$_sta_pid" in ''|*[!0-9]*) return 0 ;; esac kill "-${2:-TERM}" "$_sta_pid" 2>/dev/null - wait "$_sta_pid" 2>/dev/null _sta_t=0 - while kill -0 "$_sta_pid" 2>/dev/null; do + while sta_pid_alive "$_sta_pid"; do if [ "$_sta_t" -ge 100 ]; then echo "$1 (pid $_sta_pid) is still running 10 s after SIG${2:-TERM}" return 1 fi sleep 0.1; _sta_t=$((_sta_t + 1)) done + wait "$_sta_pid" 2>/dev/null # exited: reaps our child, no-op otherwise return 0 } diff --git a/tests/mt7612u_sta_onair.sh b/tests/mt7612u_sta_onair.sh new file mode 100755 index 00000000..ebb31fa9 --- /dev/null +++ b/tests/mt7612u_sta_onair.sh @@ -0,0 +1,533 @@ +#!/usr/bin/env bash +# mt7612u_sta_onair.sh - tests/sta_client.cpp joining a real hostapd AP, end +# to end, with the station identity armed through IRadio::SetStationIdentity. +# +# The MT7612U (DUT_SYSFS) runs sta_client: scan, authenticate, associate, the +# WPA2-PSK four-way and CCMP over src/sta/, a TAP device for the host. A +# kernel-driven adapter (AP_SYSFS) runs hostapd - an independent +# implementation on independent silicon, which is what makes its log a +# witness: "EAPOL-4WAY-HS-COMPLETED" means the AUTHENTICATOR verified our +# message 4's MIC. +# +# THE AP LIVES IN A NETWORK NAMESPACE. Both radios are on one host; with both +# addresses in the root namespace the kernel routes the ping locally and it +# never touches the air. The phy moves with `iw phy set netns`, and +# every data-plane check first asserts that `ip route get` leaves through the +# station's TAP. +# +# Cells (each scored against its own witness): +# open hostapd open: the AP associates OUR address; ping 0% loss over +# the TAP; the ledger shows plaintext and no decryption; the arm +# line, and the clear ran on exit. +# wpa2 hostapd WPA2-PSK with group and pairwise rekeys: four-way, both +# rekeys completed at the AP, ping 0% loss, ONE association +# throughout, MIC failures <= PTK installs (a pairwise rekey has a +# one-frame switchover window, see rx_frame() in sta_client.cpp), +# the arm line, the clear ran on exit, and NO tx.retry_limit=0 +# warning (the station default is nonzero). +# noarm the control: wpa2 with DEVOURER_STA_ARM=0 - nothing else +# changes. Scored: no SetStationIdentity and no clear ran. +# Reported, not scored: whether it associated and carried the +# ping (the MT7612U arm writes no register - docs/station-client.md). +# retry0 wpa2 with DEVOURER_TX_RETRY_LIMIT=0. Scored: the library's +# arm-time warning about tx.retry_limit=0 (logged inside a +# successful SetStationIdentity, so just before sta_client's +# "armed" line), and the clear ran on exit. +# Reported, not scored: the link outcome with a single-shot uplink. +# +# The clear is scored as having RUN, not by its result: on MT7612U +# ClearStationIdentity is trivially true (the arm wrote nothing), so the +# result is printed as information. +# +# Liveness: a data-plane check runs only while sta_client is alive, and again +# checks it afterwards - a ping that straddles the station's exit reports +# loss on a working link. A station that exits, or is not up within +# READY_TIMEOUT, before printing `sta_client up:` is a rig / bring-up problem +# (INCONCLUSIVE, whatever its status). After `up:`, an exit with status 0 +# ran out of SECS (INCONCLUSIVE); 3 is a FAULT the station caught (an +# exception or a failed TAP; `fault=1` in its ledger) and is a FAIL with the +# cause named, wherever in the cell it happens; any other status is a FAIL. +# +# Exit status: 0 every scored check passed; 1 a check failed; 2 INCONCLUSIVE +# (the rig was refused, the station did not come up, the AP did not come up, +# the route did not leave through the TAP, or a cell was cut short); +# 3 interrupted. +# +# sudo DUT_SYSFS=1-1 AP_SYSFS=5-1 tests/mt7612u_sta_onair.sh +# sudo DUT_SYSFS=1-1 AP_SYSFS=5-1 CH=6 tests/mt7612u_sta_onair.sh wpa2 retry0 +# +# Rig: DUT_SYSFS an MT7612U (0e8d:7612), unbound from mt76x2u here and +# re-enumerated at the end; AP_SYSFS an adapter whose kernel driver supports +# AP mode AND lets its phy change network namespace (`iw phy` lists +# set_wiphy_netns): an in-kernel cfg80211 driver such as rtw88 or mt76. +# Out-of-tree drivers such as rtl88x2cu / 88x2bu cannot, and are refused. +# Read AP_SYSFS from `lsusb -t` after its driver has loaded (it can move). +# FW_DIR must hold the DECOMPRESSED MT7612U blobs (mt7662*.bin); a host that +# ships only mt7662*.bin.zst gets INCONCLUSIVE (rig/bring-up). +# Build first: cmake --build build --target StaClientSelftest (build/sta_client). +# +# Env: DUT_SYSFS, AP_SYSFS, CH, SSID, PSK, SECS, REKEY_S, PTK_REKEY_S, FW_DIR, +# NS, TAP, READY_TIMEOUT, OUT, BUILD. +# Cells: open | wpa2 | noarm | retry0 | all (default: all four). + +# The cells are reached as "cell_$c" and cleanup through the traps. +# shellcheck disable=SC2317 +set -u +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BUILD="${BUILD:-$ROOT/build}" +DUT_SYSFS="${DUT_SYSFS:-}" +AP_SYSFS="${AP_SYSFS:-}" +CH="${CH:-6}" +SSID="${SSID:-devourerSTA}" +PSK="${PSK:-devourer123}" +# Budget from process start, not from association: firmware load, the TAP and +# the association all come before the measurement. +SECS="${SECS:-90}" +# hostapd's group and pairwise rekey intervals for the wpa2 cell. Both must +# fire inside the run: the rekeys travel inside the cipher, a path the +# four-way alone never exercises. +REKEY_S="${REKEY_S:-20}" +PTK_REKEY_S="${PTK_REKEY_S:-25}" +FW_DIR="${FW_DIR:-/lib/firmware/mediatek}" +NS="${NS:-staonair}" +TAP="${TAP:-dvsta0}" +READY_TIMEOUT="${READY_TIMEOUT:-30}" +OUT="${OUT:-}" +APIP=192.168.98.1 +STAIP=192.168.98.2 + +CELLS="${*:-all}" +[ "$CELLS" = all ] && CELLS="open wpa2 noarm retry0" +for c in $CELLS; do + case "$c" in open|wpa2|noarm|retry0) ;; *) echo "unknown cell '$c'"; exit 2 ;; esac +done + +[ "$(id -u)" = 0 ] || { echo "must run as root"; exit 2; } +for v in CH SECS REKEY_S PTK_REKEY_S READY_TIMEOUT; do + case "${!v}" in ''|*[!0-9]*) echo "$v must be a non-negative integer"; exit 2 ;; esac +done +[ -n "$DUT_SYSFS" ] || { echo "DUT_SYSFS is required (lsusb -t)"; exit 2; } +[ -n "$AP_SYSFS" ] || { echo "AP_SYSFS is required (lsusb -t)"; exit 2; } +[ "$DUT_SYSFS" != "$AP_SYSFS" ] || { echo "DUT_SYSFS and AP_SYSFS must differ"; exit 2; } +command -v hostapd >/dev/null || { echo "hostapd is required"; exit 2; } +[ -x "$BUILD/sta_client" ] || { + echo "$BUILD/sta_client is not built (cmake --build build --target StaClientSelftest)"; exit 2; } +if [ -e "/sys/class/net/$TAP" ]; then + echo "TAP=$TAP already exists - refusing to use or delete it (set TAP=)"; exit 2 +fi +ns_exists() { ip netns list 2>/dev/null | awk '{print $1}' | grep -qx "$NS"; } +if ns_exists; then + echo "netns $NS already exists - recover or remove it first (set NS= to use another name)" + exit 2 +fi + +# shellcheck source=tests/mt7612u_sta_lib.sh +. "$ROOT/tests/mt7612u_sta_lib.sh" +sta_out_prepare || exit 2 +sta_lock_take || exit 2 +sta_pid_init sta hostapd + +# --- the AP adapter: refused unless it is plainly a spare wireless adapter -- +ap_refuse() { echo "refusing AP_SYSFS=$AP_SYSFS: $*"; sta_lock_release; exit 2; } +[ -n "$(cat "/sys/bus/usb/devices/$AP_SYSFS/idVendor" 2>/dev/null)" ] || + ap_refuse "not a USB device - if its driver just loaded it may have moved; re-read lsusb -t" +[ "$(cat "/sys/bus/usb/devices/$AP_SYSFS/bDeviceClass" 2>/dev/null)" != "09" ] || ap_refuse "a hub" +AP_IF=$(sta_first_netdev "$AP_SYSFS") +[ -n "$AP_IF" ] || ap_refuse "no network interface on it" +[ -e "/sys/class/net/$AP_IF/phy80211" ] || ap_refuse "$AP_IF is not wireless" +for fam in -4 -6; do + ip "$fam" route show default 2>/dev/null | grep -qw "dev $AP_IF" && + ap_refuse "$AP_IF carries a default route" +done +AP_PHY=$(basename "$(readlink -f "/sys/class/net/$AP_IF/phy80211")") +iw phy "$AP_PHY" info 2>/dev/null | grep -q '\* AP$' || ap_refuse "$AP_IF ($AP_PHY) does not support AP mode" +iw phy "$AP_PHY" info 2>/dev/null | grep -q set_wiphy_netns || + ap_refuse "the AP phy cannot change network namespace (in-kernel cfg80211 driver needed, e.g. rtw88/mt76; out-of-tree rtl88x2cu/88x2bu cannot)" +# Restored after the phy comes back: the move takes the interface down. +AP_WAS_UP=no +ip link show "$AP_IF" 2>/dev/null | grep -q '[<,]UP[,>]' && AP_WAS_UP=yes + +# Flags and traps BEFORE anything is taken, so every exit from here on hands +# back what was. +NM_AP=no; NS_OURS=no; CLEANED=no; STA_HUNG=no; STA_PID=""; CELL="" +cleanup() { + [ "$CLEANED" = yes ] && return 0 + CLEANED=yes + local sta_gone=0 + # INT, then KILL after its window (sta_stop) - never a bare blocking wait. + [ -n "$STA_PID" ] && sta_stop + [ "$STA_HUNG" = yes ] && sta_gone=1 + sta_pid_kill hostapd + # THE PHY COMES BACK BEFORE THE NAMESPACE GOES: `ip netns del` on a + # namespace still holding a phy destroys the phy (only a re-enumeration + # brings it back). So the delete is conditional on the move having worked. + if [ "$NS_OURS" = yes ] && ns_exists; then + ip netns exec "$NS" iw phy "$AP_PHY" set netns 1 2>/dev/null + sleep 1 + if ip netns exec "$NS" ls /sys/class/ieee80211/ 2>/dev/null | grep -q .; then + echo "WARNING: a phy is still in netns $NS - NOT deleting it. Recover with:" + echo " sudo ip netns exec $NS iw phy $AP_PHY set netns 1; sudo ip netns del $NS" + else + ip netns del "$NS" 2>/dev/null + [ "$AP_WAS_UP" = yes ] && ip link set "$AP_IF" up 2>/dev/null + fi + fi + [ "$NM_AP" = yes ] && nmcli device set "$AP_IF" managed yes >/dev/null 2>&1 + # The DUT is re-enumerated only once sta_client has really exited: a + # re-enumeration inside its teardown is what the hand-back must not do. + if [ "$sta_gone" = 0 ] && [ "$STA_HUNG" = no ]; then sta_dut_handback + else echo "sta_client still running - not re-enumerating $DUT_SYSFS"; fi + sta_lock_release +} +trap cleanup EXIT +trap 'cleanup; exit 3' INT TERM + +sta_dut_take || exit 2 + +if command -v nmcli >/dev/null 2>&1; then + case "$(nmcli -t -f DEVICE,STATE device 2>/dev/null | grep "^$AP_IF:")" in + "$AP_IF:unmanaged"|"") : ;; + *) nmcli device set "$AP_IF" managed no >/dev/null 2>&1 && NM_AP=yes ;; + esac +fi +rfkill unblock wlan 2>/dev/null +NS_OURS=yes +ip netns add "$NS" || { echo "could not create netns $NS"; exit 2; } +iw phy "$AP_PHY" set netns name "$NS" || { echo "could not move $AP_PHY into $NS"; exit 2; } +sleep 2 +ip netns exec "$NS" ip link set "$AP_IF" up 2>/dev/null + +echo "DUT MT7612U at $DUT_SYSFS ($(sta_usb_id "$DUT_SYSFS"))" +echo "AP $AP_IF ($AP_PHY) at $AP_SYSFS, in netns $NS" +echo "ch$CH ssid '$SSID' tap $TAP cells: $CELLS" +echo "logs: $OUT" + +pass=0; fail=0; inconclusive=0 +ok() { pass=$((pass+1)); printf ' PASS %s\n' "$*"; } +bad() { fail=$((fail+1)); printf ' FAIL %s\n' "$*"; } +inc() { inconclusive=$((inconclusive+1)); printf ' INCONCLUSIVE %s\n' "$*"; } +info() { printf ' INFO %s\n' "$*"; } + +# Is PID running? kill -0 also succeeds on an unreaped zombie. +proc_running() { + local st + st=$(sed 's/^.*) //' "/proc/$1/stat" 2>/dev/null | cut -d' ' -f1) + [ -n "$st" ] && [ "$st" != Z ] && [ "$st" != X ] +} + +# Wait for an extended regex in a file. 0 found, 1 timed out. +wait_for() { # $1 file, $2 regex, $3 seconds + local t=0 + until grep -qE "$2" "$1" 2>/dev/null; do + [ "$t" -ge "$3" ] && return 1 + sleep 1; t=$((t + 1)) + done +} + +# --- the AP ----------------------------------------------------------------- +# hostapd runs in the foreground (backgrounded here) with its event stream on +# stdout: AP-STA-CONNECTED, EAPOL-4WAY-HS-COMPLETED and the rekey lines are +# read from that file. AP up is judged by the interface type, not the log. +ap_up() { # $1 open | wpa2, $2 cell + { + printf 'interface=%s\ndriver=nl80211\nssid=%s\n' "$AP_IF" "$SSID" + if [ "$CH" -le 14 ]; then printf 'hw_mode=g\n'; else printf 'hw_mode=a\n'; fi + printf 'channel=%s\nieee80211n=1\nauth_algs=1\nwmm_enabled=1\n' "$CH" + if [ "$1" = wpa2 ]; then + printf 'wpa=2\nwpa_passphrase=%s\nwpa_key_mgmt=WPA-PSK\nrsn_pairwise=CCMP\n' "$PSK" + printf 'wpa_group_rekey=%s\nwpa_ptk_rekey=%s\n' "$REKEY_S" "$PTK_REKEY_S" + fi + } > "$OUT/hostapd_$2.conf" + ip netns exec "$NS" hostapd -t "$OUT/hostapd_$2.conf" > "$OUT/hostapd_$2.log" 2>&1 & + sta_pid_record hostapd $! + local t=0 + until ip netns exec "$NS" iw dev "$AP_IF" info 2>/dev/null | grep -q 'type AP'; do + [ "$t" -ge 15 ] && return 1 + sleep 1; t=$((t + 1)) + done + ip netns exec "$NS" ip addr flush dev "$AP_IF" 2>/dev/null + ip netns exec "$NS" ip addr add "$APIP/24" dev "$AP_IF" +} + +# --- the station -------------------------------------------------------------- +# 0 up; 1 exited before `sta_client up:`; 2 still not up after READY_TIMEOUT. +sta_up() { # $1 cell, $2 seconds, $3.. extra env + local cell="$1" secs="$2"; shift 2 + env DEVOURER_VID=0x0e8d DEVOURER_PID=0x7612 \ + DEVOURER_USB_BUS="${DUT_SYSFS%%-*}" DEVOURER_USB_PORT="${DUT_SYSFS#*-}" \ + DEVOURER_MT7612U_FW_DIR="$FW_DIR" DEVOURER_LOG_LEVEL=info \ + DEVOURER_CHANNEL="$CH" DEVOURER_STA_SSID="$SSID" DEVOURER_STA_TAP="$TAP" \ + "$@" "$BUILD/sta_client" "$secs" > "$OUT/sta_$cell.log" 2>&1 & + STA_PID=$! + sta_pid_record sta "$STA_PID" + local t=0 + until grep -q 'sta_client up:' "$OUT/sta_$cell.log" 2>/dev/null; do + proc_running "$STA_PID" || return 1 + [ "$t" -ge "$READY_TIMEOUT" ] && return 2 + sleep 1; t=$((t + 1)) + done +} + +# The station never printed `sta_client up:` - a rig / bring-up problem, not +# a verdict on the station, whatever the exit status. $1 cell, $2 sta_up's rc. +station_not_up() { + if [ "$2" = 2 ]; then + inc "$1: sta_client not up within READY_TIMEOUT=${READY_TIMEOUT}s (rig/bring-up) - see $OUT/sta_$1.log" + return + fi + sta_stop + if [ "$STA_RC" = 2 ]; then + inc "$1: sta_client REFUSED the adapter (station_mode_ok false): $(grep -m1 REFUSED "$OUT/sta_$1.log")" + else + inc "$1: sta_client exited before 'up' (status $STA_RC; rig/bring-up): $(tail -1 "$OUT/sta_$1.log" 2>/dev/null)" + fi +} + +# Stop the station (INT: it leaves the BSS, clears the identity and prints its +# ledger) and record its exit status in STA_RC. +STA_RC="" +sta_stop() { + STA_RC="" + [ -n "$STA_PID" ] || return 0 + if proc_running "$STA_PID"; then kill -INT "$STA_PID" 2>/dev/null; fi + local t=0 + while proc_running "$STA_PID" && [ "$t" -lt 15 ]; do sleep 1; t=$((t + 1)); done + if proc_running "$STA_PID"; then + # KILL, not TERM: TERM is handled exactly like INT. Still alive after it + # means the DUT must not be re-enumerated under it. + sta_pid_kill sta KILL || STA_HUNG=yes + STA_RC=killed + else + wait "$STA_PID" 2>/dev/null; STA_RC=$? + rm -f "$OUT/.pid_sta" + fi + STA_PID="" + # Exit 3 is a fault the station caught and tore down cleanly: a FAIL + # wherever it happens, with the cause named. + if [ "$STA_RC" = 3 ] && [ -n "$CELL" ]; then + bad "$CELL: sta_client FAULT (exit 3): $(fault_cause "$CELL")" + fi +} + +# The TAP up, and the route to the AP proven to leave through it. +tap_up() { + local t=0 + until [ -d "/sys/class/net/$TAP" ]; do + [ "$t" -ge 20 ] && return 1 + sleep 1; t=$((t + 1)) + done + command -v nmcli >/dev/null 2>&1 && nmcli device set "$TAP" managed no >/dev/null 2>&1 + ip link set "$TAP" up 2>/dev/null + ip addr flush dev "$TAP" 2>/dev/null + ip addr add "$STAIP/24" dev "$TAP" 2>/dev/null + sleep 1 + case "$(ip route get "$APIP" 2>/dev/null)" in *"dev $TAP"*) return 0 ;; esac + return 1 +} + +own_of() { sed -n 's/^sta_client up: own \([0-9a-f:]\{17\}\) .*/\1/p' "$OUT/sta_$1.log" | head -1; } +# One numeric field from the station's exit ledger. +led() { sed -n "s/.*$2=\\([0-9][0-9]*\\).*/\\1/p" "$OUT/sta_$1.log" | tail -1; } + +# Ping the AP over the air. 0 = 0% loss, 1 = loss, 2 = the station was not +# alive for the whole measurement (no verdict on the link). +ping_ap() { # $1 cell + proc_running "$STA_PID" || return 2 + ping -c 1 -W 3 -I "$TAP" "$APIP" >/dev/null 2>&1 # warm ARP + ping -c 6 -W 1 -I "$TAP" "$APIP" > "$OUT/ping_$1.txt" 2>&1 + proc_running "$STA_PID" || return 2 + grep -q ' 0% packet loss' "$OUT/ping_$1.txt" +} +loss() { grep -oE '[0-9.]+% packet loss' "$OUT/ping_$1.txt" 2>/dev/null | head -1; } + +# The station exited after `sta_client up:` but before its measurement: +# status 0 ran out of SECS (INCONCLUSIVE); anything else is a FAIL. +station_gone() { # $1 cell + sta_stop + case "$STA_RC" in + 0) inc "$1: sta_client ran out of time before the measurement - raise SECS (now $SECS)" ;; + 3) ;; # a FAULT: reported by sta_stop + *) bad "$1: sta_client exited early (status $STA_RC) - see $OUT/sta_$1.log" ;; + esac +} + +# The cause of a sta_client FAULT (exit 3, `fault=1` in the ledger). +fault_cause() { + grep -m1 'FAULT\|threw' "$OUT/sta_$1.log" 2>/dev/null | sed 's/^ *//' +} + +# The clear ran on exit (scored); its result, which is trivially true on +# MT7612U, is information. +check_cleared() { # $1 cell + local line + line=$(grep -m1 'station identity clear:' "$OUT/sta_$1.log" | sed 's/^ *//') + if [ -n "$line" ]; then + ok "$1: ClearStationIdentity ran on exit" + info "$1: $line (trivially true on MT7612U: the arm wrote nothing)" + else + bad "$1: ClearStationIdentity did not run on exit" + fi +} + +# Arm and clear lines: the identity was armed for the AP's BSSID, and the +# clear ran on the way out. +check_armed() { # $1 cell + if grep -q '^ station identity armed for BSSID' "$OUT/sta_$1.log"; then + ok "$1: SetStationIdentity armed ($(grep -m1 '^ station identity armed' "$OUT/sta_$1.log" | sed 's/^ *//'))" + else + bad "$1: the identity was never armed ($(grep -m1 'station identity' "$OUT/sta_$1.log" || echo 'no arm line'))" + fi + check_cleared "$1" +} + +cell_end() { sta_stop; sta_pid_kill hostapd; } + +# --- open --------------------------------------------------------------------- +cell_open() { + CELL=open + echo; echo "== open: hostapd open network ==" + ap_up open open || { inc "open: hostapd did not bring $AP_IF up in AP mode - see $OUT/hostapd_open.log"; cell_end; return; } + local up=0 + sta_up open "$SECS" DEVOURER_STA_PSK= || up=$? + [ "$up" = 0 ] || { station_not_up open "$up"; cell_end; return; } + local own; own=$(own_of open) + tap_up || { inc "open: no TAP, or the route to $APIP does not leave through $TAP"; cell_end; return; } + if ! wait_for "$OUT/hostapd_open.log" "AP-STA-CONNECTED $own" 30; then + if proc_running "$STA_PID"; then bad "open: the AP never associated $own"; cell_end + else station_gone open; sta_pid_kill hostapd; fi + return + fi + ok "open: the AP associated $own" + ping_ap open; case $? in + 0) ok "open: ping over the air, $(loss open)" ;; + 1) bad "open: ping $(loss open)" ;; + *) station_gone open; sta_pid_kill hostapd; return ;; + esac + cell_end + local plain enc + plain=$(led open 'plaintext rx'); enc=$(led open 'encrypted rx') + if [ "${plain:-0}" -gt 0 ] && [ "${enc:-x}" = 0 ]; then + ok "open: ledger plaintext rx=$plain, encrypted rx=0" + else + bad "open: ledger plaintext rx=${plain:-?} encrypted rx=${enc:-?} (expected >0 and 0)" + fi + check_armed open +} + +# --- wpa2 and its two variants -------------------------------------------------- +# $1 cell (wpa2 | noarm | retry0), $2.. extra station env. Returns after the +# station has stopped; the caller scores the arm-specific lines. +WPA2_LINK="" +run_wpa2() { + local cell="$1"; shift + CELL="$cell" + WPA2_LINK="" + ap_up wpa2 "$cell" || { inc "$cell: hostapd did not bring $AP_IF up in AP mode - see $OUT/hostapd_$cell.log"; cell_end; return 1; } + local secs=$(( SECS + 2 * REKEY_S + PTK_REKEY_S )) + local up=0 + sta_up "$cell" "$secs" DEVOURER_STA_PSK="$PSK" "$@" || up=$? + [ "$up" = 0 ] || { station_not_up "$cell" "$up"; cell_end; return 1; } + local own; own=$(own_of "$cell") + tap_up || { inc "$cell: no TAP, or the route to $APIP does not leave through $TAP"; cell_end; return 1; } + if ! wait_for "$OUT/hostapd_$cell.log" "EAPOL-4WAY-HS-COMPLETED $own" 30; then + WPA2_LINK="no four-way" + if ! proc_running "$STA_PID"; then station_gone "$cell"; sta_pid_kill hostapd; return 1; fi + cell_end + return 0 + fi + local p=0 + ping_ap "$cell" || p=$? + if [ "$p" = 2 ]; then station_gone "$cell"; sta_pid_kill hostapd; return 1; fi + WPA2_LINK="four-way completed, ping $(loss "$cell")" + [ "$p" = 0 ] && WPA2_LINK="$WPA2_LINK OK" + [ "$cell" = wpa2 ] || { cell_end; return 0; } + + # The rekeys: waited for while the station is alive. "pairwise key + # handshake completed" is logged for the initial four-way too, so a PTK + # rekey is the SECOND such line. + local t=0 gk=0 pk=0 lim=$(( REKEY_S + PTK_REKEY_S + 25 )) + while [ "$t" -lt "$lim" ] && proc_running "$STA_PID"; do + gk=$(grep -c 'group key handshake completed' "$OUT/hostapd_$cell.log" 2>/dev/null) + pk=$(grep -c 'pairwise key handshake completed' "$OUT/hostapd_$cell.log" 2>/dev/null) + [ "${gk:-0}" -ge 1 ] && [ "${pk:-0}" -ge 2 ] && break + sleep 1; t=$((t + 1)) + done + if ! proc_running "$STA_PID" && { [ "${gk:-0}" -lt 1 ] || [ "${pk:-0}" -lt 2 ]; }; then + station_gone "$cell"; sta_pid_kill hostapd; return 1 + fi + if [ "${gk:-0}" -ge 1 ]; then ok "$cell: the AP completed a group rekey" + else bad "$cell: no group rekey completed in ${lim}s"; fi + if [ "${pk:-0}" -ge 2 ]; then ok "$cell: the AP completed a pairwise rekey ($pk pairwise handshakes)" + else bad "$cell: no pairwise rekey in ${lim}s (${pk:-0} pairwise handshake(s))"; fi + # Still carrying traffic after both rekeys. + ping_ap "${cell}_after"; case $? in + 0) ok "$cell: ping after the rekeys, $(loss "${cell}_after")" ;; + 1) bad "$cell: ping after the rekeys $(loss "${cell}_after")" ;; + *) station_gone "$cell"; sta_pid_kill hostapd; return 1 ;; + esac + cell_end + return 0 +} + +cell_wpa2() { + echo; echo "== wpa2: hostapd WPA2-PSK, group rekey ${REKEY_S}s, pairwise rekey ${PTK_REKEY_S}s ==" + run_wpa2 wpa2 || return + case "$WPA2_LINK" in + *OK) ok "wpa2: $WPA2_LINK" ;; + *) bad "wpa2: ${WPA2_LINK:-no result} - see $OUT/sta_wpa2.log and $OUT/hostapd_wpa2.log" ;; + esac + [ "$WPA2_LINK" = "no four-way" ] && { check_armed wpa2; return; } + local assoc mic ptk ans + assoc=$(led wpa2 'associations'); mic=$(led wpa2 'MIC failures') + ptk=$(led wpa2 'PTK'); ans=$(led wpa2 'answered') + if [ "${assoc:-0}" = 1 ] && [ "${ans:-0}" -gt 0 ] && [ "${ptk:-0}" -ge 2 ] && + [ "${mic:-999}" -le "${ptk:-0}" ]; then + ok "wpa2: ledger associations=1, rekeys answered=$ans, PTK installs=$ptk, MIC failures=$mic (<= PTK installs)" + else + bad "wpa2: ledger associations=${assoc:-?} answered=${ans:-?} PTK=${ptk:-?} MIC failures=${mic:-?} (expected 1, >0, >=2, MIC <= PTK)" + fi + check_armed wpa2 + # The station default retry limit is nonzero, so the arm must NOT warn. + if grep -q 'station identity armed with tx.retry_limit=0' "$OUT/sta_wpa2.log"; then + bad "wpa2: the tx.retry_limit=0 warning fired with the station default limit" + else + ok "wpa2: no tx.retry_limit=0 warning ($(grep -m1 'tx.retry_limit' "$OUT/sta_wpa2.log" | sed 's/^ *//'))" + fi +} + +cell_noarm() { + echo; echo "== noarm (control): wpa2 with DEVOURER_STA_ARM=0 ==" + run_wpa2 noarm DEVOURER_STA_ARM=0 || return + if grep -q 'sta_client up:.* arm=0' "$OUT/sta_noarm.log" && + ! grep -q 'station identity' "$OUT/sta_noarm.log"; then + ok "noarm: no SetStationIdentity and no ClearStationIdentity ran" + else + bad "noarm: an arm or clear ran with DEVOURER_STA_ARM=0 ($(grep -m1 'station identity' "$OUT/sta_noarm.log"))" + fi + info "noarm: link unarmed: ${WPA2_LINK:-no result} (the MT7612U arm writes no register; a difference from wpa2 here is worth a look)" +} + +cell_retry0() { + echo; echo "== retry0: wpa2 with DEVOURER_TX_RETRY_LIMIT=0 ==" + run_wpa2 retry0 DEVOURER_TX_RETRY_LIMIT=0 || return + local armed warn + armed=$(grep -n -m1 '^ station identity armed for BSSID' "$OUT/sta_retry0.log" | cut -d: -f1) + warn=$(grep -n -m1 'station identity armed with tx.retry_limit=0' "$OUT/sta_retry0.log" | cut -d: -f1) + if [ -z "$armed" ]; then + inc "retry0: the identity was never armed, so the arm-time warning could not fire - see $OUT/sta_retry0.log" + elif [ -n "$warn" ] && [ "$warn" -lt "$armed" ]; then + ok "retry0: the library warned at arm time: $(sed -n "${warn}p" "$OUT/sta_retry0.log" | cut -c1-100)..." + else + bad "retry0: armed with tx.retry_limit=0 and no arm-time warning" + fi + check_cleared retry0 + info "retry0: single-shot uplink: ${WPA2_LINK:-no result}" +} + +for c in $CELLS; do "cell_$c"; done + +echo +echo "=== $pass passed, $fail failed, $inconclusive inconclusive (logs: $OUT) ===" +[ "$fail" -gt 0 ] && exit 1 +[ "$inconclusive" -gt 0 ] && exit 2 +exit 0 diff --git a/tests/sta_client.cpp b/tests/sta_client.cpp new file mode 100644 index 00000000..0da22a64 --- /dev/null +++ b/tests/sta_client.cpp @@ -0,0 +1,1333 @@ +/* sta_client.cpp — devourer as an 802.11 infrastructure STATION. + * + * The mirror of tests/ap_responder.cpp and tests/ap_wpa2.cpp: where those + * serve a BSS, this one JOINS one. Scan, authenticate, associate, run the + * WPA2-PSK four-way as the supplicant, and carry CCMP-protected data to and + * from the host through a TAP device. The protocol is src/sta/; this file is + * the integration around it, over IRadio. + * + * NO BACKEND BRANCH. The two places where the silicon genuinely differs are + * handled through the library: + * + * - the trailing FCS, via RxAtrib.fcs_present (see mpdu_len()). + * - the station identity, via IRadio::SetStationIdentity, called only when + * AdapterCaps::station_mode_ok is true (MT7612U, and the Realtek 8822C / + * 8822B arm - docs/realtek-station-arm.md). A backend that reports false + * is refused here (exit 2) rather than run as a station whose ACKs nobody + * armed; DEVOURER_STA_ARM=0 runs it unarmed, as a control. + * + * WHAT THIS OWNS THAT src/sta/ DOES NOT: + * + * - THE SCANNER. BssTable parses beacons and ranks them; scan_step() and + * supervise() wire it to StationSm with channels and dwell times. + * - THE RECONNECT POLICY. StationSm notices a silent AP and fails; when to + * re-join is an integrator's decision, and supervise() is this file's. + * - THE DATA PLANE. CCMP framing is library code; which key, which PN space + * and which replay / duplicate window a frame belongs to is decided here. + * There is one transmitter on this data plane - the joined AP - so one + * DupDetector covers it (src/sta/Dot11.h: one per transmitter). + * + * TRANSMISSION IS THIS FILE'S, NOT THE ARM'S (IRadio::SetStationIdentity). + * Unicast airs with an ACK-requesting radiotap (DEVOURER_STA_ACK=0 turns that + * off), and the hardware retry limit defaults to kStationRetryLimit unless + * DEVOURER_TX_RETRY_LIMIT is set - so DEVOURER_TX_RETRY_LIMIT=0 is how a run + * asks for the single-shot uplink the library warns about at arm time. + * + * THE STATION'S OWN ADDRESS IS THE ADAPTER'S. On MT7612U the auto-response + * engine matches address 1 against MT_MAC_ADDR, so a station transmitting + * from any other address is not acknowledged (docs/mt7612u-station-identity.md). + * `own` comes from GetPermanentMacAddress and is never invented. + * + * THE RECEIVE PATH IS PROMISCUOUS on MT7612U (Mt7612uRadio::StartRxLoop + * installs the monitor filter), so StationSm::on_rx is the address filter, + * and its refusal counters are printed at every exit: they distinguish "the + * AP never answered" from "we never heard the AP". + * + * Exit status: 0 the run completed (the ledger says how it went); 1 setup + * failed; 2 refused - the adapter's station_mode_ok is false, or the + * duration, DEVOURER_CHANNEL, DEVOURER_STA_SCAN_DWELL_MS or + * DEVOURER_STA_BACKOFF_MS is not a valid number in range; 3 a FAULT - an + * exception was caught or the TAP failed mid-run. The run still left the BSS, + * cleared the identity and printed its ledger (whose first line then carries + * `fault=1`), but it did not end the way it was asked to. + * + * Build: CMake target StaClientSelftest, binary build/sta_client. + * Run: + * sudo DEVOURER_VID=0x0e8d DEVOURER_PID=0x7612 DEVOURER_CHANNEL=6 \ + * DEVOURER_STA_SSID=devourerSTA DEVOURER_STA_PSK=devourer123 \ + * DEVOURER_STA_TAP=dvsta0 build/sta_client 60 + * Headless (no device, no root, no airtime): + * build/sta_client --self-test + * On air: tests/mt7612u_sta_onair.sh. + */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "DeviceSession.h" +#include "RadiotapBuilder.h" +#include "RxPacket.h" +#include "SelectedChannel.h" +#include "TxMode.h" +#include "UsbOpen.h" +#include "WiFiDriver.h" +#include "env_config.h" +#include "logger.h" +#include "openssl_crypto_ops.h" +#include "sta/BssTable.h" +#include "sta/Ccmp.h" +#include "sta/Dot11.h" +#include "sta/StationSm.h" +#include "usb_select.h" + +namespace { + +using devourer::sta::BssEntry; +using devourer::sta::BssTable; +using devourer::sta::StationSm; + +/* ---- configuration ----------------------------------------------------- */ + +/* The hardware retry limit a station runs with unless DEVOURER_TX_RETRY_LIMIT + * says otherwise. Nonzero because a station's unicast - authentication, + * association, the four-way, data - relies on MAC retransmission, and the + * library default of 0 sends each frame exactly once. */ +constexpr int kStationRetryLimit = 7; + +std::string g_ssid = "devourerAP"; +std::string g_psk; /* empty means an OPEN network */ +uint8_t g_chan = 6; +/* The channels scan_step() sweeps while unassociated; defaults to the one + * configured channel. Never swept while associated - retuning under a live + * association loses it. */ +std::vector g_scan_chans; +uint32_t g_scan_dwell_ms = 250; +bool g_reconnect = true; +uint32_t g_rejoin_backoff_ms = 1000; +/* DEVOURER_STA_ARM=0: never call SetStationIdentity. A control: everything + * else about the run is unchanged. */ +bool g_arm = true; + +/* ---- the radio side, which the headless cells never touch --------------- */ + +IRadio* g_dev = nullptr; +std::vector g_rt; /* NOACK radiotap: group-addressed frames */ +std::vector g_rt_ack; /* ACK-requested: unicast, unless empty */ +std::mutex g_q_mu; +std::vector> g_q; +std::atomic g_sent{0}, g_send_fail{0}, g_q_drop{0}; +/* The channel the radio is tuned to: BssTable::observe takes it as the + * channel of a beacon that does not state its own. Written by the main loop, + * read by the RX thread. No backend reports a frame's own RX channel + * (RxAtrib has none), so a frame received just before a retune can be + * delivered after it: 0 while a retune is in progress, and for kRetuneGuardMs + * after one rx_frame() passes "unknown" (0) - a beacon without a DS Parameter + * Set is then not folded in at all, rather than tagged with the new channel. */ +std::atomic g_tuned{6}; +std::atomic g_retune_ms{0}; +constexpr uint32_t kRetuneGuardMs = 50; + +/* ---- the station core, under one mutex --------------------------------- */ + +std::mutex g_mu; +devourer::test::OpenSslCryptoOps g_crypto; +BssTable g_bss; +StationSm g_sm; +uint8_t g_own[6] = {0}; +devourer::sta::SeqCounter g_data_seq; + +/* THE TRANSMIT PN SPACE BELONGS TO THE PAIRWISE KEY. It starts at 1 (PN 0 is + * never valid) and restarts at every PTK install - see note_keys(): reusing a + * PN under a new key is keystream reuse. */ +uint64_t g_tx_pn = 1; +devourer::sta::CcmpReplay g_rx_replay; /* pairwise, per TID */ +/* 802.11 duplicate detection for the AP's unicast: a retransmission a lost + * ACK caused is dropped before decrypt and counted as a duplicate, not a + * replay. Group frames are never retried and would clobber the cache. Reset + * per association (on_association), never per key. */ +devourer::sta::DupDetector g_rx_dup; +std::atomic g_dup_drop{0}; +devourer::sta::CcmpReplay g_group_replay; /* the GTK's own PN space */ +/* WHICH KEYS THE PN STATE BELONGS TO, as the supplicant's install + * generations rather than copies of the keys. */ +uint32_t g_ptk_gen_seen = 0; +uint32_t g_gtk_gen_seen = 0; + +/* the scan/join policy's own state */ +size_t g_scan_idx = 0; +uint32_t g_scan_switch_ms = 0; +uint32_t g_next_join_ms = 0; +int g_join_attempts = 0; +bool g_gave_up = false; +/* Entering Failed is an EVENT, and supervise() runs on every loop pass: the + * latch makes one lost link one reconnect, not one per pass. */ +bool g_failed_noted = false; +/* Cleared by every join attempt is the latch above; this one says whether + * the link was ever up since the last loss, so retries while the AP is away + * are not counted as more lost links. */ +bool g_was_associated = false; + +/* ---- the ledger --------------------------------------------------------- */ + +std::atomic g_beacons{0}, g_probe_tx{0}; +std::atomic g_joins{0}, g_associations{0}, g_reconnects{0}; +std::atomic g_enc_rx{0}, g_mic_fail{0}, g_replays{0}; +std::atomic g_group_rx{0}, g_plain_rx{0}, g_rx_short{0}; +/* One counter per direction, so each direction's books close on their own: + * from host == encrypted + plaintext + dropped down + * queued == aired + queue dropped + send failed */ +std::atomic g_tap_tx{0}, g_tap_rx{0}, g_tap_drop{0}, + g_tap_down_drop{0}; +std::atomic g_q_in{0}; /* every frame handed to enqueue() */ +std::atomic g_tap_stop{false}; /* the TAP reader's exit */ +std::atomic g_tap_read_err{0}; /* a fatal TAP poll/read: a fault */ +std::atomic g_tx_enc{0}, g_tx_enc_fail{0}, g_tx_plain{0}; +std::atomic g_crc_err{0}, g_amsdu_drop{0}, g_frag_drop{0}; +/* The group / pairwise rekey rides INSIDE the cipher, so it is counted apart + * from the four-way's cleartext EAPOL. */ +std::atomic g_eapol_enc_rx{0}, g_eapol_enc_tx{0}; +/* How many times each key has actually been installed. A rekey the data + * plane failed to notice is otherwise invisible: the link stays Connected + * and frames stop arriving. */ +std::atomic g_ptk_installs{0}, g_gtk_installs{0}; +/* Protected frames we hold NO KEY for - a group frame at a key id the + * supplicant has not installed. Counted apart from MIC + * failures: a key we were never given is not tampering. */ +std::atomic g_no_key{0}; + +int g_tap_fd = -1; + +/* A CLEAN STOP: the on-air harness ends a run by signalling this process, + * and the ledger printed on the way out is the run's diagnostic. Written by + * the signal handler AND by the RX and TAP threads, read by the main loop: + * a std::atomic, because a volatile sig_atomic_t is only safe against a + * signal handler, not across threads - and a lock-free one, so the + * handler's store stays async-signal-safe. */ +std::atomic g_stop{0}; +static_assert(std::atomic::is_always_lock_free, + "the signal handler's store must be lock-free"); +extern "C" void on_signal(int) { g_stop.store(1); } +/* Set by every caught exception and by a failed TAP: the run is stopped + * through the normal teardown, and the exit status says it was a fault + * (exit_status()) rather than a run that completed. */ +std::atomic g_fault{0}; +/* A fault stops the run; the teardown and the exit status do the rest. */ +void fault(const char* what, const char* detail) { + if (detail) + std::fprintf(stderr, "sta_client: FAULT: %s threw: %s - stopping\n", what, + detail); + else + std::fprintf(stderr, "sta_client: FAULT: %s - stopping\n", what); + g_fault = 1; + g_stop = 1; +} +int exit_status() { return g_fault.load() ? 3 : 0; } + +/* ---- small helpers ------------------------------------------------------ */ + +uint32_t now_ms() { + static const auto t0 = std::chrono::steady_clock::now(); + return (uint32_t)std::chrono::duration_cast( + std::chrono::steady_clock::now() - t0) + .count(); +} + +void enqueue(std::vector mpdu) { + /* addr1's I/G bit: a group address is never ACKed. */ + const bool unicast = mpdu.size() >= 10 && (mpdu[4] & 0x01) == 0; + const std::vector& rt = (unicast && !g_rt_ack.empty()) ? g_rt_ack : g_rt; + std::vector f; + f.reserve(rt.size() + mpdu.size()); + f.insert(f.end(), rt.begin(), rt.end()); + f.insert(f.end(), mpdu.begin(), mpdu.end()); + std::lock_guard lk(g_q_mu); + g_q_in.fetch_add(1); + /* Bounded: everything queued here answers a received frame or a timer, so + * an unbounded queue is an allocation the air controls. */ + if (g_q.size() < 128) g_q.push_back(std::move(f)); + else g_q_drop.fetch_add(1); +} + +/* The dBm convention this tree uses (src/LinkHealth.cpp, src/RxQuality.h): + * dBm ~= raw - 110; MT7612U's mapping layer converts into the same raw + * scale. Only the ORDERING matters - BssTable ranks BSSes heard by one radio + * in one scan - and a raw 0 (the PHY reported nothing) must not outrank a + * real reading, hence the floor rather than -110. */ +int8_t rssi_dbm(uint8_t raw) { + if (raw == 0) return -128; + const int dbm = (int)raw - 110; + return (int8_t)(dbm < -128 ? -128 : (dbm > 127 ? 127 : dbm)); +} + +/* ---- keys and per-association state ------------------------------------- */ + +/* Called under g_mu when the station reaches Connected on a new + * association. The duplicate cache is reset here and NOT at a rekey: it is + * per transmitter and TID over Sequence Control (DupDetector, Dot11.h), which + * a rekey does not restart - a Retry copy of the rekey's own message 3 must + * still be a duplicate. The per-key state is not reset here: a PTK or GTK + * rekey happens with the machine already Connected, so note_keys() owns it, + * keyed on the supplicant's install generations. */ +void on_association() { + g_rx_dup.reset(); + g_failed_noted = false; + g_was_associated = true; + g_associations.fetch_add(1); +} + +/* A REKEY RESTARTS A PN SPACE, and the windows restart with it - both + * directions, both keys. The AP's new key starts at PN 1, so a window left at + * the old key's head would reject every frame; and our transmit PN under a + * new key must restart, because continuing it is keystream reuse. + * Caller holds g_mu. */ +void note_keys() { + const devourer::sta::Supplicant& sup = g_sm.supplicant(); + + if (sup.ptk_valid() && sup.ptk_generation() != g_ptk_gen_seen) { + g_ptk_gen_seen = sup.ptk_generation(); + g_tx_pn = 1; + g_rx_replay.reset(); + g_ptk_installs.fetch_add(1); + } + if (sup.gtk_valid() && sup.gtk_generation() != g_gtk_gen_seen) { + g_gtk_gen_seen = sup.gtk_generation(); + /* Seeded from the AUTHENTICATED Key RSC, not reset: a window opened at + * whichever group frame arrives first would accept a replayed capture + * from earlier in this GTK's life. */ + g_group_replay.seed(sup.gtk_rsc()); + g_gtk_installs.fetch_add(1); + } +} + +/* Defined with the transmit path below; the receive path needs it for a + * rekey's answer, which is encrypted exactly as a data frame is. + * `from_host` false for an MSDU this file originates itself (a rekey's EAPOL + * answer): it is then counted in g_eapol_enc_tx, not in the host's books. */ +bool air_msdu(const uint8_t* msdu, size_t len, const uint8_t da[6], + const uint8_t* tk = nullptr, bool from_host = true); + +/* ---- UP: one received MPDU --------------------------------------------- */ + +/* Hand a decrypted (or never-encrypted) MSDU to the host. Returns false when + * it is not something the host can be given - a non-ethertype LLC encoding, + * or too big for the buffer. Caller holds g_mu. */ +bool tap_up(const uint8_t* da, const uint8_t* sa, const uint8_t* msdu, + size_t len) { + uint8_t eth[2048]; + const size_t n = devourer::sta::msdu_to_eth(da, sa, msdu, len, eth, + sizeof eth); + if (n == 0) { g_tap_drop.fetch_add(1); return false; } + if (g_tap_fd < 0) return true; /* no TAP: counted, not an error */ + if (::write(g_tap_fd, eth, n) == (ssize_t)n) { g_tap_tx.fetch_add(1); return true; } + /* The fd is non-blocking (tap_open): a host that is not reading costs a + * counted drop (EAGAIN), never a stalled RX thread holding g_mu. */ + g_tap_drop.fetch_add(1); + return false; +} + +/* THE RECEIVE DECISION, with no Packet and no radio in it, so every branch is + * reachable from `sta_client --self-test`. `mpdu`/`len` is the MPDU without + * its FCS (mpdu_len()). */ +void rx_frame(const uint8_t* mpdu, size_t len, int8_t rssi, uint32_t now) { + std::lock_guard l(g_mu); + + if (len < 24) { g_rx_short.fetch_add(1); return; } + + /* The scan folds in EVERY beacon and probe response on the channel; on_rx + * below ignores everything that is not our BSS, so the two need not agree + * about which AP matters. */ + if (mpdu[0] == devourer::sta::kFcBeacon || + mpdu[0] == devourer::sta::kFcProbeResp) { + uint8_t rx_chan = g_tuned.load(); + if ((uint32_t)(now - g_retune_ms.load()) < kRetuneGuardMs) rx_chan = 0; + devourer::sta::BssInfo ds; + /* Channel unknown and the frame does not state one: not folded in (see + * g_tuned). */ + const bool usable = rx_chan != 0 || + (devourer::sta::parse_beacon(mpdu, len, &ds) && + ds.channel != 0); + if (usable && g_bss.observe(mpdu, len, rssi, rx_chan, now)) + g_beacons.fetch_add(1); + } + + const StationSm::State before = g_sm.state(); + g_sm.on_rx(mpdu, len, now); + if (before != StationSm::State::Connected && g_sm.connected()) + on_association(); + if (g_sm.connected()) note_keys(); + + /* The data plane runs only on a live association: a protected frame that + * arrives before one cannot be decrypted with a key we do not have. */ + if (!g_sm.connected()) return; + + const uint8_t fc0 = mpdu[0], fc1 = mpdu[1]; + if (fc0 != devourer::sta::kFcData && !devourer::sta::is_qos_data(fc0)) return; + /* A station receives from the DS. ToDS set is another station's uplink. */ + if (!(fc1 & devourer::sta::kFcFromDs) || (fc1 & devourer::sta::kFcToDs)) + return; + if (std::memcmp(mpdu + 10, g_sm.bssid(), 6) != 0) return; + const bool to_us = std::memcmp(mpdu + 4, g_own, 6) == 0; + const bool group = (mpdu[4] & 0x01) != 0; + if (!to_us && !group) return; + + /* FRAGMENTS AND A-MSDUs ARE REFUSED, NOT MISREAD: neither is reassembled + * here, and half an MSDU handed up as a whole one decodes to nonsense. + * More Fragments is clear on a LAST fragment, so the fragment number is + * checked too. */ + if ((fc1 & devourer::sta::kFcMoreFrag) || (mpdu[22] & 0x0f)) { + g_frag_drop.fetch_add(1); + return; + } + const size_t hlen = devourer::sta::data_hdr_len(fc0, fc1); + if (len < hlen) { g_rx_short.fetch_add(1); return; } + if (devourer::sta::is_qos_data(fc0) && (mpdu[24] & 0x80)) { + g_amsdu_drop.fetch_add(1); + return; + } + + const uint8_t* da = devourer::sta::data_da(mpdu, fc1); + const uint8_t* sa = devourer::sta::data_sa(mpdu, fc1); + /* THE QoS CONTROL FIELD IS AT A FIXED OFFSET (24), NOT AT hlen - 2: HT + * Control follows it on a frame with the Order bit set. A 4-address frame + * cannot reach here - the FromDS/ToDS test above admits from-the-DS + * frames only. */ + const int tid = devourer::sta::is_qos_data(fc0) + ? (mpdu[24] & 0x0f) + : devourer::sta::CcmpReplay::kNonQosTid; + + if (!group && + g_rx_dup.is_duplicate((fc1 & devourer::sta::kFcRetry) != 0, + (uint16_t)(mpdu[22] | (mpdu[23] << 8)), tid)) { + g_dup_drop.fetch_add(1); + return; + } + + if (!(fc1 & devourer::sta::kFcProtected)) { + /* Plaintext on a WPA2 link is not forwarded: accepting it would let + * anyone on the channel inject into the host's stack. Cleartext EAPOL is + * StationSm::on_rx's, and it has already had it. */ + if (g_sm.security() != StationSm::Security::Open) return; + g_plain_rx.fetch_add(1); + if (len > hlen) tap_up(da, sa, mpdu + hlen, len - hlen); + return; + } + if (g_sm.security() == StationSm::Security::Open) return; + + /* THE CCMP HEADER AND THE MIC MUST BE THERE BEFORE ANYTHING READS THEM - + * the key-id read below touches mpdu[hlen + 3]. A frame this short is + * malformed, not forged, so it is not counted as a MIC failure. */ + if (devourer::sta::ccmp_decrypted_len(len, hlen) == 0) { + g_rx_short.fetch_add(1); + return; + } + g_enc_rx.fetch_add(1); + /* Key id 0 is the pairwise key, by the convention every AP follows + * (hostapd's group key index toggles 1 <-> 2); the frame's own key id + * chooses, not its address - an AP may unicast under the group key + * during a rekey. */ + const uint8_t key_id = devourer::sta::ccmp_key_id(mpdu + hlen); + const devourer::sta::Supplicant& sup = g_sm.supplicant(); + const bool pairwise = key_id == 0; + const uint8_t* tk = pairwise ? sup.tk() : sup.gtk(); + /* The supplicant installs only a CCMP-length GTK (Supplicant::kGtkLenCcmp). */ + if (!pairwise && (!sup.gtk_valid() || key_id != sup.gtk_key_id())) { + g_no_key.fetch_add(1); + return; + } + + std::vector plain(devourer::sta::ccmp_decrypted_len(len, hlen)); + size_t plain_len = 0; + uint64_t pn = 0; + /* A PAIRWISE REKEY COSTS AT MOST ONE FRAME HERE, by protocol + * (802.11-2016 12.7.6.5): the supplicant installs the new PTK at message 3, + * the authenticator only once it has accepted message 4, so for one round + * trip the AP still transmits under the old key. Not defended against - a + * grace-period key would be a second key and a second replay window to + * save one frame. The on-air cell asserts MIC failures <= PTK installs. */ + if (!devourer::sta::ccmp_decrypt(g_crypto, tk, mpdu, len, hlen, mpdu + 10, + plain.data(), plain.size(), &plain_len, + &pn)) { + g_mic_fail.fetch_add(1); + return; + } + /* THE PN IS ADMITTED ONLY AFTER THE MIC VERIFIED; admitting it first lets + * anyone on the channel advance the window with garbage. */ + devourer::sta::CcmpReplay& win = pairwise ? g_rx_replay : g_group_replay; + if (!win.accept(pn, pairwise ? tid : devourer::sta::CcmpReplay::kNonQosTid)) { + g_replays.fetch_add(1); + return; + } + if (!pairwise) g_group_rx.fetch_add(1); + + /* AN EAPOL-KEY FRAME INSIDE THE CIPHER IS A REKEY, and it is the state + * machine's, not the host's. One not addressed to us, or not under the + * PAIRWISE key, is not part of any handshake this station is in: anything + * on the BSS could have forged it under the group key. It is dropped + * either way - an EAPOL-Key frame is never the host's. Any OTHER EAPOL + * packet (EAP, Start, Logoff) is the host's: on_decrypted_msdu returns + * false for it and it is delivered below. */ + const bool is_eapol_key = + devourer::sta::is_ethertype_snap(plain.data(), plain_len) && + plain[6] == 0x88 && plain[7] == 0x8e && + devourer::sta::eapol_is_key(plain.data() + devourer::sta::kLlcSnapLen, + plain_len - devourer::sta::kLlcSnapLen); + if (is_eapol_key && (!to_us || !pairwise)) return; + + /* THE ANSWER GOES OUT UNDER THE KEY THE REQUEST CAME IN UNDER - for a PTK + * rekey the OLD pairwise key, since the authenticator switches only once + * it has accepted message 4. Copied BEFORE on_decrypted_msdu(): `tk` points + * into the supplicant, and message 3 installs the new key through it. */ + uint8_t tk_in[16]; + if (pairwise) std::memcpy(tk_in, tk, 16); + + std::vector reply; + if (to_us && pairwise && + g_sm.on_decrypted_msdu(plain.data(), plain_len, now, &reply)) { + g_eapol_enc_rx.fetch_add(1); + if (!reply.empty()) { + std::vector out; + devourer::sta::append_llc_snap(out, 0x888e); + out.insert(out.end(), reply.begin(), reply.end()); + /* Addressed to the BSSID: the AP is both the receiver and the + * destination of an EAPOL-Key frame. */ + if (air_msdu(out.data(), out.size(), g_sm.bssid(), tk_in, + /*from_host=*/false)) + g_eapol_enc_tx.fetch_add(1); + } + /* Only now - after the reply was encrypted under the old key - do the PN + * spaces restart for a newly installed one. */ + note_keys(); + devourer::sta::secure_wipe(tk_in, sizeof tk_in); + return; + } + if (pairwise) devourer::sta::secure_wipe(tk_in, sizeof tk_in); + tap_up(da, sa, plain.data(), plain_len); +} + +/* ---- DOWN: one MSDU onto the air --------------------------------------- */ + +/* Frame and (on a protected link) encrypt one MSDU for `da`, and queue it. + * Returns false when the cipher refused. Caller holds g_mu. Shared by the + * host's traffic and a rekey's answer, so the PN space has one owner. */ +bool air_msdu(const uint8_t* msdu, size_t len, const uint8_t da[6], + const uint8_t* tk, bool from_host) { + const bool protect = g_sm.security() != StationSm::Security::Open; + /* Null means "whatever is installed now"; a rekey's answer passes the key + * its request arrived under. */ + if (!tk) tk = g_sm.supplicant().tk(); + std::vector hdr = devourer::sta::data_hdr_to_ds( + g_sm.bssid(), g_own, da, protect, g_data_seq.next()); + + if (!protect) { + hdr.insert(hdr.end(), msdu, msdu + len); + if (from_host) g_tx_plain.fetch_add(1); + enqueue(std::move(hdr)); + return true; + } + /* 0 means the length would overflow: refused like any cipher failure. */ + const size_t cap = devourer::sta::ccmp_encrypted_len(hdr.size(), len); + if (cap == 0) { g_tx_enc_fail.fetch_add(1); return false; } + std::vector f(cap); + const size_t n = devourer::sta::ccmp_encrypt( + g_crypto, tk, hdr.data(), hdr.size(), g_own, + g_tx_pn, /*key_id=*/0, msdu, len, f.data(), f.size()); + if (n == 0) { g_tx_enc_fail.fetch_add(1); return false; } + /* Only after the cipher succeeded: a PN burned on a frame never aired is + * harmless; a PN reused because a failure skipped the increment is not. */ + g_tx_pn++; + f.resize(n); + if (from_host) g_tx_enc.fetch_add(1); + enqueue(std::move(f)); + return true; +} + +/* ---- DOWN: one Ethernet frame from the host ---------------------------- */ + +/* Outside the reader thread so the headless cells can drive it. */ +void tap_down_one(const uint8_t* eth, size_t len) { + uint8_t msdu[2048], da[6], sa[6]; + /* COUNTED FIRST, so "from host" is every frame the host handed us and the + * books close. */ + g_tap_rx.fetch_add(1); + const size_t m = devourer::sta::eth_to_msdu(eth, len, msdu, sizeof msdu, da, + sa); + if (m == 0) { g_tap_down_drop.fetch_add(1); return; } + + std::lock_guard l(g_mu); + if (!g_sm.connected()) { g_tap_down_drop.fetch_add(1); return; } + + /* THE SOURCE ADDRESS MUST BE OURS: the AP matches addr2 against the + * association it holds. A TAP with the wrong MAC is the usual cause. */ + if (std::memcmp(sa, g_own, 6) != 0) { g_tap_down_drop.fetch_add(1); return; } + + if (!air_msdu(msdu, m, da)) g_tap_down_drop.fetch_add(1); +} + +/* ---- the scan and the reconnect policy ---------------------------------- */ + +/* Which channel the radio should be on while looking for a BSS. */ +uint8_t scan_step(uint32_t now) { + if (g_scan_chans.empty()) return g_chan; + if (g_scan_chans.size() == 1) return g_scan_chans[0]; + if ((uint32_t)(now - g_scan_switch_ms) >= g_scan_dwell_ms) { + g_scan_switch_ms = now; + g_scan_idx = (g_scan_idx + 1) % g_scan_chans.size(); + } + return g_scan_chans[g_scan_idx]; +} + +/* A directed probe request for the SSID we want, on the channel we are on: + * it finds a hidden BSS and shortens the wait on a swept channel. Caller + * holds g_mu. */ +void probe(uint8_t chan) { + std::vector m = + devourer::sta::build_probe_req(g_own, g_ssid, chan, chan > 14); + if (m.empty()) return; + devourer::sta::assign_seq(m, g_data_seq.next()); + g_probe_tx.fetch_add(1); + enqueue(std::move(m)); +} + +/* The join and re-join policy. Returns the channel the radio should be tuned + * to. Caller must NOT hold g_mu. */ +uint8_t supervise(uint32_t now) { + std::lock_guard l(g_mu); + + const StationSm::State st = g_sm.state(); + if (st != StationSm::State::Idle && st != StationSm::State::Failed) + return g_sm.channel() ? g_sm.channel() : g_chan; + + if (g_gave_up) return g_chan; + + /* The transition INTO Failed, handled once. */ + if (st == StationSm::State::Failed && !g_failed_noted) { + g_failed_noted = true; + /* Counted apart from a first join, once per lost link. */ + if (g_was_associated) { + g_was_associated = false; + g_reconnects.fetch_add(1); + } + if (!g_reconnect) { g_gave_up = true; return g_chan; } + /* The backoff is for a re-join only; the first attempt does not wait. */ + g_next_join_ms = now + g_rejoin_backoff_ms; + } + if (g_next_join_ms && (int32_t)(now - g_next_join_ms) < 0) + return scan_step(now); + + /* Aged first, so a BSS that went off the air is not joined and reported as + * "no response". Ten seconds is ~100 beacon intervals. */ + g_bss.expire(now, 10000); + + const bool open = g_sm.security() == StationSm::Security::Open; + const BssEntry* e = open ? g_bss.select_open(g_ssid) : g_bss.select(g_ssid); + const uint8_t chan = scan_step(now); + if (!e) { + probe(chan); + g_next_join_ms = now + 200; /* probe again shortly, do not spin */ + return chan; + } + + uint8_t snonce[32]; + /* A FRESH SNonce for every attempt: reusing one makes the PTK a function + * of the ANonce alone. */ + if (!open && RAND_bytes(snonce, sizeof snonce) != 1) { + /* A fault, not an outcome: the run stops and exits 3. */ + fault("RAND_bytes", "refusing to join with a predictable SNonce"); + g_gave_up = true; + return g_chan; + } + g_join_attempts++; + g_joins.fetch_add(1); + g_next_join_ms = 0; + g_failed_noted = false; + if (!g_sm.join(*e, open ? nullptr : snonce, now)) { + /* join() refused the BSS itself (cipher, MFP, no channel, not an ESS...) + * and will refuse the same entry again: back off rather than spin. */ + g_next_join_ms = now + g_rejoin_backoff_ms; + } + return e->info.channel ? e->info.channel : chan; +} + +/* ---- TAP ---------------------------------------------------------------- */ + +int tap_open(const char* name, const uint8_t mac[6]) { + /* NON-BLOCKING: tap_up() writes from the RX callback under g_mu, and the + * teardown waits for the RX loop - a blocked write would stall both. */ + int fd = ::open("/dev/net/tun", O_RDWR | O_NONBLOCK); + if (fd < 0) { perror(" TAP: open /dev/net/tun"); return -1; } + struct ifreq ifr; + std::memset(&ifr, 0, sizeof ifr); + ifr.ifr_flags = IFF_TAP | IFF_NO_PI; + std::snprintf(ifr.ifr_name, IFNAMSIZ, "%s", name); + if (::ioctl(fd, TUNSETIFF, &ifr) < 0) { + perror(" TAP: TUNSETIFF (CAP_NET_ADMIN?)"); + ::close(fd); + return -1; + } + /* THE TAP CARRIES THE RADIO'S MAC: every frame the host sends leaves with + * addr2 = our 802.11 address, and tap_down_one refuses any other source. */ + struct ifreq set; + std::memset(&set, 0, sizeof set); + std::snprintf(set.ifr_name, IFNAMSIZ, "%s", ifr.ifr_name); + set.ifr_hwaddr.sa_family = ARPHRD_ETHER; + std::memcpy(set.ifr_hwaddr.sa_data, mac, 6); + int s = ::socket(AF_INET, SOCK_DGRAM, 0); + if (s >= 0) { + if (::ioctl(s, SIOCSIFHWADDR, &set) < 0) + perror(" TAP: SIOCSIFHWADDR"); + ::close(s); + } + std::fprintf(stderr, + " TAP: %s open with %02x:%02x:%02x:%02x:%02x:%02x - the host " + "stack owns ARP/ICMP/DHCP\n", + ifr.ifr_name, mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + return fd; +} + +/* ---- the radio adapter --------------------------------------------------- */ + +/* THE MPDU'S LENGTH WITHOUT ITS FCS. Realtek delivers the four trailing FCS + * bytes in Packet::Data (RxAtrib.fcs_present, src/RxPacket.h); MT7612U strips + * them. Counted in, they move the expected CCMP MIC four bytes late and every + * protected frame reads as a MIC failure - an attack where there is a length + * bug. 0 rather than an unsigned underflow for a runt shorter than its FCS. */ +size_t mpdu_len(size_t raw, bool fcs_present) { + if (!fcs_present) return raw; + return raw >= 4 ? raw - 4 : 0; +} + +void on_rx(const Packet& p) { + const size_t mlen = mpdu_len(p.Data.size(), p.RxAtrib.fcs_present); + if (p.RxAtrib.crc_err) { g_crc_err.fetch_add(1); return; } + if (mlen < 24) { g_rx_short.fetch_add(1); return; } + /* An exception must not unwind into the backend's RX thread (that is + * std::terminate: no leave, no clear, no ledger). Stop the run instead. */ + try { + rx_frame(p.Data.data(), mlen, rssi_dbm(p.RxAtrib.rssi[0]), now_ms()); + } catch (const std::exception& e) { + fault("receive path", e.what()); + } catch (...) { + fault("receive path", "unknown exception"); + } +} + +const char* state_name(StationSm::State s) { + switch (s) { + case StationSm::State::Idle: return "Idle"; + case StationSm::State::Authenticating: return "Authenticating"; + case StationSm::State::Associating: return "Associating"; + case StationSm::State::FourWay: return "FourWay"; + case StationSm::State::Connected: return "Connected"; + case StationSm::State::Failed: return "Failed"; + } + return "?"; +} + +const char* fail_name(StationSm::Failure f) { + switch (f) { + case StationSm::Failure::None: return "none"; + case StationSm::Failure::AuthTimeout: return "auth-timeout"; + case StationSm::Failure::AuthRefused: return "auth-refused"; + case StationSm::Failure::AssocTimeout: return "assoc-timeout"; + case StationSm::Failure::AssocRefused: return "assoc-refused"; + case StationSm::Failure::Deauthenticated: return "deauthenticated"; + case StationSm::Failure::HandshakeTimeout: return "handshake-timeout"; + case StationSm::Failure::BeaconLost: return "beacon-lost"; + case StationSm::Failure::NoPmk: return "no-pmk"; + case StationSm::Failure::NotConfigured: return "not-configured"; + case StationSm::Failure::NoChannel: return "no-channel"; + case StationSm::Failure::NotInfrastructure: return "not-infrastructure"; + case StationSm::Failure::SsidMismatch: return "ssid-mismatch"; + } + return "?"; +} + +/* THE LEDGER, printed at every exit once `sta_client up:` has printed, + * whether the run worked or not: "we heard + * nothing", "we heard the wrong AP" and "we heard our AP and it said no" are + * different lines here. */ +void report() { + std::lock_guard l(g_mu); + std::fprintf(stderr, "fault=%d state=%s", g_fault.load(), + state_name(g_sm.state())); + if (g_sm.state() == StationSm::State::Failed) + std::fprintf(stderr, " reason=%s status=%u", fail_name(g_sm.fail_reason()), + g_sm.status()); + std::fprintf(stderr, " aid=%u keyed=%d bss_known=%d\n", g_sm.aid(), + (int)g_sm.keyed(), g_bss.count()); + std::fprintf(stderr, + " join: beacons observed=%llu, probes sent=%llu, joins=%llu," + " associations=%llu, reconnects=%llu\n", + (unsigned long long)g_beacons.load(), + (unsigned long long)g_probe_tx.load(), + (unsigned long long)g_joins.load(), + (unsigned long long)g_associations.load(), + (unsigned long long)g_reconnects.load()); + std::fprintf(stderr, + " station rx: auth_tx=%u assoc_tx=%u eapol_tx=%u eapol_rx=%u" + " beacons=%u\n", + g_sm.auth_tx, g_sm.assoc_tx, g_sm.eapol_tx, g_sm.eapol_rx, + g_sm.beacons_rx); + std::fprintf(stderr, + " refused by the address filter: not-our-bss=%u," + " not-for-us=%u, ignored=%u, malformed=%u, tx-dropped=%u" + " (protected data, handled here: %u)\n", + g_sm.rx_not_our_bss, g_sm.rx_not_for_us, g_sm.rx_ignored, + g_sm.rx_malformed, g_sm.tx_dropped, g_sm.rx_protected); + const devourer::sta::Supplicant& sup = g_sm.supplicant(); + std::fprintf(stderr, + " rekeys (EAPOL inside the cipher): received=%llu," + " answered=%llu; keys installed: PTK=%llu GTK=%llu\n", + (unsigned long long)g_eapol_enc_rx.load(), + (unsigned long long)g_eapol_enc_tx.load(), + (unsigned long long)g_ptk_installs.load(), + (unsigned long long)g_gtk_installs.load()); + std::fprintf(stderr, + " four-way: mic_failures=%u replays=%u retransmits=%u" + " malformed=%u out_of_state=%u ignored=%u crypto_errors=%u" + " rsn_mismatches=%u\n", + sup.mic_failures, sup.replays, sup.retransmits, sup.malformed, + sup.out_of_state, sup.ignored, sup.crypto_errors, + sup.rsn_mismatches); + std::fprintf(stderr, + " data plane: encrypted rx=%llu (group=%llu), plaintext rx=" + "%llu, MIC failures=%llu, replays rejected=%llu," + " duplicates dropped=%llu, no key for it=%llu\n", + (unsigned long long)g_enc_rx.load(), + (unsigned long long)g_group_rx.load(), + (unsigned long long)g_plain_rx.load(), + (unsigned long long)g_mic_fail.load(), + (unsigned long long)g_replays.load(), + (unsigned long long)g_dup_drop.load(), + (unsigned long long)g_no_key.load()); + std::fprintf(stderr, + " refused before the host: fragmented=%llu, A-MSDU=%llu," + " short=%llu, crc_err=%llu\n", + (unsigned long long)g_frag_drop.load(), + (unsigned long long)g_amsdu_drop.load(), + (unsigned long long)g_rx_short.load(), + (unsigned long long)g_crc_err.load()); + /* The two identities (see the ledger declarations) hold exactly here, + * because the TAP reader is stopped and the queue drained before this + * runs. A rekey's encrypted EAPOL answer is in `queued` and `answered`, + * not in `encrypted`. */ + std::fprintf(stderr, + " TAP: to host=%llu, from host=%llu, dropped up=%llu," + " dropped down=%llu, read errors=%llu\n", + (unsigned long long)g_tap_tx.load(), + (unsigned long long)g_tap_rx.load(), + (unsigned long long)g_tap_drop.load(), + (unsigned long long)g_tap_down_drop.load(), + (unsigned long long)g_tap_read_err.load()); + std::fprintf(stderr, + " tx: encrypted=%llu (cipher refused %llu), plaintext=%llu," + " queued=%llu, aired=%llu, send failed=%llu, queue dropped=%llu\n", + (unsigned long long)g_tx_enc.load(), + (unsigned long long)g_tx_enc_fail.load(), + (unsigned long long)g_tx_plain.load(), + (unsigned long long)g_q_in.load(), + (unsigned long long)g_sent.load(), + (unsigned long long)g_send_fail.load(), + (unsigned long long)g_q_drop.load()); +} + +/* A whole-string decimal integer (surrounding whitespace allowed), or + * false: std::atoi would read garbage as 0 - a zero-length run, channel 0. */ +bool parse_long_strict(const char* s, long* out) { + if (!s) return false; + char* end = nullptr; + errno = 0; + const long v = std::strtol(s, &end, 10); + if (end == s || errno == ERANGE) return false; + while (*end == ' ' || *end == '\t' || *end == '\n') ++end; + if (*end != '\0') return false; + *out = v; + return true; +} + +/* The run length (argv[1]): seconds, > 0. */ +bool parse_secs(const char* s, int* out) { + long v = 0; + if (!parse_long_strict(s, &v) || v <= 0 || v > 86400) return false; + *out = (int)v; + return true; +} + +/* DEVOURER_CHANNEL: a channel this station can tune (channel_valid). */ +bool parse_channel(const char* s, uint8_t* out) { + long v = 0; + if (!parse_long_strict(s, &v) || v <= 0 || v > 255 || + !devourer::sta::channel_valid((uint8_t)v)) + return false; + *out = (uint8_t)v; + return true; +} + +/* A millisecond knob from the environment, parsed strictly + * (devourer_env_long_strict) and bounded to [lo, hi]. Unset keeps *out (the + * default); set but empty, non-numeric or out of range returns false. */ +constexpr long kDwellMinMs = 10, kDwellMaxMs = 10000; +constexpr long kBackoffMinMs = 0, kBackoffMaxMs = 60000; +bool parse_env_ms(const char* name, long lo, long hi, uint32_t* out) { + if (!std::getenv(name)) return true; + long v = 0; + if (!devourer_env_long_strict(name, &v) || v < lo || v > hi) return false; + *out = (uint32_t)v; + return true; +} + +/* The station's retry limit: DEVOURER_TX_RETRY_LIMIT when the library + * actually took it - the same strict parse devourer_config_from_env applies, + * so an empty or non-numeric value is not mistaken for one - else + * kStationRetryLimit. Returns whether the environment supplied it. */ +bool apply_station_retry_limit(devourer::DeviceConfig& cfg) { + long v = 0; + if (devourer_env_long_strict("DEVOURER_TX_RETRY_LIMIT", &v)) return true; + cfg.tx.retry_limit = kStationRetryLimit; + return false; +} + +std::vector parse_chan_list(const char* s) { + std::vector v; + while (*s) { + char* end = nullptr; + const long n = std::strtol(s, &end, 10); + if (end == s) break; + if (devourer::sta::channel_valid((uint8_t)(n > 0 && n < 256 ? n : 0))) + v.push_back((uint8_t)n); + s = (*end == ',') ? end + 1 : end; + } + return v; +} + +void send_batch() { + std::vector> batch; + { std::lock_guard l(g_q_mu); batch.swap(g_q); } + for (auto& f : batch) { + if (g_dev->send_packet(f.data(), f.size())) g_sent.fetch_add(1); + else g_send_fail.fetch_add(1); + } +} + +int self_test(); + +} // namespace + +int main(int argc, char** argv) { + /* HEADLESS FIRST, before libusb is touched. */ + if (argc > 1 && std::strcmp(argv[1], "--self-test") == 0) return self_test(); + + /* FIRST, before the open and the bring-up: a harness may start this + * process with SIGINT ignored (a background job of a non-interactive + * shell), and an explicit handler both undoes that and lets a stop during + * bring-up end the run at the loop instead of being lost. Safe this early: + * the handler is one atomic store, and nothing reads g_stop before the + * loop. The device open (up to 15 s waiting for re-enumeration) and the + * bring-up do not poll it, so a stop there takes effect once they return. */ + std::signal(SIGINT, on_signal); + std::signal(SIGTERM, on_signal); + + int sec = 60; + if (argc > 1 && !parse_secs(argv[1], &sec)) { + std::fprintf(stderr, "usage: sta_client [seconds > 0] | --self-test " + "(got '%s')\n", argv[1]); + return 2; + } + if (const char* s = std::getenv("DEVOURER_STA_SSID")) g_ssid = s; + if (const char* k = std::getenv("DEVOURER_STA_PSK")) g_psk = k; + if (const char* c = std::getenv("DEVOURER_CHANNEL"); c && !parse_channel(c, &g_chan)) { + std::fprintf(stderr, "sta_client: DEVOURER_CHANNEL='%s' is not a valid " + "channel\n", c); + return 2; + } + if (const char* c = std::getenv("DEVOURER_STA_SCAN_CHANNELS")) + g_scan_chans = parse_chan_list(c); + if (g_scan_chans.empty()) g_scan_chans.push_back(g_chan); + if (!parse_env_ms("DEVOURER_STA_SCAN_DWELL_MS", kDwellMinMs, kDwellMaxMs, + &g_scan_dwell_ms)) { + std::fprintf(stderr, "sta_client: DEVOURER_STA_SCAN_DWELL_MS must be %ld..%ld\n", + kDwellMinMs, kDwellMaxMs); + return 2; + } + if (const char* r = std::getenv("DEVOURER_STA_RECONNECT")) + g_reconnect = std::strcmp(r, "0") != 0; + if (!parse_env_ms("DEVOURER_STA_BACKOFF_MS", kBackoffMinMs, kBackoffMaxMs, + &g_rejoin_backoff_ms)) { + std::fprintf(stderr, "sta_client: DEVOURER_STA_BACKOFF_MS must be %ld..%ld\n", + kBackoffMinMs, kBackoffMaxMs); + return 2; + } + if (const char* a = std::getenv("DEVOURER_STA_ARM")) + g_arm = std::strcmp(a, "0") != 0; + + devourer::DeviceConfig cfg = devourer_config_from_env(); + const bool limit_from_env = apply_station_retry_limit(cfg); + /* A station always receives. A backend whose TX bring-up closes the RX + * path unless asked (Jaguar3's InitWrite) must be asked up front: + * StartRxLoop after a TX-only bring-up is not a reliable way to get RX. */ + cfg.rx.enable_with_tx = true; + + auto logger = std::make_shared(); + apply_logging_env(*logger); + /* The teardown order (radio, interface, handle, libusb_exit) is held by + * the demos' RAII session; declared before the RX thread, so it outlives + * that thread's join. */ + devourer::DeviceSession session{logger}; + libusb_context* ctx = nullptr; + libusb_init(&ctx); + session.adopt_context(ctx); + libusb_set_option(ctx, LIBUSB_OPTION_LOG_LEVEL, LIBUSB_LOG_LEVEL_WARNING); + /* The MT7612U by default (with DEVOURER_VID=0x0e8d); DEVOURER_VID / + * DEVOURER_PID name any other adapter, which must then report + * station_mode_ok (or run with DEVOURER_STA_ARM=0). */ + static const uint16_t pids[] = {0x7612}; + auto* h = open_selected_usb(ctx, logger, pids, 1); + if (!h) return 1; + session.adopt_handle(h); + std::shared_ptr lk; + if (devourer::claim_interface_then_reset( + h, devourer::find_wifi_interface(h), logger, true, lk) != 0) + return 1; + session.adopt_lock(lk); + WiFiDriver wifi(logger); + session.adopt_device(wifi.CreateRadio(h, ctx, lk, cfg)); + g_dev = session.device(); + if (!g_dev) return 1; + + /* Refused before any bring-up: a station on an adapter that cannot arm its + * identity is not acknowledged by the AP it joins. Capabilities are + * resolved at construction. */ + const devourer::AdapterCaps caps = g_dev->GetAdapterCaps(); + if (g_arm && !caps.station_mode_ok) { + std::fprintf(stderr, + "sta_client: REFUSED - this adapter's station_mode_ok is " + "false (IRadio::SetStationIdentity is not ported or not " + "measured on it). DEVOURER_STA_ARM=0 runs it unarmed.\n"); + return 2; + } + + /* No rate control: the harness's job is to be able to ASK for a rate. + * Unicast requests an ACK, so the hardware retries it; group-addressed + * frames stay NOACK - nobody acknowledges them. */ + const char* rate_s = std::getenv("DEVOURER_TX_RATE"); + if (!rate_s || !*rate_s) rate_s = "6M"; + g_rt = devourer::build_stream_radiotap(devourer::parse_tx_mode_str(rate_s)); + if (const char* a = std::getenv("DEVOURER_STA_ACK"); !a || !*a || std::strcmp(a, "0") != 0) + g_rt_ack = devourer::build_stream_radiotap(devourer::parse_tx_mode_str(rate_s), + /*no_ack=*/false); + std::fprintf(stderr, " TX rate: %s, unicast %s, tx.retry_limit %d (%s)\n", + rate_s, + g_rt_ack.empty() ? "NOACK (no retries)" + : "ACK-requested (hardware retries)", + cfg.tx.retry_limit, + limit_from_env ? "DEVOURER_TX_RETRY_LIMIT" : "station default"); + try { + g_dev->InitWrite(SelectedChannel{g_chan, 0, CHANNEL_WIDTH_20}); + } catch (const std::exception& e) { + std::fprintf(stderr, "sta_client: bring-up failed: %s\n", e.what()); + return 1; + } catch (...) { + std::fprintf(stderr, "sta_client: bring-up failed\n"); + return 1; + } + g_tuned.store(g_chan); + g_retune_ms.store(now_ms()); + + /* AFTER InitWrite: there is no device behind the radio until bring-up. A + * station cannot invent its address (see the top of this file). */ + if (!g_dev->GetPermanentMacAddress(g_own)) { + std::fprintf(stderr, + "sta_client: the radio does not report its MAC address - a " + "station cannot invent one\n"); + return 1; + } + + { + std::lock_guard l(g_mu); + /* set_wanted BEFORE any beacon is folded in, so a flood of fabricated + * BSSIDs cannot age the genuine AP out of the table. */ + g_bss.set_wanted(g_ssid); + const bool ok = g_psk.empty() + ? g_sm.configure_open(g_ssid, g_own) + : g_sm.configure(g_crypto, g_ssid, g_psk.c_str(), g_own); + if (!ok) { + std::fprintf(stderr, "sta_client: configure failed\n"); + return 1; + } + } + + /* A TAP that was asked for and could not be opened is a refusal, not a + * TAP-less run. */ + if (const char* t = std::getenv("DEVOURER_STA_TAP")) { + g_tap_fd = tap_open(t, g_own); + if (g_tap_fd < 0) { + std::fprintf(stderr, "sta_client: DEVOURER_STA_TAP=%s could not be " + "opened - refusing to run without it\n", t); + return 1; + } + } + + /* A thread that cannot be started is refused before anything is armed + * or joined: nothing to undo yet. */ + std::thread rx; + try { + rx = std::thread([&] { + try { + g_dev->StartRxLoop(on_rx); + } catch (const std::exception& e) { + fault("RX loop", e.what()); + } catch (...) { + fault("RX loop", "unknown exception"); + } + }); + } catch (const std::system_error& e) { + std::fprintf(stderr, "sta_client: RX thread did not start: %s\n", + e.what()); + return 1; + } + + /* From here on `rx` is running: a TAP reader that cannot be started stops + * the run through the normal teardown (leave, clear, ledger), never by + * unwinding past a joinable `rx`. */ + std::thread tap_rd; + if (g_tap_fd >= 0) try { + tap_rd = std::thread([&] { + uint8_t eth[2048]; + const int fd = g_tap_fd; + for (;;) { + /* A poll with a timeout and a stop flag: a close() from another + * thread does not wake a read() already blocked on the fd. */ + pollfd pf{fd, POLLIN, 0}; + const int r = ::poll(&pf, 1, 200); + if (g_tap_stop.load()) return; + if (r < 0 && errno == EINTR) continue; + if (r == 0) continue; + /* A TAP that errors (or is deleted under us) is a fault, not a + * quiet end of the reader: the station would look healthy and + * carry nothing from the host. */ + if (r < 0 || (pf.revents & (POLLERR | POLLHUP | POLLNVAL))) { + g_tap_read_err.fetch_add(1); + fault("TAP poll", std::strerror(r < 0 ? errno : EIO)); + return; + } + const ssize_t got = ::read(fd, eth, sizeof eth); + /* The fd is non-blocking: poll() said readable, but a frame can + * still be gone - nothing to read, wait again. */ + if (got < 0 && (errno == EAGAIN || errno == EWOULDBLOCK || + errno == EINTR)) + continue; + if (got <= 0) { + g_tap_read_err.fetch_add(1); + fault("TAP read", got < 0 ? std::strerror(errno) : "end of file"); + return; + } + /* Guarded like on_rx: a throw here is std::terminate otherwise. */ + try { + tap_down_one(eth, (size_t)got); + } catch (const std::exception& e) { + fault("TAP path", e.what()); + return; + } catch (...) { + fault("TAP path", "unknown exception"); + return; + } + } + }); + } catch (const std::system_error& e) { + fault("TAP thread start", e.what()); + } + + std::fprintf(stderr, + "sta_client up: own %02x:%02x:%02x:%02x:%02x:%02x ssid '%s' " + "%s ch%u station_mode_ok=%d arm=%d\n", + g_own[0], g_own[1], g_own[2], g_own[3], g_own[4], g_own[5], + g_ssid.c_str(), g_psk.empty() ? "OPEN" : "WPA2-PSK", g_chan, + (int)caps.station_mode_ok, (int)g_arm); + + uint8_t tuned = g_chan; + /* THE IDENTITY IS ARMED FOR THE BSS ACTUALLY JOINED, once per BSSID, after + * StartRxLoop (IRadio's ordering rule) - the BSSID is not known before a + * BSS has been selected. A refused arm is retried a few times, a second + * apart. */ + uint8_t bssid_armed[6] = {0}; + uint8_t bssid_joined[6] = {0}; + constexpr int kArmTries = 5; + int arm_tries = 0; + bool arm_ok = false; + bool arm_attempted = false; /* any SetStationIdentity call this run */ + uint32_t next_arm_ms = 0; + const auto end = std::chrono::steady_clock::now() + std::chrono::seconds(sec); + while (!g_stop && std::chrono::steady_clock::now() < end) try { + const uint32_t now = now_ms(); + const uint8_t want = supervise(now); + if (want && want != tuned) { + /* Only ever reached while unassociated: supervise() returns the joined + * channel once the machine has left Idle/Failed. */ + g_tuned.store(0); /* unknown until the retune returns */ + g_dev->SetMonitorChannel(SelectedChannel{want, 0, CHANNEL_WIDTH_20}); + tuned = want; + g_retune_ms.store(now_ms()); + g_tuned.store(want); + } + bool arm_now = false; + bool join_now = false; + { + std::lock_guard l(g_mu); + g_sm.tick(now); + if (g_sm.state() != StationSm::State::Idle && + std::memcmp(bssid_joined, g_sm.bssid(), 6) != 0) { + std::memcpy(bssid_joined, g_sm.bssid(), 6); + join_now = true; + } + /* DECIDED under g_mu, MADE below without it. */ + if (g_arm && caps.station_mode_ok && + g_sm.state() != StationSm::State::Idle) { + if (std::memcmp(bssid_armed, g_sm.bssid(), 6) != 0) { + std::memcpy(bssid_armed, g_sm.bssid(), 6); + arm_tries = 0; + arm_ok = false; + next_arm_ms = now; + } + if (!arm_ok && arm_tries < kArmTries && + (int32_t)(now - next_arm_ms) >= 0) { + ++arm_tries; + next_arm_ms = now + 1000; + arm_now = true; + } + } + std::vector f; + while (g_sm.pop_tx(&f)) { + devourer::sta::assign_seq(f, g_data_seq.next()); + enqueue(std::move(f)); + } + } + if (join_now) + std::fprintf(stderr, + " station joining BSSID %02x:%02x:%02x:%02x:%02x:%02x\n", + bssid_joined[0], bssid_joined[1], bssid_joined[2], + bssid_joined[3], bssid_joined[4], bssid_joined[5]); + /* THE ARM IS MADE OUTSIDE g_mu: it is synchronous USB control I/O, and a + * libusb synchronous transfer can wait on the thread handling events - + * the RX thread, inside on_rx, which takes g_mu (IRadio::StartRxLoop's + * lock rule). Before the send below, so the auth just queued airs armed. */ + if (arm_now) { + const devourer::MacAddr own{{g_own[0], g_own[1], g_own[2], g_own[3], + g_own[4], g_own[5]}}; + const devourer::MacAddr bss{{bssid_armed[0], bssid_armed[1], + bssid_armed[2], bssid_armed[3], + bssid_armed[4], bssid_armed[5]}}; + arm_attempted = true; + arm_ok = g_dev->SetStationIdentity(own, bss); + std::fprintf(stderr, + " station identity %s for BSSID " + "%02x:%02x:%02x:%02x:%02x:%02x (attempt %d/%d)\n", + arm_ok ? "armed" : "REFUSED", bssid_armed[0], + bssid_armed[1], bssid_armed[2], bssid_armed[3], + bssid_armed[4], bssid_armed[5], arm_tries, kArmTries); + } + send_batch(); + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } catch (const std::exception& e) { + /* Out through the normal exit path below - leave, clear, ledger - and + * not through std::terminate. */ + fault("main loop", e.what()); + } catch (...) { + fault("main loop", "unknown exception"); + } + + /* LEAVE CLEANLY: an abandoned association stays alive at the AP until it + * times the station out, holding an AID. */ + /* Guarded like every teardown step: `tap_rd` and `rx` are still joinable + * here, so an exception must not leave main. */ + try { + std::lock_guard l(g_mu); + g_sm.leave(); + std::vector f; + while (g_sm.pop_tx(&f)) { + devourer::sta::assign_seq(f, g_data_seq.next()); + enqueue(std::move(f)); + } + } catch (const std::exception& e) { + fault("leave", e.what()); + } catch (...) { + fault("leave", "unknown exception"); + } + /* THE TEARDOWN ORDER. Both producers stop before the last drain - the TAP + * reader, then the RX thread (a rekey's answer is queued from it) - so + * nothing is encrypted or queued between the drain and the ledger and its + * two identities hold exactly. The TAP fd is closed under g_mu, after the + * RX thread is gone, because tap_up() writes through it from that thread. + * The final send needs no RX loop: it is the leave's deauth. */ + g_tap_stop.store(true); + if (tap_rd.joinable()) tap_rd.join(); + /* Guarded: a throw here would leave `rx` joinable, and its destructor + * would terminate before the clear and the ledger. */ + try { + g_dev->StopRxLoop(); + } catch (const std::exception& e) { + fault("StopRxLoop", e.what()); + } catch (...) { + fault("StopRxLoop", "unknown exception"); + } + if (rx.joinable()) rx.join(); + { + std::lock_guard l(g_mu); + if (g_tap_fd >= 0) { ::close(g_tap_fd); g_tap_fd = -1; } + } + try { + send_batch(); + } catch (const std::exception& e) { + fault("final send", e.what()); + } catch (...) { + fault("final send", "unknown exception"); + } + /* Cleared on the way out whenever an arm was attempted - every path that + * can reach SetStationIdentity ends here. The result is the only way to + * learn a rollback did not land (IRadio: the port may keep answering for + * `own`), so it is printed; on a backend whose arm wrote nothing it is + * trivially true. */ + if (arm_attempted) { + const char* r = "NOT VERIFIED"; + try { + if (g_dev->ClearStationIdentity()) r = "restored (verified)"; + /* The port may still answer for `own`: a fault, not a quiet line. */ + else fault("ClearStationIdentity", "rollback NOT VERIFIED"); + } catch (const std::exception& e) { + fault("ClearStationIdentity", e.what()); + } catch (...) { + fault("ClearStationIdentity", "unknown exception"); + } + std::fprintf(stderr, " station identity clear: %s\n", r); + } + + report(); + return exit_status(); +} + +/* The headless cells. Included rather than linked because everything they + * drive is in this file's anonymous namespace. */ +#include "sta_client_selftest.inc" diff --git a/tests/sta_client_selftest.inc b/tests/sta_client_selftest.inc new file mode 100644 index 00000000..5b65e331 --- /dev/null +++ b/tests/sta_client_selftest.inc @@ -0,0 +1,1947 @@ +/* sta_client_selftest.inc — the headless half of tests/sta_client.cpp. + * + * WHY THIS IS AN .inc AND NOT A .cpp. Everything it drives lives in + * sta_client.cpp's anonymous namespace: rx_frame(), tap_down_one(), + * supervise(), the keys, the replay windows and the ledger. A separate + * translation unit cannot reach any of it, and giving them external linkage + * would export a harness's internals for anything to link against. So this + * file is textually included at the end of sta_client.cpp and reached through + * `sta_client --self-test`, which returns before libusb is touched. It is not + * a header; do not include it anywhere else. + * + * WHAT IT DRIVES. Real frames into the real rx_frame(), the real transmit + * queue read back out, and the real TAP write path with a pipe standing in + * for the device. It plays the AUTHENTICATOR: it answers authentication and + * association, derives the PTK from the same PSK, builds messages 1 and 3 + * with a correct MIC, and encrypts data frames under the PTK it negotiated. + * Nothing is stubbed; the only missing piece is the radio. + * + * WHY THAT MATTERS HERE MORE THAN USUAL. This harness's whole job is the part + * src/sta/ refused to guess at - the scan, the reconnect policy and the key + * selection in the data plane - and none of those has any other test. Without + * this file they would exist only inside an on-air script. + * + * WHAT IT DOES NOT COVER, stated rather than implied: + * - USB, the radio, SetStationIdentity, the channel retune, and everything + * below send_packet(). Those need a device and stay in + * tests/mt7612u_sta_onair.sh. + * - The four-way's INTEROPERABILITY. The fixture below and the code it + * tests share an author, so a shared misreading is invisible to it. + * tests/eapol_kernel_vectors.h (ctest `supplicant`) is what pins that, + * against frames hostapd and wpa_supplicant actually exchanged. + * - The TAP's reader THREAD, and the main loop's. tap_down_one() and + * supervise() are called directly; the threads around them are a read() + * loop and a sleep loop and nothing else. + */ + +namespace selftest { + +int g_fail = 0; + +void check(bool ok, const char* what) { + if (!ok) { + std::fprintf(stdout, "FAIL: %s\n", what); + g_fail++; + } +} + +const uint8_t kBssid[6] = {0x02, 0x42, 0x75, 0x05, 0xd6, 0x00}; +const uint8_t kStaMac[6] = {0x02, 0x11, 0x22, 0x33, 0x44, 0x01}; +const uint8_t kPeer[6] = {0x02, 0x99, 0x88, 0x77, 0x66, 0x55}; +const uint8_t kBcast[6] = {0xff, 0xff, 0xff, 0xff, 0xff, 0xff}; +const char* kSsid = "devourerSTA"; +const char* kPsk = "devourer123"; + +int g_tap_rd = -1; + +/* Every cell starts from a clean process. These are file-scope globals in a + * harness that normally runs once, so a cell that inherited the previous + * one's replay window or join counter would pass or fail for reasons its own + * name does not mention. */ +void reset_all() { + std::lock_guard l(g_mu); + g_bss.clear(); + /* DESTROY AND RECONSTRUCT rather than assign: StationSm holds a SeqCounter, + * whose counter is a std::atomic, so the class is not copy-assignable - and + * a cell that inherited the previous one's counters would pass or fail for + * reasons its own name does not mention. The destructor wipes the PMK, + * which is the behaviour we want here anyway. */ + g_sm.~StationSm(); + ::new (&g_sm) StationSm(); + std::memcpy(g_own, kStaMac, 6); + g_ssid = kSsid; + g_psk = kPsk; + g_tx_pn = 1; + g_rx_replay.reset(); + g_rx_dup.reset(); + g_dup_drop = 0; + g_group_replay.reset(); + g_scan_idx = 0; + g_scan_switch_ms = 0; + g_next_join_ms = 0; + g_join_attempts = 0; + g_gave_up = false; + g_failed_noted = false; + g_was_associated = false; + g_reconnect = true; + g_rejoin_backoff_ms = 1000; + g_scan_chans.clear(); + g_scan_chans.push_back(6); + g_chan = 6; + g_bss.set_wanted(kSsid); + { std::lock_guard q(g_q_mu); g_q.clear(); } + g_beacons = 0; g_probe_tx = 0; g_joins = 0; g_associations = 0; + g_reconnects = 0; g_enc_rx = 0; g_mic_fail = 0; g_replays = 0; + g_group_rx = 0; g_plain_rx = 0; g_rx_short = 0; + g_tap_tx = 0; g_tap_rx = 0; g_tap_drop = 0; g_tap_down_drop = 0; + g_q_in = 0; + g_tx_enc = 0; g_tx_enc_fail = 0; g_tx_plain = 0; + g_crc_err = 0; g_amsdu_drop = 0; g_frag_drop = 0; + g_eapol_enc_rx = 0; g_eapol_enc_tx = 0; + g_ptk_installs = 0; g_gtk_installs = 0; g_no_key = 0; + g_ptk_gen_seen = 0; g_gtk_gen_seen = 0; + g_scan_dwell_ms = 250; + g_tuned = 6; + g_retune_ms = 0u - kRetuneGuardMs; /* no retune in progress at now = 0 */ + g_fault = 0; + g_stop = 0; + g_tap_read_err = 0; +} + +/* Drain whatever the station queued, stripping the radiotap prefix the real + * enqueue() adds, so a cell reads MPDUs. */ +std::vector> drain_tx() { + std::vector> out; + std::lock_guard l(g_q_mu); + for (auto& f : g_q) { + if (f.size() <= g_rt.size()) continue; + out.emplace_back(f.begin() + (long)g_rt.size(), f.end()); + } + g_q.clear(); + return out; +} + +/* Everything the main loop does between ticks, minus the radio. */ +void pump_tx() { + std::lock_guard l(g_mu); + std::vector f; + while (g_sm.pop_tx(&f)) { + devourer::sta::assign_seq(f, g_data_seq.next()); + enqueue(std::move(f)); + } +} + +void tick(uint32_t now) { + std::lock_guard l(g_mu); + g_sm.tick(now); +} + +/* One Ethernet frame off the TAP pipe, or empty. Non-blocking, so a cell that + * expects nothing does not hang. */ +std::vector tap_read() { + uint8_t buf[2048]; + const ssize_t n = ::read(g_tap_rd, buf, sizeof buf); + if (n <= 0) return {}; + return std::vector(buf, buf + n); +} + +/* ---- the fixture authenticator ----------------------------------------- */ + +std::vector beacon(bool rsn, uint8_t chan = 6, + const uint8_t* bssid = kBssid, + const char* ssid = kSsid, bool with_ds = true) { + std::vector m = + devourer::sta::mgmt_hdr(devourer::sta::kFcBeacon, kBcast, bssid, bssid); + m.insert(m.end(), 8, 0); + devourer::sta::put_le16(m, 100); + devourer::sta::put_le16(m, (uint16_t)(rsn ? 0x0011 : 0x0001)); + devourer::sta::append_ssid(m, ssid); + devourer::sta::append_supported_rates(m); + if (with_ds) devourer::sta::append_ds_params(m, chan); + if (rsn) devourer::sta::append_rsn_ccmp_psk(m); + return m; +} + +struct Ap { + devourer::test::OpenSslCryptoOps crypto; + uint8_t pmk[32] = {0}; + uint8_t anonce[32] = {0}; + uint8_t ptk[48] = {0}; + uint8_t gtk[32] = {0}; + uint8_t gtk_id = 1; + uint64_t replay = 0; + uint64_t tx_pn = 1; + uint64_t group_pn = 1; + uint16_t aid = 3; + devourer::sta::SeqCounter seq; + bool saw_auth = false, saw_assoc = false, saw_msg2 = false, saw_msg4 = false; + + Ap() { + devourer::sta::pmk_from_psk(crypto, kPsk, kSsid, pmk); + std::memset(anonce, 0x5e, 32); + std::memset(gtk, 0x31, 32); + } + + std::vector mgmt(uint8_t fc) { + return devourer::sta::mgmt_hdr(fc, kStaMac, kBssid, kBssid); + } + + std::vector eapol_frame(const std::vector& body) { + std::vector m = devourer::sta::data_hdr_from_ds( + kStaMac, kBssid, kBssid, /*protect=*/false, seq.next()); + devourer::sta::append_llc_snap(m, 0x888e); + m.insert(m.end(), body.begin(), body.end()); + return m; + } + + std::vector msg1() { + replay++; + return devourer::sta::build_eapol_key( + devourer::sta::kKeyDescVersionCcmp | devourer::sta::kKiPairwise | + devourer::sta::kKiAck, + 16, replay, anonce, nullptr, nullptr, 0, nullptr, nullptr); + } + + /* 16 for CCMP. A KDE may carry 16 to 32 (find_gtk_kde accepts that whole + * range, because the field is cipher-independent), so an AP can hand a + * station a group key its data plane cannot use. */ + size_t gtk_len = 16; + + std::vector msg3() { + std::vector kd; + const uint8_t hdr[8] = {0xdd, (uint8_t)(6 + gtk_len), 0x00, 0x0f, 0xac, + 0x01, gtk_id, 0x00}; + devourer::sta::append_rsn_ccmp_psk(kd); + kd.insert(kd.end(), hdr, hdr + 8); + kd.insert(kd.end(), gtk, gtk + gtk_len); + if (kd.size() % 8) { + kd.push_back(0xdd); + while (kd.size() % 8) kd.push_back(0x00); + } + std::vector w(kd.size() + 8); + const int n = devourer::test::OpenSslCryptoOps::key_wrap( + ptk + 16, 16, kd.data(), kd.size(), w.data()); + w.resize(n > 0 ? (size_t)n : 0); + replay++; + return devourer::sta::build_eapol_key( + devourer::sta::kKeyDescVersionCcmp | devourer::sta::kKiPairwise | + devourer::sta::kKiInstall | devourer::sta::kKiAck | + devourer::sta::kKiMic | devourer::sta::kKiSecure | + devourer::sta::kKiEncrypted, + 16, replay, anonce, nullptr, w.data(), w.size(), &crypto, ptk); + } + + /* Answer one frame the station aired. Returns what the AP would send. */ + std::vector respond(const std::vector& f, bool rsn) { + if (f.size() < 24) return {}; + const uint8_t fc0 = f[0]; + if (fc0 == devourer::sta::kFcAuth) { + saw_auth = true; + std::vector m = mgmt(devourer::sta::kFcAuth); + devourer::sta::put_le16(m, 0); + devourer::sta::put_le16(m, 2); + devourer::sta::put_le16(m, 0); + return m; + } + if (fc0 == devourer::sta::kFcAssocReq) { + saw_assoc = true; + std::vector m = mgmt(devourer::sta::kFcAssocResp); + devourer::sta::put_le16(m, (uint16_t)(rsn ? 0x0011 : 0x0001)); + devourer::sta::put_le16(m, 0); + devourer::sta::put_le16(m, (uint16_t)(0xc000 | aid)); + return m; + } + if (fc0 != devourer::sta::kFcData) return {}; + const size_t hlen = 24; + if (f.size() < hlen + 8 + devourer::sta::kEapolKeyFixedLen) return {}; + if (!(f[hlen + 6] == 0x88 && f[hlen + 7] == 0x8e)) return {}; + devourer::sta::EapolKey k; + if (!devourer::sta::parse_eapol_key(f.data() + hlen + 8, + f.size() - hlen - 8, &k)) + return {}; + if (!k.secure()) { + saw_msg2 = true; + if (!devourer::sta::derive_ptk(crypto, pmk, kBssid, kStaMac, anonce, + k.nonce, ptk)) + return {}; + if (devourer::sta::eapol_mic_ok(crypto, ptk, k) != + devourer::sta::MicCheck::Ok) + return {}; + return eapol_frame(msg3()); + } + saw_msg4 = devourer::sta::eapol_mic_ok(crypto, ptk, k) == + devourer::sta::MicCheck::Ok; + return {}; + } + + /* A downlink data frame to the station, protected under the pairwise key. */ + std::vector data_to_sta(const uint8_t* payload, size_t len, + uint16_t ethertype = 0x0800) { + std::vector pt; + devourer::sta::append_llc_snap(pt, ethertype); + pt.insert(pt.end(), payload, payload + len); + std::vector hdr = devourer::sta::data_hdr_from_ds( + kStaMac, kBssid, kPeer, /*protect=*/true, seq.next()); + std::vector m( + devourer::sta::ccmp_encrypted_len(hdr.size(), pt.size())); + const size_t n = devourer::sta::ccmp_encrypt( + crypto, ptk + 32, hdr.data(), hdr.size(), kBssid, tx_pn++, 0, + pt.data(), pt.size(), m.data(), m.size()); + m.resize(n); + return m; + } + + /* A group frame, under the GTK at the key id message 3 advertised. */ + std::vector group_to_bss(const uint8_t* payload, size_t len, + uint8_t key_id_override = 0xff) { + std::vector pt; + devourer::sta::append_llc_snap(pt, 0x0806); + pt.insert(pt.end(), payload, payload + len); + std::vector hdr = devourer::sta::data_hdr_from_ds( + kBcast, kBssid, kPeer, /*protect=*/true, seq.next()); + std::vector m( + devourer::sta::ccmp_encrypted_len(hdr.size(), pt.size())); + const uint8_t kid = key_id_override == 0xff ? gtk_id : key_id_override; + const size_t n = devourer::sta::ccmp_encrypt( + crypto, gtk, hdr.data(), hdr.size(), kBssid, group_pn++, kid, + pt.data(), pt.size(), m.data(), m.size()); + m.resize(n); + return m; + } + + /* A QoS downlink, so the A-MSDU and per-TID arms have a frame to use. Built + * by hand rather than through data_hdr_from_ds, which only makes the + * non-QoS form - and the two differ by exactly the two octets this cell is + * about. */ + std::vector qos_to_sta(const uint8_t* payload, size_t len, + uint8_t tid, bool amsdu) { + std::vector pt; + devourer::sta::append_llc_snap(pt, 0x0800); + pt.insert(pt.end(), payload, payload + len); + std::vector hdr; + hdr.push_back(0x88); /* QoS Data */ + hdr.push_back((uint8_t)(devourer::sta::kFcFromDs | + devourer::sta::kFcProtected)); + devourer::sta::put_le16(hdr, 0); + hdr.insert(hdr.end(), kStaMac, kStaMac + 6); + hdr.insert(hdr.end(), kBssid, kBssid + 6); + hdr.insert(hdr.end(), kPeer, kPeer + 6); + devourer::sta::put_le16(hdr, (uint16_t)((seq.next() & 0x0fff) << 4)); + hdr.push_back((uint8_t)((tid & 0x0f) | (amsdu ? 0x80 : 0))); + hdr.push_back(0); + std::vector m( + devourer::sta::ccmp_encrypted_len(hdr.size(), pt.size())); + const size_t n = devourer::sta::ccmp_encrypt( + crypto, ptk + 32, hdr.data(), hdr.size(), kBssid, tx_pn++, 0, + pt.data(), pt.size(), m.data(), m.size()); + m.resize(n); + return m; + } + + /* A group rekey: message 1 of the group key handshake, carrying a NEW GTK. + * Not pairwise, not install; ack + MIC + secure + encrypted. */ + std::vector group_msg1(const uint8_t new_gtk[16], uint8_t key_id, + const uint8_t* rsc = nullptr) { + std::memcpy(gtk, new_gtk, 16); + gtk_id = key_id; + group_pn = 1; /* a new key means a new PN space */ + std::vector kd; + const uint8_t hdr[8] = {0xdd, 0x16, 0x00, 0x0f, 0xac, 0x01, key_id, 0x00}; + kd.insert(kd.end(), hdr, hdr + 8); + kd.insert(kd.end(), gtk, gtk + 16); + std::vector w(kd.size() + 8); + const int n = devourer::test::OpenSslCryptoOps::key_wrap( + ptk + 16, 16, kd.data(), kd.size(), w.data()); + w.resize(n > 0 ? (size_t)n : 0); + replay++; + return devourer::sta::build_eapol_key( + devourer::sta::kKeyDescVersionCcmp | devourer::sta::kKiAck | + devourer::sta::kKiMic | devourer::sta::kKiSecure | + devourer::sta::kKiEncrypted, + 16, replay, anonce, rsc, w.data(), w.size(), &crypto, ptk); + } + + /* The same QoS downlink with an HT CONTROL field, which is what an HT AP + * sends whenever it wants link adaptation - so, routinely. HT Control sits + * AFTER the QoS Control, which is why a receiver that looks for the QoS + * Control at "header length minus two" reads link-adaptation bits instead. + * + * AND HT CONTROL IS NOT IN THE CCMP AAD (see ccmp_aad: frame control, + * addr1-3, the fragment number, addr4, the QoS TID - and nothing else), so + * an attacker can rewrite it on a captured frame without breaking the MIC. + * That is what turns a wrong offset from an accounting error into a replay + * bypass: flip HT Control, and the replayed frame is filed under a + * different per-TID window from the original. */ + std::vector qos_htc_to_sta(const uint8_t* payload, size_t len, + uint8_t tid, uint32_t htc, + uint64_t pn_override = 0) { + std::vector pt; + devourer::sta::append_llc_snap(pt, 0x0800); + pt.insert(pt.end(), payload, payload + len); + std::vector hdr; + hdr.push_back(0x88); /* QoS Data */ + hdr.push_back((uint8_t)(devourer::sta::kFcFromDs | + devourer::sta::kFcProtected | 0x80)); /* +HTC */ + devourer::sta::put_le16(hdr, 0); + hdr.insert(hdr.end(), kStaMac, kStaMac + 6); + hdr.insert(hdr.end(), kBssid, kBssid + 6); + hdr.insert(hdr.end(), kPeer, kPeer + 6); + devourer::sta::put_le16(hdr, (uint16_t)((seq.next() & 0x0fff) << 4)); + hdr.push_back((uint8_t)(tid & 0x0f)); + hdr.push_back(0); + for (int i = 0; i < 4; i++) + hdr.push_back((uint8_t)((htc >> (8 * i)) & 0xff)); + std::vector m( + devourer::sta::ccmp_encrypted_len(hdr.size(), pt.size())); + const uint64_t pn = pn_override ? pn_override : tx_pn++; + const size_t n = devourer::sta::ccmp_encrypt( + crypto, ptk + 32, hdr.data(), hdr.size(), kBssid, pn, 0, + pt.data(), pt.size(), m.data(), m.size()); + m.resize(n); + return m; + } + + /* A PTK rekey: the four-way again, with the association already up. Both + * messages ride INSIDE the cipher, under the CURRENT pairwise key. */ + std::vector ptk_rekey_msg1() { + std::memset(anonce, 0x77, 32); /* a fresh ANonce, as an AP does */ + return eapol_protected(msg1()); + } + + /* An EAPOL-Key frame protected under the GROUP key and addressed to the + * BROADCAST. Not part of any handshake, and not the host's either. */ + std::vector group_eapol(const std::vector& body) { + std::vector pt; + devourer::sta::append_llc_snap(pt, 0x888e); + pt.insert(pt.end(), body.begin(), body.end()); + std::vector hdr = devourer::sta::data_hdr_from_ds( + kBcast, kBssid, kBssid, /*protect=*/true, seq.next()); + std::vector m( + devourer::sta::ccmp_encrypted_len(hdr.size(), pt.size())); + const size_t n = devourer::sta::ccmp_encrypt( + crypto, gtk, hdr.data(), hdr.size(), kBssid, group_pn++, gtk_id, + pt.data(), pt.size(), m.data(), m.size()); + m.resize(n); + return m; + } + + /* An EAPOL-Key frame PROTECTED under the pairwise key - which is how a + * real AP sends a group rekey, because it runs after the PTK is + * installed. The cleartext eapol_frame() above is the four-way's shape and + * is not this one. */ + /* `tk` defaults to the currently installed pairwise key. A PTK REKEY has + * to pass the OLD one explicitly: message 3 carries the material for the + * new key and is itself protected by the key still in force. Getting this + * wrong in the FIXTURE produced exactly the failure the code under test + * must not have - the station could not decrypt message 3 at all. */ + std::vector eapol_protected(const std::vector& body, + const uint8_t* tk = nullptr) { + std::vector pt; + devourer::sta::append_llc_snap(pt, 0x888e); + pt.insert(pt.end(), body.begin(), body.end()); + std::vector hdr = devourer::sta::data_hdr_from_ds( + kStaMac, kBssid, kBssid, /*protect=*/true, seq.next()); + std::vector m( + devourer::sta::ccmp_encrypted_len(hdr.size(), pt.size())); + const size_t n = devourer::sta::ccmp_encrypt( + crypto, tk ? tk : ptk + 32, hdr.data(), hdr.size(), kBssid, tx_pn++, + 0, pt.data(), pt.size(), m.data(), m.size()); + m.resize(n); + return m; + } + + /* A UNICAST frame encrypted under the GROUP key. Not a contrivance: an AP + * may do this during a rekey, and it is the one shape where the address + * and the key id disagree in the direction that a key-by-address receiver + * gets WRONG (it reaches for the pairwise key and fails the MIC). */ + std::vector unicast_under_gtk(const uint8_t* payload, size_t len) { + std::vector pt; + devourer::sta::append_llc_snap(pt, 0x0800); + pt.insert(pt.end(), payload, payload + len); + std::vector hdr = devourer::sta::data_hdr_from_ds( + kStaMac, kBssid, kPeer, /*protect=*/true, seq.next()); + std::vector m( + devourer::sta::ccmp_encrypted_len(hdr.size(), pt.size())); + const size_t n = devourer::sta::ccmp_encrypt( + crypto, gtk, hdr.data(), hdr.size(), kBssid, group_pn++, gtk_id, + pt.data(), pt.size(), m.data(), m.size()); + m.resize(n); + return m; + } + + /* An UNPROTECTED data frame, which a WPA2 link must refuse. */ + std::vector plain_to_sta(const uint8_t* payload, size_t len) { + std::vector m = devourer::sta::data_hdr_from_ds( + kStaMac, kBssid, kPeer, /*protect=*/false, seq.next()); + devourer::sta::append_llc_snap(m, 0x0800); + m.insert(m.end(), payload, payload + len); + return m; + } +}; + +/* Run the whole ladder: beacon -> supervise joins -> the AP answers until the + * station is Connected. Returns the time it finished at. */ +uint32_t associate(Ap& ap, bool rsn, uint32_t now = 0) { + const std::vector b = beacon(rsn); + rx_frame(b.data(), b.size(), -40, now); + supervise(now); + pump_tx(); + + for (int round = 0; round < 8; round++) { + const std::vector> tx = drain_tx(); + if (tx.empty()) break; + for (const std::vector& f : tx) { + const std::vector r = ap.respond(f, rsn); + if (!r.empty()) rx_frame(r.data(), r.size(), -40, now); + if (rsn && f[0] == devourer::sta::kFcAssocReq) { + const std::vector m1 = ap.eapol_frame(ap.msg1()); + rx_frame(m1.data(), m1.size(), -40, now); + } + } + pump_tx(); + } + return now; +} + +/* ---- cells -------------------------------------------------------------- */ + +/* THE OPEN LADDER. It exists so that a station which fails to connect can say + * whether authentication/association or the key exchange is what broke: on a + * WPA2 BSS the two come up together or not at all. */ +void test_open_ladder() { + reset_all(); + { + std::lock_guard l(g_mu); + g_psk.clear(); + /* NOT `check(configure_open(...))`: that return is `true` on every path + * of the function, so the assertion could only fail if someone edited + * the `return` statement itself. The observable consequence is what is + * worth asserting. */ + g_sm.configure_open(kSsid, g_own); + check(g_sm.security() == StationSm::Security::Open, + "configure_open puts the station on the open path"); + } + Ap ap; + associate(ap, /*rsn=*/false); + + check(ap.saw_auth && ap.saw_assoc, "the AP saw both requests"); + check(g_sm.state() == StationSm::State::Connected, "the station connects"); + check(g_sm.connected() && !g_sm.keyed(), "connected, and not keyed"); + check(g_associations.load() == 1, "one association counted"); + check(g_beacons.load() == 1, "the beacon was folded into the scan"); + + /* An unprotected downlink is the ONLY kind an open link carries, so it must + * reach the host - the WPA2 arm below asserts the opposite for the same + * frame shape, which is what makes either arm mean anything. */ + const uint8_t payload[20] = {0xde, 0xad, 0xbe, 0xef}; + const std::vector d = ap.plain_to_sta(payload, sizeof payload); + rx_frame(d.data(), d.size(), -40, 0); + const std::vector eth = tap_read(); + check(eth.size() == devourer::sta::kEthHdrLen + sizeof payload, + "the plaintext frame reaches the host"); + if (eth.size() >= 14) { + check(std::memcmp(eth.data(), kStaMac, 6) == 0, "...addressed to us"); + check(std::memcmp(eth.data() + 6, kPeer, 6) == 0, "...from the sender"); + check(eth[12] == 0x08 && eth[13] == 0x00, "...with its ethertype"); + } + check(g_plain_rx.load() == 1 && g_enc_rx.load() == 0, + "counted as plaintext, not as encrypted"); + + /* And the uplink is unprotected too. A station that encrypted here with no + * key would air a frame under a zero TK. */ + uint8_t e[60] = {0}; + std::memcpy(e, kPeer, 6); + std::memcpy(e + 6, kStaMac, 6); + e[12] = 0x08; + e[13] = 0x00; + tap_down_one(e, sizeof e); + const std::vector> tx = drain_tx(); + check(tx.size() == 1, "the host's frame is aired"); + if (tx.size() == 1) { + check((tx[0][1] & devourer::sta::kFcProtected) == 0, + "...unprotected, on an open link"); + check((tx[0][1] & devourer::sta::kFcToDs) != 0, "...and to the DS"); + } + check(g_tx_plain.load() == 1 && g_tx_enc.load() == 0, + "counted as a plaintext transmission"); +} + +/* THE WPA2 LADDER, end to end through this harness's own receive path. */ +void test_wpa2_ladder() { + reset_all(); + { + std::lock_guard l(g_mu); + check(g_sm.configure(g_crypto, kSsid, kPsk, g_own), "configure"); + } + Ap ap; + associate(ap, /*rsn=*/true); + + check(g_sm.state() == StationSm::State::Connected, "the station connects"); + check(g_sm.keyed(), "...and is keyed"); + check(ap.saw_msg2 && ap.saw_msg4, "the AP saw messages 2 and 4"); + check(std::memcmp(g_sm.supplicant().ptk(), ap.ptk, 48) == 0, + "both sides derived the same PTK"); + check(g_sm.supplicant().gtk_valid() && + std::memcmp(g_sm.supplicant().gtk(), ap.gtk, 16) == 0, + "the GTK the AP sent was installed"); + check(g_sm.supplicant().gtk_key_id() == ap.gtk_id, + "...at the key id it advertised"); + check(g_gtk_installs.load() == 1, "...and counted as one group key"); + + const uint8_t payload[40] = {1, 2, 3, 4, 5}; + const std::vector d = ap.data_to_sta(payload, sizeof payload); + rx_frame(d.data(), d.size(), -40, 0); + const std::vector eth = tap_read(); + check(eth.size() == devourer::sta::kEthHdrLen + sizeof payload, + "the encrypted frame decrypts and reaches the host"); + if (eth.size() >= 14) + check(std::memcmp(eth.data() + 14, payload, sizeof payload) == 0, + "...with its payload intact"); + check(g_enc_rx.load() == 1 && g_mic_fail.load() == 0 && + g_replays.load() == 0, + "counted as one clean encrypted frame"); + + /* The uplink, and the AP decrypts it - which is what proves the station + * built the AAD and the nonce the way the peer expects, rather than the + * way this file happens to read them back. */ + uint8_t e[60] = {0}; + std::memcpy(e, kPeer, 6); + std::memcpy(e + 6, kStaMac, 6); + e[12] = 0x08; + e[13] = 0x00; + for (size_t i = 14; i < sizeof e; i++) e[i] = (uint8_t)i; + tap_down_one(e, sizeof e); + const std::vector> tx = drain_tx(); + check(tx.size() == 1, "the host's frame is aired"); + if (tx.size() == 1) { + const std::vector& f = tx[0]; + check((f[1] & devourer::sta::kFcProtected) != 0, "...protected"); + check((f[1] & devourer::sta::kFcToDs) != 0 && + (f[1] & devourer::sta::kFcFromDs) == 0, + "...to the DS and not from it"); + check(std::memcmp(f.data() + 4, kBssid, 6) == 0, "...addr1 = the BSSID"); + check(std::memcmp(f.data() + 10, kStaMac, 6) == 0, "...addr2 = us"); + check(std::memcmp(f.data() + 16, kPeer, 6) == 0, + "...addr3 = the destination"); + check(devourer::sta::ccmp_key_id(f.data() + 24) == 0, + "...under the pairwise key id"); + uint8_t plain[128]; + size_t plain_len = 0; + uint64_t pn = 0; + const bool ok = devourer::sta::ccmp_decrypt( + ap.crypto, ap.ptk + 32, f.data(), f.size(), 24, f.data() + 10, plain, + sizeof plain, &plain_len, &pn); + check(ok, "...and the AP decrypts it"); + if (ok) { + check(pn == 1, "...at PN 1, the first of this association"); + check(plain_len == devourer::sta::kLlcSnapLen + sizeof e - 14 && + std::memcmp(plain + devourer::sta::kLlcSnapLen, e + 14, + sizeof e - 14) == 0, + "...carrying the host's bytes"); + } + } + check(g_tx_enc.load() == 1, "counted as one encrypted transmission"); +} + +/* A replayed frame must be refused AFTER its MIC verified, and must not reach + * the host. The window is the station's only defence against an attacker who + * records one frame and airs it a million times. */ +void test_replay_is_refused() { + reset_all(); + { + std::lock_guard l(g_mu); + g_sm.configure(g_crypto, kSsid, kPsk, g_own); + } + Ap ap; + associate(ap, /*rsn=*/true); + + const uint8_t payload[16] = {9}; + const std::vector d = ap.data_to_sta(payload, sizeof payload); + rx_frame(d.data(), d.size(), -40, 0); + check(!tap_read().empty(), "the first copy reaches the host"); + rx_frame(d.data(), d.size(), -40, 0); + check(tap_read().empty(), "the replayed copy does NOT"); + check(g_replays.load() == 1, "...and is counted as a replay"); + check(g_mic_fail.load() == 0, "...not as a MIC failure - the MIC was fine"); + check(g_enc_rx.load() == 2, "both copies were seen"); +} + +/* A tampered frame. The cipher writes plaintext out BEFORE the tag is + * checked, so "it decrypted to something" is never the test - the return + * value is. */ +void test_forged_mic_is_refused() { + reset_all(); + { + std::lock_guard l(g_mu); + g_sm.configure(g_crypto, kSsid, kPsk, g_own); + } + Ap ap; + associate(ap, /*rsn=*/true); + + const uint8_t payload[16] = {7}; + std::vector d = ap.data_to_sta(payload, sizeof payload); + d[24 + 8] ^= 0x01; /* one ciphertext byte */ + rx_frame(d.data(), d.size(), -40, 0); + check(tap_read().empty(), "a tampered frame does not reach the host"); + check(g_mic_fail.load() == 1, "...and is counted as a MIC failure"); + /* AND THE PN IS NOT ADMITTED. Otherwise an attacker advances the window + * with garbage and locks the real AP out - the same rule the four-way's + * replay counter follows. */ + check(g_rx_replay.last(devourer::sta::CcmpReplay::kNonQosTid) == 0, + "...and its PN never entered the replay window"); + /* So the real AP's frame at the SAME PN the forgery claimed still gets + * through, and only then does the window move. */ + ap.tx_pn--; + const std::vector good = ap.data_to_sta(payload, sizeof payload); + rx_frame(good.data(), good.size(), -40, 0); + check(tap_read().size() == devourer::sta::kEthHdrLen + sizeof payload, + "the real AP's frame at that PN is delivered"); + check(g_rx_replay.last(devourer::sta::CcmpReplay::kNonQosTid) == ap.tx_pn - 1, + "...and its PN is the one admitted"); + rx_frame(good.data(), good.size(), -40, 0); + check(tap_read().empty() && g_replays.load() == 1, + "...and a replay of it is refused"); +} + +/* The key is chosen by the frame's own key id, not by its address. Guessing + * from the address is wrong in both directions, and the AP half of this tree + * had the mirror of that bug - it cost the whole BSS its broadcast traffic. */ +void test_group_key_is_chosen_by_key_id() { + reset_all(); + { + std::lock_guard l(g_mu); + g_sm.configure(g_crypto, kSsid, kPsk, g_own); + } + Ap ap; + associate(ap, /*rsn=*/true); + + const uint8_t arp[28] = {0, 1, 8, 0, 6, 4, 0, 1}; + const std::vector g = ap.group_to_bss(arp, sizeof arp); + rx_frame(g.data(), g.size(), -40, 0); + const std::vector eth = tap_read(); + check(eth.size() == devourer::sta::kEthHdrLen + sizeof arp, + "a group frame under the GTK reaches the host"); + if (eth.size() >= 14) + check((eth[0] & 0x01) != 0, "...addressed to the group"); + check(g_group_rx.load() == 1, "...counted as a group frame"); + + /* THE ARM THAT ACTUALLY DISCRIMINATES. A UNICAST frame under the group + * key: the address says pairwise, the key id says group, and they disagree + * in the direction a key-by-address receiver gets wrong - it reaches for + * the pairwise key and fails the MIC on a frame that was perfectly good. + * (A GROUP frame mislabelled key id 0 would not discriminate: it is + * refused a line later by the installed-GTK key-id check either way.) */ + const uint8_t payload[24] = {0x5a}; + const std::vector u = ap.unicast_under_gtk(payload, sizeof payload); + rx_frame(u.data(), u.size(), -40, 0); + check(tap_read().size() == devourer::sta::kEthHdrLen + sizeof payload, + "a UNICAST frame under the group key decrypts - the key id decides"); + check(g_mic_fail.load() == 0, "...with no MIC failure"); + + /* And the converse: a group frame claiming a key id we hold NO key for. + * + * KEY ID 3, NOT 0: the receiver maps 0 to the PAIRWISE branch before the + * "we hold no key for this id" rule is reached, so the MIC would fail on + * the cipher and the rule under test would go unexercised. */ + const std::vector mis = ap.group_to_bss(arp, sizeof arp, 3); + const uint64_t before = g_mic_fail.load(); + rx_frame(mis.data(), mis.size(), -40, 0); + check(tap_read().empty(), + "a group frame at a key id we hold no key for is refused"); + check(g_no_key.load() == 1, "...as a key we do not have"); + check(g_mic_fail.load() == before, "...and NOT as a MIC failure"); +} + +/* A GROUP KEY THIS DATA PLANE CANNOT USE never reaches it. The GTK KDE's + * key field is cipher-independent (find_gtk_kde admits 16 to 32 bytes), so + * an AP can offer a 32-byte group key in message 3; the supplicant refuses + * it there (Supplicant::kGtkLenCcmp), so the four-way does not complete and + * the data plane is never handed a key it would misuse. */ +void test_a_group_key_we_cannot_use() { + reset_all(); + { + std::lock_guard l(g_mu); + g_sm.configure(g_crypto, kSsid, kPsk, g_own); + } + Ap ap; + ap.gtk_len = 32; /* valid KDE, unusable by CCMP */ + associate(ap, /*rsn=*/true); + check(!g_sm.keyed(), "a 32-byte group key stops the four-way"); + check(!ap.saw_msg4, "...no message 4 answers it"); + check(g_gtk_installs.load() == 0 && g_ptk_installs.load() == 0, + "...and the data plane installed no key"); +} + +/* A RETRANSMISSION IS A DUPLICATE, NOT A REPLAY. The AP retries a frame + * whose ACK it missed: same sequence number, same PN, Retry set. The + * DupDetector drops it before decrypt; without it, the replay window would + * count the AP's ordinary retransmissions as attacks. */ +void test_a_retransmission_is_a_duplicate() { + reset_all(); + { + std::lock_guard l(g_mu); + g_sm.configure(g_crypto, kSsid, kPsk, g_own); + } + Ap ap; + associate(ap, /*rsn=*/true); + check(g_sm.keyed(), "the link is keyed"); + const uint8_t pl[16] = {7}; + std::vector d = ap.data_to_sta(pl, sizeof pl); + rx_frame(d.data(), d.size(), -40, 0); + check(tap_read().size() == devourer::sta::kEthHdrLen + sizeof pl, + "the original arrives"); + /* Retry is masked out of the CCMP AAD, so setting it keeps the MIC valid. */ + d[1] |= devourer::sta::kFcRetry; + rx_frame(d.data(), d.size(), -40, 0); + check(tap_read().empty(), "its retransmission does not"); + check(g_dup_drop.load() == 1, "...counted as a duplicate"); + check(g_replays.load() == 0, "...and NOT as a replay"); + /* The control: the same bytes WITHOUT Retry are not a duplicate by 802.11's + * rule, so they reach the replay window - which refuses the reused PN. */ + d[1] &= (uint8_t)~devourer::sta::kFcRetry; + rx_frame(d.data(), d.size(), -40, 0); + check(tap_read().empty() && g_replays.load() == 1, + "without Retry the same frame is a replay"); +} + +/* THE DUPLICATE CACHE OUTLIVES A REKEY AND NOT AN ASSOCIATION. Sequence + * Control is per transmitter and TID (DupDetector, Dot11.h); a PTK rekey does + * not restart it, so a Retry copy of the rekey's own message 3 - whose ACK + * the AP missed - is a duplicate. Decrypted instead, it would fail under the + * new key and read as a MIC failure. A NEW association is a new sequence + * space, and the cache must not refuse its frames. */ +void test_the_dup_cache_spans_a_rekey_not_an_association() { + reset_all(); + { + std::lock_guard l(g_mu); + g_sm.configure(g_crypto, kSsid, kPsk, g_own); + g_rejoin_backoff_ms = 100; + } + Ap ap; + associate(ap, /*rsn=*/true); + uint8_t old_ptk[48]; + std::memcpy(old_ptk, ap.ptk, sizeof old_ptk); + const std::vector m1 = ap.ptk_rekey_msg1(); + rx_frame(m1.data(), m1.size(), -40, 0); + std::vector> tx = drain_tx(); + if (tx.size() == 1) { + uint8_t plain[256]; + size_t plen = 0; + uint64_t pn = 0; + devourer::sta::EapolKey k; + if (devourer::sta::ccmp_decrypt(ap.crypto, old_ptk + 32, tx[0].data(), + tx[0].size(), 24, tx[0].data() + 10, plain, + sizeof plain, &plen, &pn) && + devourer::sta::parse_eapol_key(plain + devourer::sta::kLlcSnapLen, + plen - devourer::sta::kLlcSnapLen, &k)) + devourer::sta::derive_ptk(ap.crypto, ap.pmk, kBssid, kStaMac, ap.anonce, + k.nonce, ap.ptk); + } + std::vector m3 = ap.eapol_protected(ap.msg3(), old_ptk + 32); + rx_frame(m3.data(), m3.size(), -40, 0); + check(g_ptk_installs.load() == 2, "the rekey installed a new pairwise key"); + drain_tx(); + + m3[1] |= devourer::sta::kFcRetry; /* the AP's retransmission */ + rx_frame(m3.data(), m3.size(), -40, 0); + check(g_dup_drop.load() == 1, "a Retry copy of message 3 is a duplicate"); + check(g_mic_fail.load() == 0, "...not a MIC failure under the new key"); + + /* The link drops and a new association comes up on the same AP address. */ + uint32_t now = StationSm::kBeaconLossMs + 1; + tick(now); + supervise(now); + now += 200; + const std::vector b = beacon(true); + rx_frame(b.data(), b.size(), -40, now); + supervise(now); + /* The new association's message 3 completes it, and it is the first frame + * of that association to reach the duplicate check. Sent here with the + * Sequence Control the old cache last saw and Retry set: a cache that + * survived the association would count it as a duplicate. (Message 3's + * MIC covers the EAPOL body, not the header.) */ + Ap ap2; + for (int round = 0; round < 8; round++) { + pump_tx(); + tx = drain_tx(); + if (tx.empty()) break; + for (const std::vector& f : tx) { + std::vector r = ap2.respond(f, true); + if (r.size() > 24 && r[0] == devourer::sta::kFcData) { + r[22] = m3[22]; + r[23] = m3[23]; + r[1] |= devourer::sta::kFcRetry; + } + if (!r.empty()) rx_frame(r.data(), r.size(), -40, now); + if (f[0] == devourer::sta::kFcAssocReq) { + const std::vector e = ap2.eapol_frame(ap2.msg1()); + rx_frame(e.data(), e.size(), -40, now); + } + } + } + check(g_associations.load() == 2, "a second association"); + check(g_dup_drop.load() == 1, + "its first frame is not a duplicate of the old association's"); +} + +/* A HOST THAT IS NOT READING COSTS A DROP, NOT A STALL. The TAP is + * non-blocking (tap_open), and tap_up() runs on the RX thread under g_mu: a + * full TAP must return EAGAIN and be counted as an up-drop. The fixture's + * pipe is non-blocking at both ends, like the real fd; filled here, a + * blocking write would hang this cell. */ +void test_a_full_tap_is_a_drop_not_a_stall() { + reset_all(); + uint8_t fill[4096]; + std::memset(fill, 0, sizeof fill); + while (::write(g_tap_fd, fill, sizeof fill) > 0) {} + check(errno == EAGAIN || errno == EWOULDBLOCK, "the TAP is full"); + + uint8_t msdu[devourer::sta::kLlcSnapLen + 16]; + std::memset(msdu, 0, sizeof msdu); + std::vector snap; + devourer::sta::append_llc_snap(snap, 0x0800); + std::memcpy(msdu, snap.data(), snap.size()); + bool delivered = true; + { + std::lock_guard l(g_mu); + delivered = tap_up(kStaMac, kPeer, msdu, sizeof msdu); + } + check(!delivered, "a frame for a full TAP is not delivered"); + check(g_tap_drop.load() == 1 && g_tap_tx.load() == 0, + "...and is counted as an up-drop"); + + /* Empty the pipe for the cells that follow. */ + uint8_t sink[4096]; + while (::read(g_tap_rd, sink, sizeof sink) > 0) {} +} + +/* THE DURATION AND THE CHANNEL ARE PARSED STRICTLY: garbage is refused, + * not read as 0 (a zero-length run, or channel 0). */ +void test_duration_and_channel_parse_strictly() { + int secs = -1; + check(parse_secs("60", &secs) && secs == 60, "60 seconds"); + check(!parse_secs("0", &secs) && !parse_secs("-5", &secs) && + !parse_secs("abc", &secs) && !parse_secs("60s", &secs) && + !parse_secs("", &secs), + "a zero, negative or non-numeric duration is refused"); + uint8_t ch = 0; + check(parse_channel("6", &ch) && ch == 6, "channel 6"); + check(parse_channel("36", &ch) && ch == 36, "channel 36"); + check(!parse_channel("0", &ch) && !parse_channel("x", &ch) && + !parse_channel("6a", &ch) && !parse_channel("300", &ch) && + !parse_channel("", &ch), + "channel 0, garbage or out of range is refused"); + + const char* name = "DEVOURER_STA_SELFTEST_MS"; + uint32_t ms = 250; + ::unsetenv(name); + check(parse_env_ms(name, kDwellMinMs, kDwellMaxMs, &ms) && ms == 250, + "an unset millisecond knob keeps its default"); + ::setenv(name, "100", 1); + check(parse_env_ms(name, kDwellMinMs, kDwellMaxMs, &ms) && ms == 100, + "100 ms"); + const char* bad[] = {"", "abc", "100ms", "5", "20000", "-1"}; + bool all_refused = true; + for (const char* b : bad) { + ::setenv(name, b, 1); + uint32_t keep = 250; + if (parse_env_ms(name, kDwellMinMs, kDwellMaxMs, &keep)) all_refused = false; + } + check(all_refused, "an empty, non-numeric or out-of-range dwell is refused"); + ::setenv(name, "0", 1); + check(parse_env_ms(name, kBackoffMinMs, kBackoffMaxMs, &ms) && ms == 0, + "a zero backoff is a valid one"); + ::setenv(name, "60001", 1); + check(!parse_env_ms(name, kBackoffMinMs, kBackoffMaxMs, &ms), + "...and one past its bound is refused"); + ::unsetenv(name); +} + +/* A CAUGHT FAULT IS NOT A COMPLETED RUN. Every guard stops the run through + * the normal teardown, and the exit status is what tells the harness the two + * apart: 0 would read as "ran out of time". */ +void test_a_fault_exits_nonzero() { + reset_all(); + check(exit_status() == 0, "a run without a fault exits 0"); + fault("selftest", "a deliberate exception"); + check(g_stop.load() == 1, "a fault stops the run"); + check(exit_status() == 3, "...and the run exits 3"); + g_fault = 0; + g_stop = 0; +} + +/* THE STATION DEFAULT RETRY LIMIT applies unless the library actually took + * DEVOURER_TX_RETRY_LIMIT - an empty or non-numeric value is not one. */ +void test_the_retry_limit_env_is_parsed() { + const char* saved = std::getenv("DEVOURER_TX_RETRY_LIMIT"); + const std::string keep = saved ? saved : ""; + struct Case { const char* v; bool from_env; int limit; } cases[] = { + {nullptr, false, kStationRetryLimit}, + {"", false, kStationRetryLimit}, + {"5x", false, kStationRetryLimit}, + {"0", true, 0}, + {"12", true, 12}, + }; + for (const Case& c : cases) { + if (c.v) ::setenv("DEVOURER_TX_RETRY_LIMIT", c.v, 1); + else ::unsetenv("DEVOURER_TX_RETRY_LIMIT"); + devourer::DeviceConfig cfg = devourer_config_from_env(); + const bool from_env = apply_station_retry_limit(cfg); + check(from_env == c.from_env && cfg.tx.retry_limit == c.limit, + c.v ? c.v : "(unset)"); + } + if (saved) ::setenv("DEVOURER_TX_RETRY_LIMIT", keep.c_str(), 1); + else ::unsetenv("DEVOURER_TX_RETRY_LIMIT"); +} + +/* A BEACON IN THE RETUNE WINDOW. No backend reports a frame's RX channel, so + * one received just before a retune can arrive just after it. While the + * channel is unknown a beacon that states its own channel (DS Parameter Set) + * is still folded in; one that does not is not, rather than being tagged + * with the new channel. */ +void test_a_beacon_in_the_retune_window() { + reset_all(); + const uint8_t other[6] = {0x02, 0x42, 0x75, 0x05, 0xd6, 0x01}; + g_retune_ms = 1000; /* a retune just finished */ + const std::vector no_ds = beacon(false, 6, kBssid, kSsid, false); + rx_frame(no_ds.data(), no_ds.size(), -40, 1010); + check(g_beacons.load() == 0, "a DS-less beacon inside the window is dropped"); + const std::vector ds = beacon(false, 11, other, kSsid, true); + rx_frame(ds.data(), ds.size(), -40, 1010); + check(g_beacons.load() == 1, "...one that states its channel is kept"); + rx_frame(no_ds.data(), no_ds.size(), -40, 1000 + kRetuneGuardMs); + { + std::lock_guard l(g_mu); + const devourer::sta::BssEntry* e = g_bss.find(kBssid); + check(g_beacons.load() == 2 && e && e->info.channel == 6, + "...and after the window a DS-less beacon takes the tuned channel"); + } +} + +/* AN EAPOL PACKET THAT IS NOT A KEY IS THE HOST'S. on_decrypted_msdu claims + * only EAPOL-Key frames; an EAP packet under the pairwise key is returned + * unconsumed and must be delivered, not dropped as if it were a rekey. */ +void test_a_non_key_eapol_reaches_the_host() { + reset_all(); + { + std::lock_guard l(g_mu); + g_sm.configure(g_crypto, kSsid, kPsk, g_own); + } + Ap ap; + associate(ap, /*rsn=*/true); + check(g_sm.keyed(), "the link is keyed"); + /* EAPOL version 2, type 0 (EAP-Packet), body length 4, an EAP request. */ + const uint8_t eap[8] = {2, 0, 0, 4, 1, 1, 0, 4}; + const std::vector d = ap.data_to_sta(eap, sizeof eap, 0x888e); + const uint64_t enc_before = g_eapol_enc_rx.load(); + rx_frame(d.data(), d.size(), -40, 0); + check(tap_read().size() == devourer::sta::kEthHdrLen + sizeof eap, + "an EAP packet inside the cipher reaches the host"); + check(g_eapol_enc_rx.load() == enc_before, + "...and is not counted as a rekey"); +} + +/* THE FCS IS TRIMMED ONLY WHERE IT IS PRESENT, and a runt shorter than its + * FCS is length 0, not an unsigned wrap that every later bounds check would + * pass. */ +void test_the_fcs_is_trimmed_only_where_present() { + check(mpdu_len(100, false) == 100, "no FCS: the buffer is the MPDU"); + check(mpdu_len(100, true) == 96, "an FCS: four bytes come off"); + check(mpdu_len(2, true) == 0, "a runt shorter than its FCS is empty"); +} + +/* A WPA2 link must not carry plaintext. The four-way's whole point is that + * everything after it is protected; accepting an unprotected data frame lets + * anyone on the channel inject straight into the host's stack. */ +void test_plaintext_is_refused_on_a_protected_link() { + reset_all(); + { + std::lock_guard l(g_mu); + g_sm.configure(g_crypto, kSsid, kPsk, g_own); + } + Ap ap; + associate(ap, /*rsn=*/true); + + const uint8_t payload[16] = {0x42}; + const std::vector d = ap.plain_to_sta(payload, sizeof payload); + rx_frame(d.data(), d.size(), -40, 0); + check(tap_read().empty(), "an unprotected data frame is refused"); + check(g_plain_rx.load() == 0, "...and is not counted as plaintext traffic"); +} + +/* Fragments and A-MSDUs are refused rather than misread. Handing half an MSDU + * to the host as a whole one produces a frame that looks well-formed and + * decodes to nonsense. */ +void test_fragments_and_amsdu_are_refused() { + reset_all(); + { + std::lock_guard l(g_mu); + g_sm.configure(g_crypto, kSsid, kPsk, g_own); + } + Ap ap; + associate(ap, /*rsn=*/true); + + const uint8_t payload[16] = {5}; + std::vector d = ap.data_to_sta(payload, sizeof payload); + d[1] |= devourer::sta::kFcMoreFrag; + rx_frame(d.data(), d.size(), -40, 0); + check(g_frag_drop.load() == 1, "a More Fragments frame is refused"); + + /* THE LAST FRAGMENT HAS MoreFrag CLEAR. A refusal that only checked that + * bit would forward it as a whole frame. */ + d = ap.data_to_sta(payload, sizeof payload); + d[22] = 0x03; /* fragment number 3, MoreFrag clear */ + rx_frame(d.data(), d.size(), -40, 0); + check(g_frag_drop.load() == 2, "...and so is a LAST fragment"); + check(tap_read().empty(), "neither reaches the host"); + + /* AND THE A-MSDU. This cell was named for both and tested only one - the + * A-MSDU bit lives in the QoS Control field, so a non-QoS frame cannot + * carry it and no arm here reached that branch at all. An A-MSDU is a + * whole list of subframes; handing one to msdu_to_eth reads the first + * subframe's 14-byte header as an LLC/SNAP and produces garbage. */ + const uint8_t big[32] = {6}; + const std::vector a = ap.qos_to_sta(big, sizeof big, 0, true); + rx_frame(a.data(), a.size(), -40, 0); + check(g_amsdu_drop.load() == 1, "an A-MSDU is refused"); + check(tap_read().empty(), "...and does not reach the host"); + + /* The control: the SAME frame without the A-MSDU bit goes through, so the + * arm above cannot be passing because QoS frames are refused wholesale. */ + const std::vector q = ap.qos_to_sta(big, sizeof big, 0, false); + rx_frame(q.data(), q.size(), -40, 0); + check(g_amsdu_drop.load() == 1, "...while a plain QoS frame is not"); + check(tap_read().size() == devourer::sta::kEthHdrLen + sizeof big, + "...and reaches the host"); +} + +/* THE REPLAY WINDOW IS INDEXED BY THE REAL TID, AND THAT IS A SECURITY RULE. + * + * The QoS Control field is at a fixed offset and HT Control follows it, so + * "header length minus two" lands inside HT Control on any +HTC frame. HT + * Control is NOT covered by the CCMP AAD, so an attacker can rewrite it on a + * CAPTURED frame without breaking its MIC - and a receiver that takes the + * TID from there files the replay under a different per-TID window from the + * original, where it is accepted. That is a replay-protection bypass, not an + * accounting error. + */ +void test_the_tid_comes_from_the_qos_control_field() { + reset_all(); + { + std::lock_guard l(g_mu); + g_sm.configure(g_crypto, kSsid, kPsk, g_own); + } + Ap ap; + associate(ap, /*rsn=*/true); + + const uint8_t payload[16] = {0x2c}; + const std::vector f = ap.qos_htc_to_sta( + payload, sizeof payload, /*tid=*/2, /*htc=*/0x00000005, /*pn=*/7); + rx_frame(f.data(), f.size(), -40, 0); + check(tap_read().size() == devourer::sta::kEthHdrLen + sizeof payload, + "a +HTC QoS frame decrypts and reaches the host"); + check(g_rx_replay.last(2) == 7, "...and its PN landed in TID 2's window"); + check(g_rx_replay.last(5) == 0, "...NOT in the window HT Control names"); + + /* THE ATTACK, end to end. The same MPDU with HT Control rewritten - which + * the MIC does not cover - replayed at the same PN. A receiver reading the + * TID from HT Control files it under TID 6, which has never seen PN 7, and + * accepts it. */ + std::vector replayed = ap.qos_htc_to_sta( + payload, sizeof payload, /*tid=*/2, /*htc=*/0x00000005, /*pn=*/7); + replayed[26] = 0x06; /* HT Control, byte 0 */ + rx_frame(replayed.data(), replayed.size(), -40, 0); + check(tap_read().empty(), "the replay is refused despite the rewritten HTC"); + check(g_replays.load() == 1, "...as a replay"); +} + +/* A PTK REKEY, which happens with the association already up and the machine + * already Connected - so nothing hung off the "we just connected" transition + * sees it. Both PN spaces restart with the new key; a receive window left at + * the old key's head rejects everything until the AP's PN climbs back within + * 64 of it, which is a link that reports itself keyed and carries nothing. */ +void test_ptk_rekey() { + reset_all(); + { + std::lock_guard l(g_mu); + g_sm.configure(g_crypto, kSsid, kPsk, g_own); + } + Ap ap; + associate(ap, /*rsn=*/true); + check(g_ptk_installs.load() == 1, "one pairwise key so far"); + + /* Carry enough traffic that a window which is NOT reset would reject the + * new key's PN 1 as ancient - the whole failure mode. */ + const uint8_t pl[8] = {1}; + for (int i = 0; i < 70; i++) { + const std::vector d = ap.data_to_sta(pl, sizeof pl); + rx_frame(d.data(), d.size(), -40, 0); + (void)tap_read(); + } + check(g_rx_replay.last() > 64, "the receive window is past a whole window"); + + uint8_t old_ptk[48]; + std::memcpy(old_ptk, ap.ptk, sizeof old_ptk); + + /* Message 1 of the rekey, inside the cipher, under the CURRENT key. */ + const std::vector m1 = ap.ptk_rekey_msg1(); + rx_frame(m1.data(), m1.size(), -40, 0); + std::vector> tx = drain_tx(); + check(tx.size() == 1, "message 2 goes out"); + if (tx.size() == 1) { + /* UNDER THE OLD KEY. The authenticator has not switched its own yet. */ + uint8_t plain[256]; + size_t plen = 0; + uint64_t pn = 0; + check(devourer::sta::ccmp_decrypt(ap.crypto, old_ptk + 32, tx[0].data(), + tx[0].size(), 24, tx[0].data() + 10, + plain, sizeof plain, &plen, &pn), + "...encrypted under the key the rekey arrived under"); + devourer::sta::EapolKey k; + if (devourer::sta::parse_eapol_key(plain + devourer::sta::kLlcSnapLen, + plen - devourer::sta::kLlcSnapLen, &k)) + check(devourer::sta::derive_ptk(ap.crypto, ap.pmk, kBssid, kStaMac, + ap.anonce, k.nonce, ap.ptk), + "...and the AP derives the new PTK from it"); + } + + /* Message 3 completes it, still under the OLD key - it carries the NEW + * key's material and is protected by the one still in force. */ + const std::vector m3 = ap.eapol_protected(ap.msg3(), old_ptk + 32); + rx_frame(m3.data(), m3.size(), -40, 0); + check(g_ptk_installs.load() == 2, "the new pairwise key is installed"); + check(std::memcmp(g_sm.supplicant().ptk(), ap.ptk, 48) == 0, + "...and both sides have the same one"); + tx = drain_tx(); + check(tx.size() == 1, "message 4 goes out"); + if (tx.size() == 1) { + uint8_t plain[256]; + size_t plen = 0; + uint64_t pn = 0; + /* THE SAME RULE, and the one that matters most: an authenticator that + * gets a message 4 it cannot decrypt fails the rekey and deauthenticates + * the station. */ + check(devourer::sta::ccmp_decrypt(ap.crypto, old_ptk + 32, tx[0].data(), + tx[0].size(), 24, tx[0].data() + 10, + plain, sizeof plain, &plen, &pn), + "...also under the OLD key, which is all the AP can read"); + } + + check(g_tx_pn == 1, "the transmit PN restarted with the new key"); + check(g_rx_replay.last() == 0, "...and so did the receive window"); + + /* And the AP's first frame under the new key, at PN 1, gets through. */ + ap.tx_pn = 1; + const std::vector d = ap.data_to_sta(pl, sizeof pl); + rx_frame(d.data(), d.size(), -40, 0); + check(tap_read().size() == devourer::sta::kEthHdrLen + sizeof pl, + "the first frame under the new key reaches the host"); + check(g_replays.load() == 0, "...and is not rejected as a replay"); +} + +/* A group-addressed EAPOL-Key frame. It is not part of any handshake this + * station is in, and it is emphatically not the host's: writing an 0x888e + * frame onto the TAP hands the host stack a link-layer control frame the + * supplicant owns. */ +void test_group_addressed_eapol_is_not_the_hosts() { + reset_all(); + { + std::lock_guard l(g_mu); + g_sm.configure(g_crypto, kSsid, kPsk, g_own); + } + Ap ap; + associate(ap, /*rsn=*/true); + drain_tx(); + + const uint64_t rx_before = g_eapol_enc_rx.load(); + const std::vector g = ap.group_eapol(ap.msg1()); + rx_frame(g.data(), g.size(), -40, 0); + check(tap_read().empty(), "a group-addressed EAPOL-Key is not given to the host"); + check(g_eapol_enc_rx.load() == rx_before, + "...and is not fed to the state machine either"); + check(drain_tx().empty(), "...and is not answered"); + + /* The control: a group frame that is NOT EAPOL still reaches the host, so + * the check above is about the ethertype and not about group frames. */ + const uint8_t arp[28] = {0, 1}; + const std::vector ok_frame = ap.group_to_bss(arp, sizeof arp); + rx_frame(ok_frame.data(), ok_frame.size(), -40, 0); + check(tap_read().size() == devourer::sta::kEthHdrLen + sizeof arp, + "...while an ordinary group frame still does"); +} + +/* The multi-channel sweep. The docs claimed a cell covered this and none + * did: reset_all() configures exactly one channel, and scan_step() returns + * early for a single-entry list, so the rotation was unreached by the whole + * file. */ +void test_the_channel_sweep_rotates() { + reset_all(); + { + std::lock_guard l(g_mu); + g_sm.configure(g_crypto, kSsid, kPsk, g_own); + g_scan_chans.clear(); + g_scan_chans.push_back(1); + g_scan_chans.push_back(6); + g_scan_chans.push_back(11); + g_scan_dwell_ms = 100; + g_scan_switch_ms = 0; + g_scan_idx = 0; + } + /* No BSS in the table, so supervise() stays in the scan and returns the + * channel it wants the radio on. */ + check(supervise(0) == 1, "the sweep starts on the first channel"); + check(supervise(50) == 1, "...and holds it for the dwell"); + check(supervise(150) == 6, "...then moves on"); + check(supervise(300) == 11, "...and on"); + check(supervise(450) == 1, "...and wraps"); + + /* AND IT STOPS SWEEPING ONCE THERE IS SOMETHING TO JOIN. Retuning under a + * live association is how a station loses one. */ + const std::vector b = beacon(true, 6); + rx_frame(b.data(), b.size(), -40, 500); + check(supervise(600) == 6, "the joined BSS's channel is what it asks for"); + check(g_joins.load() == 1, "...because it joined"); + check(supervise(5000) == 6, "...and it does not sweep away from it"); +} + +/* A GROUP REKEY installs a new GTK under the same association, and its PN + * space restarts with the key. A station that kept the old window goes deaf + * to broadcast for a whole window - every ARP the AP relays - so the link + * looks up and carries nothing. */ +void test_group_rekey() { + reset_all(); + { + std::lock_guard l(g_mu); + g_sm.configure(g_crypto, kSsid, kPsk, g_own); + } + Ap ap; + associate(ap, /*rsn=*/true); + + const uint8_t arp[28] = {0, 1}; + /* Push the group PN well up, so a window that is NOT reset would reject + * the new key's PN 1 as ancient. */ + for (int i = 0; i < 5; i++) { + const std::vector g = ap.group_to_bss(arp, sizeof arp); + rx_frame(g.data(), g.size(), -40, 0); + (void)tap_read(); + } + check(g_group_rx.load() == 5, "five group frames under the first GTK"); + + /* THE REKEY ARRIVES INSIDE THE CIPHER, because that is where a real AP + * puts it - it runs after the PTK is installed. Sending it in cleartext + * here, which the first version of this cell did, tests the four-way's + * path and leaves the one that matters unreached: against hostapd all + * four of its message 1s were counted as rx_ignored and the AP threw the + * station off the BSS ("group key handshake failed (RSN) after 4 tries"). + * A green cell and a dead link. */ + uint8_t gtk2[16]; + std::memset(gtk2, 0xa7, sizeof gtk2); + const std::vector rk = + ap.eapol_protected(ap.group_msg1(gtk2, 2)); + rx_frame(rk.data(), rk.size(), -40, 0); + check(g_eapol_enc_rx.load() == 1, + "the protected rekey is recognised as EAPOL, not handed to the host"); + check(tap_read().empty(), "...so nothing of it reaches the host"); + check(g_sm.supplicant().gtk_valid() && + std::memcmp(g_sm.supplicant().gtk(), gtk2, 16) == 0, + "the new GTK is installed"); + check(g_sm.supplicant().gtk_key_id() == 2, "...at its new key id"); + check(g_gtk_installs.load() == 2, "...and counted as a SECOND group key"); + + /* AND THE ANSWER IS ENCRYPTED TOO. An AP that gets a cleartext message 2 + * to a protected message 1 drops it, which is the same failure with an + * extra round trip. */ + check(g_eapol_enc_tx.load() == 1, "the rekey is answered"); + const std::vector> ans = drain_tx(); + check(ans.size() == 1, "...with exactly one frame"); + if (ans.size() == 1) { + check((ans[0][1] & devourer::sta::kFcProtected) != 0, + "...and it is PROTECTED"); + uint8_t plain[256]; + size_t plain_len = 0; + uint64_t pn = 0; + const bool ok = devourer::sta::ccmp_decrypt( + ap.crypto, ap.ptk + 32, ans[0].data(), ans[0].size(), 24, + ans[0].data() + 10, plain, sizeof plain, &plain_len, &pn); + check(ok, "...the AP decrypts it"); + if (ok) { + check(plain_len > devourer::sta::kLlcSnapLen && + plain[6] == 0x88 && plain[7] == 0x8e, + "...and it is an EAPOL-Key frame"); + devourer::sta::EapolKey k; + check(devourer::sta::parse_eapol_key( + plain + devourer::sta::kLlcSnapLen, + plain_len - devourer::sta::kLlcSnapLen, &k) && + devourer::sta::eapol_mic_ok(ap.crypto, ap.ptk, k) == + devourer::sta::MicCheck::Ok, + "...whose MIC the AP accepts"); + } + } + + const std::vector g = ap.group_to_bss(arp, sizeof arp); + rx_frame(g.data(), g.size(), -40, 0); + check(tap_read().size() == devourer::sta::kEthHdrLen + sizeof arp, + "a frame under the NEW GTK, at PN 1, reaches the host"); + check(g_replays.load() == 0, "...and is not rejected as a replay"); +} + +/* KRACK, END TO END THROUGH THIS HARNESS (CVE-2017-13077/13078). Our message + * 4 is lost, so the AP retransmits message 3 - in the clear, it has not + * installed its key yet - at a GREATER replay counter, as hostapd does. The + * station must answer it and must NOT restart its transmit PN, its receive + * windows or its group window: every one of those restarts under an + * unchanged key is nonce reuse or a reopened replay window. And a new GTK's + * window starts at the Key RSC the AP quoted, not at whatever arrives. */ +void test_msg3_retransmit_keeps_the_pn() { + reset_all(); + { + std::lock_guard l(g_mu); + g_sm.configure(g_crypto, kSsid, kPsk, g_own); + } + Ap ap; + associate(ap, /*rsn=*/true); + + uint8_t e[60] = {0}; + std::memcpy(e, kPeer, 6); + std::memcpy(e + 6, kStaMac, 6); + e[12] = 0x08; + for (int i = 0; i < 3; i++) tap_down_one(e, sizeof e); + (void)drain_tx(); + const uint8_t arp[28] = {0, 1}; + const std::vector g1 = ap.group_to_bss(arp, sizeof arp); + rx_frame(g1.data(), g1.size(), -40, 0); + (void)tap_read(); + const uint64_t pn_before = g_tx_pn; + check(pn_before > 1, "the transmit PN has moved past 1"); + + const std::vector m3 = ap.eapol_frame(ap.msg3()); + rx_frame(m3.data(), m3.size(), -40, 0); + pump_tx(); + check(drain_tx().size() == 1, "the retransmitted message 3 is answered"); + check(g_ptk_installs.load() == 1 && g_gtk_installs.load() == 1, + "...AND NOTHING IS REINSTALLED"); + check(g_tx_pn == pn_before, "...THE TRANSMIT PN IS NOT RESET"); + rx_frame(g1.data(), g1.size(), -40, 0); + check(tap_read().empty() && g_replays.load() == 1, + "...and the group window was not reopened: a replayed group frame " + "is still refused"); + + /* A real group rekey, quoting RSC 100: its window starts THERE. */ + uint8_t gtk2[16]; + const uint8_t rsc[8] = {100, 0, 0, 0, 0, 0, 0, 0}; + std::memset(gtk2, 0xa7, sizeof gtk2); + const std::vector rk = + ap.eapol_protected(ap.group_msg1(gtk2, 2, rsc)); + rx_frame(rk.data(), rk.size(), -40, 0); + (void)drain_tx(); + check(g_gtk_installs.load() == 2, "a new GTK is installed"); + ap.group_pn = 50; + const std::vector old = ap.group_to_bss(arp, sizeof arp); + rx_frame(old.data(), old.size(), -40, 0); + check(tap_read().empty(), "a group frame BELOW the Key RSC is refused"); + ap.group_pn = 101; + const std::vector fresh = ap.group_to_bss(arp, sizeof arp); + rx_frame(fresh.data(), fresh.size(), -40, 0); + check(tap_read().size() == devourer::sta::kEthHdrLen + sizeof arp, + "...and one above it gets through"); +} + +/* A BSS heard once and never again must not be joined minutes later. A + * station that keeps them forever reports "no response" as though the AP were + * broken, when what it actually did was try to talk to one that left. */ +void test_a_stale_bss_is_not_joined() { + reset_all(); + { + std::lock_guard l(g_mu); + g_sm.configure(g_crypto, kSsid, kPsk, g_own); + } + const std::vector b = beacon(true); + rx_frame(b.data(), b.size(), -40, 0); + check(g_bss.count() == 1, "the BSS is in the table"); + + supervise(20000); /* twenty seconds later */ + check(g_joins.load() == 0, "a BSS unheard for 20 s is not joined"); + check(g_bss.count() == 0, "...it was expired out of the table"); + check(g_probe_tx.load() > 0, "...and a probe went out instead"); + + /* The control: the same table, aged only two seconds, IS joined. Without + * it this cell passes for a supervisor that never joins anything. */ + reset_all(); + { + std::lock_guard l(g_mu); + g_sm.configure(g_crypto, kSsid, kPsk, g_own); + } + rx_frame(b.data(), b.size(), -40, 0); + supervise(2000); + check(g_joins.load() == 1, "a BSS heard 2 s ago IS joined"); +} + +/* The RSSI conversion, which nothing else reaches - it is called only from + * on_rx(), which needs a Packet and therefore a radio. Only the ORDERING + * matters to BssTable, but a raw 0 (the PHY reported nothing) must not + * outrank a real reading, and that is a value and not an order. */ +void test_rssi_conversion() { + check(rssi_dbm(0) == -128, "a raw 0 is the floor, not -110 dBm"); + check(rssi_dbm(70) == -40, "raw 70 is -40 dBm"); + check(rssi_dbm(90) == -20, "raw 90 is -20 dBm"); + check(rssi_dbm(90) > rssi_dbm(70), "and a stronger raw reading ranks higher"); + check(rssi_dbm(1) > rssi_dbm(0), + "...while any real reading beats 'nothing reported'"); +} + +/* THE RECONNECT POLICY. StationSm notices beacon loss and fails, and does + * not re-join; supervise() is the harness's answer. */ +void test_reconnect_after_beacon_loss() { + reset_all(); + { + std::lock_guard l(g_mu); + g_sm.configure(g_crypto, kSsid, kPsk, g_own); + g_rejoin_backoff_ms = 100; + } + Ap ap; + associate(ap, /*rsn=*/true); + check(g_sm.state() == StationSm::State::Connected, "connected first"); + const uint32_t joins_before = (uint32_t)g_joins.load(); + + /* Carry real traffic first, so both PN spaces are well past 1 when the + * link drops. Without this the "it restarted" checks below are true + * whether or not anything resets them. */ + for (int i = 0; i < 4; i++) { + const uint8_t pl[8] = {(uint8_t)i}; + const std::vector d = ap.data_to_sta(pl, sizeof pl); + rx_frame(d.data(), d.size(), -40, 0); + (void)tap_read(); + uint8_t e[60] = {0}; + std::memcpy(e, kPeer, 6); + std::memcpy(e + 6, kStaMac, 6); + e[12] = 0x08; + e[13] = 0x00; + tap_down_one(e, sizeof e); + } + drain_tx(); + check(g_tx_pn > 1 && g_rx_replay.last() > 1, + "both PN spaces have advanced"); + + /* Nothing is fed for longer than kBeaconLossMs, which is what an AP that + * was switched off looks like. */ + uint32_t now = StationSm::kBeaconLossMs + 1; + tick(now); + check(g_sm.state() == StationSm::State::Failed, "beacon loss fails the link"); + check(g_sm.fail_reason() == StationSm::Failure::BeaconLost, "...and says so"); + + /* The backoff has to actually hold: a supervisor that re-joins on the same + * millisecond spins against an AP that is off. */ + supervise(now); + check(g_joins.load() == joins_before, "no re-join before the backoff"); + check(g_reconnects.load() == 1, "the loss is counted as a reconnect attempt"); + + /* The AP comes back. The table still holds it, so a fresh beacon is not + * strictly needed - but feeding one is what actually happens, and it also + * proves expire() has not thrown the entry away. */ + now += 200; + const std::vector b = beacon(true); + rx_frame(b.data(), b.size(), -40, now); + supervise(now); + check(g_joins.load() == joins_before + 1, "...and a re-join after it"); + check(g_sm.state() == StationSm::State::Authenticating, + "the machine is authenticating again"); + + Ap ap2; + for (int round = 0; round < 8; round++) { + pump_tx(); + const std::vector> tx = drain_tx(); + if (tx.empty()) break; + for (const std::vector& f : tx) { + const std::vector r = ap2.respond(f, true); + if (!r.empty()) rx_frame(r.data(), r.size(), -40, now); + if (f[0] == devourer::sta::kFcAssocReq) { + const std::vector m1 = ap2.eapol_frame(ap2.msg1()); + rx_frame(m1.data(), m1.size(), -40, now); + } + } + } + check(g_sm.state() == StationSm::State::Connected, "the link comes back"); + check(g_associations.load() == 2, "two associations, not one"); + /* A NEW PTK MEANS A NEW PN SPACE. Carrying the transmit PN across an + * association is keystream reuse, not a replay problem. */ + check(g_tx_pn == 1, "the transmit PN restarted with the new key"); + + /* AND THE RECEIVE WINDOW RESTARTED TOO. The new AP's PN space also begins + * at 1, so a station holding the old window rejects the first frames of + * the new association as ancient replays - a link that associates, reports + * itself keyed, and carries nothing. */ + const uint8_t payload[16] = {3}; + const std::vector d = ap2.data_to_sta(payload, sizeof payload); + rx_frame(d.data(), d.size(), -40, now); + check(tap_read().size() == devourer::sta::kEthHdrLen + sizeof payload, + "the first frame of the new association reaches the host"); + check(g_replays.load() == 0, "...and is not rejected as a replay"); +} + +/* ONE LOST LINK IS ONE RECONNECT, however many times the retry fails. + * + * The first latch has to be cleared by every join attempt, or a SECOND loss + * could never be noticed - so on its own it counts attempts, not losses: an + * AP that is away for several backoff periods would read as several lost + * links. */ +void test_a_lost_link_is_counted_once() { + reset_all(); + { + std::lock_guard l(g_mu); + g_sm.configure(g_crypto, kSsid, kPsk, g_own); + g_rejoin_backoff_ms = 100; + } + Ap ap; + associate(ap, /*rsn=*/true); + check(g_reconnects.load() == 0, "no reconnect while the link is up"); + + uint32_t now = StationSm::kBeaconLossMs + 1; + tick(now); + check(g_sm.state() == StationSm::State::Failed, "the link drops"); + + /* The AP stays OFF. Every re-join therefore authenticates into silence and + * times out, which is exactly the on-air shape. */ + for (int i = 0; i < 6; i++) { + supervise(now); + pump_tx(); + drain_tx(); /* nothing answers */ + now += StationSm::kMgmtTimeoutMs * (StationSm::kMaxTries + 1); + tick(now); + now += 200; + } + check(g_joins.load() > 1, "it kept trying"); + check(g_reconnects.load() == 1, + "...and the lost link is still counted exactly once"); +} + +/* With reconnect off, the harness gives up - and says so by not joining + * again. The control arm for the cell above: without it, "it re-joined" + * could just be what this code always does. */ +void test_reconnect_can_be_disabled() { + reset_all(); + { + std::lock_guard l(g_mu); + g_sm.configure(g_crypto, kSsid, kPsk, g_own); + g_reconnect = false; + g_rejoin_backoff_ms = 100; + } + Ap ap; + associate(ap, /*rsn=*/true); + const uint32_t joins_before = (uint32_t)g_joins.load(); + check(joins_before == 1, "one join so far"); + + uint32_t now = StationSm::kBeaconLossMs + 1; + tick(now); + check(g_sm.state() == StationSm::State::Failed, "the link fails"); + for (int i = 0; i < 5; i++) { + now += 500; + const std::vector b = beacon(true); + rx_frame(b.data(), b.size(), -40, now); + supervise(now); + } + check(g_joins.load() == joins_before, "no re-join with reconnect disabled"); +} + +/* A frame from the host claiming somebody else's source address. A real AP + * refuses it and one that does not is an injection tool; either way airing it + * is wrong, and a TAP whose MAC was never set is the ordinary cause. */ +void test_tap_refuses_a_foreign_source() { + reset_all(); + { + std::lock_guard l(g_mu); + g_sm.configure(g_crypto, kSsid, kPsk, g_own); + } + Ap ap; + associate(ap, /*rsn=*/true); + drain_tx(); + + uint8_t e[60] = {0}; + std::memcpy(e, kPeer, 6); + std::memcpy(e + 6, kBssid, 6); /* not us */ + e[12] = 0x08; + e[13] = 0x00; + const uint64_t dropped = g_tap_down_drop.load(); + tap_down_one(e, sizeof e); + check(drain_tx().empty(), "a frame with a foreign source is not aired"); + check(g_tap_down_drop.load() == dropped + 1, "...and is counted as a drop"); + + /* The control: the same frame with OUR address goes out. Without this the + * cell passes for a tap_down_one() that airs nothing at all. */ + std::memcpy(e + 6, kStaMac, 6); + tap_down_one(e, sizeof e); + check(drain_tx().size() == 1, "the same frame from us IS aired"); +} + +/* Nothing is transmitted before the link is up. A data plane that airs + * frames while unassociated leaks the host's traffic onto the channel in + * plaintext, addressed to a BSSID we have not joined. */ +void test_no_data_before_association() { + reset_all(); + { + std::lock_guard l(g_mu); + g_sm.configure(g_crypto, kSsid, kPsk, g_own); + } + uint8_t e[60] = {0}; + std::memcpy(e, kPeer, 6); + std::memcpy(e + 6, kStaMac, 6); + e[12] = 0x08; + e[13] = 0x00; + tap_down_one(e, sizeof e); + check(drain_tx().empty(), "nothing is aired before the association"); + check(g_tap_down_drop.load() == 1, "...and the frame is counted as dropped"); + + /* And a protected frame that arrives BEFORE the four-way finishes must be + * refused by the "are we connected" gate specifically. + * + * IT HAS TO REACH THAT GATE TO TEST IT. Fed while the machine is Idle, + * bssid_ is all zeros and the address check refuses the frame instead. + * Driving authentication and association but NOT the key exchange puts the + * machine in FourWay with the right BSSID, where the gate is the only + * thing left. */ + Ap ap; + const std::vector b = beacon(true); + rx_frame(b.data(), b.size(), -40, 0); + supervise(0); + for (int round = 0; round < 6; round++) { + pump_tx(); + const std::vector> tx = drain_tx(); + if (tx.empty()) break; + for (const std::vector& f : tx) { + const std::vector r = ap.respond(f, true); + if (!r.empty()) rx_frame(r.data(), r.size(), -40, 0); + } + } + check(g_sm.state() == StationSm::State::FourWay, + "associated, with the key exchange still outstanding"); + check(std::memcmp(g_sm.bssid(), kBssid, 6) == 0, + "...so the BSSID check can no longer be what refuses the frame"); + + /* The AP's PTK is whatever derive_ptk left it - all zeros, since message 2 + * never arrived - which is emphatically not a key this station holds. */ + const uint8_t payload[16] = {1}; + std::memset(ap.ptk, 0xab, sizeof ap.ptk); + const std::vector d = ap.data_to_sta(payload, sizeof payload); + rx_frame(d.data(), d.size(), -40, 0); + check(g_enc_rx.load() == 0, "a protected frame before the key is not counted"); + check(g_mic_fail.load() == 0, "...not even as a MIC failure - it is refused"); + check(tap_read().empty(), "...and does not reach the host"); +} + +/* The scan picks the network by NAME, out of a channel carrying others - and + * a neighbour airing the same SSID with a cipher we cannot speak is not a + * candidate. */ +void test_scan_selects_the_wanted_network() { + reset_all(); + { + std::lock_guard l(g_mu); + g_sm.configure(g_crypto, kSsid, kPsk, g_own); + } + const uint8_t other[6] = {0x02, 0xaa, 0xbb, 0xcc, 0xdd, 0xee}; + const std::vector nb = beacon(true, 6, other, "somebody-else"); + const std::vector open_same = beacon(false, 6, other, kSsid); + rx_frame(nb.data(), nb.size(), -20, 0); /* stronger */ + rx_frame(open_same.data(), open_same.size(), -20, 0); + check(g_bss.count() >= 1, "the neighbour is in the table"); + supervise(0); + check(g_joins.load() == 0, "nothing joinable yet, so no join"); + check(g_probe_tx.load() > 0, "...but a directed probe went out"); + + const std::vector ours = beacon(true); + rx_frame(ours.data(), ours.size(), -70, 1000); /* weaker, and ours */ + supervise(1000); + check(g_joins.load() == 1, "our network is joined once it appears"); + check(std::memcmp(g_sm.bssid(), kBssid, 6) == 0, + "...and it is OUR BSSID, not the stronger neighbour's"); +} + +/* A short frame, a runt, and a frame that is not ours. The receive path is + * promiscuous on this hardware, so these are the ordinary case and not the + * edge case. */ +void test_hostile_and_foreign_frames() { + reset_all(); + { + std::lock_guard l(g_mu); + g_sm.configure(g_crypto, kSsid, kPsk, g_own); + } + Ap ap; + associate(ap, /*rsn=*/true); + const uint32_t not_ours_before = g_sm.rx_not_our_bss; + + uint8_t runt[23] = {0}; + rx_frame(runt, sizeof runt, -40, 0); + check(g_rx_short.load() >= 1, "a frame shorter than a header is refused"); + + /* A PROTECTED FRAME WITH NOTHING AFTER THE HEADER. A key-id read before + * the CCMP header is proved present reads three bytes past the end of the + * buffer - on MT7612U past the allocation, because this part strips the + * FCS. Any station on the channel can air it. + * + * THIS ARM IS ONLY MEANINGFUL UNDER THE SANITIZER BUILD. In a normal build + * an overread succeeds and the counters come out the same; run it under + * -DDEVOURER_SANITIZE=address+undefined. */ + const uint64_t mic_before = g_mic_fail.load(); + const uint64_t short_before = g_rx_short.load(); + std::vector stub = devourer::sta::data_hdr_from_ds( + kStaMac, kBssid, kPeer, /*protect=*/true, 9); + rx_frame(stub.data(), stub.size(), -40, 0); + check(g_rx_short.load() == short_before + 1, + "a protected frame with no CCMP header is short"); + check(g_mic_fail.load() == mic_before, + "...and NOT a MIC failure - it is malformed, not forged"); + + /* A well-formed protected frame from a DIFFERENT BSS. Our address filter is + * the only one in the system - the MAC runs promiscuous. */ + std::vector foreign = ap.data_to_sta((const uint8_t*)"x", 1); + foreign[10] ^= 0x02; /* addr2: a locally-administered bit */ + const uint64_t enc_before = g_enc_rx.load(); + rx_frame(foreign.data(), foreign.size(), -40, 0); + check(g_enc_rx.load() == enc_before, "a frame from another BSS is not decrypted"); + check(g_sm.rx_not_our_bss > not_ours_before, "...and is counted as such"); + check(tap_read().empty(), "...and reaches nothing"); +} + +/* THE BOOKS CLOSE. The ledger states two identities, and an identity that + * holds only because every confounding term is zero proves nothing - so + * they are pinned here with the confounding terms made non-zero on purpose. + * + * from host == encrypted + plaintext + dropped down + * queued == aired + queue dropped + send failed + */ +void test_the_books_close() { + reset_all(); + { + std::lock_guard l(g_mu); + g_sm.configure(g_crypto, kSsid, kPsk, g_own); + } + Ap ap; + associate(ap, /*rsn=*/true); + drain_tx(); + + uint8_t e[60] = {0}; + std::memcpy(e, kPeer, 6); + std::memcpy(e + 6, kStaMac, 6); + e[12] = 0x08; + e[13] = 0x00; + for (int i = 0; i < 5; i++) tap_down_one(e, sizeof e); /* aired */ + + /* A frame the host had no business sending: counted DOWN, not up - not in + * the same counter as a failed write to the host. */ + std::memcpy(e + 6, kBssid, 6); + tap_down_one(e, sizeof e); + std::memcpy(e + 6, kStaMac, 6); + + /* And one the Ethernet parser refuses: counted in `from host` too, or it + * would be a loss no total contained. */ + tap_down_one(e, 6); + + /* AND A REKEY. Its encrypted EAPOL answer goes through the same cipher + * path as host traffic, and must not be counted as `encrypted`: there is + * no `from host` to match it, and any rekeying AP (hostapd's + * wpa_group_rekey) would break the identity. */ + uint8_t gtk2[16]; + std::memset(gtk2, 0xa7, sizeof gtk2); + const std::vector rk = ap.eapol_protected(ap.group_msg1(gtk2, 2)); + rx_frame(rk.data(), rk.size(), -40, 0); + check(g_eapol_enc_tx.load() == 1, "the rekey in this cell is answered"); + + const uint64_t from = g_tap_rx.load(); + const uint64_t down = g_tap_down_drop.load(); + check(from == 7, "every frame the host handed us is counted, malformed included"); + check(down == 2, "...and the two it could not air are counted as down-drops"); + check(from == g_tx_enc.load() + g_tx_plain.load() + down, + "from host == encrypted + plaintext + dropped down"); + + /* THE SECOND IDENTITY, with the queue cap (128) made to bite: ordinary + * traffic never reaches it, which is why it needs a cell. */ + drain_tx(); /* empty the queue: the identity below is a DELTA, + * because drain_tx() is the cells' stand-in for the + * caller and does not count what it takes. */ + const uint64_t q_in_before = g_q_in.load(); + const uint64_t q_drop_before = g_q_drop.load(); + for (int i = 0; i < 200; i++) enqueue(std::vector(32, 0xa5)); + check(g_q_in.load() == q_in_before + 200, "every frame offered to the queue is counted"); + check(g_q_drop.load() > q_drop_before, + "...and the ones the cap refused are counted as queue drops"); + + /* What send_batch() would take. Its split into aired / send failed needs + * a device, so this half of the identity is checked up to the queue. */ + size_t kept = 0; + { + std::lock_guard l(g_q_mu); + kept = g_q.size(); + g_q.clear(); + } + check(g_q_in.load() - q_in_before == kept + (g_q_drop.load() - q_drop_before), + "queued == kept + queue dropped (send_batch's split needs a device)"); + check(kept > 0 && kept < 200, "...and neither term was vacuous"); +} + +} // namespace selftest + +namespace { + +int self_test() { + int fds[2]; + if (::pipe(fds) != 0) { + std::fprintf(stdout, "FAIL: pipe()\n"); + return 1; + } + /* The read end is non-blocking so a cell that expects NOTHING on the TAP + * does not hang waiting for it - and "expects nothing" is the assertion in + * six of the cells below. */ + ::fcntl(fds[0], F_SETFL, O_NONBLOCK); + /* And the write end, like the real TAP fd (tap_open). */ + ::fcntl(fds[1], F_SETFL, O_NONBLOCK); + selftest::g_tap_rd = fds[0]; + g_tap_fd = fds[1]; + /* The radiotap prefix enqueue() adds. Empty would work; a realistic one + * means drain_tx()'s stripping is exercised rather than trivially true. */ + g_rt = devourer::build_stream_radiotap(devourer::parse_tx_mode_str("6M")); + + selftest::test_open_ladder(); + selftest::test_wpa2_ladder(); + selftest::test_replay_is_refused(); + selftest::test_forged_mic_is_refused(); + selftest::test_group_key_is_chosen_by_key_id(); + selftest::test_plaintext_is_refused_on_a_protected_link(); + selftest::test_fragments_and_amsdu_are_refused(); + selftest::test_the_tid_comes_from_the_qos_control_field(); + selftest::test_a_group_key_we_cannot_use(); + selftest::test_the_fcs_is_trimmed_only_where_present(); + selftest::test_a_retransmission_is_a_duplicate(); + selftest::test_the_dup_cache_spans_a_rekey_not_an_association(); + selftest::test_a_full_tap_is_a_drop_not_a_stall(); + selftest::test_a_fault_exits_nonzero(); + selftest::test_duration_and_channel_parse_strictly(); + selftest::test_the_retry_limit_env_is_parsed(); + selftest::test_a_beacon_in_the_retune_window(); + selftest::test_a_non_key_eapol_reaches_the_host(); + selftest::test_group_addressed_eapol_is_not_the_hosts(); + selftest::test_ptk_rekey(); + selftest::test_the_channel_sweep_rotates(); + selftest::test_group_rekey(); + selftest::test_msg3_retransmit_keeps_the_pn(); + selftest::test_a_stale_bss_is_not_joined(); + selftest::test_rssi_conversion(); + selftest::test_reconnect_after_beacon_loss(); + selftest::test_a_lost_link_is_counted_once(); + selftest::test_reconnect_can_be_disabled(); + selftest::test_tap_refuses_a_foreign_source(); + selftest::test_no_data_before_association(); + selftest::test_scan_selects_the_wanted_network(); + selftest::test_hostile_and_foreign_frames(); + selftest::test_the_books_close(); + + ::close(fds[0]); + ::close(fds[1]); + g_tap_fd = -1; + if (selftest::g_fail) { + std::fprintf(stdout, "sta_client_selftest: %d failure(s)\n", + selftest::g_fail); + return 1; + } + std::fprintf(stdout, "sta_client_selftest: OK\n"); + return 0; +} + +} // namespace