90740782e940

fail closed on private relay with no address: relayInForce gate, PRIVATE_RELAY_UNSET outcome, honest empty relay field (v2.5.9)

time
2026-07-26 19:36 UTC · 2 months ago
machine
koh2
agent
claude-opus-5
session
android private relay fail closed
parent
29ce33a356c2
12 files VERSION+1 −1app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/CloudRelayReconnectListener.kt+17 −5app/src/main/java/com/peachpasswords/sync/RelayAdoptionHost.kt+13 −1app/src/main/java/com/peachpasswords/sync/RelaySettingsStore.kt+54 −8app/src/main/java/com/peachpasswords/sync/SyncOutcomeReason.kt+11 −11app/src/main/java/com/peachpasswords/ui/pairing/CodePairScreen.kt+4 −0app/src/main/java/com/peachpasswords/ui/pairing/CodePairViewModel.kt+4 −3app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+15 −4app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+12 −8app/src/test/java/com/peachpasswords/sync/RelayGateTest.kt+94 −0app/src/test/java/com/peachpasswords/sync/SyncOutcomeTest.kt+1 −0
VERSION+1 −1
@@ -1 +1 @@1-2.5.81+2.5.9

view file

app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 24010820-        versionName "2.5.8"19+        versionCode 24010920+        versionName "2.5.9"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/sync/CloudRelayReconnectListener.kt+17 −5
@@ -116,10 +116,22 @@116116         localIdentity: LocalDeviceIdentity,117117         vaultFactory: () -> SyncVaultAdapter?,118118     ) {119-        if (!relaySettings.isSyncEnabled()) {120-            watchers.values.forEach(Job::cancel)121-            watchers.clear()122-            return119+        val meshRelayUrl = when (val decision = relaySettings.relayInForce()) {120+            is RelayDecision.Refuse -> {121+                watchers.values.forEach(Job::cancel)122+                watchers.clear()123+                // This loop is the only thing running when nobody is looking,124+                // so it is where a misconfiguration has to become visible —125+                // otherwise "private relay, no address" is just a phone that126+                // never syncs. Only on change: re-reporting the same reason127+                // every minute would rewrite the store for nothing.128+                if (syncOutcomes.summary.value.reason != decision.reason) {129+                    syncOutcomes.report(SyncOutcomeSummary(reason = decision.reason))130+                }131+                return132+            }133+134+            is RelayDecision.Dial -> decision.relayUrl123135         }124136 125137         val now = System.currentTimeMillis() / 1000L@@ -131,7 +143,7 @@131143                     ?: continue132144                 val relayUrl = device.relayUrl133145                     ?.let { runCatching { Rendezvous.normalizeRelayUrl(it) }.getOrNull() }134-                    ?: relaySettings.relayUrl()146+                    ?: meshRelayUrl135147                 // One role per peer, never both: our own dialler and our own136148                 // waiter would otherwise splice with each other and hold the137149                 // room against the peer they were both there for.

view file

app/src/main/java/com/peachpasswords/sync/RelayAdoptionHost.kt+13 −1
@@ -41,7 +41,19 @@4141         sourceDeps: AdoptionSourceDeps,4242         windowMs: Long = PAIRING_WINDOW_MS,4343     ): Flow<RelayAdoptionEvent> = callbackFlow {44-        val relayUrl = relaySettings.relayUrl()44+        // Pairing is also how this mesh's relay reaches a new peer: the address45+        // in the invitation is the one the receiver will dial for ever after.46+        // Handing out Peach's relay while the user has asked for a private one47+        // would push the same lie one layer down, into the mesh.48+        val relayUrl = when (val decision = relaySettings.relayInForce()) {49+            is RelayDecision.Refuse -> {50+                trySend(RelayAdoptionEvent.Rejected(decision.reason.wire()))51+                close()52+                return@callbackFlow53+            }54+55+            is RelayDecision.Dial -> decision.relayUrl56+        }4557         val token = Rendezvous.newPairingToken()4658         val invitation = createPairingInvitation(relayUrl, token)4759 

view file

app/src/main/java/com/peachpasswords/sync/RelaySettingsStore.kt+54 −8
@@ -39,6 +39,43 @@3939 }4040 4141 /**42+ * The relay to dial, or why there is none. Single gate for every sync and43+ * pairing entry point, so a new caller cannot forget one of the two ways sync44+ * can be unusable — and so a URL only ever reaches the wire out of [Dial].45+ */46+sealed interface RelayDecision {47+    @JvmInline48+    value class Dial(val relayUrl: String) : RelayDecision49+    data class Refuse(val reason: SyncOutcomeReason) : RelayDecision50+}51+52+/**53+ * [SyncMode.PRIVATE] chosen but no relay entered yet. The stored URL still54+ * reads as the public default in that state, so syncing would quietly put this55+ * phone on Peach's relay under a "Private relay" label — the exact trap the56+ * retired `lanOnly` flag set. Every entry point fails closed on it instead.57+ */58+fun isPrivateRelayUnconfigured(mode: SyncMode, storedRelayUrl: String): Boolean =59+    mode == SyncMode.PRIVATE && storedRelayUrl == Rendezvous.DEFAULT_RELAY_URL60+61+/**62+ * A custom URL implies [SyncMode.PRIVATE]; Peach's own relay stays63+ * [SyncMode.PUBLIC]. Adoption hands the source's relay over the same path, so64+ * labelling that "private" would strand every device that joined over the65+ * public relay in the unconfigured state below.66+ */67+fun modeForRelayUrl(normalizedRelayUrl: String): SyncMode =68+    if (normalizedRelayUrl == Rendezvous.DEFAULT_RELAY_URL) SyncMode.PUBLIC else SyncMode.PRIVATE69+70+fun relayInForce(mode: SyncMode, storedRelayUrl: String): RelayDecision = when {71+    mode == SyncMode.OFF -> RelayDecision.Refuse(SyncOutcomeReason.SYNC_DISABLED)72+    isPrivateRelayUnconfigured(mode, storedRelayUrl) ->73+        RelayDecision.Refuse(SyncOutcomeReason.PRIVATE_RELAY_UNSET)74+    mode == SyncMode.PUBLIC -> RelayDecision.Dial(Rendezvous.DEFAULT_RELAY_URL)75+    else -> RelayDecision.Dial(storedRelayUrl)76+}77+78+/**4279  * This handset's sync configuration. Plain SharedPreferences rather than the4380  * encrypted store: none of it is a secret, and it must not travel to other4481  * devices the way a vault setting would.@@ -66,21 +103,30 @@66103         prefs.edit().putString(KEY_MODE, mode.wire()).apply()67104     }68105 69-    fun isSyncEnabled(): Boolean = syncMode() != SyncMode.OFF106+    /** The relay to dial, or the reason there is none. */107+    fun relayInForce(): RelayDecision = relayInForce(syncMode(), storedRelayUrl())70108 71-    /** The relay in force: always the default under [SyncMode.PUBLIC]. */72-    fun relayUrl(): String =73-        if (syncMode() == SyncMode.PUBLIC) Rendezvous.DEFAULT_RELAY_URL else storedRelayUrl()109+    /**110+     * The private relay as the user should see it: blank when none is set.111+     * Never Peach's own URL wearing a private label — an empty field is the112+     * honest representation of "not configured".113+     */114+    fun privateRelayUrl(): String =115+        storedRelayUrl().takeIf { it != Rendezvous.DEFAULT_RELAY_URL }.orEmpty()74116 75117     /**76-     * Point this mesh at a self-hosted relay. Implies [SyncMode.PRIVATE] —77-     * offering a custom relay and not switching to it would be a trap.118+     * Point this mesh at a relay by URL. A custom URL implies119+     * [SyncMode.PRIVATE] — offering one and not switching to it would be a120+     * trap. Peach's own relay stays [SyncMode.PUBLIC]: adoption hands the121+     * source's relay over this same path, so calling that "private" would122+     * mislabel every device that joined over the public relay and strand it in123+     * the unconfigured-private state above.78124      */79-    fun setPrivateRelayUrl(input: String): String {125+    fun setRelayUrl(input: String): String {80126         val normalized = Rendezvous.normalizeRelayUrl(input)81127         prefs.edit()82128             .putString(KEY_URL, normalized)83-            .putString(KEY_MODE, SyncMode.PRIVATE.wire())129+            .putString(KEY_MODE, modeForRelayUrl(normalized).wire())84130             .apply()85131         return normalized86132     }

view file

app/src/main/java/com/peachpasswords/sync/SyncOutcomeReason.kt+11 −11
@@ -14,17 +14,11 @@1414  * swallowed by the reconnect loop's `runCatching`, so "synced fine", "the peer1515  * refused us" and "the vault was locked" were one indistinguishable state.1616  *17- * Two browser outcomes are deliberately absent, because on Android they name18- * states that cannot occur:19- *  - `runner_unavailable` — the browser injects its per-device sync runner and20- *    can find it missing. Android's runner is [PeachSyncService], which is21- *    always present; the only way it fails to run is a locked vault, which is22- *    [VAULT_LOCKED].23- *  - `private_relay_unset` — the browser treats "private mode, no address" as24- *    terminal. `RelaySettingsStore.relayUrl()` always falls back to the public25- *    relay instead, so here that configuration syncs rather than failing, and26- *    a private URL equal to the public one is indistinguishable from none.27- *    Reporting it would be a guess.17+ * One browser outcome is deliberately absent, because on Android it names a18+ * state that cannot occur: `runner_unavailable` — the browser injects its19+ * per-device sync runner and can find it missing. Android's runner is20+ * [PeachSyncService], which is always present; the only way it fails to run is21+ * a locked vault, which is [VAULT_LOCKED].2822  *2923  * `no_eligible_host` is kept but means something narrower than the browser's:3024  * the browser rejects peers it cannot dial, whereas this phone usually *waits*@@ -38,6 +32,7 @@3832     NO_PAIRED_DEVICES,3933     NO_ELIGIBLE_HOST,4034     SYNC_DISABLED,35+    PRIVATE_RELAY_UNSET,4136     VAULT_LOCKED,4237     RELAY_UNREACHABLE,4338     HANDSHAKE_FAILED,@@ -46,10 +41,14 @@4641     RUN_FAILED,4742     ;4843 44+    /** The browser's snake_case spelling, for the screens that carry a code. */45+    fun wire(): String = name.lowercase()46+4947     /** Outcomes where sync did not do what was asked, so the light goes red. */5048     val isFailure: Boolean5149         get() = when (this) {5250             NO_ELIGIBLE_HOST,51+            PRIVATE_RELAY_UNSET,5352             VAULT_LOCKED,5453             RELAY_UNREACHABLE,5554             HANDSHAKE_FAILED,@@ -130,6 +129,7 @@130129         SyncOutcomeReason.NO_PAIRED_DEVICES -> "No devices paired yet"131130         SyncOutcomeReason.NO_ELIGIBLE_HOST -> "Paired, but no device here can accept a sync"132131         SyncOutcomeReason.SYNC_DISABLED -> "Sync is switched off"132+        SyncOutcomeReason.PRIVATE_RELAY_UNSET -> "Private relay chosen but no address set"133133         SyncOutcomeReason.VAULT_LOCKED -> "Vault was locked — unlock and sync again"134134         SyncOutcomeReason.RELAY_UNREACHABLE -> "Couldn't reach the relay"135135         SyncOutcomeReason.HANDSHAKE_FAILED -> "Reached the relay, but the device didn't answer"

view file

app/src/main/java/com/peachpasswords/ui/pairing/CodePairScreen.kt+4 −0
@@ -205,6 +205,10 @@205205     "vault_locked" -> "Unlock your vault first — pairing shares the key that protects it."206206     "no_local_network" -> "This phone isn't on a Wi-Fi network, so there's no address to hand out."207207     "sas_mismatch" -> "The codes didn't match, so nothing was saved. That's the right outcome if you didn't expect a connection."208+    "sync_disabled" -> "Sync is switched off for this vault. Turn it on in Settings → Sync first."209+    // Pairing hands the relay address to the other device, so there is nothing210+    // honest to put in the code until one exists.211+    "private_relay_unset" -> "Private relay chosen but no address set. Enter your relay URL in Settings → Sync first."208212     // Every other reason collapsed into this one sentence, which is fine for a209213     // user and useless for anyone debugging: "bad_token", "no_hello" and a raw210214     // exception message all looked identical on screen. Debug builds show the

view file

app/src/main/java/com/peachpasswords/ui/pairing/CodePairViewModel.kt+4 −3
@@ -11,7 +11,7 @@1111 import com.peachpasswords.sync.RelayAdoptionEvent1212 import com.peachpasswords.sync.RelayAdoptionHost1313 import com.peachpasswords.sync.RelaySettingsStore14-import com.peachpasswords.sync.SyncMode14+import com.peachpasswords.sync.RelayDecision1515 import com.peachpasswords.sync.AdoptionSourceDeps1616 import com.peachpasswords.sync.AdoptionFrame1717 import com.peachpasswords.sync.LocalDeviceIdentity@@ -81,8 +81,9 @@8181             _state.value = CodePairUiState.Failed("vault_locked")8282             return8383         }84-        if (relaySettings.syncMode() == SyncMode.OFF) {85-            _state.value = CodePairUiState.Failed("sync_disabled")84+        val decision = relaySettings.relayInForce()85+        if (decision is RelayDecision.Refuse) {86+            _state.value = CodePairUiState.Failed(decision.reason.wire())8687             return8788         }8889         PeachSyncService.start(appContext)

view file

app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+15 −4
@@ -646,7 +646,7 @@646646     val pairedDevices by syncVm.pairedDevices.collectAsState()647647     val pendingVouched by syncVm.pendingVouched.collectAsState()648648     val syncStatus by syncVm.status.collectAsState()649-    val configuredRelayUrl by syncVm.relayUrl.collectAsState()649+    val configuredRelayUrl by syncVm.privateRelayUrl.collectAsState()650650     val syncMode by syncVm.syncMode.collectAsState()651651     val scope = rememberCoroutineScope()652652     var confirmUnpair by remember { mutableStateOf<String?>(null) }@@ -816,11 +816,22 @@816816                 modifier = Modifier.fillMaxWidth(),817817             )818818         }819+        // Private with no address is not the public relay. Say so here rather820+        // than letting the phone sync through Peach under a private label.821+        val needsRelayUrl = configuredRelayUrl.isEmpty()819822         RowLine(fg) {820-            ValueText(relayUrlStatus ?: "Only secure wss:// relays are accepted.", fg)823+            val message = relayUrlStatus824+                ?: if (needsRelayUrl) {825+                    "Sync is paused until you enter your relay URL."826+                } else {827+                    "Only secure wss:// relays are accepted."828+                }829+            val warn = relayUrlStatus?.startsWith("Use") == true ||830+                (relayUrlStatus == null && needsRelayUrl)831+            ValueText(message, if (warn) peachColors.Breach else fg)821832             SubtleButton("save", fg) {822-                relayUrlStatus = if (syncVm.setPrivateRelayUrl(relayUrlDraft)) {823-                    relayUrlDraft = syncVm.relayUrl.value833+                relayUrlStatus = if (syncVm.setRelayUrl(relayUrlDraft)) {834+                    relayUrlDraft = syncVm.privateRelayUrl.value824835                     "Saved"825836                 } else {826837                     "Use a valid wss:// relay URL"

view file

app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+12 −8
@@ -10,6 +10,7 @@1010 import com.peachpasswords.sync.PendingVouchedDeviceStore1111 import com.peachpasswords.sync.hexToPubkey1212 import com.peachpasswords.sync.PeachSyncService13+import com.peachpasswords.sync.RelayDecision1314 import com.peachpasswords.sync.RelaySettingsStore1415 import com.peachpasswords.sync.SyncMode1516 import com.peachpasswords.sync.SyncAlertStore@@ -59,8 +60,9 @@5960     /** The last sync attempt, whatever it was. Survives process death. */6061     val status: StateFlow<SyncOutcomeSummary> = syncOutcomes.summary6162 62-    private val _relayUrl = MutableStateFlow(relaySettings.relayUrl())63-    val relayUrl: StateFlow<String> = _relayUrl.asStateFlow()63+    /** Blank while no private relay is configured, so the field stays honest. */64+    private val _privateRelayUrl = MutableStateFlow(relaySettings.privateRelayUrl())65+    val privateRelayUrl: StateFlow<String> = _privateRelayUrl.asStateFlow()6466 6567 6668     private val _syncMode = MutableStateFlow(relaySettings.syncMode())@@ -69,12 +71,13 @@6971     fun setSyncMode(mode: SyncMode) {7072         relaySettings.setSyncMode(mode)7173         _syncMode.value = relaySettings.syncMode()72-        _relayUrl.value = relaySettings.relayUrl()74+        _privateRelayUrl.value = relaySettings.privateRelayUrl()7375     }7476 75-    /** Saving a URL implies private mode. Returns false on an invalid URL. */76-    fun setPrivateRelayUrl(input: String): Boolean = runCatching {77-        _relayUrl.value = relaySettings.setPrivateRelayUrl(input)77+    /** A custom URL implies private mode. Returns false on an invalid URL. */78+    fun setRelayUrl(input: String): Boolean = runCatching {79+        relaySettings.setRelayUrl(input)80+        _privateRelayUrl.value = relaySettings.privateRelayUrl()7881         _syncMode.value = relaySettings.syncMode()7982         true8083     }.getOrDefault(false)@@ -158,8 +161,9 @@158161      * comes from whoever actually finishes it.159162      */160163     fun syncNow() {161-        if (!relaySettings.isSyncEnabled()) {162-            syncOutcomes.report(SyncOutcomeSummary(reason = SyncOutcomeReason.SYNC_DISABLED))164+        val decision = relaySettings.relayInForce()165+        if (decision is RelayDecision.Refuse) {166+            syncOutcomes.report(SyncOutcomeSummary(reason = decision.reason))163167             return164168         }165169         if (!vault.isUnlocked.value) {

view file

app/src/test/java/com/peachpasswords/sync/RelayGateTest.kt+94 −0
@@ -0,0 +1,94 @@1+package com.peachpasswords.sync2+3+import org.junit.Assert.assertEquals4+import org.junit.Assert.assertFalse5+import org.junit.Assert.assertNull6+import org.junit.Assert.assertTrue7+import org.junit.Test8+9+/**10+ * The fail-closed relay gate, mirroring the browser's `sync-settings.test.ts`.11+ * Everything here is the pure decision; the store around it is only12+ * SharedPreferences and a Context this suite has no reason to stand up.13+ */14+class RelayGateTest {15+    private val selfHosted = "wss://relay.example.com"16+17+    @Test18+    fun `public dials peach's relay`() {19+        assertEquals(20+            RelayDecision.Dial(Rendezvous.DEFAULT_RELAY_URL),21+            relayInForce(SyncMode.PUBLIC, Rendezvous.DEFAULT_RELAY_URL),22+        )23+    }24+25+    @Test26+    fun `private with an address dials it`() {27+        assertEquals(28+            RelayDecision.Dial(selfHosted),29+            relayInForce(SyncMode.PRIVATE, selfHosted),30+        )31+    }32+33+    @Test34+    fun `private with no address refuses instead of falling back to public`() {35+        assertTrue(isPrivateRelayUnconfigured(SyncMode.PRIVATE, Rendezvous.DEFAULT_RELAY_URL))36+        assertEquals(37+            RelayDecision.Refuse(SyncOutcomeReason.PRIVATE_RELAY_UNSET),38+            relayInForce(SyncMode.PRIVATE, Rendezvous.DEFAULT_RELAY_URL),39+        )40+    }41+42+    @Test43+    fun `off refuses with sync_disabled`() {44+        assertEquals(45+            RelayDecision.Refuse(SyncOutcomeReason.SYNC_DISABLED),46+            relayInForce(SyncMode.OFF, selfHosted),47+        )48+        assertEquals("sync_disabled", SyncOutcomeReason.SYNC_DISABLED.wire())49+        assertEquals("private_relay_unset", SyncOutcomeReason.PRIVATE_RELAY_UNSET.wire())50+    }51+52+    @Test53+    fun `saving peach's own url lands on public, not a broken private`() {54+        assertEquals(SyncMode.PUBLIC, modeForRelayUrl(Rendezvous.DEFAULT_RELAY_URL))55+        assertEquals(SyncMode.PRIVATE, modeForRelayUrl(selfHosted))56+        // Adoption hands the source's relay through this same path, so a57+        // receiver that joined over the public relay must not be relabelled58+        // private and stranded in the unconfigured state.59+        assertFalse(60+            isPrivateRelayUnconfigured(61+                modeForRelayUrl(Rendezvous.DEFAULT_RELAY_URL),62+                Rendezvous.DEFAULT_RELAY_URL,63+            ),64+        )65+    }66+67+    /**68+     * The one guarantee behind mesh propagation: [RelayAdoptionHost] and the69+     * reconnect loop can only obtain a URL out of [RelayDecision.Dial], so an70+     * unconfigured private relay leaves nothing to put in an invitation and71+     * nothing to hand a peer.72+     */73+    @Test74+    fun `no relay is available to push to peers while private is unconfigured`() {75+        val decision = relayInForce(SyncMode.PRIVATE, Rendezvous.DEFAULT_RELAY_URL)76+        assertNull((decision as? RelayDecision.Dial)?.relayUrl)77+        assertNull((relayInForce(SyncMode.OFF, selfHosted) as? RelayDecision.Dial)?.relayUrl)78+    }79+80+    @Test81+    fun `a stored mode the app no longer understands is not a crash`() {82+        assertNull(SyncMode.parse("lan_only"))83+        assertNull(SyncMode.parse(null))84+        assertEquals(SyncMode.PRIVATE, SyncMode.parse("private"))85+    }86+87+    @Test88+    fun `the private-relay refusal is a red light with the browser's words`() {89+        val summary = SyncOutcomeSummary(reason = SyncOutcomeReason.PRIVATE_RELAY_UNSET)90+        assertTrue(SyncOutcomeReason.PRIVATE_RELAY_UNSET.isFailure)91+        assertEquals(SyncIndicator.ERROR, syncIndicator(summary, pairedCount = 1))92+        assertEquals("Private relay chosen but no address set", describeSyncOutcome(summary))93+    }94+}

view file

app/src/test/java/com/peachpasswords/sync/SyncOutcomeTest.kt+1 −0
@@ -39,6 +39,7 @@3939         assertEquals(4040             setOf(4141                 SyncOutcomeReason.NO_ELIGIBLE_HOST,42+                SyncOutcomeReason.PRIVATE_RELAY_UNSET,4243                 SyncOutcomeReason.VAULT_LOCKED,4344                 SyncOutcomeReason.RELAY_UNREACHABLE,4445                 SyncOutcomeReason.HANDSHAKE_FAILED,

view file