[PATCH] FT: Add MLO support for the FT protocol (IEEE 802.11be)

Rachel Tseng Rachel.Tseng at mediatek.com
Wed Oct 28 00:51:43 PDT 2026


From: Herman Yang <herman.yang at mediatek.com>

Extend the FT protocol to support multi-link operation. Report the target
AP MLD address, valid links, and per-link STA addresses with the FT
Response and pass them to wpa_ft_process_response(). Use the AP MLD address
for PTK derivation and installation and for the Reassociation Request MIC,
which also covers the per-link STA address list.

Validate the Reassociation Response MIC and element count using the
per-link RSNE/RSNXE from the response and the per-link AP BSSIDs. Update
the MLO association state before processing the association information
so that validation uses the target AP. Install the per-link MLO GTK,
IGTK, and BIGTK subelements from the response.

Allow the driver to report the RSNXE it transmits for the FT exchange.
Use those octets for the Reassociation Request MIC instead of relying
on the RSNXE of the previous AP.

Signed-off-by: Herman Yang <herman.yang at mediatek.com>
---
 src/drivers/driver.h    |  41 +++
 src/rsn_supp/wpa.h      |  11 +-
 src/rsn_supp/wpa_ft.c   | 588 +++++++++++++++++++++++++++++++++++++---
 wpa_supplicant/events.c |  37 ++-
 wpa_supplicant/sme.c    |   3 +-
 5 files changed, 630 insertions(+), 50 deletions(-)

diff --git a/src/drivers/driver.h b/src/drivers/driver.h
index e957a56ae..754674ace 100644
--- a/src/drivers/driver.h
+++ b/src/drivers/driver.h
@@ -6846,6 +6846,26 @@ enum sta_connect_fail_reason_codes {
 	STA_CONNECT_FAIL_REASON_ASSOC_NO_RESP_RECEIVED = 7,
 };
 
+/**
+ * struct mlo_ft_info - Per-link MLO info carried with EVENT_FT_RESPONSE
+ * @ap_mld_addr: Target AP MLD address, used as the AP address when deriving
+ *	the FT PTK and building the Reassociation Request MIC for an MLO FT
+ * @valid_links: Bitmap of valid link ids (indexes into @links); 0 for a
+ *	non-MLO FT
+ * @links: Per-link own STA link MAC address (indexed by link id), used to
+ *	build the MLO FT Reassociation Request MIC
+ *
+ * links[] is indexed by link id, mirroring the cfg80211 MLO link array
+ * convention.
+ */
+struct mlo_ft_info {
+	u8 ap_mld_addr[ETH_ALEN];
+	u16 valid_links;
+	struct {
+		u8 addr[ETH_ALEN];
+	} links[MAX_NUM_MLD_LINKS];
+};
+
 /**
  * union wpa_event_data - Additional data for wpa_supplicant_event() calls
  */
