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 — rendezvous=$rendezvousId, peer=Tablet",122 ),123 )124 assertTrue(125 logs.contains(126 Log.INFO to "cloud-relay: dial ok — rendezvous=$rendezvousId",127 ),128 )129 130 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 190 191 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 260 261 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}