1package com.peachpasswords.sync23import kotlinx.coroutines.Dispatchers4import kotlinx.coroutines.async5import kotlinx.coroutines.channels.Channel6import kotlinx.coroutines.runBlocking7import org.bouncycastle.crypto.params.X25519PrivateKeyParameters8import org.junit.Assert.assertEquals9import org.junit.Assert.assertFalse10import org.junit.Assert.assertNotEquals11import org.junit.Assert.assertTrue12import org.junit.Test13import java.security.SecureRandom1415161718192021class CloudRelayDialerTest {22 private data class KeyPair(val publicKey: ByteArray, val privateKey: ByteArray)2324 private fun keys(): KeyPair {25 val privateKey = X25519PrivateKeyParameters(SecureRandom())26 return KeyPair(privateKey.generatePublicKey().encoded, privateKey.encoded)27 }2829 private fun socketPair(): Pair<RelayBinarySocket, RelayBinarySocket> {30 val leftInbox = Channel<ByteArray>(Channel.UNLIMITED)31 val rightInbox = Channel<ByteArray>(Channel.UNLIMITED)32 fun socket(inbox: Channel<ByteArray>, peer: Channel<ByteArray>) =33 object : RelayBinarySocket {34 override fun send(frame: ByteArray) {35 check(peer.trySend(frame.copyOf()).isSuccess)36 }3738 override suspend fun receive(): ByteArray = inbox.receive()3940 override fun close(code: Int, reason: String) {41 inbox.close()42 }43 }44 return socket(leftInbox, rightInbox) to socket(rightInbox, leftInbox)45 }4647 private fun identity(id: String, name: String, publicKeyHex: String) = LocalDeviceIdentity(48 deviceId = id,49 deviceName = name,50 deviceType = DeviceType.ANDROID,51 longTermPublicKeyHex = publicKeyHex,52 signingPublicKeyHex = "33".repeat(32),53 )5455 @Test56 fun `two phones complete the reconnect wire, one dialling and one waiting`() = runBlocking {57 val dialerKeys = keys()58 val waiterKeys = keys()59 val secretHex = "71".repeat(32)60 val secret = Rendezvous.secretFromHex(secretHex)61 val now = System.currentTimeMillis() / 1000L62 63 val rendezvousId = Rendezvous.waitingIds(secret, now).first()64 assertTrue(rendezvousId in Rendezvous.dialingIds(secret, now))6566 val dialerIdentity = identity("dialer", "Fold", pubkeyToHex(dialerKeys.publicKey))67 val waiterIdentity = identity("waiter", "Tablet", pubkeyToHex(waiterKeys.publicKey))68 val dialerAsSeenByWaiter = PairedDevice(69 deviceId = "dialer",70 deviceName = "Fold",71 deviceType = "android",72 longTermPublicKeyHex = pubkeyToHex(dialerKeys.publicKey),73 rendezvousSecretHex = secretHex,74 pairedAt = 1L,75 )76 val waiterAsSeenByDialer = PairedDevice(77 deviceId = "waiter",78 deviceName = "Tablet",79 deviceType = "android",80 longTermPublicKeyHex = pubkeyToHex(waiterKeys.publicKey),81 rendezvousSecretHex = secretHex,82 pairedAt = 1L,83 )8485 val (dialerSocket, waiterSocket) = socketPair()86 var dialerSynced = false87 var waiterSynced = false8889 val waiter = async(Dispatchers.Default) {90 runCloudRelayResponderSession(91 socket = waiterSocket,92 known = dialerAsSeenByWaiter,93 registeredRendezvousId = rendezvousId,94 ourStaticPrivateKey = waiterKeys.privateKey,95 ourStaticPublicKey = waiterKeys.publicKey,96 localIdentity = waiterIdentity,97 vaultFactory = { null },98 onSyncComplete = { waiterSynced = true },99 )100 }101 val dialer = async(Dispatchers.Default) {102 runCloudRelayInitiatorSession(103 socket = dialerSocket,104 known = waiterAsSeenByDialer,105 rendezvousId = rendezvousId,106 ourStaticPrivateKey = dialerKeys.privateKey,107 ourStaticPublicKey = dialerKeys.publicKey,108 localIdentity = dialerIdentity,109 vaultFactory = { null },110 onSyncComplete = { dialerSynced = true },111 )112 }113114 waiter.await()115 dialer.await()116 117 118 assertFalse(dialerSynced)119 assertFalse(waiterSynced)120 }121122 @Test123 fun `a peer presenting a different key than KK authenticated is refused`() = runBlocking {124 val dialerKeys = keys()125 val waiterKeys = keys()126 val impostorKeys = keys()127 val secretHex = "72".repeat(32)128 val rendezvousId = Rendezvous.waitingIds(129 Rendezvous.secretFromHex(secretHex),130 System.currentTimeMillis() / 1000L,131 ).first()132 val (dialerSocket, waiterSocket) = socketPair()133134 135 136 137 val waiter = async(Dispatchers.Default) {138 val selector = String(waiterSocket.receive(), Charsets.UTF_8)139 assertTrue(selector.contains("PEACH_RECONNECT"))140 val session = noiseKKResponder(141 waiterKeys.privateKey,142 waiterKeys.publicKey,143 dialerKeys.publicKey,144 waiterSocket::send,145 waiterSocket::receive,146 )147 SyncPadding.unpad(session.decrypt(waiterSocket.receive()))148 waiterSocket.send(149 session.encrypt(150 SyncPadding.pad(151 encodePairIdentityFrame(152 identity("waiter", "Tablet", pubkeyToHex(impostorKeys.publicKey)),153 ),154 ),155 ),156 )157 }158159 val failure = runCatching {160 runCloudRelayInitiatorSession(161 socket = dialerSocket,162 known = PairedDevice(163 deviceId = "waiter",164 deviceType = "android",165 longTermPublicKeyHex = pubkeyToHex(waiterKeys.publicKey),166 rendezvousSecretHex = secretHex,167 pairedAt = 1L,168 ),169 rendezvousId = rendezvousId,170 ourStaticPrivateKey = dialerKeys.privateKey,171 ourStaticPublicKey = dialerKeys.publicKey,172 localIdentity = identity("dialer", "Fold", pubkeyToHex(dialerKeys.publicKey)),173 vaultFactory = { null },174 onSyncComplete = {},175 )176 }.exceptionOrNull()177 waiter.await()178179 assertTrue(180 "expected identity mismatch, got $failure",181 failure?.message?.contains("does not match authenticated KK key") == true,182 )183 }184}185186class SyncPeerRoleTest {187 private fun peer(type: String?, keyHex: String?) = PairedDevice(188 deviceId = "peer",189 deviceType = type,190 longTermPublicKeyHex = keyHex,191 pairedAt = 1L,192 )193194 @Test195 fun `we never dial a browser extension, whichever key is lower`() {196 val low = "aa".repeat(32)197 val high = "bb".repeat(32)198 assertFalse(weDialPeer(low, peer("extension", high)))199 assertFalse(weDialPeer(high, peer("extension", low)))200 }201202 @Test203 fun `phone to phone assigns exactly one dialler`() {204 val ours = "aa".repeat(32)205 val theirs = "bb".repeat(32)206 207 208 209 assertTrue(weDialPeer(ours, peer("android", theirs)))210 assertFalse(weDialPeer(theirs, peer("android", ours)))211 assertNotEquals(212 weDialPeer(ours, peer("android", theirs)),213 weDialPeer(theirs, peer("android", ours)),214 )215 }216217 @Test218 fun `a tablet is a phone for this purpose, and an unknown type is too`() {219 val low = "11".repeat(32)220 val high = "ff".repeat(32)221 assertTrue(weDialPeer(low, peer("ios", high)))222 assertTrue(weDialPeer(low, peer(null, high)))223 assertFalse(weDialPeer(high, peer(null, low)))224 }225226 @Test227 fun `a peer with no static key is never dialled`() {228 assertFalse(weDialPeer("00".repeat(32), peer("android", null)))229 }230231 @Test232 fun `roles hold across many random pairs`() {233 val random = SecureRandom()234 repeat(200) {235 val a = ByteArray(32).also(random::nextBytes).let(::pubkeyToHex)236 val b = ByteArray(32).also(random::nextBytes).let(::pubkeyToHex)237 if (a == b) return@repeat238 assertNotEquals(weDialPeer(a, peer("android", b)), weDialPeer(b, peer("android", a)))239 }240 }241}