0e74837151f3

add codeless reconnect listener on a fixed port for known peers, advertise it in PAIR_IDENTITY, host it from the sync service

time
2026-07-25 00:04 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
sync reconnect listener
parent
d9f3f7e7ee34
3 files app/src/main/java/com/peachpasswords/sync/ChirpIdentity.kt+4 −0app/src/main/java/com/peachpasswords/sync/PeachSyncService.kt+36 −0app/src/main/java/com/peachpasswords/sync/SyncReconnectListener.kt+155 −0
app/src/main/java/com/peachpasswords/sync/ChirpIdentity.kt+4 −0
@@ -57,6 +57,10 @@5757         deviceType = extensionDeviceType(),5858         longTermPublicKeyHex = pubkeyToHex(kp.publicKey),5959         signingPublicKeyHex = pubkeyToHex(signing.publicKeyRaw),60+        // Where peers should dial to reconnect without a pairing code. The61+        // pairing window uses an ephemeral port and then closes, so a peer that62+        // cached that one would never reach us again.63+        wsPort = SyncReconnectListener.SYNC_PORT,6064     )6165 }6266 

view file

app/src/main/java/com/peachpasswords/sync/PeachSyncService.kt+36 −0
@@ -13,9 +13,12 @@1313 import com.peachpasswords.MainActivity1414 import com.peachpasswords.R1515 import dagger.hilt.android.AndroidEntryPoint16+import com.peachpasswords.vault.VaultCrypto17+import com.peachpasswords.vault.VaultRepository1618 import kotlinx.coroutines.CoroutineScope1719 import kotlinx.coroutines.SupervisorJob1820 import kotlinx.coroutines.cancel21+import javax.inject.Inject1922 2023 /**2124  * Keeps the pairing/sync listener reachable.@@ -34,12 +37,19 @@3437 @AndroidEntryPoint3538 class PeachSyncService : Service() {3639 40+    @Inject lateinit var listener: SyncReconnectListener41+    @Inject lateinit var vault: VaultRepository42+    @Inject lateinit var deviceStore: PairedDeviceStore43+    @Inject lateinit var pendingVouchedStore: PendingVouchedDeviceStore44+    @Inject lateinit var syncAlertStore: SyncAlertStore45+3746     private val scope = CoroutineScope(SupervisorJob())3847 3948     override fun onBind(intent: Intent?): IBinder? = null4049 4150     override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {4251         startForeground(NOTIFICATION_ID, buildNotification())52+        startReconnectListener()4353         // START_STICKY: if the system reclaims us under pressure, come back —4454         // a listener that silently stays down looks identical to a peer that4555         // has simply gone offline, and the user gets no signal either way.@@ -47,8 +57,34 @@4757     }4858 4959     override fun onDestroy() {60+        listener.stop()5061         scope.cancel()5162         super.onDestroy()63+    }64+65+    /**66+     * Only possible while unlocked — the sync identity is derived from the67+     * vault's root key, so a locked phone genuinely has nothing to listen with.68+     * That is also why this is re-attempted on every start command rather than69+     * once: the service may well come up before the user has unlocked.70+     */71+    private fun startReconnectListener() {72+        val master = vault.syncMasterKey() ?: return73+        try {74+            val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(this))75+            val identity = buildLocalIdentity(this, master)76+            listener.start(scope, keys.secretKey, keys.publicKey, identity) {77+                if (!vault.isUnlocked.value) null else VaultSyncAdapter(78+                    repo = vault,79+                    ourDeviceId = getOrCreateDeviceId(this),80+                    pairedDevices = deviceStore,81+                    pendingVouched = pendingVouchedStore,82+                    syncAlerts = syncAlertStore,83+                )84+            }85+        } finally {86+            VaultCrypto.zero(master)87+        }5288     }5389 5490     private fun buildNotification(): Notification {

view file

app/src/main/java/com/peachpasswords/sync/SyncReconnectListener.kt+155 −0
@@ -0,0 +1,155 @@1+package com.peachpasswords.sync2+3+import kotlinx.coroutines.CoroutineScope4+import kotlinx.coroutines.Dispatchers5+import kotlinx.coroutines.isActive6+import kotlinx.coroutines.launch7+import kotlinx.coroutines.withContext8+import java.net.ServerSocket9+import java.net.Socket10+import javax.inject.Inject11+import javax.inject.Singleton12+13+/**14+ * The everyday sync path: already-paired devices reconnecting without a code.15+ *16+ * The pairing window ([CodePairingServer]) is a first-join ceremony and closes17+ * after one success. This is the listener that stays up, and it is the reason18+ * [PeachSyncService] exists — a browser extension can never listen, so if the19+ * phone is not reachable, two devices simply never sync and neither shows an20+ * error, because "no connection" and "nothing to sync" look identical.21+ *22+ * Trust here is NOT the pairing token — there is none. Noise NK authenticates23+ * the responder (this phone) to the dialer, not the reverse, so after the24+ * handshake the peer must present a PAIR_IDENTITY whose device is already in25+ * [PairedDeviceRegistry]. An unknown peer is dropped without a reply, the same26+ * silence a bad pairing token gets: an unpaired caller learns nothing about27+ * whether this is a Peach device.28+ *29+ * The port is fixed rather than OS-assigned because the browser reconnects to a30+ * cached address; an ephemeral port would be stale the moment the service31+ * restarted, and the reconnect would fail in the one way that looks exactly32+ * like the phone being offline.33+ */34+@Singleton35+class SyncReconnectListener @Inject constructor(36+    private val deviceStore: PairedDeviceRegistry,37+) {38+    private var serverSocket: ServerSocket? = null39+40+    companion object {41+        /**42+         * Advertised to peers in PAIR_IDENTITY's `wsPort` so they cache it.43+         * Picked in the dynamic/private range to avoid colliding with anything44+         * a user is likely to be running.45+         */46+        const val SYNC_PORT = 4914247+    }48+49+    /** The port actually bound, or null if the listener is not running. */50+    val boundPort: Int? get() = serverSocket?.localPort51+52+    fun start(53+        scope: CoroutineScope,54+        ourStaticPrivateKey: ByteArray,55+        ourStaticPublicKey: ByteArray,56+        localIdentity: LocalChirpIdentity,57+        vaultFactory: () -> ChirpSyncVaultAdapter?,58+    ) {59+        if (serverSocket != null) return60+        scope.launch(Dispatchers.IO) {61+            val socket = try {62+                ServerSocket(SYNC_PORT)63+            } catch (_: Exception) {64+                // Port taken — better to be unreachable than to bind somewhere65+                // no peer has cached and pretend we are listening.66+                return@launch67+            }68+            serverSocket = socket69+            while (isActive && !socket.isClosed) {70+                val client = runCatching { socket.accept() }.getOrNull() ?: break71+                launch {72+                    runCatching {73+                        handle(client, ourStaticPrivateKey, ourStaticPublicKey, localIdentity, vaultFactory)74+                    }75+                }76+            }77+        }78+    }79+80+    fun stop() {81+        runCatching { serverSocket?.close() }82+        serverSocket = null83+    }84+85+    private suspend fun handle(86+        client: Socket,87+        ourStaticPrivateKey: ByteArray,88+        ourStaticPublicKey: ByteArray,89+        localIdentity: LocalChirpIdentity,90+        vaultFactory: () -> ChirpSyncVaultAdapter?,91+    ) = withContext(Dispatchers.IO) {92+        val ws = WebSocketConnection(client, client.getInputStream(), client.getOutputStream())93+        try {94+            if (!ws.accept()) return@withContext95+96+            val session = noiseNKResponder(97+                ourStaticPrivateKey,98+                ourStaticPublicKey,99+                { frame -> ws.send(frame) },100+                { (ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed") },101+            )102+103+            val theirIdentity = parsePairIdentityFrame(104+                session.decrypt(105+                    (ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed"),106+                ),107+            )108+109+            // The whole authentication step. Without it this port would sync110+            // the vault with anyone on the LAN who completed a handshake.111+            val known = deviceStore.get(theirIdentity.deviceId)112+            if (known == null || !keysMatch(known, theirIdentity)) return@withContext113+114+            ws.send(session.encrypt(encodePairIdentityFrame(localIdentity)))115+116+            // Refresh the reconnect hint — phones and laptops change address117+            // across network joins, and a stale cache is the usual reason a118+            // reconnect quietly stops working.119+            deviceStore.upsert(120+                known.copy(121+                    lastKnownIp = client.inetAddress?.hostAddress ?: known.lastKnownIp,122+                    lastSyncedAt = System.currentTimeMillis(),123+                ),124+            )125+126+            val vault = vaultFactory() ?: return@withContext127+            runChirpSyncResponder(webSocketSyncSession(session, ws), vault)128+        } finally {129+            runCatching { client.close() }130+        }131+    }132+133+    /**134+     * A rotated key on a known device id is a different device, or an attempt135+     * to become one. Either way it is not this peer, and re-pairing is the136+     * only correct route back.137+     */138+    private fun keysMatch(known: PairedDevice, presented: PairIdentity): Boolean {139+        val expected = known.longTermPublicKeyHex ?: return false140+        return expected.equals(presented.longTermPublicKeyHex, ignoreCase = true)141+    }142+}143+144+/** Bridges the sync coordinator's frame IO onto a WebSocket. */145+internal fun webSocketSyncSession(session: NoiseNKSession, ws: WebSocketConnection): ChirpSyncSession =146+    ChirpSyncSession(147+        remoteStaticPublicKey = ByteArray(0),148+        send = { plaintext -> ws.send(session.encrypt(plaintext)) },149+        receive = {150+            session.decrypt(151+                (ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed"),152+            )153+        },154+        close = { ws.closeSilently() },155+    )

view file