cd7deee44786

feat: wait for secure relay sync (v2.3.75)

time
2026-07-26 04:02 UTC · 3 months ago
machine
koh2
parent
b7a2b0329555
6 files app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/CloudRelayReconnectListener.kt+302 −0app/src/main/java/com/peachpasswords/sync/PeachSyncService.kt+13 −2app/src/main/java/com/peachpasswords/sync/SyncReconnectListener.kt+14 −11app/src/main/java/com/peachpasswords/ui/lock/LockViewModel.kt+3 −0app/src/test/java/com/peachpasswords/sync/CloudRelayReconnectListenerTest.kt+149 −0
app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 23740020-        versionName "2.3.74"19+        versionCode 23750020+        versionName "2.3.75"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/sync/CloudRelayReconnectListener.kt+302 −0
@@ -0,0 +1,302 @@1+package com.peachpasswords.sync2+3+import com.peachpasswords.vault.VaultCrypto4+import kotlinx.coroutines.CompletableDeferred5+import kotlinx.coroutines.CoroutineScope6+import kotlinx.coroutines.Dispatchers7+import kotlinx.coroutines.Job8+import kotlinx.coroutines.channels.Channel9+import kotlinx.coroutines.delay10+import kotlinx.coroutines.isActive11+import kotlinx.coroutines.launch12+import kotlinx.coroutines.withContext13+import kotlinx.coroutines.withTimeout14+import okhttp3.OkHttpClient15+import okhttp3.Request16+import okhttp3.Response17+import okhttp3.WebSocket18+import okhttp3.WebSocketListener19+import okio.ByteString20+import okio.ByteString.Companion.toByteString21+import org.json.JSONObject22+import java.security.MessageDigest23+import java.util.concurrent.TimeUnit24+import javax.inject.Inject25+import javax.inject.Singleton26+27+private const val RECONCILE_INTERVAL_MS = 60_000L28+private const val RETRY_DELAY_MS = 1_000L29+private const val RELAY_CONNECT_TIMEOUT_MS = 10_000L30+private const val RELAY_INBOX_CAPACITY = 831+32+/**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.37+ */38+@Singleton39+class CloudRelayReconnectListener @Inject constructor(40+    private val deviceStore: PairedDeviceStore,41+    private val relaySettings: RelaySettingsStore,42+) {43+    private var supervisor: Job? = null44+    private val watchers = mutableMapOf<String, Job>()45+    private var retainedPrivateKey: ByteArray? = null46+    private var retainedPublicKey: ByteArray? = null47+48+    fun start(49+        scope: CoroutineScope,50+        ourStaticPrivateKey: ByteArray,51+        ourStaticPublicKey: ByteArray,52+        localIdentity: LocalChirpIdentity,53+        vaultFactory: () -> ChirpSyncVaultAdapter?,54+    ) {55+        if (supervisor?.isActive == true) return56+        val privateKey = ourStaticPrivateKey.copyOf()57+        val publicKey = ourStaticPublicKey.copyOf()58+        retainedPrivateKey = privateKey59+        retainedPublicKey = publicKey60+        supervisor = scope.launch(Dispatchers.IO) {61+            try {62+                while (isActive) {63+                    reconcile(64+                        this,65+                        privateKey,66+                        publicKey,67+                        localIdentity,68+                        vaultFactory,69+                    )70+                    delay(RECONCILE_INTERVAL_MS)71+                }72+            } finally {73+                watchers.values.forEach(Job::cancel)74+                watchers.clear()75+            }76+        }77+    }78+79+    fun stop() {80+        supervisor?.cancel()81+        supervisor = null82+        watchers.values.forEach(Job::cancel)83+        watchers.clear()84+        retainedPrivateKey?.let(VaultCrypto::zero)85+        retainedPublicKey?.let(VaultCrypto::zero)86+        retainedPrivateKey = null87+        retainedPublicKey = null88+    }89+90+    private fun reconcile(91+        scope: CoroutineScope,92+        privateKey: ByteArray,93+        publicKey: ByteArray,94+        localIdentity: LocalChirpIdentity,95+        vaultFactory: () -> ChirpSyncVaultAdapter?,96+    ) {97+        if (relaySettings.isLanOnly()) {98+            watchers.values.forEach(Job::cancel)99+            watchers.clear()100+            return101+        }102+103+        val now = System.currentTimeMillis() / 1000L104+        val desired = buildMap {105+            for (device in deviceStore.getAll()) {106+                val secret = device.rendezvousSecretHex107+                    ?.let { runCatching { Rendezvous.secretFromHex(it) }.getOrNull() }108+                    ?: continue109+                val relayUrl = device.relayUrl110+                    ?.let { runCatching { Rendezvous.normalizeRelayUrl(it) }.getOrNull() }111+                    ?: relaySettings.relayUrl()112+                for (rendezvousId in Rendezvous.waitingIds(secret, now)) {113+                    val key = "${device.deviceId}|$relayUrl|$rendezvousId"114+                    put(key, RelayWaitTarget(device.deviceId, relayUrl, rendezvousId))115+                }116+            }117+        }118+119+        for ((key, job) in watchers.toMap()) {120+            if (key !in desired) {121+                job.cancel()122+                watchers.remove(key)123+            }124+        }125+        for ((key, target) in desired) {126+            if (watchers[key]?.isActive == true) continue127+            watchers[key] = scope.launch {128+                while (isActive) {129+                    runCatching {130+                        val known = deviceStore.get(target.deviceId)131+                            ?: throw IllegalStateException("relay peer was unpaired")132+                        val socket = OkHttpRelaySocket.connect(133+                            Rendezvous.buildUrl(target.relayUrl, target.rendezvousId),134+                        )135+                        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+                            )148+                        } finally {149+                            socket.close()150+                        }151+                    }152+                    delay(RETRY_DELAY_MS)153+                }154+            }155+        }156+    }157+}158+159+private data class RelayWaitTarget(160+    val deviceId: String,161+    val relayUrl: String,162+    val rendezvousId: String,163+)164+165+internal interface RelayBinarySocket {166+    fun send(frame: ByteArray)167+    suspend fun receive(): ByteArray168+    fun close(code: Int = 1000, reason: String = "relay session complete")169+}170+171+/**172+ * One relayed reconnect session. The registered rendezvous capability selects173+ * local state; KK still performs all authentication and has no NK fallback.174+ */175+internal suspend fun runCloudRelayResponderSession(176+    socket: RelayBinarySocket,177+    known: PairedDevice,178+    registeredRendezvousId: String,179+    ourStaticPrivateKey: ByteArray,180+    ourStaticPublicKey: ByteArray,181+    localIdentity: LocalChirpIdentity,182+    vaultFactory: () -> ChirpSyncVaultAdapter?,183+    onSyncComplete: () -> Unit,184+) {185+    val selector = JSONObject(String(socket.receive(), Charsets.UTF_8))186+    require(selector.optString("type") == "PEACH_RECONNECT") {187+        "expected PEACH_RECONNECT"188+    }189+    val presentedId = selector.optString("rendezvous_id")190+    require(191+        MessageDigest.isEqual(192+            registeredRendezvousId.toByteArray(Charsets.US_ASCII),193+            presentedId.toByteArray(Charsets.US_ASCII),194+        ),195+    ) { "reconnect capability does not match relay room" }196+    val secret = known.rendezvousSecretHex197+        ?.let { runCatching { Rendezvous.secretFromHex(it) }.getOrNull() }198+        ?: throw IllegalArgumentException("known peer missing rendezvous secret")199+    require(200+        Rendezvous.waitingIds(secret, System.currentTimeMillis() / 1000L).any {201+            MessageDigest.isEqual(202+                it.toByteArray(Charsets.US_ASCII),203+                registeredRendezvousId.toByteArray(Charsets.US_ASCII),204+            )205+        },206+    ) { "relay room is outside the accepted clock-skew windows" }207+208+    val initiatorKeyHex = known.longTermPublicKeyHex209+        ?: throw IllegalArgumentException("known peer missing static key")210+    val initiatorKey = hexToPubkey(initiatorKeyHex)211+    val session = noiseKKResponder(212+        ourStaticPrivateKey,213+        ourStaticPublicKey,214+        initiatorKey,215+        socket::send,216+        socket::receive,217+    )218+    val theirIdentity = parsePairIdentityFrame(219+        session.decrypt(socket.receive()),220+    )221+    require(identityMatchesAuthenticatedPeer(known, theirIdentity, initiatorKey)) {222+        "peer identity does not match authenticated KK key"223+    }224+    socket.send(session.encrypt(encodePairIdentityFrame(localIdentity)))225+226+    val vault = vaultFactory() ?: return227+    vault.bindAuthenticatedPeer(known)228+    val outcome = runChirpSyncResponder(229+        ChirpSyncSession(230+            remoteStaticPublicKey = initiatorKey.copyOf(),231+            send = { plaintext -> socket.send(session.encrypt(plaintext)) },232+            receive = { session.decrypt(socket.receive()) },233+            close = { socket.close() },234+        ),235+        vault,236+    )237+    if (outcome.status == SyncOutcomeStatus.COMPLETED) {238+        onSyncComplete()239+    }240+}241+242+private object OkHttpRelaySocket {243+    private val client = OkHttpClient.Builder()244+        .readTimeout(0, TimeUnit.MILLISECONDS)245+        .build()246+247+    suspend fun connect(url: String): RelayBinarySocket = withContext(Dispatchers.IO) {248+        val opened = CompletableDeferred<WebSocket>()249+        val incoming = Channel<ByteArray>(RELAY_INBOX_CAPACITY)250+        val listener = object : WebSocketListener() {251+            override fun onOpen(webSocket: WebSocket, response: Response) {252+                opened.complete(webSocket)253+            }254+255+            override fun onMessage(webSocket: WebSocket, bytes: ByteString) {256+                if (bytes.size > MAX_SYNC_FRAME_BYTES) {257+                    webSocket.close(1009, "relay frame too large")258+                    incoming.close(IllegalStateException("relay_frame_too_large"))259+                    return260+                }261+                if (!incoming.trySend(bytes.toByteArray()).isSuccess) {262+                    webSocket.close(1008, "relay receive queue full")263+                    incoming.close(IllegalStateException("relay_receive_queue_full"))264+                }265+            }266+267+            override fun onMessage(webSocket: WebSocket, text: String) {268+                webSocket.close(1003, "binary frames required")269+                incoming.close(IllegalStateException("relay_text_frame_rejected"))270+            }271+272+            override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {273+                incoming.close(IllegalStateException("relay_closed_$code"))274+            }275+276+            override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {277+                opened.completeExceptionally(t)278+                incoming.close(t)279+            }280+        }281+        val webSocket = client.newWebSocket(Request.Builder().url(url).build(), listener)282+        val connected = try {283+            withTimeout(RELAY_CONNECT_TIMEOUT_MS) { opened.await() }284+        } catch (error: Throwable) {285+            webSocket.cancel()286+            throw error287+        }288+        object : RelayBinarySocket {289+            override fun send(frame: ByteArray) {290+                require(frame.size <= MAX_SYNC_FRAME_BYTES) { "relay_frame_too_large" }291+                check(connected.send(frame.toByteString())) { "relay_send_failed" }292+            }293+294+            override suspend fun receive(): ByteArray = incoming.receive()295+296+            override fun close(code: Int, reason: String) {297+                connected.close(code, reason)298+                incoming.close()299+            }300+        }301+    }302+}

