package com.peachpasswords.sync import android.util.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.runBlocking import org.bouncycastle.crypto.params.X25519PrivateKeyParameters import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals import org.junit.Assert.assertTrue import org.junit.Test import java.security.SecureRandom /** * Phone-to-phone: the case that had no initiator at all. Both halves here are * this app's own code, so a passing test means two Peach phones can complete * the reconnect wire against each other — not that one of them can talk to a * stand-in for the extension. */ class CloudRelayDialerTest { private data class KeyPair(val publicKey: ByteArray, val privateKey: ByteArray) private fun keys(): KeyPair { val privateKey = X25519PrivateKeyParameters(SecureRandom()) return KeyPair(privateKey.generatePublicKey().encoded, privateKey.encoded) } private fun socketPair(): Pair { val leftInbox = Channel(Channel.UNLIMITED) val rightInbox = Channel(Channel.UNLIMITED) fun socket(inbox: Channel, peer: Channel) = object : RelayBinarySocket { override fun send(frame: ByteArray) { check(peer.trySend(frame.copyOf()).isSuccess) } override suspend fun receive(): ByteArray = inbox.receive() override fun close(code: Int, reason: String) { inbox.close() } } return socket(leftInbox, rightInbox) to socket(rightInbox, leftInbox) } private fun identity(id: String, name: String, publicKeyHex: String) = LocalDeviceIdentity( deviceId = id, deviceName = name, deviceType = DeviceType.ANDROID, longTermPublicKeyHex = publicKeyHex, signingPublicKeyHex = "33".repeat(32), ) @Test fun `two phones complete the reconnect wire, one dialling and one waiting`() = runBlocking { val dialerKeys = keys() val waiterKeys = keys() val secretHex = "71".repeat(32) val secret = Rendezvous.secretFromHex(secretHex) val now = System.currentTimeMillis() / 1000L // Both sides derive the same room from the shared pair secret. val rendezvousId = Rendezvous.waitingIds(secret, now).first() assertTrue(rendezvousId in Rendezvous.dialingIds(secret, now)) val dialerIdentity = identity("dialer", "Fold", pubkeyToHex(dialerKeys.publicKey)) val waiterIdentity = identity("waiter", "Tablet", pubkeyToHex(waiterKeys.publicKey)) val dialerAsSeenByWaiter = PairedDevice( deviceId = "dialer", deviceName = "Fold", deviceType = "android", longTermPublicKeyHex = pubkeyToHex(dialerKeys.publicKey), rendezvousSecretHex = secretHex, pairedAt = 1L, ) val waiterAsSeenByDialer = PairedDevice( deviceId = "waiter", deviceName = "Tablet", deviceType = "android", longTermPublicKeyHex = pubkeyToHex(waiterKeys.publicKey), rendezvousSecretHex = secretHex, pairedAt = 1L, ) val (dialerSocket, waiterSocket) = socketPair() var dialerSynced = false var waiterSynced = false val logs = capturePeachSyncLogs { val waiter = async(Dispatchers.Default) { runCloudRelayResponderSession( socket = waiterSocket, known = dialerAsSeenByWaiter, registeredRendezvousId = rendezvousId, ourStaticPrivateKey = waiterKeys.privateKey, ourStaticPublicKey = waiterKeys.publicKey, localIdentity = waiterIdentity, vaultFactory = { null }, onSyncComplete = { waiterSynced = true }, ) } val dialer = async(Dispatchers.Default) { runCloudRelayInitiatorSession( socket = dialerSocket, known = waiterAsSeenByDialer, rendezvousId = rendezvousId, ourStaticPrivateKey = dialerKeys.privateKey, ourStaticPublicKey = dialerKeys.publicKey, localIdentity = dialerIdentity, vaultFactory = { null }, onSyncComplete = { dialerSynced = true }, ) } waiter.await() dialer.await() } assertTrue( logs.contains( Log.INFO to "cloud-relay: dial — peerType=android, trust=unset", ), ) assertTrue( logs.contains( Log.INFO to "cloud-relay: dial ok", ), ) assertTrue(logs.none { (_, message) -> message.contains(rendezvousId) || message.contains("Tablet") }) // No vault adapter on either side, so the handshake completes and the // sync exchange is skipped — the wire is what is under test here. assertFalse(dialerSynced) assertFalse(waiterSynced) } @Test fun `dial failure logs the reason without relay capability or device name`() = runBlocking { val dialerKeys = keys() val rendezvousId = "ROOM-FAILED" val socket = object : RelayBinarySocket { override fun send(frame: ByteArray) = error("not reached") override suspend fun receive(): ByteArray = error("not reached") override fun close(code: Int, reason: String) = Unit } var failure: Throwable? = null val logs = capturePeachSyncLogs { failure = runCatching { runCloudRelayInitiatorSession( socket = socket, known = PairedDevice( deviceId = "waiter", deviceName = "Tablet", deviceType = "android", pairedAt = 1L, ), rendezvousId = rendezvousId, ourStaticPrivateKey = dialerKeys.privateKey, ourStaticPublicKey = dialerKeys.publicKey, localIdentity = identity("dialer", "Fold", pubkeyToHex(dialerKeys.publicKey)), vaultFactory = { null }, onSyncComplete = {}, ) }.exceptionOrNull() } assertEquals("known peer missing static key", failure?.message) assertEquals( listOf( Log.INFO to "cloud-relay: dial — peerType=android, trust=unset", Log.ERROR to "cloud-relay: dial failed — reason=known peer missing static key", ), logs, ) assertTrue(logs.none { (_, message) -> message.contains(rendezvousId) || message.contains("Tablet") }) } @Test fun `a peer presenting a different key than KK authenticated is refused`() = runBlocking { val dialerKeys = keys() val waiterKeys = keys() val impostorKeys = keys() val secretHex = "72".repeat(32) val rendezvousId = Rendezvous.waitingIds( Rendezvous.secretFromHex(secretHex), System.currentTimeMillis() / 1000L, ).first() val (dialerSocket, waiterSocket) = socketPair() // The waiter answers with an identity frame naming a key that is not // the one KK proved. Nothing about the room can be trusted; only the // authenticated key can, so the dialler must refuse. val waiter = async(Dispatchers.Default) { val selector = String(waiterSocket.receive(), Charsets.UTF_8) assertTrue(selector.contains("PEACH_RECONNECT")) val session = noiseKKResponder( waiterKeys.privateKey, waiterKeys.publicKey, dialerKeys.publicKey, waiterSocket::send, waiterSocket::receive, ) receiveSyncFrame(waiterSocket::receive, session::decrypt) sendSyncFrame( encodePairIdentityFrame( identity("waiter", "Tablet", pubkeyToHex(impostorKeys.publicKey)), ), session::encrypt, waiterSocket::send, ) } val failure = runCatching { runCloudRelayInitiatorSession( socket = dialerSocket, known = PairedDevice( deviceId = "waiter", deviceType = "android", longTermPublicKeyHex = pubkeyToHex(waiterKeys.publicKey), rendezvousSecretHex = secretHex, pairedAt = 1L, ), rendezvousId = rendezvousId, ourStaticPrivateKey = dialerKeys.privateKey, ourStaticPublicKey = dialerKeys.publicKey, localIdentity = identity("dialer", "Fold", pubkeyToHex(dialerKeys.publicKey)), vaultFactory = { null }, onSyncComplete = {}, ) }.exceptionOrNull() waiter.await() assertTrue( "expected identity mismatch, got $failure", failure?.message?.contains("does not match authenticated KK key") == true, ) } } class SyncPeerRoleTest { private fun peer(type: String?, keyHex: String?) = PairedDevice( deviceId = "peer", deviceType = type, longTermPublicKeyHex = keyHex, pairedAt = 1L, ) @Test fun `extension peers use the same deterministic key ordering`() { val low = "aa".repeat(32) val high = "bb".repeat(32) assertTrue(weDialPeer(low, peer("extension", high))) assertFalse(weDialPeer(high, peer("extension", low))) } @Test fun `phone to phone assigns exactly one dialler`() { val ours = "aa".repeat(32) val theirs = "bb".repeat(32) // The same comparison run from each side must disagree — one dials, // one waits. Two waiters was the phone-to-phone deadlock; two diallers // would splice our own sockets together. assertTrue(weDialPeer(ours, peer("android", theirs))) assertFalse(weDialPeer(theirs, peer("android", ours))) assertNotEquals( weDialPeer(ours, peer("android", theirs)), weDialPeer(theirs, peer("android", ours)), ) } @Test fun `a tablet is a phone for this purpose, and an unknown type is too`() { val low = "11".repeat(32) val high = "ff".repeat(32) assertTrue(weDialPeer(low, peer("ios", high))) assertTrue(weDialPeer(low, peer(null, high))) assertFalse(weDialPeer(high, peer(null, low))) } @Test fun `a peer with no static key is never dialled`() { assertFalse(weDialPeer("00".repeat(32), peer("android", null))) } @Test fun `roles hold across many random pairs`() { val random = SecureRandom() repeat(200) { val a = ByteArray(32).also(random::nextBytes).let(::pubkeyToHex) val b = ByteArray(32).also(random::nextBytes).let(::pubkeyToHex) if (a == b) return@repeat assertNotEquals(weDialPeer(a, peer("android", b)), weDialPeer(b, peer("android", a))) } } }