The padlock that lied: what CoreWLAN's supportsSecurity really answers

Netfox's Wi-Fi tool puts a padlock beside every network: green and closed when the traffic is encrypted, red and open when it isn't. Version 0.29.0 added a third case, Enhanced Open (OWE): a network you join without a password whose traffic is still encrypted. It deserves the green lock, and the join sheet tells you so.

A day after shipping it, I filtered the list to "No password" and saw two networks with a green closed padlock. One, by the look of its name, was a neighbour's smart vacuum showing its setup hotspot. Its radio didn't even do 802.11n, and it was supposedly speaking a protocol from 2018. system_profiler SPAirPortDataType agreed with my suspicion: Security: None. Netfox said Enhanced Open, Open.

The code looked right

This is the classifier as it shipped:

private static let enhancedOpenModes: [CWSecurity] = [.OWE, .oweTransition]

static func classifyAccess(for cwNetwork: CWNetwork) -> WiFiNetwork.Access {
    WiFiNetwork.Access.classify(
        open: cwNetwork.supportsSecurity(.none),
        enhancedOpen: enhancedOpenModes.contains { cwNetwork.supportsSecurity($0) },
        password: personalModes.contains { cwNetwork.supportsSecurity($0) },
        enterprise: enterpriseModes.contains { cwNetwork.supportsSecurity($0) }
    )
}

OWE has a transition mode: the access point runs an open network plus a hidden OWE one, and a client that speaks OWE quietly uses the encrypted side. So .oweTransition looked like exactly the flag to check. The header documents it as "OWE Transition" and nothing more.

Asking CoreWLAN directly, with no access point and no permission

To see what the API really answers, I needed networks whose security I knew. I didn't have an OWE access point, and on recent macOS an app without Location access gets scan results with the SSID, the BSSID and the raw information elements blanked out.

But CWNetwork keeps everything it parsed in a private dictionary, and supportsSecurity(_:) reads from it. Dumping that dictionary from a process with no Location permission showed that the parsed security elements survive the redaction:

let networks = try CWWiFiClient.shared().interface()!.scanForNetworks(withSSID: nil)
for network in networks where network.supportsSecurity(.wpa2Personal) {
    let record = (network as NSObject).value(forKey: "scanRecord") as? [String: Any] ?? [:]
    print(network.ssid ?? "<redacted>", record["RSN_IE"] ?? "no RSN")
}
<redacted> {
    "IE_KEY_RSN_AUTHSELS" = ( 2 );
    "IE_KEY_RSN_MCIPHER" = 4;
    "IE_KEY_RSN_UCIPHERS" = ( 4 );
    "IE_KEY_RSN_VERSION" = 1;
    ...
}

AUTHSELS is the list of AKM suites: 2 is a WPA2 passphrase (PSK), 8 is WPA3's SAE, 18 is OWE. That's enough to write the record myself and hand it to a fresh CWNetwork:

func network(_ record: [String: Any]) -> CWNetwork {
    let network = CWNetwork()
    network.setValue(record as NSDictionary, forKey: "scanRecord")  // private: probes and tests only
    return network
}

func rsn(_ akms: [Int], mfpRequired: Bool = false) -> [String: Any] {
    ["IE_KEY_RSN_VERSION": 1, "IE_KEY_RSN_MCIPHER": 4, "IE_KEY_RSN_UCIPHERS": [4],
     "IE_KEY_RSN_AUTHSELS": akms,
     "IE_KEY_RSN_CAPS": ["MFP_CAPABLE": 1, "MFP_REQUIRED": mfpRequired ? 1 : 0]]
}

let open = network(["CAPABILITIES": 0x421])            // no RSN, privacy bit off
let owe  = network(["CAPABILITIES": 0x431, "RSN_IE": rsn([18], mfpRequired: true)])
let wpa2 = network(["CAPABILITIES": 0x431, "RSN_IE": rsn([2])])

Then ask every flag. This is what macOS 27 answered:

Access point

.none

.OWE

.oweTransition

.wpa2Personal

.wpa3Personal

.wpa3Transition

.wpaPersonalMixed

Open, no RSN

yes

–

yes

–

–

–

–

OWE (AKM 18)

–

yes

yes

–

–

–

–

WPA2 (AKM 2)

–

–

–

yes

–

yes

yes

WPA2 + WPA3 (AKM 2, 8)

–

–

–

yes

yes

yes

yes

WPA3 (AKM 8)

–

–

–

–

yes

yes

–

An open network says yes to .oweTransition. So does a record with nothing in it at all. A WPA2-only network says yes to WPA3 Transition and to WPA Mixed.

It's not a bug, it's a different question

Once the table is in front of you, the pattern is clear. supportsSecurity(_:) doesn't answer "does this access point advertise this mode?". It answers "could a profile saved under this mode join this access point?". A WPA3 Transition profile can join a WPA2 network or a WPA3 one. A WPA/WPA2 Mixed profile can join a WPA2 network.

An OWE Transition profile can join a plain open network, because that's what the open half of a transition pair is.

The base flags I checked (.none, .wpaPersonal, .wpa2Personal, .wpa3Personal, .OWE) track what the network advertises, because for them the two questions have the same answer. The transition and mixed flags are where they diverge.

