Skip to content

wlan: the WPA2-Personal handshakes, sans-IO (W2a) - #1130

Merged
Japabu merged 5 commits into
mainfrom
w2a-wpa2
Nov 1, 2026
Merged

Japabu merged 5 commits into
mainfrom
w2a-wpa2

Conversation

@Japabu

@Japabu Japabu commented Nov 1, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

W2 (issues/there-is-no-wifi.md) is the handshakes wlan runs. Its SAE, AES-CMAC and PSK-SHA256 parts need P6, which the owner has paused. Its WPA2-Personal part needs only what lib/crypto already has. W4 depends on W2, so W2a, the WPA2 part, is built now.

Resulting behavior

toyos-wlan-handshake (system/wlan/handshake, a pure host-tested crate) is the supplicant side of two handshakes, sans-IO:

  • the 4-way handshake (IEEE Std 802.11-2020 §12.7.6);
  • the group key handshake (§12.7.7).

It covers AKM 2 (PSK) with CCMP-128, plus the BIP-CMAC-128 IGTK when management frame protection (PMF) is negotiated.

  • Setup. Supplicant::new(pmk, &Join) takes the PMK and what the association settled: AA, SPA, the beacon's RSNE/RSNXE and the station's own. An association it cannot key is refused as Unjoinable, by name.
  • Each frame. receive(frame, nonce) answers one EAPOL-Key frame. It returns a Step: the reply frame, plus the pairwise, group and integrity keys to install after sending it. Keys are held in toyos_crypto::Secret, which erases them on drop.
  • Refusals. Each one has its own name (Refused):
    • Replayed counter. The frame's Key Replay Counter is not above the last verified one. This includes message 1, which has no MIC and so never moves the counter.
    • Resent message 1. It is answered under the SNonce the first one drew. After a completed handshake, a message 1 rekeys under a fresh SNonce.
    • Retransmitted message 3. It is answered and installs nothing. A group key is installed only when it is not already the key under its ID.
    • Message 3's RSNE/RSNXE must match the beacon's byte for byte. A missing RSNXE (the downgrade from hash-to-element) is refused, and so is an RSNXE the beacon lacked.
    • Key IDs. A GTK under key ID 0 is refused, and so is an IGTK outside 4 and 5.
    • Integrity failures: a MIC mismatch, a failed unwrap, a second RSNE, and a duplicate KDE.
    • Malformed fields: wrong descriptors, wrong flag combinations, an RSC past 48 bits, and every length that runs past its frame.
  • State. A refused frame changes nothing, with one exception: once its MIC has verified, the replay counter advances. Leaving the network after a verified-but-refused message 3 is the caller's job (W4).

lib/crypto publishes ct::equal and Secret<N> unchanged, as ruled (option a). Secret<N> gains new, expose and expose_mut. Zeroable becomes public because it bounds Secret.

Decisions

  • Independent test authenticator. The tests drive the supplicant with an authenticator written from the standard (tests/support). It uses ring's HMAC-SHA1 and the aes crate's block cipher, so the two sides share no crypto code. Both crates were already in Cargo.lock; they are dev-dependencies only, and no new crate is added.
  • Reply version. The reply echoes the authenticator's EAPOL Protocol Version (1–3).
  • Ignored octets. Octets past the Packet Body Length are ignored.
  • Unknown content. Unknown elements and KDEs, including other vendors' KDEs, are skipped.
  • Second RSNE. A second RSNE in message 3 is refused instead of switching to its pairwise cipher: CCMP-128 is the only pairwise cipher supported.
  • Out of scope here (W2b): SAE, AES-CMAC, AKM 6, and the Transition Disable KDE with its PSK refusal. These wait for P6.

Round 2: the recorded handshake

