CloudRelayDialerTest.kt

9.2 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            SyncPadding.unpad(session.decrypt(waiterSocket.receive()))148            waiterSocket.send(149                session.encrypt(150                    SyncPadding.pad(151                        encodePairIdentityFrame(152                            identity("waiter", "Tablet", pubkeyToHex(impostorKeys.publicKey)),153                        ),154                    ),155                ),156            )157        }158159        val failure = runCatching {160            runCloudRelayInitiatorSession(161                socket = dialerSocket,162                known = PairedDevice(163                    deviceId = "waiter",164                    deviceType = "android",165                    longTermPublicKeyHex = pubkeyToHex(waiterKeys.publicKey),166                    rendezvousSecretHex = secretHex,167                    pairedAt = 1L,168                ),169                rendezvousId = rendezvousId,170                ourStaticPrivateKey = dialerKeys.privateKey,171                ourStaticPublicKey = dialerKeys.publicKey,172                localIdentity = identity("dialer", "Fold", pubkeyToHex(dialerKeys.publicKey)),173                vaultFactory = { null },174                onSyncComplete = {},175            )176        }.exceptionOrNull()177        waiter.await()178179        assertTrue(180            "expected identity mismatch, got $failure",181            failure?.message?.contains("does not match authenticated KK key") == true,182        )183    }184}185186class SyncPeerRoleTest {187    private fun peer(type: String?, keyHex: String?) = PairedDevice(188        deviceId = "peer",189        deviceType = type,190        longTermPublicKeyHex = keyHex,191        pairedAt = 1L,192    )193194    @Test195    fun `we never dial a browser extension, whichever key is lower`() {196        val low = "aa".repeat(32)197        val high = "bb".repeat(32)198        assertFalse(weDialPeer(low, peer("extension", high)))199        assertFalse(weDialPeer(high, peer("extension", low)))200    }201202    @Test203    fun `phone to phone assigns exactly one dialler`() {204        val ours = "aa".repeat(32)205        val theirs = "bb".repeat(32)206        // The same comparison run from each side must disagree — one dials,207        // one waits. Two waiters was the phone-to-phone deadlock; two diallers208        // would splice our own sockets together.209        assertTrue(weDialPeer(ours, peer("android", theirs)))210        assertFalse(weDialPeer(theirs, peer("android", ours)))211        assertNotEquals(212            weDialPeer(ours, peer("android", theirs)),213            weDialPeer(theirs, peer("android", ours)),214        )215    }216217    @Test218    fun `a tablet is a phone for this purpose, and an unknown type is too`() {219        val low = "11".repeat(32)220        val high = "ff".repeat(32)221        assertTrue(weDialPeer(low, peer("ios", high)))222        assertTrue(weDialPeer(low, peer(null, high)))223        assertFalse(weDialPeer(high, peer(null, low)))224    }225226    @Test227    fun `a peer with no static key is never dialled`() {228        assertFalse(weDialPeer("00".repeat(32), peer("android", null)))229    }230231    @Test232    fun `roles hold across many random pairs`() {233        val random = SecureRandom()234        repeat(200) {235            val a = ByteArray(32).also(random::nextBytes).let(::pubkeyToHex)236            val b = ByteArray(32).also(random::nextBytes).let(::pubkeyToHex)237            if (a == b) return@repeat238            assertNotEquals(weDialPeer(a, peer("android", b)), weDialPeer(b, peer("android", a)))239        }240    }241}