Skip to content

fix(ios): return a retryable error when AsyncStorage protected data is unavailable#5

Closed
exodus-jee wants to merge 1 commit into
exodusfrom
jee/fix/data-protection-none
Closed

fix(ios): return a retryable error when AsyncStorage protected data is unavailable#5
exodus-jee wants to merge 1 commit into
exodusfrom
jee/fix/data-protection-none

Conversation

@exodus-jee

@exodus-jee exodus-jee commented Jun 16, 2026

Copy link
Copy Markdown

Summary

When the app touches AsyncStorage before the first unlock after a reboot (iOS can background-launch us via remote-notification), the storage files are protection-locked and the read fails. Today that surfaces as a generic, permanent-looking AsyncStorageError. This change detects that window via UIApplication.isProtectedDataAvailable and returns a distinct, explicitly-retryable error instead, without changing the files' data protection class (encryption at rest is preserved).

This replaces the earlier idea of relaxing the class to NSFileProtectionNone (see #4 and the first revision of this PR). That approach was wrong on three counts, so it is dropped:

  • it traded away at-rest protection (passcode-gated) for an unproven cause;
  • the iOS default is already CompleteUntilFirstUserAuthentication (no default-data-protection entitlement), so fix(ios): relax AsyncStorage data protection to allow access while locked #4's CompleteUntilFirstUserAuthentication was a no-op (thanks @exo-mv);
  • the native layer does not actually brick: _ensureSetup already refuses to create a fresh manifest when the read errors, and _haveSetup is per-process, so it self-heals on the next unlocked launch. There was no permanent storage corruption to "fix" with a class change.

What this actually does

In _ensureSetup, before any file read, if protected data is unavailable, return AsyncStorageError [AsyncStorage protected data unavailable, retry after device unlock] and do nothing else (no manifest read, no directory mutation). The flag is cached from UIApplicationProtectedDataDidBecomeAvailable/WillBecomeUnavailable notifications and read lock-free off the storage queue, so the main-thread-only UIApplication API is never touched off the main thread. Guarded with #if !TARGET_OS_OSX.

Net effect: the before-first-unlock window is correctly classified as transient/expected rather than reported as a hard failure, and we never weaken the protection class.

Honest scope (what this does and does not fix)

This is the native half. Full resolution of #39598 also needs:

  1. JS side (exodus-mobile): treat the new protected data unavailable message as retryable/deferrable in storage-mobile (the #39599 allowlist) so callers back off until unlock instead of erroring.
  2. The real proven failure (hydra / @exodus/pay-user): the background report calls payUserAtom.get() unconditionally, so a locked background export hangs on deferred storage until the 15s timeout. That is the actual source of the "Export took longer than the maximum export timeout" symptom, and it is independent of the protection class. Root fix opened: ExodusMovement/exodus-hydra#17212 (gate the report on isLocked, like payAuth / walletAccounts already do).
  3. Diagnostic to confirm the cause: today we do not capture the underlying NSError.code/domain (RCTMakeError drops them; errors.js matches on message strings only), so we cannot distinguish data-protection (Cocoa 257) from disk-full (640) or directory-missing (4). A diagnostic that surfaces the code/domain should land so we can confirm the before-first-unlock case is actually dominant before relying on this.

Security

No change to data protection class. Files keep the default CompleteUntilFirstUserAuthentication (encrypted at rest, passcode-gated). No entitlement change.

Release / test plan

Bumps to 1.17.11-exodus.6 (exodus.5 is already taken by a separate RN 0.85 release line). Native build plus on-device QA: with files created under the default class, background-wake the app before the first unlock after a reboot and confirm the operation returns the new protected data unavailable retryable error (not a generic permanent AsyncStorageError), and that a normal unlocked launch reads/writes fine. Then bump @exodus/react-native-async-storage in exodus-mobile from 1.17.11-exodus.4 to 1.17.11-exodus.6 and wire the JS-side retry/defer (item 1 above).

@exodus-jee exodus-jee self-assigned this Jun 16, 2026
@exodus-jee exodus-jee force-pushed the jee/fix/data-protection-none branch from 7ca8098 to 6321d73 Compare June 16, 2026 03:16
@exodus-jee exodus-jee marked this pull request as draft June 16, 2026 03:20
@exodus-jee exodus-jee force-pushed the jee/fix/data-protection-none branch from 6321d73 to 2c4be4c Compare June 16, 2026 05:38
@exodus-jee exodus-jee changed the title fix(ios): set AsyncStorage data protection to None for pre-first-unlock access fix(ios): return a retryable error when AsyncStorage protected data is unavailable Jun 16, 2026
@exodus-jee exodus-jee marked this pull request as ready for review June 16, 2026 06:09
@exodus-jee

Copy link
Copy Markdown
Author

Closing this. The root fix for the proven symptom landed elsewhere: ExodusMovement/exodus-hydra#17212 gates the pay-user report on isLocked so a locked background export no longer reads unlock-gated storage and no longer hangs to the export timeout. That keeps storage encrypted at rest, with no data protection change.

This native change is defense-in-depth, and right now it is not load-bearing, so I would rather not ship it as-is:

  • The data-protection cause is still unconfirmed. We do not capture the underlying NSError.code/domain today (RCTMakeError drops them, errors.js matches on message strings), so we cannot tell the before-first-unlock permission case (Cocoa 257) apart from disk-full (640) or directory-missing (4). Landing a native change for an unconfirmed cause is the same trap as fix(ios): relax AsyncStorage data protection to allow access while locked #4.
  • Nothing on the JS side consumes the new "protected data unavailable" retryable error yet, and the native layer already self-heals (it does not clobber the manifest, and _haveSetup is per-process). So today this only produces a cleaner error string that nobody acts on.

Happy to reopen once we (a) add a diagnostic that surfaces the NSError code/domain and confirm the before-first-unlock case is actually dominant, and (b) wire the JS-side retry/defer (the #39599 allowlist). The branch jee/fix/data-protection-none stays, so reviving it is a one-liner. Thanks @exo-mv for the earlier catch that got us here.

@exodus-jee exodus-jee closed this Jun 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant