CloudRelayDialerTest.kt

11.3 KB · raw

1package com.peachpasswords.sync23import android.util.Log4import kotlinx.coroutines.Dispatchers5import kotlinx.coroutines.async6import kotlinx.coroutines.channels.Channel7import kotlinx.coroutines.runBlocking8import org.bouncycastle.crypto.params.X25519PrivateKeyParameters9import org.junit.Assert.assertEquals10import org.junit.Assert.assertFalse11import org.junit.Assert.assertNotEquals12import org.junit.Assert.assertTrue13import org.junit.Test14import java.security.SecureRandom1516/**17 * Phone-to-phone: the case that had no initiator at all. Both halves here are18 * this app's own code, so a passing test means two Peach phones can complete19 * the reconnect wire against each other — not that one of them can talk to a20 * stand-in for the extension.21 */22class CloudRelayDialerTest {23    private data class KeyPair(val publicKey: ByteArray, val privateKey: ByteArray)2425    private fun keys(): KeyPair {26        val privateKey = X25519PrivateKeyParameters(SecureRandom())27        return KeyPair(privateKey.generatePublicKey().encoded, privateKey.encoded)28    }2930    private fun socketPair(): Pair<RelayBinarySocket, RelayBinarySocket> {31        val leftInbox = Channel<ByteArray>(Channel.UNLIMITED)32        val rightInbox = Channel<ByteArray>(Channel.UNLIMITED)33        fun socket(inbox: Channel<ByteArray>, peer: Channel<ByteArray>) =34            object : RelayBinarySocket {35                override fun send(frame: ByteArray) {36                    check(peer.trySend(frame.copyOf()).isSuccess)37                }3839                override suspend fun receive(): ByteArray = inbox.receive()4041                override fun close(code: Int, reason: String) {42                    inbox.close()43                }44            }45        return socket(leftInbox, rightInbox) to socket(rightInbox, leftInbox)46    }4748    private fun identity(id: String, name: String, publicKeyHex: String) = LocalDeviceIdentity(49        deviceId = id,50        deviceName = name,51        deviceType = DeviceType.ANDROID,52        longTermPublicKeyHex = publicKeyHex,53        signingPublicKeyHex = "33".repeat(32),54    )5556    @Test57    fun `two phones complete the reconnect wire, one dialling and one waiting`() = runBlocking {58        val dialerKeys = keys()59        val waiterKeys = keys()60        val secretHex = "71".repeat(32)61        val secret = Rendezvous.secretFromHex(secretHex)62        val now = System.currentTimeMillis() / 1000L63        // Both sides derive the same room from the shared pair secret.64        val rendezvousId = Rendezvous.waitingIds(secret, now).first()65        assertTrue(rendezvousId in Rendezvous.dialingIds(secret, now))6667        val dialerIdentity = identity("dialer", "Fold", pubkeyToHex(dialerKeys.publicKey))68        val waiterIdentity = identity("waiter", "Tablet", pubkeyToHex(waiterKeys.publicKey))69        val dialerAsSeenByWaiter = PairedDevice(70            deviceId = "dialer",71            deviceName = "Fold",72            deviceType = "android",73            longTermPublicKeyHex = pubkeyToHex(dialerKeys.publicKey),74            rendezvousSecretHex = secretHex,75            pairedAt = 1L,76        )77        val waiterAsSeenByDialer = PairedDevice(78            deviceId = "waiter",79            deviceName = "Tablet",80            deviceType = "android",81            longTermPublicKeyHex = pubkeyToHex(waiterKeys.publicKey),82            rendezvousSecretHex = secretHex,83            pairedAt = 1L,84        )8586        val (dialerSocket, waiterSocket) = socketPair()87        var dialerSynced = false88        var waiterSynced = false8990        val logs = capturePeachSyncLogs {91            val waiter = async(Dispatchers.Default) {92                runCloudRelayResponderSession(93                    socket = waiterSocket,94                    known = dialerAsSeenByWaiter,95                    registeredRendezvousId = rendezvousId,96                    ourStaticPrivateKey = waiterKeys.privateKey,97                    ourStaticPublicKey = waiterKeys.publicKey,98                    localIdentity = waiterIdentity,99                    vaultFactory = { null },100                    onSyncComplete = { waiterSynced = true },101                )102            }103            val dialer = async(Dispatchers.Default) {104                runCloudRelayInitiatorSession(105                    socket = dialerSocket,106                    known = waiterAsSeenByDialer,107                    rendezvousId = rendezvousId,108                    ourStaticPrivateKey = dialerKeys.privateKey,109                    ourStaticPublicKey = dialerKeys.publicKey,110                    localIdentity = dialerIdentity,111                    vaultFactory = { null },112                    onSyncComplete = { dialerSynced = true },113                )114            }115116            waiter.await()117            dialer.await()118        }119        assertTrue(120            logs.contains(121                Log.INFO to "cloud-relay: dial — rendezvous=$rendezvousId, peer=Tablet",122            ),123        )124        assertTrue(125            logs.contains(126                Log.INFO to "cloud-relay: dial ok — rendezvous=$rendezvousId",127            ),128        )129        // No vault adapter on either side, so the handshake completes and the130        // sync exchange is skipped — the wire is what is under test here.131        assertFalse(dialerSynced)132        assertFalse(waiterSynced)133    }134135    @Test136    fun `dial failure logs the rendezvous and underlying reason`() = runBlocking {137        val dialerKeys = keys()138        val rendezvousId = "ROOM-FAILED"139        val socket = object : RelayBinarySocket {140            override fun send(frame: ByteArray) = error("not reached")141            override suspend fun receive(): ByteArray = error("not reached")142            override fun close(code: Int, reason: String) = Unit143        }144        var failure: Throwable? = null145146        val logs = capturePeachSyncLogs {147            failure = runCatching {148                runCloudRelayInitiatorSession(149                    socket = socket,150                    known = PairedDevice(151                        deviceId = "waiter",152                        deviceName = "Tablet",153                        deviceType = "android",154                        pairedAt = 1L,155                    ),156                    rendezvousId = rendezvousId,157                    ourStaticPrivateKey = dialerKeys.privateKey,158                    ourStaticPublicKey = dialerKeys.publicKey,159                    localIdentity = identity("dialer", "Fold", pubkeyToHex(dialerKeys.publicKey)),160                    vaultFactory = { null },161                    onSyncComplete = {},162                )163            }.exceptionOrNull()164        }165166        assertEquals("known peer missing static key", failure?.message)167        assertEquals(168            listOf(169                Log.INFO to "cloud-relay: dial — rendezvous=$rendezvousId, peer=Tablet",170                Log.ERROR to "cloud-relay: dial failed — rendezvous=$rendezvousId, " +171                    "reason=known peer missing static key",172            ),173            logs,174        )175    }176177    @Test178    fun `a peer presenting a different key than KK authenticated is refused`() = runBlocking {179        val dialerKeys = keys()180        val waiterKeys = keys()181        val impostorKeys = keys()182        val secretHex = "72".repeat(32)183        val rendezvousId = Rendezvous.waitingIds(184            Rendezvous.secretFromHex(secretHex),185            System.currentTimeMillis() / 1000L,186        ).first()187        val (dialerSocket, waiterSocket) = socketPair()188189        // The waiter answers with an identity frame naming a key that is not190        // the one KK proved. Nothing about the room can be trusted; only the191        // authenticated key can, so the dialler must refuse.192        val waiter = async(Dispatchers.Default) {193            val selector = String(waiterSocket.receive(), Charsets.UTF_8)194            assertTrue(selector.contains("PEACH_RECONNECT"))195            val session = noiseKKResponder(196                waiterKeys.privateKey,197                waiterKeys.publicKey,198                dialerKeys.publicKey,199                waiterSocket::send,200                waiterSocket::receive,201            )202            receiveSyncFrame(waiterSocket::receive, session::decrypt)203            sendSyncFrame(204                encodePairIdentityFrame(205                    identity("waiter", "Tablet", pubkeyToHex(impostorKeys.publicKey)),206                ),207                session::encrypt,208                waiterSocket::send,209            )210        }211212        val failure = runCatching {213            runCloudRelayInitiatorSession(214                socket = dialerSocket,215                known = PairedDevice(216                    deviceId = "waiter",217                    deviceType = "android",218                    longTermPublicKeyHex = pubkeyToHex(waiterKeys.publicKey),219                    rendezvousSecretHex = secretHex,220                    pairedAt = 1L,221                ),222                rendezvousId = rendezvousId,223                ourStaticPrivateKey = dialerKeys.privateKey,224                ourStaticPublicKey = dialerKeys.publicKey,225                localIdentity = identity("dialer", "Fold", pubkeyToHex(dialerKeys.publicKey)),226                vaultFactory = { null },227                onSyncComplete = {},228            )229        }.exceptionOrNull()230        waiter.await()231232        assertTrue(233            "expected identity mismatch, got $failure",234            failure?.message?.contains("does not match authenticated KK key") == true,235        )236    }237}238239class SyncPeerRoleTest {240    private fun peer(type: String?, keyHex: String?) = PairedDevice(241        deviceId = "peer",242        deviceType = type,243        longTermPublicKeyHex = keyHex,244        pairedAt = 1L,245    )246247    @Test248    fun `we never dial a browser extension, whichever key is lower`() {249        val low = "aa".repeat(32)250        val high = "bb".repeat(32)251        assertFalse(weDialPeer(low, peer("extension", high)))252        assertFalse(weDialPeer(high, peer("extension", low)))253    }254255    @Test256    fun `phone to phone assigns exactly one dialler`() {257        val ours = "aa".repeat(32)258        val theirs = "bb".repeat(32)259        // The same comparison run from each side must disagree — one dials,260        // one waits. Two waiters was the phone-to-phone deadlock; two diallers261        // would splice our own sockets together.262        assertTrue(weDialPeer(ours, peer("android", theirs)))263        assertFalse(weDialPeer(theirs, peer("android", ours)))264        assertNotEquals(265            weDialPeer(ours, peer("android", theirs)),266            weDialPeer(theirs, peer("android", ours)),267        )268    }269270    @Test271    fun `a tablet is a phone for this purpose, and an unknown type is too`() {272        val low = "11".repeat(32)273        val high = "ff".repeat(32)274        assertTrue(weDialPeer(low, peer("ios", high)))275        assertTrue(weDialPeer(low, peer(null, high)))276        assertFalse(weDialPeer(high, peer(null, low)))277    }278279    @Test280    fun `a peer with no static key is never dialled`() {281        assertFalse(weDialPeer("00".repeat(32), peer("android", null)))282    }283284    @Test285    fun `roles hold across many random pairs`() {286        val random = SecureRandom()287        repeat(200) {288            val a = ByteArray(32).also(random::nextBytes).let(::pubkeyToHex)289            val b = ByteArray(32).also(random::nextBytes).let(::pubkeyToHex)290            if (a == b) return@repeat291            assertNotEquals(weDialPeer(a, peer("android", b)), weDialPeer(b, peer("android", a)))292        }293    }294}