blog article
Three Ways to Fake Wi-Fi, One Merged: Zephyr's New hwsim
Linux has had mac80211_hwsim for years; Zephyr had nothing like it — you couldn't test the Wi-Fi supplicant without a radio on the bench. Three different fixes landed on the table at once; an RFC and a Wi-Fi telco sorted out which does what, and I built and merged the in-process one: N virtual radios in a single image, running real WPA2 and WPA3 handshakes with zero hardware. Here's the design call, the one problem that makes it hard on Zephyr specifically, and the war story of getting SAE green.
A note before this starts: this was my own initiative, not a client brief. I
maintain Zephyr’s Wi-Fi stack and drivers — which includes its hostap
(wpa_supplicant/hostapd) integration — and I contribute to the hostap
upstream project itself. I’d wanted Linux-grade Wi-Fi testability in Zephyr — the
whole supplicant exercised with no radio on the bench — for years. It landed in a commercial
context: the work was done while engaged with Nordic Semiconductor, a Dotstar
client, and the driver carries Nordic’s copyright. This is my own engineering
account of how it was designed and landed, in a personal capacity; it isn’t a
statement from Nordic. The idea, the design call, and any mistakes are mine.
The short version for a product team: this is how you test a complete Wi-Fi stack — WPA2, WPA3, the lot — in CI, in seconds, with no radios to buy or babysit. Everything after that is how it was built, and how it was led to a clean upstream merge.
The one-paragraph version
Zephyr’s Wi-Fi stack — the same wpa_supplicant/hostapd code that runs on
real silicon — could not be tested without real silicon. Every scan, every
4-way handshake, every WPA3-SAE exchange in CI needed a physical radio and a
physical access point sitting on a bench somewhere. Linux solved this a decade
ago with mac80211_hwsim, a kernel module that fakes the radio so the entire
software stack can be exercised against itself. Zephyr had no equivalent. This
is the story of building one — wifi_hwsim, now merged as
zephyr#111236:
N virtual Wi-Fi radios inside a single Zephyr image, connected by an in-process
“medium,” running the real supplicant with real crypto, so that open,
WPA2-PSK, and WPA3-SAE all connect and ping — deterministically, on
native_sim, with no hardware anywhere. It was one of three approaches on
the table, and picking between them mattered as much as writing the code.
The idea itself wasn’t new. It had been logged as a Zephyr enhancement years
earlier —
zephyr#84027,
“evaluate the best strategy for Wi-Fi simulation based tests” — and then sat,
for want of time more than want of interest. What changed is prosaic and worth
being honest about: LLM-assisted development made a job this fiddly — a driver
shim, a wpa_supplicant/hostapd integration, three security paths, and walls
of protocol tracing — tractable in a way it simply hadn’t been before. That’s
what finally let two of these land in the same window: this in-process driver,
and Jukka’s, which had been quietly coming together in the background to make a
Zephyr native_sim process and the host Linux mac80211_hwsim talk to each
other. Two long-idle ideas, unblocked at roughly the same moment by better
tooling.
This post is two things at once, deliberately: a deep dive into the one problem that makes this genuinely hard on Zephyr (it has no network namespaces), and a walk through how the work was actually run — idea, to a public RFC comparing three designs, to a decision taken in the open at the Wi-Fi telco, to a reviewed and merged upstream contribution. The engineering and the way of working are both the point.
Why this was worth doing at all
Start with the use case, because it’s the whole justification. A Wi-Fi stack is a huge amount of protocol software — association state machines, the EAPOL 4-way handshake, WPA3’s SAE dragonfly exchange, key derivation, PMF — and almost none of it needs a radio to be logically wrong. But if the only way to run it is against hardware, then:
- CI can’t cover it. Regression tests that need a bench don’t run on every pull request. They run rarely, by hand, if at all — which is exactly how a 10× crypto regression rides into a release unnoticed (I’ve written about one).
- It’s not deterministic. Real RF is flaky. A test that sometimes fails because the air was noisy teaches people to ignore failures.
- It doesn’t scale. You can’t give every developer two radios and an AP.
You can give every developer a
native_simbinary that runs in 2.7 seconds.
mac80211_hwsim gives Linux all of that: a virtual medium where the complete
stack — including a real hostapd acting as the AP — runs against itself with
only the transceiver faked. That’s the target. Nothing above the antenna should
be simulated; the supplicant shouldn’t know it isn’t talking to a real radio.
Leading the design, not just shipping a patch
There’s more than one way to give Zephyr a Wi-Fi test harness, and the approaches are genuinely different in their trade-offs — not better and worse, but suited to different jobs. So rather than push my preferred patch and let the others sort themselves out in review, I opened an RFC that laid the whole design space out side by side, so the community could reason about the choice explicitly. Three approaches were live at the same time:
| A — in-process hwsim (this PR) | B — native_sim + host nl80211 | C — emulated driver | |
|---|---|---|---|
| How it fakes the radio | An in-process frame-relay “medium” between virtual radios | Zephyr’s native_sim talks to the host Linux kernel’s real mac80211_hwsim | No radio at all — a test API injects scan results and connect outcomes |
| Runs on | native_sim, QEMU, any Zephyr target | Linux host only | Any target |
| Protocol fidelity | Full: real EAPOL, real SAE, real supplicant/AP | Full: leans on the battle-tested kernel module | Scripted events only — no EAPOL, no hostapd |
| AP + STA in one image | Yes | Needs the host to provide the AP | No |
| The hard part | Zephyr has no network namespaces (see below) | Needs kernel-module + elevated privilege on CI runners | N/A — it deliberately simulates nothing |
The RFC’s conclusion wasn’t “mine wins.” It was that these are complementary, and saying so plainly is the useful part. Approach B (Jukka’s, zephyr#110681) leans on a kernel module that has been hardened for a decade — it’s the right tool for manual and application-protocol testing (MQTT, HTTPS over a real medium), and it was approved for exactly that. Approach C (Pieter’s, zephyr#111194) deliberately fakes nothing below the management API — perfect for unit-testing an application’s reconnection logic or scan throttling, and pointless overkill for handshake testing. Approach A — the one this post is about — is the only one that runs the complete stack, AP included, on any target with no host dependency. That portability is the reason it’s the one that can gate every CI job on every platform.
And none of that was decided unilaterally, which is the part I’d underline. The comparison lived in the RFC; the decision was made in the open, at the Zephyr Wi-Fi biweekly telco, with the people who own the surrounding code in the room — Jukka, Pieter, Maochen, Hui Bai, Satish Nallamala, Robert Lubos. As Zephyr’s Wi-Fi maintainer I was one of them, which is exactly why the job was to build consensus rather than to win an argument. The minutes are terse, and paraphrased they read:
| Approach | Simulator | Its job |
|---|---|---|
| A | hwsim (this PR) | Wi-Fi-focused testing + CI |
| B | native_sim + host mac80211_hwsim | Manual + app-protocol testing (MQTT, HTTPS); CI deferred — needs sudo on runners |
| C | — | Dropped — covered by A + B |
Next step: test and merge the open PRs, and find names for A and B that better convey their intent. Getting three contributors to converge on “these are complementary — here’s who does what”, instead of three overlapping PRs competing to be the Wi-Fi test harness, took as much care as any patch in the diff. The goal was never to win the merge — it was to make the merge uncontentious.
Choosing A meant signing up for its hard problem with eyes open. That’s the next section, and it’s the technical heart of the whole thing.
The architecture: N radios, one process, one medium
The shape is simple to state. Each virtual radio is a Zephyr net_if running
Ethernet L2 plus the Wi-Fi offload API — from the network stack’s point of view,
an ordinary Wi-Fi interface. They’re wired together by a small in-process
medium (wifi_hwsim_medium.c): a mutex-protected registry of radios and a
frame-relay function. Each radio has an RX thread draining a k_fifo; frames
are fixed-size slab allocations (HWSIM_FRAME_MTU = 2346, an 802.11 max frame)
carrying a source index, a channel, and — a detail that matters — an explicit
is_mgmt flag.
The relay rules fall straight out of what each frame type is for:
- Data frames (802.3) are delivered by destination MAC: a unicast frame
goes to the single radio whose link address matches; a broadcast/multicast
frame (
mac[0] & 0x01) goes to every peer on the same channel. That’s a medium behaving like a medium. - Management frames (802.11) are broadcast to every same-channel peer and handed up to the supplicant. That’s the air.
Early code guessed a frame’s type by peeking at the 802.11 frame-control byte;
that got replaced with the explicit is_mgmt flag set at the two call sites, so
the medium never has to parse a frame to route it. Radios only ever hear peers
on the same freq_mhz, so retuning a radio’s channel is how you connect a STA
to an AP — exactly like real hardware.
That’s the easy 80%. Now the hard part.
The one problem that makes this hard on Zephyr
Linux’s mac80211_hwsim gets a gift from the kernel that Zephyr does not:
network namespaces. On Linux, each virtual radio lives in its own netns, so
a frame from the STA to the AP and the AP’s reply back are isolated by the
kernel — two radios, two separate network stacks, no confusion about who owns
which IP.
Zephyr has no network namespaces. Every virtual radio shares one global IP stack. And that single shared stack is full of correctness guards that assume “an address that belongs to me” means “don’t put this on the wire” — which is precisely the assumption you have to defeat when both ends of the link are you.
Linux mac80211_hwsim Zephyr wifi_hwsim
-------------------- -----------------
each virtual radio in its ALL radios share ONE IP stack
own network namespace (Zephyr has no netns)
| |
v v
STA->AP and AP->STA are the stack sees the peer's IP as
isolated by the kernel; "one of my own addresses" and
replies route correctly short-circuits the frame INTERNALLY,
so it never reaches the medium
I flagged this in the RFC as the open question for Approach A — and, at the start, a stale note in my own project memory insisted it was unsolvable: that inter-radio data traffic in a single process fundamentally needed separate IP stacks. That turned out to be wrong, which is worth saying out loud, because the resolution is the satisfying part. The problem is real but it’s entirely solvable in the driver — no core networking changes — once you find the three guards and the lever for each.
The debugging that surfaced these was the classic peel-one-layer-at-a-time. The
first trace of a ping between two virtual radios showed the echo request from
192.168.2.1 → 192.168.1.1 being answered in the same receive queue at the
same timestamp — the frame never left the stack. That’s guard one, in
net_core.c: it swaps addresses and loops any packet destined for a local
address straight back, and because both radios’ IPs are “mine,” every inter-radio
packet qualified. CONFIG_NET_IP_ADDR_CHECK=n turns that off.
With TX finally reaching the medium, ARP worked — and the ICMP request now got
dropped on the far radio instead. Grep the drop message and you land on
ipv4.c: DROP: src addr is mine. Same root cause, other direction — the
peer’s source IP is also local in this one-stack image. The stack makes an
exception for genuinely looped-back packets, so the fix is to mark every frame
the hwsim RX thread injects with net_pkt_set_loopback(pkt, true). (A separate
check confirmed that flag doesn’t interfere with delivery to the raw packet
socket that EAPOL rides — important for what comes next.)
Defeat all three and a full ARP + ICMP round-trip crosses the emulated medium
between two radios that share one IP stack. 5 of 5 tests green. That’s the
data path — but “ping works” is table stakes. The reason to build this at all is
to run the secured handshakes.
The bet that made security work: relay the association, not the frame
When I first wired up open association I hardcoded the STA as authorized=1 —
associated, done, no handshake. A reviewer asked the obvious question: can we
actually test the 4-way handshake? The honest first answer was “no.” The much
more interesting answer, after pushing on it, was “yes — and the way to do it
tells you something about how FullMAC drivers really work.”
Here’s the tempting-but-wrong approach: make the STA build real 802.11 authentication and association frames, put them on the medium, and have the AP parse them back. That’s a SoftMAC design, and it means writing (and debugging) an 802.11 frame builder and parser purely to serialize data between two contexts that are already in the same process.
Looking at how the real nrf_wifi FullMAC driver behaves reframed it. When
wpa_supplicant runs its connection state machine (SME) in-host, it doesn’t
hand the driver a frame — it hands it a params struct (the RSN IE, the SAE
auth data, and so on). A FullMAC driver packs those params off to firmware,
firmware builds the actual over-the-air frame, and the result comes back up as
an event. In hwsim, both “firmwares” live in the same process. Serializing
to an 802.11 frame just to re-parse it on the other side is pure ceremony.
So the design is: relay the association as a firmware-style event between radio contexts, and only put real frames on the medium when they carry real crypto.
Concretely, the STA’s .associate injects an EVENT_ASSOC — carrying the
STA’s own wpa_ie as the association request IEs — into the peer AP radio’s
supplicant context, which is exactly what a FullMAC firmware reports upward. The
AP’s supplicant routes that into hostapd_notif_assoc(), builds the station,
and starts the authenticator. Crucially, for a secured network the STA now
reports its own association with authorized=0, so the real 4-way handshake
actually runs — and EAPOL crosses the medium as ordinary 0x888E data frames,
the real messages with the real MICs. When it worked, the trace showed the
handshake as four frames on the wire: 113 / 141 / 217 / 113 bytes — msg1
through msg4.
Getting there flushed out a genuinely sneaky bug. WPA2-PSK connect kept failing
with “no suitable network found,” and the supplicant debug (all 12 million
lines of it) pinned the reason: the STA’s scan result showed
wpa_ie_len=0 rsn_ie_len=0 — every hwsim AP looked open. The scan-results
path was synthesizing a fake IE set (SSID, rates, DSSS param) and never
surfacing the AP’s real RSN element from its beacon. The AP had computed a
perfectly good RSNE; the STA just never saw it. Fix: carry the AP’s real beacon
IEs into the scan result, skipping the fixed prefix. WPA2-PSK went green.
WPA3-SAE and the one capability bit
SAE — WPA3’s dragonfly handshake — was the last and best war story. Everything seemed in place: the driver relayed the SAE commit/confirm as real authentication frames over the medium, the AP path was taught to accept SAE and set the mandatory PMF (Protected Management Frames — SAE requires it), and the tests got distinct SSIDs so a stale beacon from an earlier test couldn’t win a “fast associate” race. And still the STA-side SAE connect just… hung, then timed out.
The decisive log line, once the noise was cleared:
wlan1: WPA: AP key_mgmt 0x400 network profile key_mgmt 0x400; available key_mgmt 0x0
wlan1: WPA: Failed to select authenticated key management type
Read that carefully. The AP offers SAE (key_mgmt 0x400). The station’s profile
wants SAE (0x400). Their intersection is 0x400 — a match. And then
“available key_mgmt” is 0x0. Something masked the one bit that both sides
agreed on, after they agreed on it.
AP offers SAE : key_mgmt = 0x400 (WPA_KEY_MGMT_SAE = BIT(10) = 1024)
STA profile wants : key_mgmt = 0x400
sel = AP & profile : 0x400 <- agreed!
|
wpa_supplicant.c:1877
v
if the driver did NOT advertise WPA_DRIVER_FLAGS_SAE:
sel &= ~WPA_KEY_MGMT_SAE -> sel = 0x0
|
v
"available key_mgmt 0x0" -> SAE never starts, connect hangs
The culprit is in wpa_supplicant.c: unless the driver advertises
WPA_DRIVER_FLAGS_SAE (or an offload flag), the supplicant strips the SAE
key-management bit from the selection — a sensible guard on real hardware, where
a driver that can’t do SAE shouldn’t claim it. The hwsim get_capa advertised
WPA_DRIVER_FLAGS_SME | WPA_DRIVER_FLAGS_AP but not WPA_DRIVER_FLAGS_SAE. One
line — add the flag (0x02000000) and the matching key-mgmt capability — and
SAE went from hanging to a clean 7 of 7, deterministic across four
back-to-back runs at ~2.7 seconds each.
There’s a coda worth admitting. Blocked on SAE, I was tempted to defer it — ship open and WPA2-PSK now, file WPA3 as “future work,” and move on. I didn’t, because of a rule I don’t bend: a security feature isn’t done until it completes end to end, and “open, WPA2-PSK, and WPA3-SAE all connect and ping” was the bar I’d set at the outset. Deferring would have been the easy, defensible call — and half-finished security is exactly the kind of thing that passes review and bites someone in production. Once I stopped looking for the exit, the fix was one line.
Why the test passed but the shell didn’t
One more find is a small masterclass in “green tests can lie.” At one point the
automated suite was fully green while a human typing wifi ap start at the
shell got a flat rejection. Same driver, opposite results.
The reason: enabling AP mode through the network-management API — which is what
the ztest calls directly — bypasses a gate the interactive shell enforces. The
shell checks that the interface is registered as a software AP before it lets
you start one, and the supplicant glue was registering every interface as a
station only, never as both. The test never hit that gate; the human did. The
fix was to let a single net_if be registered as both STA and SAP at once
(one of the upstream hostap changes this PR carries), so the same interface can
be a station and bring up an AP — which is exactly the multi-role setup the
whole harness depends on. Lesson, same as always: a passing test proves the path
the test takes, not the path the user takes.
Execution: idea → RFC → reviewed → merged
The engineering above is half the story; landing it upstream cleanly is the other half, and it’s the part clients are actually buying. A few things that turned a debugging spike into something mergeable:
- The debugging branch was rebuilt into six logical commits — three
upstream
hostapglue changes (single-interface STA+SAP, hardened event lookup, WPA3-SAE for the software AP), the driver itself, thenative_simsample, and the test suite. Reviewers read intention, not archaeology. - CI was made fully green before asking anyone to look — the whole gauntlet of Zephyr’s compliance checks: license/SPDX headers, Kconfig prompt style, YAML lint on the devicetree binding, commit-message wrapping and sign-off/identity matching, doc build, and a test-metadata filename rule. None of it is glamorous; all of it is table stakes for a project the size of Zephyr, and getting it wrong wastes a reviewer’s goodwill.
- Every review comment got a real answer — across two rounds and roughly a
dozen comments from two maintainers, including fixing genuine races (medium
configuration was being written after its lock was dropped) and hardening
one-time-init flags to
atomic_t. Where I disagreed with a suggestion, the reply was a reason, not a shrug. - The claims were checked, not asserted. When a reviewer asked whether one of the hostap commits was even necessary, rather than argue I reverted those files to mainline, re-ran the suite, and reported what actually happened (still green under the test’s direct API path — but needed for the interactive shell path). That distinction is the STA/SAP finding above; it came out of checking.
The result merged on July 17, approved by the maintainers who’d pushed hardest
in review. Full hostapd AP mode is scoped as a deliberate follow-up — the
current PR uses the supplicant’s software-AP path, which is enough to exercise
open, WPA2-PSK, and WPA3-SAE end to end, and I said so explicitly rather than
letting scope blur.
Where it goes next
The merge is a floor, not a ceiling. I scoped this PR tightly on purpose, so a few things are deliberately open rather than done:
- Full
hostapdAP mode (CONFIG_WIFI_NM_HOSTAPD_AP). The current driver runs the supplicant’s software-AP path — enough for open, WPA2-PSK, and WPA3-SAE end to end. Standing up a genuine hostapd-mode AP is the next PR. - Better names for A and B. A telco action item, non-blocking: “hwsim” and “native_sim” describe the mechanism, not the intent, and the two get confused.
- One netif or two? Whether a single
net_ifacting as both STA and SAP is the right model, versus vendors that expose two separate interfaces, is a design thread left open in review — settled enough to merge, not enough to call final. - 64-bit parity. Work and CI have leaned on 32-bit
native_sim; keeping thenative_sim/native/64target green is ongoing.
And then the genuinely new capability, all of it agreed at the same telco:
- Enterprise. A hostapd-based AP with a built-in RADIUS server already runs in Zephyr; the plan is full Enterprise coverage — STA, AP, and RADIUS server — so EAP methods get the same no-hardware treatment SAE just got.
- More modes for B. Jukka’s host-backed harness grows sideways into SoftAP and Wi-Fi Direct / P2P.
- Real medium simulation — the piece I’m keenest on, and the one that closes
the loop back to that original enhancement request. Today every radio hears
every other radio perfectly; wiring in
wmediumd— the userspace daemon Linux’s hwsim pairs with for per-link RSSI and packet-loss control — would let a test dial in attenuation, mobility, and loss on purpose. Its appeal is that it slots under both approaches: reimplemented in-process for A, native for B.
That last one is the whole thesis in miniature: choose the harness once, and the hard infrastructure beneath it is worth building for everyone. That’s the shared foundation the RFC set out to create.
What this unlocks
Step back from the bug-by-bug and the payoff is concrete. Zephyr now has a way
to run its complete Wi-Fi stack — real supplicant, real AP, real 4-way
handshake, real SAE dragonfly, real key derivation — in a single native_sim
process, in a couple of seconds, on any platform, with no radio and no bench.
And this isn’t hypothetical framing — it’s already wired in. The suite ships
tagged wifi (alongside hwsim, networking, drivers) and allowed on
native_sim and native_sim/native/64, so it’s part of the set Zephyr’s
Twister-based CI already selects: any change that touches the Wi-Fi test scope
now runs open, WPA2-PSK, and WPA3-SAE connect-and-ping automatically, with no
hardware to schedule and nothing to plug in. That flips the usual state of
affairs from “you could gate CI on the supplicant if someone stood up a lab”
to “CI already does, on every runner.” The class of bug I spend real time
chasing on hardware — a crypto path that silently got an order of magnitude
slower, a handshake that stopped completing inside the AP’s timeout — is exactly
the class this catches before it reaches silicon, deterministically, for free.
For a product team, that arithmetic is the whole pitch. A hardware Wi-Fi CI lab
is real money and real flake: radios, APs, RF isolation, someone to maintain all
of it, and tests that fail because the air was noisy rather than because the code
broke. This replaces the common cases of that lab with a native_sim binary that
runs the real supplicant, real AP, and real handshakes in about 2.7 seconds on
hardware you already own — every developer’s laptop, every CI runner. The payoff
isn’t “we added a test.” It’s that a crypto regression or a handshake-timeout
regression gets caught on the pull request that introduces it, instead of in a
certification lab or a customer’s field return — which is exactly where catching
it stops being cheap.
And it sits in a considered place in the design space, not in spite of the other two approaches but alongside them: the host-nl80211 harness for real-medium application testing, the emulated driver for management-plane unit tests, and this one for full-stack, any-target, in-CI handshake coverage. Knowing which harness answers which question is itself the deliverable.
This is the mirror image of a post I wrote about the Windows side — where the sample driver’s test harness ships no AP on purpose, and one relay hop had to be faked by hand. Same underlying discipline, opposite corner of the map: there, fake exactly the one hop you need and nothing else; here, fake only the radio and run everything above it for real. Neither is more correct. Picking the wrong one for your actual goal is where the wasted weeks come from — and picking, out loud, in an RFC the whole community can weigh in on, is the part I’d do the same way every time.
PS: This post was drafted with Claude Code from my own session transcripts and project notes, and every PR and discussion linked was checked against the public GitHub history before being cited. The design call, the debugging, the wrong turns, and the one-line SAE fix are as they happened.
Dotstar Systems is a principal-led wireless practice — run by the maintainer
of Zephyr’s Wi-Fi stack and drivers (including its hostap /
wpa_supplicant / hostapd integration) and the nRF70/nRF71 drivers, and a
contributor to the upstream hostap project and the Linux wireless stack. We design,
build, debug, and upstream wireless systems where firmware meets silicon: an
in-process test harness like this one, mac80211-class drivers, WPA3 crypto
paths on constrained parts — on Zephyr, Linux, or Windows. Engagements are scoped
to fit the work — fixed-scope bring-up, a time-boxed hunt for a bug that won’t
die, or ongoing work embedded in your firmware team — with a named principal
accountable and every deliverable signed off at principal level, not handed to a
team you’ll never meet. If you have a Wi-Fi stack you can’t test without a bench,
or a hard driver problem you want driven from idea to merged,
let’s talk.