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
36 changes: 29 additions & 7 deletions .github/workflows/cmake-multi-platform.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ jobs:
run: |
echo "build-output-dir=${{ github.workspace }}/build" >> "$GITHUB_OUTPUT"

# OpenSSL for the station crypto selftests (supplicant, station_sm,
# ccmp_framing), which CMakeLists only builds when find_package(OpenSSL)
# succeeds. Every job that runs ctest configures with
# DEVOURER_REQUIRE_STA_CRYPTO_TESTS=ON, so a runner that stops providing
# OpenSSL fails configure instead of silently dropping those cells.
# Linux and macOS install it explicitly (cheap). The MSVC cell does NOT:
# its runner image already ships an OpenSSL that FindOpenSSL picks up,
# and building one through vcpkg would add minutes to every run.
- name: Install dependency libraries (Windows)
id: vars
if: runner.os == 'Windows'
Expand All @@ -77,12 +85,13 @@ jobs:
- name: Install dependency libraries (Linux)
if: runner.os == 'Linux'
run:
sudo apt install libusb-1.0-0-dev
sudo apt install libusb-1.0-0-dev libssl-dev

- name: Install dependency libraries (macOS)
if: runner.os == 'macOS'
run:
brew install libusb
run: |
brew install libusb openssl@3
echo "OPENSSL_ROOT_DIR=$(brew --prefix openssl@3)" >> "$GITHUB_ENV"

- name: Configure CMake
# DEVOURER_MT7612U=ON: the option defaults OFF, so without it here the
Expand All @@ -98,6 +107,7 @@ jobs:
-DCMAKE_C_COMPILER=${{ matrix.c_compiler }}
-DCMAKE_BUILD_TYPE=${{ matrix.build_type }}
-DDEVOURER_MT7612U=ON
-DDEVOURER_REQUIRE_STA_CRYPTO_TESTS=ON
-S ${{ github.workspace }}

- name: Build
Expand All @@ -124,6 +134,11 @@ jobs:
steps:
- uses: actions/checkout@v4

# OpenSSL so the station crypto acceptance cells (supplicant, station_sm,
# ccmp_framing - the KRACK and known-answer tests) build and run here
# too. CMakeLists gates them on find_package(OpenSSL); the configure
# below sets DEVOURER_REQUIRE_STA_CRYPTO_TESTS=ON, so if this package
# ever stops providing it the job fails rather than skipping them.
- name: Set up MSYS2 / mingw-w64
uses: msys2/setup-msys2@v2
with:
Expand All @@ -135,6 +150,7 @@ jobs:
mingw-w64-x86_64-ninja
mingw-w64-x86_64-libusb
mingw-w64-x86_64-pkgconf
mingw-w64-x86_64-openssl

- name: Configure CMake (mingw, pkg-config libusb, no vcpkg)
# VCPKG_ROOT is unset, so CMakeLists takes the pkg-config path — the
Expand All @@ -145,6 +161,7 @@ jobs:
-DCMAKE_C_COMPILER=gcc
-DCMAKE_CXX_COMPILER=g++
-DDEVOURER_MT7612U=ON
-DDEVOURER_REQUIRE_STA_CRYPTO_TESTS=ON

- name: Build (library + stream demos + self-tests)
# rxdemo / txdemo / precoder use POSIX-only APIs
Expand All @@ -153,7 +170,11 @@ jobs:
# the stdin-driven stream demos plus the `selftests` aggregate — a target
# that depends on every *Selftest binary (collected automatically in
# CMakeLists.txt), so a newly-added self-test is picked up here without
# touching this file, and can never be skipped into a ctest "Not Run".
# touching this file, and a REGISTERED one can never be skipped into a
# ctest "Not Run". What the aggregate cannot see is a test that was never
# registered: the OpenSSL-gated station cells are simply absent when
# OpenSSL is, which is why configure sets
# DEVOURER_REQUIRE_STA_CRYPTO_TESTS=ON above.
run: cmake --build build --target devourer streamtx duplex selftests

- name: Test
Expand Down Expand Up @@ -211,10 +232,10 @@ jobs:
- uses: actions/checkout@v4

- name: Install dependency libraries
run: sudo apt install libusb-1.0-0-dev
run: sudo apt install libusb-1.0-0-dev libssl-dev

