MOB-1836: Return to onboarding after the encrypted store is wiped; retry and quarantine instead of delete (MOB-1565) - #2497
Conversation
The onboarding flag (co.electriccoin.zcash prefs) and the persisted wallet (co.electriccoin.zcash.encrypted prefs) live in separate files and can diverge when the encrypted store is recreated after provable corruption (MOB-1691/MOB-1452). WalletRepositoryImpl.secretState used to derive READY from the onboarding flag alone, so a user whose encrypted store had been wiped landed on a Home screen with no wallet behind it and spun forever. secretState now combines the onboarding flag with the presence of a stored wallet through a pure resolveSecretState() so READY requires both. createNewWallet() now marks the onboarding flag READY only after the wallet is actually persisted, so a crash mid-write can never leave "flag without wallet". init() gained a self-heal step that detects a READY-but-wallet-less state left over from before this fix, erases any stale SDK databases, and resets the flag to NONE so RootNavGraph routes back to onboarding instead of hanging on Home. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016rqq1yYyS5XofPcqE7bZ4r
Opening the encrypted preferences store used to condemn it on the first failure, with no way to distinguish a transient Keystore operation error from real corruption. AndroidPreferenceFactoryImpl .newEncrypted now retries up to ENCRYPTED_PREFERENCES_OPEN_ATTEMPTS times with a doubling backoff before createEncryptedPreferencesWith Recovery classifies the last failure, and only recreates the store once per process per filename. A store that is still unopenable after the retries, and is either orphaned (device-to-device transfer) or provably corrupted, is now quarantined instead of deleted: quarantineCorruptedEncryptedPreferences moves the .xml and its .bak sibling into a noBackupFilesDir directory (EncryptedPreferenceQuarantine.kt) rather than destroying them, clears the in-memory SharedPreferences cache, and deletes the Keystore master-key alias only when the failure is a genuine key-level one (isMasterKeyFailure) — a data-level failure keeps the key so the quarantined file stays decryptable if the classification was ever wrong. purgeEncryptedPreferencesQuarantine() removes the quarantine directory; ResetZashiUseCase now calls it so "Reset Zodl" also clears any set-aside ciphertext. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016rqq1yYyS5XofPcqE7bZ4r
…ted tests Update the two device-to-device recovery instrumented tests to match the new quarantine-instead-of-delete behavior: after recovery, assert exactly one timestamped quarantine file exists under noBackupFilesDir /encrypted_prefs_quarantine (containing the corrupted content for the data-level scenario), that no .bak file is left behind in shared_prefs, and that the Keystore master-key alias is present again afterward. The master-key-lost scenario additionally verifies the recreated store is usable. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016rqq1yYyS5XofPcqE7bZ4r
Two user-facing entries under Unreleased / Fixed: the app returns to onboarding when its secret store is found empty while it still believes a wallet was set up, and opening the encrypted store is retried before it is declared unreadable and set aside instead of deleted. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016rqq1yYyS5XofPcqE7bZ4r
8b98625 to
67a7c0a
Compare
Persist the "already recreated" quarantine guard to disk (a <filename>.recreated marker) instead of an in-memory set, so a store that keeps failing after recreation is rethrown on later launches instead of being re-quarantined and eventually purged by pruneQuarantine. Drop the isEncryptedFileOrphaned pre-check: it can false-positive when the Keystore daemon is transiently unavailable, and a genuinely orphaned file already fails with an exception isUnrecoverableCorruption covers. Narrow master-key deletion with isMasterKeyFailure so an AOSP-wrapped transient Keystore HAL error (InvalidKeyException wrapping android.security.KeyStoreException) no longer deletes the shared master key and permanently orphans both the quarantined file and the SDK's own encrypted store. Centralize quarantine/marker path construction in EncryptedPreferenceQuarantine.kt, run the quarantine purge on IO and expose it from EncryptedPreferenceProvider, and add ensureEncryptedReadable so the app can run the same open-with-recovery ladder against a foreign encrypted file (the SDK's own store) without caching a provider for it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016rqq1yYyS5XofPcqE7bZ4r
…per review Add SdkEncryptedPreferenceRecoveryProvider, backed by the new AndroidPreferenceProvider.ensureEncryptedReadable, to repair the SDK's own cash.z.ecc.android.sdk.encrypted store: it shares this app's Keystore master key but has no corruption recovery of its own, and Synchronizer.erase/Synchronizer.new open it unguarded. In WalletRepositoryImpl.resetOnboardingIfWalletMissing, persist the reset onboarding flag first, before repairing the SDK store or erasing stale SDK data, so a Create/Restore the user completes while either of those is still running can't be clobbered by this write landing afterwards. migrateDecommissionedEndpointIfNeeded and resetOnboardingIfWalletMissing now take the already-fetched wallet as a parameter instead of each re-reading it, and the onboarding check reuses the class's onboardingState flow instead of re-deriving it from the raw preference key. Gate secretState's wait on the encrypted wallet store behind the onboarding flag (onboardingWithWallet), so a fresh install's splash no longer waits on the Keystore before persistableWalletProvider's flow first emits when the flag is not READY. ResetZashiUseCase drops its Application constructor parameter, calling encryptedPreferenceProvider.purgeQuarantine() instead of the (now internal) top-level purgeEncryptedPreferencesQuarantine. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016rqq1yYyS5XofPcqE7bZ4r
WalletRepositoryImpl.init() now erases SDK data and quarantines encrypted stores as part of its self-heal, but ZcashApplication.onCreate ran full app initialization (Koin, walletRepository.init(), the synchronizer observers) in the secondary :crash process too. Skip all of that there: the :crash process only hosts ExceptionReceiver and the crash content provider, which are Koin-free. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016rqq1yYyS5XofPcqE7bZ4r
|
Pushed 3 review-response commits that were sitting in the local worktree:
ktlintFormat/detektAll are clean and |
|
@zodl-review |
🤖 Claude ReviewCoverageFull diff and file contents reviewed. SummaryThis PR fixes two related bugs: MOB-1836, where a wiped encrypted store left the onboarding flag as Risk AssessmentMedium. The logic is well-reasoned and well-tested, but the recovery path permanently alters user state (erases SDK databases, quarantines secrets). Two correctness concerns below deserve attention before merging. Key ObservationsCorrectness1. TOCTOU in The function receives Suggestion: Pass the already-collected 2.
3.
4. After the move, Design5. onboardingState == OnboardingState.READY && hasWallet -> SecretState.READY
else -> SecretState.NONEA user mid-backup ( 6.
7. Step-numbers in The KDoc uses numbered implementation steps ( Minor / Non-blocking
Areas of DisagreementNone between the two reviewers on substance — both flagged Medium risk. Reviewer A raised more implementation-level detail; Reviewer B's observations were all confirmed against the file contents and are reflected above. Automated review by |
…call Clearing Android's in-memory SharedPreferences cache commits an empty <filename>.xml back to the path the corrupted file was just moved off, so a recreate that then fails left a stray empty file for the next launch to quarantine instead of letting the marker guard rethrow. The file is now removed again right after the clear, through a shared helper that also replaces the two ad-hoc deletes on the quarantine-failed fallback. isMasterKeyFailure no longer short-circuits on java.security.KeyStoreException: an AOSP transient-HAL marker anywhere in the chain now vetoes the whole classification rather than only the InvalidKeyException branch, so a transient failure cannot cost the shared master key. The KDoc records that the classification only runs once the retry ladder is exhausted, and that Tink's validateAead() stringifies its inner cause, which leaves the retry ladder as the only mitigation for a transient failure surfacing there. ensureEncryptedReadable documents why it caches no provider and what ordering its caller must keep, and the quarantine KDoc is prose instead of numbered steps. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXV241FzSZi6wy2PXPNz3S
The startup self-heal read the wallet in init() and the onboarding flag again inside resetOnboardingIfWalletMissing, so a concurrent create or restore could slip between the two and have its SDK databases erased. Both values are now read once in init() and passed in, all three paths run under one mutex, and the wallet is re-read immediately before the erase so a wallet stored while the SDK secret store was being repaired keeps its data. resolveSecretState records why NEEDS_WARN and NEEDS_BACKUP resolve to NONE even with a wallet present: nothing writes those ordinals any more and both mean onboarding never completed. WalletRepositoryImplTest covers the race shape and cancels the scopes it installs in teardown. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXV241FzSZi6wy2PXPNz3S
|
Thanks — addressed the 1. TOCTOU in 2. 3. 4. Empty XML left by the cache clear — the file the clear commits back to the vacated path is deleted right after, via a shared 5. 6. Non-default 7. Step-numbered KDoc — rewritten as prose. Minor: Gates: |
🤖 Claude ReviewCoverageFull diff and file contents reviewed at head_sha=247d4c66. SummaryThis PR fixes two bugs: MOB-1836 (app opens a wallet-less home screen when the encrypted store was silently wiped) and MOB-1565 (transient Keystore errors deleted the seed instead of retrying). It adds Risk AssessmentMedium. Core correctness issues are well-addressed. Two narrower items below deserve attention before merge. Key ObservationsCorrectness / Bugs1.
suspend fun ensureEncryptedReadable(context: Context, filename: String)No default body is provided. Any external code implementing 2. In runCatching {
quarantineDir.mkdirs()
recreatedMarkerFile(quarantineDir, filename).createNewFile()
}
Resources / Concurrency3.
private val sharingScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
4.
@set:RestrictTo(RestrictTo.Scope.TESTS)
@VisibleForTesting
internal var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())In Design / Refactoring5.
private val walletMutation = Mutex()Confirmed: 6.
internal fun resolveSecretState(
isConfigurationLoaded: Boolean,
onboardingState: OnboardingState,
hasWallet: Boolean,
): SecretState =
when {
!isConfigurationLoaded -> SecretState.LOADING
onboardingState == OnboardingState.READY && hasWallet -> SecretState.READY
else -> SecretState.NONE
}Logic is correct and well-tested. The Suggestions
Areas of DisagreementNone — both reviewers were in agreement on risk level (Medium) and the non-blocking nature of the concurrency observations. Reviewer A raised the Automated review by |
…in tests AndroidPreferenceFactory.ensureEncryptedReadable had no default body, making it a breaking addition for any implementor outside this in-repo companion object. Default it to a no-op. WalletRepositoryImpl.sharingScope backs secretState and the other stateIn-shared flows but was never exposed, so WalletRepositoryImplTest could only cancel the mutable `scope` seam in teardown and always leaked sharingScope's coroutines across test cases. Expose it as @VisibleForTesting and register it alongside scope. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXV241FzSZi6wy2PXPNz3S
|
Addressed 2 of the 6 items from the latest @zodl-review pass in
Not touched in this round — flagging for a maintainer/reviewer call since they touch the quarantine/erase paths directly:
ktlintFormat/detektAll clean, |
🤖 Claude ReviewCoverageFull diff and file contents reviewed. SummaryThis PR fixes two bugs: MOB-1836 (app opened a wallet-less home screen after the encrypted store was silently wiped) and MOB-1565 (transient Keystore errors immediately deleted the seed). It adds Risk AssessmentLow-Medium. The changes touch sensitive recovery paths (encrypted secrets, seed storage, SDK databases). The core logic is sound and well-tested; the residual items below are non-blocking. Key ObservationsCorrectness / Bugs1. In runCatching {
quarantineDir.mkdirs()
recreatedMarkerFile(quarantineDir, filename).createNewFile()
}
2. In runCatching { sdkEncryptedPreferenceRecoveryProvider.ensureReadable() }
.onFailure { Twig.error(it) { "Repairing the SDK secret store failed; continuing" } }
if (persistableWalletProvider.getPersistableWallet() != null) { ... }
runCatching { Synchronizer.erase(application, ZcashNetwork.fromResources(application)) }
.onFailure { Twig.error(it) { "Erasing stale wallet data failed; continuing" } }If Design3.
interface SdkEncryptedPreferenceRecoveryProvider {
suspend fun ensureReadable()
}The KDoc explaining intent lives on 4. In internal class RecordingPreferenceProvider(
private val events: MutableList<String>
) : PreferenceProvider {This class is only referenced within the same file. Its visibility can be Confirmed Resolved (from prior review)
Suggestions
Automated review by |
createNewFile()'s boolean result was discarded inside runCatching, so a false return (e.g. storage full) left the once-per-filename quarantine guard silently not engaged. Log it via Twig.error; no other behaviour change. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXV241FzSZi6wy2PXPNz3S
|
Follow-up in |
…ten a test's visibility Move the intent KDoc from SdkEncryptedPreferenceRecoveryProviderImpl to the ensureReadable() method on SdkEncryptedPreferenceRecoveryProvider itself, so the contract (repair-in-place, caller must await before anything else opens the SDK store) is visible from the interface future implementors would code against. RecordingPreferenceProvider has no callers outside WalletRepositoryImplTest.kt; narrow it from internal to private. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXV241FzSZi6wy2PXPNz3S
|
Follow-up in
Left for a maintainer call, since it touches the erase path directly: the bot's Observation 2 — ktlint/detekt clean, |
🤖 Claude ReviewCoverageFull diff and file contents reviewed at head_sha=533443e1. SummaryThis PR fixes MOB-1836 (wallet-less home screen when encrypted store is silently wiped) and MOB-1565 (transient Keystore errors immediately deleted the seed). It adds Risk AssessmentLow. The remaining observations are narrow, non-blocking, or design-level. Both reviewers agreed the core logic is sound and well-tested. Key ObservationsCorrectness / Bugs1. In private fun moveFile(source: File, destination: File) {
if (!source.renameTo(destination)) {
source.copyTo(destination, overwrite = true)
source.delete()
}
}
2. val cause = checkNotNull(lastFailure) { "maxAttempts must be at least 1" }If 3. runCatching { sdkEncryptedPreferenceRecoveryProvider.ensureReadable() }
.onFailure { Twig.error(it) { "Repairing the SDK secret store failed; continuing" } }If Design / Refactoring4. In class SdkEncryptedPreferenceRecoveryProviderImpl(
private val context: Context
) : SdkEncryptedPreferenceRecoveryProvider {The 5. In private suspend fun clearSharedPrefs() {
standardPreferenceProvider().clearPreferences()
encryptedPreferenceProvider().clearPreferences()
encryptedPreferenceProvider.purgeQuarantine()
}The ordering is correct (clear first, then purge markers), but there is no comment explaining why Suggestions
Automated review by |
|
Closing the loop on the erase-after-failed- The latest @zodl-review pass (Low risk, nits only, incl. the |
🤖 Claude ReviewCoverageFull diff and file contents reviewed at head_sha=533443e1. SummaryThis PR fixes two bugs: MOB-1836 (wallet-less home screen after the encrypted store is silently wiped) by requiring both the onboarding flag and a stored wallet for Risk AssessmentLow. Core logic is sound and well-tested. Remaining items are narrow correctness edge-cases and design nits. Key ObservationsCorrectness / Bugs1. In private fun moveFile(source: File, destination: File) {
if (!source.renameTo(destination)) {
source.copyTo(destination, overwrite = true)
source.delete()
}
}After a cross-filesystem copy, if 2. In val cause = checkNotNull(lastFailure) { "maxAttempts must be at least 1" }This is a post-loop 3.
class SdkEncryptedPreferenceRecoveryProviderImpl(
private val context: Context
) : SdkEncryptedPreferenceRecoveryProvider {No 4. The interface declares Design / Refactoring5.
private fun isCrashProcess() = ProcessNameCompat.getProcessName(this).endsWith(CRASH_PROCESS_NAME_SUFFIX)and at the bottom: private const val CRASH_PROCESS_NAME_SUFFIX = ":crash"The KDoc explains why the literal is deliberately repeated. Adding a Suggestions
Automated review by |
- EncryptedPreferenceQuarantine.moveFile: log via Twig.error when the copy-then-delete fallback's source.delete() returns false, naming the file. No other behaviour change. - createEncryptedPreferencesWithRecovery: require(maxAttempts >= 1) at function entry instead of a misleading post-loop checkNotNull message; checkNotNull(lastFailure) stays for the smart cast. - SdkEncryptedPreferenceRecoveryProviderImpl: mark internal, closing unintended public API surface now that the Koin singleOf binding (same module) is the only construction site. - CRASH_PROCESS_NAME_SUFFIX: KDoc noting it must stay in sync with GlobalCrashReporter's own copy, so a future rename is grep-able. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXV241FzSZi6wy2PXPNz3S
|
Addressed all items from the latest @zodl-review pass in
Gates: ktlintFormat/detektAll clean, |
LukasKorba
left a comment
There was a problem hiding this comment.
Reviewed all three passes and the deferred items. Answers to your three maintainer calls at the bottom — but first, two things the bot missed across all three passes that I do want fixed here. Both are inside code this PR writes, both on the "can this orphan the only copy of a spending key" axis. Neither is a regression against maint/v3.10.x (which deletes the store outright), so this is still strictly safer than what's on the branch — but both fixes are small.
Blocking
1. isUnrecoverableCorruption doesn't apply the transient-HAL veto that isMasterKeyFailure does
AndroidPreferenceProvider.kt:365-374 vs :397-403.
internal fun isUnrecoverableCorruption(exception: Exception): Boolean =
generateSequence<Throwable>(exception) { it.cause }
.take(CAUSE_CHAIN_LIMIT)
.any { ... || it is InvalidKeyException || ... } // :372 — no veto
internal fun isMasterKeyFailure(exception: Exception): Boolean {
val chain = ...
val hasAndroidKeyStoreFailure =
chain.any { it !is KeyStoreException && it.javaClass.simpleName == "KeyStoreException" }
if (hasAndroidKeyStoreFailure) return false // :401 — veto present
...
}Your own KDoc at :376-395 says AOSP wraps a transient HAL error as InvalidKeyException("Keystore operation failed") carrying android.security.KeyStoreException. The code acts on that for key deletion and ignores it for the quarantine decision.
Sequence — Keystore daemon wedged >600ms after an OS update or a StrongBox hiccup, i.e. the MOB-1565 scenario:
create()throws that shape on all three attempts (:431-445, 200+400ms total).isUnrecoverableCorruption→ true (chain containsInvalidKeyException).:451 quarantine(cause)— the seed blob leavesshared_prefs.isMasterKeyFailure→ false, so the key survives. Good, but too late for reachability.getPersistableWallet()→ null, onboarding is READY →WalletRepository.kt:299writes NONE,:309erases SDK databases.- User lands on onboarding. Seed is in
no_backup/encrypted_prefs_quarantine/, unreachable from any UI, excluded from Auto Backup, and one Reset Zodl away frompurgeQuarantine().
Failing safe here means rethrowing and leaving the store in place: the user gets the (already-deferred) splash hang, recoverable by relaunching once the Keystore settles. Quarantine is not user-recoverable. Please extract the veto into one shared hasTransientAndroidKeyStoreMarker(chain) so the two classifiers can't drift again. EncryptedPreferenceRecoveryTest.kt:83-90 pins the AOSP shape for isMasterKeyFailure and has no counterpart for isUnrecoverableCorruption — that's why this survived three passes.
2. pruneQuarantine deletes oldest-first, i.e. the only copy that has a wallet in it
EncryptedPreferenceQuarantine.kt:110-116, QUARANTINE_KEEP_NEWEST = 3:
xmlFiles.sortedByDescending { it.name }.drop(keepNewest).forEach { ... delete() }After the first quarantine the live store is a fresh empty one, so every subsequent quarantine of that filename sets aside an empty or newly-created store — while the first entry, the one holding the seed, is the oldest and therefore the first thing drop(3) deletes.
Flaky hardware Keystore: launch 1 quarantines the real seed (T1). The recreated-marker is cleared the same run (:260 deletes it on any successful open, and the post-quarantine create() on an empty store basically always succeeds), so nothing prevents recurrence. Launches 2-4 quarantine T2/T3/T4. On the fourth, sortedByDescending keeps T4/T3/T2 and deletes T1. Four app launches to total loss.
Cheapest defensible fix: keep the oldest entry for a filename unconditionally, plus the newest N-1. A few KB of XML against total loss.
Related — the PR description says "at most one recreate per filename per process", but because :260 clears the marker on any successful open, it bounds the immediate retry, not repeat quarantine across launches. Worth correcting the description.
3. The quarantine-failure fallback deletes the user's ciphertext
AndroidPreferenceProvider.kt:316-319:
.onFailure { failure ->
Twig.error(failure) { "Quarantining encrypted preferences $filename failed; deleting instead" }
runCatching { deleteEncryptedPreferencesFiles(sharedPrefsDir, filename) }
}moveFile (EncryptedPreferenceQuarantine.kt:87-90) falls back to copyTo(overwrite = true) + delete() when renameTo fails. If copyTo throws mid-write — low disk being the obvious trigger, and low disk is also a plausible reason renameTo failed — the exception unwinds into this onFailure, which then deletes the original, leaving a truncated partial in quarantine. Seed gone, log line cheerful about it.
Three lines: when quarantine fails, don't delete — rethrow so createEncryptedPreferencesWithRecovery propagates and the store stays put for a later attempt. "Recovery still completes" (KDoc at :286) isn't worth an unrecoverable seed. If you want to keep a fallback, gate it on quarantinedXml.exists() && quarantinedXml.length() == originalLength.
(The bot flagged moveFile in pass 3 but chased the delete()-returns-false hazard, which is harmless since :335 deletes that path anyway.)
Your three deferred items
Erase after a failed ensureReadable — agreed, follow-up ticket. By WalletRepository.kt:301 we've established twice (:296, :304) that no wallet is stored, and Synchronizer.erase destroys SDK databases — block cache and derived note data, all reconstructible from the seed, which isn't in there. Skipping the erase would be no safer, just the crash-loop MOB-1836 was chasing. File it with the SecretState.ERROR ticket, since the honest fix is surfacing "your secret store is unreadable, do not reset" rather than routing silently to onboarding. One thing before merge though: the erase's .onFailure should log at a level that reaches crash reporting — "erase failed and we continued anyway" is the state that produces the next MOB-1836.
Test scope replacement ordering — genuinely fine, close it. A test calling init() before useTestScope would run the self-heal on real Dispatchers.IO, advanceUntilIdle() would no-op, and it'd pass vacuously. Test-integrity risk only, all current tests comply, and the bot offered no concrete fix. Not refactoring a passing test file on a maint branch.
sharingScope never cancelled in production — fine. singleOf + init() called once from ZcashApplication.onCreate. Process-lifetime scope on a process-lifetime singleton. Your reasoning is right.
What I checked and found safe
The retry-then-rethrow default is genuinely fail-safe (:449 requires both isRecreateAllowed and isUnrecoverableCorruption, else :455 throw cause), and PreferenceHolder/PreferenceProviderCache cache nothing on throw so a later launch retries cleanly. A read failure cannot surface as hasWallet = false — there's no try/runCatching anywhere in getPersistableWallet() → BaseNullableStorageProvider.get() → PersistableWalletPreferenceDefault.getValue, so null always means "key absent". That's the most important property in init() and it rests entirely on the absence of a catch three frames down — one coEvery { … } throws test would pin it against a future well-meaning runCatching. Device-locked/UserNotAuthenticatedException isn't reachable (MasterKey.Builder:267-271 never sets setUserAuthenticationRequired). Quarantine atomicity is sound: same filesystem so renameTo is an atomic rename, the .bak sibling is taken along (:76-78, correct and non-obvious — leaving it would let loadFromDisk resurrect the corrupt data), and I walked both crash windows (die after move before marker; die after marker before recreate) — neither loops and neither erases a usable store. Master-key deletion is correctly narrowed by the veto at :399-401, which is what makes quarantine meaningfully non-destructive at all.
TOCTOU (finding 1 pass 1) is over-delivered — single read at :257-260, walletMutation over init/create/restore, re-read before erase at :304-307, pinned by initSkipsEraseWhenAWalletIsStoredWhileSelfHealing. Findings 2, 4, 5 and both minors check out; I verified the resolveSecretState one against the pre-image and the old code mapped both ordinals to NONE already, so there's genuinely no behaviour change.
No log statement this PR adds leaks anything — :317, :325, :440, :450, :454 take only filename and counters. Separate pre-existing ticket though: WalletRepository.kt:262-264 does Twig.error(e) on a PersistableWallet parse failure, and Android's JSONTokener.syntaxError appends the entire input to the message — which at that point is the decrypted wallet JSON. Same catch existed before this PR; flagging it because this PR makes malformed-decrypt a routine path rather than an anomaly.
Test gaps
Three, and they map exactly onto the above: no isUnrecoverableCorruption assertion for the AOSP-wrapped transient shape; pruneQuarantine is tested for "keeps the newest" but nothing asserts which copy holds a wallet, so the retention policy's actual purpose is untested; and nothing tests the fail-safe hinge (throw → no onboarding write, no erase). The WalletRepositoryImplTest decision table is otherwise complete — all four quadrants, create-ordering, the race shape, the not-READY short-circuit.
One process note
The bot's three passes converged on nits while the two highest-consequence issues in the diff went unraised in all three. Both are only visible by reading the two classifiers against each other and by asking what pruneQuarantine is retaining rather than whether it retains correctly. Worth calibrating how much weight its "Risk: Low" carries on this kind of change.
Also: the user is told nothing (:298 is a Twig.warn) before landing on onboarding. Not a blocker — but a user who assumes a reinstall and taps Reset Zodl hits ResetZashiUseCase.kt:94 → purgeQuarantine() → deleteRecursively(), which is the last mile of the loss chain in items 1 and 2.
🤖 Claude ReviewCoverageFull diff and file contents reviewed at head_sha=71559314. SummaryThis PR fixes two bugs: MOB-1836 (wiped encrypted store left the onboarding flag as READY, causing a wallet-less home screen) and MOB-1565 (transient Keystore errors immediately destroyed the seed). It adds Risk AssessmentLow. All prior blocking items resolved. Remaining items are narrow edge cases and documentation nits. Key ObservationsCorrectness / Bugs1. Marker written even when quarantine falls back to delete In runCatching {
quarantineDir.mkdirs()
val markerCreated = recreatedMarkerFile(quarantineDir, filename).createNewFile()This creates the marker even though no quarantined file exists. On the next launch 2.
3. In if (!source.delete()) {
Twig.error { "Failed to delete source file after copy: $source" }
}The error is logged but the original file remains alongside the quarantined copy. On a truly storage-full device the original corrupted file survives, and the next launch's quarantine would see it again (though the marker guard would prevent a second quarantine attempt). Non-blocking. Concurrency4.
Design / Misc5. In private const val CRASH_PROCESS_NAME_SUFFIX = ":crash"The KDoc says "Kept in sync with 6. 7. Suggestions
Automated review by |
…-1565] `isUnrecoverableCorruption` treated any `InvalidKeyException` in the cause chain as corruption, while `isMasterKeyFailure` first vetoed on the AOSP transient-HAL marker (`android.security.KeyStoreException`, matched by simple name). A wedged Keystore daemon therefore moved the encrypted store — the only copy of the seed — into quarantine, where no screen can reach it, even though the master key survived and the store would have opened again once the daemon settled. Both classifiers now share one `hasTransientAndroidKeyStoreMarker(chain)`, so neither can act on a transient shape and the two can no longer drift apart. The retry-then-rethrow default then leaves the store in place for a later attempt. Quarantine retention keeps the oldest entry per filename unconditionally plus the newest N-1, instead of dropping oldest-first: only the first quarantine of a filename can set aside a store that ever held a wallet, because the recovery that follows recreates it empty, so oldest-first deleted exactly the copy worth keeping after four launches on flaky hardware. Quarantine failure no longer falls back to deleting the original files. A copy that throws mid-write leaves a truncated copy in quarantine, and deleting the original on top of that is unrecoverable loss; the failure is rethrown instead, propagating out of `createEncryptedPreferencesWithRecovery` with the store untouched. The recreated-marker guard is written only after the quarantine actually succeeded, so a store that was never set aside stays recoverable on the next launch. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXV241FzSZi6wy2PXPNz3S
`Synchronizer.erase`'s `.onFailure` only wrote a `Twig.error`, which is `Log.e` and never leaves the device. "The erase failed and we carried on anyway" is the half-reset state MOB-1836 was reported for, so it now also goes through `GlobalCrashReporter.reportCaughtException`, the same path `NearSwapDataSource` uses for caught failures worth seeing. Adds the missing test for the fail-safe hinge of the self-heal: a wallet read that throws must write no onboarding state and erase nothing, so an unreadable store can never surface as "no wallet". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXV241FzSZi6wy2PXPNz3S
|
Thanks Lukas — both of the blocking findings are exactly the ones three bot passes walked past, and they are the two that matter here. Reading the two classifiers against each other, and asking what 1. Shared transient-HAL veto — 2. Quarantine retention — I corrected the PR description too: it now says the recreated-marker bounds the immediate retry within a run — it is cleared on any successful open — and explicitly not repeat quarantines across launches, which is what the retention policy is there for. 3. No delete fallback — 4. Erase failure visibility — Third test gap — the fail-safe hinge — Your three deferred items, as agreed
Gates: 🤖 Generated with Claude Code |
`hasTransientAndroidKeyStoreMarker` vetoed both classifiers on the mere
presence of an `android.security.KeyStoreException` in the cause chain. That
class is not a transient marker: AOSP raises it for every Keystore and KeyMint
failure, permanent ones included. The instrumented
`graceful_recovery_when_master_key_is_lost_in_migration` therefore failed with
`javax.crypto.AEADBadTagException` — the chain a device-to-device transfer
actually produces is
javax.crypto.AEADBadTagException
caused by android.security.KeyStoreException:
Signature/MAC verification failed (internal Keystore code: -30)
observed identically on an emulator on API 31 and API 35. `MasterKey.Builder
.build()` mints a replacement key under the old alias, so the stored ciphertext
can never authenticate again; the veto read that as transient, the retry ladder
exhausted, the failure was rethrown, and the store was never quarantined, so
onboarding was never reached.
The veto now holds only while the failure is not positively permanent, and both
classifiers keep sharing the one implementation so they cannot drift.
Permanence is read from what AOSP itself exposes: `isTransientFailure()` short
-circuits it whenever the platform says the failure will heal, and permanence
then needs either the hard-coded Keystore wording for a gone, unparseable or
unauthenticating key, or `getNumericErrorCode()` reporting
ERROR_KEY_DOES_NOT_EXIST / ERROR_KEY_CORRUPTED. The typed API landed in API 33,
so it is read reflectively and the wording carries devices below it; a failure
that neither signal classifies stays vetoed and is rethrown for a later launch
rather than quarantined.
`EncryptedPreferenceRecoveryTest` pins the permanent shapes — key not found,
key permanently invalidated, ciphertext that no longer authenticates, the
numeric code standing alone, and the pre-API-33 message-only path — alongside
the transient ones, including that a transient verdict outranks a
permanent-sounding message. The Keystore stand-in gained the API 33
classification, with a second stand-in for the pre-33 shape that carries none.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXV241FzSZi6wy2PXPNz3S
|
CI regression fixed in 2de9a76. What failed
The store was never quarantined, so the recovery under test never happened and the failure was rethrown instead. The chain that actually occurs I reproduced the scenario on an emulator and dumped the cause chain. It is the same on API 35 and API 31: with, on API 35:
How the veto was made precise The veto is unchanged in shape and is still shared by both classifiers, so they cannot drift. It now holds only while the failure is not positively permanent:
The typed classification is API 33+, so it is read reflectively; the message strings carry devices below that. I checked the AOSP sources for API 30, 31 and 36: the strings come from Tests The instrumented test is unchanged — it was correct, and it is what caught this. Verified locally
|
LukasKorba
left a comment
There was a problem hiding this comment.
All three blocking items are properly closed, and (2) plus the new tests are better than the minimum ask. One thing needs to be on the record rather than riding in on a fix round — see the predicate note below. Good to merge once that's acknowledged.
The predicate change — my call, recorded
2de9a76 narrowed the veto I asked for. I asked for the bare it !is java.security.KeyStoreException && simpleName == "KeyStoreException"; head has a third conjunct, !isPermanentAndroidKeyStoreFailure(it) (AndroidPreferenceProvider.kt:457-462).
You were right to push back and I'm accepting it. My predicate would have stranded the device-to-device transfer case — the thing this recovery exists for — permanently: the real chain is AEADBadTagException ← android.security.KeyStoreException: Signature/MAC verification failed, MasterKey.Builder.build() mints a replacement under the old alias so the ciphertext can never authenticate again, and a bare marker veto reads that as transient forever. Ladder exhausts, rethrow, splash with no recourse. I'd have traded a loss path for a brick, which is not obviously the better trade. Reproducing it on API 31 and 35 and pinning it with graceful_recovery_when_master_key_is_lost_in_migration is the right way to have made that argument.
The structure is also what I asked for: one hasTransientAndroidKeyStoreMarker at :457, called as the first statement of both classifiers (:486, :516), with causeChain() shared too, so CAUSE_CHAIN_LIMIT can't drift either. And the ordering inside isPermanentAndroidKeyStoreFailure is the safe one — isTransientPerAndroidKeyStore first at :430, so when AOSP says transient that outranks whatever the message string says, and anything unclassifiable stays vetoed.
I checked the veto didn't over-apply, which was my worry: BadPaddingException, AEADBadTagException, CharConversionException and InvalidProtocolBufferException with no marker still classify as corruption and still recover. Genuine corruption isn't stuck.
The residual I'm accepting with open eyes, so it's written down: on API 27-32 both reflective signals are absent (isTransientFailure() and the numeric code are API 33+), so PERMANENT_ANDROID_KEY_STORE_MESSAGES (:383-390) is the only guard there. A transient failure that happened to carry "Key not found" — a keystore daemon whose DB isn't up yet, say — would release the veto, and then InvalidKeyException in the chain makes both classifiers true: quarantine the seed and delete the shared master key. I think that's unlikely (AOSP returns SYSTEM_ERROR, not KEY_NOT_FOUND, for daemon-level trouble) and the ~1.4s ladder covers the common case, but it's the one place the narrowing costs loss-axis margin, and it's the thing to revisit first if a pre-33 report ever comes in.
The rest
(2) retention — fixed, and the shape is right. EncryptedPreferenceQuarantine.kt:150-170, drop(keepNewest - 1).filterNot { it == oldest }. I walked it out to five quarantines: Q1 survives every round, steady state is exactly 3 entries, no unbounded growth. keepNewest = 1 and even 0 still keep the oldest, so there's no argument for which prune deletes the last copy. .bak pairing is correct — the filter requires .xml so .bak files are never candidates themselves, and EncryptedPreferenceQuarantineTest.kt:83-94 asserts the three survivors' .bak siblings all remain.
(3) — fixed. The deleting instead block is gone; :318-322 calls quarantine unguarded, so a throw propagates with the originals in place. The bonus is the better half: the marker now writes only after the move succeeded (EncryptedPreferenceQuarantine.kt:99-121), so a store that was never set aside can still recover next launch. a failed quarantine keeps the original files and writes no marker pins both.
Crash reporting — fixed, WalletRepository.kt:314-318, pinned with verify(exactly = 1) on the instance.
All three test gaps genuinely pinned, not adjacent: EncryptedPreferenceQuarantineTest.kt:103-133 writes "seed ciphertext" then asserts by content that target-1700000000001.xml still holds it after five quarantines — that's the property, not a file count. WalletRepositoryImplTest.kt:198-221 asserts the fail-safe hinge with an empty event list, the onboarding pref unchanged at READY, and coVerify(exactly = 0) on erase. And EncryptedPreferenceRecoveryTest.kt:71-201 now covers both API tiers with stand-ins, including transient-outranks-message.
Nits, not blocking
EncryptedPreferenceQuarantine.kt:150-158— if a quarantine ever moves only a.bak(xml absent mid-commit), the resulting<filename>-<millis>.xml.bakhas no.xmlsibling, so theendsWith(".xml")filter never sees it and it's never pruned. Genuinely unbounded, though it's a few KB andpurgeQuarantine()clears it.- Worth naming in the KDoc: on an unclassifiable permanent failure the user now sits on the splash with no in-app recourse, since Reset Zodl is behind it. That's the deliberate price of failing safe and it's the right default — but it should be written down next to
WalletRepository.kt:146-153rather than discovered later.
|
Follow-up tickets from Lukas's second pass:
|
…il-safe recourse trade-off pruneQuarantine identified an entry by its .xml file alone, so an entry whose .xml move failed mid-commit (leaving only its .xml.bak sibling) was invisible to the .xml-only listing and never aged out, growing the quarantine directory without bound. Identify entries by their shared <filename>-<millis> base name instead, covering the .xml, .bak, or both-present cases alike; the oldest-survives retention rule is unchanged. Pins the gap with a new test. Also documents, on isPermanentAndroidKeyStoreFailure, the deliberate trade-off Lukas asked to have written down: an unclassifiable Keystore failure is rethrown rather than acted on, leaving the user on the splash with no in-app recourse (Reset Zodl sits behind a normal boot), which is the price of never quarantining or wiping a store that might still turn out to be readable. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXV241FzSZi6wy2PXPNz3S
|
Acknowledged on the record, per your 19:35Z review. API 27-32 residual risk — accepted with open eyes, exactly as framed: with Both nits pushed in
Filed separately by the orchestrator, not in this PR: the Gates: ktlintFormat/detektAll clean, |
LukasKorba
left a comment
There was a problem hiding this comment.
Both nits done, and the orphan fix is the right shape — EncryptedPreferenceQuarantine.kt:160-165 collects base names from .xml and .xml.bak into a set and deletes both siblings per entry, so a .bak with no .xml is now reachable by the pruner while the oldest entry stays pinned. Retention semantics are unchanged.
The KDoc is better than what I asked for. I wanted the no-in-app-recourse consequence written down; you also supplied the fact that makes it defensible — that the crashed sharing coroutine is the same terminal outcome the app already reaches today via SynchronizerProvider collecting the same underlying store. That reframes it: the splash hang isn't a new failure mode this PR introduces, it's the existing one, and the alternative would be misrouting an intact-but-unreadable store to onboarding. That's the stronger argument and I hadn't had it in front of me.
Nothing further from me on this one. Signed off on the API 27-32 residual as discussed — the pre-33 message list is the thing to revisit first if a report ever comes in from that range.
LukasKorba
left a comment
There was a problem hiding this comment.
Approving.
Summary of what this sign-off covers, for the record: the three blocking items I raised are closed in code — the transient-Keystore veto is shared by both classifiers through one helper so they can't drift, pruneQuarantine pins the oldest (seed-bearing) entry indefinitely with no unbounded growth, and the quarantine-failure path no longer deletes the original. The erase failure reaches crash reporting, and all three test gaps are pinned by tests that assert the property rather than something adjacent — the quarantine one asserts by content that the seed survives five rounds.
Two things I'm explicitly signing off rather than silently accepting:
- The narrowed veto (
hasTransientAndroidKeyStoreMarker+isPermanentAndroidKeyStoreFailure). This is not the predicate I originally asked for. You were right that the bare version would have stranded the device-to-device transfer case on the splash forever, and reproducing it on API 31 and 35 was the right way to make that argument. - The API 27-32 residual. Below API 33 both reflective signals are absent, so the five-string message list is the only guard, and a transient failure carrying one of those messages would release the veto and both quarantine the seed and delete the master key. I judge this unlikely and the ~1.4s ladder covers the common case. If a report ever comes in from that API range, this is the first thing to look at.
Fixes MOB-1836 — Exception on Android after updating from an old release and MOB-1565 — Seed destroyed on any transient Keystore/encrypted-prefs exception.
Problem
The wallet seed lives in the encrypted preferences store; the onboarding flag lives in the plain store. When the encrypted store is provably unreadable (for example after a device-to-device transfer, or the
AEADBadTagExceptionfrom MOB-1452),newEncrypteddeletes and recreates it (MOB-1452 in 3.7.1, narrowed by MOB-1691 in 3.10.1) but nothing resets the onboarding flag.secretStatederived READY from the flag alone, so the app opened Home with no wallet behind it and the balance and transaction list spun forever. That is the state the MOB-1836 user is in on 3.10.2: the reported exception predates their update, and 3.10.2 wiped the store silently.MOB-1565 additionally asked for a bounded retry before the store is condemned, and for the corrupted file to be set aside rather than deleted.
Fix
MOB-1836 (ui-lib)
SecretState.READYnow requires both the onboarding flag and a stored wallet (resolveSecretState); READY with no wallet resolves to NONE, andRootNavGraphalready routes NONE to onboarding. The flow deliberately never maps a store failure to null.WalletRepositoryImpl.init(): when the flag says READY but no wallet is stored, erase the stale SDK databases and persist the flag back to NONE. This is what recovers users who were already wiped by 3.10.1/3.10.2.createNewWallet()persists the wallet before marking onboarding READY, so a crash between the two writes can never leave "flag without wallet".Synchronizer.eraseon the self-heal path is reported throughGlobalCrashReporteras well as logged: "the erase failed and we carried on anyway" is the half-reset state this ticket was reported for, and aTwig.errornever leaves the device.MOB-1565 (preference-impl-android-lib)
AEADBadTagExceptioncaused by a Keystore operation error).shared_prefs/<file>.xmland its.baksibling underno_backup/encrypted_prefs_quarantine/instead of deleting them. Retention keeps the oldest copy of a filename unconditionally plus the two newest: only the first quarantine can set aside a store that ever held a wallet, because the recovery that follows recreates it empty. A quarantine that fails mid-move is rethrown with the original files left in place — there is no delete fallback. The Keystore master-key alias is deleted only for key-level failures, so a quarantined file stays decryptable by this device for data-level ones.android.security.KeyStoreException) is never classified as corruption or as a master-key failure — one shared veto used by both classifiers — so a wedged Keystore daemon rethrows and leaves the store where it is.Deliberately left for follow-up tickets: the
:crashprocess running the full app init, aSecretState.ERRORfor a persistently unreadable store (which is also where the erase-after-a-failed-ensureReadablequestion belongs), finer API 33+KeyStoreExceptionclassification, and the pre-existingTwig.error(e)on aPersistableWalletparse failure, which can log decrypted wallet JSON throughJSONTokener.syntaxError.Test plan
:preference-impl-android-lib:testDebugUnitTest— 35/35 (retry ladder, classification incl. the transient-marker veto for both classifiers, quarantine/prune retention, quarantine-failure rethrow).:ui-lib:testZcashmainnetStoreDebugUnitTest— full suite 469/469, incl. newWalletRepositoryImplTest(resolver, real combine, create-wallet ordering, init self-heal).:ui-lib:testZcashmainnetInternalDebugUnitTest --tests '*WalletRepository*'— 11/11 after the review fixes, incl. the fail-safe hinge (a wallet read that throws writes no onboarding state and erases nothing) and the failed-erase crash report.:preference-impl-android-lib:connectedDebugAndroidTeston Pixel 9 Pro XL (API 35) — 12/12; the two recovery tests now assert the quarantine file, no leftover.bak, and alias retention.Author
Reviewer
🤖 Generated with Claude Code
https://claude.ai/code/session_016rqq1yYyS5XofPcqE7bZ4r