d620000dab23

feat: Make pairing safe, QR-only, and single-confirm (v2.5.22)

time
2026-07-27 19:42 UTC · 2 months ago
machine
koh2
parent
d2b3473d34ed
12 files app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/RelayAdoptionHost.kt+6 −6app/src/main/java/com/peachpasswords/sync/RelayAdoptionReceiver.kt+4 −0app/src/main/java/com/peachpasswords/sync/VaultAdopt.kt+34 −0app/src/main/java/com/peachpasswords/ui/onboarding/OnboardingScreen.kt+36 −10app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultScreen.kt+28 −87app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultViewModel.kt+80 −42app/src/main/java/com/peachpasswords/ui/pairing/CodePairScreen.kt+5 −16app/src/main/java/com/peachpasswords/ui/pairing/CodePairViewModel.kt+5 −10app/src/test/java/com/peachpasswords/sync/VaultAdoptTest.kt+80 −2app/src/test/java/com/peachpasswords/ui/onboarding/OnboardingTest.kt+5 −0app/src/test/java/com/peachpasswords/ui/pairing/AdoptVaultSafetyTest.kt+86 −0
app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 24012120-        versionName "2.5.21"19+        versionCode 24012220+        versionName "2.5.22"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/sync/RelayAdoptionHost.kt+6 −6
@@ -16,10 +16,9 @@1616 sealed interface RelayAdoptionEvent {1717     /** The source is already occupying the non-buffering room; it is safe to show this. */1818     data class InvitationReady(val invitation: String) : RelayAdoptionEvent19-    data class AwaitingConfirmation(19+    data class AwaitingPeerConfirmation(2020         val sas: String,2121         val deviceName: String,22-        val session: PairingSession,2322     ) : RelayAdoptionEvent2423     data class Adopted(val deviceName: String) : RelayAdoptionEvent2524     data object Expired : RelayAdoptionEvent@@ -77,15 +76,16 @@7776                         localIdentity = localIdentity,7877                         sourceDeps = sourceDeps,7978                         confirm = { sas, request ->80-                            val pending = PairingSession()8179                             trySend(82-                                RelayAdoptionEvent.AwaitingConfirmation(80+                                RelayAdoptionEvent.AwaitingPeerConfirmation(8381                                     sas = sas,8482                                     deviceName = request.deviceName.ifBlank { "New device" },85-                                    session = pending,8683                                 ),8784                             )88-                            pending.await()85+                            // Showing a fresh invitation from an unlocked vault86+                            // is source-side authorization. The scanning device's87+                            // explicit SAS confirmation is the single commit gate.88+                            true8989                         },9090                     )9191                 }

view file

app/src/main/java/com/peachpasswords/sync/RelayAdoptionReceiver.kt+4 −0
@@ -89,6 +89,10 @@8989             override suspend fun confirmSas(): Boolean =9090                 confirm(SASDerivation.derive(noise.handshakeHash))9191 92+            override suspend fun validateSource(source: AdoptionFrame.Source) {93+                receiverDeps.validateSource(source)94+            }95+9296             override suspend fun createVaultFromRootKey(rootKeyHex: String): String =9397                 receiverDeps.createVaultFromRootKey(rootKeyHex)9498 

view file

app/src/main/java/com/peachpasswords/sync/VaultAdopt.kt+34 −0
@@ -63,7 +63,21 @@6363         @SerialName("signing_public_key_hex") val signingPublicKeyHex: String? = null,6464     ) : AdoptionFrame()6565 66+    /**67+     * Encrypted source-to-receiver acknowledgement emitted only after the source68+     * has durably recorded the receiver. The receiver cannot truthfully report a69+     * successful pair before this arrives.70+     */6671     @Serializable72+    @SerialName("ADOPT_COMPLETE")73+    data object Complete : AdoptionFrame()74+75+    /** Encrypted receiver-to-source acknowledgement after the source row is durable. */76+    @Serializable77+    @SerialName("ADOPT_APPLIED")78+    data object Applied : AdoptionFrame()79+80+    @Serializable6781     data class Source(6882         @SerialName("device_id") val deviceId: String,6983         @SerialName("device_name") val deviceName: String,@@ -87,6 +101,11 @@87101     fun generateDeviceId(): String88102     /** Root-key transfer cannot begin until this device confirms the SAS. */89103     suspend fun confirmSas(): Boolean104+    /**105+     * Local safety policy evaluated after the authenticated grant is validated106+     * but before any vault state changes.107+     */108+    suspend fun validateSource(source: AdoptionFrame.Source) {}90109 91110     /** Seal a fresh local vault under the receiver's password, reusing [rootKeyHex]. Returns the vault id. */92111     suspend fun createVaultFromRootKey(rootKeyHex: String): String@@ -144,6 +163,12 @@144163         isDeviceType(frame.deviceType) &&145164         Hex64.matches(frame.longTermPublicKeyHex)146165 166+fun isAdoptComplete(frame: AdoptionFrame?): Boolean =167+    frame is AdoptionFrame.Complete168+169+fun isAdoptApplied(frame: AdoptionFrame?): Boolean =170+    frame is AdoptionFrame.Applied171+147172 /**148173  * RECEIVER side: ask a source to adopt us. Sends ADOPT_REQUEST, verifies the149174  * granted root key actually derives the advertised vault id (catching a lying or@@ -188,6 +213,7 @@188213         rootKeyBytes.fill(0)189214     }190215 216+    deps.validateSource(grant.source)191217     val vaultId = deps.createVaultFromRootKey(grant.rootKeyHex)192218     if (vaultId != grant.vaultId) {193219         deps.onError("ADOPT_VAULT_ID_MISMATCH")@@ -205,6 +231,11 @@205231         signingPublicKeyHex = identity.signingPublicKeyHex,206232     ))207233 234+    if (!isAdoptComplete(receiveFrame(channel))) {235+        deps.onError("ADOPT_COMPLETION_MISSING")236+        return null237+    }238+208239     val sourceDevice = PairedDevice(209240         deviceId = grant.source.deviceId,210241         deviceName = grant.source.deviceName,@@ -222,6 +253,7 @@222253     // source as both paired and revoked, even transiently.223254     deps.clearPairedDeviceRevocation(sourceDevice.deviceId)224255     deps.upsertPairedDevice(sourceDevice)256+    sendFrame(channel, AdoptionFrame.Applied)225257     return sourceDevice226258 }227259 @@ -277,6 +309,8 @@277309         pairedAt = nowMs(),278310     )279311     deps.upsertPairedDevice(device)312+    sendFrame(channel, AdoptionFrame.Complete)313+    if (!isAdoptApplied(receiveFrame(channel))) return null280314     return device281315 }282316 

view file

app/src/main/java/com/peachpasswords/ui/onboarding/OnboardingScreen.kt+36 −10
@@ -315,20 +315,34 @@315315 /**316316  * Card 3's two existing-vault entry points.317317  */318+internal const val ONBOARD_PAIR_LABEL = "Pair with another device"319+internal const val ONBOARD_RESTORE_LABEL = "Restore a backup"320+318321 @Composable319322 private fun ChoiceRow(onRestore: () -> Unit, onAdopt: () -> Unit) {320-    Spacer(Modifier.height(14.dp))321-    ChoiceButton("I already have Peach", onAdopt)323+    Spacer(Modifier.height(18.dp))324+    Text(325+        "Already using Peach?",326+        color = peachColors.T1,327+        style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),328+    )322329     Spacer(Modifier.height(8.dp))323-    ChoiceButton("restore from backup", onRestore)330+    ChoiceButton(331+        ONBOARD_PAIR_LABEL,332+        "Scan a QR and confirm one 8-digit code.",333+        onAdopt,334+    )335+    Spacer(Modifier.height(8.dp))336+    ChoiceButton(337+        ONBOARD_RESTORE_LABEL,338+        "Use a Peach backup or Codex.",339+        onRestore,340+    )324341 }325342 326343 @Composable327-private fun ChoiceButton(label: String, onClick: () -> Unit) {328-    Text(329-        label,330-        color = peachColors.T0,331-        style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),344+private fun ChoiceButton(label: String, detail: String, onClick: () -> Unit) {345+    Column(332346         modifier = Modifier333347             .fillMaxWidth()334348             .clip(RoundedCornerShape(8.dp))@@ -339,8 +353,20 @@339353                 indication = null,340354                 onClick = onClick,341355             )342-            .padding(vertical = 8.dp, horizontal = 10.dp),343-    )356+            .padding(vertical = 9.dp, horizontal = 10.dp),357+    ) {358+        Text(359+            label,360+            color = peachColors.T0,361+            style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),362+        )363+        Spacer(Modifier.height(2.dp))364+        Text(365+            detail,366+            color = peachColors.T1,367+            style = PeachType.labelMono.copy(fontSize = 9.sp),368+        )369+    }344370 }345371 346372 // ── Nav row ─────────────────────────────────────────────────────────────────

view file

app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultScreen.kt+28 −87
@@ -30,6 +30,8 @@3030 import com.peachpasswords.ui.security.StrengthBar3131 import com.peachpasswords.ui.security.scorePassword3232 import com.peachpasswords.ui.security.strengthLabel33+import com.peachpasswords.ui.components.BirdAnimation34+import com.peachpasswords.ui.components.PairingAnimState3335 import com.peachpasswords.ui.theme.LocalPeachAccent3436 import com.peachpasswords.ui.theme.PeachSpacing3537 import com.peachpasswords.ui.theme.PeachType@@ -46,7 +48,6 @@4648     var password by remember { mutableStateOf("") }4749     var confirmation by remember { mutableStateOf("") }4850     var reveal by remember { mutableStateOf(false) }49-    var invitation by remember { mutableStateOf("") }5051     var showBackgroundSyncPrompt by remember { mutableStateOf(false) }5152     val score = scorePassword(password)5253     val passwordReady =@@ -63,82 +64,46 @@6364             verticalArrangement = Arrangement.Center,6465         ) {6566             Text(66-                if (vm.joining) "Join a vault" else "I already have Peach",67+                if (vm.joining) "Re-pair a device" else "I already have Peach",6768                 color = peachColors.T0,6869                 fontFamily = PeachType.Display,6970                 fontSize = 26.sp,7071             )7172             Spacer(Modifier.height(PeachSpacing.sm))7273 74+            if (75+                state is AdoptVaultUiState.ConfirmSas ||76+                state is AdoptVaultUiState.Adopting ||77+                state is AdoptVaultUiState.Complete78+            ) {79+                BirdAnimation(80+                    state = if (state is AdoptVaultUiState.Complete) {81+                        PairingAnimState.CONNECTED82+                    } else {83+                        PairingAnimState.LISTENING84+                    },85+                    modifier = Modifier.fillMaxWidth().height(170.dp),86+                )87+            }88+7389             when (val current = state) {7490                 AdoptVaultUiState.Disclosure -> {7591                     Body(76-                        "This device already has a vault. Joining merges it with the one you " +77-                            "scan: nothing is deleted, and after the first sync both devices " +78-                            "hold every entry from both.",92+                        "This device already has a vault. Peach can re-pair only a device from " +93+                            "its own paired or removed-device history.",7994                     )8095                     Spacer(Modifier.height(PeachSpacing.md))8196                     Body(82-                        "One thing changes for good. Both devices end up sharing a single set " +83-                            "of recovery words — the ones from the vault you join. This " +84-                            "device's current recovery words will stop working, and nothing " +85-                            "will restore from them afterwards.",97+                        "A new device must start empty, even if it looks like it contains the " +98+                            "same logins. This prevents separately restored records from being " +99+                            "combined and duplicated.",86100                     )87101                     Spacer(Modifier.height(PeachSpacing.lg))88-                    PillButton("I understand — continue", LocalPeachAccent.current, filled = true) {102+                    PillButton("Check and re-pair", LocalPeachAccent.current, filled = true) {89103                         vm.acceptDisclosure()90104                     }91105                 }92106 93-                is AdoptVaultUiState.RetireWords -> {94-                    Body(95-                        "Both devices confirmed the same code. Nothing has been written yet.",96-                    )97-                    Spacer(Modifier.height(PeachSpacing.md))98-                    Body(99-                        "The moment you continue, this device's recovery words are retired and " +100-                            "replaced by the words below — the ones for the vault you are " +101-                            "joining. Write them down now, or continue if you already have them.",102-                    )103-                    Spacer(Modifier.height(PeachSpacing.md))104-                    Text(105-                        if (reveal) "hide the words" else "show the words",106-                        color = LocalPeachAccent.current,107-                        fontFamily = PeachType.Mono,108-                        fontSize = 11.sp,109-                        modifier = Modifier110-                            .clickable { reveal = !reveal }111-                            .padding(PeachSpacing.sm),112-                    )113-                    if (reveal) {114-                        Text(115-                            current.incomingWords116-                                .mapIndexed { i, w -> "${i + 1}. $w" }117-                                .joinToString("   "),118-                            color = LocalPeachAccent.current,119-                            fontFamily = PeachType.Mono,120-                            fontSize = 13.sp,121-                            lineHeight = 22.sp,122-                            textAlign = TextAlign.Center,123-                            modifier = Modifier.fillMaxWidth(),124-                        )125-                        Spacer(Modifier.height(PeachSpacing.md))126-                    }127-                    PillButton(128-                        if (reveal) "I've written them down" else "I already have these words",129-                        LocalPeachAccent.current,130-                        filled = true,131-                    ) {132-                        reveal = false133-                        vm.acknowledgeWordRetirement(true)134-                    }135-                    Spacer(Modifier.height(PeachSpacing.sm))136-                    PillButton("Cancel — keep my words", peachColors.T2, filled = false) {137-                        reveal = false138-                        vm.acknowledgeWordRetirement(false)139-                    }140-                }141-142107                 AdoptVaultUiState.Password -> {143108                     Body(144109                         "Choose the password this phone will use. It never leaves this device; " +@@ -212,26 +177,6 @@212177                     )213178                     Spacer(Modifier.height(PeachSpacing.lg))214179                     PairingQrCamera(onInvitation = vm::adopt)215-                    Spacer(Modifier.height(PeachSpacing.lg))216-                    Text(217-                        "or enter the invitation manually",218-                        color = peachColors.T2,219-                        fontFamily = PeachType.Mono,220-                        fontSize = 10.sp,221-                    )222-                    OutlinedTextField(223-                        value = invitation,224-                        onValueChange = { invitation = it },225-                        label = { Text("peach-pair-v1:…") },226-                        minLines = 3,227-                        modifier = Modifier.fillMaxWidth(),228-                    )229-                    Spacer(Modifier.height(PeachSpacing.md))230-                    PillButton(231-                        "Connect",232-                        LocalPeachAccent.current,233-                        filled = true,234-                    ) { vm.adopt(invitation) }235180                 }236181 237182                 AdoptVaultUiState.Connecting -> {@@ -265,7 +210,7 @@265210                 AdoptVaultUiState.Adopting -> {266211                     Body(267212                         if (vm.joining) {268-                            "Joining the vault. Your entries stay exactly where they are…"213+                            "Checking that both devices already share the same vault…"269214                         } else {270215                             "Both devices confirmed. Sealing the adopted vault under this phone's password…"271216                         },@@ -275,8 +220,7 @@275220                 is AdoptVaultUiState.Complete -> {276221                     Body(277222                         if (vm.joining) {278-                            "Joined ${current.sourceName}. Both devices now share one vault — " +279-                                "the next sync brings every entry across in both directions."223+                            "Re-paired with ${current.sourceName}. No entries or recovery words changed."280224                         } else {281225                             "${current.sourceName} shared the vault successfully. Encrypted sync is now active."282226                         },@@ -291,7 +235,6 @@291235                     Body(adoptionFailureCopy(current.reason))292236                     Spacer(Modifier.height(PeachSpacing.lg))293237                     PillButton("Start again", LocalPeachAccent.current, filled = true) {294-                        invitation = ""295238                         vm.restart()296239                     }297240                 }@@ -334,10 +277,8 @@334277         "The confirmation was cancelled. No vault key was accepted."335278     "ADOPT_VAULT_ID_MISMATCH" ->336279         "The received vault did not pass its integrity check, so Peach refused it."337-    "join_declined_at_word_retirement" ->338-        "Cancelled before anything changed. This device kept its own vault and its recovery words still work."339-    "JOIN_UNRECOVERABLE_ROOT_KEY" ->340-        "That vault's recovery words cannot be represented on this device, so Peach stopped rather than leave you unable to restore. Nothing changed."280+    "JOIN_POPULATED_VAULT_BLOCKED" ->281+        "These are different populated vaults, so Peach stopped before combining their entries. Reset or use a fresh destination, then pair again."341282     "JOIN_NO_LOCAL_VAULT", "JOIN_DECOY_MODE", "vault_locked" ->342283         "Unlock this device's vault first, then try joining again."343284     else ->

view file

app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultViewModel.kt+80 −42
@@ -6,7 +6,10 @@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.DeviceRevocation911 import com.peachpasswords.sync.DeviceType12+import com.peachpasswords.sync.AdoptionFrame1013 import com.peachpasswords.sync.LocalDeviceIdentity1114 import com.peachpasswords.sync.PairedDevice1215 import com.peachpasswords.sync.PairedDeviceStore@@ -14,7 +17,9 @@1417 import com.peachpasswords.sync.RelayAdoptionReceiver1518 import com.peachpasswords.sync.RelaySettingsStore1619 import com.peachpasswords.sync.buildLocalIdentity20+import com.peachpasswords.sync.computeVaultId1721 import com.peachpasswords.sync.getDeviceName22+import com.peachpasswords.sync.hexToPubkey1823 import com.peachpasswords.sync.parsePairingInvitation1924 import com.peachpasswords.ui.security.scorePassword2025 import com.peachpasswords.vault.AdoptVaultResult@@ -35,21 +40,14 @@3540 sealed interface AdoptVaultUiState {3641     data object Password : AdoptVaultUiState3742     /**38-     * Join-mode only: this device already holds a vault, so joining replaces39-     * its root key and therefore its recovery words. Nothing connects until40-     * the user has been told that and accepted it.43+     * Existing-vault mode only. Pairing is allowed only when the incoming root44+     * key identifies this same vault; Peach refuses to combine populated45+     * vaults because separately imported copies can otherwise nearly double.4146      */4247     data object Disclosure : AdoptVaultUiState4348     data object Pair : AdoptVaultUiState4449     data object Connecting : AdoptVaultUiState4550     data class ConfirmSas(val sas: String) : AdoptVaultUiState46-    /**47-     * Join-mode only, and the last point of no return: the SAS matched and the48-     * root key has been granted, but nothing is written yet. Shows the words49-     * that are about to replace this device's, so the user can record them or50-     * confirm they already hold them.51-     */52-    data class RetireWords(val incomingWords: List<String>) : AdoptVaultUiState5351     data object Adopting : AdoptVaultUiState5452     data class Complete(val sourceName: String) : AdoptVaultUiState5553     data class Failed(val reason: String) : AdoptVaultUiState@@ -66,9 +64,9 @@6664     @ApplicationContext private val appContext: Context,6765 ) : ViewModel() {6866     /**69-     * True when this device already has a vault, so the flow joins the scanned70-     * vault (keeping local entries) instead of creating one. Read once: the71-     * answer cannot change while this screen is open.67+     * True when this device already has a vault. A populated device may refresh68+     * a pairing only for that exact vault identity; it may not adopt a different69+     * root key and silently merge two record sets.7270      */7371     val joining: Boolean = vault.hasVault()7472 @@ -79,7 +77,6 @@7977 8078     private var password: String? = null8179     private var confirmation: CompletableDeferred<Boolean>? = null82-    private var wordRetirement: CompletableDeferred<Boolean>? = null8380     private var adoptionJob: Job? = null8481     private var protocolError: String? = null8582 @@ -137,7 +134,6 @@137134                 )138135             } finally {139136                 confirmation = null140-                wordRetirement = null141137                 clearPassword()142138             }143139         }@@ -146,25 +142,15 @@146142     fun confirmSas(matches: Boolean) {147143         val pending = confirmation ?: return148144         confirmation = null149-        // In join mode the word-retirement gate comes next and owns the state.150-        if (matches && !joining) _state.value = AdoptVaultUiState.Adopting145+        if (matches) _state.value = AdoptVaultUiState.Adopting151146         pending.complete(matches)152147     }153148 154-    /** Proceed with the join, retiring this device's words. */155-    fun acknowledgeWordRetirement(proceed: Boolean) {156-        val pending = wordRetirement ?: return157-        wordRetirement = null158-        pending.complete(proceed)159-    }160-161149     fun restart() {162150         adoptionJob?.cancel()163151         adoptionJob = null164152         confirmation?.complete(false)165153         confirmation = null166-        wordRetirement?.complete(false)167-        wordRetirement = null168154         clearPassword()169155         _state.value =170156             if (joining) AdoptVaultUiState.Disclosure else AdoptVaultUiState.Password@@ -180,24 +166,33 @@180166             // completed Noise transcript.181167             override suspend fun confirmSas(): Boolean = false182168 169+            override suspend fun validateSource(source: AdoptionFrame.Source) {170+                if (!joining) return171+                requireKnownPeerForPopulatedRePair(172+                    sourceDeviceId = source.deviceId,173+                    sourcePublicKeyHex = source.longTermPublicKeyHex,174+                    pairedDevices = devices.getAll(),175+                    revocations = devices.listRevocations(),176+                )177+            }178+183179             override suspend fun createVaultFromRootKey(rootKeyHex: String): String {184-                // Same protocol seam, two local outcomes: a fresh device builds185-                // a vault around the granted root key, while a device that186-                // already has one keeps its entries and joins.180+                // Same protocol seam, two safe outcomes: a fresh device builds181+                // around the granted key; a populated device may only refresh a182+                // pairing when it already has that exact vault identity.187183                 val result = if (joining) {188-                    // Last gate before the old words become unusable. The root189-                    // key is granted but nothing is written yet, so declining190-                    // here leaves this vault exactly as it was.191-                    val incoming = vault.previewRecoveryWords(rootKeyHex)192-                        ?: throw IllegalStateException("JOIN_UNRECOVERABLE_ROOT_KEY")193-                    val acknowledged = CompletableDeferred<Boolean>()194-                    wordRetirement = acknowledged195-                    _state.value = AdoptVaultUiState.RetireWords(incoming)196-                    if (!acknowledged.await()) {197-                        throw IllegalStateException("join_declined_at_word_retirement")184+                    val incomingRoot = hexToPubkey(rootKeyHex)185+                    val incomingVaultId = try {186+                        computeVaultId(incomingRoot)187+                    } finally {188+                        VaultCrypto.zero(incomingRoot)198189                     }199-                    _state.value = AdoptVaultUiState.Adopting200-                    vault.joinVault(rootKeyHex)190+                    AdoptVaultResult.Success(191+                        requireSameVaultForPopulatedRePair(192+                            localVaultId = vault.syncVaultId(),193+                            incomingVaultId = incomingVaultId,194+                        ),195+                    )201196                 } else {202197                     val localPassword = password203198                         ?: throw IllegalStateException("adoption_password_unavailable")@@ -250,8 +245,51 @@250245     override fun onCleared() {251246         adoptionJob?.cancel()252247         confirmation?.complete(false)253-        wordRetirement?.complete(false)254248         clearPassword()255249         super.onCleared()250+    }251+}252+253+/**254+ * Pairing is not a merge tool. A populated destination may re-pair only when255+ * both sides already derive the same vault id. Different ids must stop before256+ * recovery words, entries, revocations, or paired-device state are changed.257+ */258+internal fun requireSameVaultForPopulatedRePair(259+    localVaultId: String?,260+    incomingVaultId: String,261+): String {262+    if (localVaultId == null) throw IllegalStateException("vault_locked")263+    if (localVaultId != incomingVaultId) {264+        throw IllegalStateException("JOIN_POPULATED_VAULT_BLOCKED")265+    }266+    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")256294     }257295 }

view file

app/src/main/java/com/peachpasswords/ui/pairing/CodePairScreen.kt+5 −16
@@ -32,8 +32,6 @@3232 import androidx.compose.ui.unit.sp3333 import androidx.compose.ui.geometry.Offset3434 import androidx.compose.ui.geometry.Size35-import androidx.compose.ui.platform.LocalClipboardManager36-import androidx.compose.ui.text.AnnotatedString3735 import androidx.hilt.navigation.compose.hiltViewModel3836 import androidx.lifecycle.compose.collectAsStateWithLifecycle3937 import com.peachpasswords.ui.components.BirdAnimation@@ -82,7 +80,7 @@8280 8381     val animState = when (state) {8482         is CodePairUiState.Paired -> PairingAnimState.CONNECTED85-        is CodePairUiState.ConfirmSas -> PairingAnimState.LISTENING83+        is CodePairUiState.AwaitingPeerConfirmation -> PairingAnimState.LISTENING8684         else -> PairingAnimState.WAITING8785     }8886 @@ -101,7 +99,6 @@10199                 is CodePairUiState.Starting -> Headline("Starting…", "Opening a secure relay pairing room.")102100 103101                 is CodePairUiState.ShowingInvitation -> {104-                    val clipboard = LocalClipboardManager.current105102                     Headline(106103                         "Scan this in Peach",107104                         "Connecting via secure relay — works anywhere. No vault or identity information is in this QR.",@@ -113,17 +110,13 @@113110                             .fillMaxWidth()114111                             .height(260.dp),115112                     )116-                    Spacer(Modifier.height(PeachSpacing.md))117-                    PillButton("Copy invitation", LocalPeachAccent.current, filled = false) {118-                        clipboard.setText(AnnotatedString(s.invitation))119-                    }120113                 }121114 122-                is CodePairUiState.ConfirmSas -> {115+                is CodePairUiState.AwaitingPeerConfirmation -> {123116                     Headline(124-                        "Verify the code",125-                        "Only continue if this code is identical on both screens. " +126-                            "This is what proves you're talking to your own device.",117+                        "Confirm on the scanning device",118+                        "If this code matches there, tap “They match” on that device. " +119+                            "Peach finishes only after both sides save the pairing.",127120                     )128121                     Spacer(Modifier.height(PeachSpacing.lg))129122                     Text(@@ -135,10 +128,6 @@135128                         textAlign = TextAlign.Center,136129                         modifier = Modifier.fillMaxWidth(),137130                     )138-                    Spacer(Modifier.height(PeachSpacing.lg))139-                    PillButton("They match — pair", LocalPeachAccent.current, filled = true) { vm.confirmSas(true) }140-                    Spacer(Modifier.height(PeachSpacing.sm))141-                    PillButton("Cancel", peachColors.T2, filled = false) { vm.confirmSas(false); onBack() }142131                 }143132 144133                 is CodePairUiState.Paired -> {

view file

app/src/main/java/com/peachpasswords/ui/pairing/CodePairViewModel.kt+5 −10
@@ -44,7 +44,7 @@4444 sealed interface CodePairUiState {4545     data object Starting : CodePairUiState4646     data class ShowingInvitation(val invitation: String) : CodePairUiState47-    data class ConfirmSas(val sas: String, val deviceName: String, val session: PairingSession) : CodePairUiState47+    data class AwaitingPeerConfirmation(val sas: String, val deviceName: String) : CodePairUiState4848     data class Paired(val deviceName: String) : CodePairUiState4949     data object Expired : CodePairUiState5050     data class Failed(val reason: String) : CodePairUiState@@ -114,11 +114,10 @@114114                     _state.value = when (event) {115115                         is RelayAdoptionEvent.InvitationReady ->116116                             CodePairUiState.ShowingInvitation(event.invitation)117-                        is RelayAdoptionEvent.AwaitingConfirmation ->118-                            CodePairUiState.ConfirmSas(117+                        is RelayAdoptionEvent.AwaitingPeerConfirmation ->118+                            CodePairUiState.AwaitingPeerConfirmation(119119                                 event.sas,120120                                 event.deviceName,121-                                event.session,122121                             )123122                         is RelayAdoptionEvent.Adopted ->124123                             CodePairUiState.Paired(event.deviceName)@@ -161,8 +160,8 @@161160             override suspend fun license(): String? =162161                 license.getLicenseKey()?.takeIf(license::validateLicense)163162 164-            // RelayAdoptionHost owns the actual UI consent gate so it can bind165-            // that decision to the session SAS before delegating here.163+            // Opening this source screen while unlocked is the source-side164+            // consent. The scanning device confirms the SAS before any grant.166165             override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true167166 168167             override suspend fun upsertPairedDevice(device: PairedDevice) {@@ -176,10 +175,6 @@176175     fun setBackgroundSync(enabled: Boolean) {177176         backgroundSync.setEnabled(enabled)178177         if (enabled) PeachSyncService.start(appContext) else PeachSyncService.stop(appContext)179-    }180-181-    fun confirmSas(matches: Boolean) {182-        (_state.value as? CodePairUiState.ConfirmSas)?.session?.confirm(matches)183178     }184179 185180     fun restart() {

view file

app/src/test/java/com/peachpasswords/sync/VaultAdoptTest.kt+80 −2
@@ -50,7 +50,7 @@5050         }5151     }5252 53-    private class RecorderReceiver(53+    private open class RecorderReceiver(5454         override val deviceName: String,5555         override val deviceType: DeviceType,5656         private val vaultId: String?,@@ -102,7 +102,7 @@102102         override suspend fun upsertPairedDevice(device: PairedDevice) { upserted += device }103103     }104104 105-    @Test fun completes_request_grant_identity_round_trip() = runBlocking {105+    @Test fun completes_request_through_mutual_completion_acknowledgements() = runBlocking {106106         val (receiverCh, sourceCh) = memoryPair()107107         val rootKeyHex = "11".repeat(32)108108         val vaultId = vaultIdFor(rootKeyHex)@@ -152,6 +152,8 @@152152                 signingPublicKeyHex = "bb".repeat(32),153153             ),154154         )155+        receiveFrame(receiverCh) // ADOPT_COMPLETE156+        sendFrame(receiverCh, AdoptionFrame.Applied)155157         grantJob.await()156158 157159         val persisted = source.upserted.single()@@ -262,6 +264,82 @@262264 263265         assertNull(result)264266         assertTrue(source.upserted.isEmpty())267+    }268+269+    @Test fun receiver_does_not_persist_source_without_complete() = runBlocking {270+        val (receiverCh, sourceCh) = memoryPair()271+        val rootKeyHex = "77".repeat(32)272+        val vaultId = vaultIdFor(rootKeyHex)273+        val receiver = RecorderReceiver(274+            "Receiver",275+            DeviceType.ANDROID,276+            vaultId,277+            derived = identity("receiver").copy(deviceType = DeviceType.ANDROID),278+        )279+        val receiverJob = async { requestAdoption(receiverCh, receiver) { 0L } }280+281+        receiveFrame(sourceCh) // ADOPT_REQUEST282+        receiveFrame(sourceCh) // ADOPT_CONFIRM283+        sendFrame(284+            sourceCh,285+            AdoptionFrame.Grant(286+                rootKeyHex = rootKeyHex,287+                vaultId = vaultId,288+                source = AdoptionFrame.Source(289+                    "source-id",290+                    "Source",291+                    "extension",292+                    "ab".repeat(32),293+                ),294+            ),295+        )296+        receiveFrame(sourceCh) // ADOPT_IDENTITY297+        sendFrame(sourceCh, AdoptionFrame.Confirm(true))298+299+        assertNull(receiverJob.await())300+        assertEquals(listOf("ADOPT_COMPLETION_MISSING"), receiver.errors)301+        assertTrue(receiver.revocationsCleared.isEmpty())302+        assertTrue(receiver.upserted.isEmpty())303+    }304+305+    @Test fun receiver_safety_policy_runs_before_vault_creation() = runBlocking {306+        val (receiverCh, sourceCh) = memoryPair()307+        val rootKeyHex = "78".repeat(32)308+        val vaultId = vaultIdFor(rootKeyHex)309+        val receiver = object : RecorderReceiver(310+            "Receiver",311+            DeviceType.ANDROID,312+            vaultId,313+            derived = identity("receiver").copy(deviceType = DeviceType.ANDROID),314+        ) {315+            override suspend fun validateSource(source: AdoptionFrame.Source) {316+                throw IllegalStateException("JOIN_POPULATED_VAULT_BLOCKED")317+            }318+        }319+        val receiverJob = async {320+            runCatching { requestAdoption(receiverCh, receiver) { 0L } }321+        }322+323+        receiveFrame(sourceCh) // ADOPT_REQUEST324+        receiveFrame(sourceCh) // ADOPT_CONFIRM325+        sendFrame(326+            sourceCh,327+            AdoptionFrame.Grant(328+                rootKeyHex = rootKeyHex,329+                vaultId = vaultId,330+                source = AdoptionFrame.Source(331+                    "new-source",332+                    "Source",333+                    "extension",334+                    "ab".repeat(32),335+                ),336+            ),337+        )338+339+        val outcome = receiverJob.await()340+        assertTrue(outcome.isFailure)341+        assertEquals("JOIN_POPULATED_VAULT_BLOCKED", outcome.exceptionOrNull()?.message)342+        assertNull(receiver.createCalledWith)265343     }266344 267345     @Test fun does_not_apply_license_when_vault_creation_fails() = runBlocking {

view file

app/src/test/java/com/peachpasswords/ui/onboarding/OnboardingTest.kt+5 −0
@@ -85,4 +85,9 @@8585         val count = ONBOARDING_CARDS.size8686         assertEquals("create vault", onboardingNextLabel(count - 1, count))8787     }88+89+    @Test fun existing_user_choices_put_pairing_first_and_name_restore_separately() {90+        assertEquals("Pair with another device", ONBOARD_PAIR_LABEL)91+        assertEquals("Restore a backup", ONBOARD_RESTORE_LABEL)92+    }8893 }

view file

app/src/test/java/com/peachpasswords/ui/pairing/AdoptVaultSafetyTest.kt+86 −0
@@ -0,0 +1,86 @@1+package com.peachpasswords.ui.pairing2+3+import com.peachpasswords.sync.DeviceRevocation4+import com.peachpasswords.sync.PairedDevice5+import org.junit.Assert.assertEquals6+import org.junit.Assert.assertThrows7+import org.junit.Test8+9+class AdoptVaultSafetyTest {10+    @Test fun same_vault_identity_may_repair_without_mutation() {11+        val id = "ab".repeat(32)12+13+        assertEquals(id, requireSameVaultForPopulatedRePair(id, id))14+    }15+16+    @Test fun different_populated_vaults_are_never_merged_by_pairing() {17+        val error = assertThrows(IllegalStateException::class.java) {18+            requireSameVaultForPopulatedRePair("ab".repeat(32), "cd".repeat(32))19+        }20+21+        assertEquals("JOIN_POPULATED_VAULT_BLOCKED", error.message)22+    }23+24+    @Test fun locked_populated_vault_cannot_be_repaired() {25+        val error = assertThrows(IllegalStateException::class.java) {26+            requireSameVaultForPopulatedRePair(null, "cd".repeat(32))27+        }28+29+        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)85+    }86+}

view file