CodePairingServerTest.kt

12.1 KB · raw

1package com.peachpasswords.sync23import kotlinx.coroutines.CoroutineScope4import kotlinx.coroutines.Dispatchers5import kotlinx.coroutines.SupervisorJob6import kotlinx.coroutines.launch7import kotlinx.coroutines.runBlocking8import okhttp3.OkHttpClient9import okhttp3.Request10import okhttp3.Response11import okhttp3.WebSocket12import okhttp3.WebSocketListener13import okio.ByteString.Companion.toByteString14import org.json.JSONObject15import org.junit.Assert.assertEquals16import org.junit.Assert.assertTrue17import org.junit.Test18import java.util.concurrent.CompletableFuture19import java.util.concurrent.TimeUnit2021/**22 * Covers the two behaviours that are easy to get quietly wrong: a bad token23 * must be refused with no reply at all, and an abandoned code must stop working.24 * Both are invisible when broken — a leaky token check still pairs happily with25 * the right code, and a window that never closes looks identical to one that26 * does until months later.27 */28class CodePairingServerTest {2930    private val identity = LocalChirpIdentity(31        deviceId = "test-device",32        deviceName = "Test Phone",33        deviceType = DeviceType.ANDROID,34        longTermPublicKeyHex = "00".repeat(32),35        signingPublicKeyHex = "00".repeat(32),36    )3738    /** Enough of the registry for pairing; the real store needs Android crypto. */39    private class FakeRegistry : PairedDeviceRegistry {40        val devices = mutableListOf<PairedDevice>()41        override fun getAll(): List<PairedDevice> = devices42        override fun get(id: String): PairedDevice? = devices.firstOrNull { it.deviceId == id }43        override fun upsert(device: PairedDevice) { devices.add(device) }44    }4546    private fun server() = CodePairingServer(FakeRegistry())4748    private class Client {49        val opened = CompletableFuture<Boolean>()50        val closed = CompletableFuture<Boolean>()51        val frames = java.util.concurrent.LinkedBlockingQueue<String>()52        val binaryFrames = java.util.concurrent.LinkedBlockingQueue<ByteArray>()53        lateinit var ws: WebSocket5455        fun connect(port: Int) {56            ws = OkHttpClient.Builder().readTimeout(10, TimeUnit.SECONDS).build().newWebSocket(57                Request.Builder().url("ws://127.0.0.1:$port").build(),58                object : WebSocketListener() {59                    override fun onOpen(webSocket: WebSocket, response: Response) { opened.complete(true) }60                    override fun onMessage(webSocket: WebSocket, text: String) { frames.add(text) }61                    override fun onMessage(webSocket: WebSocket, bytes: okio.ByteString) { binaryFrames.add(bytes.toByteArray()) }62                    override fun onFailure(webSocket: WebSocket, t: Throwable, r: Response?) { closed.complete(true) }63                    override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { closed.complete(true) }64                },65            )66            opened.get(5, TimeUnit.SECONDS)67        }68    }6970    /**71     * Collects into a queue on a job that stays alive. Terminating the flow with72     * `first {}` would trip awaitClose and shut the listener down before the73     * test could connect to it.74     */75    private class Harness(val scope: CoroutineScope) {76        val events = java.util.concurrent.LinkedBlockingQueue<PairingEvent>()77        inline fun <reified T : PairingEvent> await(timeoutMs: Long = 10_000): T {78            val deadline = System.currentTimeMillis() + timeoutMs79            val seen = mutableListOf<PairingEvent>()80            while (System.currentTimeMillis() < deadline) {81                val e = events.poll(200, TimeUnit.MILLISECONDS) ?: continue82                seen.add(e)83                if (e is T) return e84            }85            throw AssertionError("never saw ${T::class.simpleName}; saw $seen")86        }87    }8889    private fun CoroutineScope.run(srv: CodePairingServer, windowMs: Long = CodePairingServer.PAIRING_WINDOW_MS): Harness {90        val h = Harness(this)91        launch { srv.start(this@run, ByteArray(32), ByteArray(32), identity, windowMs).collect { h.events.add(it) } }92        return h93    }9495    @Test fun `a wrong token gets no reply and the socket closes`() = runBlocking {96        val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)97        val srv = server()98        try {99            val listening = scope.run(srv).await<PairingEvent.Listening>()100101            val c = Client()102            c.connect(listening.port)103            c.ws.send("""{"type":"PEACH_HELLO","token":"00000000"}""")104105            assertTrue("socket should be closed on a bad token", c.closed.get(5, TimeUnit.SECONDS))106            assertEquals("nothing should be sent back — silence is the signal", 0, c.frames.size)107        } finally {108            srv.stop()109        }110    }111112    /**113     * The browser's pairing socket is binary end to end (`openPairingSocket` in114     * peach-browser's pairing-dial.ts sets `binaryType = "arraybuffer"` and115     * sends `Uint8Array`). This listener required a TEXT hello and answered with116     * a TEXT ack, so every correct code was silently closed on and the browser117     * reported "invalid code" — pairing could not succeed at all. Neither half118     * fails loudly, which is exactly why both are pinned here.119     */120    @Test fun `a binary hello is accepted and answered with a binary ack`() = runBlocking {121        val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)122        val srv = server()123        try {124            val listening = scope.run(srv).await<PairingEvent.Listening>()125            val token = PairingCode.parse(listening.code)!!.tokenHex126127            val c = Client()128            c.connect(listening.port)129            c.ws.send("""{"type":"PEACH_HELLO","token":"$token"}""".toByteArray().toByteString())130131            val ack = c.binaryFrames.poll(10, TimeUnit.SECONDS)132            assertTrue("the phone must answer a binary hello, not close on it", ack != null)133            assertTrue(134                "the ack must be a BINARY frame — a text one decodes to zero bytes in the browser",135                c.frames.isEmpty(),136            )137            assertTrue(String(ack!!), String(ack).contains("PEACH_HELLO_ACK"))138        } finally {139            srv.stop()140        }141    }142143    @Test fun `LAN adoption dispatches before device-pair auth and keeps both SAS gates`() = runBlocking {144        val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)145        val registry = FakeRegistry()146        val srv = CodePairingServer(registry)147        val root = ByteArray(32) { (it + 1).toByte() }148        val sourceKeys = deriveLocalSyncKeypair(root, "source-id")149        val receiverKeys = deriveLocalSyncKeypair(root, "receiver-id")150        val sourceIdentity = identity.copy(151            deviceId = "source-id",152            deviceName = "Source phone",153            longTermPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey),154            relayUrl = "wss://relay.example.test",155        )156        var rootRequested = false157        val sourceDeps = object : AdoptionSourceDeps {158            override val sourceIdentity = sourceIdentity159            override suspend fun rootKeyHex(): String {160                rootRequested = true161                return Rendezvous.bytesToHex(root)162            }163            override suspend fun vaultId() = computeVaultId(root)164            override suspend fun onConsent(request: AdoptionFrame.Request) = true165            override suspend fun upsertPairedDevice(device: PairedDevice) {166                registry.upsert(device)167            }168        }169        val h = Harness(scope)170        srv.confirmationSink = { it.session.confirm(true) }171        scope.launch {172            srv.start(173                this,174                sourceKeys.secretKey,175                sourceKeys.publicKey,176                sourceIdentity,177                adoptionSourceDeps = sourceDeps,178            ).collect { h.events.add(it) }179        }180181        try {182            val listening = h.await<PairingEvent.Listening>()183            val token = PairingCode.parse(listening.code)!!.tokenHex184            val client = Client()185            client.connect(listening.port)186            client.ws.send(187                JSONObject()188                    .put("type", "PEACH_HELLO")189                    .put("token", token)190                    .put("flow", "adoption")191                    .toString()192                    .toByteArray()193                    .toByteString(),194            )195            val ack = JSONObject(String(client.binaryFrames.poll(10, TimeUnit.SECONDS)))196            val noise = noiseNKInitiator(197                hexToPubkey(ack.getString("ephemeralPublicKeyHex")),198                { client.ws.send(it.toByteString()) },199                {200                    client.binaryFrames.poll(10, TimeUnit.SECONDS)201                        ?: throw IllegalStateException("timed out waiting for server frame")202                },203            )204            val channel = object : PairingTransport {205                override suspend fun send(msg: ByteArray) {206                    client.ws.send(noise.encrypt(msg).toByteString())207                }208                override suspend fun receive(): ByteArray {209                    val frame = client.binaryFrames.poll(10, TimeUnit.SECONDS)210                        ?: throw IllegalStateException("timed out waiting for encrypted frame")211                    return noise.decrypt(frame)212                }213                override suspend fun close() {214                    client.ws.close(1000, "done")215                }216            }217            val receiver = requestAdoption(218                channel,219                object : AdoptionReceiverDeps {220                    override val deviceName = "Browser"221                    override val deviceType = DeviceType.EXTENSION222                    override fun generateDeviceId() = "session-request"223                    override suspend fun confirmSas() = true224                    override suspend fun createVaultFromRootKey(rootKeyHex: String) =225                        computeVaultId(hexToPubkey(rootKeyHex))226                    override suspend fun deriveLocalIdentity() = LocalChirpIdentity(227                        deviceId = "receiver-id",228                        deviceName = "Browser",229                        deviceType = DeviceType.EXTENSION,230                        longTermPublicKeyHex = Rendezvous.bytesToHex(receiverKeys.publicKey),231                        signingPublicKeyHex = "ef".repeat(32),232                    )233                    override suspend fun upsertPairedDevice(device: PairedDevice) = Unit234                    override fun onError(error: String) = throw AssertionError(error)235                },236            )237238            assertTrue(rootRequested)239            assertEquals("source-id", receiver?.deviceId)240            assertEquals("Browser", h.await<PairingEvent.Paired>().deviceName)241            assertEquals("receiver-id", registry.devices.single().deviceId)242            assertEquals(64, registry.devices.single().rendezvousSecretHex?.length)243            assertEquals("wss://relay.example.test", registry.devices.single().relayUrl)244        } finally {245            srv.stop()246        }247    }248249    @Test fun `the pairing window expires and stops accepting`() = runBlocking {250        val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)251        val srv = server()252        try {253            // 300ms rather than the real five minutes; the window is a parameter254            // precisely so this is testable at all.255            val h = scope.run(srv, windowMs = 300)256            val listening = h.await<PairingEvent.Listening>()257            h.await<PairingEvent.Expired>(timeoutMs = 5_000)258259            var refused = false260            runCatching { java.net.Socket("127.0.0.1", listening.port).close() }261                .onFailure { refused = true }262            assertTrue("the port should be closed after the window", refused)263        } finally {264            srv.stop()265        }266    }267268    @Test fun `the advertised address is never a tunnel interface`() {269        // Not asserting a specific address — CI and dev machines differ — only270        // that whatever is chosen is one a LAN peer could route back to.271        val ip = server().localIpv4()272        if (ip != null) {273            assertTrue("expected a site-local address, got $ip", java.net.InetAddress.getByName(ip).isSiteLocalAddress)274        }275    }276}