[PATCH v3 6/6] tests: relay: also add GCMP/GMAC

Johannes Berg johannes at sipsolutions.net
Wed Oct 7 13:35:55 PDT 2026


From: Johannes Berg <johannes.berg at intel.com>

Turns out these are needed for UHR, since that
requires security profile, which in turn only
defines profiles with GCMP/GMAC. Add some code
to handle that as well.

type=feature
ticket=none

Signed-off-by: Johannes Berg <johannes.berg at intel.com>
---
 tests/hwsim/relay/gcmp.py | 84 +++++++++++++++++++++++++++++++++++++++
 tests/hwsim/relay/gmac.py | 68 +++++++++++++++++++++++++++++++
 2 files changed, 152 insertions(+)
 create mode 100644 tests/hwsim/relay/gcmp.py
 create mode 100644 tests/hwsim/relay/gmac.py

diff --git a/tests/hwsim/relay/gcmp.py b/tests/hwsim/relay/gcmp.py
new file mode 100644
index 000000000000..1d3aee6afbf5
--- /dev/null
+++ b/tests/hwsim/relay/gcmp.py
@@ -0,0 +1,84 @@
+#
+# GCMP-256 protected management frame crypto (relay layer)
+#
+# Copyright (C) 2026 Intel Corporation
+#
+# This software may be distributed under the terms of the BSD license.
+# See README for more details.
+#
+
+try:
+    from Cryptodome.Cipher import AES
+except ImportError:
+    from Crypto.Cipher import AES
+
+from . import RelayLayer
+from .frame import (IEEE80211_FCTL_PROTECTED, MGMT_HDR_LEN, is_protected_mgmt,
+                    masked_fc_aad, client_addr)
+from .ccmp import sta_tk_getter
+
+GCMP_HDR_LEN = 8
+GCMP_MIC_LEN = 16
+
+
+def _mgmt_aad_nonce(frame):
+    aad = masked_fc_aad(frame, extra_mask=IEEE80211_FCTL_PROTECTED)
+    aad += bytes([frame[22] & 0x0f, 0])
+
+    gcmp_hdr = frame[MGMT_HDR_LEN:MGMT_HDR_LEN + GCMP_HDR_LEN]
+    pn = bytes([gcmp_hdr[7], gcmp_hdr[6], gcmp_hdr[5], gcmp_hdr[4],
+               gcmp_hdr[1], gcmp_hdr[0]])
+    return aad, frame[10:16] + pn
+
+
+def gcmp_decrypt_mgmt(frame, tk):
+    aad, nonce = _mgmt_aad_nonce(frame)
+
+    body_start = MGMT_HDR_LEN + GCMP_HDR_LEN
+    ciphertext = frame[body_start:len(frame) - GCMP_MIC_LEN]
+    mic = frame[len(frame) - GCMP_MIC_LEN:]
+
+    cipher = AES.new(tk, AES.MODE_GCM, nonce=nonce, mac_len=GCMP_MIC_LEN)
+    cipher.update(aad)
+    return cipher.decrypt_and_verify(ciphertext, mic)
+
+
+def gcmp_encrypt_mgmt(frame, tk, body):
+    aad, nonce = _mgmt_aad_nonce(frame)
+
+    cipher = AES.new(tk, AES.MODE_GCM, nonce=nonce, mac_len=GCMP_MIC_LEN)
+    cipher.update(aad)
+    ciphertext, mic = cipher.encrypt_and_digest(body)
+
+    body_start = MGMT_HDR_LEN + GCMP_HDR_LEN
+    return frame[:body_start] + ciphertext + mic
+
+
+class ProtectedMgmtCrypto(RelayLayer):
+    """
+    Decrypt GCMP-256-protected management frames and pass them to
+    the next layer unencrypted (but protected bit still set)
+
+    get_tk: callable get_tk(client_addr) or hostapd instance
+    """
+    def __init__(self, upstream, get_tk):
+        super().__init__(upstream)
+        self._get_tk = get_tk if callable(get_tk) else \
+            sta_tk_getter(get_tk, cipher="GCMP-256")
+
+    def _run(self, src, freq, frame):
+        if is_protected_mgmt(frame):
+            tk = self._get_tk(client_addr(frame))
+            if tk is not None:
+                try:
+                    body = gcmp_decrypt_mgmt(frame, tk)
+                except (ValueError, IndexError):
+                    body = None
+                if body is not None:
+                    clear_frame = frame[:MGMT_HDR_LEN] + body
+                    out_frame = self._call_next(src, freq, clear_frame)
+                    if out_frame is not None and is_protected_mgmt(out_frame):
+                        return gcmp_encrypt_mgmt(frame, tk,
+                                                 out_frame[MGMT_HDR_LEN:])
+                    return out_frame
+        return self._call_next(src, freq, frame)
diff --git a/tests/hwsim/relay/gmac.py b/tests/hwsim/relay/gmac.py
new file mode 100644
index 000000000000..b639ea9b9920
--- /dev/null
+++ b/tests/hwsim/relay/gmac.py
@@ -0,0 +1,68 @@
+#
+# BIP-GMAC-256 group management frame authentication (relay layer)
+#
+# Copyright (C) 2026 Intel Corporation
+#
+# This software may be distributed under the terms of the BSD license.
+# See README for more details.
+#
+
+try:
+    from Cryptodome.Cipher import AES
+except ImportError:
+    from Crypto.Cipher import AES
+
+from . import RelayLayer
+from .frame import MGMT_HDR_LEN, is_beacon
+from .bip import find_mmie, bip_aad, group_key_getter
+
+GMAC_MIC_LEN = 16
+
+
+def bip_gmac_mic(key, frame):
+    aad = bip_aad(frame)
+    body = bytearray(frame[MGMT_HDR_LEN:])
+    if is_beacon(frame):
+        body[0:8] = b'\x00' * 8
+    body[-GMAC_MIC_LEN:] = b'\x00' * GMAC_MIC_LEN
+
+    mmie = find_mmie(frame, GMAC_MIC_LEN)
+    ipn = mmie[4:10]
+    nonce = frame[10:16] + bytes(reversed(ipn))
+
+    cipher = AES.new(key, AES.MODE_GCM, nonce=nonce, mac_len=GMAC_MIC_LEN)
+    cipher.update(aad + bytes(body))
+    _, mic = cipher.encrypt_and_digest(b'')
+    return mic
+
+
+def resign_frame(frame, key):
+    if find_mmie(frame, GMAC_MIC_LEN) is None:
+        return frame
+    return frame[:-GMAC_MIC_LEN] + bip_gmac_mic(key, frame)
+
+
+class GroupMgmtSigner(RelayLayer):
+    """
+    Recalculate the MIC for a group-addressed frame after the inner
+    layers have changed it.
+
+    get_key: callable get_key(frame) or hostapd instance
+    """
+
+    def __init__(self, upstream, get_key):
+        super().__init__(upstream)
+        self._get_key = get_key if callable(get_key) else \
+            group_key_getter(get_key)
+
+    def _run(self, src, freq, frame):
+        out_frame = self._call_next(src, freq, frame)
+        if out_frame is None or find_mmie(out_frame, GMAC_MIC_LEN) is None:
+            return out_frame
+        return self._resign(frame, out_frame)
+
+    def _resign(self, old, frame):
+        if frame == old:
+            return frame
+        key = self._get_key(frame)
+        return resign_frame(frame, key) if key is not None else frame
-- 
2.56.0




More information about the Hostap mailing list