CloudRelayDialerTest.kt

11.4 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 — peerType=android, trust=unset",122            ),123        )124        assertTrue(125            logs.contains(126                Log.INFO to "cloud-relay: dial ok",127            ),128        )129        assertTrue(logs.none { (_, message) -> message.contains(rendezvousId) || message.contains("Tablet") })130        // No vault adapter on either side, so the handshake completes and the131        // sync exchange is skipped — the wire is what is under test here.132        assertFalse(dialerSynced)133        assertFalse(waiterSynced)134    }135136    @Test137    fun `dial failure logs the reason without relay capability or device name`() = runBlocking {138        val dialerKeys = keys()139        val rendezvousId = "ROOM-FAILED"140        val socket = object : RelayBinarySocket {141            override fun send(frame: ByteArray) = error("not reached")142            override suspend fun receive(): ByteArray = error("not reached")143            override fun close(code: Int, reason: String) = Unit144        }145        var failure: Throwable? = null146147        val logs = capturePeachSyncLogs {148            failure = runCatching {149                runCloudRelayInitiatorSession(150                    socket = socket,151                    known = PairedDevice(152                        deviceId = "waiter",153                        deviceName = "Tablet",154                        deviceType = "android",155                        pairedAt = 1L,156                    ),157                    rendezvousId = rendezvousId,158                    ourStaticPrivateKey = dialerKeys.privateKey,159                    ourStaticPublicKey = dialerKeys.publicKey,160                    localIdentity = identity("dialer", "Fold", pubkeyToHex(dialerKeys.publicKey)),161                    vaultFactory = { null },162                    onSyncComplete = {},163                )164            }.exceptionOrNull()165        }166167        assertEquals("known peer missing static key", failure?.message)168        assertEquals(169            listOf(170                Log.INFO to "cloud-relay: dial — peerType=android, trust=unset",171                Log.ERROR to "cloud-relay: dial failed — reason=known peer missing static key",172            ),173            logs,174        )175        assertTrue(logs.none { (_, message) -> message.contains(rendezvousId) || message.contains("Tablet") })176    }177178    @Test179    fun `a peer presenting a different key than KK authenticated is refused`() = runBlocking {180        val dialerKeys = keys()181        val waiterKeys = keys()182        val impostorKeys = keys()183        val secretHex = "72".repeat(32)184        val rendezvousId = Rendezvous.waitingIds(185            Rendezvous.secretFromHex(secretHex),186            System.currentTimeMillis() / 1000L,187        ).first()188        val (dialerSocket, waiterSocket) = socketPair()189190        // The waiter answers with an identity frame naming a key that is not191        // the one KK proved. Nothing about the room can be trusted; only the192        // authenticated key can, so the dialler must refuse.193        val waiter = async(Dispatchers.Default) {194            val selector = String(waiterSocket.receive(), Charsets.UTF_8)195            assertTrue(selector.contains("PEACH_RECONNECT"))196            val session = noiseKKResponder(197                waiterKeys.privateKey,198                waiterKeys.publicKey,199                dialerKeys.publicKey,200                waiterSocket::send,201                waiterSocket::receive,202            )203            receiveSyncFrame(waiterSocket::receive, session::decrypt)204            sendSyncFrame(205                encodePairIdentityFrame(206                    identity("waiter", "Tablet", pubkeyToHex(impostorKeys.publicKey)),207                ),208                session::encrypt,209                waiterSocket::send,210            )211        }212213        val failure = runCatching {214            runCloudRelayInitiatorSession(215                socket = dialerSocket,216                known = PairedDevice(217                    deviceId = "waiter",218                    deviceType = "android",219                    longTermPublicKeyHex = pubkeyToHex(waiterKeys.publicKey),220                    rendezvousSecretHex = secretHex,221                    pairedAt = 1L,222                ),223                rendezvousId = rendezvousId,224                ourStaticPrivateKey = dialerKeys.privateKey,225                ourStaticPublicKey = dialerKeys.publicKey,226                localIdentity = identity("dialer", "Fold", pubkeyToHex(dialerKeys.publicKey)),227                vaultFactory = { null },228                onSyncComplete = {},229            )230        }.exceptionOrNull()231        waiter.await()232233        assertTrue(234            "expected identity mismatch, got $failure",235            failure?.message?.contains("does not match authenticated KK key") == true,236        )237    }238}239240class SyncPeerRoleTest {241    private fun peer(type: String?, keyHex: String?) = PairedDevice(242        deviceId = "peer",243        deviceType = type,244        longTermPublicKeyHex = keyHex,245        pairedAt = 1L,246    )247248    @Test249    fun `extension peers use the same deterministic key ordering`() {250        val low = "aa".repeat(32)251        val high = "bb".repeat(32)252        assertTrue(weDialPeer(low, peer("extension", high)))253        assertFalse(weDialPeer(high, peer("extension", low)))254    }255256    @Test257    fun `phone to phone assigns exactly one dialler`() {258        val ours = "aa".repeat(32)259        val theirs = "bb".repeat(32)260        // The same comparison run from each side must disagree — one dials,261        // one waits. Two waiters was the phone-to-phone deadlock; two diallers262        // would splice our own sockets together.263        assertTrue(weDialPeer(ours, peer("android", theirs)))264        assertFalse(weDialPeer(theirs, peer("android", ours)))265        assertNotEquals(266            weDialPeer(ours, peer("android", theirs)),267            weDialPeer(theirs, peer("android", ours)),268        )269    }270271    @Test272    fun `a tablet is a phone for this purpose, and an unknown type is too`() {273        val low = "11".repeat(32)274        val high = "ff".repeat(32)275        assertTrue(weDialPeer(low, peer("ios", high)))276        assertTrue(weDialPeer(low, peer(null, high)))277        assertFalse(weDialPeer(high, peer(null, low)))278    }279280    @Test281    fun `a peer with no static key is never dialled`() {282        assertFalse(weDialPeer("00".repeat(32), peer("android", null)))283    }284285    @Test286    fun `roles hold across many random pairs`() {287        val random = SecureRandom()288        repeat(200) {289            val a = ByteArray(32).also(random::nextBytes).let(::pubkeyToHex)290            val b = ByteArray(32).also(random::nextBytes).let(::pubkeyToHex)291            if (a == b) return@repeat292            assertNotEquals(weDialPeer(a, peer("android", b)), weDialPeer(b, peer("android", a)))293        }294    }295}