8d5c5d9501a7

let a device that already has a vault join another one: take the source root key, keep every entry, and gate it behind a disclosure before connecting and a second one showing the incoming recovery words before anything is written (v2.4.12)

time
2026-07-26 14:05 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
join existing vault
parent
4f8a9094ec9b
7 files VERSION+1 −1app/build.gradle+2 −2app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultScreen.kt+89 −4app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultViewModel.kt+69 −8app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+19 −3app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+75 −2app/src/test/java/com/peachpasswords/vault/VaultRepositoryTest.kt+57 −0
VERSION+1 −1
@@ -1 +1 @@1-2.4.111+2.4.12

view file

app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 24001120-        versionName "2.4.11"19+        versionCode 24001220+        versionName "2.4.12"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultScreen.kt+89 −4
@@ -62,7 +62,7 @@6262             verticalArrangement = Arrangement.Center,6363         ) {6464             Text(65-                "I already have Peach",65+                if (vm.joining) "Join a vault" else "I already have Peach",6666                 color = peachColors.T0,6767                 fontFamily = PeachType.Display,6868                 fontSize = 26.sp,@@ -70,6 +70,74 @@7070             Spacer(Modifier.height(PeachSpacing.sm))7171 7272             when (val current = state) {73+                AdoptVaultUiState.Disclosure -> {74+                    Body(75+                        "This device already has a vault. Joining merges it with the one you " +76+                            "scan: nothing is deleted, and after the first sync both devices " +77+                            "hold every entry from both.",78+                    )79+                    Spacer(Modifier.height(PeachSpacing.md))80+                    Body(81+                        "One thing changes for good. Both devices end up sharing a single set " +82+                            "of recovery words — the ones from the vault you join. This " +83+                            "device's current recovery words will stop working, and nothing " +84+                            "will restore from them afterwards.",85+                    )86+                    Spacer(Modifier.height(PeachSpacing.lg))87+                    PillButton("I understand — continue", LocalPeachAccent.current, filled = true) {88+                        vm.acceptDisclosure()89+                    }90+                }91+92+                is AdoptVaultUiState.RetireWords -> {93+                    Body(94+                        "Both devices confirmed the same code. Nothing has been written yet.",95+                    )96+                    Spacer(Modifier.height(PeachSpacing.md))97+                    Body(98+                        "The moment you continue, this device's recovery words are retired and " +99+                            "replaced by the words below — the ones for the vault you are " +100+                            "joining. Write them down now, or continue if you already have them.",101+                    )102+                    Spacer(Modifier.height(PeachSpacing.md))103+                    Text(104+                        if (reveal) "hide the words" else "show the words",105+                        color = LocalPeachAccent.current,106+                        fontFamily = PeachType.Mono,107+                        fontSize = 11.sp,108+                        modifier = Modifier109+                            .clickable { reveal = !reveal }110+                            .padding(PeachSpacing.sm),111+                    )112+                    if (reveal) {113+                        Text(114+                            current.incomingWords115+                                .mapIndexed { i, w -> "${i + 1}. $w" }116+                                .joinToString("   "),117+                            color = LocalPeachAccent.current,118+                            fontFamily = PeachType.Mono,119+                            fontSize = 13.sp,120+                            lineHeight = 22.sp,121+                            textAlign = TextAlign.Center,122+                            modifier = Modifier.fillMaxWidth(),123+                        )124+                        Spacer(Modifier.height(PeachSpacing.md))125+                    }126+                    PillButton(127+                        if (reveal) "I've written them down" else "I already have these words",128+                        LocalPeachAccent.current,129+                        filled = true,130+                    ) {131+                        reveal = false132+                        vm.acknowledgeWordRetirement(true)133+                    }134+                    Spacer(Modifier.height(PeachSpacing.sm))135+                    PillButton("Cancel — keep my words", peachColors.T2, filled = false) {136+                        reveal = false137+                        vm.acknowledgeWordRetirement(false)138+                    }139+                }140+73141                 AdoptVaultUiState.Password -> {74142                     Body(75143                         "Choose the password this phone will use. It never leaves this device; " +@@ -183,7 +251,7 @@183251                     )184252                     Spacer(Modifier.height(PeachSpacing.lg))185253                     PillButton(186-                        "They match — adopt vault",254+                        if (vm.joining) "They match — join vault" else "They match — adopt vault",187255                         LocalPeachAccent.current,188256                         filled = true,189257                     ) { vm.confirmSas(true) }@@ -195,12 +263,23 @@195263 196264                 AdoptVaultUiState.Adopting -> {197265                     Body(198-                        "Both devices confirmed. Sealing the adopted vault under this phone's password…",266+                        if (vm.joining) {267+                            "Joining the vault. Your entries stay exactly where they are…"268+                        } else {269+                            "Both devices confirmed. Sealing the adopted vault under this phone's password…"270+                        },199271                     )200272                 }201273 202274                 is AdoptVaultUiState.Complete -> {203-                    Body("${current.sourceName} shared the vault successfully. Encrypted sync is now active.")275+                    Body(276+                        if (vm.joining) {277+                            "Joined ${current.sourceName}. Both devices now share one vault — " +278+                                "the next sync brings every entry across in both directions."279+                        } else {280+                            "${current.sourceName} shared the vault successfully. Encrypted sync is now active."281+                        },282+                    )204283                     Spacer(Modifier.height(PeachSpacing.lg))205284                     PillButton("Open vault", LocalPeachAccent.current, filled = true, onClick = onComplete)206285                 }@@ -244,6 +323,12 @@244323         "The confirmation was cancelled. No vault key was accepted."245324     "ADOPT_VAULT_ID_MISMATCH" ->246325         "The received vault did not pass its integrity check, so Peach refused it."326+    "join_declined_at_word_retirement" ->327+        "Cancelled before anything changed. This device kept its own vault and its recovery words still work."328+    "JOIN_UNRECOVERABLE_ROOT_KEY" ->329+        "That vault's recovery words cannot be represented on this device, so Peach stopped rather than leave you unable to restore. Nothing changed."330+    "JOIN_NO_LOCAL_VAULT", "JOIN_DECOY_MODE", "vault_locked" ->331+        "Unlock this device's vault first, then try joining again."247332     else ->248333         "The secure adoption session ended before it completed. Start again with a fresh invitation."249334 }

view file

app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultViewModel.kt+69 −8
@@ -32,9 +32,22 @@3232 3333 sealed interface AdoptVaultUiState {3434     data object Password : AdoptVaultUiState35+    /**36+     * Join-mode only: this device already holds a vault, so joining replaces37+     * its root key and therefore its recovery words. Nothing connects until38+     * the user has been told that and accepted it.39+     */40+    data object Disclosure : AdoptVaultUiState3541     data object Pair : AdoptVaultUiState3642     data object Connecting : AdoptVaultUiState3743     data class ConfirmSas(val sas: String) : AdoptVaultUiState44+    /**45+     * Join-mode only, and the last point of no return: the SAS matched and the46+     * root key has been granted, but nothing is written yet. Shows the words47+     * that are about to replace this device's, so the user can record them or48+     * confirm they already hold them.49+     */50+    data class RetireWords(val incomingWords: List<String>) : AdoptVaultUiState3851     data object Adopting : AdoptVaultUiState3952     data class Complete(val sourceName: String) : AdoptVaultUiState4053     data class Failed(val reason: String) : AdoptVaultUiState@@ -48,11 +61,21 @@4861     private val license: LicenseRepository,4962     @ApplicationContext private val appContext: Context,5063 ) : ViewModel() {51-    private val _state = MutableStateFlow<AdoptVaultUiState>(AdoptVaultUiState.Password)64+    /**65+     * True when this device already has a vault, so the flow joins the scanned66+     * vault (keeping local entries) instead of creating one. Read once: the67+     * answer cannot change while this screen is open.68+     */69+    val joining: Boolean = vault.hasVault()70+71+    private val _state = MutableStateFlow<AdoptVaultUiState>(72+        if (joining) AdoptVaultUiState.Disclosure else AdoptVaultUiState.Password,73+    )5274     val state: StateFlow<AdoptVaultUiState> = _state.asStateFlow()5375 5476     private var password: String? = null5577     private var confirmation: CompletableDeferred<Boolean>? = null78+    private var wordRetirement: CompletableDeferred<Boolean>? = null5679     private var adoptionJob: Job? = null5780     private var protocolError: String? = null5881 @@ -65,6 +88,13 @@6588         return true6689     }6790 91+    /** The user has read what joining does to this device's recovery words. */92+    fun acceptDisclosure() {93+        if (_state.value is AdoptVaultUiState.Disclosure) {94+            _state.value = AdoptVaultUiState.Pair95+        }96+    }97+6898     fun adopt(invitation: String) {6999         if (_state.value !is AdoptVaultUiState.Pair) return70100         if (parsePairingInvitation(invitation) == null) {@@ -97,6 +127,7 @@97127                 )98128             } finally {99129                 confirmation = null130+                wordRetirement = null100131                 clearPassword()101132             }102133         }@@ -105,17 +136,28 @@105136     fun confirmSas(matches: Boolean) {106137         val pending = confirmation ?: return107138         confirmation = null108-        if (matches) _state.value = AdoptVaultUiState.Adopting139+        // In join mode the word-retirement gate comes next and owns the state.140+        if (matches && !joining) _state.value = AdoptVaultUiState.Adopting109141         pending.complete(matches)110142     }111143 144+    /** Proceed with the join, retiring this device's words. */145+    fun acknowledgeWordRetirement(proceed: Boolean) {146+        val pending = wordRetirement ?: return147+        wordRetirement = null148+        pending.complete(proceed)149+    }150+112151     fun restart() {113152         adoptionJob?.cancel()114153         adoptionJob = null115154         confirmation?.complete(false)116155         confirmation = null156+        wordRetirement?.complete(false)157+        wordRetirement = null117158         clearPassword()118-        _state.value = AdoptVaultUiState.Password159+        _state.value =160+            if (joining) AdoptVaultUiState.Disclosure else AdoptVaultUiState.Password119161     }120162 121163     private fun receiverDeps(): AdoptionReceiverDeps =@@ -129,11 +171,29 @@129171             override suspend fun confirmSas(): Boolean = false130172 131173             override suspend fun createVaultFromRootKey(rootKeyHex: String): String {132-                val localPassword = password133-                    ?: throw IllegalStateException("adoption_password_unavailable")134-                return when (135-                    val result = vault.createVaultFromRootKey(localPassword, rootKeyHex)136-                ) {174+                // Same protocol seam, two local outcomes: a fresh device builds175+                // a vault around the granted root key, while a device that176+                // already has one keeps its entries and joins.177+                val result = if (joining) {178+                    // Last gate before the old words become unusable. The root179+                    // key is granted but nothing is written yet, so declining180+                    // here leaves this vault exactly as it was.181+                    val incoming = vault.previewRecoveryWords(rootKeyHex)182+                        ?: throw IllegalStateException("JOIN_UNRECOVERABLE_ROOT_KEY")183+                    val acknowledged = CompletableDeferred<Boolean>()184+                    wordRetirement = acknowledged185+                    _state.value = AdoptVaultUiState.RetireWords(incoming)186+                    if (!acknowledged.await()) {187+                        throw IllegalStateException("join_declined_at_word_retirement")188+                    }189+                    _state.value = AdoptVaultUiState.Adopting190+                    vault.joinVault(rootKeyHex)191+                } else {192+                    val localPassword = password193+                        ?: throw IllegalStateException("adoption_password_unavailable")194+                    vault.createVaultFromRootKey(localPassword, rootKeyHex)195+                }196+                return when (result) {137197                     is AdoptVaultResult.Success -> result.vaultId138198                     is AdoptVaultResult.Error -> throw IllegalStateException(result.reason)139199                 }@@ -175,6 +235,7 @@175235     override fun onCleared() {176236         adoptionJob?.cancel()177237         confirmation?.complete(false)238+        wordRetirement?.complete(false)178239         clearPassword()179240         super.onCleared()180241     }

view file

app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+19 −3
@@ -692,14 +692,30 @@692692     }693693     RowLine(fg) {694694         Column(Modifier.weight(1f)) {695-            Text("Pair new device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)695+            Text("Show a code", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)696696             Text(697-                "This phone shows a code; type it into Peach in your browser.",697+                "This phone shows a QR or code for the other device to scan or type.",698698                 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,699699                 modifier = Modifier.padding(top = 2.dp),700700             )701701         }702-        SubtleButton("pair", fg) { onNavigate(PeachNav.CodePair) }702+        SubtleButton("show", fg) { onNavigate(PeachNav.CodePair) }703+    }704+    // A browser extension can only ever display a code, so something has to be705+    // able to scan one — and both devices showing a QR was the deadlock. This706+    // phone can take either role; phone-to-phone works for the same reason.707+    RowLine(fg) {708+        Column(Modifier.weight(1f)) {709+            Text("Scan a code", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)710+            Text(711+                "Point this phone at the QR on your other device. Your entries are kept — " +712+                    "the two vaults merge. This phone's recovery words are replaced by that " +713+                    "vault's, and Peach shows them before anything changes.",714+                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,715+                modifier = Modifier.padding(top = 2.dp),716+            )717+        }718+        SubtleButton("scan", fg) { onNavigate(PeachNav.AdoptVault) }703719     }704720 705721     SubLabel("PAIRED DEVICES", fg)

view file

app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+75 −2
@@ -592,16 +592,89 @@592592      * credentials, vault_id, and recovery words all change.593593      */594594     suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {595+        installRootKey(generateRecoverableRootKey())596+    }597+598+    /**599+     * Join the vault behind [rootKeyHex]: adopt that vault's identity while600+     * keeping every local entry.601+     *602+     * Sync identity (vault id, signing keys) derives from the root key, and603+     * `sync-coordinator` refuses a `vault_id_mismatch`, so two independently604+     * created vaults can never reconcile. Taking the other side's root key is605+     * what makes them one vault; from the next sync on, both entry sets merge.606+     *607+     * Entries survive because they are sealed under the password-derived key,608+     * not the root key — the same property [rotateSyncIdentity] relies on.609+     *610+     * CONSEQUENCE, which the caller must have disclosed and had confirmed:611+     * this device's previous recovery words stop working for good, and its new612+     * words are the source vault's words, because words are a deterministic613+     * encoding of the root key.614+     */615+    suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {616+        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {617+            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")618+        }619+        if (derivedKey == null) return AdoptVaultResult.Error("vault_locked")620+        if (decoyMode) return AdoptVaultResult.Error("JOIN_DECOY_MODE")621+        if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")622+623+        val incoming = hexToRootKey(rootKeyHex)624+        if (incoming.size != VaultCrypto.KEY_SIZE) {625+            VaultCrypto.zero(incoming)626+            return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")627+        }628+        // Refuse before committing rather than leave this device holding a vault629+        // whose recovery words cannot be typed back. generateRecoverableRootKey630+        // screens its own keys for this; a key arriving from a peer has not been.631+        val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess632+        if (!roundTrips) {633+            VaultCrypto.zero(incoming)634+            return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")635+        }636+637+        val vaultId = com.peachpasswords.sync.computeVaultId(incoming)638+        return if (installRootKey(incoming)) {639+            AdoptVaultResult.Success(vaultId)640+        } else {641+            AdoptVaultResult.Error("JOIN_INSTALL_FAILED")642+        }643+    }644+645+    /**646+     * The recovery words [rootKeyHex] will produce once joined, or null if it is647+     * not a usable root key. Lets the join flow show the user the words that are648+     * about to replace theirs *before* anything is committed.649+     */650+    fun previewRecoveryWords(rootKeyHex: String): List<String>? {651+        if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null652+        val bytes = hexToRootKey(rootKeyHex)653+        return try {654+            val words = rootKeyToWords(bytes)655+            if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null656+        } catch (_: Exception) {657+            null658+        } finally {659+            VaultCrypto.zero(bytes)660+        }661+    }662+663+    /**664+     * Swap the vault's root key, keeping every entry, and re-escrow under the665+     * words the new key encodes. Takes ownership of [nextRootKey]. Caller holds666+     * [mutex].667+     */668+    private suspend fun installRootKey(nextRootKey: ByteArray): Boolean {595669         val key = derivedKey ?: return false596670         if (decoyMode) return false597671         val priorSlot = store.loadSlotA() ?: return false598672         val priorRootKey = rootKey ?: return false599673         val priorRecoveryHash = recoveryWordHash600674         val priorRecoverySet = recoveryWordsSet601-        val nextRootKey = generateRecoverableRootKey()602675         val nextWords = rootKeyToWords(nextRootKey)603676 604-        try {677+        return try {605678             takeVaultSnapshot("local_save")606679             rootKey = nextRootKey607680             recoveryWordHash = recoveryWordsHash(nextWords)

view file

app/src/test/java/com/peachpasswords/vault/VaultRepositoryTest.kt+57 −0
@@ -362,6 +362,63 @@362362         assertTrue(repo.verifyRecoveryWords(words))363363     }364364 365+    @Test fun join_vault_takes_the_source_identity_keeps_entries_and_adopts_its_words() = runBlocking {366+        // The source: a second, independent vault whose root key we will join.367+        val sourceRepo = VaultRepository(FakeStorage())368+        sourceRepo.createVault("source-pw")369+        val sourceRootHex = sourceRepo.syncMasterKey()!!.let { key ->370+            key.joinToString("") { "%02x".format(it) }.also { VaultCrypto.zero(key) }371+        }372+        val sourceVaultId = sourceRepo.syncVaultId()373+        val sourceWords = sourceRepo.syncMasterKey()!!.let { key ->374+            rootKeyToWords(key).also { VaultCrypto.zero(key) }375+        }376+377+        // The joiner: our own vault, with an entry that must survive.378+        val storage = FakeStorage()379+        val repo = VaultRepository(storage)380+        repo.createVault("pw")381+        val mine = repo.addEntry(login("Bank"))382+        val myWordsBefore = repo.syncMasterKey()!!.let { key ->383+            rootKeyToWords(key).also { VaultCrypto.zero(key) }384+        }385+        assertTrue(repo.setRecoveryWords(myWordsBefore))386+        assertTrue(repo.syncVaultId() != sourceVaultId)387+388+        assertEquals(sourceWords, repo.previewRecoveryWords(sourceRootHex))389+390+        val result = repo.joinVault(sourceRootHex)391+        assertTrue(result is AdoptVaultResult.Success)392+393+        // Same vault now: this is what lets sync run at all (vault_id_mismatch).394+        assertEquals(sourceVaultId, repo.syncVaultId())395+        // Entries are sealed under the password key, so joining keeps them.396+        assertEquals("Bank", repo.getEntries().first { it.id == mine.id }.title)397+        // Words converge on the source's, and the old ones are dead.398+        assertTrue(repo.verifyRecoveryWords(sourceWords))399+        assertFalse(repo.verifyRecoveryWords(myWordsBefore))400+        assertFalse(repo.isRecoveryWordsSet())401+402+        // Survives a lock/unlock under the joiner's own unchanged password.403+        repo.lock()404+        assertEquals(UnlockResult.Success, repo.unlock("pw"))405+        assertEquals("Bank", repo.getEntries().first { it.id == mine.id }.title)406+        assertEquals(sourceVaultId, repo.syncVaultId())407+    }408+409+    @Test fun join_vault_refuses_a_malformed_root_key_and_changes_nothing() = runBlocking {410+        val repo = VaultRepository(FakeStorage())411+        repo.createVault("pw")412+        val added = repo.addEntry(login("Bank"))413+        val idBefore = repo.syncVaultId()414+415+        assertTrue(repo.joinVault("not-hex") is AdoptVaultResult.Error)416+        assertTrue(repo.joinVault("ab".repeat(31)) is AdoptVaultResult.Error)417+418+        assertEquals(idBefore, repo.syncVaultId())419+        assertEquals("Bank", repo.getEntries().first { it.id == added.id }.title)420+    }421+365422     @Test fun rotate_sync_identity_changes_root_keeps_entries_and_requires_new_recovery_confirmation() = runBlocking {366423         val storage = FakeStorage()367424         val repo = VaultRepository(storage)

view file