feat(reliability): PSK uplink security-mode fallback chain (sae-mixed/sae/psk2 with PMF)

This commit is contained in:
2026-08-23 08:19:20 -06:00
parent 501fa455ce
commit 2ef07a28fc
2 changed files with 56 additions and 15 deletions
@@ -9,6 +9,10 @@ IFACE = 'wlan1up'
# wifi reload returns while wpa_supplicant is still scanning/authenticating;
# poll instead of checking once or every real uplink would false-fail.
ASSOC_ATTEMPTS = 5
# Security modes tried in order for PSK uplinks. sae-mixed covers
# WPA2/WPA3 transition APs; plain SAE covers WPA3-only (PMF required);
# psk2 covers legacy WPA2-PSK. ieee80211w matches each mode's PMF need.
PSK_MODE_CHAIN = (('sae-mixed', '1'), ('sae', '2'), ('psk2', '0'))
ASSOC_WAIT_SECONDS = 2
@@ -94,36 +98,43 @@ def set_role(role, ssid=None, psk=None):
if role == 'uplink':
if not ssid:
return {'ok': False, 'error': 'ssid required'}
cmds = [
base_cmds = [
['uci', 'set', 'wireless.wlan1up=wifi-iface'],
['uci', 'set', 'wireless.wlan1up.device=radio1'],
['uci', 'set', 'wireless.wlan1up.mode=sta'],
['uci', 'set', 'wireless.wlan1up.network=cli'],
['uci', 'set', 'wireless.wlan1up.ssid=%s' % ssid],
# sae-mixed = WPA2-PSK/WPA3-SAE transition; associates with
# either security mode. 'none' for open networks.
['uci', 'set', 'wireless.wlan1up.encryption=%s'
% ('sae-mixed' if psk else 'none')],
['uci', 'set', 'wireless.wlan1up.disabled=0'],
]
if psk:
cmds.append(['uci', 'set', 'wireless.wlan1up.key=%s' % psk])
for c in cmds:
base_cmds.append(['uci', 'set',
'wireless.wlan1up.key=%s' % psk])
for c in base_cmds:
device_run(c)
if _ensure_cli_network():
# netifd consumes committed config only; staging without commit
# would leave the STA with no L3 attachment.
device_run(['uci', 'commit', 'network'])
device_run(['uci', 'commit', 'wireless'])
_pause_hop()
device_run(['wifi', 'reload'], timeout=60)
assoc = None
for _ in range(ASSOC_ATTEMPTS):
time.sleep(ASSOC_WAIT_SECONDS)
assoc = associated()
used_mode = None
modes = PSK_MODE_CHAIN if psk else [('none', None)]
for enc, pmf in modes:
device_run(['uci', 'set', 'wireless.wlan1up.encryption=%s' % enc])
if pmf is not None:
device_run(['uci', 'set',
'wireless.wlan1up.ieee80211w=%s' % pmf])
device_run(['uci', 'commit', 'wireless'])
device_run(['wifi', 'reload'], timeout=60)
for _ in range(ASSOC_ATTEMPTS):
time.sleep(ASSOC_WAIT_SECONDS)
assoc = associated()
if assoc:
break
if assoc:
used_mode = enc
break
if not assoc:
if not assoc or not used_mode:
disable_uplink()
# UCI alone does not converge runtime: without a reload wlan1up
# keeps scanning/authenticating and pins phy1 until some unrelated
@@ -131,8 +142,11 @@ def set_role(role, ssid=None, psk=None):
# Converge now like the idle branch, then reapply the hop policy.
device_run(['wifi', 'reload'], timeout=60)
_resume_hop()
return {'ok': False, 'error': 'association failed; reverted'}
return {'ok': True, 'role': 'uplink', 'assoc': assoc}
return {'ok': False,
'error': 'association failed; reverted',
'tried_modes': [m for m, _ in modes]}
return {'ok': True, 'role': 'uplink', 'assoc': assoc,
'mode': used_mode}
# attack/idle: tear down the STA so radio1 is free again.
disable_uplink()
_resume_hop()