- name: Configure (${{ matrix.name }})
run: cmake -B build -DCMAKE_BUILD_TYPE=Release ${{ matrix.flags }} -S ${{ github.workspace }}
run: cmake -B build -DCMAKE_BUILD_TYPE=Release -DDEVOURER_REQUIRE_STA_CRYPTO_TESTS=ON ${{ matrix.flags }} -S ${{ github.workspace }}

- name: Build (${{ matrix.name }})
run: cmake --build build
Expand All @@ -234,7 +255,7 @@ jobs:
- uses: actions/checkout@v4

- name: Install dependency libraries
run: sudo apt install libusb-1.0-0-dev
run: sudo apt install libusb-1.0-0-dev libssl-dev

- name: Configure (ASan + UBSan)
# DEVOURER_MT7612U=ON here too: that subtree moved from calloc/free to
Expand All @@ -243,6 +264,7 @@ jobs:
run: >
cmake -B build-asan -DCMAKE_BUILD_TYPE=RelWithDebInfo
-DDEVOURER_SANITIZE=address+undefined -DDEVOURER_MT7612U=ON
-DDEVOURER_REQUIRE_STA_CRYPTO_TESTS=ON
-S ${{ github.workspace }}

- name: Build (ASan + UBSan)
Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ facts live in nested `CLAUDE.md` files, auto-loaded when working there:
`src/{jaguar1,jaguar2,jaguar3,kestrel,rtl8733b}/` for per-generation registers,
descriptors and per-chip mechanisms; `src/hopset/` for keyed FHSS and the
adaptive hopset; `src/chanmig/` for channel migration; `src/sensing/` for the
device-touching survey executor. Add new facts to the
device-touching survey executor; `src/sta/` for the device-free 802.11
station core. Add new facts to the
narrowest file that covers them.

Two standing rules for this file: never duplicate what a header already
Expand Down
123 changes: 123 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -975,6 +975,129 @@ if(DEVOURER_MT7612U)
add_test(NAME mt7612u_tsf_api COMMAND Mt7612uTsfApiSelftest)
endif()

# --- src/sta/: the device-free 802.11 station core -------------------------
# Header-only and pure: no libusb, no device, no IRadio. Every target below is
# standalone (it does not link `devourer`, so it does not inherit that
# target's PUBLIC cxx_std_20) and asks for C++20 itself - the station headers
# use inline variables and nested namespaces, which MSVC rejects below C++17.
#
# The crypto cells need a CryptoOps implementation, and
# tests/openssl_crypto_ops.h is the one complete implementation in the tree;
# the modules themselves link nothing. OpenSSL is optional for the library,
# so those cells are gated on it - loudly. A gated-out test is not "Not Run",
# it is simply never registered, so nothing downstream can notice it missing:
# DEVOURER_REQUIRE_STA_CRYPTO_TESTS=ON turns the warning into a configure
# error, and CI sets it on every job that runs ctest.
option(DEVOURER_REQUIRE_STA_CRYPTO_TESTS
"Fail configure when OpenSSL is missing and the station crypto selftests would be skipped"
OFF)
find_package(OpenSSL QUIET)
if(OpenSSL_FOUND)
# src/sta/Eapol.h and src/sta/Supplicant.h - the station half of the
# WPA2-PSK key exchange.
#
# It also carries tests/eapol_kernel_vectors.h: the four EAPOL-Key frames
# hostapd and wpa_supplicant actually exchanged over a virtual rig, with
# the PTK wpa_supplicant derived and the GTK it installed. That is the
# only thing in the tree that pins the PRF, the EAPOL MIC and the GTK KDE
# layout against software that has never read this repository - the
# fixture authenticator in the same file cannot, because it shares an
# author with the code it tests. Regenerate with
# tests/eapol_capture_vectors.sh; checked in, so ctest needs no rig.
#
# Two of its cells pin the rules a supplicant is most easily wrong about:
# a forged EAPOL-Key MIC is rejected, and an equal-counter replayed group
# rekey installs nothing (it is answered as a retransmission). It also
# carries the IEEE 802.11i Annex H.4 passphrase-to-PSK known answers.
add_executable(SupplicantSelftest tests/supplicant_selftest.cpp)
target_include_directories(SupplicantSelftest PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src
${CMAKE_CURRENT_SOURCE_DIR}/tests)
target_link_libraries(SupplicantSelftest PRIVATE OpenSSL::Crypto)
target_compile_features(SupplicantSelftest PRIVATE cxx_std_20)
add_test(NAME supplicant COMMAND SupplicantSelftest)

