RelayAdoptionInitiatorSourceTest.kt

12.8 KB · raw

1package com.peachpasswords.sync23import kotlinx.coroutines.async4import kotlinx.coroutines.runBlocking5import org.junit.Assert.assertEquals6import org.junit.Assert.assertFalse7import org.junit.Assert.assertNotNull8import org.junit.Assert.assertNull9import org.junit.Assert.assertTrue10import org.junit.Assert.fail11import org.junit.Test1213/**14 * Source-initiator counterpart to [RelayAdoptionHostTest]: the phone dials the15 * relay room, runs Noise as initiator, and sends the grant to the (empty)16 * responder. The responder side runs the real [requestAdoption] receiver17 * pipeline so this test exercises both directions of the protocol over a18 * real Noise channel.19 */20class RelayAdoptionInitiatorSourceTest {2122    private fun identity(name: String, publicKey: ByteArray) = LocalDeviceIdentity(23        deviceId = "$name-id",24        deviceName = name,25        deviceType = if (name == "phone") DeviceType.ANDROID else DeviceType.EXTENSION,26        longTermPublicKeyHex = Rendezvous.bytesToHex(publicKey),27        signingPublicKeyHex = "cd".repeat(32),28        wsPort = 49142,29        relayUrl = "wss://relay.example.test",30    )3132    private class MemoryRelaySocket(33        private val inbound: kotlinx.coroutines.channels.Channel<ByteArray>,34        private val outbound: kotlinx.coroutines.channels.Channel<ByteArray>,35    ) : RelayBinarySocket {36        override fun send(frame: ByteArray) {37            check(outbound.trySend(frame.copyOf()).isSuccess)38        }3940        override suspend fun receive(): ByteArray = inbound.receive()41        override fun close(code: Int, reason: String) {42            outbound.close()43        }44    }4546    private fun socketPair(): Pair<RelayBinarySocket, RelayBinarySocket> {47        val leftToRight = kotlinx.coroutines.channels.Channel<ByteArray>(48            kotlinx.coroutines.channels.Channel.UNLIMITED,49        )50        val rightToLeft = kotlinx.coroutines.channels.Channel<ByteArray>(51            kotlinx.coroutines.channels.Channel.UNLIMITED,52        )53        return MemoryRelaySocket(rightToLeft, leftToRight) to54            MemoryRelaySocket(leftToRight, rightToLeft)55    }5657    @Test fun `phone source sends grant to empty responder over a real Noise channel`() = runBlocking {58        val (phoneSocket, responderSocket) = socketPair()59        val token = "AAECAwQFBgcICQoLDA0ODw"60        val relay = "wss://relay.example.test"61        val root = ByteArray(32) { (it + 5).toByte() }62        val phoneKeys = deriveLocalSyncKeypair(root, "phone-id")63        val responderKeys = deriveLocalSyncKeypair(root, "responder-id")64        val phoneIdentity = identity("phone", phoneKeys.publicKey)65        val responderIdentity = identity("responder", responderKeys.publicKey)66        var phoneStored: PairedDevice? = null6768        val phoneJob = async {69            runRelayAdoptionInitiatorSourceSession(70                socket = phoneSocket,71                invitation = PairingInvitation(relay, token, VaultStatus.EMPTY),72                sourceDeps = object : AdoptionSourceDeps {73                    override val sourceIdentity = phoneIdentity74                    override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)75                    override suspend fun vaultId() = computeVaultId(root)76                    override suspend fun license() = "PPK-test"77                    override suspend fun onConsent(request: AdoptionFrame.Request) = true78                    override suspend fun upsertPairedDevice(device: PairedDevice) {79                        phoneStored = device80                    }81                },82                confirm = { sas, request ->83                    assertEquals(responderIdentity.deviceName, request.deviceName)84                    assertEquals("extension", request.deviceType)85                    assertEquals(8, sas.length)86                    true87                },88            )89        }9091        // Responder (empty browser) handshake: receive HELLO, send ACK, run Noise.92        val hello = org.json.JSONObject(String(responderSocket.receive()))93        assertEquals("PEACH_HELLO", hello.optString("type"))94        assertEquals(token, hello.optString("token"))95        responderSocket.send(96            org.json.JSONObject().put("type", "PEACH_HELLO_ACK")97                .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(responderKeys.publicKey))98                .toString().toByteArray(),99        )100        val noise = noiseNKResponder(101            responderKeys.secretKey,102            responderKeys.publicKey,103            responderSocket::send,104            responderSocket::receive,105        )106        var responderStored: PairedDevice? = null107        try {108            val channel = object : PairingTransport {109                override suspend fun send(msg: ByteArray) = responderSocket.send(noise.encrypt(msg))110                override suspend fun receive(): ByteArray = noise.decrypt(responderSocket.receive())111                override suspend fun close() = responderSocket.close()112            }113            // Run the real receiver pipeline on the responder side so the test114            // exercises both protocol directions end-to-end.115            val responderJob = async {116                requestAdoption(117                    channel = channel,118                    deps = object : AdoptionReceiverDeps {119                        override val deviceName = responderIdentity.deviceName120                        override val deviceType = DeviceType.EXTENSION121                        override fun generateDeviceId() = "session-only"122                        override suspend fun confirmSas() = true123                        override suspend fun createVaultFromRootKey(rootKeyHex: String): String {124                            assertEquals(Rendezvous.bytesToHex(root), rootKeyHex)125                            return computeVaultId(hexToPubkey(rootKeyHex))126                        }127                        override suspend fun deriveLocalIdentity() = responderIdentity128                        override suspend fun upsertPairedDevice(device: PairedDevice) {129                            responderStored = device130                        }131                        override fun onError(error: String) = throw AssertionError(error)132                    },133                )134            }135            assertNotNull(responderJob.await())136        } finally {137            noise.close()138        }139140        assertNotNull(phoneJob.await())141        assertNotNull(phoneStored)142        assertNotNull(responderStored)143        assertEquals("responder-id", phoneStored?.deviceId)144        assertEquals(responderIdentity.deviceName, phoneStored?.deviceName)145        assertEquals(relay, phoneStored?.relayUrl)146        assertEquals(64, phoneStored?.rendezvousSecretHex?.length)147        // The responder stores the phone's identity (from the ADOPT_GRANT source).148        assertEquals("phone-id", responderStored?.deviceId)149        assertEquals(phoneIdentity.deviceName, responderStored?.deviceName)150    }151152    @Test fun `phone source never reads or sends a grant when SAS is rejected`() = runBlocking {153        val (phoneSocket, responderSocket) = socketPair()154        val token = "AAECAwQFBgcICQoLDA0ODw"155        val root = ByteArray(32) { (it + 11).toByte() }156        val phoneKeys = deriveLocalSyncKeypair(root, "phone-id")157        val responderKeys = deriveLocalSyncKeypair(root, "responder-id")158        var rootRequested = false159        var upserted = false160161        val phoneJob = async {162            runRelayAdoptionInitiatorSourceSession(163                socket = phoneSocket,164                invitation = PairingInvitation(165                    "wss://relay.example.test",166                    token,167                    VaultStatus.EMPTY,168                ),169                sourceDeps = object : AdoptionSourceDeps {170                    override val sourceIdentity = identity("phone", phoneKeys.publicKey)171                    override suspend fun rootKeyHex(): String {172                        rootRequested = true173                        return Rendezvous.bytesToHex(root)174                    }175                    override suspend fun vaultId() = computeVaultId(root)176                    override suspend fun onConsent(request: AdoptionFrame.Request) = true177                    override suspend fun upsertPairedDevice(device: PairedDevice) {178                        upserted = true179                    }180                },181                confirm = { _, _ -> false },182            )183        }184185        // Responder handshake186        responderSocket.receive() // PEACH_HELLO187        responderSocket.send(188            org.json.JSONObject().put("type", "PEACH_HELLO_ACK")189                .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(responderKeys.publicKey))190                .toString().toByteArray(),191        )192        val noise = noiseNKResponder(193            responderKeys.secretKey,194            responderKeys.publicKey,195            responderSocket::send,196            responderSocket::receive,197        )198        try {199            val channel = object : PairingTransport {200                override suspend fun send(msg: ByteArray) = responderSocket.send(noise.encrypt(msg))201                override suspend fun receive(): ByteArray = noise.decrypt(responderSocket.receive())202                override suspend fun close() = responderSocket.close()203            }204            // Send only the request. The phone's source flow must reject before205            // it ever reads the root key or sends the grant.206            sendFrame(channel, AdoptionFrame.Request("session-only", "Empty Browser", "extension"))207        } finally {208            noise.close()209        }210211        assertNull(phoneJob.await())212        assertFalse("root key must stay on this device until SAS matches", rootRequested)213        assertFalse("paired-device store must not be touched on rejection", upserted)214    }215}216217/**218 * Dispatch tests: each receiver entry point rejects the wrong vaultStatus, so219 * the ViewModel's `when (parsed.vaultStatus)` is forced to call the matching220 * path. Combined with the parser tests this is the flow-level guarantee that221 * an empty invitation triggers grantAdoption and a populated one triggers222 * requestAdoption.223 */224class RelayAdoptionReceiverDispatchTest {225226    @Test fun `adopt rejects empty invitations so the source path is used instead`() = runBlocking {227        val receiver = RelayAdoptionReceiver()228        val empty = createPairingInvitation(229            "wss://relay.example.test",230            "AAECAwQFBgcICQoLDA0ODw",231            VaultStatus.EMPTY,232        )233        try {234            receiver.adopt(235                encodedInvitation = empty,236                deps = object : AdoptionReceiverDeps {237                    override val deviceName = "phone"238                    override val deviceType = DeviceType.ANDROID239                    override fun generateDeviceId() = "x"240                    override suspend fun confirmSas() = true241                    override suspend fun createVaultFromRootKey(rootKeyHex: String) = "v"242                    override suspend fun deriveLocalIdentity() = error("unused")243                    override suspend fun upsertPairedDevice(device: PairedDevice) = Unit244                    override fun onError(error: String) = Unit245                },246                confirm = { true },247            )248            fail("adopt must reject empty invitations")249        } catch (expected: IllegalArgumentException) {250            assertTrue(251                "expected vaultStatus in the error, got: ${expected.message}",252                expected.message!!.contains("EMPTY", ignoreCase = true),253            )254        }255    }256257    @Test fun `grantFromInvitation rejects populated invitations so the receiver path is used instead`() = runBlocking {258        val receiver = RelayAdoptionReceiver()259        val populated = createPairingInvitation(260            "wss://relay.example.test",261            "AAECAwQFBgcICQoLDA0ODw",262            VaultStatus.POPULATED,263        )264        try {265            receiver.grantFromInvitation(266                encodedInvitation = populated,267                sourceDeps = object : AdoptionSourceDeps {268                    override val sourceIdentity = LocalDeviceIdentity(269                        "phone", "phone", DeviceType.ANDROID,270                        "ab".repeat(32), "cd".repeat(32),271                    )272                    override suspend fun rootKeyHex() = "ab".repeat(32)273                    override suspend fun vaultId() = "cd".repeat(32)274                    override suspend fun onConsent(request: AdoptionFrame.Request) = true275                    override suspend fun upsertPairedDevice(device: PairedDevice) = Unit276                },277                confirm = { _, _ -> true },278            )279            fail("grantFromInvitation must reject populated invitations")280        } catch (expected: IllegalArgumentException) {281            assertTrue(282                "expected vaultStatus in the error, got: ${expected.message}",283                expected.message!!.contains("POPULATED", ignoreCase = true),284            )285        }286    }287}