Add authenticated WiFi transport with BLE fallback
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
import json
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from app.mobile.wifi import wifi_cipher, encode, install_wifi_route
|
||||
from app.mobile.protocol import ProtocolError
|
||||
from test_mobile_session import connection, request
|
||||
import base64
|
||||
|
||||
|
||||
def opened(now=None):
|
||||
now = now or [0]
|
||||
manager, ble, calls = connection(lambda: now[0])
|
||||
manager.wifi.address = lambda: '192.0.2.10'
|
||||
request(manager, ble, 1, '1', 'session.open', {'client_name': '手机'})
|
||||
return manager, ble, calls, now
|
||||
|
||||
|
||||
def offer(manager, ble):
|
||||
result = request(manager, ble, 2, '2', 'transport.offer')['result']
|
||||
client = wifi_cipher(base64.b64decode(result['secret']), result['channel_id'], server=False)
|
||||
hello = result['channel_id'].encode() + client.encrypt(encode({'device_id': 'test'}))
|
||||
return result, client, hello
|
||||
|
||||
|
||||
def test_upgrade_rpc_fallback_and_ble_lifetime():
|
||||
m, ble, calls, now = opened()
|
||||
generation = m.generation
|
||||
o, client, hello = offer(m, ble)
|
||||
channel, ack = m.wifi.attach(hello)
|
||||
assert json.loads(client.decrypt(ack)) == {'device_id': 'test', 'ready': True}
|
||||
assert m.status()['active_transport'] == 'wifi' and m.generation == generation
|
||||
for number, method in enumerate(('status.get', 'content.play', 'transport.ping'), 1):
|
||||
now[0] += 5
|
||||
packet = client.encrypt(encode(dict(id=str(number), method=method, params={})))
|
||||
assert json.loads(client.decrypt(m.wifi.accept(channel, packet)))['ok']
|
||||
assert calls == ['status.get', 'content.play']
|
||||
now[0] = 20
|
||||
assert m.expired() # WiFi traffic never refreshes BLE activity.
|
||||
with pytest.raises(ProtocolError): m.wifi.accept(channel, b'bad')
|
||||
m.disconnect('a')
|
||||
assert m.status()['active_transport'] is None
|
||||
|
||||
|
||||
def test_offer_expiry_identity_tamper_and_single_use():
|
||||
m, ble, _, now = opened()
|
||||
o, c, hello = offer(m, ble)
|
||||
with pytest.raises(ProtocolError): m.wifi.attach(hello[:-1] + bytes([hello[-1] ^ 1]))
|
||||
assert m.wifi.pending is not None
|
||||
channel, _ = m.wifi.attach(hello)
|
||||
with pytest.raises(ProtocolError): m.wifi.attach(hello)
|
||||
assert m.wifi.valid(channel) # Attacker cannot evict the valid owner.
|
||||
assert request(m, ble, 3, '3', 'transport.close')['ok']
|
||||
assert m.status()['active_transport'] == 'ble'
|
||||
with pytest.raises(ProtocolError): m.wifi.attach(hello)
|
||||
m, ble, _, now = opened()
|
||||
_, _, hello = offer(m, ble)
|
||||
now[0] = 10
|
||||
with pytest.raises(ProtocolError): m.wifi.attach(hello)
|
||||
|
||||
|
||||
def test_no_wifi_address_and_wrong_identity():
|
||||
m, ble, _, _ = opened()
|
||||
m.wifi.address = lambda: None
|
||||
assert not request(m, ble, 2, '2', 'transport.offer')['ok']
|
||||
m.wifi.address = lambda: '198.51.100.20'
|
||||
o = request(m, ble, 3, '3', 'transport.offer')['result']
|
||||
c = wifi_cipher(base64.b64decode(o['secret']), o['channel_id'], server=False)
|
||||
with pytest.raises(ProtocolError):
|
||||
m.wifi.attach(o['channel_id'].encode() + c.encrypt(encode({'device_id': 'other'})))
|
||||
|
||||
|
||||
def test_duplicate_write_not_replayed_and_ble_only_commands_rejected():
|
||||
m, ble, calls, _ = opened()
|
||||
o, c, hello = offer(m, ble)
|
||||
channel, ack = m.wifi.attach(hello); c.decrypt(ack)
|
||||
for rid, method, expected in [('1', 'content.play', True), ('1', 'content.play', False),
|
||||
('2', 'wifi.set', False), ('3', 'session.ping', False)]:
|
||||
packet = c.encrypt(encode(dict(id=rid, method=method, params={})))
|
||||
assert json.loads(c.decrypt(m.wifi.accept(channel, packet)))['ok'] is expected
|
||||
assert calls == ['content.play']
|
||||
|
||||
|
||||
def test_real_websocket_records_and_disconnect():
|
||||
m, ble, calls, _ = opened()
|
||||
app = FastAPI(); app.state.mobile_sessions = m; install_wifi_route(app)
|
||||
o, c, hello = offer(m, ble)
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect('/ws/mobile') as ws:
|
||||
ws.send_bytes(hello)
|
||||
assert json.loads(c.decrypt(ws.receive_bytes()))['ready']
|
||||
ws.send_bytes(c.encrypt(encode(dict(id='1', method='status.get', params={}))))
|
||||
assert json.loads(c.decrypt(ws.receive_bytes()))['ok']
|
||||
m.disconnect('a')
|
||||
assert ws.receive()['type'] == 'websocket.close'
|
||||
assert calls == ['status.get']
|
||||
|
||||
|
||||
def test_wifi_idle_expiry_and_cross_subnet_not_filtered():
|
||||
m, ble, _, now = opened()
|
||||
m.wifi.address = lambda: '198.51.100.42'
|
||||
o, c, hello = offer(m, ble)
|
||||
assert o['host'] == '198.51.100.42'
|
||||
m.wifi.attach(hello)
|
||||
now[0] = 10
|
||||
assert not m.wifi.valid()
|
||||
assert m.status()['active_transport'] == 'ble'
|
||||
|
||||
|
||||
def test_runtime_preserves_existing_transport_statistics():
|
||||
from app.mobile.bluez import BluezRuntime
|
||||
m, ble, _, _ = opened()
|
||||
_, _, hello = offer(m, ble)
|
||||
m.wifi.attach(hello)
|
||||
status = BluezRuntime(m).status()
|
||||
assert status['active_transport'] == 'wifi'
|
||||
assert isinstance(status['transport'], dict)
|
||||
assert 'rx_bytes' in status['transport']
|
||||
|
||||
|
||||
def test_public_wifi_vector_is_reproducible():
|
||||
from pathlib import Path
|
||||
from generate_wifi_vector import generate
|
||||
assert generate() == json.loads((Path(__file__).parent / 'fixtures/protocol-wifi-v1.json').read_text(encoding='utf-8'))
|
||||
Reference in New Issue
Block a user