[PATCH] FT: Start a new initial MD association for client reauthentication
Nikolay Martynov
mar.kolya at gmail.com
Sat Oct 31 19:47:27 PDT 2026
The PMKSA renewal callback requests EAPOL reauthentication on the
existing connection. With FT-EAP, this sends EAPOL-Start after a
successful initial mobility domain association. hostapd discards that
frame, so renewal does not start. This also affects the REAUTHENTICATE
control command.
IEEE Std 802.11-2024, 13.4.2 requires a STA that has completed an FT
initial mobility domain association to repeat those procedures when
its Supplicant EAPOL state machines are triggered to send EAPOL-Start.
Handle EAPOL-Start in the supplicant glue by starting a new initial MD
association when FT is in use and the initial connection has completed.
Clear PMKSA entries for the current network to prevent the new
association from reusing cached credentials, including when
ft_eap_pmksa_caching is enabled. Use the existing reconnect path to
clear the old connection and FT state.
Keep EAPOL-Start handling unchanged during the initial association and
for non-FT connections.
Add hwsim coverage for PMKSA timer renewal after FT, manual renewal on
the initial AP with FT PMKSA caching enabled, and EAPOL-Start during the
initial association. Verify fresh EAP, a new PMKSA, working traffic and
a subsequent FT, including changed PMKR0Names across timer renewal.
Signed-off-by: Nikolay Martynov <mar.kolya at gmail.com>
---
Prepared with AI assistance.
Validation on upstream main 1f2231a88a74, with current hostapd and
wpa_supplicant binaries in UML/hwsim (Linux 6.18.44):
- Both renewal cases fail without this fix; initial EAPOL-Start passes.
- All three new cases pass with the fix, including the PMKR0Name check.
- Sixteen existing controls passed across the broad run and an isolated
rerun, covering FT/over-DS/pull/SHA384, PMKSA, SAE, and non-FT 802.1X,
including the newly added EAP-in-Authentication reauthentication cases.
One pmksa_cache_and_reauth run timed out. Its AP reauthentication command
arrived before the cached-key handshake finished. Isolated unmodified
and patched reruns both passed; this timing observation is retained in
the local validation notes.
An additional manual A/B on an Intel iwlwifi laptop, using an earlier
source base with the same fix, showed the stock client sending Start
and the patched client initiating a fresh association instead. The AP
workaround was enabled, so both clients completed EAP. Firmware-offloaded
FT and voice continuity have not been validated.
tests/hwsim/test_ap_ft.py | 119 +++++++++++++++++++++++++++++++++++++
wpa_supplicant/wpas_glue.c | 16 +++++
2 files changed, 135 insertions(+)
diff --git a/tests/hwsim/test_ap_ft.py b/tests/hwsim/test_ap_ft.py
index 29bec3de9..90be9d771 100644
--- a/tests/hwsim/test_ap_ft.py
+++ b/tests/hwsim/test_ap_ft.py
@@ -1742,6 +1742,125 @@ def test_ap_ft_eap(dev, apdev):
"""WPA2-EAP-FT AP"""
generic_ap_ft_eap(dev, apdev)
+def run_ap_ft_eap_client_reauth(dev, apdev, manual=False):
+ ssid = "test-ft-reauth"
+ aps = []
+ for ap, ft_params_func in zip(apdev, (ft_params1, ft_params2)):
+ params = hostapd.radius_params()
+ params.update(ft_params_func(ssid=ssid))
+ params.update({"wpa_key_mgmt": "FT-EAP", "ieee8021x": "1",
+ "ieee80211w": "2"})
+ aps.append(hostapd.add_ap(ap, params))
+
+ def ft_roam(target):
+ dev.scan_for_bss(apdev[target]['bssid'], freq="2412")
+ dev.dump_monitor()
+ if "OK" not in dev.request("ROAM " + apdev[target]['bssid']):
+ raise Exception("ROAM failed")
+ ev = dev.wait_event(["CTRL-EVENT-CONNECTED", "CTRL-EVENT-EAP-STARTED",
+ "CTRL-EVENT-DISCONNECTED"], timeout=10)
+ if ev is None or "CTRL-EVENT-CONNECTED" not in ev:
+ raise Exception("FT roam did not complete without EAP: " + str(ev))
+ if dev.get_status_field('bssid') != apdev[target]['bssid']:
+ raise Exception("FT roam connected to an unexpected BSSID")
+ aps[target].wait_sta()
+
+ # Exercise the PMKSA timer across FT, or manual renewal with caching on
+ # the initial AP. No unrelated cache entries are needed.
+ dev.set("dot11RSNAConfigPMKLifetime", "43200" if manual else "30")
+ dev.set("dot11RSNAConfigPMKReauthThreshold", "50")
+ id = dev.connect(ssid, key_mgmt="FT-EAP", eap="GPSK",
+ identity="gpsk user",
+ password="abcdefghijklmnop0123456789abcdef",
+ bssid=apdev[0]['bssid'], scan_freq="2412", ieee80211w="2",
+ ft_eap_pmksa_caching="1" if manual else "0")
+ aps[0].wait_sta()
+ old_pmksa = dev.get_pmksa(apdev[0]['bssid'])
+ if old_pmksa is None:
+ raise Exception("No PMKSA entry after initial association")
+ current = 0
+ if not manual:
+ dev.set_network(id, "bssid", "any")
+ ft_roam(1)
+ current = 1
+ hwsim_utils.test_connectivity(dev, aps[current])
+
+ # Pin the new initial association to the current AP and use the normal
+ # lifetime for the new hierarchy so a second renewal does not mask results.
+ dev.set_network(id, "bssid", apdev[current]['bssid'])
+ dev.set("dot11RSNAConfigPMKLifetime", "43200")
+ dev.dump_monitor()
+ if manual and "OK" not in dev.request("REAUTHENTICATE"):
+ raise Exception("REAUTHENTICATE failed")
+ dev.wait_disconnected(timeout=20)
+ for event in ("CTRL-EVENT-EAP-STARTED", "CTRL-EVENT-EAP-SUCCESS"):
+ if dev.wait_event([event], timeout=10) is None:
+ raise Exception("Client FT renewal missing " + event)
+ dev.wait_connected()
+ if dev.get_status_field('bssid') != apdev[current]['bssid']:
+ raise Exception("Client FT renewal connected to an unexpected BSSID")
+ aps[current].wait_sta()
+ new_pmksa = dev.get_pmksa(apdev[current]['bssid'])
+ if new_pmksa is None or old_pmksa['pmkid'] == new_pmksa['pmkid']:
+ raise Exception("Client FT renewal did not create a new PMKSA")
+ hwsim_utils.test_connectivity(dev, aps[current])
+
+ # The next FT must use the hierarchy generated by the fresh EAP exchange.
+ dev.set_network(id, "bssid", "any")
+ ft_roam(1 - current)
+ hwsim_utils.test_connectivity(dev, aps[1 - current])
+
+def test_ap_ft_eap_client_renewal(dev, apdev, params):
+ """FT-EAP client PMKSA renewal after an FT roam"""
+ run_ap_ft_eap_client_reauth(dev[0], apdev)
+ # Authentication Requests name the root used for each FT. Verify the
+ # post-renewal roam does not silently keep using the old hierarchy.
+ out = run_tshark(os.path.join(params['logdir'], "hwsim0.pcapng"),
+ "wlan.fc.type_subtype == 11 && wlan.fixed.auth.alg == 2 && "
+ "wlan.fixed.auth_seq == 1 && wlan.sa == " + dev[0].own_addr(),
+ ["wlan.pmkid.akms"])
+ names = list(dict.fromkeys(out.split()))
+ if len(names) != 2:
+ raise Exception("FT renewal did not change the PMKR0Name")
+
+def test_ap_ft_eap_client_reauth_pmksa_caching(dev, apdev):
+ """FT-EAP client reauthentication with initial MD PMKSA caching"""
+ run_ap_ft_eap_client_reauth(dev[0], apdev, manual=True)
+
+def test_ap_ft_eap_initial_eapol_start(dev, apdev):
+ """FT-EAP allows EAPOL-Start during the initial MD association"""
+ params = hostapd.radius_params()
+ params.update(ft_params1(ssid="test-ft-start"))
+ params.update({"wpa_key_mgmt": "FT-EAP", "ieee8021x": "1"})
+ hapd = hostapd.add_ap(apdev[0], params)
+ hapd.set("ext_eapol_frame_io", "1")
+ try:
+ dev[0].set("ext_eapol_frame_io", "1")
+ dev[0].set("EAPOL::startPeriod", "1")
+ dev[0].connect("test-ft-start", key_mgmt="FT-EAP", eap="GPSK",
+ identity="gpsk user",
+ password="abcdefghijklmnop0123456789abcdef",
+ scan_freq="2412", wait_connect=False)
+ # Withhold the initial Request/Identity until the client sends Start.
+ ev = dev[0].wait_event(["EAPOL-TX", "CTRL-EVENT-DISCONNECTED"], timeout=5)
+ if ev is None:
+ raise Exception("No initial EAPOL-Start received")
+ if "CTRL-EVENT-DISCONNECTED" in ev:
+ raise Exception("Unexpected disconnection during initial association")
+ frame = ev.split(' ')[2]
+ if binascii.unhexlify(frame)[1] != 1:
+ raise Exception("Expected an EAPOL-Start frame")
+ hapd.set("ext_eapol_frame_io", "0")
+ dev[0].set("ext_eapol_frame_io", "0")
+ hapd.request("EAPOL_RX " + dev[0].own_addr() + " " + frame)
+ dev[0].wait_connected()
+ hapd.wait_sta()
+ hwsim_utils.test_connectivity(dev[0], hapd)
+ finally:
+ hapd.set("ext_eapol_frame_io", "0")
+ dev[0].set("ext_eapol_frame_io", "0")
+ dev[0].set("EAPOL::startPeriod", "30")
+
def test_ap_ft_eap_cui(dev, apdev):
"""WPA2-EAP-FT AP with CUI"""
generic_ap_ft_eap(dev, apdev, vlan=False, cui=True)
diff --git a/wpa_supplicant/wpas_glue.c b/wpa_supplicant/wpas_glue.c
index e9f1d5e6c..f71ee1ce3 100644
--- a/wpa_supplicant/wpas_glue.c
+++ b/wpa_supplicant/wpas_glue.c
@@ -168,6 +168,22 @@ static int wpa_supplicant_eapol_send(void *ctx, int type, const u8 *buf,
return -1;
}
+#ifdef CONFIG_IEEE80211R
+ if (type == IEEE802_1X_TYPE_EAPOL_START &&
+ wpa_key_mgmt_ft(wpa_s->key_mgmt) && !wpa_s->new_connection) {
+ /* IEEE Std 802.11-2020, 13.4.2 requires a new FT initial
+ * mobility domain association instead of EAPOL-Start after a
+ * successful initial mobility domain association. Do not reuse
+ * a cached PMKSA for the new association. */
+ wpa_dbg(wpa_s, MSG_DEBUG,
+ "FT: Start a new initial mobility domain association for reauthentication");
+ pmksa_cache_clear_current(wpa_s->wpa);
+ wpa_sm_pmksa_cache_flush(wpa_s->wpa, wpa_s->current_ssid);
+ wpa_supplicant_reconnect(wpa_s);
+ return 0;
+ }
+#endif /* CONFIG_IEEE80211R */
+
if (pmksa_cache_get_current(wpa_s->wpa) &&
type == IEEE802_1X_TYPE_EAPOL_START) {
/*
base-commit: 1f2231a88a742befbea44bc19ffee94bed616505
--
2.53.0
More information about the Hostap
mailing list