# src/sta/StationSm.h - the association state machine. Its timeouts are
# assertable at all only because the clock is an argument.
add_executable(StationSmSelftest tests/station_sm_selftest.cpp)
target_include_directories(StationSmSelftest PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src
${CMAKE_CURRENT_SOURCE_DIR}/tests)
target_link_libraries(StationSmSelftest PRIVATE OpenSSL::Crypto)
target_compile_features(StationSmSelftest PRIVATE cxx_std_20)
add_test(NAME station_sm COMMAND StationSmSelftest)

# src/sta/Ccmp.h - the 802.11 CCMP framing. The vectors
# (tests/ccmp_gen_vectors.py) pin the CIPHER against a third
# implementation; they do NOT independently pin the framing, because
# generator and header share one author's reading - a zero CCM nonce
# Flags octet passes them. See the note atop tests/ccmp_selftest.cpp.
# tests/ccmp_kernel_vectors.h closes that gap from the other side: frames
# the LINUX KERNEL encrypted, at all eight TIDs, captured off a virtual
# two-radio rig by tests/ccmp_capture_vectors.sh. Checked in, so this
# target stays hardware-free and needs neither hwsim nor root.
# Also pins the AAD masking rules, the 48-bit PN packing, and the replay
# gate's `<=` (a `<` there admits a replayed PN, a KRACK-class defect).
add_executable(CcmpSelftest tests/ccmp_selftest.cpp)
target_include_directories(CcmpSelftest PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/tests
${CMAKE_CURRENT_SOURCE_DIR}/src)
target_link_libraries(CcmpSelftest PRIVATE OpenSSL::Crypto)
target_compile_features(CcmpSelftest PRIVATE cxx_std_20)
add_test(NAME ccmp_framing COMMAND CcmpSelftest)
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")
endif()
message(WARNING "OpenSSL not found: the station crypto selftests "
"(supplicant, station_sm, ccmp_framing) are NOT built "
"on this host")
endif()

# tests/ccmp_vectors.h is GENERATED by tests/ccmp_gen_vectors.py; --check
# regenerates it in memory and byte-compares, so a hand edit or a generator
# change that was not re-run fails here. Skipped (77) where
# python-cryptography is not installed. The kernel-capture headers
# (ccmp_kernel_vectors.h, eapol_kernel_vectors.h) have no such cell: their
# input is a hwsim capture that is not in the tree.
find_package(Python3 COMPONENTS Interpreter)
if(Python3_Interpreter_FOUND)
add_test(
NAME ccmp_vectors_generated
COMMAND ${Python3_EXECUTABLE}
${CMAKE_CURRENT_SOURCE_DIR}/tests/ccmp_gen_vectors.py --check
)
set_tests_properties(ccmp_vectors_generated PROPERTIES SKIP_RETURN_CODE 77)
endif()

# src/sta/Dot11.h - management/data frame builders and the IE walker. Pure and
# header-only, so this needs neither OpenSSL nor libusb and runs in every CI
# job. Covers the IE walker against truncated and hostile lengths, the
# builders' exact wire bytes, and the 12-bit sequence counter.
add_executable(Dot11Selftest tests/dot11_selftest.cpp)
target_include_directories(Dot11Selftest PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src)
target_compile_features(Dot11Selftest PRIVATE cxx_std_20)
add_test(NAME dot11_frames COMMAND Dot11Selftest)

# src/sta/BssTable.h - the scan result, and which BSS is worth joining. The
# two cells that matter are select(), which must not offer a BSS this station
# cannot finish a handshake with, and observe(), which must not invent a BSS
# out of a frame that is not a beacon - every management frame shares the
# same 24-byte header, so a deauth handed to parse_beacon reads its reason
# code as part of a timestamp.
add_executable(BssTableSelftest tests/bss_table_selftest.cpp)
target_include_directories(BssTableSelftest PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src
${CMAKE_CURRENT_SOURCE_DIR}/tests)
target_compile_features(BssTableSelftest PRIVATE cxx_std_20)
add_test(NAME bss_table COMMAND BssTableSelftest)

