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
↓ peach-android-8d5c5d95.tar.gzatlas at this save
files changed7 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 −0app/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 {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 }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 }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)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)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)