4f2161320ccf

give Android the relay initiator it never had, and assign the role per peer so two phones cannot both wait: an extension is always dialled by, and between phones the lower long-term key opens the room (v2.5.2)

time
2026-07-26 17:13 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
phone-to-phone relay dialler
parent
a7ef4b655fa4
9 files VERSION+1 −1app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/CloudRelayDialer.kt+74 −0app/src/main/java/com/peachpasswords/sync/CloudRelayReconnectListener.kt+72 −20app/src/main/java/com/peachpasswords/sync/ForegroundSyncCoordinator.kt+6 −5app/src/main/java/com/peachpasswords/sync/PeachSyncService.kt+5 −0app/src/main/java/com/peachpasswords/sync/SyncPeerRole.kt+30 −0app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+11 −17app/src/test/java/com/peachpasswords/sync/CloudRelayDialerTest.kt+241 −0
VERSION+1 −1
@@ -1 +1 @@1-2.5.11+2.5.2

view file

app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 24010120-        versionName "2.5.1"19+        versionCode 24010220+        versionName "2.5.2"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/sync/CloudRelayDialer.kt+74 −0
@@ -0,0 +1,74 @@1+package com.peachpasswords.sync2+3+import org.json.JSONObject4+5+/**6+ * One relayed reconnect session where WE speak first — the mirror of7+ * [runCloudRelayResponderSession], and the half Android never had.8+ *9+ * Until this existed the phone could only ever wait, which was fine against a10+ * browser extension (it can only dial) and a deadlock against another phone:11+ * two waiters in the same room, nobody opening. See [weDialPeer] for how the12+ * role is assigned.13+ *14+ * The wire is the extension's, byte for byte: an unencrypted `PEACH_RECONNECT`15+ * selector naming the room, then Noise KK, then a padded PAIR_IDENTITY each16+ * way, then the sync exchange.17+ */18+internal suspend fun runCloudRelayInitiatorSession(19+    socket: RelayBinarySocket,20+    known: PairedDevice,21+    rendezvousId: String,22+    ourStaticPrivateKey: ByteArray,23+    ourStaticPublicKey: ByteArray,24+    localIdentity: LocalChirpIdentity,25+    vaultFactory: () -> ChirpSyncVaultAdapter?,26+    onSyncComplete: () -> Unit,27+) {28+    val responderKeyHex = known.longTermPublicKeyHex29+        ?: throw IllegalArgumentException("known peer missing static key")30+    val responderKey = hexToPubkey(responderKeyHex)31+32+    // The capability names the room only; it grants no trust. KK proves both33+    // private keys immediately after, and a wrong room is refused there.34+    socket.send(35+        JSONObject()36+            .put("type", "PEACH_RECONNECT")37+            .put("rendezvous_id", rendezvousId)38+            .toString()39+            .toByteArray(Charsets.UTF_8),40+    )41+    val session = noiseKKInitiator(42+        ourStaticPrivateKey,43+        ourStaticPublicKey,44+        responderKey,45+        socket::send,46+        socket::receive,47+    )48+    try {49+        socket.send(session.encrypt(SyncPadding.pad(encodePairIdentityFrame(localIdentity))))50+        val theirIdentity = parsePairIdentityFrame(51+            SyncPadding.unpad(session.decrypt(socket.receive())),52+        )53+        require(identityMatchesAuthenticatedPeer(known, theirIdentity, responderKey)) {54+            "peer identity does not match authenticated KK key"55+        }56+57+        val vault = vaultFactory() ?: return58+        vault.bindAuthenticatedPeer(known)59+        val outcome = runChirpSyncInitiator(60+            ChirpSyncSession(61+                remoteStaticPublicKey = responderKey.copyOf(),62+                send = { plaintext -> socket.send(session.encrypt(SyncPadding.pad(plaintext))) },63+                receive = { SyncPadding.unpad(session.decrypt(socket.receive())) },64+                close = { socket.close() },65+            ),66+            vault,67+        )68+        if (outcome.status == SyncOutcomeStatus.COMPLETED) {69+            onSyncComplete()70+        }71+    } finally {72+        session.close()73+    }74+}

view file

app/src/main/java/com/peachpasswords/sync/CloudRelayReconnectListener.kt+72 −20
@@ -11,6 +11,7 @@1111 import kotlinx.coroutines.launch1212 import kotlinx.coroutines.withContext1313 import kotlinx.coroutines.withTimeout14+import kotlinx.coroutines.withTimeoutOrNull1415 import okhttp3.OkHttpClient1516 import okhttp3.Request1617 import okhttp3.Response@@ -26,14 +27,21 @@2627 2728 private const val RECONCILE_INTERVAL_MS = 60_000L2829 private const val RETRY_DELAY_MS = 1_000L30+/** A dialled session that has not reached sync by now is talking to nobody. */31+private const val DIAL_SESSION_TIMEOUT_MS = 30_000L32+/** Slower than the waiter's retry, so the peer is already in the room. */33+private const val DIAL_RETRY_DELAY_MS = 5_000L2934 private const val RELAY_CONNECT_TIMEOUT_MS = 10_000L3035 private const val RELAY_INBOX_CAPACITY = 83136 3237 /**33- * Keeps one outbound waiting socket for each paired peer's current and previous34- * rendezvous windows. The Cloudflare relay is only a splice: once a browser35- * arrives, this class runs the same selector + Noise KK + PAIR_IDENTITY + sync36- * responder protocol as the LAN listener.38+ * Keeps one socket open in each paired peer's live rendezvous rooms. The39+ * Cloudflare relay is only a splice, so being in the room is all either side40+ * does at the transport level — what differs is who opens the conversation.41+ *42+ * Toward a browser extension we wait, because an extension can only ever dial.43+ * Toward another phone or tablet the role comes from [weDialPeer], because two44+ * waiters in one room is a deadlock and was why phone-to-phone never synced.3745  */3846 @Singleton3947 class CloudRelayReconnectListener @Inject constructor(@@ -42,6 +50,13 @@4250 ) {4351     private var supervisor: Job? = null4452     private val watchers = mutableMapOf<String, Job>()53+    /**54+     * "Sync now" has to do something now. Without this the loop would not look55+     * at the device list again for up to a minute, which on a phone-to-phone56+     * pair — where we are the one who dials — means the button appears to do57+     * nothing at all.58+     */59+    private val reconcileNow = Channel<Unit>(Channel.CONFLATED)4560     private var retainedPrivateKey: ByteArray? = null4661     private var retainedPublicKey: ByteArray? = null4762 @@ -67,7 +82,7 @@6782                         localIdentity,6883                         vaultFactory,6984                     )70-                    delay(RECONCILE_INTERVAL_MS)85+                    withTimeoutOrNull(RECONCILE_INTERVAL_MS) { reconcileNow.receive() }7186                 }7287             } finally {7388                 watchers.values.forEach(Job::cancel)@@ -76,6 +91,11 @@7691         }7792     }7893 94+    /** Re-read the device list at once instead of on the next interval. */95+    fun requestReconcile() {96+        reconcileNow.trySend(Unit)97+    }98+7999     fun stop() {80100         supervisor?.cancel()81101         supervisor = null@@ -101,6 +121,7 @@101121         }102122 103123         val now = System.currentTimeMillis() / 1000L124+        val ourKeyHex = pubkeyToHex(publicKey)104125         val desired = buildMap {105126             for (device in deviceStore.getAll()) {106127                 val secret = device.rendezvousSecretHex@@ -109,9 +130,16 @@109130                 val relayUrl = device.relayUrl110131                     ?.let { runCatching { Rendezvous.normalizeRelayUrl(it) }.getOrNull() }111132                     ?: relaySettings.relayUrl()112-                for (rendezvousId in Rendezvous.waitingIds(secret, now)) {133+                // One role per peer, never both: our own dialler and our own134+                // waiter would otherwise splice with each other and hold the135+                // room against the peer they were both there for.136+                val weDial = weDialPeer(ourKeyHex, device)137+                val ids =138+                    if (weDial) Rendezvous.dialingIds(secret, now)139+                    else Rendezvous.waitingIds(secret, now)140+                for (rendezvousId in ids) {113141                     val key = "${device.deviceId}|$relayUrl|$rendezvousId"114-                    put(key, RelayWaitTarget(device.deviceId, relayUrl, rendezvousId))142+                    put(key, RelayWaitTarget(device.deviceId, relayUrl, rendezvousId, weDial))115143                 }116144             }117145         }@@ -133,23 +161,46 @@133161                             Rendezvous.buildUrl(target.relayUrl, target.rendezvousId),134162                         )135163                         try {136-                            runCloudRelayResponderSession(137-                                socket = socket,138-                                known = known,139-                                registeredRendezvousId = target.rendezvousId,140-                                ourStaticPrivateKey = privateKey,141-                                ourStaticPublicKey = publicKey,142-                                localIdentity = localIdentity,143-                                vaultFactory = vaultFactory,144-                                onSyncComplete = {145-                                    deviceStore.updateLastSync(known.deviceId)146-                                },147-                            )164+                            if (target.weDial) {165+                                // A dialler that arrived before its peer had166+                                // its selector dropped — the relay does not167+                                // buffer for a room with one occupant. Bound168+                                // the wait so we close, free the room and try169+                                // again in the other arrival order, instead of170+                                // both sockets sitting there forever.171+                                withTimeout(DIAL_SESSION_TIMEOUT_MS) {172+                                    runCloudRelayInitiatorSession(173+                                        socket = socket,174+                                        known = known,175+                                        rendezvousId = target.rendezvousId,176+                                        ourStaticPrivateKey = privateKey,177+                                        ourStaticPublicKey = publicKey,178+                                        localIdentity = localIdentity,179+                                        vaultFactory = vaultFactory,180+                                        onSyncComplete = {181+                                            deviceStore.updateLastSync(known.deviceId)182+                                        },183+                                    )184+                                }185+                            } else {186+                                runCloudRelayResponderSession(187+                                    socket = socket,188+                                    known = known,189+                                    registeredRendezvousId = target.rendezvousId,190+                                    ourStaticPrivateKey = privateKey,191+                                    ourStaticPublicKey = publicKey,192+                                    localIdentity = localIdentity,193+                                    vaultFactory = vaultFactory,194+                                    onSyncComplete = {195+                                        deviceStore.updateLastSync(known.deviceId)196+                                    },197+                                )198+                            }148199                         } finally {149200                             socket.close()150201                         }151202                     }152-                    delay(RETRY_DELAY_MS)203+                    delay(if (target.weDial) DIAL_RETRY_DELAY_MS else RETRY_DELAY_MS)153204                 }154205             }155206         }@@ -160,6 +211,7 @@160211     val deviceId: String,161212     val relayUrl: String,162213     val rendezvousId: String,214+    val weDial: Boolean,163215 )164216 165217 internal interface RelayBinarySocket {

view file

app/src/main/java/com/peachpasswords/sync/ForegroundSyncCoordinator.kt+6 −5
@@ -26,11 +26,12 @@2626     /**2727      * Bring this device back onto the relay so paired peers can reach it.2828      *29-     * It no longer dials anyone. Under the relay-only model this device WAITS30-     * in its rendezvous rooms as the responder and the peer dials in — a31-     * browser extension cannot hold a room open, so it has to be the dialler.32-     * Coming to the foreground therefore means "make sure we are registered",33-     * not "go and sync now". See docs/sync-lan-retirement.md.29+     * Coming to the foreground means "make sure we are in our rooms", not "go30+     * and sync now" — the service holds a socket in each peer's rendezvous31+     * room and the role there decides who opens the conversation32+     * ([weDialPeer]). Toward a browser extension we wait, because an extension33+     * cannot hold a room open; toward another phone the lower key dials.34+     * See docs/sync-lan-retirement.md.3435      */3536     fun triggerForegroundSync() {3637         if (!vault.isUnlocked.value) return

view file

app/src/main/java/com/peachpasswords/sync/PeachSyncService.kt+5 −0
@@ -53,6 +53,11 @@5353     override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {5454         startForeground(NOTIFICATION_ID, buildNotification())5555         startReconnectListener()56+        // A start command means something changed — a new pairing, a manual57+        // "sync now", the app coming forward. Re-read the device list now58+        // rather than on the next interval; the listener is already running59+        // on every start but the first.60+        cloudRelayListener.requestReconcile()5661         // START_STICKY: if the system reclaims us under pressure, come back —5762         // a listener that silently stays down looks identical to a peer that5863         // has simply gone offline, and the user gets no signal either way.

view file

app/src/main/java/com/peachpasswords/sync/SyncPeerRole.kt+30 −0
@@ -0,0 +1,30 @@1+package com.peachpasswords.sync2+3+/**4+ * Who speaks first in a relay room.5+ *6+ * The relay is a two-party splice with no buffering and no "peer joined"7+ * signal, so exactly one side must open with `PEACH_RECONNECT` and the other8+ * must wait for it. Both sides derive the same room id from the shared pair9+ * secret, so the role has to be derivable from state both already hold —10+ * there is nothing on the wire to negotiate it with.11+ *12+ * Two rules, in order:13+ *14+ *  1. A browser extension can never hold a room open, so it always dials.15+ *     Toward an extension peer we therefore always wait.16+ *  2. Otherwise — phone to phone, phone to tablet — the lower long-term public17+ *     key dials. Comparing the keys is symmetric by construction: each side18+ *     compares the same two values and reaches the opposite conclusion, so the19+ *     roles can never collide (both dialling, or both waiting, which is what20+ *     left phone-to-phone with nobody dialling).21+ *22+ * The keys are hex of the same length, so an ordinary lexicographic compare is23+ * a total order over them. Ties are impossible: two devices sharing a static24+ * key are the same device.25+ */26+internal fun weDialPeer(ourLongTermPublicKeyHex: String, peer: PairedDevice): Boolean {27+    if (peer.deviceType == "extension") return false28+    val theirs = peer.longTermPublicKeyHex ?: return false29+    return ourLongTermPublicKeyHex.compareTo(theirs) < 030+}

view file

app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+11 −17
@@ -31,19 +31,15 @@3131 import javax.inject.Inject3232 3333 /**34- * Drives a Chirp Sync round and surfaces its status to the Sync settings card.34+ * Backs the Sync settings card: relay mode, the paired-device list, and the35+ * manual sync control.3536  *36- * Trigger conditions (browser `sync-trigger.ts` parity):37- *   1. Vault mutation — a local add/update/delete should schedule a sync.38- *   2. App foreground — reconnect to nearby peers on resume.39- *   3. Manual — the "Sync now" control on the Sync card.40- *   4. mDNS peer discovery — a paired device appears on the LAN.41- *42- * Discovery, the WebSocket server role, and the paired-device store are wired43- * Under the relay-only model this device WAITS in its rendezvous rooms as the44- * responder and the peer dials in, so "sync now" makes sure the listener is up45- * rather than dialling anyone. A browser extension cannot hold a relay room46- * open, so it has to be the dialler. See docs/sync-lan-retirement.md.37+ * Sync itself runs in [PeachSyncService], which holds a socket in each paired38+ * peer's rendezvous room. Which side opens the conversation there depends on39+ * the peer: we wait for a browser extension, because an extension can only40+ * dial, and for another phone the lower long-term key dials. So41+ * "sync now" makes sure the service is up and asks it to re-read the device42+ * list — the work happens there, not here. See docs/sync-lan-retirement.md.4743  */4844 @HiltViewModel4945 class SyncViewModel @Inject constructor(@@ -141,9 +137,9 @@141137     }142138 143139     /**144-     * Manual trigger: discover each paired device on the LAN and run an initiator145-     * sync round against it. Reports Offline when no peers are paired. mDNS146-     * discovery + audio cannot be emulator-verified — device QA pending (C7).140+     * Manual trigger. Reports Offline when no peers are paired; otherwise141+     * brings the relay service up (or nudges it, if already running) so it142+     * reconnects to every peer's room straight away.147143      */148144     fun syncNow() {149145         if (!vault.isUnlocked.value) {@@ -154,8 +150,6 @@154150             _status.value = SyncStatus.Offline155151             return156152         }157-        // Make sure we are registered on the relay and reachable. The peer158-        // performs the dial; there is nothing to initiate from here.159153         PeachSyncService.start(context)160154         _status.value = SyncStatus.LastSynced(System.currentTimeMillis())161155     }

view file

app/src/test/java/com/peachpasswords/sync/CloudRelayDialerTest.kt+241 −0
@@ -0,0 +1,241 @@1+package com.peachpasswords.sync2+3+import kotlinx.coroutines.Dispatchers4+import kotlinx.coroutines.async5+import kotlinx.coroutines.channels.Channel6+import kotlinx.coroutines.runBlocking7+import org.bouncycastle.crypto.params.X25519PrivateKeyParameters8+import org.junit.Assert.assertEquals9+import org.junit.Assert.assertFalse10+import org.junit.Assert.assertNotEquals11+import org.junit.Assert.assertTrue12+import org.junit.Test13+import java.security.SecureRandom14+15+/**16+ * Phone-to-phone: the case that had no initiator at all. Both halves here are17+ * this app's own code, so a passing test means two Peach phones can complete18+ * the reconnect wire against each other — not that one of them can talk to a19+ * stand-in for the extension.20+ */21+class CloudRelayDialerTest {22+    private data class KeyPair(val publicKey: ByteArray, val privateKey: ByteArray)23+24+    private fun keys(): KeyPair {25+        val privateKey = X25519PrivateKeyParameters(SecureRandom())26+        return KeyPair(privateKey.generatePublicKey().encoded, privateKey.encoded)27+    }28+29+    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+                }37+38+                override suspend fun receive(): ByteArray = inbox.receive()39+40+                override fun close(code: Int, reason: String) {41+                    inbox.close()42+                }43+            }44+        return socket(leftInbox, rightInbox) to socket(rightInbox, leftInbox)45+    }46+47+    private fun identity(id: String, name: String, publicKeyHex: String) = LocalChirpIdentity(48+        deviceId = id,49+        deviceName = name,50+        deviceType = DeviceType.ANDROID,51+        longTermPublicKeyHex = publicKeyHex,52+        signingPublicKeyHex = "33".repeat(32),53+    )54+55+    @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+        // Both sides derive the same room from the shared pair secret.63+        val rendezvousId = Rendezvous.waitingIds(secret, now).first()64+        assertTrue(rendezvousId in Rendezvous.dialingIds(secret, now))65+66+        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+        )84+85+        val (dialerSocket, waiterSocket) = socketPair()86+        var dialerSynced = false87+        var waiterSynced = false88+89+        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+        }113+114+        waiter.await()115+        dialer.await()116+        // No vault adapter on either side, so the handshake completes and the117+        // sync exchange is skipped — the wire is what is under test here.118+        assertFalse(dialerSynced)119+        assertFalse(waiterSynced)120+    }121+122+    @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()133+134+        // The waiter answers with an identity frame naming a key that is not135+        // the one KK proved. Nothing about the room can be trusted; only the136+        // authenticated key can, so the dialler must refuse.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+        }158+159+        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()178+179+        assertTrue(180+            "expected identity mismatch, got $failure",181+            failure?.message?.contains("does not match authenticated KK key") == true,182+        )183+    }184+}185+186+class SyncPeerRoleTest {187+    private fun peer(type: String?, keyHex: String?) = PairedDevice(188+        deviceId = "peer",189+        deviceType = type,190+        longTermPublicKeyHex = keyHex,191+        pairedAt = 1L,192+    )193+194+    @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+    }201+202+    @Test203+    fun `phone to phone assigns exactly one dialler`() {204+        val ours = "aa".repeat(32)205+        val theirs = "bb".repeat(32)206+        // The same comparison run from each side must disagree — one dials,207+        // one waits. Two waiters was the phone-to-phone deadlock; two diallers208+        // would splice our own sockets together.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+    }216+217+    @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+    }225+226+    @Test227+    fun `a peer with no static key is never dialled`() {228+        assertFalse(weDialPeer("00".repeat(32), peer("android", null)))229+    }230+231+    @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+}

view file