396 lines
20 KiB
Markdown
396 lines
20 KiB
Markdown
to-do: rewrite & convert to html for docs pages
|
|
|
|
# Wi-Fi
|
|
|
|
MontaukOS drives Intel AX210-family adapters (the reference part is the AX211).
|
|
The driver scans, joins open and WPA2/WPA3-PSK networks, and presents itself to
|
|
the network stack as an ordinary Ethernet interface, so `dhcp`, `ping`, `nslookup`
|
|
and anything speaking sockets work over Wi-Fi exactly as they do over a cable.
|
|
|
|
```
|
|
wifi scan list nearby networks
|
|
wifi connect <ssid> <passphrase> join one
|
|
dhcp pick up an address
|
|
wifi status what you are connected to
|
|
wifi saved / wifi forget <ssid> networks remembered for next time
|
|
```
|
|
|
|
There is a graphical path too: a Wi-Fi entry in the desktop panel and a
|
|
Wi-Fi tab in the Network app. See "The desktop side" below.
|
|
|
|
## Layout
|
|
|
|
```
|
|
kernel/src/Drivers/Net/Wifi/
|
|
IwxTrans.cpp PCIe transport: MMIO, MSI-X, DMA rings, firmware boot,
|
|
host commands, RX processing, frame TX, key installation
|
|
IwxFw.cpp .ucode / .pnvm TLV parsing
|
|
IwxMvm.cpp post-ALIVE init, NVM, UMAC scan, RX dispatch
|
|
IwxConnect.cpp MLME: contexts, authenticate, associate, 802.11 <-> Ethernet
|
|
Wpa.cpp WPA2/WPA3-PSK supplicant (EAPOL 4-way + group rekey)
|
|
Ieee80211.hpp frame, element and RSN constants
|
|
Wifi.cpp subsystem facade: probe, scan table, syscalls, netif hooks
|
|
kernel/src/Libraries/Crypto.cpp SHA-1/SHA-256, HMAC, PBKDF2, AES, CMAC
|
|
kernel/src/Net/NetIf.cpp interface registry the Ethernet layer uses
|
|
```
|
|
|
|
## Joining a network
|
|
|
|
`SYS_WIFI_CONNECT` blocks until the link is up or the attempt fails, and
|
|
returns a `WIFI_ERR_*` code the `wifi` tool turns into a specific message
|
|
(wrong passphrase, unsupported security, AP out of range, and so on).
|
|
|
|
The sequence:
|
|
|
|
1. **Look up the BSS.** The SSID is matched against the scan table, strongest
|
|
signal first. If it is not there, one scan is run automatically and the
|
|
lookup retried, so `wifi connect` works without scanning first.
|
|
2. **Negotiate ciphers.** The AP's RSN element (kept verbatim in the scan
|
|
table) picks the pairwise cipher and AKM. CCMP is preferred over GCMP,
|
|
plain PSK over PSK-SHA256.
|
|
3. **Derive the PMK.** PBKDF2-HMAC-SHA1 over the passphrase with the SSID as
|
|
salt, 4096 iterations. A 64-character hex string is taken as a raw PSK
|
|
instead.
|
|
4. **Bring up firmware contexts.** PHY, MAC, binding and station, then one TX
|
|
queue on the management TID.
|
|
5. **Authenticate and associate.** Open-system authentication, then an
|
|
association request carrying the SSID, supported rates and, for encrypted
|
|
networks, the RSN element the supplicant built. Both are retransmitted up
|
|
to four times at 400 ms.
|
|
6. **Run the 4-way handshake.** EAPOL-Key messages 1-4, then the pairwise key
|
|
and the group key go into the firmware with `ADD_STA_KEY`.
|
|
7. **Report the link up.** Only now does `NetIf` see `wlan0` as usable.
|
|
|
|
## The data path
|
|
|
|
Once associated the driver translates between 802.11 and Ethernet II:
|
|
|
|
- **TX** - an Ethernet frame becomes a to-DS 802.11 data header plus an
|
|
RFC 1042 LLC/SNAP shim carrying the EtherType. The protected bit is set
|
|
once keys are installed and the firmware does the CCMP encryption.
|
|
- **RX** - the firmware decrypts and strips the MIC but leaves the 8-byte
|
|
CCMP header, which is skipped; the LLC/SNAP shim is replaced by an Ethernet
|
|
header built from addresses 1 and 3. EAPOL frames are diverted to the
|
|
supplicant instead.
|
|
|
|
One TX queue carries management frames, EAPOL and non-QoS data. The firmware
|
|
maps non-QoS data onto the management TID anyway, and the driver never
|
|
negotiates block-ack sessions that would need per-TID queues.
|
|
|
|
### Threading
|
|
|
|
The RX path runs under `IwxProcessEvents()`'s reentrancy guard and **must not
|
|
send a host command** - the command's completion is pumped by the very
|
|
function it would be re-entering. Anything needing a command (post-association
|
|
context updates, key installation, and therefore the whole EAPOL handshake) is
|
|
queued and applied from `IwxConnectService()`, which every idling core calls
|
|
and which is itself serialized. Transmitting is safe from either context: it
|
|
only writes a descriptor and rings the doorbell.
|
|
|
|
### Never wait on the clock under a spinlock
|
|
|
|
`kcp::Spinlock::Acquire()` does `cli`, and `Timekeeping::GetMilliseconds()` is
|
|
driven by the APIC timer interrupt. A wall-clock timeout inside a spinlock
|
|
therefore cannot expire: the counter never advances, the loop never ends, and
|
|
the machine locks solid with interrupts off - no mouse, no keyboard, no
|
|
scheduler. This is the same trap `ApicTimer.cpp` documents for the idle path.
|
|
|
|
`IwxSendCmd` holds a lock across a wait for the firmware's reply, so that lock
|
|
is a `kcp::Mutex` (which keeps interrupts enabled), and the wait carries a spin
|
|
cap as well as the clock check so it terminates even if the clock is somehow
|
|
stuck. It also bails immediately once the firmware is known to have asserted,
|
|
because nothing after that will ever be answered.
|
|
|
|
A firmware command that goes unanswered now dumps the firmware's own error
|
|
table on the first timeout - that names the command that asserted - and gives
|
|
up on the adapter after three, rather than stalling for seconds per command.
|
|
|
|
### Sample the clock after the work, not before it
|
|
|
|
`IwxConnectService()` sends host commands, and every one of them is a round
|
|
trip to the adapter that takes real milliseconds. Each step also stamps the
|
|
timestamp its deadline is measured from - `EnterState()` sets
|
|
`g_stateEnteredMs`, transmitting sets `g_lastTxMs`. A `now` read at the top of
|
|
the pass is therefore *older* than the stamps it is about to be compared
|
|
against, and because these are unsigned counters, `now - g_stateEnteredMs`
|
|
wraps to about 2^64 instead of going negative. Every deadline in the pass then
|
|
reads as long expired.
|
|
|
|
The symptom was a join that failed the instant it succeeded:
|
|
|
|
```
|
|
WiFi: [OK] Associated, AID 3
|
|
WiFi: [WARNING] Connection failed: timed out while joining the network
|
|
WiFi: [INFO] EAPOL frame received (99 bytes)
|
|
```
|
|
|
|
The two post-association context commands moved the clock forward, the timeout
|
|
check compared a stale `now` against the `g_stateEnteredMs` they had just set,
|
|
and the contexts came down before the access point's first EAPOL frame could
|
|
arrive - which is why message 1 shows up *after* the failure. So the clock is
|
|
read only once the command-sending work in the pass is done, and the
|
|
comparisons go through `Elapsed()`, which refuses to underflow.
|
|
|
|
### Teardown unwinds contexts in the order they depend on each other
|
|
|
|
Firmware 89 asserts when a context is taken away while another still points at
|
|
it, and the assert names the command rather than the reason. Three of these
|
|
have been hit so far:
|
|
|
|
| UMAC error | Cause |
|
|
|---|---|
|
|
| 0x2010330F | PHY binding and link activation folded into one LINK_CONFIG |
|
|
| 0x2010330E | link removed while its station still existed |
|
|
| 0x2000320F | link deactivated while the MAC was still marked associated |
|
|
|
|
The last one is the teardown side of the same rule. While
|
|
`MAC_CONFIG.is_assoc` is set, the firmware's MAC context owns the link carrying
|
|
the BSS, so `TearDown()` sends a `MAC_CONFIG` MODIFY clearing `is_assoc` first,
|
|
and only then removes the station, deactivates the link, removes the link,
|
|
removes the MAC and drops the PHY context - the order
|
|
`iwl_mvm_mld_vif_cfg_changed_station` and the paths below it use on the way
|
|
down. `tests/wifi/ap_mlme.py` pins that order.
|
|
|
|
### A bad access point must not become a bad computer
|
|
|
|
Three separate defects turned "the Wi-Fi connection went wrong" into "the whole
|
|
machine went wrong". They are worth keeping straight because they have nothing
|
|
to do with each other beyond sharing a trigger.
|
|
|
|
**The command wait is a busy spin on a core the scheduler has been told not to
|
|
touch.** `ServiceEvents()` runs from `ApicTimer::ServiceDeferredWork()`, which
|
|
sets `cpu->reservedForKernelWork` so a bottom half holding a process-context
|
|
mutex cannot be preempted into the process that would wait on it. The
|
|
scheduler honours that by refusing to place any process on that core and by
|
|
skipping it for the reschedule IPI, and on the BSP `RunBspMaintenance()` - the
|
|
thing that wakes sleeping processes - is not reached until the pass returns.
|
|
`IwxSendCmd` then waits for the firmware by spinning on `IwxDelayUs`, up to a
|
|
second. One pass of `TearDown()` is eight commands. Against an adapter that has
|
|
stopped answering, that pinned a core for the better part of ten seconds. The
|
|
symptom is not a Wi-Fi symptom at all: the cursor crawls, windows stop
|
|
repainting, everything stutters.
|
|
|
|
So a service pass now carries a budget - `IwxBeginServicePass()` /
|
|
`IwxEndServicePass()` - of roughly one command's worth of waiting in total.
|
|
Commands that do not fit are not sent at all (an abandoned command still holds
|
|
its ring slot, and a late answer would be misread as the *next* command's
|
|
completion) and are retried on the next trip round the idle loop. The bring-up
|
|
path does not bracket itself: it reserves its CPU deliberately and has nothing
|
|
to starve, so it keeps the full per-command timeout.
|
|
|
|
**Counting consecutive failures never fires on the failure that matters.** The
|
|
give-up rule was three unanswered commands in a row, with any success resetting
|
|
the count. An adapter that answers some commands and drops others - which is
|
|
exactly what a marginal link leaves the firmware doing - therefore never
|
|
reached the cutoff, while every drop still cost a full timeout. The stall was
|
|
not a one-off; it repeated indefinitely, and the driver never concluded
|
|
anything was wrong. It is a leaky bucket now: a failure adds one, a success
|
|
drains one, so a firmware failing even a fraction of its commands trips the
|
|
cutoff in bounded time.
|
|
|
|
**`IwxFwState::Error` was a one-way door.** Nothing anywhere cleared it. A
|
|
single firmware assert left `StartJoin()` returning `WIFI_ERR_NO_ADAPTER` -
|
|
which the user sees as *no adapter is ready* - for the rest of the uptime, and
|
|
the only way back was a reboot. Disconnecting and reconnecting could not help:
|
|
there was nothing to reconnect with. Since `IwxReadFirmware()` is idempotent
|
|
and the parsed image stays resident, the cure is to stop the device and run the
|
|
same bring-up again, which `ServiceRecovery()` now does. It is rate-limited to
|
|
one attempt per five seconds and capped at three, because the reset has its own
|
|
multi-second handshakes and an adapter that will not come back after three
|
|
tries is genuinely broken - repeating it forever would be its own kind of
|
|
stall.
|
|
|
|
### The link is up only while the access point says so
|
|
|
|
`IwxLinkUp()` was nothing but `g_state == Connected`, and nothing moved it off
|
|
that state unless the access point was polite enough to send a
|
|
deauthentication frame. An access point that simply stops being there - a phone
|
|
hotspot that sleeps, wanders off channel, or drops the station without saying
|
|
so - left the link reported as up indefinitely. `NetIf::Active()` went on
|
|
choosing `wlan0`, every packet went into the void, and the desktop showed a
|
|
healthy connection while nothing resolved and nothing connected.
|
|
|
|
Beacon loss cannot be used to notice this: once associated, `MacConfigCmd`
|
|
stops asking for beacons and the firmware tracks them itself. What is
|
|
observable is that our own frames stop being acknowledged, since `IwxTxComplete`
|
|
gets a status per frame. A long enough run of failures - with any
|
|
acknowledgement, or any frame received from the BSS, restarting the count -
|
|
means the access point is gone, and the link comes down so the stack can fall
|
|
back to a cable and the panel can report the truth.
|
|
|
|
The threshold is counted in frames rather than seconds on purpose: a link that
|
|
is merely idle has nothing to send and must not be torn down for it. And
|
|
`IwxTxComplete` runs inside the RX pump, so it only sets a flag; the teardown
|
|
happens in `IwxConnectService()`, where sending the commands it needs is
|
|
allowed. `tests/wifi/ap_mlme.py` pins both halves of that.
|
|
|
|
## The desktop side
|
|
|
|
### Nothing in the GUI may block
|
|
|
|
`SYS_WIFI_SCAN` sweeps for up to twenty seconds and `SYS_WIFI_CONNECT` waits
|
|
out a whole handshake. Either one called from `desktop.elf` would freeze the
|
|
compositor - and with it the mouse, the panel and every window - for seconds at
|
|
a time. So the same work has a second, non-blocking entry point:
|
|
|
|
| | |
|
|
|---|---|
|
|
| `SYS_WIFI_SCAN_START` | starts a sweep and returns immediately |
|
|
| `SYS_WIFI_RESULTS` | copies the scan table out without touching the radio |
|
|
| `SYS_WIFI_CONNECT_ASYNC` | starts a join and returns immediately |
|
|
| `SYS_NETIFS` | lists the registered interfaces (see below) |
|
|
|
|
`WifiInfo` grew the fields that make polling enough to follow along:
|
|
`scanning` and `scanGeneration` for the sweep, `joining` and `connState` for
|
|
the handshake, and `lastError` for how the last join ended. The GUI reads
|
|
`SYS_WIFI_INFO` every 400 ms while something is in flight and every three
|
|
seconds otherwise.
|
|
|
|
The deadlines belong to the kernel, not the caller: `ServiceAsync()` runs from
|
|
`ServiceEvents()` - after the RX pump has returned, so it is allowed to send
|
|
commands - and aborts a scan that overruns, tears down a join that stalls, and
|
|
records `lastError` when one fails. A failed join is unwound the moment it
|
|
fails, so `connState` is back to idle by the time anyone looks; `lastError` is
|
|
what survives to be reported.
|
|
|
|
### Wired and wireless are separate on the panel
|
|
|
|
The IP configuration is global to the stack, so "which interface does this
|
|
address belong to?" is not a question `SYS_GETNETCFG` can answer. `SYS_NETIFS`
|
|
reports each registered interface with its name, MAC, link state and an
|
|
`active` flag - the one `NetIf::Active()` currently sends through. The Ethernet
|
|
popup shows the address only while the wired interface is the active one, and
|
|
says "Not in use" when the cable is up but Wi-Fi is carrying the traffic; the
|
|
Wi-Fi popup does the mirror image. Each icon appears only when its hardware
|
|
does: no wired interface, no Ethernet icon; no adapter, no Wi-Fi icon.
|
|
|
|
The Wi-Fi icon stays white whatever the radio is doing. State belongs in the
|
|
popup, and an icon that changes colour next to the clock is just noise.
|
|
|
|
Picking a network that needs a passphrase opens a real window - created with
|
|
`desktop_create_window()` and the four callbacks, exactly like the reboot and
|
|
shutdown dialogs in `dialogs.cpp` - rather than something painted into the
|
|
panel overlay. It therefore has a title bar, can be dragged and closed, appears
|
|
in the window list, and gets its text field, checkboxes and buttons from the
|
|
same `mtk` widgets the settings apps use.
|
|
|
|
### What runs at startup
|
|
|
|
`desktop.elf` keeps looking for an adapter until one appears (firmware loads
|
|
well after login), starts one scan as soon as the firmware reports ready, and
|
|
when the results land joins the strongest saved network that is in range. Once
|
|
the link is up, and only if no address is configured, it spawns `dhcp.elf`.
|
|
|
|
### Saved networks
|
|
|
|
`0:/config/wifi.toml` holds them:
|
|
|
|
```toml
|
|
[wifi]
|
|
autoconnect = true
|
|
|
|
[network.0]
|
|
ssid = "Home"
|
|
psk = "passphrase"
|
|
```
|
|
|
|
**No default copy of this file ships in the image.** It is created on the first
|
|
save, the way `bluetooth.toml`, `display.toml` and `session.toml` are. A shipped
|
|
default looks harmless - it only documents the schema - but it is laid down
|
|
again by anything that refreshes the system files, and it takes the user's saved
|
|
networks with it when it lands. That is exactly what happened the first time
|
|
this was written, and it is why the schema is documented here instead.
|
|
|
|
`programs/include/montauk/wifi.h` is the one implementation of reading,
|
|
writing and searching that file, shared by the panel, the Network app and the
|
|
`wifi` command, so all three agree on the schema. The passphrase is stored as
|
|
typed because that is what the join needs - the kernel derives the PMK from it,
|
|
or takes a 64-character hex string as a raw PSK. There is no key store to hide
|
|
it in: anyone who can read `0:/config` can read the passphrases.
|
|
|
|
## The interface registry
|
|
|
|
`Net::NetIf` replaced the Ethernet layer's direct calls into the E1000
|
|
drivers. Drivers register a name, a kind, and three function pointers; the
|
|
stack sends through `NetIf::Active()`, which prefers a wired interface with a
|
|
link and otherwise takes the first interface reporting one. A Wi-Fi-only
|
|
machine therefore has no link until it joins a network, and a machine with a
|
|
cable plugged in keeps using it.
|
|
|
|
`SYS_NETSTATUS` reports whichever interface is active, so `ifconfig` shows the
|
|
wireless counters once Wi-Fi is carrying traffic.
|
|
|
|
## What is supported
|
|
|
|
| | |
|
|
|---|---|
|
|
| Open networks | yes |
|
|
| WPA2-PSK, CCMP or GCMP | yes |
|
|
| WPA2-PSK-SHA256 | yes |
|
|
| WPA3 transition mode (PSK advertised alongside SAE) | yes, joins via PSK |
|
|
| Group key rekeying | yes |
|
|
| WPA3-only (SAE) | no |
|
|
| Management frame protection required (MFPR) | no |
|
|
| WEP, original WPA / TKIP | no |
|
|
| 802.1X enterprise (EAP) | no |
|
|
| Block-ack aggregation, HT/VHT/HE rates | no - legacy rates only |
|
|
|
|
SAE needs finite-field or elliptic-curve arithmetic that does not belong in
|
|
this kernel, and MFP needs BIP. Both are rejected up front with a specific
|
|
log line rather than failing partway through a handshake. Mixed WPA/WPA2
|
|
networks that still broadcast under TKIP are refused for the same reason: the
|
|
pairwise key would install but every broadcast frame would be dropped, which
|
|
looks like a working connection that cannot get a DHCP lease.
|
|
|
|
## Crypto
|
|
|
|
`kernel/src/Libraries/Crypto.cpp` exists because the supplicant runs in the
|
|
kernel and BearSSL is a userspace library. It provides SHA-1, SHA-256, HMAC
|
|
over both, PBKDF2-HMAC-SHA1, AES-128/256, RFC 3394 key wrap/unwrap and
|
|
AES-CMAC. It is not a general-purpose crypto library and should not be used
|
|
as one.
|
|
|
|
## Testing
|
|
|
|
`./tests/wifi/run.sh` compiles the shipping sources for the host against a
|
|
small shim and drives them from Python. It is the real `Crypto.cpp`,
|
|
`Wpa.cpp` and `IwxConnect.cpp`, not a copy, with only the transport stubbed.
|
|
|
|
- **Crypto primitives** against the published vectors - FIPS-197 for AES,
|
|
RFC 2202/4231 for HMAC, RFC 3394 for key wrap, RFC 4493 for CMAC, and the
|
|
IEEE 802.11i Annex H.4 WPA passphrase vectors for PBKDF2.
|
|
- **The supplicant** against an independent authenticator using `hashlib` and
|
|
`cryptography`: messages 2 and 4 carry MICs that verify under a PTK the AP
|
|
derived itself, the installed TK and GTK match the AP's, a wrong passphrase
|
|
produces a MIC the AP rejects, group rekeys and message-3 retransmissions
|
|
are answered, and RSN negotiation picks the right suites across eight
|
|
real-world information elements.
|
|
- **The MLME and data path** against a simulated AP that decodes every frame
|
|
the driver emits: the authentication request, the association request and
|
|
its elements (SSID, rates, capabilities, RSN), the handshake carried inside
|
|
real 802.11 data frames, key installation arguments, and the encapsulation
|
|
both ways - to-DS addressing, the protected bit, LLC/SNAP, sequence numbers,
|
|
broadcast delivery, and the filtering of foreign-BSSID and null-data frames.
|
|
Also the branches: open networks, retransmission and give-up when the AP is
|
|
silent, authentication and association rejections, and an AP-initiated
|
|
deauthentication bringing the link down.
|
|
- **Firmware context ordering** - the MLD command sizes and field offsets
|
|
against the decoded Linux trace, and the order the teardown unwinds the
|
|
contexts in, which is what the asserts above are about.
|
|
|
|
The harness clock advances on every host command
|
|
(`CMD_ROUND_TRIP_MS` in `mlme_harness.cpp`) rather than standing still. That
|
|
detail matters: a frozen clock makes every elapsed-time comparison in the
|
|
service loop trivially true or trivially false, and hid the underflow described
|
|
under "Sample the clock after the work, not before it" - the host tests passed
|
|
while the adapter could not join a network at all. Anything that reads
|
|
`Timekeeping::GetMilliseconds()` should be tested with time actually moving.
|
|
|
|
What is left needs the adapter, because it is the firmware's opinion rather
|
|
than the driver's logic: whether the firmware accepts the TX command and TFD
|
|
layout and actually radiates the frames, whether `ADD_STA_KEY` installs the
|
|
keys the driver asks for, whether the RX MPDU descriptor is read correctly off
|
|
real receptions, and whether association succeeds against a real AP's timing
|
|
and rate expectations. None of that can be exercised in QEMU, which has no
|
|
AX210-family device to emulate.
|