view file

app/src/main/java/com/peachpasswords/sync/PeachSyncService.kt+13 −2
@@ -38,6 +38,7 @@3838 class PeachSyncService : Service() {3939 4040     @Inject lateinit var listener: SyncReconnectListener41+    @Inject lateinit var cloudRelayListener: CloudRelayReconnectListener4142     @Inject lateinit var vault: VaultRepository4243     @Inject lateinit var deviceStore: PairedDeviceStore4344     @Inject lateinit var pendingVouchedStore: PendingVouchedDeviceStore@@ -58,6 +59,7 @@5859 5960     override fun onDestroy() {6061         listener.stop()62+        cloudRelayListener.stop()6163         scope.cancel()6264         super.onDestroy()6365     }@@ -82,6 +84,15 @@8284                     syncAlerts = syncAlertStore,8385                 )8486             }87+            cloudRelayListener.start(scope, keys.secretKey, keys.publicKey, identity) {88+                if (!vault.isUnlocked.value) null else VaultSyncAdapter(89+                    repo = vault,90+                    ourDeviceId = getOrCreateDeviceId(this),91+                    pairedDevices = deviceStore,92+                    pendingVouched = pendingVouchedStore,93+                    syncAlerts = syncAlertStore,94+                )95+            }8596         } finally {8697             VaultCrypto.zero(master)8798         }@@ -92,7 +103,7 @@92103         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {93104             manager.createNotificationChannel(94105                 NotificationChannel(CHANNEL_ID, "Peach sync", NotificationManager.IMPORTANCE_LOW).apply {95-                    description = "Shown while Peach is reachable for syncing on your network."106+                    description = "Shown while Peach is reachable for encrypted device sync."96107                     setShowBadge(false)97108                 },98109             )@@ -105,7 +116,7 @@105116         )106117         return NotificationCompat.Builder(this, CHANNEL_ID)107118             .setContentTitle("Peach sync is active")108-            .setContentText("Your other devices can reach this phone on your network.")119+            .setContentText("Your other devices can reach this phone for encrypted sync.")109120             .setSmallIcon(R.drawable.ic_launcher_monochrome)110121             .setOngoing(true)111122             .setPriority(NotificationCompat.PRIORITY_LOW)

view file

app/src/main/java/com/peachpasswords/sync/SyncReconnectListener.kt+14 −11
@@ -122,7 +122,9 @@122122                 ),123123             )124124 125-            if (!identityMatches(known, theirIdentity, initiatorKey)) return@withContext125+            if (!identityMatchesAuthenticatedPeer(known, theirIdentity, initiatorKey)) {126+                return@withContext127+            }126128 127129             ws.send(session.encrypt(encodePairIdentityFrame(localIdentity)))128130 @@ -207,16 +209,17 @@207209      * to become one. Either way it is not this peer, and re-pairing is the208210      * only correct route back.209211      */210-    private fun identityMatches(211-        known: PairedDevice,212-        presented: PairIdentity,213-        authenticatedKey: ByteArray,214-    ): Boolean {215-        val expected = known.longTermPublicKeyHex ?: return false216-        return known.deviceId == presented.deviceId &&217-            expected.equals(presented.longTermPublicKeyHex, ignoreCase = true) &&218-            MessageDigest.isEqual(authenticatedKey, hexToPubkey(presented.longTermPublicKeyHex))219-    }212+}213+214+internal fun identityMatchesAuthenticatedPeer(215+    known: PairedDevice,216+    presented: PairIdentity,217+    authenticatedKey: ByteArray,218+): Boolean {219+    val expected = known.longTermPublicKeyHex ?: return false220+    return known.deviceId == presented.deviceId &&221+        expected.equals(presented.longTermPublicKeyHex, ignoreCase = true) &&222+        MessageDigest.isEqual(authenticatedKey, hexToPubkey(presented.longTermPublicKeyHex))220223 }221224 222225 /**

view file

app/src/main/java/com/peachpasswords/ui/lock/LockViewModel.kt+3 −0
@@ -6,6 +6,7 @@66 import androidx.lifecycle.ViewModel77 import androidx.lifecycle.viewModelScope88 import com.peachpasswords.sync.ForegroundSyncCoordinator9+import com.peachpasswords.sync.PeachSyncService910 import com.peachpasswords.ui.PeachNav1011 import com.peachpasswords.ui.vault.entryTapDestination1112 import com.peachpasswords.vault.CreateVaultResult@@ -120,6 +121,7 @@120121                 slotA != null -> {121122                     vault.commitSlotAUnlock(slotA)122123                     lockTimer.start()124+                    PeachSyncService.start(appContext)123125                     foregroundSync.triggerForegroundSync()124126                     _state.update { it.copy(loading = false, unlocked = true) }125127                 }@@ -200,6 +202,7 @@200202                     when (r) {201203                         is UnlockResult.Success -> {202204                             lockTimer.start()205+                            PeachSyncService.start(appContext)203206                             foregroundSync.triggerForegroundSync()204207                             _state.update { it.copy(unlocked = true) }205208                         }

view file

app/src/test/java/com/peachpasswords/sync/CloudRelayReconnectListenerTest.kt+149 −0
@@ -0,0 +1,149 @@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.assertTrue11+import org.junit.Test12+import java.security.SecureRandom13+14+class CloudRelayReconnectListenerTest {15+    private data class KeyPair(val publicKey: ByteArray, val privateKey: ByteArray)16+17+    private fun keys(): KeyPair {18+        val privateKey = X25519PrivateKeyParameters(SecureRandom())19+        return KeyPair(privateKey.generatePublicKey().encoded, privateKey.encoded)20+    }21+22+    private fun socketPair(): Pair<RelayBinarySocket, RelayBinarySocket> {23+        val leftInbox = Channel<ByteArray>(Channel.UNLIMITED)24+        val rightInbox = Channel<ByteArray>(Channel.UNLIMITED)25+        fun socket(inbox: Channel<ByteArray>, peer: Channel<ByteArray>) =26+            object : RelayBinarySocket {27+                override fun send(frame: ByteArray) {28+                    check(peer.trySend(frame.copyOf()).isSuccess)29+                }30+31+                override suspend fun receive(): ByteArray = inbox.receive()32+33+                override fun close(code: Int, reason: String) {34+                    inbox.close()35+                }36+            }37+        return socket(leftInbox, rightInbox) to socket(rightInbox, leftInbox)38+    }39+40+    @Test41+    fun `relayed responder completes the same selector KK and identity wire`() = runBlocking {42+        val browserKeys = keys()43+        val phoneKeys = keys()44+        val secretHex = "61".repeat(32)45+        val rendezvousId = Rendezvous.waitingIds(46+            Rendezvous.secretFromHex(secretHex),47+            System.currentTimeMillis() / 1000L,48+        ).first()49+        val known = PairedDevice(50+            deviceId = "browser",51+            deviceName = "Firefox",52+            deviceType = "extension",53+            longTermPublicKeyHex = pubkeyToHex(browserKeys.publicKey),54+            rendezvousSecretHex = secretHex,55+            pairedAt = 1L,56+        )57+        val phoneIdentity = LocalChirpIdentity(58+            deviceId = "phone",59+            deviceName = "Pixel",60+            deviceType = DeviceType.ANDROID,61+            longTermPublicKeyHex = pubkeyToHex(phoneKeys.publicKey),62+            signingPublicKeyHex = "22".repeat(32),63+        )64+        val browserIdentity = LocalChirpIdentity(65+            deviceId = "browser",66+            deviceName = "Firefox",67+            deviceType = DeviceType.EXTENSION,68+            longTermPublicKeyHex = pubkeyToHex(browserKeys.publicKey),69+            signingPublicKeyHex = "11".repeat(32),70+        )71+        val (browserSocket, phoneSocket) = socketPair()72+        var syncMarked = false73+74+        val responder = async(Dispatchers.Default) {75+            runCloudRelayResponderSession(76+                socket = phoneSocket,77+                known = known,78+                registeredRendezvousId = rendezvousId,79+                ourStaticPrivateKey = phoneKeys.privateKey,80+                ourStaticPublicKey = phoneKeys.publicKey,81+                localIdentity = phoneIdentity,82+                vaultFactory = { null },83+                onSyncComplete = { syncMarked = true },84+            )85+        }86+        browserSocket.send(87+            """{"type":"PEACH_RECONNECT","rendezvous_id":"$rendezvousId"}"""88+                .toByteArray(),89+        )90+        val session = noiseKKInitiator(91+            browserKeys.privateKey,92+            browserKeys.publicKey,93+            phoneKeys.publicKey,94+            browserSocket::send,95+            browserSocket::receive,96+        )97+        browserSocket.send(session.encrypt(encodePairIdentityFrame(browserIdentity)))98+        val theirIdentity = parsePairIdentityFrame(99+            session.decrypt(browserSocket.receive()),100+        )101+102+        responder.await()103+        assertEquals(phoneIdentity.deviceId, theirIdentity.deviceId)104+        assertEquals(phoneIdentity.longTermPublicKeyHex, theirIdentity.longTermPublicKeyHex)105+        assertFalse(syncMarked)106+    }107+108+    @Test109+    fun `selector that does not name the registered relay room fails before KK`() = runBlocking {110+        val phoneKeys = keys()111+        val secretHex = "62".repeat(32)112+        val rendezvousId = Rendezvous.waitingIds(113+            Rendezvous.secretFromHex(secretHex),114+            System.currentTimeMillis() / 1000L,115+        ).first()116+        val known = PairedDevice(117+            deviceId = "browser",118+            longTermPublicKeyHex = pubkeyToHex(keys().publicKey),119+            rendezvousSecretHex = secretHex,120+            pairedAt = 1L,121+        )122+        val (sender, receiver) = socketPair()123+        sender.send(124+            """{"type":"PEACH_RECONNECT","rendezvous_id":"${"Z".repeat(43)}"}"""125+                .toByteArray(),126+        )127+128+        val failure = runCatching {129+            runCloudRelayResponderSession(130+                socket = receiver,131+                known = known,132+                registeredRendezvousId = rendezvousId,133+                ourStaticPrivateKey = phoneKeys.privateKey,134+                ourStaticPublicKey = phoneKeys.publicKey,135+                localIdentity = LocalChirpIdentity(136+                    deviceId = "phone",137+                    deviceName = "Pixel",138+                    deviceType = DeviceType.ANDROID,139+                    longTermPublicKeyHex = pubkeyToHex(phoneKeys.publicKey),140+                    signingPublicKeyHex = "22".repeat(32),141+                ),142+                vaultFactory = { null },143+                onSyncComplete = {},144+            )145+        }.exceptionOrNull()146+147+        assertTrue(failure?.message?.contains("does not match relay room") == true)148+    }149+}

view file