From 2ef07a28fcba43ab029e0cca10b98df12909e4fd Mon Sep 17 00:00:00 2001 From: c4ch3c4d3 Date: Sun, 23 Aug 2026 08:19:20 -0600 Subject: [PATCH] feat(reliability): PSK uplink security-mode fallback chain (sae-mixed/sae/psk2 with PMF) --- .../remote_access/pager-webui/mk8_rfplan.py | 44 ++++++++++++------- tests/test_mk8_rfplan.py | 27 ++++++++++++ 2 files changed, 56 insertions(+), 15 deletions(-) diff --git a/payload/user/remote_access/pager-webui/mk8_rfplan.py b/payload/user/remote_access/pager-webui/mk8_rfplan.py index 0994e64..88efde9 100644 --- a/payload/user/remote_access/pager-webui/mk8_rfplan.py +++ b/payload/user/remote_access/pager-webui/mk8_rfplan.py @@ -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() diff --git a/tests/test_mk8_rfplan.py b/tests/test_mk8_rfplan.py index e155da9..bf939d5 100644 --- a/tests/test_mk8_rfplan.py +++ b/tests/test_mk8_rfplan.py @@ -298,3 +298,30 @@ class RfPlanTest(unittest.TestCase): if __name__ == '__main__': unittest.main() + + +class PskModeChainTest(RfPlanTest): + def test_chain_tries_next_mode_on_failure(self): + # first mode (sae-mixed) never associates; second (sae) does + self.iw_fail_left = 5 # fail all link polls of attempt 1 + result = mk8_rfplan.set_role('uplink', ssid='Net', psk='secret') + self.assertTrue(result['ok'], result) + self.assertEqual(result['mode'], 'sae') + reloads = [c for c in self.cmds if c.startswith('wifi reload')] + self.assertEqual(len(reloads), 2) + + def test_chain_exhaustion_reports_modes(self): + self.iw_fail_left = 99 + result = mk8_rfplan.set_role('uplink', ssid='Net', psk='secret') + self.assertFalse(result['ok']) + self.assertEqual(result['tried_modes'], + ['sae-mixed', 'sae', 'psk2']) + + def test_first_mode_success_records_mode(self): + result = mk8_rfplan.set_role('uplink', ssid='Net', psk='secret') + self.assertTrue(result['ok']) + self.assertEqual(result['mode'], 'sae-mixed') + self.assertIn('uci set wireless.wlan1up.ieee80211w=1', self.cmds) + +if __name__ == '__main__': + unittest.main()