Coming from #853 discussion
Implementation Ticket: Preserve Keychain Error Messages and Improve Error Handling
Parent SPIKE: #853 — "Failed to set key" errors discard the underlying iOS Keychain reason
Sentry Issue: FREIGHTER-MOBILE-R8 — 56 events / 30 users
Related: #852 (FREIGHTER-MOBILE-R9 — encryption type-safety bug, same import-wallet flow)
Context
The SPIKE confirmed that ReactNativeKeychainFacade.setKey swallows the native iOS/Android Keychain error in its catch block, replacing it with "Failed to set key 0.xxx". This single generic message groups all Keychain failures together in Sentry, making diagnosis impossible.
Additionally, auth store error catches pass raw technical error messages directly into user-facing UI state (set({ error: error.message })). After fixing the Facade, those raw messages would become "Failed to write to keychain: User interaction is not allowed." in the user's face — still wrong.
Safety confirmed by full native-layer audit: iOS messageForError and Android CryptoFailedException only surface sanitized system strings; no stored data, passwords, or secrets are ever echoed back.
Implementation Path
Change 1 — src/helpers/keyManager/ReactNativeKeychainFacade.ts
1a. setKey — lines 110–112 (the core bug)
Current:
} catch (error) {
throw new Error(`Failed to set key ${id}`);
}
After:
} catch (error) {
const cause = error instanceof Error ? error.message : String(error);
throw new Error(`Keychain write rejected: ${cause}`);
}
Why this way:
- Drops the key ID from the thrown message — the
Math.random() suffix (0.195…) made every Sentry event unique, preventing grouping.
- Does NOT call
logger.error here. The store catch upstream (useAuthenticationStore.importWallet) already calls logger.error with the rethrown error — that is the single Sentry event. The technical cause is preserved in the error message and goes to Sentry there.
- The numeric error code (OSStatus) is not carried through. The human-readable cause string (
"User interaction is not allowed.") is sufficient for Sentry grouping and diagnosis.
1b. getKey — lines 84–95
Already logs and returns null. Update the logger context name for consistency (currently misnamed ReactNativeKeychainKeyStore):
Current:
} catch (error) {
logger.error(
"ReactNativeKeychainKeyStore.getKey",
`Error getting key ${id.slice(0, 8)}...:`,
error,
);
return null;
}
After:
} catch (error) {
logger.error(
"ReactNativeKeychainFacade.getKey",
`Keychain read failed for key ${id.slice(0, 8)}...:`,
error,
);
return null;
}
Note: getKey intentionally does not throw — ReactNativeKeychainKeyStore.loadKey (line 151) converts a null return to Promise.reject(id), so the failure does propagate up. Keeping the non-throwing pattern here is correct.
1c. removeKey — lines 123–131
Already best-effort (no throw). Update the logger context name for consistency:
Current:
} catch (error) {
logger.error(
"ReactNativeKeychainKeyStore.removeKey",
`Error removing key ${id.slice(0, 8)}...:`,
error,
);
// Don't throw here, as the key might not exist
}
After:
} catch (error) {
logger.error(
"ReactNativeKeychainFacade.removeKey",
`Keychain remove failed for key ${id.slice(0, 8)}...:`,
error,
);
}
Change 2 — src/ducks/auth.ts: Stop passing raw error messages to user-facing state
There are four store catch blocks that follow the same anti-pattern:
error instanceof Error ? error.message : t("authStore.error.someKey")
This means if any error has a .message, it gets shown to the user verbatim. Before Change 1, this only surfaced generic-but-safe strings like "Failed to set key 0.xxx". After Change 1, it would surface "Keychain write rejected: User interaction is not allowed." — still raw and technical.
All four i18n keys already exist in src/i18n/locales/en/translations.json. The fix is to always use them for user-facing state, and rely on the existing logger.error call in each catch to send the technical detail to Sentry.
2a. importWallet — lines 2537–2547
Current:
logger.error(
"useAuthenticationStore.importWallet",
"Import wallet failed",
error,
);
set({
error:
error instanceof Error
? error.message
: t("authStore.error.failedToImportWallet"),
isLoading: false,
});
After:
logger.error(
"useAuthenticationStore.importWallet",
"Import wallet failed",
error,
);
set({
error: t("authStore.error.failedToImportWallet"),
isLoading: false,
});
2b. signUp — lines 2110–2118
Current:
error instanceof Error
? error.message
: t("authStore.error.failedToSignUp"),
After:
t("authStore.error.failedToSignUp"),
2c. signIn — lines 2184–2191
Current:
error instanceof Error
? error.message
: t("authStore.error.failedToSignIn"),
After:
t("authStore.error.failedToSignIn"),
2d. logout — lines 2080–2087
Current:
error instanceof Error
? error.message
: t("authStore.error.failedToLogout"),
After:
t("authStore.error.failedToLogout"),
Important: In all four cases, the logger.error call immediately above already sends the technical error (including the now-preserved keychain cause) to Sentry. The ternary was redundant — if a message was available, logger.error already captured it. The i18n string has always been the right user-facing copy.
Change 3 — Biometric login: verify existing toast covers blocking keychain errors
The current flow in src/hooks/useAppOpenBiometricsLogin.ts (lines 37–49):
verifyActionWithBiometrics(async (biometricPassword?: string) => {
if (biometricPassword) {
await signIn({ password: biometricPassword });
}
}).catch(() => {
showToast({
toastId: "unlock-wallet-error",
variant: "error",
title: t("lockScreen.errorUnlockingWalletTitle"),
message: t("lockScreen.errorUnlockingWalletMessage"),
duration: 6000,
});
});
The .catch() already handles any error thrown from signIn — including propagated keychain errors. No change needed here for the happy path.
However, verifyActionWithBiometrics in auth.ts (lines 2506–2512) catches and re-throws with only set({ isLoading: false }) — it does NOT call logger.error, so a keychain read failure during biometric unlock is not captured in Sentry. Add the logger call:
Locate: verifyActionWithBiometrics inner catch in auth.ts (grep for "Biometric authentication failed").
After:
} catch (error) {
logger.error(
"verifyActionWithBiometrics",
"Biometric authentication failed",
error,
);
set({ isLoading: false });
throw error;
}
(This logger call may already exist — verify before adding.)
What Does NOT Change
hasKey — swallows errors and returns false. This is correct: it's a probe, not a critical read.
removeKey swallowing errors — intentional, keys may not exist.
getAllKeys / addToKeyIndex / removeFromKeyIndex error handling — all already log properly.
- The toast in
useAppOpenBiometricsLogin — already surfaces errors to users in the biometric flow.
secureStorage.ts — separate from the keychain facade, out of scope for this ticket.
Expected Sentry Outcome After These Changes
Sentry events for FREIGHTER-MOBILE-R8 will split into distinct groups:
| New Sentry title |
Likely cause |
Keychain write rejected: User interaction is not allowed. |
Background/locked device race (errSecInteractionNotAllowed) |
Keychain write rejected: Failed to allocate memory. |
Low-memory device (errSecAllocate) |
Keychain write rejected: No keychain is available. You may need to restart your device or enable a passcode. |
Device passcode not set or keychain corrupt (errSecNotAvailable) |
Keychain write rejected: The user name or passphrase you entered is not correct. |
Biometric auth failure during write (errSecAuthFailed) |
Each is a separate Sentry group with a separate root cause and separate remediation path. A follow-up issue will be opened once 2–3 weeks of post-fix data accumulates.
Acceptance Criteria
Open Decision (need sign-off before implementation)
- Follow-up trigger — No concrete criteria for when to open the UX unification follow-up. Suggest: re-evaluate after 4 weeks of post-fix Sentry data.
Coming from #853 discussion
Implementation Ticket: Preserve Keychain Error Messages and Improve Error Handling
Parent SPIKE: #853 — "Failed to set key" errors discard the underlying iOS Keychain reason
Sentry Issue: FREIGHTER-MOBILE-R8 — 56 events / 30 users
Related: #852 (FREIGHTER-MOBILE-R9 — encryption type-safety bug, same import-wallet flow)
Context
The SPIKE confirmed that
ReactNativeKeychainFacade.setKeyswallows the native iOS/Android Keychain error in itscatchblock, replacing it with"Failed to set key 0.xxx". This single generic message groups all Keychain failures together in Sentry, making diagnosis impossible.Additionally, auth store error catches pass raw technical error messages directly into user-facing UI state (
set({ error: error.message })). After fixing the Facade, those raw messages would become"Failed to write to keychain: User interaction is not allowed."in the user's face — still wrong.Safety confirmed by full native-layer audit: iOS
messageForErrorand AndroidCryptoFailedExceptiononly surface sanitized system strings; no stored data, passwords, or secrets are ever echoed back.Implementation Path
Change 1 —
src/helpers/keyManager/ReactNativeKeychainFacade.ts1a.
setKey— lines 110–112 (the core bug)Current:
After:
Why this way:
Math.random()suffix (0.195…) made every Sentry event unique, preventing grouping.logger.errorhere. The store catch upstream (useAuthenticationStore.importWallet) already callslogger.errorwith the rethrown error — that is the single Sentry event. The technical cause is preserved in the error message and goes to Sentry there."User interaction is not allowed.") is sufficient for Sentry grouping and diagnosis.1b.
getKey— lines 84–95Already logs and returns null. Update the logger context name for consistency (currently misnamed
ReactNativeKeychainKeyStore):Current:
After:
Note:
getKeyintentionally does not throw —ReactNativeKeychainKeyStore.loadKey(line 151) converts a null return toPromise.reject(id), so the failure does propagate up. Keeping the non-throwing pattern here is correct.1c.
removeKey— lines 123–131Already best-effort (no throw). Update the logger context name for consistency:
Current:
After:
Change 2 —
src/ducks/auth.ts: Stop passing raw error messages to user-facing stateThere are four store catch blocks that follow the same anti-pattern:
This means if any error has a
.message, it gets shown to the user verbatim. Before Change 1, this only surfaced generic-but-safe strings like"Failed to set key 0.xxx". After Change 1, it would surface"Keychain write rejected: User interaction is not allowed."— still raw and technical.All four i18n keys already exist in
src/i18n/locales/en/translations.json. The fix is to always use them for user-facing state, and rely on the existinglogger.errorcall in each catch to send the technical detail to Sentry.2a.
importWallet— lines 2537–2547Current:
After:
2b.
signUp— lines 2110–2118Current:
After:
2c.
signIn— lines 2184–2191Current:
After:
2d.
logout— lines 2080–2087Current:
After:
Important: In all four cases, the
logger.errorcall immediately above already sends the technical error (including the now-preserved keychain cause) to Sentry. The ternary was redundant — if a message was available,logger.erroralready captured it. The i18n string has always been the right user-facing copy.Change 3 — Biometric login: verify existing toast covers blocking keychain errors
The current flow in
src/hooks/useAppOpenBiometricsLogin.ts(lines 37–49):The
.catch()already handles any error thrown fromsignIn— including propagated keychain errors. No change needed here for the happy path.However,
verifyActionWithBiometricsinauth.ts(lines 2506–2512) catches and re-throws with onlyset({ isLoading: false })— it does NOT calllogger.error, so a keychain read failure during biometric unlock is not captured in Sentry. Add the logger call:Locate:
verifyActionWithBiometricsinner catch inauth.ts(grep for"Biometric authentication failed").After:
(This logger call may already exist — verify before adding.)
What Does NOT Change
hasKey— swallows errors and returns false. This is correct: it's a probe, not a critical read.removeKeyswallowing errors — intentional, keys may not exist.getAllKeys/addToKeyIndex/removeFromKeyIndexerror handling — all already log properly.useAppOpenBiometricsLogin— already surfaces errors to users in the biometric flow.secureStorage.ts— separate from the keychain facade, out of scope for this ticket.Expected Sentry Outcome After These Changes
Sentry events for FREIGHTER-MOBILE-R8 will split into distinct groups:
Keychain write rejected: User interaction is not allowed.errSecInteractionNotAllowed)Keychain write rejected: Failed to allocate memory.errSecAllocate)Keychain write rejected: No keychain is available. You may need to restart your device or enable a passcode.errSecNotAvailable)Keychain write rejected: The user name or passphrase you entered is not correct.errSecAuthFailed)Each is a separate Sentry group with a separate root cause and separate remediation path. A follow-up issue will be opened once 2–3 weeks of post-fix data accumulates.
Acceptance Criteria
setKeycatch preserves the native error message in the thrown error, without the key ID.setKeydoes NOT calllogger.error— the single Sentry event fires at the store catch level.getKeyandremoveKeylogger context names are corrected toReactNativeKeychainFacade.*.errorstate inimportWallet,signIn,signUp, andlogoutstore actions always uses the i18n string — nevererror.messagedirectly.logger.errorso the technical cause reaches Sentry.logger.erroris called inverifyActionWithBiometricsif not already present.Open Decision (need sign-off before implementation)