Add authenticated WiFi transport with BLE fallback

This commit is contained in:
2026-10-01 14:32:18 +08:00
parent 071f06d365
commit f011d8357d
55 changed files with 6327 additions and 29 deletions
+1 -1
View File
@@ -1 +1 @@
2026-09-29T22:15+08:00
2026-10-01T10:37+08:00
+5 -1
View File
@@ -869,13 +869,17 @@ def create_app(
mobile = MobileControl(control, network, templates, animations, resolve_content,
apply_resolved_content, effective_default_content, set_default_content,
read_device_status, storage_monitor.get_status, library_order=library_order)
mobile_sessions = SessionManager(mobile.dispatch, mobile.identity)
mobile_sessions = SessionManager(mobile.dispatch, mobile.identity,
wifi_address=lambda: network.get_cached_status().get("active", {}).get("ipv4_address")
if network.get_cached_status().get("active", {}).get("connected") else None)
mobile_runtime = BluezRuntime(mobile_sessions, enabled=os.environ.get("MATRIX_BLE_ENABLED") == "1")
app.state.mobile_control = mobile
app.state.mobile_sessions = mobile_sessions
except Exception:
logger.exception("Mobile control initialization failed; display remains available")
app.state.mobile_runtime = mobile_runtime
from app.mobile.wifi import install_wifi_route
install_wifi_route(app)
@app.get("/api/status")
def get_status() -> dict:
@@ -51,8 +51,8 @@ class MobileControl:
def identity(self):
record = self.identity_record
return dict(device_id=record["device_id"], device_name=record["name"],
short_id=record["device_id"].replace("-", "")[:8], protocol_major=1, protocol_minor=1,
capabilities=["status", "settings", "library", "library_progressive", "playback", "frame", "device_name", "wifi", "wifi_scan"],
short_id=record["device_id"].replace("-", "")[:8], protocol_major=1, protocol_minor=2,
capabilities=["status", "settings", "library", "library_progressive", "playback", "frame", "device_name", "wifi", "wifi_scan", "wifi_transport"],
limits=dict(max_message_bytes=65536, library_page_size=50, preview_interval_ms=2000))
def settings(self):
@@ -134,7 +134,8 @@ class Handshake:
class Cipher:
def __init__(self, material: bytes, transcript: bytes, *, server: bool):
def __init__(self, material: bytes, transcript: bytes, *, server: bool, label=LABEL):
self.label = label
self.tx_direction = 1 if server else 0
self.rx_direction = 1 - self.tx_direction
self.keys = (AESGCM(material[:32]), AESGCM(material[32:64]))
@@ -148,7 +149,7 @@ class Cipher:
raise ProtocolError("BAD_REQUEST")
seq = struct.pack("!Q", self.tx_sequence)
d = self.tx_direction
result = seq + self.keys[d].encrypt(self.prefixes[d] + seq, payload, LABEL + self.transcript + bytes([d]) + seq)
result = seq + self.keys[d].encrypt(self.prefixes[d] + seq, payload, self.label + self.transcript + bytes([d]) + seq)
self.tx_sequence += 1
return result
@@ -157,7 +158,7 @@ class Cipher:
if self.failed or not 25 <= len(record) <= MAX_MESSAGE or int.from_bytes(record[:8], "big") != self.rx_sequence:
raise ValueError()
seq, d = record[:8], self.rx_direction
result = self.keys[d].decrypt(self.prefixes[d] + seq, record[8:], LABEL + self.transcript + bytes([d]) + seq)
result = self.keys[d].decrypt(self.prefixes[d] + seq, record[8:], self.label + self.transcript + bytes([d]) + seq)
self.rx_sequence += 1
return result
except Exception:
+11 -2
View File
@@ -23,7 +23,7 @@ def checked_name(value):
class SessionManager:
def __init__(self, dispatch, identity, *, clock=time.monotonic):
def __init__(self, dispatch, identity, *, clock=time.monotonic, wifi_address=lambda: None):
self.dispatch = dispatch
self.identity = identity
self.clock = clock
@@ -37,6 +37,8 @@ class SessionManager:
self.connected_at = self.last_activity = 0
self.generation = 0
self.peer_mtu = 23
from .wifi import WifiSession
self.wifi = WifiSession(self, wifi_address)
def connect(self, owner):
with self.lock:
@@ -55,6 +57,7 @@ class SessionManager:
self.opened = False
self.last_request = -1
self.peer_mtu = 23
self.wifi.close()
self.generation += 1
def expired(self):
@@ -68,7 +71,8 @@ class SessionManager:
def status(self):
with self.lock:
return dict(connected=self.opened, client_name=self.client_name if self.opened else None,
generation=self.generation)
generation=self.generation, active_transport=("wifi" if self.wifi.valid() else "ble") if self.opened else None,
wifi_counters=dict(self.wifi.counters))
def accept(self, owner, fragment):
with self.lock:
@@ -119,6 +123,11 @@ class SessionManager:
result = dict(client_name=self.client_name)
elif method == "session.open":
raise RpcError("CONFLICT", "会话已经建立")
elif method == "transport.offer":
result = self.wifi.offer()
elif method == "transport.close":
self.wifi.close()
result = dict(closed=True)
else:
result = self.dispatch(method, params)
response.update(ok=True, result=result)
+164
View File
@@ -0,0 +1,164 @@
"""BLE-owned, single-use encrypted LAN channel. No independent WiFi session."""
import asyncio
import base64
import hashlib
import ipaddress
import json
import secrets
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from .protocol import Cipher, ProtocolError, json_object, MAX_MESSAGE
LABEL = b'QMS-WIFI-1'
def wifi_cipher(secret, channel_id, *, server):
transcript = hashlib.sha256(channel_id.encode('ascii')).digest()
material = HKDF(algorithm=hashes.SHA256(), length=72, salt=transcript,
info=LABEL + transcript).derive(secret)
return Cipher(material, transcript, server=server, label=LABEL)
def encode(value):
return json.dumps(value, ensure_ascii=False, separators=(',', ':'), allow_nan=False).encode('utf-8')
class WifiSession:
# All methods run under the owning SessionManager's reentrant lock.
def __init__(self, sessions, address):
self.sessions, self.address = sessions, address
self.counters = dict(requests=0, rx_bytes=0, tx_bytes=0)
self.close()
def close(self):
self.pending = None
self.channel = None
self.cipher = None
self.last_request = -1
self.last_activity = 0
def valid(self, channel=None):
s = self.sessions
return bool(self.channel and s.opened and not s.expired() and
(channel is None or channel == self.channel) and
s.clock() - self.last_activity < 10)
def offer(self):
from .session import RpcError
if self.valid():
raise RpcError('BUSY', 'WiFi 通道已经启用')
self.close()
try:
host = str(ipaddress.IPv4Address(self.address()))
if ipaddress.ip_address(host).is_unspecified or ipaddress.ip_address(host).is_loopback:
raise ValueError()
except (ValueError, TypeError):
raise RpcError('NOT_READY', '设备 WiFi 尚未取得地址') from None
channel = secrets.token_hex(16)
secret = secrets.token_bytes(32)
self.pending = (channel, secret, self.sessions.clock() + 10)
return dict(channel_id=channel, secret=base64.b64encode(secret).decode('ascii'),
host=host, port=8080, path='/ws/mobile', expires_in_ms=10000,
device_id=self.sessions.identity()['device_id'])
def attach(self, packet):
if not isinstance(packet, bytes) or not 57 <= len(packet) <= MAX_MESSAGE + 32:
raise ProtocolError('BAD_REQUEST')
if not self.sessions.opened or self.sessions.expired() or not self.pending or self.valid():
raise ProtocolError('NOT_READY')
channel, secret, expires = self.pending
if packet[:32] != channel.encode('ascii') or self.sessions.clock() >= expires:
raise ProtocolError('NOT_READY')
cipher = wifi_cipher(secret, channel, server=True)
hello = json_object(cipher.decrypt(packet[32:]))
device_id = self.sessions.identity()['device_id']
if hello != dict(device_id=device_id):
raise ProtocolError('BAD_REQUEST')
self.pending = None
self.channel, self.cipher = channel, cipher
self.last_activity = self.sessions.clock()
return channel, cipher.encrypt(encode(dict(device_id=device_id, ready=True)))
def accept(self, channel, packet):
from .session import RpcError
if not self.valid(channel):
raise ProtocolError('NOT_READY')
request = json_object(self.cipher.decrypt(packet))
rid = request.get('id')
if not isinstance(rid, str) or not 1 <= len(rid) <= 64 or not rid.isascii() or not rid.isdecimal():
raise ProtocolError('BAD_REQUEST')
response = dict(id=rid)
try:
if int(rid) <= self.last_request:
raise RpcError('CONFLICT', '请求编号已使用')
self.last_request = int(rid)
method, params = request.get('method'), request.get('params')
if not isinstance(method, str) or not isinstance(params, dict):
raise RpcError('BAD_REQUEST', '请求格式无效')
if method == 'transport.ping':
result = dict(alive=True)
elif method.startswith(('session.', 'transport.', 'wifi.')) or method == 'task.get':
raise RpcError('BAD_REQUEST', '此操作须使用蓝牙')
else:
result = self.sessions.dispatch(method, params)
response.update(ok=True, result=result)
except RpcError as error:
response.update(ok=False, error=dict(code=error.code, message=error.message, retryable=False))
except Exception:
response.update(ok=False, error=dict(code='DEVICE_ERROR', message='设备操作失败,请刷新状态', retryable=False))
self.last_activity = self.sessions.clock()
record = self.cipher.encrypt(encode(response))
self.counters['requests'] += 1
self.counters['rx_bytes'] += len(packet)
self.counters['tx_bytes'] += len(record)
return record
def install_wifi_route(app):
from fastapi import WebSocket
@app.websocket('/ws/mobile')
async def mobile_wifi(socket: WebSocket):
sessions = getattr(app.state, 'mobile_sessions', None)
if sessions is None:
await socket.close(code=1008)
return
channel = None
receive = None
await socket.accept()
def locked(function, *args):
with sessions.lock:
return function(*args)
try:
packet = await asyncio.wait_for(socket.receive_bytes(), 5)
channel, reply = await asyncio.to_thread(locked, sessions.wifi.attach, packet)
await socket.send_bytes(reply)
while await asyncio.to_thread(locked, sessions.wifi.valid, channel):
if receive is None:
receive = asyncio.create_task(socket.receive_bytes())
done, _ = await asyncio.wait([receive], timeout=0.25)
if not done:
continue
packet = receive.result()
receive = None
reply = await asyncio.to_thread(locked, sessions.wifi.accept, channel, packet)
# A BLE disconnect while executing a command must not send stale data.
if not await asyncio.to_thread(locked, sessions.wifi.valid, channel):
break
await socket.send_bytes(reply)
except Exception:
# Never log records, secrets, peer addresses or normal disconnections.
pass
finally:
if receive is not None:
receive.cancel()
await asyncio.gather(receive, return_exceptions=True)
def release():
if channel is not None and sessions.wifi.channel == channel:
sessions.wifi.close()
await asyncio.to_thread(locked, release)
try:
await socket.close(code=1000)
except Exception:
pass
@@ -41,7 +41,7 @@
<p id="last-message" class="last-message" aria-live="polite">控制台已就绪</p>
</div>
<div class="topbar-statuses" aria-label="设备实时状态">
<span id="topbar-mobile" class="resource-indicator" role="status" aria-live="polite">手机未连接</span>
<span id="topbar-mobile" class="resource-indicator" role="status" aria-live="polite">连接设备:未连接</span>
<button id="topbar-current-content" class="current-content-indicator" type="button" aria-haspopup="dialog" aria-controls="current-display-dialog" aria-live="polite" aria-atomic="true">
当前画面:正在读取…
</button>
@@ -769,8 +769,8 @@ export async function refreshStatus({ source = "manual" } = {}) {
const mobile = data.mobile;
if (statusEls.mobile) {
statusEls.mobile.textContent = mobile?.connected
? `手机:${mobile.client_name || "已连接"}`
: (mobile?.available === false ? "手机蓝牙暂不可用" : "手机未连接");
? `连接设备:${mobile.client_name || "手机"} · ${mobile.active_transport === "wifi" ? "WiFi" : "蓝牙"}`
: "连接设备:未连接";
const session = `${data.service?.instance_id || ""}:${mobile?.generation || 0}`;
if (mobile?.connected && lastMobileSession !== null && session !== lastMobileSession) {
announce(`${mobile.client_name || "手机"}已连接`);
@@ -0,0 +1,8 @@
{
"secret": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
"channel_id": "0123456789abcdef0123456789abcdef",
"request": "7b226465766963655f6964223a227075626c69632d746573742d646576696365227d",
"response": "7b226465766963655f6964223a227075626c69632d746573742d646576696365222c227265616479223a747275657d",
"client_record": "0000000000000000d92ca79a81950869034538790ce815813153b6f67fda4c7c925fd2d3e8cca821df751e3a9a8ea0c9365dba2533f004114c73",
"server_record": "0000000000000000d38868d91e24a0ce8cdd64ec485ab485312aac9f2c2342131544eb3c1a4c3b2827a823d361f12492a3d5f806004ddf6e097b37d0673ddbf203d07ccbb53981"
}
@@ -0,0 +1,20 @@
"""Public deterministic interop fixture, never live session material."""
import json
from pathlib import Path
from app.mobile.wifi import wifi_cipher
def generate():
secret = bytes(range(32))
channel = '0123456789abcdef0123456789abcdef'
request = b'{"device_id":"public-test-device"}'
response = b'{"device_id":"public-test-device","ready":true}'
c, s = wifi_cipher(secret, channel, server=False), wifi_cipher(secret, channel, server=True)
return dict(secret=secret.hex(), channel_id=channel, request=request.hex(), response=response.hex(),
client_record=c.encrypt(request).hex(), server_record=s.encrypt(response).hex())
if __name__ == '__main__':
root = Path(__file__).resolve().parents[2]
raw = json.dumps(generate(), indent=2) + '\n'
for path in [root / '核桃派软件源代码/tests/fixtures/protocol-wifi-v1.json',
root / '移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/jvmTest/resources/protocol-wifi-v1.json']:
path.write_text(raw, encoding='utf-8')
@@ -50,7 +50,8 @@ def test_progressive_preview_and_revision_cleanup(tmp_path):
from PIL import Image
app = create_app(project_root=tmp_path, driver_kind='mock')
mobile = app.state.mobile_control
assert mobile.identity()['protocol_minor'] == 1
assert mobile.identity()['protocol_minor'] == 2
assert 'wifi_transport' in mobile.identity()['capabilities']
assert 'library_progressive' in mobile.identity()['capabilities']
created = mobile.templates.create('预览清理', {
'version': 1, 'width': 64, 'height': 64,
@@ -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'))