# Headless guard for the TX quiesce seam (ITransport::quiesce_tx via
# RtlAdapter): the explicit "stop TX and wait it out" call every device makes
# before anything is released. UsbTransport's cancel/drain is validated on
Expand Down
75 changes: 75 additions & 0 deletions docs/station-core.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# The 802.11 station core (`src/sta/`)

A device-free implementation of the protocol half of an 802.11 station:
frame building and parsing, the scan table, the association state machine,
the WPA2-PSK supplicant and CCMP framing. It is header-only and pure: no
`IRadio`, no libusb, no clock, no threads. Time is an argument, frames go in
through `on_rx()` and out through `pop_tx()`, and crypto is a `CryptoOps`
vtable the caller fills, so `libdevourer` gains no dependency and everything
runs under plain `ctest`.

This page is an overview. The contracts (what is refused, when a key is
installed, what the replay window accepts) are documented once, at their
declarations. The maintainer's map of which header holds which rule is
`src/sta/CLAUDE.md`.

## Reading order

`Dot11.h` → `BssTable.h` → `CryptoOps.h` / `Ccmp.h` → `Eapol.h` →
`Supplicant.h` → `StationSm.h`. Each header depends only on the ones before
it.

## What the tests pin

Each area below is a contract documented at the declaration named, and
pinned by the ctest cell named. The per-rule map with individual test
functions is `src/sta/CLAUDE.md`.

- **Key installation and the replay gate**, including key reinstallation
(the CVE-2017-13077/13078/13080 class): `Supplicant.h` (`on_eapol`,
`install_gtk`). Cell: `supplicant`.
- **The RSNE downgrade check** (802.11-2016 12.7.6.4):
`Supplicant::rsn_equivalent`, `RsnInfo` in `Dot11.h`. Cells: `supplicant`,
`station_sm`.
- **The data-plane replay window and GTK RSC seeding**: `CcmpReplay` in
`Ccmp.h`. Cell: `ccmp_framing`.
- **What the CCMP calls refuse**: `ccmp_decrypt`, `ccmp_encrypt`. Cell:
`ccmp_framing`.
- **What the EAPOL-Key parser, MIC check and builder refuse**:
`parse_eapol_key`, `eapol_mic_ok`, `build_eapol_key`, `find_gtk_kde` in
`Eapol.h`. Cell: `supplicant`.
- **Which BSS is offered or joined, on which channel**: `BssTable::observe`,
`StationSm::join`, and the predicates in `Dot11.h`. Cells: `bss_table`,
`station_sm`.
- **The group rekey path** after association, `StationSm::on_decrypted_msdu`:
without it hostapd deauthenticates the station after "group key handshake
failed". Cell: `station_sm`.
- **Cleartext EAPOL-Key once a PTK is installed**:
`StationSm::cleartext_eapol_allowed`. Cell: `station_sm`
(`test_cleartext_eapol_after_keying`).

## Where the known answers come from

- **Linux kernel CCMP frames** at all eight TIDs, and **a real hostapd /
wpa_supplicant four-way**, both captured off `mac80211_hwsim` and checked
in. They are independent of this code's author, but they are an interop
reference, not the IEEE Annex J vector: if mac80211 and this code misread
the same clause the same way, no cell would notice. The captures are not
in the tree, so these two headers cannot be regenerated from it.
- **IEEE 802.11i Annex H.4.2** passphrase-to-PSK vectors.
- **python-cryptography CCM vectors** (`tests/ccmp_vectors.h`), regenerable
and checked by the `ccmp_vectors_generated` cell. These are a same-author
transcription of the framing, so they pin the cipher plumbing only. A
zero CCM nonce Flags octet passes them, which is why the kernel captures
exist.

## 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
declarations rather than here:
- the replay-window width, and why it must grow before HE/EHT use: `CcmpReplay`;
- the SNonce policy: `Supplicant::start`;
- what a forged message 1 can still cost: `Supplicant::on_msg1`.
Loading
Loading