RelayAdoptionHostTest.kt

24.9 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    /**31     * Models OkHttp's asynchronous send queue: each receive flushes the prior32     * outbound frame, but ADOPT_APPLIED has no following receive and therefore33     * reaches the peer only through a graceful close.34     */35    private class QueuedFinalFrameSocket(36        private val delegate: RelayBinarySocket,37    ) : RelayBinarySocket {38        private val queued = mutableListOf<ByteArray>()39        var gracefullyClosed = false40            private set41        var aborted = false42            private set4344        override fun send(frame: ByteArray) {45            queued += frame.copyOf()46        }4748        override suspend fun receive(): ByteArray {49            flush()50            return delegate.receive()51        }5253        override fun close(code: Int, reason: String) {54            aborted = true55            queued.clear()56            delegate.close(code, reason)57        }5859        override fun closeAfterQueuedFrames(code: Int, reason: String) {60            gracefullyClosed = true61            flush()62            delegate.closeAfterQueuedFrames(code, reason)63        }6465        private fun flush() {66            queued.forEach(delegate::send)67            queued.clear()68        }69    }7071    private fun socketPair(): Pair<RelayBinarySocket, RelayBinarySocket> {72        val leftToRight = Channel<ByteArray>(Channel.UNLIMITED)73        val rightToLeft = Channel<ByteArray>(Channel.UNLIMITED)74        return MemoryRelaySocket(rightToLeft, leftToRight) to75            MemoryRelaySocket(leftToRight, rightToLeft)76    }7778    private fun identity(name: String, publicKey: ByteArray) = LocalDeviceIdentity(79        deviceId = "$name-id",80        deviceName = name,81        deviceType = if (name == "source") DeviceType.ANDROID else DeviceType.EXTENSION,82        longTermPublicKeyHex = Rendezvous.bytesToHex(publicKey),83        signingPublicKeyHex = "cd".repeat(32),84        wsPort = 49142,85        relayUrl = "wss://relay.example.test",86    )8788    @Test fun `relay invitation runs Noise and persists session-derived pair state`() = runBlocking {89        val (sourceSocket, receiverSocket) = socketPair()90        val token = "AAECAwQFBgcICQoLDA0ODw"91        val relay = "wss://relay.example.test"92        val sourceRoot = ByteArray(32) { (it + 1).toByte() }93        val sourceKeys = localSyncKeypair(randomLocalIdentityCredentials("source-id"))94        val receiverKeys = localSyncKeypair(randomLocalIdentityCredentials("receiver-id"))95        val sourceIdentity = identity("source", sourceKeys.publicKey)96        val receiverIdentity = identity("receiver", receiverKeys.publicKey)97        var sourceStored: PairedDevice? = null98        var receiverStored: PairedDevice? = null99100        val sourceJob = async {101            runRelayAdoptionSourceSession(102                socket = sourceSocket,103                expectedToken = token,104                relayUrl = relay,105                ourStaticPrivateKey = sourceKeys.secretKey,106                ourStaticPublicKey = sourceKeys.publicKey,107                localIdentity = sourceIdentity,108                sourceDeps = object : AdoptionSourceV2Deps {109        override suspend fun rotateSourceIdentity() = sourceIdentity110        override fun hasDeviceIdentityCollision(device: PairedDevice) = false111        override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long) = true112        override suspend fun completePendingIdentity(device: PairedDevice) = true113        override fun snapshotCapable() = true114115                    override val sourceIdentity = sourceIdentity116                    override suspend fun rootKeyHex() = Rendezvous.bytesToHex(sourceRoot)117                    override suspend fun vaultId() = computeVaultId(sourceRoot)118                    override suspend fun license() = "PPK-test"119                    override suspend fun onConsent(request: AdoptionFrame.Request) = true120                    override suspend fun onConsent(request: AdoptionFrame.RequestV2) = true121                    override suspend fun upsertPairedDevice(device: PairedDevice) {122                        sourceStored = device123                    }124                },125                confirm = { sas, request ->126                    assertEquals("receiver", request.deviceName)127                    assertEquals(8, sas.length)128                    true129                },130            )131        }132133        receiverSocket.send(134            JSONObject().put("type", "PEACH_HELLO").put("token", token)135                .toString().toByteArray(),136        )137        val ack = JSONObject(String(receiverSocket.receive()))138        val sourcePublic = hexToPubkey(ack.getString("ephemeralPublicKeyHex"))139        val noise = noiseNKInitiator(140            sourcePublic,141            receiverSocket::send,142            receiverSocket::receive,143        )144        val channel = encryptedChannel(receiverSocket, noise)145        val received = requestAdoption(146            channel,147            object : AdoptionReceiverV2Deps {148        override fun localIdentity() = receiverIdentity149        override suspend fun rotateLocalIdentity() = localIdentity()150        override suspend fun validateSource(prepare: AdoptionFrame.Prepare) {}151        override suspend fun hasSourceIdentityCollision(prepare: AdoptionFrame.Prepare) = false152153                override val deviceName = "receiver"154                override val deviceType = DeviceType.EXTENSION155                override val pairState = AdoptionPairState(156                    Rendezvous.bytesToHex(Rendezvous.deriveSecret(noise.handshakeHash)),157                    relay,158                )159                override fun generateDeviceId() = "session-request"160                override suspend fun confirmSas() = true161                override suspend fun createVaultFromRootKey(rootKeyHex: String) =162                    computeVaultId(hexToPubkey(rootKeyHex))163                override suspend fun deriveLocalIdentity() = receiverIdentity164                override suspend fun upsertPairedDevice(device: PairedDevice) {165                    receiverStored = device166                }167                override fun onError(error: String) = throw AssertionError(error)168            },169        )170171        assertNotNull(received)172        assertNotNull(sourceJob.await())173        assertEquals(relay, receiverStored?.relayUrl)174        assertEquals(relay, sourceStored?.relayUrl)175        assertEquals(receiverStored?.rendezvousSecretHex, sourceStored?.rendezvousSecretHex)176        assertEquals(64, receiverStored?.rendezvousSecretHex?.length)177    }178179    @Test fun `source rejection never requests the root`() = runBlocking {180        val (sourceSocket, receiverSocket) = socketPair()181        val token = "AAECAwQFBgcICQoLDA0ODw"182        val root = ByteArray(32) { (it + 7).toByte() }183        val sourceKeys = localSyncKeypair(randomLocalIdentityCredentials("source-id"))184        val rootRequested = AtomicBoolean(false)185186        val sourceJob = async {187            runRelayAdoptionSourceSession(188                sourceSocket,189                token,190                "wss://relay.example.test",191                sourceKeys.secretKey,192                sourceKeys.publicKey,193                identity("source", sourceKeys.publicKey),194                object : AdoptionSourceV2Deps {195        override suspend fun rotateSourceIdentity() = sourceIdentity196        override fun hasDeviceIdentityCollision(device: PairedDevice) = false197        override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long) = true198        override suspend fun completePendingIdentity(device: PairedDevice) = true199        override fun snapshotCapable() = true200201                    override val sourceIdentity = identity("source", sourceKeys.publicKey)202                    override suspend fun rootKeyHex(): String {203                        rootRequested.set(true)204                        return Rendezvous.bytesToHex(root)205                    }206                    override suspend fun vaultId() = computeVaultId(root)207                    override suspend fun onConsent(request: AdoptionFrame.Request) = true208                    override suspend fun onConsent(request: AdoptionFrame.RequestV2) = true209                    override suspend fun upsertPairedDevice(device: PairedDevice) = Unit210                },211                confirm = { _, _ -> false },212            )213        }214        receiverSocket.send(215            JSONObject().put("type", "PEACH_HELLO").put("token", token)216                .toString().toByteArray(),217        )218        val ack = JSONObject(String(receiverSocket.receive()))219        val noise = noiseNKInitiator(220            hexToPubkey(ack.getString("ephemeralPublicKeyHex")),221            receiverSocket::send,222            receiverSocket::receive,223        )224        val channel = encryptedChannel(receiverSocket, noise)225        sendFrame(channel, AdoptionFrame.Request("session-request", "receiver", "extension"))226227        assertNull(sourceJob.await())228        assertFalse(rootRequested.get())229    }230231    @Test fun `a pre-reset source still runs the v1 transcript`() = runBlocking {232        val (sourceSocket, receiverSocket) = socketPair()233        val token = "AAECAwQFBgcICQoLDA0ODw"234        val root = ByteArray(32) { (it + 9).toByte() }235        val sourceKeys = localSyncKeypair(randomLocalIdentityCredentials("source-id"))236        val rootRequested = AtomicBoolean(false)237        val prompted = AtomicBoolean(false)238        val outdated = AtomicBoolean(false)239240        val sourceJob = async {241            sourceSession(242                socket = sourceSocket,243                token = token,244                root = root,245                sourceKeys = sourceKeys,246                rootRequested = rootRequested,247                confirm = { _, _ -> prompted.set(true); false },248                postReset = { false },249                onPeerOutdated = { outdated.set(true) },250            )251        }252        receiverSocket.send(253            JSONObject().put("type", "PEACH_HELLO").put("token", token)254                .toString().toByteArray(),255        )256        val ack = JSONObject(String(receiverSocket.receive()))257        val noise = noiseNKInitiator(258            hexToPubkey(ack.getString("ephemeralPublicKeyHex")),259            receiverSocket::send,260            receiverSocket::receive,261        )262        val channel = encryptedChannel(receiverSocket, noise)263        sendFrame(channel, AdoptionFrame.Request("session-request", "receiver", "extension"))264265        assertNull(sourceJob.await())266        assertTrue("v1 is still offered before a reset", prompted.get())267        assertFalse("v1 is not an outdated peer before a reset", outdated.get())268        assertFalse("a declined prompt never reads the root", rootRequested.get())269    }270271    @Test fun `a post-reset source refuses v1 before any prompt or grant`() = runBlocking {272        val (sourceSocket, receiverSocket) = socketPair()273        val token = "AAECAwQFBgcICQoLDA0ODw"274        val root = ByteArray(32) { (it + 13).toByte() }275        val sourceKeys = localSyncKeypair(randomLocalIdentityCredentials("source-id"))276        val rootRequested = AtomicBoolean(false)277        val prompted = AtomicBoolean(false)278        val outdated = AtomicBoolean(false)279280        val sourceJob = async {281            sourceSession(282                socket = sourceSocket,283                token = token,284                root = root,285                sourceKeys = sourceKeys,286                rootRequested = rootRequested,287                confirm = { _, _ -> prompted.set(true); true },288                postReset = { true },289                onPeerOutdated = { outdated.set(true) },290            )291        }292        receiverSocket.send(293            JSONObject().put("type", "PEACH_HELLO").put("token", token)294                .toString().toByteArray(),295        )296        val ack = JSONObject(String(receiverSocket.receive()))297        val noise = noiseNKInitiator(298            hexToPubkey(ack.getString("ephemeralPublicKeyHex")),299            receiverSocket::send,300            receiverSocket::receive,301        )302        val channel = encryptedChannel(receiverSocket, noise)303        sendFrame(channel, AdoptionFrame.Request("session-request", "receiver", "extension"))304305        assertNull(sourceJob.await())306        assertTrue("the refusal must be reported as an outdated peer", outdated.get())307        assertFalse("no SAS prompt for a peer that is refused", prompted.get())308        assertFalse("the root must never be read for a refused peer", rootRequested.get())309    }310311    /** Runs the production source session with only the deps these two cases need. */312    private suspend fun sourceSession(313        socket: RelayBinarySocket,314        token: String,315        root: ByteArray,316        sourceKeys: SyncIdentityKeypair,317        rootRequested: AtomicBoolean,318        confirm: suspend (String, AdoptionFrame.Request) -> Boolean,319        postReset: () -> Boolean,320        onPeerOutdated: () -> Unit,321    ): PairedDevice? = runRelayAdoptionSourceSession(322        socket,323        token,324        "wss://relay.example.test",325        sourceKeys.secretKey,326        sourceKeys.publicKey,327        identity("source", sourceKeys.publicKey),328        object : AdoptionSourceV2Deps {329            override suspend fun rotateSourceIdentity() = sourceIdentity330            override fun hasDeviceIdentityCollision(device: PairedDevice) = false331            override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long) = true332            override suspend fun completePendingIdentity(device: PairedDevice) = true333            override fun snapshotCapable() = true334335            override val sourceIdentity = identity("source", sourceKeys.publicKey)336            override suspend fun rootKeyHex(): String {337                rootRequested.set(true)338                return Rendezvous.bytesToHex(root)339            }340341            override suspend fun vaultId() = computeVaultId(root)342            override suspend fun onConsent(request: AdoptionFrame.Request) = true343            override suspend fun onConsent(request: AdoptionFrame.RequestV2) = true344            override suspend fun upsertPairedDevice(device: PairedDevice) = Unit345        },346        confirm = confirm,347        postReset = postReset,348        onPeerOutdated = onPeerOutdated,349    )350351    @Test fun `receiver session completes real Noise adoption with matching pair state`() = runBlocking {352        val (sourceSocket, receiverSocket) = socketPair()353        val token = "AAECAwQFBgcICQoLDA0ODw"354        val relay = "wss://relay.example.test"355        val root = ByteArray(32) { (it + 3).toByte() }356        val sourceKeys = localSyncKeypair(randomLocalIdentityCredentials("source-id"))357        val receiverKeys = localSyncKeypair(randomLocalIdentityCredentials("receiver-id"))358        val sourceIdentity = identity("source", sourceKeys.publicKey)359        val receiverIdentity = identity("receiver", receiverKeys.publicKey)360        var sourceStored: PairedDevice? = null361        var receiverStored: PairedDevice? = null362        var sourceSas = ""363        var receiverSas = ""364365        val sourceJob = async {366            runRelayAdoptionSourceSession(367                sourceSocket,368                token,369                relay,370                sourceKeys.secretKey,371                sourceKeys.publicKey,372                sourceIdentity,373                object : AdoptionSourceV2Deps {374        override suspend fun rotateSourceIdentity() = sourceIdentity375        override fun hasDeviceIdentityCollision(device: PairedDevice) = false376        override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long) = true377        override suspend fun completePendingIdentity(device: PairedDevice) = true378        override fun snapshotCapable() = false379380                    override val sourceIdentity = sourceIdentity381                    override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)382                    override suspend fun vaultId() = computeVaultId(root)383                    override suspend fun license() = "PPK-test"384                    override suspend fun onConsent(request: AdoptionFrame.Request) = true385                    override suspend fun onConsent(request: AdoptionFrame.RequestV2) = true386                    override suspend fun upsertPairedDevice(device: PairedDevice) {387                        sourceStored = device388                    }389                },390                confirm = { sas, _ ->391                    sourceSas = sas392                    true393                },394            )395        }396397        val queuedReceiverSocket = QueuedFinalFrameSocket(receiverSocket)398        val received = adoptFromRelaySocket(399            socket = queuedReceiverSocket,400            invitation = PairingInvitation(401                relay,402                token,403                noiseStaticPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey),404            ),405            deps = object : AdoptionReceiverV2Deps {406        override fun localIdentity() = receiverIdentity407        override suspend fun rotateLocalIdentity() = localIdentity()408        override suspend fun validateSource(prepare: AdoptionFrame.Prepare) {}409        override suspend fun hasSourceIdentityCollision(prepare: AdoptionFrame.Prepare) = false410411                override val deviceName = "receiver"412                override val deviceType = DeviceType.ANDROID413                override fun generateDeviceId() = "session-only-request"414                override suspend fun confirmSas() = error("session wrapper owns SAS")415                override suspend fun createVaultFromRootKey(rootKeyHex: String): String {416                    assertEquals(Rendezvous.bytesToHex(root), rootKeyHex)417                    return computeVaultId(hexToPubkey(rootKeyHex))418                }419                override suspend fun deriveLocalIdentity() = receiverIdentity420                override suspend fun upsertPairedDevice(device: PairedDevice) {421                    receiverStored = device422                }423                override fun onError(error: String) = throw AssertionError(error)424            },425            confirm = { sas ->426                receiverSas = sas427                true428            },429        )430431        assertNotNull(received)432        assertNotNull(sourceJob.await())433        assertTrue(queuedReceiverSocket.gracefullyClosed)434        assertFalse(queuedReceiverSocket.aborted)435        assertEquals(sourceSas, receiverSas)436        assertEquals(relay, sourceStored?.relayUrl)437        assertEquals(relay, receiverStored?.relayUrl)438        assertEquals(sourceStored?.rendezvousSecretHex, receiverStored?.rendezvousSecretHex)439    }440441    @Test fun `receiver rejection is encrypted and source never reads root`() = runBlocking {442        val token = "AAECAwQFBgcICQoLDA0ODw"443        val relay = "wss://relay.example.test"444        val root = ByteArray(32) { (it + 9).toByte() }445        val sourceKeys = localSyncKeypair(randomLocalIdentityCredentials("source-id"))446        val receiverKeys = localSyncKeypair(randomLocalIdentityCredentials("receiver-id"))447        val receiverIdentity = identity("receiver", receiverKeys.publicKey)448        val (sourceSocket, receiverSocket) = socketPair()449        val rootRequested = AtomicBoolean(false)450451        val sourceJob = async {452            runRelayAdoptionSourceSession(453                sourceSocket,454                token,455                relay,456                sourceKeys.secretKey,457                sourceKeys.publicKey,458                identity("source", sourceKeys.publicKey),459                object : AdoptionSourceV2Deps {460        override suspend fun rotateSourceIdentity() = sourceIdentity461        override fun hasDeviceIdentityCollision(device: PairedDevice) = false462        override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long) = true463        override suspend fun completePendingIdentity(device: PairedDevice) = true464        override fun snapshotCapable() = true465466                    override val sourceIdentity = identity("source", sourceKeys.publicKey)467                    override suspend fun rootKeyHex(): String {468                        rootRequested.set(true)469                        return Rendezvous.bytesToHex(root)470                    }471                    override suspend fun vaultId() = computeVaultId(root)472                    override suspend fun onConsent(request: AdoptionFrame.Request) = true473                    override suspend fun onConsent(request: AdoptionFrame.RequestV2) = true474                    override suspend fun upsertPairedDevice(device: PairedDevice) = Unit475                },476                confirm = { _, _ -> true },477            )478        }479        var created = false480        val received = runRelayAdoptionReceiverSession(481            receiverSocket,482            PairingInvitation(483                relay,484                token,485                noiseStaticPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey),486            ),487            object : AdoptionReceiverV2Deps {488        override fun localIdentity() = receiverIdentity489        override suspend fun rotateLocalIdentity() = localIdentity()490        override suspend fun validateSource(prepare: AdoptionFrame.Prepare) {}491        override suspend fun hasSourceIdentityCollision(prepare: AdoptionFrame.Prepare) = false492493                override val deviceName = "receiver"494                override val deviceType = DeviceType.ANDROID495                override fun generateDeviceId() = "session-only-request"496                override suspend fun confirmSas() = error("session wrapper owns SAS")497                override suspend fun createVaultFromRootKey(rootKeyHex: String): String {498                    created = true499                    return computeVaultId(hexToPubkey(rootKeyHex))500                }501                override suspend fun deriveLocalIdentity() =502                    identity("receiver", sourceKeys.publicKey)503                override suspend fun upsertPairedDevice(device: PairedDevice) = Unit504                override fun onError(error: String) {505                    assertEquals("ADOPT_SAS_REJECTED", error)506                }507            },508            confirm = { false },509        )510511        assertNull(received)512        assertNull(sourceJob.await())513        assertFalse(rootRequested.get())514        assertFalse(created)515    }516517    @Test fun `malformed receiver ACK fails before any vault mutation`() = runBlocking {518        val (unusedSource, receiverSocket) = socketPair()519        unusedSource.send(520            JSONObject()521                .put("type", "PEACH_HELLO_ACK")522                .put("ephemeralPublicKeyHex", "ab")523                .toString()524                .toByteArray(),525        )526        var created = false527        val result = runCatching {528            runRelayAdoptionReceiverSession(529                receiverSocket,530                PairingInvitation(531                    "wss://relay.example.test",532                    "AAECAwQFBgcICQoLDA0ODw",533                ),534                object : AdoptionReceiverV2Deps {535        override fun localIdentity() = LocalDeviceIdentity(generateDeviceId(), deviceName, deviceType, "", "")536        override suspend fun rotateLocalIdentity() = localIdentity()537        override suspend fun validateSource(prepare: AdoptionFrame.Prepare) {}538        override suspend fun hasSourceIdentityCollision(prepare: AdoptionFrame.Prepare) = false539540                    override val deviceName = "receiver"541                    override val deviceType = DeviceType.ANDROID542                    override fun generateDeviceId() = "request"543                    override suspend fun confirmSas() = false544                    override suspend fun createVaultFromRootKey(rootKeyHex: String): String {545                        created = true546                        return ""547                    }548                    override suspend fun deriveLocalIdentity() =549                        error("must not derive identity")550                    override suspend fun upsertPairedDevice(device: PairedDevice) = Unit551                    override fun onError(error: String) = Unit552                },553                confirm = { true },554            )555        }556        assertTrue(result.isFailure)557        assertFalse(created)558    }559560    private fun encryptedChannel(561        socket: RelayBinarySocket,562        noise: NoiseNKSession,563    ): PairingTransport = object : PairingTransport {564        override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))565        override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())566        override suspend fun close() = socket.close()567    }568}