From 665545cd656b01c05b796cdc8975910c8b85b32e Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Sat, 10 Jan 2026 00:43:36 -0800 Subject: [PATCH] Centralize keychain error helpers and add unit tests (#1571) * Add keychain error tests * format --- app/src/providers/authProvider.tsx | 30 ++---- app/src/providers/passportDataProvider.tsx | 109 +++++++++------------ app/src/utils/keychainErrors.ts | 46 +++++++++ app/tests/src/utils/keychainErrors.test.ts | 64 ++++++++++++ 4 files changed, 164 insertions(+), 85 deletions(-) create mode 100644 app/src/utils/keychainErrors.ts create mode 100644 app/tests/src/utils/keychainErrors.test.ts diff --git a/app/src/providers/authProvider.tsx b/app/src/providers/authProvider.tsx index 66e0ccca6..bfcdf8023 100644 --- a/app/src/providers/authProvider.tsx +++ b/app/src/providers/authProvider.tsx @@ -25,6 +25,11 @@ import { import { trackEvent } from '@/services/analytics'; import { useSettingStore } from '@/stores/settingStore'; import type { Mnemonic } from '@/types/mnemonic'; +import { + getKeychainErrorIdentity, + isKeychainCryptoError, + isUserCancellation, +} from '@/utils/keychainErrors'; const SERVICE_NAME = 'secret'; @@ -151,29 +156,6 @@ let keychainCryptoFailureCallback: | ((errorType: 'user_cancelled' | 'crypto_failed') => void) | null = null; -function isUserCancellation(error: unknown): boolean { - const err = error as { code?: string; message?: string }; - return Boolean( - err?.code === 'E_AUTHENTICATION_FAILED' || - err?.code === 'USER_CANCELED' || - err?.message?.includes('User canceled') || - err?.message?.includes('Authentication canceled') || - err?.message?.includes('cancelled by user'), - ); -} - -function isKeychainCryptoError(error: unknown): boolean { - const err = error as { code?: string; name?: string; message?: string }; - return Boolean( - (err?.code === 'E_CRYPTO_FAILED' || - err?.name === 'com.oblador.keychain.exceptions.CryptoFailedException' || - err?.message?.includes('CryptoFailedException') || - err?.message?.includes('Decryption failed') || - err?.message?.includes('Authentication tag verification failed')) && - !isUserCancellation(error), - ); -} - async function loadOrCreateMnemonic( keychainOptions: KeychainOptions, ): Promise { @@ -214,7 +196,7 @@ async function loadOrCreateMnemonic( } if (isKeychainCryptoError(error)) { - const err = error as { code?: string; name?: string }; + const err = getKeychainErrorIdentity(error); console.error('Keychain crypto error:', { code: err?.code, name: err?.name, diff --git a/app/src/providers/passportDataProvider.tsx b/app/src/providers/passportDataProvider.tsx index df0a1c11a..dffcab240 100644 --- a/app/src/providers/passportDataProvider.tsx +++ b/app/src/providers/passportDataProvider.tsx @@ -67,6 +67,12 @@ import { getAllDocuments, useSelfClient } from '@selfxyz/mobile-sdk-alpha'; import { createKeychainOptions } from '@/integrations/keychain'; import { unsafe_getPrivateKey, useAuth } from '@/providers/authProvider'; +import type { KeychainErrorType } from '@/utils/keychainErrors'; +import { + getKeychainErrorIdentity, + isKeychainCryptoError, + isUserCancellation, +} from '@/utils/keychainErrors'; let keychainCryptoFailureCallback: | ((errorType: 'user_cancelled' | 'crypto_failed') => void) @@ -78,29 +84,41 @@ export function setPassportKeychainErrorCallback( keychainCryptoFailureCallback = callback; } -function isUserCancellation(error: unknown): boolean { - const err = error as { code?: string; message?: string }; - // User cancelled biometric/PIN authentication - return Boolean( - err?.code === 'E_AUTHENTICATION_FAILED' || - err?.code === 'USER_CANCELED' || - err?.message?.includes('User canceled') || - err?.message?.includes('Authentication canceled') || - err?.message?.includes('cancelled by user'), - ); +function notifyKeychainFailure(type: KeychainErrorType) { + if (keychainCryptoFailureCallback) { + keychainCryptoFailureCallback(type); + } } -function isKeychainCryptoError(error: unknown): boolean { - const err = error as { code?: string; name?: string; message?: string }; - // Only true crypto failures, not user cancellations - return Boolean( - (err?.code === 'E_CRYPTO_FAILED' || - err?.name === 'com.oblador.keychain.exceptions.CryptoFailedException' || - err?.message?.includes('CryptoFailedException') || - err?.message?.includes('Decryption failed') || - err?.message?.includes('Authentication tag verification failed')) && - !isUserCancellation(error), - ); +function handleKeychainReadError({ + contextLabel, + error, + throwOnUserCancel = false, +}: { + contextLabel: string; + error: unknown; + throwOnUserCancel?: boolean; +}) { + if (isUserCancellation(error)) { + console.log(`User cancelled authentication for ${contextLabel}`); + notifyKeychainFailure('user_cancelled'); + + if (throwOnUserCancel) { + throw error; + } + } + + if (isKeychainCryptoError(error)) { + const err = getKeychainErrorIdentity(error); + console.error(`Keychain crypto error loading ${contextLabel}:`, { + code: err?.code, + name: err?.name, + }); + + notifyKeychainFailure('crypto_failed'); + } + + console.log(`Error loading ${contextLabel}:`, error); } // Create safe wrapper functions to prevent undefined errors during early initialization @@ -482,25 +500,10 @@ export async function loadDocumentByIdDirectlyFromKeychain( return JSON.parse(documentCreds.password); } } catch (error) { - if (isUserCancellation(error)) { - console.log(`User cancelled authentication for document ${documentId}`); - if (keychainCryptoFailureCallback) { - keychainCryptoFailureCallback('user_cancelled'); - } - } - - if (isKeychainCryptoError(error)) { - const err = error as { code?: string; name?: string }; - console.error(`Keychain crypto error loading document ${documentId}:`, { - code: err?.code, - name: err?.name, - }); - - if (keychainCryptoFailureCallback) { - keychainCryptoFailureCallback('crypto_failed'); - } - } - console.log(`Error loading document ${documentId}:`, error); + handleKeychainReadError({ + contextLabel: `document ${documentId}`, + error, + }); } return null; } @@ -544,27 +547,11 @@ export async function loadDocumentCatalogDirectlyFromKeychain(): Promise { + it('identifies user cancellation errors', () => { + expect(isUserCancellation({ code: 'E_AUTHENTICATION_FAILED' })).toBe(true); + expect(isUserCancellation({ code: 'USER_CANCELED' })).toBe(true); + expect(isUserCancellation({ message: 'User canceled' })).toBe(true); + expect(isUserCancellation({ message: 'Authentication canceled' })).toBe( + true, + ); + expect(isUserCancellation({ message: 'cancelled by user' })).toBe(true); + }); + + it('does not classify non-cancellation errors as user cancellation', () => { + expect(isUserCancellation({ code: 'E_CRYPTO_FAILED' })).toBe(false); + expect(isUserCancellation({ message: 'Decryption failed' })).toBe(false); + expect(isUserCancellation({})).toBe(false); + }); + + it('identifies crypto failures and excludes user cancellations', () => { + expect(isKeychainCryptoError({ code: 'E_CRYPTO_FAILED' })).toBe(true); + expect( + isKeychainCryptoError({ + name: 'com.oblador.keychain.exceptions.CryptoFailedException', + }), + ).toBe(true); + expect( + isKeychainCryptoError({ + message: 'Authentication tag verification failed', + }), + ).toBe(true); + expect(isKeychainCryptoError({ message: 'Decryption failed' })).toBe(true); + expect( + isKeychainCryptoError({ + code: 'E_AUTHENTICATION_FAILED', + message: 'User canceled', + }), + ).toBe(false); + }); + + it('extracts keychain error identity safely', () => { + expect( + getKeychainErrorIdentity({ + code: 'E_CRYPTO_FAILED', + name: 'com.oblador.keychain.exceptions.CryptoFailedException', + }), + ).toEqual({ + code: 'E_CRYPTO_FAILED', + name: 'com.oblador.keychain.exceptions.CryptoFailedException', + }); + expect(getKeychainErrorIdentity({})).toEqual({ + code: undefined, + name: undefined, + }); + }); +});