Files
MontaukOS/tests/wifi/ap_mlme.py
T

641 lines
30 KiB
Python

#!/usr/bin/env python3
"""Drives the real MLME through a complete join and validates every frame it
puts on the air, plus the 802.11 <-> Ethernet translation in both directions.
The access point side is written independently here (hashlib / cryptography for
the handshake, hand-decoded 802.11 for the frames), so this is a check of what
the driver actually emits rather than a restatement of it.
Copyright (c) 2026 Daniel Hammer
"""
import hashlib, hmac, os, struct, subprocess, sys
from cryptography.hazmat.primitives.keywrap import aes_key_wrap
HARNESS = sys.argv[1]
AP = bytes.fromhex('001122334455')
STA = bytes.fromhex('aabbccddeeff')
SSID = "MontaukTest"
PASS = "supersecret123"
CHANNEL = 6
fails = []
def state_of(result):
f = result.split()
return int(f[f.index('STATE') + 1]) if 'STATE' in f else -1
def link_of(result):
f = result.split()
return int(f[f.index('LINK') + 1]) if 'LINK' in f else -1
def check(cond, what):
print(f" {'PASS' if cond else 'FAIL'} {what}")
if not cond:
fails.append(what)
# --------------------------------------------------------------- harness I/O
class Driver:
def __init__(self):
self.p = subprocess.Popen([HARNESS], stdin=subprocess.PIPE,
stdout=subprocess.PIPE, text=True, bufsize=1)
def cmd(self, line):
"""Send a command; collect emitted events until the terminator."""
self.p.stdin.write(line + '\n'); self.p.stdin.flush()
ev = {'TX': [], 'KEY': [], 'KEYDEL': [], 'ETH': [], 'CMD': [], 'TXQ': []}
while True:
l = self.p.stdout.readline()
if not l:
raise RuntimeError("harness died")
l = l.strip()
k = l.split()[0] if l else ''
if k == 'TX':
f = l.split()
ev['TX'].append({'enc': f[1] == 'enc=1', 'rate': f[2] == 'rate=1',
'hdr': bytes.fromhex(f[3]),
'body': bytes.fromhex(f[4]) if len(f) > 4 else b''})
elif k == 'KEY':
f = l.split()
ev['KEY'].append({'pairwise': f[1] == 'pairwise=1',
'idx': int(f[2].split('=')[1]),
'cipher': int(f[3].split('=')[1]),
'key': bytes.fromhex(f[4]),
'rsc': bytes.fromhex(f[5]) if len(f) > 5 else b''})
elif k == 'ETH':
ev['ETH'].append(bytes.fromhex(l.split()[1]))
elif k == 'CMD':
f = l.split()
ev['CMD'].append((int(f[1]),
bytes.fromhex(f[2]) if len(f) > 2 else b''))
elif k == 'KEY-REMOVE':
ev['KEYDEL'].append(l)
elif k in ('TXQ-UP', 'TXQ-DOWN'):
ev['TXQ'].append(l)
else:
ev['result'] = l
return ev
# --------------------------------------------------------------- 802.11 bits
def mgmt(subtype, dst, src, bssid, body, seq=0):
return bytes([0x00 | subtype, 0x00]) + b'\x00\x00' + dst + src + bssid \
+ struct.pack('<H', seq << 4) + body
def data_from_ds(da, bssid, sa, ethertype, payload, protected=False):
fc1 = 0x02 | (0x40 if protected else 0)
hdr = bytes([0x08, fc1]) + b'\x00\x00' + da + bssid + sa + b'\x00\x00'
# The firmware decrypts in place and strips the MIC, but leaves the
# 8-byte CCMP header, so model that when the protected bit is set.
iv = b'\x11\x22\x00\x20\x00\x00\x00\x00' if protected else b''
snap = bytes([0xaa, 0xaa, 0x03, 0, 0, 0]) + struct.pack('>H', ethertype)
return hdr + iv + snap + payload
def parse_ies(b):
ies, off = {}, 0
while off + 2 <= len(b):
i, l = b[off], b[off + 1]
if off + 2 + l > len(b): break
ies[i] = b[off + 2:off + 2 + l]
off += 2 + l
return ies
def suite(t): return bytes([0x00, 0x0f, 0xac, t])
AP_RSN = (struct.pack('<H', 1) + suite(4)
+ struct.pack('<H', 1) + suite(4)
+ struct.pack('<H', 1) + suite(2)
+ struct.pack('<H', 0))
# ------------------------------------------------------------------- EAPOL
def prf(key, label, data, n):
r, i = b'', 0
while len(r) < n:
r += hmac.new(key, label.encode() + b'\x00' + data + bytes([i]), hashlib.sha1).digest(); i += 1
return r[:n]
def derive_ptk(pmk, an, sn):
return prf(pmk, "Pairwise key expansion",
min(AP, STA) + max(AP, STA) + min(an, sn) + max(an, sn), 48)
def emic(kck, f): return hmac.new(kck, f, hashlib.sha1).digest()[:16]
HDR = 99
def eapol(ki, replay, nonce, rsc, kd, kck=None):
b = bytearray(HDR + len(kd)); b[0] = 2; b[1] = 3
struct.pack_into('>H', b, 2, HDR + len(kd) - 4); b[4] = 2
struct.pack_into('>H', b, 5, ki); struct.pack_into('>H', b, 7, 16)
b[9:17] = replay; b[17:49] = nonce; b[65:73] = rsc
struct.pack_into('>H', b, 97, len(kd)); b[HDR:] = kd
if ki & 0x0100: b[81:97] = emic(kck, bytes(b))
return bytes(b)
def check_emic(f, kck):
x = bytearray(f); x[81:97] = b'\x00' * 16
return emic(kck, bytes(x)) == f[81:97]
def gtk_kde(gtk, idx):
kd = bytes([0xdd, 6 + len(gtk), 0x00, 0x0f, 0xac, 0x01, idx, 0x00]) + gtk
if len(kd) % 8: kd += b'\xdd' + b'\x00' * (7 - len(kd) % 8)
return kd
# =============================================================================
d = Driver()
d.cmd("MAC " + STA.hex())
print("=== authentication ===")
ev = d.cmd(f"CONNECT {AP.hex()} {CHANNEL} 0 {SSID} {PASS} {AP_RSN.hex()}")
check(ev['result'].startswith('CONNECT-OK 1'), "connect starts")
check(len(ev['TXQ']) == 1 and ev['TXQ'][0].startswith('TXQ-UP'),
"a transmit queue is opened before any frame is sent")
check(len(ev['TX']) == 1, "exactly one frame is sent (the authentication request)")
auth = ev['TX'][0]
h, b = auth['hdr'], auth['body']
check(h[0] == 0xb0, "authentication frame: type management, subtype auth")
check(h[4:10] == AP and h[10:16] == STA and h[16:22] == AP,
"authentication frame: addressed to the AP, from us, BSSID correct")
check(len(h) == 24, "authentication frame: 24-byte header")
check(struct.unpack('<H', b[0:2])[0] == 0, "authentication: open system algorithm")
check(struct.unpack('<H', b[2:4])[0] == 1, "authentication: transaction sequence 1")
check(struct.unpack('<H', b[4:6])[0] == 0, "authentication: status 0")
check(not auth['enc'] and auth['rate'],
"authentication frame sent in the clear at a fixed rate")
print("\n=== association ===")
d.cmd("RXMGMT " + mgmt(0xb0, STA, AP, AP, struct.pack('<HHH', 0, 2, 0)).hex())
ev = d.cmd("SERVICE")
check(len(ev['TX']) == 1, "authentication success triggers one association request")
ar = ev['TX'][0]
check(ar['hdr'][0] == 0x00, "association request: subtype assoc-req")
check(ar['hdr'][4:10] == AP and ar['hdr'][10:16] == STA,
"association request: addressing correct")
caps = struct.unpack('<H', ar['body'][0:2])[0]
check(caps & 0x0001, "association request: ESS capability set")
check(caps & 0x0010, "association request: privacy bit set for an encrypted network")
li = struct.unpack('<H', ar['body'][2:4])[0]
check(li == 10, "association request: listen interval present")
ies = parse_ies(ar['body'][4:])
check(ies.get(0) == SSID.encode(), "association request: SSID element matches")
check(1 in ies and len(ies[1]) >= 4, "association request: supported rates present")
check(any(r & 0x80 for r in ies[1]), "association request: at least one basic rate")
check(48 in ies, "association request: RSN element present")
sta_rsn = ies[48]
check(sta_rsn[0:2] == struct.pack('<H', 1), "RSN element: version 1")
check(sta_rsn[2:6] == suite(4), "RSN element: CCMP group cipher")
check(sta_rsn[8:12] == suite(4), "RSN element: CCMP pairwise cipher")
check(sta_rsn[14:18] == suite(2), "RSN element: PSK AKM")
aid = 0x0007
d.cmd("RXMGMT " + mgmt(0x10, STA, AP, AP,
struct.pack('<HHH', 0x0431, 0, aid | 0xc000)).hex())
ev = d.cmd("SERVICE")
check(state_of(ev['result']) == 6, "association moves the link into the handshake")
check(len(ev['CMD']) >= 2, "post-association context updates are sent")
print("\n=== 4-way handshake carried over 802.11 data frames ===")
pmk = hashlib.pbkdf2_hmac('sha1', PASS.encode(), SSID.encode(), 4096, 32)
anonce = os.urandom(32)
m1 = eapol(0x0002 | 0x0008 | 0x0080, (1).to_bytes(8, 'big'), anonce, b'\x00' * 8, b'')
d.cmd("RXDATA " + data_from_ds(STA, AP, AP, 0x888e, m1).hex())
ev = d.cmd("SERVICE")
check(len(ev['TX']) == 1, "EAPOL message 1 produces exactly one reply")
m2f = ev['TX'][0]
h = m2f['hdr']
check(h[0] == 0x08 and (h[1] & 0x01), "EAPOL reply: data frame with to-DS set")
check(not (h[1] & 0x40), "EAPOL reply: not marked protected (no key yet)")
check(not m2f['enc'], "EAPOL reply: firmware told not to encrypt")
check(h[4:10] == AP and h[10:16] == STA and h[16:22] == AP,
"EAPOL reply: addr1 the AP, addr2 us, addr3 the AP")
check(m2f['body'][0:6] == bytes([0xaa, 0xaa, 0x03, 0, 0, 0]),
"EAPOL reply: RFC 1042 LLC/SNAP shim")
check(m2f['body'][6:8] == struct.pack('>H', 0x888e),
"EAPOL reply: EtherType 0x888e")
m2 = m2f['body'][8:]
snonce = m2[17:49]
ptk = derive_ptk(pmk, anonce, snonce)
kck, kek, tk = ptk[:16], ptk[16:32], ptk[32:48]
check(check_emic(m2, kck), "EAPOL message 2 MIC verifies under the AP's own PTK")
check(m2[HDR:] == bytes([48, len(sta_rsn)]) + sta_rsn,
"EAPOL message 2 carries the same RSN element as the association request")
gtk = os.urandom(16)
rsc = bytes.fromhex('0a0b0c0d0e0f0000')
m3 = eapol(0x0002 | 0x0008 | 0x0040 | 0x0080 | 0x0100 | 0x0200 | 0x1000,
(2).to_bytes(8, 'big'), anonce, rsc, aes_key_wrap(kek, gtk_kde(gtk, 1)), kck)
d.cmd("RXDATA " + data_from_ds(STA, AP, AP, 0x888e, m3).hex())
ev = d.cmd("SERVICE")
check(len(ev['TX']) == 1, "EAPOL message 3 produces message 4")
m4 = ev['TX'][0]['body'][8:]
check(check_emic(m4, kck), "EAPOL message 4 MIC verifies")
keys = {('pairwise' if k['pairwise'] else 'group'): k for k in ev['KEY']}
check('pairwise' in keys and keys['pairwise']['key'] == tk,
"pairwise key installed matches the AP's temporal key")
check(keys.get('pairwise', {}).get('cipher') == 4, "pairwise key installed as CCMP")
check('group' in keys and keys['group']['key'] == gtk, "group key installed matches")
check(keys.get('group', {}).get('rsc') == rsc[:6],
"group key installed with the AP's receive sequence counter")
check(state_of(ev['result']) == 7 and link_of(ev['result']) == 1,
"link reports up once keyed")
print("\n=== data path ===")
ip = bytes.fromhex('4500002800010000401100000a0000010a000002') + b'payload-here'
peer = bytes.fromhex('665544332211')
eth_out = peer + STA + struct.pack('>H', 0x0800) + ip
ev = d.cmd("TXETH " + eth_out.hex())
check(ev['result'] == 'TXETH-OK 1', "an Ethernet frame is accepted for transmit")
f = ev['TX'][0]
h = f['hdr']
check(h[0] == 0x08 and (h[1] & 0x01), "outbound data: to-DS data frame")
check(h[1] & 0x40, "outbound data: protected bit set now that keys are installed")
check(f['enc'], "outbound data: firmware asked to encrypt")
check(not f['rate'], "outbound data: rate control left to the firmware")
check(h[4:10] == AP, "outbound data: addr1 is the AP")
check(h[10:16] == STA, "outbound data: addr2 is us")
check(h[16:22] == peer, "outbound data: addr3 is the final destination")
check(f['body'][0:8] == bytes([0xaa, 0xaa, 0x03, 0, 0, 0]) + struct.pack('>H', 0x0800),
"outbound data: LLC/SNAP carries the EtherType")
check(f['body'][8:] == ip, "outbound data: IP payload preserved byte for byte")
seq1 = struct.unpack('<H', h[22:24])[0]
ev2 = d.cmd("TXETH " + eth_out.hex())
seq2 = struct.unpack('<H', ev2['TX'][0]['hdr'][22:24])[0]
check(seq2 != seq1, "outbound data: the sequence number advances between frames")
reply = bytes.fromhex('450000300002000040110000' '0a000002' '0a000001') + b'inbound-payload'
ev = d.cmd("RXDATA " + data_from_ds(STA, AP, peer, 0x0800, reply, protected=True).hex())
check(len(ev['ETH']) == 1, "an encrypted inbound data frame yields one Ethernet frame")
e = ev['ETH'][0]
check(e[0:6] == STA, "inbound data: Ethernet destination is addr1")
check(e[6:12] == peer, "inbound data: Ethernet source is addr3")
check(e[12:14] == struct.pack('>H', 0x0800), "inbound data: EtherType recovered")
check(e[14:] == reply, "inbound data: payload recovered past the CCMP header")
bcast = bytes.fromhex('ffffffffffff')
ev = d.cmd("RXDATA " + data_from_ds(bcast, AP, peer, 0x0806, b'arp-request-body').hex())
check(len(ev['ETH']) == 1 and ev['ETH'][0][0:6] == bcast,
"inbound broadcast (ARP) is delivered")
ev = d.cmd("RXDATA " + data_from_ds(STA, bytes.fromhex('aa0000000001'),
peer, 0x0800, b'from-a-stranger').hex())
check(len(ev['ETH']) == 0, "a data frame from a different BSSID is ignored")
ev = d.cmd("RXDATA " + (bytes([0x48, 0x02]) + b'\x00\x00' + STA + AP + peer
+ b'\x00\x00').hex())
check(len(ev['ETH']) == 0, "a null-data keepalive produces no Ethernet frame")
print("\n=== teardown ===")
ev = d.cmd("ABORT")
check(any(x.startswith('TXQ-DOWN') for x in ev['TXQ']), "abort closes the transmit queue")
check(len(ev['KEYDEL']) == 2, "abort removes both hardware keys before the station")
check(state_of(ev['result']) == 0, "abort returns to idle")
ev = d.cmd("TXETH " + eth_out.hex())
check(ev['result'] == 'TXETH-OK 0', "transmit is refused once the link is down")
# =============================================================================
# Other paths through the state machine, each on a fresh harness.
# =============================================================================
print("\n=== open network ===")
d2 = Driver()
d2.cmd("MAC " + STA.hex())
ev = d2.cmd(f"CONNECT {AP.hex()} 11 0 OpenNet - -")
check(ev['result'].startswith('CONNECT-OK 1'), "open network: connect starts")
caps_seen = []
d2.cmd("RXMGMT " + mgmt(0xb0, STA, AP, AP, struct.pack('<HHH', 0, 2, 0)).hex())
ev = d2.cmd("SERVICE")
ar = ev['TX'][0]
caps = struct.unpack('<H', ar['body'][0:2])[0]
check(not (caps & 0x0010), "open network: privacy bit clear")
ies = parse_ies(ar['body'][4:])
check(48 not in ies, "open network: no RSN element in the association request")
d2.cmd("RXMGMT " + mgmt(0x10, STA, AP, AP,
struct.pack('<HHH', 0x0421, 0, 3 | 0xc000)).hex())
ev = d2.cmd("SERVICE")
check(state_of(ev['result']) == 7 and link_of(ev['result']) == 1,
"open network: link comes up straight after association, no handshake")
check(len(ev['KEY']) == 0, "open network: no keys installed")
ev = d2.cmd("TXETH " + (peer + STA + struct.pack('>H', 0x0800) + ip).hex())
f = ev['TX'][0]
check(not (f['hdr'][1] & 0x40) and not f['enc'],
"open network: data frames are sent unprotected")
print("\n=== the access point never answers ===")
d3 = Driver()
d3.cmd("MAC " + STA.hex())
d3.cmd(f"CONNECT {AP.hex()} 6 0 Nowhere - -")
retries = 0
for _ in range(6):
d3.cmd("TICK 500")
ev = d3.cmd("SERVICE")
retries += len(ev['TX'])
if state_of(ev['result']) == 8:
break
check(retries >= 3, f"authentication is retransmitted ({retries} retries) before giving up")
check(state_of(ev['result']) == 8, "the attempt eventually fails rather than hanging")
check(any(x.startswith('TXQ-DOWN') for x in ev['TXQ']),
"giving up tears the transmit queue back down")
print("\n=== the access point rejects us ===")
d4 = Driver()
d4.cmd("MAC " + STA.hex())
d4.cmd(f"CONNECT {AP.hex()} 6 0 Rejects - -")
ev = d4.cmd("RXMGMT " + mgmt(0xb0, STA, AP, AP, struct.pack('<HHH', 0, 2, 1)).hex())
check(state_of(ev['result']) == 8, "an authentication rejection fails the attempt")
d5 = Driver()
d5.cmd("MAC " + STA.hex())
d5.cmd(f"CONNECT {AP.hex()} 6 0 Rejects - -")
d5.cmd("RXMGMT " + mgmt(0xb0, STA, AP, AP, struct.pack('<HHH', 0, 2, 0)).hex())
d5.cmd("SERVICE")
ev = d5.cmd("RXMGMT " + mgmt(0x10, STA, AP, AP,
struct.pack('<HHH', 0x0421, 17, 0)).hex())
check(state_of(ev['result']) == 8, "an association rejection fails the attempt")
print("\n=== the access point drops us ===")
d6 = Driver()
d6.cmd("MAC " + STA.hex())
d6.cmd(f"CONNECT {AP.hex()} 6 0 OpenNet - -")
d6.cmd("RXMGMT " + mgmt(0xb0, STA, AP, AP, struct.pack('<HHH', 0, 2, 0)).hex())
d6.cmd("SERVICE")
d6.cmd("RXMGMT " + mgmt(0x10, STA, AP, AP,
struct.pack('<HHH', 0x0421, 0, 3 | 0xc000)).hex())
ev = d6.cmd("SERVICE")
check(link_of(ev['result']) == 1, "link up before the deauthentication")
d6.cmd("RXMGMT " + mgmt(0xc0, STA, AP, AP, struct.pack('<H', 3)).hex())
ev = d6.cmd("SERVICE")
check(state_of(ev['result']) == 0 and link_of(ev['result']) == 0,
"a deauthentication from the AP brings the link down")
ev = d6.cmd("TXETH " + (peer + STA + struct.pack('>H', 0x0800) + ip).hex())
check(ev['result'] == 'TXETH-OK 0', "transmit is refused after being dropped")
# =============================================================================
# PHY context command layout.
#
# Regression test for a lockup: firmware 89 advertises ULTRA_HB_CHANNELS, which
# selects an 8-byte channel-info sub-structure (32-bit channel first). Sending
# the older 4-byte form shifted every field after it, asserted the firmware, and
# the driver then spun forever waiting for a reply that would never come.
# =============================================================================
print("\n=== PHY context command layout ===")
PHY_CONTEXT_CMD = 0x08
ULTRA_HB = 48
def phy_cmd_for(uhb):
dd = Driver()
dd.cmd("MAC " + STA.hex())
dd.cmd(f"CAPA {ULTRA_HB} {1 if uhb else 0}")
ev = dd.cmd(f"CONNECT {AP.hex()} {CHANNEL} 0 OpenNet - -")
dd.p.stdin.close()
for cid, payload in ev['CMD']:
if cid == PHY_CONTEXT_CMD:
return payload
return None
p_uhb = phy_cmd_for(True)
check(p_uhb is not None and len(p_uhb) == 32,
f"ultra-high-band firmware gets a 32-byte PHY context ({len(p_uhb) if p_uhb else 0})")
if p_uhb and len(p_uhb) == 32:
chan = struct.unpack_from('<I', p_uhb, 8)[0]
band, width, ctrl = p_uhb[12], p_uhb[13], p_uhb[14]
lmac = struct.unpack_from('<I', p_uhb, 16)[0]
check(chan == CHANNEL, "UHB layout: channel is the 32-bit field at offset 8")
check(band == 1, "UHB layout: band follows the channel (2.4 GHz = 1)")
check(width == 0 and ctrl == 0, "UHB layout: 20 MHz, control position below")
check(lmac == 0, "UHB layout: lmac_id lands at offset 16, not shifted")
p_v1 = phy_cmd_for(False)
check(p_v1 is not None and len(p_v1) == 28,
f"older firmware still gets the 28-byte form ({len(p_v1) if p_v1 else 0})")
if p_v1 and len(p_v1) == 28:
check(p_v1[8] == 1 and p_v1[9] == CHANNEL,
"legacy layout: band then channel, both single bytes")
check(struct.unpack_from('<I', p_v1, 12)[0] == 0,
"legacy layout: lmac_id at offset 12")
# =============================================================================
# MLD command sizes and layout.
#
# This firmware implements the MLD API, so the connect path uses MAC_CONFIG /
# LINK_CONFIG / STA_CONFIG rather than the legacy MAC_CONTEXT / BINDING /
# ADD_STA. Sizes and field offsets below are exactly what Linux puts on the
# wire against this same firmware (tests/wifi/decode_iwl_trace.py).
# =============================================================================
print("\n=== MLD command sizes ===")
MAC_CONFIG = (3 << 8) | 0x08
LINK_CONFIG = (3 << 8) | 0x09
STA_CONFIG = (3 << 8) | 0x0a
STA_REMOVE = (3 << 8) | 0x0c
PHY_CONTEXT = 0x08
dd = Driver()
dd.cmd("MAC " + STA.hex())
dd.cmd(f"CAPA {ULTRA_HB} 1")
ev = dd.cmd(f"CONNECT {AP.hex()} {CHANNEL} 0 OpenNet - -")
sizes, first = {}, {}
for cid, payload in ev['CMD']:
sizes.setdefault(cid, len(payload))
first.setdefault(cid, payload)
check(sizes.get(MAC_CONFIG) == 52, f"MAC_CONFIG_CMD is 52 bytes (got {sizes.get(MAC_CONFIG)})")
check(sizes.get(LINK_CONFIG) == 208, f"LINK_CONFIG_CMD is 208 bytes (got {sizes.get(LINK_CONFIG)})")
check(sizes.get(STA_CONFIG) == 96, f"STA_CONFIG_CMD is 96 bytes (got {sizes.get(STA_CONFIG)})")
check(sizes.get(PHY_CONTEXT) == 32, f"PHY_CONTEXT_CMD is 32 bytes (got {sizes.get(PHY_CONTEXT)})")
check(0x28 not in sizes and 0x2b not in sizes and 0x18 not in sizes,
"no legacy MAC_CONTEXT / BINDING / ADD_STA is sent")
m = first.get(MAC_CONFIG)
if m:
check(struct.unpack_from('<I', m, 8)[0] == 5, "MAC_CONFIG: mac_type is BSS_STA")
check(m[12:18] == STA, "MAC_CONFIG: local_mld_addr is our MAC")
check(struct.unpack_from('<I', m, 20)[0] == 0x0c,
"MAC_CONFIG: accepts group + beacon frames before association")
check(m[36] == 0, "MAC_CONFIG: is_assoc clear on the initial add")
l = first.get(LINK_CONFIG)
if l:
check(struct.unpack_from('<I', l, 0)[0] == 1, "LINK_CONFIG: first one is an ADD")
check(struct.unpack_from('<I', l, 12)[0] == 0xffffffff,
"LINK_CONFIG: phy_id is INVALID until the link is bound")
check(l[16:22] == STA, "LINK_CONFIG: local_link_addr is our MAC")
check(struct.unpack_from('<I', l, 136)[0] == 0,
"LINK_CONFIG: no beacon timing before association")
# The PHY binding and the activation must be two separate MODIFYs, in that
# order, after the PHY context exists. Folding them into one command asserts
# fw 89 (UMAC error 0x2010330F, seen on the AX211); Linux sends the binding
# first with mask 0 (__iwl_mvm_mld_assign_vif_chanctx).
links = [(i, p) for i, (c, p) in enumerate(ev['CMD']) if c == LINK_CONFIG]
phy_add = next((i for i, (c, p) in enumerate(ev['CMD'])
if c == PHY_CONTEXT and struct.unpack_from('<I', p, 4)[0] == 1), None)
check(len(links) >= 3, f"link add, PHY binding and activation are three commands "
f"(got {len(links)})")
if len(links) >= 3 and phy_add is not None:
bind_i, bind = links[1]
act_i, act = links[2]
check(phy_add < bind_i, "the PHY context exists before the link binds to it")
check(struct.unpack_from('<I', bind, 0)[0] == 2
and struct.unpack_from('<I', bind, 12)[0] == 0
and struct.unpack_from('<I', bind, 24)[0] == 0
and struct.unpack_from('<I', bind, 28)[0] == 0,
"PHY binding is its own MODIFY: phy_id set, mask 0, still inactive")
check(struct.unpack_from('<I', act, 0)[0] == 2
and struct.unpack_from('<I', act, 24)[0] == 0x03
and struct.unpack_from('<I', act, 28)[0] == 1,
"activation is a later MODIFY with exactly ACTIVE|RATES_INFO")
# Field values the Linux trace carries at this stage (fw 89 asserted with
# UMAC error 0x2010303E when they differed).
check(struct.unpack_from('<I', act, 136)[0] == 100
and struct.unpack_from('<I', act, 140)[0] == 0,
"activation carries the beacon interval but no DTIM interval yet")
check(struct.unpack_from('<I', act, 56)[0] == 0x2,
"pre-assoc qos_flags is TGN without UPDATE_EDCA")
check(struct.unpack_from('<H', act, 82)[0] == 0
and struct.unpack_from('<H', act, 62)[0] == 1023,
"pre-assoc EDCA is the default contention set, not WMM")
check(struct.unpack_from('<H', act, 146)[0] == 0,
"no RTS threshold before association")
st = first.get(STA_CONFIG)
if st:
check(struct.unpack_from('<I', st, 0)[0] == 0, "STA_CONFIG: station id 0")
check(st[8:14] == AP and st[16:22] == AP,
"STA_CONFIG: both peer addresses are the BSSID")
check(struct.unpack_from('<I', st, 28)[0] == 0,
"STA_CONFIG: association id 0 before association")
dd.p.stdin.close()
# =============================================================================
# Beacon timing arrives with association, not before.
# =============================================================================
print("\n=== link beacon timing ===")
dd = Driver()
dd.cmd("MAC " + STA.hex())
dd.cmd(f"CAPA {ULTRA_HB} 1")
dd.cmd(f"CONNECT {AP.hex()} {CHANNEL} 0 OpenNet - -")
dd.cmd("RXMGMT " + mgmt(0xb0, STA, AP, AP, struct.pack('<HHH', 0, 2, 0)).hex())
dd.cmd("SERVICE")
dd.cmd("RXMGMT " + mgmt(0x10, STA, AP, AP,
struct.pack('<HHH', 0x0421, 0, 7 | 0xc000)).hex())
ev = dd.cmd("SERVICE")
link_i = next((i for i, (c, p) in enumerate(ev['CMD']) if c == LINK_CONFIG), None)
mac_i = next((i for i, (c, p) in enumerate(ev['CMD']) if c == MAC_CONFIG), None)
link = ev['CMD'][link_i][1] if link_i is not None else None
mac = ev['CMD'][mac_i][1] if mac_i is not None else None
check(link is not None and mac is not None,
"association updates the link and the MAC")
check(link_i is not None and mac_i is not None and link_i < mac_i,
"one-shot link timing is programmed before the MAC is marked associated")
if link:
mask = struct.unpack_from('<I', link, 24)[0]
check(struct.unpack_from('<I', link, 0)[0] == 2, "post-assoc link is a MODIFY")
check(mask & 0x10, "post-assoc link sets the BEACON_TIMING modify bit")
check(mask == 0x1e,
f"legacy post-assoc link changes exactly rates/protection/QoS/timing (got {mask:#x})")
check(not (mask & 0x01),
"post-assoc link does not re-assert ACTIVE on an active link")
check(struct.unpack_from('<I', link, 136)[0] == 100,
"post-assoc link carries the beacon interval")
check(struct.unpack_from('<I', link, 140)[0] == 200,
"post-assoc link carries bi * dtim period")
check(struct.unpack_from('<I', link, 56)[0] == 0x3,
"post-assoc qos_flags is TGN plus UPDATE_EDCA")
check(struct.unpack_from('<H', link, 82)[0] == 3008,
"post-assoc EDCA switches to the WMM set")
if mac:
check(mac[36] == 1, "post-assoc MAC has is_assoc set")
check(struct.unpack_from('<H', mac, 40)[0] == 7, "post-assoc MAC carries the AID")
check(struct.unpack_from('<I', mac, 20)[0] == 0x04,
"post-assoc MAC stops asking for beacons")
check(not any(c == STA_CONFIG for c, p in ev['CMD']),
"client AID is not incorrectly written to STA_CONFIG's GO-only assoc_id")
dd.p.stdin.close()
# =============================================================================
# Deadlines are not allowed to expire the moment they are set.
#
# The service loop sampled the clock once at the top of the pass, then spent
# real milliseconds inside the post-association context commands, each of which
# stamps a *newer* timestamp. Comparing the stale `now` against those stamps
# underflowed the unsigned subtraction, so a join that had just succeeded
# reported "timed out while joining the network" straight after "Associated",
# tore the contexts down, and the access point's first EAPOL frame arrived to
# find the station already gone.
#
# The harness advances its clock on every host command (CMD_ROUND_TRIP_MS), so
# a pass that sends commands and then checks a deadline reproduces this.
# =============================================================================
print("\n=== deadlines survive the time spent sending commands ===")
d7 = Driver()
d7.cmd("MAC " + STA.hex())
d7.cmd(f"CONNECT {AP.hex()} {CHANNEL} 0 {SSID} {PASS} {AP_RSN.hex()}")
d7.cmd("RXMGMT " + mgmt(0xb0, STA, AP, AP, struct.pack('<HHH', 0, 2, 0)).hex())
ev = d7.cmd("SERVICE")
check(state_of(ev['result']) == 4,
"sending the association request does not immediately expire its own retry timer")
check(len(ev['TX']) == 1,
"the association request is sent once per pass, not retransmitted on the spot")
d7.cmd("RXMGMT " + mgmt(0x10, STA, AP, AP,
struct.pack('<HHH', 0x0421, 0, 7 | 0xc000)).hex())
ev = d7.cmd("SERVICE")
check(state_of(ev['result']) == 6,
"association reaches the handshake instead of timing out in the same pass")
check(not any(x.startswith('TXQ-DOWN') for x in ev['TXQ']),
"a successful association does not tear the transmit queue back down")
check(not any(c == STA_REMOVE for c, p in ev['CMD']),
"a successful association does not remove the station it just added")
# The EAPOL exchange arrives a moment later, exactly as it does on the air.
m1_late = eapol(0x0002 | 0x0008 | 0x0080, (1).to_bytes(8, 'big'),
bytes(range(32)), b'\x00' * 8, b'')
ev = d7.cmd("RXDATA " + data_from_ds(STA, AP, AP, 0x888e, m1_late).hex())
ev = d7.cmd("SERVICE")
check(len(ev['TX']) == 1,
"an EAPOL message 1 that arrives after the context updates is still answered")
check(state_of(ev['result']) == 6,
"the station is still in the handshake when message 1 is answered")
# =============================================================================
# Teardown order: the MAC gives up its association first.
#
# While MAC_CONFIG.is_assoc is set, the firmware's MAC context owns the link
# carrying the BSS. Deactivating that link underneath it asserts the UMAC
# (0x2000320F on firmware 89) -- the failure the log above ends in.
# =============================================================================
print("\n=== teardown clears the association before the link ===")
ev = d7.cmd("ABORT")
seq = [(c, p) for c, p in ev['CMD']]
def index_of(pred):
return next((i for i, (c, p) in enumerate(seq) if pred(c, p)), None)
deassoc = index_of(lambda c, p: c == MAC_CONFIG
and struct.unpack_from('<I', p, 4)[0] == 2 and p[36] == 0)
sta_rm = index_of(lambda c, p: c == STA_REMOVE)
deact = index_of(lambda c, p: c == LINK_CONFIG
and struct.unpack_from('<I', p, 0)[0] == 2
and struct.unpack_from('<I', p, 24)[0] & 0x01
and struct.unpack_from('<I', p, 28)[0] == 0)
link_rm = index_of(lambda c, p: c == LINK_CONFIG
and struct.unpack_from('<I', p, 0)[0] == 3)
mac_rm = index_of(lambda c, p: c == MAC_CONFIG
and struct.unpack_from('<I', p, 4)[0] == 3)
check(deassoc is not None,
"teardown sends a MAC_CONFIG MODIFY clearing is_assoc")
check(deact is not None,
"teardown deactivates the link before removing it")
check(deassoc is not None and deact is not None and deassoc < deact,
"the association is cleared before the link is deactivated")
check(deassoc is not None and sta_rm is not None and deassoc < sta_rm,
"the association is cleared before the station is removed")
check(sta_rm is not None and deact is not None and sta_rm < deact,
"the station is removed before the link it sits on is deactivated")
check(deact is not None and link_rm is not None and deact < link_rm,
"the link is deactivated before it is removed")
check(link_rm is not None and mac_rm is not None and link_rm < mac_rm,
"the link is removed before the MAC that owns it")
d7.p.stdin.close()
print()
if fails:
for f in fails: print("FAILURE:", f)
sys.exit(1)
print("All MLME and data-path cases passed.")