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