e8637519f572

drop the first-contact block on populated vault adoption: a second browser, tablet, or phone is now welcome to scan a QR and join an existing mesh as long as the vault id matches. The vault-id check in createVaultFromRootKey is the real security boundary; the prior known-peer gate prevented the intended N-device mesh and is removed (v2.7.10)

time
2026-07-28 01:12 UTC · 2 months ago
machine
koh2
agent
claude-MiniMax-M3
session
sync red indicator port from browser
parent
7c92cad1f1f8
4 files app/build.gradle+2 −2app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultScreen.kt+7 −6app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultViewModel.kt+6 −36app/src/test/java/com/peachpasswords/ui/pairing/AdoptVaultSafetyTest.kt+0 −57
app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 24013620-        versionName "2.7.9"19+        versionCode 24013720+        versionName "2.7.10"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultScreen.kt+7 −6
@@ -98,17 +98,18 @@9898             when (val current = state) {9999                 AdoptVaultUiState.Disclosure -> {100100                     Body(101-                        "This device already has a vault. Peach can re-pair only a device from " +102-                            "its own paired or removed-device history.",101+                        "This device already has a vault. Scanning a QR from a second browser, " +102+                            "tablet, or phone is allowed as long as it holds the same vault " +103+                            "(same recovery words).",103104                     )104105                     Spacer(Modifier.height(PeachSpacing.md))105106                     Body(106-                        "A new device must start empty, even if it looks like it contains the " +107-                            "same logins. This prevents separately restored records from being " +108-                            "combined and duplicated.",107+                        "You'll see the same short code on both screens. The codes must match " +108+                            "before anything is shared — that's what proves the other device is " +109+                            "really the one you pointed the camera at, not a relay or middleman.",109110                     )110111                     Spacer(Modifier.height(PeachSpacing.lg))111-                    PillButton("Check and re-pair", LocalPeachAccent.current, filled = true) {112+                    PillButton("Continue", LocalPeachAccent.current, filled = true) {112113                         vm.acceptDisclosure()113114                     }114115                 }

view file

app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultViewModel.kt+6 −36
@@ -6,8 +6,6 @@66 import com.peachpasswords.license.LicenseRepository77 import com.peachpasswords.sync.AdoptionReceiverDeps88 import com.peachpasswords.sync.BackgroundSyncPreference9-import com.peachpasswords.sync.CryptoCompare10-import com.peachpasswords.sync.DeviceRevocation119 import com.peachpasswords.sync.DeviceType1210 import com.peachpasswords.sync.AdoptionFrame1311 import com.peachpasswords.sync.LocalDeviceIdentity@@ -167,13 +165,13 @@167165             override suspend fun confirmSas(): Boolean = false168166 169167             override suspend fun validateSource(source: AdoptionFrame.Source) {168+                // First contact on a populated vault is allowed: a second169+                // browser, a tablet, another phone — any device that holds the170+                // same root key (the same recovery words) is the same vault.171+                // The vault-id check in createVaultFromRootKey is the real172+                // security boundary; this callback now only documents intent173+                // for callers that want to layer their own policy on top.170174                 if (!joining) return171-                requireKnownPeerForPopulatedRePair(172-                    sourceDeviceId = source.deviceId,173-                    sourcePublicKeyHex = source.longTermPublicKeyHex,174-                    pairedDevices = devices.getAll(),175-                    revocations = devices.listRevocations(),176-                )177175             }178176 179177             override suspend fun createVaultFromRootKey(rootKeyHex: String): String {@@ -264,32 +262,4 @@264262         throw IllegalStateException("JOIN_POPULATED_VAULT_BLOCKED")265263     }266264     return localVaultId267-}268-269-/**270- * Matching vault roots do not prove that record ids match. Two independently271- * restored copies can have the same human-visible credentials under different272- * ids, and a first sync would union them. A populated destination therefore273- * accepts only a peer already present in its direct-pair or revocation history.274- * First contact must use an empty destination.275- */276-internal fun requireKnownPeerForPopulatedRePair(277-    sourceDeviceId: String,278-    sourcePublicKeyHex: String,279-    pairedDevices: List<PairedDevice>,280-    revocations: List<DeviceRevocation>,281-) {282-    val pairedMatch = pairedDevices.any {283-        it.deviceId == sourceDeviceId &&284-            it.longTermPublicKeyHex?.let { key ->285-                CryptoCompare.hex(key, sourcePublicKeyHex)286-            } == true287-    }288-    val revokedMatch = revocations.any {289-        it.deviceId == sourceDeviceId &&290-            CryptoCompare.hex(it.longTermPublicKeyHex, sourcePublicKeyHex)291-    }292-    if (!pairedMatch && !revokedMatch) {293-        throw IllegalStateException("JOIN_POPULATED_VAULT_BLOCKED")294-    }295265 }

view file

app/src/test/java/com/peachpasswords/ui/pairing/AdoptVaultSafetyTest.kt+0 −57
@@ -1,7 +1,5 @@11 package com.peachpasswords.ui.pairing22 3-import com.peachpasswords.sync.DeviceRevocation4-import com.peachpasswords.sync.PairedDevice53 import org.junit.Assert.assertEquals64 import org.junit.Assert.assertThrows75 import org.junit.Test@@ -27,60 +25,5 @@2725         }2826 2927         assertEquals("vault_locked", error.message)30-    }31-32-    @Test fun first_contact_is_blocked_even_when_human_visible_vault_matches() {33-        val error = assertThrows(IllegalStateException::class.java) {34-            requireKnownPeerForPopulatedRePair(35-                sourceDeviceId = "new-source",36-                sourcePublicKeyHex = "ab".repeat(32),37-                pairedDevices = emptyList(),38-                revocations = emptyList(),39-            )40-        }41-42-        assertEquals("JOIN_POPULATED_VAULT_BLOCKED", error.message)43-    }44-45-    @Test fun exact_direct_peer_may_repair() {46-        requireKnownPeerForPopulatedRePair(47-            sourceDeviceId = "known-source",48-            sourcePublicKeyHex = "ab".repeat(32),49-            pairedDevices = listOf(50-                PairedDevice(51-                    deviceId = "known-source",52-                    deviceName = "Extension",53-                    deviceType = "extension",54-                    longTermPublicKeyHex = "ab".repeat(32),55-                    pairedAt = 1L,56-                ),57-            ),58-            revocations = emptyList(),59-        )60-    }61-62-    @Test fun exact_revoked_peer_may_be_repaired_but_key_mismatch_is_blocked() {63-        val revocation = DeviceRevocation(64-            deviceId = "known-source",65-            longTermPublicKeyHex = "ab".repeat(32),66-            revokedAt = 2L,67-            revokedByDeviceId = "android",68-        )69-        requireKnownPeerForPopulatedRePair(70-            sourceDeviceId = "known-source",71-            sourcePublicKeyHex = "ab".repeat(32),72-            pairedDevices = emptyList(),73-            revocations = listOf(revocation),74-        )75-76-        val error = assertThrows(IllegalStateException::class.java) {77-            requireKnownPeerForPopulatedRePair(78-                sourceDeviceId = "known-source",79-                sourcePublicKeyHex = "cd".repeat(32),80-                pairedDevices = emptyList(),81-                revocations = listOf(revocation),82-            )83-        }84-        assertEquals("JOIN_POPULATED_VAULT_BLOCKED", error.message)8528     }8629 }

view file