b3f592370e5a

dispatch relay traffic on the sync listener and add the Settings relay toggle, default on

time
2026-07-25 00:51 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
browser relay dispatch
parent
b6208af4fe5e
6 files app/src/main/java/com/peachpasswords/di/VaultModule.kt+6 −0app/src/main/java/com/peachpasswords/sync/RelaySettingsStore.kt+36 −0app/src/main/java/com/peachpasswords/sync/SyncReconnectListener.kt+70 −7app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+14 −0app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+11 −0app/src/test/java/com/peachpasswords/sync/SyncReconnectListenerRelayTest.kt+201 −0
app/src/main/java/com/peachpasswords/di/VaultModule.kt+6 −0
@@ -2,6 +2,8 @@22 33 import com.peachpasswords.sync.PairedDeviceRegistry44 import com.peachpasswords.sync.PairedDeviceStore5+import com.peachpasswords.sync.RelayPolicy6+import com.peachpasswords.sync.RelaySettingsStore57 import com.peachpasswords.vault.VaultStorage68 import com.peachpasswords.vault.VaultStore79 import dagger.Binds@@ -20,4 +22,8 @@2022     @Binds2123     @Singleton2224     abstract fun bindPairedDeviceRegistry(impl: PairedDeviceStore): PairedDeviceRegistry25+26+    @Binds27+    @Singleton28+    abstract fun bindRelayPolicy(impl: RelaySettingsStore): RelayPolicy2329 }

view file

app/src/main/java/com/peachpasswords/sync/RelaySettingsStore.kt+36 −0
@@ -0,0 +1,36 @@1+package com.peachpasswords.sync2+3+import android.content.Context4+import dagger.hilt.android.qualifiers.ApplicationContext5+import javax.inject.Inject6+import javax.inject.Singleton7+8+/**9+ * Whether this device relays sync traffic for other paired devices.10+ *11+ * Decided 2026-07-24 (`PEACH_SYNC_RELAY_DESIGN.md` §5): on by default. Two12+ * laptops cannot sync with each other at all unless some native device relays,13+ * so a phone that had to be configured first would leave that path silently14+ * broken for everyone who never opened Settings.15+ *16+ * Plain SharedPreferences rather than the encrypted store: this is a boolean17+ * about this handset's role, not a secret, and it must not travel to other18+ * devices the way a vault setting would.19+ */20+@Singleton21+class RelaySettingsStore @Inject constructor(22+    @ApplicationContext context: Context,23+) : RelayPolicy {24+    private val prefs = context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE)25+26+    override fun isRelayEnabled(): Boolean = prefs.getBoolean(KEY_ENABLED, true)27+28+    fun setRelayEnabled(enabled: Boolean) {29+        prefs.edit().putBoolean(KEY_ENABLED, enabled).apply()30+    }31+32+    private companion object {33+        const val PREFS_FILE = "peach_relay_settings"34+        const val KEY_ENABLED = "relay_enabled"35+    }36+}

view file

