RelayAdoptionInitiatorSourceTest.kt

19.8 KB · raw

1package com.peachpasswords.sync23import kotlinx.coroutines.async4import kotlinx.coroutines.withTimeoutOrNull5import kotlinx.coroutines.runBlocking6import org.junit.Assert.assertEquals7import org.junit.Assert.assertFalse8import org.junit.Assert.assertNotNull9import org.junit.Assert.assertNull10import org.junit.Assert.assertTrue11import org.junit.Assert.fail12import org.junit.Test1314/**15 * Source-initiator counterpart to [RelayAdoptionHostTest]: the phone dials the16 * relay room, runs Noise as initiator, and sends the grant to the (empty)17 * responder. The responder side runs the real [requestAdoption] receiver18 * pipeline so this test exercises both directions of the protocol over a19 * real Noise channel.20 */21class RelayAdoptionInitiatorSourceTest {2223    /**24     * Time-boxed so these adversarial tests fail rather than hang. Without the25     * pin check the phone completes a handshake against the substituted key and26     * then waits on a protocol step the attacker never performs, so a plain27     * await() would never return.28     */29    private val HANDSHAKE_TIMEOUT_MS = 5_000L3031    private fun identity(name: String, publicKey: ByteArray) = LocalDeviceIdentity(32        deviceId = "$name-id",33        deviceName = name,34        deviceType = if (name == "phone") DeviceType.ANDROID else DeviceType.EXTENSION,35        longTermPublicKeyHex = Rendezvous.bytesToHex(publicKey),36        signingPublicKeyHex = "cd".repeat(32),37        wsPort = 49142,38        relayUrl = "wss://relay.example.test",39    )4041    private class MemoryRelaySocket(42        private val inbound: kotlinx.coroutines.channels.Channel<ByteArray>,43        private val outbound: kotlinx.coroutines.channels.Channel<ByteArray>,44    ) : RelayBinarySocket {45        override fun send(frame: ByteArray) {46            check(outbound.trySend(frame.copyOf()).isSuccess)47        }4849        override suspend fun receive(): ByteArray = inbound.receive()50        override fun close(code: Int, reason: String) {51            outbound.close()52        }53    }5455    private fun socketPair(): Pair<RelayBinarySocket, RelayBinarySocket> {56        val leftToRight = kotlinx.coroutines.channels.Channel<ByteArray>(57            kotlinx.coroutines.channels.Channel.UNLIMITED,58        )59        val rightToLeft = kotlinx.coroutines.channels.Channel<ByteArray>(60            kotlinx.coroutines.channels.Channel.UNLIMITED,61        )62        return MemoryRelaySocket(rightToLeft, leftToRight) to63            MemoryRelaySocket(leftToRight, rightToLeft)64    }6566    @Test fun `phone source sends grant to empty responder over a real Noise channel`() = runBlocking {67        val (phoneSocket, responderSocket) = socketPair()68        val token = "AAECAwQFBgcICQoLDA0ODw"69        val relay = "wss://relay.example.test"70        val root = ByteArray(32) { (it + 5).toByte() }71        val phoneKeys = deriveLocalSyncKeypair(root, "phone-id")72        val responderKeys = deriveLocalSyncKeypair(root, "responder-id")73        val phoneIdentity = identity("phone", phoneKeys.publicKey)74        val responderIdentity = identity("responder", responderKeys.publicKey)75        var phoneStored: PairedDevice? = null7677        val phoneJob = async {78            runRelayAdoptionInitiatorSourceSession(79                socket = phoneSocket,80                invitation = PairingInvitation(81                    relay,82                    token,83                    VaultStatus.EMPTY,84                    Rendezvous.bytesToHex(responderKeys.publicKey),85                ),86                sourceDeps = object : AdoptionSourceDeps {87                    override val sourceIdentity = phoneIdentity88                    override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)89                    override suspend fun vaultId() = computeVaultId(root)90                    override suspend fun license() = "PPK-test"91                    override suspend fun onConsent(request: AdoptionFrame.Request) = true92                    override suspend fun upsertPairedDevice(device: PairedDevice) {93                        phoneStored = device94                    }95                },96                confirm = { sas, request ->97                    assertEquals(responderIdentity.deviceName, request.deviceName)98                    assertEquals("extension", request.deviceType)99                    assertEquals(8, sas.length)100                    true101                },102            )103        }104105        // Responder (empty browser) handshake: receive HELLO, send ACK, run Noise.106        val hello = org.json.JSONObject(String(responderSocket.receive()))107        assertEquals("PEACH_HELLO", hello.optString("type"))108        assertEquals(token, hello.optString("token"))109        responderSocket.send(110            org.json.JSONObject().put("type", "PEACH_HELLO_ACK")111                .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(responderKeys.publicKey))112                .toString().toByteArray(),113        )114        val noise = noiseNKResponder(115            responderKeys.secretKey,116            responderKeys.publicKey,117            responderSocket::send,118            responderSocket::receive,119        )120        var responderStored: PairedDevice? = null121        try {122            val channel = object : PairingTransport {123                override suspend fun send(msg: ByteArray) = responderSocket.send(noise.encrypt(msg))124                override suspend fun receive(): ByteArray = noise.decrypt(responderSocket.receive())125                override suspend fun close() = responderSocket.close()126            }127            // Run the real receiver pipeline on the responder side so the test128            // exercises both protocol directions end-to-end.129            val responderJob = async {130                requestAdoption(131                    channel = channel,132                    deps = object : AdoptionReceiverDeps {133                        override val deviceName = responderIdentity.deviceName134                        override val deviceType = DeviceType.EXTENSION135                        override fun generateDeviceId() = "session-only"136                        override suspend fun confirmSas() = true137                        override suspend fun createVaultFromRootKey(rootKeyHex: String): String {138                            assertEquals(Rendezvous.bytesToHex(root), rootKeyHex)139                            return computeVaultId(hexToPubkey(rootKeyHex))140                        }141                        override suspend fun deriveLocalIdentity() = responderIdentity142                        override suspend fun upsertPairedDevice(device: PairedDevice) {143                            responderStored = device144                        }145                        override fun onError(error: String) = throw AssertionError(error)146                    },147                )148            }149            assertNotNull(responderJob.await())150        } finally {151            noise.close()152        }153154        assertNotNull(phoneJob.await())155        assertNotNull(phoneStored)156        assertNotNull(responderStored)157        assertEquals("responder-id", phoneStored?.deviceId)158        assertEquals(responderIdentity.deviceName, phoneStored?.deviceName)159        assertEquals(relay, phoneStored?.relayUrl)160        assertEquals(64, phoneStored?.rendezvousSecretHex?.length)161        // The responder stores the phone's identity (from the ADOPT_GRANT source).162        assertEquals("phone-id", responderStored?.deviceId)163        assertEquals(phoneIdentity.deviceName, responderStored?.deviceName)164    }165166    // PB-26-103: this is the root-key-bearing direction. The phone grants its167    // vault to an empty browser, so if the relay can substitute the responder's168    // static in the cleartext ACK the phone completes an NK handshake with the169    // attacker and hands over the root key. The invitation pin is the only170    // machine-checkable defence — the SAS on the source side is a human step171    // the attack assumes is skipped.172    /**173     * The attacker's half of the adoption protocol once it has substituted its174     * static: complete Noise as responder, ask for the vault, and read whatever175     * the phone sends back. Fire-and-forget — with the pin check in place the176     * phone aborts before the handshake and this never gets a peer.177     */178    private suspend fun attackerReceivesGrant(179        socket: RelayBinarySocket,180        keys: SyncIdentityKeypair,181    ) {182        val noise = noiseNKResponder(keys.secretKey, keys.publicKey, socket::send, socket::receive)183        try {184            val channel = object : PairingTransport {185                override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))186                override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())187                override suspend fun close() = socket.close()188            }189            sendFrame(channel, AdoptionFrame.Request("attacker-session", "Empty Browser", "extension"))190            receiveFrame(channel)191        } finally {192            noise.close()193        }194    }195196    @Test fun `phone source rejects a substituted ACK static`() = runBlocking {197        val (phoneSocket, mitmSocket) = socketPair()198        val token = "AAECAwQFBgcICQoLDA0ODw"199        val root = ByteArray(32) { (it + 7).toByte() }200        val responderKeys = deriveLocalSyncKeypair(root, "responder-id")201        val attackerKeys = deriveLocalSyncKeypair(root, "attacker-id")202        var rootRequested = false203204        // The result is captured inside the job: an exception escaping a child205        // coroutine cancels the parent scope before await() can observe it.206        val phoneJob = async {207            try {208                runRelayAdoptionInitiatorSourceSession(209                    socket = phoneSocket,210                    invitation = PairingInvitation(211                        "wss://relay.example.test",212                        token,213                        VaultStatus.EMPTY,214                        Rendezvous.bytesToHex(responderKeys.publicKey),215                    ),216                    sourceDeps = object : AdoptionSourceDeps {217                        override val sourceIdentity =218                            identity("phone", deriveLocalSyncKeypair(root, "phone-id").publicKey)219                        override suspend fun rootKeyHex(): String {220                            rootRequested = true221                            return Rendezvous.bytesToHex(root)222                        }223                        override suspend fun vaultId() = computeVaultId(root)224                        override suspend fun onConsent(request: AdoptionFrame.Request) = true225                        override suspend fun upsertPairedDevice(device: PairedDevice) = Unit226                    },227                    confirm = { _, _ -> true },228                )229                "completed"230            } catch (expected: IllegalArgumentException) {231                expected.message232            }233        }234235        mitmSocket.receive() // PEACH_HELLO236        // The relay terminates the session itself: a different static in the ACK.237        mitmSocket.send(238            org.json.JSONObject().put("type", "PEACH_HELLO_ACK")239                .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(attackerKeys.publicKey))240                .toString().toByteArray(),241        )242        // The attacker then plays the receiver through a complete handshake so243        // the pre-fix code path runs to the grant instead of stalling.244        val attackerJob = async { attackerReceivesGrant(mitmSocket, attackerKeys) }245246        // Pre-fix the phone completes the handshake against the attacker's key247        // and stalls waiting on the protocol; either way it is not this error.248        assertEquals(249            "pairing_static_key_mismatch",250            withTimeoutOrNull(HANDSHAKE_TIMEOUT_MS) { phoneJob.await() },251        )252        attackerJob.cancel()253        assertFalse("root key must never be read against a substituted static", rootRequested)254    }255256    @Test fun `phone source rejects an invitation that pins no static key`() = runBlocking {257        val (phoneSocket, mitmSocket) = socketPair()258        val root = ByteArray(32) { (it + 23).toByte() }259        val attackerKeys = deriveLocalSyncKeypair(root, "attacker-id")260261        val phoneJob = async {262            try {263                runRelayAdoptionInitiatorSourceSession(264                    socket = phoneSocket,265                    // Pre-fix invitations carried no static; accepting one here266                    // is the same hole as accepting a mismatch.267                    invitation = PairingInvitation(268                        "wss://relay.example.test",269                        "AAECAwQFBgcICQoLDA0ODw",270                        VaultStatus.EMPTY,271                    ),272                    sourceDeps = object : AdoptionSourceDeps {273                        override val sourceIdentity =274                            identity("phone", deriveLocalSyncKeypair(root, "phone-id").publicKey)275                        override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)276                        override suspend fun vaultId() = computeVaultId(root)277                        override suspend fun onConsent(request: AdoptionFrame.Request) = true278                        override suspend fun upsertPairedDevice(device: PairedDevice) = Unit279                    },280                    confirm = { _, _ -> true },281                )282                "completed"283            } catch (expected: IllegalArgumentException) {284                expected.message285            }286        }287288        mitmSocket.receive() // PEACH_HELLO289        mitmSocket.send(290            org.json.JSONObject().put("type", "PEACH_HELLO_ACK")291                .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(attackerKeys.publicKey))292                .toString().toByteArray(),293        )294        val attackerJob = async { attackerReceivesGrant(mitmSocket, attackerKeys) }295296        assertEquals(297            "pairing_invitation_missing_static_key",298            withTimeoutOrNull(HANDSHAKE_TIMEOUT_MS) { phoneJob.await() },299        )300        attackerJob.cancel()301    }302303    @Test fun `phone source never reads or sends a grant when SAS is rejected`() = runBlocking {304        val (phoneSocket, responderSocket) = socketPair()305        val token = "AAECAwQFBgcICQoLDA0ODw"306        val root = ByteArray(32) { (it + 11).toByte() }307        val phoneKeys = deriveLocalSyncKeypair(root, "phone-id")308        val responderKeys = deriveLocalSyncKeypair(root, "responder-id")309        var rootRequested = false310        var upserted = false311312        val phoneJob = async {313            runRelayAdoptionInitiatorSourceSession(314                socket = phoneSocket,315                invitation = PairingInvitation(316                    "wss://relay.example.test",317                    token,318                    VaultStatus.EMPTY,319                    Rendezvous.bytesToHex(responderKeys.publicKey),320                ),321                sourceDeps = object : AdoptionSourceDeps {322                    override val sourceIdentity = identity("phone", phoneKeys.publicKey)323                    override suspend fun rootKeyHex(): String {324                        rootRequested = true325                        return Rendezvous.bytesToHex(root)326                    }327                    override suspend fun vaultId() = computeVaultId(root)328                    override suspend fun onConsent(request: AdoptionFrame.Request) = true329                    override suspend fun upsertPairedDevice(device: PairedDevice) {330                        upserted = true331                    }332                },333                confirm = { _, _ -> false },334            )335        }336337        // Responder handshake338        responderSocket.receive() // PEACH_HELLO339        responderSocket.send(340            org.json.JSONObject().put("type", "PEACH_HELLO_ACK")341                .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(responderKeys.publicKey))342                .toString().toByteArray(),343        )344        val noise = noiseNKResponder(345            responderKeys.secretKey,346            responderKeys.publicKey,347            responderSocket::send,348            responderSocket::receive,349        )350        try {351            val channel = object : PairingTransport {352                override suspend fun send(msg: ByteArray) = responderSocket.send(noise.encrypt(msg))353                override suspend fun receive(): ByteArray = noise.decrypt(responderSocket.receive())354                override suspend fun close() = responderSocket.close()355            }356            // Send only the request. The phone's source flow must reject before357            // it ever reads the root key or sends the grant.358            sendFrame(channel, AdoptionFrame.Request("session-only", "Empty Browser", "extension"))359        } finally {360            noise.close()361        }362363        assertNull(phoneJob.await())364        assertFalse("root key must stay on this device until SAS matches", rootRequested)365        assertFalse("paired-device store must not be touched on rejection", upserted)366    }367}368369/**370 * Dispatch tests: each receiver entry point rejects the wrong vaultStatus, so371 * the ViewModel's `when (parsed.vaultStatus)` is forced to call the matching372 * path. Combined with the parser tests this is the flow-level guarantee that373 * an empty invitation triggers grantAdoption and a populated one triggers374 * requestAdoption.375 */376class RelayAdoptionReceiverDispatchTest {377378    @Test fun `adopt rejects empty invitations so the source path is used instead`() = runBlocking {379        val receiver = RelayAdoptionReceiver()380        val empty = createPairingInvitation(381            "wss://relay.example.test",382            "AAECAwQFBgcICQoLDA0ODw",383            VaultStatus.EMPTY,384        )385        try {386            receiver.adopt(387                encodedInvitation = empty,388                deps = object : AdoptionReceiverDeps {389                    override val deviceName = "phone"390                    override val deviceType = DeviceType.ANDROID391                    override fun generateDeviceId() = "x"392                    override suspend fun confirmSas() = true393                    override suspend fun createVaultFromRootKey(rootKeyHex: String) = "v"394                    override suspend fun deriveLocalIdentity() = error("unused")395                    override suspend fun upsertPairedDevice(device: PairedDevice) = Unit396                    override fun onError(error: String) = Unit397                },398                confirm = { true },399            )400            fail("adopt must reject empty invitations")401        } catch (expected: IllegalArgumentException) {402            assertTrue(403                "expected vaultStatus in the error, got: ${expected.message}",404                expected.message!!.contains("EMPTY", ignoreCase = true),405            )406        }407    }408409    @Test fun `grantFromInvitation rejects populated invitations so the receiver path is used instead`() = runBlocking {410        val receiver = RelayAdoptionReceiver()411        val populated = createPairingInvitation(412            "wss://relay.example.test",413            "AAECAwQFBgcICQoLDA0ODw",414            VaultStatus.POPULATED,415        )416        try {417            receiver.grantFromInvitation(418                encodedInvitation = populated,419                sourceDeps = object : AdoptionSourceDeps {420                    override val sourceIdentity = LocalDeviceIdentity(421                        "phone", "phone", DeviceType.ANDROID,422                        "ab".repeat(32), "cd".repeat(32),423                    )424                    override suspend fun rootKeyHex() = "ab".repeat(32)425                    override suspend fun vaultId() = "cd".repeat(32)426                    override suspend fun onConsent(request: AdoptionFrame.Request) = true427                    override suspend fun upsertPairedDevice(device: PairedDevice) = Unit428                },429                confirm = { _, _ -> true },430            )431            fail("grantFromInvitation must reject populated invitations")432        } catch (expected: IllegalArgumentException) {433            assertTrue(434                "expected vaultStatus in the error, got: ${expected.message}",435                expected.message!!.contains("POPULATED", ignoreCase = true),436            )437        }438    }439}