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
@@ -0,0 +1,136 @@
package org.qimiaoscreen.core
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.serialization.json.*
import kotlin.test.*
private class PlainCrypto : SecureChannel {
override val hello = byteArrayOf(1)
override fun finish(serverHello: ByteArray) {}
override fun encrypt(payload: ByteArray) = payload
override fun decrypt(record: ByteArray) = record
}
private class RpcFixture : WifiPlatform {
override val state = MutableStateFlow(WifiNetworkState(1, true))
var wifiWrites = 0
var bleWrites = 0
var wifiReads = 0
var failRead = false
var failWrite = false
var wrongIdentity = false
var blePings = 0
var failBarrier = false
var stopped = false
val offer = buildJsonObject { put("channel_id", "0".repeat(32)); put("device_id", "test") }
val ble = object : DeviceLink {
override val mtu = 247
override val disconnected = CompletableDeferred<Unit>()
val reassembler = Reassembler()
val replies = Channel<ByteArray>(Channel.UNLIMITED)
var sequence = 0L
override suspend fun send(fragment: ByteArray) {
val (kind, payload) = reassembler.accept(fragment) ?: return
val raw = if (kind == 1) byteArrayOf(2) else {
val r = Protocol.json.parseToJsonElement(payload.decodeToString()).jsonObject
val method = r.getValue("method").jsonPrimitive.content
if (method == "transport.close" && failBarrier) throw DeviceTransportFailure("BLE lost during fallback")
if (method == "content.play") bleWrites++
if (method == "session.ping") blePings++
val result = if (method == "transport.offer") offer else buildJsonObject { put("ble", true) }
buildJsonObject { put("id", r.getValue("id")); put("ok", true); put("result", result) }.toString().encodeToByteArray()
}
Protocol.encode(if (kind == 1) 2 else 3, sequence++, raw, mtu).forEach { replies.send(it) }
}
override suspend fun receive() = replies.receive()
override fun close() { disconnected.complete(Unit); replies.close() }
}
override fun start() {}
override fun stop() { stopped = true }
override fun crypto(offer: JsonObject): SecureChannel = PlainCrypto()
override suspend fun connect(offer: JsonObject): DeviceLink = object : DeviceLink {
override val mtu = 65536
val replies = Channel<ByteArray>(Channel.UNLIMITED)
var opened = false
override suspend fun send(fragment: ByteArray) {
if (!opened) {
opened = true
replies.send(buildJsonObject { put("device_id", if (wrongIdentity) "wrong" else "test"); put("ready", true) }.toString().encodeToByteArray())
return
}
val r = Protocol.json.parseToJsonElement(fragment.decodeToString()).jsonObject
val method = r.getValue("method").jsonPrimitive.content
if (method == "content.play") { wifiWrites++; if (failWrite) throw DeviceTransportFailure("lost reply") }
if (method == "status.get") { wifiReads++; if (failRead) throw DeviceTransportFailure("lost read") }
replies.send(buildJsonObject { put("id", r.getValue("id")); put("ok", true); put("result", buildJsonObject { put("wifi", true) }) }.toString().encodeToByteArray())
}
override suspend fun receive() = replies.receive()
override fun close() { replies.close() }
}
}
class HybridRpcTest {
private suspend fun awaitTrue(predicate: () -> Boolean) = withTimeout(3_000) { while (!predicate()) delay(10) }
@Test fun bleFailureDuringNetworkFallbackIsHandled() = runBlocking {
val f = RpcFixture(); val ble = DeviceRpc(f.ble, PlainCrypto()); ble.open("test")
var mode = "ble"; var lost = false; var uncaught = false
val parent = CoroutineScope(coroutineContext + CoroutineExceptionHandler { _, _ -> uncaught = true })
val router = HybridRpc(ble, f.ble, f, parent, "test", { t, _ -> mode = t }, { lost = true })
try {
awaitTrue { mode == "wifi" }
f.failBarrier = true
f.state.value = WifiNetworkState(2, false)
awaitTrue { lost }
delay(50)
assertFalse(uncaught)
} finally { router.close() }
}
@Test fun upgradesReadFailureFallsBackAndWriteIsNeverReplayed() = runBlocking {
val f = RpcFixture(); val ble = DeviceRpc(f.ble, PlainCrypto()); ble.open("test")
var transport = "ble"
val router = HybridRpc(ble, f.ble, f, this, "test", { t, _ -> transport = t }, { fail(it) })
try {
awaitTrue { transport == "wifi" }
assertTrue(router.call("status.get").containsKey("wifi"))
f.failWrite = true
val error = assertFailsWith<DeviceFailure> { router.call("content.play") }
assertEquals("RESULT_UNKNOWN", error.code)
assertEquals(1, f.wifiWrites); assertEquals(0, f.bleWrites)
assertEquals("ble", transport)
assertTrue(router.call("status.get").containsKey("ble"))
} finally { router.close() }
assertTrue(f.stopped)
}
@Test fun readRetriesOnBleAndNetworkChangeDoesNotCloseBle() = runBlocking {
val f = RpcFixture(); val ble = DeviceRpc(f.ble, PlainCrypto()); ble.open("test")
var transport = "ble"; var lost = false
val router = HybridRpc(ble, f.ble, f, this, "test", { t, _ -> transport = t }, { lost = true })
try {
awaitTrue { transport == "wifi" }
f.failRead = true
assertTrue(router.call("status.get").containsKey("ble"))
assertEquals(1, f.wifiReads)
f.state.value = WifiNetworkState(2, false)
delay(100)
assertFalse(lost)
f.failRead = false; f.state.value = WifiNetworkState(3, true)
awaitTrue { transport == "wifi" }
delay(2_100)
assertTrue(f.blePings > 0)
f.ble.close()
awaitTrue { lost }
assertTrue(f.stopped)
assertFails { router.call("status.get") }
Unit
} finally { router.close() }
}
@Test fun identityMismatchNeverUpgrades() = runBlocking {
val f = RpcFixture(); f.wrongIdentity = true
val ble = DeviceRpc(f.ble, PlainCrypto()); ble.open("test")
var transport = "ble"
val router = HybridRpc(ble, f.ble, f, this, "test", { t, _ -> transport = t }, { fail(it) })
try { delay(200); assertEquals("ble", transport); assertTrue(router.call("status.get").containsKey("ble")) }
finally { router.close() }
}
}
@@ -0,0 +1,23 @@
package org.qimiaoscreen.core
import kotlinx.serialization.json.*
import kotlin.test.*
class WifiCryptoTest {
private val vector = Protocol.json.parseToJsonElement(checkNotNull(javaClass.getResourceAsStream("/protocol-wifi-v1.json"))
.bufferedReader().use { it.readText() }).jsonObject
private fun bytes(key: String) = vector.getValue(key).jsonPrimitive.content.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
private fun cipher() = JavaSecureChannel.wifi(bytes("secret"), vector.getValue("channel_id").jsonPrimitive.content)
@Test fun pythonGoldenRecordsAndReplay() {
val c = cipher()
assertContentEquals(bytes("client_record"), c.encrypt(bytes("request")))
assertContentEquals(bytes("response"), c.decrypt(bytes("server_record")))
assertFails { c.decrypt(bytes("server_record")) }
}
@Test fun tamperAndWrongChannel() {
val record = bytes("server_record"); record[record.lastIndex] = (record.last().toInt() xor 1).toByte()
assertFails { cipher().decrypt(record) }
val c = JavaSecureChannel.wifi(bytes("secret"), "f".repeat(32))
assertFails { c.decrypt(bytes("server_record")) }
}
}
@@ -0,0 +1,8 @@
{
"secret": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
"channel_id": "0123456789abcdef0123456789abcdef",
"request": "7b226465766963655f6964223a227075626c69632d746573742d646576696365227d",
"response": "7b226465766963655f6964223a227075626c69632d746573742d646576696365222c227265616479223a747275657d",
"client_record": "0000000000000000d92ca79a81950869034538790ce815813153b6f67fda4c7c925fd2d3e8cca821df751e3a9a8ea0c9365dba2533f004114c73",
"server_record": "0000000000000000d38868d91e24a0ce8cdd64ec485ab485312aac9f2c2342131544eb3c1a4c3b2827a823d361f12492a3d5f806004ddf6e097b37d0673ddbf203d07ccbb53981"
}