package com.peachpasswords.sync import kotlinx.coroutines.async import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Assert.fail import org.junit.Test /** * Source-initiator counterpart to [RelayAdoptionHostTest]: the phone dials the * relay room, runs Noise as initiator, and sends the grant to the (empty) * responder. The responder side runs the real [requestAdoption] receiver * pipeline so this test exercises both directions of the protocol over a * real Noise channel. */ class RelayAdoptionInitiatorSourceTest { /** * Time-boxed so these adversarial tests fail rather than hang. Without the * pin check the phone completes a handshake against the substituted key and * then waits on a protocol step the attacker never performs, so a plain * await() would never return. */ private val HANDSHAKE_TIMEOUT_MS = 5_000L private fun identity(name: String, publicKey: ByteArray) = LocalDeviceIdentity( deviceId = "$name-id", deviceName = name, deviceType = if (name == "phone") DeviceType.ANDROID else DeviceType.EXTENSION, longTermPublicKeyHex = Rendezvous.bytesToHex(publicKey), signingPublicKeyHex = "cd".repeat(32), wsPort = 49142, relayUrl = "wss://relay.example.test", ) private class MemoryRelaySocket( private val inbound: kotlinx.coroutines.channels.Channel, private val outbound: kotlinx.coroutines.channels.Channel, ) : RelayBinarySocket { override fun send(frame: ByteArray) { check(outbound.trySend(frame.copyOf()).isSuccess) } override suspend fun receive(): ByteArray = inbound.receive() override fun close(code: Int, reason: String) { outbound.close() } } private fun socketPair(): Pair { val leftToRight = kotlinx.coroutines.channels.Channel( kotlinx.coroutines.channels.Channel.UNLIMITED, ) val rightToLeft = kotlinx.coroutines.channels.Channel( kotlinx.coroutines.channels.Channel.UNLIMITED, ) return MemoryRelaySocket(rightToLeft, leftToRight) to MemoryRelaySocket(leftToRight, rightToLeft) } @Test fun `phone source sends grant to empty responder over a real Noise channel`() = runBlocking { val (phoneSocket, responderSocket) = socketPair() val token = "AAECAwQFBgcICQoLDA0ODw" val relay = "wss://relay.example.test" val root = ByteArray(32) { (it + 5).toByte() } val phoneKeys = deriveSyncIdentityKeypair(root, "phone-id") val responderKeys = deriveSyncIdentityKeypair(root, "responder-id") val phoneIdentity = identity("phone", phoneKeys.publicKey) val responderIdentity = identity("responder", responderKeys.publicKey) var phoneStored: PairedDevice? = null val phoneJob = async { runRelayAdoptionInitiatorSourceSession( socket = phoneSocket, invitation = PairingInvitation( relay, token, VaultStatus.EMPTY, Rendezvous.bytesToHex(responderKeys.publicKey), ), sourceDeps = object : AdoptionSourceV2Deps { override suspend fun rotateSourceIdentity() = sourceIdentity override fun hasDeviceIdentityCollision(device: PairedDevice) = false override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long) = true override suspend fun completePendingIdentity(device: PairedDevice) = true override fun snapshotCapable() = true override val sourceIdentity = phoneIdentity override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root) override suspend fun vaultId() = computeVaultId(root) override suspend fun license() = "PPK-test" override suspend fun onConsent(request: AdoptionFrame.Request) = true override suspend fun onConsent(request: AdoptionFrame.RequestV2) = true override suspend fun upsertPairedDevice(device: PairedDevice) { phoneStored = device } }, confirm = { sas, request -> assertEquals(responderIdentity.deviceName, request.deviceName) assertEquals("extension", request.deviceType) assertEquals(8, sas.length) true }, ) } // Responder (empty browser) handshake: receive HELLO, send ACK, run Noise. val hello = org.json.JSONObject(String(responderSocket.receive())) assertEquals("PEACH_HELLO", hello.optString("type")) assertEquals(token, hello.optString("token")) responderSocket.send( org.json.JSONObject().put("type", "PEACH_HELLO_ACK") .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(responderKeys.publicKey)) .toString().toByteArray(), ) val noise = noiseNKResponder( responderKeys.secretKey, responderKeys.publicKey, responderSocket::send, responderSocket::receive, ) var responderStored: PairedDevice? = null try { val channel = object : PairingTransport { override suspend fun send(msg: ByteArray) = responderSocket.send(noise.encrypt(msg)) override suspend fun receive(): ByteArray = noise.decrypt(responderSocket.receive()) override suspend fun close() = responderSocket.close() } // Run the real receiver pipeline on the responder side so the test // exercises both protocol directions end-to-end. val responderJob = async { requestAdoption( channel = channel, deps = object : AdoptionReceiverV2Deps { override fun localIdentity() = LocalDeviceIdentity(generateDeviceId(), deviceName, deviceType, "", "") override suspend fun rotateLocalIdentity() = localIdentity() override suspend fun validateSource(prepare: AdoptionFrame.Prepare) {} override suspend fun hasSourceIdentityCollision(prepare: AdoptionFrame.Prepare) = false override val deviceName = responderIdentity.deviceName override val deviceType = DeviceType.EXTENSION override fun generateDeviceId() = "session-only" override suspend fun confirmSas() = true override suspend fun createVaultFromRootKey(rootKeyHex: String): String { assertEquals(Rendezvous.bytesToHex(root), rootKeyHex) return computeVaultId(hexToPubkey(rootKeyHex)) } override suspend fun deriveLocalIdentity() = responderIdentity override suspend fun upsertPairedDevice(device: PairedDevice) { responderStored = device } override fun onError(error: String) = throw AssertionError(error) }, ) } assertNotNull(responderJob.await()) } finally { noise.close() } assertNotNull(phoneJob.await()) assertNotNull(phoneStored) assertNotNull(responderStored) assertEquals("responder-id", phoneStored?.deviceId) assertEquals(responderIdentity.deviceName, phoneStored?.deviceName) assertEquals(relay, phoneStored?.relayUrl) assertEquals(64, phoneStored?.rendezvousSecretHex?.length) // The responder stores the phone's identity (from the ADOPT_GRANT source). assertEquals("phone-id", responderStored?.deviceId) assertEquals(phoneIdentity.deviceName, responderStored?.deviceName) } @Test fun `phone source rotates after browser rejects its revoked identity`() = runBlocking { val (phoneSocket, responderSocket) = socketPair() val token = "AAECAwQFBgcICQoLDA0ODw" val relay = "wss://relay.example.test" val root = ByteArray(32) { (it + 19).toByte() } val phoneKeys = deriveSyncIdentityKeypair(root, "phone-id") val responderKeys = deriveSyncIdentityKeypair(root, "responder-id") val phoneIdentity = identity("phone", phoneKeys.publicKey) val rotatedPhoneIdentity = identity( "phone-fresh", deriveSyncIdentityKeypair(root, "phone-fresh-id").publicKey, ).copy(deviceId = "phone-fresh-id") val responderIdentity = identity("responder", responderKeys.publicKey) var rotations = 0 val phoneJob = async { runRelayAdoptionInitiatorSourceSession( socket = phoneSocket, invitation = PairingInvitation( relay, token, VaultStatus.EMPTY, Rendezvous.bytesToHex(responderKeys.publicKey), ), sourceDeps = object : AdoptionSourceV2Deps { override fun hasDeviceIdentityCollision(device: PairedDevice) = false override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long) = true override suspend fun completePendingIdentity(device: PairedDevice) = true override fun snapshotCapable() = true override val sourceIdentity = phoneIdentity override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root) override suspend fun vaultId() = computeVaultId(root) override suspend fun license() = "PPK-test" override suspend fun onConsent(request: AdoptionFrame.Request) = true override suspend fun onConsent(request: AdoptionFrame.RequestV2) = true override suspend fun rotateSourceIdentity(): LocalDeviceIdentity { rotations++ return rotatedPhoneIdentity } override suspend fun upsertPairedDevice(device: PairedDevice) = Unit }, confirm = { _, _ -> true }, ) } val hello = org.json.JSONObject(String(responderSocket.receive())) assertEquals("PEACH_HELLO", hello.optString("type")) responderSocket.send( org.json.JSONObject().put("type", "PEACH_HELLO_ACK") .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(responderKeys.publicKey)) .toString().toByteArray(), ) val noise = noiseNKResponder( responderKeys.secretKey, responderKeys.publicKey, responderSocket::send, responderSocket::receive, ) try { val channel = object : PairingTransport { override suspend fun send(msg: ByteArray) = responderSocket.send(noise.encrypt(msg)) override suspend fun receive(): ByteArray = noise.decrypt(responderSocket.receive()) override suspend fun close() = responderSocket.close() } val responderJob = async { requestAdoption( channel, object : AdoptionReceiverV2Deps { override fun localIdentity() = LocalDeviceIdentity(generateDeviceId(), deviceName, deviceType, "", "") override suspend fun rotateLocalIdentity() = localIdentity() override suspend fun validateSource(prepare: AdoptionFrame.Prepare) {} override suspend fun hasSourceIdentityCollision(prepare: AdoptionFrame.Prepare) = false override val deviceName = responderIdentity.deviceName override val deviceType = DeviceType.EXTENSION override fun generateDeviceId() = "session-only" override suspend fun confirmSas() = true override suspend fun createVaultFromRootKey(rootKeyHex: String) = computeVaultId(hexToPubkey(rootKeyHex)) override suspend fun deriveLocalIdentity() = responderIdentity override suspend fun hasSourceIdentityCollision(source: AdoptionFrame.Source) = source.deviceId == phoneIdentity.deviceId override suspend fun upsertPairedDevice(device: PairedDevice) = Unit override fun onError(error: String) = throw AssertionError(error) }, ) } assertNotNull(withTimeoutOrNull(HANDSHAKE_TIMEOUT_MS) { responderJob.await() }) } finally { noise.close() } assertNotNull(withTimeoutOrNull(HANDSHAKE_TIMEOUT_MS) { phoneJob.await() }) assertEquals(1, rotations) } // PB-26-103: this is the root-key-bearing direction. The phone grants its // vault to an empty browser, so if the relay can substitute the responder's // static in the cleartext ACK the phone completes an NK handshake with the // attacker and hands over the root key. The invitation pin is the only // machine-checkable defence — the SAS on the source side is a human step // the attack assumes is skipped. /** * The attacker's half of the adoption protocol once it has substituted its * static: complete Noise as responder, ask for the vault, and read whatever * the phone sends back. Fire-and-forget — with the pin check in place the * phone aborts before the handshake and this never gets a peer. */ private suspend fun attackerReceivesGrant( socket: RelayBinarySocket, keys: SyncIdentityKeypair, ) { val noise = noiseNKResponder(keys.secretKey, keys.publicKey, socket::send, socket::receive) try { val channel = object : PairingTransport { override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg)) override suspend fun receive(): ByteArray = noise.decrypt(socket.receive()) override suspend fun close() = socket.close() } sendFrame(channel, AdoptionFrame.Request("attacker-session", "Empty Browser", "extension")) receiveFrame(channel) } finally { noise.close() } } @Test fun `phone source rejects a substituted ACK static`() = runBlocking { val (phoneSocket, mitmSocket) = socketPair() val token = "AAECAwQFBgcICQoLDA0ODw" val root = ByteArray(32) { (it + 7).toByte() } val responderKeys = deriveSyncIdentityKeypair(root, "responder-id") val attackerKeys = deriveSyncIdentityKeypair(root, "attacker-id") var rootRequested = false // The result is captured inside the job: an exception escaping a child // coroutine cancels the parent scope before await() can observe it. val phoneJob = async { try { runRelayAdoptionInitiatorSourceSession( socket = phoneSocket, invitation = PairingInvitation( "wss://relay.example.test", token, VaultStatus.EMPTY, Rendezvous.bytesToHex(responderKeys.publicKey), ), sourceDeps = object : AdoptionSourceV2Deps { override suspend fun rotateSourceIdentity() = sourceIdentity override fun hasDeviceIdentityCollision(device: PairedDevice) = false override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long) = true override suspend fun completePendingIdentity(device: PairedDevice) = true override fun snapshotCapable() = true override val sourceIdentity = identity("phone", deriveSyncIdentityKeypair(root, "phone-id").publicKey) override suspend fun rootKeyHex(): String { rootRequested = true return Rendezvous.bytesToHex(root) } override suspend fun vaultId() = computeVaultId(root) override suspend fun onConsent(request: AdoptionFrame.Request) = true override suspend fun onConsent(request: AdoptionFrame.RequestV2) = true override suspend fun upsertPairedDevice(device: PairedDevice) = Unit }, confirm = { _, _ -> true }, ) "completed" } catch (expected: IllegalArgumentException) { expected.message } } mitmSocket.receive() // PEACH_HELLO // The relay terminates the session itself: a different static in the ACK. mitmSocket.send( org.json.JSONObject().put("type", "PEACH_HELLO_ACK") .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(attackerKeys.publicKey)) .toString().toByteArray(), ) // The attacker then plays the receiver through a complete handshake so // the pre-fix code path runs to the grant instead of stalling. val attackerJob = async { attackerReceivesGrant(mitmSocket, attackerKeys) } // Pre-fix the phone completes the handshake against the attacker's key // and stalls waiting on the protocol; either way it is not this error. assertEquals( "pairing_static_key_mismatch", withTimeoutOrNull(HANDSHAKE_TIMEOUT_MS) { phoneJob.await() }, ) attackerJob.cancel() assertFalse("root key must never be read against a substituted static", rootRequested) } @Test fun `phone source rejects an invitation that pins no static key`() = runBlocking { val (phoneSocket, mitmSocket) = socketPair() val root = ByteArray(32) { (it + 23).toByte() } val attackerKeys = deriveSyncIdentityKeypair(root, "attacker-id") val phoneJob = async { try { runRelayAdoptionInitiatorSourceSession( socket = phoneSocket, // Pre-fix invitations carried no static; accepting one here // is the same hole as accepting a mismatch. invitation = PairingInvitation( "wss://relay.example.test", "AAECAwQFBgcICQoLDA0ODw", VaultStatus.EMPTY, ), sourceDeps = object : AdoptionSourceV2Deps { override suspend fun rotateSourceIdentity() = sourceIdentity override fun hasDeviceIdentityCollision(device: PairedDevice) = false override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long) = true override suspend fun completePendingIdentity(device: PairedDevice) = true override fun snapshotCapable() = true override val sourceIdentity = identity("phone", deriveSyncIdentityKeypair(root, "phone-id").publicKey) override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root) override suspend fun vaultId() = computeVaultId(root) override suspend fun onConsent(request: AdoptionFrame.Request) = true override suspend fun onConsent(request: AdoptionFrame.RequestV2) = true override suspend fun upsertPairedDevice(device: PairedDevice) = Unit }, confirm = { _, _ -> true }, ) "completed" } catch (expected: IllegalArgumentException) { expected.message } } mitmSocket.receive() // PEACH_HELLO mitmSocket.send( org.json.JSONObject().put("type", "PEACH_HELLO_ACK") .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(attackerKeys.publicKey)) .toString().toByteArray(), ) val attackerJob = async { attackerReceivesGrant(mitmSocket, attackerKeys) } assertEquals( "pairing_invitation_missing_static_key", withTimeoutOrNull(HANDSHAKE_TIMEOUT_MS) { phoneJob.await() }, ) attackerJob.cancel() } @Test fun `phone source never reads or sends a grant when SAS is rejected`() = runBlocking { val (phoneSocket, responderSocket) = socketPair() val token = "AAECAwQFBgcICQoLDA0ODw" val root = ByteArray(32) { (it + 11).toByte() } val phoneKeys = deriveSyncIdentityKeypair(root, "phone-id") val responderKeys = deriveSyncIdentityKeypair(root, "responder-id") var rootRequested = false var upserted = false val phoneJob = async { runRelayAdoptionInitiatorSourceSession( socket = phoneSocket, invitation = PairingInvitation( "wss://relay.example.test", token, VaultStatus.EMPTY, Rendezvous.bytesToHex(responderKeys.publicKey), ), sourceDeps = object : AdoptionSourceV2Deps { override suspend fun rotateSourceIdentity() = sourceIdentity override fun hasDeviceIdentityCollision(device: PairedDevice) = false override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long) = true override suspend fun completePendingIdentity(device: PairedDevice) = true override fun snapshotCapable() = true override val sourceIdentity = identity("phone", phoneKeys.publicKey) override suspend fun rootKeyHex(): String { rootRequested = true return Rendezvous.bytesToHex(root) } override suspend fun vaultId() = computeVaultId(root) override suspend fun onConsent(request: AdoptionFrame.Request) = true override suspend fun onConsent(request: AdoptionFrame.RequestV2) = true override suspend fun upsertPairedDevice(device: PairedDevice) { upserted = true } }, confirm = { _, _ -> false }, ) } // Responder handshake responderSocket.receive() // PEACH_HELLO responderSocket.send( org.json.JSONObject().put("type", "PEACH_HELLO_ACK") .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(responderKeys.publicKey)) .toString().toByteArray(), ) val noise = noiseNKResponder( responderKeys.secretKey, responderKeys.publicKey, responderSocket::send, responderSocket::receive, ) try { val channel = object : PairingTransport { override suspend fun send(msg: ByteArray) = responderSocket.send(noise.encrypt(msg)) override suspend fun receive(): ByteArray = noise.decrypt(responderSocket.receive()) override suspend fun close() = responderSocket.close() } // Send only the request. The phone's source flow must reject before // it ever reads the root key or sends the grant. sendFrame(channel, AdoptionFrame.Request("session-only", "Empty Browser", "extension")) } finally { noise.close() } assertNull(phoneJob.await()) assertFalse("root key must stay on this device until SAS matches", rootRequested) assertFalse("paired-device store must not be touched on rejection", upserted) } } /** * Dispatch tests: each receiver entry point rejects the wrong vaultStatus, so * the ViewModel's `when (parsed.vaultStatus)` is forced to call the matching * path. Combined with the parser tests this is the flow-level guarantee that * an empty invitation triggers grantAdoption and a populated one triggers * requestAdoption. */ class RelayAdoptionReceiverDispatchTest { @Test fun `adopt rejects empty invitations so the source path is used instead`() = runBlocking { val receiver = RelayAdoptionReceiver(FakeSyncGeneration(), EmptyRegistry) val empty = createPairingInvitation( "wss://relay.example.test", "AAECAwQFBgcICQoLDA0ODw", VaultStatus.EMPTY, ) try { receiver.adopt( encodedInvitation = empty, deps = object : AdoptionReceiverV2Deps { override fun localIdentity() = LocalDeviceIdentity(generateDeviceId(), deviceName, deviceType, "", "") override suspend fun rotateLocalIdentity() = localIdentity() override suspend fun validateSource(prepare: AdoptionFrame.Prepare) {} override suspend fun hasSourceIdentityCollision(prepare: AdoptionFrame.Prepare) = false override val deviceName = "phone" override val deviceType = DeviceType.ANDROID override fun generateDeviceId() = "x" override suspend fun confirmSas() = true override suspend fun createVaultFromRootKey(rootKeyHex: String) = "v" override suspend fun deriveLocalIdentity() = error("unused") override suspend fun upsertPairedDevice(device: PairedDevice) = Unit override fun onError(error: String) = Unit }, confirm = { true }, ) fail("adopt must reject empty invitations") } catch (expected: IllegalArgumentException) { assertTrue( "expected vaultStatus in the error, got: ${expected.message}", expected.message!!.contains("EMPTY", ignoreCase = true), ) } } @Test fun `grantFromInvitation rejects populated invitations so the receiver path is used instead`() = runBlocking { val receiver = RelayAdoptionReceiver(FakeSyncGeneration(), EmptyRegistry) val populated = createPairingInvitation( "wss://relay.example.test", "AAECAwQFBgcICQoLDA0ODw", VaultStatus.POPULATED, ) try { receiver.grantFromInvitation( encodedInvitation = populated, sourceDeps = object : AdoptionSourceV2Deps { override suspend fun rotateSourceIdentity() = sourceIdentity override fun hasDeviceIdentityCollision(device: PairedDevice) = false override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long) = true override suspend fun completePendingIdentity(device: PairedDevice) = true override fun snapshotCapable() = true override val sourceIdentity = LocalDeviceIdentity( "phone", "phone", DeviceType.ANDROID, "ab".repeat(32), "cd".repeat(32), ) override suspend fun rootKeyHex() = "ab".repeat(32) override suspend fun vaultId() = "cd".repeat(32) override suspend fun onConsent(request: AdoptionFrame.Request) = true override suspend fun onConsent(request: AdoptionFrame.RequestV2) = true override suspend fun upsertPairedDevice(device: PairedDevice) = Unit }, confirm = { _, _ -> true }, ) fail("grantFromInvitation must reject populated invitations") } catch (expected: IllegalArgumentException) { assertTrue( "expected vaultStatus in the error, got: ${expected.message}", expected.message!!.contains("POPULATED", ignoreCase = true), ) } } }