diff --git a/.github/workflows/cmake-multi-platform.yml b/.github/workflows/cmake-multi-platform.yml index d7a53ce8..f2927fbd 100644 --- a/.github/workflows/cmake-multi-platform.yml +++ b/.github/workflows/cmake-multi-platform.yml @@ -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' @@ -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 @@ -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 @@ -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: @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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) diff --git a/CLAUDE.md b/CLAUDE.md index 7cfcff9e..a6ebed51 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/CMakeLists.txt b/CMakeLists.txt index 6a59bbce..216830ac 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/docs/station-core.md b/docs/station-core.md new file mode 100644 index 00000000..271c6021 --- /dev/null +++ b/docs/station-core.md @@ -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`. diff --git a/src/sta/BssTable.h b/src/sta/BssTable.h new file mode 100644 index 00000000..ce1d3485 --- /dev/null +++ b/src/sta/BssTable.h @@ -0,0 +1,312 @@ +/* BssTable — what a scan found, and which of it is worth joining. + * + * A station hears the same BSS dozens of times a second and several BSSes at + * once. This collapses that stream into one record per BSSID, keeps the + * freshest view of each, and answers the only question the association state + * machine actually asks: given an SSID, which BSS should I join? + * + * Fixed capacity, and it takes frames straight off the air — every field + * comes from `parse_beacon`, which bounds-checks everything and resets its + * output so a frame that omits an element cannot leave the previous BSS's + * value in place. + * + * NOT allocation-free: `BssInfo::ssid` is a std::string, so an SSID longer + * than the short-string buffer heap-allocates on every beacon that carries + * it. It is a handful of allocations a second on a busy channel, which is + * nothing next to the CCMP work a station does per frame. + * + * WHAT IT DELIBERATELY DOES NOT DO. It does not scan: it has no notion of + * channels, dwell times or probe requests, because those need a radio and this + * file is meant to be testable without one. A scanner drives the radio and + * feeds the frames here. + */ +#ifndef DEVOURER_STA_BSS_TABLE_H +#define DEVOURER_STA_BSS_TABLE_H + +#include +#include +#include +#include + +#include "sta/Dot11.h" + +namespace devourer { +namespace sta { + +struct BssEntry { + BssInfo info; + /* The most recent RSSI in dBm. Signed and initialised to the floor rather + * than to zero: zero dBm is an enormous signal, and an entry that has never + * been given an RSSI must not win a comparison against one that has. */ + int8_t rssi = -128; + uint32_t last_seen_ms = 0; + uint32_t frames = 0; /* beacons and probe responses that fed this entry */ +}; + +class BssTable { + public: + /* Sixteen. A crowded band shows more than that, but this exists to find one + * named network, not to render a survey, and the eviction rule below keeps + * the useful entries. */ + static constexpr int kMaxBss = 16; + + static constexpr int capacity() { return kMaxBss; } + int count() const { return count_; } + + /* The network this station is looking for. + * + * WITHOUT IT THE EVICTION RULE IS AN ATTACK. The victim is the entry heard + * from longest ago, and a real AP beacons roughly ten times a second - so a + * neighbour (or an attacker) emitting beacons for sixteen fabricated BSSIDs + * as fast as the radio allows keeps every fabricated entry at age zero and + * makes the GENUINE AP the oldest entry, every time. The table thrashes and + * select() returns nothing for most of the window in which the station is + * trying to associate. + * + * An entry whose SSID matches this is never evicted. Empty by default, so a + * caller that does not set it gets the old behaviour and the old exposure. */ + void set_wanted(const std::string& ssid) { wanted_ = ssid; } + const std::string& wanted() const { return wanted_; } + + void clear() { + for (int i = 0; i < kMaxBss; i++) used_[i] = false; + count_ = 0; + } + + const BssEntry* at(int i) const { + if (i < 0 || i >= kMaxBss || !used_[i]) return nullptr; + return &slots_[i]; + } + + const BssEntry* find(const uint8_t bssid[6]) const { + const int i = index_of(bssid); + return i < 0 ? nullptr : &slots_[i]; + } + + /* Fold one beacon or probe response into the table. + * + * Returns the entry, or null when the frame is not one of those two + * subtypes or does not parse. THE SUBTYPE CHECK IS NOT COSMETIC: every + * management frame has the same 24-byte header, so handing a deauth or an + * association response to parse_beacon reads its fixed fields as a + * timestamp and a capability and invents a BSS out of them. + * + * `rx_channel` IS THE CHANNEL THE RADIO WAS ON when it received the frame + * (0 = unknown). It is required because the beacon cannot always say: the + * DS Parameter Set is a 2.4 GHz element and many 5 GHz beacons omit it, and + * without a channel the association request would be built with the + * 2.4 GHz rate set, which a 5 GHz AP refuses. When the frame DOES carry a DS + * element + * and it disagrees, the DS element wins: on 2.4 GHz a receiver hears the + * adjacent overlapping channels, and the AP's own statement of where it is + * beats where we happened to hear it. Both sources must pass + * channel_valid() (Dot11.h); an invalid DS value falls back to a valid + * receive channel. An entry with no valid channel from either source is + * kept (it still shows up in find()) but select() never offers it. + */ + const BssEntry* observe(const uint8_t* frame, size_t len, int8_t rssi, + uint8_t rx_channel, uint32_t now_ms) { + BssInfo info; + + if (!frame || len < 24) return nullptr; + if (frame[0] != kFcBeacon && frame[0] != kFcProbeResp) return nullptr; + if (!parse_beacon(frame, len, &info)) return nullptr; + /* parse_beacon has already dropped a DS value that is not a channel; the + * receive channel stands in only if it is one itself (channel_valid). */ + if (info.channel == 0 && channel_valid(rx_channel)) info.channel = rx_channel; + + int i = index_of(info.bssid); + /* allocate() evicts rather than refusing - even when every slot is + * protected - so it always yields a slot. */ + if (i < 0) i = allocate(now_ms); + + const uint32_t seen = used_[i] ? slots_[i].frames : 0; + const bool fresh = !used_[i]; + + /* A HIDDEN BSS beacons an empty (or all-zero) SSID, and only a directed + * probe response names it. Replacing the whole entry on every beacon + * would wipe that name within a beacon interval or two - long before + * select() is next asked - so the hidden-BSS join a directed probe exists + * for would essentially never happen. Keep the name this BSSID has + * already been heard with. */ + if (!fresh && !slots_[i].info.ssid.empty()) { + bool hidden = true; + for (char c : info.ssid) + if (c != 0) { hidden = false; break; } + if (hidden) info.ssid = slots_[i].info.ssid; + } + + if (fresh) { + used_[i] = true; + count_++; + slots_[i] = BssEntry{}; + } + slots_[i].info = info; + slots_[i].rssi = rssi; + slots_[i].last_seen_ms = now_ms; + slots_[i].frames = seen + 1; + return &slots_[i]; + } + + /* Drop entries not heard from in `max_age_ms`. + * + * A station that keeps a BSS forever will happily try to associate with one + * that went off the air ten minutes ago, and then report "no response" as + * though the AP were broken. Returns how many were dropped. + */ + int expire(uint32_t now_ms, uint32_t max_age_ms) { + int dropped = 0; + + for (int i = 0; i < kMaxBss; i++) { + if (!used_[i]) continue; + /* Unsigned subtraction, so a now_ms that has wrapped past an entry's + * timestamp yields a huge age and expires it, rather than a negative + * one that never does. */ + if ((uint32_t)(now_ms - slots_[i].last_seen_ms) >= max_age_ms) { + used_[i] = false; + count_--; + dropped++; + } + } + return dropped; + } + + /* The BSS to join for this SSID: the strongest one this station can + * actually speak to. + * + * "Can speak to" is bss_is_wpa2_psk (Dot11.h): the Privacy bit set and + * `rsn_ccmp_psk`, which parse_beacon computes as CCMP + * among the pairwise suites, PSK among the AKMs, and management-frame + * protection NOT required — 802.11w is unimplemented here, and a BSS that + * requires it will refuse the association after a successful authentication, + * which is a confusing place to fail. Skipping it in selection turns that + * into "no candidate". + * + * Ties break on the most recently heard, so a stale entry never beats a live + * one at equal signal. + */ + const BssEntry* select(const std::string& ssid) const { + return best_matching(ssid, /*want_rsn=*/true); + } + + /* The same question for a station configured for an OPEN network. + * + * Two named entry points rather than one `require_rsn` argument, so a + * caller's intent is in the name it calls. The loop is shared, so the two + * cannot drift in the tie-break or the eviction-safe iteration. + * + * "Open" is `!privacy`, not `!has_rsn`. A WEP BSS carries no RSN element + * and is not joinable by a station with no keys, and WPA1 lives in a vendor + * element this parser does not read at all - the Privacy capability bit is + * the one test that covers every protected BSS. StationSm::join refuses the + * same way, so a hand-picked entry gets the same answer as a selected one. + */ + const BssEntry* select_open(const std::string& ssid) const { + return best_matching(ssid, /*want_rsn=*/false); + } + + private: + /* The body both selectors share. `want_rsn` picks the joinability test: + * WPA2-PSK-CCMP without MFP required, or no encryption at all. */ + const BssEntry* best_matching(const std::string& ssid, bool want_rsn) const { + const BssEntry* best = nullptr; + + for (int i = 0; i < kMaxBss; i++) { + if (!used_[i]) continue; + const BssEntry& e = slots_[i]; + if (e.info.ssid != ssid) continue; + if (want_rsn ? !bss_is_wpa2_psk(e.info) : e.info.privacy) continue; + /* No channel from the beacon or the receiver: the association request + * would be built for the wrong band. Not a candidate. */ + if (!channel_valid(e.info.channel)) continue; + /* An IBSS (or a frame claiming neither ESS nor IBSS) has no AP to + * authenticate or associate with. */ + if (!bss_is_infrastructure(e.info)) continue; + /* "More recent" is a SIGNED difference, not `>` on the raw stamps: + * the millisecond clock wraps every ~49.7 days, and a raw comparison + * across the wrap picks the stale entry. Correct while the two are + * within ~24.8 days of each other, which any practical expire() age + * keeps true. */ + if (!best || e.rssi > best->rssi || + (e.rssi == best->rssi && + (int32_t)(e.last_seen_ms - best->last_seen_ms) > 0)) + best = &e; + } + return best; + } + + int index_of(const uint8_t bssid[6]) const { + for (int i = 0; i < kMaxBss; i++) + if (used_[i] && std::memcmp(slots_[i].info.bssid, bssid, 6) == 0) + return i; + return -1; + } + + /* A free slot, or the one worth losing. + * + * REFUSING WHEN FULL IS THE WRONG ANSWER. Sixteen neighbours heard before + * the target network would make the table permanently useless, and a scan + * in a block of flats hits that in a second. The victim is the entry heard + * from longest ago, with the weakest signal breaking a tie — the two things + * that make a BSS least likely to be the one being looked for. + */ + bool protected_slot(int i) const { + return !wanted_.empty() && slots_[i].info.ssid == wanted_; + } + + int allocate(uint32_t now_ms) { + for (int i = 0; i < kMaxBss; i++) + if (!used_[i]) return i; + + int victim = -1; + for (int i = 0; i < kMaxBss; i++) { + if (protected_slot(i)) continue; + if (victim < 0) { victim = i; continue; } + const uint32_t age_v = (uint32_t)(now_ms - slots_[victim].last_seen_ms); + const uint32_t age_i = (uint32_t)(now_ms - slots_[i].last_seen_ms); + + if (age_i > age_v || + (age_i == age_v && slots_[i].rssi < slots_[victim].rssi)) + victim = i; + } + /* EVERY SLOT IS PROTECTED. Refusing here would turn the anti-flood rule + * into the attack it exists to prevent: sixteen + * beacons for sixteen fabricated BSSIDs, all carrying the WANTED SSID, + * fill the table with unevictable entries and the genuine AP can then + * never be inserted at all. select() returns only the attacker's BSSes, + * for as long as they keep beaconing. + * + * So fall back to the ordinary victim rule over every slot. The + * protection still does its job in the case it exists for - a flood + * of OTHER SSIDs cannot evict the network being looked for - and in the + * degenerate case where every entry claims to be the wanted network, + * this table cannot tell which one is real and staying fresh beats + * staying stuck. */ + if (victim < 0) { + for (int i = 0; i < kMaxBss; i++) { + if (victim < 0) { victim = i; continue; } + const uint32_t age_v = (uint32_t)(now_ms - slots_[victim].last_seen_ms); + const uint32_t age_i = (uint32_t)(now_ms - slots_[i].last_seen_ms); + + if (age_i > age_v || + (age_i == age_v && slots_[i].rssi < slots_[victim].rssi)) + victim = i; + } + } + /* victim >= 0 here: the table is full and the fallback considers every + * slot. */ + used_[victim] = false; + count_--; + return victim; + } + + BssEntry slots_[kMaxBss]; + bool used_[kMaxBss] = {false}; + int count_ = 0; + std::string wanted_; +}; + +} // namespace sta +} // namespace devourer + +#endif /* DEVOURER_STA_BSS_TABLE_H */ diff --git a/src/sta/CLAUDE.md b/src/sta/CLAUDE.md new file mode 100644 index 00000000..87e77166 --- /dev/null +++ b/src/sta/CLAUDE.md @@ -0,0 +1,100 @@ +# src/sta/ — the device-free 802.11 station core + +Deep facts for this subtree, loaded alongside the root CLAUDE.md. Everything +here is header-only and pure: no `IRadio`, no libusb, no clock, no threads, no +environment. Time is a `now_ms` argument, frames go in through `on_rx()` and +out through `pop_tx()`, and crypto is the `CryptoOps` vtable the caller fills, +so `libdevourer` links nothing new. The contracts are documented at their +declarations; this file only maps them. Overview for readers: +`docs/station-core.md`. + +## File map, in dependency order + +Each header includes only headers above it, which is also the reading order. + +| Header | Holds | Includes | +|---|---|---| +| `Dot11.h` | frame builders/parsers, IE walker, `parse_rsn`/`RsnInfo`, `DupDetector`, `SeqCounter`, MSDU<->Ethernet | — | +| `BssTable.h` | per-BSSID scan table, `select()`/`select_open()` | Dot11 | +| `CryptoOps.h` | the crypto interface (CCM, HMAC-SHA1, PBKDF2, key unwrap) | — | +| `Ccmp.h` | CCMP AAD/nonce/header/PN, `ccmp_encrypt`/`ccmp_decrypt`, `CcmpReplay` | CryptoOps, Dot11 | +| `Eapol.h` | EAPOL-Key format, PRF/PTK, `eapol_mic_ok`, GTK KDE, `pmk_from_psk`, `secure_wipe` | CryptoOps | +| `Supplicant.h` | 4-way + group-key decisions, the refusal counters | CryptoOps, Dot11, Eapol | +| `StationSm.h` | auth → assoc → 4-way → connected, timeouts, bounded TX queue | all of the above except Ccmp | + +`Ccmp.h` is not included by the state machine on purpose: the data plane +(encrypt/decrypt, per-key PN and replay state) belongs to the caller, which +learns when to reset it from `Supplicant::ptk_generation()`/`gtk_generation()` +and seeds the group window from `gtk_rsc()`. + +## Where each rule lives + +The rules themselves are the comments at these declarations; this table says +only what, where, and which cell pins it. Change the rule at the declaration +and the cell, never here. + +| What | Where (the contract) | Cells | +|---|---|---| +| When a key may be installed; the replay gate; retransmissions | top of `Supplicant.h`, `Supplicant::on_eapol`, `classify` | supplicant: `test_forged_mic_is_rejected`, `test_replay_counter_rules`, `test_group_rekey_replay_rejected`, `test_msg3_without_secure_installs_nothing` | +| Key reinstallation (KRACK class) | `Supplicant::install_gtk`, `on_msg3` | supplicant: `test_msg3_retransmit_does_not_reinstall`, `test_group1_same_gtk_does_not_reinstall` | +| Message 1: the candidate, its cache, what a forgery costs | `Supplicant::on_msg1`, `on_eapol` | supplicant: `test_forged_msg1_cannot_poison_the_counter`, `test_forged_msg1_does_not_orphan_msg3_retransmit`, `test_forged_msg1_first_does_not_poison_the_genuine_one`, `test_msg1_crypto_failure_keeps_the_candidate` | +| Which GTKs install; the key-data walk (one walker for the GTK KDE and message 3's RSNE) | `Supplicant::kGtkLenCcmp`, `walk_key_data`, `find_gtk_kde`, `Supplicant::find_rsn_element` | supplicant: `test_32_byte_gtk_is_refused`, `test_gtk_kde`, `test_gtk_followed_by_truncated_element_is_refused`, `test_rsn_element_after_an_empty_dd` | +| RSNE downgrade check | `Supplicant::rsn_equivalent`, `RsnInfo`/`parse_rsn` | supplicant: `test_rsne_downgrade_check`, `test_parse_rsn_sets`; station_sm: `test_rsn_downgrade_is_refused` | +| Data-plane replay window | `CcmpReplay` | ccmp_framing: `test_replay`, `test_replay_seed` | +| What `ccmp_decrypt` refuses | `ccmp_decrypt` | ccmp_framing: `test_mic_rejected`, `test_short_output_refused`, `test_null_output_and_zero_body`, `test_ext_iv_required`, `test_protected_bit_required` | +| What `ccmp_encrypt` refuses | `ccmp_encrypt`, `kCcmpPnMax` | ccmp_framing: `test_pn_is_48_bits` | +| EAPOL-Key format, MIC compare, builder refusals | `parse_eapol_key`, `eapol_mic_ok`, `build_eapol_key` | supplicant: `test_parse_bounds`, `test_mic_ok_refuses_a_bad_key`, `test_build_length_limit`, `test_mic_crypto_failure_is_not_a_mic_failure` | +| PSK spellings | `pmk_from_psk` | supplicant: `test_psk_known_answers` | +| What a failure / `leave()` drops | `StationSm::fail`, `leave` | station_sm: `test_peer_deauth_drops_the_association_keys`, `test_leave_drops_stale_frames` | +| When the station is Connected | `StationSm::promote_if_keyed` | station_sm: `test_dropped_msg4_does_not_connect` | +| Which EAPOL-Key may arrive in the clear once a PTK is installed | `StationSm::cleartext_eapol_allowed`, `Supplicant::is_installed_msg3` | station_sm: `test_cleartext_eapol_after_keying` | +| Which BSS `join()` accepts, what it tears down first, the beacon-loss window | `StationSm::join`, `kMaxBeaconIntervalTu` | station_sm: `test_join_refuses_an_ibss`, `test_join_refuses_rsn_without_privacy`, `test_5ghz_beacon_without_ds_uses_the_rx_channel`, `test_join_on_a_live_association`, `test_beacon_interval_is_capped` | +| Which BSS selection offers; its channel | `parse_beacon`, `BssTable::observe`, `best_matching`, `channel_valid`, `bss_is_infrastructure`, `bss_is_wpa2_psk` | bss_table: `test_rx_channel`, `test_channel_validity`, `test_only_infrastructure_is_offered`, `test_rsn_without_privacy_is_not_selected`, `test_mfp_required_is_skipped`, `test_tie_break_across_the_wrap`, `test_overlong_ssid_is_refused` | +| Which EAPOL packets reach the supplicant | `eapol_is_key`, `StationSm::on_decrypted_msdu`, `on_rx` | station_sm: `test_only_eapol_key_is_claimed`, `test_group_rekey_through_the_decrypted_path` | +| PMK lifetime across reconfigures | `StationSm::configure`, `configure_open` | station_sm: `test_failed_reconfigure_wipes_the_old_pmk`, `test_reconfigure_forgets_the_keys` | +| 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` | + +## Tests + +| ctest | Binary / script | Covers | Needs | +|---|---|---|---| +| `dot11_frames` | `tests/dot11_selftest.cpp` | Dot11.h incl. `DupDetector` | — | +| `bss_table` | `tests/bss_table_selftest.cpp` | BssTable.h | — | +| `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 | +| `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 +WARNING). `-DDEVOURER_REQUIRE_STA_CRYPTO_TESTS=ON` makes that a configure +error; CI sets it on every job that runs ctest. `tests/openssl_crypto_ops.h` +is the one complete `CryptoOps`; `tests/ccmp_software.h` is its CCM. + +## Vectors + +What each vector file can and cannot catch is stated in its own header +comment and its generator's; read those, not a summary. + +- `tests/ccmp_vectors.h` — generated by `tests/ccmp_gen_vectors.py`, + reproducible (`--check`, the `ccmp_vectors_generated` cell). +- `tests/ccmp_kernel_vectors.h`, `tests/eapol_kernel_vectors.h` — cut by + `tests/{ccmp,eapol}_extract_vectors.py` from captures made by + `tests/{ccmp,eapol}_capture_vectors.sh` (root + hwsim; never in CI). Not + reproducible from the tree: edit them only by re-capturing. + +## Deliberately out of scope + +No device or radio calls; no hardware crypto offload; no PMF/802.11w +(`StationSm::on_rx` states what that costs); key descriptor v2 only (top of +`Eapol.h`); no AP-side per-station table. SNonce policy: `Supplicant::start`. +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`, +`tests/ap_wpa2.cpp`) carry their own inline builders rather than using this +module. diff --git a/src/sta/Ccmp.h b/src/sta/Ccmp.h new file mode 100644 index 00000000..90f8affb --- /dev/null +++ b/src/sta/Ccmp.h @@ -0,0 +1,490 @@ +/* Ccmp — 802.11 CCMP framing, role-neutral: the same code serves an AP and a + * station. In this tree its user is the station. + * + * This is the framing only: AAD construction, nonce construction, the 8-byte + * CCMP header, and the PN. The cipher itself is a CryptoOps call, so nothing + * here includes a crypto library and `libdevourer` gains no dependency. + * + * The same rules sit inline and untested in `tests/ap_wpa2.cpp`; this is the + * tested form. Each rule is recorded at the function that implements it, + * because each one fails silently - a wrong byte anywhere looks exactly like + * a wrong key: the AAD masks the FC subtype/retry/pwr-mgmt/more-data bits and + * sets Protected, and masks the sequence number while KEEPING the fragment + * number; the nonce is Flags | A2 | PN as six big-endian bytes, where Flags + * carries the QoS TID and the management bit; and the CCMP header carries + * the 48-bit PN split across two discontiguous ranges with an Ext IV bit that + * is not optional. + * + * Both roles use the identical framing — an AP encrypting to a station and a + * station encrypting to its AP differ only in which address is A2 and which + * PN counter they draw from. That is the whole reason this is one file rather + * than two. + */ +#ifndef DEVOURER_STA_CCMP_H +#define DEVOURER_STA_CCMP_H + +#include +#include +#include + +#include "sta/CryptoOps.h" +#include "sta/Dot11.h" + +namespace devourer { +namespace sta { + +/* An 802.11 CCMP header is 8 bytes and the MIC is 8; a protected MPDU is + * therefore 16 bytes longer than its plaintext plus the 802.11 header. */ +constexpr size_t kCcmpHdrLen = 8; +constexpr size_t kCcmpMicLen = 8; +constexpr size_t kCcmpAadMax = 32; +constexpr size_t kCcmpNonceLen = 13; + +/* Additional authenticated data over the 802.11 header (802.11-2016 12.5.3.3.3). + * + * Everything is derived from the header itself: `hdr_len` is explicit and + * the TID comes out of the frame. A separate `qos_tid` argument would let a + * caller pass a TID that disagrees with the QoS Control field it hands in, and + * a fixed 24-byte read would let the CCMP header overwrite that field. + * + * The masking is the part that is easy to get subtly wrong and impossible to + * debug from the other end, because a wrong AAD looks exactly like a wrong key: + * the MIC simply fails. + * + * - Frame Control: Retry, Pwr Mgmt and More Data are masked because they may + * legitimately differ between transmission and reception. Protected is + * forced ON because the receiver sees it set. The SUBTYPE bits are masked + * only for non-management frames - a protected management frame (802.11w) + * keeps its subtype in the AAD, which is what mac80211 and hostapd's + * wlantest both do. + * - Sequence Control: the sequence number is masked, the FRAGMENT number is + * kept. Masking both breaks fragmented frames; keeping both breaks every + * frame, because the sequence number is assigned after the MIC is computed + * on a hardware-sequencing MAC. + * - A4 is included for a 4-address frame (ToDS and FromDS both set). + * - QoS Control is included for a QoS data frame, with only the TID retained; + * the ack-policy, EOSP and A-MSDU bits are masked. + * + * Returns the AAD length: 22, +6 for A4, +2 for QoS. Zero if `hdr_len` is too + * short for what the frame control claims. + */ +inline size_t ccmp_aad(const uint8_t* hdr, size_t hdr_len, uint8_t* aad) { + bool four_addr, qos, mgmt; + size_t need, n; + uint16_t fc; + + /* THE LENGTH CHECK COMES FIRST, before the three reads below: a + * precondition a function checks after dereferencing is not a + * precondition. (ccmp_decrypt proves the length before calling this, so no + * in-tree caller reaches it from the air; a direct caller could.) */ + if (hdr_len < 24) return 0; + four_addr = (hdr[1] & (kFcToDs | kFcFromDs)) == (kFcToDs | kFcFromDs); + qos = is_qos_data(hdr[0]); + mgmt = (hdr[0] & 0x0c) == 0x00; /* type 0 = management */ + need = 24 + (four_addr ? 6 : 0) + (qos ? 2 : 0); + fc = (uint16_t)(hdr[0] | (hdr[1] << 8)); + + if (hdr_len < need) return 0; + if (!mgmt) fc &= (uint16_t)~0x0070u; /* subtype: data/control */ + fc &= (uint16_t)~(0x0800u | 0x1000u | 0x2000u); /* retry, pwr mgmt, more data */ + /* THE ORDER BIT, masked for a QoS data frame and ONLY for one. + * + * On a QoS data frame bit 15 means "+HTC: an HT Control field follows", it + * is set per transmission, and 802.11-2016 12.5.3.3.3 masks it - Linux does + * the same inside its is_data_qos branch (net/mac80211/wpa.c, + * "Retry, PwrMgt, MoreData, Order (if Qos Data)"). On a NON-QoS frame the + * same bit is the strictly-ordered service class and is NOT masked, which + * is why this is not folded into the line above. + * + * Without this mask every +HTC QoS frame fails its MIC - refused, and + * indistinguishable from a wrong key - and it has to agree with + * data_hdr_len(), which counts the four HT Control bytes. The devourer AP + * advertises no HT, so only a real AP sends such frames; the qos_order_htc + * vector pins it. */ + if (qos) fc &= (uint16_t)~0x8000u; + fc |= 0x4000u; /* protected */ + aad[0] = (uint8_t)(fc & 0xff); + aad[1] = (uint8_t)(fc >> 8); + std::memcpy(aad + 2, hdr + 4, 18); /* addr1, addr2, addr3 */ + { + uint16_t seq = (uint16_t)((hdr[22] | (hdr[23] << 8)) & 0x000f); + + aad[20] = (uint8_t)(seq & 0xff); + aad[21] = (uint8_t)(seq >> 8); + } + n = 22; + if (four_addr) { + std::memcpy(aad + n, hdr + 24, 6); /* addr4 */ + n += 6; + } + if (qos) { + /* The QoS Control field sits immediately after the addresses. */ + const size_t qoff = four_addr ? 30 : 24; + + aad[n++] = (uint8_t)(hdr[qoff] & 0x0f); + aad[n++] = 0; + } + return n; +} + +/* CCM nonce: the Nonce Flags octet, then A2, then the 48-bit PN BIG-endian + * (802.11-2016 12.5.3.3.4). Big-endian here and little-endian in the CCMP + * header below — they genuinely differ, and reusing one for the other is the + * classic way to produce a frame only your own implementation can read. + * + * THE FLAGS OCTET IS NOT ZERO. It is Priority (b0..b3) | Management (b4), + * where Priority is the QoS TID for a QoS data frame and 0 otherwise. Linux + * builds the same byte as `qos_tid | (ieee80211_is_mgmt(fc) << 4)` + * (net/mac80211/wpa.c). + * + * A zero flags octet is correct only for TID 0, which is ordinary client + * traffic - so the mistake is invisible until TID 1..7, the video and voice + * access categories, and a same-author vector generator shares it. The TID + * must reach BOTH the AAD (ccmp_aad) and this octet; the kernel-captured + * vectors at all eight TIDs pin both (test_kernel_vectors, test_nonce_flags). + * + * Takes the header rather than a priority argument on purpose: ccmp_aad + * derives the same facts from the same bytes, and a caller that had to pass + * the TID separately is a caller that can pass a different one to each. + * + * Returns false if `hdr_len` is too short for what the frame control claims, + * matching ccmp_aad's zero return. */ +inline bool ccmp_nonce(const uint8_t* hdr, size_t hdr_len, + const uint8_t a2[6], uint64_t pn, uint8_t* nonce) { + bool four_addr, qos, mgmt; + size_t need; + uint8_t flags = 0; + + /* Length first, for the reason ccmp_aad gives. */ + if (hdr_len < 24) return false; + four_addr = (hdr[1] & (kFcToDs | kFcFromDs)) == (kFcToDs | kFcFromDs); + qos = is_qos_data(hdr[0]); + mgmt = (hdr[0] & 0x0c) == 0x00; /* type 0 = management */ + need = 24 + (four_addr ? 6 : 0) + (qos ? 2 : 0); + + if (hdr_len < need) return false; + if (qos) flags = (uint8_t)(hdr[four_addr ? 30 : 24] & 0x0f); + if (mgmt) flags |= 0x10; + + nonce[0] = flags; + std::memcpy(nonce + 1, a2, 6); + for (int i = 0; i < 6; i++) + nonce[7 + i] = (uint8_t)((pn >> (8 * (5 - i))) & 0xff); + return true; +} + +/* The 8-byte CCMP header: PN0, PN1, a reserved byte, then the key-id octet + * with Ext IV set, then PN2..PN5 little-endian. The PN is deliberately NOT + * contiguous - byte 2 is reserved and byte 3 is not part of the PN at all. */ +inline void ccmp_header(uint64_t pn, uint8_t key_id, uint8_t* out) { + out[0] = (uint8_t)(pn & 0xff); + out[1] = (uint8_t)((pn >> 8) & 0xff); + out[2] = 0; + out[3] = (uint8_t)(0x20 | ((key_id & 0x03) << 6)); /* Ext IV | KeyID */ + out[4] = (uint8_t)((pn >> 16) & 0xff); + out[5] = (uint8_t)((pn >> 24) & 0xff); + out[6] = (uint8_t)((pn >> 32) & 0xff); + out[7] = (uint8_t)((pn >> 40) & 0xff); +} + +/* Recover the 48-bit PN from a received CCMP header. */ +inline uint64_t ccmp_header_pn(const uint8_t* ccmp_hdr) { + return (uint64_t)ccmp_hdr[0] | ((uint64_t)ccmp_hdr[1] << 8) | + ((uint64_t)ccmp_hdr[4] << 16) | ((uint64_t)ccmp_hdr[5] << 24) | + ((uint64_t)ccmp_hdr[6] << 32) | ((uint64_t)ccmp_hdr[7] << 40); +} + +/* Which key this frame was protected with: the top two bits of the CCMP + * header's fourth octet (802.11-2016 12.5.3.2). + * + * A RECEIVER MUST READ THIS AND NOT INFER IT. The obvious inference - group + * address means the group key - is wrong in both directions: an AP may + * unicast under the GTK during a rekey, and a station that guesses by address + * fails the MIC and reports it as an attack. The mirror bug on the transmit + * side costs a whole BSS's broadcast traffic: a group frame sent at key id 0 + * is looked up as the PAIRWISE key at the station and fails its MIC with no + * diagnostic at either end. */ +inline uint8_t ccmp_key_id(const uint8_t* ccmp_hdr) { + return (uint8_t)((ccmp_hdr[3] >> 6) & 0x03); +} + +/* Protect one MPDU. + * + * `hdr` is the frame header and `hdr_len` its true length - 24, 26 for QoS, 30 + * for 4-address, 32 for both. `a2` is the transmitter address the nonce is + * built from, passed separately rather than read from the header because a + * 4-address frame puts it elsewhere and silently reading the wrong one would + * produce frames that only decrypt locally. + * + * Writes hdr_len + 8 + plain_len + 8 bytes to `out`. `out_cap` is that + * buffer's size and is CHECKED: without it a caller who sizes `out` wrong gets + * a heap overflow with no diagnostic, in a function whose whole job is + * handling frames from the air. `ccmp_encrypted_len()` computes the size to + * allocate, and a total that does not fit in size_t is refused rather than + * wrapped into a small number that a small buffer would pass. + * + * Returns the length written, or 0 on failure (including a short buffer). + * + * THE PN IS 48 BITS, and a larger one is refused before anything is written. + * The nonce and the CCMP header each carry only the low 48 bits, so PN 2^48 + * would air with the same nonce as PN 0 under the same TK - keystream reuse. + * A transmitter that reaches 2^48-1 has to rekey: 802.11 requires that a PN + * never repeat under one temporal key. + */ +inline constexpr uint64_t kCcmpPnMax = 0xffffffffffffull; + +/* The protected MPDU's length: hdr_len + 8 + plain_len + 8. ZERO means + * IMPOSSIBLE - the sum does not fit in size_t. Zero is never a real result + * (the CCMP overhead alone is 16), and a buffer sized from it is one + * ccmp_encrypt refuses, where a wrapped sum would pass its capacity check. */ +inline size_t ccmp_encrypted_len(size_t hdr_len, size_t plain_len) { + const size_t overhead = kCcmpHdrLen + kCcmpMicLen; + const size_t max = SIZE_MAX; + if (hdr_len > max - overhead || plain_len > max - overhead - hdr_len) + return 0; + return hdr_len + overhead + plain_len; +} + +/* How many plaintext bytes ccmp_decrypt can write for an MPDU of this size, + * so a caller can size its buffer instead of guessing. Zero when the frame is + * too short to hold a header, a CCMP header and a MIC. Computed by + * subtraction, so no header length can wrap it. */ +inline size_t ccmp_decrypted_len(size_t mpdu_len, size_t hdr_len) { + const size_t overhead = kCcmpHdrLen + kCcmpMicLen; + if (hdr_len >= mpdu_len || mpdu_len - hdr_len <= overhead) return 0; + return mpdu_len - hdr_len - overhead; +} + +inline size_t ccmp_encrypt(CryptoOps& crypto, const uint8_t tk[16], + const uint8_t* hdr, size_t hdr_len, + const uint8_t a2[6], uint64_t pn, uint8_t key_id, + const uint8_t* plain, size_t plain_len, + uint8_t* out, size_t out_cap) { + uint8_t aad[kCcmpAadMax]; + uint8_t nonce[kCcmpNonceLen]; + size_t aad_len; + const size_t need = ccmp_encrypted_len(hdr_len, plain_len); + + if (pn > kCcmpPnMax) return 0; + if (need == 0 || !out || out_cap < need) return 0; + aad_len = ccmp_aad(hdr, hdr_len, aad); + if (aad_len == 0) return 0; + if (!ccmp_nonce(hdr, hdr_len, a2, pn, nonce)) return 0; + std::memcpy(out, hdr, hdr_len); + out[1] |= 0x40; /* Protected, in the frame we actually air */ + ccmp_header(pn, key_id, out + hdr_len); + if (!crypto.aes_ccm(true, tk, nonce, aad, aad_len, plain, plain_len, + out + hdr_len + kCcmpHdrLen, + out + hdr_len + kCcmpHdrLen + plain_len)) + return 0; + return need; +} + +/* Unprotect one MPDU. `mpdu` is the whole received frame starting at the + * 802.11 header, WITHOUT the FCS, and `hdr_len` is its header length + * (data_hdr_len() computes it). + * + * Returns false when the frame is too short, when `out_cap` is too small, or + * when the MIC does not verify. A false return means DROP: `out` holds no + * trustworthy bytes, and the PN must not be admitted to a replay window. + * Replay checking itself is the caller's - it needs per-TID state this + * function does not own. + * + * `out_cap` IS NOT OPTIONAL, for the same reason ccmp_encrypt takes one: the + * plaintext length comes from the MPDU, which comes from the air. Without it + * a caller with a fixed-size buffer is one full-MTU frame away from a stack + * smash, and the cipher writes the plaintext out BEFORE the tag is checked, + * so a forged frame is enough - while short test vectors never trigger it. + * ccmp_decrypted_len() computes the size to allocate. + * + * A NULL `out` IS REFUSED for a non-empty body, and never reaches the cipher + * for an empty one. OpenSSL's CCM reads a NULL output pointer as "this is + * AAD", so a decrypt handed one returns success with NO TAG CHECK - and the + * natural idiom `std::vector plain(ccmp_decrypted_len(...))` hands + * over exactly that (`data()` of an empty vector) for a forged zero-body + * frame, whose attacker-chosen PN would then move the replay window. A + * zero-length body is decrypted into a local scratch byte instead, so its + * tag is still checked. + * + * THE FRAME MUST SAY IT IS PROTECTED (Frame Control bit 14, fc[1] & 0x40). + * ccmp_aad forces that bit to 1 in the AAD, so a received frame with it + * cleared would otherwise still verify - and a frame that claims to be + * unprotected must not come out of here as authenticated plaintext. + * + * THE EXT IV BIT MUST BE SET (bit 5 of the key-id octet). CCMP always uses + * the extended IV (802.11-2016 12.5.3.2); a header without it is not a CCMP + * header, and since that octet is outside the AAD the MIC alone would not + * notice. The reserved bits of that octet (0-4) and the reserved octet 2 are + * NOT checked: 802.11's convention is that reserved fields are set to 0 on + * transmission and ignored on reception (9.2.2). + */ +inline bool ccmp_decrypt(CryptoOps& crypto, const uint8_t tk[16], + const uint8_t* mpdu, size_t mpdu_len, size_t hdr_len, + const uint8_t a2[6], uint8_t* out, size_t out_cap, + size_t* out_len, uint64_t* pn_out) { + const size_t overhead = kCcmpHdrLen + kCcmpMicLen; + uint8_t aad[kCcmpAadMax]; + uint8_t nonce[kCcmpNonceLen]; + /* The tag is COPIED rather than passed by casting away const on the input. + * A CryptoOps that writes the computed tag into the buffer before comparing + * - a perfectly normal implementation shape - would otherwise write through + * a pointer into the caller's frame, or into .rodata for a static test + * vector. */ + uint8_t tag[kCcmpMicLen]; + uint8_t scratch[1]; + size_t aad_len, body; + uint64_t pn; + + /* By subtraction, before any indexing: hdr_len comes from the caller and a + * sum with it could wrap past a short frame. */ + if (hdr_len > mpdu_len || mpdu_len - hdr_len < overhead) return false; + body = mpdu_len - hdr_len - overhead; + if (out_cap < body) return false; + if (body > 0 && !out) return false; + if (body == 0) out = scratch; + if (!(mpdu[1] & kFcProtected)) return false; /* Protected */ + if (!(mpdu[hdr_len + 3] & 0x20)) return false; /* Ext IV */ + pn = ccmp_header_pn(mpdu + hdr_len); + aad_len = ccmp_aad(mpdu, hdr_len, aad); + if (aad_len == 0) return false; + if (!ccmp_nonce(mpdu, hdr_len, a2, pn, nonce)) return false; + std::memcpy(tag, mpdu + hdr_len + kCcmpHdrLen + body, kCcmpMicLen); + if (!crypto.aes_ccm(false, tk, nonce, aad, aad_len, + mpdu + hdr_len + kCcmpHdrLen, body, out, tag)) + return false; + if (out_len) *out_len = body; + if (pn_out) *pn_out = pn; + return true; +} + +/* CCMP replay protection: a sliding window per TID, not a bare counter. + * + * ONE WINDOW PER TID, not one per key. 802.11 keeps a separate receive replay + * counter for each TID of QoS traffic, and a single shared one drops + * legitimate frames as soon as two TIDs interleave - routine the moment voice + * or video shares a link with best-effort. Non-QoS traffic uses a window of + * its own (index kNonQosTid). + * + * An EQUAL PN is a replay and must be refused: admitting an equal-counter + * replay is exactly how a key-reinstallation (KRACK-class) attack lands. + * Here that is bit 0 of the mask, which is always set for the head, so + * `pn == last_` can never be re-admitted (test_replay). + * + * 802.11-2016 12.5.3.4.4 requires a receiver to discard an MPDU whose PN is + * not greater than the replay counter for its TID. Implemented as a strict + * `pn > last` test that is correct ONLY while frames cannot arrive out of + * order - and that is an assumption about the peer, not about the standard. + * The moment a BlockAck agreement exists, an A-MPDU can deliver + * PN 5, 7, 6 legitimately, and a strict counter drops frame 6 as a replay: + * silent, unattributable data loss that looks like a radio problem. + * + * This tree's AP harnesses never set up a BlockAck agreement, so a strict + * counter is safe against them. A STATION does not get to choose - it + * associates with whatever the AP offers - so the assumption has to go before + * a station relies on it. + * + * The window is the standard anti-replay bitmap (the shape IPsec RFC 4303 + * appendix B and every 802.11 driver use): `last_` is the highest PN + * accepted, and bit i of `mask_` records whether PN (last_ - i) has already + * been seen. So a frame is accepted exactly once whether it arrives early, + * on time, or late but still inside the window. + * + * kWindow = 64 because that is IEEE80211_MAX_AMPDU_BUF_HT: the largest + * BlockAck buffer an HT or VHT peer can negotiate, so anything such a reorder + * buffer can legitimately hold fits. A PN older than that is not reordering - + * it is a replay, or a peer that has lost its way - and is refused. + * + * THAT BOUND IS NOT UNIVERSAL. The kernel's own constants are HT 0x40, HE + * 0x100, EHT 0x400 + * (linux/ieee80211.h). So on an 802.11ax die a peer may negotiate a 256-frame + * reorder buffer and a frame 64..255 behind would be refused here - the exact + * silent loss this window exists to prevent, one generation up. It is left at + * 64 because the station role is MT7612U-first and that part is VHT, where 64 + * IS the bound; widening it means a multi-word mask, which is real complexity + * for a die this code does not yet run on. Anyone porting the station role to + * Kestrel must revisit this constant first. + * + * What this deliberately does NOT do: tolerate a PN that jumps forward and + * then asks for the skipped values later beyond the window. A forward jump of + * more than 64 clears the mask, so the skipped PNs can never be accepted + * afterwards. That is the safe direction - an attacker who can inject one + * frame with a huge PN can deny the window, but cannot replay anything. */ +class CcmpReplay { +public: + static constexpr int kNonQosTid = 16; + static constexpr int kSlots = 17; + static constexpr int kWindow = 64; + + /* True when this PN is acceptable AND records it. A rejected PN leaves the + * window untouched - accepting a frame's PN before its MIC verifies would + * let an attacker advance the window with garbage, locking out the real + * peer. Call this only after a successful decrypt. + * + * PN 0 is never valid: a CCMP transmitter starts at 1, so a frame claiming + * 0 is malformed or forged. */ + bool accept(uint64_t pn, int tid = kNonQosTid) { + if (pn == 0) return false; + if (tid < 0 || tid >= kSlots) return false; + + if (!seen_[tid]) { + seen_[tid] = true; + last_[tid] = pn; + mask_[tid] = 1; /* bit 0 == last_ itself */ + return true; + } + if (pn > last_[tid]) { + const uint64_t shift = pn - last_[tid]; + /* A jump of kWindow or more leaves nothing in the old window + * reachable, and shifting a uint64_t by >= 64 is undefined - which is + * exactly the kind of gap a hostile peer would aim for. */ + mask_[tid] = (shift >= (uint64_t)kWindow) ? 1u + : ((mask_[tid] << shift) | 1u); + last_[tid] = pn; + return true; + } + const uint64_t behind = last_[tid] - pn; + if (behind >= (uint64_t)kWindow) return false; /* too old to judge */ + const uint64_t bit = 1ull << behind; + if (mask_[tid] & bit) return false; /* already seen */ + mask_[tid] |= bit; + return true; + } + + /* Start every window at a KNOWN head: after seed(rsc) only a PN strictly + * greater than `rsc` is accepted, on every TID. This is how a group key + * is installed - 802.11-2016 12.7.6.4 hands the receiver the GTK's current + * receive sequence counter (the Key RSC field) precisely so that the first + * group frame it accepts is not simply whichever arrives first, which + * would let a capture from earlier in the key's life in. The whole mask is + * marked seen, so nothing at or below the seed gets in through the window + * either. seed(0) is reset(). */ + void seed(uint64_t pn) { + for (int i = 0; i < kSlots; i++) { + last_[i] = pn; + mask_[i] = pn ? ~0ull : 0; + seen_[i] = pn != 0; + } + } + + /* Every rekey resets every counter: a new key means a new PN space. */ + void reset() { + for (int i = 0; i < kSlots; i++) { + last_[i] = 0; + mask_[i] = 0; + seen_[i] = false; + } + } + uint64_t last(int tid = kNonQosTid) const { + return (tid >= 0 && tid < kSlots) ? last_[tid] : 0; + } + +private: + uint64_t last_[kSlots] = {0}; + uint64_t mask_[kSlots] = {0}; + bool seen_[kSlots] = {false}; +}; + +} // namespace sta +} // namespace devourer + +#endif /* DEVOURER_STA_CCMP_H */ diff --git a/src/sta/CryptoOps.h b/src/sta/CryptoOps.h new file mode 100644 index 00000000..e1cfe6e2 --- /dev/null +++ b/src/sta/CryptoOps.h @@ -0,0 +1,72 @@ +/* CryptoOps — the crypto primitives src/sta/ needs, as an interface it does + * not implement. + * + * WHY AN INTERFACE AND NOT JUST OPENSSL. `libdevourer` links no crypto + * library, and adding one for a station would be a dependency every consumer + * of the library pays for whether or not they ever associate to anything. So + * the protocol logic here takes its crypto as a vtable: the selftests fill it + * with OpenSSL (tests/openssl_crypto_ops.h), checked against vectors from + * third implementations, and an embedded integrator can fill it with mbedTLS + * without touching a line of the state machine. + * + * It also makes the 4-way handshake testable without a radio OR a crypto + * library, and it keeps the cipher, PBKDF2 and the PRF out of this tree: + * hand-rolled crypto without known-answer tests is the defect class this + * module is built to avoid, and the vectors in tests/ pin every primitive the + * protocol code relies on. + * + * Every method returns false on failure rather than throwing; a station that + * cannot decrypt a frame drops it and carries on. + */ +#ifndef DEVOURER_STA_CRYPTO_OPS_H +#define DEVOURER_STA_CRYPTO_OPS_H + +#include +#include + +namespace devourer { +namespace sta { + +struct CryptoOps { + virtual ~CryptoOps() = default; + + /* AES-128-CCM with a 13-byte nonce and an 8-byte tag — the shape 802.11 + * CCMP uses, and the only primitive Ccmp.h needs. + * + * encrypt: `in`/`in_len` is the plaintext, `out` takes in_len ciphertext + * bytes and `tag` takes 8 bytes. `out` may alias `in`. + * decrypt: `in` is the ciphertext, `tag` the received MIC; returns false + * when the tag does not verify, and the caller MUST treat that as + * a dropped frame rather than inspecting `out`. + * + * The tag comparison must be constant-time in any implementation that runs + * against untrusted input. */ + virtual bool aes_ccm(bool encrypt, const uint8_t key[16], + const uint8_t nonce[13], const uint8_t* aad, + size_t aad_len, const uint8_t* in, size_t in_len, + uint8_t* out, uint8_t* tag) = 0; + + /* HMAC-SHA1. The EAPOL-Key MIC and the PRF are both built on it. */ + virtual bool hmac_sha1(const uint8_t* key, size_t key_len, + const uint8_t* data, size_t data_len, + uint8_t out[20]) = 0; + + /* PBKDF2-HMAC-SHA1, 4096 iterations, 32-byte output — the WPA2-PSK PMK + * derivation from passphrase + SSID. */ + virtual bool pbkdf2_sha1(const char* passphrase, const uint8_t* salt, + size_t salt_len, unsigned iterations, + uint8_t* out, size_t out_len) = 0; + + /* RFC 3394 AES key unwrap — the supplicant half of the GTK delivery in + * EAPOL-Key message 3. (The AP harness wraps; a station unwraps.) Returns + * false when the integrity check value does not match, which is a real + * authentication failure and not a decode hiccup. */ + virtual bool aes_key_unwrap(const uint8_t* kek, size_t kek_len, + const uint8_t* in, size_t in_len, + uint8_t* out) = 0; +}; + +} // namespace sta +} // namespace devourer + +#endif /* DEVOURER_STA_CRYPTO_OPS_H */ diff --git a/src/sta/Dot11.h b/src/sta/Dot11.h new file mode 100644 index 00000000..c2e43c09 --- /dev/null +++ b/src/sta/Dot11.h @@ -0,0 +1,899 @@ +/* Dot11 — 802.11 management-frame construction and parsing, written to serve + * either role. In this tree its user is the station (StationSm, BssTable). + * + * Pure: no device access, no environment, no clock, no threads, no sockets — + * the contract `src/hopset/` and `src/chanmig/` keep, and the reason those are + * testable without hardware. Everything here is a function of its arguments. + * + * WHY ONE ROLE-NEUTRAL FILE. A station's auth-request and an AP's + * auth-response are the same frame with two fields swapped; a beacon an AP + * builds and a beacon a station parses are the same bytes read in opposite + * directions. `tests/ap_responder.cpp` and `tests/ap_wpa2.cpp` carry their own + * inline, untested copies of the builder half and do not use this module. Its + * golden-byte cells pin the exact rate sets and RSN element those two + * harnesses air, so the AP-side builders here produce what the validated AP + * already sends. + * + * Byte order: 802.11 is little-endian on the wire. Every 16-bit field here is + * written and read as such explicitly, never by casting a struct over a + * buffer. + */ +#ifndef DEVOURER_STA_DOT11_H +#define DEVOURER_STA_DOT11_H + +#include +#include +#include +#include +#include +#include + +namespace devourer { +namespace sta { + +/* ---- frame control ---------------------------------------------------- */ + +/* fc[0] values: type and subtype together, which is how the harnesses match. + * Keeping them as the composed byte rather than separate type/subtype fields + * is deliberate — every dispatch site compares the whole octet. */ +enum : uint8_t { + kFcAssocReq = 0x00, + kFcAssocResp = 0x10, + kFcReassocReq = 0x20, + kFcReassocResp = 0x30, + kFcProbeReq = 0x40, + kFcProbeResp = 0x50, + kFcBeacon = 0x80, + kFcDisassoc = 0xa0, + kFcAuth = 0xb0, + kFcDeauth = 0xc0, + kFcData = 0x08, + kFcQosData = 0x88, +}; + +/* fc[1] flags */ +enum : uint8_t { + kFcToDs = 0x01, + kFcFromDs = 0x02, + kFcMoreFrag = 0x04, + kFcRetry = 0x08, + kFcPwrMgmt = 0x10, + kFcMoreData = 0x20, + kFcProtected = 0x40, +}; + +/* Element IDs used by an infrastructure BSS association. */ +enum : uint8_t { + kEidSsid = 0, + kEidSupportedRates = 1, + kEidDsParams = 3, + kEidTim = 5, + kEidErp = 42, + kEidHtCaps = 45, + kEidRsn = 48, + kEidExtSupportedRates = 50, + kEidHtOperation = 61, + kEidVhtCaps = 191, + kEidVhtOperation = 192, +}; + +inline void put_le16(std::vector& v, uint16_t x) { + v.push_back((uint8_t)(x & 0xff)); + v.push_back((uint8_t)(x >> 8)); +} +inline uint16_t get_le16(const uint8_t* p) { + return (uint16_t)(p[0] | (p[1] << 8)); +} + +/* ---- headers ---------------------------------------------------------- */ + +/* A 24-byte 3-address management header. + * + * `da` is address 1, `sa` address 2, `bssid` address 3. That ordering is the + * whole difference between the two roles: an AP answering a station passes + * (station, bssid, bssid); a station addressing its AP passes + * (bssid, own, bssid). Sequence control is left zero — see assign_seq below, + * which is NOT optional for a station. + */ +inline std::vector mgmt_hdr(uint8_t subtype_fc, const uint8_t da[6], + const uint8_t sa[6], + const uint8_t bssid[6]) { + std::vector m; + m.reserve(24); + m.push_back(subtype_fc); + m.push_back(0x00); + put_le16(m, 0); /* duration */ + m.insert(m.end(), da, da + 6); + m.insert(m.end(), sa, sa + 6); + m.insert(m.end(), bssid, bssid + 6); + put_le16(m, 0); /* sequence control */ + return m; +} + +/* Write a sequence number into a built frame's Sequence Control field. + * + * THIS MATTERS AND IS EASY TO MISS. On this project's MediaTek backend the MAC + * assigns sequence numbers only for beacons — `MT_TXWI_ACK_CTL_NSEQ` is set + * for `MT_TXOPT_BEACON` and nothing else (`src/mt7612u/tx.cpp`). Both AP + * harnesses therefore air every management frame with sequence 0, which + * survives only because they air so few. A station's data plane feeds the AP's + * duplicate detector, where a pinned sequence number is precisely what gets + * dropped. `seq` is a 12-bit counter; the low 4 bits are the fragment number + * and stay zero for an unfragmented frame. + */ +inline void assign_seq(std::vector& frame, uint16_t seq) { + if (frame.size() < 24) return; + uint16_t sc = (uint16_t)((seq & 0x0fff) << 4); + frame[22] = (uint8_t)(sc & 0xff); + frame[23] = (uint8_t)(sc >> 8); +} + +/* DUPLICATE DETECTION, 802.11-2016 10.3.2.14. A transmitter that retries - + * and both ends of this link do, since STA_ACK and AP_RETRY - resends the + * same frame with the Retry bit set whenever an ACK is lost, so the receiver + * sees it twice. The standard answer is a per-transmitter cache of the last + * Sequence Control accepted (per TID for QoS data): a frame with Retry set + * that matches it is a duplicate and is discarded before anything else looks + * at it. + * + * Without this the second copy reaches the CCMP replay check, which rejects + * it (same PN) - the right outcome, counted as the wrong thing: a replay + * counter that goes up on every lost ACK can no longer tell a retransmission + * from an attack. Pure, per peer; keep one per transmitter. + * + * It runs before decryption, as it does in real stacks, so a forged frame can + * move the cache, and that cuts BOTH ways. A forgery that moves it off a + * sequence number costs one legitimate retransmission being processed + * instead of dropped - and the replay window still refuses it. A forgery + * that moves it ONTO one (Retry=0 at the peer's next sequence number) makes + * the peer's genuine Retry=1 copy of that frame match and be DROPPED as a + * duplicate, if the original was lost on air - one lost frame per forgery, + * counted in the dup-drop counter rather than as a replay. mac80211 has the + * same exposure; the cache is not an integrity mechanism. */ +class DupDetector { +public: + static constexpr int kNonQosTid = 16; + static constexpr int kSlots = 17; + + /* True when this frame is a retransmission of the last one accepted on the + * same TID (Retry bit set, identical Sequence Control). A frame that is not + * a duplicate becomes the new "last". */ + bool is_duplicate(bool retry, uint16_t seq_ctl, int tid = kNonQosTid) { + if (tid < 0 || tid >= kSlots) tid = kNonQosTid; + const bool dup = retry && seen_[tid] && last_[tid] == seq_ctl; + if (!dup) { + seen_[tid] = true; + last_[tid] = seq_ctl; + } + return dup; + } + void reset() { + for (int i = 0; i < kSlots; ++i) { + seen_[i] = false; + last_[i] = 0; + } + } + +private: + bool seen_[kSlots] = {}; + uint16_t last_[kSlots] = {}; +}; + +/* A monotonic 12-bit sequence counter. One per transmitter address; a station + * needs exactly one for everything it sends. + * + * Atomic, because "one for everything it sends" is an invitation to share it + * between a TX thread and the RX thread that answers management frames - which + * is exactly the shape both AP harnesses have. The relaxed ordering is right: + * the only requirement is that no two frames get the same number, not that the + * numbers order against anything else. */ +class SeqCounter { +public: + uint16_t next() { + return (uint16_t)(n_.fetch_add(1, std::memory_order_relaxed) & 0x0fff); + } + void reset() { n_.store(0, std::memory_order_relaxed); } + +private: + std::atomic n_{0}; +}; + +/* ---- information elements --------------------------------------------- */ + +/* Returns false and emits NOTHING when the body will not fit an 8-bit length. + * The truncating form silently wrote a length shorter than the bytes that + * followed it, which corrupts every element after it in the frame - a + * whole-frame corruption with no local symptom. */ +inline bool append_ie(std::vector& m, uint8_t eid, const uint8_t* body, + size_t len) { + if (len > 255) return false; + m.push_back(eid); + m.push_back((uint8_t)len); + m.insert(m.end(), body, body + len); + return true; +} + +/* An SSID is at most 32 octets (802.11-2016 9.4.2.2). Longer is a caller bug, + * refused here rather than aired as a corrupt element. */ +inline bool append_ssid(std::vector& m, const std::string& ssid) { + if (ssid.size() > 32) return false; + return append_ie(m, kEidSsid, (const uint8_t*)ssid.data(), ssid.size()); +} + +/* The 2.4 GHz rate set both AP harnesses air, byte for byte: 1/2/5.5/11 CCK + * marked BASIC, then 18/24/36/54 OFDM non-basic. Kept as one function so the + * AP and a station advertise the same thing and a mismatch cannot appear + * between them. + * + * Note what is NOT here: 6, 9, 12 and 48 Mbps. For an AP's advertised basic + * set that is a deliberate, on-air-validated choice, and these bytes match + * what the AP harnesses air. For a STATION's probe and association request it + * is wrong - a station that omits 6/12/24 is claiming it cannot do the + * mandatory OFDM rates - which is why a station uses its own builder, + * append_supported_rates_sta, rather than widening this one underneath a + * validated AP. */ +inline void append_supported_rates(std::vector& m) { + static const uint8_t r[] = {0x82, 0x84, 0x8b, 0x96, 0x24, 0x30, 0x48, 0x6c}; + append_ie(m, kEidSupportedRates, r, sizeof r); +} + +/* THE STATION'S OWN RATE SET, which is what the note above says a station + * needs rather than widening the AP's validated one. + * + * 802.11-2007 onwards makes 6, 12 and 24 Mbps the mandatory OFDM rates, and a + * conforming AP whose basic-rate set includes one the station did not + * advertise refuses the association with status 18 ("does not support all + * data rates in the BSSBasicRateSet"). The AP's set omits 6, 9, 12 and 48 for + * on-air-validated reasons of its own; a station claiming the same thing is + * claiming it cannot do the mandatory rates. + * + * A Supported Rates element holds at most eight; the rest go in Extended + * Supported Rates, which is what append_ext_supported_rates_sta is for. None + * are marked BASIC - that is the AP's statement to make, not a station's. */ +inline void append_supported_rates_sta(std::vector& m, + bool five_ghz) { + static const uint8_t g[] = {0x02, 0x04, 0x0b, 0x16, + 0x0c, 0x12, 0x18, 0x24}; + static const uint8_t a[] = {0x0c, 0x12, 0x18, 0x24, + 0x30, 0x48, 0x60, 0x6c}; + + if (five_ghz) append_ie(m, kEidSupportedRates, a, sizeof a); + else append_ie(m, kEidSupportedRates, g, sizeof g); +} + +/* 2.4 GHz only: the four OFDM rates that did not fit above. A 5 GHz station + * has all eight of its rates in the Supported Rates element already, and an + * empty Extended Supported Rates element is malformed. */ +inline void append_ext_supported_rates_sta(std::vector& m, + bool five_ghz) { + static const uint8_t g[] = {0x30, 0x48, 0x60, 0x6c}; + + if (!five_ghz) append_ie(m, kEidExtSupportedRates, g, sizeof g); +} + +/* 5 GHz has no CCK, so the basic set is OFDM-only. Airing CCK rates as BASIC + * on a 5 GHz BSS is a spec violation a strict station may refuse outright. */ +inline void append_supported_rates_5g(std::vector& m) { + static const uint8_t r[] = {0x8c, 0x12, 0x98, 0x24, 0xb0, 0x48, 0x60, 0x6c}; + append_ie(m, kEidSupportedRates, r, sizeof r); +} + +inline void append_ds_params(std::vector& m, uint8_t chan) { + append_ie(m, kEidDsParams, &chan, 1); +} + +/* Traffic Indication Map (802.11-2016 9.4.2.6). + * + * EVERY beacon must carry one. A beacon without a TIM is not a conforming AP + * beacon (802.11-2016 9.4.2.6), and this tree's AP harnesses, which build + * their beacons inline, air none. + * + * WHAT A MISSING TIM COSTS, precisely. A station in power save loses most of + * what these harnesses send it - measured, 0/60 pings with power save on + * against 60/60 with it off - but adding this element would not repair that. + * The loss is caused by nothing being buffered: replies air the instant the + * request is parsed, so a dozing station misses them whatever schedule the + * beacon advertises. What a missing TIM costs is CONFORMANCE, and a station + * having no DTIM schedule to synchronise to at all. Those are worth fixing on + * their own; they are not the 0/60. + * + * This is the MINIMUM conforming element and nothing more. It advertises + * "nothing is buffered for anyone": + * + * DTIM Count 0 - this beacon IS a DTIM beacon + * DTIM Period 1 - every beacon is, so a station never waits + * Bitmap Ctrl 0 - offset 0, and bit 0 clear means no group-addressed + * traffic is buffered either + * Partial VBM 0 - one octet, no AID's bit set + * + * That is the truth for these harnesses: they buffer nothing and send every + * reply immediately. It is NOT power-save support, and a station that dozes + * will still miss frames. Actually serving a dozing peer needs a per-AID + * bitmap and a buffer, which is out of scope (docs/ap-mode.md), and + * tests/mt7612u_ap_onair.sh still requires power save OFF. + * + * `aid` is accepted so a future implementation that DOES buffer can set the + * right bit without changing every caller; 0 means "nobody", which is the + * only thing these harnesses can honestly advertise. + * + * AN AID OUTSIDE 1..7 SETS NOTHING, silently, and a caller that begins + * buffering must notice that before relying on it: this minimum body carries + * one bitmap octet with offset 0, so it can only page AIDs 1..7. Paging + * anything higher needs a longer partial virtual bitmap and a non-zero + * bitmap-control offset, i.e. a real implementation - at which point this + * function's signature should grow a way to report refusal. It is left + * silent rather than half-encoding, because a wrapped shift would set some + * OTHER station's bit and tell the wrong peer to stay awake. */ +inline void append_tim(std::vector& m, uint8_t dtim_count = 0, + uint8_t dtim_period = 1, uint16_t aid = 0) { + uint8_t tim[4]; + + tim[0] = dtim_count; + tim[1] = dtim_period; + tim[2] = 0; /* bitmap control: offset 0, no buffered group traffic */ + tim[3] = 0; /* partial virtual bitmap: one octet, AIDs 1..7 */ + /* AIDs 1..7 live in bits 1..7 of this first octet. Anything larger needs a + * longer bitmap and an offset, which this minimum form does not carry - so + * refuse to half-encode it rather than set a bit for the wrong station. */ + if (aid >= 1 && aid <= 7) + tim[3] = (uint8_t)(1u << aid); + append_ie(m, kEidTim, tim, sizeof tim); +} + +/* The WPA2-PSK RSN element: CCMP group, CCMP pairwise, PSK AKM. + * + * Both roles need byte-identical bytes here — an AP advertises it in its + * beacon and probe response, and a station echoes the AP's choice back in its + * association request. A station that sends something the AP did not offer is + * refused, so there is one builder and no per-cipher path that could emit a + * different or truncated element. + */ +inline void append_rsn_ccmp_psk(std::vector& m) { + static const uint8_t rsn[] = { + 0x01, 0x00, /* version 1 */ + 0x00, 0x0f, 0xac, 0x04, /* group cipher: CCMP */ + 0x01, 0x00, 0x00, 0x0f, 0xac, 0x04, /* 1 pairwise: CCMP */ + 0x01, 0x00, 0x00, 0x0f, 0xac, 0x02, /* 1 AKM: PSK */ + 0x00, 0x00, /* RSN capabilities */ + }; + append_ie(m, kEidRsn, rsn, sizeof rsn); +} + +/* Walk the IEs in `body` and return a pointer to the first with `eid`, with + * its length in `len_out`. Returns nullptr when absent. + * + * Bounds-checked against a truncated or hostile frame: an element whose length + * runs past the end of the buffer terminates the walk rather than reading off + * it. Every caller here is parsing frames from the air. */ +inline const uint8_t* find_ie(const uint8_t* body, size_t body_len, uint8_t eid, + size_t* len_out) { + size_t i = 0; + + while (i + 2 <= body_len) { + uint8_t id = body[i]; + size_t len = body[i + 1]; + + if (i + 2 + len > body_len) return nullptr; /* truncated: stop, do not read */ + if (id == eid) { + if (len_out) *len_out = len; + return body + i + 2; + } + i += 2 + len; + } + return nullptr; +} + +/* ---- parsing ---------------------------------------------------------- */ + +/* A parsed RSN element (802.11-2016 9.4.2.25). + * + * The element is VARIABLE: counts precede the suite lists, and a real + * deployment almost never has exactly one of each. A mixed-mode WPA/WPA2 AP + * offers TKIP and CCMP as pairwise; a WPA3-transition AP offers PSK and + * PSK-SHA256 as AKM. Byte-comparing against a canonical one-of-each layout + * would report both as unusable, and a station would skip BSSes it could join + * perfectly well - so the element is parsed, never byte-compared. + */ +struct RsnInfo { + /* Version 1, and no suite count that overruns the element. NOT "complete": + * every field after the version is individually optional, so a version-only + * or group-cipher-only element is `valid` with its later flags false. */ + bool valid = false; + uint16_t version = 0; + bool group_ccmp = false; + bool pairwise_ccmp = false; /* CCMP is AMONG the offered pairwise suites */ + bool akm_psk = false; /* PSK is AMONG the offered AKMs */ + uint16_t capabilities = 0; + bool mfp_required = false; /* RSN capabilities bit 6 */ + bool mfp_capable = false; /* bit 7 */ + uint16_t pairwise_count = 0; + uint16_t akm_count = 0; + /* THE WHOLE ELEMENT, as sets, for comparing two of them (the 12.7.6.4 + * downgrade check in Supplicant.h). A selector is its four bytes read + * big-endian - OUI then type - and 0 means the field was absent. Bit n of a + * mask is suite 00-0F-AC:n; a suite from any other OUI, or with a type of + * 32 or more, sets the matching *_other flag instead. */ + uint32_t group_suite = 0; + uint32_t pairwise_mask = 0; + bool pairwise_other = false; + uint32_t akm_mask = 0; + bool akm_other = false; + /* The group management cipher, which follows the PMKID list (802.11w). + * Nothing here implements it; it is parsed so a difference is visible. */ + uint32_t group_mgmt_suite = 0; +}; + +/* Suite selectors are 4 bytes: a 3-byte OUI then a type. 00-0F-AC is the + * 802.11 OUI; a vendor OUI is a suite we do not implement and must not + * mistake for one we do. */ +inline bool rsn_suite_is(const uint8_t* s, uint8_t type) { + return s[0] == 0x00 && s[1] == 0x0f && s[2] == 0xac && s[3] == type; +} + +inline uint32_t rsn_suite_u32(const uint8_t* s) { + return ((uint32_t)s[0] << 24) | ((uint32_t)s[1] << 16) | + ((uint32_t)s[2] << 8) | (uint32_t)s[3]; +} + +/* Add one suite to a set: a bit for an 00-0F-AC suite, the flag otherwise. */ +inline void rsn_suite_note(const uint8_t* s, uint32_t* mask, bool* other) { + if (s[0] == 0x00 && s[1] == 0x0f && s[2] == 0xac && s[3] < 32) + *mask |= 1u << s[3]; + else + *other = true; +} + +/* Parse an RSN element body (the bytes AFTER the EID and length octets). + * + * Every step is bounds-checked against `len` and stops rather than reading + * on: this is attacker-controlled input from the air. Absent trailing fields + * are legal - an element may stop after the group cipher - and leave their + * flags false rather than failing the parse. */ +inline bool parse_rsn(const uint8_t* p, size_t len, RsnInfo* out) { + size_t i = 0; + + if (!p || !out) return false; + *out = RsnInfo{}; + if (len < 2) return false; + out->version = get_le16(p); + i = 2; + if (out->version != 1) { *out = RsnInfo{}; return false; } /* nothing else defined */ + + if (i + 4 <= len) { + out->group_ccmp = rsn_suite_is(p + i, 4); + out->group_suite = rsn_suite_u32(p + i); + i += 4; + } + if (i + 2 <= len) { + out->pairwise_count = get_le16(p + i); + i += 2; + /* A count that overruns the element is malformed, not merely unsupported. + * Refuse rather than walk off the end. */ + if (i + (size_t)out->pairwise_count * 4 > len) { *out = RsnInfo{}; return false; } + for (uint16_t n = 0; n < out->pairwise_count; n++, i += 4) { + if (rsn_suite_is(p + i, 4)) out->pairwise_ccmp = true; + rsn_suite_note(p + i, &out->pairwise_mask, &out->pairwise_other); + } + } + if (i + 2 <= len) { + out->akm_count = get_le16(p + i); + i += 2; + if (i + (size_t)out->akm_count * 4 > len) { *out = RsnInfo{}; return false; } + for (uint16_t n = 0; n < out->akm_count; n++, i += 4) { + if (rsn_suite_is(p + i, 2)) out->akm_psk = true; + rsn_suite_note(p + i, &out->akm_mask, &out->akm_other); + } + } + /* An element may legitimately stop before RSN Capabilities, and an absent + * field means MFPR=0 - which is what hostap assumes too. Such an element is + * accepted, deliberately, and test_parse_rsn_sets pins that direction so a + * flip either way is visible rather than silent. An AP that really requires + * MFP refuses the association regardless. */ + if (i + 2 <= len) { + out->capabilities = get_le16(p + i); + out->mfp_required = (out->capabilities & 0x0040) != 0; + out->mfp_capable = (out->capabilities & 0x0080) != 0; + i += 2; + } + /* PMKID count and list, then the group management cipher. Both optional; + * a PMKID count that overruns is malformed, like the suite counts. */ + if (i + 2 <= len) { + const size_t pmkids = get_le16(p + i); + i += 2; + if (i + pmkids * 16 > len) { *out = RsnInfo{}; return false; } + i += pmkids * 16; + } + if (i + 4 <= len) { + out->group_mgmt_suite = rsn_suite_u32(p + i); + i += 4; + } + out->valid = true; + return true; +} + +/* What a station learns about a BSS from one beacon or probe response. */ +struct BssInfo { + uint8_t bssid[6] = {0}; + std::string ssid; + uint16_t capability = 0; + uint16_t beacon_interval_tu = 0; + uint8_t channel = 0; /* from the DS Parameter Set; 0 when absent or + * not channel_valid() */ + bool privacy = false; /* capability bit 4 */ + bool has_rsn = false; + bool rsn_ccmp_psk = false; /* the only suite this project speaks */ + /* RSN Capabilities bit 6 (MFPR). A BSS that REQUIRES management-frame + * protection will refuse an association from a station that does not + * implement 802.11w - which this project does not. Surfaced so a scan can + * skip it, rather than associating and failing the handshake with no + * diagnostic. */ + bool rsn_mfp_required = false; + uint16_t rsn_capabilities = 0; + RsnInfo rsn; /* the whole element, for a caller that wants more than the + * one verdict above */ +}; + +/* A channel number this station can tune and pick a band for: 1..14 is + * 2.4 GHz, 32..253 is 5 GHz (freq = 5000 + 5 * chan, the extended range the + * devourer 5 GHz synthesizer covers). Anything else - 0, 15..31, 254, 255 - + * is not a channel, whether it came from a DS element or the receiver. No + * regulatory check: the caller owns compliance. 6 GHz numbering overlaps + * both ranges and is not modelled. */ +inline bool channel_valid(uint8_t chan) { + return (chan >= 1 && chan <= 14) || (chan >= 32 && chan <= 253); +} + +/* An infrastructure BSS: capability ESS set and IBSS clear (802.11-2016 + * 9.4.1.4). A station's authenticate/associate exchange is meaningless + * against an IBSS, which has no AP to answer it. */ +inline bool bss_is_infrastructure(const BssInfo& b) { + return (b.capability & 0x0001) != 0 && (b.capability & 0x0002) == 0; +} + +/* A BSS this station can join with WPA2-PSK: the Privacy capability bit set + * AND a usable RSN element (rsn_ccmp_psk). An RSN element under a clear + * Privacy bit is self-contradictory - a conforming AP sets Privacy whenever it + * protects data - and joining it would mean guessing which half to believe. */ +inline bool bss_is_wpa2_psk(const BssInfo& b) { + return b.privacy && b.rsn_ccmp_psk; +} + +/* A beacon/probe-response body is a 12-byte fixed part (timestamp, beacon + * interval, capability) followed by IEs. `frame` starts at the 802.11 header. + * Returns false on anything too short to trust. */ +inline bool parse_beacon(const uint8_t* frame, size_t len, BssInfo* out) { + const size_t fixed = 24 + 12; + const uint8_t* body; + size_t body_len, ie_len; + + if (!frame || !out || len < fixed) return false; + /* A scan loop reuses one BssInfo across beacons. Without this, a frame that + * omits the SSID, DS Parameter Set or RSN element leaves the PREVIOUS BSS's + * values in place and the caller reads them as this BSS's - including when a + * hostile frame truncates an element so find_ie declines it. */ + *out = BssInfo{}; + std::memcpy(out->bssid, frame + 16, 6); /* addr3 */ + out->beacon_interval_tu = get_le16(frame + 24 + 8); + out->capability = get_le16(frame + 24 + 10); + out->privacy = (out->capability & 0x0010) != 0; + + body = frame + fixed; + body_len = len - fixed; + + /* An SSID is at most 32 octets (802.11-2016 9.4.2.2), the same limit + * append_ssid builds to. A longer SSID element makes the whole frame + * malformed: it is refused, not recorded under a name no conforming + * station could send back in an association request. */ + if (const uint8_t* p = find_ie(body, body_len, kEidSsid, &ie_len)) { + if (ie_len > 32) { + *out = BssInfo{}; + return false; + } + out->ssid.assign((const char*)p, ie_len); + } + /* A DS value that is not a channel (15, 255, ...) is dropped here, so the + * caller's receive channel can stand in for it (BssTable::observe). */ + if (const uint8_t* p = find_ie(body, body_len, kEidDsParams, &ie_len)) + if (ie_len >= 1 && channel_valid(p[0])) out->channel = p[0]; + if (const uint8_t* p = find_ie(body, body_len, kEidRsn, &ie_len)) { + out->has_rsn = true; + parse_rsn(p, ie_len, &out->rsn); + /* Joinable when CCMP is among the offered pairwise suites and PSK among + * the AKMs - NOT when they are the only ones. And not when the BSS + * requires management-frame protection, which this project does not + * implement: better to skip it in the scan than to associate and fail the + * handshake with no diagnostic. */ + out->rsn_ccmp_psk = out->rsn.valid && out->rsn.group_ccmp && + out->rsn.pairwise_ccmp && out->rsn.akm_psk && + !out->rsn.mfp_required; + out->rsn_capabilities = out->rsn.capabilities; + out->rsn_mfp_required = out->rsn.mfp_required; + } + return true; +} + +/* Authentication frame body: algorithm, sequence, status. */ +struct AuthFields { + uint16_t algorithm = 0; + uint16_t seq = 0; + uint16_t status = 0; +}; +inline bool parse_auth(const uint8_t* frame, size_t len, AuthFields* out) { + if (!frame || !out || len < 24 + 6) return false; + out->algorithm = get_le16(frame + 24); + out->seq = get_le16(frame + 26); + out->status = get_le16(frame + 28); + return true; +} + +/* Association-response body: capability, status, AID. */ +struct AssocRespFields { + uint16_t capability = 0; + uint16_t status = 0; + uint16_t aid = 0; /* the two top bits are always set on the wire */ +}; +inline bool parse_assoc_resp(const uint8_t* frame, size_t len, + AssocRespFields* out) { + if (!frame || !out || len < 24 + 6) return false; + out->capability = get_le16(frame + 24); + out->status = get_le16(frame + 26); + out->aid = (uint16_t)(get_le16(frame + 28) & 0x3fff); + return true; +} + +/* Deauth/disassoc reason code. */ +inline bool parse_reason(const uint8_t* frame, size_t len, uint16_t* reason) { + if (!frame || !reason || len < 24 + 2) return false; + *reason = get_le16(frame + 24); + return true; +} + +/* ---- station-side builders -------------------------------------------- */ + +/* Probe request. A broadcast-SSID probe with an empty SSID element is a + * wildcard scan; a named SSID is a directed probe, which is what finds a + * hidden BSS. */ +inline std::vector build_probe_req(const uint8_t own[6], + const std::string& ssid, + uint8_t chan, bool five_ghz) { + static const uint8_t bcast[6] = {0xff, 0xff, 0xff, 0xff, 0xff, 0xff}; + std::vector m = mgmt_hdr(kFcProbeReq, bcast, own, bcast); + + /* An over-length SSID must abort the build, not produce a management frame + * that is silently missing its SSID element - which is invalid, and which a + * peer drops without comment. */ + if (!append_ssid(m, ssid)) return {}; + /* The STATION set here too: an AP may answer a probe request based on the + * rates it advertises, and the two requests should not claim different + * capabilities. */ + append_supported_rates_sta(m, five_ghz); + append_ext_supported_rates_sta(m, five_ghz); + /* The DS Parameter Set is a 2.4 GHz element (802.11-2016 9.4.2.4); a 5 GHz + * probe carries no channel element. */ + if (chan && !five_ghz) append_ds_params(m, chan); + return m; +} + +/* Open-system authentication, sequence 1 — the station's half. */ +inline std::vector build_auth_req(const uint8_t own[6], + const uint8_t bssid[6]) { + std::vector m = mgmt_hdr(kFcAuth, bssid, own, bssid); + put_le16(m, 0); /* open system */ + put_le16(m, 1); /* sequence 1 */ + put_le16(m, 0); /* status 0 */ + return m; +} + +/* Association request. `capability` must claim ESS, and Privacy when the BSS + * advertises RSN — an association request whose Privacy bit disagrees with the + * RSN element it carries is refused by a conforming AP. */ +inline std::vector build_assoc_req(const uint8_t own[6], + const uint8_t bssid[6], + const std::string& ssid, + bool rsn, bool five_ghz, + uint16_t listen_interval = 10) { + std::vector m = mgmt_hdr(kFcAssocReq, bssid, own, bssid); + put_le16(m, (uint16_t)(0x0001 | (rsn ? 0x0010 : 0))); /* ESS | Privacy */ + put_le16(m, listen_interval); + if (!append_ssid(m, ssid)) return {}; + /* The STATION set, not the AP's: an association request that omits 6 and 12 + * Mbps can be refused with status 18 by any AP whose basic set includes + * them. See append_supported_rates_sta. */ + append_supported_rates_sta(m, five_ghz); + append_ext_supported_rates_sta(m, five_ghz); + /* The RSN element goes AFTER the rates, and anything that follows it must + * still be emitted on every cipher path: an association request that drops + * its HT/VHT/ExtCap tail is refused or downgraded by the AP. There is no + * tail here yet; one added later belongs below this line, not above it. */ + if (rsn) append_rsn_ccmp_psk(m); + return m; +} + +/* Deauthentication, so a station leaves cleanly instead of making the AP time + * it out. Reason 3 = "station is leaving". */ +inline std::vector build_deauth(const uint8_t own[6], + const uint8_t bssid[6], + uint16_t reason = 3) { + std::vector m = mgmt_hdr(kFcDeauth, bssid, own, bssid); + put_le16(m, reason); + return m; +} + +/* ---- data frames ------------------------------------------------------ */ + +/* LLC/SNAP header for an ethertype, the 8 bytes that precede every IP payload + * inside an 802.11 data frame. */ +inline void append_llc_snap(std::vector& m, uint16_t ethertype) { + m.insert(m.end(), {0xaa, 0xaa, 0x03, 0x00, 0x00, 0x00}); + m.push_back((uint8_t)(ethertype >> 8)); + m.push_back((uint8_t)(ethertype & 0xff)); +} + +/* A station's uplink data header: to-DS, addr1 = BSSID, addr2 = own, + * addr3 = destination. The AP's downlink is the mirror (from-DS, addr1 = sta, + * addr2 = bssid, addr3 = source), which is why this takes a direction rather + * than being two near-identical functions. */ +inline std::vector data_hdr_to_ds(const uint8_t bssid[6], + const uint8_t own[6], + const uint8_t dest[6], + bool protect, uint16_t seq = 0) { + std::vector m; + m.reserve(24); + m.push_back(kFcData); + m.push_back((uint8_t)(kFcToDs | (protect ? kFcProtected : 0))); + put_le16(m, 0); + m.insert(m.end(), bssid, bssid + 6); + m.insert(m.end(), own, own + 6); + m.insert(m.end(), dest, dest + 6); + put_le16(m, (uint16_t)((seq & 0x0fff) << 4)); + return m; +} + +inline std::vector data_hdr_from_ds(const uint8_t sta[6], + const uint8_t bssid[6], + const uint8_t src[6], + bool protect, uint16_t seq = 0) { + std::vector m; + m.reserve(24); + m.push_back(kFcData); + m.push_back((uint8_t)(kFcFromDs | (protect ? kFcProtected : 0))); + put_le16(m, 0); + m.insert(m.end(), sta, sta + 6); + m.insert(m.end(), bssid, bssid + 6); + m.insert(m.end(), src, src + 6); + put_le16(m, (uint16_t)((seq & 0x0fff) << 4)); + return m; +} + +/* True for EVERY QoS data subtype, not just QoS Data itself. + * + * Data frames are type 2 (fc0 bits 3:2 == 10) and the QoS subtypes are those + * with bit 7 set - QoS Data, QoS Null, QoS Data+CF-Ack and the rest. An exact + * `fc0 == 0x88` test misses QoS Null (0xc8), which a real station sends, and + * then reads its body two bytes early. */ +inline bool is_qos_data(uint8_t fc0) { return (fc0 & 0x8c) == 0x88; } + +/* Bytes before the frame body: 24 base, +2 for the QoS Control field, +6 for a + * 4-address frame, +4 for HT Control when the Order bit is set + * (802.11-2016 9.2.4.1.10). Getting this wrong reads the LLC header at the + * wrong offset and silently drops the frame. */ +/* ------------------------------------------------------- 802.11 <-> 802.3 + * + * The translation anything that bridges a station's data plane to an + * Ethernet-shaped interface needs (a TAP device, or an in-process stack with + * no netdev at all). NOTHING IN THIS TREE CALLS IT YET; it is here, tested, + * so that such callers share one conversion instead of each writing their + * own. `append_llc_snap` above encodes the 8-byte LLC/SNAP header and + * tests/ap_responder.cpp decodes it inline, but neither builds or parses the + * 14-byte Ethernet II header, which is the actual work. + * + * An 802.11 MSDU is LLC/SNAP(8) + payload, and its addresses live in the + * 802.11 header. An Ethernet II frame is DA(6) + SA(6) + ethertype(2) + + * payload. So the conversion moves addresses in from outside, and the + * ethertype up out of the SNAP header. + * + * Both directions return 0 rather than truncating or guessing. A silent + * truncation here would produce a frame that looks well-formed and decodes to + * nonsense at the far end. */ +inline constexpr size_t kEthHdrLen = 14; +inline constexpr size_t kLlcSnapLen = 8; + +/* True for the exact SNAP header 802.11 uses to carry an ethertype: + * AA AA 03 with a zero OUI. Anything else is a payload this translation has + * no business rewriting - other LLC encodings exist and carry no ethertype at + * bytes 6..7. */ +inline bool is_ethertype_snap(const uint8_t* msdu, size_t len) { + return len >= kLlcSnapLen && msdu[0] == 0xaa && msdu[1] == 0xaa && + msdu[2] == 0x03 && msdu[3] == 0x00 && msdu[4] == 0x00 && + msdu[5] == 0x00; +} + +/* MSDU -> Ethernet II. Returns bytes written into `out`, or 0. */ +inline size_t msdu_to_eth(const uint8_t da[6], const uint8_t sa[6], + const uint8_t* msdu, size_t msdu_len, + uint8_t* out, size_t out_cap) { + if (!da || !sa || !msdu || !out) return 0; + if (!is_ethertype_snap(msdu, msdu_len)) return 0; + const size_t payload = msdu_len - kLlcSnapLen; + if (out_cap < kEthHdrLen + payload) return 0; + std::memcpy(out, da, 6); + std::memcpy(out + 6, sa, 6); + out[12] = msdu[6]; /* ethertype, straight out of the SNAP */ + out[13] = msdu[7]; + std::memcpy(out + kEthHdrLen, msdu + kLlcSnapLen, payload); + return kEthHdrLen + payload; +} + +/* Ethernet II -> MSDU. The addresses come OUT through `out_da`/`out_sa`, + * because the caller needs them for the 802.11 header, not for the MSDU. + * Returns bytes written into `out`, or 0. */ +inline size_t eth_to_msdu(const uint8_t* eth, size_t eth_len, + uint8_t* out, size_t out_cap, + uint8_t out_da[6], uint8_t out_sa[6]) { + if (!eth || !out) return 0; + if (eth_len < kEthHdrLen) return 0; + const size_t payload = eth_len - kEthHdrLen; + if (out_cap < kLlcSnapLen + payload) return 0; + if (out_da) std::memcpy(out_da, eth, 6); + if (out_sa) std::memcpy(out_sa, eth + 6, 6); + out[0] = 0xaa; out[1] = 0xaa; out[2] = 0x03; + out[3] = 0x00; out[4] = 0x00; out[5] = 0x00; + out[6] = eth[12]; + out[7] = eth[13]; + std::memcpy(out + kLlcSnapLen, eth + kEthHdrLen, payload); + return kLlcSnapLen + payload; +} + +/* Direction-aware addressing — 802.11-2016 Table 9-26. + * + * An AP that relays needs the DESTINATION of a frame, and the destination is + * not in a fixed place: it is addr1 when the frame comes from the DS and addr3 + * when it goes to the DS. A receive path that reads only addr1 and addr2 + * assumes the frame is for the AP itself, which is true right up until the AP + * has a second station to forward to. + * + * ToDS FromDS | addr1 addr2 addr3 addr4 + * 0 0 | DA SA BSSID - (IBSS) + * 0 1 | DA BSSID SA - (from the DS) + * 1 0 | BSSID SA DA - (to the DS) + * 1 1 | RA TA DA SA (4-address / WDS) + * + * `hdr` must be at least data_hdr_len() bytes; for the 4-address case that is + * 30, and data_sa() reads addr4 at offset 24. */ +inline const uint8_t* data_da(const uint8_t* hdr, uint8_t fc1) { + const bool to_ds = (fc1 & kFcToDs) != 0; + /* DA is addr1 unless the frame is going TO the DS, where addr1 is the + * BSSID and the real destination sits in addr3. */ + return to_ds ? hdr + 16 : hdr + 4; +} + +inline const uint8_t* data_sa(const uint8_t* hdr, uint8_t fc1) { + const bool to_ds = (fc1 & kFcToDs) != 0; + const bool from_ds = (fc1 & kFcFromDs) != 0; + if (to_ds && from_ds) return hdr + 24; /* addr4 */ + if (from_ds) return hdr + 16; /* addr3 */ + return hdr + 10; /* addr2 */ +} + +/* True when this frame's final destination is a group address. Note this is + * NOT the same question as "is this frame group-addressed", which is about + * addr1/RA and decides which key protects it: a station's broadcast ARP goes + * out as an individually addressed frame to the AP with a group DA in addr3. */ +inline bool data_da_is_group(const uint8_t* hdr, uint8_t fc1) { + return (data_da(hdr, fc1)[0] & 0x01) != 0; +} + +inline size_t data_hdr_len(uint8_t fc0, uint8_t fc1) { + size_t n = 24; + if (is_qos_data(fc0)) n += 2; + if ((fc1 & (kFcToDs | kFcFromDs)) == (kFcToDs | kFcFromDs)) n += 6; /* 4-addr */ + /* HT Control rides only QoS data frames; the Order bit means something else + * on a non-QoS frame and must not add four bytes there. */ + if (is_qos_data(fc0) && (fc1 & 0x80)) n += 4; + return n; +} + +} // namespace sta +} // namespace devourer + +#endif /* DEVOURER_STA_DOT11_H */ diff --git a/src/sta/Eapol.h b/src/sta/Eapol.h new file mode 100644 index 00000000..946a9221 --- /dev/null +++ b/src/sta/Eapol.h @@ -0,0 +1,490 @@ +/* Eapol — the EAPOL-Key wire format and the key derivations built on it. + * + * This is format and arithmetic only: parse, build, derive, verify. The + * decisions - what to do with a message that arrives in the wrong state, + * whether a replay counter is acceptable, when to install a key - belong to + * Supplicant.h, which is where they can be tested as decisions. + * + * Everything cryptographic goes through CryptoOps, so `libdevourer` gains no + * dependency. That is not a stylistic preference: hand-rolled AES, CCM, + * PBKDF2 or PRF code without known-answer tests is the defect class this + * module exists to avoid, and every primitive used here is pinned by vectors + * in tests/. + * + * ONLY KEY DESCRIPTOR VERSION 2 (HMAC-SHA1 MIC, AES key wrap, CCMP). Version 1 + * is TKIP - HMAC-MD5 and RC4 - and version 3 is AES-128-CMAC. Neither is + * implemented, and both are REFUSED rather than treated as version 2, because + * a version mismatch means the MIC is computed with a different algorithm and + * "the MIC did not verify" would be the only symptom. + */ +#ifndef DEVOURER_STA_EAPOL_H +#define DEVOURER_STA_EAPOL_H + +#include +#include +#include +#include +#include + +#include "sta/CryptoOps.h" + +namespace devourer { +namespace sta { + +/* An EAPOL-Key frame is 99 bytes before its key data. The offsets below are + * 802.11-2016 12.7.2. + * + * DO NOT CONVERT tests/ap_wpa2.cpp ONTO THESE. That authenticator hand-rolls + * every one of these offsets inline and shares no code with this header - + * which is exactly what makes a cross-role + * test between the two a real oracle. Two implementations from + * one set of constants cannot disagree, and cannot catch a misreading either. + * The duplication is the test. */ +inline constexpr size_t kEapolKeyFixedLen = 99; +inline constexpr size_t kEapolMicOff = 81; +inline constexpr size_t kEapolMicLen = 16; +inline constexpr size_t kEapolNonceOff = 17; +inline constexpr size_t kEapolReplayOff = 9; +inline constexpr size_t kEapolRscOff = 65; +inline constexpr size_t kEapolKeyDataLenOff = 97; + +/* Key Information bits (802.11-2016 Figure 12-34). */ +enum : uint16_t { + kKiVersionMask = 0x0007, + kKiPairwise = 0x0008, + kKiKeyIdMask = 0x0030, + kKiInstall = 0x0040, + kKiAck = 0x0080, + kKiMic = 0x0100, + kKiSecure = 0x0200, + kKiError = 0x0400, + kKiRequest = 0x0800, + kKiEncrypted = 0x1000, +}; + +/* The only key descriptor version this implements: HMAC-SHA1-128 MIC and + * NIST AES key wrap, which is what WPA2-PSK with CCMP uses. */ +inline constexpr uint16_t kKeyDescVersionCcmp = 2; +inline constexpr uint8_t kKeyDescTypeRsn = 2; + +/* THE 802.1X PROTOCOL VERSION A SUPPLICANT SENDS. + * + * One, not two, and not an echo of what the authenticator sent. This is what + * wpa_supplicant ships as its default, and the reason is compatibility: the + * octet is inside the MIC'd region, some access points have historically + * misbehaved on version 2 from a station, and there is no upside to claiming + * a higher number - the field is not a negotiation. Echoing the + * authenticator's version would send 2 against hostapd; the captured + * wpa_supplicant message 2 carries 1, and test_against_a_real_four_way + * compares ours with it byte for byte. */ +inline constexpr uint8_t kEapolVersionSupplicant = 1; + +/* A parsed EAPOL-Key frame. The pointers alias the caller's buffer and are + * valid only as long as it is. */ +struct EapolKey { + const uint8_t* frame = nullptr; /* the whole EAPOL frame, from byte 0 */ + size_t frame_len = 0; /* its true length, key data included */ + uint8_t descriptor = 0; + uint16_t key_info = 0; + uint16_t version = 0; + uint16_t key_len = 0; + uint64_t replay = 0; + const uint8_t* nonce = nullptr; /* 32 bytes */ + const uint8_t* rsc = nullptr; /* 8 bytes */ + const uint8_t* mic = nullptr; /* 16 bytes */ + const uint8_t* key_data = nullptr; + size_t key_data_len = 0; + + bool pairwise() const { return (key_info & kKiPairwise) != 0; } + bool install() const { return (key_info & kKiInstall) != 0; } + bool ack() const { return (key_info & kKiAck) != 0; } + bool has_mic() const { return (key_info & kKiMic) != 0; } + bool secure() const { return (key_info & kKiSecure) != 0; } + bool error() const { return (key_info & kKiError) != 0; } + bool request() const { return (key_info & kKiRequest) != 0; } + bool encrypted() const { return (key_info & kKiEncrypted) != 0; } + uint8_t key_id() const { return (uint8_t)((key_info & kKiKeyIdMask) >> 4); } +}; + +inline uint16_t eapol_be16(const uint8_t* p) { + return (uint16_t)((p[0] << 8) | p[1]); +} + +inline uint64_t eapol_be64(const uint8_t* p) { + uint64_t v = 0; + for (int i = 0; i < 8; i++) v = (v << 8) | p[i]; + return v; +} + +inline void eapol_put_be64(uint8_t* p, uint64_t v) { + for (int i = 0; i < 8; i++) p[i] = (uint8_t)((v >> (8 * (7 - i))) & 0xff); +} + +/* Is this EAPOL packet an EAPOL-Key (802.1X packet type 3)? Only the type + * octet is read, so this answers "should the key handshake see this at all" + * without judging whether the frame is well formed - EAP, EAPOL-Start and + * EAPOL-Logoff share the ethertype and are not the supplicant's to consume. + * False when the type octet is not even present. */ +inline constexpr uint8_t kEapolTypeKey = 3; +inline bool eapol_is_key(const uint8_t* eapol, size_t len) { + return eapol && len >= 2 && eapol[1] == kEapolTypeKey; +} + +/* Parse an EAPOL frame (starting at the 802.1X version octet, i.e. what + * follows the LLC/SNAP header of an 0x888E data frame). + * + * Everything is bounds-checked against `len` because this arrives from the + * air before anything has authenticated it. In particular the declared key + * data length is checked against what is actually present: a frame claiming + * 4096 bytes of key data in a 99-byte buffer is the first thing an attacker + * tries, and the GTK is read out of that region. + * + * `len` may be longer than the frame (a padded MSDU); the 802.1X body length + * is authoritative and is what bounds the parse. A body length longer than + * the buffer is refused rather than clamped - clamping would let a truncated + * frame's MIC be computed over fewer bytes than the sender signed. + */ +inline bool parse_eapol_key(const uint8_t* eapol, size_t len, EapolKey* out) { + size_t body_len, total, kdlen; + + if (!eapol || !out || len < kEapolKeyFixedLen) return false; + if (eapol[1] != 3) return false; /* packet type: EAPOL-Key */ + body_len = eapol_be16(eapol + 2); + /* The body starts at offset 4; the fixed part is 95 bytes of body. */ + if (body_len < kEapolKeyFixedLen - 4) return false; + total = 4 + body_len; + if (total > len) return false; + + if (eapol[4] != kKeyDescTypeRsn) return false; + kdlen = eapol_be16(eapol + kEapolKeyDataLenOff); + if (kEapolKeyFixedLen + kdlen != total) return false; + + *out = EapolKey{}; + out->frame = eapol; + out->frame_len = total; + out->descriptor = eapol[4]; + out->key_info = eapol_be16(eapol + 5); + out->version = (uint16_t)(out->key_info & kKiVersionMask); + out->key_len = eapol_be16(eapol + 7); + out->replay = eapol_be64(eapol + kEapolReplayOff); + out->nonce = eapol + kEapolNonceOff; + out->rsc = eapol + kEapolRscOff; + out->mic = eapol + kEapolMicOff; + out->key_data = kdlen ? eapol + kEapolKeyFixedLen : nullptr; + out->key_data_len = kdlen; + return true; +} + +/* Build an EAPOL-Key frame. `mic_kck` non-null sets the MIC over the finished + * frame with the MIC field zeroed, which is the only order that works: the + * MIC covers the key data and the key data length, so nothing may be appended + * afterwards. + * + * `proto_version` IS THE 802.1X VERSION OCTET, and it is an argument because + * it sits inside the MIC'd region and the two reference implementations do + * not agree on it: hostapd sends 2 and wpa_supplicant sends 1, in the same + * exchange, and each accepts the other. It is "the highest version the sender + * supports", not a negotiation, so neither is wrong. + * + * The default is 2 because the authenticators in this tree send 2. A + * supplicant should send kEapolVersionSupplicant - see the note there. + * + * Returns an EMPTY vector, rather than a truncated frame, when the key data + * would not fit: the body length and the key data length are 16-bit fields, + * and writing their low 16 bits would produce a frame whose declared lengths + * disagree with its bytes - under a MIC that then signs the lie. + * + * EMPTY TOO for two inconsistent requests: a nonzero `key_data_len` with no + * `key_data` (the frame would declare bytes it does not carry), and a + * `mic_kck` with no `crypto` to compute the MIC (the frame would go out + * unsigned though the caller asked for a MIC). Both null is the valid + * unsigned frame, which message 1 is. */ +inline std::vector build_eapol_key(uint16_t key_info, uint16_t key_len, + uint64_t replay, + const uint8_t nonce[32], + const uint8_t rsc[8], + const uint8_t* key_data, + size_t key_data_len, + CryptoOps* crypto, + const uint8_t* mic_kck, + uint8_t proto_version = 2) { + if (key_data_len > 0xffffu - (kEapolKeyFixedLen - 4)) return {}; + if (key_data_len != 0 && !key_data) return {}; + if (mic_kck && !crypto) return {}; + std::vector e(kEapolKeyFixedLen, 0); + + e[0] = proto_version; /* 802.1X version */ + e[1] = 3; /* EAPOL-Key */ + const size_t body = kEapolKeyFixedLen - 4 + key_data_len; + e[2] = (uint8_t)((body >> 8) & 0xff); + e[3] = (uint8_t)(body & 0xff); + e[4] = kKeyDescTypeRsn; + e[5] = (uint8_t)(key_info >> 8); + e[6] = (uint8_t)(key_info & 0xff); + e[7] = (uint8_t)(key_len >> 8); + e[8] = (uint8_t)(key_len & 0xff); + eapol_put_be64(e.data() + kEapolReplayOff, replay); + if (nonce) std::memcpy(e.data() + kEapolNonceOff, nonce, 32); + if (rsc) std::memcpy(e.data() + kEapolRscOff, rsc, 8); + e[kEapolKeyDataLenOff] = (uint8_t)((key_data_len >> 8) & 0xff); + e[kEapolKeyDataLenOff + 1] = (uint8_t)(key_data_len & 0xff); + if (key_data && key_data_len) + e.insert(e.end(), key_data, key_data + key_data_len); + if (crypto && mic_kck) { + uint8_t d[20]; + + std::memset(e.data() + kEapolMicOff, 0, kEapolMicLen); + if (crypto->hmac_sha1(mic_kck, 16, e.data(), e.size(), d)) + std::memcpy(e.data() + kEapolMicOff, d, kEapolMicLen); + else + e.clear(); /* refuse, do not ship unsigned */ + } + return e; +} + +/* Verify an EAPOL-Key MIC with the KCK. + * + * THE COMPARISON IS THE POINT. It is done over a copy with the MIC field + * zeroed - the field is part of the signed region, so it has to be removed + * before the HMAC, and doing that in place would write through a pointer into + * a received frame. The result is compared with a constant-time reduction: + * this runs against attacker-supplied input, and an early-exit memcmp over a + * MAC is the textbook way to hand out a forgery oracle. + * + * A wrong descriptor version fails here rather than being tolerated, because + * version 1 and 3 use different MIC algorithms entirely. + * + * THREE OUTCOMES, NOT TWO. `Mismatch` is the frame's fault: a MIC that does + * not verify, or a frame that cannot carry a version-2 MIC at all. + * `CryptoError` is ours: the HMAC provider failed, so nothing is known about + * the frame. A caller that folds the two together reports its own provider + * failing as a forgery. + */ +enum class MicCheck : uint8_t { Ok, Mismatch, CryptoError }; + +inline MicCheck eapol_mic_ok(CryptoOps& crypto, const uint8_t kck[16], + const EapolKey& k) { + uint8_t got[kEapolMicLen], want[20]; + uint8_t diff = 0; + + /* Checked BEFORE the copy: an EapolKey that did not come from + * parse_eapol_key may carry a null frame or a short length. */ + if (!k.frame || k.frame_len < kEapolKeyFixedLen) return MicCheck::Mismatch; + if (k.version != kKeyDescVersionCcmp) return MicCheck::Mismatch; + if (!k.has_mic()) return MicCheck::Mismatch; + std::vector copy(k.frame, k.frame + k.frame_len); + std::memcpy(got, k.frame + kEapolMicOff, kEapolMicLen); + std::memset(copy.data() + kEapolMicOff, 0, kEapolMicLen); + if (!crypto.hmac_sha1(kck, 16, copy.data(), copy.size(), want)) + return MicCheck::CryptoError; + for (size_t i = 0; i < kEapolMicLen; i++) diff |= (uint8_t)(got[i] ^ want[i]); + return diff == 0 ? MicCheck::Ok : MicCheck::Mismatch; +} + +/* The 802.11 PRF built on HMAC-SHA1 (802.11-2016 12.7.1.2). `olen` bytes are + * produced 20 at a time; the label's terminating NUL is part of the input. */ +inline bool prf_sha1(CryptoOps& crypto, const uint8_t* key, size_t key_len, + const char* label, const uint8_t* data, size_t data_len, + uint8_t* out, size_t olen) { + const size_t ll = std::strlen(label); + std::vector buf(ll + 1 + data_len + 1); + + std::memcpy(buf.data(), label, ll); + buf[ll] = 0; + if (data_len) std::memcpy(buf.data() + ll + 1, data, data_len); + for (size_t gen = 0, i = 0; gen < olen; gen += 20, i++) { + uint8_t d[20]; + const size_t take = (olen - gen < 20) ? olen - gen : 20; + + buf[ll + 1 + data_len] = (uint8_t)i; + if (!crypto.hmac_sha1(key, key_len, buf.data(), buf.size(), d)) + return false; + std::memcpy(out + gen, d, take); + } + return true; +} + +/* PMK = PBKDF2(passphrase, SSID, 4096, 32). The SSID is the salt, which is + * why two networks with the same passphrase and different names do not share + * a PMK. + * + * THE PSK HAS TWO SPELLINGS (802.11-2016 J.4.1), as hostapd's wpa_psk= and + * wpa_supplicant's psk= both accept: a passphrase of 8..63 characters, run + * through PBKDF2, or EXACTLY 64 hex digits, which ARE the PMK and are + * decoded, not hashed. Anything else is refused here. Hashing a 64-hex PSK + * as though it were a passphrase produces a PMK nothing else derives, whose + * only symptom is MIC failures and a handshake timeout; refusing here turns + * a malformed PSK into a configuration failure at the point it is given. */ +inline void secure_wipe(void* p, size_t n); /* defined below */ +inline bool pmk_from_psk(CryptoOps& crypto, const char* passphrase, + const std::string& ssid, uint8_t pmk[32]) { + if (!passphrase || ssid.empty() || ssid.size() > 32) return false; + const size_t n = std::strlen(passphrase); + if (n == 64) { + uint8_t raw[32]; + for (size_t i = 0; i < 64; i++) { + const char c = passphrase[i]; + int v; + if (c >= '0' && c <= '9') v = c - '0'; + else if (c >= 'a' && c <= 'f') v = c - 'a' + 10; + else if (c >= 'A' && c <= 'F') v = c - 'A' + 10; + else { secure_wipe(raw, sizeof raw); return false; } + if (i % 2 == 0) raw[i / 2] = (uint8_t)(v << 4); + else raw[i / 2] = (uint8_t)(raw[i / 2] | v); + } + std::memcpy(pmk, raw, 32); + secure_wipe(raw, sizeof raw); + return true; + } + if (n < 8 || n > 63) return false; + return crypto.pbkdf2_sha1(passphrase, (const uint8_t*)ssid.data(), + ssid.size(), 4096, pmk, 32); +} + +/* PTK = PRF-384(PMK, "Pairwise key expansion", + * min(AA,SPA) || max(AA,SPA) || min(ANonce,SNonce) || max(...)) + * + * THE SORTING IS NOT DECORATION. Both ends derive the same key only because + * each orders the pair the same way, and each end knows the addresses and + * nonces by different names - the authenticator's "own" is the supplicant's + * "peer". Sorting removes the asymmetry. Getting it wrong produces a PTK that + * works against nothing, and the only symptom is a MIC failure. + * + * Layout: KCK[0:16] KEK[16:32] TK[32:48]. + */ +inline bool derive_ptk(CryptoOps& crypto, const uint8_t pmk[32], + const uint8_t aa[6], const uint8_t spa[6], + const uint8_t anonce[32], const uint8_t snonce[32], + uint8_t ptk[48]) { + uint8_t b[76]; + const uint8_t* amin = std::memcmp(aa, spa, 6) < 0 ? aa : spa; + const uint8_t* amax = std::memcmp(aa, spa, 6) < 0 ? spa : aa; + const uint8_t* nmin = std::memcmp(anonce, snonce, 32) < 0 ? anonce : snonce; + const uint8_t* nmax = std::memcmp(anonce, snonce, 32) < 0 ? snonce : anonce; + + std::memcpy(b, amin, 6); + std::memcpy(b + 6, amax, 6); + std::memcpy(b + 12, nmin, 32); + std::memcpy(b + 44, nmax, 32); + return prf_sha1(crypto, pmk, 32, "Pairwise key expansion", b, sizeof b, ptk, + 48); +} + +/* A GTK lifted out of a key-data KDE. */ +struct GtkKde { + uint8_t key_id = 0; + bool tx = false; + uint8_t gtk[32] = {0}; + size_t gtk_len = 0; +}; + +/* THREE OUTCOMES, NOT TWO. "there is no GTK KDE here" and "a KDE in here is + * truncated or claims an impossible key length" are completely different + * facts: the first can be legitimate, the second is hostile input. A single + * `false` for both invites two callers to read it in opposite ways - one + * carrying on and completing the handshake, the other refusing the frame - + * and the first leaves a station `Connected` and keyed with no group key, no + * counter moved and nothing to diagnose from. */ +enum class KdeResult : uint8_t { + Found, + Absent, /* well-formed key data with no GTK KDE in it */ + Malformed, /* a KDE that runs past the buffer or declares a bad length */ +}; + +/* THE KEY-DATA WALK, the one way this module reads unwrapped key data: + * find_gtk_kde and Supplicant's RSN-element search both use it, so the two + * cannot disagree about where an element starts. + * + * Key data is a sequence of elements - ID, length, body. The 802.11i padding + * is 0xDD followed by zeros (802.11-2016 12.7.2), and it walks as elements + * too: the 0xDD reads as an empty element, each following pair of zeros as an + * empty element with ID 0, and a single trailing byte ends the walk. A lone + * 0x00 is NOT skipped as padding - ID 0 is a real element ID. Every step is + * bounds-checked: this region comes out of an AES unwrap of attacker-supplied + * bytes. + * + * `visit(id, body, body_len)` is called for each element and returns false to + * stop early. Returns false when an element runs past the end of the buffer, + * true otherwise. */ +template +inline bool walk_key_data(const uint8_t* kd, size_t len, Visit&& visit) { + size_t i = 0; + + if (!kd) return len == 0; + while (i + 2 <= len) { + const uint8_t id = kd[i]; + const size_t l = kd[i + 1]; + + if (i + 2 + l > len) return false; /* truncated: stop, do not guess */ + if (!visit(id, kd + i + 2, l)) return true; + i += 2 + l; + } + return true; +} + +/* The GTK KDE (00-0F-AC type 1) in unwrapped key data, read with + * walk_key_data. A KDE is `0xDD len 00 0F AC type` followed by its body; + * every other element is stepped over. + * + * Returns Absent when the key data is well formed and simply carries no GTK + * KDE, and Malformed when something in it does not add up. The caller decides + * what Absent means for the message it arrived in. + * + * THE WHOLE FIELD IS WALKED, not just up to the GTK: key data that goes on to + * a truncated element does not parse, and a frame whose key data does not + * parse installs nothing, even with a verified MIC. Any truncation anywhere is + * Malformed, and so is a second GTK KDE: two group keys in one message is not + * something to pick between. `out` is only written for Found, and wiped + * otherwise. + */ +inline KdeResult find_gtk_kde(const uint8_t* kd, size_t len, GtkKde* out) { + bool found = false, bad = false; + + if (!kd || !out) return KdeResult::Malformed; + *out = GtkKde{}; + const bool whole = walk_key_data( + kd, len, [&](uint8_t id, const uint8_t* b, size_t l) { + if (!(id == 0xdd && l >= 4 && b[0] == 0x00 && b[1] == 0x0f && + b[2] == 0xac && b[3] == 0x01)) + return true; /* not a GTK KDE: step over */ + /* The KDE length counts OUI(3) + data type(1) + data. The GTK KDE's + * data is a KeyID/Tx octet, a reserved octet, then the key, so a + * 16-byte GTK gives a length of 22 - and subtracting 6 rather than 4 + * here would make every real KDE look two bytes short and silently + * truncate the key. */ + const size_t body = l - 4; /* keyid/tx + reserved + key */ + if (found || body < 2 + 16 || body > 2 + 32) { + bad = true; /* a second KDE, or a bad length */ + return false; + } + out->key_id = (uint8_t)(b[4] & 0x03); + out->tx = (b[4] & 0x04) != 0; + out->gtk_len = body - 2; + std::memcpy(out->gtk, b + 6, out->gtk_len); + found = true; + return true; + }); + if (!whole || bad) { + secure_wipe(out, sizeof *out); + return KdeResult::Malformed; + } + return found ? KdeResult::Found : KdeResult::Absent; +} + +/* Overwrite key material so it does not outlive the object holding it. + * + * Through a volatile pointer, because a compiler is entitled to delete a + * memset whose result is never read - which is every memset in a destructor. + * Keys are zeroized when their owner lets them go; the Supplicant and + * StationSm use this for every key they hold. + */ +inline void secure_wipe(void* p, size_t n) { + volatile uint8_t* v = static_cast(p); + + while (n--) *v++ = 0; +} + +} // namespace sta +} // namespace devourer + +#endif /* DEVOURER_STA_EAPOL_H */ diff --git a/src/sta/StationSm.h b/src/sta/StationSm.h new file mode 100644 index 00000000..91ee0910 --- /dev/null +++ b/src/sta/StationSm.h @@ -0,0 +1,821 @@ +/* StationSm — the association state machine: scan result in, connected out. + * + * Authenticate, associate, run the four-way, and notice when any of it stops + * working. It owns a Supplicant and drives it; it does not reimplement any of + * the key exchange. + * + * NO CLOCK AND NO RADIO. Time arrives as a `now_ms` argument and frames arrive + * through on_rx(); frames to send come out of pop_tx(). That is what makes the + * retransmission and timeout behaviour testable at all: logic that reads a + * steady_clock itself can only be observed on a bench, never asserted. + * + * WHAT IT IS NOT. Not a scanner: it has no notion of channels or dwell times, + * because those need a radio. Feed it beacons through a BssTable and hand it + * the entry to join. + */ +#ifndef DEVOURER_STA_STATION_SM_H +#define DEVOURER_STA_STATION_SM_H + +#include +#include +#include +#include +#include + +#include "sta/BssTable.h" +#include "sta/CryptoOps.h" +#include "sta/Dot11.h" +#include "sta/Eapol.h" +#include "sta/Supplicant.h" + +namespace devourer { +namespace sta { + +class StationSm { + public: + enum class State : uint8_t { + Idle, /* nothing in progress */ + Authenticating, /* auth request sent */ + Associating, /* association request sent */ + FourWay, /* associated; the key exchange is running */ + Connected, /* keyed, and the data plane may run */ + Failed, /* gave up, or was thrown off; see fail_reason() */ + }; + + /* Why the machine is in Failed. `status` carries the 802.11 status code of + * a refused authentication or association, or the reason code of a deauth, + * so "it did not connect" always comes with the number the AP gave. */ + enum class Failure : uint8_t { + None, + AuthTimeout, + AuthRefused, + AssocTimeout, + AssocRefused, + Deauthenticated, + HandshakeTimeout, + BeaconLost, /* no frame from the AP - beacon or data - for the window */ + NoPmk, + NotConfigured, + NoChannel, /* the BSS entry carries no channel: the band is unknown */ + NotInfrastructure, /* the BSS is an IBSS (or claims no ESS): no AP */ + SsidMismatch, /* the entry is not the configured network */ + }; + + /* WHICH KIND OF BSS THIS STATION IS CONFIGURED FOR. + * + * Not a hypothetical second mode: without an open path a station that never + * reaches Connected cannot say whether authentication/association or the + * key exchange is what failed, because on a WPA2 BSS the two halves come up + * together or not at all. The AP side of this tree has had the same ladder + * since the beginning - tests/ap_responder.cpp is the open AP and + * tests/ap_wpa2.cpp the protected one - and the station half did not. + * + * Open costs this file almost nothing: it skips the four-way and takes no + * CryptoOps, which is the same property the open AP harness has (it links + * no crypto library at all). */ + enum class Security : uint8_t { + Open, + Wpa2Psk, + }; + + /* Three transmissions of each management frame, 300 ms apart. An AP that + * has not answered three probes in a second is not going to. */ + static constexpr int kMaxTries = 3; + static constexpr uint32_t kMgmtTimeoutMs = 300; + /* The authenticator drives the four-way and retransmits it; this side only + * answers, so its timeout is a give-up, not a retry schedule. */ + static constexpr uint32_t kHandshakeTimeoutMs = 3000; + /* The AP-liveness window. "Beacon loss" is the conventional name; what it + * measures is ANY frame from the AP: a beacon with its fixed body, a data + * frame from the BSSID addressed to this station, or a decrypted MSDU + * handed to on_decrypted_msdu. Beacons alone would declare a link dead + * while traffic flows whenever a receiver under load drops beacons first. + * + * Ten beacon intervals at the usual 100 TU: long enough that a few lost + * frames mean nothing, short enough that a station does not sit Connected + * to an AP that has been switched off. This is the FLOOR: join() widens it + * to ten of the BSS's own advertised intervals, so an AP beaconing at + * 500-1000 TU is not declared lost after one or two late beacons - + * beacon_loss_ms() is the window actually in force. */ + static constexpr uint32_t kBeaconLossMs = 1024; + /* THE CEILING on the beacon interval that widens that window. The interval + * comes from a beacon, which anyone can forge: 65535 TU would stretch the + * window to about 11 minutes, during which a vanished AP goes unnoticed. + * 1000 TU (1.024 s) is ten times the usual 100 TU and covers every + * interval an AP uses in practice, so the window is at most 10240 ms. */ + static constexpr uint32_t kMaxBeaconIntervalTu = 1000; + /* THE TRANSMIT QUEUE IS BOUNDED. Three authentication retries plus one + * in-flight EAPOL reply is the most this machine legitimately owes, and + * every frame in here is produced in response to a received one - so an + * unbounded queue is an unbounded allocation an attacker controls. */ + static constexpr size_t kMaxTxQueue = 8; + + ~StationSm() { secure_wipe(pmk_, sizeof pmk_); } + + bool configure(CryptoOps& crypto, const std::string& ssid, const char* psk, + const uint8_t own[6]) { + crypto_ = &crypto; + ssid_ = ssid; + std::memcpy(own_, own, 6); + /* PBKDF2 once, here, rather than per association attempt: it is 4096 + * HMAC-SHA1 iterations and the answer only depends on the passphrase and + * the SSID, neither of which changes between retries. + * + * THE SNONCE IS NOT HERE. The PMK and the SNonce have opposite + * lifetimes: the PMK is fixed for the network and the SNonce must be + * fresh for every association. Taken here, a caller doing the obvious + * thing - configure once, join repeatedly - would reuse one nonce across + * every attempt and every roam, making the PTK a function of the ANonce + * alone. It is an argument to join(). + * + * THE OLD PMK GOES FIRST. pmk_from_psk writes nothing when it refuses a + * passphrase, so without this wipe a reconfigure with an invalid one would + * leave the PREVIOUS network's PMK resident. Wiped before the derivation, + * and again after a failed one (a CryptoOps may have written part of an + * answer). */ + secure_wipe(pmk_, sizeof pmk_); + have_pmk_ = pmk_from_psk(crypto, psk, ssid, pmk_); + if (!have_pmk_) secure_wipe(pmk_, sizeof pmk_); + security_ = Security::Wpa2Psk; + drop_association_keys(); + /* CONFIGURED EVEN WHEN THE PMK DERIVATION FAILED, on purpose. Gating this + * on have_pmk_ would make the NoPmk branch in join() unreachable - the + * caller would get NotConfigured, which names the wrong thing - and a + * caller that ignores this return value is exactly the one that needs the + * accurate diagnosis. */ + configured_ = true; + return have_pmk_; + } + + /* The same station on an OPEN BSS: no PSK, no PMK, no four-way, and no + * CryptoOps - which is why this overload takes none. See the Security enum + * for why an open path is worth having at all. + * + * It is a separate function rather than a null `psk` because a null + * passphrase reads like a caller's mistake, and because the WPA2 form needs + * a CryptoOps this one has no use for. */ + bool configure_open(const std::string& ssid, const uint8_t own[6]) { + crypto_ = nullptr; + security_ = Security::Open; + ssid_ = ssid; + std::memcpy(own_, own, 6); + /* A station reconfigured from WPA2 to open must not keep the old PMK + * sitting in memory for the rest of the process's life - and that means + * the Supplicant's copy too, with the PTK and GTK it derived, not only + * this object's. drop_association_keys() does that half. The wipe is + * observable through pmk() and pinned by the reconfigure cell in + * tests/station_sm_selftest.cpp. */ + secure_wipe(pmk_, sizeof pmk_); + have_pmk_ = false; + drop_association_keys(); + configured_ = true; + return true; + } + + /* A reconfigured station is associated to nothing: the Supplicant forgets + * its PMK, PTK and GTK, and a live association drops back to Idle rather + * than claiming keyed() under a configuration that no longer matches it. + * A Failed state keeps its reason for the caller to read. */ + void drop_association_keys() { + sup_.forget(); + /* And whatever was queued for that association - an auth, assoc or + * EAPOL frame built under the old configuration must not air after the + * machine has let the association go (join() clears it for the same + * reason). */ + tx_.clear(); + aid_ = 0; + authenticated_ = false; + if (state_ != State::Failed) state_ = State::Idle; + } + + /* Begin an association with this BSS. + * + * `snonce` must be UNPREDICTABLE AND FRESH FOR THIS ATTEMPT - see + * Supplicant::start, which explains at length why this library takes it + * rather than inventing it. */ + bool join(const BssEntry& bss, const uint8_t snonce[32], uint32_t now_ms) { + if (!configured_) { fail(Failure::NotConfigured, 0); return false; } + /* The entry must be the network this station is configured for: + * BssTable::select matches on the SSID, and a hand-picked entry is held to + * the same rule - the PMK is derived from this SSID, so a different one + * could never complete the four-way. */ + if (bss.info.ssid != ssid_) { fail(Failure::SsidMismatch, 0); return false; } + /* The channel picks the band, and the band picks the association + * request's rate set: channel 0 would send 802.11b rates to a 5 GHz AP, + * which refuses them. BssTable::select never offers such an entry; this + * catches a hand-picked one. */ + if (!channel_valid(bss.info.channel)) { + fail(Failure::NoChannel, 0); + return false; + } + /* Authentication and association are an exchange with an AP; an IBSS + * has none. BssTable::select never offers one either. */ + if (!bss_is_infrastructure(bss.info)) { + fail(Failure::NotInfrastructure, 0); + return false; + } + /* Refuse a BSS this station cannot finish with, rather than authenticating + * and discovering it at the four-way. BssTable::select already filters on + * this; join() is also reachable with a hand-picked entry. */ + if (security_ == Security::Wpa2Psk) { + if (!have_pmk_) { fail(Failure::NoPmk, 0); return false; } + if (!bss_is_wpa2_psk(bss.info)) { + fail(Failure::AssocRefused, 0); + return false; + } + /* Kept for the four-way: message 3 must carry the same RSN element + * (Supplicant::start, the downgrade check). */ + ap_rsn_ = bss.info.rsn; + /* The SNonce is only read on this path, so it is only required on this + * path - but a WPA2 join without one would start the supplicant with a + * nonce of whatever was in the buffer, which for a caller that + * configures once and joins repeatedly is the PREVIOUS association's. + * See the comment in configure() about why it is an argument at all. */ + if (!snonce) { fail(Failure::NotConfigured, 0); return false; } + } else if (bss.info.privacy) { + /* An open station cannot carry traffic on a BSS that encrypts it. The + * Privacy bit is set by WEP, WPA and RSN alike, so this one test covers + * every protected BSS without parsing any of them. Refusing here rather + * than at the data plane is the difference between "no candidate" and + * an association that succeeds and then passes nothing. */ + fail(Failure::AssocRefused, 0); + return false; + } + + /* THE QUEUE IS CLEARED. Without this, frames still queued for the BSS we + * gave up on are transmitted at the one we just joined - addressed to the + * old BSSID, on the new channel, after the radio has retuned. A test that + * drains the queue between steps cannot see it; + * test_join_clears_the_transmit_queue does not drain. */ + tx_.clear(); + /* A join on a live association says goodbye to the OLD AP first, for the + * reason leave() does: an AP that accepted our authentication holds state + * for this station until it is told otherwise or times it out. */ + if (authenticated_) { + std::vector m = build_deauth(own_, bssid_, 3); + assign_seq(m, seq_.next()); + queue(std::move(m)); + } + std::memcpy(bssid_, bss.info.bssid, 6); + if (security_ == Security::Wpa2Psk) std::memcpy(snonce_, snonce, 32); + channel_ = bss.info.channel; + { + /* Ten of THIS BSS's beacon intervals (1 TU = 1.024 ms), never less + * than the kBeaconLossMs floor, and with the interval capped at + * kMaxBeaconIntervalTu: it comes off the air. */ + const uint32_t tu = bss.info.beacon_interval_tu > kMaxBeaconIntervalTu + ? kMaxBeaconIntervalTu + : bss.info.beacon_interval_tu; + const uint32_t ten = tu * 1024u * 10u / 1000u; + beacon_loss_ms_ = ten > kBeaconLossMs ? ten : kBeaconLossMs; + } + aid_ = 0; + fail_ = Failure::None; + status_ = 0; + sup_.forget(); + authenticated_ = false; + state_ = State::Authenticating; + tries_ = 0; + last_heard_ms_ = now_ms; + send_auth(now_ms); + return true; + } + + /* Leave cleanly: tell the AP, drop the keys, and go back to Idle. + * + * An association this side simply abandons stays alive at the AP until it + * times the station out, holding an AID and, on this project's own AP, a + * slot in a seven-entry table. */ + void leave(uint16_t reason = 3) { + if (state_ == State::Idle) return; + /* Whatever was still queued for this association - an authentication or + * association retry, an EAPOL reply - must not air after the station has + * said goodbye, and must not air BEFORE the goodbye either. */ + tx_.clear(); + /* A deauthentication goes out only if the AP holds state for this + * station - an authentication it accepted, whether or not an association + * followed. After an AuthTimeout there is none, and a deauth would be + * addressed to an AP that never heard of us. */ + if (authenticated_) { + std::vector m = build_deauth(own_, bssid_, reason); + assign_seq(m, seq_.next()); + queue(std::move(m)); + } + sup_.forget(); + state_ = State::Idle; + fail_ = Failure::None; + aid_ = 0; + authenticated_ = false; + } + + /* One received frame. `len` is the true MPDU length with no FCS. */ + void on_rx(const uint8_t* frame, size_t len, uint32_t now_ms) { + if (!frame || len < 24) return; + if (state_ == State::Idle || state_ == State::Failed) return; + + const uint8_t fc0 = frame[0], fc1 = frame[1]; + const uint8_t* a1 = frame + 4; + const uint8_t* a2 = frame + 10; + + /* EVERYTHING must come from the BSS we are talking to and be addressed to + * this station (or broadcast). Without the addr2 check, any frame from any + * AP on the channel drives this machine. + * + * THE DROPS ARE COUNTED. On real hardware this is the only address filter + * in the system - the MT7612U RX path runs promiscuous - so most of a busy + * channel lands here, and a station that connects to nothing has to be + * able to say whether it heard its AP at all. */ + if (std::memcmp(a2, bssid_, 6) != 0) { rx_not_our_bss++; return; } + const bool to_us = std::memcmp(a1, own_, 6) == 0; + const bool bcast = (a1[0] & 0x01) != 0; + if (!to_us && !bcast) { rx_not_for_us++; return; } + + /* A beacon from our own BSS is a liveness signal (so is data - below). + * Counted before the switch because it matters in every state. */ + if (fc0 == kFcBeacon || fc0 == kFcProbeResp) { + /* Only a frame that carries the 12-byte fixed body (timestamp, + * interval, capability) counts: a bare 24-byte header from the BSSID + * is not a beacon, and must not hold off beacon-loss supervision. */ + if (len < 24 + 12) { rx_malformed++; return; } + beacons_rx++; + last_heard_ms_ = now_ms; + return; + } + + switch (fc0) { + case kFcAuth: + if (to_us) on_auth(frame, len, now_ms); + return; + /* Association Response only. This station sends an Association + * Request, never a Reassociation Request, so a Reassociation Response + * answers nothing it asked - ignored, not taken as success. */ + case kFcAssocResp: + if (to_us) on_assoc_resp(frame, len, now_ms); + return; + case kFcReassocResp: + rx_ignored++; + return; + case kFcDeauth: + case kFcDisassoc: { + uint16_t reason = 0; + + /* ACCEPTED UNAUTHENTICATED, and that is a known cost rather than an + * oversight: 802.11w is not implemented here, so there is no way to + * tell a real deauthentication from a forged one, and a station that + * ignored them would stay associated to an AP that has forgotten it. + * This is the accepted price of having no MFP. + * + * A frame too short to carry its reason code is not a + * deauthentication at all: it is counted malformed and changes + * nothing, rather than ending the association "with reason 0". */ + if (!parse_reason(frame, len, &reason)) { rx_malformed++; return; } + fail(Failure::Deauthenticated, reason); + authenticated_ = false; /* the AP has already let us go */ + return; + } + default: + break; + } + + /* Data frames: the only one this machine cares about is EAPOL. Anything + * else is somebody's traffic, not a protocol error, so it is counted + * separately from a frame that was addressed wrongly. */ + if (fc0 != kFcData && !is_qos_data(fc0)) { rx_ignored++; return; } + if (!(fc1 & kFcFromDs) || (fc1 & kFcToDs)) { rx_ignored++; return; } + /* AP LIVENESS: a from-DS data frame from the BSSID, addressed to this + * station, with its whole header present, is proof the AP is there - + * protected or not. Under load a receiver drops beacons first, and a link + * carrying traffic must not be declared lost for want of them. */ + if (to_us && len >= data_hdr_len(fc0, fc1)) last_heard_ms_ = now_ms; + /* A "no data" subtype (Null, QoS Null: subtype bit 2, fc0 & 0x40) is a + * well-formed frame with no body by definition - ignored, not malformed, + * and it has already counted as liveness above. */ + if (fc0 & 0x40) { rx_ignored++; return; } + /* The FOUR-WAY is never protected - the keys it carries are what + * protection would need - so a protected data frame is not one of its + * messages and this machine cannot read it anyway: it holds no cipher. + * + * THE GROUP KEY HANDSHAKE IS A DIFFERENT MATTER, and this refusal used + * to be the end of the story for it. It runs AFTER the PTK is installed + * and is therefore protected like any other data frame. The caller + * decrypts and hands the plaintext back through on_decrypted_msdu(). + * + * COUNTED SEPARATELY FROM rx_ignored, because on a working link this is + * EVERY DATA FRAME, and lumping it in would bury the one counter set that + * answers "why did nothing associate": 75 frames of ordinary traffic + * would read as ignored=75, i.e. as 75 protocol errors. */ + if (fc1 & kFcProtected) { rx_protected++; return; } + /* FRAGMENTS AND A-MSDUs ARE NOT MSDUs. Nothing here reassembles, so the + * bytes at the LLC offset are a piece of a frame, not a frame - and an + * A-MSDU's are a subframe header. Feeding either to the EAPOL parser + * asks it to interpret the wrong bytes. + * + * A caller's data plane should refuse both as well, and the two receive + * layers disagreeing about it is the kind of gap a + * later reader closes in only one place. More Fragments is CLEAR on the + * LAST fragment, so the fragment number has to be tested too. */ + if ((fc1 & kFcMoreFrag) || (frame[22] & 0x0f)) { rx_malformed++; return; } + const size_t hlen = data_hdr_len(fc0, fc1); + if (len < hlen + kLlcSnapLen) { rx_malformed++; return; } + /* The A-MSDU Present bit, in the QoS Control field - which is at a FIXED + * offset, with HT Control after it, so it is 24 and not hlen - 2. A + * 4-address frame would put it at 30, and cannot reach here: the + * FromDS/ToDS test above accepts only from-the-DS frames. */ + if (is_qos_data(fc0) && (frame[24] & 0x80)) { rx_malformed++; return; } + const uint8_t* llc = frame + hlen; + if (!(llc[0] == 0xaa && llc[1] == 0xaa && llc[2] == 0x03)) { + rx_ignored++; + return; + } + if (!(llc[6] == 0x88 && llc[7] == 0x8e)) { rx_ignored++; return; } + /* A GROUP-ADDRESSED EAPOL-Key is part of no handshake with this station: + * every message of the four-way and the group key handshake is unicast + * to the supplicant. The decrypted path (on_decrypted_msdu's caller) + * already refuses one; this layer refuses it too, so the two receive + * paths agree and a broadcast forgery cannot reach the supplicant. */ + if (!to_us) { rx_ignored++; return; } + /* ONLY EAPOL-KEY REACHES THE SUPPLICANT. EAP, EAPOL-Start and Logoff + * share the ethertype; the key parser would refuse them as malformed, + * which counts a protocol this station does not speak as a broken + * handshake. Not a Key packet: ignored. No packet-type octet at all: + * malformed. (on_decrypted_msdu applies the same test and hands a + * non-Key packet back to its caller instead.) */ + const uint8_t* eapol = llc + kLlcSnapLen; + const size_t eapol_len = len - hlen - kLlcSnapLen; + if (eapol_len < 2) { rx_malformed++; return; } + if (!eapol_is_key(eapol, eapol_len)) { rx_ignored++; return; } + if (!cleartext_eapol_allowed(eapol, eapol_len)) { rx_ignored++; return; } + on_eapol(eapol, eapol_len, now_ms); + } + + /* One DECRYPTED MSDU - LLC/SNAP followed by its payload - that arrived + * from our AP addressed to this station. Returns true when it was an + * EAPOL-Key frame and has been consumed; the caller gives anything else to + * the host. + * + * WHY THIS EXISTS. on_rx() refuses every protected data frame, which is + * right for the four-way (it runs before there is a key) and WRONG for the + * GROUP KEY HANDSHAKE, which runs after the PTK is installed and is + * protected like any other data frame. Without this path hostapd logs + * + * WPA: pairwise key handshake completed (RSN) + * WPA: group key handshake failed (RSN) after 4 tries + * AP-STA-DISCONNECTED + * + * A station that cannot answer a rekey is thrown off by every AP that + * performs one, which is most of them - and the link looks healthy right up + * until it ends. (test_group_rekey_through_the_decrypted_path) + * + * `out_reply` takes the EAPOL-Key body to send back. It is a BODY and not + * a frame because the answer has to be encrypted, and the cipher belongs + * to the caller - see eapol_reply(). + * + * The caller has already verified the frame's MIC, which is a stronger + * statement than "the Protected bit was set", so nothing is given up by + * taking the plaintext. */ + bool on_decrypted_msdu(const uint8_t* msdu, size_t len, uint32_t now_ms, + std::vector* out_reply) { + if (!msdu || len == 0) return false; + /* A decrypted MSDU from the AP is the strongest liveness signal there is + * - its MIC verified - whatever it carries. */ + last_heard_ms_ = now_ms; + if (!is_ethertype_snap(msdu, len)) return false; + if (!(msdu[6] == 0x88 && msdu[7] == 0x8e)) return false; + /* Claimed only if it is an EAPOL-KEY packet. An EAP packet, an + * EAPOL-Start or a Logoff is returned unconsumed so the caller can deliver + * it, rather than vanishing into a key parser that refuses it. A Key + * packet is claimed even when it is malformed - it was ours, and it was + * bad. */ + if (!eapol_is_key(msdu + kLlcSnapLen, len - kLlcSnapLen)) return false; + bool progressed = false; + std::vector reply = + eapol_reply(msdu + kLlcSnapLen, len - kLlcSnapLen, now_ms, &progressed); + /* Handed to the caller, who sends it: counted as sent here, because this + * machine never sees it again. */ + if (!reply.empty()) { + eapol_tx++; + if (progressed) last_tx_ms_ = now_ms; + promote_if_keyed(now_ms); + } + if (out_reply) *out_reply = std::move(reply); + return true; + } + + /* Drive timeouts and retransmissions. Call it as often as convenient; it + * does nothing until a deadline has passed. */ + void tick(uint32_t now_ms) { + const uint32_t since = (uint32_t)(now_ms - last_tx_ms_); + + switch (state_) { + case State::Authenticating: + if (since < kMgmtTimeoutMs) return; + if (tries_ >= kMaxTries) { fail(Failure::AuthTimeout, 0); return; } + send_auth(now_ms); + return; + case State::Associating: + if (since < kMgmtTimeoutMs) return; + if (tries_ >= kMaxTries) { fail(Failure::AssocTimeout, 0); return; } + send_assoc(now_ms); + return; + case State::FourWay: + /* No retransmission: the authenticator owns that schedule. This is + * only the give-up, and it is measured from the last thing that + * actually moved the handshake forward. */ + if (since >= kHandshakeTimeoutMs) fail(Failure::HandshakeTimeout, 0); + return; + case State::Connected: + /* AP-LIVENESS SUPERVISION ("beacon loss": see kBeaconLossMs for what + * counts as hearing the AP). Without it Connected has no exit but a + * deauth, and an AP that is switched off leaves the station reporting + * a link that does not exist - the caller sees keyed() forever and + * has no hook to notice. */ + if ((uint32_t)(now_ms - last_heard_ms_) >= beacon_loss_ms_) + fail(Failure::BeaconLost, 0); + return; + default: + return; + } + } + + /* Take one frame to transmit, oldest first. Returns false when empty, and + * for a null `out`: a frame taken off the queue with nowhere to put it + * would be lost while the caller is told it was delivered. */ + bool pop_tx(std::vector* out) { + if (!out || tx_.empty()) return false; + *out = std::move(tx_.front()); + tx_.erase(tx_.begin()); + return true; + } + + size_t pending_tx() const { return tx_.size(); } + /* The Connected-state beacon-loss window in force - see kBeaconLossMs. */ + uint32_t beacon_loss_ms() const { return beacon_loss_ms_; } + static constexpr size_t tx_capacity() { return kMaxTxQueue; } + State state() const { return state_; } + Failure fail_reason() const { return fail_; } + uint16_t status() const { return status_; } + uint16_t aid() const { return aid_; } + uint8_t channel() const { return channel_; } + const uint8_t* bssid() const { return bssid_; } + const Supplicant& supplicant() const { return sup_; } + /* The PMK this station derived, 32 bytes, all zero when it holds none - + * like supplicant().ptk(), exposed so a caller or a test can confirm key + * material is gone rather than take it on trust. */ + const uint8_t* pmk() const { return pmk_; } + bool has_pmk() const { return have_pmk_; } + bool keyed() const { return state_ == State::Connected && sup_.ptk_valid(); } + Security security() const { return security_; } + /* Associated and able to carry data. On a WPA2 BSS that is keyed(); on an + * open one there is no key, so a data plane gated on keyed() would never + * transmit at all. This is the predicate a caller wants. */ + bool connected() const { + return state_ == State::Connected && + (security_ == Security::Open || sup_.ptk_valid()); + } + + uint32_t auth_tx = 0; + uint32_t assoc_tx = 0; + uint32_t eapol_tx = 0; + uint32_t eapol_rx = 0; + uint32_t beacons_rx = 0; + uint32_t rx_not_our_bss = 0; + uint32_t rx_not_for_us = 0; + uint32_t rx_ignored = 0; + /* Protected data frames, which this machine cannot read and the caller is + * expected to decrypt. Ordinary traffic on a keyed link, NOT an error. */ + uint32_t rx_protected = 0; + uint32_t rx_malformed = 0; + uint32_t tx_dropped = 0; + + private: + /* WHAT AN UNPROTECTED EAPOL-KEY FRAME MAY BE. The clear carries the + * four-way, which runs before there is a key; once keys exist, the AP + * protects its EAPOL frames and they arrive through on_decrypted_msdu. + * + * - A group-key message (not pairwise) is never taken from the clear: the + * group handshake runs only after the PTK is installed, protected. + * - Before the PTK is installed, pairwise messages 1 and 3 are. + * - After it, one pairwise message only: a retransmission of the message 3 + * of the handshake already installed (same ANonce). The AP installs its + * own PTK only when our message 4 arrives, so if that message 4 is lost + * it retransmits message 3 in the clear; ignoring it would leave the AP + * to give up and deauthenticate a working station. The supplicant + * answers it and never reinstalls. A PTK rekey is protected, so a + * cleartext message 1, or a message 3 with a new ANonce, is ignored. + * + * A frame that does not parse is let through, so the supplicant counts it + * as malformed. */ + bool cleartext_eapol_allowed(const uint8_t* eapol, size_t len) const { + EapolKey k; + if (!parse_eapol_key(eapol, len, &k)) return true; + if (!k.pairwise()) return false; + if (!sup_.ptk_valid()) return true; + return sup_.is_installed_msg3(k); + } + + /* One place that enforces the bound, so no future sender can forget it. + * Returns false when the frame was dropped, so a caller whose bookkeeping + * assumes the frame went out (a deadline, a counter) can tell. */ + bool queue(std::vector m) { + if (tx_.size() >= kMaxTxQueue) { tx_dropped++; return false; } + tx_.push_back(std::move(m)); + return true; + } + + /* A try, its deadline and its counter advance only for a frame that was + * actually queued, as on the EAPOL path. join() clears the queue and adds + * at most one deauthentication, and nothing else is queued while + * authenticating or associating, so kMaxTries of each request always fit: + * a management request is never the frame queue() drops, and the + * static_assert keeps that true. */ + static_assert((size_t)(2 * kMaxTries + 1) <= kMaxTxQueue, + "a deauth plus every auth and assoc retry must fit the queue"); + void send_auth(uint32_t now_ms) { + std::vector m = build_auth_req(own_, bssid_); + assign_seq(m, seq_.next()); + if (!queue(std::move(m))) return; + last_tx_ms_ = now_ms; + tries_++; + auth_tx++; + } + + void send_assoc(uint32_t now_ms) { + /* The band comes from the joined entry's channel - the beacon's DS + * Parameter Set, or the channel it was received on when the beacon has + * none (BssTable::observe) - because the rate set differs: a 5 GHz + * association request carrying 802.11b rates is refused. join() refuses + * an entry with no channel, so channel_ is never 0 here. */ + std::vector m = + build_assoc_req(own_, bssid_, ssid_, + /*rsn=*/security_ == Security::Wpa2Psk, + /*five_ghz=*/channel_ > 14); + /* build_assoc_req returns an empty vector for an SSID it cannot encode. + * Sending a truncated association request would be worse than failing. */ + if (m.empty()) { fail(Failure::AssocRefused, 0); return; } + assign_seq(m, seq_.next()); + if (!queue(std::move(m))) return; + last_tx_ms_ = now_ms; + tries_++; + assoc_tx++; + } + + void on_auth(const uint8_t* frame, size_t len, uint32_t now_ms) { + AuthFields a; + + if (state_ != State::Authenticating) return; + /* Too short for its fixed fields: malformed, counted like a short deauth. */ + if (!parse_auth(frame, len, &a)) { rx_malformed++; return; } + /* Open System only. A Shared Key response is a four-frame exchange this + * does not implement, and treating its sequence 2 as success would send an + * association request into a state the AP is not in. */ + if (a.algorithm != 0) { fail(Failure::AuthRefused, a.status); return; } + if (a.seq != 2) return; + if (a.status != 0) { fail(Failure::AuthRefused, a.status); return; } + + authenticated_ = true; /* the AP now holds state for this station */ + state_ = State::Associating; + tries_ = 0; + send_assoc(now_ms); + } + + void on_assoc_resp(const uint8_t* frame, size_t len, uint32_t now_ms) { + AssocRespFields r; + + if (state_ != State::Associating) return; + if (!parse_assoc_resp(frame, len, &r)) { rx_malformed++; return; } + if (r.status != 0) { fail(Failure::AssocRefused, r.status); return; } + /* AID 0 is not a valid association identifier; an AP that answers success + * with one has not actually allocated anything. */ + if (r.aid == 0) { fail(Failure::AssocRefused, r.status); return; } + + aid_ = r.aid; + last_tx_ms_ = now_ms; + last_heard_ms_ = now_ms; + /* An open association is complete the moment the AP accepts it - there is + * no key exchange to wait for, so FourWay would be a state nothing could + * ever leave. */ + if (security_ == Security::Open) { + state_ = State::Connected; + return; + } + state_ = State::FourWay; + sup_.start(*crypto_, pmk_, own_, bssid_, snonce_, &ap_rsn_); + } + + /* One EAPOL-Key frame in, the EAPOL-Key body to send back out (or empty). + * + * THE REPLY IS A BODY AND NOT A FRAME because the two callers need + * different framing: the four-way is unprotected and this machine can + * build it, while a group rekey's answer must be encrypted and this + * machine holds no cipher. The caller with the keys does that half. + * + * `*progressed` says whether the handshake moved (a Reply, not a + * Retransmit). The CALLER moves the deadline, and only once the reply has + * actually left: moving it here, before a queue() that can drop the frame, + * would let the FourWay give-up keep sliding forward while nothing is + * sent. */ + std::vector eapol_reply(const uint8_t* eapol, size_t len, + uint32_t now_ms, bool* progressed) { + std::vector reply; + + *progressed = false; + + /* An EAPOL-Key frame on an open link is never ours: the supplicant was + * never started, so it holds no PMK and has nothing to verify a MIC + * against. Counted as ignored rather than dropped silently, because "the + * AP is trying to key us and we are configured open" is a configuration + * mismatch worth being able to see. */ + if (security_ != Security::Wpa2Psk) { rx_ignored++; return {}; } + if (state_ != State::FourWay && state_ != State::Connected) return {}; + eapol_rx++; + const Supplicant::Verdict v = sup_.on_eapol(eapol, len, &reply); + + /* The deadline moves only when the handshake moved. A retransmission we + * answered again is not progress, and letting it push the give-up out + * would let a stuck authenticator hold this state open forever. */ + *progressed = v == Supplicant::Verdict::Reply; + if (v != Supplicant::Verdict::Reply && + v != Supplicant::Verdict::Retransmit) + return {}; + return reply; + } + + void on_eapol(const uint8_t* eapol, size_t len, uint32_t now_ms) { + bool progressed = false; + const std::vector reply = + eapol_reply(eapol, len, now_ms, &progressed); + + if (reply.empty()) return; + std::vector m = data_hdr_to_ds(bssid_, own_, bssid_, + /*protect=*/false, seq_.next()); + append_llc_snap(m, 0x888e); + m.insert(m.end(), reply.begin(), reply.end()); + if (!queue(std::move(m))) return; /* dropped: nothing was sent */ + eapol_tx++; + if (progressed) last_tx_ms_ = now_ms; + promote_if_keyed(now_ms); + } + + /* FourWay -> Connected, ONLY once message 4 has actually left. The + * supplicant is Done the moment it accepts message 3, but a message 4 that + * queue() dropped never reached the AP, and a station that called itself + * Connected then would carry traffic the AP has not keyed. Staying in + * FourWay is safe: the AP retransmits message 3, the supplicant answers it + * again (Retransmit at an equal counter, a fresh Reply at a greater one, + * never a reinstall), and THAT reply being queued is what promotes. The + * give-up still bounds it, because a dropped reply does not move + * last_tx_ms_. */ + void promote_if_keyed(uint32_t now_ms) { + if (state_ == State::FourWay && sup_.state() == Supplicant::State::Done && + sup_.ptk_valid()) { + state_ = State::Connected; + last_heard_ms_ = now_ms; + } + } + + /* A failure ends the association, whatever caused it - a timeout, a + * refusal, or the peer's deauth/disassoc. So it drops what an ended + * association leaves behind: frames still queued for it (they must not air + * at an AP that has let us go) and the supplicant's per-association keys + * and cached replies, which would otherwise stay readable through + * supplicant() for as long as this object lives. The PMK and the + * configuration stay - a caller rejoins without reconfiguring - and so do + * the failure reason and status, which are what the caller reads next. */ + void fail(Failure why, uint16_t status) { + tx_.clear(); + sup_.forget(); + aid_ = 0; /* no association, so no association ID to report */ + state_ = State::Failed; + fail_ = why; + status_ = status; + } + + CryptoOps* crypto_ = nullptr; + Security security_ = Security::Wpa2Psk; + bool configured_ = false; + State state_ = State::Idle; + Failure fail_ = Failure::None; + std::string ssid_; + uint8_t own_[6] = {0}; + uint8_t bssid_[6] = {0}; + RsnInfo ap_rsn_{}; /* the joined BSS's advertised RSN element */ + uint8_t snonce_[32] = {0}; + uint8_t pmk_[32] = {0}; + bool have_pmk_ = false; + uint8_t channel_ = 0; + uint16_t aid_ = 0; + bool authenticated_ = false; /* the AP accepted our authentication */ + uint16_t status_ = 0; + int tries_ = 0; + uint32_t last_tx_ms_ = 0; + uint32_t last_heard_ms_ = 0; + uint32_t beacon_loss_ms_ = kBeaconLossMs; + SeqCounter seq_; + Supplicant sup_; + std::vector> tx_; +}; + +} // namespace sta +} // namespace devourer + +#endif /* DEVOURER_STA_STATION_SM_H */ diff --git a/src/sta/Supplicant.h b/src/sta/Supplicant.h new file mode 100644 index 00000000..a58bbe2b --- /dev/null +++ b/src/sta/Supplicant.h @@ -0,0 +1,728 @@ +/* Supplicant — the station half of the WPA2-PSK key exchange. + * + * The opposite role to the authenticator in tests/ap_wpa2.cpp, and written + * from the standard rather than by mirroring that code, so the two are + * independent implementations that a test can run against each other. + * + * Reactive and timer-free. The authenticator retransmits message 1 and + * message 3 (802.11-2016 12.7.6.4); a supplicant answers what arrives and + * never retransmits on its own, so there is no clock in here and no schedule + * to get wrong. + * + * WHAT IT REFUSES, and why each refusal is a counter rather than a silent + * drop - a handshake that does not complete has to say which rule stopped it, + * or the only diagnosis available is "it did not associate": + * + * - a MIC that does not verify mic_failures + * - a key replay counter that does not advance replays + * - a retransmission (equal counter): answered again, installs nothing + * retransmits + * - a message arriving in a state that cannot use it out_of_state + * - a message 3 whose RSN element differs from the + * one the AP advertised (a downgrade, 12.7.6.4) rsn_mismatches + * (counted under malformed too) + * - anything malformed, over-long, or a descriptor + * version whose MIC is a different algorithm malformed + * - a well-formed EAPOL-Key this role does not handle ignored + * - our own CryptoOps failing, which is not the + * frame's fault and must not read as an attack crypto_errors + * + * THE FOUR RULES THIS IS BUILT ON, each pinned by the cells named: + * + * 1. NOTHING IS INSTALLED BEFORE A MIC VERIFIES. Message 3 carries the GTK + * and confirms the PTK; accepting it without a verified MIC hands an + * attacker the ability to install key material. Nothing here touches the + * installed keys until a MIC verifies under a key derived from the PMK. + * (test_forged_mic_is_rejected) + * + * 2. AN EQUAL-COUNTER REPLAYED GROUP REKEY INSTALLS NOTHING. Replaying a + * captured group message 1 would reinstall an OLD GTK - which, with its + * own PN space reset, is keystream reuse across every group frame since. + * Only a STRICTLY GREATER counter installs anything; an equal one is + * treated as the AP's retransmission and answered with the cached reply. + * (test_group_rekey_replay_rejected) + * + * 3. MESSAGE 1 NEVER MOVES THE REPLAY COUNTER. It carries no MIC - anyone + * who can hear the BSSID can build one - so if it could advance the + * counter, one forged frame quoting 2^64-1 would refuse every genuine + * EAPOL-Key for the rest of the association, silently: the station would + * stay Connected and keyed with a dead rekey path. The counter advances + * ONLY where a MIC has verified. What an unauthenticated message 1 can + * still do is replace the candidate PTK of a handshake in flight - see + * on_msg1 for exactly what that costs. The INSTALLED handshake's ANonce + * and its message 4 are kept apart from the candidate for exactly that + * reason: a forged message 1 must not stop the AP's own retransmitted + * message 3 from being answered. (test_forged_msg1_cannot_poison_the_ + * counter, test_forged_msg1_does_not_orphan_msg3_retransmit) + * + * 4. NO KEY REINSTALLATION (KRACK, CVE-2017-13077/13078/13080). The AP + * retransmits message 3 whenever our message 4 is lost, at a STRICTLY + * GREATER counter (hostapd increments on every retransmission), so the + * replay gate rightly lets it through. It must be answered, and it must + * NOT reinstall: a caller that restarts its PN when the key generation + * moves would then reuse CCMP nonces under the unchanged TK. So a key + * that is already installed is never installed again - the PTK + * generation moves only when the PTK bytes change, and the GTK + * generation only when the key id or bytes change, as wpa_supplicant's + * "not reinstalling already in-use" rule does. + * (test_msg3_retransmit_does_not_reinstall, + * test_group1_same_gtk_does_not_reinstall) + */ +#ifndef DEVOURER_STA_SUPPLICANT_H +#define DEVOURER_STA_SUPPLICANT_H + +#include +#include +#include +#include + +#include "sta/CryptoOps.h" +#include "sta/Dot11.h" +#include "sta/Eapol.h" + +namespace devourer { +namespace sta { + +class Supplicant { + public: + enum class State : uint8_t { + Idle, /* not started */ + WaitMsg1, /* started; nothing derived yet */ + WaitMsg3, /* msg1 seen, candidate PTK derived, msg2 sent */ + Done, /* PTK and GTK installed, msg4 sent */ + }; + + /* What on_eapol() did with the frame. `Reply` means `out` holds an EAPOL + * frame body for the caller to wrap in a to-DS data frame and send. */ + enum class Verdict : uint8_t { + Ignored, /* a well-formed EAPOL-Key this role does not handle */ + Malformed, + CryptoError, /* our own CryptoOps failed; not the frame's fault */ + MicFailed, + Replayed, + Retransmit, /* equal counter, same message: `out` holds the same reply */ + OutOfState, + Reply, /* `out` holds msg2, msg4, or group msg2 */ + }; + + ~Supplicant() { forget(); } + + /* Begin a handshake. + * + * `snonce` IS SUPPLIED BY THE CALLER, and this is deliberate rather than + * lazy. `libdevourer` has no random-number dependency and CryptoOps offers + * none, so the alternative is a stub that looks like entropy and is not. + * Making it an argument puts the requirement where somebody has to read it: + * THE SNONCE MUST BE UNPREDICTABLE, AND FRESH PER ASSOCIATION. A constant, a + * counter or a timestamp makes the PTK derivable from the air by anyone who + * knows the PSK - which on a PSK network is every other station. + */ + /* `ap_rsn` is the RSN element the AP advertised in the Beacon or Probe + * Response this association was chosen from. When given, message 3's RSN + * element must match it (802.11-2016 12.7.6.4, the downgrade check): an + * attacker who forged the advertisement to steer the choice is caught by + * the MIC-protected copy. Compared field by field, not byte by byte - an AP + * may encode the same element differently in the two places, which is what + * wpa_supplicant tolerates too. Null skips the check; StationSm always + * passes it. */ + void start(CryptoOps& crypto, const uint8_t pmk[32], const uint8_t own[6], + const uint8_t bssid[6], const uint8_t snonce[32], + const RsnInfo* ap_rsn = nullptr) { + forget(); + if (ap_rsn && ap_rsn->valid) { + ap_rsn_ = *ap_rsn; + ap_rsn_set_ = true; + } + crypto_ = &crypto; + std::memcpy(pmk_, pmk, 32); + std::memcpy(own_, own, 6); + std::memcpy(bssid_, bssid, 6); + std::memcpy(snonce_, snonce, 32); + state_ = State::WaitMsg1; + } + + /* Drop every key this object holds. Called by start() and the destructor; + * a caller that is finished early may call it directly. */ + void forget() { + secure_wipe(pmk_, sizeof pmk_); + secure_wipe(snonce_, sizeof snonce_); + secure_wipe(anonce_, sizeof anonce_); + secure_wipe(inst_anonce_, sizeof inst_anonce_); + secure_wipe(m1_anonce_, sizeof m1_anonce_); + secure_wipe(ptk_, sizeof ptk_); + secure_wipe(cand_ptk_, sizeof cand_ptk_); + secure_wipe(gtk_, sizeof gtk_); + if (!last_reply_.empty()) + secure_wipe(last_reply_.data(), last_reply_.size()); + last_reply_.clear(); + if (!m1_reply_.empty()) secure_wipe(m1_reply_.data(), m1_reply_.size()); + m1_reply_.clear(); + m1_replay_ = 0; + m1_answered_ = false; + gtk_rsc_ = 0; + ap_rsn_ = RsnInfo{}; + ap_rsn_set_ = false; + state_ = State::Idle; + crypto_ = nullptr; + rx_replay_ = 0; + rx_replay_set_ = false; + answered_replay_ = 0; + answered_ = false; + answered_kind_ = Kind::None; + ptk_valid_ = false; + cand_valid_ = false; + gtk_valid_ = false; + gtk_len_ = 0; + gtk_key_id_ = 0; + } + + /* Feed one EAPOL frame — the bytes after the LLC/SNAP header of an 0x888E + * data frame. */ + Verdict on_eapol(const uint8_t* eapol, size_t len, + std::vector* out) { + EapolKey k; + + if (!crypto_ || state_ == State::Idle) return note(Verdict::OutOfState); + if (!parse_eapol_key(eapol, len, &k)) return note(Verdict::Malformed); + /* Version 1 is TKIP's HMAC-MD5 and version 3 is AES-CMAC. Treating either + * as version 2 means computing the MIC with the wrong algorithm, and the + * only symptom would be "the MIC failed" — a diagnosis that sends the + * reader looking at the key rather than at the cipher suite. */ + if (k.version != kKeyDescVersionCcmp) return note(Verdict::Malformed); + /* A Request bit set is a SUPPLICANT-to-authenticator frame. Receiving one + * means something is echoing our own traffic back at us. */ + if (k.request()) return note(Verdict::Malformed); + /* Key data longer than an MSDU can hold is not a frame anyone sent. */ + if (k.key_data_len > kMaxKeyData) return note(Verdict::Malformed); + + const Kind kind = classify(k); + if (kind == Kind::None) return note(Verdict::Ignored); + + /* THE REPLAY GATE, ahead of every branch below so no message type added + * later can forget it. + * + * `rx_replay_` is the last counter this station AUTHENTICATED - it moves + * in on_msg3 and on_group1 and nowhere else. Message 1 has no MIC, so + * letting it move this would let one forged frame refuse every genuine + * message for the rest of the association (rule 3 at the top of this + * file). + * + * Strictly greater is required to install anything. Equal is answered + * with the cached reply and installs NOTHING, which is what lets an + * authenticator that does not increment on retransmission (802.11-2016 + * 12.7.6.4 permits either) finish its handshake; installing on one would + * be a reinstallation (rule 2). The cached reply is returned only for the + * SAME message type that produced it, so a msg1 replayed at a msg3's + * counter does not get a msg4 back. */ + if (rx_replay_set_ && k.replay <= rx_replay_) + return retransmit_or_replay(k, kind, out); + /* A repeat of something answered but never authenticated: a retransmitted + * message 1. It has its own cache, so it can never collect a message 4. + * + * A retransmission repeats the ANONCE as well as the counter, so both + * must match. On the counter alone, a forged message 1 that arrives FIRST + * would poison the genuine one: the genuine frame, at the same counter, + * would be answered with the reply derived from the forger's ANonce, + * whose MIC the AP cannot verify, and every join the forger raced would + * fail. A same-counter message 1 with a different ANonce is processed as + * new (with the same SNonce, which is also what wpa_supplicant does + * within one handshake). */ + if (kind == Kind::Msg1 && m1_answered_ && k.replay == m1_replay_ && + std::memcmp(k.nonce, m1_anonce_, 32) == 0 && !m1_reply_.empty()) { + retransmits++; + if (out) *out = m1_reply_; + return Verdict::Retransmit; + } + + switch (kind) { + case Kind::Msg1: return on_msg1(k, out); + case Kind::Msg3: return on_msg3(k, out); + case Kind::Group1: return on_group1(k, out); + default: return note(Verdict::Ignored); + } + } + + State state() const { return state_; } + bool ptk_valid() const { return ptk_valid_; } + /* Is `k` a message 3 of the handshake already installed - same ANonce as + * the one the live PTK was derived from? The AP retransmits exactly that + * when our message 4 is lost. A classification only: nothing is verified + * or installed here, and on_eapol still applies every rule to it. */ + bool is_installed_msg3(const EapolKey& k) const { + return ptk_valid_ && k.nonce && k.pairwise() && k.ack() && k.has_mic() && + k.install() && k.secure() && + std::memcmp(k.nonce, inst_anonce_, 32) == 0; + } + bool gtk_valid() const { return gtk_valid_; } + /* KCK[0:16] KEK[16:32] TK[32:48] */ + const uint8_t* ptk() const { return ptk_; } + const uint8_t* tk() const { return ptk_ + 32; } + const uint8_t* gtk() const { return gtk_; } + size_t gtk_len() const { return gtk_len_; } + uint8_t gtk_key_id() const { return gtk_key_id_; } + /* The installed GTK's receive sequence counter, from the MIC-verified + * message that delivered it: the Key RSC field, a 48-bit little-endian PN + * for CCMP. 802.11-2016 12.7.6.4 has the receiver START its group replay + * counter here rather than at whatever group frame happens to arrive + * first - otherwise a capture from earlier in the GTK's life is accepted + * as the first frame. Updated only when the GTK itself changes. */ + uint64_t gtk_rsc() const { return gtk_rsc_; } + /* The last counter this station AUTHENTICATED, not the last it saw. */ + uint64_t replay_counter() const { return rx_replay_; } + + /* HOW MANY TIMES EACH KEY HAS BEEN INSTALLED, monotonic for the life of + * this object and NOT reset by forget(). A message that re-delivers the + * key already installed does NOT count - see the fourth defect at the top + * of this file. + * + * A caller with a cipher has per-key state - packet numbers, replay + * windows - that must restart when the key does, and "are we connected + * now" is not the event: a PTK or GTK rekey happens with the association + * already up and the state machine already Connected. Without this the + * caller either misses the rekey or keeps a copy of the key to diff + * against, and a harness holding its own copy of the pairwise key is worse + * than a counter. + * + * A caller with a data plane must read both. Getting this wrong is not + * subtle on air: the AP's new key starts at PN 1, so a stale window rejects every + * frame until the PN climbs back within 64 of the old head - a link that + * reports itself keyed and carries nothing. */ + uint32_t ptk_generation() const { return ptk_gen_; } + uint32_t gtk_generation() const { return gtk_gen_; } + + uint32_t mic_failures = 0; + uint32_t replays = 0; + uint32_t retransmits = 0; + uint32_t malformed = 0; + uint32_t out_of_state = 0; + uint32_t ignored = 0; + uint32_t crypto_errors = 0; + uint32_t rsn_mismatches = 0; + + private: + /* An MSDU is 2304 bytes; key data larger than that never crossed a link. */ + static constexpr size_t kMaxKeyData = 2048; + /* The only group cipher this station speaks is CCMP-128, and its key is 16 + * bytes. find_gtk_kde accepts up to 32 (the KDE format allows CCMP-256 and + * GCMP-256 keys); a GTK of any other length is not one this station could + * use: a CCMP-128 data plane would take 16 of its bytes and decrypt every + * group frame with the wrong key. + * + * THE GTK'S KEY ID IS 1, 2 OR 3, never 0. A data plane picks the key by the + * CCMP header's key id, and 0 is where the pairwise key is used: a GTK "at + * 0" could not be told from the PTK. hostapd does not send one; a GTK KDE + * that claims it is refused on both routes a GTK arrives by. */ + static constexpr size_t kGtkLenCcmp = 16; + /* Message 3's Key Length is the pairwise cipher's key length: 16 for + * CCMP-128, the only pairwise cipher this supplicant speaks (802.11-2016 + * 12.7.2). Any other value names a cipher this station did not negotiate. */ + static constexpr uint16_t kPtkTkLenCcmp = 16; + + enum class Kind : uint8_t { None, Msg1, Msg3, Group1 }; + + /* Message 3 of an RSNA sets Secure (802.11-2016 12.7.6.4), exactly as the + * group message 1 below must: a pairwise Ack+MIC+Install frame WITHOUT it + * is not a message this supplicant answers, and it installs nothing. */ + static Kind classify(const EapolKey& k) { + if (k.pairwise()) { + if (k.ack() && !k.has_mic()) return Kind::Msg1; + if (k.ack() && k.has_mic() && k.install() && k.secure()) + return Kind::Msg3; + return Kind::None; + } + if (k.ack() && k.has_mic() && k.secure()) return Kind::Group1; + return Kind::None; + } + + Verdict note(Verdict v) { + switch (v) { + case Verdict::Malformed: malformed++; break; + case Verdict::CryptoError: crypto_errors++; break; + case Verdict::MicFailed: mic_failures++; break; + case Verdict::Replayed: replays++; break; + case Verdict::OutOfState: out_of_state++; break; + case Verdict::Ignored: ignored++; break; + default: break; + } + return v; + } + + /* A failed MIC check is the frame's fault only when the MIC mismatched; a + * failed HMAC is our provider's, and counts as crypto_errors. */ + static Verdict mic_verdict(MicCheck m) { + return m == MicCheck::CryptoError ? Verdict::CryptoError + : Verdict::MicFailed; + } + + Verdict retransmit_or_replay(const EapolKey& k, Kind kind, + std::vector* out) { + if (k.replay == rx_replay_ && kind == answered_kind_ && answered_ && + k.replay == answered_replay_ && !last_reply_.empty()) { + retransmits++; + if (out) *out = last_reply_; + return Verdict::Retransmit; + } + return note(Verdict::Replayed); + } + + /* Message 1: ANonce, no MIC, nothing to verify. + * + * NOTHING INSTALLED IS TOUCHED, AND THE STATE DOES NOT GO BACKWARDS. The + * PTK is derived into a CANDIDATE and the live one is left alone; a station + * that is already Done stays Done, because on_group1 requires Done and an + * unauthenticated frame must not be able to switch the group-rekey path + * off. The candidate is promoted in on_msg3, after a MIC computed with it + * verifies. + * + * WHAT A FORGED MESSAGE 1 STILL COSTS. There is one candidate, and anyone + * who can spoof the BSSID can replace it with one derived from an ANonce of + * their choosing. The genuine message 3 then carries an ANonce that matches + * neither that candidate nor the installed handshake, so on_msg3 refuses it + * as Malformed at the ANonce comparison, before any MIC is computed, and + * the handshake cannot complete until the AP starts it again with a fresh + * message 1. That is the same exposure wpa_supplicant has with + * its single TPTK. During the initial four-way it costs an attempt. During + * a PTK rekey on an ESTABLISHED association it can cost the link: an AP + * whose rekey fails after its retries typically deauthenticates the + * station. The installed PTK is never touched, so traffic keeps flowing + * until then. + */ + Verdict on_msg1(const EapolKey& k, std::vector* out) { + /* THE CANDIDATE CHANGES ONLY ON SUCCESS. The new PTK and message 2 are + * built in locals and committed together; if our own CryptoOps fails + * part-way, the previous candidate (ANonce and PTK) is left exactly as it + * was, so a message 3 for the handshake already in flight still finds a + * consistent candidate - never an ANonce from one handshake beside a + * PTK from another. */ + uint8_t new_anonce[32]; + uint8_t new_ptk[48]; + std::memcpy(new_anonce, k.nonce, 32); + if (!derive_ptk(*crypto_, pmk_, bssid_, own_, new_anonce, snonce_, + new_ptk)) { + secure_wipe(new_ptk, sizeof new_ptk); + return note(Verdict::CryptoError); + } + + std::vector rsn; + append_rsn_ccmp_psk(rsn); + /* The key data carries the whole RSN element, EID and length included - + * a conforming authenticator compares it with the one in the association + * request. */ + /* KEY LENGTH ZERO. 802.11-2016 12.7.6.3: the Key Length field of message + * 2 is 0 in an RSNA - the pairwise key length is the AUTHENTICATOR's + * statement, made in messages 1 and 3, and a supplicant does not repeat + * it. The field is inside the MIC, so hostapd accepts a nonzero value and + * an AP that checks it would not; test_against_a_real_four_way compares + * this message 2 with wpa_supplicant's byte for byte. */ + std::vector e = build_eapol_key( + (uint16_t)(kKeyDescVersionCcmp | kKiPairwise | kKiMic), 0, k.replay, + snonce_, nullptr, rsn.data(), rsn.size(), crypto_, new_ptk, + kEapolVersionSupplicant); + if (e.empty()) { + secure_wipe(new_ptk, sizeof new_ptk); + return note(Verdict::CryptoError); + } + + std::memcpy(anonce_, new_anonce, 32); + std::memcpy(cand_ptk_, new_ptk, 48); + secure_wipe(new_ptk, sizeof new_ptk); + cand_valid_ = true; + std::memcpy(m1_anonce_, k.nonce, 32); + answer(Kind::Msg1, k.replay, e); + if (state_ == State::WaitMsg1) state_ = State::WaitMsg3; + if (out) *out = e; + return Verdict::Reply; + } + + /* Message 3: the GTK, and the confirmation that the authenticator holds the + * same PTK. Everything is checked before anything is installed. */ + Verdict on_msg3(const EapolKey& k, std::vector* out) { + /* WHICH PTK THIS MESSAGE 3 BELONGS TO. Either the CANDIDATE of a + * handshake in flight (gated on the candidate, not on the state: a + * message 1 that arrived on a working association leaves the state at + * Done deliberately, and its message 3 still has to be processable), or + * the handshake ALREADY INSTALLED - the AP retransmitting message 3 + * because our message 4 was lost, which must be answered and must not + * reinstall anything. + * + * 12.7.6.4: the ANonce in message 3 must equal the one in message 1, or + * the authenticator is not the party we derived against. Checked BEFORE + * the MIC so a mix-and-match is refused as what it is rather than as a + * key mismatch. */ + const uint8_t* kptk = nullptr; + if (cand_valid_ && std::memcmp(k.nonce, anonce_, 32) == 0) + kptk = cand_ptk_; + else if (ptk_valid_ && std::memcmp(k.nonce, inst_anonce_, 32) == 0) + kptk = ptk_; + else if (!cand_valid_ && !ptk_valid_) + return note(Verdict::OutOfState); + else + return note(Verdict::Malformed); + + if (k.key_len != kPtkTkLenCcmp) return note(Verdict::Malformed); + + /* THE FORGERY GATE. Verified with the KCK of the PTK chosen above, which + * exists only because a message 1 named an ANonce and we hold the PMK. */ + const MicCheck mic = eapol_mic_ok(*crypto_, kptk, k); + if (mic != MicCheck::Ok) return note(mic_verdict(mic)); + + /* Message 3's key data is AES-key-wrapped with the KEK. An unwrap is an + * integrity check in its own right, so a failure here is not a decode + * hiccup — it is a frame whose MIC verified but whose key data did not, + * which should not be possible and is refused rather than parsed. */ + GtkKde g; + if (!k.encrypted() || k.key_data_len == 0) { + /* Unencrypted key data in message 3 is a downgrade - the GTK would be + * in the clear - and message 3 with no key data at all carries no GTK, + * which in RSN it always does. Both are refused rather than completed + * into a station that is keyed with no group key. + * + * DELETING THIS CHECK CHANGES NO OUTCOME, and that is recorded rather + * than hidden: a mutation removing it survives the whole test suite, + * because plaintext key data fails the AES unwrap below on its + * integrity check and an absent one fails the length check. It stays + * because "the unwrap happened to refuse it" is a different reason from + * "we do not accept an unprotected GTK", and only one of those survives + * a future edit to the unwrap path. */ + return note(Verdict::Malformed); + } + if (k.key_data_len < 16 || (k.key_data_len % 8) != 0) + return note(Verdict::Malformed); + std::vector plain(k.key_data_len - 8); + /* Wiped on EVERY exit, the failed unwrap included: an implementation may + * have written some plaintext before its integrity check failed. */ + if (!crypto_->aes_key_unwrap(kptk + 16, 16, k.key_data, k.key_data_len, + plain.data())) { + secure_wipe(plain.data(), plain.size()); + return note(Verdict::Malformed); + } + /* Absent and Malformed are both refusals HERE - see the note at + * KdeResult. A message 3 with no GTK would otherwise complete the + * handshake into a station that can decrypt no broadcast at all, with + * nothing counted and nothing to look at. */ + if (find_gtk_kde(plain.data(), plain.size(), &g) != KdeResult::Found || + g.gtk_len != kGtkLenCcmp || g.key_id == 0) { + secure_wipe(plain.data(), plain.size()); + secure_wipe(&g, sizeof g); + return note(Verdict::Malformed); + } + /* THE DOWNGRADE CHECK (12.7.6.4). The RSN element in this MIC-verified, + * KEK-wrapped key data is the AP's own; the one we chose the BSS by came + * off the air unauthenticated. They must agree, or someone rewrote the + * advertisement - refused before anything is installed. */ + if (ap_rsn_set_) { + RsnInfo got; + if (!find_rsn_element(plain.data(), plain.size(), &got) || + !rsn_equivalent(got, ap_rsn_)) { + secure_wipe(plain.data(), plain.size()); + secure_wipe(&g, sizeof g); + rsn_mismatches++; + return note(Verdict::Malformed); + } + } + + std::vector e = build_eapol_key( + (uint16_t)(kKeyDescVersionCcmp | kKiPairwise | kKiMic | kKiSecure), 0, + k.replay, nullptr, nullptr, nullptr, 0, crypto_, kptk, + kEapolVersionSupplicant); + if (e.empty()) { + secure_wipe(plain.data(), plain.size()); + secure_wipe(&g, sizeof g); + return note(Verdict::CryptoError); + } + + /* INSTALL LAST. Up to here a failure has cost nothing. + * + * AND NEVER REINSTALL (the fourth defect at the top of this file). The + * comparison is on the key bytes, not on which path chose `kptk`: a + * message 1 re-quoting the installed ANonce re-derives the identical PTK + * into the candidate, and its message 3 must not count as a new key + * either. */ + if (!ptk_valid_ || std::memcmp(kptk, ptk_, 48) != 0) { + std::memcpy(ptk_, kptk, 48); + std::memcpy(inst_anonce_, k.nonce, 32); + ptk_valid_ = true; + ptk_gen_++; + } + /* The candidate is spent: only a fresh message 1 re-arms it. */ + if (kptk == cand_ptk_) { + secure_wipe(cand_ptk_, sizeof cand_ptk_); + cand_valid_ = false; + } + install_gtk(g, k.rsc); + authenticated(k.replay); + answer(Kind::Msg3, k.replay, e); + state_ = State::Done; + secure_wipe(plain.data(), plain.size()); + secure_wipe(&g, sizeof g); + if (out) *out = e; + return Verdict::Reply; + } + + /* Group key handshake, message 1: a new GTK under the KEK, MIC'd with the + * KCK. Needs an installed PTK; one arriving before the 4-way has finished is + * out of state, not merely unverifiable. */ + Verdict on_group1(const EapolKey& k, std::vector* out) { + if (state_ != State::Done || !ptk_valid_) + return note(Verdict::OutOfState); + const MicCheck mic = eapol_mic_ok(*crypto_, ptk_, k); + if (mic != MicCheck::Ok) return note(mic_verdict(mic)); + if (!k.encrypted() || k.key_data_len < 16 || (k.key_data_len % 8) != 0) + return note(Verdict::Malformed); + + std::vector plain(k.key_data_len - 8); + if (!crypto_->aes_key_unwrap(ptk_ + 16, 16, k.key_data, k.key_data_len, + plain.data())) { + secure_wipe(plain.data(), plain.size()); + return note(Verdict::Malformed); + } + GtkKde g; + if (find_gtk_kde(plain.data(), plain.size(), &g) != KdeResult::Found || + g.gtk_len != kGtkLenCcmp || g.key_id == 0) { + secure_wipe(plain.data(), plain.size()); + secure_wipe(&g, sizeof g); + return note(Verdict::Malformed); + } + + std::vector e = build_eapol_key( + (uint16_t)(kKeyDescVersionCcmp | kKiMic | kKiSecure), 0, k.replay, + nullptr, nullptr, nullptr, 0, crypto_, ptk_, kEapolVersionSupplicant); + if (e.empty()) { + secure_wipe(plain.data(), plain.size()); + secure_wipe(&g, sizeof g); + return note(Verdict::CryptoError); + } + + install_gtk(g, k.rsc); + authenticated(k.replay); + answer(Kind::Group1, k.replay, e); + secure_wipe(plain.data(), plain.size()); + secure_wipe(&g, sizeof g); + if (out) *out = e; + return Verdict::Reply; + } + + /* The first RSN element (EID 48) in unwrapped key data, read with + * walk_key_data (Eapol.h) - the same walk find_gtk_kde uses, padding + * included. Elements before it are stepped over; a truncation before it, + * or no RSN element at all, is "not found". */ + static bool find_rsn_element(const uint8_t* kd, size_t len, RsnInfo* out) { + bool found = false, ok = false; + + walk_key_data(kd, len, [&](uint8_t id, const uint8_t* b, size_t l) { + if (id != 48) return true; + found = true; + ok = parse_rsn(b, l, out) && out->valid; + return false; + }); + return found && ok; + } + + /* 802.11-2016 12.7.6.4: message 3's RSN element must match the one the BSS + * was chosen by. Compared PARSED, field by field - version, group cipher, + * the full pairwise and AKM suite SETS, capabilities (exactly), and the + * group management cipher - which is the shape of wpa_supplicant's + * wpa_compare_rsn_ie() once its memcmp has failed. A byte-identical element + * therefore always matches, and so does one listing the same suites in a + * different order: the standard orders nothing within a list, and an AP may + * encode the same element differently in its beacon and its message 3. + * + * THE SETS, NOT THE COUNTS. Comparing only "CCMP is among the pairwise + * suites" plus the suite counts would judge {CCMP, GCMP-128} advertised + * against {CCMP, TKIP} in message 3 - one suite swapped for a weaker one - + * equivalent. The counts are compared as well, so a duplicated suite is a + * difference too. + * + * Suites outside 00-0F-AC (vendor) are compared by presence only - which + * is stricter than wpa_supplicant, whose bitfields drop them entirely. */ + static bool rsn_equivalent(const RsnInfo& a, const RsnInfo& b) { + return a.version == b.version && a.group_suite == b.group_suite && + a.pairwise_mask == b.pairwise_mask && + a.pairwise_other == b.pairwise_other && + a.pairwise_count == b.pairwise_count && a.akm_mask == b.akm_mask && + a.akm_other == b.akm_other && a.akm_count == b.akm_count && + a.capabilities == b.capabilities && + a.group_mgmt_suite == b.group_mgmt_suite; + } + + /* The GTK already in use is NOT reinstalled - not copied, not counted, and + * its RSC is not re-read. Every PTK rekey's message 3 re-delivers the + * current GTK, and a caller that reopens its group replay window on a + * generation change would otherwise reopen it on every rekey, with no + * attacker involved (CVE-2017-13078/13080 class). */ + void install_gtk(const GtkKde& g, const uint8_t* rsc) { + if (gtk_valid_ && gtk_len_ == g.gtk_len && gtk_key_id_ == g.key_id && + std::memcmp(gtk_, g.gtk, g.gtk_len) == 0) + return; + std::memcpy(gtk_, g.gtk, g.gtk_len); + gtk_len_ = g.gtk_len; + gtk_key_id_ = g.key_id; + gtk_rsc_ = 0; + if (rsc) + for (int i = 0; i < 6; i++) gtk_rsc_ |= (uint64_t)rsc[i] << (8 * i); + gtk_valid_ = true; + gtk_gen_++; + } + + /* THE ONLY PLACE rx_replay_ MOVES, and both call sites have verified a MIC + * before reaching it. */ + void authenticated(uint64_t replay) { + rx_replay_ = replay; + rx_replay_set_ = true; + } + + /* The retransmission caches, kept with the message type they answered so a + * different message quoting the same counter cannot collect them. Message + * 1's answer is cached APART from the authenticated one: an + * unauthenticated message 1 must not be able to evict the message 4 that + * the AP's equal-counter retransmission of message 3 is owed. */ + void answer(Kind kind, uint64_t replay, const std::vector& reply) { + if (kind == Kind::Msg1) { + m1_replay_ = replay; + m1_answered_ = true; + m1_reply_ = reply; + return; + } + answered_kind_ = kind; + answered_replay_ = replay; + answered_ = true; + last_reply_ = reply; + } + + CryptoOps* crypto_ = nullptr; + State state_ = State::Idle; + uint8_t pmk_[32] = {0}; + uint8_t own_[6] = {0}; + uint8_t bssid_[6] = {0}; + uint8_t snonce_[32] = {0}; + uint8_t anonce_[32] = {0}; /* the in-flight candidate's ANonce */ + uint8_t inst_anonce_[32] = {0}; /* the installed PTK's ANonce */ + uint8_t ptk_[48] = {0}; + uint8_t cand_ptk_[48] = {0}; + uint8_t gtk_[32] = {0}; + size_t gtk_len_ = 0; + uint8_t gtk_key_id_ = 0; + uint64_t gtk_rsc_ = 0; + RsnInfo ap_rsn_{}; /* the advertisement message 3 must match */ + bool ap_rsn_set_ = false; + uint32_t ptk_gen_ = 0; + uint32_t gtk_gen_ = 0; + bool ptk_valid_ = false; + bool cand_valid_ = false; + bool gtk_valid_ = false; + uint64_t rx_replay_ = 0; + bool rx_replay_set_ = false; + uint64_t answered_replay_ = 0; + bool answered_ = false; + Kind answered_kind_ = Kind::None; + std::vector last_reply_; + uint64_t m1_replay_ = 0; + uint8_t m1_anonce_[32] = {0}; /* the ANonce the cached reply answered */ + bool m1_answered_ = false; + std::vector m1_reply_; +}; + +} // namespace sta +} // namespace devourer + +#endif /* DEVOURER_STA_SUPPLICANT_H */ diff --git a/tests/bss_table_selftest.cpp b/tests/bss_table_selftest.cpp new file mode 100644 index 00000000..f5bf7e61 --- /dev/null +++ b/tests/bss_table_selftest.cpp @@ -0,0 +1,667 @@ +/* Headless guard for src/sta/BssTable.h — what a scan found, and which of it + * is worth joining. + * + * Pure and header-only like the rest of src/sta/, so this needs neither + * OpenSSL nor libusb and runs in every CI job. + * + * The load-bearing cells are the two that decide whether a station can + * connect at all: `select()` must pick a BSS this station can actually finish + * a handshake with, and `observe()` must not invent one out of a frame that + * is not a beacon. Both are assertions about behaviour a caller depends on, + * not about the shape of the struct. + */ +#include +#include +#include +#include + +#include "sta/BssTable.h" + +namespace { + +using devourer::sta::BssEntry; +using devourer::sta::BssTable; + +int g_fail = 0; + +void check(bool ok, const char* what) { + if (!ok) { + std::printf("FAIL: %s\n", what); + g_fail++; + } +} + +/* A beacon as a real AP airs one: the 24-byte header, the 12-byte fixed body, + * then the elements in the standard's order. Built with Dot11.h's own + * builders, so a change to those shows up here. */ +std::vector beacon(const uint8_t bssid[6], const std::string& ssid, + uint8_t chan, bool rsn, bool privacy = true, + bool ds = true) { + static const uint8_t bcast[6] = {0xff, 0xff, 0xff, 0xff, 0xff, 0xff}; + std::vector m = devourer::sta::mgmt_hdr(devourer::sta::kFcBeacon, + bcast, bssid, bssid); + + m.insert(m.end(), 8, 0); /* timestamp */ + devourer::sta::put_le16(m, 100); /* beacon interval, TU */ + devourer::sta::put_le16(m, + (uint16_t)(0x0001 | (privacy && rsn ? 0x0010 : 0))); + devourer::sta::append_ssid(m, ssid); + devourer::sta::append_supported_rates(m); + if (ds) devourer::sta::append_ds_params(m, chan); + if (rsn) devourer::sta::append_rsn_ccmp_psk(m); + return m; +} + +void test_observe_and_dedupe() { + BssTable t; + const uint8_t a[6] = {0x02, 0, 0, 0, 0, 0x01}; + const uint8_t b[6] = {0x02, 0, 0, 0, 0, 0x02}; + + check(t.count() == 0, "a fresh table is empty"); + + std::vector f = beacon(a, "one", 6, true); + const BssEntry* e = t.observe(f.data(), f.size(), -40, 6, 1000); + check(e != nullptr, "a beacon is observed"); + check(t.count() == 1, "...as one entry"); + check(e->info.ssid == "one" && e->info.channel == 6, "...parsed"); + check(e->info.rsn_ccmp_psk, "...and recognised as WPA2-PSK/CCMP"); + check(e->rssi == -40 && e->last_seen_ms == 1000 && e->frames == 1, + "...with its RSSI, timestamp and frame count"); + + /* THE SAME BSS AGAIN IS NOT A SECOND ENTRY. Without this a station hears a + * beacon ten times a second and fills a sixteen-slot table in under two + * seconds, then evicts the network it was looking for. */ + e = t.observe(f.data(), f.size(), -55, 6, 1100); + check(t.count() == 1, "the same BSSID does not create a second entry"); + check(e->rssi == -55 && e->last_seen_ms == 1100 && e->frames == 2, + "...it refreshes the existing one"); + + std::vector g = beacon(b, "two", 11, true); + t.observe(g.data(), g.size(), -70, 6, 1200); + check(t.count() == 2, "a different BSSID is a second entry"); + check(t.find(a) != nullptr && t.find(b) != nullptr, "both are findable"); + const uint8_t absent[6] = {0x02, 0, 0, 0, 0, 0x09}; + check(t.find(absent) == nullptr, "an unheard BSSID is not"); +} + +/* A frame that is not a beacon or probe response must be refused. Every + * management frame shares the same 24-byte header, so handing a deauth to + * parse_beacon reads its reason code as part of a timestamp and invents a + * BSS. */ +void test_only_beacons_are_observed() { + BssTable t; + const uint8_t a[6] = {0x02, 0, 0, 0, 0, 0x01}; + + /* A 26-byte deauth is refused on LENGTH, not on subtype - parse_beacon + * needs 36 - so this arm alone would survive deleting the subtype check. + * Kept because a short frame must also be refused, and followed by a + * FULL-LENGTH frame whose only fault is its subtype. */ + std::vector d = + devourer::sta::build_deauth(a, a, 7); + check(t.observe(d.data(), d.size(), -40, 6, 1) == nullptr, + "a short deauth is not observed as a BSS"); + + std::vector longd = beacon(a, "one", 6, true); + longd[0] = devourer::sta::kFcDeauth; + check(t.observe(longd.data(), longd.size(), -40, 6, 1) == nullptr, + "a FULL-LENGTH deauth is not observed either - this is the arm that " + "tests the subtype rather than the length"); + + std::vector f = beacon(a, "one", 6, true); + f[0] = devourer::sta::kFcAssocResp; + check(t.observe(f.data(), f.size(), -40, 6, 1) == nullptr, + "an association response is not observed as a BSS"); + + f[0] = devourer::sta::kFcProbeResp; + check(t.observe(f.data(), f.size(), -40, 6, 1) != nullptr, + "a probe response IS observed - same body layout as a beacon"); + check(t.count() == 1, "...into one entry"); + + /* A runt. parse_beacon refuses anything shorter than its fixed part, and + * observe must not reach it with a 24-byte buffer either. */ + check(t.observe(f.data(), 24, -40, 6, 1) == nullptr, "a runt is refused"); + check(t.observe(f.data(), 10, -40, 6, 1) == nullptr, "a 10-byte frame is refused"); + check(t.observe(nullptr, 100, -40, 6, 1) == nullptr, "null is refused"); +} + +void test_expire() { + BssTable t; + const uint8_t a[6] = {0x02, 0, 0, 0, 0, 0x01}; + const uint8_t b[6] = {0x02, 0, 0, 0, 0, 0x02}; + + std::vector f = beacon(a, "one", 6, true); + std::vector g = beacon(b, "two", 6, true); + t.observe(f.data(), f.size(), -40, 6, 1000); + t.observe(g.data(), g.size(), -40, 6, 5000); + + check(t.expire(5100, 10000) == 0, "nothing expires before its age"); + check(t.count() == 2, "...and the table is unchanged"); + check(t.expire(6000, 2000) == 1, "the older entry expires"); + check(t.count() == 1 && t.find(a) == nullptr && t.find(b) != nullptr, + "...and it is the right one"); + + /* A station that keeps a BSS forever will try to associate with one that + * went off the air ten minutes ago and then report the AP as broken. */ + check(t.expire(100000, 2000) == 1, "everything stale expires"); + check(t.count() == 0, "...leaving nothing"); + + /* THE CLOCK WRAPS. `now_ms` is a uint32_t, so about every fifty days it + * passes zero, and the subtraction has to be unsigned. + * + * A SMALL AGE ACROSS THE WRAP DOES NOT DISTINGUISH THE TWO: 0x00001000 - + * 0xfffff000 is 0x2000 either way, positive in both readings, so a cell + * that only crosses the wrap cannot tell an int32_t age from a uint32_t + * one. The difference only appears once the age passes 2^31 ms - about + * twenty-five days - where the signed reading goes NEGATIVE and the entry + * never expires again; the second check below is that case. */ + t.observe(f.data(), f.size(), -40, 6, 0xfffff000u); + check(t.count() == 1, "an entry seen just before the wrap"); + check(t.expire(0x00001000u, 2000) == 1, "expires just after the wrap"); + + t.observe(f.data(), f.size(), -40, 6, 0); + check(t.expire(0x90000000u, 2000) == 1, + "an entry twenty-eight days old expires - the age must be UNSIGNED, " + "or it reads as negative and nothing is ever dropped again"); + check(t.count() == 0, "...leaving the table empty"); +} + +void test_select() { + BssTable t; + const uint8_t weak[6] = {0x02, 0, 0, 0, 0, 0x01}; + const uint8_t strong[6] = {0x02, 0, 0, 0, 0, 0x02}; + const uint8_t open_bss[6] = {0x02, 0, 0, 0, 0, 0x03}; + const uint8_t other[6] = {0x02, 0, 0, 0, 0, 0x04}; + + std::vector f1 = beacon(weak, "net", 6, true); + std::vector f2 = beacon(strong, "net", 36, true); + std::vector f3 = beacon(open_bss, "net", 1, false, false); + std::vector f4 = beacon(other, "elsewhere", 6, true); + t.observe(f1.data(), f1.size(), -80, 6, 1000); + t.observe(f2.data(), f2.size(), -35, 6, 1000); + t.observe(f3.data(), f3.size(), -20, 6, 1000); /* strongest, and unusable */ + t.observe(f4.data(), f4.size(), -10, 6, 1000); /* strongest, wrong SSID */ + + const BssEntry* s = t.select("net"); + check(s != nullptr, "a candidate is found"); + check(s && std::memcmp(s->info.bssid, strong, 6) == 0, + "the STRONGEST joinable BSS wins"); + check(s && s->info.channel == 36, "...and carries its channel"); + + /* THE THREE NEGATIVE ARMS, each of which the positive one would hide. */ + check(t.select("absent") == nullptr, "an SSID nobody airs has no candidate"); + const BssEntry* o = t.find(open_bss); + check(o && !o->info.rsn_ccmp_psk, + "the open BSS is in the table but not joinable"); + check(s && std::memcmp(s->info.bssid, open_bss, 6) != 0, + "...so it is not selected despite the strongest signal"); + check(s && std::memcmp(s->info.bssid, other, 6) != 0, + "a stronger BSS with another SSID is not selected"); + + /* A tie breaks on the most recently heard, so a stale entry never beats a + * live one at equal signal. */ + BssTable u; + const uint8_t p[6] = {0x02, 0, 0, 0, 0, 0x0a}; + const uint8_t q[6] = {0x02, 0, 0, 0, 0, 0x0b}; + std::vector g1 = beacon(p, "tie", 6, true); + std::vector g2 = beacon(q, "tie", 6, true); + u.observe(g1.data(), g1.size(), -50, 6, 1000); + u.observe(g2.data(), g2.size(), -50, 6, 2000); + const BssEntry* w = u.select("tie"); + check(w && std::memcmp(w->info.bssid, q, 6) == 0, + "at equal signal the fresher entry wins"); +} + +/* 802.11w is not implemented here. A BSS that REQUIRES management-frame + * protection will accept the authentication and then refuse the association, + * which is a confusing place to fail; skipping it in selection turns that + * into an honest "no candidate". */ +void test_mfp_required_is_skipped() { + BssTable t; + const uint8_t a[6] = {0x02, 0, 0, 0, 0, 0x01}; + std::vector f = beacon(a, "net", 6, true); + + /* Set RSN capabilities bit 6 (MFPR) in the element the builder emitted. + * The capabilities field is the last two octets of that element. */ + size_t ie_len = 0; + const uint8_t* rsn = devourer::sta::find_ie(f.data() + 36, f.size() - 36, + devourer::sta::kEidRsn, &ie_len); + check(rsn != nullptr && ie_len >= 2, "the beacon carries an RSN element"); + if (!rsn) return; + uint8_t* caps = const_cast(rsn) + ie_len - 2; + caps[0] = (uint8_t)(caps[0] | 0x40); + + const BssEntry* e = t.observe(f.data(), f.size(), -30, 6, 1000); + check(e && e->info.rsn_mfp_required, "MFPR is parsed out of the beacon"); + check(e && !e->info.rsn_ccmp_psk, "...and makes the BSS unjoinable"); + check(t.select("net") == nullptr, "...so selection finds no candidate"); +} + +/* Full is not the same as broken. Sixteen neighbours heard before the target + * network would make the table permanently useless, and a scan in a block of + * flats hits that in a second. */ +void test_eviction_when_full() { + BssTable t; + const int cap = BssTable::capacity(); + + for (int i = 0; i < cap; i++) { + const uint8_t id[6] = {0x02, 0, 0, 0, 0, (uint8_t)(0x10 + i)}; + std::vector f = beacon(id, "filler", 6, true); + /* Ascending timestamps, so slot 0 is the oldest. */ + t.observe(f.data(), f.size(), -50, 6, (uint32_t)(1000 + i)); + } + check(t.count() == cap, "the table fills to capacity"); + + const uint8_t oldest[6] = {0x02, 0, 0, 0, 0, 0x10}; + const uint8_t wanted[6] = {0x02, 0, 0, 0, 0, 0x77}; + std::vector f = beacon(wanted, "target", 6, true); + const BssEntry* e = t.observe(f.data(), f.size(), -30, 6, 2000); + + check(e != nullptr, "a new BSS is still admitted when the table is full"); + check(t.find(wanted) != nullptr, "...and is findable"); + check(t.find(oldest) == nullptr, "...having evicted the least recently heard"); + check(t.count() == cap, "...with the count unchanged"); + check(t.select("target") != nullptr, + "the network being looked for survives a crowded band"); + + t.clear(); + check(t.count() == 0 && t.find(wanted) == nullptr, "clear() empties it"); +} + +/* THE EVICTION RULE IS AN ATTACK WITHOUT set_wanted(). + * + * The victim is the entry heard from longest ago. A real AP beacons about ten + * times a second, so its age is nearly always zero-ish - but an attacker + * emitting beacons for sixteen fabricated BSSIDs as fast as the radio allows + * keeps every fabricated entry at age zero and makes the GENUINE AP the + * oldest, every time. The table thrashes and select() returns nothing for + * most of the window in which the station is trying to associate. */ +void test_wanted_ssid_survives_a_flood() { + BssTable t; + const uint8_t target[6] = {0x02, 0, 0, 0, 0, 0x77}; + std::vector want = beacon(target, "target", 6, true); + + /* THE NEGATIVE ARM FIRST, so the positive one is not a coincidence: with no + * wanted SSID set, the flood evicts the network being looked for. */ + t.observe(want.data(), want.size(), -30, 6, 1000); + for (int i = 0; i < BssTable::capacity() * 2; i++) { + const uint8_t id[6] = {0x02, 0, 0, 0, 1, (uint8_t)i}; + std::vector f = beacon(id, "flood", 6, true); + t.observe(f.data(), f.size(), -30, 6, (uint32_t)(2000 + i)); + } + check(t.select("target") == nullptr, + "unprotected, a flood evicts the wanted network"); + + /* And with it set, the same flood cannot touch it. */ + BssTable u; + u.set_wanted("target"); + u.observe(want.data(), want.size(), -30, 6, 1000); + for (int i = 0; i < BssTable::capacity() * 4; i++) { + const uint8_t id[6] = {0x02, 0, 0, 0, 1, (uint8_t)i}; + std::vector f = beacon(id, "flood", 6, true); + u.observe(f.data(), f.size(), -30, 6, (uint32_t)(2000 + i)); + } + check(u.select("target") != nullptr, + "with set_wanted(), the flood cannot evict it"); + check(u.find(target) != nullptr, "...and it is still findable by BSSID"); + check(u.count() == BssTable::capacity(), + "...and the table is still full of the flood, as it should be"); + + /* The protection is not a leak: a second BSS airing the wanted SSID is + * admitted, because that is a real roaming candidate. */ + const uint8_t second[6] = {0x02, 0, 0, 0, 0, 0x78}; + std::vector also = beacon(second, "target", 36, true); + u.observe(also.data(), also.size(), -20, 6, 9000); + check(u.find(second) != nullptr, "a second BSS for the wanted SSID is admitted"); + check(u.find(target) != nullptr, "...without evicting the first"); +} + +/* select_open() - the same question for a station with no keys. + * + * The two selectors must disagree about every BSS: one that offers WPA2-PSK + * is useless to an open station and vice versa. A single function answering + * both would make the `open` cell of the on-air harness pass against a + * protected AP, associate, and then carry nothing. + */ +void test_select_open() { + BssTable t; + const uint8_t protect[6] = {0x02, 0, 0, 0, 0, 0x11}; + const uint8_t plain[6] = {0x02, 0, 0, 0, 0, 0x12}; + const uint8_t wep[6] = {0x02, 0, 0, 0, 0, 0x13}; + + /* Same SSID on all three, so the ONLY thing that can separate them is the + * joinability test - not the name. */ + std::vector a = beacon(protect, "net", 6, /*rsn=*/true); + std::vector b = beacon(plain, "net", 6, /*rsn=*/false); + t.observe(a.data(), a.size(), -30, 6, 1000); /* the STRONGER one */ + t.observe(b.data(), b.size(), -70, 6, 1000); + + const BssEntry* o = t.select_open("net"); + check(o != nullptr, "an open BSS is selectable by select_open"); + if (o) check(std::memcmp(o->info.bssid, plain, 6) == 0, + "...and it is the OPEN one, not the stronger protected one"); + + const BssEntry* w = t.select("net"); + check(w != nullptr, "the protected BSS is still selectable by select"); + if (w) check(std::memcmp(w->info.bssid, protect, 6) == 0, + "...and it is the protected one"); + + /* A WEP BSS: Privacy set, no RSN element at all. Neither selector may + * offer it - select() because there is no RSN, select_open() because the + * link is encrypted with a key this station does not have. Testing + * !has_rsn instead of !privacy would hand it to the open station. */ + std::vector c = beacon(wep, "wepnet", 6, /*rsn=*/false); + c[24 + 10] |= 0x10; /* capability: Privacy */ + t.observe(c.data(), c.size(), -20, 6, 1000); + check(t.find(wep) != nullptr && t.find(wep)->info.privacy, + "the WEP BSS is observed, with Privacy set"); + check(t.select_open("wepnet") == nullptr, + "...and select_open refuses it although it carries no RSN"); + check(t.select("wepnet") == nullptr, "...and select refuses it too"); +} + +/* SIXTEEN BEACONS FOR THE WANTED SSID, FROM SIXTEEN BSSIDs. + * + * The rule that makes an entry with the wanted SSID unevictable exists to + * stop a flood of OTHER SSIDs pushing the target out. Applied without a + * fallback it becomes the attack it was written against: fill every slot + * with fabricated BSSes all claiming the wanted name, and the GENUINE AP can + * never be inserted at all - select() then returns only the attacker's, for + * as long as they keep beaconing. + */ +void test_a_flood_of_the_wanted_ssid_cannot_lock_the_table() { + BssTable t; + const uint8_t real_ap[6] = {0x02, 0xff, 0, 0, 0, 0x01}; + + t.set_wanted("target"); + for (int i = 0; i < BssTable::kMaxBss; i++) { + const uint8_t bssid[6] = {0x02, 0, 0, 0, 0, (uint8_t)(0x40 + i)}; + std::vector f = beacon(bssid, "target", 6, true); + /* All at the same instant, and all refreshed constantly - which is what + * an attacker with a radio does. */ + t.observe(f.data(), f.size(), -30, 6, 1000); + } + check(t.count() == BssTable::kMaxBss, "the table is full of the wanted SSID"); + + std::vector real = beacon(real_ap, "target", 6, true); + const BssEntry* e = t.observe(real.data(), real.size(), -20, 6, 2000); + check(e != nullptr, "the genuine AP is still admitted"); + check(t.find(real_ap) != nullptr, "...and is in the table"); + const BssEntry* sel = t.select("target"); + check(sel != nullptr, "...and something is selectable"); + if (sel) + check(std::memcmp(sel->info.bssid, real_ap, 6) == 0, + "...and it is the genuine AP, which has the strongest signal"); +} + +/* A HIDDEN BSS: its beacons carry an empty (or all-zero) SSID and only a + * directed probe response names it. The beacons that follow must not wipe + * the name, or select() never finds the network the probe was sent for. */ +void test_hidden_ssid_survives_its_beacons() { + BssTable t; + const uint8_t a[6] = {0x02, 0, 0, 0, 0, 0x0a}; + + std::vector pr = beacon(a, "net", 6, true); + pr[0] = devourer::sta::kFcProbeResp; + t.observe(pr.data(), pr.size(), -40, 6, 1000); + check(t.select("net") != nullptr, "the probe response names the BSS"); + + std::vector empty = beacon(a, "", 6, true); + t.observe(empty.data(), empty.size(), -41, 6, 1100); + check(t.select("net") != nullptr, + "AN EMPTY-SSID BEACON DOES NOT WIPE THE NAME"); + std::vector zeros = beacon(a, std::string(3, '\0'), 6, true); + t.observe(zeros.data(), zeros.size(), -42, 6, 1200); + const BssEntry* e = t.select("net"); + check(e != nullptr, "...nor does an all-zero SSID"); + check(e && e->rssi == -42 && e->frames == 3, + "...while the rest of the entry still refreshes"); + + /* A BSS that genuinely renames itself is believed. */ + std::vector other = beacon(a, "renamed", 6, true); + t.observe(other.data(), other.size(), -40, 6, 1300); + check(t.select("net") == nullptr && t.select("renamed") != nullptr, + "a non-empty new SSID replaces the old one"); +} + +} // namespace + +/* THE EQUAL-RSSI TIE-BREAK ACROSS THE CLOCK WRAP. The millisecond clock + * wraps every ~49.7 days; an entry heard just after the wrap has a SMALLER + * raw timestamp than one heard just before it, and a raw `>` picks the stale + * one. Both orders of insertion, so the result cannot depend on slot order. */ +void test_tie_break_across_the_wrap() { + const uint8_t before[6] = {0x02, 0, 0, 0, 0x0a, 0x01}; + const uint8_t after[6] = {0x02, 0, 0, 0, 0x0a, 0x02}; + const uint32_t t_before = 0xffffff00u; /* 256 ms before the wrap */ + const uint32_t t_after = 0x00000100u; /* 256 ms after it */ + + for (int order = 0; order < 2; order++) { + BssTable t; + const std::vector fb = beacon(before, "wrap", 6, true); + const std::vector fa = beacon(after, "wrap", 6, true); + if (order == 0) { + t.observe(fb.data(), fb.size(), -50, 6, t_before); + t.observe(fa.data(), fa.size(), -50, 6, t_after); + } else { + t.observe(fa.data(), fa.size(), -50, 6, t_after); + t.observe(fb.data(), fb.size(), -50, 6, t_before); + } + const BssEntry* e = t.select("wrap"); + check(e && std::memcmp(e->info.bssid, after, 6) == 0, + order == 0 ? "tie-break: the entry heard after the wrap wins" + : "tie-break: ...whichever was inserted first"); + } + + /* And without a wrap the ordinary case still holds. */ + BssTable t; + const std::vector fb = beacon(before, "plain", 6, true); + const std::vector fa = beacon(after, "plain", 6, true); + t.observe(fb.data(), fb.size(), -50, 6, 1000); + t.observe(fa.data(), fa.size(), -50, 6, 2000); + const BssEntry* e = t.select("plain"); + check(e && std::memcmp(e->info.bssid, after, 6) == 0, + "tie-break: the more recent entry wins without a wrap too"); +} + +/* THE RECEIVE CHANNEL. A beacon without a DS Parameter Set (common on 5 GHz) + * takes the channel it was received on; one that carries it keeps its own, + * even when it was heard on an adjacent channel; with neither, the entry is + * kept but never offered, by either selector. */ +void test_rx_channel() { + const uint8_t a[6] = {0x02, 0, 0, 0, 0x0c, 0x01}; + + { + BssTable t; + const std::vector f = beacon(a, "five", 36, true, true, false); + const BssEntry* e = t.observe(f.data(), f.size(), -40, 36, 1000); + check(e && e->info.channel == 36, + "rx channel: a beacon without DS takes the RX channel"); + check(t.select("five") == e, "rx channel: ...and is selectable"); + } + { + BssTable t; + const std::vector f = beacon(a, "six", 6, true); + const BssEntry* e = t.observe(f.data(), f.size(), -40, 7, 1000); + check(e && e->info.channel == 6, + "rx channel: a DS element that disagrees with the RX channel wins"); + } + { + BssTable t; + const std::vector f = beacon(a, "none", 36, true, true, false); + const std::vector o = beacon(a, "none-open", 36, false, false, + false); + t.observe(f.data(), f.size(), -40, 0, 1000); + check(t.find(a) && t.find(a)->info.channel == 0, + "rx channel: with no channel from either source the entry is kept"); + check(t.select("none") == nullptr, + "rx channel: ...but select() never offers it"); + BssTable u; + u.observe(o.data(), o.size(), -40, 0, 1000); + check(u.select_open("none-open") == nullptr, + "rx channel: ...nor does select_open()"); + } +} + +/* ONLY AN INFRASTRUCTURE BSS IS OFFERED. An IBSS carrying the wanted SSID + * must not be selectable: joining it would run an AP's + * authenticate/associate exchange against peers that have no AP. ESS set and + * IBSS clear, both selectors. */ +void test_only_infrastructure_is_offered() { + const uint8_t a[6] = {0x02, 0, 0, 0, 0x0d, 0x01}; + const auto with_cap = [](std::vector f, uint16_t cap) { + f[34] = (uint8_t)(cap & 0xff); /* capability, LE */ + f[35] = (uint8_t)(cap >> 8); + return f; + }; + + BssTable t; + const std::vector ibss = with_cap(beacon(a, "adhoc", 6, true), + 0x0002 | 0x0010); + const BssEntry* e = t.observe(ibss.data(), ibss.size(), -40, 6, 1000); + check(e != nullptr, "ibss: the beacon is observed"); + check(t.select("adhoc") == nullptr, "ibss: an IBSS is not selectable"); + + BssTable u; + const std::vector open_ibss = + with_cap(beacon(a, "adhoc-open", 6, false, false), 0x0002); + u.observe(open_ibss.data(), open_ibss.size(), -40, 6, 1000); + check(u.select_open("adhoc-open") == nullptr, + "ibss: ...nor by select_open()"); + + BssTable v; + const std::vector both = with_cap(beacon(a, "both", 6, true), + 0x0001 | 0x0002 | 0x0010); + v.observe(both.data(), both.size(), -40, 6, 1000); + check(v.select("both") == nullptr, + "ibss: a frame claiming ESS AND IBSS is not offered either"); + + BssTable w; + const std::vector ess = beacon(a, "infra", 6, true); + w.observe(ess.data(), ess.size(), -40, 6, 1000); + check(w.select("infra") != nullptr, "ibss: ...while an ESS still is"); +} + +/* A CHANNEL MUST BE A CHANNEL. 1..14 or 32..253, from the DS element or the + * receiver alike. An invalid DS value must not override a usable receive + * channel (15 over 6), and 255 must not be selectable: the association + * request's band is picked by channel > 14, so either would build it for the + * wrong band. */ +void test_channel_validity() { + const uint8_t a[6] = {0x02, 0, 0, 0, 0x0e, 0x01}; + const auto with_ds = [](std::vector f, uint8_t ds) { + /* The DS element is the one after the SSID and rates: find it. */ + for (size_t i = 36; i + 2 < f.size(); i += 2 + f[i + 1]) + if (f[i] == devourer::sta::kEidDsParams) { f[i + 2] = ds; break; } + return f; + }; + + check(devourer::sta::channel_valid(1) && devourer::sta::channel_valid(14) && + devourer::sta::channel_valid(32) && + devourer::sta::channel_valid(253), + "channel: the edges of both ranges are valid"); + check(!devourer::sta::channel_valid(0) && !devourer::sta::channel_valid(15) && + !devourer::sta::channel_valid(31) && + !devourer::sta::channel_valid(254) && + !devourer::sta::channel_valid(255), + "channel: 0, 15, 31, 254 and 255 are not"); + + { + BssTable t; + const std::vector f = with_ds(beacon(a, "ds15", 6, true), 15); + const BssEntry* e = t.observe(f.data(), f.size(), -40, 6, 1000); + check(e && e->info.channel == 6, + "channel: DS 15 is not a channel; the RX channel 6 stands in"); + check(t.select("ds15") != nullptr, "channel: ...and it is selectable"); + } + { + BssTable t; + const std::vector f = with_ds(beacon(a, "ds255", 6, true), 255); + t.observe(f.data(), f.size(), -40, 0, 1000); + check(t.find(a) && t.find(a)->info.channel == 0 && + t.select("ds255") == nullptr, + "channel: DS 255 with no RX channel is not selectable"); + } + { + BssTable t; + const std::vector f = with_ds(beacon(a, "ds36", 6, true), 36); + const BssEntry* e = t.observe(f.data(), f.size(), -40, 36, 1000); + check(e && e->info.channel == 36 && t.select("ds36") == e, + "channel: a 5 GHz channel in the DS element is kept"); + } + { + BssTable t; + const std::vector f = beacon(a, "rx15", 6, true, true, false); + t.observe(f.data(), f.size(), -40, 15, 1000); + check(t.select("rx15") == nullptr, + "channel: an invalid RX channel is not used either"); + } +} + +/* WPA2 SELECTION NEEDS THE PRIVACY BIT TOO. A CCMP/PSK RSN element under a + * clear Privacy capability bit is self-contradictory, so the RSN element + * alone is not enough for select() to offer the BSS. */ +void test_rsn_without_privacy_is_not_selected() { + const uint8_t a[6] = {0x02, 0, 0, 0, 0x0f, 0x01}; + BssTable t; + const std::vector f = beacon(a, "noprivacy", 6, true, + /*privacy=*/false); + const BssEntry* e = t.observe(f.data(), f.size(), -40, 6, 1000); + check(e && e->info.rsn_ccmp_psk && !e->info.privacy, + "privacy: setup - a CCMP/PSK RSN element with Privacy clear"); + check(t.select("noprivacy") == nullptr, + "privacy: it is not selected for WPA2"); +} + +/* AN SSID ELEMENT LONGER THAN 32 OCTETS MAKES THE BEACON MALFORMED. It is + * refused rather than recorded under a name no station could send back; + * append_ssid refuses to build one for the same reason. */ +void test_overlong_ssid_is_refused() { + static const uint8_t bcast[6] = {0xff, 0xff, 0xff, 0xff, 0xff, 0xff}; + const uint8_t a[6] = {0x02, 0, 0, 0, 0x10, 0x01}; + std::vector m = devourer::sta::mgmt_hdr(devourer::sta::kFcBeacon, + bcast, a, a); + m.insert(m.end(), 8, 0); /* timestamp */ + devourer::sta::put_le16(m, 100); + devourer::sta::put_le16(m, 0x0011); + m.push_back(devourer::sta::kEidSsid); + m.push_back(33); + m.insert(m.end(), 33, 'x'); + devourer::sta::append_ds_params(m, 6); + devourer::sta::append_rsn_ccmp_psk(m); + + BssTable t; + check(t.observe(m.data(), m.size(), -40, 6, 1000) == nullptr && + t.count() == 0, + "ssid: a 33-octet SSID element makes the beacon malformed"); + std::vector ok32 = m; + ok32[37] = 32; /* the SSID length octet */ + ok32.erase(ok32.begin() + 38); /* one 'x' fewer */ + check(t.observe(ok32.data(), ok32.size(), -40, 6, 1000) != nullptr, + "ssid: ...while 32 octets is accepted"); +} + +int main() { + test_observe_and_dedupe(); + test_overlong_ssid_is_refused(); + test_rsn_without_privacy_is_not_selected(); + test_only_infrastructure_is_offered(); + test_channel_validity(); + test_tie_break_across_the_wrap(); + test_rx_channel(); + test_only_beacons_are_observed(); + test_expire(); + test_select(); + test_select_open(); + test_mfp_required_is_skipped(); + test_eviction_when_full(); + test_wanted_ssid_survives_a_flood(); + test_a_flood_of_the_wanted_ssid_cannot_lock_the_table(); + test_hidden_ssid_survives_its_beacons(); + + if (g_fail) { + std::printf("bss_table_selftest: %d failure(s)\n", g_fail); + return 1; + } + std::printf("bss_table_selftest: OK\n"); + return 0; +} diff --git a/tests/ccmp_capture_vectors.sh b/tests/ccmp_capture_vectors.sh new file mode 100755 index 00000000..0e2332e0 --- /dev/null +++ b/tests/ccmp_capture_vectors.sh @@ -0,0 +1,172 @@ +#!/bin/sh +# Regenerate tests/ccmp_kernel_vectors.h from a mac80211_hwsim rig. +# +# WHY THIS EXISTS. tests/ccmp_vectors.h pins the CIPHER against a third +# implementation, but its generator and src/sta/Ccmp.h share one author's +# reading of the 802.11 framing rules, so a CCM nonce with a zero Flags octet +# - wrong for every TID except 0 - would stay green through that whole vector +# suite. This script does not read the standard at all. It +# makes the LINUX KERNEL encrypt frames and copies the bytes. +# +# NO HARDWARE. Two virtual radios, so this does not touch the bench. It does +# need root (module load, netns, a monitor capture) and hostapd, +# wpa_supplicant and tcpdump on PATH. +# +# THE TID IS THE WHOLE POINT. hostapd runs with wmm_enabled=1, so the station +# sends QoS data frames; `ping -Q` does NOT give exact TID control (setting +# IP_TOS sets sk_priority through ip_tos2prio[] before cfg80211_classify8021d +# ever sees the DSCP), so the traffic generator sets SO_PRIORITY to 256+tid, +# which that function special-cases as "user priority tid, verbatim". That is +# what reaches all eight. +# +# The extracted frames' key material is a throwaway lab PSK on virtual radios +# and protects nothing. +set -eu + +HERE=$(cd "$(dirname "$0")" && pwd) +WORK=${WORK:-$(mktemp -d /tmp/ccmpvec.XXXXXX)} +NS=ccmpvec_sta +PSK=ccmpvectors123 +SSID=ccmpvec + +[ "$(id -u)" = 0 ] || { echo "this needs root"; exit 1; } + +cleanup() { + # BY PID, and only the processes this run started. A name or command-line + # match would also hit somebody else's capture or hostapd, and the host's + # own wpa_supplicant: `ip netns exec` does not change the PID namespace. + for p in ${CAP_PID:-} ${HOSTAPD_PID:-} ${WPA_PID:-}; do + kill "$p" 2>/dev/null || true + done + [ "${NS_OURS:-no}" = yes ] && ip netns del "$NS" 2>/dev/null || true + [ "${HWSIM_OURS:-no}" = yes ] && rmmod mac80211_hwsim 2>/dev/null || true +} +trap cleanup EXIT + +echo "--- two virtual radios" +# REFUSE, do not reuse: an already-loaded hwsim is somebody else's rig (or a +# leftover), and if it is in use the rmmod fails, modprobe is a no-op, and +# "the two highest phys" can then include a REAL adapter - which would be +# moved into the namespace and destroyed with it. +if [ -d /sys/module/mac80211_hwsim ]; then + echo "mac80211_hwsim is already loaded - refusing (unload it if it is yours)" + exit 1 +fi +if ip netns list 2>/dev/null | awk '{print $1}' | grep -qx "$NS"; then + echo "netns $NS already exists - refusing to use or delete it" + exit 1 +fi +PHYS_BEFORE=$(ls /sys/class/ieee80211 2>/dev/null || true) +modprobe mac80211_hwsim radios=2 +HWSIM_OURS=yes +sleep 2 +# The phys hwsim just CREATED - the set difference against the listing +# before the modprobe, not "the two highest-numbered", which is a guess that +# can pick a real adapter - and there must be exactly two. +PHYS=$(ls /sys/class/ieee80211 | grep -vxF "$PHYS_BEFORE" | sed 's/phy//' | sort -n) +[ "$(echo "$PHYS" | grep -c .)" = 2 ] || { echo "expected 2 new hwsim phys, got: $PHYS"; exit 1; } +AP_PHY=phy$(echo "$PHYS" | head -1) +STA_PHY=phy$(echo "$PHYS" | tail -1) +if_for_phy() { + for l in /sys/class/net/*/phy80211; do + [ -e "$l" ] || continue + if [ "$(basename "$(readlink -f "$l")")" = "$1" ]; then + basename "$(dirname "$l")" + return + fi + done +} +AP_IF=$(if_for_phy "$AP_PHY") +STA_IF=$(if_for_phy "$STA_PHY") +[ -n "$AP_IF" ] && [ -n "$STA_IF" ] || { echo "no hwsim interfaces"; exit 1; } +echo " AP $AP_PHY/$AP_IF STA $STA_PHY/$STA_IF" +nmcli dev set "$AP_IF" managed no 2>/dev/null || true +nmcli dev set "$STA_IF" managed no 2>/dev/null || true + +echo "--- the station gets its own namespace" +ip netns add "$NS" +NS_OURS=yes +# A cfg80211 interface cannot be moved with `ip link set netns`; the whole phy +# moves or nothing does. +iw phy "$STA_PHY" set netns name "$NS" +ip netns exec "$NS" ip link set "$STA_IF" up +ip netns exec "$NS" ip addr add 10.77.0.2/24 dev "$STA_IF" + +cat > "$WORK/hostapd.conf" < "$WORK/wpa.conf" <"$WORK/tcpdump.log" 2>&1 & +CAP_PID=$! +sleep 2 +# $! is the daemon itself: a background child of a non-interactive shell is +# not a process-group leader, so setsid (and `ip netns exec`) exec in place. +setsid hostapd -dd -K -t "$WORK/hostapd.conf" >"$WORK/hostapd.log" 2>&1 & +HOSTAPD_PID=$! +sleep 4 +grep -q AP-ENABLED "$WORK/hostapd.log" || { echo "hostapd did not come up"; exit 1; } +ip addr add 10.77.0.1/24 dev "$AP_IF" +# -K is what dumps the derived keys; without it there is no TK to extract. +ip netns exec "$NS" setsid wpa_supplicant -i "$STA_IF" -c "$WORK/wpa.conf" \ + -dd -K -t -f "$WORK/wpa.log" >/dev/null 2>&1 & +WPA_PID=$! +i=0 +while [ $i -lt 30 ]; do + grep -q CTRL-EVENT-CONNECTED "$WORK/wpa.log" 2>/dev/null && break + i=$((i + 1)); sleep 1 +done +grep -q CTRL-EVENT-CONNECTED "$WORK/wpa.log" || { echo "no association"; exit 1; } +TK=$(grep -m1 'WPA: TK - hexdump' "$WORK/wpa.log" | sed 's/.*): //; s/ //g') +[ ${#TK} = 32 ] || { echo "could not read the TK"; exit 1; } +echo " associated, TK is 16 bytes" + +echo "--- one datagram per user priority, in each direction" +ip netns exec "$NS" ping -c 1 -W 3 10.77.0.1 >/dev/null 2>&1 || true +ip netns exec "$NS" python3 "$HERE/ccmp_tid_send.py" 10.77.0.1 +python3 "$HERE/ccmp_tid_send.py" 10.77.0.2 +sleep 2 +kill "$CAP_PID" 2>/dev/null || true +sleep 1 + +python3 "$HERE/ccmp_extract_vectors.py" "$WORK/cap.pcap" "$TK" \ + "$HERE/ccmp_kernel_vectors.h" + +# VERIFY ITS OWN OUTPUT. The extractor refuses a short vector set and an FCS +# flag, but a TK read from the wrong hexdump line would still produce a +# plausible-looking header that only fails when somebody else builds. Running +# the test here is what makes a bad regeneration this script's failure. +BUILD=${BUILD:-$HERE/../build} +if [ -f "$BUILD/CMakeCache.txt" ]; then + echo "--- rebuilding and running CcmpSelftest against the new vectors" + cmake --build "$BUILD" --target CcmpSelftest >/dev/null 2>&1 \ + || { echo "FAIL: CcmpSelftest does not build"; exit 1; } + "$BUILD/CcmpSelftest" || { echo "FAIL: the new vectors do not verify"; exit 1; } +else + echo "--- no build tree at $BUILD; build and run CcmpSelftest yourself" +fi +echo "--- done" diff --git a/tests/ccmp_extract_vectors.py b/tests/ccmp_extract_vectors.py new file mode 100644 index 00000000..a92389df --- /dev/null +++ b/tests/ccmp_extract_vectors.py @@ -0,0 +1,198 @@ +"""Turn a hwsim capture into C test vectors for src/sta/Ccmp.h. + +This script does NO crypto. It only cuts the radiotap header off, picks one +protected QoS data frame per (direction, TID), and writes the raw bytes out. +That is deliberate: if the vectors' expected values came from a Python +reimplementation of CCMP they would encode the same reading of the spec the +header does, and a misreading shared by both (a zero-flags nonce, say) would +stay green through a full vector suite. Here the expected value IS the byte +stream the Linux kernel put on the air, and the oracle is the MIC. + +NOT REPRODUCIBLE FROM THE TREE ALONE, and so there is no --check mode: the +input is a pcap from a mac80211_hwsim run (tests/ccmp_capture_vectors.sh, +root + hwsim), and that capture is not checked in - a fresh run negotiates a +fresh TK (new handshake nonces), so it would not reproduce these bytes +anyway. The checked-in +tests/ccmp_kernel_vectors.h is the artifact; this script documents how it was +cut. +""" +import struct +import sys +from collections import OrderedDict + +pcap = sys.argv[1] +tk = bytes.fromhex(sys.argv[2]) +out_path = sys.argv[3] +assert len(tk) == 16 + + +def radiotap_flags(pkt): + """The radiotap Flags octet, or None when the header does not carry it. + + Only ONE field is ever needed here, and only one field can precede it: + radiotap fields appear in present-bit order, Flags is bit 1, and the only + lower bit is TSFT. So this walks exactly that much and refuses everything + else, with no table of other field sizes: such a table would never be + read, and a table that is never read is a table nobody notices is wrong. + + hwsim emits two different present words in one capture, which is why the + offset of Flags cannot simply be hardcoded. + """ + rt_len = struct.unpack(' 76: + lines.append(cur.rstrip()) + cur = ' ' + cur += tok + ', ' + lines.append(cur.rstrip().rstrip(',')) + return '\n'.join(lines) + + +with open(out_path, 'w') as f: + f.write('''/* ccmp_kernel_vectors.h - CCMP frames produced by the LINUX KERNEL. + * + * GENERATED, do not edit by hand. See tests/ccmp_capture_vectors.sh, which + * builds a two-radio mac80211_hwsim rig, runs hostapd (WMM on) and + * wpa_supplicant over it, sends one datagram per 802.11 user priority in each + * direction, and cuts these bytes out of the capture. No hardware. + * + * WHY THESE EXIST. tests/ccmp_vectors.h pins the CIPHER against a third + * implementation, but its generator and src/sta/Ccmp.h share one author's + * reading of the framing rules, so a misreading shared by both - a CCM nonce + * with a zero flags octet, say - stays green through that suite. These are + * not a second reading of the specification. They are the actual bytes + * mac80211 put on the air, with the MIC as the oracle: if our AAD or nonce + * differs from the kernel's by a single bit, the tag does not verify. + * + * The TID is the point. The devourer AP advertises neither WMM nor HT, so no + * station ever sends it a QoS data frame and TID 1..7 are unreachable on the + * bench. Here all eight appear, in both directions. + * + * KEY MATERIAL IS FROM A THROWAWAY LAB PSK ("ccmpvectors123") on virtual + * radios; it protects nothing. + */ +#ifndef DEVOURER_TEST_CCMP_KERNEL_VECTORS_H +#define DEVOURER_TEST_CCMP_KERNEL_VECTORS_H + +#include +#include + +namespace devourer { +namespace test { + +/* The pairwise TK wpa_supplicant derived for this association. */ +static const uint8_t kKernelTk[16] = { +''') + f.write(carr(tk)) + f.write(''' +}; + +struct KernelCcmpVector { + const char* name; /* direction and TID, for a failure message */ + uint8_t tid; /* the TID in the QoS control field */ + size_t hdr_len; /* 26: a 3-address QoS data header */ + const uint8_t* mpdu; /* the whole protected MPDU, no FCS */ + size_t mpdu_len; +}; + +''') + names = [] + for (d, tid), v in picked.items(): + sym = 'kKv_%s_tid%d' % (d, tid) + names.append((sym, d, tid, len(v['mpdu']))) + f.write('static const uint8_t %s[] = {\n%s\n};\n\n' + % (sym, carr(v['mpdu']))) + f.write('static const KernelCcmpVector kKernelCcmpVectors[] = {\n') + for sym, d, tid, n in names: + f.write(' {"%s tid %d", %d, 26, %s, %d},\n' % (d, tid, tid, sym, n)) + f.write('''}; + +static const size_t kKernelCcmpVectorCount = + sizeof(kKernelCcmpVectors) / sizeof(kKernelCcmpVectors[0]); + +} // namespace test +} // namespace devourer + +#endif /* DEVOURER_TEST_CCMP_KERNEL_VECTORS_H */ +''') +print('wrote %d vectors from %d candidate frames -> %s' + % (len(picked), seen, out_path)) diff --git a/tests/ccmp_gen_vectors.py b/tests/ccmp_gen_vectors.py new file mode 100644 index 00000000..c1454b36 --- /dev/null +++ b/tests/ccmp_gen_vectors.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +"""Generate the CCMP known-answer vectors in tests/ccmp_vectors.h. + +WHAT THESE VECTORS ACTUALLY PIN — read this before trusting them. + +The ciphertext and MIC come from python-cryptography's AESCCM, which is a +genuinely third-party cipher. The 802.11 FRAMING below, however, is a +transcription of the same rules src/sta/Ccmp.h implements, written by the same +author from the same reading of 802.11-2016 12.5.3.3. It is NOT an +independent check of the framing: the masks, their order and even the idioms +match the C line for line, so a misreading shared by both passes here. + +So, honestly: + + * These vectors DO pin the cipher plumbing - that the AAD and nonce this + module builds reach AES-CCM intact and unmutated, and that ciphertext, + MIC, PN and header packing round-trip byte-exactly. A regression in any of + that turns the cell red. + * These vectors DO NOT independently verify the framing RULES. A shared + misreading of the standard passes. What pins the rules is the direct + assertions in tests/ccmp_selftest.cpp (one rule at a time, written against + the standard's text) and tests/ccmp_kernel_vectors.h (frames the Linux + kernel encrypted, at all eight TIDs, with the MIC as the oracle). + +THE REMAINING GAP. The IEEE 802.11-2016 Annex J.4 CCMP test vector - an +answer computed by people who were not reading this code, rather than an +interop reference - is not in the tree, and the selftest is shaped to take +it. It must be copied from the standard, never transcribed from memory: a +known-answer test whose answer is remembered rather than sourced is a second +guess by the same author, and if it disagreed the natural move would be to +"correct" it until it matched, which is how a wrong implementation acquires a +passing test. hostapd's wlantest vectors are the next-best source. + +Regenerate: python3 tests/ccmp_gen_vectors.py > tests/ccmp_vectors.h +Verify: python3 tests/ccmp_gen_vectors.py --check + (regenerates in memory and byte-compares with the checked-in + header; exit 0 match, 1 mismatch, 77 = python-cryptography + missing, which ctest reports as skipped. Registered as the + ccmp_vectors_generated ctest cell.) +Deterministic - every input below is fixed. +""" +import io +import os +import sys + +CHECK = '--check' in sys.argv[1:] +TARGET = os.path.join(os.path.dirname(os.path.abspath(__file__)), + 'ccmp_vectors.h') + +try: + from cryptography.hazmat.primitives.ciphers.aead import AESCCM +except ImportError: + if CHECK: + print('ccmp_gen_vectors --check: python-cryptography is not ' + 'installed; skipped', file=sys.stderr) + sys.exit(77) + sys.exit("needs python-cryptography (pip install cryptography)") + +if CHECK: + _stdout = sys.stdout + sys.stdout = io.StringIO() + + +def is_qos(fc0): + return (fc0 & 0x8C) == 0x88 + + +def hdr_len_of(hdr): + # Mirrors src/sta/Dot11.h data_hdr_len(), INCLUDING the HT Control field a + # QoS frame with the Order bit carries. Being a mirror, it shares any + # omission with the C - which is why the qos_order_htc case below exists + # and why the kernel vectors, not this file, are the independent check. + n = 24 + if (hdr[1] & 0x03) == 0x03: + n += 6 + if is_qos(hdr[0]): + n += 2 + if hdr[1] & 0x80: + n += 4 + return n + + +def ccmp_aad(hdr): + """802.11-2016 12.5.3.3.3.""" + four = (hdr[1] & 0x03) == 0x03 + qos = is_qos(hdr[0]) + mgmt = (hdr[0] & 0x0C) == 0x00 + fc = hdr[0] | (hdr[1] << 8) + if not mgmt: + fc &= ~0x0070 # subtype masked only for non-management + fc &= ~0x0800 # retry + fc &= ~0x1000 # pwr mgmt + fc &= ~0x2000 # more data + if qos: + fc &= ~0x8000 # +HTC/Order, masked for QoS data only + fc |= 0x4000 # protected + seq = (hdr[22] | (hdr[23] << 8)) & 0x000F + aad = bytes([fc & 0xFF, fc >> 8]) + bytes(hdr[4:22]) + \ + bytes([seq & 0xFF, seq >> 8]) + if four: + aad += bytes(hdr[24:30]) + if qos: + q = 30 if four else 24 + aad += bytes([hdr[q] & 0x0F, 0x00]) + return aad + + +def ccmp_nonce(hdr, a2, pn): + """802.11-2016 12.5.3.3.4: Nonce Flags | A2 | PN(6, big-endian). + + The flags octet is Priority (b0..b3) | Management (b4) - NOT zero. A zero + octet here and in src/sta/Ccmp.h would agree with each other and keep + these vectors green: independence of implementation (python-cryptography + vs OpenSSL) is not independence of interpretation. + """ + four_addr = (hdr[1] & 0x03) == 0x03 + flags = 0 + if is_qos(hdr[0]): + flags = hdr[30 if four_addr else 24] & 0x0F + if (hdr[0] & 0x0C) == 0x00: # type 0 = management + flags |= 0x10 + return bytes([flags]) + bytes(a2) + bytes((pn >> (8 * (5 - i))) & 0xFF + for i in range(6)) + + +def ccmp_hdr(pn, key_id): + return bytes([ + pn & 0xFF, (pn >> 8) & 0xFF, 0x00, + 0x20 | ((key_id & 3) << 6), + (pn >> 16) & 0xFF, (pn >> 24) & 0xFF, + (pn >> 32) & 0xFF, (pn >> 40) & 0xFF, + ]) + + +def c_array(name, data): + body = ',\n '.join( + ', '.join('0x%02x' % b for b in data[i:i + 12]) + for i in range(0, len(data), 12)) + return 'static const uint8_t %s[%d] = {\n %s\n};\n' % ( + name, len(data), body) + + +# Each header below is a REAL frame of its shape - the right length for what +# its frame control claims. A "QoS" header of 24 bytes would put the CCMP +# header where the QoS Control field belongs (a frame that cannot exist on +# air), and a to-DS+from-DS header is a 4-address frame with a 4-address AAD. +CASES = [ + # 3-address data, from-DS. The baseline shape both AP harnesses air. + ('basic', bytes(range(0x10, 0x20)), + bytes.fromhex('02424475d600'), 0x000000000001, 0, + bytes.fromhex('0842000002aabbccddee02424475d60002424475d6000000'), + bytes.fromhex('aaaa030000000800') + b'devourer station mode'), + + # RETRY SET, plus pwr-mgmt and more-data, on a plain 3-address to-DS frame. + # Retry must actually be set here, or no vector can detect a missing + # Retry mask - the classic slip. fc1 = 0x39: to-DS, retry, pwr-mgmt, + # more-data. Subtype bits set in fc0 too. + ('masked_fc', bytes.fromhex('c97c1f67ce371185514a8a19f2bdd52f'), + bytes.fromhex('001122334455'), 0x0b5039768834, 2, + bytes.fromhex('7839') + bytes.fromhex('1234') + + bytes.fromhex('001122334455') + bytes.fromhex('66778899aabb') + + bytes.fromhex('ccddeeff0011') + bytes.fromhex('3412'), + b'\x01\x02\x03\x04'), + + # Non-zero fragment number: the AAD keeps it while masking the sequence + # number, the half of that rule most likely to be dropped. + ('fragment', bytes(16), + bytes.fromhex('020000000001'), 0xffffffffffff, 1, + bytes.fromhex('0842000002000000000202000000000102000000000175f0'), + b'x' * 64), + + # A REAL QoS data MPDU: 26-byte header with the QoS Control field at bytes + # 24-25, TID 5. The AAD gains the TID octets. A station omitting them + # against a peer that includes them produces a MIC failure with no + # diagnostic at either end, and a station's data plane is QoS against any + # 802.11n+ AP - so this is the shape a station's data plane depends on. + ('qos_tid5', bytes.fromhex('0f0e0d0c0b0a09080706050403020100'), + bytes.fromhex('aabbccddeeff'), 0x000000abcdef, 0, + bytes.fromhex('8841000002424475d600aabbccddeeff02424475d6002010') + + bytes.fromhex('0500'), + bytes.fromhex('aaaa030000000806') + b'qos-tid-5-body'), + + # QoS WITH THE ORDER BIT (+HTC): fc1 = 0xC1, so an HT Control field + # follows the QoS Control and data_hdr_len() reports 30. The AAD must MASK + # bit 15 here - it does not on a non-QoS frame, where the same bit is the + # strictly-ordered service class. Without the mask every +HTC frame fails + # its MIC against any conforming peer, and no other case here sets the + # bit. + ('qos_order_htc', bytes.fromhex('101112131415161718191a1b1c1d1e1f'), + bytes.fromhex('aabbccddeeff'), 0x0000000000aa, 5, + bytes.fromhex('88c1000002424475d600aabbccddeeff02424475d6003050') + + bytes.fromhex('0300') + bytes.fromhex('00000000'), + bytes.fromhex('aaaa030000000800') + b'htc-ordered'), + + # 4-address QoS: the AAD gains A4 as well as the TID, 30 octets in all. + # Nothing in the tree builds one yet, which is exactly why it is here - + # the AAD length branch is otherwise dead code nobody would notice broken. + ('four_addr_qos', bytes.fromhex('00112233445566778899aabbccddeeff'), + bytes.fromhex('020000000011'), 0x0000000000ff, 3, + bytes.fromhex('8803000002000000001102000000001102000000002201f0') + + bytes.fromhex('020000000033') + bytes.fromhex('0700'), + b'four-address-qos'), +] + +print('/* GENERATED by tests/ccmp_gen_vectors.py - do not edit.') +print(' *') +print(' * CCMP known-answer vectors. The ciphertext and MIC come from') +print(" * python-cryptography's AESCCM; the 802.11 framing is transcribed in") +print(' * that script from the same reading of the standard as src/sta/Ccmp.h,') +print(' * so these pin the CIPHER PLUMBING and NOT the framing rules. The') +print(' * script header explains what that does and does not buy; the direct') +print(' * assertions in tests/ccmp_selftest.cpp are what pin the rules. */') +print('#ifndef DEVOURER_TESTS_CCMP_VECTORS_H') +print('#define DEVOURER_TESTS_CCMP_VECTORS_H') +print() +print('#include ') +print('#include ') +print() +print('struct CcmpVector {') +print(' const char* name;') +print(' const uint8_t* tk;') +print(' const uint8_t* a2;') +print(' uint64_t pn;') +print(' uint8_t key_id;') +print(' const uint8_t* hdr; size_t hdr_len;') +print(' const uint8_t* plain; size_t plain_len;') +print(' const uint8_t* mpdu; size_t mpdu_len; /* hdr|ccmp|ct|mic */') +print(' size_t aad_len; /* 22, +6 four-addr, +2 QoS */') +print('};') +print() + +entries = [] +for label, tk, a2, pn, kid, hdr, plain in CASES: + hl = hdr_len_of(hdr) + assert len(hdr) == hl, (label, len(hdr), hl) + aad = ccmp_aad(hdr) + nonce = ccmp_nonce(hdr, a2, pn) + blob = AESCCM(tk, tag_length=8).encrypt(nonce, plain, aad) + prot = bytearray(hdr) + prot[1] |= 0x40 + mpdu = bytes(prot) + ccmp_hdr(pn, kid) + blob + print(c_array('kTk_' + label, tk)) + print(c_array('kA2_' + label, a2)) + print(c_array('kHdr_' + label, hdr)) + print(c_array('kPlain_' + label, plain)) + print(c_array('kMpdu_' + label, mpdu)) + entries.append(' {"%s", kTk_%s, kA2_%s, 0x%012xULL, %d,\n' + ' kHdr_%s, sizeof kHdr_%s,\n' + ' kPlain_%s, sizeof kPlain_%s,\n' + ' kMpdu_%s, sizeof kMpdu_%s, %d},' + % (label, label, label, pn, kid, label, label, label, + label, label, label, len(aad))) + +print('static const CcmpVector kCcmpVectors[] = {') +print('\n'.join(entries)) +print('};') +print('static const size_t kCcmpVectorCount =') +print(' sizeof kCcmpVectors / sizeof kCcmpVectors[0];') +print() +print('#endif /* DEVOURER_TESTS_CCMP_VECTORS_H */') + +if CHECK: + got = sys.stdout.getvalue() + sys.stdout = _stdout + with open(TARGET, newline='') as f: + # A Windows checkout may have converted line endings; the content is + # what is compared, not the platform's newline convention. + want = f.read().replace('\r\n', '\n') + if got != want: + g, w = got.splitlines(), want.splitlines() + first = next((i for i in range(min(len(g), len(w))) if g[i] != w[i]), + min(len(g), len(w))) + print('ccmp_gen_vectors --check: tests/ccmp_vectors.h does NOT match ' + 'the generator (first difference at line %d)' % (first + 1), + file=sys.stderr) + sys.exit(1) + print('ccmp_gen_vectors --check: tests/ccmp_vectors.h reproduced byte ' + 'for byte') diff --git a/tests/ccmp_kernel_vectors.h b/tests/ccmp_kernel_vectors.h new file mode 100644 index 00000000..52c26aa9 --- /dev/null +++ b/tests/ccmp_kernel_vectors.h @@ -0,0 +1,266 @@ +/* ccmp_kernel_vectors.h - CCMP frames produced by the LINUX KERNEL. + * + * GENERATED, do not edit by hand. See tests/ccmp_capture_vectors.sh, which + * builds a two-radio mac80211_hwsim rig, runs hostapd (WMM on) and + * wpa_supplicant over it, sends one datagram per 802.11 user priority in each + * direction, and cuts these bytes out of the capture. No hardware. + * + * WHY THESE EXIST. tests/ccmp_vectors.h pins the CIPHER against a third + * implementation, but its generator and src/sta/Ccmp.h share one author's + * reading of the framing rules, so a misreading shared by both - a CCM nonce + * with a zero flags octet, say - stays green through that suite. These are + * not a second reading of the specification. They are the actual bytes + * mac80211 put on the air, with the MIC as the oracle: if our AAD or nonce + * differs from the kernel's by a single bit, the tag does not verify. + * + * The TID is the point. The devourer AP advertises neither WMM nor HT, so no + * station ever sends it a QoS data frame and TID 1..7 are unreachable on the + * bench. Here all eight appear, in both directions. + * + * KEY MATERIAL IS FROM A THROWAWAY LAB PSK ("ccmpvectors123") on virtual + * radios; it protects nothing. + */ +#ifndef DEVOURER_TEST_CCMP_KERNEL_VECTORS_H +#define DEVOURER_TEST_CCMP_KERNEL_VECTORS_H + +#include +#include + +namespace devourer { +namespace test { + +/* The pairwise TK wpa_supplicant derived for this association. */ +static const uint8_t kKernelTk[16] = { + 0x91, 0x11, 0xdf, 0x18, 0xbb, 0x36, 0x3a, 0x57, 0xe7, 0x49, 0x25, 0xe0, + 0xd5, 0x00, 0xc8, 0x83 +}; + +struct KernelCcmpVector { + const char* name; /* direction and TID, for a failure message */ + uint8_t tid; /* the TID in the QoS control field */ + size_t hdr_len; /* 26: a 3-address QoS data header */ + const uint8_t* mpdu; /* the whole protected MPDU, no FCS */ + size_t mpdu_len; +}; + +static const uint8_t kKv_up_tid0[] = { + 0x88, 0x41, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, + 0x00, 0x00, 0x01, 0x00, 0x33, 0x33, 0x00, 0x00, 0x00, 0x16, 0x00, 0x00, + 0x00, 0x00, 0x01, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x4e, 0x7e, + 0xe4, 0x24, 0x40, 0x53, 0x13, 0x04, 0x1d, 0x62, 0xd9, 0x4f, 0xc3, 0xf4, + 0x64, 0xf1, 0x6a, 0x6b, 0xd3, 0x71, 0x0c, 0xd2, 0x4f, 0x6a, 0xf3, 0x1c, + 0x5c, 0xf5, 0x89, 0x8d, 0x08, 0x06, 0xc6, 0x06, 0x9b, 0xd7, 0x1a, 0x1a, + 0x89, 0x97, 0xab, 0x53, 0xa7, 0xb3, 0xbb, 0x08, 0x61, 0x13, 0xbd, 0x26, + 0x04, 0xcb, 0x0b, 0xdf, 0x61, 0xb5, 0x4e, 0x1f, 0xf6, 0xa0, 0xdb, 0x3d, + 0xfd, 0xa0, 0xda, 0xb5, 0x9b, 0x32, 0x9e, 0x91, 0xf1, 0xe3, 0xb8, 0xcb, + 0x20, 0xe3, 0x2e, 0xcd, 0x74, 0xd7, 0xe6, 0x09, 0x70, 0x60, 0xdb, 0x8c, + 0x34, 0x21, 0x10, 0x34, 0x84, 0xc7 +}; + +static const uint8_t kKv_down_tid0[] = { + 0x88, 0x42, 0xd5, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x02, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x01, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0xbd, 0xcc, + 0xc6, 0xbd, 0x82, 0x87, 0xf9, 0xa4, 0xee, 0xe8, 0x81, 0x50, 0x2d, 0x66, + 0x3a, 0x0e, 0xae, 0x2d, 0xd9, 0x27, 0xaf, 0x61, 0xc7, 0x6d, 0x5f, 0x4c, + 0x98, 0x41, 0x06, 0xcf, 0x09, 0x72, 0x18, 0xac, 0xdd, 0x16, 0xec, 0x45, + 0x1f, 0xa1, 0xf9, 0x34, 0x28, 0x02 +}; + +static const uint8_t kKv_down_tid7[] = { + 0x88, 0x42, 0xd5, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x02, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, + 0x07, 0x00, 0x03, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x63, 0xd6, + 0x4f, 0x7a, 0x75, 0x2d, 0x49, 0x2e, 0x94, 0x57, 0xe3, 0xd6, 0xb4, 0x72, + 0x5e, 0x6f, 0x8d, 0x6d, 0x53, 0xfd, 0xe3, 0x5d, 0x5f, 0xb0, 0x9c, 0x88, + 0x4c, 0x44, 0x87, 0x59, 0x45, 0xc9, 0x91, 0xff, 0x85, 0x1d, 0x98, 0x5b, + 0xbe, 0x5e, 0x44, 0x72, 0x1a, 0x36, 0x3e, 0xd9, 0xf7, 0xea, 0x46, 0x9b, + 0x90, 0xc0, 0x3a, 0xf2, 0x8a, 0x4d, 0x72, 0x51, 0xf8, 0x1d, 0x1a, 0xa4, + 0x8d, 0x6f, 0x0d, 0xe6, 0x86, 0xf8, 0x72, 0x62, 0xbc, 0x05, 0x13, 0x90, + 0x7f, 0x3e, 0xb8, 0x5f, 0x46, 0xc6, 0x09, 0x8f, 0x85, 0x6c, 0x71, 0x28, + 0xc7, 0x12, 0x1c, 0x86, 0x6b, 0x41, 0x1d, 0xf0, 0x95, 0x1e, 0xe9, 0xd3, + 0x87 +}; + +static const uint8_t kKv_up_tid1[] = { + 0x88, 0x41, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, + 0x00, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x06, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x61, 0x40, + 0xe8, 0xc5, 0x54, 0xf5, 0x02, 0x15, 0x0c, 0xa5, 0xff, 0x61, 0x30, 0xc2, + 0xcd, 0xde, 0x05, 0xe7, 0x46, 0x02, 0x5e, 0x30, 0xee, 0xd1, 0x73, 0x7c, + 0x93, 0x6a, 0x1f, 0x3b, 0xee, 0x08, 0xb3, 0xfa, 0x97, 0x23, 0x0c, 0xbb, + 0x7d, 0x88, 0x68, 0x98, 0x7b, 0xc7, 0x85, 0x9d, 0x16, 0xb4, 0x9f, 0x09, + 0xdf, 0x45, 0x40, 0xad, 0x26, 0x78, 0x8d, 0x3f, 0x3d, 0xce, 0x7d, 0x13, + 0xcd, 0x22, 0xc1, 0xf1, 0xec, 0xfb, 0xa1, 0x3d, 0x02 +}; + +static const uint8_t kKv_up_tid2[] = { + 0x88, 0x41, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, + 0x00, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x02, 0x00, 0x07, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0xa3, 0xde, + 0xee, 0x46, 0x17, 0x01, 0xf4, 0x60, 0x69, 0x2c, 0xf9, 0xc7, 0x4f, 0x06, + 0x86, 0x10, 0xb5, 0x28, 0x96, 0x64, 0x17, 0xce, 0xb6, 0xe0, 0x7a, 0xbb, + 0x9c, 0xe2, 0x0f, 0x71, 0x46, 0x08, 0x92, 0x50, 0x8e, 0x66, 0x06, 0x90, + 0x9f, 0xf5, 0xb1, 0x06, 0x3b, 0x47, 0xcb, 0xf0, 0x08, 0xca, 0x7c, 0x96, + 0xfc, 0xfe, 0xa5, 0x51, 0x05, 0x34, 0x5a, 0xff, 0xe2, 0xab, 0xa3, 0xec, + 0x99, 0x51, 0x86, 0xe3, 0x94, 0xb4, 0x88, 0x62, 0x71 +}; + +static const uint8_t kKv_up_tid3[] = { + 0x88, 0x41, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, + 0x00, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x03, 0x00, 0x08, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x7e, 0xd4, + 0x7e, 0x16, 0x7e, 0xf2, 0x4e, 0x3c, 0x91, 0x25, 0x5c, 0x55, 0xfe, 0x13, + 0xb0, 0xd5, 0xc4, 0xd0, 0x13, 0xcd, 0xe0, 0x52, 0x56, 0xc8, 0x49, 0x90, + 0x69, 0x7a, 0x76, 0x8c, 0x75, 0xa0, 0xb0, 0xcd, 0xda, 0xd7, 0x4e, 0x31, + 0x86, 0x7d, 0x3f, 0xb3, 0xc9, 0xf1, 0x3d, 0x6d, 0xfd, 0xdc, 0xb6, 0x96, + 0xe3, 0xd9, 0xe6, 0x7b, 0xbb, 0x1f, 0xd0, 0xc5, 0x46, 0x6c, 0x19, 0x59, + 0xa3, 0xd0, 0x2e, 0xd0, 0x47, 0x4b, 0xa2, 0x5d, 0x66 +}; + +static const uint8_t kKv_up_tid4[] = { + 0x88, 0x41, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, + 0x00, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x04, 0x00, 0x09, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0xa4, 0x4e, + 0xdf, 0x36, 0x40, 0xaa, 0x68, 0xb8, 0xdb, 0xa3, 0x78, 0x3d, 0x74, 0x73, + 0x1b, 0x6a, 0x04, 0xdc, 0x15, 0x6f, 0xde, 0x36, 0xc7, 0xe0, 0xa7, 0xdb, + 0x51, 0xed, 0xf6, 0xbb, 0x43, 0x97, 0x4b, 0x15, 0xaa, 0x7e, 0x52, 0x94, + 0xcd, 0x0a, 0xdd, 0xad, 0xfc, 0xbd, 0x95, 0x2c, 0x07, 0xa3, 0x62, 0xb0, + 0x06, 0xb3, 0x2f, 0x14, 0x29, 0x88, 0x97, 0x95, 0x24, 0x82, 0x68, 0x17, + 0xa1, 0xbf, 0xd9, 0xfb, 0x66, 0xa3, 0x98, 0x0f, 0x82 +}; + +static const uint8_t kKv_up_tid5[] = { + 0x88, 0x41, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, + 0x00, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x05, 0x00, 0x0b, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0xb0, 0x8e, + 0x56, 0xdb, 0xad, 0x93, 0x78, 0x1c, 0xb8, 0x29, 0xac, 0x46, 0xe4, 0xe6, + 0x23, 0x57, 0x5b, 0x1c, 0x07, 0xba, 0xfb, 0x6f, 0x4d, 0x69, 0xb3, 0x96, + 0xbd, 0xef, 0xc0, 0xa9, 0xda, 0xcc, 0xc2, 0x4f, 0x34, 0x2d, 0x30, 0xe7, + 0xcd, 0xde, 0xea, 0xd3, 0x04, 0x83, 0x38, 0x0e, 0x4d, 0x22, 0x72, 0xec, + 0xcb, 0xb3, 0x42, 0x01, 0xce, 0x6a, 0x7c, 0x88, 0xe6, 0x38, 0x2d, 0xc7, + 0xb8, 0xf3, 0xa5, 0x7c, 0x6d, 0xcf, 0xbe, 0x23, 0xac +}; + +static const uint8_t kKv_up_tid6[] = { + 0x88, 0x41, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, + 0x00, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x06, 0x00, 0x0c, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x4f, 0x3d, + 0xe1, 0xff, 0xba, 0x59, 0x1d, 0xea, 0x1a, 0xc1, 0xeb, 0xe4, 0x12, 0x42, + 0xd0, 0x5d, 0xf3, 0xf2, 0x70, 0xc9, 0xf2, 0x0d, 0xb4, 0x7d, 0xbd, 0x0e, + 0x06, 0x5f, 0x60, 0x8c, 0xf7, 0xa0, 0xc7, 0x89, 0x67, 0x48, 0x0e, 0x67, + 0x44, 0x32, 0x22, 0xca, 0xe3, 0x5d, 0x72, 0x29, 0xfc, 0x32, 0x91, 0xc0, + 0xf7, 0xf7, 0x5d, 0x78, 0x91, 0x86, 0x72, 0x12, 0x4b, 0x46, 0xdd, 0x30, + 0x1b, 0xc5, 0xf0, 0x92, 0x11, 0x58, 0xc4, 0xa4, 0x0c +}; + +static const uint8_t kKv_up_tid7[] = { + 0x88, 0x41, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, + 0x00, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, + 0x07, 0x00, 0x0d, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0xa3, 0x28, + 0xfe, 0x8c, 0x03, 0xf5, 0x79, 0x73, 0x5d, 0xa3, 0x88, 0x3b, 0xf4, 0x6a, + 0x68, 0xf2, 0xf9, 0x1f, 0x5e, 0xc7, 0x3b, 0x54, 0x25, 0x8a, 0x8c, 0x00, + 0x13, 0xfe, 0xfe, 0x0f, 0xcf, 0x62, 0xde, 0x7b, 0x4a, 0x72, 0xd2, 0xca, + 0x77, 0xb0, 0x12, 0xcf, 0xb3, 0x8a, 0x90, 0xf8, 0xe7, 0xc1, 0x4f, 0x49, + 0x04, 0x22, 0x41, 0xa2, 0x1a, 0xb9, 0x8c, 0x77, 0xf3, 0xaa, 0x56, 0x0e, + 0x41, 0xf0, 0xc6, 0xf3, 0x03, 0x2c, 0xfa, 0xf8, 0x12 +}; + +static const uint8_t kKv_down_tid1[] = { + 0x88, 0x42, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x02, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x0b, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0xe6, 0x1b, + 0x39, 0xb8, 0x04, 0x84, 0x2a, 0x8c, 0x45, 0x76, 0x86, 0x04, 0xee, 0xef, + 0xcb, 0xd1, 0xf0, 0xb0, 0x8e, 0x5d, 0x36, 0x76, 0xe1, 0x53, 0xab, 0x72, + 0xcc, 0xb2, 0x6f, 0xf9, 0x90, 0x07, 0xa0, 0x29, 0x40, 0x7b, 0xd4, 0xd8, + 0xd9, 0x25, 0xd6, 0x7d, 0x12, 0x6e, 0xc8, 0x12, 0x4b, 0x28, 0xa3, 0x89, + 0x2d, 0xb0, 0x66, 0xe7, 0x15, 0x7e, 0x82, 0x88, 0x29, 0xd2, 0x40, 0xc0, + 0x00, 0x79, 0xfd, 0x75, 0x08, 0x74, 0x86, 0x7f, 0x19 +}; + +static const uint8_t kKv_down_tid2[] = { + 0x88, 0x42, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x02, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x02, 0x00, 0x0c, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0xca, 0x18, + 0xee, 0x81, 0x48, 0xf0, 0x6f, 0x03, 0x8c, 0x41, 0xa4, 0xb7, 0x0c, 0xaa, + 0x54, 0x67, 0x92, 0xb6, 0x10, 0x10, 0x70, 0x1a, 0xf2, 0xb8, 0x1c, 0x37, + 0x6f, 0x58, 0x9a, 0xe1, 0xbb, 0x1d, 0x0a, 0xbb, 0x05, 0x03, 0xb6, 0xcc, + 0x24, 0x6f, 0xf6, 0xb3, 0xc1, 0x9c, 0x1f, 0x09, 0x5b, 0x3b, 0xa5, 0x0c, + 0x73, 0xfa, 0x27, 0x8f, 0x85, 0x2f, 0x98, 0x9b, 0xdb, 0x1b, 0x5d, 0x20, + 0x4e, 0xe1, 0x6a, 0x3d, 0x62, 0x3e, 0xc1, 0x31, 0xa5 +}; + +static const uint8_t kKv_down_tid3[] = { + 0x88, 0x42, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x02, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x03, 0x00, 0x0d, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0xb1, 0xaf, + 0x84, 0x44, 0xa3, 0x09, 0x63, 0x89, 0x64, 0xa8, 0x12, 0xf4, 0xcf, 0xbd, + 0x1c, 0x08, 0x15, 0x21, 0x4d, 0x23, 0x7a, 0x23, 0xce, 0xd8, 0x54, 0xb1, + 0xf3, 0x6a, 0x41, 0x20, 0x56, 0x21, 0x2d, 0x21, 0xb3, 0xae, 0x17, 0x1f, + 0xcf, 0x92, 0x33, 0xc4, 0x83, 0x33, 0x92, 0x72, 0xb4, 0x9c, 0x12, 0x2a, + 0xa2, 0x4a, 0xe9, 0x71, 0x6d, 0x9f, 0xfe, 0x6c, 0x17, 0x35, 0xb1, 0xde, + 0xb6, 0x4f, 0x73, 0x39, 0x98, 0x17, 0x2e, 0x73, 0xcf +}; + +static const uint8_t kKv_down_tid4[] = { + 0x88, 0x42, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x02, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x04, 0x00, 0x0e, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0xd1, 0x2d, + 0x62, 0x24, 0xf4, 0xed, 0xbd, 0x39, 0x7b, 0x4a, 0xe8, 0xba, 0x7a, 0x58, + 0x45, 0x94, 0xd0, 0x86, 0xa5, 0xb8, 0xca, 0x78, 0xef, 0x1b, 0x31, 0x86, + 0x9b, 0x7f, 0xbb, 0x03, 0xae, 0xf0, 0x6f, 0x5f, 0x5e, 0x87, 0xd6, 0x9b, + 0x1a, 0xcc, 0x19, 0xd7, 0xd3, 0x27, 0x19, 0xea, 0x42, 0x1f, 0xf7, 0x99, + 0x91, 0xf7, 0xdc, 0x4c, 0x9d, 0x24, 0x53, 0xd1, 0x7d, 0x4d, 0x94, 0x90, + 0xc7, 0x98, 0xb5, 0xd3, 0x3d, 0x3a, 0x51, 0x48, 0x26 +}; + +static const uint8_t kKv_down_tid5[] = { + 0x88, 0x42, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x02, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x05, 0x00, 0x0f, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x29, 0x64, + 0x96, 0xa5, 0x5f, 0x4f, 0x88, 0x4f, 0x30, 0x41, 0xb3, 0x48, 0xc2, 0xe1, + 0x13, 0x1e, 0xc0, 0x53, 0x42, 0x7c, 0x10, 0xd2, 0x0b, 0xc5, 0xf3, 0x33, + 0x40, 0xcf, 0xc7, 0x02, 0xde, 0x0a, 0x76, 0x19, 0x57, 0x92, 0x99, 0x6f, + 0x50, 0x3c, 0x8d, 0x61, 0xb2, 0xd6, 0x8d, 0xc7, 0xf1, 0xed, 0xcd, 0x44, + 0x11, 0x86, 0xab, 0x70, 0xba, 0x4a, 0x60, 0xf3, 0x74, 0xbb, 0x90, 0x71, + 0xce, 0x57, 0xfb, 0x29, 0x80, 0xc4, 0xf2, 0x78, 0x42 +}; + +static const uint8_t kKv_down_tid6[] = { + 0x88, 0x42, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x02, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x06, 0x00, 0x10, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x52, 0x6e, + 0x64, 0x9b, 0xf9, 0x73, 0xaa, 0x8b, 0x10, 0x9f, 0xaa, 0x6f, 0x9a, 0x3e, + 0x98, 0xb8, 0x2e, 0x9f, 0x60, 0x11, 0x22, 0x05, 0x47, 0x99, 0x2e, 0x7f, + 0xd9, 0xea, 0x45, 0xe2, 0xe3, 0x70, 0x32, 0x43, 0x72, 0x88, 0x91, 0x3f, + 0xc8, 0x09, 0xfd, 0xe0, 0x41, 0xbb, 0x3d, 0x85, 0x87, 0xac, 0x03, 0x6f, + 0x27, 0xe7, 0xa4, 0x0c, 0x2f, 0x27, 0x63, 0xf8, 0x62, 0x98, 0xc3, 0x02, + 0x6f, 0xcc, 0x64, 0x3a, 0xd5, 0x73, 0xc5, 0x20, 0xa4 +}; + +static const KernelCcmpVector kKernelCcmpVectors[] = { + {"up tid 0", 0, 26, kKv_up_tid0, 126}, + {"down tid 0", 0, 26, kKv_down_tid0, 78}, + {"down tid 7", 7, 26, kKv_down_tid7, 133}, + {"up tid 1", 1, 26, kKv_up_tid1, 105}, + {"up tid 2", 2, 26, kKv_up_tid2, 105}, + {"up tid 3", 3, 26, kKv_up_tid3, 105}, + {"up tid 4", 4, 26, kKv_up_tid4, 105}, + {"up tid 5", 5, 26, kKv_up_tid5, 105}, + {"up tid 6", 6, 26, kKv_up_tid6, 105}, + {"up tid 7", 7, 26, kKv_up_tid7, 105}, + {"down tid 1", 1, 26, kKv_down_tid1, 105}, + {"down tid 2", 2, 26, kKv_down_tid2, 105}, + {"down tid 3", 3, 26, kKv_down_tid3, 105}, + {"down tid 4", 4, 26, kKv_down_tid4, 105}, + {"down tid 5", 5, 26, kKv_down_tid5, 105}, + {"down tid 6", 6, 26, kKv_down_tid6, 105}, +}; + +static const size_t kKernelCcmpVectorCount = + sizeof(kKernelCcmpVectors) / sizeof(kKernelCcmpVectors[0]); + +} // namespace test +} // namespace devourer + +#endif /* DEVOURER_TEST_CCMP_KERNEL_VECTORS_H */ diff --git a/tests/ccmp_selftest.cpp b/tests/ccmp_selftest.cpp new file mode 100644 index 00000000..bc3bef8d --- /dev/null +++ b/tests/ccmp_selftest.cpp @@ -0,0 +1,943 @@ +/* Headless guard for src/sta/Ccmp.h — the role-neutral 802.11 CCMP framing. + * + * WHAT THE GENERATED VECTORS PIN: the CIPHER PLUMBING, against a third + * implementation - python-cryptography's AESCCM against OpenSSL. They do NOT + * pin the 802.11 framing rules. tests/ccmp_gen_vectors.py transcribes the + * framing from the same reading of the standard as src/sta/Ccmp.h, so a + * misreading made once is made twice and the vectors agree with it (a zero + * CCM nonce Flags octet passes them). Independence of IMPLEMENTATION is not + * independence of INTERPRETATION. The framing rules are pinned by the direct + * assertion cells below - test_nonce_flags, test_qos_aad, test_aad_masking - + * and by test_kernel_vectors(). + * + * test_kernel_vectors() is the one cell here whose expected values were not + * written by anyone reading the standard. They are frames the LINUX KERNEL + * encrypted, captured off a mac80211_hwsim rig running hostapd with WMM on, + * so all eight TIDs appear — the case the devourer AP cannot produce on the + * bench, because it advertises neither WMM nor HT. See ccmp_kernel_vectors.h. + * + * WHAT IT IS NOT. Not the official IEEE Annex J vector; mac80211 is an + * interop reference, not the specification, and if the kernel and this header + * misread the same clause in the same way, no cell in this file would notice. + * That is a smaller risk than it sounds — mac80211 interoperates with every + * commercial AP — but it is not zero, and Annex J remains a drop-in upgrade. + * And it is not a round-trip: hand-rolled crypto with no known-answer test is + * exactly what a round-trip cannot catch, because an implementation that is + * wrong in both directions round-trips perfectly. + * + * The cipher here is OpenSSL, via tests/ccmp_software.h - the same primitive + * tests/openssl_crypto_ops.h hands the supplicant and state-machine cells, so + * those run on a cipher these vectors have pinned. + */ +#include + +#include +#include +#include + +#include "ccmp_kernel_vectors.h" +#include "ccmp_software.h" +#include "ccmp_vectors.h" +#include "sta/Ccmp.h" + +namespace { + +int g_fail = 0; + +void check(bool ok, const char* what) { + if (!ok) { + std::printf("FAIL: %s\n", what); + g_fail++; + } +} + +/* check(), but it says whether it passed, so a cell can stop working on a + * vector whose decrypt already failed instead of asserting on garbage. */ +bool checked(bool ok, const char* what) { + check(ok, what); + return ok; +} + +/* The OpenSSL CryptoOps the harnesses will supply. Only aes_ccm is exercised + * here; the rest refuse rather than pretend, so a future test that needs them + * fails loudly instead of silently passing on a stub. */ +struct OpenSslCcm : devourer::sta::CryptoOps { + bool aes_ccm(bool encrypt, const uint8_t key[16], const uint8_t nonce[13], + const uint8_t* aad, size_t aad_len, const uint8_t* in, + size_t in_len, uint8_t* out, uint8_t* tag) override { + return devourer::test::ccmp_software(encrypt, key, nonce, aad, (int)aad_len, + in, (int)in_len, out, tag); + } + bool hmac_sha1(const uint8_t*, size_t, const uint8_t*, size_t, + uint8_t[20]) override { + return false; + } + bool pbkdf2_sha1(const char*, const uint8_t*, size_t, unsigned, uint8_t*, + size_t) override { + return false; + } + bool aes_key_unwrap(const uint8_t*, size_t, const uint8_t*, size_t, + uint8_t*) override { + return false; + } +}; + +void test_vectors() { + OpenSslCcm crypto; + + for (size_t i = 0; i < kCcmpVectorCount; i++) { + const CcmpVector& v = kCcmpVectors[i]; + std::vector out(v.mpdu_len + 64, 0); + char label[128]; + + std::snprintf(label, sizeof label, "encrypt vector '%s'", v.name); + size_t n = devourer::sta::ccmp_encrypt(crypto, v.tk, v.hdr, v.hdr_len, + v.a2, v.pn, v.key_id, v.plain, + v.plain_len, out.data(), + out.size()); + { + uint8_t aad[devourer::sta::kCcmpAadMax]; + check(devourer::sta::ccmp_aad(v.hdr, v.hdr_len, aad) == v.aad_len, + label); /* 22 / +6 four-address / +2 QoS */ + } + check(n == v.mpdu_len, label); + if (n == v.mpdu_len) + check(std::memcmp(out.data(), v.mpdu, n) == 0, label); + + /* Decrypt the vector's own bytes, not the ones we just produced — a + * mutual-agreement test between our two directions would pass even if both + * disagreed with the standard. */ + std::vector mpdu(v.mpdu, v.mpdu + v.mpdu_len); + std::vector plain(v.mpdu_len, 0); + size_t plain_len = 0; + uint64_t pn = 0; + + std::snprintf(label, sizeof label, "decrypt vector '%s'", v.name); + bool ok = devourer::sta::ccmp_decrypt(crypto, v.tk, mpdu.data(), + mpdu.size(), v.hdr_len, v.a2, + plain.data(), plain.size(), + &plain_len, &pn); + check(ok, label); + if (ok) { + check(plain_len == v.plain_len, label); + check(std::memcmp(plain.data(), v.plain, v.plain_len) == 0, label); + check(pn == v.pn, label); + } + } +} + +/* A corrupted MIC must be rejected. Without this the decrypt path could ignore + * the tag entirely and every other test above would still pass. */ +/* A short output buffer must be refused rather than overflowed. */ +void test_short_output_refused() { + OpenSslCcm crypto; + const CcmpVector& v = kCcmpVectors[0]; + const size_t need = devourer::sta::ccmp_encrypted_len(v.hdr_len, v.plain_len); + std::vector out(need); + + check(need == v.mpdu_len, "ccmp_encrypted_len matches the vector"); + check(devourer::sta::ccmp_encrypt(crypto, v.tk, v.hdr, v.hdr_len, v.a2, v.pn, + v.key_id, v.plain, v.plain_len, out.data(), + need) == need, + "an exactly-sized buffer is accepted"); + check(devourer::sta::ccmp_encrypt(crypto, v.tk, v.hdr, v.hdr_len, v.a2, v.pn, + v.key_id, v.plain, v.plain_len, out.data(), + need - 1) == 0, + "a buffer one byte short is REFUSED, not overflowed"); +} + +/* LENGTHS THAT WOULD WRAP ARE REFUSED. The caller's hdr_len and plain_len are + * summed with the CCMP overhead; a sum past SIZE_MAX would wrap to a small + * number that a small buffer passes, so ccmp_encrypted_len reports 0 + * (impossible) and both calls refuse before touching any buffer. */ +void test_length_overflow_refused() { + OpenSslCcm crypto; + const CcmpVector& v = kCcmpVectors[0]; + const size_t max = SIZE_MAX; + std::vector out(64, 0xa5); + const std::vector untouched = out; + + check(devourer::sta::ccmp_encrypted_len(24, max - 40) == max, + "overflow: a total of exactly SIZE_MAX is still a length"); + check(devourer::sta::ccmp_encrypted_len(24, max - 39) == 0, + "overflow: one more byte is impossible (0)"); + check(devourer::sta::ccmp_encrypted_len(max - 8, 0) == 0, + "overflow: ...and so is a header length near SIZE_MAX"); + check(devourer::sta::ccmp_encrypt(crypto, v.tk, v.hdr, v.hdr_len, v.a2, v.pn, + v.key_id, v.plain, max - 10, out.data(), + out.size()) == 0 && + out == untouched, + "overflow: encrypt refuses plain_len near SIZE_MAX, buffer untouched"); + + check(devourer::sta::ccmp_decrypted_len(v.mpdu_len, max - 8) == 0, + "overflow: decrypted_len of a header near SIZE_MAX is 0"); + check(!devourer::sta::ccmp_decrypt(crypto, v.tk, v.mpdu, v.mpdu_len, + max - 8, v.a2, out.data(), out.size(), + nullptr, nullptr) && + out == untouched, + "overflow: decrypt refuses hdr_len near SIZE_MAX, buffer untouched"); +} + +void test_mic_rejected() { + OpenSslCcm crypto; + const CcmpVector& v = kCcmpVectors[0]; + std::vector mpdu(v.mpdu, v.mpdu + v.mpdu_len); + std::vector plain(v.mpdu_len, 0); + + mpdu[mpdu.size() - 1] ^= 0x01; + check(!devourer::sta::ccmp_decrypt(crypto, v.tk, mpdu.data(), mpdu.size(), + v.hdr_len, v.a2, plain.data(), + plain.size(), nullptr, nullptr), + "a flipped MIC bit must be rejected"); + + /* Same for the ciphertext: CCM authenticates it, so a body edit must fail + * the tag too. */ + std::vector body(v.mpdu, v.mpdu + v.mpdu_len); + body[v.hdr_len + devourer::sta::kCcmpHdrLen] ^= 0x80; + check(!devourer::sta::ccmp_decrypt(crypto, v.tk, body.data(), body.size(), + v.hdr_len, v.a2, plain.data(), + plain.size(), nullptr, nullptr), + "a flipped ciphertext bit must be rejected"); + + /* A buffer too small for the plaintext is refused rather than written past. + * The capacity check matters on decrypt as much as on encrypt: the cipher + * writes the plaintext out before it verifies the tag, so a forged + * oversized frame is enough to overrun an unchecked buffer. */ + check(!devourer::sta::ccmp_decrypt(crypto, v.tk, v.mpdu, v.mpdu_len, + v.hdr_len, v.a2, plain.data(), + v.plain_len - 1, nullptr, nullptr), + "an output buffer one byte too small must be refused"); + check(devourer::sta::ccmp_decrypted_len(v.mpdu_len, v.hdr_len) == v.plain_len, + "ccmp_decrypted_len says exactly how much room to allocate"); + + /* And a frame shorter than its own overhead must be refused rather than + * read past its end. */ + check(!devourer::sta::ccmp_decrypt(crypto, v.tk, v.mpdu, + v.hdr_len + 8 + 8 - 1, v.hdr_len, v.a2, + plain.data(), plain.size(), nullptr, + nullptr), + "a frame shorter than its own overhead must be refused"); +} + +/* A QoS frame's AAD must include the TID, and the module must not be able to + * disagree with the frame it was handed. The header length is explicit and + * the TID comes out of the header, so a caller cannot pass a 26-byte QoS + * header to a function that copies only 24 bytes and overwrites the QoS + * Control field with the CCMP header. What is left to check is that the TID + * actually reaches the MIC. */ +void test_qos_aad() { + OpenSslCcm crypto; + const CcmpVector* q = nullptr; + + for (size_t i = 0; i < kCcmpVectorCount; i++) + if (kCcmpVectors[i].hdr_len == 26) q = &kCcmpVectors[i]; + check(q != nullptr, "there is a real 26-byte QoS vector"); + if (!q) return; + + uint8_t aad[devourer::sta::kCcmpAadMax]; + size_t n = devourer::sta::ccmp_aad(q->hdr, q->hdr_len, aad); + check(n == 24, "a QoS AAD is 24 bytes"); + check(aad[22] == (q->hdr[24] & 0x0f) && aad[23] == 0, + "the QoS AAD carries the TID with the other bits masked"); + + /* Treating the same frame as non-QoS - the mistake a 24-byte-only + * implementation makes - must not verify. */ + std::vector plain(q->mpdu_len, 0); + check(!devourer::sta::ccmp_decrypt(crypto, q->tk, q->mpdu, q->mpdu_len, 24, + q->a2, plain.data(), plain.size(), nullptr, + nullptr), + "a QoS frame read as non-QoS must NOT verify"); + + /* A different TID in the header must not verify either, or the TID is not + * really authenticated. */ + std::vector tweak(q->mpdu, q->mpdu + q->mpdu_len); + tweak[24] = (uint8_t)((tweak[24] & 0xf0) | ((q->hdr[24] + 1) & 0x0f)); + check(!devourer::sta::ccmp_decrypt(crypto, q->tk, tweak.data(), tweak.size(), + q->hdr_len, q->a2, plain.data(), + plain.size(), nullptr, nullptr), + "a altered TID must not verify"); + + /* And a header too short for what its frame control claims is refused + * rather than read past. */ + check(devourer::sta::ccmp_aad(q->hdr, 24, aad) == 0, + "a QoS header declared as 24 bytes is refused"); + + /* THE SUBFIELDS ABOVE THE TID, which no vector in this repository can see. + * + * 802.11-2016 12.5.3.3.3 keeps only the TID out of the QoS Control field: + * EOSP, the ack policy and A-MSDU-present are masked, and the second octet + * is replaced by zero. Every real frame available here - the generated + * vectors AND the kernel-captured ones - carries a plain TID with a zero + * upper nibble and a zero second octet, so `hdr[qoff] & 0x0f` and + * `hdr[qoff]` are the same byte and the mask is invisible to all of them. + * + * So deleting that mask passes every other cell in this file, kernel frames + * included. This cell is the only thing that distinguishes it, and it has + * to build its own header to do so, because the mask only matters for a + * frame nobody in this rig transmits: a block-acked or A-MSDU frame, which + * is what a real WMM/HT station sends constantly. + * + * The nonce half of the same rule is test_nonce_flags, which feeds it a + * 0xf5 QoS octet; this is the AAD half. */ + { + uint8_t loud[26], plainq[26]; + uint8_t a_loud[devourer::sta::kCcmpAadMax]; + uint8_t a_plain[devourer::sta::kCcmpAadMax]; + + std::memcpy(loud, q->hdr, 26); + loud[24] = 0x05 | 0x10 | 0x60 | 0x80; /* TID 5, EOSP, ack policy 3, A-MSDU */ + loud[25] = 0xff; /* TXOP limit / queue size */ + std::memcpy(plainq, loud, 26); + plainq[24] = 0x05; /* the same TID, nothing else */ + plainq[25] = 0x00; + + check(devourer::sta::ccmp_aad(loud, 26, a_loud) == 24 && + devourer::sta::ccmp_aad(plainq, 26, a_plain) == 24, + "both QoS AADs are 24 bytes"); + check(a_loud[22] == 0x05 && a_loud[23] == 0x00, + "the QoS AAD masks EOSP, ack policy and A-MSDU-present"); + check(std::memcmp(a_loud, a_plain, 24) == 0, + "only the TID of the QoS control field reaches the AAD"); + } +} + +/* The nonce's Flags octet, asserted DIRECTLY rather than through a vector. + * + * The generated vectors cannot catch a wrong Flags octet: the generator in + * tests/ccmp_gen_vectors.py builds the nonce from the same reading as + * ccmp_nonce(), so a `nonce[0] = 0` in both would be self-consistent, and + * test_qos_aad above checks the AAD, not the nonce. Independence of + * implementation (python-cryptography vs OpenSSL) is not independence of + * interpretation. + * + * 802.11-2016 12.5.3.3.4: Flags = Priority (b0..b3) | Management (b4). + * Priority is the QoS TID for a QoS data frame, 0 otherwise. Linux builds the + * identical byte as `qos_tid | (ieee80211_is_mgmt(fc) << 4)` + * (net/mac80211/wpa.c) - that is the independent reading this pins against. + * + * A round trip against ourselves cannot see this at all, so the interop proof + * is test_kernel_vectors(): frames mac80211 actually encrypted at every TID, + * whose MIC fails the moment this octet is wrong. Zeroing the Flags octet + * breaks 14 of those 16 vectors and leaves the two TID-0 ones green: TID 0 + * is where a zero octet is coincidentally right, which is why ordinary + * best-effort traffic alone can never show this defect. */ +void test_nonce_flags() { + const uint8_t a2[6] = {0x02, 0xaa, 0xbb, 0xcc, 0xdd, 0x01}; + uint8_t nonce[devourer::sta::kCcmpNonceLen]; + uint8_t hdr[32]; + + std::memset(hdr, 0, sizeof hdr); + + /* Non-QoS data, to-DS: priority 0, not management. */ + hdr[0] = 0x08; hdr[1] = 0x01; + check(devourer::sta::ccmp_nonce(hdr, 24, a2, 1, nonce), + "a 24-byte non-QoS header is accepted"); + check(nonce[0] == 0x00, "non-QoS data has a zero Flags octet"); + check(std::memcmp(nonce + 1, a2, 6) == 0, "the nonce carries A2"); + check(nonce[7] == 0 && nonce[12] == 1, "the nonce PN is big-endian"); + + /* QoS data, TID 5 - the case a zero Flags octet gets wrong, and the reason + * it matters: TID 5 is the video access category. */ + hdr[0] = 0x88; hdr[1] = 0x01; hdr[24] = 0x05; + check(devourer::sta::ccmp_nonce(hdr, 26, a2, 1, nonce), + "a 26-byte QoS header is accepted"); + check(nonce[0] == 0x05, "QoS TID 5 puts 5 in the Flags octet"); + + /* The ack-policy / EOSP / A-MSDU bits in the QoS Control must be masked + * out, exactly as the AAD masks them. */ + hdr[24] = 0xf5; + devourer::sta::ccmp_nonce(hdr, 26, a2, 1, nonce); + check(nonce[0] == 0x05, "only the TID's low nibble reaches the Flags octet"); + + /* Four-address QoS: the QoS Control moves to offset 30. Reading it at 24 + * would take an address byte as the TID. */ + std::memset(hdr, 0, sizeof hdr); + hdr[0] = 0x88; hdr[1] = 0x03; hdr[30] = 0x07; + check(devourer::sta::ccmp_nonce(hdr, 32, a2, 1, nonce), + "a 32-byte four-address QoS header is accepted"); + check(nonce[0] == 0x07, "four-address QoS reads its TID at offset 30"); + + /* Management: bit 4 set, priority 0. */ + std::memset(hdr, 0, sizeof hdr); + hdr[0] = 0xd0; /* type 0 (management), subtype 13 (action) */ + check(devourer::sta::ccmp_nonce(hdr, 24, a2, 1, nonce), + "a management header is accepted"); + check(nonce[0] == 0x10, "management sets bit 4 of the Flags octet"); + + /* Fails CLOSED on a header too short for what the frame control claims, + * matching ccmp_aad's zero return rather than reading past the buffer. */ + hdr[0] = 0x88; hdr[1] = 0x01; + check(!devourer::sta::ccmp_nonce(hdr, 24, a2, 1, nonce), + "a QoS header declared as 24 bytes is refused"); + hdr[1] = 0x03; + check(!devourer::sta::ccmp_nonce(hdr, 30, a2, 1, nonce), + "a four-address QoS header declared as 30 bytes is refused"); +} + +/* A 4-address frame's AAD includes A4. Nothing in the tree builds one yet, + * which is why the branch needs a vector - it would otherwise be dead code + * that nobody would notice was broken. */ +void test_four_address_aad() { + const CcmpVector* f = nullptr; + + for (size_t i = 0; i < kCcmpVectorCount; i++) + if (kCcmpVectors[i].aad_len == 30) f = &kCcmpVectors[i]; + check(f != nullptr, "there is a 4-address QoS vector"); + if (!f) return; + + uint8_t aad[devourer::sta::kCcmpAadMax]; + check(devourer::sta::ccmp_aad(f->hdr, f->hdr_len, aad) == 30, + "a 4-address QoS AAD is 30 bytes"); + check(std::memcmp(aad + 22, f->hdr + 24, 6) == 0, + "the 4-address AAD carries A4"); +} + +/* The AAD rules, asserted directly, because they are the part that is silent + * when wrong: a bad AAD is indistinguishable from a bad key at the far end. */ +void test_aad_masking() { + /* TWENTY-SIX, not twenty-four. This cell declares a QoS frame and passes + * hdr_len 26 below, so ccmp_aad reads the QoS Control field at offset 24 - + * off the end of a 24-byte array. Only a sanitizer build notices that + * overread, which is why these cells run under ASan/UBSan in CI. */ + uint8_t hdr[26]; + uint8_t aad[devourer::sta::kCcmpAadMax]; + + std::memset(hdr, 0, sizeof hdr); + hdr[0] = 0x88; /* QoS data, subtype bits set */ + /* ToDS SET, so the DS-bit check below can actually fail. With ToDS and + * FromDS both clear the assertion `(aad[1] & 0x03) == (hdr[1] & 0x03)` + * reads `0 == 0` and passes an AAD that clears the DS bits. */ + hdr[1] = devourer::sta::kFcToDs | 0x08 | 0x10 | 0x20; /* retry | pwr mgmt | more data */ + hdr[22] = 0x35; /* frag 5, seq low bits */ + hdr[23] = 0x12; /* seq high */ + + size_t n = devourer::sta::ccmp_aad(hdr, 26, aad); + check(n == 24, "a QoS 3-address AAD is 24 bytes"); + check((aad[0] & 0x70) == 0, "AAD masks the FC subtype bits"); + check((aad[1] & 0x08) == 0, "AAD masks Retry"); + check((aad[1] & 0x10) == 0, "AAD masks Pwr Mgmt"); + check((aad[1] & 0x20) == 0, "AAD masks More Data"); + check((aad[1] & 0x40) != 0, "AAD forces Protected on"); + check(aad[20] == 0x05, "AAD keeps the fragment number"); + check(aad[21] == 0x00, "AAD masks the sequence number"); + /* What SURVIVES matters as much as what is masked: an AAD that zeroed the + * addresses or the DS bits would pass every assertion above. */ + check((aad[1] & 0x03) == (hdr[1] & 0x03), "AAD preserves ToDS/FromDS"); + check(std::memcmp(aad + 2, hdr + 4, 18) == 0, + "AAD carries addr1/addr2/addr3 verbatim"); + + /* THE ORDER BIT (+HTC), which no vector in this file can see because none + * of them sets it. On a QoS data frame bit 15 means "an HT Control field + * follows" and 802.11-2016 12.5.3.3.3 masks it; on a NON-QoS frame the same + * bit is the strictly-ordered service class and must survive. Both arms, + * because masking it unconditionally is as wrong as never masking it. */ + { + uint8_t htc[30]; + uint8_t a[devourer::sta::kCcmpAadMax]; + + std::memset(htc, 0, sizeof htc); + htc[0] = 0x88; /* QoS data */ + htc[1] = (uint8_t)(devourer::sta::kFcToDs | 0x80); /* +HTC / Order */ + htc[24] = 0x03; /* TID 3 */ + check(devourer::sta::ccmp_aad(htc, 30, a) == 24, + "a QoS +HTC AAD is still 24 bytes - HT Control is not in it"); + check((a[1] & 0x80) == 0, "AAD masks the Order bit on a QoS data frame"); + check((a[1] & 0x01) != 0, "...without losing ToDS"); + + /* The same bit on a non-QoS data frame is a different field. */ + uint8_t ord[24]; + std::memset(ord, 0, sizeof ord); + ord[0] = 0x08; /* data, not QoS */ + ord[1] = (uint8_t)(devourer::sta::kFcToDs | 0x80); + check(devourer::sta::ccmp_aad(ord, 24, a) == 22, "a non-QoS AAD is 22"); + check((a[1] & 0x80) != 0, + "AAD KEEPS bit 15 on a non-QoS frame - there it is the " + "strictly-ordered service class, not +HTC"); + } + + /* A header shorter than a frame control is refused rather than READ: the + * length check has to come before the DS bits and the subtype are derived, + * or it is an overread for any caller that gets the length wrong. + * + * THE BUFFERS ARE ONE BYTE ON THE HEAP, deliberately. Passing a short + * length over a long buffer proves nothing: the read succeeds, the second + * length check returns 0 anyway, and the reordering is invisible. With a + * genuine one-byte allocation the overread is a heap-buffer-overflow, which + * the `build-sanitizers` CI job turns into a failure. IN A NON-SANITIZED + * BUILD THIS ARM CANNOT FAIL: reading before checking passes here, and only + * the sanitizer job catches it. */ + { + std::vector one(1, 0x88); + std::vector none; + uint8_t a2[6] = {0}; + uint8_t nonce[devourer::sta::kCcmpNonceLen]; + + check(devourer::sta::ccmp_aad(one.data(), 1, aad) == 0, + "ccmp_aad refuses a one-byte header without reading past it"); + check(devourer::sta::ccmp_aad(none.data(), 0, aad) == 0, + "ccmp_aad refuses a zero-length header without reading it"); + check(!devourer::sta::ccmp_nonce(one.data(), 1, a2, 1, nonce), + "ccmp_nonce refuses a one-byte header without reading past it"); + } + + /* A protected MANAGEMENT frame keeps its subtype - mac80211 masks the + * subtype only for non-management frames, and 802.11w depends on it. */ + uint8_t mgmt[24]; + std::memset(mgmt, 0, sizeof mgmt); + mgmt[0] = 0xd0; /* Action frame: type 0, subtype 13 */ + mgmt[1] = 0x08; /* retry, which must still be masked */ + check(devourer::sta::ccmp_aad(mgmt, 24, aad) == 22, "a mgmt AAD is 22 bytes"); + check((aad[0] & 0x70) == 0x50, "a management frame KEEPS its subtype"); + check((aad[1] & 0x08) == 0, "a management frame still masks Retry"); +} + +/* The CCMP header's PN is split across two discontiguous ranges and the Ext IV + * bit is not optional. Round-tripping the maximum PN catches a 32-bit + * truncation, which would otherwise only appear after 4 billion frames. */ +void test_header_pn() { + uint8_t h[8]; + const uint64_t pn = 0xfedcba987654ULL; + + devourer::sta::ccmp_header(pn, 2, h); + check((h[3] & 0x20) != 0, "CCMP header sets Ext IV"); + check(((h[3] >> 6) & 3) == 2, "CCMP header carries the key id"); + check(h[2] == 0, "CCMP header byte 2 is reserved and zero"); + check(devourer::sta::ccmp_header_pn(h) == pn, "48-bit PN round-trips"); + + devourer::sta::ccmp_header(0xffffffffffffULL, 0, h); + check(devourer::sta::ccmp_header_pn(h) == 0xffffffffffffULL, + "the maximum PN round-trips"); +} + +/* The replay gate. + * + * AN EQUAL PN IS A REPLAY. A gate written as `<` admits an equal-counter + * replay, which is how a group-key reinstallation (KRACK class) lands. That + * is the first block below. + * + * The rest covers the sliding window. A bare counter is only correct while + * frames cannot arrive out of order, which is an assumption about the PEER: + * the moment a BlockAck agreement exists an A-MPDU can deliver PN 5, 7, 6 + * legitimately and a counter drops 6 as a replay. This tree's AP harnesses + * never set up a BlockAck agreement; a station does not get to choose. */ +void test_replay() { + devourer::sta::CcmpReplay r; + + check(!r.accept(0), "PN 0 is never valid"); + check(r.accept(1), "first PN is accepted"); + check(!r.accept(1), "an EQUAL-counter replay must be REJECTED"); + check(!r.accept(0), "a lower PN must be rejected"); + check(r.accept(2), "a higher PN is accepted"); + check(r.last() == 2, "the window advanced to the accepted PN"); + check(!r.accept(2), "the advanced counter still rejects its equal"); + + /* A rejected PN must not advance the window; otherwise a forged high PN + * would lock out the legitimate peer. The rejected value here is one that + * is genuinely out of range: PN 50 after 100 would not do, because a + * sliding window correctly ACCEPTS it - 50 frames of reordering is not a + * replay. */ + devourer::sta::CcmpReplay r2; + check(r2.accept(100), "setup"); + check(!r2.accept(100 - devourer::sta::CcmpReplay::kWindow), + "a PN exactly one window behind is too old to judge"); + check(r2.last() == 100, "a rejected PN does not move the window"); + + /* --- REORDERING, which a bare counter gets wrong ----------------------- + * + * A BlockAck agreement lets an A-MPDU deliver PNs out of order. The strict + * `pn > last` rule drops the late ones as replays: silent data loss that + * looks like a radio problem. A station does not get to choose whether its + * AP sets up BlockAck. */ + devourer::sta::CcmpReplay w; + check(w.accept(10), "window: first frame"); + check(w.accept(12), "window: a gap is fine"); + check(w.accept(11), "window: THE LATE FRAME IN THE GAP IS ACCEPTED"); + check(!w.accept(11), "window: but only once"); + check(!w.accept(12), "window: and the one that arrived early is not replayable"); + check(w.last() == 12, "window: a late frame does not move the head backwards"); + + /* Fill a whole window out of order, then prove every one of them is a + * replay on a second pass. */ + devourer::sta::CcmpReplay f; + check(f.accept(1000), "window: head"); + for (int i = 1; i < devourer::sta::CcmpReplay::kWindow; i++) + if (!f.accept(1000 - (uint64_t)i)) + check(false, "window: every PN inside the window is accepted once"); + { + bool any = false; + for (int i = 0; i < devourer::sta::CcmpReplay::kWindow; i++) + if (f.accept(1000 - (uint64_t)i)) any = true; + check(!any, "window: and every one of them is a replay the second time"); + } + + /* The edges. One inside is accepted, one outside is refused - off by one + * here is either a dropped frame or an admitted replay. */ + devourer::sta::CcmpReplay e; + check(e.accept(1000), "edge: head"); + check(e.accept(1000 - (devourer::sta::CcmpReplay::kWindow - 1)), + "edge: the oldest PN still inside the window is accepted"); + check(!e.accept(1000 - devourer::sta::CcmpReplay::kWindow), + "edge: one past the window is refused"); + + /* A forward jump larger than the window must clear it, and must not shift + * a uint64_t by >= 64 on the way - undefined behaviour, and exactly the + * gap a hostile peer would choose. Everything behind becomes unreachable, + * which is the safe direction: no replay can be admitted afterwards. */ + devourer::sta::CcmpReplay j; + check(j.accept(10), "jump: head"); + check(j.accept(10 + devourer::sta::CcmpReplay::kWindow + 5), "jump: a big skip"); + check(!j.accept(11), "jump: the skipped PNs are gone, not replayable"); + check(!j.accept(10), "jump: including the one already seen"); + check(j.accept(10 + devourer::sta::CcmpReplay::kWindow + 4), + "jump: but a frame inside the NEW window is still accepted"); + + /* THE SHIFT GUARD, and it needs a carefully chosen jump to be visible. + * + * Shifting a uint64_t by >= 64 is undefined, and on x86 the count is taken + * modulo 64 - so an UNGUARDED `mask << shift` with shift == kWindow + 1 + * becomes `mask << 1`, and the head's own bit survives as bit 1 of the new + * window. That marks a PN the receiver has NEVER SEEN as already seen, and + * the next legitimate frame at that PN is dropped as a replay. + * + * So the symptom is a LOST FRAME, not an admitted one, and it only appears + * for a jump that lands a stale bit inside the new window. A jump of + * kWindow + 5 does not: everything behind it falls outside the window and + * is refused for that reason instead, so a cell using that jump passes + * with the guard removed. kWindow + 1 is the jump that shows it. */ + devourer::sta::CcmpReplay sh; + check(sh.accept(10), "shift: head at 10"); + check(sh.accept(10 + devourer::sta::CcmpReplay::kWindow + 1), + "shift: jump one past the window"); + check(sh.accept(10 + devourer::sta::CcmpReplay::kWindow), + "shift: THE PN JUST BEHIND THE NEW HEAD WAS NEVER SEEN - accept it"); + + /* An enormous jump - the same shift, at the top of the PN space. */ + devourer::sta::CcmpReplay h2; + check(h2.accept(1), "huge: head"); + check(h2.accept(0xffffffffffffULL), "huge: the maximum PN is accepted"); + check(!h2.accept(1), "huge: the old PN is not replayable"); + check(h2.last() == 0xffffffffffffULL, "huge: the head moved"); + + /* NOT TESTABLE HERE, stated rather than implied: the `behind >= kWindow` + * bound. Relaxing it to `>` lets `behind == kWindow` through to a + * `1ull << 64`, which is undefined - but on x86 that evaluates to 1, and + * bit 0 is the head, which is always set, so the frame is refused anyway + * and the defect is invisible. The guard is there by construction, not + * because a test on this ISA can distinguish it. */ + + /* ONE COUNTER PER TID. A single shared counter drops legitimate frames as + * soon as two TIDs interleave, which is routine the moment voice or video + * shares a link with best-effort. */ + devourer::sta::CcmpReplay t; + check(t.accept(10, 0), "TID 0 accepts PN 10"); + check(t.accept(5, 6), "TID 6 accepts a LOWER PN than TID 0 has seen"); + check(!t.accept(5, 6), "TID 6 still rejects its own replay"); + check(t.accept(11, 0), "TID 0 continues independently"); + check(t.last(0) == 11 && t.last(6) == 5, "the two windows are separate"); + /* ACCEPTING ONE PN PROVES NOTHING: the first PN in any window is accepted, + * and PN 1 is inside TID 0's window too, so aliasing kNonQosTid to 0 would + * pass that alone. The heads have to be read back separately. */ + check(t.accept(1, devourer::sta::CcmpReplay::kNonQosTid), + "non-QoS traffic has a window of its own"); + check(t.last(devourer::sta::CcmpReplay::kNonQosTid) == 1 && t.last(0) == 11, + "...a SEPARATE one - its head is 1 while TID 0's is still 11"); + check(!t.accept(99, -1), "an out-of-range TID is refused"); + check(!t.accept(99, 17), "an out-of-range TID is refused"); + + /* A rekey resets every counter: a new key is a new PN space. + * + * ACCEPTING PN 1 AFTER THE RESET PROVES NOTHING - it is inside the window + * and unmarked whether or not reset() ran, so an empty reset() would pass + * it. What cannot pass is re-accepting a PN the window has ALREADY SEEN, + * and reading the head back. */ + t.reset(); + check(t.last(0) == 0 && t.last(6) == 0, "reset clears both heads"); + check(t.accept(11, 0), "a PN already accepted on TID 0 is accepted again"); + check(t.accept(5, 6), "...and one already accepted on TID 6"); + check(t.accept(1, 0) && t.accept(1, 6), "reset clears every TID window"); +} + + +/* The Linux kernel's own CCMP output, decrypted and then reproduced. + * + * Every other vector in this file comes from software that shares this + * repository's reading of 802.11-2016 12.5.3.3, so a shared misreading - a + * nonce with a zero Flags octet, wrong for TID 1..7 - passes them all. + * These frames are mac80211's output, captured off the air of a two-radio + * mac80211_hwsim rig; nothing in this repository chose their bytes. + * + * Two directions of proof per vector: + * + * DECRYPT. The MIC is the oracle. CCM authenticates the AAD and the nonce, + * so a single wrong bit in either — a masked frame-control bit, the kept + * fragment number, the TID in the Flags octet, the big-endian PN — makes the + * tag fail. There is no way to pass this by accident. + * + * ENCRYPT. Re-protecting the recovered plaintext under the same TK and PN + * must reproduce the captured MPDU byte for byte, which additionally pins + * the CCMP header layout (the reserved byte, the Ext IV bit, the split + * little-endian PN) that a decrypt-only test would let drift. + */ +void test_kernel_vectors() { + OpenSslCcm crypto; + unsigned tids_seen = 0; + + for (size_t i = 0; i < devourer::test::kKernelCcmpVectorCount; i++) { + const devourer::test::KernelCcmpVector& v = + devourer::test::kKernelCcmpVectors[i]; + /* A 3-address frame's A2 is the transmitter in both directions. */ + const uint8_t* a2 = v.mpdu + 10; + /* SIZED FROM THE VECTOR, not from a guess. A fixed `uint8_t plain[512]` + * is tied to nothing: regenerate on a rig whose first frame for some TID + * is full-MTU and the decrypt would write a kilobyte past it. */ + std::vector plain( + devourer::sta::ccmp_decrypted_len(v.mpdu_len, v.hdr_len)); + std::vector again(v.mpdu_len); + size_t plen = 0, n; + uint64_t pn = 0; + char what[128]; + + std::snprintf(what, sizeof what, "kernel %s: the kernel's MIC verifies", + v.name); + if (!checked(devourer::sta::ccmp_decrypt(crypto, devourer::test::kKernelTk, + v.mpdu, v.mpdu_len, v.hdr_len, a2, + plain.data(), plain.size(), &plen, + &pn), + what)) + continue; + tids_seen |= 1u << v.tid; + + /* The recovered plaintext is an 802.11 MSDU, so it opens with LLC/SNAP. + * Redundant given the tag verified, but it turns a corrupted vector file + * into a legible failure instead of a crypto mystery. */ + std::snprintf(what, sizeof what, "kernel %s: plaintext is LLC/SNAP", + v.name); + check(plen > 8 && plain[0] == 0xaa && plain[1] == 0xaa && plain[2] == 0x03, + what); + + /* The PN the kernel wrote into the header is the PN we must encrypt + * under, and the key id comes out of the same octet. */ + std::snprintf(what, sizeof what, "kernel %s: re-encrypts to the same bytes", + v.name); + n = devourer::sta::ccmp_encrypt( + crypto, devourer::test::kKernelTk, v.mpdu, v.hdr_len, a2, pn, + (uint8_t)((v.mpdu[v.hdr_len + 3] >> 6) & 0x03), plain.data(), plen, + again.data(), again.size()); + check(n == v.mpdu_len && std::memcmp(again.data(), v.mpdu, n) == 0, what); + } + + /* The coverage assertion is the point of the exercise. TID 0 passes with a + * zero Flags octet too, so a run that silently lost the QoS vectors would + * still be green without this. */ + check(tids_seen == 0xffu, "kernel vectors cover all eight TIDs"); + check(devourer::test::kKernelCcmpVectorCount == 16, + "kernel vectors: eight TIDs in each direction"); +} + +/* A NULL OUTPUT MUST NEVER REACH THE CIPHER. OpenSSL's CCM reads a NULL + * output pointer as "this is AAD" and returns success with NO TAG CHECK, and + * `std::vector plain(ccmp_decrypted_len(...))` hands exactly that + * over (`data()` of an empty vector) for a ZERO-BODY frame. A forged one + * then decrypts "successfully" with an attacker-chosen PN, which moves the + * replay window to wherever the attacker likes. Both layers are pinned: the + * module never passes NULL, and the software primitive refuses it anyway. */ +struct NullSpy : OpenSslCcm { + bool saw_null = false; + bool aes_ccm(bool encrypt, const uint8_t key[16], const uint8_t nonce[13], + const uint8_t* aad, size_t aad_len, const uint8_t* in, + size_t in_len, uint8_t* out, uint8_t* tag) override { + if (!out) saw_null = true; + return OpenSslCcm::aes_ccm(encrypt, key, nonce, aad, aad_len, in, in_len, + out, tag); + } +}; + +void test_null_output_and_zero_body() { + NullSpy crypto; + const CcmpVector& v = kCcmpVectors[0]; + const uint64_t pn = 0x7fffffffffffULL; + + /* Forged: header, CCMP header at a huge PN, and eight bytes of made-up + * MIC. No body. */ + std::vector forged(v.hdr, v.hdr + v.hdr_len); + forged[1] |= 0x40; + forged.resize(v.hdr_len + devourer::sta::kCcmpHdrLen); + devourer::sta::ccmp_header(pn, 0, forged.data() + v.hdr_len); + forged.insert(forged.end(), devourer::sta::kCcmpMicLen, 0x5a); + + std::vector plain( + devourer::sta::ccmp_decrypted_len(forged.size(), v.hdr_len)); + size_t plen = 99; + uint64_t got = 0; + check(plain.empty(), "a zero-body frame sizes an EMPTY output vector"); + check(!devourer::sta::ccmp_decrypt(crypto, v.tk, forged.data(), + forged.size(), v.hdr_len, v.a2, + plain.data(), plain.size(), &plen, &got), + "A FORGED ZERO-BODY FRAME IS REFUSED with a NULL output buffer"); + check(got == 0, "...and reports no PN"); + check(!crypto.saw_null, "...and the cipher was never handed NULL"); + + /* The primitive on its own, the layer an integrator's CryptoOps sits at. */ + uint8_t nonce[13] = {0}, aad[32] = {0}, tag[8]; + std::memset(tag, 0x5a, sizeof tag); + check(!devourer::test::ccmp_software(false, v.tk, nonce, aad, 22, + forged.data(), 0, nullptr, tag), + "ccmp_software refuses a forged tag with a NULL output"); + + /* THE POSITIVE ARM: a genuine zero-body frame still decrypts, with the + * same empty output vector, and its tag IS checked. */ + std::vector real( + devourer::sta::ccmp_encrypted_len(v.hdr_len, 0)); + const size_t n = devourer::sta::ccmp_encrypt( + crypto, v.tk, v.hdr, v.hdr_len, v.a2, 7, 0, nullptr, 0, real.data(), + real.size()); + check(n == real.size(), "a zero-body frame encrypts"); + got = 0; + check(devourer::sta::ccmp_decrypt(crypto, v.tk, real.data(), real.size(), + v.hdr_len, v.a2, plain.data(), + plain.size(), &plen, &got), + "...and a GENUINE zero-body frame decrypts into an empty vector"); + check(plen == 0 && got == 7, "...with no bytes and its own PN"); + real[real.size() - 1] ^= 0x01; + check(!devourer::sta::ccmp_decrypt(crypto, v.tk, real.data(), real.size(), + v.hdr_len, v.a2, plain.data(), + plain.size(), nullptr, nullptr), + "...whose tag is still verified"); + check(!crypto.saw_null, "the cipher was never handed NULL"); +} + +/* seed(): the group window starts at the Key RSC the AP quoted. */ +void test_replay_seed() { + devourer::sta::CcmpReplay s; + + s.seed(1000); + check(!s.accept(1000), "seed: the RSC itself is refused"); + check(!s.accept(999), "seed: one below it is refused"); + check(!s.accept(1000 - 40), "seed: inside the window below it is refused"); + check(!s.accept(3), "seed: far below it is refused"); + check(s.accept(1001, 3), "seed: above it is accepted, on any TID"); + check(s.accept(1001), "seed: ...and on the non-QoS window too"); + + s.seed(0); + check(s.accept(1), "seed(0) is reset(): PN 1 is accepted"); +} + +} // namespace + +/* THE PN IS 48 BITS. ccmp_encrypt takes a uint64_t, but the nonce and the + * CCMP header carry only the low 48 bits, so PN 2^48 would air with PN 0's + * nonce under the same TK. The last valid PN encrypts and round-trips; one + * more is refused and writes nothing. */ +void test_pn_is_48_bits() { + OpenSslCcm crypto; + const CcmpVector& v = kCcmpVectors[0]; + const uint64_t last = devourer::sta::kCcmpPnMax; + + check(last == 0xffffffffffffull, "pn: the limit is 2^48 - 1"); + std::vector out(v.mpdu_len, 0); + const size_t n = devourer::sta::ccmp_encrypt( + crypto, v.tk, v.hdr, v.hdr_len, v.a2, last, 0, v.plain, v.plain_len, + out.data(), out.size()); + check(n == v.mpdu_len, "pn: 2^48 - 1 encrypts"); + std::vector plain(v.plain_len); + size_t plen = 0; + uint64_t pn = 0; + check(n == v.mpdu_len && + devourer::sta::ccmp_decrypt(crypto, v.tk, out.data(), n, v.hdr_len, + v.a2, plain.data(), plain.size(), &plen, + &pn) && + pn == last && plen == v.plain_len && + std::memcmp(plain.data(), v.plain, v.plain_len) == 0, + "pn: ...and round-trips with that PN"); + + std::vector untouched(v.mpdu_len, 0xa5); + check(devourer::sta::ccmp_encrypt(crypto, v.tk, v.hdr, v.hdr_len, v.a2, + last + 1, 0, v.plain, v.plain_len, + untouched.data(), untouched.size()) == 0, + "pn: 2^48 is refused"); + bool clean = true; + for (uint8_t b : untouched) clean = clean && b == 0xa5; + check(clean, "pn: ...and nothing is written"); + check(devourer::sta::ccmp_encrypt(crypto, v.tk, v.hdr, v.hdr_len, v.a2, + ~0ull, 0, v.plain, v.plain_len, + untouched.data(), untouched.size()) == 0, + "pn: so is the largest uint64_t"); +} + +/* EXT IV IS REQUIRED on receive. The key-id octet is not in the AAD, so a + * valid frame with Ext IV cleared still carries a correct MIC - only an + * explicit check refuses it. The reserved bits of that octet are ignored, + * per 802.11's reserved-field convention. */ +void test_ext_iv_required() { + OpenSslCcm crypto; + const CcmpVector& v = kCcmpVectors[0]; + std::vector f(v.mpdu, v.mpdu + v.mpdu_len); + std::vector plain(v.plain_len); + size_t plen = 0; + + check(devourer::sta::ccmp_decrypt(crypto, v.tk, f.data(), f.size(), v.hdr_len, + v.a2, plain.data(), plain.size(), &plen, + nullptr), + "ext iv: the untouched vector decrypts"); + f[v.hdr_len + 3] &= (uint8_t)~0x20; + check(!devourer::sta::ccmp_decrypt(crypto, v.tk, f.data(), f.size(), + v.hdr_len, v.a2, plain.data(), + plain.size(), &plen, nullptr), + "ext iv: the same frame with Ext IV cleared is refused"); + f[v.hdr_len + 3] |= 0x20; + f[v.hdr_len + 3] |= 0x1f; /* reserved bits 0-4 */ + f[v.hdr_len + 2] = 0xff; /* reserved octet */ + check(devourer::sta::ccmp_decrypt(crypto, v.tk, f.data(), f.size(), + v.hdr_len, v.a2, plain.data(), + plain.size(), &plen, nullptr), + "ext iv: reserved bits are ignored on receive"); +} + +/* THE FRAME MUST SAY IT IS PROTECTED. ccmp_aad forces the Protected bit to 1 + * in the AAD, so a valid MPDU with the bit cleared on the wire still carries a + * correct MIC - only an explicit check refuses it. */ +void test_protected_bit_required() { + OpenSslCcm crypto; + const CcmpVector& v = kCcmpVectors[0]; + std::vector f(v.mpdu, v.mpdu + v.mpdu_len); + std::vector plain(v.plain_len); + size_t plen = 0; + + f[1] &= (uint8_t)~devourer::sta::kFcProtected; + check(!devourer::sta::ccmp_decrypt(crypto, v.tk, f.data(), f.size(), + v.hdr_len, v.a2, plain.data(), + plain.size(), &plen, nullptr), + "protected bit: a valid frame with Protected cleared is refused"); + f[1] |= devourer::sta::kFcProtected; + check(devourer::sta::ccmp_decrypt(crypto, v.tk, f.data(), f.size(), + v.hdr_len, v.a2, plain.data(), + plain.size(), &plen, nullptr), + "protected bit: ...and decrypts with it set again"); +} + +int main() { + test_vectors(); + test_protected_bit_required(); + test_pn_is_48_bits(); + test_ext_iv_required(); + test_short_output_refused(); + test_length_overflow_refused(); + test_mic_rejected(); + test_qos_aad(); + test_nonce_flags(); + test_kernel_vectors(); + test_four_address_aad(); + test_aad_masking(); + test_header_pn(); + test_replay(); + test_null_output_and_zero_body(); + test_replay_seed(); + + if (g_fail) { + std::printf("ccmp_selftest: %d failure(s)\n", g_fail); + return 1; + } + std::printf("ccmp_selftest: OK (%zu vectors, %zu kernel frames)\n", + kCcmpVectorCount, devourer::test::kKernelCcmpVectorCount); + return 0; +} diff --git a/tests/ccmp_software.h b/tests/ccmp_software.h new file mode 100644 index 00000000..e4063116 --- /dev/null +++ b/tests/ccmp_software.h @@ -0,0 +1,62 @@ +#pragma once + +#include + +#include + +// The software AES-128-CCM primitive behind tests/openssl_crypto_ops.h and +// tests/ccmp_selftest.cpp: a fresh EVP context per call. Anything that +// measures CCMP cost should measure this function: EVP_aes_128_ccm() through +// a different context-lifetime policy can give a very different answer on the +// small frames for which per-packet cost matters most. +namespace devourer::test { + +inline bool ccmp_software(bool encrypt, const uint8_t* key, + const uint8_t* nonce, const uint8_t* aad, + int aad_len, const uint8_t* input, int input_len, + uint8_t* output, uint8_t* tag) { + // NEVER HAND OPENSSL A NULL OUTPUT. Its CCM reads EVP_*Update(ctx, NULL, + // ...) as AAD, so a decrypt with a NULL output "succeeds" with no tag + // check at all. A zero-length payload gets a scratch byte instead. A NULL + // INPUT is the mirror trap: with in == NULL the update is read as the + // finish step and no payload pass runs, so a zero-length encrypt produced + // no tag. Same substitution. + uint8_t scratch[1] = {0}; + if (!output) { + if (input_len != 0) return false; + output = scratch; + } + if (!input) { + if (input_len != 0) return false; + input = scratch; + } + EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new(); + if (!ctx) return false; + + + int len = 0; + bool ok = true; + if (encrypt) { + ok = EVP_EncryptInit_ex(ctx, EVP_aes_128_ccm(), nullptr, nullptr, nullptr) == 1 && + EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_AEAD_SET_IVLEN, 13, nullptr) == 1 && + EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_AEAD_SET_TAG, 8, nullptr) == 1 && + EVP_EncryptInit_ex(ctx, nullptr, nullptr, key, nonce) == 1 && + EVP_EncryptUpdate(ctx, nullptr, &len, nullptr, input_len) == 1 && + EVP_EncryptUpdate(ctx, nullptr, &len, aad, aad_len) == 1 && + EVP_EncryptUpdate(ctx, output, &len, input, input_len) == 1 && + EVP_EncryptFinal_ex(ctx, output + len, &len) == 1 && + EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_AEAD_GET_TAG, 8, tag) == 1; + } else { + ok = EVP_DecryptInit_ex(ctx, EVP_aes_128_ccm(), nullptr, nullptr, nullptr) == 1 && + EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_AEAD_SET_IVLEN, 13, nullptr) == 1 && + EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_AEAD_SET_TAG, 8, tag) == 1 && + EVP_DecryptInit_ex(ctx, nullptr, nullptr, key, nonce) == 1 && + EVP_DecryptUpdate(ctx, nullptr, &len, nullptr, input_len) == 1 && + EVP_DecryptUpdate(ctx, nullptr, &len, aad, aad_len) == 1 && + EVP_DecryptUpdate(ctx, output, &len, input, input_len) == 1; + } + EVP_CIPHER_CTX_free(ctx); + return ok; +} + +} // namespace devourer::test diff --git a/tests/ccmp_tid_send.py b/tests/ccmp_tid_send.py new file mode 100644 index 00000000..c0396b8e --- /dev/null +++ b/tests/ccmp_tid_send.py @@ -0,0 +1,23 @@ +"""Send one UDP datagram per 802.11 user priority. + +`ping -Q` does not give exact TID control: the TOS byte reaches +cfg80211_classify8021d() only when skb->priority has not already been set, and +setting IP_TOS sets sk_priority through ip_tos2prio[] first. SO_PRIORITY in +the range 256..263 is special-cased by that same function as "user priority +0..7, use it verbatim", which is the only way to address all eight TIDs. +""" +import socket +import sys +import time + +dst = sys.argv[1] +tag = (sys.argv[2] if len(sys.argv) > 2 else "").encode() +for tid in range(8): + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_PRIORITY, 256 + tid) + # A payload that says which TID it was sent with, so a decrypted vector can + # be checked against the TID in the header it was encrypted under. + s.sendto(b"devourer-ccmp-vector tid=%d %s" % (tid, tag), (dst, 9999)) + s.close() + time.sleep(0.15) +print("sent 8 datagrams, one per TID") diff --git a/tests/ccmp_vectors.h b/tests/ccmp_vectors.h new file mode 100644 index 00000000..8836e8c9 --- /dev/null +++ b/tests/ccmp_vectors.h @@ -0,0 +1,232 @@ +/* GENERATED by tests/ccmp_gen_vectors.py - do not edit. + * + * CCMP known-answer vectors. The ciphertext and MIC come from + * python-cryptography's AESCCM; the 802.11 framing is transcribed in + * that script from the same reading of the standard as src/sta/Ccmp.h, + * so these pin the CIPHER PLUMBING and NOT the framing rules. The + * script header explains what that does and does not buy; the direct + * assertions in tests/ccmp_selftest.cpp are what pin the rules. */ +#ifndef DEVOURER_TESTS_CCMP_VECTORS_H +#define DEVOURER_TESTS_CCMP_VECTORS_H + +#include +#include + +struct CcmpVector { + const char* name; + const uint8_t* tk; + const uint8_t* a2; + uint64_t pn; + uint8_t key_id; + const uint8_t* hdr; size_t hdr_len; + const uint8_t* plain; size_t plain_len; + const uint8_t* mpdu; size_t mpdu_len; /* hdr|ccmp|ct|mic */ + size_t aad_len; /* 22, +6 four-addr, +2 QoS */ +}; + +static const uint8_t kTk_basic[16] = { + 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, + 0x1c, 0x1d, 0x1e, 0x1f +}; + +static const uint8_t kA2_basic[6] = { + 0x02, 0x42, 0x44, 0x75, 0xd6, 0x00 +}; + +static const uint8_t kHdr_basic[24] = { + 0x08, 0x42, 0x00, 0x00, 0x02, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0x02, 0x42, + 0x44, 0x75, 0xd6, 0x00, 0x02, 0x42, 0x44, 0x75, 0xd6, 0x00, 0x00, 0x00 +}; + +static const uint8_t kPlain_basic[29] = { + 0xaa, 0xaa, 0x03, 0x00, 0x00, 0x00, 0x08, 0x00, 0x64, 0x65, 0x76, 0x6f, + 0x75, 0x72, 0x65, 0x72, 0x20, 0x73, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x20, 0x6d, 0x6f, 0x64, 0x65 +}; + +static const uint8_t kMpdu_basic[69] = { + 0x08, 0x42, 0x00, 0x00, 0x02, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0x02, 0x42, + 0x44, 0x75, 0xd6, 0x00, 0x02, 0x42, 0x44, 0x75, 0xd6, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0xc4, 0x71, 0x39, 0x97, + 0xb2, 0x5c, 0x55, 0x71, 0xc4, 0x4a, 0xad, 0xb7, 0x6c, 0x67, 0x98, 0x3d, + 0x8c, 0x89, 0x14, 0x42, 0xaf, 0xaa, 0x3b, 0xbe, 0xb0, 0xb2, 0x54, 0x48, + 0x02, 0x88, 0xc4, 0x03, 0x07, 0x7b, 0xfe, 0x92, 0xf7 +}; + +static const uint8_t kTk_masked_fc[16] = { + 0xc9, 0x7c, 0x1f, 0x67, 0xce, 0x37, 0x11, 0x85, 0x51, 0x4a, 0x8a, 0x19, + 0xf2, 0xbd, 0xd5, 0x2f +}; + +static const uint8_t kA2_masked_fc[6] = { + 0x00, 0x11, 0x22, 0x33, 0x44, 0x55 +}; + +static const uint8_t kHdr_masked_fc[24] = { + 0x78, 0x39, 0x12, 0x34, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, + 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, 0x11, 0x34, 0x12 +}; + +static const uint8_t kPlain_masked_fc[4] = { + 0x01, 0x02, 0x03, 0x04 +}; + +static const uint8_t kMpdu_masked_fc[44] = { + 0x78, 0x79, 0x12, 0x34, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, + 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, 0x11, 0x34, 0x12, + 0x34, 0x88, 0x00, 0xa0, 0x76, 0x39, 0x50, 0x0b, 0x19, 0x2c, 0x50, 0x66, + 0x7a, 0x9c, 0x57, 0xae, 0xa6, 0x4e, 0x59, 0x0d +}; + +static const uint8_t kTk_fragment[16] = { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00 +}; + +static const uint8_t kA2_fragment[6] = { + 0x02, 0x00, 0x00, 0x00, 0x00, 0x01 +}; + +static const uint8_t kHdr_fragment[24] = { + 0x08, 0x42, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x02, 0x02, 0x00, + 0x00, 0x00, 0x00, 0x01, 0x02, 0x00, 0x00, 0x00, 0x00, 0x01, 0x75, 0xf0 +}; + +static const uint8_t kPlain_fragment[64] = { + 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, + 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, + 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, + 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, + 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, + 0x78, 0x78, 0x78, 0x78 +}; + +static const uint8_t kMpdu_fragment[104] = { + 0x08, 0x42, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x02, 0x02, 0x00, + 0x00, 0x00, 0x00, 0x01, 0x02, 0x00, 0x00, 0x00, 0x00, 0x01, 0x75, 0xf0, + 0xff, 0xff, 0x00, 0x60, 0xff, 0xff, 0xff, 0xff, 0xec, 0xbe, 0x89, 0xbd, + 0x36, 0xd2, 0x86, 0xfa, 0x1d, 0xbc, 0xc5, 0xa9, 0x1f, 0x3c, 0x68, 0x72, + 0x50, 0x01, 0xf3, 0x89, 0xff, 0x5f, 0x23, 0xc7, 0xeb, 0x53, 0xb7, 0x72, + 0xcd, 0x34, 0xa4, 0xf3, 0xa5, 0x02, 0x2a, 0x40, 0xcc, 0x37, 0xf6, 0x49, + 0xbd, 0x79, 0xe3, 0x57, 0x0d, 0x0c, 0xc0, 0xff, 0xa9, 0x74, 0x48, 0xb1, + 0x3e, 0xe2, 0xaf, 0x39, 0x89, 0xf8, 0xad, 0x44, 0x9c, 0x9f, 0x3d, 0x67, + 0x36, 0xa2, 0x50, 0xf0, 0x10, 0xe9, 0xb4, 0xfc +}; + +static const uint8_t kTk_qos_tid5[16] = { + 0x0f, 0x0e, 0x0d, 0x0c, 0x0b, 0x0a, 0x09, 0x08, 0x07, 0x06, 0x05, 0x04, + 0x03, 0x02, 0x01, 0x00 +}; + +static const uint8_t kA2_qos_tid5[6] = { + 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff +}; + +static const uint8_t kHdr_qos_tid5[26] = { + 0x88, 0x41, 0x00, 0x00, 0x02, 0x42, 0x44, 0x75, 0xd6, 0x00, 0xaa, 0xbb, + 0xcc, 0xdd, 0xee, 0xff, 0x02, 0x42, 0x44, 0x75, 0xd6, 0x00, 0x20, 0x10, + 0x05, 0x00 +}; + +static const uint8_t kPlain_qos_tid5[22] = { + 0xaa, 0xaa, 0x03, 0x00, 0x00, 0x00, 0x08, 0x06, 0x71, 0x6f, 0x73, 0x2d, + 0x74, 0x69, 0x64, 0x2d, 0x35, 0x2d, 0x62, 0x6f, 0x64, 0x79 +}; + +static const uint8_t kMpdu_qos_tid5[64] = { + 0x88, 0x41, 0x00, 0x00, 0x02, 0x42, 0x44, 0x75, 0xd6, 0x00, 0xaa, 0xbb, + 0xcc, 0xdd, 0xee, 0xff, 0x02, 0x42, 0x44, 0x75, 0xd6, 0x00, 0x20, 0x10, + 0x05, 0x00, 0xef, 0xcd, 0x00, 0x20, 0xab, 0x00, 0x00, 0x00, 0x4c, 0xc6, + 0xec, 0x89, 0xa4, 0xb9, 0x39, 0xd2, 0x05, 0x51, 0xdd, 0xc2, 0x3c, 0x33, + 0x94, 0xf4, 0x68, 0x5a, 0x24, 0x46, 0x31, 0x8c, 0xe5, 0x70, 0xf9, 0x17, + 0xd1, 0xe3, 0x3f, 0x5f +}; + +static const uint8_t kTk_qos_order_htc[16] = { + 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, + 0x1c, 0x1d, 0x1e, 0x1f +}; + +static const uint8_t kA2_qos_order_htc[6] = { + 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff +}; + +static const uint8_t kHdr_qos_order_htc[30] = { + 0x88, 0xc1, 0x00, 0x00, 0x02, 0x42, 0x44, 0x75, 0xd6, 0x00, 0xaa, 0xbb, + 0xcc, 0xdd, 0xee, 0xff, 0x02, 0x42, 0x44, 0x75, 0xd6, 0x00, 0x30, 0x50, + 0x03, 0x00, 0x00, 0x00, 0x00, 0x00 +}; + +static const uint8_t kPlain_qos_order_htc[19] = { + 0xaa, 0xaa, 0x03, 0x00, 0x00, 0x00, 0x08, 0x00, 0x68, 0x74, 0x63, 0x2d, + 0x6f, 0x72, 0x64, 0x65, 0x72, 0x65, 0x64 +}; + +static const uint8_t kMpdu_qos_order_htc[65] = { + 0x88, 0xc1, 0x00, 0x00, 0x02, 0x42, 0x44, 0x75, 0xd6, 0x00, 0xaa, 0xbb, + 0xcc, 0xdd, 0xee, 0xff, 0x02, 0x42, 0x44, 0x75, 0xd6, 0x00, 0x30, 0x50, + 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0xaa, 0x00, 0x00, 0x60, 0x00, 0x00, + 0x00, 0x00, 0x83, 0x1a, 0x92, 0x82, 0x25, 0xdc, 0x1c, 0xd2, 0xb5, 0x4e, + 0x7c, 0x0d, 0x12, 0x9c, 0xb1, 0x44, 0x6d, 0x48, 0xfc, 0xdc, 0xf0, 0xef, + 0x9e, 0x89, 0x45, 0xa5, 0xdd +}; + +static const uint8_t kTk_four_addr_qos[16] = { + 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, + 0xcc, 0xdd, 0xee, 0xff +}; + +static const uint8_t kA2_four_addr_qos[6] = { + 0x02, 0x00, 0x00, 0x00, 0x00, 0x11 +}; + +static const uint8_t kHdr_four_addr_qos[32] = { + 0x88, 0x03, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x11, 0x02, 0x00, + 0x00, 0x00, 0x00, 0x11, 0x02, 0x00, 0x00, 0x00, 0x00, 0x22, 0x01, 0xf0, + 0x02, 0x00, 0x00, 0x00, 0x00, 0x33, 0x07, 0x00 +}; + +static const uint8_t kPlain_four_addr_qos[16] = { + 0x66, 0x6f, 0x75, 0x72, 0x2d, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, + 0x2d, 0x71, 0x6f, 0x73 +}; + +static const uint8_t kMpdu_four_addr_qos[64] = { + 0x88, 0x43, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x11, 0x02, 0x00, + 0x00, 0x00, 0x00, 0x11, 0x02, 0x00, 0x00, 0x00, 0x00, 0x22, 0x01, 0xf0, + 0x02, 0x00, 0x00, 0x00, 0x00, 0x33, 0x07, 0x00, 0xff, 0x00, 0x00, 0xe0, + 0x00, 0x00, 0x00, 0x00, 0x01, 0xa2, 0xe6, 0xdd, 0x23, 0x64, 0xdb, 0x3b, + 0xa3, 0xe1, 0x17, 0x66, 0x73, 0x96, 0xca, 0x28, 0xaf, 0xf2, 0x6a, 0xcd, + 0xa2, 0x9b, 0x50, 0xc6 +}; + +static const CcmpVector kCcmpVectors[] = { + {"basic", kTk_basic, kA2_basic, 0x000000000001ULL, 0, + kHdr_basic, sizeof kHdr_basic, + kPlain_basic, sizeof kPlain_basic, + kMpdu_basic, sizeof kMpdu_basic, 22}, + {"masked_fc", kTk_masked_fc, kA2_masked_fc, 0x0b5039768834ULL, 2, + kHdr_masked_fc, sizeof kHdr_masked_fc, + kPlain_masked_fc, sizeof kPlain_masked_fc, + kMpdu_masked_fc, sizeof kMpdu_masked_fc, 22}, + {"fragment", kTk_fragment, kA2_fragment, 0xffffffffffffULL, 1, + kHdr_fragment, sizeof kHdr_fragment, + kPlain_fragment, sizeof kPlain_fragment, + kMpdu_fragment, sizeof kMpdu_fragment, 22}, + {"qos_tid5", kTk_qos_tid5, kA2_qos_tid5, 0x000000abcdefULL, 0, + kHdr_qos_tid5, sizeof kHdr_qos_tid5, + kPlain_qos_tid5, sizeof kPlain_qos_tid5, + kMpdu_qos_tid5, sizeof kMpdu_qos_tid5, 24}, + {"qos_order_htc", kTk_qos_order_htc, kA2_qos_order_htc, 0x0000000000aaULL, 5, + kHdr_qos_order_htc, sizeof kHdr_qos_order_htc, + kPlain_qos_order_htc, sizeof kPlain_qos_order_htc, + kMpdu_qos_order_htc, sizeof kMpdu_qos_order_htc, 24}, + {"four_addr_qos", kTk_four_addr_qos, kA2_four_addr_qos, 0x0000000000ffULL, 3, + kHdr_four_addr_qos, sizeof kHdr_four_addr_qos, + kPlain_four_addr_qos, sizeof kPlain_four_addr_qos, + kMpdu_four_addr_qos, sizeof kMpdu_four_addr_qos, 30}, +}; +static const size_t kCcmpVectorCount = + sizeof kCcmpVectors / sizeof kCcmpVectors[0]; + +#endif /* DEVOURER_TESTS_CCMP_VECTORS_H */ diff --git a/tests/dot11_selftest.cpp b/tests/dot11_selftest.cpp new file mode 100644 index 00000000..f3672d68 --- /dev/null +++ b/tests/dot11_selftest.cpp @@ -0,0 +1,1002 @@ +/* Headless guard for src/sta/Dot11.h — the role-neutral management-frame + * module. + * + * The properties worth testing here are the ones whose failure is silent on + * air. A malformed IE walk reads off the end of a hostile frame; a builder + * that emits the right bytes in the wrong order produces a frame the peer + * ignores without comment; a station that never assigns a sequence number + * feeds the AP's duplicate detector and watches its own traffic vanish. + * + * Every case below is either a round-trip through the module's own parser + * (build → parse → compare) or an assertion about exact wire bytes. The + * round-trips would pass on a self-consistently wrong implementation, so the + * byte-exact assertions carry the weight and the round-trips catch the rest. + */ +#include +#include +#include +#include + +#include "sta/Dot11.h" + +namespace { + +using namespace devourer::sta; + +int g_fail = 0; + +void check(bool ok, const char* what) { + if (!ok) { + std::printf("FAIL: %s\n", what); + g_fail++; + } +} + +const uint8_t kBssid[6] = {0x02, 0x42, 0x75, 0x05, 0xd6, 0x00}; +const uint8_t kOwn[6] = {0x40, 0xa5, 0xef, 0x5a, 0x32, 0xf8}; + +/* The header's address order IS the difference between the two roles, so it + * gets an exact-bytes test rather than a round-trip. */ +void test_mgmt_hdr() { + std::vector m = mgmt_hdr(kFcAuth, kBssid, kOwn, kBssid); + + check(m.size() == 24, "management header is 24 bytes"); + check(m[0] == kFcAuth, "fc0 carries type+subtype"); + check(m[1] == 0, "fc1 starts clear"); + check(std::memcmp(m.data() + 4, kBssid, 6) == 0, "addr1 is the DA"); + check(std::memcmp(m.data() + 10, kOwn, 6) == 0, "addr2 is the SA"); + check(std::memcmp(m.data() + 16, kBssid, 6) == 0, "addr3 is the BSSID"); + check(m[22] == 0 && m[23] == 0, "sequence control starts zero"); +} + +/* Sequence assignment: the low 4 bits are the fragment number and must stay + * clear, and the counter must wrap at 12 bits rather than overflow into them. */ +void test_seq() { + std::vector m = mgmt_hdr(kFcAuth, kBssid, kOwn, kBssid); + + assign_seq(m, 1); + check(m[22] == 0x10 && m[23] == 0x00, "seq 1 lands above the fragment nibble"); + assign_seq(m, 0x0fff); + check(m[22] == 0xf0 && m[23] == 0xff, "the maximum sequence number packs"); + assign_seq(m, 0x1001); + check(m[22] == 0x10 && m[23] == 0x00, "a sequence number wraps at 12 bits"); + + SeqCounter c; + check(c.next() == 0 && c.next() == 1, "the counter starts at 0 and advances"); + for (int i = 0; i < 4093; i++) c.next(); + check(c.next() == 4095, "the counter reaches 4095"); + check(c.next() == 0, "the counter wraps to 0, not to 4096"); + + /* A short buffer must be refused, not written past. */ + /* THE SHORT-BUFFER GUARD IS NOT ASSERTABLE FROM HERE, and saying so is + * better than an assertion that cannot fail. assign_seq writes only at + * offsets 22 and 23, which for a 10-byte buffer is precisely the overflow + * the guard prevents - so `tiny[0] == 0` is true whether the guard exists + * or not. Deleting `if (frame.size() < 24) return;` is caught by the + * sanitizer job (ctest `build-sanitizers`), not by this file. + * + * What IS assertable is the positive: the minimum legal buffer gets its + * sequence number and nothing else is touched. */ + std::vector tiny(10, 0); + assign_seq(tiny, 7); + check(tiny.size() == 10, "assign_seq on a short buffer changes nothing " + "observable - the guard is proved by ASan, not here"); + + std::vector exact(24, 0); + assign_seq(exact, 7); + check(exact[22] == 0x70 && exact[23] == 0x00, + "a 24-byte buffer - the minimum legal header - IS numbered"); + for (size_t i = 0; i < 22; i++) + if (exact[i] != 0) { + check(false, "assign_seq touches nothing but the sequence control"); + break; + } +} + +/* The IE walker is the one function here that reads attacker-controlled + * lengths. A truncated element must end the walk, never read past the end. */ +void test_ie_walk() { + std::vector m; + size_t len = 0; + + append_ssid(m, "devourerAP"); + append_supported_rates(m); + append_ds_params(m, 149); + + const uint8_t* p = find_ie(m.data(), m.size(), kEidSsid, &len); + check(p && len == 10 && std::memcmp(p, "devourerAP", 10) == 0, + "SSID element round-trips"); + p = find_ie(m.data(), m.size(), kEidDsParams, &len); + check(p && len == 1 && p[0] == 149, "DS Parameter Set carries the channel"); + check(find_ie(m.data(), m.size(), kEidVhtCaps, &len) == nullptr, + "an absent element reports absent"); + + /* An element claiming more bytes than the buffer holds. */ + const uint8_t truncated[] = {kEidSsid, 200, 'a', 'b'}; + check(find_ie(truncated, sizeof truncated, kEidSsid, &len) == nullptr, + "an element longer than the buffer is refused, not read"); + /* And one whose header itself is cut off. */ + const uint8_t stub[] = {kEidSsid}; + check(find_ie(stub, sizeof stub, kEidSsid, &len) == nullptr, + "a one-byte element header is refused"); + /* A zero-length element must not stall the walk. */ + const uint8_t empty_then_ds[] = {kEidSsid, 0, kEidDsParams, 1, 36}; + p = find_ie(empty_then_ds, sizeof empty_then_ds, kEidDsParams, &len); + check(p && len == 1 && p[0] == 36, + "a zero-length element does not stall the walk"); +} + +/* The RSN element's exact bytes matter in both directions: an AP advertises + * them and a station echoes them back. */ +/* Golden bytes. The rate sets and the RSN element are byte for byte what the + * two on-air validated AP harnesses (tests/ap_responder.cpp, + * tests/ap_wpa2.cpp) emit inline, so a builder change that would alter them + * fails here rather than on air. */ +void test_golden_bytes() { + static const uint8_t k24[] = {0x01, 0x08, 0x82, 0x84, 0x8b, + 0x96, 0x24, 0x30, 0x48, 0x6c}; + static const uint8_t k5[] = {0x01, 0x08, 0x8c, 0x12, 0x98, + 0x24, 0xb0, 0x48, 0x60, 0x6c}; + static const uint8_t kSsidIe[] = {0x00, 0x0a, 'd', 'e', 'v', 'o', + 'u', 'r', 'e', 'r', 'A', 'P'}; + static const uint8_t kDs[] = {0x03, 0x01, 0x24}; + std::vector m; + + append_supported_rates(m); + check(m.size() == sizeof k24 && std::memcmp(m.data(), k24, sizeof k24) == 0, + "2.4 GHz Supported Rates bytes are unchanged"); + m.clear(); + append_supported_rates_5g(m); + check(m.size() == sizeof k5 && std::memcmp(m.data(), k5, sizeof k5) == 0, + "5 GHz Supported Rates bytes are unchanged"); + m.clear(); + check(append_ssid(m, "devourerAP"), "the SSID element builds"); + check(m.size() == sizeof kSsidIe && + std::memcmp(m.data(), kSsidIe, sizeof kSsidIe) == 0, + "SSID element bytes are unchanged"); + m.clear(); + append_ds_params(m, 36); + check(m.size() == sizeof kDs && std::memcmp(m.data(), kDs, sizeof kDs) == 0, + "DS Parameter Set bytes are unchanged"); + + /* Over-length bodies must be refused, not truncated into a corrupt frame. */ + m.clear(); + check(!append_ssid(m, std::string(33, 'x')), "a 33-byte SSID is refused"); + check(m.empty(), "a refused element emits nothing at all"); + m.clear(); + std::vector big(256, 0); + check(!append_ie(m, kEidSsid, big.data(), big.size()), + "an IE body over 255 bytes is refused"); + check(m.empty(), "a refused IE emits nothing at all"); +} + +void test_rsn() { + std::vector m; + size_t len = 0; + + append_rsn_ccmp_psk(m); + const uint8_t* p = find_ie(m.data(), m.size(), kEidRsn, &len); + check(p && len == 20, "RSN element is 20 bytes"); + if (!p) return; + check(p[0] == 0x01 && p[1] == 0x00, "RSN version 1"); + check(p[2] == 0x00 && p[3] == 0x0f && p[4] == 0xac && p[5] == 0x04, + "group cipher is CCMP"); + check(p[10] == 0xac && p[11] == 0x04, "pairwise cipher is CCMP"); + check(p[16] == 0xac && p[17] == 0x02, "AKM is PSK"); + /* The COUNT fields. A swapped count makes parse_beacon reject real APs, and + * a build/parse round-trip would not notice because both sides share it. */ + check(p[6] == 0x01 && p[7] == 0x00, "exactly one pairwise cipher suite"); + check(p[12] == 0x01 && p[13] == 0x00, "exactly one AKM suite"); + check(p[8] == 0x00 && p[9] == 0x0f, "pairwise suite OUI 00-0F-AC"); + check(p[14] == 0x00 && p[15] == 0x0f, "AKM suite OUI 00-0F-AC"); + check(p[18] == 0x00 && p[19] == 0x00, "RSN capabilities are zero (no MFP)"); +} + +/* Build a beacon the way an AP does, parse it the way a station will. */ +void test_beacon_roundtrip() { + // SA deliberately DIFFERENT from the BSSID. With both set to kBssid the + // "BSSID comes from addr3" assertion below would pass even if the parser + // read addr2, and pin nothing. + static const uint8_t kOtherSa[6] = {0x06, 0x06, 0x06, 0x06, 0x06, 0x06}; + std::vector m = mgmt_hdr(kFcBeacon, (const uint8_t*)"\xff\xff\xff\xff\xff\xff", + kOtherSa, kBssid); + for (int i = 0; i < 8; i++) m.push_back(0); /* timestamp */ + put_le16(m, 100); /* beacon interval */ + put_le16(m, 0x0011); /* ESS | Privacy */ + append_ssid(m, "devourerAP"); + append_supported_rates_5g(m); + append_ds_params(m, 36); + append_rsn_ccmp_psk(m); + + BssInfo b; + check(parse_beacon(m.data(), m.size(), &b), "a beacon parses"); + check(std::memcmp(b.bssid, kBssid, 6) == 0, "BSSID comes from addr3"); + check(std::memcmp(b.bssid, kOtherSa, 6) != 0, + "BSSID is NOT addr2 (the test can tell them apart)"); + check(b.ssid == "devourerAP", "SSID round-trips"); + check(b.beacon_interval_tu == 100, "beacon interval round-trips"); + check(b.capability == 0x0011, "capability round-trips"); + check(b.privacy, "the Privacy bit is decoded"); + check(b.channel == 36, "the channel comes from the DS Parameter Set"); + check(b.has_rsn && b.rsn_ccmp_psk, "our own RSN element is recognised"); + + /* An RSN element we do not implement must be reported as such rather than + * associated with and failed later. TKIP pairwise (…ac 02 in the cipher + * position) is the classic case. */ + std::vector tkip = m; + size_t rl = 0; + uint8_t* r = const_cast(find_ie(tkip.data() + 36, tkip.size() - 36, + kEidRsn, &rl)); + check(r != nullptr, "setup: found the RSN element to corrupt"); + if (r) { + r[11] = 0x02; /* pairwise CCMP -> TKIP */ + BssInfo b2; + check(parse_beacon(tkip.data(), tkip.size(), &b2), "setup: still parses"); + check(b2.has_rsn && !b2.rsn_ccmp_psk, + "an unsupported RSN suite is rejected, not silently accepted"); + } + + /* Too short to hold the fixed body. */ + BssInfo b3; + check(!parse_beacon(m.data(), 30, &b3), "a truncated beacon is refused"); + + /* A REUSED BssInfo must not carry the previous BSS's fields. A scan loop + * does exactly this, and a frame missing the SSID/DS/RSN elements would + * otherwise report the last BSS's values as this one's. */ + std::vector bare = mgmt_hdr(kFcBeacon, + (const uint8_t*)"\xff\xff\xff\xff\xff\xff", + kOtherSa, kOwn); + for (int i = 0; i < 8; i++) bare.push_back(0); + put_le16(bare, 200); + put_le16(bare, 0x0001); /* ESS, no Privacy, and no IEs at all */ + check(parse_beacon(bare.data(), bare.size(), &b), "a bare beacon parses"); + check(b.ssid.empty(), "a reused BssInfo does not keep the old SSID"); + check(b.channel == 0, "a reused BssInfo does not keep the old channel"); + check(!b.has_rsn, "a reused BssInfo does not keep the old RSN flag"); + check(!b.rsn_ccmp_psk, "a reused BssInfo does not keep the old RSN verdict"); + check(!b.privacy, "a reused BssInfo does not keep the old Privacy bit"); + check(b.beacon_interval_tu == 200, "the new beacon's own fields are set"); + + /* An RSN element that stops before the Capabilities field cannot be judged + * safe to join, and an MFP-required BSS must be surfaced rather than + * associated with and failed later. */ + std::vector mfp = m; + size_t ml = 0; + uint8_t* mr = const_cast(find_ie(mfp.data() + 36, mfp.size() - 36, + kEidRsn, &ml)); + check(mr && ml == 20, "setup: RSN element found"); + if (mr && ml == 20) { + mr[18] = 0x40; /* RSN Capabilities: MFPR */ + BssInfo b4; + check(parse_beacon(mfp.data(), mfp.size(), &b4), "setup: parses"); + check(b4.rsn_mfp_required, + "a BSS that REQUIRES management-frame protection is flagged"); + } +} + +void test_station_builders() { + std::vector pr = build_probe_req(kOwn, "devourerAP", 36, true); + size_t len = 0; + + check(pr[0] == kFcProbeReq, "probe request subtype"); + check(pr[4] == 0xff && pr[9] == 0xff, "probe request addr1 is broadcast"); + check(std::memcmp(pr.data() + 10, kOwn, 6) == 0, "probe request SA is ours"); + const uint8_t* p = find_ie(pr.data() + 24, pr.size() - 24, kEidSsid, &len); + check(p && len == 10, "a directed probe carries the SSID"); + p = find_ie(pr.data() + 24, pr.size() - 24, kEidSupportedRates, &len); + check(p && len == 8, "a 5 GHz probe advertises eight rates"); + /* NO CCK on 5 GHz. The rates are OFDM 6..54. */ + check(p && p[0] == 0x0c && p[7] == 0x6c, + "a 5 GHz probe advertises OFDM 6..54, not CCK"); + /* AND NONE OF THEM IS MARKED BASIC. A station's Supported Rates element + * says what the STATION can do; the basic set is the AP's statement about + * its BSS, and mac80211 sets no basic bit in a station's requests either. + * The AP's 5 GHz builder marks four of them basic, which is why a station + * does not reuse it. */ + for (size_t i = 0; p && i < len; i++) + if (p[i] & 0x80) { + check(false, "a station's own rate set marks nothing BASIC"); + break; + } + check(find_ie(pr.data() + 24, pr.size() - 24, kEidExtSupportedRates, &len) == + nullptr, + "a 5 GHz probe needs no Extended Supported Rates - eight rates fit"); + + /* 2.4 GHz: the mandatory OFDM rates 6, 12 and 24 must be advertised, or an + * AP whose basic set includes them refuses the association with status 18. + * The AP's builder omits 6, 9, 12 and 48 for reasons of its own, which is + * why a station has its own. 24 (0x30) lands in the extended element. */ + { + std::vector g = build_probe_req(kOwn, "devourerAP", 6, false); + size_t sl = 0, el = 0; + const uint8_t* sr = + find_ie(g.data() + 24, g.size() - 24, kEidSupportedRates, &sl); + const uint8_t* er = + find_ie(g.data() + 24, g.size() - 24, kEidExtSupportedRates, &el); + bool has6 = false, has12 = false, has24 = false; + + check(sr && sl == 8, "a 2.4 GHz probe carries eight supported rates"); + check(er && el == 4, "...and four more in Extended Supported Rates"); + for (size_t i = 0; sr && i < sl; i++) { + if ((sr[i] & 0x7f) == 0x0c) has6 = true; + if ((sr[i] & 0x7f) == 0x18) has12 = true; + if ((sr[i] & 0x7f) == 0x30) has24 = true; + } + for (size_t i = 0; er && i < el; i++) { + if ((er[i] & 0x7f) == 0x0c) has6 = true; + if ((er[i] & 0x7f) == 0x18) has12 = true; + if ((er[i] & 0x7f) == 0x30) has24 = true; + } + check(has6 && has12 && has24, + "the mandatory OFDM rates 6, 12 and 24 are advertised"); + check(sr && (sr[0] & 0x7f) == 0x02, + "...and 1 Mbps CCK is still there for a 2.4 GHz BSS"); + for (size_t i = 0; sr && i < sl; i++) + if (sr[i] & 0x80) { + check(false, "a 2.4 GHz station marks none of its rates BASIC"); + break; + } + } + + /* THE ASSOCIATION REQUEST, not just the probe. This is the frame an AP + * refuses with status 18 when a mandatory rate is missing, and a check on + * the probe alone would not notice it carrying the AP's set instead. */ + { + std::vector a = + build_assoc_req(kOwn, kBssid, "devourerAP", true, false); + size_t sl = 0, el = 0; + const uint8_t* sr = + find_ie(a.data() + 28, a.size() - 28, kEidSupportedRates, &sl); + const uint8_t* er = + find_ie(a.data() + 28, a.size() - 28, kEidExtSupportedRates, &el); + bool has6 = false, has12 = false; + + check(sr && sl == 8 && er && el == 4, + "a 2.4 GHz association request carries twelve rates in two elements"); + for (size_t i = 0; sr && i < sl; i++) { + if ((sr[i] & 0x7f) == 0x0c) has6 = true; + if ((sr[i] & 0x7f) == 0x18) has12 = true; + if (sr[i] & 0x80) { check(false, "assoc request marks nothing BASIC"); break; } + } + check(has6 && has12, + "the association request advertises the mandatory 6 and 12 Mbps"); + } + + std::vector wildcard = build_probe_req(kOwn, "", 0, false); + p = find_ie(wildcard.data() + 24, wildcard.size() - 24, kEidSsid, &len); + check(p && len == 0, "a wildcard probe carries an EMPTY SSID element"); + check(find_ie(wildcard.data() + 24, wildcard.size() - 24, kEidDsParams, + &len) == nullptr, + "channel 0 omits the DS Parameter Set"); + + AuthFields af; + std::vector au = build_auth_req(kOwn, kBssid); + check(parse_auth(au.data(), au.size(), &af), "auth request parses"); + check(af.algorithm == 0, "open-system authentication"); + check(af.seq == 1, "the station's auth is sequence 1"); + check(af.status == 0, "auth request status is 0"); + check(std::memcmp(au.data() + 4, kBssid, 6) == 0, "auth is addressed to the AP"); + + /* The capability/RSN agreement an AP checks. */ + std::vector ar = build_assoc_req(kOwn, kBssid, "devourerAP", true, true); + check(ar[0] == kFcAssocReq, "assoc request subtype"); + check(get_le16(ar.data() + 24) == 0x0011, + "an RSN assoc request sets ESS and Privacy together"); + check(find_ie(ar.data() + 28, ar.size() - 28, kEidRsn, &len) != nullptr, + "an RSN assoc request carries the RSN element"); + + std::vector open = build_assoc_req(kOwn, kBssid, "devourerAP", false, false); + check(get_le16(open.data() + 24) == 0x0001, + "an open assoc request claims ESS without Privacy"); + check(find_ie(open.data() + 28, open.size() - 28, kEidRsn, &len) == nullptr, + "an open assoc request carries no RSN element"); + + std::vector dr = build_deauth(kOwn, kBssid, 3); + uint16_t reason = 0; + check(dr[0] == kFcDeauth, "deauth subtype"); + check(parse_reason(dr.data(), dr.size(), &reason) && reason == 3, + "deauth carries its reason code"); +} + +void test_assoc_resp_parse() { + std::vector m = mgmt_hdr(kFcAssocResp, kOwn, kBssid, kBssid); + put_le16(m, 0x0011); + put_le16(m, 0); + put_le16(m, 0xc001); /* AID 1 with both top bits set, as on the wire */ + append_supported_rates_5g(m); + + AssocRespFields f; + check(parse_assoc_resp(m.data(), m.size(), &f), "assoc response parses"); + check(f.status == 0, "status 0 is success"); + check(f.aid == 1, "the AID has its two top bits masked off"); + + AssocRespFields g; + check(!parse_assoc_resp(m.data(), 24, &g), + "an assoc response with no body is refused"); +} + +/* Data-frame direction and header length. The QoS +2 is the offset error that + * silently drops every QoS frame. */ +void test_data_frames() { + const uint8_t dest[6] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06}; + std::vector up = data_hdr_to_ds(kBssid, kOwn, dest, true); + + check(up[1] == (kFcToDs | kFcProtected), "uplink sets to-DS and Protected"); + check(std::memcmp(up.data() + 4, kBssid, 6) == 0, "uplink addr1 is the BSSID"); + check(std::memcmp(up.data() + 10, kOwn, 6) == 0, "uplink addr2 is us"); + check(std::memcmp(up.data() + 16, dest, 6) == 0, "uplink addr3 is the dest"); + + std::vector down = data_hdr_from_ds(kOwn, kBssid, dest, false); + check(down[1] == kFcFromDs, "downlink sets from-DS only"); + check(std::memcmp(down.data() + 4, kOwn, 6) == 0, "downlink addr1 is the STA"); + check(std::memcmp(down.data() + 10, kBssid, 6) == 0, + "downlink addr2 is the BSSID"); + + /* The builders and the length function must not drift apart. */ + check(up.size() == data_hdr_len(up[0], up[1]), + "data_hdr_to_ds's size matches data_hdr_len"); + check(down.size() == data_hdr_len(down[0], down[1]), + "data_hdr_from_ds's size matches data_hdr_len"); + + /* Every QoS data subtype, not just QoS Data. A real station sends QoS Null + * (0xc8); an exact fc0 == 0x88 test reads its body two bytes early. */ + check(is_qos_data(0x88), "QoS Data is QoS"); + check(is_qos_data(0xc8), "QoS Null is QoS"); + check(is_qos_data(0x98), "QoS Data+CF-Ack is QoS"); + check(!is_qos_data(0x08), "plain Data is not QoS"); + check(!is_qos_data(0x48), "Null (non-QoS) is not QoS"); + check(!is_qos_data(0x80), "a Beacon is not QoS data"); + check(data_hdr_len(0xc8, kFcToDs) == 26, "QoS Null carries the QoS field"); + /* HT Control rides the Order bit on a QoS frame, and means something else + * on a non-QoS one. */ + check(data_hdr_len(kFcQosData, kFcToDs | 0x80) == 30, + "QoS + Order adds the 4-byte HT Control field"); + check(data_hdr_len(kFcData, kFcToDs | 0x80) == 24, + "Order on a non-QoS frame adds nothing"); + + check(data_hdr_len(kFcData, kFcToDs) == 24, "a 3-address data header is 24"); + check(data_hdr_len(kFcQosData, kFcToDs) == 26, "QoS data adds 2 bytes"); + check(data_hdr_len(kFcData, kFcToDs | kFcFromDs) == 30, + "a 4-address frame adds 6 bytes"); + check(data_hdr_len(kFcQosData, kFcToDs | kFcFromDs) == 32, + "4-address QoS adds both"); + + std::vector llc; + append_llc_snap(llc, 0x0800); + check(llc.size() == 8 && llc[0] == 0xaa && llc[1] == 0xaa && llc[2] == 0x03, + "LLC/SNAP prefix"); + check(llc[6] == 0x08 && llc[7] == 0x00, "ethertype is big-endian in SNAP"); +} + + +/* RSN elements as they actually appear in the field. + * + * A byte comparison against a canonical one-pairwise/one-AKM layout would + * report every mixed-mode WPA/WPA2 AP and every WPA3-transition AP as + * unusable, and a station would skip BSSes it can join. These cases are those + * real-world shapes, plus the malformed ones that must be refused. */ +namespace rsn { + +std::vector build(uint16_t ver, const std::vector& group, + const std::vector& pairwise, + const std::vector& akm, + bool caps, uint16_t capval) { + std::vector v; + auto suite = [&](uint32_t t) { + v.push_back(0x00); v.push_back(0x0f); v.push_back(0xac); + v.push_back((uint8_t)t); + }; + put_le16(v, ver); + for (uint32_t g : group) suite(g); + if (!pairwise.empty() || !akm.empty() || caps) { + put_le16(v, (uint16_t)pairwise.size()); + for (uint32_t c : pairwise) suite(c); + } + if (!akm.empty() || caps) { + put_le16(v, (uint16_t)akm.size()); + for (uint32_t a : akm) suite(a); + } + if (caps) put_le16(v, capval); + return v; +} + +} // namespace rsn + +void test_rsn_real_world() { + RsnInfo r; + + /* The canonical one-of-each element. */ + auto plain = rsn::build(1, {4}, {4}, {2}, true, 0x0000); + check(parse_rsn(plain.data(), plain.size(), &r), "canonical RSN parses"); + check(r.group_ccmp && r.pairwise_ccmp && r.akm_psk, "canonical is CCMP/PSK"); + check(!r.mfp_required && !r.mfp_capable, "canonical has no MFP"); + + /* MIXED MODE: TKIP group, TKIP+CCMP pairwise, PSK - a very common real + * AP. The group cipher is TKIP, so this is NOT joinable by this + * project - but the PAIRWISE search must still find CCMP. */ + auto mixed = rsn::build(1, {2}, {2, 4}, {2}, true, 0x0000); + check(parse_rsn(mixed.data(), mixed.size(), &r), "mixed-mode RSN parses"); + check(r.pairwise_count == 2, "two pairwise suites are counted"); + check(r.pairwise_ccmp, "CCMP is found AMONG several pairwise suites"); + check(!r.group_ccmp, "a TKIP group cipher is reported as not CCMP"); + + /* WPA2-only AP that still lists two pairwise suites (CCMP first). */ + auto two_cc = rsn::build(1, {4}, {4, 2}, {2}, true, 0x0000); + check(parse_rsn(two_cc.data(), two_cc.size(), &r), "parses"); + check(r.group_ccmp && r.pairwise_ccmp && r.akm_psk, + "CCMP group with a TKIP fallback pairwise is joinable"); + + /* WPA3 TRANSITION: CCMP, AKM = PSK + PSK-SHA256, MFP capable but not + * required - an extremely common real AP. */ + auto trans = rsn::build(1, {4}, {4}, {2, 6}, true, 0x0080); + check(parse_rsn(trans.data(), trans.size(), &r), "WPA3-transition parses"); + check(r.akm_count == 2, "two AKMs are counted"); + check(r.akm_psk, "PSK is found AMONG several AKMs"); + check(r.mfp_capable && !r.mfp_required, "MFP capable, not required"); + + /* WPA3-ONLY: SAE AKM, MFP required. Must NOT be reported as joinable. */ + auto sae = rsn::build(1, {4}, {4}, {8}, true, 0x00c0); + check(parse_rsn(sae.data(), sae.size(), &r), "WPA3-only parses"); + check(!r.akm_psk, "SAE is not PSK"); + check(r.mfp_required, "WPA3-only requires MFP"); + + /* A vendor OUI must not be mistaken for an 802.11 suite of the same type. */ + std::vector vendor = plain; + vendor[8] = 0x00; vendor[9] = 0x50; vendor[10] = 0xf2; /* pairwise OUI */ + check(parse_rsn(vendor.data(), vendor.size(), &r), "vendor-OUI RSN parses"); + check(!r.pairwise_ccmp, "a vendor OUI is not 00-0F-AC CCMP"); + + /* Truncation and malformed counts: refuse, never read past the end. */ + check(!parse_rsn(plain.data(), 1, &r), "a 1-byte RSN body is refused"); + auto bad_ver = rsn::build(2, {4}, {4}, {2}, true, 0); + check(!parse_rsn(bad_ver.data(), bad_ver.size(), &r), + "an unknown RSN version is refused"); + std::vector overrun = plain; + overrun[6] = 0xff; overrun[7] = 0xff; /* pairwise count = 65535 */ + check(!parse_rsn(overrun.data(), overrun.size(), &r), + "a pairwise count that overruns the element is refused"); + std::vector akm_overrun = plain; + akm_overrun[12] = 0xff; akm_overrun[13] = 0xff; + check(!parse_rsn(akm_overrun.data(), akm_overrun.size(), &r), + "an AKM count that overruns the element is refused"); + + /* An element that stops BEFORE RSN Capabilities is accepted, with MFPR + * defaulting to 0 (trailing fields may be omitted). Pinned both at + * parse_rsn and through parse_beacon, so a flip in either direction is a + * failing cell rather than a silent change. */ + auto no_caps = rsn::build(1, {4}, {4}, {2}, false, 0); + check(no_caps.size() == 18, "the no-capabilities element is 18 bytes"); + check(parse_rsn(no_caps.data(), no_caps.size(), &r), + "an RSN element without capabilities parses"); + check(r.group_ccmp && r.pairwise_ccmp && r.akm_psk, + "its suites are still read"); + check(!r.mfp_required && !r.mfp_capable, + "absent capabilities mean MFPR=0, not unknown"); + { + std::vector m = mgmt_hdr(kFcBeacon, + (const uint8_t*)"\xff\xff\xff\xff\xff\xff", + kOwn, kBssid); + for (int i = 0; i < 8; i++) m.push_back(0); + put_le16(m, 100); put_le16(m, 0x0011); + append_ssid(m, "ap"); + append_ie(m, kEidRsn, no_caps.data(), no_caps.size()); + BssInfo b; + check(parse_beacon(m.data(), m.size(), &b), "parses"); + check(b.rsn_ccmp_psk, + "a BSS whose RSN element omits capabilities IS joinable"); + } + + /* A count landing exactly on the element boundary is legal; one byte past + * is not. `>` vs `>=` in the overrun check is the difference. */ + auto exact = rsn::build(1, {4}, {4}, {2}, false, 0); + check(parse_rsn(exact.data(), exact.size(), &r), + "a count reaching exactly the element end is accepted"); + check(!parse_rsn(exact.data(), exact.size() - 1, &r), + "one byte short of that same count is refused"); + + /* A refused element must leave NO partial state behind. */ + std::vector ov = plain; + ov[6] = 0xff; ov[7] = 0xff; + check(!parse_rsn(ov.data(), ov.size(), &r), "setup: refused"); + check(r.pairwise_count == 0 && !r.pairwise_ccmp && !r.group_ccmp, + "a refused element leaves no partial state"); + + check(!parse_rsn(nullptr, 20, &r), "a null body is refused"); + + /* A short-but-legal element stops early and leaves later flags false. */ + auto group_only = rsn::build(1, {4}, {}, {}, false, 0); + check(parse_rsn(group_only.data(), group_only.size(), &r), + "an element with only a group cipher is legal"); + check(r.group_ccmp && !r.pairwise_ccmp && !r.akm_psk, + "absent lists leave their flags false"); + + /* And the end-to-end verdict through parse_beacon: a WPA3-transition BSS + * is JOINABLE, and an MFP-required one is not. */ + auto mkbeacon = [&](const std::vector& ie) { + std::vector m = mgmt_hdr(kFcBeacon, + (const uint8_t*)"\xff\xff\xff\xff\xff\xff", + kOwn, kBssid); + for (int i = 0; i < 8; i++) m.push_back(0); + put_le16(m, 100); + put_le16(m, 0x0011); + append_ssid(m, "ap"); + append_ie(m, kEidRsn, ie.data(), ie.size()); + return m; + }; + BssInfo b; + auto bt = mkbeacon(trans); + check(parse_beacon(bt.data(), bt.size(), &b), "transition beacon parses"); + check(b.rsn_ccmp_psk, + "a WPA3-TRANSITION BSS is joinable"); + auto bm = mkbeacon(mixed); + check(parse_beacon(bm.data(), bm.size(), &b), "mixed beacon parses"); + check(!b.rsn_ccmp_psk, "a TKIP-group BSS is not joinable"); + auto bs = mkbeacon(sae); + check(parse_beacon(bs.data(), bs.size(), &b), "WPA3-only beacon parses"); + check(!b.rsn_ccmp_psk && b.rsn_mfp_required, + "an MFP-REQUIRED BSS is not joinable and says why"); +} + +/* Data frames carry sequence numbers too. Management frames are the visible + * half; the data plane is the one that feeds a duplicate detector in + * volume. */ +void test_data_seq() { + const uint8_t dest[6] = {1, 2, 3, 4, 5, 6}; + + std::vector a = data_hdr_to_ds(kBssid, kOwn, dest, true, 1); + check(a[22] == 0x10 && a[23] == 0x00, "uplink carries its sequence number"); + std::vector b = data_hdr_from_ds(kOwn, kBssid, dest, false, 0x0fff); + check(b[22] == 0xf0 && b[23] == 0xff, "downlink carries its sequence number"); + std::vector c = data_hdr_to_ds(kBssid, kOwn, dest, true); + check(c[22] == 0 && c[23] == 0, "the default is still zero"); + std::vector d = data_hdr_to_ds(kBssid, kOwn, dest, true, 0x1001); + check(d[22] == 0x10 && d[23] == 0x00, "a data sequence wraps at 12 bits"); +} + +} // namespace + +/* The TIM element (802.11-2016 9.4.2.6). + * + * Every conforming beacon carries one. The element built here is the MINIMUM + * conforming one - "nothing is buffered for anyone" - which is the truth for + * a sender that buffers nothing. It is not power-save support: a dozing + * station still misses replies that are never buffered, whatever schedule + * the beacon advertises (see append_tim in Dot11.h). The test pins exactly + * that content. */ +void test_tim() { + std::vector m; + append_tim(m); + + check(m.size() == 6, "tim: 2 bytes of header and 4 of body"); + check(m[0] == kEidTim, "tim: element id 5"); + check(m[1] == 4, "tim: length 4 - the minimum conforming body"); + check(m[2] == 0, "tim: DTIM count 0 - this beacon IS a DTIM beacon"); + check(m[3] == 1, "tim: DTIM period 1 - every beacon is, so nobody waits"); + check(m[4] == 0, "tim: bitmap control 0 - offset 0, no buffered group traffic"); + check(m[5] == 0, "tim: empty partial virtual bitmap - nothing buffered"); + + /* The walker must accept it, since a real station parses beacons with it. */ + size_t len = 0; + const uint8_t* found = find_ie(m.data(), m.size(), kEidTim, &len); + check(found != nullptr && len == 4, "tim: the IE walker finds it"); + + /* An AID inside the one-octet bitmap sets exactly its own bit. */ + std::vector a3; + append_tim(a3, 0, 1, 3); + check(a3[5] == 0x08, "tim: AID 3 sets bit 3 and nothing else"); + std::vector a7; + append_tim(a7, 0, 1, 7); + check(a7[5] == 0x80, "tim: AID 7 sets the top bit of the octet"); + + /* AID 0 is not a station - it is the group-addressed indication, and that + * lives in bit 0 of the BITMAP CONTROL octet, not the bitmap. Setting + * bitmap bit 0 for "AID 0" would announce buffered multicast that does not + * exist and make a station wait for it. */ + std::vector a0; + append_tim(a0, 0, 1, 0); + check(a0[5] == 0, "tim: AID 0 sets no bitmap bit"); + check(a0[4] == 0, "tim: AID 0 does not claim buffered group traffic"); + + /* An AID this minimum form CANNOT express must set nothing rather than + * half-encode it - a wrapped shift would set some other station's bit and + * tell the wrong peer to stay awake. */ + std::vector big; + append_tim(big, 0, 1, 8); + check(big[5] == 0, "tim: AID 8 is out of range for a one-octet bitmap"); + std::vector huge; + append_tim(huge, 0, 1, 2007); + check(huge[5] == 0, "tim: the maximum AID does not wrap into someone else's bit"); + + /* DTIM count and period are passed through, for a caller that does buffer. */ + std::vector d; + append_tim(d, 2, 3); + check(d[2] == 2 && d[3] == 3, "tim: DTIM count and period are carried"); + + /* BEACON ONLY. 802.11-2016 9.4.2.6 puts the TIM in the Beacon frame body; + * a TIM in a probe response is a malformed frame some stations reject + * outright. The harnesses build beacons and probe/assoc responses from the + * SAME IE helper with a flag, so nothing but this pins the flag - and + * wiring that is correct by inspection but unpinned is exactly what + * regresses unnoticed. + * + * Modelled the way the harnesses order it: SSID, rates, DS Params, then + * the TIM only when the frame is a beacon. */ + auto ies = [](bool beacon) { + std::vector m; + append_ssid(m, "devourerAP"); + append_supported_rates(m); + append_ds_params(m, 6); + if (beacon) append_tim(m); + return m; + }; + size_t n = 0; + check(find_ie(ies(true).data(), ies(true).size(), kEidTim, &n) != nullptr, + "tim: present when the frame is a beacon"); + check(find_ie(ies(false).data(), ies(false).size(), kEidTim, &n) == nullptr, + "tim: ABSENT from a probe/assoc response"); + /* And it must come after the DS Parameter Set, which is the element order + * the standard gives and which a strict parser will check. */ + { + std::vector b = ies(true); + size_t dl = 0, tl = 0; + const uint8_t* ds = find_ie(b.data(), b.size(), kEidDsParams, &dl); + const uint8_t* tm = find_ie(b.data(), b.size(), kEidTim, &tl); + check(ds != nullptr && tm != nullptr && ds < tm, + "tim: ordered after the DS Parameter Set"); + } +} + +/* Direction-aware DA/SA, against 802.11-2016 Table 9-26, and the + * relayed header an AP has to build. + * + * A receive path that takes addr1 and addr2 and assumes the frame is for the + * AP holds exactly until there is a second station to forward to; the + * destination is addr3 on a to-DS frame. + * + * The relay cell is the load-bearing one. It builds the to-DS frame station A + * sends for station B, extracts DA and SA with the accessors, rebuilds the + * from-DS frame the AP must air, and byte-compares the result against the + * layout written out by hand - not against another call of the same builder, + * which would only prove the builder agrees with itself. */ +static void test_relay_addressing() { + const uint8_t A[6] = {0x02, 0xaa, 0, 0, 0, 0x01}; /* station A */ + const uint8_t B[6] = {0x02, 0xbb, 0, 0, 0, 0x02}; /* station B */ + const uint8_t BSSID[6] = {0x02, 0x42, 0x75, 0x05, 0xd6, 0x00}; + const uint8_t GRP[6] = {0xff, 0xff, 0xff, 0xff, 0xff, 0xff}; + + /* --- to-DS: addr1 = BSSID, addr2 = SA, addr3 = DA ---------------------- */ + /* data_hdr_to_ds is (bssid, own, dest) - A sending to B. A caller that + * guesses the order as (dest, bssid, own) fails here, so the cell pins the + * argument order as well as the bytes. */ + std::vector up = devourer::sta::data_hdr_to_ds(BSSID, A, B, + /*protect=*/false, 0); + check(up.size() == 24, "a to-DS data header is 24 bytes"); + check(std::memcmp(up.data() + 4, BSSID, 6) == 0, "to-DS addr1 is the BSSID"); + check(std::memcmp(up.data() + 10, A, 6) == 0, "to-DS addr2 is the source"); + check(std::memcmp(up.data() + 16, B, 6) == 0, "to-DS addr3 is the destination"); + check(std::memcmp(devourer::sta::data_da(up.data(), up[1]), B, 6) == 0, + "data_da reads addr3 on a to-DS frame"); + check(std::memcmp(devourer::sta::data_sa(up.data(), up[1]), A, 6) == 0, + "data_sa reads addr2 on a to-DS frame"); + + /* --- the relay: same payload, rebuilt as from-DS ----------------------- */ + const uint8_t* da = devourer::sta::data_da(up.data(), up[1]); + const uint8_t* sa = devourer::sta::data_sa(up.data(), up[1]); + std::vector down = devourer::sta::data_hdr_from_ds(da, BSSID, sa, + /*protect=*/false, 0); + /* Written out by hand rather than by calling the builder again. */ + uint8_t want[24] = {0}; + want[0] = 0x08; /* type data, subtype data */ + want[1] = 0x02; /* From DS */ + std::memcpy(want + 4, B, 6); /* addr1 = DA */ + std::memcpy(want + 10, BSSID, 6); /* addr2 = BSSID */ + std::memcpy(want + 16, A, 6); /* addr3 = SA */ + check(down.size() == 24 && std::memcmp(down.data(), want, 24) == 0, + "the relayed from-DS header is byte-for-byte Table 9-26"); + + /* And it round-trips: the relayed frame's DA/SA are the originals. */ + check(std::memcmp(devourer::sta::data_da(down.data(), down[1]), B, 6) == 0, + "data_da reads addr1 on a from-DS frame"); + check(std::memcmp(devourer::sta::data_sa(down.data(), down[1]), A, 6) == 0, + "data_sa reads addr3 on a from-DS frame"); + + /* --- a station's broadcast is individually addressed to the AP --------- */ + std::vector bc = devourer::sta::data_hdr_to_ds(BSSID, A, GRP, + /*protect=*/false, 0); + check((bc[4] & 0x01) == 0, + "a station's broadcast has an INDIVIDUAL addr1 - it goes to the AP"); + check(devourer::sta::data_da_is_group(bc.data(), bc[1]), + "...while its DA in addr3 is the group address"); + + /* This distinction is why the uplink is pairwise-protected however broadcast + * its payload: the key follows addr1, not the DA. */ + check(!devourer::sta::data_da_is_group(up.data(), up[1]), + "a unicast relay's DA is not a group address"); + + /* --- IBSS (ToDS=0, FromDS=0): DA is addr1, SA is addr2 ----------------- */ + uint8_t ibss[24] = {0}; + ibss[0] = 0x08; ibss[1] = 0x00; + std::memcpy(ibss + 4, B, 6); /* addr1 = DA */ + std::memcpy(ibss + 10, A, 6); /* addr2 = SA */ + std::memcpy(ibss + 16, BSSID, 6); /* addr3 = BSSID */ + check(std::memcmp(devourer::sta::data_da(ibss, ibss[1]), B, 6) == 0, + "IBSS DA is addr1"); + check(std::memcmp(devourer::sta::data_sa(ibss, ibss[1]), A, 6) == 0, + "IBSS SA is addr2"); + check(!devourer::sta::data_da_is_group(ibss, ibss[1]), + "an IBSS unicast DA is not a group address"); + + /* --- a from-DS frame whose DA (addr1) is the group address ------------- */ + std::vector flood = devourer::sta::data_hdr_from_ds(GRP, BSSID, A, + /*protect=*/false, 0); + check(devourer::sta::data_da_is_group(flood.data(), flood[1]), + "a from-DS flood reads its group DA from addr1, not addr3"); + + /* --- 4-address: SA moves to addr4 -------------------------------------- */ + uint8_t four[30] = {0}; + four[0] = 0x08; + four[1] = (uint8_t)(devourer::sta::kFcToDs | devourer::sta::kFcFromDs); + std::memcpy(four + 16, B, 6); /* addr3 = DA */ + std::memcpy(four + 24, A, 6); /* addr4 = SA */ + check(std::memcmp(devourer::sta::data_da(four, four[1]), B, 6) == 0, + "4-address DA is addr3"); + check(std::memcmp(devourer::sta::data_sa(four, four[1]), A, 6) == 0, + "4-address SA is addr4, not addr2"); +} + +/* 802.11 MSDU <-> Ethernet II. + * + * The round trip is the weakest possible test on its own: an encoder and a + * decoder that share a misreading round-trip perfectly. So the cells below + * assert the WIRE BYTES by hand first, and only then round-trip. */ +static void test_eth_translation() { + const uint8_t DA[6] = {0x02, 0xbb, 0, 0, 0, 0x02}; + const uint8_t SA[6] = {0x02, 0xaa, 0, 0, 0, 0x01}; + const uint8_t payload[4] = {0xde, 0xad, 0xbe, 0xef}; + + /* An MSDU as it appears on air: LLC/SNAP then payload. */ + uint8_t msdu[12] = {0xaa, 0xaa, 0x03, 0x00, 0x00, 0x00, 0x08, 0x00, + 0xde, 0xad, 0xbe, 0xef}; + uint8_t eth[64]; + size_t n = devourer::sta::msdu_to_eth(DA, SA, msdu, sizeof msdu, + eth, sizeof eth); + check(n == 14 + 4, "an MSDU becomes an Ethernet frame 6 bytes shorter"); + check(std::memcmp(eth, DA, 6) == 0, "Ethernet DA comes first"); + check(std::memcmp(eth + 6, SA, 6) == 0, "then the SA"); + check(eth[12] == 0x08 && eth[13] == 0x00, + "then the ethertype, lifted out of the SNAP header"); + check(std::memcmp(eth + 14, payload, 4) == 0, "then the payload, unchanged"); + + /* And back. The addresses come out through the out-parameters, because the + * caller needs them for the 802.11 header rather than for the MSDU. */ + uint8_t back[64], bda[6], bsa[6]; + size_t m = devourer::sta::eth_to_msdu(eth, n, back, sizeof back, bda, bsa); + check(m == sizeof msdu, "and back to the same length"); + check(std::memcmp(back, msdu, sizeof msdu) == 0, + "byte-for-byte the MSDU we started with"); + check(std::memcmp(bda, DA, 6) == 0 && std::memcmp(bsa, SA, 6) == 0, + "with the addresses handed back out"); + + /* --- refusals, all of which must return 0 rather than truncate -------- */ + uint8_t small[8]; + check(devourer::sta::msdu_to_eth(DA, SA, msdu, sizeof msdu, + small, sizeof small) == 0, + "msdu_to_eth refuses an output buffer that cannot hold the result"); + check(devourer::sta::eth_to_msdu(eth, n, small, sizeof small, bda, bsa) == 0, + "eth_to_msdu refuses the same way"); + + /* An MSDU that is not an ethertype SNAP carries no ethertype at bytes 6..7, + * so rewriting it would invent one. Other LLC encodings are real. */ + uint8_t not_snap[12]; + std::memcpy(not_snap, msdu, sizeof msdu); + not_snap[2] = 0x04; /* control field, not 0x03 */ + check(devourer::sta::msdu_to_eth(DA, SA, not_snap, sizeof not_snap, + eth, sizeof eth) == 0, + "a non-SNAP MSDU is refused, not reinterpreted"); + not_snap[2] = 0x03; not_snap[3] = 0x01; /* non-zero OUI */ + check(devourer::sta::msdu_to_eth(DA, SA, not_snap, sizeof not_snap, + eth, sizeof eth) == 0, + "a SNAP header with a non-zero OUI is refused too"); + + check(devourer::sta::msdu_to_eth(DA, SA, msdu, 7, eth, sizeof eth) == 0, + "an MSDU shorter than its own SNAP header is refused"); + check(devourer::sta::eth_to_msdu(eth, 13, back, sizeof back, bda, bsa) == 0, + "an Ethernet frame shorter than its own header is refused"); + + /* A zero-payload frame is legal and must survive both ways rather than + * being refused as if it were truncated. */ + uint8_t bare[8] = {0xaa, 0xaa, 0x03, 0, 0, 0, 0x86, 0xdd}; + n = devourer::sta::msdu_to_eth(DA, SA, bare, sizeof bare, eth, sizeof eth); + check(n == 14, "an MSDU with no payload becomes a bare Ethernet header"); + check(eth[12] == 0x86 && eth[13] == 0xdd, "carrying its ethertype"); + m = devourer::sta::eth_to_msdu(eth, n, back, sizeof back, bda, bsa); + check(m == 8 && std::memcmp(back, bare, 8) == 0, "and round-trips"); +} + +/* DupDetector - the 802.11 duplicate cache (Retry set AND the same Sequence + * Control as the last frame accepted on that TID). Each rule its comment + * states, one cell each. */ +void test_dup_detector() { + using devourer::sta::DupDetector; + const auto sc = [](uint16_t seq, uint8_t frag) { + return (uint16_t)((seq << 4) | (frag & 0x0f)); + }; + + { + DupDetector d; + check(!d.is_duplicate(true, sc(10, 0)), + "dup: the first frame is never a duplicate, even with Retry set"); + check(d.is_duplicate(true, sc(10, 0)), + "dup: a retry of the same sequence control is dropped"); + check(d.is_duplicate(true, sc(10, 0)), + "dup: ...every time it is retried"); + check(!d.is_duplicate(false, sc(10, 0)), + "dup: the same number WITHOUT Retry is not a duplicate - only a " + "retransmission can be one"); + check(!d.is_duplicate(true, sc(11, 0)), + "dup: a new sequence number is accepted, Retry or not"); + check(d.is_duplicate(true, sc(11, 0)), + "dup: ...and becomes the one a retry is compared with"); + check(!d.is_duplicate(true, sc(11, 1)), + "dup: the next fragment of the same MSDU is not a duplicate"); + } + { + /* WRAP: 4095 then 0 is the ordinary next number, not a repeat. */ + DupDetector d; + check(!d.is_duplicate(false, sc(4095, 0)), "dup wrap: 4095 accepted"); + check(!d.is_duplicate(true, sc(0, 0)), + "dup wrap: 0 after 4095 is new, even with Retry set"); + check(d.is_duplicate(true, sc(0, 0)), "dup wrap: ...and its retry drops"); + } + { + /* PER TID: the cache keeps one "last" per TID, so interleaved access + * categories do not evict each other. */ + DupDetector d; + check(!d.is_duplicate(false, sc(20, 0), 5), "dup tid: TID 5 accepts 20"); + check(!d.is_duplicate(false, sc(900, 0), 6), "dup tid: TID 6 accepts 900"); + check(d.is_duplicate(true, sc(20, 0), 5), + "dup tid: TID 5's retry still matches after TID 6 moved"); + check(!d.is_duplicate(true, sc(20, 0), 6), + "dup tid: the same number on another TID is not its duplicate"); + check(!d.is_duplicate(true, sc(20, 0)), + "dup tid: nor on the non-QoS slot"); + /* An out-of-range TID folds onto the non-QoS slot rather than indexing + * out of bounds. */ + check(d.is_duplicate(true, sc(20, 0), 99), + "dup tid: an out-of-range TID uses the non-QoS slot"); + check(d.is_duplicate(true, sc(20, 0), -1), + "dup tid: ...a negative one too"); + } + { + DupDetector d; + d.is_duplicate(false, sc(7, 0)); + d.reset(); + check(!d.is_duplicate(true, sc(7, 0)), + "dup: after reset() nothing is remembered"); + } +} + +int main() { + test_tim(); + test_dup_detector(); + test_mgmt_hdr(); + test_seq(); + test_ie_walk(); + test_golden_bytes(); + test_rsn(); + test_beacon_roundtrip(); + test_station_builders(); + test_assoc_resp_parse(); + test_data_frames(); + test_rsn_real_world(); + test_data_seq(); + test_relay_addressing(); + test_eth_translation(); + + if (g_fail) { + std::printf("dot11_selftest: %d failure(s)\n", g_fail); + return 1; + } + std::printf("dot11_selftest: OK\n"); + return 0; +} diff --git a/tests/eapol_capture_vectors.sh b/tests/eapol_capture_vectors.sh new file mode 100755 index 00000000..1799db6d --- /dev/null +++ b/tests/eapol_capture_vectors.sh @@ -0,0 +1,161 @@ +#!/bin/sh +# Regenerate tests/eapol_kernel_vectors.h from a mac80211_hwsim rig. +# +# The sibling of tests/ccmp_capture_vectors.sh, and for the same reason. That +# one made the kernel encrypt CCMP frames so our framing could be checked +# against something that did not share our reading of the standard. This one +# makes hostapd and wpa_supplicant run a four-way handshake so the PRF, the +# PTK derivation, the EAPOL-Key MIC and the GTK KDE layout can be checked the +# same way. +# +# NO HARDWARE. Two virtual radios, so the bench is untouched. Needs root and +# hostapd, wpa_supplicant and tcpdump on PATH. +# +# -K is what makes both daemons log their derived keys. Without it there is +# nothing to compare against and the capture is just four opaque frames. +set -eu + +HERE=$(cd "$(dirname "$0")" && pwd) +WORK=${WORK:-$(mktemp -d /tmp/eapolvec.XXXXXX)} +NS=eapolvec_sta +PSK=eapolvectors123 +SSID=eapolvec + +[ "$(id -u)" = 0 ] || { echo "this needs root"; exit 1; } + +cleanup() { + # BY PID, and only the processes this run started. A name or command-line + # match would also hit somebody else's capture or hostapd, and the host's + # own wpa_supplicant: `ip netns exec` does not change the PID namespace. + for p in ${CAP_PID:-} ${HOSTAPD_PID:-} ${WPA_PID:-}; do + kill "$p" 2>/dev/null || true + done + [ "${NS_OURS:-no}" = yes ] && ip netns del "$NS" 2>/dev/null || true + [ "${HWSIM_OURS:-no}" = yes ] && rmmod mac80211_hwsim 2>/dev/null || true +} +trap cleanup EXIT + +echo "--- two virtual radios" +# REFUSE, do not reuse: an already-loaded hwsim is somebody else's rig (or a +# leftover), and if it is in use the rmmod fails, modprobe is a no-op, and +# "the two highest phys" can then include a REAL adapter - which would be +# moved into the namespace and destroyed with it. +if [ -d /sys/module/mac80211_hwsim ]; then + echo "mac80211_hwsim is already loaded - refusing (unload it if it is yours)" + exit 1 +fi +if ip netns list 2>/dev/null | awk '{print $1}' | grep -qx "$NS"; then + echo "netns $NS already exists - refusing to use or delete it" + exit 1 +fi +PHYS_BEFORE=$(ls /sys/class/ieee80211 2>/dev/null || true) +modprobe mac80211_hwsim radios=2 +HWSIM_OURS=yes +sleep 2 +# The phys hwsim just CREATED - the set difference against the listing +# before the modprobe - and there must be exactly two. +PHYS=$(ls /sys/class/ieee80211 | grep -vxF "$PHYS_BEFORE" | sed 's/phy//' | sort -n) +[ "$(echo "$PHYS" | grep -c .)" = 2 ] || { echo "expected 2 new hwsim phys, got: $PHYS"; exit 1; } +AP_PHY=phy$(echo "$PHYS" | head -1) +STA_PHY=phy$(echo "$PHYS" | tail -1) +if_for_phy() { + for l in /sys/class/net/*/phy80211; do + [ -e "$l" ] || continue + if [ "$(basename "$(readlink -f "$l")")" = "$1" ]; then + basename "$(dirname "$l")" + return + fi + done +} +AP_IF=$(if_for_phy "$AP_PHY") +STA_IF=$(if_for_phy "$STA_PHY") +[ -n "$AP_IF" ] && [ -n "$STA_IF" ] || { echo "no hwsim interfaces"; exit 1; } +echo " AP $AP_PHY/$AP_IF STA $STA_PHY/$STA_IF" +nmcli dev set "$AP_IF" managed no 2>/dev/null || true +nmcli dev set "$STA_IF" managed no 2>/dev/null || true + +ip netns add "$NS" +NS_OURS=yes +iw phy "$STA_PHY" set netns name "$NS" +ip netns exec "$NS" ip link set "$STA_IF" up + +cat > "$WORK/hostapd.conf" < "$WORK/wpa.conf" <"$WORK/tcpdump.log" 2>&1 & +CAP_PID=$! +sleep 2 +# $! is the daemon itself: a background child of a non-interactive shell is +# not a process-group leader, so setsid (and `ip netns exec`) exec in place. +setsid hostapd -dd -K -t "$WORK/hostapd.conf" >"$WORK/hostapd.log" 2>&1 & +HOSTAPD_PID=$! +sleep 4 +grep -q AP-ENABLED "$WORK/hostapd.log" || { echo "hostapd did not come up"; exit 1; } +ip netns exec "$NS" setsid wpa_supplicant -i "$STA_IF" -c "$WORK/wpa.conf" \ + -dd -K -t -f "$WORK/wpa.log" >/dev/null 2>&1 & +WPA_PID=$! +i=0 +while [ $i -lt 30 ]; do + grep -q CTRL-EVENT-CONNECTED "$WORK/wpa.log" 2>/dev/null && break + i=$((i + 1)); sleep 1 +done +grep -q CTRL-EVENT-CONNECTED "$WORK/wpa.log" || { echo "no association"; exit 1; } +sleep 1 +kill "$CAP_PID" 2>/dev/null || true +sleep 1 + +PTK=$(grep -m1 'WPA: PTK - hexdump' "$WORK/wpa.log" | sed 's/.*): //; s/ //g') +[ ${#PTK} = 96 ] || { echo "could not read wpa_supplicant's 48-byte PTK"; exit 1; } +# THE GTK COMES FROM THE SUPPLICANT'S LOG, NOT THE AUTHENTICATOR'S. +# +# hostapd generates a GTK when it starts and then throws it away - "WPA: +# Re-initialize GMK/Counter on first station" - so its log carries two, and +# the first `GTK - hexdump(len=16)` line is the one that never reached the +# air. Taking it produced a vector file that failed on the first run, which is +# the good outcome and cost an hour. +# +# wpa_supplicant's "WPA: Group Key" line is the key it took OUT of message 3 +# and installed. That is unambiguous, and it is the receiving end's own record +# - which is what our extraction has to agree with. +GTK=$(grep -m1 'WPA: Group Key - hexdump(len=16)' "$WORK/wpa.log" \ + | sed 's/.*): //; s/ //g') +[ ${#GTK} = 32 ] || { echo "could not read the installed 16-byte GTK"; exit 1; } +echo " associated; wpa_supplicant's PTK and the installed GTK captured" + +python3 "$HERE/eapol_extract_vectors.py" "$WORK/cap.pcap" "$PTK" "$GTK" \ + "$PSK" "$SSID" "$HERE/eapol_kernel_vectors.h" + +BUILD=${BUILD:-$HERE/../build} +if [ -f "$BUILD/CMakeCache.txt" ]; then + echo "--- rebuilding and running SupplicantSelftest against the new vectors" + cmake --build "$BUILD" --target SupplicantSelftest >/dev/null 2>&1 \ + || { echo "FAIL: SupplicantSelftest does not build"; exit 1; } + "$BUILD/SupplicantSelftest" || { echo "FAIL: the new vectors do not verify"; exit 1; } +else + echo "--- no build tree at $BUILD; build and run SupplicantSelftest yourself" +fi +echo "--- done" diff --git a/tests/eapol_extract_vectors.py b/tests/eapol_extract_vectors.py new file mode 100644 index 00000000..803ea52c --- /dev/null +++ b/tests/eapol_extract_vectors.py @@ -0,0 +1,196 @@ +"""Cut a real WPA2 four-way out of a capture into C test vectors. + +Like tests/ccmp_extract_vectors.py, this does NO crypto. It finds the four +EAPOL-Key frames, copies their bytes, and writes down the PTK and GTK that +wpa_supplicant and hostapd logged. Everything the test then asserts is +something one of those two produced. + +That is the point. tests/supplicant_selftest.cpp drives src/sta/Supplicant.h +with a fixture authenticator written by the same author from the same reading +of the same clause; it can pin the state machine and cannot pin the PRF, the +MIC or the KDE layout. These frames were produced by hostapd and answered by +wpa_supplicant. + +NOT REPRODUCIBLE FROM THE TREE ALONE, and so there is no --check mode: the +input is a pcap plus the keys hostapd and wpa_supplicant logged during a +mac80211_hwsim run (tests/eapol_capture_vectors.sh, root + hwsim), none of +which is checked in - and a fresh run draws fresh nonces, so it would produce +a different, equally valid four-way. The checked-in +tests/eapol_kernel_vectors.h is the artifact; this script documents how it was +cut. +""" +import struct +import sys + +pcap = sys.argv[1] +ptk_hex = sys.argv[2] +gtk_hex = sys.argv[3] +psk = sys.argv[4] +ssid = sys.argv[5] +out_path = sys.argv[6] + + +def radiotap_flags(pkt): + """The radiotap Flags octet, or None. Only one field can precede it.""" + rt_len = struct.unpack('' +if magic not in (b'\xd4\xc3\xb2\xa1', b'\xa1\xb2\xc3\xd4'): + raise SystemExit('not a classic pcap') +if struct.unpack(endian + 'I', blob[20:24])[0] != 127: + raise SystemExit('expected DLT_IEEE802_11_RADIOTAP') + +off = 24 +frames = [] +while off + 16 <= len(blob): + _, _, incl, _ = struct.unpack(endian + 'IIII', blob[off:off + 16]) + off += 16 + pkt = blob[off:off + incl] + off += incl + if len(pkt) < 8: + continue + rt_len = struct.unpack(' 76: + lines.append(cur.rstrip()) + cur = indent + cur += t + ', ' + lines.append(cur.rstrip().rstrip(',')) + return '\n'.join(lines) + + +with open(out_path, 'w') as f: + f.write('''/* eapol_kernel_vectors.h - a real WPA2-PSK four-way, off the air. + * + * GENERATED, do not edit by hand. See tests/eapol_capture_vectors.sh: two + * mac80211_hwsim radios, hostapd and wpa_supplicant, and the four EAPOL-Key + * frames they actually exchanged. No hardware. + * + * WHY. tests/supplicant_selftest.cpp drives src/sta/Supplicant.h against a + * fixture authenticator written by the same author from the same reading of + * the same clause. That pins the state machine and cannot pin the PRF, the + * EAPOL-Key MIC or the GTK KDE layout - a shared misreading is invisible to + * it, the same way a zero CCM nonce flags octet is invisible to a CCMP + * vector generator written from the same reading. + * + * The PTK below is the one WPA_SUPPLICANT derived and logged; the GTK is the + * one HOSTAPD generated and logged. Nothing here was computed by this + * repository. + * + * KEY MATERIAL IS FROM A THROWAWAY LAB PSK on virtual radios and protects + * nothing. + */ +#ifndef DEVOURER_TEST_EAPOL_KERNEL_VECTORS_H +#define DEVOURER_TEST_EAPOL_KERNEL_VECTORS_H + +#include +#include + +namespace devourer { +namespace test { + +''') + f.write('static const char kHostapdPassphrase[] = "%s";\n' % psk) + f.write('static const char kHostapdSsid[] = "%s";\n\n' % ssid) + f.write('/* The authenticator (the AP) and the supplicant (the station). */\n') + f.write('static const uint8_t kEapolAa[6] = {\n%s\n};\n\n' % carr(aa)) + f.write('static const uint8_t kEapolSpa[6] = {\n%s\n};\n\n' % carr(spa)) + f.write('/* wpa_supplicant derived and logged this. */\n') + f.write('static const uint8_t kSupplicantPtk[48] = {\n%s\n};\n\n' + % carr(bytes.fromhex(ptk_hex))) + f.write('/* hostapd generated and logged this. */\n') + f.write('static const uint8_t kHostapdGtk[16] = {\n%s\n};\n\n' + % carr(bytes.fromhex(gtk_hex))) + for i, fr in enumerate(four, 1): + f.write('/* message %d, %s */\n' + % (i, 'station -> AP' if fr['to_ds'] else 'AP -> station')) + f.write('static const uint8_t kEapolMsg%d[] = {\n%s\n};\n\n' + % (i, carr(fr['eapol']))) + f.write('''struct EapolFrameVector { + const uint8_t* eapol; + size_t len; +}; + +static const EapolFrameVector kEapolFourWay[4] = { +''') + for i, fr in enumerate(four, 1): + f.write(' {kEapolMsg%d, sizeof kEapolMsg%d},\n' % (i, i)) + f.write('''}; + +} // namespace test +} // namespace devourer + +#endif /* DEVOURER_TEST_EAPOL_KERNEL_VECTORS_H */ +''') +print('wrote a four-way (%d bytes of EAPOL) -> %s' + % (sum(len(f['eapol']) for f in four), out_path)) diff --git a/tests/eapol_kernel_vectors.h b/tests/eapol_kernel_vectors.h new file mode 100644 index 00000000..55bc8f1c --- /dev/null +++ b/tests/eapol_kernel_vectors.h @@ -0,0 +1,129 @@ +/* eapol_kernel_vectors.h - a real WPA2-PSK four-way, off the air. + * + * GENERATED, do not edit by hand. See tests/eapol_capture_vectors.sh: two + * mac80211_hwsim radios, hostapd and wpa_supplicant, and the four EAPOL-Key + * frames they actually exchanged. No hardware. + * + * WHY. tests/supplicant_selftest.cpp drives src/sta/Supplicant.h against a + * fixture authenticator written by the same author from the same reading of + * the same clause. That pins the state machine and cannot pin the PRF, the + * EAPOL-Key MIC or the GTK KDE layout - a shared misreading is invisible to + * it, the same way a zero CCM nonce flags octet is invisible to a CCMP + * vector generator written from the same reading. + * + * The PTK below is the one WPA_SUPPLICANT derived and logged; the GTK is the + * one HOSTAPD generated and logged. Nothing here was computed by this + * repository. + * + * KEY MATERIAL IS FROM A THROWAWAY LAB PSK on virtual radios and protects + * nothing. + */ +#ifndef DEVOURER_TEST_EAPOL_KERNEL_VECTORS_H +#define DEVOURER_TEST_EAPOL_KERNEL_VECTORS_H + +#include +#include + +namespace devourer { +namespace test { + +static const char kHostapdPassphrase[] = "eapolvectors123"; +static const char kHostapdSsid[] = "eapolvec"; + +/* The authenticator (the AP) and the supplicant (the station). */ +static const uint8_t kEapolAa[6] = { + 0x02, 0x00, 0x00, 0x00, 0x00, 0x00 +}; + +static const uint8_t kEapolSpa[6] = { + 0x02, 0x00, 0x00, 0x00, 0x01, 0x00 +}; + +/* wpa_supplicant derived and logged this. */ +static const uint8_t kSupplicantPtk[48] = { + 0x89, 0x6b, 0x9c, 0xff, 0x53, 0xa8, 0x27, 0x9b, 0x76, 0x12, 0x01, 0x99, + 0x88, 0x63, 0x87, 0x81, 0x37, 0x73, 0x0c, 0xab, 0x1f, 0xb1, 0x55, 0x2c, + 0x80, 0xfb, 0x0c, 0x3a, 0x16, 0xbf, 0x3e, 0x4b, 0x68, 0x6c, 0xf6, 0x77, + 0x0c, 0xd3, 0x89, 0x6d, 0x16, 0x86, 0x7a, 0x62, 0xc3, 0x45, 0xea, 0x4d +}; + +/* hostapd generated and logged this. */ +static const uint8_t kHostapdGtk[16] = { + 0xca, 0x1f, 0x19, 0xf0, 0x15, 0x1c, 0x45, 0xb5, 0x76, 0x51, 0xda, 0x5a, + 0xae, 0x2b, 0x6f, 0xd8 +}; + +/* message 1, AP -> station */ +static const uint8_t kEapolMsg1[] = { + 0x02, 0x03, 0x00, 0x5f, 0x02, 0x00, 0x8a, 0x00, 0x10, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x01, 0x96, 0xee, 0xcc, 0x5b, 0x8c, 0x60, 0x2e, + 0x16, 0xe1, 0x15, 0x76, 0x5b, 0xa3, 0xbc, 0x03, 0x94, 0x11, 0xe6, 0x86, + 0xd7, 0x6d, 0x27, 0x1f, 0x54, 0x0a, 0xd2, 0xa5, 0x4a, 0x92, 0x08, 0x3b, + 0xf3, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00 +}; + +/* message 2, station -> AP */ +static const uint8_t kEapolMsg2[] = { + 0x01, 0x03, 0x00, 0x75, 0x02, 0x01, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x01, 0x9d, 0x31, 0x94, 0x74, 0x52, 0x41, 0xb8, + 0xed, 0x25, 0x5f, 0x67, 0x8f, 0x3a, 0x87, 0x18, 0x15, 0x0f, 0x8d, 0x15, + 0x46, 0x81, 0x84, 0x1e, 0x9d, 0x6e, 0x8e, 0x50, 0xb1, 0x68, 0x74, 0xa2, + 0x23, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x97, 0x2c, 0x4d, + 0x3d, 0x9f, 0x40, 0x10, 0x50, 0x0c, 0xeb, 0x38, 0x33, 0xe0, 0x72, 0x09, + 0xca, 0x00, 0x16, 0x30, 0x14, 0x01, 0x00, 0x00, 0x0f, 0xac, 0x04, 0x01, + 0x00, 0x00, 0x0f, 0xac, 0x04, 0x01, 0x00, 0x00, 0x0f, 0xac, 0x02, 0x00, + 0x00 +}; + +/* message 3, AP -> station */ +static const uint8_t kEapolMsg3[] = { + 0x02, 0x03, 0x00, 0x97, 0x02, 0x13, 0xca, 0x00, 0x10, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x02, 0x96, 0xee, 0xcc, 0x5b, 0x8c, 0x60, 0x2e, + 0x16, 0xe1, 0x15, 0x76, 0x5b, 0xa3, 0xbc, 0x03, 0x94, 0x11, 0xe6, 0x86, + 0xd7, 0x6d, 0x27, 0x1f, 0x54, 0x0a, 0xd2, 0xa5, 0x4a, 0x92, 0x08, 0x3b, + 0xf3, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x6f, 0x61, + 0x1e, 0xe4, 0x29, 0x8a, 0xbb, 0x79, 0xf4, 0xa4, 0xee, 0x69, 0x1c, 0x8a, + 0x26, 0x00, 0x38, 0x16, 0x89, 0x5f, 0x57, 0xa0, 0x77, 0xe5, 0xab, 0x12, + 0xa1, 0x55, 0x04, 0x8a, 0x95, 0xd1, 0x1b, 0x33, 0x1b, 0x3c, 0x6d, 0x9a, + 0x1a, 0x5d, 0x69, 0x65, 0x30, 0xa0, 0x90, 0x3e, 0x55, 0x84, 0x32, 0x47, + 0x21, 0xbd, 0xd6, 0xb4, 0xd8, 0x71, 0xd1, 0x55, 0x34, 0x13, 0x28, 0xd8, + 0x2a, 0xe3, 0x30, 0x26, 0xd1, 0xb1, 0x77, 0x27, 0xd2, 0x02, 0x6b +}; + +/* message 4, station -> AP */ +static const uint8_t kEapolMsg4[] = { + 0x01, 0x03, 0x00, 0x5f, 0x02, 0x03, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x75, 0xda, 0x5e, + 0xd0, 0x51, 0xaf, 0x6a, 0xfa, 0x71, 0xd2, 0x77, 0xcf, 0x31, 0xc1, 0xfd, + 0xcf, 0x00, 0x00 +}; + +struct EapolFrameVector { + const uint8_t* eapol; + size_t len; +}; + +static const EapolFrameVector kEapolFourWay[4] = { + {kEapolMsg1, sizeof kEapolMsg1}, + {kEapolMsg2, sizeof kEapolMsg2}, + {kEapolMsg3, sizeof kEapolMsg3}, + {kEapolMsg4, sizeof kEapolMsg4}, +}; + +} // namespace test +} // namespace devourer + +#endif /* DEVOURER_TEST_EAPOL_KERNEL_VECTORS_H */ diff --git a/tests/openssl_crypto_ops.h b/tests/openssl_crypto_ops.h new file mode 100644 index 00000000..de710558 --- /dev/null +++ b/tests/openssl_crypto_ops.h @@ -0,0 +1,104 @@ +/* openssl_crypto_ops.h — a complete CryptoOps, for tests and harnesses. + * + * `src/sta/` takes its crypto as a vtable so `libdevourer` gains no + * dependency. Everything that drives it in-tree has OpenSSL available, and + * one shared implementation is what keeps the cells from each filling the + * vtable themselves: partial implementations of one interface, each stubbing + * a different subset, is how they come to disagree. + * + * This one implements ALL FOUR methods. A caller that needs to wrap one (to + * time its CCM calls, say) derives and overrides that method only. + * + * NOT constant-time beyond what OpenSSL gives, and not hardened for key + * material in memory. It is a test facility; an embedded integrator + * fills the same vtable with mbedTLS. + */ +#ifndef DEVOURER_TEST_OPENSSL_CRYPTO_OPS_H +#define DEVOURER_TEST_OPENSSL_CRYPTO_OPS_H + +#include +#include + +#include +#include + +#include "ccmp_software.h" +#include "sta/CryptoOps.h" + +namespace devourer { +namespace test { + +struct OpenSslCryptoOps : devourer::sta::CryptoOps { + bool aes_ccm(bool encrypt, const uint8_t key[16], const uint8_t nonce[13], + const uint8_t* aad, size_t aad_len, const uint8_t* in, + size_t in_len, uint8_t* out, uint8_t* tag) override { + return ccmp_software(encrypt, key, nonce, aad, (int)aad_len, in, + (int)in_len, out, tag); + } + + bool hmac_sha1(const uint8_t* key, size_t key_len, const uint8_t* data, + size_t data_len, uint8_t out[20]) override { + unsigned int l = 0; + + if (!HMAC(EVP_sha1(), key, (int)key_len, data, data_len, out, &l)) + return false; + return l == 20; + } + + bool pbkdf2_sha1(const char* passphrase, const uint8_t* salt, + size_t salt_len, unsigned iterations, uint8_t* out, + size_t out_len) override { + return PKCS5_PBKDF2_HMAC(passphrase, (int)std::strlen(passphrase), salt, + (int)salt_len, (int)iterations, EVP_sha1(), + (int)out_len, out) == 1; + } + + /* RFC 3394 unwrap. The integrity check value is checked by the cipher, so a + * false return here is a real authentication failure and the caller must + * treat `out` as untouched rather than parse it. */ + bool aes_key_unwrap(const uint8_t* kek, size_t kek_len, const uint8_t* in, + size_t in_len, uint8_t* out) override { + const EVP_CIPHER* c = kek_len == 16 ? EVP_aes_128_wrap() + : kek_len == 32 ? EVP_aes_256_wrap() + : nullptr; + EVP_CIPHER_CTX* ctx; + int ol = 0, tmp = 0, ok; + + if (!c || in_len < 16 || (in_len % 8) != 0) return false; + ctx = EVP_CIPHER_CTX_new(); + if (!ctx) return false; + EVP_CIPHER_CTX_set_flags(ctx, EVP_CIPHER_CTX_FLAG_WRAP_ALLOW); + ok = EVP_DecryptInit_ex(ctx, c, nullptr, kek, nullptr); + if (ok) ok = EVP_DecryptUpdate(ctx, out, &ol, in, (int)in_len); + if (ok) ok = EVP_DecryptFinal_ex(ctx, out + ol, &tmp); + EVP_CIPHER_CTX_free(ctx); + return ok == 1 && (size_t)(ol + tmp) == in_len - 8; + } + + /* The wrap direction, which no production path needs — only an authenticator + * does, and the only authenticators here are test fixtures. Not part of + * CryptoOps for that reason. */ + static int key_wrap(const uint8_t* kek, size_t kek_len, const uint8_t* in, + size_t in_len, uint8_t* out) { + const EVP_CIPHER* c = kek_len == 16 ? EVP_aes_128_wrap() + : kek_len == 32 ? EVP_aes_256_wrap() + : nullptr; + EVP_CIPHER_CTX* ctx; + int ol = 0, tmp = 0, ok; + + if (!c || (in_len % 8) != 0) return -1; + ctx = EVP_CIPHER_CTX_new(); + if (!ctx) return -1; + EVP_CIPHER_CTX_set_flags(ctx, EVP_CIPHER_CTX_FLAG_WRAP_ALLOW); + ok = EVP_EncryptInit_ex(ctx, c, nullptr, kek, nullptr); + if (ok) ok = EVP_EncryptUpdate(ctx, out, &ol, in, (int)in_len); + if (ok) ok = EVP_EncryptFinal_ex(ctx, out + ol, &tmp); + EVP_CIPHER_CTX_free(ctx); + return ok == 1 ? ol + tmp : -1; + } +}; + +} // namespace test +} // namespace devourer + +#endif /* DEVOURER_TEST_OPENSSL_CRYPTO_OPS_H */ diff --git a/tests/station_sm_selftest.cpp b/tests/station_sm_selftest.cpp new file mode 100644 index 00000000..feac445b --- /dev/null +++ b/tests/station_sm_selftest.cpp @@ -0,0 +1,2262 @@ +/* Headless guard for src/sta/StationSm.h — the association state machine. + * + * Authenticate, associate, four-way, connected; and every way that stops. + * + * THE TIMEOUTS ARE TESTABLE HERE BECAUSE THE CLOCK IS AN ARGUMENT. The AP + * harness's equivalent retry logic reads a steady_clock, which is why its + * schedule can only be watched on a bench, never asserted. Every + * cell below that involves a deadline advances `now_ms` by hand. + * + * The AP is a fixture: it answers what a real one would, and nothing more. It + * does not model a station table, retransmissions, or refusal for any reason + * a cell does not ask for. + */ +#include +#include +#include +#include + +#include "openssl_crypto_ops.h" +#include "sta/BssTable.h" +#include "sta/StationSm.h" + +namespace { + +using devourer::sta::BssEntry; +using devourer::sta::BssTable; +using devourer::sta::StationSm; +using devourer::sta::MicCheck; +using devourer::test::OpenSslCryptoOps; + +int g_fail = 0; + +void check(bool ok, const char* what) { + if (!ok) { + std::printf("FAIL: %s\n", what); + g_fail++; + } +} + +const uint8_t kBssid[6] = {0x02, 0x42, 0x75, 0x05, 0xd6, 0x00}; +const uint8_t kOwn[6] = {0x02, 0x11, 0x22, 0x33, 0x44, 0x01}; +const char* kSsid = "devourerAP"; +const char* kPsk = "devourer123"; + +std::vector beacon(const uint8_t bssid[6], uint8_t chan, + bool rsn = true, bool ds = true) { + static const uint8_t bcast[6] = {0xff, 0xff, 0xff, 0xff, 0xff, 0xff}; + std::vector m = + devourer::sta::mgmt_hdr(devourer::sta::kFcBeacon, bcast, bssid, bssid); + + m.insert(m.end(), 8, 0); + devourer::sta::put_le16(m, 100); + /* Privacy tracks the RSN element. An open BSS that still set the bit would + * be refused by the open path for the right reason by accident, which is + * the sort of agreement that makes a cell unfalsifiable. */ + devourer::sta::put_le16(m, (uint16_t)(rsn ? 0x0011 : 0x0001)); + devourer::sta::append_ssid(m, kSsid); + devourer::sta::append_supported_rates(m); + /* `ds` false: no DS Parameter Set, as many 5 GHz beacons send it. */ + if (ds) devourer::sta::append_ds_params(m, chan); + if (rsn) devourer::sta::append_rsn_ccmp_psk(m); + return m; +} + +/* Put a BSS in a table and hand back the entry, so join() is always reached + * the way a real station reaches it. */ +const BssEntry* discovered(BssTable& t, uint8_t chan = 6, bool rsn = true) { + std::vector b = beacon(kBssid, chan, rsn); + return t.observe(b.data(), b.size(), -40, chan, 0); +} + +/* ---- the fixture AP ---------------------------------------------------- */ + +struct FixtureAp { + OpenSslCryptoOps crypto; + uint8_t pmk[32] = {0}; + uint8_t anonce[32] = {0}; + uint8_t ptk[48] = {0}; + uint8_t gtk[16] = {0}; + uint64_t replay = 0; + uint16_t assoc_status = 0; + uint16_t auth_status = 0; + uint16_t aid = 3; + /* A real AP answers a refusal with AID 0, which means the AID check would + * catch a refusal even if the status check were deleted. This knob makes + * the status field the only thing that can refuse, so a cell can pin it. */ + bool aid_even_when_refused = false; + /* An AP on an OPEN BSS sends no message 1. The knob exists so an open cell + * can choose either: quiet, which is what a real open AP does, or noisy, + * which is the configuration mismatch an open station must survive. */ + bool sends_msg1 = true; + bool saw_auth = false, saw_assoc = false, saw_msg4 = false; + /* The association request as it went out, so a cell can read the bytes + * rather than infer them from the outcome. */ + std::vector last_assoc; + + FixtureAp() { + devourer::sta::pmk_from_psk(crypto, kPsk, kSsid, pmk); + std::memset(anonce, 0x5e, 32); + std::memset(gtk, 0x31, 16); + } + + std::vector mgmt(uint8_t fc) { + return devourer::sta::mgmt_hdr(fc, kOwn, kBssid, kBssid); + } + + /* Wrap an EAPOL body in the from-DS data frame a station receives. */ + std::vector eapol_frame(const std::vector& body) { + std::vector m = devourer::sta::data_hdr_from_ds( + kOwn, kBssid, kBssid, /*protect=*/false, 1); + devourer::sta::append_llc_snap(m, 0x888e); + m.insert(m.end(), body.begin(), body.end()); + return m; + } + + /* Answer one frame from the station. Returns what the AP would send back, + * which may be empty. */ + std::vector respond(const std::vector& f) { + 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); /* open system */ + devourer::sta::put_le16(m, 2); /* sequence 2 */ + devourer::sta::put_le16(m, auth_status); + return m; + } + if (fc0 == devourer::sta::kFcAssocReq) { + saw_assoc = true; + last_assoc = f; + std::vector m = mgmt(devourer::sta::kFcAssocResp); + devourer::sta::put_le16(m, 0x0011); + devourer::sta::put_le16(m, assoc_status); + devourer::sta::put_le16( + m, (uint16_t)(assoc_status && !aid_even_when_refused + ? 0 + : (0xc000 | aid))); + return m; + } + if (fc0 != devourer::sta::kFcData) return {}; + + /* A data frame from the station: the only one this fixture speaks is + * EAPOL, and the only messages are 2 and 4. */ + const size_t hlen = 24; + if (f.size() < hlen + 8 + devourer::sta::kEapolKeyFixedLen) return {}; + devourer::sta::EapolKey k; + if (!devourer::sta::parse_eapol_key(f.data() + hlen + 8, + f.size() - hlen - 8, &k)) + return {}; + if (!k.secure()) { /* message 2 */ + if (!devourer::sta::derive_ptk(crypto, pmk, kBssid, kOwn, anonce, + k.nonce, ptk)) + return {}; + if (devourer::sta::eapol_mic_ok(crypto, ptk, k) != MicCheck::Ok) + return {}; + return eapol_frame(msg3()); + } + saw_msg4 = devourer::sta::eapol_mic_ok(crypto, ptk, k) == MicCheck::Ok; + return {}; + } + + 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); + } + + /* Non-empty: the RSN element message 3 carries instead of the advertised + * one - the downgrade-check cell. */ + std::vector rsn_override; + + std::vector msg3() { + std::vector kd; + const uint8_t hdr[8] = {0xdd, 0x16, 0x00, 0x0f, 0xac, 0x01, 1, 0x00}; + + if (!rsn_override.empty()) + kd = rsn_override; + else + devourer::sta::append_rsn_ccmp_psk(kd); + kd.insert(kd.end(), hdr, hdr + 8); + kd.insert(kd.end(), gtk, gtk + 16); + 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 = 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); + } + + /* Group key handshake message 1: a new GTK at `keyid`, wrapped with the + * KEK and MIC'd with the KCK of the PTK the four-way left - what an AP + * sends when it rotates the group key. */ + std::vector group1(const uint8_t* key, uint8_t keyid) { + std::vector kd; + const uint8_t hdr[8] = {0xdd, 0x16, 0x00, 0x0f, 0xac, 0x01, keyid, 0x00}; + + kd.insert(kd.end(), hdr, hdr + 8); + kd.insert(kd.end(), key, key + 16); + 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 = 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, nullptr, nullptr, w.data(), w.size(), &crypto, ptk); + } +}; + +/* Pump: drain the station's transmit queue into the AP, feed the AP's answers + * back, until nothing moves. `now_ms` does not advance, so nothing here can + * accidentally depend on a timeout. */ +void pump(StationSm& sm, FixtureAp& ap, uint32_t now_ms, int rounds = 8) { + std::vector f; + + for (int i = 0; i < rounds; i++) { + bool moved = false; + while (sm.pop_tx(&f)) { + moved = true; + const std::vector r = ap.respond(f); + if (!r.empty()) sm.on_rx(r.data(), r.size(), now_ms); + /* The AP sends message 1 unprompted once it has associated us. */ + if (f[0] == devourer::sta::kFcAssocReq && ap.assoc_status == 0 && + ap.sends_msg1) { + const std::vector m1 = ap.eapol_frame(ap.msg1()); + sm.on_rx(m1.data(), m1.size(), now_ms); + } + } + if (!moved) break; + } +} + +/* ---- cells ------------------------------------------------------------- */ + +void test_full_association() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + FixtureAp ap; + uint8_t snonce[32]; + + std::memset(snonce, 0x7a, 32); + check(sm.configure(crypto, kSsid, kPsk, kOwn), + "configure derives the PMK"); + const BssEntry* bss = table.select(kSsid); + check(bss == nullptr, "nothing is selectable before a beacon"); + discovered(table); + bss = table.select(kSsid); + check(bss != nullptr, "the BSS is selectable after one beacon"); + if (!bss) return; + + check(sm.join(*bss, snonce, 0), "join starts"); + check(sm.state() == StationSm::State::Authenticating, + "...in Authenticating"); + check(sm.pending_tx() == 1, "...with an authentication request queued"); + + pump(sm, ap, 0); + + check(ap.saw_auth && ap.saw_assoc, "the AP saw both requests"); + check(sm.state() == StationSm::State::Connected, "the station connects"); + check(sm.aid() == ap.aid, "...with the AID the AP allocated"); + check(sm.keyed(), "...and is keyed"); + check(std::memcmp(sm.supplicant().ptk(), ap.ptk, 48) == 0, + "...on the same PTK the AP derived"); + check(sm.supplicant().gtk_valid() && + std::memcmp(sm.supplicant().gtk(), ap.gtk, 16) == 0, + "...and the GTK the AP sent"); + check(ap.saw_msg4, "the AP's message 4 verified"); + check(sm.auth_tx == 1 && sm.assoc_tx == 1, + "neither request needed a retransmission"); + check(sm.eapol_rx == 2 && sm.eapol_tx == 2, + "two EAPOL frames in, two out"); +} + +/* No AP at all. Three transmissions, then a give-up that names the reason — + * not a machine that sits in Authenticating forever. */ +void test_auth_timeout() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + uint8_t snonce[32]; + std::vector f; + + std::memset(snonce, 0x7a, 32); + sm.configure(crypto, kSsid, kPsk, kOwn); + const BssEntry* bss = discovered(table); + if (!bss) { check(false, "beacon"); return; } + sm.join(*bss, snonce, 0); + + /* Below the deadline nothing happens: a tick is not a retransmission. */ + sm.tick(StationSm::kMgmtTimeoutMs - 1); + check(sm.auth_tx == 1, "no retransmission before the deadline"); + + uint32_t now = 0; + for (int i = 0; i < 6; i++) { + now += StationSm::kMgmtTimeoutMs; + sm.tick(now); + } + check(sm.auth_tx == StationSm::kMaxTries, + "exactly kMaxTries authentication requests are sent"); + check(sm.state() == StationSm::State::Failed, "then it gives up"); + check(sm.fail_reason() == StationSm::Failure::AuthTimeout, + "...saying which step timed out"); + + while (sm.pop_tx(&f)) { + } + /* The rule is "sends nothing further", so give it the things that would + * make a live machine send: the authentication success it was waiting + * for (Authenticating answers that with an association request) and a + * message 1 (the four-way answers that with message 2), then deadlines. */ + FixtureAp ap; + std::vector ok = ap.mgmt(devourer::sta::kFcAuth); + devourer::sta::put_le16(ok, 0); + devourer::sta::put_le16(ok, 2); + devourer::sta::put_le16(ok, 0); + sm.on_rx(ok.data(), ok.size(), now + 1); + const std::vector m1 = ap.eapol_frame(ap.msg1()); + sm.on_rx(m1.data(), m1.size(), now + 2); + for (int i = 1; i <= 4; i++) sm.tick(now + i * StationSm::kMgmtTimeoutMs); + sm.tick(now + 100000); + check(sm.pending_tx() == 0 && sm.assoc_tx == 0 && sm.eapol_tx == 0 && + sm.auth_tx == StationSm::kMaxTries, + "a failed machine sends nothing further, whatever it hears"); + check(sm.state() == StationSm::State::Failed && + sm.fail_reason() == StationSm::Failure::AuthTimeout, + "...and stays failed for the reason it failed"); +} + +void test_auth_refused() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + FixtureAp ap; + uint8_t snonce[32]; + + std::memset(snonce, 0x7a, 32); + sm.configure(crypto, kSsid, kPsk, kOwn); + ap.auth_status = 1; /* unspecified failure */ + const BssEntry* bss = discovered(table); + if (!bss) { check(false, "beacon"); return; } + sm.join(*bss, snonce, 0); + pump(sm, ap, 0); + + check(sm.state() == StationSm::State::Failed, "a refused auth fails"); + check(sm.fail_reason() == StationSm::Failure::AuthRefused, "...as refused"); + check(sm.status() == 1, "...carrying the AP's status code"); + check(!ap.saw_assoc, "...and no association is attempted"); +} + +void test_assoc_refused() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + FixtureAp ap; + uint8_t snonce[32]; + + std::memset(snonce, 0x7a, 32); + sm.configure(crypto, kSsid, kPsk, kOwn); + ap.assoc_status = 17; /* cannot handle more STAs */ + const BssEntry* bss = discovered(table); + if (!bss) { check(false, "beacon"); return; } + sm.join(*bss, snonce, 0); + pump(sm, ap, 0); + + check(sm.state() == StationSm::State::Failed, "a refused association fails"); + check(sm.fail_reason() == StationSm::Failure::AssocRefused, "...as refused"); + check(sm.status() == 17, "...carrying status 17, not a generic timeout"); +} + +/* THE STATUS FIELD IS WHAT REFUSES, not the AID. An AP that answers a + * refusal with a plausible-looking AID must still be refused. Without this + * cell the AID-zero check would do the status check's job, and deleting the + * status check would change nothing any other cell can see. */ +void test_assoc_refused_with_a_plausible_aid() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + FixtureAp ap; + uint8_t snonce[32]; + + std::memset(snonce, 0x7a, 32); + sm.configure(crypto, kSsid, kPsk, kOwn); + ap.assoc_status = 12; /* denied, unspecified */ + ap.aid_even_when_refused = true; + const BssEntry* bss = discovered(table); + if (!bss) { check(false, "beacon"); return; } + sm.join(*bss, snonce, 0); + pump(sm, ap, 0); + + check(sm.state() == StationSm::State::Failed, + "a refusal carrying an AID is still a refusal"); + check(sm.fail_reason() == StationSm::Failure::AssocRefused, "...as refused"); + check(sm.status() == 12, "...with the status the AP gave"); + check(sm.aid() == 0, "...and no AID is recorded"); + check(sm.eapol_rx == 0, "...and no handshake is attempted"); +} + +/* A success status with AID 0 means the AP answered yes without allocating + * anything. Taking it would leave the station associated with an AID a TIM + * bitmap cannot index. */ +void test_assoc_success_with_zero_aid() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + FixtureAp ap; + uint8_t snonce[32]; + + std::memset(snonce, 0x7a, 32); + sm.configure(crypto, kSsid, kPsk, kOwn); + ap.aid = 0; + const BssEntry* bss = discovered(table); + if (!bss) { check(false, "beacon"); return; } + sm.join(*bss, snonce, 0); + pump(sm, ap, 0); + + check(sm.state() == StationSm::State::Failed, + "a success response with AID 0 is refused"); + check(sm.aid() == 0, "...and no AID is recorded"); +} + +void test_deauth_during_handshake() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + FixtureAp ap; + uint8_t snonce[32]; + std::vector f; + + std::memset(snonce, 0x7a, 32); + sm.configure(crypto, kSsid, kPsk, kOwn); + const BssEntry* bss = discovered(table); + if (!bss) { check(false, "beacon"); return; } + sm.join(*bss, snonce, 0); + + /* Auth and assoc only: stop before the four-way finishes. */ + while (sm.pop_tx(&f)) { + const std::vector r = ap.respond(f); + if (!r.empty()) sm.on_rx(r.data(), r.size(), 0); + if (f[0] == devourer::sta::kFcAssocReq) break; + } + check(sm.state() == StationSm::State::FourWay, "the four-way is running"); + + std::vector d = devourer::sta::build_deauth(kOwn, kBssid, 7); + /* build_deauth builds a frame FROM the station; retarget it so it arrives + * from the AP, which is the direction that matters here. */ + std::memcpy(d.data() + 4, kOwn, 6); + std::memcpy(d.data() + 10, kBssid, 6); + std::memcpy(d.data() + 16, kBssid, 6); + sm.on_rx(d.data(), d.size(), 0); + + check(sm.state() == StationSm::State::Failed, "a deauth ends the attempt"); + check(sm.fail_reason() == StationSm::Failure::Deauthenticated, "...as such"); + check(sm.status() == 7, "...with the reason code the AP gave"); +} + +/* A deauth or disassoc TOO SHORT TO CARRY ITS REASON CODE is not one: it is + * malformed, it does not end the association "with reason 0", and a + * connected station stays connected. The full-length frame, right after, + * still ends it - so the cell cannot pass by ignoring deauths altogether. */ +void test_short_deauth_is_malformed() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + FixtureAp ap; + uint8_t snonce[32]; + + std::memset(snonce, 0x7a, 32); + sm.configure(crypto, kSsid, kPsk, kOwn); + discovered(table); + const BssEntry* bss = table.select(kSsid); + if (!bss) { check(false, "beacon"); return; } + sm.join(*bss, snonce, 0); + pump(sm, ap, 0); + if (!sm.keyed()) { check(false, "short deauth: the station connects first"); return; } + + std::vector d = devourer::sta::build_deauth(kOwn, kBssid, 7); + std::memcpy(d.data() + 4, kOwn, 6); + std::memcpy(d.data() + 10, kBssid, 6); + std::memcpy(d.data() + 16, kBssid, 6); + const uint32_t m0 = sm.rx_malformed; + sm.on_rx(d.data(), 25, 0); /* header + one reason octet */ + d[0] = devourer::sta::kFcDisassoc; + sm.on_rx(d.data(), 24, 0); /* header alone */ + check(sm.state() == StationSm::State::Connected && sm.keyed(), + "a deauth/disassoc with no room for a reason code does not end the link"); + check(sm.rx_malformed == m0 + 2, "...and both are counted malformed"); + + d[0] = devourer::sta::kFcDeauth; + sm.on_rx(d.data(), d.size(), 0); + check(sm.state() == StationSm::State::Failed && + sm.fail_reason() == StationSm::Failure::Deauthenticated && + sm.status() == 7, + "...while the full-length deauth still ends it, with its reason"); +} + +/* THE GROUP REKEY, END TO END THROUGH THE STATE MACHINE. After the four-way + * every EAPOL frame the AP sends is protected, so on_rx() only counts it; + * on_decrypted_msdu() is the one route by which a group key rotation reaches + * the supplicant. Without it hostapd logged "group key handshake failed + * (RSN) after 4 tries" and disconnected the station. The supplicant's own + * cells test the handshake; this one tests that the machine wires it up. */ +void test_group_rekey_through_the_decrypted_path() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + FixtureAp ap; + uint8_t snonce[32], gtk2[16]; + + std::memset(snonce, 0x7a, 32); + std::memset(gtk2, 0x62, 16); + sm.configure(crypto, kSsid, kPsk, kOwn); + discovered(table); + const BssEntry* bss = table.select(kSsid); + if (!bss) { check(false, "beacon"); return; } + sm.join(*bss, snonce, 0); + pump(sm, ap, 0); + if (!sm.keyed()) { check(false, "rekey: the four-way completes first"); return; } + + const uint32_t gg = sm.supplicant().gtk_generation(); + const uint32_t rx0 = sm.eapol_rx, tx0 = sm.eapol_tx; + const size_t q0 = sm.pending_tx(); + + std::vector msdu; + devourer::sta::append_llc_snap(msdu, 0x888e); + const std::vector g1 = ap.group1(gtk2, 2); + msdu.insert(msdu.end(), g1.begin(), g1.end()); + + std::vector reply; + check(sm.on_decrypted_msdu(msdu.data(), msdu.size(), 50, &reply), + "rekey: a decrypted group message 1 is consumed as EAPOL"); + devourer::sta::EapolKey k; + check(!reply.empty() && + devourer::sta::parse_eapol_key(reply.data(), reply.size(), &k) && + !k.pairwise() && k.has_mic() && k.secure() && + k.replay == ap.replay && + devourer::sta::eapol_mic_ok(ap.crypto, ap.ptk, k) == MicCheck::Ok, + "rekey: the reply is group message 2, at the AP's counter, under the " + "AP's KCK"); + check(sm.supplicant().gtk_generation() == gg + 1 && + sm.supplicant().gtk_key_id() == 2 && + sm.supplicant().gtk_len() == 16 && + std::memcmp(sm.supplicant().gtk(), gtk2, 16) == 0, + "rekey: the new GTK is installed, under its key id"); + check(sm.eapol_rx == rx0 + 1 && sm.eapol_tx == tx0 + 1, + "rekey: one EAPOL frame counted in and one out"); + check(sm.pending_tx() == q0, + "rekey: the reply is handed back for encryption, never queued in the " + "clear"); + check(sm.state() == StationSm::State::Connected && sm.keyed(), + "rekey: the station stays connected"); + + /* The AP's retransmission of the same message (its message 2 was lost): + * answered again with the cached reply, and nothing reinstalled. */ + std::vector again; + check(sm.on_decrypted_msdu(msdu.data(), msdu.size(), 60, &again) && + again == reply && sm.supplicant().gtk_generation() == gg + 1, + "rekey: an equal-counter repeat gets the cached reply and installs " + "nothing"); + + /* Not EAPOL: handed back to the caller as data, untouched. */ + std::vector ip; + devourer::sta::append_llc_snap(ip, 0x0800); + ip.insert(ip.end(), 20, 0x45); + std::vector none = {1}; + check(!sm.on_decrypted_msdu(ip.data(), ip.size(), 70, &none) && + none.size() == 1, + "rekey: a non-EAPOL MSDU is not consumed"); +} + +/* Every frame must come from the BSS being talked to. Without the addr2 + * check, any AP on the channel drives this machine — including one sending + * association responses to somebody else. */ +void test_frames_from_elsewhere_are_ignored() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + FixtureAp ap; + uint8_t snonce[32]; + + std::memset(snonce, 0x7a, 32); + sm.configure(crypto, kSsid, kPsk, kOwn); + const BssEntry* bss = discovered(table); + if (!bss) { check(false, "beacon"); return; } + sm.join(*bss, snonce, 0); + + /* A perfectly good authentication response, from the wrong AP. */ + std::vector m = ap.mgmt(devourer::sta::kFcAuth); + devourer::sta::put_le16(m, 0); + devourer::sta::put_le16(m, 2); + devourer::sta::put_le16(m, 0); + m[10] ^= 0xff; /* addr2: another BSSID */ + sm.on_rx(m.data(), m.size(), 0); + check(sm.state() == StationSm::State::Authenticating, + "an auth response from another BSSID does not advance the machine"); + + /* And a deauth from a third party must not tear anything down. */ + std::vector d = devourer::sta::build_deauth(kOwn, kBssid, 7); + std::memcpy(d.data() + 4, kOwn, 6); + std::memcpy(d.data() + 10, kBssid, 6); + d[10] ^= 0xff; + sm.on_rx(d.data(), d.size(), 0); + check(sm.state() == StationSm::State::Authenticating, + "a deauth from another BSSID is ignored"); + + /* Addressed to a different station, from the right AP. */ + std::vector n = ap.mgmt(devourer::sta::kFcAuth); + devourer::sta::put_le16(n, 0); + devourer::sta::put_le16(n, 2); + devourer::sta::put_le16(n, 0); + /* THE LAST OCTET of addr1. Flipping byte 0 sets the group bit and makes the + * frame a BROADCAST, which is addressed to this station as much as to + * anyone - it then survives the unicast filter and is dropped only by the + * `if (to_us)` inside the auth branch, so deleting the filter would not + * change the outcome. Flipping the last octet keeps the frame unicast, so + * only the address filter can drop it. */ + const uint32_t before = sm.rx_not_for_us; + n[9] ^= 0xff; + sm.on_rx(n.data(), n.size(), 0); + check(sm.state() == StationSm::State::Authenticating, + "an auth response for another station is ignored"); + check(sm.rx_not_for_us == before + 1, + "...by the address filter, which counted it"); +} + +/* The four-way is never protected. A PROTECTED frame claiming to be EAPOL + * cannot be one, because the keys it carries are what protection would need. */ +/* A protected data frame is the caller's to decrypt, and is counted apart + * from a protocol error. Counted in rx_ignored, it would be EVERY data frame + * on a working link - a 60-second on-air run carrying 75 frames reads as + * ignored=75 - and that counter set is the one thing that answers "why did + * nothing associate". */ +void test_protected_data_is_counted_apart() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + FixtureAp ap; + uint8_t snonce[32]; + + std::memset(snonce, 0x7a, 32); + sm.configure(crypto, kSsid, kPsk, kOwn); + discovered(table); + const BssEntry* bss = table.select(kSsid); + if (!bss) { check(false, "BSS discovered"); return; } + sm.join(*bss, snonce, 0); + pump(sm, ap, 0); + check(sm.state() == StationSm::State::Connected, "connected"); + + const uint32_t ignored = sm.rx_ignored; + std::vector f = devourer::sta::data_hdr_from_ds( + kOwn, kBssid, kBssid, /*protect=*/true, 7); + f.insert(f.end(), 40, 0x11); + sm.on_rx(f.data(), f.size(), 0); + check(sm.rx_protected == 1, "a protected data frame is counted as protected"); + check(sm.rx_ignored == ignored, "...and NOT as ignored"); +} + +/* A FRAGMENT AND AN A-MSDU ARE NOT MSDUs, and this machine reassembles + * neither. The bytes at the LLC offset are a piece of a frame, or a subframe + * header - so feeding them to the EAPOL parser asks it to read the wrong + * bytes. The caller's data plane refuses both; the two receive layers + * disagreeing about it is how a later reader closes the gap in one place. */ +void test_fragmented_and_amsdu_eapol_are_refused() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + FixtureAp ap; + uint8_t snonce[32]; + + std::memset(snonce, 0x7a, 32); + sm.configure(crypto, kSsid, kPsk, kOwn); + discovered(table); + const BssEntry* bss = table.select(kSsid); + if (!bss) { check(false, "BSS discovered"); return; } + sm.join(*bss, snonce, 0); + /* Authenticate and associate, and stop in FourWay: the clear carries + * EAPOL-Key messages 1 and 3 only before the PTK is installed. */ + std::vector f; + while (sm.pop_tx(&f)) { + const std::vector r = ap.respond(f); + if (!r.empty()) sm.on_rx(r.data(), r.size(), 0); + if (f[0] == devourer::sta::kFcAssocReq) break; + } + check(sm.state() == StationSm::State::FourWay, "in the four-way"); + + /* A well-formed EAPOL frame is the control: the arms below must differ + * from it by exactly the bit under test. */ + const uint32_t rx_before = sm.eapol_rx; + std::vector good = ap.eapol_frame(ap.msg1()); + sm.on_rx(good.data(), good.size(), 0); + check(sm.eapol_rx == rx_before + 1, "a whole EAPOL frame reaches the supplicant"); + + uint32_t bad_before = sm.rx_malformed; + std::vector frag = ap.eapol_frame(ap.msg1()); + frag[1] |= devourer::sta::kFcMoreFrag; + sm.on_rx(frag.data(), frag.size(), 0); + check(sm.rx_malformed == bad_before + 1, "a More Fragments EAPOL is refused"); + check(sm.eapol_rx == rx_before + 1, "...and never reaches the supplicant"); + + /* THE LAST FRAGMENT HAS MoreFrag CLEAR. */ + bad_before = sm.rx_malformed; + std::vector last = ap.eapol_frame(ap.msg1()); + last[22] = 0x02; /* fragment number 2 */ + sm.on_rx(last.data(), last.size(), 0); + check(sm.rx_malformed == bad_before + 1, "...and so is a LAST fragment"); + + /* An A-MSDU. The bit lives in the QoS Control field, so the frame has to + * be a QoS one - which is why no non-QoS arm can reach this branch. */ + bad_before = sm.rx_malformed; + std::vector amsdu = ap.eapol_frame(ap.msg1()); + amsdu[0] = 0x88; /* QoS Data */ + amsdu.insert(amsdu.begin() + 24, {0x80, 0x00}); /* QoS Control: A-MSDU */ + sm.on_rx(amsdu.data(), amsdu.size(), 0); + check(sm.rx_malformed == bad_before + 1, "an A-MSDU is refused"); + check(sm.eapol_rx == rx_before + 1, "...and never reaches the supplicant"); + + /* The control for THAT: the same QoS frame without the bit does get + * through, so the arm is about the A-MSDU bit and not about QoS. */ + std::vector qos = ap.eapol_frame(ap.msg1()); + qos[0] = 0x88; + qos.insert(qos.begin() + 24, {0x00, 0x00}); + sm.on_rx(qos.data(), qos.size(), 0); + check(sm.eapol_rx == rx_before + 2, "...while a plain QoS EAPOL does"); +} + +void test_protected_eapol_ignored() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + FixtureAp ap; + uint8_t snonce[32]; + std::vector f; + + std::memset(snonce, 0x7a, 32); + sm.configure(crypto, kSsid, kPsk, kOwn); + const BssEntry* bss = discovered(table); + if (!bss) { check(false, "beacon"); return; } + sm.join(*bss, snonce, 0); + while (sm.pop_tx(&f)) { + const std::vector r = ap.respond(f); + if (!r.empty()) sm.on_rx(r.data(), r.size(), 0); + if (f[0] == devourer::sta::kFcAssocReq) break; + } + + std::vector m1 = ap.eapol_frame(ap.msg1()); + m1[1] |= devourer::sta::kFcProtected; + sm.on_rx(m1.data(), m1.size(), 0); + check(sm.eapol_rx == 0, "a protected EAPOL frame is not fed to the supplicant"); + check(sm.pending_tx() == 0, "...and nothing is answered"); + + /* The same frame unprotected IS accepted — so the cell above is testing the + * protected bit and not something else about the frame. */ + m1[1] &= (uint8_t)~devourer::sta::kFcProtected; + sm.on_rx(m1.data(), m1.size(), 0); + check(sm.eapol_rx == 1, "the same frame unprotected is accepted"); + check(sm.pending_tx() == 1, "...and answered"); +} + +/* The four-way give-up. The authenticator owns the retransmission schedule, + * so this side must not sit in FourWay forever when it stops. */ +void test_handshake_timeout() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + FixtureAp ap; + uint8_t snonce[32]; + std::vector f; + + std::memset(snonce, 0x7a, 32); + sm.configure(crypto, kSsid, kPsk, kOwn); + const BssEntry* bss = discovered(table); + if (!bss) { check(false, "beacon"); return; } + sm.join(*bss, snonce, 0); + while (sm.pop_tx(&f)) { + const std::vector r = ap.respond(f); + if (!r.empty()) sm.on_rx(r.data(), r.size(), 0); + if (f[0] == devourer::sta::kFcAssocReq) break; + } + check(sm.state() == StationSm::State::FourWay, "the four-way is running"); + + sm.tick(StationSm::kHandshakeTimeoutMs - 1); + check(sm.state() == StationSm::State::FourWay, "it waits out its deadline"); + sm.tick(StationSm::kHandshakeTimeoutMs); + check(sm.state() == StationSm::State::Failed, "then gives up"); + check(sm.fail_reason() == StationSm::Failure::HandshakeTimeout, + "...saying the handshake timed out, not the association"); +} + +/* AN AUTHENTICATOR THAT ONLY RETRANSMITS IS NOT MAKING PROGRESS. Answering a + * retransmitted message 1 is correct, but letting it push the give-up + * deadline out means a stuck AP holds this state open forever — a station + * that never connects and never tries anything else. */ +void test_retransmission_does_not_extend_the_deadline() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + FixtureAp ap; + uint8_t snonce[32]; + std::vector f; + + std::memset(snonce, 0x7a, 32); + sm.configure(crypto, kSsid, kPsk, kOwn); + const BssEntry* bss = discovered(table); + if (!bss) { check(false, "beacon"); return; } + sm.join(*bss, snonce, 0); + while (sm.pop_tx(&f)) { + const std::vector r = ap.respond(f); + if (!r.empty()) sm.on_rx(r.data(), r.size(), 0); + if (f[0] == devourer::sta::kFcAssocReq) break; + } + check(sm.state() == StationSm::State::FourWay, "the four-way is running"); + + /* The AP's message 1, over and over, at the same replay counter. */ + const std::vector m1 = ap.eapol_frame(ap.msg1()); + sm.on_rx(m1.data(), m1.size(), 0); + while (sm.pop_tx(&f)) { + } + for (uint32_t t = 500; t < StationSm::kHandshakeTimeoutMs; t += 500) { + sm.on_rx(m1.data(), m1.size(), t); + while (sm.pop_tx(&f)) { + } + sm.tick(t); + } + check(sm.supplicant().retransmits > 0, + "the retransmissions were seen as retransmissions"); + check(sm.state() == StationSm::State::FourWay, + "...and the machine is still waiting"); + + sm.tick(StationSm::kHandshakeTimeoutMs); + check(sm.state() == StationSm::State::Failed, + "the give-up fires on time DESPITE the retransmissions"); + check(sm.fail_reason() == StationSm::Failure::HandshakeTimeout, + "...as a handshake timeout"); +} + +/* JOINING A SECOND BSS MUST NOT AIR THE FIRST ONE'S FRAMES. + * + * join() reset everything except the transmit queue, so auth requests still + * queued for the BSS we gave up on went out at the one we just joined - + * addressed to the old BSSID, after the radio had retuned to the new channel. + * Every other cell here drains the queue between steps, which is exactly why + * none of them saw it. */ +void test_join_clears_the_transmit_queue() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + uint8_t snonce[32]; + std::vector f; + const uint8_t kOther[6] = {0x02, 0x42, 0x75, 0x05, 0xd6, 0x99}; + + std::memset(snonce, 0x7a, 32); + sm.configure(crypto, kSsid, kPsk, kOwn); + const BssEntry* bss = discovered(table); + if (!bss) { check(false, "beacon"); return; } + sm.join(*bss, snonce, 0); + + /* Three unanswered authentication requests pile up, unread. */ + uint32_t now = 0; + for (int i = 0; i < 2; i++) { now += StationSm::kMgmtTimeoutMs; sm.tick(now); } + check(sm.pending_tx() == 3, "three auth requests are queued for the first BSS"); + + BssEntry other = *bss; + std::memcpy(other.info.bssid, kOther, 6); + sm.join(other, snonce, now); + check(sm.pending_tx() == 1, + "join() clears the queue - only the new BSS's request is pending"); + check(sm.pop_tx(&f) && std::memcmp(f.data() + 4, kOther, 6) == 0, + "...and it is addressed to the BSS we actually joined"); +} + +/* THE QUEUE IS BOUNDED. Every frame in it is produced in answer to a received + * one, so an unbounded queue is an unbounded allocation an attacker controls: + * one captured EAPOL frame replayed at the current counter is answered every + * time. */ +void test_transmit_queue_is_bounded() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + FixtureAp ap; + uint8_t snonce[32]; + std::vector f; + + std::memset(snonce, 0x7a, 32); + sm.configure(crypto, kSsid, kPsk, kOwn); + const BssEntry* bss = discovered(table); + if (!bss) { check(false, "beacon"); return; } + sm.join(*bss, snonce, 0); + while (sm.pop_tx(&f)) { + const std::vector r = ap.respond(f); + if (!r.empty()) sm.on_rx(r.data(), r.size(), 0); + if (f[0] == devourer::sta::kFcAssocReq) break; + } + const std::vector m1 = ap.eapol_frame(ap.msg1()); + sm.on_rx(m1.data(), m1.size(), 0); + + /* The caller never drains. An attacker replays the same frame. */ + for (int i = 0; i < 2000; i++) sm.on_rx(m1.data(), m1.size(), 0); + check(sm.pending_tx() <= StationSm::tx_capacity(), + "the transmit queue never exceeds its capacity"); + check(sm.tx_dropped > 0, "...and the drops are counted, not silent"); +} + +/* CONNECTED HAS AN EXIT. Without beacon supervision the only way out is a + * deauth from an AP that may have been switched off, and the caller sees + * keyed() forever. */ +void test_beacon_loss() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + FixtureAp ap; + uint8_t snonce[32]; + + std::memset(snonce, 0x7a, 32); + sm.configure(crypto, kSsid, kPsk, kOwn); + const BssEntry* bss = discovered(table); + if (!bss) { check(false, "beacon"); return; } + sm.join(*bss, snonce, 0); + pump(sm, ap, 0); + check(sm.state() == StationSm::State::Connected, "the station connects"); + + /* Beacons keep arriving: nothing happens, however long it runs. */ + uint32_t now = 0; + for (int i = 0; i < 20; i++) { + now += StationSm::kBeaconLossMs / 2; + std::vector b = beacon(kBssid, 6); + sm.on_rx(b.data(), b.size(), now); + sm.tick(now); + } + check(sm.state() == StationSm::State::Connected, + "a beaconing AP keeps the station connected indefinitely"); + check(sm.beacons_rx == 20, "...and the beacons are counted"); + + /* They stop. */ + sm.tick(now + StationSm::kBeaconLossMs - 1); + check(sm.state() == StationSm::State::Connected, "...it waits out the window"); + sm.tick(now + StationSm::kBeaconLossMs); + check(sm.state() == StationSm::State::Failed, "a silent AP ends the link"); + check(sm.fail_reason() == StationSm::Failure::BeaconLost, + "...saying the beacon was lost, not that the handshake timed out"); + check(!sm.keyed(), "...and keyed() stops claiming a link that is gone"); +} + +/* leave() tells the AP rather than letting it time the station out - which on + * this project's own AP holds an AID and one of seven table slots. */ +void test_leave() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + FixtureAp ap; + uint8_t snonce[32]; + std::vector f; + + std::memset(snonce, 0x7a, 32); + sm.configure(crypto, kSsid, kPsk, kOwn); + const BssEntry* bss = discovered(table); + if (!bss) { check(false, "beacon"); return; } + sm.join(*bss, snonce, 0); + pump(sm, ap, 0); + check(sm.state() == StationSm::State::Connected, "the station connects"); + + sm.leave(3); + check(sm.state() == StationSm::State::Idle, "leave() goes back to Idle"); + check(!sm.keyed(), "...and drops the keys"); + check(sm.aid() == 0, "...and the AID"); + check(sm.pop_tx(&f), "...having queued a frame"); + check(f[0] == devourer::sta::kFcDeauth, "...which is a deauthentication"); + check(std::memcmp(f.data() + 4, kBssid, 6) == 0, "...addressed to the AP"); + sm.leave(3); + check(sm.pending_tx() == 0, "leaving twice sends nothing the second time"); +} + +/* The RX filter counts what it discards. On hardware this is the only address + * filter in the system, so "it did not associate" must come with a number + * saying whether the AP was ever heard. */ +void test_rx_counters() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + FixtureAp ap; + uint8_t snonce[32]; + + std::memset(snonce, 0x7a, 32); + sm.configure(crypto, kSsid, kPsk, kOwn); + const BssEntry* bss = discovered(table); + if (!bss) { check(false, "beacon"); return; } + sm.join(*bss, snonce, 0); + + std::vector m = ap.mgmt(devourer::sta::kFcAuth); + devourer::sta::put_le16(m, 0); + devourer::sta::put_le16(m, 2); + devourer::sta::put_le16(m, 0); + + std::vector foreign = m; + foreign[10] ^= 0xff; + sm.on_rx(foreign.data(), foreign.size(), 0); + check(sm.rx_not_our_bss == 1, "a frame from another BSS is counted"); + + std::vector elsewhere = m; + /* The LAST octet of addr1, not the first: flipping byte 0 sets the + * group bit and turns the frame into a broadcast, which is addressed to + * this station as much as to anyone, so the cell would measure the wrong + * counter. */ + elsewhere[9] ^= 0xff; + sm.on_rx(elsewhere.data(), elsewhere.size(), 0); + check(sm.rx_not_for_us == 1, "a frame for another station is counted"); + + /* Somebody else's data traffic, correctly addressed to us: not an error, + * but it must not be confused with one. */ + std::vector d = devourer::sta::data_hdr_from_ds( + kOwn, kBssid, kBssid, /*protect=*/false, 1); + devourer::sta::append_llc_snap(d, 0x0800); + d.insert(d.end(), 20, 0x41); + sm.on_rx(d.data(), d.size(), 0); + check(sm.rx_ignored == 1, "a non-EAPOL data frame is counted separately"); + check(sm.rx_not_our_bss == 1 && sm.rx_not_for_us == 1, + "...and does not move the address counters"); +} + +/* join() must refuse a BSS this station cannot finish with, rather than + * authenticating and discovering it three frames later. */ +void test_join_refuses_an_unusable_bss() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + uint8_t snonce[32]; + + std::memset(snonce, 0x7a, 32); + sm.configure(crypto, kSsid, kPsk, kOwn); + + BssEntry open{}; + std::memcpy(open.info.bssid, kBssid, 6); + open.info.ssid = kSsid; + open.info.rsn_ccmp_psk = false; + check(!sm.join(open, snonce, 0), "an open BSS is refused by join()"); + check(sm.state() == StationSm::State::Failed, "...and says so"); + check(sm.pending_tx() == 0, "...without sending anything"); +} + +/* A CryptoOps whose PBKDF2 refuses, which is the only way to reach the NoPmk + * branch: OpenSSL's does not fail for any passphrase a caller can supply. + * Without it that branch is unreachable from this file and deleting it costs + * nothing - which is what "the test could not fail" means. */ +struct NoPbkdf2Crypto : OpenSslCryptoOps { + bool pbkdf2_sha1(const char*, const uint8_t*, size_t, unsigned, uint8_t*, + size_t) override { + return false; + } +}; + +/* configure() reports the failure, and join() must then name it NoPmk rather + * than authenticating at an AP it can never finish a handshake with. */ +void test_no_pmk() { + NoPbkdf2Crypto crypto; + BssTable table; + StationSm sm; + uint8_t snonce[32]; + + std::memset(snonce, 0x7a, 32); + check(!sm.configure(crypto, kSsid, kPsk, kOwn), + "configure reports a failed PMK derivation"); + discovered(table); + const BssEntry* bss = table.select(kSsid); + if (!bss) { check(false, "BSS discovered"); return; } + check(!sm.join(*bss, snonce, 0), "join refuses"); + check(sm.fail_reason() == StationSm::Failure::NoPmk, + "...naming NoPmk, not NotConfigured"); + check(sm.pending_tx() == 0, "...without airing an authentication request"); +} + +/* ---- the open-network path --------------------------------------------- + * + * WHY IT EXISTS: without it a station that never reaches Connected cannot + * say whether the failure is in authentication/association or in the key + * exchange, because on a WPA2 BSS the two halves come up together or not at + * all. An on-air harness can run an `open` cell first for exactly that + * reason, mirroring the AP side's ap_responder/ap_wpa2 ladder. + */ +void test_open_association() { + BssTable table; + StationSm sm; + FixtureAp ap; + + ap.sends_msg1 = false; /* a real open AP keys nothing */ + check(sm.configure_open(kSsid, kOwn), "configure_open succeeds"); + check(sm.security() == StationSm::Security::Open, "...and says it is open"); + + discovered(table, 6, /*rsn=*/false); + const BssEntry* bss = table.select_open(kSsid); + check(bss != nullptr, "an open BSS is selectable by select_open"); + check(table.select(kSsid) == nullptr, + "...and NOT by select(), which wants WPA2-PSK"); + if (!bss) return; + + /* A null SNonce, deliberately: the open path must not read it. Passing a + * real one would leave a mutation that deleted the Open branch of the copy + * undetectable. */ + check(sm.join(*bss, nullptr, 0), "join starts without an SNonce"); + pump(sm, ap, 0); + + check(ap.saw_auth && ap.saw_assoc, "the AP saw both requests"); + check(sm.state() == StationSm::State::Connected, + "the station connects with no four-way"); + check(sm.connected(), "connected() is true"); + check(!sm.keyed(), "...and keyed() is FALSE - there is no key"); + check(sm.aid() == ap.aid, "...with the AID the AP allocated"); + check(sm.eapol_tx == 0 && sm.eapol_rx == 0, "no EAPOL in either direction"); + check(sm.supplicant().state() == devourer::sta::Supplicant::State::Idle, + "the supplicant was never started"); + + /* THE WIRE BYTES, not the outcome. An association request that still + * carried the RSN element, or still claimed Privacy, would associate + * against this fixture exactly as happily - the fixture does not look - and + * would be refused by a real open AP. */ + check(!ap.last_assoc.empty(), "the association request was captured"); + if (ap.last_assoc.size() >= 28) { + const uint16_t cap = devourer::sta::get_le16(ap.last_assoc.data() + 24); + size_t ie_len = 0; + check((cap & 0x0010) == 0, "...with the Privacy capability bit CLEAR"); + check((cap & 0x0001) != 0, "...and ESS still set"); + check(devourer::sta::find_ie(ap.last_assoc.data() + 28, + ap.last_assoc.size() - 28, + devourer::sta::kEidRsn, &ie_len) == nullptr, + "...and no RSN element"); + } +} + +/* An open station on a BSS that encrypts. It must refuse before + * authenticating: associating would succeed and every data frame would then + * be dropped by one side or the other, with no diagnostic anywhere. */ +void test_open_station_refuses_a_protected_bss() { + BssTable table; + StationSm sm; + + sm.configure_open(kSsid, kOwn); + discovered(table, 6, /*rsn=*/true); + check(table.select_open(kSsid) == nullptr, + "select_open skips a BSS that advertises Privacy"); + + /* select_open refusing is not enough - join() is reachable with a + * hand-picked entry, which is how a caller with a configured BSSID gets + * here. Both gates are tested because either alone can be deleted. */ + BssEntry e{}; + std::memcpy(e.info.bssid, kBssid, 6); + e.info.ssid = kSsid; + e.info.privacy = true; + check(!sm.join(e, nullptr, 0), "join() refuses it too"); + check(sm.state() == StationSm::State::Failed, "...and says so"); + check(sm.pending_tx() == 0, "...without airing an authentication request"); +} + +/* The configuration mismatch: an open station at an AP that tries to key it. + * The supplicant holds no PMK and cannot verify a MIC, so feeding it an + * EAPOL-Key frame would either crash or invent a reply. It is counted and + * dropped, and the link stays up. */ +void test_open_station_ignores_eapol() { + BssTable table; + StationSm sm; + FixtureAp ap; + + ap.sends_msg1 = true; /* the mismatch, on purpose */ + sm.configure_open(kSsid, kOwn); + discovered(table, 6, /*rsn=*/false); + const BssEntry* bss = table.select_open(kSsid); + if (!bss) { check(false, "open BSS discovered"); return; } + sm.join(*bss, nullptr, 0); + pump(sm, ap, 0); + + const uint32_t ignored_before = sm.rx_ignored; + const std::vector m1 = ap.eapol_frame(ap.msg1()); + sm.on_rx(m1.data(), m1.size(), 0); + + check(sm.rx_ignored > ignored_before, "the EAPOL-Key frame is counted"); + check(sm.eapol_rx == 0, "...and never reaches the supplicant"); + check(sm.pending_tx() == 0, "...and is not answered"); + check(sm.state() == StationSm::State::Connected, "...and the link stays up"); +} + +/* A WPA2 join with no SNonce is refused. Copying 32 bytes from whatever the + * caller passed would let a caller that configures once and joins repeatedly + * reuse the PREVIOUS association's nonce - the defect configure()'s comment + * warns about from the other direction. */ +void test_wpa2_join_needs_an_snonce() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + + sm.configure(crypto, kSsid, kPsk, kOwn); + discovered(table); + const BssEntry* bss = table.select(kSsid); + if (!bss) { check(false, "BSS discovered"); return; } + check(!sm.join(*bss, nullptr, 0), "a WPA2 join without an SNonce is refused"); + check(sm.fail_reason() == StationSm::Failure::NotConfigured, + "...as NotConfigured"); + check(sm.pending_tx() == 0, "...without airing anything"); +} + +/* A GROUP-ADDRESSED EAPOL-Key is part of no handshake with this station, and + * a caller's decrypted path should refuse one too. This layer does not feed + * it to the supplicant, so a broadcast forged message 1 cannot drive + * on_msg1. */ +void test_broadcast_eapol_is_not_fed_to_the_supplicant() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + FixtureAp ap; + uint8_t snonce[32]; + std::vector f; + static const uint8_t bcast[6] = {0xff, 0xff, 0xff, 0xff, 0xff, 0xff}; + + std::memset(snonce, 0x7a, 32); + sm.configure(crypto, kSsid, kPsk, kOwn); + const BssEntry* bss = discovered(table); + if (!bss) { check(false, "beacon"); return; } + sm.join(*bss, snonce, 0); + while (sm.pop_tx(&f)) { + const std::vector r = ap.respond(f); + if (!r.empty()) sm.on_rx(r.data(), r.size(), 0); + if (f[0] == devourer::sta::kFcAssocReq) break; + } + + std::vector m1 = ap.eapol_frame(ap.msg1()); + std::memcpy(m1.data() + 4, bcast, 6); /* addr1 = broadcast */ + sm.on_rx(m1.data(), m1.size(), 0); + check(sm.eapol_rx == 0, "a BROADCAST EAPOL-Key is not fed to the supplicant"); + check(sm.pending_tx() == 0, "...and nothing is answered"); + + /* The control: the same frame to us is accepted. */ + std::memcpy(m1.data() + 4, kOwn, 6); + sm.on_rx(m1.data(), m1.size(), 0); + check(sm.eapol_rx == 1, "the same frame unicast to us is accepted"); +} + +/* Beacon supervision follows the BSS's OWN interval: an AP beaconing at + * 1000 TU (1.024 s) must not be declared lost after one late beacon. */ +void test_beacon_loss_follows_the_interval() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + FixtureAp ap; + uint8_t snonce[32]; + + std::memset(snonce, 0x7a, 32); + sm.configure(crypto, kSsid, kPsk, kOwn); + std::vector b = beacon(kBssid, 6); + b[32] = (uint8_t)(1000 & 0xff); /* beacon interval, TU */ + b[33] = (uint8_t)(1000 >> 8); + const BssEntry* bss = table.observe(b.data(), b.size(), -40, 6, 0); + if (!bss) { check(false, "beacon"); return; } + sm.join(*bss, snonce, 0); + pump(sm, ap, 0); + check(sm.state() == StationSm::State::Connected, "the station connects"); + check(sm.beacon_loss_ms() == 10240, + "the loss window is ten of the BSS's own intervals"); + + sm.tick(StationSm::kBeaconLossMs * 2); + check(sm.state() == StationSm::State::Connected, + "two 100-TU windows of silence do not end a 1000-TU link"); + sm.tick(10240); + check(sm.state() == StationSm::State::Failed && + sm.fail_reason() == StationSm::Failure::BeaconLost, + "...ten of its own intervals do"); +} + +/* Reconfiguring drops the Supplicant's keys too, not just this object's PMK: + * configure(WPA2) -> join -> configure_open() must not leave the PTK and GTK + * resident, nor a station claiming keyed() under the new configuration. */ +void test_reconfigure_forgets_the_keys() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + FixtureAp ap; + uint8_t snonce[32], zero[48] = {0}; + + std::memset(snonce, 0x7a, 32); + sm.configure(crypto, kSsid, kPsk, kOwn); + const BssEntry* bss = discovered(table); + if (!bss) { check(false, "beacon"); return; } + sm.join(*bss, snonce, 0); + pump(sm, ap, 0); + check(sm.keyed() && sm.supplicant().ptk_valid(), "keyed"); + + sm.configure_open(kSsid, kOwn); + check(!sm.supplicant().ptk_valid() && !sm.supplicant().gtk_valid(), + "configure_open() drops the supplicant's keys"); + check(std::memcmp(sm.supplicant().ptk(), zero, 48) == 0, + "...and wipes the PTK bytes"); + check(!sm.keyed() && sm.state() == StationSm::State::Idle, + "...and the station no longer claims a keyed link"); + check(sm.aid() == 0, "...nor the old association's AID"); + check(!sm.has_pmk() && std::memcmp(sm.pmk(), zero, 32) == 0, + "...and configure_open() wipes this object's PMK too"); +} + +} // namespace + +/* THE ADVERTISEMENT REACHES THE SUPPLICANT. join() keeps the RSN element of + * the BSS it chose and the four-way holds message 3 to it (802.11-2016 + * 12.7.6.4). Here the beacon advertises CCMP alone while the AP's + * MIC-protected message 3 offers TKIP as well: someone rewrote the beacon. The + * station must not come up keyed. Without this cell, a join() that stopped + * passing the element would leave the supplicant's check silently off. */ +void test_rsn_downgrade_is_refused() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + FixtureAp ap; + uint8_t snonce[32]; + + std::memset(snonce, 0x7a, 32); + check(sm.configure(crypto, kSsid, kPsk, kOwn), "configure derives the PMK"); + discovered(table); + const BssEntry* bss = table.select(kSsid); + if (!bss) { check(false, "the BSS is selectable"); return; } + ap.rsn_override = {0x30, 0x18, 0x01, 0x00, 0x00, 0x0f, 0xac, 0x04, + 0x02, 0x00, 0x00, 0x0f, 0xac, 0x02, 0x00, 0x0f, + 0xac, 0x04, 0x01, 0x00, 0x00, 0x0f, 0xac, 0x02, + 0x00, 0x00}; + check(sm.join(*bss, snonce, 0), "join starts"); + pump(sm, ap, 0); + check(!sm.keyed() && !ap.saw_msg4, + "a message 3 RSN element that differs from the beacon's: not keyed, " + "no message 4"); + check(sm.supplicant().rsn_mismatches == 1, + "...and the supplicant counted the mismatch"); +} + +/* A RECONFIGURE DROPS WHAT WAS QUEUED for the association it lets go: the + * authentication request join() queued must not air afterwards, built for a + * configuration the machine no longer has. */ +void test_reconfigure_drops_queued_frames() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + uint8_t snonce[32]; + std::memset(snonce, 0x7a, 32); + check(sm.configure(crypto, kSsid, kPsk, kOwn), "configure"); + discovered(table); + const BssEntry* bss = table.select(kSsid); + if (!bss) { check(false, "beacon"); return; } + check(sm.join(*bss, snonce, 0) && sm.pending_tx() == 1, + "join queues an authentication request"); + check(sm.configure(crypto, kSsid, kPsk, kOwn), "reconfigure"); + check(sm.pending_tx() == 0, + "...and the reconfigure dropped it: nothing for the old association airs"); +} + +/* LEAVE CLEARS WHAT IS QUEUED, then says goodbye. A deauth queued BEHIND + * whatever is pending would let a caller draining the queue air a stale + * authentication or association retry first - and after it. */ +void test_leave_drops_stale_frames() { + OpenSslCryptoOps crypto; + std::vector f; + uint8_t snonce[32]; + std::memset(snonce, 0x7a, 32); + + { + /* During authentication, with two auth requests pending. leave() sends + * no deauth in this state (nothing has been authenticated to), so the + * queue ends empty. */ + BssTable table; + StationSm sm; + sm.configure(crypto, kSsid, kPsk, kOwn); + const BssEntry* bss = discovered(table); + if (!bss) { check(false, "beacon"); return; } + sm.join(*bss, snonce, 0); + sm.tick(StationSm::kMgmtTimeoutMs); /* a retransmission */ + check(sm.pending_tx() == 2, "leave/auth: setup - two auth requests pending"); + sm.leave(); + check(sm.pending_tx() == 0, + "leave/auth: nothing stale airs after leaving during authentication"); + } + { + /* During association, with two association requests pending: the + * deauth pops first and nothing follows it. */ + BssTable table; + StationSm sm; + FixtureAp ap; + sm.configure(crypto, kSsid, kPsk, kOwn); + const BssEntry* bss = discovered(table); + if (!bss) { check(false, "beacon"); return; } + sm.join(*bss, snonce, 0); + sm.pop_tx(&f); + const std::vector r = ap.respond(f); + sm.on_rx(r.data(), r.size(), 0); /* auth success: assoc queued */ + sm.tick(StationSm::kMgmtTimeoutMs); /* and retransmitted */ + check(sm.state() == StationSm::State::Associating && sm.pending_tx() == 2, + "leave/assoc: setup - two association requests pending"); + sm.leave(); + check(sm.pop_tx(&f) && !f.empty() && f[0] == devourer::sta::kFcDeauth, + "leave/assoc: the deauth is the first frame out"); + check(!sm.pop_tx(&f), "leave/assoc: ...and nothing stale follows it"); + } +} + +/* A PEER DEAUTH ENDS THE ASSOCIATION, KEYS AND ALL. fail() drops the queue + * and the supplicant's PTK, GTK and cached replies, which would otherwise + * stay readable through supplicant(). The PMK and configuration stay: a + * rejoin needs no reconfigure. */ +void test_peer_deauth_drops_the_association_keys() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + FixtureAp ap; + uint8_t snonce[32]; + std::vector f; + + std::memset(snonce, 0x7a, 32); + sm.configure(crypto, kSsid, kPsk, kOwn); + discovered(table); + const BssEntry* bss = table.select(kSsid); + if (!bss) { check(false, "beacon"); return; } + sm.join(*bss, snonce, 0); + pump(sm, ap, 0); + if (!sm.keyed()) { check(false, "peer deauth: setup - connected"); return; } + check(sm.aid() == ap.aid, "peer deauth: setup - the AP's AID is held"); + + std::vector d = devourer::sta::build_deauth(kOwn, kBssid, 15); + std::memcpy(d.data() + 4, kOwn, 6); + std::memcpy(d.data() + 10, kBssid, 6); + std::memcpy(d.data() + 16, kBssid, 6); + sm.on_rx(d.data(), d.size(), 10); + + check(sm.state() == StationSm::State::Failed && + sm.fail_reason() == StationSm::Failure::Deauthenticated && + sm.status() == 15, + "peer deauth: failed, with the peer's reason"); + check(sm.aid() == 0, "peer deauth: aid() no longer reports the old AID"); + check(!sm.supplicant().ptk_valid() && !sm.supplicant().gtk_valid(), + "peer deauth: the PTK and GTK are no longer valid"); + bool zero = true; + for (size_t i = 0; i < 48; i++) zero = zero && sm.supplicant().ptk()[i] == 0; + for (size_t i = 0; i < 32; i++) zero = zero && sm.supplicant().gtk()[i] == 0; + check(zero, "peer deauth: ...and their bytes are wiped"); + + /* During the four-way, with message 2 still queued: it must not air at an + * AP that has just thrown us off. */ + StationSm sm2; + FixtureAp ap2; + sm2.configure(crypto, kSsid, kPsk, kOwn); + sm2.join(*bss, snonce, 0); + while (sm2.pop_tx(&f)) { + const std::vector r = ap2.respond(f); + if (!r.empty()) sm2.on_rx(r.data(), r.size(), 0); + if (f[0] == devourer::sta::kFcAssocReq) break; + } + const std::vector m1 = ap2.eapol_frame(ap2.msg1()); + sm2.on_rx(m1.data(), m1.size(), 0); + check(sm2.pending_tx() == 1, "peer deauth: setup - message 2 queued"); + sm2.on_rx(d.data(), d.size(), 0); + check(sm2.pending_tx() == 0, + "peer deauth: the queued message 2 is dropped with the association"); + + /* The PMK survived: the same machine rejoins without reconfiguring. */ + FixtureAp ap3; + check(sm.join(*bss, snonce, 100), "peer deauth: a rejoin starts"); + pump(sm, ap3, 100); + check(sm.keyed(), "peer deauth: ...and completes on the kept PMK"); +} + +/* A REPLY THE QUEUE DROPPED DID NOT GO OUT, so it must not move the + * four-way's give-up deadline. Refreshing the deadline before queue(), which + * drops when full, would let an attacker who fills the queue with answered + * retransmissions slide the deadline forward with each new message 1 while + * nothing is sent. */ +void test_dropped_reply_does_not_move_the_deadline() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + FixtureAp ap; + uint8_t snonce[32]; + std::vector f; + + std::memset(snonce, 0x7a, 32); + sm.configure(crypto, kSsid, kPsk, kOwn); + const BssEntry* bss = discovered(table); + if (!bss) { check(false, "beacon"); return; } + sm.join(*bss, snonce, 0); + while (sm.pop_tx(&f)) { + const std::vector r = ap.respond(f); + if (!r.empty()) sm.on_rx(r.data(), r.size(), 0); + if (f[0] == devourer::sta::kFcAssocReq) break; + } + const std::vector m1 = ap.eapol_frame(ap.msg1()); + sm.on_rx(m1.data(), m1.size(), 0); + /* Retransmissions of the same message 1, answered and never drained, + * until the queue is full. */ + for (int i = 0; i < 64 && sm.pending_tx() < StationSm::tx_capacity(); i++) + sm.on_rx(m1.data(), m1.size(), 0); + check(sm.pending_tx() == StationSm::tx_capacity(), + "dropped reply: setup - the transmit queue is full"); + const uint32_t tx0 = sm.eapol_tx, dropped0 = sm.tx_dropped; + + /* A NEW message 1 (next counter) is real progress for the supplicant - + * but its reply cannot be queued. */ + const std::vector m1b = ap.eapol_frame(ap.msg1()); + sm.on_rx(m1b.data(), m1b.size(), 2000); + check(sm.tx_dropped == dropped0 + 1 && sm.eapol_tx == tx0, + "dropped reply: the reply was dropped, and not counted as sent"); + sm.tick(StationSm::kHandshakeTimeoutMs); + check(sm.state() == StationSm::State::Failed && + sm.fail_reason() == StationSm::Failure::HandshakeTimeout, + "dropped reply: the deadline still runs from the last reply that went " + "out"); +} + +/* Up to the point where message 3 is in hand and the transmit queue is full + * of answered message-1 retransmissions, so message 4 will be dropped. */ +bool msg4_drop_setup(StationSm& sm, FixtureAp& ap, OpenSslCryptoOps& crypto, + BssTable& table, std::vector* m3) { + uint8_t snonce[32]; + std::vector f; + + std::memset(snonce, 0x7a, 32); + sm.configure(crypto, kSsid, kPsk, kOwn); + const BssEntry* bss = discovered(table); + if (!bss) return false; + sm.join(*bss, snonce, 0); + while (sm.pop_tx(&f)) { + const std::vector r = ap.respond(f); + if (!r.empty()) sm.on_rx(r.data(), r.size(), 0); + if (f[0] == devourer::sta::kFcAssocReq) break; + } + const std::vector m1 = ap.eapol_frame(ap.msg1()); + sm.on_rx(m1.data(), m1.size(), 0); + if (!sm.pop_tx(&f)) return false; /* message 2 */ + *m3 = ap.respond(f); /* the AP's message 3 */ + if (m3->empty()) return false; + for (int i = 0; i < 64 && sm.pending_tx() < StationSm::tx_capacity(); i++) + sm.on_rx(m1.data(), m1.size(), 0); + return sm.pending_tx() == StationSm::tx_capacity(); +} + +/* MESSAGE 4 MUST LEAVE BEFORE THE STATION CALLS ITSELF CONNECTED. The + * supplicant is Done the moment it accepts message 3, but queue() can still + * drop message 4 when full. A station Connected and keyed whose message 4 + * never reached the AP carries traffic the AP has not keyed. Three ways on + * from a dropped message 4. */ +void test_dropped_msg4_does_not_connect() { + for (int variant = 0; variant < 3; variant++) { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + FixtureAp ap; + std::vector m3, f; + + if (!msg4_drop_setup(sm, ap, crypto, table, &m3)) { + check(false, "msg4 drop: setup - message 3 in hand, queue full"); + return; + } + const uint32_t dropped0 = sm.tx_dropped; + sm.on_rx(m3.data(), m3.size(), 100); + check(sm.supplicant().state() == devourer::sta::Supplicant::State::Done && + sm.tx_dropped == dropped0 + 1, + "msg4 drop: setup - message 3 accepted, message 4 dropped"); + check(sm.state() == StationSm::State::FourWay && !sm.keyed() && + !sm.connected(), + "msg4 drop: the station stays in FourWay, not Connected"); + + if (variant == 0 || variant == 1) { + while (sm.pop_tx(&f)) { + } + /* 0: the AP's retransmission at the SAME counter - answered from the + * cached message 4 (Retransmit). + * 1: hostapd's shape, a retransmission at a GREATER counter - a fresh + * Reply under the installed PTK, which must not reinstall. */ + const uint32_t pg = sm.supplicant().ptk_generation(); + const std::vector again = + variant == 0 ? m3 : ap.eapol_frame(ap.msg3()); + sm.on_rx(again.data(), again.size(), 200); + check(sm.state() == StationSm::State::Connected && sm.keyed(), + variant == 0 + ? "msg4 drop: an equal-counter msg3 retransmission queues " + "message 4 and THAT promotes to Connected" + : "msg4 drop: a greater-counter msg3 retransmission queues " + "message 4 and THAT promotes to Connected"); + check(sm.supplicant().ptk_generation() == pg, + "msg4 drop: ...without reinstalling the PTK"); + check(sm.pop_tx(&f) && ap.respond(f).empty() && ap.saw_msg4, + "msg4 drop: ...and that message 4 verifies at the AP"); + } else { + /* 2: the queue never drains. Retransmissions are answered and dropped + * too, none of it moves the deadline, and the give-up fires. */ + sm.on_rx(m3.data(), m3.size(), 1000); + sm.on_rx(m3.data(), m3.size(), 2000); + check(sm.state() == StationSm::State::FourWay, + "msg4 drop: under a full queue it stays in FourWay"); + sm.tick(StationSm::kHandshakeTimeoutMs); + check(sm.state() == StationSm::State::Failed && + sm.fail_reason() == StationSm::Failure::HandshakeTimeout, + "msg4 drop: ...until the handshake timeout ends it"); + } + } +} + +/* THE CHANNEL OF A BEACON WITHOUT A DS ELEMENT. Many 5 GHz beacons carry no + * DS Parameter Set; an entry left at channel 0 would be joined with the + * 2.4 GHz rate set, which a 5 GHz AP refuses. The channel it was received on + * is used instead, and with no channel at all the entry is not offered. */ +void test_5ghz_beacon_without_ds_uses_the_rx_channel() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + FixtureAp ap; + uint8_t snonce[32]; + + std::memset(snonce, 0x7a, 32); + const std::vector b = beacon(kBssid, 36, true, /*ds=*/false); + const BssEntry* seen = table.observe(b.data(), b.size(), -40, 36, 0); + check(seen && seen->info.channel == 36, + "5 GHz no-DS: the entry takes the channel it was received on"); + const BssEntry* bss = table.select(kSsid); + check(bss && bss->info.channel == 36, "5 GHz no-DS: ...and is selectable"); + if (!bss) return; + + sm.configure(crypto, kSsid, kPsk, kOwn); + sm.join(*bss, snonce, 0); + pump(sm, ap, 0); + check(sm.channel() == 36 && sm.keyed(), + "5 GHz no-DS: the station joins on channel 36"); + const std::vector& a = ap.last_assoc; + size_t len = 0; + const uint8_t* rates = + a.size() > 28 ? devourer::sta::find_ie(a.data() + 28, a.size() - 28, + devourer::sta::kEidSupportedRates, + &len) + : nullptr; + check(rates && len >= 1 && (rates[0] & 0x7f) == 0x0c, + "5 GHz no-DS: the association request carries the 5 GHz rate set " + "(6 Mbps first, no CCK)"); + check(a.size() > 28 && + !devourer::sta::find_ie(a.data() + 28, a.size() - 28, + devourer::sta::kEidExtSupportedRates, + nullptr), + "5 GHz no-DS: ...and no Extended Supported Rates element"); + + /* No channel from either source: kept, but never offered - and refused if + * a caller hands it to join() anyway. */ + BssTable blind; + blind.observe(b.data(), b.size(), -40, 0, 0); + check(blind.select(kSsid) == nullptr, + "5 GHz no-DS: with no RX channel either, the entry is not selectable"); + const BssEntry* hand = blind.find(kBssid); + StationSm sm2; + sm2.configure(crypto, kSsid, kPsk, kOwn); + check(hand && !sm2.join(*hand, snonce, 0) && + sm2.fail_reason() == StationSm::Failure::NoChannel && + sm2.pending_tx() == 0, + "5 GHz no-DS: ...and join() refuses it if it is hand-picked"); +} + +/* A HAND-PICKED IBSS IS REFUSED. BssTable never offers one; join() is also + * reachable with an entry a caller picked itself, and an infrastructure + * authenticate/associate against an IBSS has no AP to answer it. */ +void test_join_refuses_an_ibss() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + uint8_t snonce[32]; + + std::memset(snonce, 0x7a, 32); + std::vector b = beacon(kBssid, 6); + b[34] = 0x12; /* IBSS | Privacy, no ESS */ + b[35] = 0x00; + table.observe(b.data(), b.size(), -40, 6, 0); + check(table.select(kSsid) == nullptr, "ibss join: not offered by select()"); + const BssEntry* hand = table.find(kBssid); + sm.configure(crypto, kSsid, kPsk, kOwn); + check(hand && !sm.join(*hand, snonce, 0) && + sm.fail_reason() == StationSm::Failure::NotInfrastructure && + sm.pending_tx() == 0 && sm.auth_tx == 0, + "ibss join: a hand-picked IBSS is refused before any frame is queued"); +} + +/* A FAILED RECONFIGURE DOES NOT KEEP THE OLD PMK. pmk_from_psk writes nothing + * when it refuses a passphrase, so configure() must wipe the previous + * network's PMK itself or leave it resident. */ +void test_failed_reconfigure_wipes_the_old_pmk() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + uint8_t snonce[32]; + const uint8_t zero[32] = {0}; + + std::memset(snonce, 0x7a, 32); + check(sm.configure(crypto, kSsid, kPsk, kOwn) && sm.has_pmk() && + std::memcmp(sm.pmk(), zero, 32) != 0, + "reconfigure: setup - a valid passphrase yields a PMK"); + check(!sm.configure(crypto, kSsid, "short", kOwn), + "reconfigure: a 5-character passphrase is refused"); + check(!sm.has_pmk() && std::memcmp(sm.pmk(), zero, 32) == 0, + "reconfigure: ...and the previous PMK is wiped, not kept"); + const BssEntry* bss = discovered(table); + check(bss && !sm.join(*bss, snonce, 0) && + sm.fail_reason() == StationSm::Failure::NoPmk, + "reconfigure: ...and join() still says NoPmk"); +} + +/* ONLY EAPOL-KEY IS CLAIMED. EAP, EAPOL-Start and Logoff share ethertype + * 0x888e, and a key parser would only refuse them. On the decrypted path a + * non-Key packet goes back to the caller; a Key packet is claimed even when + * malformed. The cleartext path has + * no caller to give it back to, so it counts a non-Key packet as ignored + * and never shows it to the supplicant. */ +void test_only_eapol_key_is_claimed() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + FixtureAp ap; + uint8_t snonce[32]; + + std::memset(snonce, 0x7a, 32); + sm.configure(crypto, kSsid, kPsk, kOwn); + discovered(table); + const BssEntry* bss = table.select(kSsid); + if (!bss) { check(false, "beacon"); return; } + sm.join(*bss, snonce, 0); + pump(sm, ap, 0); + if (!sm.keyed()) { check(false, "eapol type: setup - connected"); return; } + + const auto msdu = [](std::initializer_list eapol) { + std::vector m; + devourer::sta::append_llc_snap(m, 0x888e); + m.insert(m.end(), eapol.begin(), eapol.end()); + return m; + }; + const std::vector start = msdu({0x02, 0x01, 0x00, 0x00}); + const std::vector eap = msdu({0x02, 0x00, 0x00, 0x05, + 0x01, 0x01, 0x00, 0x05, 0x01}); + const std::vector bad_key = msdu({0x02, 0x03, 0x00, 0x05, + 0x02, 0x00, 0x8a, 0x00, 0x10}); + const std::vector runt = msdu({0x02}); + const uint32_t mal0 = sm.supplicant().malformed; + std::vector reply = {0xaa}; + + check(!sm.on_decrypted_msdu(start.data(), start.size(), 1, &reply) && + reply.size() == 1, + "eapol type: an EAPOL-Start is handed back to the caller"); + check(!sm.on_decrypted_msdu(eap.data(), eap.size(), 1, &reply), + "eapol type: so is an EAP packet"); + check(!sm.on_decrypted_msdu(runt.data(), runt.size(), 1, &reply), + "eapol type: ...and one too short to carry a packet type"); + check(sm.supplicant().malformed == mal0, + "eapol type: ...none of which reaches the key parser"); + check(sm.on_decrypted_msdu(bad_key.data(), bad_key.size(), 1, &reply) && + reply.empty() && sm.supplicant().malformed == mal0 + 1, + "eapol type: a malformed EAPOL-Key is still claimed, and counted"); + + /* The cleartext path, same packets. */ + const auto from_ap = [&](const std::vector& body) { + std::vector f = devourer::sta::data_hdr_from_ds( + kOwn, kBssid, kBssid, /*protect=*/false, 7); + f.insert(f.end(), body.begin(), body.end()); + return f; + }; + const uint32_t ign0 = sm.rx_ignored, rxm0 = sm.rx_malformed; + const uint32_t mal1 = sm.supplicant().malformed; + const std::vector fs = from_ap(start), fe = from_ap(eap), + fr = from_ap(runt); + sm.on_rx(fs.data(), fs.size(), 2); + sm.on_rx(fe.data(), fe.size(), 2); + sm.on_rx(fr.data(), fr.size(), 2); + check(sm.rx_ignored == ign0 + 2 && sm.rx_malformed == rxm0 + 1 && + sm.supplicant().malformed == mal1, + "eapol type: in the clear, Start and EAP are ignored, a runt is " + "malformed, and the supplicant sees none of them"); + check(sm.keyed(), "eapol type: ...and the link is untouched"); +} + +/* A BARE HEADER IS NOT A BEACON. A Beacon or Probe Response from the BSSID + * without its 12-byte fixed body counts as malformed, not as liveness, and a + * stream of them does not hold off BeaconLost. */ +void test_header_only_beacons_do_not_hold_off_loss() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + FixtureAp ap; + uint8_t snonce[32]; + static const uint8_t bcast[6] = {0xff, 0xff, 0xff, 0xff, 0xff, 0xff}; + + std::memset(snonce, 0x7a, 32); + sm.configure(crypto, kSsid, kPsk, kOwn); + discovered(table); + const BssEntry* bss = table.select(kSsid); + if (!bss) { check(false, "beacon"); return; } + sm.join(*bss, snonce, 0); + pump(sm, ap, 0); + if (!sm.keyed()) { check(false, "bare beacon: setup - connected"); return; } + + const std::vector bare = + devourer::sta::mgmt_hdr(devourer::sta::kFcBeacon, bcast, kBssid, kBssid); + const uint32_t seen0 = sm.beacons_rx, mal0 = sm.rx_malformed; + uint32_t t = 0; + for (; t <= sm.beacon_loss_ms() + 100; t += 100) { + sm.on_rx(bare.data(), bare.size(), t); + sm.tick(t); + } + check(sm.beacons_rx == seen0 && sm.rx_malformed > mal0, + "bare beacon: a 24-byte beacon is counted malformed, not as a beacon"); + check(sm.state() == StationSm::State::Failed && + sm.fail_reason() == StationSm::Failure::BeaconLost, + "bare beacon: ...and a stream of them does not prevent BeaconLost"); +} + +/* ONLY AN ASSOCIATION RESPONSE ANSWERS AN ASSOCIATION REQUEST. This station + * never sends a Reassociation Request, so a success-form Reassociation + * Response must not complete its join. */ +void test_reassoc_resp_does_not_complete_a_join() { + BssTable table; + StationSm sm; + FixtureAp ap; + std::vector f; + + sm.configure_open(kSsid, kOwn); + const BssEntry* bss = discovered(table, 6, /*rsn=*/false); + if (!bss) { check(false, "beacon"); return; } + sm.join(*bss, nullptr, 0); + sm.pop_tx(&f); /* auth request */ + const std::vector ok = ap.respond(f); + sm.on_rx(ok.data(), ok.size(), 0); + check(sm.state() == StationSm::State::Associating, + "reassoc: setup - associating"); + + std::vector r = ap.mgmt(devourer::sta::kFcReassocResp); + devourer::sta::put_le16(r, 0x0001); /* capability: ESS */ + devourer::sta::put_le16(r, 0); /* status: success */ + devourer::sta::put_le16(r, 0xc000 | 5); /* AID 5 */ + const uint32_t ign0 = sm.rx_ignored; + sm.on_rx(r.data(), r.size(), 1); + check(sm.state() == StationSm::State::Associating && sm.aid() == 0 && + sm.rx_ignored == ign0 + 1, + "reassoc: a success-form Reassociation Response is ignored"); + + pump(sm, ap, 2); + check(sm.state() == StationSm::State::Connected && sm.aid() == ap.aid, + "reassoc: ...and the real Association Response still completes it"); +} + +/* A HAND-PICKED RSN BSS WITH PRIVACY CLEAR IS REFUSED, as select() refuses + * it. */ +void test_join_refuses_rsn_without_privacy() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + uint8_t snonce[32]; + + std::memset(snonce, 0x7a, 32); + std::vector b = beacon(kBssid, 6); + b[34] = 0x01; /* ESS only: no Privacy */ + b[35] = 0x00; + table.observe(b.data(), b.size(), -40, 6, 0); + const BssEntry* hand = table.find(kBssid); + sm.configure(crypto, kSsid, kPsk, kOwn); + check(hand && hand->info.rsn_ccmp_psk && table.select(kSsid) == nullptr, + "privacy join: setup - RSN present, not selectable"); + check(hand && !sm.join(*hand, snonce, 0) && + sm.fail_reason() == StationSm::Failure::AssocRefused && + sm.pending_tx() == 0, + "privacy join: a hand-picked one is refused before any frame"); +} + +/* DATA FROM THE AP IS LIVENESS TOO. Under load a promiscuous receiver drops + * beacons first; a Connected station receiving its downlink must not be + * declared lost for want of them. Three windows of traffic and no beacons: + * protected data frames through on_rx, then decrypted MSDUs through + * on_decrypted_msdu. Then silence, and the loss still fires. */ +void test_data_keeps_the_link_alive() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + FixtureAp ap; + uint8_t snonce[32]; + + std::memset(snonce, 0x7a, 32); + sm.configure(crypto, kSsid, kPsk, kOwn); + discovered(table); + const BssEntry* bss = table.select(kSsid); + if (!bss) { check(false, "beacon"); return; } + sm.join(*bss, snonce, 0); + pump(sm, ap, 0); + if (!sm.keyed()) { check(false, "liveness: setup - connected"); return; } + + const uint32_t w = sm.beacon_loss_ms(); + std::vector prot = devourer::sta::data_hdr_from_ds( + kOwn, kBssid, kBssid, /*protect=*/true, 9); + prot.insert(prot.end(), 40, 0x5a); /* opaque ciphertext */ + std::vector msdu; + devourer::sta::append_llc_snap(msdu, 0x0800); + msdu.insert(msdu.end(), 20, 0x45); + + uint32_t t = 0; + for (; t <= 3 * w; t += 100) { + if (t < (3 * w) / 2) { + sm.on_rx(prot.data(), prot.size(), t); + } else { + std::vector reply; + sm.on_decrypted_msdu(msdu.data(), msdu.size(), t, &reply); + } + sm.tick(t); + } + check(sm.state() == StationSm::State::Connected, + "liveness: three loss windows of downlink data and no beacons: still " + "Connected"); + + /* The control: nothing at all from the AP, and the loss fires. */ + sm.tick(t + w); + check(sm.state() == StationSm::State::Failed && + sm.fail_reason() == StationSm::Failure::BeaconLost, + "liveness: ...and with nothing at all from the AP, BeaconLost"); +} + +/* LEAVE SENDS A DEAUTH ONLY IF THE AP HOLDS STATE FOR US. After an + * AuthTimeout the AP never accepted an authentication, so there is nothing to + * deauthenticate; after an AssocTimeout it did, and the deauth clears it. + * After the AP's own deauth there is nothing left either. */ +void test_leave_after_a_timeout() { + OpenSslCryptoOps crypto; + uint8_t snonce[32]; + std::vector f; + std::memset(snonce, 0x7a, 32); + + { + BssTable table; + StationSm sm; + sm.configure(crypto, kSsid, kPsk, kOwn); + const BssEntry* bss = discovered(table); + if (!bss) { check(false, "beacon"); return; } + sm.join(*bss, snonce, 0); + for (uint32_t t = 0; t <= 4 * StationSm::kMgmtTimeoutMs; + t += StationSm::kMgmtTimeoutMs) + sm.tick(t); + check(sm.fail_reason() == StationSm::Failure::AuthTimeout, + "leave after timeout: setup - AuthTimeout"); + sm.leave(); + check(sm.pending_tx() == 0, + "leave after timeout: no deauth after an AuthTimeout (never " + "authenticated)"); + } + { + BssTable table; + StationSm sm; + FixtureAp ap; + sm.configure(crypto, kSsid, kPsk, kOwn); + const BssEntry* bss = discovered(table); + if (!bss) { check(false, "beacon"); return; } + sm.join(*bss, snonce, 0); + sm.pop_tx(&f); + const std::vector r = ap.respond(f); + sm.on_rx(r.data(), r.size(), 0); /* authenticated */ + for (uint32_t t = 0; t <= 4 * StationSm::kMgmtTimeoutMs; + t += StationSm::kMgmtTimeoutMs) + sm.tick(t); + check(sm.fail_reason() == StationSm::Failure::AssocTimeout, + "leave after timeout: setup - AssocTimeout"); + sm.leave(); + check(sm.pop_tx(&f) && f[0] == devourer::sta::kFcDeauth && + !sm.pop_tx(&f), + "leave after timeout: one deauth after an AssocTimeout (the AP " + "holds our authentication)"); + } + { + BssTable table; + StationSm sm; + FixtureAp ap; + sm.configure(crypto, kSsid, kPsk, kOwn); + discovered(table); + const BssEntry* bss = table.select(kSsid); + if (!bss) { check(false, "beacon"); return; } + sm.join(*bss, snonce, 0); + pump(sm, ap, 0); + std::vector d = devourer::sta::build_deauth(kOwn, kBssid, 3); + std::memcpy(d.data() + 4, kOwn, 6); + std::memcpy(d.data() + 10, kBssid, 6); + std::memcpy(d.data() + 16, kBssid, 6); + sm.on_rx(d.data(), d.size(), 1); + sm.leave(); + check(sm.pending_tx() == 0, + "leave after timeout: no deauth back after the AP's own deauth"); + } +} + +/* Connected to the fixture AP after a real four-way. */ +bool connected_to(StationSm& sm, FixtureAp& ap, BssTable& table, + OpenSslCryptoOps& crypto) { + uint8_t snonce[32]; + std::memset(snonce, 0x7a, 32); + sm.configure(crypto, kSsid, kPsk, kOwn); + discovered(table); + const BssEntry* bss = table.select(kSsid); + if (!bss) return false; + sm.join(*bss, snonce, 0); + pump(sm, ap, 0); + return sm.keyed(); +} + +/* ONCE KEYED, THE CLEAR CARRIES ONE EAPOL-KEY MESSAGE ONLY: a retransmission + * of the installed handshake's message 3 (the AP sends it in the clear when + * our message 4 is lost, because it installs its PTK only on receiving it). + * A group message 1 in the clear is never taken, and a message 3 with a new + * ANonce - a rekey, which the AP protects - is ignored. */ +void test_cleartext_eapol_after_keying() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + FixtureAp ap; + if (!connected_to(sm, ap, table, crypto)) { + check(false, "cleartext eapol: setup - connected"); + return; + } + const uint32_t gg = sm.supplicant().gtk_generation(); + const uint32_t pg = sm.supplicant().ptk_generation(); + const uint32_t ign0 = sm.rx_ignored, rx0 = sm.eapol_rx; + + uint8_t gtk2[16]; + std::memset(gtk2, 0x62, 16); + const std::vector g1 = ap.eapol_frame(ap.group1(gtk2, 2)); + sm.on_rx(g1.data(), g1.size(), 1); + check(sm.pending_tx() == 0 && sm.eapol_rx == rx0 && + sm.rx_ignored == ign0 + 1 && + sm.supplicant().gtk_generation() == gg && + std::memcmp(sm.supplicant().gtk(), ap.gtk, 16) == 0, + "cleartext eapol: a group message 1 in the clear is not answered, and " + "the GTK is unchanged"); + + uint8_t anonce[32]; + std::memcpy(anonce, ap.anonce, 32); + std::memset(ap.anonce, 0x4d, 32); /* a rekey's new ANonce */ + const std::vector m3new = ap.eapol_frame(ap.msg3()); + sm.on_rx(m3new.data(), m3new.size(), 2); + check(sm.pending_tx() == 0 && sm.eapol_rx == rx0 && + sm.rx_ignored == ign0 + 2 && + sm.supplicant().ptk_generation() == pg, + "cleartext eapol: a message 3 with a new ANonce after Connected is " + "ignored"); + + std::memcpy(ap.anonce, anonce, 32); /* the installed handshake */ + const std::vector m3again = ap.eapol_frame(ap.msg3()); + sm.on_rx(m3again.data(), m3again.size(), 3); + std::vector f; + ap.saw_msg4 = false; + check(sm.pop_tx(&f) && ap.respond(f).empty() && ap.saw_msg4 && + sm.supplicant().ptk_generation() == pg && sm.keyed(), + "cleartext eapol: ...while a retransmission of the installed message " + "3 is answered, with no reinstall"); +} + +/* A JOIN ON A LIVE ASSOCIATION DEAUTHENTICATES THE OLD AP FIRST, and a join + * is held to the configured SSID, as select() is. */ +void test_join_on_a_live_association() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + FixtureAp ap; + uint8_t snonce[32]; + std::memset(snonce, 0x7a, 32); + if (!connected_to(sm, ap, table, crypto)) { + check(false, "live join: setup - connected"); + return; + } + + const uint8_t kBssid2[6] = {0x02, 0x42, 0x75, 0x05, 0xd6, 0x01}; + const std::vector b2 = beacon(kBssid2, 6); + table.observe(b2.data(), b2.size(), -40, 6, 0); + const BssEntry* ap2 = table.find(kBssid2); + if (!ap2) { check(false, "live join: setup - second AP"); return; } + check(sm.join(*ap2, snonce, 10), "live join: the join starts"); + std::vector f; + check(sm.pop_tx(&f) && f[0] == devourer::sta::kFcDeauth && + std::memcmp(f.data() + 4, kBssid, 6) == 0 && + std::memcmp(f.data() + 16, kBssid, 6) == 0, + "live join: the first frame out is a deauth to the OLD AP"); + check(sm.pop_tx(&f) && f[0] == devourer::sta::kFcAuth && + std::memcmp(f.data() + 4, kBssid2, 6) == 0 && !sm.pop_tx(&f), + "live join: ...then the authentication request to the new one"); + + StationSm fresh; + fresh.configure(crypto, kSsid, kPsk, kOwn); + BssEntry other = *ap2; + other.info.ssid = "not-this-network"; + check(!fresh.join(other, snonce, 0) && + fresh.fail_reason() == StationSm::Failure::SsidMismatch && + fresh.pending_tx() == 0, + "live join: an entry with another SSID is refused before any frame"); +} + +/* THE BEACON INTERVAL THAT WIDENS THE LOSS WINDOW IS CAPPED. It comes off + * the air; 65535 TU would stretch the window to about 11 minutes. */ +void test_beacon_interval_is_capped() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + uint8_t snonce[32]; + std::memset(snonce, 0x7a, 32); + std::vector b = beacon(kBssid, 6); + b[32] = 0xff; /* beacon interval 65535 TU */ + b[33] = 0xff; + const BssEntry* bss = table.observe(b.data(), b.size(), -40, 6, 0); + if (!bss) { check(false, "beacon"); return; } + sm.configure(crypto, kSsid, kPsk, kOwn); + sm.join(*bss, snonce, 0); + check(sm.beacon_loss_ms() == + StationSm::kMaxBeaconIntervalTu * 1024u * 10u / 1000u, + "interval cap: 65535 TU gives the capped 10240 ms window, not minutes"); +} + +/* pop_tx(nullptr) REFUSES rather than dropping a frame and saying it + * delivered one. */ +void test_pop_tx_refuses_null() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + uint8_t snonce[32]; + std::memset(snonce, 0x7a, 32); + sm.configure(crypto, kSsid, kPsk, kOwn); + const BssEntry* bss = discovered(table); + if (!bss) { check(false, "beacon"); return; } + sm.join(*bss, snonce, 0); + check(!sm.pop_tx(nullptr) && sm.pending_tx() == 1, + "pop_tx(nullptr): refused, and the frame stays queued"); +} + +/* A TRUNCATED AUTH OR ASSOC RESPONSE IS COUNTED MALFORMED, like a short + * deauth, and changes nothing. */ +void test_truncated_auth_and_assoc_are_malformed() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + FixtureAp ap; + uint8_t snonce[32]; + std::vector f; + std::memset(snonce, 0x7a, 32); + sm.configure(crypto, kSsid, kPsk, kOwn); + const BssEntry* bss = discovered(table); + if (!bss) { check(false, "beacon"); return; } + sm.join(*bss, snonce, 0); + + std::vector short_auth = ap.mgmt(devourer::sta::kFcAuth); + devourer::sta::put_le16(short_auth, 0); /* 2 of the 6 body bytes */ + uint32_t m0 = sm.rx_malformed; + sm.on_rx(short_auth.data(), short_auth.size(), 0); + check(sm.rx_malformed == m0 + 1 && + sm.state() == StationSm::State::Authenticating, + "truncated: a short authentication response is malformed"); + + sm.pop_tx(&f); + const std::vector ok = ap.respond(f); + sm.on_rx(ok.data(), ok.size(), 0); /* now associating */ + std::vector short_assoc = ap.mgmt(devourer::sta::kFcAssocResp); + devourer::sta::put_le16(short_assoc, 0x0011); /* 2 of the 6 body bytes */ + m0 = sm.rx_malformed; + sm.on_rx(short_assoc.data(), short_assoc.size(), 0); + check(sm.rx_malformed == m0 + 1 && + sm.state() == StationSm::State::Associating, + "truncated: ...and so is a short association response"); +} + +/* A QoS NULL IS WELL FORMED. It has no body by definition, so it is ignored + * rather than malformed - and it is still the AP talking, so it keeps the + * link alive. */ +void test_qos_null_is_ignored_and_alive() { + OpenSslCryptoOps crypto; + BssTable table; + StationSm sm; + FixtureAp ap; + if (!connected_to(sm, ap, table, crypto)) { + check(false, "qos null: setup - connected"); + return; + } + std::vector qn = devourer::sta::data_hdr_from_ds( + kOwn, kBssid, kBssid, /*protect=*/false, 5); + qn[0] = 0xc8; /* QoS Null */ + qn.insert(qn.begin() + 24, {0x00, 0x00}); /* QoS Control */ + const uint32_t ign0 = sm.rx_ignored, mal0 = sm.rx_malformed; + sm.on_rx(qn.data(), qn.size(), 1); + check(sm.rx_ignored == ign0 + 1 && sm.rx_malformed == mal0, + "qos null: a 26-byte QoS Null is ignored, not malformed"); + + const uint32_t w = sm.beacon_loss_ms(); + for (uint32_t t = 100; t <= 2 * w; t += 100) { + sm.on_rx(qn.data(), qn.size(), t); + sm.tick(t); + } + check(sm.state() == StationSm::State::Connected, + "qos null: ...and a stream of them keeps the link alive"); +} + +int main() { + test_cleartext_eapol_after_keying(); + test_join_on_a_live_association(); + test_beacon_interval_is_capped(); + test_pop_tx_refuses_null(); + test_truncated_auth_and_assoc_are_malformed(); + test_qos_null_is_ignored_and_alive(); + test_data_keeps_the_link_alive(); + test_leave_after_a_timeout(); + test_header_only_beacons_do_not_hold_off_loss(); + test_reassoc_resp_does_not_complete_a_join(); + test_join_refuses_rsn_without_privacy(); + test_join_refuses_an_ibss(); + test_failed_reconfigure_wipes_the_old_pmk(); + test_only_eapol_key_is_claimed(); + test_dropped_msg4_does_not_connect(); + test_5ghz_beacon_without_ds_uses_the_rx_channel(); + test_leave_drops_stale_frames(); + test_peer_deauth_drops_the_association_keys(); + test_dropped_reply_does_not_move_the_deadline(); + test_full_association(); + test_auth_timeout(); + test_auth_refused(); + test_assoc_refused(); + test_assoc_refused_with_a_plausible_aid(); + test_assoc_success_with_zero_aid(); + test_deauth_during_handshake(); + test_short_deauth_is_malformed(); + test_group_rekey_through_the_decrypted_path(); + test_frames_from_elsewhere_are_ignored(); + test_protected_eapol_ignored(); + test_protected_data_is_counted_apart(); + test_fragmented_and_amsdu_eapol_are_refused(); + test_handshake_timeout(); + test_retransmission_does_not_extend_the_deadline(); + test_join_clears_the_transmit_queue(); + test_transmit_queue_is_bounded(); + test_beacon_loss(); + test_leave(); + test_rx_counters(); + test_join_refuses_an_unusable_bss(); + test_no_pmk(); + test_open_association(); + test_open_station_refuses_a_protected_bss(); + test_open_station_ignores_eapol(); + test_wpa2_join_needs_an_snonce(); + test_broadcast_eapol_is_not_fed_to_the_supplicant(); + test_beacon_loss_follows_the_interval(); + test_reconfigure_forgets_the_keys(); + test_rsn_downgrade_is_refused(); + test_reconfigure_drops_queued_frames(); + + if (g_fail) { + std::printf("station_sm_selftest: %d failure(s)\n", g_fail); + return 1; + } + std::printf("station_sm_selftest: OK\n"); + return 0; +} diff --git a/tests/supplicant_selftest.cpp b/tests/supplicant_selftest.cpp new file mode 100644 index 00000000..3101f85a --- /dev/null +++ b/tests/supplicant_selftest.cpp @@ -0,0 +1,2101 @@ +/* Headless guard for src/sta/Eapol.h and src/sta/Supplicant.h — the station + * half of the WPA2-PSK key exchange. + * + * THE SUPPLICANT'S ACCEPTANCE IS TWO NEGATIVE CASES, named here so that + * a future edit that deletes them has to delete a stated requirement: + * + * test_forged_mic_is_rejected() — a forged EAPOL-Key MIC + * test_group_rekey_replay_rejected() — an equal-counter group rekey + * + * Either defect hands an attacker key material: a forged MIC installs keys + * nobody authenticated, and an equal-counter group rekey reinstalls an old + * GTK with its PN space reset. A supplicant that cannot fail these two tests + * has not been tested. + * + * WHAT IS AND IS NOT INDEPENDENT HERE. The authenticator below is a fixture, + * and it builds its frames with the same `build_eapol_key` the supplicant + * parses — so this file pins the STATE MACHINE and the CHECKS, not the wire + * format. Three things cover the format instead: + * + * - the IEEE 802.11i Annex H.4.2 vectors at the top, which cover PBKDF2 + * AND NOTHING ELSE - not the EAPOL-Key format. + * - test_against_a_real_four_way(), which replays the four EAPOL-Key frames + * HOSTAPD AND WPA_SUPPLICANT actually exchanged and checks our PTK + * against the one wpa_supplicant derived. That is the PRF, the address + * and nonce sorting, the MIC and the GTK KDE layout, all pinned against + * software that has never read this repository. + * - NOT a cross-role cell against the AP harness's authenticator, which + * hand-rolls every offset: that harness has no headless tests in this + * tree, so the hostapd replay above is the independent format check. + */ +#include +#include +#include +#include +#include + +#include "eapol_kernel_vectors.h" +#include "openssl_crypto_ops.h" +#include "sta/Eapol.h" +#include "sta/Supplicant.h" + +namespace { + +using devourer::sta::EapolKey; +using devourer::sta::Supplicant; +using devourer::sta::MicCheck; +using devourer::test::OpenSslCryptoOps; + +int g_fail = 0; + +void check(bool ok, const char* what) { + if (!ok) { + std::printf("FAIL: %s\n", what); + g_fail++; + } +} + +const uint8_t kAa[6] = {0x02, 0x42, 0x75, 0x05, 0xd6, 0x00}; /* the AP */ +const uint8_t kSpa[6] = {0x02, 0x11, 0x22, 0x33, 0x44, 0x01}; /* the station */ +const char* kPsk = "devourer123"; +const char* kSsid = "devourerAP"; + +/* ---- known answers ------------------------------------------------------ + * + * Hand-rolled or not, a PBKDF2 or PRF with no known-answer test can be wrong + * in a way no round-trip notices. These are the IEEE 802.11i Annex H.4.2 + * passphrase-to-PSK vectors — + * published, widely reproduced, and recomputed with Python's hashlib before + * being written down here, so they are not this repository's own arithmetic + * repeated back at itself. */ +struct PskVector { + const char* passphrase; + const char* ssid; + const char* pmk_hex; +}; +const PskVector kPskVectors[] = { + {"password", "IEEE", + "f42c6fc52df0ebef9ebb4b90b38a5f902e83fe1b135a70e23aed762e9710a12e"}, + {"ThisIsAPassword", "ThisIsASSID", + "0dc0d6eb90555ed6419756b9a15ec3e3209b63df707dd508d14581f8982721af"}, + {"aaaaaaaaaa", "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ", + "727c76be68d32158bed0787c002aaf97ac38a34d11da8be1afdcf3e96965346d"}, +}; + +std::vector unhex(const char* h) { + std::vector v; + for (size_t i = 0; h[i] && h[i + 1]; i += 2) { + auto nib = [](char c) -> int { + return c >= 'a' ? c - 'a' + 10 : (c >= 'A' ? c - 'A' + 10 : c - '0'); + }; + v.push_back((uint8_t)((nib(h[i]) << 4) | nib(h[i + 1]))); + } + return v; +} + +void test_psk_known_answers() { + OpenSslCryptoOps c; + + for (const PskVector& v : kPskVectors) { + uint8_t pmk[32]; + const std::vector want = unhex(v.pmk_hex); + char label[96]; + + std::snprintf(label, sizeof label, "PSK vector '%s' / '%s'", v.passphrase, + v.ssid); + check(devourer::sta::pmk_from_psk(c, v.passphrase, v.ssid, pmk), label); + check(want.size() == 32 && std::memcmp(pmk, want.data(), 32) == 0, label); + } + + /* The SSID is the SALT, which is why the same passphrase on two networks + * does not share a PMK. Without this, dropping the salt entirely would pass + * every symmetry test in the file. */ + uint8_t a[32], b[32]; + devourer::sta::pmk_from_psk(c, "password", "IEEE", a); + devourer::sta::pmk_from_psk(c, "password", "IEEF", b); + check(std::memcmp(a, b, 32) != 0, "the SSID is part of the derivation"); + + /* Refusals: an SSID must be 1..32 octets. */ + check(!devourer::sta::pmk_from_psk(c, "password", "", a), + "an empty SSID is refused"); + check(!devourer::sta::pmk_from_psk(c, "password", std::string(33, 'x'), a), + "a 33-octet SSID is refused"); + + /* THE OTHER SPELLING: exactly 64 hex digits ARE the PMK (802.11-2016 + * J.4.1; hostapd wpa_psk=, wpa_supplicant psk=). Known answer: the first + * Annex H vector's PMK, given raw, must come back byte for byte and must + * NOT be the PBKDF2 of those 64 characters. */ + const char* hex = kPskVectors[0].pmk_hex; + const std::vector want = unhex(hex); + std::memset(a, 0, sizeof a); + check(devourer::sta::pmk_from_psk(c, hex, "IEEE", a) && + std::memcmp(a, want.data(), 32) == 0, + "a 64-hex-digit PSK is decoded as the raw PMK"); + std::string upper(hex); + for (char& ch : upper) + if (ch >= 'a' && ch <= 'f') ch = (char)(ch - 'a' + 'A'); + std::memset(b, 0, sizeof b); + check(devourer::sta::pmk_from_psk(c, upper.c_str(), "other", b) && + std::memcmp(b, want.data(), 32) == 0, + "...in either case, and independent of the SSID"); + std::string bad(hex); + bad[10] = 'g'; + check(!devourer::sta::pmk_from_psk(c, bad.c_str(), "IEEE", a), + "64 characters that are not all hex are refused"); + + /* And a passphrase must be 8..63 characters. */ + check(!devourer::sta::pmk_from_psk(c, "1234567", "IEEE", a), + "a 7-character passphrase is refused"); + check(devourer::sta::pmk_from_psk(c, std::string(63, 'p').c_str(), "IEEE", + a), + "a 63-character passphrase is accepted"); + check(!devourer::sta::pmk_from_psk(c, std::string(65, 'a').c_str(), "IEEE", + a), + "a 65-character passphrase is refused"); +} + +/* The PTK derivation's address and nonce sorting, asserted directly. Both + * ends derive the same key ONLY because each sorts the pair the same way, and + * each end knows them by opposite names. */ +void test_ptk_sorting() { + OpenSslCryptoOps c; + uint8_t pmk[32], n1[32], n2[32], p1[48], p2[48], p3[48]; + + std::memset(pmk, 0x11, 32); + std::memset(n1, 0xa0, 32); + std::memset(n2, 0xb0, 32); + + check(devourer::sta::derive_ptk(c, pmk, kAa, kSpa, n1, n2, p1), "derive"); + check(devourer::sta::derive_ptk(c, pmk, kSpa, kAa, n1, n2, p2), "derive"); + check(std::memcmp(p1, p2, 48) == 0, + "swapping the two addresses derives the SAME PTK"); + check(devourer::sta::derive_ptk(c, pmk, kAa, kSpa, n2, n1, p3), "derive"); + check(std::memcmp(p1, p3, 48) == 0, + "swapping the two nonces derives the SAME PTK"); + + /* And it is not simply constant: a different nonce is a different key. */ + uint8_t n3[32], p4[48]; + std::memset(n3, 0xc0, 32); + devourer::sta::derive_ptk(c, pmk, kAa, kSpa, n1, n3, p4); + check(std::memcmp(p1, p4, 48) != 0, "a different nonce is a different PTK"); + + /* The three sections must not alias: KCK, KEK and TK are different bytes. + * A PRF that produced 48 identical bytes would pass every test above. */ + check(std::memcmp(p1, p1 + 16, 16) != 0 && + std::memcmp(p1 + 16, p1 + 32, 16) != 0, + "KCK, KEK and TK differ"); +} + +/* ---- the fixture authenticator ----------------------------------------- + * + * Enough of an authenticator to drive a supplicant through everything it + * implements, and no more: no retransmission schedule, no station table. + */ +struct Authenticator { + OpenSslCryptoOps crypto; + uint8_t pmk[32] = {0}; + uint8_t anonce[32] = {0}; + uint8_t ptk[48] = {0}; + uint8_t gtk[16] = {0}; + uint8_t gtk_keyid = 1; + uint64_t replay = 0; + bool have_ptk = false; + /* The RSN element message 3 carries: empty = the advertised one + * (append_rsn_ccmp_psk); `rsn_omit` leaves it out altogether. For the + * downgrade-check cells. */ + std::vector rsn_override; + bool rsn_omit = false; + + void init() { + devourer::sta::pmk_from_psk(crypto, kPsk, kSsid, pmk); + std::memset(anonce, 0x5e, 32); + std::memset(gtk, 0x31, 16); + } + + 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); + } + + /* Derive from the supplicant's SNonce, then verify its MIC. */ + bool on_msg2(const std::vector& e) { + EapolKey k; + + if (!devourer::sta::parse_eapol_key(e.data(), e.size(), &k)) return false; + if (!devourer::sta::derive_ptk(crypto, pmk, kAa, kSpa, anonce, k.nonce, + ptk)) + return false; + if (devourer::sta::eapol_mic_ok(crypto, ptk, k) != MicCheck::Ok) + return false; + have_ptk = true; + return true; + } + + /* Key data: the RSN element then a GTK KDE, 802.11i-padded, AES-wrapped. */ + std::vector wrapped_gtk(const uint8_t* key, uint8_t keyid) { + std::vector kd; + if (!rsn_override.empty()) + kd = rsn_override; + else if (!rsn_omit) + devourer::sta::append_rsn_ccmp_psk(kd); + const uint8_t hdr[8] = {0xdd, 0x16, 0x00, 0x0f, 0xac, 0x01, keyid, 0x00}; + kd.insert(kd.end(), hdr, hdr + 8); + kd.insert(kd.end(), key, key + 16); + 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 = OpenSslCryptoOps::key_wrap(ptk + 16, 16, kd.data(), + kd.size(), w.data()); + w.resize(n > 0 ? (size_t)n : 0); + return w; + } + + std::vector msg3() { + const std::vector w = wrapped_gtk(gtk, gtk_keyid); + + 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); + } + + bool on_msg4(const std::vector& e) { + EapolKey k; + + if (!devourer::sta::parse_eapol_key(e.data(), e.size(), &k)) return false; + return devourer::sta::eapol_mic_ok(crypto, ptk, k) == MicCheck::Ok; + } + + /* Group key handshake message 1, at a chosen replay counter so a test can + * hand the supplicant one it has already seen. */ + std::vector group1(const uint8_t* key, uint8_t keyid, + uint64_t at_replay, + const uint8_t* rsc = nullptr) { + const std::vector w = wrapped_gtk(key, keyid); + + return devourer::sta::build_eapol_key( + devourer::sta::kKeyDescVersionCcmp | devourer::sta::kKiAck | + devourer::sta::kKiMic | devourer::sta::kKiSecure | + devourer::sta::kKiEncrypted, + 16, at_replay, nullptr, rsc, w.data(), w.size(), &crypto, ptk); + } + + std::vector group1_next(const uint8_t* key, uint8_t keyid, + const uint8_t* rsc = nullptr) { + replay++; + return group1(key, keyid, replay, rsc); + } +}; + +/* Run a complete four-way. Leaves both sides keyed. */ +bool handshake(Authenticator& ap, Supplicant& sup, OpenSslCryptoOps& crypto) { + uint8_t snonce[32]; + std::vector out; + + std::memset(snonce, 0x7a, 32); + ap.init(); + sup.start(crypto, ap.pmk, kSpa, kAa, snonce); + + const std::vector m1 = ap.msg1(); + if (sup.on_eapol(m1.data(), m1.size(), &out) != Supplicant::Verdict::Reply) + return false; + if (!ap.on_msg2(out)) return false; + + const std::vector m3 = ap.msg3(); + if (sup.on_eapol(m3.data(), m3.size(), &out) != Supplicant::Verdict::Reply) + return false; + return ap.on_msg4(out); +} + +/* ---- the downgrade check (802.11-2016 12.7.6.4) --------------------------- + * + * Message 3 carries the AP's RSN element under the MIC and the KEK; the one + * the station chose the BSS by came off the air unauthenticated. A station + * given the advertisement must refuse a message 3 whose element differs - + * and must refuse it BEFORE installing anything. */ +/* `element` is a whole RSN element, EID and length included; empty means the + * canonical one-of-each append_rsn_ccmp_psk. */ +devourer::sta::RsnInfo advertised_rsn(const std::vector& element = {}) { + std::vector e = element; + if (e.empty()) devourer::sta::append_rsn_ccmp_psk(e); + devourer::sta::RsnInfo r; + devourer::sta::parse_rsn(e.data() + 2, e.size() - 2, &r); + return r; +} + +/* One four-way against `ap` with the advertisement passed to start(); + * returns the message 3 verdict. */ +Supplicant::Verdict four_way_with_advert(Authenticator& ap, Supplicant& sup, + OpenSslCryptoOps& crypto, + const std::vector& advert = {}) { + uint8_t snonce[32]; + std::vector out; + std::memset(snonce, 0x7a, 32); + ap.init(); + const devourer::sta::RsnInfo adv = advertised_rsn(advert); + sup.start(crypto, ap.pmk, kSpa, kAa, snonce, &adv); + const std::vector m1 = ap.msg1(); + if (sup.on_eapol(m1.data(), m1.size(), &out) != Supplicant::Verdict::Reply || + !ap.on_msg2(out)) + return Supplicant::Verdict::CryptoError; + const std::vector m3 = ap.msg3(); + return sup.on_eapol(m3.data(), m3.size(), &out); +} + +void test_rsne_downgrade_check() { + { + Authenticator ap; + Supplicant sup; + OpenSslCryptoOps crypto; + check(four_way_with_advert(ap, sup, crypto) == Supplicant::Verdict::Reply && + sup.rsn_mismatches == 0 && sup.ptk_generation() == 1, + "a message 3 carrying the ADVERTISED RSN element completes the four-way"); + } + { + Authenticator ap; + Supplicant sup; + OpenSslCryptoOps crypto; + /* The real AP offers TKIP as well as CCMP; the advertisement the station + * saw had been rewritten to CCMP alone. */ + ap.rsn_override = {0x30, 0x18, 0x01, 0x00, 0x00, 0x0f, 0xac, 0x04, + 0x02, 0x00, 0x00, 0x0f, 0xac, 0x02, 0x00, 0x0f, + 0xac, 0x04, 0x01, 0x00, 0x00, 0x0f, 0xac, 0x02, + 0x00, 0x00}; + const uint32_t pg = sup.ptk_generation(), gg = sup.gtk_generation(); + check(four_way_with_advert(ap, sup, crypto) == Supplicant::Verdict::Malformed && + sup.rsn_mismatches == 1, + "a message 3 whose RSN element DIFFERS from the advertisement is refused"); + check(sup.ptk_generation() == pg && sup.gtk_generation() == gg && !sup.ptk_valid(), + "...and nothing is installed"); + } + { + Authenticator ap; + Supplicant sup; + OpenSslCryptoOps crypto; + ap.rsn_omit = true; + check(four_way_with_advert(ap, sup, crypto) == Supplicant::Verdict::Malformed && + sup.rsn_mismatches == 1 && !sup.ptk_valid(), + "a message 3 with NO RSN element is refused when an advertisement is known"); + } + { + Authenticator ap; + Supplicant sup; + OpenSslCryptoOps crypto; + /* Same suites, different capabilities (MFP capable): still a different + * element. */ + ap.rsn_override = {0x30, 0x14, 0x01, 0x00, 0x00, 0x0f, 0xac, 0x04, + 0x01, 0x00, 0x00, 0x0f, 0xac, 0x04, 0x01, 0x00, + 0x00, 0x0f, 0xac, 0x02, 0x80, 0x00}; + check(four_way_with_advert(ap, sup, crypto) == Supplicant::Verdict::Malformed && + sup.rsn_mismatches == 1, + "a capabilities difference alone is a mismatch"); + } + + /* THE SUITE LISTS, NOT JUST THEIR LENGTHS. Two pairwise suites in each: + * the advertisement offers CCMP + GCMP-128 (00-0F-AC-08), message 3 offers + * CCMP + TKIP (00-0F-AC-02). The counts agree and CCMP is in both, so a + * comparison of presence flags and counts alone would call them equal. */ + const std::vector ccmp_gcmp = { + 0x30, 0x18, 0x01, 0x00, 0x00, 0x0f, 0xac, 0x04, 0x02, 0x00, + 0x00, 0x0f, 0xac, 0x04, 0x00, 0x0f, 0xac, 0x08, /* CCMP, GCMP */ + 0x01, 0x00, 0x00, 0x0f, 0xac, 0x02, 0x00, 0x00}; + const std::vector gcmp_ccmp = { + 0x30, 0x18, 0x01, 0x00, 0x00, 0x0f, 0xac, 0x04, 0x02, 0x00, + 0x00, 0x0f, 0xac, 0x08, 0x00, 0x0f, 0xac, 0x04, /* GCMP, CCMP */ + 0x01, 0x00, 0x00, 0x0f, 0xac, 0x02, 0x00, 0x00}; + const std::vector ccmp_tkip = { + 0x30, 0x18, 0x01, 0x00, 0x00, 0x0f, 0xac, 0x04, 0x02, 0x00, + 0x00, 0x0f, 0xac, 0x04, 0x00, 0x0f, 0xac, 0x02, /* CCMP, TKIP */ + 0x01, 0x00, 0x00, 0x0f, 0xac, 0x02, 0x00, 0x00}; + { + Authenticator ap; + Supplicant sup; + OpenSslCryptoOps crypto; + ap.rsn_override = ccmp_tkip; + check(four_way_with_advert(ap, sup, crypto, ccmp_gcmp) == + Supplicant::Verdict::Malformed && + sup.rsn_mismatches == 1 && !sup.ptk_valid(), + "a different pairwise suite LIST with the same count is a mismatch"); + } + { + Authenticator ap; + Supplicant sup; + OpenSslCryptoOps crypto; + ap.rsn_override = ccmp_gcmp; + check(four_way_with_advert(ap, sup, crypto, ccmp_gcmp) == + Supplicant::Verdict::Reply && + sup.rsn_mismatches == 0 && sup.ptk_valid(), + "a byte-identical two-suite element is accepted"); + } + { + Authenticator ap; + Supplicant sup; + OpenSslCryptoOps crypto; + /* The comparison is of SETS: the standard orders nothing within a + * suite list, and wpa_supplicant's parsed comparison accepts this too. */ + ap.rsn_override = gcmp_ccmp; + check(four_way_with_advert(ap, sup, crypto, ccmp_gcmp) == + Supplicant::Verdict::Reply && + sup.rsn_mismatches == 0 && sup.ptk_valid(), + "the same suites in a different order are accepted"); + } + { + Authenticator ap; + Supplicant sup; + OpenSslCryptoOps crypto; + /* A different AKM with the same count: PSK advertised, PSK-SHA256 + * (00-0F-AC-06) in message 3. */ + ap.rsn_override = {0x30, 0x14, 0x01, 0x00, 0x00, 0x0f, 0xac, 0x04, + 0x01, 0x00, 0x00, 0x0f, 0xac, 0x04, 0x01, 0x00, + 0x00, 0x0f, 0xac, 0x06, 0x00, 0x00}; + check(four_way_with_advert(ap, sup, crypto) == Supplicant::Verdict::Malformed && + sup.rsn_mismatches == 1, + "a different AKM with the same count is a mismatch"); + } + { + Authenticator ap; + Supplicant sup; + OpenSslCryptoOps crypto; + /* A different group cipher: TKIP group, CCMP pairwise. */ + ap.rsn_override = {0x30, 0x14, 0x01, 0x00, 0x00, 0x0f, 0xac, 0x02, + 0x01, 0x00, 0x00, 0x0f, 0xac, 0x04, 0x01, 0x00, + 0x00, 0x0f, 0xac, 0x02, 0x00, 0x00}; + check(four_way_with_advert(ap, sup, crypto) == Supplicant::Verdict::Malformed && + sup.rsn_mismatches == 1, + "a different group cipher is a mismatch"); + } +} + +/* parse_rsn keeps the whole element: the suite sets and the group + * management cipher that follows the PMKID list. */ +void test_parse_rsn_sets() { + /* CCMP group; pairwise {CCMP, GCMP-128}; AKM {PSK, vendor 00-50-F2-01}; + * caps 0x0080; one PMKID; group mgmt BIP-CMAC-128 (00-0F-AC-06). */ + std::vector e = {0x01, 0x00, 0x00, 0x0f, 0xac, 0x04, + 0x02, 0x00, 0x00, 0x0f, 0xac, 0x04, + 0x00, 0x0f, 0xac, 0x08, + 0x02, 0x00, 0x00, 0x0f, 0xac, 0x02, + 0x00, 0x50, 0xf2, 0x01, + 0x80, 0x00, 0x01, 0x00}; + e.insert(e.end(), 16, 0xab); /* the PMKID */ + const uint8_t bip[4] = {0x00, 0x0f, 0xac, 0x06}; + e.insert(e.end(), bip, bip + 4); + devourer::sta::RsnInfo r; + check(devourer::sta::parse_rsn(e.data(), e.size(), &r) && r.valid, + "rsn sets: a full element parses"); + check(r.group_suite == 0x000fac04u, "rsn sets: the group cipher selector"); + check(r.pairwise_mask == ((1u << 4) | (1u << 8)) && !r.pairwise_other, + "rsn sets: both pairwise suites are in the set"); + check(r.akm_mask == (1u << 2) && r.akm_other, + "rsn sets: a vendor AKM sets the other flag, not a bit"); + check(r.capabilities == 0x0080, "rsn sets: capabilities"); + check(r.group_mgmt_suite == 0x000fac06u, + "rsn sets: the group management cipher after the PMKID list"); + + /* A PMKID count that runs past the element is malformed, like the suite + * counts - not silently skipped. */ + std::vector bad(e.begin(), e.begin() + 30); + bad[28] = 0x02; /* two PMKIDs, none present */ + check(!devourer::sta::parse_rsn(bad.data(), bad.size(), &r) && !r.valid, + "rsn sets: an overrunning PMKID count is refused"); + + /* And an element that stops after the capabilities is still valid, with + * no group management cipher: trailing fields may be omitted. */ + std::vector shorter(e.begin(), e.begin() + 28); + check(devourer::sta::parse_rsn(shorter.data(), shorter.size(), &r) && + r.valid && r.group_mgmt_suite == 0, + "rsn sets: omitted trailing fields leave the group mgmt cipher absent"); + + /* TRAILING PARTIAL FIELDS ARE IGNORED, not refused - wpa_supplicant's + * shape ("ie has trailing bytes - ignored"), which leaves room for fields + * a later amendment appends. An optional field is read only when all of it + * is present. */ + std::vector stray(e.begin(), e.begin() + 28); + stray.push_back(0x01); /* half a PMKID count */ + check(devourer::sta::parse_rsn(stray.data(), stray.size(), &r) && r.valid && + r.capabilities == 0x0080 && r.group_mgmt_suite == 0, + "rsn sets: a lone byte after the capabilities is ignored"); + std::vector half_bip(e.begin(), e.end() - 1); /* 3 of 4 bytes */ + check(devourer::sta::parse_rsn(half_bip.data(), half_bip.size(), &r) && + r.valid && r.group_mgmt_suite == 0, + "rsn sets: a partial group mgmt cipher is ignored, not half-read"); + std::vector extra = e; + extra.push_back(0xee); + extra.push_back(0xee); + check(devourer::sta::parse_rsn(extra.data(), extra.size(), &r) && r.valid && + r.group_mgmt_suite == 0x000fac06u, + "rsn sets: bytes after the last known field are ignored"); +} + +/* ---- the four-way ------------------------------------------------------ */ + +void test_four_way() { + Authenticator ap; + Supplicant sup; + OpenSslCryptoOps crypto; + + check(handshake(ap, sup, crypto), "the four-way completes"); + check(sup.state() == Supplicant::State::Done, "the supplicant is Done"); + check(sup.ptk_valid(), "the PTK is installed"); + check(std::memcmp(sup.ptk(), ap.ptk, 48) == 0, + "both sides derived the SAME PTK"); + check(sup.gtk_valid() && sup.gtk_len() == 16, "the GTK is installed"); + check(std::memcmp(sup.gtk(), ap.gtk, 16) == 0, + "the GTK is the one the authenticator sent"); + check(sup.gtk_key_id() == ap.gtk_keyid, + "the GTK's key id survives the KDE — a group frame at the wrong key id " + "is looked up as the pairwise key and MIC-fails"); + check(sup.mic_failures == 0 && sup.replays == 0 && sup.malformed == 0, + "a clean handshake trips no refusal counter"); +} + +/* ACCEPTANCE NEGATIVE 1. Message 3 carries the GTK and confirms the PTK. A + * forged MIC must change nothing: not the state, not the installed keys, not + * the replay counter, and it must produce no message 4 — replying would tell + * an attacker the forgery was accepted even if the keys were not installed. */ +void test_forged_mic_is_rejected() { + Authenticator ap; + Supplicant sup; + OpenSslCryptoOps crypto; + uint8_t snonce[32]; + std::vector out; + + std::memset(snonce, 0x7a, 32); + ap.init(); + sup.start(crypto, ap.pmk, kSpa, kAa, snonce); + + const std::vector m1 = ap.msg1(); + check(sup.on_eapol(m1.data(), m1.size(), &out) == Supplicant::Verdict::Reply, + "msg1 is answered"); + check(ap.on_msg2(out), "the authenticator accepts msg2"); + check(!sup.ptk_valid(), "nothing is installed before msg3"); + + std::vector m3 = ap.msg3(); + const uint64_t before_replay = sup.replay_counter(); + + /* One bit, in the MIC field. Everything else about this frame is correct — + * the counter advances, the ANonce matches, the key data unwraps. */ + /* THE LAST BYTE, not the first. A bit in the first eight would be caught by + * a comparison that only looks at half the MIC, so it could not tell that + * comparison from a correct one. */ + m3[devourer::sta::kEapolMicOff + devourer::sta::kEapolMicLen - 1] ^= 0x01; + out.clear(); + check(sup.on_eapol(m3.data(), m3.size(), &out) == + Supplicant::Verdict::MicFailed, + "A FORGED EAPOL-Key MIC IS REJECTED"); + check(sup.mic_failures == 1, "...and counted"); + check(out.empty(), "...with no message 4 sent"); + check(!sup.ptk_valid(), "...the PTK is NOT installed"); + check(!sup.gtk_valid(), "...the GTK is NOT installed"); + check(sup.state() == Supplicant::State::WaitMsg3, + "...and the state does not advance"); + check(sup.replay_counter() == before_replay, + "...and the replay counter does not advance, so the real msg3 still " + "works"); + + /* THE POSITIVE ARM. Without it, a supplicant that rejected everything would + * pass the test above. The genuine message 3 must still be accepted. */ + const std::vector good = ap.msg3(); + out.clear(); + check(sup.on_eapol(good.data(), good.size(), &out) == + Supplicant::Verdict::Reply, + "the genuine msg3 is still accepted afterwards"); + check(sup.ptk_valid() && sup.gtk_valid(), "...and installs both keys"); + check(ap.on_msg4(out), "...and its msg4 verifies at the authenticator"); +} + +/* ACCEPTANCE NEGATIVE 2. Replaying a captured group message 1 must not + * reinstall its GTK. The old key comes back with its PN space reset, so every + * group frame sent since is encrypted again under a nonce already used — CCM + * keystream reuse across the whole BSS, from one captured frame. */ +void test_group_rekey_replay_rejected() { + Authenticator ap; + Supplicant sup; + OpenSslCryptoOps crypto; + std::vector out; + uint8_t gtk2[16], gtk3[16]; + + check(handshake(ap, sup, crypto), "the four-way completes"); + std::memset(gtk2, 0x62, 16); + std::memset(gtk3, 0x93, 16); + + /* A genuine rekey first, so the test is not measuring a supplicant that + * refuses all group messages. */ + const std::vector g1 = ap.group1_next(gtk2, 2); + check(sup.on_eapol(g1.data(), g1.size(), &out) == Supplicant::Verdict::Reply, + "a genuine group rekey is accepted"); + check(sup.gtk_valid() && std::memcmp(sup.gtk(), gtk2, 16) == 0, + "...and installs the new GTK"); + check(sup.gtk_key_id() == 2, "...at its key id"); + const uint64_t after = sup.replay_counter(); + + /* THE REPLAY. Byte-identical to the frame just accepted, which is what an + * attacker captures off the air. Its MIC is valid — that is the point. */ + out.clear(); + const Supplicant::Verdict v = sup.on_eapol(g1.data(), g1.size(), &out); + check(v == Supplicant::Verdict::Retransmit, + "AN EQUAL-COUNTER GROUP REKEY IS NOT TREATED AS NEW"); + check(std::memcmp(sup.gtk(), gtk2, 16) == 0, + "...the GTK is unchanged (it was already this one)"); + check(sup.replay_counter() == after, "...the counter does not move"); + + /* The case that actually matters: the SAME counter carrying a DIFFERENT + * key. This is a captured older rekey, and installing it is the + * reinstallation - the supplicant must not take the key out of it. */ + const std::vector forged = ap.group1(gtk3, 3, after); + out.clear(); + check(sup.on_eapol(forged.data(), forged.size(), &out) == + Supplicant::Verdict::Retransmit, + "AN EQUAL-COUNTER REKEY CARRYING A DIFFERENT GTK IS REFUSED"); + check(std::memcmp(sup.gtk(), gtk2, 16) == 0, + "...AND THE OLD GTK IS NOT REINSTALLED"); + check(sup.gtk_key_id() == 2, "...nor its key id"); + + /* A LOWER counter is a plain replay and is refused outright. */ + const std::vector older = ap.group1(gtk3, 3, after - 1); + out.clear(); + check(sup.on_eapol(older.data(), older.size(), &out) == + Supplicant::Verdict::Replayed, + "a LOWER counter is refused outright"); + check(sup.replays == 1, "...and counted"); + check(std::memcmp(sup.gtk(), gtk2, 16) == 0, "...installing nothing"); + + /* POSITIVE ARM: a strictly greater counter still works, so the rule above + * is a window and not a wall. */ + const std::vector g2 = ap.group1_next(gtk3, 3); + out.clear(); + check(sup.on_eapol(g2.data(), g2.size(), &out) == Supplicant::Verdict::Reply, + "a greater counter installs the new GTK"); + check(std::memcmp(sup.gtk(), gtk3, 16) == 0, "...and it is the new key"); +} + +/* MESSAGE 3 MUST CARRY A GTK, and a key-data field that does not yield one + * must not complete the handshake. + * + * In RSN, message 3 always carries the GTK KDE. A station that accepted one + * without it would end up Connected, keyed, and unable to decrypt a single + * broadcast frame - with no counter moved and nothing to diagnose from. Both + * an absent GTK KDE and a truncated one must refuse the message, which is + * why the walker reports them as two different results. */ +void test_msg3_without_a_usable_gtk_is_refused() { + const char* names[] = {"no GTK KDE at all", "a truncated GTK KDE"}; + + for (int variant = 0; variant < 2; variant++) { + Authenticator ap; + Supplicant sup; + OpenSslCryptoOps crypto; + uint8_t snonce[32]; + std::vector out; + char label[96]; + + std::memset(snonce, 0x7a, 32); + ap.init(); + sup.start(crypto, ap.pmk, kSpa, kAa, snonce); + const std::vector m1 = ap.msg1(); + sup.on_eapol(m1.data(), m1.size(), &out); + if (!ap.on_msg2(out)) { check(false, "setup: msg2"); return; } + + /* Key data that is correctly wrapped and correctly MIC'd - the ONLY thing + * wrong is what is inside it. */ + std::vector kd; + devourer::sta::append_rsn_ccmp_psk(kd); + if (variant == 1) { + /* A GTK KDE whose declared length runs past the key data. */ + const uint8_t hdr[8] = {0xdd, 0x30, 0x00, 0x0f, 0xac, 0x01, 1, 0x00}; + kd.insert(kd.end(), hdr, hdr + 8); + kd.insert(kd.end(), ap.gtk, ap.gtk + 16); + } + 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 = OpenSslCryptoOps::key_wrap(ap.ptk + 16, 16, kd.data(), + kd.size(), w.data()); + w.resize(n > 0 ? (size_t)n : 0); + const std::vector m3 = 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, ap.replay + 1, ap.anonce, nullptr, w.data(), w.size(), &ap.crypto, + ap.ptk); + + out.clear(); + std::snprintf(label, sizeof label, "msg3 with %s is refused", + names[variant]); + check(sup.on_eapol(m3.data(), m3.size(), &out) == + Supplicant::Verdict::Malformed, + label); + check(!sup.ptk_valid(), "...and installs no PTK"); + check(!sup.gtk_valid(), "...and no GTK"); + check(sup.state() != Supplicant::State::Done, + "...and does NOT complete the handshake"); + check(out.empty(), "...and sends no message 4"); + } +} + +/* A retransmission is answered with the reply that answered THAT message, not + * with whatever was cached last. Without the message-type check, a message 1 + * replayed at a message 3's counter collects a message 4. */ +void test_retransmit_is_matched_to_its_message() { + Authenticator ap; + Supplicant sup; + OpenSslCryptoOps crypto; + std::vector out; + + check(handshake(ap, sup, crypto), "the four-way completes"); + const uint64_t at = sup.replay_counter(); + + /* A message 1 quoting the counter that message 3 was authenticated at. */ + std::vector m1 = devourer::sta::build_eapol_key( + devourer::sta::kKeyDescVersionCcmp | devourer::sta::kKiPairwise | + devourer::sta::kKiAck, + 16, at, ap.anonce, nullptr, nullptr, 0, nullptr, nullptr); + out.clear(); + check(sup.on_eapol(m1.data(), m1.size(), &out) == + Supplicant::Verdict::Replayed, + "a msg1 at msg3's counter is a replay, not a retransmission"); + check(out.empty(), "...and collects no cached message 4"); +} + +/* Key material does not outlive the object. forget() is what the destructor + * calls; asserting on a live object is the only way to see it at all. */ +void test_forget_wipes_key_material() { + Authenticator ap; + Supplicant sup; + OpenSslCryptoOps crypto; + uint8_t zero[48] = {0}; + + check(handshake(ap, sup, crypto), "the four-way completes"); + check(std::memcmp(sup.ptk(), zero, 48) != 0, "there is a PTK to wipe"); + sup.forget(); + check(std::memcmp(sup.ptk(), zero, 48) == 0, "forget() wipes the PTK"); + check(std::memcmp(sup.gtk(), zero, 16) == 0, "...and the GTK"); + check(!sup.ptk_valid() && !sup.gtk_valid(), "...and says so"); + check(sup.state() == Supplicant::State::Idle, "...and goes back to Idle"); +} + +/* A group message whose MIC does not verify installs nothing either — the + * same rule as message 3, on the path that runs for the life of the + * association rather than once at the start. */ +void test_group_forged_mic_rejected() { + Authenticator ap; + Supplicant sup; + OpenSslCryptoOps crypto; + std::vector out; + uint8_t gtk2[16]; + + check(handshake(ap, sup, crypto), "the four-way completes"); + std::memset(gtk2, 0x62, 16); + + std::vector g1 = ap.group1_next(gtk2, 2); + g1[devourer::sta::kEapolMicOff + 7] ^= 0x80; + check(sup.on_eapol(g1.data(), g1.size(), &out) == + Supplicant::Verdict::MicFailed, + "a forged group-rekey MIC is rejected"); + check(std::memcmp(sup.gtk(), ap.gtk, 16) == 0, + "...and the GTK from the four-way is still installed"); +} + +/* A group rekey arriving BEFORE the four-way has finished has no key to be + * verified with. No other cell sends one early, so this is the only thing + * that pins the state guard - and without it the MIC check runs against an + * all-zero PTK, which is a comparison of attacker-supplied bytes against a + * key the station does not have. */ +void test_group_rekey_before_four_way() { + Authenticator ap; + Supplicant sup; + OpenSslCryptoOps crypto; + uint8_t snonce[32], gtk2[16]; + std::vector out; + + std::memset(snonce, 0x7a, 32); + std::memset(gtk2, 0x62, 16); + ap.init(); + sup.start(crypto, ap.pmk, kSpa, kAa, snonce); + + /* ap.ptk is still zero here, which is exactly the situation: neither side + * has a pairwise key yet. */ + const std::vector g = ap.group1(gtk2, 2, 1); + check(sup.on_eapol(g.data(), g.size(), &out) == + Supplicant::Verdict::OutOfState, + "a group rekey before the four-way is out of state"); + check(sup.out_of_state == 1, "...and counted as such, not as a MIC failure"); + check(!sup.gtk_valid(), "...installing nothing"); + + /* Same again after msg1 but before msg3: a PTK is a CANDIDATE at that + * point, not an installed key, and a group rekey must not use it. */ + const std::vector m1 = ap.msg1(); + sup.on_eapol(m1.data(), m1.size(), &out); + check(sup.state() == Supplicant::State::WaitMsg3, "msg1 was accepted"); + const std::vector g2 = ap.group1(gtk2, 2, 5); + out.clear(); + check(sup.on_eapol(g2.data(), g2.size(), &out) == + Supplicant::Verdict::OutOfState, + "a group rekey between msg1 and msg3 is out of state too"); + check(!sup.gtk_valid(), "...still installing nothing"); +} + +/* eapol_mic_ok() refuses a descriptor version whose MIC is a different + * algorithm, and that refusal has to be asserted HERE rather than through the + * supplicant: on_eapol() rejects a wrong version before the MIC is ever + * reached, so no supplicant-level cell can see the check inside eapol_mic_ok. + * The function is public and an authenticator calls it directly. */ +void test_mic_refuses_other_descriptor_versions() { + OpenSslCryptoOps crypto; + uint8_t kck[16]; + EapolKey k; + + std::memset(kck, 0x2b, 16); + for (uint16_t ver : {1, 2, 3}) { + /* build_eapol_key computes HMAC-SHA1 whatever the version says, so each + * of these frames carries a MIC that is correct FOR THAT ALGORITHM. Only + * version 2 declares that algorithm. */ + const std::vector e = devourer::sta::build_eapol_key( + (uint16_t)(ver | devourer::sta::kKiPairwise | devourer::sta::kKiMic), + 16, 1, nullptr, nullptr, nullptr, 0, &crypto, kck); + char label[96]; + + check(devourer::sta::parse_eapol_key(e.data(), e.size(), &k), "parses"); + std::snprintf(label, sizeof label, + "eapol_mic_ok %s descriptor version %u", + ver == 2 ? "accepts" : "refuses", ver); + const bool ok = devourer::sta::eapol_mic_ok(crypto, kck, k) == MicCheck::Ok; + check(ok == (ver == 2), label); + } + + /* And the positive arm is not an accident: the wrong KCK on a version-2 + * frame still fails. */ + const std::vector e = devourer::sta::build_eapol_key( + devourer::sta::kKeyDescVersionCcmp | devourer::sta::kKiPairwise | + devourer::sta::kKiMic, + 16, 1, nullptr, nullptr, nullptr, 0, &crypto, kck); + devourer::sta::parse_eapol_key(e.data(), e.size(), &k); + uint8_t other[16]; + std::memset(other, 0x99, 16); + check(devourer::sta::eapol_mic_ok(crypto, other, k) == MicCheck::Mismatch, + "a version-2 MIC under the wrong KCK is refused"); +} + +/* 12.7.6.4: message 3's ANonce must equal message 1's. A mismatch is a + * mix-and-match of two exchanges and is refused BEFORE the MIC, so it reads + * as what it is rather than as a key failure. */ +void test_anonce_mismatch_rejected() { + Authenticator ap; + Supplicant sup; + OpenSslCryptoOps crypto; + uint8_t snonce[32]; + std::vector out; + + std::memset(snonce, 0x7a, 32); + ap.init(); + sup.start(crypto, ap.pmk, kSpa, kAa, snonce); + const std::vector m1 = ap.msg1(); + sup.on_eapol(m1.data(), m1.size(), &out); + ap.on_msg2(out); + + std::vector m3 = ap.msg3(); + m3[devourer::sta::kEapolNonceOff] ^= 0xff; + out.clear(); + check(sup.on_eapol(m3.data(), m3.size(), &out) == + Supplicant::Verdict::Malformed, + "msg3 with a different ANonce is refused"); + check(!sup.ptk_valid(), "...and installs nothing"); +} + +/* Message 3 with its key data in the CLEAR is a downgrade: the GTK would be + * readable off the air. Refused rather than parsed. */ +void test_unencrypted_key_data_rejected() { + Authenticator ap; + Supplicant sup; + OpenSslCryptoOps crypto; + uint8_t snonce[32]; + std::vector out; + + std::memset(snonce, 0x7a, 32); + ap.init(); + sup.start(crypto, ap.pmk, kSpa, kAa, snonce); + const std::vector m1 = ap.msg1(); + sup.on_eapol(m1.data(), m1.size(), &out); + ap.on_msg2(out); + + /* Build msg3 with the GTK KDE unwrapped and the Encrypted bit clear, then + * MIC it correctly — so the ONLY thing wrong is the missing encryption. */ + std::vector kd; + const uint8_t hdr[8] = {0xdd, 0x16, 0x00, 0x0f, 0xac, 0x01, 1, 0x00}; + kd.insert(kd.end(), hdr, hdr + 8); + kd.insert(kd.end(), ap.gtk, ap.gtk + 16); + const std::vector m3 = devourer::sta::build_eapol_key( + devourer::sta::kKeyDescVersionCcmp | devourer::sta::kKiPairwise | + devourer::sta::kKiInstall | devourer::sta::kKiAck | + devourer::sta::kKiMic | devourer::sta::kKiSecure, + 16, ap.replay + 1, ap.anonce, nullptr, kd.data(), kd.size(), &ap.crypto, + ap.ptk); + out.clear(); + check(sup.on_eapol(m3.data(), m3.size(), &out) == + Supplicant::Verdict::Malformed, + "msg3 with unencrypted key data is refused"); + check(!sup.gtk_valid(), "...and the GTK is not taken from the clear"); +} + +/* The descriptor version selects the MIC ALGORITHM. Version 1 is HMAC-MD5 and + * version 3 is AES-CMAC; treating either as version 2 means verifying with + * the wrong primitive, and the only symptom would be a MIC failure pointing + * at the key. */ +void test_descriptor_version_refused() { + Authenticator ap; + Supplicant sup; + OpenSslCryptoOps crypto; + uint8_t snonce[32]; + std::vector out; + + std::memset(snonce, 0x7a, 32); + ap.init(); + sup.start(crypto, ap.pmk, kSpa, kAa, snonce); + + for (uint8_t ver : {1, 3}) { + std::vector m1 = ap.msg1(); + char label[80]; + + m1[6] = (uint8_t)((m1[6] & ~0x07) | ver); + std::snprintf(label, sizeof label, "key descriptor version %u is refused", + ver); + out.clear(); + check(sup.on_eapol(m1.data(), m1.size(), &out) == + Supplicant::Verdict::Malformed, + label); + } + check(sup.state() == Supplicant::State::WaitMsg1, + "...and none of them started a handshake"); +} + +/* The replay rule on the PAIRWISE path, including the retransmission window + * that makes it a rule rather than a wall. */ +void test_replay_counter_rules() { + Authenticator ap; + Supplicant sup; + OpenSslCryptoOps crypto; + uint8_t snonce[32]; + std::vector out, first; + + std::memset(snonce, 0x7a, 32); + ap.init(); + sup.start(crypto, ap.pmk, kSpa, kAa, snonce); + + const std::vector m1 = ap.msg1(); + check(sup.on_eapol(m1.data(), m1.size(), &first) == + Supplicant::Verdict::Reply, + "msg1 is answered"); + + /* An authenticator that does NOT increment on retransmission (802.11-2016 + * 12.7.6.4 permits either) must still be able to finish. The same reply + * goes back out, byte for byte, and nothing is re-derived. */ + out.clear(); + check(sup.on_eapol(m1.data(), m1.size(), &out) == + Supplicant::Verdict::Retransmit, + "an equal counter is answered as a retransmission"); + check(out == first, "...with the identical reply"); + check(sup.retransmits == 1, "...and counted as one"); + + /* A LOWER COUNTER ON AN UNAUTHENTICATED MESSAGE 1 IS NOT REFUSED, and this + * is deliberate. The counter in a MIC-less frame is worth nothing: a + * supplicant that remembers it lets one forged message 1 quoting 2^64-1 + * refuse every genuine EAPOL-Key for the rest of the association. Nothing + * is installed from a message 1, so answering a + * low-counter one costs a message 2 and no security; refusing it would cost + * the whole association. */ + std::vector older = ap.msg1(); + devourer::sta::eapol_put_be64(older.data() + devourer::sta::kEapolReplayOff, + 0); + out.clear(); + check(sup.on_eapol(older.data(), older.size(), &out) == + Supplicant::Verdict::Reply, + "a low-counter msg1 is answered, because its counter is unauthenticated"); + check(sup.replay_counter() == 0, + "...and the AUTHENTICATED counter has not moved - nothing has been"); + + /* ONCE SOMETHING IS AUTHENTICATED, the rule bites. Finish the handshake and + * the same trick is refused. */ + Authenticator ap2; + Supplicant s2; + OpenSslCryptoOps c2; + check(handshake(ap2, s2, c2), "a second handshake completes"); + const uint64_t authed = s2.replay_counter(); + check(authed != 0, "...and authenticated a counter"); + + std::vector low = ap2.msg1(); + devourer::sta::eapol_put_be64(low.data() + devourer::sta::kEapolReplayOff, + authed - 1); + out.clear(); + check(s2.on_eapol(low.data(), low.size(), &out) == + Supplicant::Verdict::Replayed, + "a counter below the last AUTHENTICATED one is refused"); + check(out.empty(), "...with no reply at all"); + check(s2.replays == 1, "...and counted"); +} + +/* A FORGED MESSAGE 1 MUST NOT MOVE THE REPLAY COUNTER. + * + * Message 1 has no MIC. If accepting one advances the replay counter, a + * single forged frame quoting the top of the counter space refuses every + * genuine EAPOL-Key from then on - and the station does not notice, because + * it is already keyed: it stays Connected while its rekey path is dead, and + * the failure only shows up as "multicast stopped working" at the next GTK + * rotation. */ +void test_forged_msg1_cannot_poison_the_counter() { + Authenticator ap; + Supplicant sup; + OpenSslCryptoOps crypto; + std::vector out; + uint8_t gtk2[16], ptk_before[48]; + + check(handshake(ap, sup, crypto), "the four-way completes"); + std::memcpy(ptk_before, sup.ptk(), 48); + std::memset(gtk2, 0x62, 16); + + /* Anyone can build this: it needs the BSSID, our address, and no key. */ + std::vector forged = devourer::sta::build_eapol_key( + devourer::sta::kKeyDescVersionCcmp | devourer::sta::kKiPairwise | + devourer::sta::kKiAck, + 16, 0xffffffffffffffffULL, ap.anonce, nullptr, nullptr, 0, nullptr, + nullptr); + const uint64_t authed = sup.replay_counter(); + sup.on_eapol(forged.data(), forged.size(), &out); + + check(sup.replay_counter() == authed, + "A FORGED MESSAGE 1 DOES NOT ADVANCE THE AUTHENTICATED COUNTER"); + check(sup.state() == Supplicant::State::Done, + "...and does not take a working station out of Done"); + check(std::memcmp(sup.ptk(), ptk_before, 48) == 0, + "...nor disturb the installed PTK"); + + /* THE PROOF THAT IT MATTERS: the AP's next group rekey still works. With a + * poisoned counter it is refused as a replay and the GTK never rotates + * again. */ + const std::vector g = ap.group1_next(gtk2, 2); + out.clear(); + check(sup.on_eapol(g.data(), g.size(), &out) == Supplicant::Verdict::Reply, + "...and the AP's next group rekey is still accepted"); + check(std::memcmp(sup.gtk(), gtk2, 16) == 0, "...and installs the new GTK"); + + /* And the genuine four-way can still be restarted, which is the other half + * of what a poisoned counter would break. */ + std::vector m1 = ap.msg1(); + out.clear(); + check(sup.on_eapol(m1.data(), m1.size(), &out) == Supplicant::Verdict::Reply, + "a genuine msg1 is still answered after the forgery"); +} + +/* Message 1 arriving on a working association must not disturb it. It is + * unauthenticated — anyone can send one — so a station that re-derived over + * its live PTK would lose the link to a single forged frame. */ +void test_msg1_on_a_live_association() { + Authenticator ap; + Supplicant sup; + OpenSslCryptoOps crypto; + std::vector out; + uint8_t live[48], gtk_before[16]; + + check(handshake(ap, sup, crypto), "the four-way completes"); + std::memcpy(live, sup.ptk(), 48); + std::memcpy(gtk_before, sup.gtk(), 16); + + /* A forged msg1 with a fresh ANonce and an advancing counter: everything an + * attacker can produce without the PMK. */ + std::vector m1 = ap.msg1(); + m1[devourer::sta::kEapolNonceOff] ^= 0xff; + out.clear(); + check(sup.on_eapol(m1.data(), m1.size(), &out) == Supplicant::Verdict::Reply, + "msg1 on a live association is answered"); + check(std::memcmp(sup.ptk(), live, 48) == 0, + "...and the INSTALLED PTK is untouched"); + check(std::memcmp(sup.gtk(), gtk_before, 16) == 0, + "...as is the GTK"); + check(sup.ptk_valid(), "...and the station stays keyed"); +} + +/* KEY REINSTALLATION (KRACK, CVE-2017-13077). The AP retransmits message 3 + * whenever our message 4 is lost, and hostapd increments the replay counter + * on every retransmission - so a STRICTLY GREATER counter carrying the key + * already installed is routine, not an attack. It must be answered (the AP + * is owed a message 4) and must install nothing: the caller restarts its TX + * PN when ptk_generation() moves, and doing that under the unchanged TK + * reuses CCMP nonces. The equal- and lower-counter cells elsewhere in this + * file pass even for a supplicant that reinstalls here; this one does not. */ +void test_msg3_retransmit_does_not_reinstall() { + Authenticator ap; + Supplicant sup; + OpenSslCryptoOps crypto; + std::vector out; + uint8_t ptk_before[48]; + + check(handshake(ap, sup, crypto), "the four-way completes"); + std::memcpy(ptk_before, sup.ptk(), 48); + const uint32_t pg = sup.ptk_generation(), gg = sup.gtk_generation(); + const uint64_t authed = sup.replay_counter(); + + const std::vector m3 = ap.msg3(); /* counter + 1 */ + out.clear(); + check(sup.on_eapol(m3.data(), m3.size(), &out) == + Supplicant::Verdict::Reply, + "a msg3 retransmitted at a GREATER counter is answered"); + check(ap.on_msg4(out), "...with a message 4 that verifies at the AP"); + check(sup.ptk_generation() == pg, + "...AND THE PTK IS NOT REINSTALLED (ptk_generation unchanged)"); + check(sup.gtk_generation() == gg, + "...NOR THE GTK (gtk_generation unchanged)"); + check(std::memcmp(sup.ptk(), ptk_before, 48) == 0, "...the PTK is the same"); + check(sup.replay_counter() == authed + 1, + "...and the authenticated counter advances past it"); + + /* Retransmitted again at the counter just answered: the cached reply. */ + out.clear(); + check(sup.on_eapol(m3.data(), m3.size(), &out) == + Supplicant::Verdict::Retransmit, + "the same msg3 again is a retransmission"); + check(sup.ptk_generation() == pg && sup.gtk_generation() == gg, + "...still installing nothing"); + + /* A message 1 re-quoting the INSTALLED ANonce re-derives the identical PTK + * into the candidate. Its message 3 must not count as a new key either. */ + const std::vector m1 = ap.msg1(); + out.clear(); + check(sup.on_eapol(m1.data(), m1.size(), &out) == Supplicant::Verdict::Reply, + "a msg1 re-quoting the installed ANonce is answered"); + const std::vector m3b = ap.msg3(); + out.clear(); + check(sup.on_eapol(m3b.data(), m3b.size(), &out) == + Supplicant::Verdict::Reply, + "...and its msg3 is answered"); + check(sup.ptk_generation() == pg && sup.gtk_generation() == gg, + "...WITHOUT reinstalling the identical PTK or GTK"); +} + +/* The group half of the same rule (CVE-2017-13078/13080). A group message 1 + * at a greater counter carrying the GTK already installed, and a PTK rekey + * whose message 3 re-delivers the current GTK - which EVERY PTK rekey does - + * must not move gtk_generation(), or the caller reopens its group replay + * window each time. A genuinely new PTK must still move ptk_generation(). */ +void test_group1_same_gtk_does_not_reinstall() { + Authenticator ap; + Supplicant sup; + OpenSslCryptoOps crypto; + std::vector out; + const uint8_t rsc[8] = {0x10, 0x27, 0, 0, 0, 0, 0xaa, 0xbb}; + const uint8_t rsc2[8] = {0xff, 0xff, 0, 0, 0, 0, 0, 0}; + uint8_t gtk2[16]; + + check(handshake(ap, sup, crypto), "the four-way completes"); + check(sup.gtk_rsc() == 0, "the four-way's message 3 quoted RSC 0"); + const uint32_t pg = sup.ptk_generation(), gg = sup.gtk_generation(); + + const std::vector same = ap.group1_next(ap.gtk, ap.gtk_keyid, rsc2); + out.clear(); + check(sup.on_eapol(same.data(), same.size(), &out) == + Supplicant::Verdict::Reply, + "a group msg1 re-delivering the installed GTK is answered"); + check(sup.gtk_generation() == gg, + "...AND THE GTK IS NOT REINSTALLED (gtk_generation unchanged)"); + check(sup.gtk_rsc() == 0, "...nor its RSC re-read"); + + /* A genuinely new GTK installs, with its RSC: 48 bits, little-endian. */ + std::memset(gtk2, 0x62, 16); + const std::vector g2 = ap.group1_next(gtk2, 2, rsc); + out.clear(); + check(sup.on_eapol(g2.data(), g2.size(), &out) == Supplicant::Verdict::Reply, + "a new GTK is accepted"); + check(sup.gtk_generation() == gg + 1, "...and counted as one install"); + check(sup.gtk_rsc() == 0x2710, + "...with the Key RSC it arrived with (48-bit LE, octets 6-7 ignored)"); + + /* A PTK rekey: fresh ANonce, new PTK, the same (now current) GTK. */ + ap.gtk_keyid = 2; + std::memcpy(ap.gtk, gtk2, 16); + std::memset(ap.anonce, 0x6f, 32); + const std::vector m1 = ap.msg1(); + out.clear(); + check(sup.on_eapol(m1.data(), m1.size(), &out) == Supplicant::Verdict::Reply, + "a PTK rekey's msg1 is answered"); + check(ap.on_msg2(out), "...and its msg2 verifies"); + const std::vector m3 = ap.msg3(); + out.clear(); + check(sup.on_eapol(m3.data(), m3.size(), &out) == + Supplicant::Verdict::Reply, + "the rekey's msg3 is accepted"); + check(ap.on_msg4(out), "...and its msg4 verifies under the NEW PTK"); + check(sup.ptk_generation() == pg + 1, "...a new PTK IS a new install"); + check(sup.gtk_generation() == gg + 1, + "...but the GTK it re-delivers is NOT reinstalled"); +} + +/* An UNAUTHENTICATED message 1 on a live association replaces the in-flight + * candidate, and must not stop the AP's own retransmitted message 3 - at an + * equal OR a greater counter - from being answered. That is why the installed + * handshake is kept apart from the candidate: a supplicant that compared the + * retransmission against the forged candidate would refuse it as Malformed, + * and the AP would deauthenticate after its retries. */ +void test_forged_msg1_does_not_orphan_msg3_retransmit() { + Authenticator ap; + Supplicant sup; + OpenSslCryptoOps crypto; + std::vector out; + + check(handshake(ap, sup, crypto), "the four-way completes"); + const uint32_t pg = sup.ptk_generation(); + const uint64_t at = sup.replay_counter(); + + std::vector forged = ap.msg1(); + forged[devourer::sta::kEapolNonceOff] ^= 0xff; + devourer::sta::eapol_put_be64(forged.data() + devourer::sta::kEapolReplayOff, + at + 5); + out.clear(); + sup.on_eapol(forged.data(), forged.size(), &out); + + /* Equal counter: the handshake's own msg3, rebuilt at its own counter. */ + ap.replay = at - 1; + const std::vector same = ap.msg3(); /* counter == at */ + out.clear(); + check(sup.on_eapol(same.data(), same.size(), &out) == + Supplicant::Verdict::Retransmit, + "after a forged msg1, an EQUAL-counter msg3 still collects its msg4"); + check(ap.on_msg4(out), "...and it is a real message 4"); + + const std::vector next = ap.msg3(); /* counter == at + 1 */ + out.clear(); + check(sup.on_eapol(next.data(), next.size(), &out) == + Supplicant::Verdict::Reply, + "after a forged msg1, a GREATER-counter msg3 is still answered"); + check(ap.on_msg4(out), "...with a message 4 the AP accepts"); + check(sup.malformed == 0, "...and nothing was refused as malformed"); + check(sup.ptk_generation() == pg, "...and nothing was reinstalled"); +} + +/* A REAL FOUR-WAY, FROM HOSTAPD AND WPA_SUPPLICANT. + * + * Everything else in this file is this repository talking to itself. These + * are the four EAPOL-Key frames two independent daemons exchanged over a + * mac80211_hwsim rig, with the PTK wpa_supplicant derived and the GTK hostapd + * generated, captured by tests/eapol_capture_vectors.sh. + * + * Four separate claims, each of which fails on its own: + * + * 1. Our PMK from the passphrase and SSID matches what both ends used — + * otherwise nothing below verifies at all. + * 2. Our PTK, derived from that PMK and the two nonces in the captured + * frames, equals the one WPA_SUPPLICANT LOGGED. That is the 802.11 PRF + * and the min/max sorting of the addresses and nonces, pinned. + * 3. Our MIC check accepts hostapd's message 3 and wpa_supplicant's + * messages 2 and 4 — three MICs, two implementations, one of them the + * other role. + * 4. Our Supplicant, given the captured SNonce, drives the whole exchange + * to Done and arrives at the same PTK and at HOSTAPD'S OWN GTK. + */ +void test_against_a_real_four_way() { + using namespace devourer::test; + OpenSslCryptoOps crypto; + devourer::sta::EapolKey k1, k2, k3, k4; + uint8_t pmk[32], ptk[48]; + + check(devourer::sta::parse_eapol_key(kEapolFourWay[0].eapol, + kEapolFourWay[0].len, &k1) && + devourer::sta::parse_eapol_key(kEapolFourWay[1].eapol, + kEapolFourWay[1].len, &k2) && + devourer::sta::parse_eapol_key(kEapolFourWay[2].eapol, + kEapolFourWay[2].len, &k3) && + devourer::sta::parse_eapol_key(kEapolFourWay[3].eapol, + kEapolFourWay[3].len, &k4), + "all four captured EAPOL-Key frames parse"); + + /* Our reading of the key-info bits, against what the two daemons actually + * set. If message 3 does not look like message 3 to us, nothing else in + * this file means what it says. */ + check(k1.pairwise() && k1.ack() && !k1.has_mic(), + "hostapd's message 1 is pairwise+ack with no MIC"); + check(k2.pairwise() && k2.has_mic() && !k2.ack() && !k2.secure(), + "wpa_supplicant's message 2 is pairwise+MIC, not ack, not secure"); + check(k3.pairwise() && k3.ack() && k3.has_mic() && k3.install() && + k3.secure() && k3.encrypted(), + "hostapd's message 3 is install+ack+MIC+secure+encrypted"); + check(k4.pairwise() && k4.has_mic() && k4.secure() && !k4.ack(), + "wpa_supplicant's message 4 is MIC+secure"); + check(k1.version == 2 && k3.version == 2, + "both are key descriptor version 2"); + + check(devourer::sta::pmk_from_psk(crypto, kHostapdPassphrase, kHostapdSsid, + pmk), + "the PMK derives from the captured passphrase and SSID"); + check(devourer::sta::derive_ptk(crypto, pmk, kEapolAa, kEapolSpa, k1.nonce, + k2.nonce, ptk), + "the PTK derives from the two captured nonces"); + check(std::memcmp(ptk, kSupplicantPtk, 48) == 0, + "OUR PTK EQUALS THE ONE WPA_SUPPLICANT DERIVED — the PRF and the " + "address/nonce sorting, against an implementation that has never read " + "this repository"); + + check(devourer::sta::eapol_mic_ok(crypto, ptk, k2) == MicCheck::Ok, + "our MIC check accepts wpa_supplicant's message 2"); + check(devourer::sta::eapol_mic_ok(crypto, ptk, k3) == MicCheck::Ok, + "...and hostapd's message 3"); + check(devourer::sta::eapol_mic_ok(crypto, ptk, k4) == MicCheck::Ok, + "...and wpa_supplicant's message 4"); + /* The negative arm, so the three above are not a function that returns + * true: the same frames under a key one bit different must fail. */ + { + uint8_t wrong[48]; + std::memcpy(wrong, ptk, 48); + wrong[0] ^= 0x01; + check(devourer::sta::eapol_mic_ok(crypto, wrong, k3) == MicCheck::Mismatch, + "...and refuses message 3 under a KCK one bit out"); + } + + /* THE WHOLE EXCHANGE, through our own state machine. The SNonce is + * wpa_supplicant's, taken from its own message 2, so our message 2 should + * be the one it sent. */ + Supplicant sup; + std::vector out; + sup.start(crypto, pmk, kEapolSpa, kEapolAa, k2.nonce); + check(sup.on_eapol(kEapolFourWay[0].eapol, kEapolFourWay[0].len, &out) == + Supplicant::Verdict::Reply, + "our supplicant answers hostapd's message 1"); + check(out.size() == kEapolFourWay[1].len && + std::memcmp(out.data(), kEapolFourWay[1].eapol, out.size()) == 0, + "OUR MESSAGE 2 IS BYTE-FOR-BYTE THE ONE WPA_SUPPLICANT SENT"); + + out.clear(); + check(sup.on_eapol(kEapolFourWay[2].eapol, kEapolFourWay[2].len, &out) == + Supplicant::Verdict::Reply, + "our supplicant accepts hostapd's message 3"); + check(sup.state() == Supplicant::State::Done, "...and reaches Done"); + check(std::memcmp(sup.ptk(), kSupplicantPtk, 48) == 0, + "...on wpa_supplicant's PTK"); + check(sup.gtk_valid() && sup.gtk_len() == 16 && + std::memcmp(sup.gtk(), kHostapdGtk, 16) == 0, + "...having unwrapped HOSTAPD'S OWN GTK out of message 3"); + check(out.size() == kEapolFourWay[3].len && + std::memcmp(out.data(), kEapolFourWay[3].eapol, out.size()) == 0, + "...and our message 4 is byte-for-byte the one wpa_supplicant sent"); +} + +/* ---- the wire format, from the air ------------------------------------- */ + +void test_parse_bounds() { + Authenticator ap; + EapolKey k; + + ap.init(); + const std::vector good = ap.msg1(); + + check(devourer::sta::parse_eapol_key(good.data(), good.size(), &k), + "a well-formed EAPOL-Key parses"); + check(k.frame_len == good.size(), "...to its whole length"); + check(k.key_data_len == 0, "...with no key data"); + + /* TRUNCATION THAT ONLY A LENGTH FIELD CAN CATCH. Message 1 is exactly the + * 99-byte fixed part, so every truncation of it is refused by the + * fixed-size check alone and a loop over it could not fail. Message 3 + * carries key data: every cut from the fixed part up is long enough for + * that check, and must be refused by the declared lengths. */ + const std::vector full = ap.msg3(); + check(full.size() >= devourer::sta::kEapolKeyFixedLen + 24 && + devourer::sta::parse_eapol_key(full.data(), full.size(), &k) && + k.key_data_len == full.size() - devourer::sta::kEapolKeyFixedLen, + "a message 3 with key data parses, to its whole length"); + bool parsed_short = false; + for (size_t n = devourer::sta::kEapolKeyFixedLen; n < full.size(); n++) + if (devourer::sta::parse_eapol_key(full.data(), n, &k)) parsed_short = true; + check(!parsed_short, + "a frame cut anywhere in its key data is refused (by its body length)"); + /* The same cuts with the 802.1X body length REWRITTEN to match each one, + * so the body-length check passes and only the key data length can refuse + * the frame. */ + parsed_short = false; + for (size_t n = devourer::sta::kEapolKeyFixedLen; n < full.size(); n++) { + std::vector t(full.begin(), full.begin() + (std::ptrdiff_t)n); + t[2] = (uint8_t)((n - 4) >> 8); + t[3] = (uint8_t)((n - 4) & 0xff); + if (devourer::sta::parse_eapol_key(t.data(), t.size(), &k)) + parsed_short = true; + } + check(!parsed_short, + "...and so is one whose body length was rewritten to match the cut: " + "the key data length still disagrees"); + + /* A KEY DATA LENGTH THAT LIES. This is the first thing an attacker tries, + * because the GTK is read out of that region: claim 4096 bytes in a 99-byte + * frame and a parser that trusts the field reads four kilobytes of the + * heap. */ + std::vector liar = good; + liar[devourer::sta::kEapolKeyDataLenOff] = 0x10; + liar[devourer::sta::kEapolKeyDataLenOff + 1] = 0x00; + check(!devourer::sta::parse_eapol_key(liar.data(), liar.size(), &k), + "a key data length longer than the frame is refused"); + + /* A body length longer than the buffer is refused rather than clamped: + * clamping would compute the MIC over fewer bytes than the sender signed. */ + std::vector big = good; + big[2] = 0x7f; + check(!devourer::sta::parse_eapol_key(big.data(), big.size(), &k), + "a body length longer than the buffer is refused"); + + std::vector wrong = good; + wrong[1] = 0; + check(!devourer::sta::parse_eapol_key(wrong.data(), wrong.size(), &k), + "a non-EAPOL-Key packet type is refused"); + wrong = good; + wrong[4] = 1; + check(!devourer::sta::parse_eapol_key(wrong.data(), wrong.size(), &k), + "a non-RSN key descriptor type is refused"); +} + +/* build_eapol_key REFUSES what its 16-bit length fields cannot say. Writing + * the low 16 bits would produce a frame whose declared lengths disagree with + * its bytes - and then MIC it. */ +void test_build_length_limit() { + const size_t fixed_body = devourer::sta::kEapolKeyFixedLen - 4; + const size_t max_kd = 0xffff - fixed_body; + std::vector kd(max_kd + 1, 0x5a); + + const std::vector at_max = devourer::sta::build_eapol_key( + devourer::sta::kKeyDescVersionCcmp, 0, 1, nullptr, nullptr, kd.data(), + max_kd, nullptr, nullptr); + check(at_max.size() == devourer::sta::kEapolKeyFixedLen + max_kd && + at_max[2] == 0xff && at_max[3] == 0xff, + "build: the largest key data whose body length fits is built"); + const std::vector over = devourer::sta::build_eapol_key( + devourer::sta::kKeyDescVersionCcmp, 0, 1, nullptr, nullptr, kd.data(), + max_kd + 1, nullptr, nullptr); + check(over.empty(), + "build: one byte more is refused, not written with a wrapped length"); + + /* INCONSISTENT REQUESTS ARE REFUSED. A declared key-data length with no key + * data would ship a frame claiming bytes it does not carry; a MIC key with + * no CryptoOps would ship unsigned what the caller asked to sign. Both null + * is the valid unsigned frame (message 1). */ + OpenSslCryptoOps crypto; + const uint8_t kck[16] = {1}; + check(devourer::sta::build_eapol_key(devourer::sta::kKeyDescVersionCcmp, 0, + 1, nullptr, nullptr, nullptr, 16, + &crypto, kck) + .empty(), + "build: a key data length with no key data is refused"); + check(devourer::sta::build_eapol_key(devourer::sta::kKeyDescVersionCcmp, 0, + 1, nullptr, nullptr, kd.data(), 16, + nullptr, kck) + .empty(), + "build: a MIC key with no CryptoOps is refused, not sent unsigned"); + check(devourer::sta::build_eapol_key(devourer::sta::kKeyDescVersionCcmp, 0, + 1, nullptr, nullptr, nullptr, 0, + nullptr, nullptr) + .size() == devourer::sta::kEapolKeyFixedLen, + "build: ...while the valid unsigned frame is still built"); +} + +/* eapol_mic_ok checks the frame BEFORE copying it. An EapolKey that did not + * come from parse_eapol_key may be anything, and copying first would read + * frame_len bytes from a null pointer. */ +void test_mic_ok_refuses_a_bad_key() { + OpenSslCryptoOps crypto; + const uint8_t kck[16] = {0}; + EapolKey k; + k.key_info = devourer::sta::kKeyDescVersionCcmp | devourer::sta::kKiMic; + k.version = devourer::sta::kKeyDescVersionCcmp; + k.frame = nullptr; + k.frame_len = 200; + check(devourer::sta::eapol_mic_ok(crypto, kck, k) == MicCheck::Mismatch, + "mic: a null frame is refused, not copied"); + const uint8_t shortbuf[10] = {0}; + k.frame = shortbuf; + k.frame_len = sizeof shortbuf; + check(devourer::sta::eapol_mic_ok(crypto, kck, k) == MicCheck::Mismatch, + "mic: a frame shorter than the fixed part is refused"); +} + +/* The KDE walker, against the shapes that come out of a failed unwrap. */ +void test_gtk_kde() { + devourer::sta::GtkKde g; + uint8_t kd[64]; + + /* The exact shape a real authenticator emits: length 22 counts the OUI, the + * data type, and 18 bytes of data. Subtracting 6 rather than 4 here makes + * every real KDE look two bytes short, which this cell would catch. */ + const uint8_t good[24] = {0xdd, 0x16, 0x00, 0x0f, 0xac, 0x01, 0x02, 0x00, + 1, 2, 3, 4, 5, 6, 7, 8, + 9, 10, 11, 12, 13, 14, 15, 16}; + check(devourer::sta::find_gtk_kde(good, sizeof good, &g) == + devourer::sta::KdeResult::Found, + "a GTK KDE parses"); + check(g.gtk_len == 16, "...with a 16-byte key"); + check(g.key_id == 2, "...and its key id"); + check(g.gtk[0] == 1 && g.gtk[15] == 16, "...and the key bytes"); + + /* Truncated: the length runs past the buffer. Stop, do not read on - and + * say MALFORMED, not "no GTK here", because the two mean opposite things to + * the caller. */ + check(devourer::sta::find_gtk_kde(good, 12, &g) == + devourer::sta::KdeResult::Malformed, + "a KDE truncated by the buffer is MALFORMED"); + + /* A vendor OUI is not ours and must not be mistaken for a GTK. */ + std::memcpy(kd, good, sizeof good); + kd[2] = 0x00; kd[3] = 0x50; kd[4] = 0xf2; + check(devourer::sta::find_gtk_kde(kd, sizeof good, &g) == + devourer::sta::KdeResult::Absent, + "a vendor OUI is ABSENT, not malformed - it is a well-formed KDE that " + "simply is not ours"); + + /* A declared length too small to hold a key. */ + std::memcpy(kd, good, sizeof good); + kd[1] = 0x08; + check(devourer::sta::find_gtk_kde(kd, sizeof good, &g) == + devourer::sta::KdeResult::Malformed, + "a KDE too short for a key is MALFORMED"); + + /* And no KDE at all is a clean false, not a crash: an RSN element followed + * by 802.11i padding is exactly what message 3 carries when there is no + * group key. */ + std::vector rsn; + devourer::sta::append_rsn_ccmp_psk(rsn); + rsn.push_back(0xdd); + while (rsn.size() % 8) rsn.push_back(0x00); + check(devourer::sta::find_gtk_kde(rsn.data(), rsn.size(), &g) == + devourer::sta::KdeResult::Absent, + "key data with no GTK KDE is ABSENT rather than read on"); +} + +} // namespace + +/* ---- hand-built key data, for the cells below ---------------------------- */ + +const uint16_t kMsg3Info = + devourer::sta::kKeyDescVersionCcmp | devourer::sta::kKiPairwise | + devourer::sta::kKiInstall | devourer::sta::kKiAck | devourer::sta::kKiMic | + devourer::sta::kKiSecure | devourer::sta::kKiEncrypted; +const uint16_t kGroup1Info = + devourer::sta::kKeyDescVersionCcmp | devourer::sta::kKiAck | + devourer::sta::kKiMic | devourer::sta::kKiSecure | + devourer::sta::kKiEncrypted; + +/* A GTK KDE carrying `key_len` bytes of key. */ +std::vector gtk_kde(const uint8_t* key, size_t key_len, uint8_t keyid) { + std::vector k = {0xdd, (uint8_t)(6 + key_len), 0x00, 0x0f, 0xac, + 0x01, keyid, 0x00}; + k.insert(k.end(), key, key + key_len); + return k; +} + +/* 802.11i-pad (0xdd, then zeros) and AES-wrap under the AP's KEK: key data + * that unwraps cleanly, so whatever a cell put inside it is the only fault. */ +std::vector wrap_key_data(const Authenticator& ap, + std::vector kd) { + 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 = OpenSslCryptoOps::key_wrap(ap.ptk + 16, 16, kd.data(), + kd.size(), w.data()); + w.resize(n > 0 ? (size_t)n : 0); + return w; +} + +/* An EAPOL-Key at the AP's next counter carrying exactly `kd`, MIC'd with the + * AP's KCK - authentic in every respect a cell does not change. */ +std::vector authentic_with(Authenticator& ap, uint16_t info, + const std::vector& kd, + bool pairwise_nonce) { + const std::vector w = wrap_key_data(ap, kd); + ap.replay++; + return devourer::sta::build_eapol_key( + info, 16, ap.replay, pairwise_nonce ? ap.anonce : nullptr, nullptr, + w.data(), w.size(), &ap.crypto, ap.ptk); +} + +/* Start a handshake and answer message 1; the AP then holds the PTK. */ +bool through_msg2(Authenticator& ap, Supplicant& sup, + OpenSslCryptoOps& crypto) { + uint8_t snonce[32]; + std::vector out; + std::memset(snonce, 0x7a, 32); + ap.init(); + sup.start(crypto, ap.pmk, kSpa, kAa, snonce); + const std::vector m1 = ap.msg1(); + if (sup.on_eapol(m1.data(), m1.size(), &out) != Supplicant::Verdict::Reply) + return false; + return ap.on_msg2(out); +} + +/* MESSAGE 3 SETS SECURE (802.11-2016 12.7.6.4). A pairwise Ack+MIC+Install + * frame with Secure clear is not message 3 and must install nothing. Here + * its MIC is genuine, so the only thing wrong is the missing bit - and + * the same frame with the bit set, next, completes the handshake. */ +void test_msg3_without_secure_installs_nothing() { + Authenticator ap; + Supplicant sup; + OpenSslCryptoOps crypto; + if (!through_msg2(ap, sup, crypto)) { check(false, "setup: msg2"); return; } + + std::vector kd; + devourer::sta::append_rsn_ccmp_psk(kd); + const std::vector g = gtk_kde(ap.gtk, 16, 1); + kd.insert(kd.end(), g.begin(), g.end()); + + const uint64_t before = sup.replay_counter(); + const std::vector bad = authentic_with( + ap, (uint16_t)(kMsg3Info & ~devourer::sta::kKiSecure), kd, true); + EapolKey k; + check(devourer::sta::parse_eapol_key(bad.data(), bad.size(), &k) && + devourer::sta::eapol_mic_ok(ap.crypto, ap.ptk, k) == MicCheck::Ok, + "no-secure msg3: setup - its MIC is genuine"); + std::vector out; + check(sup.on_eapol(bad.data(), bad.size(), &out) != + Supplicant::Verdict::Reply && + out.empty(), + "no-secure msg3: is not answered"); + check(!sup.ptk_valid() && !sup.gtk_valid(), "no-secure msg3: installs nothing"); + check(sup.replay_counter() == before, + "no-secure msg3: does not advance the replay counter"); + check(sup.state() != Supplicant::State::Done, + "no-secure msg3: does not reach Done"); + + const std::vector good = authentic_with(ap, kMsg3Info, kd, true); + out.clear(); + check(sup.on_eapol(good.data(), good.size(), &out) == + Supplicant::Verdict::Reply && + sup.state() == Supplicant::State::Done && ap.on_msg4(out), + "no-secure msg3: ...while the same message with Secure set completes"); +} + +/* A FORGED MESSAGE 1 THAT ARRIVES FIRST must not poison the genuine one. A + * msg1 cache matched on the counter alone would answer the genuine message 1 + * - same counter, the AP's own ANonce - with the reply derived from the + * forger's ANonce, whose MIC the AP cannot verify, and the join would fail + * every time the forger won the race. */ +void test_forged_msg1_first_does_not_poison_the_genuine_one() { + Authenticator ap; + Supplicant sup; + OpenSslCryptoOps crypto; + uint8_t snonce[32], forged_anonce[32]; + std::vector out; + + std::memset(snonce, 0x7a, 32); + std::memset(forged_anonce, 0x66, 32); + ap.init(); + sup.start(crypto, ap.pmk, kSpa, kAa, snonce); + + const std::vector forged = devourer::sta::build_eapol_key( + devourer::sta::kKeyDescVersionCcmp | devourer::sta::kKiPairwise | + devourer::sta::kKiAck, + 16, ap.replay + 1, forged_anonce, nullptr, nullptr, 0, nullptr, nullptr); + check(sup.on_eapol(forged.data(), forged.size(), &out) == + Supplicant::Verdict::Reply, + "forged-first msg1: setup - the forgery is answered (msg1 has no MIC)"); + + const std::vector m1 = ap.msg1(); /* the same counter */ + out.clear(); + check(sup.on_eapol(m1.data(), m1.size(), &out) == Supplicant::Verdict::Reply, + "forged-first msg1: the genuine msg1 at the same counter is processed " + "as new, not answered from the forger's cache"); + const std::vector genuine_reply = out; + check(ap.on_msg2(out), "forged-first msg1: ...and its message 2 verifies"); + + /* A true retransmission - same counter AND same ANonce - still gets the + * cached reply. */ + std::vector again; + check(sup.on_eapol(m1.data(), m1.size(), &again) == + Supplicant::Verdict::Retransmit && + again == genuine_reply, + "forged-first msg1: a real retransmission still gets the cached reply"); + + const std::vector m3 = ap.msg3(); + out.clear(); + check(sup.on_eapol(m3.data(), m3.size(), &out) == + Supplicant::Verdict::Reply && + sup.state() == Supplicant::State::Done && ap.on_msg4(out), + "forged-first msg1: ...and the join completes"); +} + +/* A GTK FOLLOWED BY A TRUNCATED ELEMENT is malformed key data. A walker that + * returned at the first GTK KDE would install the key whatever came after + * it. Both routes a GTK arrives by. */ +void test_gtk_followed_by_truncated_element_is_refused() { + const uint8_t truncated[5] = {0xdd, 0x30, 0x00, 0x0f, 0xac}; + { + Authenticator ap; + Supplicant sup; + OpenSslCryptoOps crypto; + if (!through_msg2(ap, sup, crypto)) { check(false, "setup: msg2"); return; } + std::vector kd; + devourer::sta::append_rsn_ccmp_psk(kd); + const std::vector g = gtk_kde(ap.gtk, 16, 1); + kd.insert(kd.end(), g.begin(), g.end()); + kd.insert(kd.end(), truncated, truncated + sizeof truncated); + const std::vector m3 = authentic_with(ap, kMsg3Info, kd, true); + std::vector out; + check(sup.on_eapol(m3.data(), m3.size(), &out) == + Supplicant::Verdict::Malformed && + !sup.ptk_valid() && !sup.gtk_valid() && + sup.state() != Supplicant::State::Done && out.empty(), + "trailing truncation: msg3 is refused, nothing installed, not Done"); + } + { + Authenticator ap; + Supplicant sup; + OpenSslCryptoOps crypto; + if (!handshake(ap, sup, crypto)) { check(false, "setup: four-way"); return; } + const uint32_t gg = sup.gtk_generation(); + uint8_t gtk2[16]; + std::memset(gtk2, 0x62, 16); + std::vector kd = gtk_kde(gtk2, 16, 2); + kd.insert(kd.end(), truncated, truncated + sizeof truncated); + const std::vector g1 = authentic_with(ap, kGroup1Info, kd, false); + std::vector out; + check(sup.on_eapol(g1.data(), g1.size(), &out) == + Supplicant::Verdict::Malformed && + sup.gtk_generation() == gg && + std::memcmp(sup.gtk(), ap.gtk, 16) == 0 && out.empty(), + "trailing truncation: a group rekey is refused and the old GTK stays"); + } + + /* The walker itself: truncation after the GTK, and a second GTK KDE. */ + devourer::sta::GtkKde g; + uint8_t key16[16]; + std::memset(key16, 0x27, sizeof key16); + std::vector kd = gtk_kde(key16, 16, 1); + kd.insert(kd.end(), truncated, truncated + sizeof truncated); + check(devourer::sta::find_gtk_kde(kd.data(), kd.size(), &g) == + devourer::sta::KdeResult::Malformed && + g.gtk_len == 0, + "trailing truncation: find_gtk_kde says Malformed and returns no key"); + std::vector two = gtk_kde(key16, 16, 1); + const std::vector second = gtk_kde(key16, 16, 2); + two.insert(two.end(), second.begin(), second.end()); + check(devourer::sta::find_gtk_kde(two.data(), two.size(), &g) == + devourer::sta::KdeResult::Malformed, + "trailing truncation: two GTK KDEs are Malformed, not a choice"); +} + +/* ONLY A 16-BYTE GTK INSTALLS. The KDE format allows up to 32 (CCMP-256 and + * GCMP-256 keys), but this station speaks CCMP-128 only, so an authenticated + * 32-byte GTK must not install. */ +void test_32_byte_gtk_is_refused() { + uint8_t key32[32]; + std::memset(key32, 0x44, sizeof key32); + { + Authenticator ap; + Supplicant sup; + OpenSslCryptoOps crypto; + if (!through_msg2(ap, sup, crypto)) { check(false, "setup: msg2"); return; } + std::vector kd; + devourer::sta::append_rsn_ccmp_psk(kd); + const std::vector g = gtk_kde(key32, 32, 1); + kd.insert(kd.end(), g.begin(), g.end()); + const std::vector m3 = authentic_with(ap, kMsg3Info, kd, true); + std::vector out; + check(sup.on_eapol(m3.data(), m3.size(), &out) == + Supplicant::Verdict::Malformed && + !sup.ptk_valid() && !sup.gtk_valid() && + sup.state() != Supplicant::State::Done && out.empty(), + "32-byte GTK: msg3 is refused, nothing installed, not Done"); + } + { + Authenticator ap; + Supplicant sup; + OpenSslCryptoOps crypto; + if (!handshake(ap, sup, crypto)) { check(false, "setup: four-way"); return; } + const uint32_t gg = sup.gtk_generation(); + const std::vector kd = gtk_kde(key32, 32, 2); + const std::vector g1 = authentic_with(ap, kGroup1Info, kd, false); + std::vector out; + check(sup.on_eapol(g1.data(), g1.size(), &out) == + Supplicant::Verdict::Malformed && + sup.gtk_generation() == gg && sup.gtk_len() == 16 && out.empty(), + "32-byte GTK: a group rekey is refused and the old GTK stays"); + } +} + +/* MESSAGE 3'S KEY LENGTH IS CCMP'S: 16. Any other value names a pairwise + * cipher this station did not negotiate; the frame is refused before its MIC + * is checked and installs nothing. The same message at 16 then completes. */ +void test_msg3_key_length_must_be_16() { + Authenticator ap; + Supplicant sup; + OpenSslCryptoOps crypto; + if (!through_msg2(ap, sup, crypto)) { check(false, "setup: msg2"); return; } + + std::vector kd; + devourer::sta::append_rsn_ccmp_psk(kd); + const std::vector g = gtk_kde(ap.gtk, 16, 1); + kd.insert(kd.end(), g.begin(), g.end()); + const std::vector w = wrap_key_data(ap, kd); + + for (uint16_t bad : {(uint16_t)0, (uint16_t)32}) { + ap.replay++; + const std::vector m3 = devourer::sta::build_eapol_key( + kMsg3Info, bad, ap.replay, ap.anonce, nullptr, w.data(), w.size(), + &ap.crypto, ap.ptk); + std::vector out; + check(sup.on_eapol(m3.data(), m3.size(), &out) == + Supplicant::Verdict::Malformed && + !sup.ptk_valid() && !sup.gtk_valid() && + sup.state() != Supplicant::State::Done && out.empty(), + bad == 0 ? "key length: a message 3 with Key Length 0 is refused" + : "key length: a message 3 with Key Length 32 is refused"); + } + const std::vector good = authentic_with(ap, kMsg3Info, kd, true); + std::vector out; + check(sup.on_eapol(good.data(), good.size(), &out) == + Supplicant::Verdict::Reply && + sup.state() == Supplicant::State::Done, + "key length: ...and the same message at 16 completes"); +} + +/* A GTK AT KEY ID 0 IS REFUSED. 0 is where the pairwise key is used, and a + * data plane selecting the key by the CCMP header's key id could not tell + * the two apart. Both routes a GTK arrives by. */ +void test_gtk_key_id_zero_is_refused() { + { + Authenticator ap; + Supplicant sup; + OpenSslCryptoOps crypto; + if (!through_msg2(ap, sup, crypto)) { check(false, "setup: msg2"); return; } + std::vector kd; + devourer::sta::append_rsn_ccmp_psk(kd); + const std::vector g = gtk_kde(ap.gtk, 16, 0); + kd.insert(kd.end(), g.begin(), g.end()); + const std::vector m3 = authentic_with(ap, kMsg3Info, kd, true); + std::vector out; + check(sup.on_eapol(m3.data(), m3.size(), &out) == + Supplicant::Verdict::Malformed && + !sup.ptk_valid() && !sup.gtk_valid() && + sup.state() != Supplicant::State::Done && out.empty(), + "gtk key id 0: message 3 is refused and installs nothing"); + } + { + Authenticator ap; + Supplicant sup; + OpenSslCryptoOps crypto; + if (!handshake(ap, sup, crypto)) { check(false, "setup: four-way"); return; } + const uint32_t gg = sup.gtk_generation(); + const uint8_t id0 = sup.gtk_key_id(); + uint8_t gtk2[16]; + std::memset(gtk2, 0x62, 16); + const std::vector g1 = + authentic_with(ap, kGroup1Info, gtk_kde(gtk2, 16, 0), false); + std::vector out; + check(sup.on_eapol(g1.data(), g1.size(), &out) == + Supplicant::Verdict::Malformed && + sup.gtk_generation() == gg && sup.gtk_key_id() == id0 && + std::memcmp(sup.gtk(), ap.gtk, 16) == 0 && out.empty(), + "gtk key id 0: a group rekey to key id 0 is refused, old GTK kept"); + } +} + +/* KEY-DATA PADDING IS 0xDD THEN ZEROS, 1 to 7 bytes of it, and it walks as + * elements: every length is accepted after a GTK KDE. A lone 0x00 is not + * padding - ID 0 is a real element - so a 0x00 ahead of the KDE is read as an + * element whose length runs off the end. */ +void test_gtk_kde_padding() { + uint8_t key[16]; + std::memset(key, 0x3c, sizeof key); + for (size_t n = 1; n <= 7; n++) { + std::vector kd = gtk_kde(key, 16, 2); + kd.push_back(0xdd); + kd.insert(kd.end(), n - 1, 0x00); + devourer::sta::GtkKde g; + char label[80]; + std::snprintf(label, sizeof label, + "padding: %zu byte(s) of 0xdd+zeros after the GTK KDE", n); + check(devourer::sta::find_gtk_kde(kd.data(), kd.size(), &g) == + devourer::sta::KdeResult::Found && + g.key_id == 2 && g.gtk_len == 16 && + std::memcmp(g.gtk, key, 16) == 0, + label); + } + std::vector lone = {0x00}; + const std::vector k = gtk_kde(key, 16, 2); + lone.insert(lone.end(), k.begin(), k.end()); + devourer::sta::GtkKde g; + check(devourer::sta::find_gtk_kde(lone.data(), lone.size(), &g) == + devourer::sta::KdeResult::Malformed, + "padding: a lone 0x00 is an element, not padding to step over"); +} + +/* A CryptoOps whose HMAC fails once when armed - the PRF under derive_ptk. */ +struct FlakyHmac : OpenSslCryptoOps { + int fail_next = 0; + bool hmac_sha1(const uint8_t* key, size_t key_len, const uint8_t* data, + size_t data_len, uint8_t out[20]) override { + if (fail_next > 0) { + fail_next--; + return false; + } + return OpenSslCryptoOps::hmac_sha1(key, key_len, data, data_len, out); + } +}; + +/* A FAILED DERIVATION LEAVES THE CANDIDATE AS IT WAS. A message 1 whose PTK + * derivation fails in our own CryptoOps must not leave its ANonce beside the + * previous handshake's PTK: the message 3 of the handshake already in flight + * still completes. */ +void test_msg1_crypto_failure_keeps_the_candidate() { + Authenticator ap; + Supplicant sup; + FlakyHmac crypto; + uint8_t snonce[32], other_anonce[32]; + std::vector out; + + std::memset(snonce, 0x7a, 32); + std::memset(other_anonce, 0x6b, 32); + ap.init(); + sup.start(crypto, ap.pmk, kSpa, kAa, snonce); + const std::vector m1 = ap.msg1(); + if (sup.on_eapol(m1.data(), m1.size(), &out) != Supplicant::Verdict::Reply || + !ap.on_msg2(out)) { + check(false, "setup: msg1/msg2"); + return; + } + + const std::vector other = devourer::sta::build_eapol_key( + devourer::sta::kKeyDescVersionCcmp | devourer::sta::kKiPairwise | + devourer::sta::kKiAck, + 16, ap.replay + 1, other_anonce, nullptr, nullptr, 0, nullptr, nullptr); + crypto.fail_next = 1; + out.clear(); + check(sup.on_eapol(other.data(), other.size(), &out) == + Supplicant::Verdict::CryptoError && + out.empty() && sup.crypto_errors == 1, + "msg1 crypto failure: the derivation failure is a CryptoError"); + + const std::vector m3 = ap.msg3(); + out.clear(); + check(sup.on_eapol(m3.data(), m3.size(), &out) == + Supplicant::Verdict::Reply && + sup.state() == Supplicant::State::Done && ap.on_msg4(out), + "msg1 crypto failure: ...and the handshake in flight still completes"); +} + +/* THE RSN ELEMENT IS FOUND THE WAY THE GTK KDE IS. Both searches use one + * walk, so an empty 0xdd element (a padding marker) ahead of the RSN element + * is stepped over, not taken as the end of the key data. */ +void test_rsn_element_after_an_empty_dd() { + Authenticator ap; + Supplicant sup; + OpenSslCryptoOps crypto; + std::vector rsn; + devourer::sta::append_rsn_ccmp_psk(rsn); + ap.rsn_override = {0xdd, 0x00}; + ap.rsn_override.insert(ap.rsn_override.end(), rsn.begin(), rsn.end()); + check(four_way_with_advert(ap, sup, crypto) == Supplicant::Verdict::Reply && + sup.rsn_mismatches == 0 && sup.ptk_valid(), + "rsn after 0xdd 0x00: the RSN element is found and matches"); +} + +/* A FAILED HMAC IS NOT A FORGERY. When our own CryptoOps fails while checking + * a MIC, message 3 and group message 1 are refused as CryptoError and counted + * in crypto_errors, never in mic_failures; nothing installs, the counter does + * not move, and the same frame is accepted once the provider works. */ +void test_mic_crypto_failure_is_not_a_mic_failure() { + Authenticator ap; + Supplicant sup; + FlakyHmac crypto; + uint8_t snonce[32], gtk2[16]; + const uint8_t rsc[8] = {0x10, 0x27, 0, 0, 0, 0, 0, 0}; + std::vector out; + + std::memset(snonce, 0x7a, 32); + ap.init(); + sup.start(crypto, ap.pmk, kSpa, kAa, snonce); + const std::vector m1 = ap.msg1(); + if (sup.on_eapol(m1.data(), m1.size(), &out) != Supplicant::Verdict::Reply || + !ap.on_msg2(out)) { + check(false, "setup: msg1/msg2"); + return; + } + + const std::vector m3 = ap.msg3(); + crypto.fail_next = 1; + out.clear(); + check(sup.on_eapol(m3.data(), m3.size(), &out) == + Supplicant::Verdict::CryptoError && + out.empty() && !sup.ptk_valid(), + "mic crypto failure: msg3 is a CryptoError and installs nothing"); + check(sup.crypto_errors == 1 && sup.mic_failures == 0, + "mic crypto failure: ...counted in crypto_errors, not mic_failures"); + out.clear(); + check(sup.on_eapol(m3.data(), m3.size(), &out) == + Supplicant::Verdict::Reply && + ap.on_msg4(out), + "mic crypto failure: ...and the same msg3 completes afterwards"); + + const uint32_t gg = sup.gtk_generation(); + std::memset(gtk2, 0x62, 16); + const std::vector g = ap.group1_next(gtk2, 2, rsc); + crypto.fail_next = 1; + out.clear(); + check(sup.on_eapol(g.data(), g.size(), &out) == + Supplicant::Verdict::CryptoError && + out.empty() && sup.gtk_generation() == gg, + "mic crypto failure: group msg1 is a CryptoError and installs nothing"); + check(sup.crypto_errors == 2 && sup.mic_failures == 0, + "mic crypto failure: ...counted in crypto_errors, not mic_failures"); + out.clear(); + check(sup.on_eapol(g.data(), g.size(), &out) == Supplicant::Verdict::Reply && + sup.gtk_generation() == gg + 1, + "mic crypto failure: ...and the same group msg1 installs afterwards"); +} + +int main() { + test_psk_known_answers(); + test_msg1_crypto_failure_keeps_the_candidate(); + test_rsn_element_after_an_empty_dd(); + test_mic_crypto_failure_is_not_a_mic_failure(); + test_msg3_key_length_must_be_16(); + test_gtk_key_id_zero_is_refused(); + test_gtk_kde_padding(); + test_ptk_sorting(); + test_against_a_real_four_way(); + test_parse_bounds(); + test_build_length_limit(); + test_mic_ok_refuses_a_bad_key(); + test_gtk_kde(); + test_four_way(); + test_forged_mic_is_rejected(); + test_group_rekey_replay_rejected(); + test_group_forged_mic_rejected(); + test_msg3_without_a_usable_gtk_is_refused(); + test_retransmit_is_matched_to_its_message(); + test_forget_wipes_key_material(); + test_anonce_mismatch_rejected(); + test_unencrypted_key_data_rejected(); + test_descriptor_version_refused(); + test_mic_refuses_other_descriptor_versions(); + test_group_rekey_before_four_way(); + test_replay_counter_rules(); + test_forged_msg1_cannot_poison_the_counter(); + test_msg1_on_a_live_association(); + test_msg3_retransmit_does_not_reinstall(); + test_group1_same_gtk_does_not_reinstall(); + test_forged_msg1_does_not_orphan_msg3_retransmit(); + test_rsne_downgrade_check(); + test_parse_rsn_sets(); + test_msg3_without_secure_installs_nothing(); + test_forged_msg1_first_does_not_poison_the_genuine_one(); + test_gtk_followed_by_truncated_element_is_refused(); + test_32_byte_gtk_is_refused(); + + if (g_fail) { + std::printf("supplicant_selftest: %d failure(s)\n", g_fail); + return 1; + } + std::printf("supplicant_selftest: OK\n"); + return 0; +}