RelayAdoptionHostTest.kt

14.7 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.Assert.assertTrue13import org.junit.Test1415class RelayAdoptionHostTest {16    private class MemoryRelaySocket(17        private val inbound: Channel<ByteArray>,18        private val outbound: Channel<ByteArray>,19    ) : RelayBinarySocket {20        override fun send(frame: ByteArray) {21            check(outbound.trySend(frame.copyOf()).isSuccess)22        }2324        override suspend fun receive(): ByteArray = inbound.receive()25        override fun close(code: Int, reason: String) {26            outbound.close()27        }28    }2930    private fun socketPair(): Pair<RelayBinarySocket, RelayBinarySocket> {31        val leftToRight = Channel<ByteArray>(Channel.UNLIMITED)32        val rightToLeft = Channel<ByteArray>(Channel.UNLIMITED)33        return MemoryRelaySocket(rightToLeft, leftToRight) to34            MemoryRelaySocket(leftToRight, rightToLeft)35    }3637    private fun identity(name: String, publicKey: ByteArray) = LocalDeviceIdentity(38        deviceId = "$name-id",39        deviceName = name,40        deviceType = if (name == "source") DeviceType.ANDROID else DeviceType.EXTENSION,41        longTermPublicKeyHex = Rendezvous.bytesToHex(publicKey),42        signingPublicKeyHex = "cd".repeat(32),43        wsPort = 49142,44        relayUrl = "wss://relay.example.test",45    )4647    @Test fun `relay invitation runs Noise and persists session-derived pair state`() = runBlocking {48        val (sourceSocket, receiverSocket) = socketPair()49        val token = "AAECAwQFBgcICQoLDA0ODw"50        val relay = "wss://relay.example.test"51        val sourceRoot = ByteArray(32) { (it + 1).toByte() }52        val sourceKeys = deriveLocalSyncKeypair(sourceRoot, "source-id")53        val receiverKeys = deriveLocalSyncKeypair(sourceRoot, "receiver-id")54        val sourceIdentity = identity("source", sourceKeys.publicKey)55        val receiverIdentity = identity("receiver", receiverKeys.publicKey)56        var sourceStored: PairedDevice? = null57        var receiverStored: PairedDevice? = null5859        val sourceJob = async {60            runRelayAdoptionSourceSession(61                socket = sourceSocket,62                expectedToken = token,63                relayUrl = relay,64                ourStaticPrivateKey = sourceKeys.secretKey,65                ourStaticPublicKey = sourceKeys.publicKey,66                localIdentity = sourceIdentity,67                sourceDeps = object : AdoptionSourceDeps {68                    override val sourceIdentity = sourceIdentity69                    override suspend fun rootKeyHex() = Rendezvous.bytesToHex(sourceRoot)70                    override suspend fun vaultId() = computeVaultId(sourceRoot)71                    override suspend fun onConsent(request: AdoptionFrame.Request) = true72                    override suspend fun upsertPairedDevice(device: PairedDevice) {73                        sourceStored = device74                    }75                },76                confirm = { sas, request ->77                    assertEquals("receiver", request.deviceName)78                    assertEquals(8, sas.length)79                    true80                },81            )82        }8384        receiverSocket.send(85            JSONObject().put("type", "PEACH_HELLO").put("token", token)86                .toString().toByteArray(),87        )88        val ack = JSONObject(String(receiverSocket.receive()))89        val sourcePublic = hexToPubkey(ack.getString("ephemeralPublicKeyHex"))90        val noise = noiseNKInitiator(91            sourcePublic,92            receiverSocket::send,93            receiverSocket::receive,94        )95        val channel = encryptedChannel(receiverSocket, noise)96        val received = requestAdoption(97            channel,98            object : AdoptionReceiverDeps {99                override val deviceName = "receiver"100                override val deviceType = DeviceType.EXTENSION101                override val pairState = AdoptionPairState(102                    Rendezvous.bytesToHex(Rendezvous.deriveSecret(noise.handshakeHash)),103                    relay,104                )105                override fun generateDeviceId() = "session-request"106                override suspend fun confirmSas() = true107                override suspend fun createVaultFromRootKey(rootKeyHex: String) =108                    computeVaultId(hexToPubkey(rootKeyHex))109                override suspend fun deriveLocalIdentity() = receiverIdentity110                override suspend fun upsertPairedDevice(device: PairedDevice) {111                    receiverStored = device112                }113                override fun onError(error: String) = throw AssertionError(error)114            },115        )116117        assertNotNull(received)118        assertNotNull(sourceJob.await())119        assertEquals(relay, receiverStored?.relayUrl)120        assertEquals(relay, sourceStored?.relayUrl)121        assertEquals(receiverStored?.rendezvousSecretHex, sourceStored?.rendezvousSecretHex)122        assertEquals(64, receiverStored?.rendezvousSecretHex?.length)123    }124125    @Test fun `source rejection never requests the root`() = runBlocking {126        val (sourceSocket, receiverSocket) = socketPair()127        val token = "AAECAwQFBgcICQoLDA0ODw"128        val root = ByteArray(32) { (it + 7).toByte() }129        val sourceKeys = deriveLocalSyncKeypair(root, "source-id")130        val rootRequested = AtomicBoolean(false)131132        val sourceJob = async {133            runRelayAdoptionSourceSession(134                sourceSocket,135                token,136                "wss://relay.example.test",137                sourceKeys.secretKey,138                sourceKeys.publicKey,139                identity("source", sourceKeys.publicKey),140                object : AdoptionSourceDeps {141                    override val sourceIdentity = identity("source", sourceKeys.publicKey)142                    override suspend fun rootKeyHex(): String {143                        rootRequested.set(true)144                        return Rendezvous.bytesToHex(root)145                    }146                    override suspend fun vaultId() = computeVaultId(root)147                    override suspend fun onConsent(request: AdoptionFrame.Request) = true148                    override suspend fun upsertPairedDevice(device: PairedDevice) = Unit149                },150                confirm = { _, _ -> false },151            )152        }153        receiverSocket.send(154            JSONObject().put("type", "PEACH_HELLO").put("token", token)155                .toString().toByteArray(),156        )157        val ack = JSONObject(String(receiverSocket.receive()))158        val noise = noiseNKInitiator(159            hexToPubkey(ack.getString("ephemeralPublicKeyHex")),160            receiverSocket::send,161            receiverSocket::receive,162        )163        val channel = encryptedChannel(receiverSocket, noise)164        sendFrame(channel, AdoptionFrame.Request("session-request", "receiver", "extension"))165166        assertNull(sourceJob.await())167        assertFalse(rootRequested.get())168    }169170    @Test fun `receiver session completes real Noise adoption with matching pair state`() = runBlocking {171        val (sourceSocket, receiverSocket) = socketPair()172        val token = "AAECAwQFBgcICQoLDA0ODw"173        val relay = "wss://relay.example.test"174        val root = ByteArray(32) { (it + 3).toByte() }175        val sourceKeys = deriveLocalSyncKeypair(root, "source-id")176        val receiverKeys = deriveLocalSyncKeypair(root, "receiver-id")177        val sourceIdentity = identity("source", sourceKeys.publicKey)178        val receiverIdentity = identity("receiver", receiverKeys.publicKey)179        var sourceStored: PairedDevice? = null180        var receiverStored: PairedDevice? = null181        var sourceSas = ""182        var receiverSas = ""183184        val sourceJob = async {185            runRelayAdoptionSourceSession(186                sourceSocket,187                token,188                relay,189                sourceKeys.secretKey,190                sourceKeys.publicKey,191                sourceIdentity,192                object : AdoptionSourceDeps {193                    override val sourceIdentity = sourceIdentity194                    override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)195                    override suspend fun vaultId() = computeVaultId(root)196                    override suspend fun onConsent(request: AdoptionFrame.Request) = true197                    override suspend fun upsertPairedDevice(device: PairedDevice) {198                        sourceStored = device199                    }200                },201                confirm = { sas, _ ->202                    sourceSas = sas203                    true204                },205            )206        }207208        val received = runRelayAdoptionReceiverSession(209            receiverSocket,210            PairingInvitation(relay, token),211            object : AdoptionReceiverDeps {212                override val deviceName = "receiver"213                override val deviceType = DeviceType.ANDROID214                override fun generateDeviceId() = "session-only-request"215                override suspend fun confirmSas() = error("session wrapper owns SAS")216                override suspend fun createVaultFromRootKey(rootKeyHex: String): String {217                    assertEquals(Rendezvous.bytesToHex(root), rootKeyHex)218                    return computeVaultId(hexToPubkey(rootKeyHex))219                }220                override suspend fun deriveLocalIdentity() = receiverIdentity221                override suspend fun upsertPairedDevice(device: PairedDevice) {222                    receiverStored = device223                }224                override fun onError(error: String) = throw AssertionError(error)225            },226            confirm = { sas ->227                receiverSas = sas228                true229            },230        )231232        assertNotNull(received)233        assertNotNull(sourceJob.await())234        assertEquals(sourceSas, receiverSas)235        assertEquals(relay, sourceStored?.relayUrl)236        assertEquals(relay, receiverStored?.relayUrl)237        assertEquals(sourceStored?.rendezvousSecretHex, receiverStored?.rendezvousSecretHex)238    }239240    @Test fun `receiver rejection is encrypted and source never reads root`() = runBlocking {241        val (sourceSocket, receiverSocket) = socketPair()242        val token = "AAECAwQFBgcICQoLDA0ODw"243        val relay = "wss://relay.example.test"244        val root = ByteArray(32) { (it + 9).toByte() }245        val sourceKeys = deriveLocalSyncKeypair(root, "source-id")246        val rootRequested = AtomicBoolean(false)247248        val sourceJob = async {249            runRelayAdoptionSourceSession(250                sourceSocket,251                token,252                relay,253                sourceKeys.secretKey,254                sourceKeys.publicKey,255                identity("source", sourceKeys.publicKey),256                object : AdoptionSourceDeps {257                    override val sourceIdentity = identity("source", sourceKeys.publicKey)258                    override suspend fun rootKeyHex(): String {259                        rootRequested.set(true)260                        return Rendezvous.bytesToHex(root)261                    }262                    override suspend fun vaultId() = computeVaultId(root)263                    override suspend fun onConsent(request: AdoptionFrame.Request) = true264                    override suspend fun upsertPairedDevice(device: PairedDevice) = Unit265                },266                confirm = { _, _ -> true },267            )268        }269        var created = false270        val received = runRelayAdoptionReceiverSession(271            receiverSocket,272            PairingInvitation(relay, token),273            object : AdoptionReceiverDeps {274                override val deviceName = "receiver"275                override val deviceType = DeviceType.ANDROID276                override fun generateDeviceId() = "session-only-request"277                override suspend fun confirmSas() = error("session wrapper owns SAS")278                override suspend fun createVaultFromRootKey(rootKeyHex: String): String {279                    created = true280                    return computeVaultId(hexToPubkey(rootKeyHex))281                }282                override suspend fun deriveLocalIdentity() =283                    identity("receiver", sourceKeys.publicKey)284                override suspend fun upsertPairedDevice(device: PairedDevice) = Unit285                override fun onError(error: String) {286                    assertEquals("ADOPT_SAS_REJECTED", error)287                }288            },289            confirm = { false },290        )291292        assertNull(received)293        assertNull(sourceJob.await())294        assertFalse(rootRequested.get())295        assertFalse(created)296    }297298    @Test fun `malformed receiver ACK fails before any vault mutation`() = runBlocking {299        val (unusedSource, receiverSocket) = socketPair()300        unusedSource.send(301            JSONObject()302                .put("type", "PEACH_HELLO_ACK")303                .put("ephemeralPublicKeyHex", "ab")304                .toString()305                .toByteArray(),306        )307        var created = false308        val result = runCatching {309            runRelayAdoptionReceiverSession(310                receiverSocket,311                PairingInvitation(312                    "wss://relay.example.test",313                    "AAECAwQFBgcICQoLDA0ODw",314                ),315                object : AdoptionReceiverDeps {316                    override val deviceName = "receiver"317                    override val deviceType = DeviceType.ANDROID318                    override fun generateDeviceId() = "request"319                    override suspend fun confirmSas() = false320                    override suspend fun createVaultFromRootKey(rootKeyHex: String): String {321                        created = true322                        return ""323                    }324                    override suspend fun deriveLocalIdentity() =325                        error("must not derive identity")326                    override suspend fun upsertPairedDevice(device: PairedDevice) = Unit327                    override fun onError(error: String) = Unit328                },329                confirm = { true },330            )331        }332        assertTrue(result.isFailure)333        assertFalse(created)334    }335336    private fun encryptedChannel(337        socket: RelayBinarySocket,338        noise: NoiseNKSession,339    ): PairingTransport = object : PairingTransport {340        override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))341        override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())342        override suspend fun close() = socket.close()343    }344}