tests/capture.rs replays two joins that wpa_supplicant v2.10 recorded on the T14 bench (Ubuntu 24.04). One process was the access point (mode=2) and the other the station, over two mac80211_hwsim radios. One join used plain PSK, the other required PMF.

  • Fixtures. tests/fixtures/{psk,pmf}.pcap, about 1.3 KB each. Each holds six records of the radiotap capture, unmodified: the Probe Response, the Association Request and the four EAPOL-Key frames. NOTICE and COMMITTED_FILES carry their provenance and digests. The test takes the RSNEs from the Probe Response and the Association Request.
  • Message 2. Given message 1 and message 2's SNonce, the supplicant's message 2 must equal the captured one byte for byte.
  • Message 4. From message 3, its message 4 must equal the captured one too.
  • Keys. The TK, GTK and IGTK must be the ones the station's own -K log printed. Both tests passed on the first run.
  • Control. Swapping the nonce order in the PTK derivation makes both fail at "message 2, byte for byte".
  • Clause check. The readings of 802.11 to check against the text are filed as issues/the-wlan-handshakes-readings-of-802-11-are-unchecked-against-its-text.md, 24 rows after review r1, owner the orchestrator.

Round-2 checks, both exit 0:

  • cargo run -- --ci host at af13f26ec: 107 steps green. The capture tests ran in it, and so did the licence and sourcegate gates over the new fixtures.
  • cargo clippy -p toyos-wlan-handshake --all-targets.

Evidence: https://github.com/ToyOSOrg/evidence/blob/807c77d17983f8bcaff037935664588246f99b8f/w2a-r2/README.md (the masked capture, the extraction tools, the control and the CI log).

Round 4: recorded PTK rekeys

tests/fixtures/rekey-{psk,pmf}.txt come from a second capture in which the station's wpa_ptk_rekey=6 drove two rekeys per run. The supplicant's six answers per run must be the station's, byte for byte. The TKs must match the station's log. The group keys are handed out only at the join, as the station itself did not reinstall them. Reinstalling the GTK, or reusing the SNonce in a rekey, reds both tests. --ci host exit 0 (107 steps) at 26652fe. Evidence: https://github.com/ToyOSOrg/evidence/blob/2abd65ded9086596565f9f694220ea5435755310/w2a-r4/README.md

Checks (round 1)

