Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1047,18 +1047,40 @@ 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 (<linux/if_tun.h>).
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.
if(DEVOURER_REQUIRE_STA_CRYPTO_TESTS)
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
Expand Down
104 changes: 104 additions & 0 deletions docs/station-client.md
Original file line number Diff line number Diff line change
@@ -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 <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.
8 changes: 5 additions & 3 deletions docs/station-core.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down
4 changes: 4 additions & 0 deletions examples/common/env_config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions examples/common/env_config.h
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
10 changes: 6 additions & 4 deletions src/sta/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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.
28 changes: 18 additions & 10 deletions tests/mt7612u_sta_lib.sh
Original file line number Diff line number Diff line change
@@ -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:
#
Expand Down Expand Up @@ -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
}

Expand Down
Loading
Loading