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
↓ peach-android-0e748371.tar.gzatlas at this save
files changed3 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 −0app/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 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 {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+ )