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.SecureRandom1516171819202122class 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 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 131 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 191 192 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 261 262 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}