All at 228e4f76a unless stated otherwise.

  • cargo test -p toyos-wlan-handshake: 21 tests, exit 0.

  • cargo test -p toyos-crypto --features wpa2 --test vectors: exit 0. It now includes Annex J's four PRF vectors and two more pass-phrase-to-PSK vectors, plus RFC 6070's one case a pass-phrase and SSID can be. Each expected value was also recomputed with Python's hashlib/hmac before it went in.

  • cargo clippy -p toyos-wlan-handshake --all-targets: clean.

  • cargo run -- --ci host: exit 0, 107 steps all green (run started at 31a5254; it picked up the RFC 6070 test, which ran in it; the rest of 228e4f7 is an issue record and one comment, after which the crate's tests and clippy and the crypto vectors were rerun at 228e4f7, exit 0).

  • Negative controls. Six mutations, each applied as a checked patch, built, tested and reverted in the same run. Each reds exactly its target tests (exit 101):

    Mutation Tests that red
    RSNE unchecked message_3s_rsne_must_be_the_beacons
    replay unchecked a_replayed_counter_is_refused
    MIC unchecked the MIC test and every_single_octet_change_to_message_3_is_refused
    resent message 1 under another SNonce a_resent_message_1_keeps_its_snonce
    group key reinstalled the retransmitted-message-3, group-rekey and PTK-rekey tests
    key ID 0 accepted group_key_id_0_is_refused
  • Evidence: https://github.com/ToyOSOrg/evidence/blob/74cfa5780d85f2cb5aab9750e1d4e29f8c1f2feb/w2a-r1/README.md

No guest or metal test applies: the crate is pure, and nothing in an image uses it until W4.

Round 3. Rebased onto main a2a4548, with review r1's fixes at 1679ef9. --ci host exit 0 (107 steps) at both b3979f2 and 1679ef9. Evidence: https://github.com/ToyOSOrg/evidence/blob/f534bf79cb3bc67609d9f47cbbb503dcd7fffbaa/w2a-r3/README.md

Unresolved

  • The group key handshake is not in any recording. wpa_supplicant v2.10's access point parsed group_rekey and never rekeyed, and its wpa_cli has no REKEY_GTK. Group message 1 is tested only against the tests' authenticator. The PTK rekey is recorded (round 4).
  • Clause readings are unchecked against the text. The standard's text was not at hand, so the clause numbers and readings are mine. The rows to check are in issues/the-wlan-handshakes-readings-of-802-11-are-unchecked-against-its-text.md. The recording confirms the frames and keys, but not the citations or the refusals. The test-vector values are checked by computation; the Annex J subclause each one comes from is not cited.
  • Lenient rules. The GTK Tx bit and the group message 1 Key Length are not checked; nothing depends on them.

🤖 Generated with Claude Code

Japabu and others added 3 commits November 1, 2026 11:20
toyos-wlan-handshake (system/wlan/handshake) is the supplicant's side of
the 4-way and group key handshakes (IEEE Std 802.11-2020 §12.7) under AKM 2
with CCMP-128, and BIP-CMAC-128's IGTK when management frame protection is
negotiated. It takes the PMK and what the association settled, answers each
EAPOL-Key frame with the frame to send and the keys to install after it,
and refuses every hostile frame by name:

- a Key Replay Counter not above the last verified one, message 1 included;
- a resent message 1 is answered under its first SNonce; after the
  handshake, a message 1 rekeys under a fresh one;
- a retransmitted message 3 is answered and installs nothing, and a group
  key is installed only when it is not the key under its ID;
- message 3's RSNE and RSNXE must be the beacon's, byte for byte;
- GTK key ID 0, an IGTK outside 4 and 5, a MIC mismatch, failed unwraps
  and every length that runs past its frame.

lib/crypto publishes ct::equal and Secret<N> (with Zeroable, which bounds
it) unchanged, with new/expose/expose_mut: the MIC comparison and the
owners of the PTK and group keys. Its tests gain IEEE 802.11 Annex J's
four PRF vectors and two more pass-phrase-to-PSK ones.

The tests drive the supplicant with an authenticator written from the
standard on ring's HMAC-SHA1 and the aes crate's block cipher, so the two
sides share no crypto code. SAE, AES-CMAC, PSK-SHA256 and Transition
Disable are W2b, which waits for P6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The one RFC 6070 PBKDF2-HMAC-SHA1 case at 4096 iterations whose password
and salt a pass-phrase and SSID can be is a test of derive_pmk. The track's
W2 entry says what W2a built, that the captured handshake is still owed, and
what W2b waits on P6 for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two joins wpa_supplicant v2.10 recorded on the T14 bench, as access point
(mode=2) and station over two mac80211_hwsim radios: plain PSK, and PMF
required. Each fixture is six records of the radiotap capture, unmodified:
the Probe Response, the Association Request and the four EAPOL-Key
frames. tests/capture.rs takes the RSNEs from the first two. Given
message 1 and message 2's SNonce, the supplicant's message 2 must equal
the captured one; from message 3, its message 4 must too. The TK, GTK and
IGTK must be the ones the station's -K log printed. Swapping the nonces'
order in the PTK derivation reds both.

No group key handshake is recorded: v2.10's access point parsed
group_rekey and wpa_ptk_rekey and never rekeyed. The 802.11 readings the
crate cites are listed for a check against the text in
issues/the-wlan-handshakes-readings-of-802-11-are-unchecked-against-its-text.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu

Japabu commented Nov 1, 2026

Copy link
Copy Markdown
Collaborator Author

Review r1 of #1130 at af13f26

Architectural assessment

The split is right. toyos-wlan-handshake is a pure, sans-IO library of the future wlan service, placed as system/ax2xx/driver is. It takes trusted input only through Supplicant::new, which validates the RSNE and RSNXE once through toyos-ieee80211, and through receive, which parses each frame at its own boundary. The radio, the key tables and leaving the network stay with the caller (W4).

lib/crypto's new public surface is the minimum the ruling allows: ct::equal, Secret::{new, expose, expose_mut}, and Zeroable, which must be public because it bounds Secret. The crate adds no new dependencies; ring and aes are dev-only and were already in the lock.

Net change: about 900 lines of production code (crate src about 900, crypto +25), about 1,130 lines of tests and two 1.3 KB fixtures.

Security properties, traced by hand:

  • Replay. receive refuses key.replay <= last before dispatch, message 1 included. Only a frame whose MIC verified moves the counter, so a forged message 1 cannot raise it.
  • KRACK-class reinstallation. A retransmitted message 3 matches current and installs no TK (fresh == false). A GTK or IGTK equal in ID and key to the installed one is not handed out, so its RSC is never reset. Held or replayed frames are stopped by the counter. I traced the interleavings: msg3 retransmission held across a rekey message 1, and group message 1 held across a later rekey. None reinstalls.
  • Nonce reuse. An SNonce is reused only while a handshake is pending. After completion, message 1 draws a fresh one, so no (ANonce, SNonce) pair is installed twice.
  • Downgrade. Message 3's RSNE and RSNXE are compared byte for byte, absence included. A second RSNE is refused.
  • Ordering. Header and flag checks run first, then the MIC, then the counter update, then the unwrap (whose integrity check is constant time and wipes on failure), then the KDE parse.
  • Constant time. The MIC, the unwrap check and the installed-key comparisons all go through ct::equal.
  • Secrets. Secrets are owned by Secret. No type holding them derives Debug, and nothing logs. The remaining copies are by-value temporaries, which secret.rs's contract already excludes.
  • Hostile input. Hostile input is bounded. forbid(indexing_slicing, arithmetic_side_effects, unwrap_used) leaves only invariant expects, each of which I checked holds.

Evidence, verified:

  • ci-host-2.log shows 21 + 2 crate tests passed and Host(None): 107 step(s), all green, exit 0.
  • git diff 228e4f76a af13f26ec touches no src/, so round 1's six controls still apply.
  • The fixture digests match NOTICE and COMMITTED_FILES.
  • The Annex J PRF and PSK values and RFC 6070 case 3 match the published values.
  • The branch merges cleanly with current main.

Oracle independence:

  • The capture is the independent oracle for layout, PTK derivation and nonce order. The handshake.rs authenticator shares the author's readings of the standard, so it cannot catch a misreading, but the capture can, and the nonce-swap control proves it does.
  • Committing the capture is the track's own exit (W2, "a captured handshake"). wpa_supplicant is a userland black box, and Linux appears only as the recording host.
  • The clause readings are unchecked against the standard, as the new issue records. Noted, not demanded.

Required

  1. issues/the-wlan-handshakes-readings-of-802-11-are-unchecked-against-its-text.md:22-40: the table omits readings that refusals and public contracts rest on.

    • Problem. The exit deletes the file once the 17 rows are confirmed. Unlisted readings would then stay unchecked with no record. Missing:
      • supplicant.rs:296: Ack set and Request/Error clear on every authenticator frame.
      • supplicant.rs:315, :356, :406: the flag combinations of message 1 (no Install, no Encrypted), message 3 (Install and Secure) and group message 1 (Secure, no Install).
      • supplicant.rs:322, :359: Key Length 16 in messages 1 and 3.
      • supplicant.rs:80-81, :104: Key RSC and IPN are the last PN sent, which the receive replay check starts from.
      • supplicant.rs:253-256: the MFPC/MFPR negotiation rule.
    • Failure scenario. Suppose Key RSC is actually the next PN. W4's replay check, built on GroupKey::rsc's doc, would then drop the first group frame. Suppose instead a conformant AP sends message 1 with another Key Length. Every join would fail KeyLength. Either way the issue would already have been closed.
    • Fix. Add these rows.
  2. system/wlan/handshake/src/lib.rs:81: the reason text for UnknownMessage is incomplete.

    • Problem. The text reads "a group key frame without a Key MIC, or a message 3 without Install and Secure". The refusal is also returned for a message 1 with Install set (supplicant.rs:315-320), and for a group message 1 with Install set or Secure clear (:406-407).
    • Failure scenario. The crate refuses by name and W4 will log Display. A group message 1 with Install set would be logged as "a group key frame without a Key MIC", which is false.
    • Fix. Either make the text cover all three sites, or split the refusal.
  3. system/wlan/handshake/src/supplicant.rs:6-7 and :191: two comments say "until its message 3 verifies", which is false.

    • Problem. pending is taken only at :390, after every check has passed. A message 3 whose MIC verifies but is refused (RsneDiffers, NoIgtk, Rsc, …) leaves pending in place.
    • Consequence. A following message 1 is answered with the old SNonce. The header promises otherwise, and a reader reasoning about nonce freshness is misled.
    • Fix. Say "until a message 3 is accepted" in both places.

Optional

  • lib/crypto/src/ct.rs:28-29: "Its time depends on their lengths alone" states as a guarantee what lib.rs:11-13 and ct.rs:3-5 say needs compiler and hardware validation. Suggested wording: "does not branch or index on their contents".
  • supplicant.rs:178: 2,304 octets is the maximum MSDU (an EAPOL PDU is an MSDU), not the maximum MPDU. Correct the bound's rationale.
  • eapol.rs:118 and :160: if let Some(...) silently skips an invariant. Because body ≥ 95, the MIC field is always present. If it ever were not, seal would send an unsealed frame without saying so. Use expect so it fails loudly.
  • system/wlan/handshake/Cargo.toml:16-17: ring supplies only HMAC-SHA1. The PRF and the key wrap are the tests' own code, built on ring and on aes's block cipher. Reword the comment.
  • lib.rs:41: pub const ALL serves only every_reason_is_its_own. lib/ieee80211 has the same precedent, so keep or drop it as you see fit.
  • issues/there-is-no-wifi.md W2a bullet: "since it needs nothing of P6 …" is rationale, which issues/README.md keeps out of tracks. Trim it.
  • Message 1 injection: a forged message 1, which carries no MIC, replaces pending's ANonce. The genuine message 3 is then AnonceChanged until the AP resends message 1. This is the standard's own behaviour with its single TPTK, and it is availability only. It may deserve one line where W4 handles retries.

SEND BACK

…he comments say what holds

- The clause-check issue gains seven rows: the Ack, Request and Error
  rule; message 1's, message 3's and group message 1's flags; Key Length
  16; Key RSC and IPN as the last packet number sent; the MFP negotiation.
- UnknownMessage's reason names every combination it refuses.
- The pending handshake lasts until a message 3 is accepted, not until
  one verifies: a message 3 refused after its MIC verified accepts
  nothing, and a following message 1 keeps the SNonce. The behaviour
  stays: no PTK from that pending handshake was installed, so no
  (ANonce, SNonce) pair is installed twice, and a verified refusal is the
  caller's reason to leave the network, after which a new Supplicant
  draws a new SNonce.
- The MIC field's presence is an expect, not a silent skip; ct::equal's
  doc says what it does, not a timing guarantee; KEY_DATA_MAX's bound is
  the MSDU's; the dev-dependency comment and the track bullet are cut to
  what is true.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu

Japabu commented Nov 1, 2026

Copy link
Copy Markdown
Collaborator Author

Review r1 fixed at 1679ef9 (rebased onto main a2a4548).

Required

  1. Clause-check table. The issue now has rows 18–24:

    • the Ack, Request and Error rule;
    • message 1's, message 3's and group message 1's flags;
    • Key Length 16;
    • Key RSC and IPN as the last PN sent (GroupKey::rsc, IntegrityKey::ipn);
    • the MFPC/MFPR negotiation.
  2. UnknownMessage. The reason now names all three sites: a message 1 with Install; a message 3 without Install or Secure; a group key frame without a Key MIC or Secure, or with Install.

  3. The two comments. Both now say "until a message 3 is accepted". I kept the behaviour, for three reasons:

    • no PTK from the pending handshake was installed, so no (ANonce, SNonce) pair can be installed twice;
    • the standard's TPTK is likewise replaced only by a new message 1;
    • a verified refusal is the caller's reason to leave the network. A new association builds a new Supplicant, which draws a new SNonce.

    The header now says this.

Optional, taken

  • ct::equal's doc now reads "without branching or indexing on their contents".
  • KEY_DATA_MAX's bound is now explained by the MSDU.
  • expect replaces both if let skips of the MIC field.
  • The dev-dependency comment is reworded.
  • The rationale is cut from the track's bullet.

Optional, declined

  • ALL is kept, on lib/ieee80211's precedent.
  • The message 1 injection note belongs with W4's retry handling.

Checks

The second capture's station set wpa_ptk_rekey=6, so each run holds a
join and two rekeys it asked for. rekey-{psk,pmf}.txt carry the
addresses and RSNEs from the run's radio capture, then every EAPOL-Key
frame and key the station's -dd -K log printed. Rekeys are
CCMP-protected on air, so the log is their record. Each of the
supplicant's six answers must be the station's own, skipping the
Requests the station sent on its own. Each TK must be its logged PTK's,
and the group keys are handed out at the join alone, as the station
logged "Not reinstalling already in-use GTK" and "... IGTK" at each
rekey. Reinstalling the GTK, or reusing the installed handshake's
SNonce in a rekey, reds both tests.

The group key handshake stays unrecorded: v2.10's wpa_cli answered
"Unknown command 'REKEY_GTK'".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu

Japabu commented Nov 1, 2026

Copy link
Copy Markdown
Collaborator Author

Rekey fixtures folded in at 26652fe, on review r1's fixes (1679ef9, reply above).

  • What was recorded. Capture r2 on the T14 bench (v2.10). The station's wpa_ptk_rekey=6 drove two PTK rekeys per run.
  • The fixtures. tests/fixtures/rekey-{psk,pmf}.txt:
    • the addresses and RSNEs, from the run's Probe Response and Association Request;
    • then every EAPOL-Key frame and key that the station's -dd -K log printed. The rekeys are CCMP-protected on air, so the log is their record.
  • What the tests require.
    • All six answers per run must be the station's own, byte for byte.
    • Each TK must be the one the station logged.
    • Group keys are handed out at the join only. The station logged "Not reinstalling already in-use GTK/IGTK" at each rekey.
  • Controls. Each of these reds only the two rekey tests:
    • handing the GTK out again at every rekey;
    • answering a rekey under the installed handshake's SNonce.
  • Group key handshake: still unrecorded. v2.10's wpa_cli answered "Unknown command 'REKEY_GTK'", and round 1 showed group_rekey in the AP's network block parsed but never firing. The track records this. Group message 1 is tested only against the tests' authenticator.
  • --ci host. Exit 0, 107 green at 26652fe, with the four capture tests in it.

Evidence: https://github.com/ToyOSOrg/evidence/blob/2abd65ded9086596565f9f694220ea5435755310/w2a-r4/README.md

@Japabu
Japabu marked this pull request as ready for review November 1, 2026 11:53
@Japabu
Japabu added this pull request to the merge queue Nov 1, 2026
Merged via the queue into main with commit de0448b Nov 1, 2026
10 checks passed
@Japabu
Japabu deleted the w2a-wpa2 branch November 1, 2026 12:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant