Skip to content

wlan, radio, wifi and the settings program: WPA2-Personal (W4a) - #1140

Merged
Japabu merged 8 commits into
mainfrom
wifi-w4
Nov 1, 2026
Merged

Japabu merged 8 commits into
mainfrom
wifi-w4

Conversation

@Japabu

@Japabu Japabu commented Nov 1, 2026 •

Copy link
Copy Markdown
Collaborator

W4a of issues/there-is-no-wifi.md: wlan, the radio and wifi protocols and the settings program wifi, for WPA2-Personal. Host-tested. No radio says hello until W5 ships ax2xx. Head da9369136 on origin/main 190f3b164. Round 2 answers review r1: #1140 (comment)

What it builds

  • toyos-radio (lib/radio): the protocol between wlan and each Wi-Fi driver. The driver says hello (address, bands), then answers scans with the Beacons and Probe Responses it heard, and joins by Open System. A join names its BSS whole: the SSID, the station's RSNE, and the elements of the Beacon or Probe Response the BSS was chosen by, so the driver keeps no scan results. It carries EAPOL both ways. It installs the keys wlan derives, an install taking effect after every frame sent before it, opens the link on Authorize, and reports the association's end. Every frame is decoded once and refused by name.
  • toyos-wifi (lib/wifi): the protocol between wlan and the settings programs: scan, connect, disconnect and status. A passphrase travels to wlan and never back.
  • toyos-wlan-station (system/wlan/station): every decision wlan makes, sans-IO.
    • BSS choice: WPA2-Personal as toyos-wlan-handshake keys it. Any BSS at or above −70 dBm ranks before every weaker one. Among those, the higher band wins, then the stronger signal; among weaker ones, the stronger signal wins. Every unjoinable BSS is refused by name: open/WEP, SAE-only, no PSK, no CCMP-128, another group cipher, protection required under another cipher, a membership selector not met, a band the radio lacks.
    • Connect, disconnect and status. A new connect supersedes the old one, and an abandoned join is left, its late answers dropped.
    • A wrong passphrase is refused on what the station saw: "the AP did not answer message 2", with how many were sent and how the attempt ended.
    • A connection that was up and is lost is joined again once, by a new scan. So is one whose radio ended, at the next hello.
    • The radio is distrusted. A frame that does not decode, a frame out of the protocol's order, or a missed bound ends it by name, and what waited on it is refused. Malformed off-air content is counted and dropped. A refusal after a verified MIC leaves the network. One before it, a forged frame included, drops only that frame.
  • wlan (system/wlan, in the system image's [boot] start, service = true) and wifi (system/wifi; the shell starts it). wifi connect <network> reads the passphrase from standard input's descriptor itself, so no argument list, std echo or std buffer holds it. wlan logs, as counts only, what each scan and each join dropped.
  • FrameRx::wipe (SDK): zeroes a receive buffer with volatile stores. wlan calls it once the station has a settings program's frame, and again before the buffer is freed.
  • toyos-wlan-handshake: Refused::verified, an exhaustive match, tells wlan which refusals follow a verified MIC. Plaintext is split (EncryptedMessage1 is message 1's) so each reason arises on one side of the MIC check only.

Decisions for review

  • The ports run the other way from Wi-Fi is a track: the accepted AX2xx design's stages with owners and exits, and the issues it owes #876 §2's table, and the orchestrator accepted it as the owner's split ruling's pattern. wlan serves radio and each driver connects, as each NIC driver connects to netstack's link. HELD_ALONE lets only ax2xx receive radio and only wifi receive wifi.
  • The track gains W12, AP mode on the AX2xx, after W6, by the owner's direction: a Linux station joining a ToyOS access point is the exit. It also records W5's test network (the AMD bench's AX200 as a transient Linux access point) and W7's (the T14's AX210 likewise).
  • The W2b half waits with W2b: SAE before PSK, the Transition Disable mark, and its refusal of a PSK-only BSS. Until then a transition-mode network is joined by PSK, and an SAE-only one is refused "this station does not run SAE yet". No mark exists, so the Transition Disable weakness is not true of the tree, and the track now has the pull request landing the mark file it.
  • Not joined: open networks (by design), hidden networks (no directed probe), and a 64-digit hexadecimal PSK. lib/crypto's PairwiseMasterKey has no constructor from bytes, and P6 is paused. wlan drives one radio at a time and refuses a second by name.
  • Policy, not the standard: the −70 dBm threshold and the bounds (hello 5 s, scan 20 s, join 5 s, handshake 10 s, leave 5 s). Each is stated at its constant.
  • issues/the-wlan-handshakes-readings-… becomes issues/wlans-readings-of-802-11-are-unchecked-against-its-text.md, with rows 25–30 for this pull request's readings: Annex J.4.1, membership selectors, a BSS's own channel, hidden SSIDs, channel numbering, field widths. They were written without the text at hand.

Checks and evidence

Round 2, https://github.com/ToyOSOrg/evidence/blob/744977e5371d1de74b1af8e782dc54e2b5a71074/w4-r2/README.md:

  • cargo run -- --ci host at 5958bdc5f: exit 0, 108 steps green. da9369136 changes one type in wifi, a ToyOS-only program no host step builds.
  • cargo run -- --build-only at da9369136: exit 0, bin/wlan and bin/wifi in ROOT.
  • wifi status in a guest: a one-off, reverted patch to launchcase started wlan and had its job launch wifi status, which said no radio. That exercises the supervisor's acceptors and the wifi connector. It ran on 71bad9cf0's tree, and status reaches nothing changed since.
  • Seven negative controls at 5958bdc5f, each red only its target: an unanswered message 2 named as no message 1; the band ignored; a BSS outside a scan accepted; a verified refusal not left on; a positive signal decoded; no rejoin; Mic classed as verified.
  • Oracles: a whole join replays both of W2a's recorded handshakes (wpa_supplicant v2.10 over mac80211_hwsim). The station sends the recorded message 4 byte for byte, installs the recorded station's TK, GTK and IGTK, and hands the driver the recorded Probe Response's elements. Wrong-passphrase, loss and rekey cases run against the handshake crate's test authenticator, which shares no crypto code with the station.
  • Round 1 (https://github.com/ToyOSOrg/evidence/blob/3ef43ee150b4031633a78bfd56efa16287b67e30/w4-r1/README.md) also had the shipped image, booted under QEMU, log wlan: serving radio and wifi.
  • Net: +3,935 −17 over 29 files. Production Rust +2,405, tests +1,271; the rest is manifests, the lock and issues.
  • No committed integration or metal test. QEMU has no radio, and the decisions are host-tested. W5's T14 row is the first to drive wlan through a test job holding wifi.

Risks and limits

  • Not wiped by this change: the kernel's message frames, which carry a connect's passphrase and every key and are "never zeroed" (kernel/src/frame_bytes.rs), and the compiler's own copies. These are recorded in issues/a-wi-fi-passphrase-leaves-copies-outside-wlans-wiped-buffers.md.
  • Unverified on screen: that a passphrase typed at a terminal is not echoed. I read it from the code (std's canonical stdin is what echoes) and have not watched it.
  • A scan asked while a join is past its scan is answered with that join's scan, not a new one.
  • A Supplicant::new refusal of the chosen BSS fails the join rather than trying the next one. plan checks the same rules, so the refusal is a disagreement between the two, refused by name.

Next

W5 (bring-up and passive scan on the T14's AX210) depends on P1's bench exits, P4 (landed), W3 (landed) and this. It also needs the test networks: a WPA2-Personal and a WPA3-Personal access point at the T14's bench, which the orchestrator owns. A WPA2 join on the T14 is W6. W6 also needs P8, because a platform with no transmit table transmits nothing. The AMD bench's AX200 is W7, after P2 and P3.

🤖 Generated with Claude Code

Japabu and others added 4 commits November 1, 2026 18:16
W4 of issues/there-is-no-wifi.md, its WPA2 half: what needs no SAE.

- toyos-radio (lib/radio): the protocol between wlan and each Wi-Fi
  driver. wlan serves `radio` and each driver connects, as each NIC
  driver connects to netstack's `link`: a port has one server, and there
  is one driver per radio function. #876's table had the driver serving.
- toyos-wifi (lib/wifi): the protocol between wlan and the settings
  programs. A passphrase travels to wlan and never back.
- toyos-wlan-station (system/wlan/station): every decision wlan makes,
  sans-IO. BSS choice (WPA2-Personal as the handshake crate keys it, a
  strong BSS before a weak one, then band, then signal), connect,
  disconnect and status, a wrong passphrase refused because message 2
  went unanswered, a lost connection joined again once, and a radio that
  breaks its protocol or misses a bound ended by name.
- wlan (system/wlan) and wifi (system/wifi): the programs. wlan is in
  `[boot] start`; the build lets only `wifi` receive `wifi` and only
  `ax2xx` receive `radio`.
- toyos-wlan-handshake: `Refused::verified`, so the caller knows which
  refusals follow a verified MIC and leaves on them. `Plaintext` is split
  so that each reason arises on one side of the MIC check only.

Not built until W2b: SAE before PSK, the Transition Disable mark and its
PSK refusal. No image ships a radio driver until W5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A message 3 whose MIC does not verify is counted and dropped, so a
frame anyone can forge cannot end the association; the join test now
holds that. A join made again after a loss is no different from any
other: one that fails is not made again, so the flag that said so and
the refusal only it reached are gone. The radio protocol's header
states the leave rule wlan enforces: each Leave is answered by its own
Left::Asked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner's direction, through the orchestrator: ToyOS later serves as an
access point on the AX2xx, with Linux the independent station; a ToyOS
station joining it is a later regression row, never the exit. Its
firmware facts (AP-mode support, band and regulatory limits) are a
reader's question for the specification.

W5's test network is the AMD bench's AX200 as a transient Linux access
point, with a generated SSID and passphrase; W7's swaps the roles, the
T14's AX210 serving while the AMD bench runs ToyOS. Whether it offers SAE by
hash-to-element, PMF and HE is still to be read on the bench. The port
direction #1140 chose is recorded as accepted.

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 #1140 at be527dd95 (base 190f3b164)

I reviewed the code against the public standards and the track, plus #876's body. I read no reference driver and no Wi-Fi demo. I ran nothing; every point below comes from reading the code and the evidence.

Architecture

The split matches #876 and is sound. The radio and wifi protocols are lib/ crates at real process boundaries. The station is sans-IO and holds every decision. wlan is a thin loop over it, and the build gate is three lines in HELD_ALONE. The reversed port direction is accepted, as the brief says.

The production code is about 2,264 lines and the tests about 1,247. That is in proportion to the scope. The vocabulary that runs ahead of W4 (Left::Firmware/RfKill/BeaconLoss, 6 GHz numbering, the rejoin after a radio's end) is small, and host tests exercise it.

Checked and sound:

  • Refused::verified. Every true reason arises only after mic_verifies: in unwrap, KeyData::parse, group_keys and pn. Every false reason arises before it. Splitting EncryptedMessage1 out of Plaintext was needed and is done correctly.
  • Hostile driver input. Event::decode handles every input. No byte from a driver reaches a panic: unreachable! on Link::None is safe because radio_ready drops the peer once has_radio() turns false. Under the stated in-order contract, one abandoned bool is enough.
  • Own RSNE. The bytes are right: length 20, MFPC is bit 7.
  • The gate covers receives and [apps].
  • Evidence. Only clippy and the station tests needed rerunning after the fix, and both were rerun.

BLOCKER

  1. lib/radio/src/lib.rs:9-11, 265-267: Command::Join carries no SSID, so the protocol cannot do the job it documents.

    • The contract says the driver "associates, its request carrying the elements the command names". But an Association Request must carry the SSID element (IEEE 802.11 §9.3.3.6), and Join gives only BSSID, channel and RSNE.
    • Failure scenario: the W5/W6 writer implements Join from this contract and cannot build the frame. They must invent an unstated per-BSSID cache of the Seen frames, which is state duplicated across two processes that the protocol never mentions. A driver that dropped its scan results could not join at all.
    • Fix: carry the SSID in Join, since wlan holds it. If the driver is meant to keep the chosen BSS's Beacon/Probe Response for its HT/VHT/HE elements, say so in the contract.
  2. Passphrase wipes the compiler may remove, and passphrase copies left in memory with no issue filed.

    • system/wlan/src/main.rs:59 copies every client payload into a Vec. Line 167 then zeroes it with a plain loop just before the Vec is dropped. __rust_dealloc is a known deallocator, so LLVM may delete those stores as dead.
    • system/wifi/src/main.rs:49 and :64 wipe the same way, with the same exposure.
    • The PR says the FrameRx buffer "holds a connect's passphrase until the next frame overwrites it". That is false for the normal case: wifi connect sends one frame and disconnects, and end_client frees the Box<FrameRx> with the passphrase still in it.
    • Neither this retention nor the kernel's message buffers are recorded in issues/, which the no-silent-debt rule requires.
    • Fix:
      • Pass peer.rx.payload(len) straight to station.request. The borrows of self.clients and self.station are disjoint, so the copy is not needed.
      • Wipe the CLI's buffers with a volatile mechanism, such as toyos-crypto's Secret.
      • File the FrameRx and kernel-buffer retention as an issue with an owner and an exit, or add an SDK wipe.
  3. system/wlan/station/src/lib.rs:93-105, 221-223: only tests read Counters.

    • wlan never logs or reports them. "No production machinery ships solely to satisfy a test."
    • Failure scenario: on the T14, a radio that delivers nothing but malformed Beacons shows only "no BSS of that name was heard". The counter that would explain it is invisible.
    • Fix: either have wlan report them (for example, a log line at scan done or when it leaves a network), or delete them and assert the drop by its behaviour: nothing sent, nothing ended.
  4. system/wlan/station/src/lib.rs:47-49: the SCAN_BOUND comment's arithmetic is false.

    • At its own numbers, 80 channels × 1.5 × 102.4 ms ≈ 12.3 s, which is not "under 10 s".
    • The three bands actually have about 97 channels (13 + 25 + 59), which is about 14.9 s.
    • The 20 s bound still holds. Correct the comment or delete it.
  5. issues/there-is-no-wifi.md:390-391: "wlan runs in every image and answers 'no radio is present'" is false, and nothing shows it.

    • images/console.toml and images/diag.toml do not start wlan.
    • The one-off boot shows only wlan: serving radio and wifi. No client asked it anything, so the "answers" half has no support.
    • Fix: say "the system image". Then either record wifi status printing no radio in that boot, which is cheap and also exercises the untested endow::service/acceptor wiring, or drop the "answers" clause.

SUGGESTION

  • system/wlan/src/main.rs:220: the poller capacity is undercounted. It is declared as 3 + MAX_CLIENTS (11). In one wait, each acceptor token issues two watches, and a reused slot's stale completion issues one more. The worst case is about 13 watches, which trips the pending < capacity assert. wlan has no restart, so that panic ends Wi-Fi until reboot. Declare the real bound.
  • main.rs:162-171: a client already marked for ending still has its later frames acted on. If a client sends a malformed frame and then a Connect in the same batch, the join still starts. Stop feeding a client's frames once EndClient is queued.
  • main.rs:194: main ignores the Out::EndRadio(String) reason. The log already carries it, so tests can read the reason there.
  • lib/wifi/src/lib.rs:347: position(..).unwrap_or(0) silently encodes an unlisted Security variant as "open". Use a match.
  • lib/wifi/src/lib.rs:51-52: nothing uses MAX_PASSPHRASE. Passphrase hardcodes 63 at station/src/lib.rs:109-118. Use the constant there, or delete it.
  • lib/wifi/Cargo.toml: the settings protocol depends on the driver protocol just for Band and Signal.
  • choice.rs:176: the station counts the HT/VHT/HE selectors as met for every radio, though Hello carries no PHY capabilities. That is true of ax2xx; say so where it matters.
  • station/src/lib.rs:483-486: a Supplicant::new refusal of the best BSS fails the whole join without trying the next joinable BSS, while networks() still marks that BSS joinable.
  • station/src/lib.rs:52-53: the HANDSHAKE_BOUND comment states authenticator retry timing without a source. Cite the MIB's dot11RSNAConfigPairwiseUpdateCount/TimeOut, or just call it policy.
  • system/wifi/src/main.rs: a passphrase typed at a terminal is echoed on screen.

SEND BACK

Japabu and others added 3 commits November 1, 2026 20:18
…iped

- radio: Join carries the SSID and the elements of the Beacon or Probe
  Response the BSS was chosen by, so a driver keeps no scan results. Its
  association request has the SSID (§9.3.3.6), and its configuration has
  the operation elements.
- wlan reads a settings program's frame where it was received and wipes
  the buffer (FrameRx::wipe, volatile) once the station has it. Key
  commands' payloads are Secrets. The wifi program's request and its
  passphrase line are Secrets too. What is still copied elsewhere (the
  kernel's message frames, std's stdin buffer, the terminal's echo) is
  issues/a-wi-fi-passphrase-leaves-copies-outside-wlans-wiped-buffers.md.
- The station logs what it dropped: per scan, the malformed, off-channel
  and past-bound frames; per join, the stray and refused EAPOL frames.
- The scan bound's arithmetic is corrected and the handshake bound is
  called policy. The passphrase bound is MAX_PASSPHRASE, Security is
  encoded by a match, and the selector check says why HT, VHT and HE's
  are met.
- wlan's poller is sized by construction: each ready token is handled
  once a pass, and MAX_WATCHES counts what a pass registers. A client
  ended for what it sent has its later frames ignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… is true

`wifi` reads standard input's first line from the descriptor itself.
std's canonical stdin echoes each byte and keeps the line in a static
buffer; reading the descriptor directly does neither. The issue drops
those two rows and keeps the kernel's message frames.

The track says `wlan` runs in the system image, where `wifi status`
answered `no radio` in a QEMU boot. Console and diag images do not start
it.

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

Japabu commented Nov 1, 2026

Copy link
Copy Markdown
Collaborator Author

Round 2: review r1 answered

Head da9369136. The new commits are 17dfcd113 (track: W12 AP mode, and W5's and W7's test networks), 71bad9cf0, 5958bdc5f and da9369136 (one type in wifi, which the image build caught).

Blockers

  1. Join names its BSS whole. It now carries the SSID (1 to 32 octets, refused by name otherwise) and two element lists: request, the station's RSNE and any RSNXE, at most 514 octets; and heard, the elements of the Beacon or Probe Response the BSS was chosen by. The contract states that a driver keeps no scan results. MAX_PAYLOAD is now a join's, and wlan asserts at compile time that both protocols fit an IPC frame. The recorded join's test checks the SSID and that heard is the Probe Response's elements whole. tests/wire.rs refuses an empty or over-long SSID, empty or broken elements, and a truncated join.
  2. Wipes.
    • wlan no longer copies a client's frame: it hands FrameRx::payload to the station and then calls the new FrameRx::wipe (SDK, volatile stores and a fence). It also wipes when a client ends, before the buffer is freed.
    • Key commands' payloads are Secrets (Payload::Key). Nothing else wlan sends carries a secret.
    • wifi encodes its request into a Secret (Request::encode now writes into the caller's buffer). It reads the passphrase into a Secret from descriptor 0 itself, so std's canonical stdin, which echoes each byte and keeps the line in a static buffer (sdk/std/sys/stdio.rs), never sees it. That also removes the terminal echo. I found this by reading the code; I have not watched it on a screen.
    • The false PR claim is corrected.
    • issues/a-wi-fi-passphrase-leaves-copies-outside-wlans-wiped-buffers.md (owner: the orchestrator) records the kernel's message frames, "never zeroed" (kernel/src/frame_bytes.rs), and the compiler's own copies. Its exit is the kernel zeroing a message's frames.
  3. Counters are reported. At each scan's end wlan logs how many BSSes it heard and how many malformed, off-channel and past-bound frames it dropped. When a join ends it logs the stray and refused EAPOL frames. Both lines are counts only and are asserted in tests.
  4. SCAN_BOUND's comment now reads: about 97 20 MHz channels (13 + 25 + 59) at some 150 ms each is about 15 s, and the value is policy. HANDSHAKE_BOUND is called policy too.
  5. The track now says the system image. The wiring is exercised: a one-off patch to launchcase started wlan and gave test-runner starts = ["wifi"], and its job launched /system/bin/wifi status with a pipe for stdout. The job asserted "no radio\n", the guest test exited 0, and the patch was reverted (log, patch). It ran on 71bad9cf0's tree. The only code change since is wifi's stdin read, which the status path never reaches.

Suggestions

  • Poller capacity, taken. Each ready token is handled once a pass (BTreeSet). MAX_WATCHES = 2 * 2 + 1 + MAX_CLIENTS counts what a pass can register: an acceptor watches itself and the connection it accepted, and the radio and each client watch themselves. A stale completion now costs one idle pump at most.
  • An ended client's later frames, taken. The station keeps the clients it ended and ignores their frames until client_gone, which a test checks.
  • Terminal echo, taken. See blocker 2.
  • Security encoding, taken: a match both ways. MAX_PASSPHRASE, taken: Passphrase uses it, and Request::encode cuts there plus one, which wlan refuses whole. The selector reading, taken: the check says the radios radio serves are HE stations. HANDSHAKE_BOUND, taken: called policy.
  • EndRadio's reason, declined. The station logs it, and tests read it from Out.
  • toyos-wifi depending on toyos-radio, kept. Band and Signal mean the same at both ports; a copy would be two definitions of one thing.
  • Trying the next BSS when Supplicant::new refuses the best, declined. plan checks what Supplicant::new checks, so a refusal means the two disagree, which is our bug. It is refused by name, not hidden behind a second choice.

Checks at the head

std's canonical stdin (sdk/std/sys/stdio.rs) echoes every byte and holds
the line in a static buffer, so no program reads a secret through it
unseen. The issue is the orchestrator's. wifi's own read of descriptor 0
says it works around it, and goes when the issue closes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu
Japabu marked this pull request as ready for review November 1, 2026 20:05
@Japabu
Japabu added this pull request to the merge queue Nov 1, 2026
Merged via the queue into main with commit ece8ef4 Nov 1, 2026
10 checks passed
@Japabu
Japabu deleted the wifi-w4 branch November 1, 2026 20:29
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