CloudRelayDialerTest.kt

9.1 KB · raw

1package com.peachpasswords.sync23import kotlinx.coroutines.Dispatchers4import kotlinx.coroutines.async5import kotlinx.coroutines.channels.Channel6import kotlinx.coroutines.runBlocking7import org.bouncycastle.crypto.params.X25519PrivateKeyParameters8import org.junit.Assert.assertEquals9import org.junit.Assert.assertFalse10import org.junit.Assert.assertNotEquals11import org.junit.Assert.assertTrue12import org.junit.Test13import java.security.SecureRandom1415/**16 * Phone-to-phone: the case that had no initiator at all. Both halves here are17 * this app's own code, so a passing test means two Peach phones can complete18 * the reconnect wire against each other — not that one of them can talk to a19 * stand-in for the extension.20 */21class CloudRelayDialerTest {22    private data class KeyPair(val publicKey: ByteArray, val privateKey: ByteArray)2324    private fun keys(): KeyPair {25        val privateKey = X25519PrivateKeyParameters(SecureRandom())26        return KeyPair(privateKey.generatePublicKey().encoded, privateKey.encoded)27    }2829    private fun socketPair(): Pair<RelayBinarySocket, RelayBinarySocket> {30        val leftInbox = Channel<ByteArray>(Channel.UNLIMITED)31        val rightInbox = Channel<ByteArray>(Channel.UNLIMITED)32        fun socket(inbox: Channel<ByteArray>, peer: Channel<ByteArray>) =33            object : RelayBinarySocket {34                override fun send(frame: ByteArray) {35                    check(peer.trySend(frame.copyOf()).isSuccess)36                }3738                override suspend fun receive(): ByteArray = inbox.receive()3940                override fun close(code: Int, reason: String) {41                    inbox.close()42                }43            }44        return socket(leftInbox, rightInbox) to socket(rightInbox, leftInbox)45    }4647    private fun identity(id: String, name: String, publicKeyHex: String) = LocalDeviceIdentity(48        deviceId = id,49        deviceName = name,50        deviceType = DeviceType.ANDROID,51        longTermPublicKeyHex = publicKeyHex,52        signingPublicKeyHex = "33".repeat(32),53    )5455    @Test56    fun `two phones complete the reconnect wire, one dialling and one waiting`() = runBlocking {57        val dialerKeys = keys()58        val waiterKeys = keys()59        val secretHex = "71".repeat(32)60        val secret = Rendezvous.secretFromHex(secretHex)61        val now = System.currentTimeMillis() / 1000L62        // Both sides derive the same room from the shared pair secret.63        val rendezvousId = Rendezvous.waitingIds(secret, now).first()64        assertTrue(rendezvousId in Rendezvous.dialingIds(secret, now))6566        val dialerIdentity = identity("dialer", "Fold", pubkeyToHex(dialerKeys.publicKey))67        val waiterIdentity = identity("waiter", "Tablet", pubkeyToHex(waiterKeys.publicKey))68        val dialerAsSeenByWaiter = PairedDevice(69            deviceId = "dialer",70            deviceName = "Fold",71            deviceType = "android",72            longTermPublicKeyHex = pubkeyToHex(dialerKeys.publicKey),73            rendezvousSecretHex = secretHex,74            pairedAt = 1L,75        )76        val waiterAsSeenByDialer = PairedDevice(77            deviceId = "waiter",78            deviceName = "Tablet",79            deviceType = "android",80            longTermPublicKeyHex = pubkeyToHex(waiterKeys.publicKey),81            rendezvousSecretHex = secretHex,82            pairedAt = 1L,83        )8485        val (dialerSocket, waiterSocket) = socketPair()86        var dialerSynced = false87        var waiterSynced = false8889        val waiter = async(Dispatchers.Default) {90            runCloudRelayResponderSession(91                socket = waiterSocket,92                known = dialerAsSeenByWaiter,93                registeredRendezvousId = rendezvousId,94                ourStaticPrivateKey = waiterKeys.privateKey,95                ourStaticPublicKey = waiterKeys.publicKey,96                localIdentity = waiterIdentity,97                vaultFactory = { null },98                onSyncComplete = { waiterSynced = true },99            )100        }101        val dialer = async(Dispatchers.Default) {102            runCloudRelayInitiatorSession(103                socket = dialerSocket,104                known = waiterAsSeenByDialer,105                rendezvousId = rendezvousId,106                ourStaticPrivateKey = dialerKeys.privateKey,107                ourStaticPublicKey = dialerKeys.publicKey,108                localIdentity = dialerIdentity,109                vaultFactory = { null },110                onSyncComplete = { dialerSynced = true },111            )112        }113114        waiter.await()115        dialer.await()116        // No vault adapter on either side, so the handshake completes and the117        // sync exchange is skipped — the wire is what is under test here.118        assertFalse(dialerSynced)119        assertFalse(waiterSynced)120    }121122    @Test123    fun `a peer presenting a different key than KK authenticated is refused`() = runBlocking {124        val dialerKeys = keys()125        val waiterKeys = keys()126        val impostorKeys = keys()127        val secretHex = "72".repeat(32)128        val rendezvousId = Rendezvous.waitingIds(129            Rendezvous.secretFromHex(secretHex),130            System.currentTimeMillis() / 1000L,131        ).first()132        val (dialerSocket, waiterSocket) = socketPair()133134        // The waiter answers with an identity frame naming a key that is not135        // the one KK proved. Nothing about the room can be trusted; only the136        // authenticated key can, so the dialler must refuse.137        val waiter = async(Dispatchers.Default) {138            val selector = String(waiterSocket.receive(), Charsets.UTF_8)139            assertTrue(selector.contains("PEACH_RECONNECT"))140            val session = noiseKKResponder(141                waiterKeys.privateKey,142                waiterKeys.publicKey,143                dialerKeys.publicKey,144                waiterSocket::send,145                waiterSocket::receive,146            )147            receiveSyncFrame(waiterSocket::receive, session::decrypt)148            sendSyncFrame(149                encodePairIdentityFrame(150                    identity("waiter", "Tablet", pubkeyToHex(impostorKeys.publicKey)),151                ),152                session::encrypt,153                waiterSocket::send,154            )155        }156157        val failure = runCatching {158            runCloudRelayInitiatorSession(159                socket = dialerSocket,160                known = PairedDevice(161                    deviceId = "waiter",162                    deviceType = "android",163                    longTermPublicKeyHex = pubkeyToHex(waiterKeys.publicKey),164                    rendezvousSecretHex = secretHex,165                    pairedAt = 1L,166                ),167                rendezvousId = rendezvousId,168                ourStaticPrivateKey = dialerKeys.privateKey,169                ourStaticPublicKey = dialerKeys.publicKey,170                localIdentity = identity("dialer", "Fold", pubkeyToHex(dialerKeys.publicKey)),171                vaultFactory = { null },172                onSyncComplete = {},173            )174        }.exceptionOrNull()175        waiter.await()176177        assertTrue(178            "expected identity mismatch, got $failure",179            failure?.message?.contains("does not match authenticated KK key") == true,180        )181    }182}183184class SyncPeerRoleTest {185    private fun peer(type: String?, keyHex: String?) = PairedDevice(186        deviceId = "peer",187        deviceType = type,188        longTermPublicKeyHex = keyHex,189        pairedAt = 1L,190    )191192    @Test193    fun `we never dial a browser extension, whichever key is lower`() {194        val low = "aa".repeat(32)195        val high = "bb".repeat(32)196        assertFalse(weDialPeer(low, peer("extension", high)))197        assertFalse(weDialPeer(high, peer("extension", low)))198    }199200    @Test201    fun `phone to phone assigns exactly one dialler`() {202        val ours = "aa".repeat(32)203        val theirs = "bb".repeat(32)204        // The same comparison run from each side must disagree — one dials,205        // one waits. Two waiters was the phone-to-phone deadlock; two diallers206        // would splice our own sockets together.207        assertTrue(weDialPeer(ours, peer("android", theirs)))208        assertFalse(weDialPeer(theirs, peer("android", ours)))209        assertNotEquals(210            weDialPeer(ours, peer("android", theirs)),211            weDialPeer(theirs, peer("android", ours)),212        )213    }214215    @Test216    fun `a tablet is a phone for this purpose, and an unknown type is too`() {217        val low = "11".repeat(32)218        val high = "ff".repeat(32)219        assertTrue(weDialPeer(low, peer("ios", high)))220        assertTrue(weDialPeer(low, peer(null, high)))221        assertFalse(weDialPeer(high, peer(null, low)))222    }223224    @Test225    fun `a peer with no static key is never dialled`() {226        assertFalse(weDialPeer("00".repeat(32), peer("android", null)))227    }228229    @Test230    fun `roles hold across many random pairs`() {231        val random = SecureRandom()232        repeat(200) {233            val a = ByteArray(32).also(random::nextBytes).let(::pubkeyToHex)234            val b = ByteArray(32).also(random::nextBytes).let(::pubkeyToHex)235            if (a == b) return@repeat236            assertNotEquals(weDialPeer(a, peer("android", b)), weDialPeer(b, peer("android", a)))237        }238    }239}