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
↓ peach-android-d620000d.tar.gzatlas at this save
files changed12 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 −0app/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 {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 }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 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 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 ─────────────────────────────────────────────────────────────────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 ->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 }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 -> {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() {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 {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 }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+}