RelayAdoptionInitiatorSourceTest.kt

12.7 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 onConsent(request: AdoptionFrame.Request) = true77                    override suspend fun upsertPairedDevice(device: PairedDevice) {78                        phoneStored = device79                    }80                },81                confirm = { sas, request ->82                    assertEquals(responderIdentity.deviceName, request.deviceName)83                    assertEquals("extension", request.deviceType)84                    assertEquals(8, sas.length)85                    true86                },87            )88        }8990        // Responder (empty browser) handshake: receive HELLO, send ACK, run Noise.91        val hello = org.json.JSONObject(String(responderSocket.receive()))92        assertEquals("PEACH_HELLO", hello.optString("type"))93        assertEquals(token, hello.optString("token"))94        responderSocket.send(95            org.json.JSONObject().put("type", "PEACH_HELLO_ACK")96                .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(responderKeys.publicKey))97                .toString().toByteArray(),98        )99        val noise = noiseNKResponder(100            responderKeys.secretKey,101            responderKeys.publicKey,102            responderSocket::send,103            responderSocket::receive,104        )105        var responderStored: PairedDevice? = null106        try {107            val channel = object : PairingTransport {108                override suspend fun send(msg: ByteArray) = responderSocket.send(noise.encrypt(msg))109                override suspend fun receive(): ByteArray = noise.decrypt(responderSocket.receive())110                override suspend fun close() = responderSocket.close()111            }112            // Run the real receiver pipeline on the responder side so the test113            // exercises both protocol directions end-to-end.114            val responderJob = async {115                requestAdoption(116                    channel = channel,117                    deps = object : AdoptionReceiverDeps {118                        override val deviceName = responderIdentity.deviceName119                        override val deviceType = DeviceType.EXTENSION120                        override fun generateDeviceId() = "session-only"121                        override suspend fun confirmSas() = true122                        override suspend fun createVaultFromRootKey(rootKeyHex: String): String {123                            assertEquals(Rendezvous.bytesToHex(root), rootKeyHex)124                            return computeVaultId(hexToPubkey(rootKeyHex))125                        }126                        override suspend fun deriveLocalIdentity() = responderIdentity127                        override suspend fun upsertPairedDevice(device: PairedDevice) {128                            responderStored = device129                        }130                        override fun onError(error: String) = throw AssertionError(error)131                    },132                )133            }134            assertNotNull(responderJob.await())135        } finally {136            noise.close()137        }138139        assertNotNull(phoneJob.await())140        assertNotNull(phoneStored)141        assertNotNull(responderStored)142        assertEquals("responder-id", phoneStored?.deviceId)143        assertEquals(responderIdentity.deviceName, phoneStored?.deviceName)144        assertEquals(relay, phoneStored?.relayUrl)145        assertEquals(64, phoneStored?.rendezvousSecretHex?.length)146        // The responder stores the phone's identity (from the ADOPT_GRANT source).147        assertEquals("phone-id", responderStored?.deviceId)148        assertEquals(phoneIdentity.deviceName, responderStored?.deviceName)149    }150151    @Test fun `phone source never reads or sends a grant when SAS is rejected`() = runBlocking {152        val (phoneSocket, responderSocket) = socketPair()153        val token = "AAECAwQFBgcICQoLDA0ODw"154        val root = ByteArray(32) { (it + 11).toByte() }155        val phoneKeys = deriveLocalSyncKeypair(root, "phone-id")156        val responderKeys = deriveLocalSyncKeypair(root, "responder-id")157        var rootRequested = false158        var upserted = false159160        val phoneJob = async {161            runRelayAdoptionInitiatorSourceSession(162                socket = phoneSocket,163                invitation = PairingInvitation(164                    "wss://relay.example.test",165                    token,166                    VaultStatus.EMPTY,167                ),168                sourceDeps = object : AdoptionSourceDeps {169                    override val sourceIdentity = identity("phone", phoneKeys.publicKey)170                    override suspend fun rootKeyHex(): String {171                        rootRequested = true172                        return Rendezvous.bytesToHex(root)173                    }174                    override suspend fun vaultId() = computeVaultId(root)175                    override suspend fun onConsent(request: AdoptionFrame.Request) = true176                    override suspend fun upsertPairedDevice(device: PairedDevice) {177                        upserted = true178                    }179                },180                confirm = { _, _ -> false },181            )182        }183184        // Responder handshake185        responderSocket.receive() // PEACH_HELLO186        responderSocket.send(187            org.json.JSONObject().put("type", "PEACH_HELLO_ACK")188                .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(responderKeys.publicKey))189                .toString().toByteArray(),190        )191        val noise = noiseNKResponder(192            responderKeys.secretKey,193            responderKeys.publicKey,194            responderSocket::send,195            responderSocket::receive,196        )197        try {198            val channel = object : PairingTransport {199                override suspend fun send(msg: ByteArray) = responderSocket.send(noise.encrypt(msg))200                override suspend fun receive(): ByteArray = noise.decrypt(responderSocket.receive())201                override suspend fun close() = responderSocket.close()202            }203            // Send only the request. The phone's source flow must reject before204            // it ever reads the root key or sends the grant.205            sendFrame(channel, AdoptionFrame.Request("session-only", "Empty Browser", "extension"))206        } finally {207            noise.close()208        }209210        assertNull(phoneJob.await())211        assertFalse("root key must stay on this device until SAS matches", rootRequested)212        assertFalse("paired-device store must not be touched on rejection", upserted)213    }214}215216/**217 * Dispatch tests: each receiver entry point rejects the wrong vaultStatus, so218 * the ViewModel's `when (parsed.vaultStatus)` is forced to call the matching219 * path. Combined with the parser tests this is the flow-level guarantee that220 * an empty invitation triggers grantAdoption and a populated one triggers221 * requestAdoption.222 */223class RelayAdoptionReceiverDispatchTest {224225    @Test fun `adopt rejects empty invitations so the source path is used instead`() = runBlocking {226        val receiver = RelayAdoptionReceiver()227        val empty = createPairingInvitation(228            "wss://relay.example.test",229            "AAECAwQFBgcICQoLDA0ODw",230            VaultStatus.EMPTY,231        )232        try {233            receiver.adopt(234                encodedInvitation = empty,235                deps = object : AdoptionReceiverDeps {236                    override val deviceName = "phone"237                    override val deviceType = DeviceType.ANDROID238                    override fun generateDeviceId() = "x"239                    override suspend fun confirmSas() = true240                    override suspend fun createVaultFromRootKey(rootKeyHex: String) = "v"241                    override suspend fun deriveLocalIdentity() = error("unused")242                    override suspend fun upsertPairedDevice(device: PairedDevice) = Unit243                    override fun onError(error: String) = Unit244                },245                confirm = { true },246            )247            fail("adopt must reject empty invitations")248        } catch (expected: IllegalArgumentException) {249            assertTrue(250                "expected vaultStatus in the error, got: ${expected.message}",251                expected.message!!.contains("EMPTY", ignoreCase = true),252            )253        }254    }255256    @Test fun `grantFromInvitation rejects populated invitations so the receiver path is used instead`() = runBlocking {257        val receiver = RelayAdoptionReceiver()258        val populated = createPairingInvitation(259            "wss://relay.example.test",260            "AAECAwQFBgcICQoLDA0ODw",261            VaultStatus.POPULATED,262        )263        try {264            receiver.grantFromInvitation(265                encodedInvitation = populated,266                sourceDeps = object : AdoptionSourceDeps {267                    override val sourceIdentity = LocalDeviceIdentity(268                        "phone", "phone", DeviceType.ANDROID,269                        "ab".repeat(32), "cd".repeat(32),270                    )271                    override suspend fun rootKeyHex() = "ab".repeat(32)272                    override suspend fun vaultId() = "cd".repeat(32)273                    override suspend fun onConsent(request: AdoptionFrame.Request) = true274                    override suspend fun upsertPairedDevice(device: PairedDevice) = Unit275                },276                confirm = { _, _ -> true },277            )278            fail("grantFromInvitation must reject populated invitations")279        } catch (expected: IllegalArgumentException) {280            assertTrue(281                "expected vaultStatus in the error, got: ${expected.message}",282                expected.message!!.contains("POPULATED", ignoreCase = true),283            )284        }285    }286}