diff --git a/app/src/hooks/useSelfAppStalenessCheck.ts b/app/src/hooks/useProofDisclosureStalenessCheck.ts similarity index 96% rename from app/src/hooks/useSelfAppStalenessCheck.ts rename to app/src/hooks/useProofDisclosureStalenessCheck.ts index 89614c9cb..560d53b99 100644 --- a/app/src/hooks/useSelfAppStalenessCheck.ts +++ b/app/src/hooks/useProofDisclosureStalenessCheck.ts @@ -17,7 +17,7 @@ import type { RootStackParamList } from '@/navigation'; * Uses a small delay to allow store updates to propagate after navigation * (e.g., after QR code scan sets selfApp data). */ -export function useSelfAppStalenessCheck( +export function useProofDisclosureStalenessCheck( selfApp: SelfApp | null, disclosureItems: Array<{ key: string; text: string }>, navigation: NativeStackNavigationProp, 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 { const { logoSource, url, formattedUserId, disclosureItems } = useSelfAppData(selfApp); - // Check for stale data and navigate to Home if needed - useSelfAppStalenessCheck(selfApp, disclosureItems, navigation); - const [documentCatalog, setDocumentCatalog] = useState({ documents: [], }); diff --git a/app/src/screens/verification/ProveScreen.tsx b/app/src/screens/verification/ProveScreen.tsx index 5cc097bc3..21bfa6f32 100644 --- a/app/src/screens/verification/ProveScreen.tsx +++ b/app/src/screens/verification/ProveScreen.tsx @@ -39,7 +39,6 @@ import { WalletAddressModal, } from '@/components/proof-request'; import { useSelfAppData } from '@/hooks/useSelfAppData'; -import { useSelfAppStalenessCheck } from '@/hooks/useSelfAppStalenessCheck'; import { buttonTap } from '@/integrations/haptics'; import type { RootStackParamList } from '@/navigation'; import { @@ -73,12 +72,6 @@ const ProveScreen: React.FC = () => { const { logoSource, url, formattedUserId, disclosureItems } = useSelfAppData(selectedApp); - // Check for stale data and navigate to Home if needed - useSelfAppStalenessCheck( - selectedApp, - disclosureItems, - navigation as NativeStackNavigationProp, - ); const selectedAppRef = useRef(null); const processedSessionsRef = useRef>(new Set()); diff --git a/app/src/stores/database.ts b/app/src/stores/database.ts index 87915d323..76561008d 100644 --- a/app/src/stores/database.ts +++ b/app/src/stores/database.ts @@ -14,6 +14,9 @@ const STALE_PROOF_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes SQLite.enablePromise(true); +const toInsertId = (result: SQLite.ResultSet) => + result.insertId ? result.insertId.toString() : '0'; + async function openDatabase() { return SQLite.openDatabase({ name: DB_NAME, @@ -129,7 +132,7 @@ export const database: ProofDB = { ); // Handle case where INSERT OR IGNORE skips insertion due to duplicate sessionId return { - id: insertResult.insertId ? insertResult.insertId.toString() : '0', + id: toInsertId(insertResult), timestamp, rowsAffected: insertResult.rowsAffected, }; @@ -157,7 +160,7 @@ export const database: ProofDB = { ); // Handle case where INSERT OR IGNORE skips insertion due to duplicate sessionId return { - id: insertResult.insertId ? insertResult.insertId.toString() : '0', + id: toInsertId(insertResult), timestamp, rowsAffected: insertResult.rowsAffected, }; @@ -186,7 +189,7 @@ export const database: ProofDB = { ); // Handle case where INSERT OR IGNORE skips insertion due to duplicate sessionId return { - id: insertResult.insertId ? insertResult.insertId.toString() : '0', + id: toInsertId(insertResult), timestamp, rowsAffected: insertResult.rowsAffected, }; diff --git a/app/src/utils/keychainErrors.ts b/app/src/utils/keychainErrors.ts new file mode 100644 index 000000000..b3be2a113 --- /dev/null +++ b/app/src/utils/keychainErrors.ts @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +export type KeychainErrorIdentity = { + code?: string; + name?: string; +}; + +type KeychainError = { + code?: string; + message?: string; + name?: string; +}; + +export type KeychainErrorType = 'user_cancelled' | 'crypto_failed'; + +export function getKeychainErrorIdentity( + error: unknown, +): KeychainErrorIdentity { + const err = error as KeychainError; + return { code: err?.code, name: err?.name }; +} + +export function isKeychainCryptoError(error: unknown): boolean { + const err = error as KeychainError; + 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), + ); +} + +export function isUserCancellation(error: unknown): boolean { + const err = error as KeychainError; + 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'), + ); +} diff --git a/app/tests/src/hooks/useProofDisclosureStalenessCheck.test.ts b/app/tests/src/hooks/useProofDisclosureStalenessCheck.test.ts new file mode 100644 index 000000000..f9a828d3d --- /dev/null +++ b/app/tests/src/hooks/useProofDisclosureStalenessCheck.test.ts @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import { act, renderHook } from '@testing-library/react-native'; + +import type { SelfApp } from '@selfxyz/common'; + +import { useProofDisclosureStalenessCheck } from '@/hooks/useProofDisclosureStalenessCheck'; + +jest.mock('@react-navigation/native', () => ({ + useFocusEffect: (callback: () => void | (() => void)) => { + callback(); + }, +})); + +describe('useProofDisclosureStalenessCheck', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + it('navigates home when selfApp is missing', () => { + const navigation = { navigate: jest.fn() }; + + renderHook(() => + useProofDisclosureStalenessCheck( + null, + [{ key: 'a', text: 'Disclosure' }], + navigation as any, + ), + ); + + act(() => { + jest.advanceTimersByTime(300); + }); + + expect(navigation.navigate).toHaveBeenCalledWith({ + name: 'Home', + params: {}, + }); + }); + + it('navigates home when disclosure items are empty', () => { + const navigation = { navigate: jest.fn() }; + const selfApp = { appName: 'Test App' } as unknown as SelfApp; + + renderHook(() => + useProofDisclosureStalenessCheck(selfApp, [], navigation as any), + ); + + act(() => { + jest.advanceTimersByTime(300); + }); + + expect(navigation.navigate).toHaveBeenCalledWith({ + name: 'Home', + params: {}, + }); + }); + + it('does not navigate when data is present', () => { + const navigation = { navigate: jest.fn() }; + const selfApp = { appName: 'Test App' } as unknown as SelfApp; + + renderHook(() => + useProofDisclosureStalenessCheck( + selfApp, + [{ key: 'a', text: 'Disclosure' }], + navigation as any, + ), + ); + + act(() => { + jest.advanceTimersByTime(300); + }); + + expect(navigation.navigate).not.toHaveBeenCalled(); + }); +}); diff --git a/app/tests/src/utils/keychainErrors.test.ts b/app/tests/src/utils/keychainErrors.test.ts new file mode 100644 index 000000000..7b12122f0 --- /dev/null +++ b/app/tests/src/utils/keychainErrors.test.ts @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import { + getKeychainErrorIdentity, + isKeychainCryptoError, + isUserCancellation, +} from '@/utils/keychainErrors'; + +describe('keychainErrors', () => { + 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, + }); + }); +}); diff --git a/app/version.json b/app/version.json index 5477e32f9..ce8c2af95 100644 --- a/app/version.json +++ b/app/version.json @@ -1,10 +1,10 @@ { "ios": { - "build": 199, - "lastDeployed": "2026-01-03T23:45:02.007Z" + "build": 200, + "lastDeployed": "2026-01-10T09:03:18.517Z" }, "android": { - "build": 130, - "lastDeployed": "2026-01-07T19:05:43Z" + "build": 131, + "lastDeployed": "2026-01-10T09:03:18.517Z" } }