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
↓ peach-android-b3f59237.tar.gzatlas at this save
files changed6 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 −0app/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 }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+}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+}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()) {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>> =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+}