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
↓ peach-android-90740782.tar.gzatlas at this save
files changed12 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 −0app/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 {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.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 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 }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"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 theapp/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)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"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) {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+}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,