It had been costing Netfox in a second place I hadn't noticed: the security label listed every flag that said yes. A plain WPA2 network read "WPA Mixed, WPA2 Personal, WPA3 Transition", and the list, which shows the first mode, said WPA. On my own scan that was 17 networks out of 24.

And a real OWE transition network still answers .OWE itself: an open record carrying the scan key SCAN_RESULT_OWE_MULTI_SSID = 1 (the open half of a transition pair, judging by its name) comes back yes to .OWE too. So .oweTransition wasn't needed for anything.

The fix is small; the lesson is in the tests

// 0.29.0
enhancedOpen: [.OWE, .oweTransition].contains { cwNetwork.supportsSecurity($0) },
// 0.29.1
enhancedOpen: cwNetwork.supportsSecurity(.OWE),

The label now names only base modes, so a transition network reads "WPA2 Personal, WPA3 Personal", which says the same thing without claiming anything the network doesn't do.

The more interesting question is why the tests passed. The code that matches a scanned network against saved profiles was tested with a fake:

private func advertising(_ modes: [CWSecurity]) -> (CWSecurity) -> Bool {
    { modes.contains($0) }
}

#expect(profileMatches(.wpa2Personal, supports: advertising([.wpa3Transition])))

That test describes an access point that advertises WPA3 Transition and nothing else. CoreWLAN never reports one. The fake encoded my assumption about the API, so it could only ever agree with my code.

The tests now build real CWNetworks from scan records, with the same helper as above, and one test pins the premise itself:

@Test func coreWLANAnswersTheTransitionFlagsForNetworksThatDoNotCarryThem() {
    #expect(ScanRecords.open().supportsSecurity(.oweTransition))
    #expect(ScanRecords.wpa2Personal().supportsSecurity(.wpa3Transition))
    #expect(ScanRecords.wpa2Personal().supportsSecurity(.wpaPersonalMixed))
}

If Apple ever changes what the flag means, that test fails first, and its doc comment says which comment in the classifier has become stale.

Caveats, honestly

  • scanRecord is private. Setting it through KVC is fine in a test target or a throwaway probe, and has no place in the app you ship.

  • The keys and the table above were read on macOS 27. They're a measurement, not a contract, which is the point of the premise test.

  • None of this needs a real OWE access point or Location access, and that is what makes it worth knowing: you can check what Wi-Fi security code does with any security mode, from a test, in seconds.

The fix shipped in Netfox 0.29.1: open networks show as open again, and the security label names only the modes a network uses.

Netfox is free, a universal binary (Apple Silicon + Intel), runs on macOS 15.6+, and is signed and notarized.

25 views

Add a comment

Replies

Best

How did you find out the scanRecord dictionary was still filled without Location access? Was it luck, or were you already poking around in there?

  half and half tbh... the poking was on purpose, the "without Location" part was luck. my first probe was a throwaway command-line tool with no Location access, and the public API gave it nothing: 30 networks, no names, no BSSIDs, 0 bytes of raw IE data. but supportsSecurity still answered for every one of them, so the data had to be sitting somewhere

so I dumped CWNetwork's ivars with the objc runtime. there's exactly one, a private _scanRecord dictionary, and read through KVC it was all there (capabilities, RSN/WPA elements, HT info), only the name and BSSID missing. you picked the most reusable bit of the whole thing btw, that ivar dump is the first thing I'd try on the next CoreWLAN mystery 🙂

The part where you made fake networks from raw scan records is wild .. No router, no Location permission, and you still got a full table of what CoreWLAN says. I'd save that table.

  good call, and it's saved: the cells that bit me are pinned in a test built on those same fake networks, so if a future macOS changes what CoreWLAN answers, the test fails before the padlock gets a chance to lie again. the no-router part is the real win imo, a Wi-Fi test that never has to scan

"supportsSecurity doesn't answer 'does this access point advertise this mode,' it answers 'could a saved profile under this mode join it'" is such a clean way to name the bug class. the function name promised one question and quietly answered a different, related one, and nothing forced that gap into view until you went and asked the API directly with known-good fakes. we had a version of this at Dial with a carrier API field literally called "delivered" that actually meant "accepted by the carrier for delivery" - same shape, a status flag that answers a cheaper, adjacent question dressed up as the expensive one. your line about the tests passing because the fakes "were tested with a fake" that encoded the same wrong assumption is the real finding here, were you specifically looking for that kind of circular fixture, or did you stumble into it while debugging the padlock issue itself

 stumbled into it ngl, I wasn't hunting for circular fixtures. once the probe showed CoreWLAN saying yes to the OWE transition flag on a plain open network, I grepped for everything in the code that read those flags, and the test file came up with them. the fake access point in there was a closure that answered yes to exaclty the modes it was handed, so the tests and the code shared the same wrong idea and kept agreeing with each other

your carrier "delivered" flag is the perfect twin of this, the cheap question wearing the expensive one's name. the fix for the test half was making the fixtures real CWNetwork objects fed with scan records, so in the tests it's CoreWLAN answering now, not my assumption