RelayAdoptionHostTest.kt

7.5 KB · raw

1package com.peachpasswords.sync23import java.util.concurrent.atomic.AtomicBoolean4import kotlinx.coroutines.async5import kotlinx.coroutines.channels.Channel6import kotlinx.coroutines.runBlocking7import org.json.JSONObject8import org.junit.Assert.assertEquals9import org.junit.Assert.assertFalse10import org.junit.Assert.assertNotNull11import org.junit.Assert.assertNull12import org.junit.Test1314class RelayAdoptionHostTest {15    private class MemoryRelaySocket(16        private val inbound: Channel<ByteArray>,17        private val outbound: Channel<ByteArray>,18    ) : RelayBinarySocket {19        override fun send(frame: ByteArray) {20            check(outbound.trySend(frame.copyOf()).isSuccess)21        }2223        override suspend fun receive(): ByteArray = inbound.receive()24        override fun close(code: Int, reason: String) {25            outbound.close()26        }27    }2829    private fun socketPair(): Pair<RelayBinarySocket, RelayBinarySocket> {30        val leftToRight = Channel<ByteArray>(Channel.UNLIMITED)31        val rightToLeft = Channel<ByteArray>(Channel.UNLIMITED)32        return MemoryRelaySocket(rightToLeft, leftToRight) to33            MemoryRelaySocket(leftToRight, rightToLeft)34    }3536    private fun identity(name: String, publicKey: ByteArray) = LocalChirpIdentity(37        deviceId = "$name-id",38        deviceName = name,39        deviceType = if (name == "source") DeviceType.ANDROID else DeviceType.EXTENSION,40        longTermPublicKeyHex = Rendezvous.bytesToHex(publicKey),41        signingPublicKeyHex = "cd".repeat(32),42        wsPort = 49142,43        relayUrl = "wss://relay.example.test",44    )4546    @Test fun `relay invitation runs Noise and persists session-derived pair state`() = runBlocking {47        val (sourceSocket, receiverSocket) = socketPair()48        val token = "AAECAwQFBgcICQoLDA0ODw"49        val relay = "wss://relay.example.test"50        val sourceRoot = ByteArray(32) { (it + 1).toByte() }51        val sourceKeys = deriveLocalSyncKeypair(sourceRoot, "source-id")52        val receiverKeys = deriveLocalSyncKeypair(sourceRoot, "receiver-id")53        val sourceIdentity = identity("source", sourceKeys.publicKey)54        val receiverIdentity = identity("receiver", receiverKeys.publicKey)55        var sourceStored: PairedDevice? = null56        var receiverStored: PairedDevice? = null5758        val sourceJob = async {59            runRelayAdoptionSourceSession(60                socket = sourceSocket,61                expectedToken = token,62                relayUrl = relay,63                ourStaticPrivateKey = sourceKeys.secretKey,64                ourStaticPublicKey = sourceKeys.publicKey,65                localIdentity = sourceIdentity,66                sourceDeps = object : AdoptionSourceDeps {67                    override val sourceIdentity = sourceIdentity68                    override suspend fun rootKeyHex() = Rendezvous.bytesToHex(sourceRoot)69                    override suspend fun vaultId() = computeVaultId(sourceRoot)70                    override suspend fun onConsent(request: AdoptionFrame.Request) = true71                    override suspend fun upsertPairedDevice(device: PairedDevice) {72                        sourceStored = device73                    }74                },75                confirm = { sas, request ->76                    assertEquals("receiver", request.deviceName)77                    assertEquals(6, sas.length)78                    true79                },80            )81        }8283        receiverSocket.send(84            JSONObject().put("type", "PEACH_HELLO").put("token", token)85                .toString().toByteArray(),86        )87        val ack = JSONObject(String(receiverSocket.receive()))88        val sourcePublic = hexToPubkey(ack.getString("ephemeralPublicKeyHex"))89        val noise = noiseNKInitiator(90            sourcePublic,91            receiverSocket::send,92            receiverSocket::receive,93        )94        val channel = encryptedChannel(receiverSocket, noise)95        val received = requestAdoption(96            channel,97            object : AdoptionReceiverDeps {98                override val deviceName = "receiver"99                override val deviceType = DeviceType.EXTENSION100                override val pairState = AdoptionPairState(101                    Rendezvous.bytesToHex(Rendezvous.deriveSecret(noise.handshakeHash)),102                    relay,103                )104                override fun generateDeviceId() = "session-request"105                override suspend fun confirmSas() = true106                override suspend fun createVaultFromRootKey(rootKeyHex: String) =107                    computeVaultId(hexToPubkey(rootKeyHex))108                override suspend fun deriveLocalIdentity() = receiverIdentity109                override suspend fun upsertPairedDevice(device: PairedDevice) {110                    receiverStored = device111                }112                override fun onError(error: String) = throw AssertionError(error)113            },114        )115116        assertNotNull(received)117        assertNotNull(sourceJob.await())118        assertEquals(relay, receiverStored?.relayUrl)119        assertEquals(relay, sourceStored?.relayUrl)120        assertEquals(receiverStored?.rendezvousSecretHex, sourceStored?.rendezvousSecretHex)121        assertEquals(64, receiverStored?.rendezvousSecretHex?.length)122    }123124    @Test fun `source rejection never requests the root`() = runBlocking {125        val (sourceSocket, receiverSocket) = socketPair()126        val token = "AAECAwQFBgcICQoLDA0ODw"127        val root = ByteArray(32) { (it + 7).toByte() }128        val sourceKeys = deriveLocalSyncKeypair(root, "source-id")129        val rootRequested = AtomicBoolean(false)130131        val sourceJob = async {132            runRelayAdoptionSourceSession(133                sourceSocket,134                token,135                "wss://relay.example.test",136                sourceKeys.secretKey,137                sourceKeys.publicKey,138                identity("source", sourceKeys.publicKey),139                object : AdoptionSourceDeps {140                    override val sourceIdentity = identity("source", sourceKeys.publicKey)141                    override suspend fun rootKeyHex(): String {142                        rootRequested.set(true)143                        return Rendezvous.bytesToHex(root)144                    }145                    override suspend fun vaultId() = computeVaultId(root)146                    override suspend fun onConsent(request: AdoptionFrame.Request) = true147                    override suspend fun upsertPairedDevice(device: PairedDevice) = Unit148                },149                confirm = { _, _ -> false },150            )151        }152        receiverSocket.send(153            JSONObject().put("type", "PEACH_HELLO").put("token", token)154                .toString().toByteArray(),155        )156        val ack = JSONObject(String(receiverSocket.receive()))157        val noise = noiseNKInitiator(158            hexToPubkey(ack.getString("ephemeralPublicKeyHex")),159            receiverSocket::send,160            receiverSocket::receive,161        )162        val channel = encryptedChannel(receiverSocket, noise)163        sendFrame(channel, AdoptionFrame.Request("session-request", "receiver", "extension"))164165        assertNull(sourceJob.await())166        assertFalse(rootRequested.get())167    }168169    private fun encryptedChannel(170        socket: RelayBinarySocket,171        noise: NoiseNKSession,172    ): PairingTransport = object : PairingTransport {173        override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))174        override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())175        override suspend fun close() = socket.close()176    }177}