5888cb598ccc

fix: recovery escrow missing on adopted vaults + re-auth gate for viewing words (v2.9.22)

Root cause: createVaultFromRootKey never wrote recoveryEscrow/rootKeyEscrow. - Adoption path now builds both escrow types after session goes live - ensureRecoveryEscrow() self-heals existing vaults on next unlock - Settings 'view recovery words' gated on biometric/device credential

time
2026-08-01 21:00 UTC · 2 months ago
machine
koh2
parent
f4352f0fe746
2 files app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+10 −1app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+51 −2
app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+10 −1
@@ -375,7 +375,16 @@375375                 modifier = Modifier.padding(top = 2.dp),376376             )377377         }378-        SubtleButton("view", fg) { onNavigate(PeachNav.RecoveryView) }378+        SubtleButton("view", fg) {379+            if (bioActivity != null) {380+                com.peachpasswords.ui.lock.Biometrics.authenticate(381+                    activity = bioActivity,382+                    title = "View recovery words",383+                    subtitle = "Confirm your identity to view your recovery words",384+                    onSuccess = { onNavigate(PeachNav.RecoveryView) },385+                )386+            } else onNavigate(PeachNav.RecoveryView)387+        }379388     }380389     SubLabel("BREACH PROTECTION", fg)381390     val checkingBreaches by vm.checkingBreaches.collectAsState()

view file

app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+51 −2
@@ -592,6 +592,27 @@592592             val (previousDerivedKey, previousRootKey) = adopted593593             previousDerivedKey?.let(VaultCrypto::zero)594594             previousRootKey?.let(VaultCrypto::zero)595+            // Write the recovery escrow so the forgot-password flow works on596+            // this device. The words are deterministic from the root key and597+            // were already confirmed on the source device.598+            runCatching {599+                val words = rootKeyToWords(rootKey!!)600+                val escrow = buildRecoveryEscrow(words)601+                val rkEscrow = buildRootKeyEscrow(words)602+                if (escrow != null || rkEscrow != null) {603+                    val prior = store.loadSlotA()604+                    if (prior != null) {605+                        store.writeSlotA(prior.copy(606+                            recoveryEscrow = escrow ?: prior.recoveryEscrow,607+                            rootKeyEscrow = rkEscrow ?: prior.rootKeyEscrow,608+                        ))609+                    }610+                    mutateState {611+                        recoveryWordHash = recoveryWordsHash(words)612+                        recoveryWordsSet = true613+                    }614+                }615+            }595616             AdoptVaultResult.Success(vaultId)596617         } catch (e: Exception) {597618             AdoptVaultResult.Error(e.message ?: "adoption_create_failed")@@ -603,7 +624,9 @@603624     }604625 605626     suspend fun unlock(password: String): UnlockResult = mutex.withLock {606-        unlockLocked(password, currentEpoch())627+        val result = unlockLocked(password, currentEpoch())628+        if (result is UnlockResult.Success) ensureRecoveryEscrow()629+        result607630     }608631 609632     /** Unlock body without acquiring the mutex — callers must already hold it. */@@ -1172,7 +1195,7 @@11721195             }11731196             val candidate = stagedKey ?: return UnlockResult.Error("unlock_key_missing")11741197             val result = adoptSlotA(candidate, payload, expectedEpoch)1175-            if (result is UnlockResult.Success) stagedKey = null1198+            if (result is UnlockResult.Success) { stagedKey = null; ensureRecoveryEscrow() }11761199             result11771200         } finally {11781201             stagedKey?.let(VaultCrypto::zero)@@ -2120,6 +2143,32 @@21202143     /** True at rest if this vault has any recovery escrow the recovery flow can use. */21212144     fun hasRecoveryEscrow(): Boolean =21222145         store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false2146+2147+    /**2148+     * One-time self-heal: if this vault has a root key but no recovery escrow2149+     * on disk (adopted vaults created before the escrow fix), build and persist2150+     * it now. Called on every successful unlock; no-ops once the escrow exists.2151+     */2152+    private fun ensureRecoveryEscrow() {2153+        if (hasRecoveryEscrow()) return2154+        val rk = mutateState { rootKey?.copyOf() } ?: return2155+        runCatching {2156+            val words = rootKeyToWords(rk)2157+            val escrow = buildRecoveryEscrow(words)2158+            val rkEscrow = buildRootKeyEscrow(words)2159+            if (escrow != null || rkEscrow != null) {2160+                val prior = store.loadSlotA() ?: return@runCatching2161+                store.writeSlotA(prior.copy(2162+                    recoveryEscrow = escrow ?: prior.recoveryEscrow,2163+                    rootKeyEscrow = rkEscrow ?: prior.rootKeyEscrow,2164+                ))2165+                mutateState {2166+                    recoveryWordHash = recoveryWordsHash(words)2167+                    recoveryWordsSet = true2168+                }2169+            }2170+        }.also { VaultCrypto.zero(rk) }2171+    }21232172 21242173     /**21252174      * Attempt to unlock the vault using the 12 BIP39 recovery [words] instead of

view file