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
↓ peach-android-e8637519.tar.gzatlas at this save
files changed4 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 −57app/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 {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 }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 }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 }