@@ -7194,6 +7214,27 @@ union wpa_event_data {
 		const u8 *ric_ies;
 		/** Length of ric_ies buffer in octets */
 		size_t ric_ies_len;
+		/**
+		 * rsnxe - The RSNXE the driver transmits, or %NULL
+		 *
+		 * These are the octets the driver puts in the FT
+		 * Authentication and (Re)Association Request frames. The
+		 * Reassociation Request FTE MIC covers the RSNXE, so
+		 * wpa_ft_gen_req_ies() MICs these octets rather than a
+		 * locally generated element: sm->ap_rsnxe is refreshed only
+		 * when an association completes, so during the FT exchange it
+		 * still describes the AP being roamed away from. %NULL when
+		 * the driver transmits no RSNXE, in which case no element is
+		 * MIC'd either.
+		 */
+		const u8 *rsnxe;
+		/** Length of rsnxe buffer in octets */
+		size_t rsnxe_len;
+		/**
+		 * mlo - Per-link MLO info for the FT Reassociation Request
+		 * MIC (valid_links == 0 for a non-MLO FT)
+		 */
+		struct mlo_ft_info mlo;
 	} ft_ies;
 
 	/**
diff --git a/src/rsn_supp/wpa.h b/src/rsn_supp/wpa.h
index c61ca16a4..4bf54fe47 100644
--- a/src/rsn_supp/wpa.h
+++ b/src/rsn_supp/wpa.h
@@ -596,9 +596,12 @@ int wpa_ft_prepare_auth_request(struct wpa_sm *sm, const u8 *mdie);
 int wpa_ft_add_mdie(struct wpa_sm *sm, u8 *ies, size_t ies_len,
 		    const u8 *mdie);
 const u8 * wpa_sm_get_ft_md(struct wpa_sm *sm);
+struct mlo_ft_info;
 int wpa_ft_process_response(struct wpa_sm *sm, const u8 *ies, size_t ies_len,
 			    int ft_action, const u8 *target_ap,
-			    const u8 *ric_ies, size_t ric_ies_len);
+			    const u8 *ric_ies, size_t ric_ies_len,
+			    const u8 *rsnxe, size_t rsnxe_len,
+			    const struct mlo_ft_info *mlo_ft_info);
 int wpa_ft_is_completed(struct wpa_sm *sm);
 void wpa_reset_ft_completed(struct wpa_sm *sm);
 int wpa_ft_validate_reassoc_resp(struct wpa_sm *sm, const u8 *ies,
@@ -633,9 +636,13 @@ static inline int wpa_ft_add_mdie(struct wpa_sm *sm, u8 *ies, size_t ies_len,
 	return 0;
 }
 
+struct mlo_ft_info;
 static inline int
 wpa_ft_process_response(struct wpa_sm *sm, const u8 *ies, size_t ies_len,
-			int ft_action, const u8 *target_ap)
+			int ft_action, const u8 *target_ap,
+			const u8 *ric_ies, size_t ric_ies_len,
+			const u8 *rsnxe, size_t rsnxe_len,
+			const struct mlo_ft_info *mlo_ft_info)
 {
 	return 0;
 }
diff --git a/src/rsn_supp/wpa_ft.c b/src/rsn_supp/wpa_ft.c
index fb46fa2f4..d96212fbd 100644
--- a/src/rsn_supp/wpa_ft.c
+++ b/src/rsn_supp/wpa_ft.c
@@ -186,6 +186,65 @@ int wpa_sm_set_ft_params(struct wpa_sm *sm, const u8 *ies, size_t ies_len)
 }
 
 
+/*
+ * Build the per-link own STA link MAC address list (increasing link ID order)
+ * appended to the MLO FT Reassociation Request MIC data (IEEE Std
+ * 802.11be-2024 13.7). The supplicant's own address is the MLD address, so the
+ * per-link addresses are provided out of band by the driver MLO FT event
+ * delivered with the FT response. Returned as a wpabuf to be passed as
+ * wpa_ft_mic()'s "extra"; %NULL for a non-MLO FT (valid_links == 0).
+ */
+static struct wpabuf *wpa_ft_mlo_sta_addrs(const struct mlo_ft_info *info)
+{
+	struct wpabuf *buf;
+	unsigned int i, num_links = 0;
+
+	if (!info || !info->valid_links)
+		return NULL;
+
+	for_each_link(info->valid_links, i)
+		num_links++;
+
+	buf = wpabuf_alloc(num_links * ETH_ALEN);
+	if (!buf)
+		return NULL;
+
+	for_each_link(info->valid_links, i)
+		wpabuf_put_data(buf, info->links[i].addr, ETH_ALEN);
+
+	return buf;
+}
+
+
+/*
+ * Build the per-link AP MAC (BSSID) list (increasing link ID order) appended
+ * to the MLO FT Reassociation Response MIC data (IEEE Std 802.11be-2024 13.7).
+ * The per-link BSSIDs come from sm->mlo (populated at (re)association).
+ * Returned as a wpabuf to be passed as wpa_ft_mic()'s "extra"; %NULL for a
+ * non-MLO association (valid_links == 0). Sibling of wpa_ft_mlo_sta_addrs().
+ */
+static struct wpabuf *wpa_ft_mlo_ap_addrs(struct wpa_sm *sm)
+{
+	struct wpabuf *buf;
+	unsigned int i, num_links = 0;
+
+	if (!sm->mlo.valid_links)
+		return NULL;
+
+	for_each_link(sm->mlo.valid_links, i)
+		num_links++;
+
+	buf = wpabuf_alloc(num_links * ETH_ALEN);
+	if (!buf)
+		return NULL;
+
+	for_each_link(sm->mlo.valid_links, i)
+		wpabuf_put_data(buf, sm->mlo.links[i].bssid, ETH_ALEN);
+
+	return buf;
+}
+
+
 /**
  * wpa_ft_gen_req_ies - Generate FT (IEEE 802.11r) IEs for Auth/ReAssoc Request
  * @sm: Pointer to WPA state machine data from wpa_sm_init()
@@ -199,6 +258,10 @@ int wpa_sm_set_ft_params(struct wpa_sm *sm, const u8 *ies, size_t ies_len)
  * @ric_ies_len: Length of ric_ies buffer in octets
  * @ap_mdie: Mobility Domain IE from the target AP
  * @omit_rsnxe: Whether RSNXE is omitted from Reassociation Request frame
+ * @rsnxe: RSNXE the driver transmits, or %NULL if it transmits none. Only
+ *	taken when @mlo_ft_info is given, i.e. when the driver reported the FT
+ *	response and therefore owns that element
+ * @rsnxe_len: Length of rsnxe buffer in octets
  * Returns: Pointer to buffer with IEs or %NULL on failure
  *
  * Caller is responsible for freeing the returned buffer with os_free();
@@ -208,18 +271,21 @@ static u8 * wpa_ft_gen_req_ies(struct wpa_sm *sm, size_t *len,
 			       const u8 *kck, size_t kck_len,
 			       const u8 *target_ap,
 			       const u8 *ric_ies, size_t ric_ies_len,
-			       const u8 *ap_mdie, int omit_rsnxe)
+			       const u8 *ap_mdie, int omit_rsnxe,
+			       const u8 *rsnxe, size_t rsnxe_len,
+			       const struct mlo_ft_info *mlo_ft_info)
 {
 	size_t buf_len;
 	u8 *buf, *pos, *ftie_len, *ftie_pos, *fte_mic, *elem_count;
 	struct rsn_mdie *mdie;
 	struct rsn_ie_hdr *rsnie;
 	int mdie_len;
-	u8 rsnxe[257];
-	size_t rsnxe_len;
+	u8 mic_rsnxe[257];
+	size_t mic_rsnxe_len;
 	int rsnxe_used;
 	int res;
 	u8 mic_control;
+	struct wpabuf *ftmlo_extra = NULL;
 
 	sm->ft_completed = 0;
 	sm->ft_reassoc_completed = 0;
@@ -442,14 +508,35 @@ static u8 * wpa_ft_gen_req_ies(struct wpa_sm *sm, size_t *len,
 	}
 
 	if (omit_rsnxe) {
-		rsnxe_len = 0;
+		mic_rsnxe_len = 0;
 	} else {
-		res = wpa_gen_rsnxe(sm, rsnxe, sizeof(rsnxe));
+		res = wpa_gen_rsnxe(sm, mic_rsnxe, sizeof(mic_rsnxe));
 		if (res < 0) {
 			os_free(buf);
 			return NULL;
 		}
-		rsnxe_len = res;
+		mic_rsnxe_len = res;
+	}
+	/*
+	 * The driver transmits the Authentication and Reassociation Request
+	 * frames for an FT roam and builds their RSNXE itself - from the target
+	 * it is roaming to, and for a UHR target also from the selected
+	 * security profile and whether the target is taken as an SMD-ME, both
+	 * of which fix Extended RSN Capability fields (P802.11bn/D2.0 37.33,
+	 * 37.34). The AP computes the FTE MIC over the RSNXE it receives, so a
+	 * driver that reports the FT response also decides the element here:
+	 * its octets when it transmits one, and no element at all when it does
+	 * not, whatever omit_rsnxe guessed.
+	 */
+	if (mlo_ft_info) {
+		if (rsnxe && rsnxe_len && rsnxe_len <= sizeof(mic_rsnxe)) {
+			os_memcpy(mic_rsnxe, rsnxe, rsnxe_len);
+			mic_rsnxe_len = rsnxe_len;
+		} else {
+			mic_rsnxe_len = 0;
+		}
+		wpa_hexdump(MSG_DEBUG, "FT: RSNXE reported by the driver",
+			    mic_rsnxe, mic_rsnxe_len);
 	}
 
 	if (kck) {
@@ -467,20 +554,33 @@ static u8 * wpa_ft_gen_req_ies(struct wpa_sm *sm, size_t *len,
 		 */
 		/* Information element count */
 		*elem_count = 3 + ieee802_11_ie_count(ric_ies, ric_ies_len);
-		if (rsnxe_len)
+		if (mic_rsnxe_len)
 			*elem_count += 1;
+		/*
+		 * MLO Reassociation Request (IEEE Std 802.11be-2024 13.7):
+		 * append the per-link own STA link MAC address list to the
+		 * MIC via the extra data; the RSNE/RSNXE stay the normal
+		 * single elements. sm->own_addr is the MLD address, so the
+		 * per-link addresses come from the driver MLO FT event
+		 * delivered with the FT response. Empty when not an MLO FT.
+		 */
+		ftmlo_extra = wpa_ft_mlo_sta_addrs(mlo_ft_info);
 		if (wpa_ft_mic(sm->key_mgmt, kck, kck_len,
 			       sm->own_addr, target_ap, 5,
 			       ((u8 *) mdie) - 2, 2 + sizeof(*mdie),
 			       ftie_pos, 2 + *ftie_len,
 			       (u8 *) rsnie, 2 + rsnie->len, ric_ies,
-			       ric_ies_len, rsnxe_len ? rsnxe : NULL, rsnxe_len,
-			       NULL,
+			       ric_ies_len,
+			       mic_rsnxe_len ? mic_rsnxe : NULL, mic_rsnxe_len,
+			       ftmlo_extra,
 			       fte_mic) < 0) {
 			wpa_printf(MSG_INFO, "FT: Failed to calculate MIC");
+			wpabuf_free(ftmlo_extra);
 			os_free(buf);
 			return NULL;
 		}
+		wpabuf_free(ftmlo_extra);
+		ftmlo_extra = NULL;
 	}
 
 	*len = pos - buf;
@@ -489,7 +589,7 @@ static u8 * wpa_ft_gen_req_ies(struct wpa_sm *sm, size_t *len,
 }
 
 
-static int wpa_ft_install_ptk(struct wpa_sm *sm, const u8 *bssid)
+static int wpa_ft_install_ptk(struct wpa_sm *sm, const u8 *addr)
 {
 	int keylen;
 	enum wpa_alg alg;
@@ -506,8 +606,11 @@ static int wpa_ft_install_ptk(struct wpa_sm *sm, const u8 *bssid)
 	alg = wpa_cipher_to_alg(sm->pairwise_cipher);
 	keylen = wpa_cipher_key_len(sm->pairwise_cipher);
 
-	/* TODO: AP MLD address for MLO */
-	if (wpa_sm_set_key(sm, -1, alg, bssid, 0, 1, null_rsc, sizeof(null_rsc),
+	/*
+	 * addr is the AP MLD address for an MLO association (the pairwise key
+	 * is installed for the MLD peer) or the target AP BSSID otherwise.
+	 */
+	if (wpa_sm_set_key(sm, -1, alg, addr, 0, 1, null_rsc, sizeof(null_rsc),
 			   (u8 *) sm->ptk.tk, keylen,
 			   KEY_FLAG_PAIRWISE_RX_TX) < 0) {
 		wpa_printf(MSG_WARNING, "FT: Failed to set PTK to the driver");
@@ -515,7 +618,7 @@ static int wpa_ft_install_ptk(struct wpa_sm *sm, const u8 *bssid)
 	}
 	sm->tk_set = true;
 
-	wpa_sm_store_ptk(sm, bssid, sm->pairwise_cipher,
+	wpa_sm_store_ptk(sm, addr, sm->pairwise_cipher,
 			 sm->dot11RSNAConfigPMKLifetime, &sm->ptk);
 	return 0;
 }
@@ -539,7 +642,8 @@ int wpa_ft_prepare_auth_request(struct wpa_sm *sm, const u8 *mdie)
 	}
 
 	ft_ies = wpa_ft_gen_req_ies(sm, &ft_ies_len, NULL, sm->pmk_r0_name,
-				    NULL, 0, sm->bssid, NULL, 0, mdie, 0);
+				    NULL, 0, wpa_sm_get_auth_addr(sm), NULL, 0,
+				    mdie, 0, NULL, 0, NULL);
 	if (ft_ies) {
 		wpa_sm_update_ft_ies(sm, sm->mobility_domain,
 				     ft_ies, ft_ies_len);
@@ -583,7 +687,9 @@ const u8 * wpa_sm_get_ft_md(struct wpa_sm *sm)
 
 int wpa_ft_process_response(struct wpa_sm *sm, const u8 *ies, size_t ies_len,
 			    int ft_action, const u8 *target_ap,
-			    const u8 *ric_ies, size_t ric_ies_len)
+			    const u8 *ric_ies, size_t ric_ies_len,
+			    const u8 *rsnxe, size_t rsnxe_len,
+			    const struct mlo_ft_info *mlo_ft_info)
 {
 	u8 *ft_ies;
 	size_t ft_ies_len;
@@ -592,6 +698,7 @@ int wpa_ft_process_response(struct wpa_sm *sm, const u8 *ies, size_t ies_len,
 	u8 ptk_name[WPA_PMK_NAME_LEN];
 	int ret = -1, res;
 	const u8 *bssid;
+	const u8 *ap_addr;
 	const u8 *kck;
 	size_t kck_len, kdk_len;
 
@@ -703,6 +810,9 @@ int wpa_ft_process_response(struct wpa_sm *sm, const u8 *ies, size_t ies_len,
 		sm->hash_alg = RSN_HASH_SHA256;
 
 	bssid = target_ap;
+	/* Use the target AP MLD address for the MLO FT PTK and MIC. */
+	ap_addr = (mlo_ft_info && mlo_ft_info->valid_links) ?
+		mlo_ft_info->ap_mld_addr : target_ap;
 
 	wpa_ft_pasn_store_r1kh(sm, bssid);
 
@@ -713,9 +823,8 @@ int wpa_ft_process_response(struct wpa_sm *sm, const u8 *ies, size_t ies_len,
 	else
 		kdk_len = 0;
 
-	/* TODO: AP MLD address for MLO */
 	if (wpa_pmk_r1_to_ptk(sm->pmk_r1, sm->pmk_r1_len, sm->snonce,
-			      parse.fte_anonce, sm->own_addr, bssid,
+			      parse.fte_anonce, sm->own_addr, ap_addr,
 			      sm->pmk_r1_name, &sm->ptk, ptk_name, sm->key_mgmt,
 			      sm->pairwise_cipher,
 			      kdk_len) < 0)
@@ -740,12 +849,24 @@ int wpa_ft_process_response(struct wpa_sm *sm, const u8 *ies, size_t ies_len,
 		kck = sm->ptk.kck;
 		kck_len = sm->ptk.kck_len;
 	}
+	/*
+	 * omit_rsnxe = !sm->ap_rsnxe is only a guess at what the driver
+	 * transmits. sm->ap_rsnxe is refreshed only when an association
+	 * completes - wpa_supplicant_event_associnfo() from the beacon IEs, or
+	 * wpa_supplicant_assoc_update_ie() from the scan entry - which for an
+	 * FT roam happens after the Reassociation Request has been sent, so
+	 * here it still describes the AP being roamed away from. A UHR target
+	 * can also advertise a Security Profile element and no RSNXE while the
+	 * STA does send one. rsnxe overrides the guess whenever the driver
+	 * reports its own octets.
+	 */
 	ft_ies = wpa_ft_gen_req_ies(sm, &ft_ies_len, parse.fte_anonce,
 				    sm->pmk_r1_name,
-				    kck, kck_len, bssid,
+				    kck, kck_len, ap_addr,
 				    ric_ies, ric_ies_len,
 				    parse.mdie ? parse.mdie - 2 : NULL,
-				    !sm->ap_rsnxe);
+				    !sm->ap_rsnxe, rsnxe, rsnxe_len,
+				    mlo_ft_info);
 	if (ft_ies) {
 		wpa_sm_update_ft_ies(sm, sm->mobility_domain,
 				     ft_ies, ft_ies_len);
@@ -753,7 +874,7 @@ int wpa_ft_process_response(struct wpa_sm *sm, const u8 *ies, size_t ies_len,
 	}
 
 	wpa_sm_mark_authenticated(sm, bssid);
-	res = wpa_ft_install_ptk(sm, bssid);
+	res = wpa_ft_install_ptk(sm, ap_addr);
 	if (res) {
 		/*
 		 * Some drivers do not support key configuration when we are
@@ -1024,6 +1145,252 @@ static int wpa_ft_process_bigtk_subelem(struct wpa_sm *sm, const u8 *bigtk_elem,
 }
 
 
+/*
+ * Install the per-link MLO GTKs carried in the FT Reassociation Response as
+ * per-link MLO GTK subelements. elem[]/len[] are indexed by link id and
+ * valid_links is the bitmap of populated entries. Self-contained (no ml.c
+ * dependency): each subelement is AES-unwrapped with the KEK and installed
+ * to its link via wpa_sm_set_key().
+ */
+static int wpa_ft_process_mlo_gtk_subelems(struct wpa_sm *sm, const u8 **elem,
+					   const size_t *len, u16 valid_links)
+{
+	const u8 *kek;
+	size_t kek_len;
+	unsigned int i;
+
+	if (wpa_key_mgmt_fils(sm->key_mgmt)) {
+		kek = sm->ptk.kek2;
+		kek_len = sm->ptk.kek2_len;
+	} else {
+		kek = sm->ptk.kek;
+		kek_len = sm->ptk.kek_len;
+	}
+
+	for (i = 0; i < MAX_NUM_MLD_LINKS; i++) {
+		const u8 *gtk_elem = elem[i];
+		size_t gtk_elem_len = len[i];
+		u8 gtk[32];
+		size_t gtk_len, keylen, rsc_len;
+		enum wpa_alg alg;
+		int keyidx;
+		u8 link_id;
+
+		if (!(valid_links & BIT(i)) || !gtk_elem)
+			continue;
+
+		/* Key Info[2] | Link Info[1] | Key Length[1] | RSC[8] | Key */
+		if (gtk_elem_len < 12 + 24 || (gtk_elem_len - 12) % 8 ||
+		    gtk_elem_len - 20 > sizeof(gtk)) {
+			wpa_printf(MSG_DEBUG,
+				   "MLO: Invalid GTK sub-elem length %zu",
+				   gtk_elem_len);
+			return -1;
+		}
+		gtk_len = gtk_elem_len - 20;
+		if (aes_unwrap(kek, kek_len, gtk_len / 8, gtk_elem + 12, gtk)) {
+			wpa_printf(MSG_WARNING,
+				   "MLO: AES unwrap failed - could not decrypt GTK");
+			return -1;
+		}
+
+		keylen = wpa_cipher_key_len(sm->group_cipher);
+		rsc_len = wpa_cipher_rsc_len(sm->group_cipher);
+		alg = wpa_cipher_to_alg(sm->group_cipher);
+		if (alg == WPA_ALG_NONE) {
+			wpa_printf(MSG_WARNING, "MLO: Unsupported Group Cipher %d",
+				   sm->group_cipher);
+			return -1;
+		}
+		if (gtk_len < keylen) {
+			wpa_printf(MSG_DEBUG, "MLO: Too short GTK in FTIE");
+			return -1;
+		}
+
+		keyidx = WPA_GET_LE16(gtk_elem) & 0x03;
+		link_id = gtk_elem[2] & 0x0f;
+		if (gtk_elem[3] != keylen) {
+			wpa_printf(MSG_DEBUG,
+				   "MLO: GTK length mismatch: received %d negotiated %zu",
+				   gtk_elem[3], keylen);
+			return -1;
+		}
+
+		if (sm->group_cipher == WPA_CIPHER_TKIP) {
+			/* Swap Tx/Rx keys for Michael MIC */
+			u8 tmp[8];
+
+			os_memcpy(tmp, gtk + 16, 8);
+			os_memcpy(gtk + 16, gtk + 24, 8);
+			os_memcpy(gtk + 24, tmp, 8);
+		}
+
+		wpa_printf(MSG_DEBUG, "MLO: Set Link %u GTK", link_id);
+		if (wpa_sm_set_key(sm, link_id, alg, broadcast_ether_addr,
+				   keyidx, 0, gtk_elem + 4, rsc_len, gtk, keylen,
+				   KEY_FLAG_GROUP_RX) < 0) {
+			wpa_printf(MSG_WARNING,
+				   "MLO: Failed to set GTK to the driver");
+			return -1;
+		}
+	}
+
+	return 0;
+}
+
+
+static int wpa_ft_process_mlo_igtk_subelems(struct wpa_sm *sm, const u8 **elem,
+					    const size_t *len, u16 valid_links)
+{
+	const u8 *kek;
+	size_t kek_len;
+	unsigned int i;
+
+	if (sm->mgmt_group_cipher != WPA_CIPHER_AES_128_CMAC &&
+	    sm->mgmt_group_cipher != WPA_CIPHER_BIP_GMAC_128 &&
+	    sm->mgmt_group_cipher != WPA_CIPHER_BIP_GMAC_256 &&
+	    sm->mgmt_group_cipher != WPA_CIPHER_BIP_CMAC_256)
+		return 0;
+
+	if (wpa_key_mgmt_fils(sm->key_mgmt)) {
+		kek = sm->ptk.kek2;
+		kek_len = sm->ptk.kek2_len;
+	} else {
+		kek = sm->ptk.kek;
+		kek_len = sm->ptk.kek_len;
+	}
+
+	for (i = 0; i < MAX_NUM_MLD_LINKS; i++) {
+		const u8 *igtk_elem = elem[i];
+		size_t igtk_elem_len = len[i];
+		u8 igtk[WPA_IGTK_MAX_LEN];
+		size_t igtk_len;
+		u16 keyidx;
+		u8 link_id;
+
+		if (!(valid_links & BIT(i)) || !igtk_elem)
+			continue;
+
+		/* KeyID[2] | IPN[6] | Link Info[1] | Key Length[1] | Key */
+		igtk_len = wpa_cipher_key_len(sm->mgmt_group_cipher);
+		if (igtk_elem_len != 2 + 6 + 1 + 1 + igtk_len + 8) {
+			wpa_printf(MSG_DEBUG,
+				   "MLO: Invalid IGTK sub-elem length %zu",
+				   igtk_elem_len);
+			return -1;
+		}
+		if (igtk_elem[9] != igtk_len) {
+			wpa_printf(MSG_DEBUG,
+				   "MLO: Invalid IGTK sub-elem Key Length %d",
+				   igtk_elem[9]);
+			return -1;
+		}
+
+		if (aes_unwrap(kek, kek_len, igtk_len / 8, igtk_elem + 10,
+			       igtk)) {
+			wpa_printf(MSG_WARNING,
+				   "MLO: AES unwrap failed - could not decrypt IGTK");
+			return -1;
+		}
+
+		keyidx = WPA_GET_LE16(igtk_elem);
+		link_id = igtk_elem[8] & 0x0f;
+
+		wpa_printf(MSG_DEBUG, "MLO: Set Link %u IGTK", link_id);
+		if (wpa_sm_set_key(sm, link_id,
+				   wpa_cipher_to_alg(sm->mgmt_group_cipher),
+				   broadcast_ether_addr, keyidx, 0,
+				   igtk_elem + 2, 6, igtk, igtk_len,
+				   KEY_FLAG_GROUP_RX) < 0) {
+			wpa_printf(MSG_WARNING,
+				   "MLO: Failed to set IGTK to the driver");
+			forced_memzero(igtk, sizeof(igtk));
+			return -1;
+		}
+		forced_memzero(igtk, sizeof(igtk));
+	}
+
+	return 0;
+}
+
+
+static int wpa_ft_process_mlo_bigtk_subelems(struct wpa_sm *sm,
+					     const u8 **elem, const size_t *len,
+					     u16 valid_links)
+{
+	const u8 *kek;
+	size_t kek_len;
+	unsigned int i;
+
+	if (!sm->beacon_prot ||
+	    (sm->mgmt_group_cipher != WPA_CIPHER_AES_128_CMAC &&
+	     sm->mgmt_group_cipher != WPA_CIPHER_BIP_GMAC_128 &&
+	     sm->mgmt_group_cipher != WPA_CIPHER_BIP_GMAC_256 &&
+	     sm->mgmt_group_cipher != WPA_CIPHER_BIP_CMAC_256))
+		return 0;
+
+	if (wpa_key_mgmt_fils(sm->key_mgmt)) {
+		kek = sm->ptk.kek2;
+		kek_len = sm->ptk.kek2_len;
+	} else {
+		kek = sm->ptk.kek;
+		kek_len = sm->ptk.kek_len;
+	}
+
+	for (i = 0; i < MAX_NUM_MLD_LINKS; i++) {
+		const u8 *bigtk_elem = elem[i];
+		size_t bigtk_elem_len = len[i];
+		u8 bigtk[WPA_BIGTK_MAX_LEN];
+		size_t bigtk_len;
+		u16 keyidx;
+		u8 link_id;
+
+		if (!(valid_links & BIT(i)) || !bigtk_elem)
+			continue;
+
+		/* KeyID[2] | IPN[6] | Link Info[1] | Key Length[1] | Key */
+		bigtk_len = wpa_cipher_key_len(sm->mgmt_group_cipher);
+		if (bigtk_elem_len != 2 + 6 + 1 + 1 + bigtk_len + 8) {
+			wpa_printf(MSG_DEBUG,
+				   "MLO: Invalid BIGTK sub-elem length %zu",
+				   bigtk_elem_len);
+			return -1;
+		}
+		if (bigtk_elem[9] != bigtk_len) {
+			wpa_printf(MSG_DEBUG,
+				   "MLO: Invalid BIGTK sub-elem Key Length %d",
+				   bigtk_elem[9]);
+			return -1;
+		}
+
+		if (aes_unwrap(kek, kek_len, bigtk_len / 8, bigtk_elem + 10,
+			       bigtk)) {
+			wpa_printf(MSG_WARNING,
+				   "MLO: AES unwrap failed - could not decrypt BIGTK");
+			return -1;
+		}
+
+		keyidx = WPA_GET_LE16(bigtk_elem);
+		link_id = bigtk_elem[8] & 0x0f;
+
+		wpa_printf(MSG_DEBUG, "MLO: Set Link %u BIGTK", link_id);
+		if (wpa_sm_set_key(sm, link_id,
+				   wpa_cipher_to_alg(sm->mgmt_group_cipher),
+				   broadcast_ether_addr, keyidx, 0,
+				   bigtk_elem + 2, 6, bigtk, bigtk_len,
+				   KEY_FLAG_GROUP_RX) < 0) {
+			wpa_printf(MSG_WARNING,
+				   "MLO: Failed to set BIGTK to the driver");
+			forced_memzero(bigtk, sizeof(bigtk));
+			return -1;
+		}
+		forced_memzero(bigtk, sizeof(bigtk));
+	}
+
+	return 0;
+}
+
+
 int wpa_ft_validate_reassoc_resp(struct wpa_sm *sm, const u8 *ies,
 				 size_t ies_len, const u8 *src_addr)
 {
@@ -1033,9 +1400,15 @@ int wpa_ft_validate_reassoc_resp(struct wpa_sm *sm, const u8 *ies,
 	u8 mic[WPA_EAPOL_KEY_MIC_MAX_LEN];
 	const u8 *kck;
 	size_t kck_len;
+	const u8 *ap_addr;
 	int own_rsnxe_used;
 	size_t mic_len;
 	int ret = -1;
+	struct wpabuf *ftmlo_rsne = NULL, *ftmlo_rsnxe = NULL;
+	struct wpabuf *ftmlo_extra = NULL;
+	const u8 *mic_rsne, *mic_rsnxe;
+	size_t mic_rsne_len, mic_rsnxe_len;
+	const struct wpabuf *mic_extra;
 
 	os_memset(&parse, 0, sizeof(parse));
 
@@ -1137,16 +1510,107 @@ int wpa_ft_validate_reassoc_resp(struct wpa_sm *sm, const u8 *ies,
 		goto fail;
 	}
 
-	count = 3;
-	if (parse.ric)
-		count += ieee802_11_ie_count(parse.ric, parse.ric_len);
-	if (parse.rsnxe)
-		count++;
-	if (parse.fte_elem_count != count) {
-		wpa_printf(MSG_DEBUG, "FT: Unexpected IE count in MIC "
-			   "Control: received %u expected %u",
-			   parse.fte_elem_count, count);
-		goto fail;
+	/*
+	 * MLO Reassociation Response (IEEE Std 802.11be-2024 13.7): the FTE
+	 * MIC covers the concatenation of the per-link RSNEs and per-link
+	 * RSNXEs (increasing link ID order) followed by the per-link AP MAC
+	 * (BSSID) list. Build these from the Reassociation Response itself so
+	 * verification uses the exact elements the AP protected: the assoc
+	 * link uses the top-level RSNE/RSNXE and every other link uses its
+	 * Multi-Link element per-STA profile, inheriting the assoc link's
+	 * elements when the profile omits them. A non-MLO association
+	 * (valid_links == 0) uses the single RSNE/RSNXE.
+	 */
+	mic_rsne = parse.rsn - 2;
+	mic_rsne_len = parse.rsn_len + 2;
+	mic_rsnxe = parse.rsnxe ? parse.rsnxe - 2 : NULL;
+	mic_rsnxe_len = parse.rsnxe ? parse.rsnxe_len + 2 : 0;
+	mic_extra = NULL;
+	if (sm->mlo.valid_links) {
+		struct ieee802_11_elems elems;
+		struct wpabuf *mlbuf;
+		unsigned int i, num_links = 0;
+
+		/*
+		 * Each valid link contributes at most one RSNE and one RSNXE,
+		 * i.e. 2 + 255 octets each, so size the buffers by the number
+		 * of valid links instead of the whole link ID range.
+		 */
+		for_each_link(sm->mlo.valid_links, i)
+			num_links++;
+
+		ftmlo_rsne = wpabuf_alloc(num_links * 257);
+		ftmlo_rsnxe = wpabuf_alloc(num_links * 257);
+		ftmlo_extra = wpa_ft_mlo_ap_addrs(sm);
+		if (!ftmlo_rsne || !ftmlo_rsnxe || !ftmlo_extra)
+			goto fail;
+
+		for (i = 0; i < MAX_NUM_MLD_LINKS; i++) {
+			const u8 *rsne = parse.rsn ? parse.rsn - 2 : NULL;
+			const u8 *rsnxe = parse.rsnxe ? parse.rsnxe - 2 : NULL;
+			size_t rsne_len = parse.rsn ? parse.rsn_len + 2 : 0;
+			size_t rsnxe_len = parse.rsnxe ?
+				parse.rsnxe_len + 2 : 0;
+
+			if (!(sm->mlo.valid_links & BIT(i)))
+				continue;
+
+			/*
+			 * Non-assoc links: use the link's effective RSNE/RSNXE
+			 * from its per-STA profile, inheriting the assoc link
+			 * elements above when the profile omits them.
+			 *
+			 * ieee802_11_parse_link_assoc_req() defragments the
+			 * per-STA profile subelements in place, so the
+			 * Multi-Link element has to be defragmented again for
+			 * each link: sharing one buffer would make every link
+			 * after the first parse already shifted bytes.
+			 */
+			mlbuf = NULL;
+			if (i != sm->mlo.assoc_link_id &&
+			    ieee802_11_parse_elems(ies, ies_len, &elems, 1) !=
+			    ParseFailed && elems.basic_mle)
+				mlbuf = ieee802_11_defrag(elems.basic_mle,
+							  elems.basic_mle_len,
+							  true);
+
+			if (mlbuf &&
+			    ieee802_11_parse_link_assoc_req(
+				&elems, mlbuf, i, true) != ParseFailed) {
+				rsne = elems.rsn_ie ? elems.rsn_ie - 2 : NULL;
+				rsne_len = elems.rsn_ie ?
+					elems.rsn_ie_len + 2 : 0;
+				rsnxe = elems.rsnxe ? elems.rsnxe - 2 : NULL;
+				rsnxe_len = elems.rsnxe ?
+					elems.rsnxe_len + 2 : 0;
+			} else if (mlbuf) {
+				wpa_printf(MSG_DEBUG,
+					   "FT: link %u: no per-STA profile",
+					   i);
+			}
+
+			/*
+			 * rsne/rsnxe may point into mlbuf, so copy the bytes
+			 * out before it is freed.
+			 */
+			if (rsne)
+				wpabuf_put_data(ftmlo_rsne, rsne, rsne_len);
+			if (rsnxe)
+				wpabuf_put_data(ftmlo_rsnxe, rsnxe, rsnxe_len);
+
+			wpabuf_free(mlbuf);
+		}
+
+		mic_rsne = wpabuf_head(ftmlo_rsne);
+		mic_rsne_len = wpabuf_len(ftmlo_rsne);
+		if (wpabuf_len(ftmlo_rsnxe)) {
+			mic_rsnxe = wpabuf_head(ftmlo_rsnxe);
+			mic_rsnxe_len = wpabuf_len(ftmlo_rsnxe);
+		} else {
+			mic_rsnxe = NULL;
+			mic_rsnxe_len = 0;
+		}
+		mic_extra = ftmlo_extra;
 	}
 
 	if (wpa_key_mgmt_fils(sm->key_mgmt)) {
@@ -1157,14 +1621,42 @@ int wpa_ft_validate_reassoc_resp(struct wpa_sm *sm, const u8 *ies,
 		kck_len = sm->ptk.kck_len;
 	}
 
-	if (wpa_ft_mic(sm->key_mgmt, kck, kck_len, sm->own_addr, src_addr, 6,
+	/*
+	 * Element count protected by the FTE MIC: MDE + FTE + the RSNE(s) and
+	 * RSNXE(s) (per-link for an MLO association) + RIC.
+	 */
+	count = 2; /* MDE and FTE */
+	if (sm->mlo.valid_links) {
+		count += ieee802_11_ie_count(mic_rsne, mic_rsne_len);
+		if (mic_rsnxe)
+			count += ieee802_11_ie_count(mic_rsnxe, mic_rsnxe_len);
+	} else {
+		count++; /* RSNE */
+		if (parse.rsnxe)
+			count++;
+	}
+	if (parse.ric)
+		count += ieee802_11_ie_count(parse.ric, parse.ric_len);
+	if (parse.fte_elem_count != count) {
+		wpa_printf(MSG_DEBUG,
+			   "FT: Unexpected IE count in MIC Control: received %u expected %u",
+			   parse.fte_elem_count, count);
+		goto fail;
+	}
+
+	/*
+	 * Reassociation Response MIC: use the AP MLD address as the AP
+	 * address for an MLO association (sm->own_addr is the STA MLD
+	 * address); non-MLO uses the target AP BSSID from the caller.
+	 */
+	ap_addr = sm->mlo.valid_links ? sm->mlo.ap_mld_addr : src_addr;
+	if (wpa_ft_mic(sm->key_mgmt, kck, kck_len, sm->own_addr, ap_addr, 6,
 		       parse.mdie - 2, parse.mdie_len + 2,
 		       parse.ftie - 2, parse.ftie_len + 2,
-		       parse.rsn - 2, parse.rsn_len + 2,
+		       mic_rsne, mic_rsne_len,
 		       parse.ric, parse.ric_len,
-		       parse.rsnxe ? parse.rsnxe - 2 : NULL,
-		       parse.rsnxe ? parse.rsnxe_len + 2 : 0,
-		       NULL,
+		       mic_rsnxe, mic_rsnxe_len,
+		       mic_extra,
 		       mic) < 0) {
 		wpa_printf(MSG_DEBUG, "FT: Failed to calculate MIC");
 		goto fail;
@@ -1254,6 +1746,24 @@ int wpa_ft_validate_reassoc_resp(struct wpa_sm *sm, const u8 *ies,
 
 	sm->ft_reassoc_completed = 1;
 
+	/*
+	 * MLO Reassociation Response: group keys are delivered as per-link
+	 * MLO GTK/IGTK/BIGTK subelements (a top-level single key must not be
+	 * present). Install each link's keys.
+	 */
+	if (sm->mlo.valid_links &&
+	    (parse.gtk != NULL || parse.igtk != NULL || parse.bigtk != NULL ||
+	     wpa_ft_process_mlo_gtk_subelems(sm, parse.mlo_gtk,
+					     parse.mlo_gtk_len,
+					     parse.valid_mlo_gtks) < 0 ||
+	     wpa_ft_process_mlo_igtk_subelems(sm, parse.mlo_igtk,
+					      parse.mlo_igtk_len,
+					      parse.valid_mlo_igtks) < 0 ||
+	     wpa_ft_process_mlo_bigtk_subelems(sm, parse.mlo_bigtk,
+					       parse.mlo_bigtk_len,
+					       parse.valid_mlo_bigtks) < 0))
+		goto fail;
+
 	if (wpa_ft_process_gtk_subelem(sm, parse.gtk, parse.gtk_len) < 0 ||
 	    wpa_ft_process_igtk_subelem(sm, parse.igtk, parse.igtk_len) < 0 ||
 	    wpa_ft_process_bigtk_subelem(sm, parse.bigtk, parse.bigtk_len) < 0)
@@ -1262,7 +1772,7 @@ int wpa_ft_validate_reassoc_resp(struct wpa_sm *sm, const u8 *ies,
 	if (sm->set_ptk_after_assoc) {
 		wpa_printf(MSG_DEBUG, "FT: Try to set PTK again now that we "
 			   "are associated");
-		if (wpa_ft_install_ptk(sm, src_addr) < 0)
+		if (wpa_ft_install_ptk(sm, ap_addr) < 0)
 			goto fail;
 		sm->set_ptk_after_assoc = 0;
 	}
@@ -1278,6 +1788,9 @@ int wpa_ft_validate_reassoc_resp(struct wpa_sm *sm, const u8 *ies,
 
 	ret = 0;
 fail:
+	wpabuf_free(ftmlo_rsne);
+	wpabuf_free(ftmlo_rsnxe);
+	wpabuf_free(ftmlo_extra);
 	wpa_ft_parse_ies_free(&parse);
 	return ret;
 }
@@ -1316,7 +1829,8 @@ int wpa_ft_start_over_ds(struct wpa_sm *sm, const u8 *target_ap,
 	}
 
 	ft_ies = wpa_ft_gen_req_ies(sm, &ft_ies_len, NULL, sm->pmk_r0_name,
-				    NULL, 0, target_ap, NULL, 0, mdie, 0);
+				    NULL, 0, target_ap, NULL, 0, mdie, 0,
+				    NULL, 0, NULL);
 	if (ft_ies) {
 		sm->over_the_ds_in_progress = 1;
 		os_memcpy(sm->target_ap, target_ap, ETH_ALEN);
diff --git a/wpa_supplicant/events.c b/wpa_supplicant/events.c
index c0798db71..9255fdd95 100644
--- a/wpa_supplicant/events.c
+++ b/wpa_supplicant/events.c
@@ -4928,6 +4928,19 @@ static int wpa_supplicant_event_assoc(struct wpa_supplicant *wpa_s,
 		wpa_supplicant_assoc_update_ie(wpa_s);
 	}
 
+	/*
+	 * The FT Reassociation Response MIC uses the target association's
+	 * AP MLD address and per-link BSSIDs, so update sm->mlo before
+	 * processing the association info and validating that response.
+	 */
+	if (wpa_sm_set_ml_info(wpa_s)) {
+		wpa_dbg(wpa_s, MSG_INFO,
+			"Failed to set MLO connection info to wpa_sm");
+		wpa_supplicant_deauthenticate(wpa_s,
+					      WLAN_REASON_DEAUTH_LEAVING);
+		return -1;
+	}
+
 	if (data && wpa_supplicant_event_associnfo(wpa_s, data) < 0)
 		return -1;
 	/*
@@ -5058,14 +5071,6 @@ static int wpa_supplicant_event_assoc(struct wpa_supplicant *wpa_s,
 	}
 #endif /* CONFIG_PMKSA_PRIVACY */
 
-	if (wpa_sm_set_ml_info(wpa_s)) {
-		wpa_dbg(wpa_s, MSG_INFO,
-			"Failed to set MLO connection info to wpa_sm");
-		wpa_supplicant_deauthenticate(wpa_s,
-					      WLAN_REASON_DEAUTH_LEAVING);
-		return -1;
-	}
-
 	if (wpa_s->l2)
 		l2_packet_notify_auth_start(wpa_s->l2);
 
@@ -5722,12 +5727,23 @@ wpa_supplicant_event_ft_response(struct wpa_supplicant *wpa_s,
 	if (data == NULL)
 		return;
 
+	/*
+	 * For an MLO FT roam the driver reports the per-link MLO info in the
+	 * same event; pass it to wpa_ft_process_response() so the per-link
+	 * addresses are folded into the Reassociation Request MIC. It is a
+	 * no-op for a non-MLO FT (valid_links == 0). The RSNXE of the same
+	 * event is what the driver transmits, so it is the element the
+	 * Reassociation Request FTE MIC has to cover.
+	 */
 	if (wpa_ft_process_response(wpa_s->wpa, data->ft_ies.ies,
 				    data->ft_ies.ies_len,
 				    data->ft_ies.ft_action,
 				    data->ft_ies.target_ap,
 				    data->ft_ies.ric_ies,
-				    data->ft_ies.ric_ies_len) < 0) {
+				    data->ft_ies.ric_ies_len,
+				    data->ft_ies.rsnxe,
+				    data->ft_ies.rsnxe_len,
+				    &data->ft_ies.mlo) < 0) {
 		/* TODO: prevent MLME/driver from trying to associate? */
 	}
 }
@@ -5813,7 +5829,8 @@ static void ft_rx_action(struct wpa_supplicant *wpa_s, const u8 *da,
 
 	if (wpa_ft_process_response(wpa_s->wpa, data + 1 + 2 * ETH_ALEN + 2,
 				    len - (1 + 2 * ETH_ALEN + 2), 1,
-				    target_ap_addr, NULL, 0) < 0)
+				    target_ap_addr, NULL, 0, NULL, 0,
+				    NULL) < 0)
 		return;
 
 #ifdef CONFIG_SME
diff --git a/wpa_supplicant/sme.c b/wpa_supplicant/sme.c
index 1e4dc15de..544a32d8e 100644
--- a/wpa_supplicant/sme.c
+++ b/wpa_supplicant/sme.c
@@ -5178,7 +5178,8 @@ void sme_event_auth(struct wpa_supplicant *wpa_s, union wpa_event_data *data)
 		if (wpa_ft_process_response(wpa_s->wpa, data->auth.ies,
 					    data->auth.ies_len, 0,
 					    data->auth.peer,
-					    ric_ies, ric_ies_len) < 0) {
+					    ric_ies, ric_ies_len, NULL, 0,
+					    NULL) < 0) {
 			wpa_dbg(wpa_s, MSG_DEBUG,
 				"SME: FT Authentication response processing failed");
 			wpa_msg(wpa_s, MSG_INFO, WPA_EVENT_DISCONNECTED "bssid="
-- 
2.45.2




More information about the Hostap mailing list