app/src/main/java/com/peachpasswords/sync/SyncReconnectListener.kt+70 −7
@@ -34,6 +34,7 @@3434 @Singleton3535 class SyncReconnectListener @Inject constructor(3636     private val deviceStore: PairedDeviceRegistry,37+    private val relayHub: RelayHub,3738 ) {3839     private var serverSocket: ServerSocket? = null3940 @@ -49,17 +50,19 @@4950     /** The port actually bound, or null if the listener is not running. */5051     val boundPort: Int? get() = serverSocket?.localPort5152 53+    /** [port] is a seam for tests; peers only ever cache [SYNC_PORT]. */5254     fun start(5355         scope: CoroutineScope,5456         ourStaticPrivateKey: ByteArray,5557         ourStaticPublicKey: ByteArray,5658         localIdentity: LocalChirpIdentity,59+        port: Int = SYNC_PORT,5760         vaultFactory: () -> ChirpSyncVaultAdapter?,5861     ) {5962         if (serverSocket != null) return6063         scope.launch(Dispatchers.IO) {6164             val socket = try {62-                ServerSocket(SYNC_PORT)65+                ServerSocket(port)6366             } catch (_: Exception) {6467                 // Port taken — better to be unreachable than to bind somewhere6568                 // no peer has cached and pretend we are listening.@@ -123,14 +126,62 @@123126                 ),124127             )125128 126-            val vault = vaultFactory() ?: return@withContext127-            runChirpSyncResponder(webSocketSyncSession(session, ws), vault)129+            // Registered by public key: that key is the only address a relay130+            // request can name (see RelayHub).131+            val link = relayHub.register(theirIdentity.longTermPublicKeyHex) { plaintext ->132+                ws.send(session.encrypt(plaintext))133+            }134+            try {135+                dispatch(link, session, ws, vaultFactory)136+            } finally {137+                relayHub.unregister(link)138+            }128139         } finally {129140             runCatching { client.close() }130141         }131142     }132143 133144     /**145+     * The multiplexer: one authenticated connection carries either this phone's146+     * own vault sync or traffic to forward blindly to another paired device, and147+     * nothing before this point distinguishes them.148+     *149+     * The connection stays in this loop after a sync round rather than closing,150+     * because a laptop can only be relayed *to* while it is connected — a phone151+     * that hung up the moment sync finished would have no one to forward to.152+     */153+    private suspend fun dispatch(154+        link: RelayLink,155+        session: NoiseNKSession,156+        ws: WebSocketConnection,157+        vaultFactory: () -> ChirpSyncVaultAdapter?,158+    ) {159+        while (true) {160+            val frame = session.decrypt((ws.receive() as? WsMessage.Binary)?.bytes ?: return)161+            when (val relay = decodeRelayMessage(frame)) {162+                null -> {163+                    // Ours to answer. Refusing while relaying is not a policy164+                    // choice: the same Noise session is already carrying someone165+                    // else's channel, and interleaving would corrupt both.166+                    if (!relayHub.beginSync(link)) return167+                    try {168+                        val vault = vaultFactory() ?: return169+                        runChirpSyncResponder(webSocketSyncSession(session, ws, pushback = frame), vault)170+                    } finally {171+                        relayHub.endSync(link)172+                    }173+                }174+                is RelayMessage.Request -> relayHub.onRequest(link, relay.targetDeviceId)175+                is RelayMessage.Payload -> if (!relayHub.forward(link, frame)) link.send(RelayMessage.Close)176+                RelayMessage.Close -> relayHub.closeChannel(link)177+                // The rest are phone→peer answers. A peer sending one back is178+                // either confused or probing; either way there is nothing to do.179+                else -> {}180+            }181+        }182+    }183+184+    /**134185      * A rotated key on a known device id is a different device, or an attempt135186      * to become one. Either way it is not this peer, and re-pairing is the136187      * only correct route back.@@ -141,15 +192,27 @@141192     }142193 }143194 144-/** Bridges the sync coordinator's frame IO onto a WebSocket. */145-internal fun webSocketSyncSession(session: NoiseNKSession, ws: WebSocketConnection): ChirpSyncSession =146-    ChirpSyncSession(195+/**196+ * Bridges the sync coordinator's frame IO onto a WebSocket.197+ *198+ * [pushback] is the frame the relay dispatcher had to read and decrypt before it199+ * could tell whose traffic this was — the coordinator's first `receive` gets it200+ * back, or SYNC_HELLO would be missing and every reconnect would time out.201+ */202+internal fun webSocketSyncSession(203+    session: NoiseNKSession,204+    ws: WebSocketConnection,205+    pushback: ByteArray? = null,206+): ChirpSyncSession {207+    var pending = pushback208+    return ChirpSyncSession(147209         remoteStaticPublicKey = ByteArray(0),148210         send = { plaintext -> ws.send(session.encrypt(plaintext)) },149211         receive = {150-            session.decrypt(212+            pending?.also { pending = null } ?: session.decrypt(151213                 (ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed"),152214             )153215         },154216         close = { ws.closeSilently() },155217     )218+}

view file

app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+14 −0
@@ -612,6 +612,7 @@612612     val pairedDevices by syncVm.pairedDevices.collectAsState()613613     val pendingVouched by syncVm.pendingVouched.collectAsState()614614     val syncStatus by syncVm.status.collectAsState()615+    val relayEnabled by syncVm.relayEnabled.collectAsState()615616     val scope = rememberCoroutineScope()616617     var confirmUnpair by remember { mutableStateOf<String?>(null) }617618     var rotateConfirmOpen by remember { mutableStateOf(false) }@@ -683,6 +684,19 @@683684                 if (syncStatus != SyncStatus.Syncing) syncVm.syncNow()684685             }685686         }687+    }688+689+    SubLabel("RELAYING", fg)690+    RowLine(fg) {691+        Column(Modifier.weight(1f)) {692+            Text("Relay for other devices", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)693+            Text(694+                "Two browsers can't reach each other directly, so this phone passes their encrypted sync between them. It can't read any of it.",695+                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,696+                modifier = Modifier.padding(top = 2.dp),697+            )698+        }699+        Toggle(relayEnabled, fg) { syncVm.setRelayEnabled(it) }686700     }687701 688702     if (pendingVouched.isNotEmpty()) {

view file

app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+11 −0
@@ -9,6 +9,7 @@99 import com.peachpasswords.sync.PendingVouchedDevice1010 import com.peachpasswords.sync.PendingVouchedDeviceStore1111 import com.peachpasswords.sync.PairingDiscoveryClient12+import com.peachpasswords.sync.RelaySettingsStore1213 import com.peachpasswords.sync.SyncEndpoint1314 import com.peachpasswords.sync.SyncAlertStore1415 import com.peachpasswords.sync.SyncOutcomeStatus@@ -52,10 +53,20 @@5253     private val pendingVouchedStore: PendingVouchedDeviceStore,5354     private val discoveryClient: PairingDiscoveryClient,5455     private val syncAlertStore: SyncAlertStore,56+    private val relaySettings: RelaySettingsStore,5557 ) : ViewModel() {5658 5759     private val _status = MutableStateFlow<SyncStatus>(SyncStatus.Idle)5860     val status: StateFlow<SyncStatus> = _status.asStateFlow()61+62+    /** Whether this phone forwards sync traffic between other paired devices. */63+    private val _relayEnabled = MutableStateFlow(relaySettings.isRelayEnabled())64+    val relayEnabled: StateFlow<Boolean> = _relayEnabled.asStateFlow()65+66+    fun setRelayEnabled(on: Boolean) {67+        relaySettings.setRelayEnabled(on)68+        _relayEnabled.value = on69+    }5970 6071     /** Live list of trusted peers, backing the Sync settings card device list (C7). */6172     val pairedDevices: StateFlow<List<PairedDevice>> =

view file

app/src/test/java/com/peachpasswords/sync/SyncReconnectListenerRelayTest.kt+201 −0
@@ -0,0 +1,201 @@1+package com.peachpasswords.sync2+3+import kotlinx.coroutines.CoroutineScope4+import kotlinx.coroutines.Dispatchers5+import kotlinx.coroutines.SupervisorJob6+import kotlinx.coroutines.cancel7+import kotlinx.coroutines.runBlocking8+import okhttp3.OkHttpClient9+import okhttp3.Request10+import okhttp3.Response11+import okhttp3.WebSocket12+import okhttp3.WebSocketListener13+import okio.ByteString14+import okio.ByteString.Companion.toByteString15+import org.bouncycastle.crypto.params.X25519PrivateKeyParameters16+import org.junit.Assert.assertArrayEquals17+import org.junit.Assert.assertEquals18+import org.junit.Test19+import java.security.SecureRandom20+import java.util.concurrent.CompletableFuture21+import java.util.concurrent.LinkedBlockingQueue22+import java.util.concurrent.TimeUnit23+24+/**25+ * The dispatch itself, over a real socket and a real Noise session: two paired26+ * "laptops" connect, one asks to be relayed to the other, and a frame crosses.27+ *28+ * This is the half the design doc calls the harder one, and the part unit-level29+ * hub tests cannot reach — telling "sync traffic for my own vault" apart from30+ * "forward this blindly" happens on a decrypted frame inside the listener, and31+ * getting it wrong either hands another device's bytes to the vault or hands the32+ * vault's bytes to another device.33+ */34+class SyncReconnectListenerRelayTest {35+36+    private class FakeRegistry(devices: List<PairedDevice>) : PairedDeviceRegistry {37+        private val all = devices.toMutableList()38+        override fun getAll(): List<PairedDevice> = all39+        override fun get(id: String): PairedDevice? = all.firstOrNull { it.deviceId == id }40+        override fun upsert(device: PairedDevice) {41+            all.removeAll { it.deviceId == device.deviceId }42+            all.add(device)43+        }44+    }45+46+    private fun identity(name: String, keyHex: String) = LocalChirpIdentity(47+        deviceId = name,48+        deviceName = name,49+        deviceType = DeviceType.EXTENSION,50+        longTermPublicKeyHex = keyHex,51+        signingPublicKeyHex = "11".repeat(32),52+    )53+54+    /** A browser-side peer: dials, completes NK, presents its identity. */55+    private class Laptop(private val identity: LocalChirpIdentity) {56+        val inbox = LinkedBlockingQueue<ByteArray>()57+        private val opened = CompletableFuture<Boolean>()58+        @Volatile var trouble: String? = null59+        lateinit var ws: WebSocket60+        lateinit var session: NoiseNKSession61+62+        suspend fun connect(port: Int, serverPublicKey: ByteArray) {63+            ws = OkHttpClient().newWebSocket(64+                Request.Builder().url("ws://127.0.0.1:$port").build(),65+                object : WebSocketListener() {66+                    override fun onOpen(webSocket: WebSocket, response: Response) { opened.complete(true) }67+                    override fun onMessage(webSocket: WebSocket, bytes: ByteString) { inbox.add(bytes.toByteArray()) }68+                    override fun onMessage(webSocket: WebSocket, text: String) { trouble = "text frame: $text" }69+                    override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { trouble = "closed $code $reason" }70+                    override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { trouble = "failed: $t" }71+                },72+            )73+            opened.get(10, TimeUnit.SECONDS)74+            session = noiseNKInitiator(75+                serverPublicKey,76+                send = { ws.send(it.toByteString()) },77+                receive = { take() },78+            )79+            ws.send(session.encrypt(encodePairIdentityFrame(identity)).toByteString())80+            session.decrypt(take()) // the phone's own PAIR_IDENTITY81+        }82+83+        fun send(message: RelayMessage) = ws.send(session.encrypt(message.encodeToBytes()).toByteString())84+85+        /** Sends bytes the relay has no business parsing. */86+        fun sendRaw(plaintext: ByteArray) = ws.send(session.encrypt(plaintext).toByteString())87+88+        fun next(): RelayMessage? = decodeRelayMessage(session.decrypt(take()))89+90+        fun nextRaw(): ByteArray = session.decrypt(take())91+92+        private fun take(): ByteArray =93+            inbox.poll(10, TimeUnit.SECONDS) ?: throw AssertionError("no frame from the phone (${trouble ?: "socket still open"})")94+    }95+96+    @Test fun `one laptop is relayed to another and frames cross untouched`() = runBlocking {97+        val priv = X25519PrivateKeyParameters(SecureRandom())98+        val serverPub = priv.generatePublicKey().encoded99+        val keyA = "aa".repeat(32)100+        val keyB = "bb".repeat(32)101+        val registry = FakeRegistry(102+            listOf(103+                PairedDevice(deviceId = "A", longTermPublicKeyHex = keyA, pairedAt = 0),104+                PairedDevice(deviceId = "B", longTermPublicKeyHex = keyB, pairedAt = 0),105+            ),106+        )107+        val hub = RelayHub(object : RelayPolicy { override fun isRelayEnabled() = true })108+        val listener = SyncReconnectListener(registry, hub)109+        val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)110+111+        try {112+            listener.start(scope, priv.encoded, serverPub, identity("phone", "cc".repeat(32)), port = 0) { null }113+            val port = awaitBound(listener)114+115+            val a = Laptop(identity("A", keyA))116+            val b = Laptop(identity("B", keyB))117+            a.connect(port, serverPub)118+            b.connect(port, serverPub)119+120+            a.send(RelayMessage.Request(keyB))121+            assertEquals(RelayMessage.Open(keyA), b.next())122+            assertEquals(RelayMessage.Accept, a.next())123+124+            // Deliberately not a well-formed RelayMessage body: the relay must125+            // move the bytes, not understand them.126+            val opaque = """{"type":"RELAY_FRAME","payload":"c2VjcmV0"}""".toByteArray()127+            a.sendRaw(opaque)128+            assertArrayEquals(opaque, b.nextRaw())129+130+            val back = """{"type":"RELAY_FRAME","payload":"cmVwbHk="}""".toByteArray()131+            b.sendRaw(back)132+            assertArrayEquals(back, a.nextRaw())133+        } finally {134+            listener.stop()135+            scope.cancel()136+        }137+    }138+139+    @Test fun `a request for a laptop that is not connected is answered as unavailable`() = runBlocking {140+        val priv = X25519PrivateKeyParameters(SecureRandom())141+        val serverPub = priv.generatePublicKey().encoded142+        val keyA = "aa".repeat(32)143+        val registry = FakeRegistry(listOf(PairedDevice(deviceId = "A", longTermPublicKeyHex = keyA, pairedAt = 0)))144+        val hub = RelayHub(object : RelayPolicy { override fun isRelayEnabled() = true })145+        val listener = SyncReconnectListener(registry, hub)146+        val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)147+148+        try {149+            listener.start(scope, priv.encoded, serverPub, identity("phone", "cc".repeat(32)), port = 0) { null }150+            val port = awaitBound(listener)151+152+            val a = Laptop(identity("A", keyA))153+            a.connect(port, serverPub)154+            a.send(RelayMessage.Request("bb".repeat(32)))155+156+            assertEquals(RelayMessage.Unavailable, a.next())157+        } finally {158+            listener.stop()159+            scope.cancel()160+        }161+    }162+163+    @Test fun `relaying switched off is answered as disabled so the laptop tries another relay`() = runBlocking {164+        val priv = X25519PrivateKeyParameters(SecureRandom())165+        val serverPub = priv.generatePublicKey().encoded166+        val keyA = "aa".repeat(32)167+        val keyB = "bb".repeat(32)168+        val registry = FakeRegistry(169+            listOf(170+                PairedDevice(deviceId = "A", longTermPublicKeyHex = keyA, pairedAt = 0),171+                PairedDevice(deviceId = "B", longTermPublicKeyHex = keyB, pairedAt = 0),172+            ),173+        )174+        val hub = RelayHub(object : RelayPolicy { override fun isRelayEnabled() = false })175+        val listener = SyncReconnectListener(registry, hub)176+        val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)177+178+        try {179+            listener.start(scope, priv.encoded, serverPub, identity("phone", "cc".repeat(32)), port = 0) { null }180+            val port = awaitBound(listener)181+182+            val a = Laptop(identity("A", keyA))183+            val b = Laptop(identity("B", keyB))184+            a.connect(port, serverPub)185+            b.connect(port, serverPub)186+187+            a.send(RelayMessage.Request(keyB))188+            assertEquals(RelayMessage.Disabled, a.next())189+        } finally {190+            listener.stop()191+            scope.cancel()192+        }193+    }194+195+    /** start() binds on an IO coroutine, so the port is not up on return. */196+    private fun awaitBound(listener: SyncReconnectListener): Int {197+        val deadline = System.currentTimeMillis() + 10_000198+        while (listener.boundPort == null && System.currentTimeMillis() < deadline) Thread.sleep(20)199+        return listener.boundPort ?: throw AssertionError("listener never bound")200+    }201+}

view file