Add authenticated WiFi transport with BLE fallback
This commit is contained in:
+136
@@ -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() }
|
||||
}
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user