From 723206ec5c2a47b162e4f9350361dffc6cdb8639 Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Mon, 15 Jun 2026 18:51:47 -0300 Subject: [PATCH 01/19] add initial version of app auto lock --- .../components/LockScreenOverlay.test.tsx | 72 ++++ .../components/screens/LockScreen.test.tsx | 143 ++++++++ .../AutoLockTimerScreen.test.tsx | 96 ++++++ __tests__/ducks/auth.test.ts | 315 +++++++++++++++++- __tests__/ducks/preferences.test.ts | 65 ++++ __tests__/hooks/useAuthCheck.test.tsx | 149 +++++++++ .../useGetActiveAccountSigningGuard.test.tsx | 96 ++++++ __tests__/providers/ToastProvider.test.tsx | 98 ++++++ __tests__/services/autoLock.test.ts | 206 ++++++++++++ docs/auto-lock-code-review.md | 279 ++++++++++++++++ src/components/App.tsx | 10 + src/components/LockScreenOverlay.tsx | 62 ++++ src/components/screens/LockScreen.tsx | 116 ++++++- .../AutoLockTimerScreen.tsx | 68 ++++ .../AutoLockTimerScreen/index.ts | 1 + .../SecurityScreen/SecurityScreen.tsx | 10 + src/config/constants.ts | 64 ++++ src/config/routes.ts | 2 + src/ducks/auth.ts | 194 +++++++++-- src/ducks/preferences.ts | 36 +- src/hooks/useAppOpenBiometricsLogin.ts | 62 ---- src/hooks/useAuthCheck.ts | 59 +++- src/hooks/useGetActiveAccount.ts | 19 +- src/i18n/locales/en/translations.json | 15 + src/i18n/locales/pt/translations.json | 15 + src/navigators/RootNavigator.tsx | 16 +- src/navigators/SettingsNavigator.tsx | 8 + src/providers/ToastProvider.tsx | 21 +- src/services/autoLock.ts | 131 ++++++++ 29 files changed, 2313 insertions(+), 115 deletions(-) create mode 100644 __tests__/components/LockScreenOverlay.test.tsx create mode 100644 __tests__/components/screens/LockScreen.test.tsx create mode 100644 __tests__/components/screens/SettingsScreen/AutoLockTimerScreen.test.tsx create mode 100644 __tests__/ducks/preferences.test.ts create mode 100644 __tests__/hooks/useAuthCheck.test.tsx create mode 100644 __tests__/hooks/useGetActiveAccountSigningGuard.test.tsx create mode 100644 __tests__/providers/ToastProvider.test.tsx create mode 100644 __tests__/services/autoLock.test.ts create mode 100644 docs/auto-lock-code-review.md create mode 100644 src/components/LockScreenOverlay.tsx create mode 100644 src/components/screens/SettingsScreen/SecurityScreen/AutoLockTimerScreen/AutoLockTimerScreen.tsx create mode 100644 src/components/screens/SettingsScreen/SecurityScreen/AutoLockTimerScreen/index.ts delete mode 100644 src/hooks/useAppOpenBiometricsLogin.ts create mode 100644 src/services/autoLock.ts diff --git a/__tests__/components/LockScreenOverlay.test.tsx b/__tests__/components/LockScreenOverlay.test.tsx new file mode 100644 index 000000000..c83ed7966 --- /dev/null +++ b/__tests__/components/LockScreenOverlay.test.tsx @@ -0,0 +1,72 @@ +import { act } from "@testing-library/react-native"; +import { LockScreenOverlay } from "components/LockScreenOverlay"; +import { AUTH_STATUS } from "config/types"; +import { useAuthenticationStore } from "ducks/auth"; +import { renderWithProviders } from "helpers/testUtils"; +import React from "react"; + +jest.mock("ducks/auth", () => { + const actual = jest.requireActual("ducks/auth"); + return { + ...actual, + getActiveAccountPublicKey: jest.fn().mockResolvedValue(null), + }; +}); + +jest.mock("services/autoLock", () => ({ + persistAutoLockTimer: jest.fn().mockResolvedValue(undefined), + applyAutoLockTimerToHashKey: jest.fn().mockResolvedValue(undefined), +})); + +describe("LockScreenOverlay", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("renders nothing when the wallet is not soft-locked", () => { + useAuthenticationStore.setState({ + authStatus: AUTH_STATUS.AUTHENTICATED, + isSoftLocked: false, + }); + + const { queryByTestId } = renderWithProviders(); + + expect(queryByTestId("lock-screen-overlay")).toBeNull(); + expect(queryByTestId("lock-screen")).toBeNull(); + }); + + it("renders the lock UI above the app when soft-locked", () => { + useAuthenticationStore.setState({ + authStatus: AUTH_STATUS.LOCKED, + isSoftLocked: true, + }); + + const { getByTestId } = renderWithProviders(); + + expect(getByTestId("lock-screen-overlay")).toBeTruthy(); + expect(getByTestId("lock-screen")).toBeTruthy(); + expect(getByTestId("unlock-button")).toBeTruthy(); + }); + + it("disappears when the wallet is unlocked", () => { + useAuthenticationStore.setState({ + authStatus: AUTH_STATUS.LOCKED, + isSoftLocked: true, + }); + + const { getByTestId, queryByTestId } = renderWithProviders( + , + ); + expect(getByTestId("lock-screen-overlay")).toBeTruthy(); + + // Unlock: signIn success resets the store which clears isSoftLocked + act(() => { + useAuthenticationStore.setState({ + authStatus: AUTH_STATUS.AUTHENTICATED, + isSoftLocked: false, + }); + }); + + expect(queryByTestId("lock-screen-overlay")).toBeNull(); + }); +}); diff --git a/__tests__/components/screens/LockScreen.test.tsx b/__tests__/components/screens/LockScreen.test.tsx new file mode 100644 index 000000000..97d7a2ea1 --- /dev/null +++ b/__tests__/components/screens/LockScreen.test.tsx @@ -0,0 +1,143 @@ +import { NativeStackScreenProps } from "@react-navigation/native-stack"; +import { waitFor } from "@testing-library/react-native"; +import { LockScreen } from "components/screens/LockScreen"; +import { LoginType } from "config/constants"; +import { ROOT_NAVIGATOR_ROUTES, RootStackParamList } from "config/routes"; +import { useAuthenticationStore } from "ducks/auth"; +import { usePreferencesStore } from "ducks/preferences"; +import { renderWithProviders } from "helpers/testUtils"; +import React from "react"; +import { AppState } from "react-native"; + +jest.mock("ducks/auth", () => { + const actual = jest.requireActual("ducks/auth"); + return { + ...actual, + getActiveAccountPublicKey: jest.fn().mockResolvedValue(null), + }; +}); + +jest.mock("services/autoLock", () => ({ + persistAutoLockTimer: jest.fn().mockResolvedValue(undefined), + applyAutoLockTimerToHashKey: jest.fn().mockResolvedValue(undefined), +})); + +type LockScreenNavigationProp = NativeStackScreenProps< + RootStackParamList, + typeof ROOT_NAVIGATOR_ROUTES.LOCK_SCREEN +>["navigation"]; + +type LockScreenRouteProp = NativeStackScreenProps< + RootStackParamList, + typeof ROOT_NAVIGATOR_ROUTES.LOCK_SCREEN +>["route"]; + +const mockNavigation = { + replace: jest.fn(), + goBack: jest.fn(), + setOptions: jest.fn(), +} as unknown as LockScreenNavigationProp; + +const mockRoute = { + key: "lock-screen", + name: ROOT_NAVIGATOR_ROUTES.LOCK_SCREEN, +} as unknown as LockScreenRouteProp; + +describe("LockScreen", () => { + const mockSignIn = jest.fn(); + const mockVerifyActionWithBiometrics = jest.fn( + (callback: (password?: string) => Promise) => + callback("biometric-password"), + ); + + const previousAppState = AppState.currentState; + + beforeEach(() => { + jest.clearAllMocks(); + (AppState as { currentState: string }).currentState = "active"; + + useAuthenticationStore.setState({ + signIn: mockSignIn, + verifyActionWithBiometrics: + mockVerifyActionWithBiometrics as unknown as ReturnType< + typeof useAuthenticationStore.getState + >["verifyActionWithBiometrics"], + signInMethod: LoginType.FACE, + isLoading: false, + error: null, + }); + usePreferencesStore.setState({ isBiometricsEnabled: true }); + }); + + afterAll(() => { + (AppState as { currentState: typeof previousAppState }).currentState = + previousAppState; + }); + + const renderLockScreen = () => + renderWithProviders( + , + ); + + it("auto-prompts biometrics on mount and unlocks with the stored password", async () => { + renderLockScreen(); + + await waitFor(() => { + expect(mockVerifyActionWithBiometrics).toHaveBeenCalledTimes(1); + }); + expect(mockSignIn).toHaveBeenCalledWith({ + password: "biometric-password", + }); + }); + + it("does not auto-prompt when biometrics are disabled", async () => { + usePreferencesStore.setState({ isBiometricsEnabled: false }); + useAuthenticationStore.setState({ signInMethod: LoginType.PASSWORD }); + + renderLockScreen(); + + await waitFor(() => { + expect(mockVerifyActionWithBiometrics).not.toHaveBeenCalled(); + }); + expect(mockSignIn).not.toHaveBeenCalled(); + }); + + it("re-prompts biometrics when the app returns from the background", async () => { + renderLockScreen(); + + await waitFor(() => { + expect(mockVerifyActionWithBiometrics).toHaveBeenCalledTimes(1); + }); + + // Simulate the app going to the background and returning to the foreground + const appStateHandlers = ( + AppState.addEventListener as jest.Mock + ).mock.calls.map(([, handler]) => handler as (state: string) => void); + + appStateHandlers.forEach((handler) => handler("background")); + appStateHandlers.forEach((handler) => handler("active")); + + await waitFor(() => { + expect(mockVerifyActionWithBiometrics).toHaveBeenCalledTimes(2); + }); + }); + + it("does not re-prompt on inactive-to-active transitions (e.g. the biometric overlay itself)", async () => { + renderLockScreen(); + + await waitFor(() => { + expect(mockVerifyActionWithBiometrics).toHaveBeenCalledTimes(1); + }); + + const appStateHandlers = ( + AppState.addEventListener as jest.Mock + ).mock.calls.map(([, handler]) => handler as (state: string) => void); + + appStateHandlers.forEach((handler) => handler("inactive")); + appStateHandlers.forEach((handler) => handler("active")); + + await waitFor(() => { + expect(mockVerifyActionWithBiometrics).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/__tests__/components/screens/SettingsScreen/AutoLockTimerScreen.test.tsx b/__tests__/components/screens/SettingsScreen/AutoLockTimerScreen.test.tsx new file mode 100644 index 000000000..322e24c6b --- /dev/null +++ b/__tests__/components/screens/SettingsScreen/AutoLockTimerScreen.test.tsx @@ -0,0 +1,96 @@ +import { NativeStackScreenProps } from "@react-navigation/native-stack"; +import { userEvent } from "@testing-library/react-native"; +import AutoLockTimerScreen from "components/screens/SettingsScreen/SecurityScreen/AutoLockTimerScreen"; +import { AUTO_LOCK_TIMER, DEFAULT_AUTO_LOCK_TIMER } from "config/constants"; +import { SETTINGS_ROUTES, SettingsStackParamList } from "config/routes"; +import { usePreferencesStore } from "ducks/preferences"; +import { renderWithProviders } from "helpers/testUtils"; +import React from "react"; + +jest.mock("services/autoLock", () => ({ + persistAutoLockTimer: jest.fn().mockResolvedValue(undefined), + applyAutoLockTimerToHashKey: jest.fn().mockResolvedValue(undefined), +})); + +type AutoLockTimerScreenNavigationProp = NativeStackScreenProps< + SettingsStackParamList, + typeof SETTINGS_ROUTES.AUTO_LOCK_TIMER_SCREEN +>["navigation"]; + +type AutoLockTimerScreenRouteProp = NativeStackScreenProps< + SettingsStackParamList, + typeof SETTINGS_ROUTES.AUTO_LOCK_TIMER_SCREEN +>["route"]; + +const mockNavigation = { + goBack: jest.fn(), + setOptions: jest.fn(), +} as unknown as AutoLockTimerScreenNavigationProp; + +const mockRoute = { + key: "auto-lock-timer", + name: SETTINGS_ROUTES.AUTO_LOCK_TIMER_SCREEN, +} as unknown as AutoLockTimerScreenRouteProp; + +describe("AutoLockTimerScreen", () => { + beforeEach(() => { + jest.clearAllMocks(); + usePreferencesStore.setState({ autoLockTimer: DEFAULT_AUTO_LOCK_TIMER }); + }); + + const renderAutoLockTimerScreen = () => + renderWithProviders( + , + ); + + it("renders all timer options", () => { + const { getByTestId } = renderAutoLockTimerScreen(); + + expect( + getByTestId(`auto-lock-option-${AUTO_LOCK_TIMER.IMMEDIATELY}`), + ).toBeTruthy(); + expect( + getByTestId(`auto-lock-option-${AUTO_LOCK_TIMER.ONE_MINUTE}`), + ).toBeTruthy(); + expect( + getByTestId(`auto-lock-option-${AUTO_LOCK_TIMER.FIFTEEN_MINUTES}`), + ).toBeTruthy(); + expect( + getByTestId(`auto-lock-option-${AUTO_LOCK_TIMER.THIRTY_MINUTES}`), + ).toBeTruthy(); + expect( + getByTestId(`auto-lock-option-${AUTO_LOCK_TIMER.ONE_HOUR}`), + ).toBeTruthy(); + expect( + getByTestId(`auto-lock-option-${AUTO_LOCK_TIMER.TWELVE_HOURS}`), + ).toBeTruthy(); + expect( + getByTestId(`auto-lock-option-${AUTO_LOCK_TIMER.TWENTY_FOUR_HOURS}`), + ).toBeTruthy(); + expect( + getByTestId(`auto-lock-option-${AUTO_LOCK_TIMER.NONE}`), + ).toBeTruthy(); + }); + + it("renders the footer explanation", () => { + const { getByText } = renderAutoLockTimerScreen(); + + expect( + getByText( + "After a set time, you will be prompted for your password again as an extra security measure.", + ), + ).toBeTruthy(); + }); + + it("updates the preference when an option is tapped", async () => { + const { getByTestId } = renderAutoLockTimerScreen(); + + await userEvent.press( + getByTestId(`auto-lock-option-${AUTO_LOCK_TIMER.FIFTEEN_MINUTES}`), + ); + + expect(usePreferencesStore.getState().autoLockTimer).toBe( + AUTO_LOCK_TIMER.FIFTEEN_MINUTES, + ); + }); +}); diff --git a/__tests__/ducks/auth.test.ts b/__tests__/ducks/auth.test.ts index 2b8d51b3c..435edd833 100644 --- a/__tests__/ducks/auth.test.ts +++ b/__tests__/ducks/auth.test.ts @@ -1,6 +1,7 @@ import { NavigationContainerRef } from "@react-navigation/native"; import { act, renderHook } from "@testing-library/react-hooks"; import { + AUTO_LOCK_TIMER, NETWORKS, STORAGE_KEYS, SENSITIVE_STORAGE_KEYS, @@ -29,6 +30,7 @@ import { encryptDataWithDerivedKey, } from "helpers/encryptPassword"; import { createKeyManager } from "helpers/keyManager/keyManager"; +import { AppState } from "react-native"; import { getSupportedBiometryType, BIOMETRY_TYPE } from "react-native-keychain"; import { clearNonSensitiveData, @@ -110,7 +112,10 @@ jest.mock("services/storage/secureStorage", () => ({ jest.mock("ducks/preferences", () => ({ usePreferencesStore: { - getState: jest.fn(), + getState: jest.fn(() => ({ + isBiometricsEnabled: false, + setAutoLockTimer: jest.fn(), + })), }, })); @@ -261,6 +266,7 @@ describe("auth duck", () => { setNavigationRef: useAuthenticationStore.getState().setNavigationRef, signIn: useAuthenticationStore.getState().signIn, initializeNetwork: useAuthenticationStore.getState().initializeNetwork, + softLock: useAuthenticationStore.getState().softLock, }; beforeEach(() => { @@ -1601,6 +1607,313 @@ describe("auth duck", () => { }); }); + describe("getAuthStatus with auto-lock timer", () => { + const ONE_HOUR_MS = 3600000; + + const mockAuthenticatedStorage = ({ + backgroundedAt, + autoLockTimer, + }: { + backgroundedAt: number | null; + autoLockTimer: AUTO_LOCK_TIMER | null; + }) => { + (dataStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === STORAGE_KEYS.ACCOUNT_LIST) { + return Promise.resolve(JSON.stringify([mockAccount])); + } + return Promise.resolve(null); + }); + + (secureDataStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SENSITIVE_STORAGE_KEYS.TEMPORARY_STORE) { + return Promise.resolve("encrypted-temp-store"); + } + if (key === SENSITIVE_STORAGE_KEYS.AUTO_LOCK_BACKGROUNDED_AT) { + return Promise.resolve( + backgroundedAt ? String(backgroundedAt) : null, + ); + } + if (key === SENSITIVE_STORAGE_KEYS.AUTO_LOCK_TIMER_SETTING) { + return Promise.resolve(autoLockTimer); + } + // No persisted AUTH_STATUS (not LOCKED) + return Promise.resolve(null); + }); + + (getHashKey as jest.Mock).mockResolvedValue({ + hashKey: "mock-hash-key", + salt: "mock-salt", + expiresAt: Date.now() + ONE_HOUR_MS, + }); + + (secureDataStorage.remove as jest.Mock).mockResolvedValue(undefined); + (secureDataStorage.setItem as jest.Mock).mockResolvedValue(undefined); + }; + + const restoreGetAuthStatus = () => { + act(() => { + useAuthenticationStore.setState({ + getAuthStatus: originalStoreMethods.getAuthStatus, + }); + }); + }; + + it("should soft-lock when the background duration exceeds the timer", async () => { + const { result } = renderHook(() => useAuthenticationStore()); + restoreGetAuthStatus(); + + mockAuthenticatedStorage({ + backgroundedAt: Date.now() - 2 * ONE_HOUR_MS, + autoLockTimer: AUTO_LOCK_TIMER.ONE_HOUR, + }); + + await act(async () => { + const status = await result.current.getAuthStatus(); + expect(status).toBe(AUTH_STATUS.LOCKED); + }); + + // LOCKED state is persisted and the timestamp is consumed + expect(secureDataStorage.setItem).toHaveBeenCalledWith( + SENSITIVE_STORAGE_KEYS.AUTH_STATUS, + AUTH_STATUS.LOCKED, + ); + expect(secureDataStorage.remove).toHaveBeenCalledWith( + SENSITIVE_STORAGE_KEYS.AUTO_LOCK_BACKGROUNDED_AT, + ); + }); + + it("should stay authenticated and consume the timestamp WITHOUT refreshing the hash key TTL", async () => { + const { result } = renderHook(() => useAuthenticationStore()); + restoreGetAuthStatus(); + + // The jest AppState mock has no real currentState; the consume + // branch only runs when the app is actively foregrounded + const previousAppState = AppState.currentState; + (AppState as { currentState: string }).currentState = "active"; + + mockAuthenticatedStorage({ + backgroundedAt: Date.now() - 60000, // 1 minute ago + autoLockTimer: AUTO_LOCK_TIMER.ONE_HOUR, + }); + + await act(async () => { + const status = await result.current.getAuthStatus(); + expect(status).toBe(AUTH_STATUS.AUTHENTICATED); + }); + + // Timestamp is consumed... + expect(secureDataStorage.remove).toHaveBeenCalledWith( + SENSITIVE_STORAGE_KEYS.AUTO_LOCK_BACKGROUNDED_AT, + ); + // ...but the hash key expiry must NOT advance without credential + // verification (key material lifetime stays bounded) + expect(secureDataStorage.setItem).not.toHaveBeenCalledWith( + SENSITIVE_STORAGE_KEYS.HASH_KEY, + expect.any(String), + ); + + (AppState as { currentState: typeof previousAppState }).currentState = + previousAppState; + }); + + it("should return HASH_KEY_EXPIRED when the hash key expired even if within the timer", async () => { + const { result } = renderHook(() => useAuthenticationStore()); + restoreGetAuthStatus(); + + const previousAppState = AppState.currentState; + (AppState as { currentState: string }).currentState = "active"; + + mockAuthenticatedStorage({ + backgroundedAt: Date.now() - 60000, // 1 minute ago, within timer + autoLockTimer: AUTO_LOCK_TIMER.ONE_HOUR, + }); + + // Hash key hard-expired (e.g. > 24h since the last unlock) + (getHashKey as jest.Mock).mockResolvedValue({ + hashKey: "mock-hash-key", + salt: "mock-salt", + expiresAt: Date.now() - ONE_HOUR_MS, + }); + + await act(async () => { + const status = await result.current.getAuthStatus(); + expect(status).toBe(AUTH_STATUS.HASH_KEY_EXPIRED); + }); + + (AppState as { currentState: typeof previousAppState }).currentState = + previousAppState; + }); + + it("should never soft-lock when the timer is NONE", async () => { + const { result } = renderHook(() => useAuthenticationStore()); + restoreGetAuthStatus(); + + mockAuthenticatedStorage({ + backgroundedAt: Date.now() - 100 * ONE_HOUR_MS, + autoLockTimer: AUTO_LOCK_TIMER.NONE, + }); + + await act(async () => { + const status = await result.current.getAuthStatus(); + expect(status).toBe(AUTH_STATUS.AUTHENTICATED); + }); + + expect(secureDataStorage.setItem).not.toHaveBeenCalledWith( + SENSITIVE_STORAGE_KEYS.AUTH_STATUS, + AUTH_STATUS.LOCKED, + ); + }); + + it("should default to 24 hours when no timer preference is persisted", async () => { + const { result } = renderHook(() => useAuthenticationStore()); + restoreGetAuthStatus(); + + mockAuthenticatedStorage({ + backgroundedAt: Date.now() - 25 * ONE_HOUR_MS, + autoLockTimer: null, + }); + + await act(async () => { + const status = await result.current.getAuthStatus(); + expect(status).toBe(AUTH_STATUS.LOCKED); + }); + }); + + it("should not evaluate the timer when no backgrounded-at timestamp exists", async () => { + const { result } = renderHook(() => useAuthenticationStore()); + restoreGetAuthStatus(); + + mockAuthenticatedStorage({ + backgroundedAt: null, + autoLockTimer: AUTO_LOCK_TIMER.ONE_HOUR, + }); + + await act(async () => { + const status = await result.current.getAuthStatus(); + expect(status).toBe(AUTH_STATUS.AUTHENTICATED); + }); + + expect(secureDataStorage.remove).not.toHaveBeenCalledWith( + SENSITIVE_STORAGE_KEYS.AUTO_LOCK_BACKGROUNDED_AT, + ); + }); + }); + + describe("softLock", () => { + it("should set LOCKED with isSoftLocked and persist the status without touching navigation", async () => { + const { result } = renderHook(() => useAuthenticationStore()); + + const mockResetRoot = jest.fn(); + const softLockNavigationRef = { + isReady: jest.fn().mockReturnValue(true), + getCurrentRoute: jest.fn().mockReturnValue({ name: "Home" }), + resetRoot: mockResetRoot, + } as unknown as NavigationContainerRef; + + act(() => { + useAuthenticationStore.setState({ + authStatus: AUTH_STATUS.AUTHENTICATED, + isSoftLocked: false, + navigationRef: softLockNavigationRef, + }); + }); + + await act(async () => { + await result.current.softLock(); + }); + + expect(result.current.authStatus).toBe(AUTH_STATUS.LOCKED); + expect(result.current.isSoftLocked).toBe(true); + // The whole point of softLock: the navigation tree stays untouched + expect(mockResetRoot).not.toHaveBeenCalled(); + expect(secureDataStorage.setItem).toHaveBeenCalledWith( + SENSITIVE_STORAGE_KEYS.AUTH_STATUS, + AUTH_STATUS.LOCKED, + ); + }); + + it("should funnel a warm timer lock through softLock atomically", async () => { + const { result } = renderHook(() => useAuthenticationStore()); + act(() => { + useAuthenticationStore.setState({ + getAuthStatus: originalStoreMethods.getAuthStatus, + softLock: originalStoreMethods.softLock, + authStatus: AUTH_STATUS.AUTHENTICATED, + isSoftLocked: false, + }); + }); + + // Storage says the auto-lock timer fired while we were AUTHENTICATED + (dataStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === STORAGE_KEYS.ACCOUNT_LIST) { + return Promise.resolve(JSON.stringify([mockAccount])); + } + return Promise.resolve(null); + }); + (secureDataStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SENSITIVE_STORAGE_KEYS.TEMPORARY_STORE) { + return Promise.resolve("encrypted-temp-store"); + } + if (key === SENSITIVE_STORAGE_KEYS.AUTO_LOCK_BACKGROUNDED_AT) { + return Promise.resolve(String(Date.now() - 7200000)); // 2h ago + } + if (key === SENSITIVE_STORAGE_KEYS.AUTO_LOCK_TIMER_SETTING) { + return Promise.resolve(AUTO_LOCK_TIMER.ONE_HOUR); + } + return Promise.resolve(null); + }); + (getHashKey as jest.Mock).mockResolvedValue({ + hashKey: "mock-hash-key", + salt: "mock-salt", + expiresAt: Date.now() + 3600000, + }); + + // RootNavigator must NEVER observe LOCKED && !isSoftLocked — that + // combination unmounts the preserved navigation tree + const observedInvalidStates: string[] = []; + const unsubscribe = useAuthenticationStore.subscribe((state) => { + if (state.authStatus === AUTH_STATUS.LOCKED && !state.isSoftLocked) { + observedInvalidStates.push(state.authStatus); + } + }); + + await act(async () => { + const status = await result.current.getAuthStatus(); + expect(status).toBe(AUTH_STATUS.LOCKED); + }); + + unsubscribe(); + expect(observedInvalidStates).toHaveLength(0); + expect(result.current.authStatus).toBe(AUTH_STATUS.LOCKED); + expect(result.current.isSoftLocked).toBe(true); + }); + + it("should make navigateToLockScreen a no-op while soft-locked", () => { + const { result } = renderHook(() => useAuthenticationStore()); + + const mockResetRoot = jest.fn(); + const softLockedNavigationRef = { + isReady: jest.fn().mockReturnValue(true), + getCurrentRoute: jest.fn().mockReturnValue({ name: "Home" }), + resetRoot: mockResetRoot, + } as unknown as NavigationContainerRef; + + act(() => { + useAuthenticationStore.setState({ + authStatus: AUTH_STATUS.LOCKED, + isSoftLocked: true, + navigationRef: softLockedNavigationRef, + }); + }); + + act(() => { + result.current.navigateToLockScreen(); + }); + + expect(mockResetRoot).not.toHaveBeenCalled(); + }); + }); + describe("signIn from LOCKED state", () => { it("should authenticate from LOCKED state and refresh TTL on the existing hash key", async () => { // Update the keyManager mock to return the correct account diff --git a/__tests__/ducks/preferences.test.ts b/__tests__/ducks/preferences.test.ts new file mode 100644 index 000000000..8850b50d6 --- /dev/null +++ b/__tests__/ducks/preferences.test.ts @@ -0,0 +1,65 @@ +import { act, renderHook } from "@testing-library/react-hooks"; +import { AUTO_LOCK_TIMER, DEFAULT_AUTO_LOCK_TIMER } from "config/constants"; +import { usePreferencesStore } from "ducks/preferences"; +import { + applyAutoLockTimerToHashKey, + persistAutoLockTimer, +} from "services/autoLock"; + +jest.mock("services/autoLock", () => ({ + persistAutoLockTimer: jest.fn().mockResolvedValue(undefined), + applyAutoLockTimerToHashKey: jest.fn().mockResolvedValue(undefined), +})); + +describe("preferences store", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("defaults the auto-lock timer to 24 hours", () => { + const { result } = renderHook(() => usePreferencesStore()); + + expect(result.current.autoLockTimer).toBe(DEFAULT_AUTO_LOCK_TIMER); + expect(result.current.autoLockTimer).toBe( + AUTO_LOCK_TIMER.TWENTY_FOUR_HOURS, + ); + }); + + it("updates the auto-lock timer and writes through to the mirror", () => { + const { result } = renderHook(() => usePreferencesStore()); + + act(() => { + result.current.setAutoLockTimer(AUTO_LOCK_TIMER.FIFTEEN_MINUTES); + }); + + expect(result.current.autoLockTimer).toBe(AUTO_LOCK_TIMER.FIFTEEN_MINUTES); + expect(persistAutoLockTimer).toHaveBeenCalledWith( + AUTO_LOCK_TIMER.FIFTEEN_MINUTES, + ); + expect(applyAutoLockTimerToHashKey).toHaveBeenCalledWith( + AUTO_LOCK_TIMER.FIFTEEN_MINUTES, + ); + }); + + it("reverts the auto-lock timer when the mirror write fails", async () => { + const { result } = renderHook(() => usePreferencesStore()); + + act(() => { + result.current.setAutoLockTimer(AUTO_LOCK_TIMER.ONE_HOUR); + }); + expect(result.current.autoLockTimer).toBe(AUTO_LOCK_TIMER.ONE_HOUR); + + (persistAutoLockTimer as jest.Mock).mockRejectedValueOnce( + new Error("storage unavailable"), + ); + + await act(async () => { + result.current.setAutoLockTimer(AUTO_LOCK_TIMER.ONE_MINUTE); + // Allow the rejected persist promise to settle and trigger the revert + await Promise.resolve(); + }); + + // The displayed selection must never disagree with the enforced mirror + expect(result.current.autoLockTimer).toBe(AUTO_LOCK_TIMER.ONE_HOUR); + }); +}); diff --git a/__tests__/hooks/useAuthCheck.test.tsx b/__tests__/hooks/useAuthCheck.test.tsx new file mode 100644 index 000000000..74ea0e115 --- /dev/null +++ b/__tests__/hooks/useAuthCheck.test.tsx @@ -0,0 +1,149 @@ +import { renderHook, act } from "@testing-library/react-hooks"; +import { AUTO_LOCK_TIMER } from "config/constants"; +import { AUTH_STATUS } from "config/types"; +import { useAuthenticationStore } from "ducks/auth"; +import useAuthCheck from "hooks/useAuthCheck"; +import { AppState } from "react-native"; +import { getAutoLockTimer, recordBackgroundedAt } from "services/autoLock"; + +jest.mock("services/autoLock", () => ({ + getAutoLockTimer: jest.fn(), + recordBackgroundedAt: jest.fn().mockResolvedValue(undefined), +})); + +const flushMicrotasks = async () => { + await Promise.resolve(); + await Promise.resolve(); +}; + +describe("useAuthCheck", () => { + const mockGetAuthStatus = jest + .fn() + .mockResolvedValue(AUTH_STATUS.AUTHENTICATED); + const mockSoftLock = jest.fn().mockResolvedValue(undefined); + const previousAppState = AppState.currentState; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + + // The RN jest mock leaves currentState as a jest.fn; the hook tracks it + // as a string + (AppState as { currentState: string }).currentState = "active"; + + (getAutoLockTimer as jest.Mock).mockResolvedValue( + AUTO_LOCK_TIMER.TWENTY_FOUR_HOURS, + ); + + useAuthenticationStore.setState({ + authStatus: AUTH_STATUS.AUTHENTICATED, + isSoftLocked: false, + getAuthStatus: mockGetAuthStatus, + softLock: mockSoftLock, + }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + afterAll(() => { + (AppState as { currentState: typeof previousAppState }).currentState = + previousAppState; + }); + + const getAppStateHandlers = () => + (AppState.addEventListener as jest.Mock).mock.calls + .filter(([eventName]) => eventName === "change") + .map(([, handler]) => handler as (state: string) => void); + + const renderAuthCheck = () => { + const rendered = renderHook(() => useAuthCheck()); + const handlers = getAppStateHandlers(); + return { ...rendered, handlers }; + }; + + it("records the backgrounded-at timestamp when the app goes to the background while authenticated", async () => { + const { handlers, unmount } = renderAuthCheck(); + + await act(async () => { + handlers.forEach((handler) => handler("background")); + await flushMicrotasks(); + }); + + expect(recordBackgroundedAt).toHaveBeenCalledTimes(1); + unmount(); + }); + + it("does NOT record a timestamp on inactive transitions", async () => { + const { handlers, unmount } = renderAuthCheck(); + + await act(async () => { + handlers.forEach((handler) => handler("inactive")); + await flushMicrotasks(); + }); + + expect(recordBackgroundedAt).not.toHaveBeenCalled(); + expect(mockSoftLock).not.toHaveBeenCalled(); + unmount(); + }); + + it("does NOT record a timestamp when not authenticated", async () => { + useAuthenticationStore.setState({ authStatus: AUTH_STATUS.LOCKED }); + const { handlers, unmount } = renderAuthCheck(); + + await act(async () => { + handlers.forEach((handler) => handler("background")); + await flushMicrotasks(); + }); + + expect(recordBackgroundedAt).not.toHaveBeenCalled(); + unmount(); + }); + + it("soft-locks immediately on backgrounding when the timer is IMMEDIATELY", async () => { + (getAutoLockTimer as jest.Mock).mockResolvedValue( + AUTO_LOCK_TIMER.IMMEDIATELY, + ); + const { handlers, unmount } = renderAuthCheck(); + + await act(async () => { + handlers.forEach((handler) => handler("background")); + await flushMicrotasks(); + }); + + expect(recordBackgroundedAt).toHaveBeenCalledTimes(1); + expect(mockSoftLock).toHaveBeenCalledTimes(1); + unmount(); + }); + + it("does NOT soft-lock on backgrounding for timed options", async () => { + (getAutoLockTimer as jest.Mock).mockResolvedValue(AUTO_LOCK_TIMER.ONE_HOUR); + const { handlers, unmount } = renderAuthCheck(); + + await act(async () => { + handlers.forEach((handler) => handler("background")); + await flushMicrotasks(); + }); + + expect(recordBackgroundedAt).toHaveBeenCalledTimes(1); + expect(mockSoftLock).not.toHaveBeenCalled(); + unmount(); + }); + + it("delegates lock transitions to the store getAuthStatus funnel on foreground return", async () => { + const { handlers, unmount } = renderAuthCheck(); + + await act(async () => { + // background → active triggers the delayed auth check; advance past + // MIN_CHECK_INTERVAL and the active check interval so checkAuth runs + handlers.forEach((handler) => handler("background")); + handlers.forEach((handler) => handler("active")); + jest.advanceTimersByTime(6000); + await flushMicrotasks(); + }); + + expect(mockGetAuthStatus).toHaveBeenCalled(); + unmount(); + }); +}); diff --git a/__tests__/hooks/useGetActiveAccountSigningGuard.test.tsx b/__tests__/hooks/useGetActiveAccountSigningGuard.test.tsx new file mode 100644 index 000000000..a8063d12f --- /dev/null +++ b/__tests__/hooks/useGetActiveAccountSigningGuard.test.tsx @@ -0,0 +1,96 @@ +import { + Account, + Keypair, + TransactionBuilder, + Networks, +} from "@stellar/stellar-sdk"; +import { renderHook } from "@testing-library/react-hooks"; +import { AUTH_STATUS } from "config/types"; +import { useAuthenticationStore } from "ducks/auth"; + +// Use the REAL hook (it is globally mocked in jest.setup.js) +jest.unmock("hooks/useGetActiveAccount"); + +// The real hook imports the navigation ref from the App entry; stub it so we +// don't pull in the whole app tree +jest.mock("components/App", () => ({ + navigationRef: { isReady: () => false }, +})); + +// eslint-disable-next-line import/first, @typescript-eslint/no-var-requires +const useGetActiveAccount = require("hooks/useGetActiveAccount").default; + +const testKeypair = Keypair.random(); + +const buildUnsignedTransaction = () => { + const source = new Account(testKeypair.publicKey(), "1"); + return new TransactionBuilder(source, { + fee: "100", + networkPassphrase: Networks.TESTNET, + }) + .setTimeout(30) + .build(); +}; + +describe("useGetActiveAccount signing guard", () => { + beforeEach(() => { + // Neutralise the mount-time side effects of the real hook + useAuthenticationStore.setState({ + account: { + publicKey: testKeypair.publicKey(), + privateKey: testKeypair.secret(), + accountName: "Test", + id: "test-id", + subentryCount: 0, + } as never, + isLoadingAccount: false, + accountError: null, + fetchActiveAccount: jest.fn().mockResolvedValue(null), + refreshActiveAccount: jest.fn(), + setNavigationRef: jest.fn(), + }); + }); + + it("refuses to sign while soft-locked even though the account is still in the store", () => { + useAuthenticationStore.setState({ + authStatus: AUTH_STATUS.LOCKED, + isSoftLocked: true, + }); + + const { result } = renderHook(() => useGetActiveAccount()); + + expect( + result.current.signTransaction(buildUnsignedTransaction()), + ).toBeNull(); + expect(result.current.signMessage("hello")).toBeNull(); + expect(result.current.signAuthEntry("deadbeef")).toBeNull(); + }); + + it("refuses to sign when authStatus is not AUTHENTICATED", () => { + useAuthenticationStore.setState({ + authStatus: AUTH_STATUS.HASH_KEY_EXPIRED, + isSoftLocked: false, + }); + + const { result } = renderHook(() => useGetActiveAccount()); + + expect( + result.current.signTransaction(buildUnsignedTransaction()), + ).toBeNull(); + }); + + it("signs when fully unlocked (AUTHENTICATED and not soft-locked)", () => { + useAuthenticationStore.setState({ + authStatus: AUTH_STATUS.AUTHENTICATED, + isSoftLocked: false, + }); + + const { result } = renderHook(() => useGetActiveAccount()); + + const signedXdr = result.current.signTransaction( + buildUnsignedTransaction(), + ); + expect(typeof signedXdr).toBe("string"); + expect(signedXdr).toBeTruthy(); + }); +}); diff --git a/__tests__/providers/ToastProvider.test.tsx b/__tests__/providers/ToastProvider.test.tsx new file mode 100644 index 000000000..ba9780475 --- /dev/null +++ b/__tests__/providers/ToastProvider.test.tsx @@ -0,0 +1,98 @@ +import { act, render } from "@testing-library/react-native"; +import { UNLOCK_ERROR_TOAST_ID } from "config/constants"; +import { useAuthenticationStore } from "ducks/auth"; +import { ToastProvider, useToast } from "providers/ToastProvider"; +import React, { useEffect } from "react"; +import { + Animated, + PanResponder, + type PanResponderInstance, +} from "react-native"; + +const SHOWN_TITLE = "Background validation"; +const ALLOWED_TITLE = "Unlock failed"; + +const ToastEmitter: React.FC<{ + title: string; + toastId?: string; +}> = ({ title, toastId }) => { + const { showToast } = useToast(); + + useEffect(() => { + showToast({ variant: "error", title, toastId }); + }, [showToast, title, toastId]); + + return null; +}; + +describe("ToastProvider soft-lock suppression", () => { + beforeEach(() => { + // The rendered Toast runs Animated loops + a duration-based auto-dismiss + // setTimeout; mock them (as Toast.test.tsx does) so no real timers/anim + // frames leak and hang the worker on exit. + jest.useFakeTimers(); + const startMock = (callback?: () => void) => { + if (callback) { + callback(); + } + }; + jest.spyOn(Animated, "timing").mockReturnValue({ + start: startMock, + } as unknown as Animated.CompositeAnimation); + jest.spyOn(Animated, "spring").mockReturnValue({ + start: startMock, + } as unknown as Animated.CompositeAnimation); + jest.spyOn(Animated, "parallel").mockReturnValue({ + start: startMock, + } as unknown as Animated.CompositeAnimation); + jest + .spyOn(PanResponder, "create") + .mockReturnValue({ panHandlers: {} } as unknown as PanResponderInstance); + + useAuthenticationStore.setState({ isSoftLocked: false }); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it("renders toasts normally when not soft-locked", () => { + const { queryByText } = render( + + + , + ); + + expect(queryByText(SHOWN_TITLE)).toBeTruthy(); + }); + + it("suppresses background toasts while soft-locked", () => { + act(() => { + useAuthenticationStore.setState({ isSoftLocked: true }); + }); + + const { queryByText } = render( + + + , + ); + + expect(queryByText(SHOWN_TITLE)).toBeNull(); + }); + + it("still shows the lock overlay's own unlock-error toast while soft-locked", () => { + act(() => { + useAuthenticationStore.setState({ isSoftLocked: true }); + }); + + const { queryByText } = render( + + + , + ); + + expect(queryByText(ALLOWED_TITLE)).toBeTruthy(); + }); +}); diff --git a/__tests__/services/autoLock.test.ts b/__tests__/services/autoLock.test.ts new file mode 100644 index 000000000..913de3c54 --- /dev/null +++ b/__tests__/services/autoLock.test.ts @@ -0,0 +1,206 @@ +import { + AUTO_LOCK_TIMER, + DEFAULT_AUTO_LOCK_TIMER, + HASH_KEY_EXPIRATION_MS, + NEVER_EXPIRE_HASH_KEY_MS, + SENSITIVE_STORAGE_KEYS, +} from "config/constants"; +import { + applyAutoLockTimerToHashKey, + clearBackgroundedAt, + getAutoLockTimer, + getBackgroundedAt, + getHashKeyExpirationMs, + persistAutoLockTimer, + recordBackgroundedAt, +} from "services/autoLock"; +import { getHashKey } from "services/storage/helpers"; +import { secureDataStorage } from "services/storage/storageFactory"; + +jest.mock("services/storage/storageFactory", () => ({ + dataStorage: { + getItem: jest.fn(), + setItem: jest.fn().mockResolvedValue(undefined), + remove: jest.fn().mockResolvedValue(undefined), + }, + secureDataStorage: { + getItem: jest.fn(), + setItem: jest.fn().mockResolvedValue(undefined), + remove: jest.fn().mockResolvedValue(undefined), + }, +})); + +jest.mock("services/storage/helpers", () => ({ + getHashKey: jest.fn(), +})); + +describe("autoLock service", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("getAutoLockTimer", () => { + it("returns the persisted timer when it is a valid option", async () => { + (secureDataStorage.getItem as jest.Mock).mockResolvedValue( + AUTO_LOCK_TIMER.FIFTEEN_MINUTES, + ); + + const timer = await getAutoLockTimer(); + + expect(secureDataStorage.getItem).toHaveBeenCalledWith( + SENSITIVE_STORAGE_KEYS.AUTO_LOCK_TIMER_SETTING, + ); + expect(timer).toBe(AUTO_LOCK_TIMER.FIFTEEN_MINUTES); + }); + + it("falls back to the default when no timer is persisted", async () => { + (secureDataStorage.getItem as jest.Mock).mockResolvedValue(null); + + const timer = await getAutoLockTimer(); + + expect(timer).toBe(DEFAULT_AUTO_LOCK_TIMER); + }); + + it("falls back to the default when the persisted value is invalid", async () => { + (secureDataStorage.getItem as jest.Mock).mockResolvedValue("not-a-timer"); + + const timer = await getAutoLockTimer(); + + expect(timer).toBe(DEFAULT_AUTO_LOCK_TIMER); + }); + }); + + describe("persistAutoLockTimer", () => { + it("writes the timer to the secure-storage mirror", async () => { + await persistAutoLockTimer(AUTO_LOCK_TIMER.ONE_HOUR); + + expect(secureDataStorage.setItem).toHaveBeenCalledWith( + SENSITIVE_STORAGE_KEYS.AUTO_LOCK_TIMER_SETTING, + AUTO_LOCK_TIMER.ONE_HOUR, + ); + }); + }); + + describe("getHashKeyExpirationMs", () => { + it("returns the default hash key TTL for regular timers", () => { + expect(getHashKeyExpirationMs(AUTO_LOCK_TIMER.ONE_MINUTE)).toBe( + HASH_KEY_EXPIRATION_MS, + ); + expect(getHashKeyExpirationMs(AUTO_LOCK_TIMER.TWENTY_FOUR_HOURS)).toBe( + HASH_KEY_EXPIRATION_MS, + ); + }); + + it("returns the never-expire TTL for NONE", () => { + expect(getHashKeyExpirationMs(AUTO_LOCK_TIMER.NONE)).toBe( + NEVER_EXPIRE_HASH_KEY_MS, + ); + }); + }); + + describe("applyAutoLockTimerToHashKey", () => { + it("re-anchors the stored hash key expiration", async () => { + const mockHashKey = { + hashKey: "mock-hash-key", + salt: "mock-salt", + expiresAt: Date.now(), + }; + (getHashKey as jest.Mock).mockResolvedValue(mockHashKey); + + const before = Date.now(); + await applyAutoLockTimerToHashKey(AUTO_LOCK_TIMER.NONE); + + expect(secureDataStorage.setItem).toHaveBeenCalledWith( + SENSITIVE_STORAGE_KEYS.HASH_KEY, + expect.any(String), + ); + + const [, storedValue] = (secureDataStorage.setItem as jest.Mock).mock + .calls[0]; + const storedHashKey = JSON.parse(storedValue); + expect(storedHashKey.hashKey).toBe(mockHashKey.hashKey); + expect(storedHashKey.salt).toBe(mockHashKey.salt); + expect(storedHashKey.expiresAt).toBeGreaterThanOrEqual( + before + NEVER_EXPIRE_HASH_KEY_MS, + ); + }); + + it("is a no-op when no hash key is stored", async () => { + (getHashKey as jest.Mock).mockResolvedValue(null); + + await applyAutoLockTimerToHashKey(AUTO_LOCK_TIMER.ONE_HOUR); + + expect(secureDataStorage.setItem).not.toHaveBeenCalled(); + }); + }); + + describe("backgrounded-at timestamp", () => { + it("records the current time when the app backgrounds", async () => { + const before = Date.now(); + await recordBackgroundedAt(); + + expect(secureDataStorage.setItem).toHaveBeenCalledWith( + SENSITIVE_STORAGE_KEYS.AUTO_LOCK_BACKGROUNDED_AT, + expect.any(String), + ); + + const [, storedValue] = (secureDataStorage.setItem as jest.Mock).mock + .calls[0]; + expect(Number(storedValue)).toBeGreaterThanOrEqual(before); + }); + + it("returns the persisted timestamp as a number", async () => { + (secureDataStorage.getItem as jest.Mock).mockResolvedValue("1234567890"); + + const backgroundedAt = await getBackgroundedAt(); + + expect(secureDataStorage.getItem).toHaveBeenCalledWith( + SENSITIVE_STORAGE_KEYS.AUTO_LOCK_BACKGROUNDED_AT, + ); + expect(backgroundedAt).toBe(1234567890); + }); + + it("returns null when no timestamp is persisted", async () => { + (secureDataStorage.getItem as jest.Mock).mockResolvedValue(null); + + const backgroundedAt = await getBackgroundedAt(); + + expect(backgroundedAt).toBeNull(); + }); + + it("cleans up and returns null for a corrupt timestamp", async () => { + (secureDataStorage.getItem as jest.Mock).mockResolvedValue( + "not-a-number", + ); + + const backgroundedAt = await getBackgroundedAt(); + + expect(backgroundedAt).toBeNull(); + expect(secureDataStorage.remove).toHaveBeenCalledWith( + SENSITIVE_STORAGE_KEYS.AUTO_LOCK_BACKGROUNDED_AT, + ); + }); + + it("cleans up and returns null for a future-dated timestamp", async () => { + const oneHourAhead = Date.now() + 3600000; + (secureDataStorage.getItem as jest.Mock).mockResolvedValue( + String(oneHourAhead), + ); + + const backgroundedAt = await getBackgroundedAt(); + + expect(backgroundedAt).toBeNull(); + expect(secureDataStorage.remove).toHaveBeenCalledWith( + SENSITIVE_STORAGE_KEYS.AUTO_LOCK_BACKGROUNDED_AT, + ); + }); + + it("clears the persisted timestamp", async () => { + await clearBackgroundedAt(); + + expect(secureDataStorage.remove).toHaveBeenCalledWith( + SENSITIVE_STORAGE_KEYS.AUTO_LOCK_BACKGROUNDED_AT, + ); + }); + }); +}); diff --git a/docs/auto-lock-code-review.md b/docs/auto-lock-code-review.md new file mode 100644 index 000000000..2cc921ee7 --- /dev/null +++ b/docs/auto-lock-code-review.md @@ -0,0 +1,279 @@ +# Auto-Lock Timer — Consolidated Code Review + +> Scope: all staged changes for the Auto-Lock Timer feature (issue #627), the +> soft-lock overlay, and the lock-screen biometric auto-prompt. Method: two +> independent reviews in isolated contexts — a **Security** review (application +> security engineer perspective) and a **Senior Developer** review (architecture +> / correctness / tests) — merged and deduplicated below. Attribution noted per +> finding. + +**Verdict (combined): NOT ready to merge.** Core design and happy paths are +solid and tested, but the sliding hash-key TTL regression, the +lock-policy-in-plain-storage tampering path, native modals rendering above the +overlay, and the navigation-reset race must be addressed first. + +## Resolution status (updated after fixes) + +| Finding | Status | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| C1 sliding TTL | ✅ **Fixed** — the foreground-return refresh was removed from `getAuthStatus`; `expiresAt` is now anchored ONLY at credential-verified moments (`signIn`, `generateHashKey`, `applyAutoLockTimerToHashKey` on in-app setting change). "None" keeps its never-expire TTL because it is also set only at those moments. Pinned by tests: "consume the timestamp WITHOUT refreshing the hash key TTL" and "HASH_KEY_EXPIRED even if within the timer". | +| C2 plain-storage tampering | ✅ **Fixed** — the timer setting and backgrounded-at timestamp moved to `SENSITIVE_STORAGE_KEYS`/`secureDataStorage`; future-dated timestamps rejected and cleaned up (+ tests). Combined with the C1 fix, AsyncStorage tampering can no longer weaken the lock or the key TTL. | +| C3 modals above overlay | ⚠️ **Accepted (product decision)** — a native-Modal-hosted overlay was implemented and reverted for visual/UX reasons. Residual risk: an RN `Modal` open at lock time stays visible/tappable above the overlay; mitigations in place: sensitive reads are gated while LOCKED (getActiveAccount, WalletKit), `account` is cleared on soft lock. Re-evaluate if a styling-acceptable native hosting is found. | +| I1 navigation-reset race | ✅ Fixed — store `getAuthStatus` is the single funnel: every caller (checkAuth, fetchActiveAccount, selectAccount) produces the same atomic soft lock; manual `set`/`navigateToLockScreen` calls removed from those callers | +| I2 non-atomic transition | ✅ Fixed — `softLock` sets `authStatus` + `isSoftLocked` (+ `account: null`) in ONE `set()`; pinned by a store-subscription test asserting `LOCKED && !isSoftLocked` is never observable | +| I3 Android back button | ✅ Fixed — `BackHandler` listener returns `true` while soft-locked | +| I4 in-memory secrets / JSDoc | ✅ Fixed — `softLock` clears `account` (private key) atomically; JSDoc corrected to name the real protections. `derivedKeyCache` intentionally kept (documented PR #664 fast-unlock design). `getTemporaryStore` LOCKED-allowance retained — required by the unlock path itself — now noted here as the invariant. | +| I5 fire-and-forget persist | ✅ Fixed — `softLock` is async and awaits the secure-storage `LOCKED` write | +| I6 snapshot/accessibility | ⚠️ **Deferred to follow-up** — `accessibilityViewIsModal` confines screen-reader focus to the overlay. App-switcher snapshot privacy must be handled natively (the only place it works, matching MetaMask/Rainbow/Coinbase): **iOS** AppDelegate splash overlay on `applicationWillResignActive`/`applicationDidBecomeActive`; **Android** `FLAG_SECURE` on the activity. A JS curtain was tried and removed (can't beat the OS snapshot, flashed on return). Native implementation is intentionally **not in this PR** — tracked as a follow-up. | +| I7 nulled navigationRef | ✅ Fixed — `signIn`/`signUp`/`importWallet` preserve `navigationRef` through the `initialState` spread | +| I8 missing tests | ✅ Mostly fixed — new `useAuthCheck` suite (background-only recording, IMMEDIATELY soft lock, funnel delegation), funnel atomicity test, corrupt + future-dated timestamp tests, softLock account-clearing assertion. Remaining gap: no render test for RootNavigator's `showAuthenticatedStack` conditional. | +| M1 NaN cleanup | ✅ Fixed — `getBackgroundedAt` clears corrupt values and returns null (+ test) | +| M2 mirror drift | ✅ Fixed — setter reverts UI state if the mirror write fails (+ test) | +| M3 wall-clock | ✅ Documented in `getAuthStatus` (accepted limitation; bounded by the hash-key expiry) | +| M4 OEM biometric AppState | ✅ Documented in `useAuthCheck`; remains a device-QA item (test plan §8.4) | +| M5 rehydration window | ✅ Fixed — IMMEDIATELY check reads the dataStorage mirror, not zustand state | +| M6 overlay subscription | ✅ Fixed — narrow `isSoftLocked` selector | +| M7 re-prompt fatigue | ✅ Documented as intentional banking-app behavior (explicit product request) | +| M8 timer survives wipe | ✅ Fixed — full wipe resets the preference to the 24h default via the setter (store + mirror) | + +--- + +## Strengths (both reviewers) + +- Locked-state key access properly gated at the data layer: `getActiveAccount` + hard-blocks `LOCKED` (`src/ducks/auth.ts:1714-1724`); WalletKit session + proposals/requests reject while `LOCKED`/`HASH_KEY_EXPIRED` + (`src/providers/WalletKitProvider.tsx:853-864, 946-958`). +- The locked state itself is tamper-resistant: persisted `AUTH_STATUS.LOCKED` + lives in secure storage and is checked first in `getAuthStatus` — AsyncStorage + tampering cannot _un-lock_ a locked session. +- Evaluation ordering is correct (persisted-LOCKED → timer → hash-key expiry); + the consume-only-when-`active` guard correctly survives Android's 60s + background check interval. +- Fail-safe preference parsing: corrupt/missing timer values degrade to the 24h + default (more locking, never less). +- The biometric auto-prompt refactor removes the cold-start double prompt by + deleting the dual-owner `useAppOpenBiometricsLogin`; the guard ordering in + `LockScreenContent` correctly handles async `signInMethod` resolution. +- Conventions respected: enums, no magic numbers, EN+PT translations, house + JSDoc style, ESLint clean; tests are largely behavioral and all pass. +- Bottom sheets are correctly covered by the overlay (rendered after + `BottomSheetModalProvider`); `Keyboard.dismiss()` on lock; signIn clears stale + timestamps. + +--- + +## Critical (must fix) + +### C1. Sliding hash-key TTL makes key-material lifetime unbounded without re-authentication — _Security (Critical) + Senior (Minor 7, same root)_ + +`src/ducks/auth.ts:524-537` + +The old model anchored `expiresAt` only at credential-verified moments +(`generateHashKey`, `signIn`), guaranteeing the hash key — and the temporary +store it decrypts (mnemonic, private keys, **plaintext password**, see +`auth.ts:2021`) — was dead ≤ 24h after the last password/biometric entry. The +new code rewrites `expiresAt = now + 24h` (or +100 years for NONE) inside +`getAuthStatus` on every foreground return, **with zero proof of user +presence**. + +- **Attack**: a thief holding a phone inside its auto-lock window reopens the + app once per timer period; the wallet never hard-expires and never demands the + password again, indefinitely. Previously bounded at 24h. +- This regresses **every** setting, including the default 24h that the PR claims + is "no behavior change" — today's behavior is a fixed cryptoperiod, not a + sliding one. Key rotation (previously forced by each full re-auth) also never + happens. +- **Fix**: refresh `expiresAt` only after successful credential verification + (keep it in `signIn`/`generateHashKey` only). For NONE, disable the _timer_ + but keep a bounded hash-key expiry — the fast-unlock LOCKED path already makes + the periodic re-auth cheap. If product insists NONE never re-prompts, that + trade-off must not leak into the other seven options via the unconditional + slide. + +### C2. Lock policy driven by unencrypted AsyncStorage; tampering it extends the _keychain_ TTL — _Security (Important #2, escalated by C1 interaction)_ + +`src/services/autoLock.ts:22-34, 79-101`; `src/ducks/auth.ts:505-537`; +`src/hooks/useAuthCheck.ts:128-134` + +`AUTO_LOCK_TIMER_SETTING`, `AUTO_LOCK_BACKGROUNDED_AT`, and the zustand +`preferences-storage` mirror (which gates IMMEDIATELY) all live in plain +AsyncStorage. An attacker with sandbox write access (rooted Android, backup +modify-and-restore, forensic tooling) can: + +- delete/garbage/future-date `backgroundedAt` (`Number(garbage)` → `NaN` + silently disables the comparison), or +- set the timer to `"none"`, after which the app _itself_ rewrites the keychain + hash key to `now + 100 years` on next foreground (`auth.ts:530-536`). + +This contradicts the codebase's own documented threat model ("Read from SECURE +storage (encrypted) to prevent tampering", `auth.ts:484`; "prevents tampering +via ADB or rooted devices", `auth.ts:2076`) — `AUTH_STATUS` was deliberately +moved to secure storage against exactly this attacker. + +- **Fix**: store the timer preference and timestamps in `secureDataStorage` + (tiny, low-frequency values); reject future-dated timestamps; treat + missing-timestamp-with-present-hash-key conservatively. Fixing C1 removes the + TTL-extension half. + +### C3. Native `Modal`s render **above** the soft-lock overlay and stay interactive while locked — _Security (Important #3)_ + +`src/components/Modal.tsx:32-41` vs `src/components/App.tsx:85-91` + +RN `Modal` hosts content in a separate native window that z-orders above every +in-root view, including `LockScreenOverlay` (a plain sibling `View`). If a modal +is open when the lock fires (e.g. IMMEDIATELY while `RenameAccountModal` / +`ConfirmationModal` / `PermissionModal` is up), on return it is fully visible +and **tappable on top of the lock** — content leaks and its actions execute +against the mounted authenticated tree without re-auth. + +- **Fix**: render the lock overlay in a top-level native `Modal` (with no-op + `onRequestClose`), or dismiss/gate all RN modals on `softLock` + (`!isSoftLocked`). + +--- + +## Important (should fix) + +### I1. Other `getAuthStatus` callers hard-reset navigation when the timer fires, racing the soft lock — _Senior (#1)_ + +`src/ducks/auth.ts:2714-2723` (`fetchActiveAccount`), `2836-2841` +(`selectAccount`) + +Only `useAuthCheck.checkAuth` routes timer-`LOCKED` to `softLock()`. If +`fetchActiveAccount` runs first on a foreground return after expiry, it sets +`LOCKED` + `navigateToLockScreen()` while `isSoftLocked` is still `false` → +`resetRoot` wipes the tree, defeating the feature's central guarantee (and can +then fight `checkAuth`'s later `softLock()`). + +- **Fix**: centralize the AUTHENTICATED→LOCKED transition (e.g. in the store + `getAuthStatus` action, the single funnel) so every caller produces a soft + lock; also fixes I2. + +### I2. `authStatus: LOCKED` and `isSoftLocked: true` are set in separate, non-atomic updates — _Senior (#2)_ + +`src/ducks/auth.ts:2650` + `src/hooks/useAuthCheck.ts:61-66` + +Today React batches them; any future `await` inserted between silently unmounts +the whole authenticated group (`RootNavigator` sees `LOCKED && !isSoftLocked`). +The no-unmount guarantee should not rest on scheduler timing. + +- **Fix**: one atomic `set()` for the lock transition (naturally falls out of + the I1 centralization). + +### I3. Android hardware back button is not intercepted by the overlay — _Senior (#3)_ + +`src/components/LockScreenOverlay.tsx:31-36` + +The overlay swallows touches but hardware back events reach the navigation +container underneath — while "locked", back presses pop screens in the hidden +tree (mutating the state being preserved) or exit the app. + +- **Fix**: `BackHandler` listener returning `true` while `isSoftLocked` (or the + native-Modal approach from C3, which solves both). + +### I4. In-memory secrets survive the soft lock; JSDoc overstates the protection — _Security (#6, #7) + Senior (#4), merged_ + +`src/ducks/auth.ts:2158-2174` (softLock), `619-636` (getTemporaryStore), +`account.privateKey` in store + +- `softLock()` leaves `account` (including `privateKey`, signing-capable via + `useGetActiveAccount.signTransaction`) in the zustand store, and the + `derivedKeyCache` warm — unlike `logout`'s soft path which clears `account`. +- `getTemporaryStore` explicitly **allows** `LOCKED` ("preserved session that + can be unlocked"), so any future caller running under the overlay could + decrypt the mnemonic/password. Not currently exploitable (callers are + authenticated-flow-only and WalletKit rejects while locked), but the invariant + is undocumented and untested. +- `softLock`'s JSDoc claims "the temporary store denies access while authStatus + is LOCKED" — **false**; the protection is the UI overlay plus + `getActiveAccount`'s gate. +- **Fix**: clear `account: null` (signIn repopulates it anyway) and consider + clearing `derivedKeyCache` for IMMEDIATELY; correct the JSDoc; add a test + pinning "no secret-bearing path succeeds while LOCKED". + +### I5. `softLock`'s secure-storage write is fire-and-forget — _Security (#5)_ + +`src/ducks/auth.ts:2158-2173` + +In-memory lock state is set synchronously but the persisted `LOCKED` write is +`.catch(log)`. If the process is killed before the write lands (most likely +exactly on the IMMEDIATELY path — lock fires on backgrounding), a cold start +within the timer window returns `AUTHENTICATED`, silently bypassing a lock the +user already saw. `logout` awaits the same write. + +- **Fix**: await the write (or rely on the already-persisted backgrounded-at + timestamp by recording it before/synchronously with the lock so the timer path + still catches the cold start). + +### I6. No screenshot/snapshot/accessibility protection for the mounted tree — _Security (#4)_ + +No `FLAG_SECURE` (Android), no iOS privacy snapshot/blur anywhere in the +codebase. For timed options the wallet is showing real content at backgrounding +time, so the app-switcher card captures the **unlocked** screen; the live +hierarchy under the overlay also remains exposed to the accessibility +tree/screen readers (plain sibling `View`, no `accessibilityViewIsModal` / +`importantForAccessibility="no-hide-descendants"`). + +- Pre-existing gap, but the "keep screens mounted" design makes it materially + worse. **Fix**: privacy snapshot/FLAG_SECURE on background regardless of + timer; hide the locked tree from accessibility. + +### I7. Resumed screens see `account: null` (and `navigationRef: null`) right after a soft unlock — _Senior (#5)_ + +`src/ducks/auth.ts:2224-2229` + +`signIn`'s `...initialState` spread nulls +`account`/`navigationRef`/`signInMethod` while the user resumes **mid-flow** in +screens that historically only mounted from a fresh stack. Until the background +`getActiveAccount` resolves, deep flows render their null path; `navigationRef` +stays null (nothing remounts to restore it), so later +`navigateToLockScreen`/`resetRoot` calls silently no-op. + +- **Fix**: preserve `navigationRef` explicitly in `signIn` (pattern already + exists in `logout`, `auth.ts:2095-2096`); QA resume-mid-send-flow with a slow + account load. + +### I8. Missing tests for the riskiest new logic — _both reviewers_ + +`useAuthCheck` (background-only recording, IMMEDIATELY soft-lock, +LOCKED→softLock dispatch) and `RootNavigator`'s `showAuthenticatedStack` +conditional are untested — I1/I2 regressions would not be caught today. Also +add: tamper tests (AsyncStorage edits can't unlock a persisted-LOCKED session), +"expiresAt does not advance without credential verification" (after C1), and +"open RN Modal not interactive while soft-locked" (after C3). + +--- + +## Minor (nice to have) + +| # | Finding | Source | Location | +| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ----------------------------------------- | +| M1 | `getBackgroundedAt` returns `NaN` for corrupt values — treated as absent (safe) but never cleaned up; return `null` on `Number.isNaN` and clear the key | Senior | `src/services/autoLock.ts:96-101` | +| M2 | Mirror drift on partial failure: UI checkmark and enforced mirror value can disagree if `persistAutoLockTimer` rejects (fire-and-forget) | Senior | `src/ducks/preferences.ts:39-58` | +| M3 | Wall-clock timer: rolling the device clock back dodges auto-lock; likely accept-and-document (no easy monotonic source in RN) | Senior | timer evaluation | +| M4 | Some Android OEMs emit `background` (not `inactive`) when BiometricPrompt appears → with IMMEDIATELY, an in-app biometric confirmation could soft-lock mid-action / loop the re-prompt. Verify on hardware | Senior | `useAuthCheck` + `LockScreen.tsx:191-206` | +| M5 | IMMEDIATELY before zustand rehydration completes reads the 24h default and skips the instant in-process lock; mirror still enforces on return (cosmetic) | Senior | `src/hooks/useAuthCheck.ts:128` | +| M6 | `LockScreenOverlay` subscribes to the whole auth store → re-renders on all auth churn; use `useAuthenticationStore((s) => s.isSoftLocked)` | Senior | `src/components/LockScreenOverlay.tsx:25` | +| M7 | Biometric re-prompt on every background→active return is a lock-fatigue nudge toward device-PIN fallback (`allowDeviceCredentials: true`); cosmetic | Security | `LockScreen.tsx:154-205` | +| M8 | `AUTO_LOCK_TIMER_SETTING` survives full wipe → the next wallet on the device inherits the previous user's (possibly attacker-set "none") preference; reset/re-validate on fresh sign-up | Security | `src/services/storage/helpers.ts:30-33` | + +--- + +## Consolidated recommendations + +1. **Decouple "auto-lock timer" (UX) from "hash-key cryptoperiod" (security + backstop)** — the conflation is the root of C1/C2. TTL anchored only at + credential entry; timer inputs in secure storage. +2. **Centralize the AUTHENTICATED→LOCKED soft-lock transition** in the store + `getAuthStatus` action (fixes I1+I2, removes module/action duplication). +3. **Harden the overlay**: native-Modal hosting or modal dismissal on lock (C3), + BackHandler (I3), accessibility hiding + FLAG_SECURE/privacy snapshot (I6), + `account: null` + JSDoc fix (I4). +4. **Await the `softLock` persist** (I5); preserve `navigationRef` through + `signIn` (I7). +5. **Add the missing test coverage** (I8) and the tamper/secret-path invariant + tests. +6. Device QA matrix: Android back button while overlaid; OEM biometric-prompt + AppState behavior with IMMEDIATELY; resume-mid-send-flow with slow account + load; iOS app-switcher snapshot content. diff --git a/src/components/App.tsx b/src/components/App.tsx index 2418d6dca..10881bf81 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -4,6 +4,7 @@ import { createNavigationContainerRef, } from "@react-navigation/native"; import * as Sentry from "@sentry/react-native"; +import { LockScreenOverlay } from "components/LockScreenOverlay"; import { initializeSentryLogger } from "config/logger"; import { RootStackParamList } from "config/routes"; import { initializeSentry } from "config/sentryConfig"; @@ -84,6 +85,15 @@ export const App = (): React.JSX.Element => { + {/* Soft-lock overlay: rendered after the bottom sheet provider so + it covers open sheets too, keeping the navigation tree (and any + in-progress inputs) mounted underneath for after the unlock. + App-switcher snapshot privacy is handled natively (iOS + AppDelegate overlay, Android FLAG_SECURE) — a JS curtain can't + beat the OS snapshot. */} + + + diff --git a/src/components/LockScreenOverlay.tsx b/src/components/LockScreenOverlay.tsx new file mode 100644 index 000000000..bf14c0863 --- /dev/null +++ b/src/components/LockScreenOverlay.tsx @@ -0,0 +1,62 @@ +import { LockScreenContent } from "components/screens/LockScreen"; +import { THEME } from "config/theme"; +import { useAuthenticationStore } from "ducks/auth"; +import React, { useEffect } from "react"; +import { BackHandler, StyleSheet, View } from "react-native"; + +const styles = StyleSheet.create({ + container: { + ...StyleSheet.absoluteFillObject, + backgroundColor: THEME.colors.background.default, + }, +}); + +/** + * Full-screen overlay shown when the wallet is soft-locked in-process + * (auto-lock timer or the IMMEDIATELY option firing on backgrounding). + * + * Rendered as a sibling AFTER the navigation container and the bottom sheet + * provider so it covers them — while the mounted screens underneath keep + * their navigation history, params and in-progress inputs for after the + * unlock. The Android hardware back button is swallowed while locked, and + * accessibility focus is confined to the overlay. Cold starts use the + * regular LockScreen route instead (process state is gone anyway). + * Unlocking flips isSoftLocked which unmounts the overlay, resuming the + * user exactly where they were. + */ +export const LockScreenOverlay: React.FC = () => { + // Narrow selector: this component sits at the app root and must not + // re-render on unrelated auth-store churn (e.g. periodic status checks) + const isSoftLocked = useAuthenticationStore((state) => state.isSoftLocked); + + // Swallow the Android hardware back button while locked: back presses must + // not pop screens in the hidden tree underneath or exit the app + useEffect(() => { + if (!isSoftLocked) { + return undefined; + } + + const subscription = BackHandler.addEventListener( + "hardwareBackPress", + () => true, + ); + + return () => subscription.remove(); + }, [isSoftLocked]); + + if (!isSoftLocked) { + return null; + } + + return ( + + + + ); +}; + +export default LockScreenOverlay; diff --git a/src/components/screens/LockScreen.tsx b/src/components/screens/LockScreen.tsx index ab00f55cd..676131a72 100644 --- a/src/components/screens/LockScreen.tsx +++ b/src/components/screens/LockScreen.tsx @@ -1,12 +1,15 @@ import { NativeStackScreenProps } from "@react-navigation/native-stack"; import ForgotPasswordWarningModal from "components/screens/ForgotPasswordWarningModal"; import InputPasswordTemplate from "components/templates/InputPasswordTemplate"; +import { LoginType, UNLOCK_ERROR_TOAST_ID } from "config/constants"; import { ROOT_NAVIGATOR_ROUTES, RootStackParamList } from "config/routes"; import { AUTH_STATUS } from "config/types"; import { useAuthenticationStore, getActiveAccountPublicKey } from "ducks/auth"; +import { usePreferencesStore } from "ducks/preferences"; import useAppTranslation from "hooks/useAppTranslation"; import { useToast } from "providers/ToastProvider"; import React, { useCallback, useEffect, useRef, useState } from "react"; +import { AppState } from "react-native"; type LockScreenProps = NativeStackScreenProps< RootStackParamList, @@ -15,6 +18,9 @@ type LockScreenProps = NativeStackScreenProps< type TFunction = ReturnType["t"]; +// Small delay to ensure state is settled before navigating after unlock +const UNLOCK_NAVIGATION_DELAY_MS = 100; + function getErrorToastContent( error: string, t: TFunction, @@ -33,7 +39,24 @@ function getErrorToastContent( } } -export const LockScreen: React.FC = ({ navigation }) => { +interface LockScreenContentProps { + /** + * Called once the wallet is unlocked. The lock screen route uses this to + * replace itself with the main tab stack; the soft-lock overlay omits it + * because it simply unmounts when the unlock flips the auth status. + */ + onUnlocked?: () => void; +} + +/** + * Lock UI shared by the LockScreen route (hard lock / cold start) and the + * soft-lock overlay (in-process auto-lock with the navigation tree preserved + * underneath). Handles password unlock, the biometric auto-prompt, and the + * forgot-password flow. + */ +export const LockScreenContent: React.FC = ({ + onUnlocked, +}) => { const { signIn, isLoading: isSigningIn, @@ -41,7 +64,10 @@ export const LockScreen: React.FC = ({ navigation }) => { authStatus, logout, clearError, + signInMethod, + verifyActionWithBiometrics, } = useAuthenticationStore(); + const { isBiometricsEnabled } = usePreferencesStore(); const [publicKey, setPublicKey] = useState(null); const [isForgotPasswordModalVisible, setIsForgotPasswordModalVisible] = useState(false); @@ -53,20 +79,29 @@ export const LockScreen: React.FC = ({ navigation }) => { // it before the toast effect has a chance to display it. const hasInitialError = useRef(Boolean(error)); + // Auto-prompt biometrics at most once per arrival on this screen; reset when + // the app returns from the background so the user is prompted again. + const hasAutoPromptedRef = useRef(false); + // Whether the app went to the real background while this screen was mounted + // (or was mounted while backgrounded, e.g. an "Immediately" auto-lock). + // "inactive" is intentionally ignored: on iOS the biometric overlay itself + // triggers inactive→active transitions, which would re-prompt on cancel. + const wasBackgroundedRef = useRef(AppState.currentState !== "active"); + // Monitor auth status changes to navigate when unlocked useEffect(() => { - if (authStatus === AUTH_STATUS.AUTHENTICATED) { + if (authStatus === AUTH_STATUS.AUTHENTICATED && onUnlocked) { // Add a small delay to ensure state is settled before navigation const navigationTimeout = setTimeout(() => { - navigation.replace(ROOT_NAVIGATOR_ROUTES.MAIN_TAB_STACK); - }, 100); + onUnlocked(); + }, UNLOCK_NAVIGATION_DELAY_MS); return () => { clearTimeout(navigationTimeout); }; } return undefined; - }, [authStatus, navigation]); + }, [authStatus, onUnlocked]); useEffect(() => { const fetchActiveAccountPublicKey = async () => { @@ -90,7 +125,7 @@ export const LockScreen: React.FC = ({ navigation }) => { if (error && error !== t("authStore.error.invalidPassword")) { const { title, message } = getErrorToastContent(error, t); showToast({ - toastId: "unlock-wallet-error", + toastId: UNLOCK_ERROR_TOAST_ID, variant: "error", title, message, @@ -112,6 +147,67 @@ export const LockScreen: React.FC = ({ navigation }) => { [signIn, isSigningIn], ); + /** + * Prompts for biometrics and unlocks the wallet with the stored password, + * so users with biometrics enabled don't need to tap the unlock button. + */ + const attemptBiometricUnlock = useCallback(() => { + if (isSigningIn || isForgotPasswordModalVisible) return; + // The guards below must run before the auto-prompt flag is set: the + // sign-in method resolves asynchronously after mount (PASSWORD until + // biometrics availability is checked), and we still want to prompt then. + if (!isBiometricsEnabled || signInMethod === LoginType.PASSWORD) return; + if (hasAutoPromptedRef.current) return; + + hasAutoPromptedRef.current = true; + verifyActionWithBiometrics((password?: string) => { + if (password) { + handleUnlock(password); + } + return Promise.resolve(); + }).catch(() => { + // The user dismissed the biometric prompt — they can still unlock + // manually; we prompt again on the next return from the background. + }); + }, [ + isSigningIn, + isForgotPasswordModalVisible, + isBiometricsEnabled, + signInMethod, + verifyActionWithBiometrics, + handleUnlock, + ]); + + // Auto-prompt biometrics when landing on this screen with the app active + // (foreground auto-lock, manual lock, or cold start) + useEffect(() => { + if (AppState.currentState === "active") { + attemptBiometricUnlock(); + } + }, [attemptBiometricUnlock]); + + // Re-prompt biometrics when the app returns from the background while this + // screen is showing — like banking apps do + useEffect(() => { + const subscription = AppState.addEventListener("change", (nextAppState) => { + if (nextAppState === "background") { + wasBackgroundedRef.current = true; + return; + } + + if (nextAppState === "active" && wasBackgroundedRef.current) { + // Re-prompting on EVERY return from the background is intentional + // (banking-app behavior): the user landing on a locked wallet wants + // to get in, and a cancelled prompt stays cancelled until they leave. + wasBackgroundedRef.current = false; + hasAutoPromptedRef.current = false; + attemptBiometricUnlock(); + } + }); + + return () => subscription.remove(); + }, [attemptBiometricUnlock]); + const handleForgotPassword = useCallback(() => { setIsForgotPasswordModalVisible(true); }, []); @@ -145,3 +241,11 @@ export const LockScreen: React.FC = ({ navigation }) => { ); }; + +export const LockScreen: React.FC = ({ navigation }) => { + const handleUnlocked = useCallback(() => { + navigation.replace(ROOT_NAVIGATOR_ROUTES.MAIN_TAB_STACK); + }, [navigation]); + + return ; +}; diff --git a/src/components/screens/SettingsScreen/SecurityScreen/AutoLockTimerScreen/AutoLockTimerScreen.tsx b/src/components/screens/SettingsScreen/SecurityScreen/AutoLockTimerScreen/AutoLockTimerScreen.tsx new file mode 100644 index 000000000..aae6f7a29 --- /dev/null +++ b/src/components/screens/SettingsScreen/SecurityScreen/AutoLockTimerScreen/AutoLockTimerScreen.tsx @@ -0,0 +1,68 @@ +import { NativeStackScreenProps } from "@react-navigation/native-stack"; +import { List } from "components/List"; +import { BaseLayout } from "components/layout/BaseLayout"; +import Icon from "components/sds/Icon"; +import { Text } from "components/sds/Typography"; +import { AUTO_LOCK_TIMER } from "config/constants"; +import { SETTINGS_ROUTES, SettingsStackParamList } from "config/routes"; +import { usePreferencesStore } from "ducks/preferences"; +import useAppTranslation from "hooks/useAppTranslation"; +import useColors from "hooks/useColors"; +import React from "react"; +import { View } from "react-native"; + +interface AutoLockTimerScreenProps + extends NativeStackScreenProps< + SettingsStackParamList, + typeof SETTINGS_ROUTES.AUTO_LOCK_TIMER_SCREEN + > {} + +const AutoLockTimerScreen: React.FC = () => { + const { t } = useAppTranslation(); + const { themeColors } = useColors(); + const { autoLockTimer, setAutoLockTimer } = usePreferencesStore(); + + const timerLabels: Record = { + [AUTO_LOCK_TIMER.IMMEDIATELY]: t("autoLockTimerScreen.options.immediately"), + [AUTO_LOCK_TIMER.ONE_MINUTE]: t("autoLockTimerScreen.options.oneMinute"), + [AUTO_LOCK_TIMER.FIFTEEN_MINUTES]: t( + "autoLockTimerScreen.options.fifteenMinutes", + ), + [AUTO_LOCK_TIMER.THIRTY_MINUTES]: t( + "autoLockTimerScreen.options.thirtyMinutes", + ), + [AUTO_LOCK_TIMER.ONE_HOUR]: t("autoLockTimerScreen.options.oneHour"), + [AUTO_LOCK_TIMER.TWELVE_HOURS]: t( + "autoLockTimerScreen.options.twelveHours", + ), + [AUTO_LOCK_TIMER.TWENTY_FOUR_HOURS]: t( + "autoLockTimerScreen.options.twentyFourHours", + ), + [AUTO_LOCK_TIMER.NONE]: t("autoLockTimerScreen.options.none"), + }; + + const listItems = Object.values(AUTO_LOCK_TIMER).map((option) => ({ + title: timerLabels[option], + titleColor: themeColors.text.primary, + onPress: () => setAutoLockTimer(option), + trailingContent: ( + + ), + testID: `auto-lock-option-${option}`, + })); + + return ( + + + + + {t("autoLockTimerScreen.footer")} + + + + ); +}; + +export default AutoLockTimerScreen; diff --git a/src/components/screens/SettingsScreen/SecurityScreen/AutoLockTimerScreen/index.ts b/src/components/screens/SettingsScreen/SecurityScreen/AutoLockTimerScreen/index.ts new file mode 100644 index 000000000..8accffc0d --- /dev/null +++ b/src/components/screens/SettingsScreen/SecurityScreen/AutoLockTimerScreen/index.ts @@ -0,0 +1 @@ +export { default } from "./AutoLockTimerScreen"; diff --git a/src/components/screens/SettingsScreen/SecurityScreen/SecurityScreen.tsx b/src/components/screens/SettingsScreen/SecurityScreen/SecurityScreen.tsx index 44d3cf14e..dae64fffd 100644 --- a/src/components/screens/SettingsScreen/SecurityScreen/SecurityScreen.tsx +++ b/src/components/screens/SettingsScreen/SecurityScreen/SecurityScreen.tsx @@ -63,6 +63,16 @@ const SecurityScreen: React.FC = ({ navigation }) => { testID: "face-id-settings-button", }); } + listItems.push({ + icon: , + title: t("securityScreen.autoLock"), + titleColor: themeColors.text.primary, + onPress: () => navigation.navigate(SETTINGS_ROUTES.AUTO_LOCK_TIMER_SCREEN), + trailingContent: ( + + ), + testID: "auto-lock-button", + }); return ( diff --git a/src/config/constants.ts b/src/config/constants.ts index b881d078e..e71898c29 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -75,6 +75,66 @@ export const ACCOUNTS_TO_VERIFY_ON_EXISTING_MNEMONIC_PHRASE = 6; export const HASH_KEY_EXPIRATION_MS = 24 * 60 * 60 * 1000; // 24 hours export const VISUAL_DELAY_MS = 500; +const SECOND_IN_MS = 1000; +const MINUTE_IN_MS = 60 * SECOND_IN_MS; +const HOUR_IN_MS = 60 * MINUTE_IN_MS; +const YEAR_IN_MS = 365 * 24 * HOUR_IN_MS; + +/** + * Auto-lock timer options. + * + * The timer starts counting when the app goes to the background; returning to + * the app after the selected duration soft-locks the wallet (AUTH_STATUS.LOCKED, + * fast unlock path). Declaration order is the display order on the + * Auto-Lock Timer settings screen. + */ +export enum AUTO_LOCK_TIMER { + IMMEDIATELY = "immediately", + ONE_MINUTE = "oneMinute", + FIFTEEN_MINUTES = "fifteenMinutes", + THIRTY_MINUTES = "thirtyMinutes", + ONE_HOUR = "oneHour", + TWELVE_HOURS = "twelveHours", + TWENTY_FOUR_HOURS = "twentyFourHours", + NONE = "none", +} + +export const DEFAULT_AUTO_LOCK_TIMER = AUTO_LOCK_TIMER.TWENTY_FOUR_HOURS; + +/** Toast ID used by the lock screen / overlay to surface unlock errors. */ +export const UNLOCK_ERROR_TOAST_ID = "unlock-wallet-error"; + +/** + * Toast IDs allowed to surface while the wallet is soft-locked — the lock + * overlay's own messaging. Every other toast is suppressed so the still- + * mounted screens underneath the overlay can't surface errors or validation + * messages over the lock until the app is usable again. + */ +export const SOFT_LOCK_ALLOWED_TOAST_IDS: string[] = [UNLOCK_ERROR_TOAST_ID]; + +/** + * Background duration (in ms) after which each AUTO_LOCK_TIMER option locks + * the wallet. `0` locks as soon as the app is backgrounded; `null` never + * auto-locks by timer. + */ +export const AUTO_LOCK_TIMER_MS: Record = { + [AUTO_LOCK_TIMER.IMMEDIATELY]: 0, + [AUTO_LOCK_TIMER.ONE_MINUTE]: MINUTE_IN_MS, + [AUTO_LOCK_TIMER.FIFTEEN_MINUTES]: 15 * MINUTE_IN_MS, + [AUTO_LOCK_TIMER.THIRTY_MINUTES]: 30 * MINUTE_IN_MS, + [AUTO_LOCK_TIMER.ONE_HOUR]: HOUR_IN_MS, + [AUTO_LOCK_TIMER.TWELVE_HOURS]: 12 * HOUR_IN_MS, + [AUTO_LOCK_TIMER.TWENTY_FOUR_HOURS]: 24 * HOUR_IN_MS, + [AUTO_LOCK_TIMER.NONE]: null, +}; + +/** + * Hash key TTL used when the auto-lock timer is NONE: the user explicitly + * opted out of auto-lock, so the 24h hard-expiry backstop must never force + * a re-auth. Effectively "never" while remaining a valid timestamp. + */ +export const NEVER_EXPIRE_HASH_KEY_MS = 100 * YEAR_IN_MS; + // Recovery phrase validation constants export const VALIDATION_WORDS_PER_ROW: number = 3; export const VALIDATION_EXTRA_USER_WORDS: number = 2; @@ -305,6 +365,8 @@ export enum STORAGE_KEYS { * HASH_KEY The hash key and salt in an JSON stryngified object. This is used to encrypt and decrypt the temporary store. * HASH_KEY format: { hashKey: string, salt: string, expiresAt: number } * AUTH_STATUS The authentication status is stored securely to prevent tampering on rooted/jailbroken devices. + * AUTO_LOCK_TIMER_SETTING / AUTO_LOCK_BACKGROUNDED_AT The auto-lock policy inputs are stored + * securely for the same reason: tampering them from unencrypted storage must not weaken the lock. * */ export enum SENSITIVE_STORAGE_KEYS { TEMPORARY_STORE = "temporaryStore", @@ -313,6 +375,8 @@ export enum SENSITIVE_STORAGE_KEYS { // Persisted symmetric encryption key (scrypt output). Derivable from HASH_KEY, so no extra // attack surface. Stored so cold app-opens from LOCKED skip the second scrypt. DERIVED_KEY = "derivedKey", + AUTO_LOCK_TIMER_SETTING = "autoLockTimer", + AUTO_LOCK_BACKGROUNDED_AT = "autoLockBackgroundedAt", } /** diff --git a/src/config/routes.ts b/src/config/routes.ts index d45449eb2..4e85be54b 100644 --- a/src/config/routes.ts +++ b/src/config/routes.ts @@ -94,6 +94,7 @@ export const SETTINGS_ROUTES = { SHOW_RECOVERY_PHRASE_SCREEN: "ShowRecoveryPhraseScreen", YOUR_RECOVERY_PHRASE_SCREEN: "YourRecoveryPhraseScreen", BIOMETRICS_SETTINGS_SCREEN: "BiometricsSettingsScreen", + AUTO_LOCK_TIMER_SCREEN: "AutoLockTimerScreen", } as const; export const MANAGE_WALLETS_ROUTES = { @@ -213,6 +214,7 @@ export type SettingsStackParamList = { [SETTINGS_ROUTES.SHOW_RECOVERY_PHRASE_SCREEN]: undefined; [SETTINGS_ROUTES.YOUR_RECOVERY_PHRASE_SCREEN]: undefined; [SETTINGS_ROUTES.BIOMETRICS_SETTINGS_SCREEN]: undefined; + [SETTINGS_ROUTES.AUTO_LOCK_TIMER_SCREEN]: undefined; }; export type ManageWalletsStackParamList = { diff --git a/src/ducks/auth.ts b/src/ducks/auth.ts index 7aef7f301..1705ee661 100644 --- a/src/ducks/auth.ts +++ b/src/ducks/auth.ts @@ -12,10 +12,11 @@ import { import { AnalyticsEvent } from "config/analyticsConfig"; import { ACCOUNTS_TO_VERIFY_ON_EXISTING_MNEMONIC_PHRASE, + AUTO_LOCK_TIMER_MS, BIOMETRIC_STORAGE_KEYS, + DEFAULT_AUTO_LOCK_TIMER, FACE_ID_BIOMETRY_TYPES, FINGERPRINT_BIOMETRY_TYPES, - HASH_KEY_EXPIRATION_MS, LoginType, NETWORKS, SENSITIVE_STORAGE_KEYS, @@ -49,9 +50,16 @@ import { import { createKeyManager } from "helpers/keyManager/keyManager"; import { clearWalletKitStorage } from "helpers/walletKitUtil"; import { t } from "i18next"; +import { AppState, Keyboard } from "react-native"; import ReactNativeBiometrics from "react-native-biometrics"; import * as Keychain from "react-native-keychain"; import { analytics } from "services/analytics"; +import { + clearBackgroundedAt, + getAutoLockTimer, + getBackgroundedAt, + getHashKeyExpirationMs, +} from "services/autoLock"; import { getAccount } from "services/stellar"; import { clearNonSensitiveData, @@ -205,6 +213,10 @@ interface AuthState { isSwitchingAccount: boolean; error: string | null; authStatus: AuthStatus; + // True when the wallet was soft-locked in-process (auto-lock timer or + // IMMEDIATELY on backgrounding): the navigation tree stays mounted under a + // lock overlay so the user resumes exactly where they were after unlocking. + isSoftLocked: boolean; allAccounts: Account[]; // Active account state @@ -215,7 +227,6 @@ interface AuthState { // Biometric authentication state signInMethod: LoginType; - hasTriggeredAppOpenBiometricsLogin: boolean; } /** @@ -278,6 +289,7 @@ interface ImportSecretKeyParams { */ interface AuthActions { logout: (shouldWipeAllData?: boolean) => void; + softLock: () => Promise; signUp: (params: SignUpParams) => Promise; signIn: (params: SignInParams) => Promise; importWallet: (params: ImportWalletParams) => Promise; @@ -317,7 +329,6 @@ interface AuthActions { // Biometric authentication actions setSignInMethod: (method: LoginType) => void; - setHasTriggeredAppOpenBiometricsLogin: (hasTriggered: boolean) => void; } /** @@ -340,6 +351,7 @@ const initialState: Omit = { isSwitchingAccount: false, error: null, authStatus: AUTH_STATUS.NOT_AUTHENTICATED, + isSoftLocked: false, allAccounts: [], // Active account initial state account: null, @@ -348,7 +360,6 @@ const initialState: Omit = { navigationRef: null, // Biometric authentication initial state signInMethod: LoginType.PASSWORD, - hasTriggeredAppOpenBiometricsLogin: false, }; /** @@ -488,6 +499,46 @@ const getAuthStatus = async (): Promise => { return AUTH_STATUS.HASH_KEY_EXPIRED; } + // Evaluate the user-configurable auto-lock timer: a timestamp is recorded + // when the app goes to the background (useAuthCheck); returning after the + // selected duration soft-locks the wallet (LOCKED, fast unlock path). + // Covers both warm foreground returns and cold starts. + // NOTE: elapsed time uses the wall clock (Date.now()); rolling the device + // clock back can dodge the timer. Accepted limitation — the hash key + // expiry below still bounds the session. + const backgroundedAt = await getBackgroundedAt(); + if (backgroundedAt) { + const autoLockTimer = await getAutoLockTimer(); + const autoLockTimerMs = AUTO_LOCK_TIMER_MS[autoLockTimer]; + const elapsedInBackground = Date.now() - backgroundedAt; + + if ( + autoLockTimerMs !== null && + elapsedInBackground >= autoLockTimerMs && + temporaryStore + ) { + await secureDataStorage.setItem( + SENSITIVE_STORAGE_KEYS.AUTH_STATUS, + AUTH_STATUS.LOCKED, + ); + await clearBackgroundedAt(); + return AUTH_STATUS.LOCKED; + } + + if (AppState.currentState === "active") { + // The user returned within the timer: consume the timestamp so the + // foreground check interval can't lock mid-use. The hash key expiry + // is deliberately NOT refreshed here — the TTL is only ever anchored + // at credential-verified moments (signIn / generateHashKey / + // applyAutoLockTimerToHashKey) so key material has a bounded + // lifetime no matter how often the app is reopened. "None" keeps its + // never-expire TTL because it was set at one of those moments. + await clearBackgroundedAt(); + } + // Still backgrounded (periodic background check): leave the timestamp + // intact so the timer keeps counting from the original moment. + } + // Check if hash key is expired (only relevant when not LOCKED) if (hashKey && isHashKeyExpired(hashKey)) { return AUTH_STATUS.HASH_KEY_EXPIRED; @@ -801,8 +852,9 @@ const generateHashKey = async (password: string): Promise => { // Convert to base64 for storage const hashKey = base64Encode(hashKeyBytes); - // Calculate the expiration timestamp - const expirationTime = Date.now() + HASH_KEY_EXPIRATION_MS; + // Calculate the expiration timestamp (timer-aware: "None" never expires) + const autoLockTimer = await getAutoLockTimer(); + const expirationTime = Date.now() + getHashKeyExpirationMs(autoLockTimer); // Return the hash key object (caller will store it) return { @@ -1394,12 +1446,14 @@ const signIn = async ({ SENSITIVE_STORAGE_KEYS.TEMPORARY_STORE, ); if (existingHashKey && existingTempStore) { - // Fast path: temp store is intact — just refresh TTL. + // Fast path: temp store is intact — just refresh TTL + // (timer-aware: "None" never expires). + const autoLockTimer = await getAutoLockTimer(); await secureDataStorage.setItem( SENSITIVE_STORAGE_KEYS.HASH_KEY, JSON.stringify({ ...existingHashKey, - expiresAt: Date.now() + HASH_KEY_EXPIRATION_MS, + expiresAt: Date.now() + getHashKeyExpirationMs(autoLockTimer), }), ); } else { @@ -2071,6 +2125,14 @@ export const useAuthenticationStore = create()((set, get) => ({ await clearNonSensitiveData(); await dataStorage.remove(STORAGE_KEYS.COLLECTIBLES_LIST); + // Reset the auto-lock timer so a future wallet on this device + // doesn't inherit the previous user's preference, and drop any + // pending backgrounded-at timestamp + usePreferencesStore + .getState() + .setAutoLockTimer(DEFAULT_AUTO_LOCK_TIMER); + await clearBackgroundedAt(); + await clearBiometricsData(); set({ isLoading: false }); @@ -2090,6 +2152,49 @@ export const useAuthenticationStore = create()((set, get) => ({ }, 0); }, + /** + * Soft-locks the wallet in-process (auto-lock timer / IMMEDIATELY option). + * + * Unlike logout(), this does NOT reset the navigation tree: the mounted + * screens (navigation history, route params and in-progress inputs) are + * preserved underneath a full-screen lock overlay, so the user resumes + * exactly where they were after unlocking. Protection while locked comes + * from the lock overlay (covers the UI, swallows back/touch, hides the + * tree from accessibility) plus the auth gates on sensitive reads + * (getActiveAccount blocks LOCKED; WalletKit rejects requests while + * LOCKED). The active account is intentionally LEFT in the store: the + * mounted screens keep reading it, so nulling it would make them re-run + * validations and surface errors under the overlay. It carries no extra + * exposure beyond the derived-key cache, which is deliberately retained + * for the fast unlock path (see PR #664). The persisted LOCKED status + * covers cold starts (where the overlay is replaced by the regular lock + * screen route since process state is gone anyway). + */ + softLock: async () => { + Keyboard.dismiss(); + + // Single atomic update: RootNavigator must never observe + // LOCKED && !isSoftLocked, which would unmount the preserved tree + set({ + authStatus: AUTH_STATUS.LOCKED, + isSoftLocked: true, + isLoading: false, + }); + + // Persist LOCKED so tampering and cold starts are covered (same write + // as logout's soft path). Awaited: if the process is killed right after + // backgrounding (the common IMMEDIATELY case) the lock must already be + // on disk. + try { + await secureDataStorage.setItem( + SENSITIVE_STORAGE_KEYS.AUTH_STATUS, + AUTH_STATUS.LOCKED, + ); + } catch (error) { + logger.error("softLock", "Failed to persist LOCKED status", error); + } + }, + /** * Signs up a new user with the provided credentials * @@ -2102,6 +2207,7 @@ export const useAuthenticationStore = create()((set, get) => ({ await signUp(params); set({ ...initialState, + navigationRef: get().navigationRef, isLoading: false, authStatus: AUTH_STATUS.AUTHENTICATED, }); @@ -2140,6 +2246,9 @@ export const useAuthenticationStore = create()((set, get) => ({ analytics.trackReAuthSuccess(); set({ ...initialState, + // Preserve the navigation ref: nothing remounts to re-set it after a + // soft unlock, and later lock navigations would silently no-op + navigationRef: get().navigationRef, authStatus: AUTH_STATUS.AUTHENTICATED, isLoading: false, isLoadingAccount: true, @@ -2180,6 +2289,12 @@ export const useAuthenticationStore = create()((set, get) => ({ .catch((e) => logger.debug("signIn", "Failed to clear persisted auth status", e), ); + + // Clear any stale backgrounded-at timestamp so the auto-lock timer + // can't re-lock a freshly unlocked session (non-blocking) + clearBackgroundedAt().catch((e) => + logger.debug("signIn", "Failed to clear backgrounded-at timestamp", e), + ); } catch (error) { analytics.trackReAuthFail(); set({ @@ -2525,6 +2640,7 @@ export const useAuthenticationStore = create()((set, get) => ({ await importWallet(params); set({ ...initialState, + navigationRef: get().navigationRef, authStatus: AUTH_STATUS.AUTHENTICATED, isLoading: false, }); @@ -2552,12 +2668,39 @@ export const useAuthenticationStore = create()((set, get) => ({ /** * Gets the current authentication status * + * Single funnel for lock transitions: when a running (AUTHENTICATED) + * session is found LOCKED — i.e. the auto-lock timer fired — every caller + * (periodic checks, fetchActiveAccount, selectAccount, ...) produces the + * same atomic soft lock, preserving the mounted navigation tree under the + * lock overlay instead of racing it with navigation resets. + * * @returns {Promise} The current authentication status */ getAuthStatus: async () => { // Always re-validate auth status to ensure consistency // Don't rely on cached status as it may be stale after app updates + const previousAuthStatus = get().authStatus; const authStatus = await getAuthStatus(); + + if ( + authStatus === AUTH_STATUS.LOCKED && + previousAuthStatus === AUTH_STATUS.AUTHENTICATED + ) { + // In-process lock (auto-lock timer): soft lock sets authStatus and + // isSoftLocked in one atomic update so the tree never unmounts + await get().softLock(); + return authStatus; + } + + // Never let a stale read downgrade an active soft lock back to + // AUTHENTICATED: a concurrent check that resolved just before the LOCKED + // persist landed could otherwise clobber the lock (authStatus would no + // longer reflect it for guards that read it). Only a real signIn clears + // isSoftLocked. Disk already holds LOCKED, so the next check self-heals. + if (get().isSoftLocked && authStatus === AUTH_STATUS.AUTHENTICATED) { + return get().authStatus; + } + set({ authStatus }); // If the hash key is expired, navigate to lock screen @@ -2583,6 +2726,12 @@ export const useAuthenticationStore = create()((set, get) => ({ * Used when authentication expires or user needs to re-authenticate */ navigateToLockScreen: () => { + // Soft-locked: the lock overlay is already covering the app and the + // navigation tree must stay intact so the user resumes where they were. + if (get().isSoftLocked) { + return; + } + const { navigationRef } = get(); if (navigationRef && navigationRef.isReady()) { // Check if we're already on the lock screen to prevent navigation loops @@ -2611,22 +2760,18 @@ export const useAuthenticationStore = create()((set, get) => ({ set({ isLoadingAccount: true, accountError: null }); try { - // Check auth status first - const authStatus = await getAuthStatus(); + // Check auth status through the store funnel so an auto-lock detected + // here produces the same atomic soft lock as the periodic checks + // (instead of racing them with a navigation reset) + const authStatus = await get().getAuthStatus(); // Security: Block access when hash key is expired or when locked - // LOCKED state requires password re-entry before accessing sensitive data + // LOCKED state requires password re-entry before accessing sensitive + // data. The funnel has already handled the lock transition/navigation. if ( authStatus === AUTH_STATUS.HASH_KEY_EXPIRED || authStatus === AUTH_STATUS.LOCKED ) { - set({ - authStatus, - }); - - // Navigate to lock screen - get().navigateToLockScreen(); - set({ isLoadingAccount: false }); return null; } @@ -2735,15 +2880,14 @@ export const useAuthenticationStore = create()((set, get) => ({ set({ isSwitchingAccount: true, error: null }); try { - // Security: Check auth status before allowing account switching - // Block access when hash key is expired or when locked - const authStatus = await getAuthStatus(); + // Security: Check auth status before allowing account switching. + // Goes through the store funnel so an auto-lock detected here produces + // the same atomic soft lock as the periodic checks. + const authStatus = await get().getAuthStatus(); if ( authStatus === AUTH_STATUS.HASH_KEY_EXPIRED || authStatus === AUTH_STATUS.LOCKED ) { - set({ authStatus }); - get().navigateToLockScreen(); set({ isSwitchingAccount: false }); return; } @@ -2818,8 +2962,4 @@ export const useAuthenticationStore = create()((set, get) => ({ setSignInMethod: (method: LoginType) => { set({ signInMethod: method }); }, - - setHasTriggeredAppOpenBiometricsLogin: (hasTriggered: boolean) => { - set({ hasTriggeredAppOpenBiometricsLogin: hasTriggered }); - }, })); diff --git a/src/ducks/preferences.ts b/src/ducks/preferences.ts index 1bad5b477..66399fde7 100644 --- a/src/ducks/preferences.ts +++ b/src/ducks/preferences.ts @@ -1,4 +1,10 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; +import { AUTO_LOCK_TIMER, DEFAULT_AUTO_LOCK_TIMER } from "config/constants"; +import { logger } from "config/logger"; +import { + applyAutoLockTimerToHashKey, + persistAutoLockTimer, +} from "services/autoLock"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; @@ -9,17 +15,20 @@ interface PreferencesState { setIsMemoValidationEnabled: (isMemoValidationEnabled: boolean) => void; isBiometricsEnabled: boolean | undefined; setIsBiometricsEnabled: (isBiometricsEnabled: boolean) => void; + autoLockTimer: AUTO_LOCK_TIMER; + setAutoLockTimer: (autoLockTimer: AUTO_LOCK_TIMER) => void; } const INITIAL_PREFERENCES_STATE = { isHideDustEnabled: true, isMemoValidationEnabled: true, isBiometricsEnabled: undefined, + autoLockTimer: DEFAULT_AUTO_LOCK_TIMER, }; export const usePreferencesStore = create()( persist( - (set) => ({ + (set, get) => ({ ...INITIAL_PREFERENCES_STATE, setIsHideDustEnabled: (isHideDustEnabled: boolean) => set({ isHideDustEnabled }), @@ -27,6 +36,31 @@ export const usePreferencesStore = create()( set({ isMemoValidationEnabled }), setIsBiometricsEnabled: (isBiometricsEnabled: boolean) => set({ isBiometricsEnabled }), + setAutoLockTimer: (autoLockTimer: AUTO_LOCK_TIMER) => { + const previousAutoLockTimer = get().autoLockTimer; + set({ autoLockTimer }); + + // Write-through to the secure-storage mirror (read by getAuthStatus + // without depending on zustand rehydration) and re-anchor the hash + // key TTL so switching to/from NONE takes effect immediately. + // If the mirror write fails, revert the UI state so the displayed + // selection never disagrees with the enforced value. + persistAutoLockTimer(autoLockTimer).catch((error) => { + logger.error( + "setAutoLockTimer", + "Failed to persist auto-lock timer", + error, + ); + set({ autoLockTimer: previousAutoLockTimer }); + }); + applyAutoLockTimerToHashKey(autoLockTimer).catch((error) => + logger.error( + "setAutoLockTimer", + "Failed to apply auto-lock timer to hash key", + error, + ), + ); + }, }), { name: "preferences-storage", diff --git a/src/hooks/useAppOpenBiometricsLogin.ts b/src/hooks/useAppOpenBiometricsLogin.ts deleted file mode 100644 index f621c9486..000000000 --- a/src/hooks/useAppOpenBiometricsLogin.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { AUTH_STATUS } from "config/types"; -import { useAuthenticationStore } from "ducks/auth"; -import useAppTranslation from "hooks/useAppTranslation"; -import { useBiometrics } from "hooks/useBiometrics"; -import { useToast } from "providers/ToastProvider"; -import { useEffect } from "react"; - -export const useAppOpenBiometricsLogin = (initializing: boolean) => { - const { - authStatus, - verifyActionWithBiometrics, - signIn, - hasTriggeredAppOpenBiometricsLogin, - setHasTriggeredAppOpenBiometricsLogin, - } = useAuthenticationStore(); - const { isBiometricsEnabled } = useBiometrics(); - const { showToast } = useToast(); - const { t } = useAppTranslation(); - - useEffect(() => { - if (initializing) { - return; - } - - if (authStatus === AUTH_STATUS.AUTHENTICATED || !isBiometricsEnabled) { - setHasTriggeredAppOpenBiometricsLogin(true); - return; - } - - if ( - (authStatus === AUTH_STATUS.HASH_KEY_EXPIRED || - authStatus === AUTH_STATUS.LOCKED) && - !hasTriggeredAppOpenBiometricsLogin - ) { - setHasTriggeredAppOpenBiometricsLogin(true); - - 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, - }); - }); - } - }, [ - authStatus, - isBiometricsEnabled, - initializing, - hasTriggeredAppOpenBiometricsLogin, - setHasTriggeredAppOpenBiometricsLogin, - verifyActionWithBiometrics, - signIn, - showToast, - t, - ]); -}; diff --git a/src/hooks/useAuthCheck.ts b/src/hooks/useAuthCheck.ts index f6c8c44c0..dcacfd42c 100644 --- a/src/hooks/useAuthCheck.ts +++ b/src/hooks/useAuthCheck.ts @@ -1,3 +1,4 @@ +import { AUTO_LOCK_TIMER } from "config/constants"; import { logger } from "config/logger"; import { AUTH_STATUS } from "config/types"; import { useAuthenticationStore } from "ducks/auth"; @@ -8,6 +9,7 @@ import { PanResponder, PanResponderInstance, } from "react-native"; +import { getAutoLockTimer, recordBackgroundedAt } from "services/autoLock"; // Constants for interval timings (in milliseconds) const BACKGROUND_CHECK_INTERVAL = 60000; // Check every minute when in background @@ -24,8 +26,7 @@ const INITIAL_SETUP_DELAY = 500; // Delay to prevent race conditions during setu * It adjusts the check frequency based on the app state and user activity. */ const useAuthCheck = () => { - const { getAuthStatus, authStatus, navigateToLockScreen } = - useAuthenticationStore(); + const { getAuthStatus, authStatus } = useAuthenticationStore(); const [isActive, setIsActive] = useState(true); // Refs to track app state, last interaction, auth check intervals, and pan responder instance @@ -52,10 +53,11 @@ const useAuthCheck = () => { lastCheckRef.current = now; try { - const status = await getAuthStatus(); - if (status === AUTH_STATUS.HASH_KEY_EXPIRED) { - navigateToLockScreen(); - } + // The store action is the single funnel for lock transitions: it + // soft-locks atomically when the auto-lock timer fired (preserving the + // mounted screens under the overlay) and navigates to the lock screen + // when the hash key hard-expired. + await getAuthStatus(); } catch (error) { logger.error( "useAuthCheck.checkAuth", @@ -63,7 +65,7 @@ const useAuthCheck = () => { error, ); } - }, [getAuthStatus, navigateToLockScreen, authStatus]); + }, [getAuthStatus, authStatus]); /** * Setup a periodic interval to check authentication status based on the current app state and user activity. @@ -99,6 +101,49 @@ const useAuthCheck = () => { */ useEffect(() => { const handleAppStateChange = (nextAppState: AppStateStatus) => { + // Record when the app goes to the background so the auto-lock timer can + // be evaluated on the next foreground or cold start. Intentionally NOT + // on "inactive": iOS fires it for control center, app-switcher peeks, + // permission dialogs and biometric prompts. NOTE: some Android OEMs + // emit "background" when their BiometricPrompt appears — worth keeping + // in mind if IMMEDIATELY ever misbehaves around in-app biometric + // confirmations on specific devices. + const { authStatus: currentAuthStatus, softLock } = + useAuthenticationStore.getState(); + if ( + nextAppState === "background" && + currentAuthStatus === AUTH_STATUS.AUTHENTICATED + ) { + recordBackgroundedAt().catch((err) => + logger.error( + "handleAppStateChange", + "Error recording backgrounded-at timestamp", + err, + ), + ); + + // Read from the secure-storage mirror (not the zustand store) so the + // IMMEDIATELY lock also fires when backgrounding happens before + // zustand rehydration completes. + getAutoLockTimer() + .then((autoLockTimer) => { + if (autoLockTimer === AUTO_LOCK_TIMER.IMMEDIATELY) { + // Soft-lock right away: the overlay renders while the app is + // backgrounded (no wallet content flashes on return) and the + // navigation tree is preserved for after the unlock. + return softLock(); + } + return undefined; + }) + .catch((err) => + logger.error( + "handleAppStateChange", + "Error soft-locking on background", + err, + ), + ); + } + // When returning to active state, allow a slight delay before checking auth if ( appState.current.match(/inactive|background/) && diff --git a/src/hooks/useGetActiveAccount.ts b/src/hooks/useGetActiveAccount.ts index bc9b24dae..191801f95 100644 --- a/src/hooks/useGetActiveAccount.ts +++ b/src/hooks/useGetActiveAccount.ts @@ -1,6 +1,7 @@ import { FeeBumpTransaction, Keypair, Transaction } from "@stellar/stellar-sdk"; import { navigationRef } from "components/App"; import { logger } from "config/logger"; +import { AUTH_STATUS } from "config/types"; import { useAuthenticationStore } from "ducks/auth"; import { signMessage as signMessageHelper, @@ -9,6 +10,18 @@ import { import { useCallback, useEffect } from "react"; import { analytics } from "services/analytics"; +/** + * Defense-in-depth: signing must only ever happen while the wallet is fully + * unlocked. The soft-lock overlay blocks the UI, but the navigation tree + * (and these signing callbacks) stay mounted underneath, so guard the key + * material directly — never rely on the overlay alone. Read live from the + * store so a lock that engaged after the callback was created is respected. + */ +const isWalletUnlocked = (): boolean => { + const { authStatus, isSoftLocked } = useAuthenticationStore.getState(); + return authStatus === AUTH_STATUS.AUTHENTICATED && !isSoftLocked; +}; + /** * Hook that provides access to the active account with loading state * Uses the auth store to manage the active account state @@ -43,7 +56,7 @@ const useGetActiveAccount = () => { const signTransaction = useCallback( (transaction: Transaction | FeeBumpTransaction): string | null => { - if (!account) return null; + if (!account || !isWalletUnlocked()) return null; const keyPair = Keypair.fromSecret(account.privateKey); @@ -56,7 +69,7 @@ const useGetActiveAccount = () => { const signMessage = useCallback( (message: string): string | null => { - if (!account) return null; + if (!account || !isWalletUnlocked()) return null; try { return signMessageHelper(message, account.privateKey); @@ -73,7 +86,7 @@ const useGetActiveAccount = () => { ( preimageXdr: string, ): { signedAuthEntry: string; signerAddress: string } | null => { - if (!account) return null; + if (!account || !isWalletUnlocked()) return null; try { return signAuthEntryHelper(preimageXdr, account.privateKey); diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index e736f2cac..669d1e2d7 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -1023,6 +1023,7 @@ "securityScreen": { "title": "Security", "showRecoveryPhrase": "Show recovery phrase", + "autoLock": "Auto-lock", "faceId": { "title": "Face ID authentication", "description": "Enable Face ID for an added layer of security. Use biometric authentication to unlock your wallet and approve transactions quickly and securely.", @@ -1066,6 +1067,20 @@ "disableAlertMessage": "Are you sure you want to disable Iris?" } }, + "autoLockTimerScreen": { + "title": "Auto-Lock Timer", + "options": { + "immediately": "Immediately", + "oneMinute": "1 minute", + "fifteenMinutes": "15 minutes", + "thirtyMinutes": "30 minutes", + "oneHour": "1 hour", + "twelveHours": "12 hours", + "twentyFourHours": "24 hours", + "none": "None" + }, + "footer": "After a set time, you will be prompted for your password again as an extra security measure." + }, "showRecoveryPhraseScreen": { "title": "Show Recovery Phrase", "keepSafe": "Keep your recovery phrase in a safe and secure place.", diff --git a/src/i18n/locales/pt/translations.json b/src/i18n/locales/pt/translations.json index a6fa7e315..ea228c4b8 100644 --- a/src/i18n/locales/pt/translations.json +++ b/src/i18n/locales/pt/translations.json @@ -1024,6 +1024,7 @@ "securityScreen": { "title": "Segurança", "showRecoveryPhrase": "Mostrar frase de recuperação", + "autoLock": "Bloqueio automático", "faceId": { "title": "Autenticação Face ID", "description": "Habilite a autenticação Face ID para uma camada adicional de segurança. Use a autenticação biométrica para desbloquear sua carteira e aprovar transações rapidamente e com segurança.", @@ -1067,6 +1068,20 @@ "disableAlertMessage": "Tem certeza que deseja desabilitar Iris?" } }, + "autoLockTimerScreen": { + "title": "Temporizador de Bloqueio Automático", + "options": { + "immediately": "Imediatamente", + "oneMinute": "1 minuto", + "fifteenMinutes": "15 minutos", + "thirtyMinutes": "30 minutos", + "oneHour": "1 hora", + "twelveHours": "12 horas", + "twentyFourHours": "24 horas", + "none": "Nenhum" + }, + "footer": "Após um período definido, sua senha será solicitada novamente como medida extra de segurança." + }, "showRecoveryPhraseScreen": { "title": "Mostrar Frase de Recuperação", "keepSafe": "Mantenha sua frase de recuperação em um local seguro.", diff --git a/src/navigators/RootNavigator.tsx b/src/navigators/RootNavigator.tsx index 732a8e812..0d8ff6af8 100644 --- a/src/navigators/RootNavigator.tsx +++ b/src/navigators/RootNavigator.tsx @@ -41,7 +41,6 @@ import { } from "helpers/navigationOptions"; import { triggerFaceIdOnboarding } from "helpers/postOnboardingBiometrics"; import { useAnalyticsPermissions } from "hooks/useAnalyticsPermissions"; -import { useAppOpenBiometricsLogin } from "hooks/useAppOpenBiometricsLogin"; import useAppTranslation from "hooks/useAppTranslation"; import { useAppUpdate } from "hooks/useAppUpdate"; import { useBiometrics } from "hooks/useBiometrics"; @@ -75,7 +74,7 @@ export const RootNavigator = () => { useNavigation< NativeStackNavigationProp >(); - const { authStatus, getAuthStatus, initializeNetwork } = + const { authStatus, isSoftLocked, getAuthStatus, initializeNetwork } = useAuthenticationStore(); const remoteConfigInitialized = useRemoteConfigStore( (state) => state.isInitialized, @@ -94,8 +93,6 @@ export const RootNavigator = () => { previousState: initializing ? undefined : "none", }); - useAppOpenBiometricsLogin(initializing); - // Run once on mount: check jailbreak, fetch auth status from storage, trigger // face-id onboarding if needed. We intentionally omit deps so this only fires // once — subsequent auth status changes are handled by the RootStack's @@ -152,9 +149,14 @@ export const RootNavigator = () => { } }, [showFullScreenUpdateNotice]); + // Soft lock keeps the authenticated tree mounted (covered by the lock + // overlay) so navigation history and in-progress inputs survive the lock. + const showAuthenticatedStack = + authStatus === AUTH_STATUS.AUTHENTICATED || isSoftLocked; + // Make the stack re-render when auth status changes const initialRouteName = useMemo(() => { - if (authStatus === AUTH_STATUS.AUTHENTICATED) { + if (showAuthenticatedStack) { return ROOT_NAVIGATOR_ROUTES.MAIN_TAB_STACK; } @@ -166,7 +168,7 @@ export const RootNavigator = () => { } return ROOT_NAVIGATOR_ROUTES.AUTH_STACK; - }, [authStatus]); + }, [authStatus, showAuthenticatedStack]); if (isJailbroken) { return ; @@ -200,7 +202,7 @@ export const RootNavigator = () => { headerShown: false, }} > - {authStatus === AUTH_STATUS.AUTHENTICATED ? ( + {showAuthenticatedStack ? ( { headerTitle: biometryTitle[biometryType!], }} /> + ); }; diff --git a/src/providers/ToastProvider.tsx b/src/providers/ToastProvider.tsx index c1719fa38..1e037e55a 100644 --- a/src/providers/ToastProvider.tsx +++ b/src/providers/ToastProvider.tsx @@ -1,5 +1,6 @@ import { Toast, ToastProps, ToastVariant } from "components/sds/Toast"; -import { DEFAULT_PADDING } from "config/constants"; +import { DEFAULT_PADDING, SOFT_LOCK_ALLOWED_TOAST_IDS } from "config/constants"; +import { useAuthenticationStore } from "ducks/auth"; import { px, pxValue } from "helpers/dimensions"; import React, { createContext, @@ -103,6 +104,24 @@ export const ToastProvider: React.FC = ({ children }) => { const insets = useSafeAreaInsets(); const showToast = useCallback((options: ToastOptions) => { + // While soft-locked, the navigation tree stays mounted under the lock + // overlay and may still emit toasts (stale network errors, re-run + // validations). Suppress everything except the lock overlay's own + // messaging so nothing surfaces over the lock until the app is usable. + // Read imperatively (not via a reactive selector) to avoid coupling this + // low-level provider's render cycle to the auth store; the optional chain + // keeps it safe under test mocks that stub the store without getState. + const isSoftLocked = + useAuthenticationStore.getState?.()?.isSoftLocked ?? false; + if ( + isSoftLocked && + !( + options.toastId && SOFT_LOCK_ALLOWED_TOAST_IDS.includes(options.toastId) + ) + ) { + return; + } + const newToast: ToastPropsWithId = { ...options, toastId: options.toastId ?? Date.now().toString(), diff --git a/src/services/autoLock.ts b/src/services/autoLock.ts new file mode 100644 index 000000000..19495256e --- /dev/null +++ b/src/services/autoLock.ts @@ -0,0 +1,131 @@ +import { + AUTO_LOCK_TIMER, + DEFAULT_AUTO_LOCK_TIMER, + HASH_KEY_EXPIRATION_MS, + NEVER_EXPIRE_HASH_KEY_MS, + SENSITIVE_STORAGE_KEYS, +} from "config/constants"; +import { getHashKey } from "services/storage/helpers"; +import { secureDataStorage } from "services/storage/storageFactory"; + +/** + * Reads the persisted auto-lock timer preference. + * + * This is a secure-storage mirror of the zustand preferences store so that + * non-React code (e.g. getAuthStatus on cold start) can read the value + * without depending on zustand-persist rehydration timing — and so the + * lock policy can't be weakened by editing unencrypted AsyncStorage. + */ +const getAutoLockTimer = async (): Promise => { + const storedTimer = await secureDataStorage.getItem( + SENSITIVE_STORAGE_KEYS.AUTO_LOCK_TIMER_SETTING, + ); + + if ( + storedTimer && + Object.values(AUTO_LOCK_TIMER).includes(storedTimer as AUTO_LOCK_TIMER) + ) { + return storedTimer as AUTO_LOCK_TIMER; + } + + return DEFAULT_AUTO_LOCK_TIMER; +}; + +/** + * Persists the auto-lock timer preference to the secure-storage mirror + */ +const persistAutoLockTimer = async (timer: AUTO_LOCK_TIMER): Promise => { + await secureDataStorage.setItem( + SENSITIVE_STORAGE_KEYS.AUTO_LOCK_TIMER_SETTING, + timer, + ); +}; + +/** + * Returns the hash key TTL for the given auto-lock timer. With NONE the user + * explicitly opted out of auto-lock, so the 24h hard-expiry backstop is + * replaced by an effectively-never expiration. + */ +const getHashKeyExpirationMs = (timer: AUTO_LOCK_TIMER): number => + timer === AUTO_LOCK_TIMER.NONE + ? NEVER_EXPIRE_HASH_KEY_MS + : HASH_KEY_EXPIRATION_MS; + +/** + * Re-anchors the stored hash key expiration based on the given auto-lock + * timer. Called when the user changes the timer so switching to/from NONE + * takes effect immediately rather than on the next unlock. Only reachable + * from an unlocked session (the settings screen), so this is a + * credential-verified moment. + */ +const applyAutoLockTimerToHashKey = async ( + timer: AUTO_LOCK_TIMER, +): Promise => { + const hashKey = await getHashKey(); + + if (!hashKey) { + return; + } + + await secureDataStorage.setItem( + SENSITIVE_STORAGE_KEYS.HASH_KEY, + JSON.stringify({ + ...hashKey, + expiresAt: Date.now() + getHashKeyExpirationMs(timer), + }), + ); +}; + +/** + * Records the moment the app went to the background so the auto-lock timer + * can be evaluated on the next foreground or cold start + */ +const recordBackgroundedAt = async (): Promise => { + await secureDataStorage.setItem( + SENSITIVE_STORAGE_KEYS.AUTO_LOCK_BACKGROUNDED_AT, + String(Date.now()), + ); +}; + +/** + * Clears the persisted backgrounded-at timestamp + */ +const clearBackgroundedAt = async (): Promise => { + await secureDataStorage.remove( + SENSITIVE_STORAGE_KEYS.AUTO_LOCK_BACKGROUNDED_AT, + ); +}; + +/** + * Returns the persisted backgrounded-at timestamp, or null when the app + * hasn't gone to the background since the last evaluation. Corrupt + * (non-numeric) and future-dated values are cleaned up and treated as + * absent — a future timestamp would otherwise stall the timer. + */ +const getBackgroundedAt = async (): Promise => { + const backgroundedAt = await secureDataStorage.getItem( + SENSITIVE_STORAGE_KEYS.AUTO_LOCK_BACKGROUNDED_AT, + ); + + if (!backgroundedAt) { + return null; + } + + const parsedBackgroundedAt = Number(backgroundedAt); + if (Number.isNaN(parsedBackgroundedAt) || parsedBackgroundedAt > Date.now()) { + await clearBackgroundedAt(); + return null; + } + + return parsedBackgroundedAt; +}; + +export { + getAutoLockTimer, + persistAutoLockTimer, + getHashKeyExpirationMs, + applyAutoLockTimerToHashKey, + recordBackgroundedAt, + getBackgroundedAt, + clearBackgroundedAt, +}; From 1b7d43caeefcf2b983efeec6aee9c62e01ae23a3 Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Tue, 16 Jun 2026 11:58:57 -0300 Subject: [PATCH 02/19] update state handling when coming from background and comments --- __tests__/ducks/auth.test.ts | 31 +++++ src/components/App.tsx | 7 +- src/components/LockScreenOverlay.tsx | 24 ++-- src/components/Modal.tsx | 89 ++++++++------ .../screens/TransactionAmountScreen.tsx | 10 +- .../SwapScreen/screens/SwapAmountScreen.tsx | 13 +- src/ducks/auth.ts | 115 +++++++----------- 7 files changed, 163 insertions(+), 126 deletions(-) diff --git a/__tests__/ducks/auth.test.ts b/__tests__/ducks/auth.test.ts index 435edd833..da4894100 100644 --- a/__tests__/ducks/auth.test.ts +++ b/__tests__/ducks/auth.test.ts @@ -1744,6 +1744,37 @@ describe("auth duck", () => { previousAppState; }); + it("should return HASH_KEY_EXPIRED (not LOCKED) when backgrounded beyond BOTH the timer and the hash-key TTL", async () => { + // The reviewer's scenario: the timer branch would otherwise return a + // fast-path LOCKED that refreshes the expired key, silently defeating + // the 24h hard-expiry backstop. Expiry must win → full re-auth. + const { result } = renderHook(() => useAuthenticationStore()); + restoreGetAuthStatus(); + + mockAuthenticatedStorage({ + backgroundedAt: Date.now() - 2 * ONE_HOUR_MS, // beyond the 1h timer + autoLockTimer: AUTO_LOCK_TIMER.ONE_HOUR, + }); + + // ...and the hash key has hard-expired (beyond its TTL) + (getHashKey as jest.Mock).mockResolvedValue({ + hashKey: "mock-hash-key", + salt: "mock-salt", + expiresAt: Date.now() - ONE_HOUR_MS, + }); + + await act(async () => { + const status = await result.current.getAuthStatus(); + expect(status).toBe(AUTH_STATUS.HASH_KEY_EXPIRED); + }); + + // Must NOT have converted the session to a soft timer lock + expect(secureDataStorage.setItem).not.toHaveBeenCalledWith( + SENSITIVE_STORAGE_KEYS.AUTH_STATUS, + AUTH_STATUS.LOCKED, + ); + }); + it("should never soft-lock when the timer is NONE", async () => { const { result } = renderHook(() => useAuthenticationStore()); restoreGetAuthStatus(); diff --git a/src/components/App.tsx b/src/components/App.tsx index 10881bf81..2ad74abf2 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -86,11 +86,8 @@ export const App = (): React.JSX.Element => { {/* Soft-lock overlay: rendered after the bottom sheet provider so - it covers open sheets too, keeping the navigation tree (and any - in-progress inputs) mounted underneath for after the unlock. - App-switcher snapshot privacy is handled natively (iOS - AppDelegate overlay, Android FLAG_SECURE) — a JS curtain can't - beat the OS snapshot. */} + it covers open sheets, keeping the navigation tree mounted + underneath for after the unlock. */} diff --git a/src/components/LockScreenOverlay.tsx b/src/components/LockScreenOverlay.tsx index bf14c0863..c742bab8a 100644 --- a/src/components/LockScreenOverlay.tsx +++ b/src/components/LockScreenOverlay.tsx @@ -12,25 +12,19 @@ const styles = StyleSheet.create({ }); /** - * Full-screen overlay shown when the wallet is soft-locked in-process - * (auto-lock timer or the IMMEDIATELY option firing on backgrounding). - * - * Rendered as a sibling AFTER the navigation container and the bottom sheet - * provider so it covers them — while the mounted screens underneath keep - * their navigation history, params and in-progress inputs for after the - * unlock. The Android hardware back button is swallowed while locked, and - * accessibility focus is confined to the overlay. Cold starts use the - * regular LockScreen route instead (process state is gone anyway). - * Unlocking flips isSoftLocked which unmounts the overlay, resuming the - * user exactly where they were. + * Full-screen overlay for the in-process soft lock (auto-lock timer or the + * IMMEDIATELY option). Rendered after the navigation + bottom-sheet providers + * so it covers them while the screens underneath keep their state for after + * the unlock; unlocking clears isSoftLocked and unmounts it. Swallows the + * Android back button and hides the tree from accessibility. Cold starts use + * the LockScreen route instead. */ export const LockScreenOverlay: React.FC = () => { - // Narrow selector: this component sits at the app root and must not - // re-render on unrelated auth-store churn (e.g. periodic status checks) + // Narrow selector: avoid re-rendering on unrelated auth-store churn const isSoftLocked = useAuthenticationStore((state) => state.isSoftLocked); - // Swallow the Android hardware back button while locked: back presses must - // not pop screens in the hidden tree underneath or exit the app + // Swallow the Android back button while locked so it can't pop the hidden + // tree or exit the app useEffect(() => { if (!isSoftLocked) { return undefined; diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index 13917eb53..dc79d120b 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useEffect } from "react"; import { type StyleProp, View, @@ -6,6 +6,7 @@ import { Modal as RNModal, TouchableWithoutFeedback, KeyboardAvoidingView, + AppState, } from "react-native"; interface ModalProps { @@ -28,41 +29,61 @@ const Modal: React.FC = ({ contentClassName, contentStyle, testID, -}) => ( - { - onClose(); - }} - > - - { - if (closeOnOverlayPress) { - onClose(); - } - }} - > - - +}) => { + // Dismiss on background: a native RN Modal renders above the in-tree lock + // overlay, so an open modal would otherwise stay on top of the lock screen. + // (Not "inactive" — avoids closing on control-center / app-switcher peeks.) + useEffect(() => { + if (!visible) { + return undefined; + } - - { + if (nextAppState === "background") { + onClose(); + } + }); + + return () => subscription.remove(); + }, [visible, onClose]); + + return ( + { + onClose(); + }} + > + + { + if (closeOnOverlayPress) { + onClose(); + } + }} > - {children} + + + + + + {children} + - - - -); + + + ); +}; export default Modal; diff --git a/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx b/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx index d951ad945..6eec4b033 100644 --- a/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx +++ b/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx @@ -336,7 +336,7 @@ const TransactionAmountScreen: React.FC = ({ }); }; - const { balanceItems } = useBalancesList({ + const { balanceItems, isLoading: isLoadingBalances } = useBalancesList({ publicKey: publicKey ?? "", network, }); @@ -582,6 +582,13 @@ const TransactionAmountScreen: React.FC = ({ }; useEffect(() => { + // Skip while balances are loading/empty (e.g. refetching after the app + // returns from background) so a transient empty balance can't flash a + // false "amount too high" / "insufficient XLM" error until data arrives. + if (isLoadingBalances || balanceItems.length === 0) { + return; + } + const currentTokenAmount = BigNumber(tokenAmount); if (!hasXLMForFees(balanceItems, transactionFee)) { @@ -634,6 +641,7 @@ const TransactionAmountScreen: React.FC = ({ tokenAmount, spendableBalance, balanceItems, + isLoadingBalances, transactionFee, transactionHash, isCustomToken, diff --git a/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx b/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx index 3ec6c2b45..51287a2b5 100644 --- a/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx +++ b/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx @@ -100,7 +100,11 @@ const SwapAmountScreen: React.FC = ({ const deviceSize = useDeviceSize(); const isSmallScreen = deviceSize === DeviceSize.XS; - const { balanceItems, scanResults } = useBalancesList({ + const { + balanceItems, + scanResults, + isLoading: isLoadingBalances, + } = useBalancesList({ publicKey: account?.publicKey ?? "", network, }); @@ -146,6 +150,12 @@ const SwapAmountScreen: React.FC = ({ }, [sourceBalance, account, swapFee]); useEffect(() => { + // Skip while balances are loading (e.g. refetching after the app returns + // from background) so a transient balance can't flash a false error. + if (isLoadingBalances) { + return; + } + if (!sourceBalance || !sourceAmount || sourceAmount === "0") { setAmountError(null); return; @@ -200,6 +210,7 @@ const SwapAmountScreen: React.FC = ({ transactionHash, sourceBalance, balanceItems, + isLoadingBalances, ]); useSwapPathFinding({ diff --git a/src/ducks/auth.ts b/src/ducks/auth.ts index 1705ee661..7d50fdc1f 100644 --- a/src/ducks/auth.ts +++ b/src/ducks/auth.ts @@ -499,13 +499,18 @@ const getAuthStatus = async (): Promise => { return AUTH_STATUS.HASH_KEY_EXPIRED; } - // Evaluate the user-configurable auto-lock timer: a timestamp is recorded - // when the app goes to the background (useAuthCheck); returning after the - // selected duration soft-locks the wallet (LOCKED, fast unlock path). - // Covers both warm foreground returns and cold starts. - // NOTE: elapsed time uses the wall clock (Date.now()); rolling the device - // clock back can dodge the timer. Accepted limitation — the hash key - // expiry below still bounds the session. + // Hard expiry wins over the soft timer lock: checked before the timer so a + // session backgrounded past the hash-key TTL forces a full re-auth instead + // of a fast-path LOCKED that would just refresh the expired key. (The + // persisted-LOCKED branch above keeps the fast path — there it's intended.) + if (hashKey && isHashKeyExpired(hashKey)) { + return AUTH_STATUS.HASH_KEY_EXPIRED; + } + + // Auto-lock timer: useAuthCheck records a timestamp on background; on + // return (warm or cold start), elapsed >= the selected duration soft-locks + // via the fast unlock path. Uses wall-clock time, so a clock rollback can + // dodge it — the hash-key expiry above still bounds the session. const backgroundedAt = await getBackgroundedAt(); if (backgroundedAt) { const autoLockTimer = await getAutoLockTimer(); @@ -526,24 +531,17 @@ const getAuthStatus = async (): Promise => { } if (AppState.currentState === "active") { - // The user returned within the timer: consume the timestamp so the - // foreground check interval can't lock mid-use. The hash key expiry - // is deliberately NOT refreshed here — the TTL is only ever anchored - // at credential-verified moments (signIn / generateHashKey / - // applyAutoLockTimerToHashKey) so key material has a bounded - // lifetime no matter how often the app is reopened. "None" keeps its - // never-expire TTL because it was set at one of those moments. + // Returned within the timer: consume the timestamp so the foreground + // interval can't lock mid-use. The hash-key TTL is deliberately NOT + // refreshed here — it's only anchored at credential-verified moments + // (signIn / generateHashKey / applyAutoLockTimerToHashKey) so key + // material stays bounded however often the app is reopened. await clearBackgroundedAt(); } // Still backgrounded (periodic background check): leave the timestamp // intact so the timer keeps counting from the original moment. } - // Check if hash key is expired (only relevant when not LOCKED) - if (hashKey && isHashKeyExpired(hashKey)) { - return AUTH_STATUS.HASH_KEY_EXPIRED; - } - // All conditions for authentication are met return AUTH_STATUS.AUTHENTICATED; } catch (error) { @@ -2125,9 +2123,8 @@ export const useAuthenticationStore = create()((set, get) => ({ await clearNonSensitiveData(); await dataStorage.remove(STORAGE_KEYS.COLLECTIBLES_LIST); - // Reset the auto-lock timer so a future wallet on this device - // doesn't inherit the previous user's preference, and drop any - // pending backgrounded-at timestamp + // Reset auto-lock so the next wallet on this device doesn't + // inherit the previous user's timer usePreferencesStore .getState() .setAutoLockTimer(DEFAULT_AUTO_LOCK_TIMER); @@ -2155,36 +2152,26 @@ export const useAuthenticationStore = create()((set, get) => ({ /** * Soft-locks the wallet in-process (auto-lock timer / IMMEDIATELY option). * - * Unlike logout(), this does NOT reset the navigation tree: the mounted - * screens (navigation history, route params and in-progress inputs) are - * preserved underneath a full-screen lock overlay, so the user resumes - * exactly where they were after unlocking. Protection while locked comes - * from the lock overlay (covers the UI, swallows back/touch, hides the - * tree from accessibility) plus the auth gates on sensitive reads - * (getActiveAccount blocks LOCKED; WalletKit rejects requests while - * LOCKED). The active account is intentionally LEFT in the store: the - * mounted screens keep reading it, so nulling it would make them re-run - * validations and surface errors under the overlay. It carries no extra - * exposure beyond the derived-key cache, which is deliberately retained - * for the fast unlock path (see PR #664). The persisted LOCKED status - * covers cold starts (where the overlay is replaced by the regular lock - * screen route since process state is gone anyway). + * Unlike logout(), keeps the navigation tree mounted under the lock overlay + * so the user resumes where they were after unlocking. Sensitive reads stay + * gated while LOCKED (getActiveAccount, WalletKit). The active account is + * left in the store so the mounted screens don't re-run validations and flash + * errors; signing is independently blocked while LOCKED. Persisting LOCKED + * covers cold starts (which fall back to the LockScreen route). */ softLock: async () => { Keyboard.dismiss(); - // Single atomic update: RootNavigator must never observe - // LOCKED && !isSoftLocked, which would unmount the preserved tree + // Atomic update: RootNavigator must never see LOCKED && !isSoftLocked, + // which would unmount the preserved tree set({ authStatus: AUTH_STATUS.LOCKED, isSoftLocked: true, isLoading: false, }); - // Persist LOCKED so tampering and cold starts are covered (same write - // as logout's soft path). Awaited: if the process is killed right after - // backgrounding (the common IMMEDIATELY case) the lock must already be - // on disk. + // Persist LOCKED (covers tampering + cold starts). Awaited so an + // immediate post-background process kill still has the lock on disk. try { await secureDataStorage.setItem( SENSITIVE_STORAGE_KEYS.AUTH_STATUS, @@ -2246,8 +2233,8 @@ export const useAuthenticationStore = create()((set, get) => ({ analytics.trackReAuthSuccess(); set({ ...initialState, - // Preserve the navigation ref: nothing remounts to re-set it after a - // soft unlock, and later lock navigations would silently no-op + // Preserve navigationRef: nothing remounts to re-set it after a soft + // unlock, so later lock navigations would otherwise no-op navigationRef: get().navigationRef, authStatus: AUTH_STATUS.AUTHENTICATED, isLoading: false, @@ -2666,19 +2653,15 @@ export const useAuthenticationStore = create()((set, get) => ({ }, /** - * Gets the current authentication status - * - * Single funnel for lock transitions: when a running (AUTHENTICATED) - * session is found LOCKED — i.e. the auto-lock timer fired — every caller - * (periodic checks, fetchActiveAccount, selectAccount, ...) produces the - * same atomic soft lock, preserving the mounted navigation tree under the - * lock overlay instead of racing it with navigation resets. + * Gets the current authentication status. * - * @returns {Promise} The current authentication status + * Single funnel for lock transitions: an AUTHENTICATED→LOCKED transition + * (auto-lock timer) goes through softLock for every caller (periodic checks, + * fetchActiveAccount, selectAccount), keeping the navigation tree mounted + * instead of racing it with navigation resets. */ getAuthStatus: async () => { - // Always re-validate auth status to ensure consistency - // Don't rely on cached status as it may be stale after app updates + // Re-validate from storage; cached status may be stale after app updates const previousAuthStatus = get().authStatus; const authStatus = await getAuthStatus(); @@ -2686,17 +2669,14 @@ export const useAuthenticationStore = create()((set, get) => ({ authStatus === AUTH_STATUS.LOCKED && previousAuthStatus === AUTH_STATUS.AUTHENTICATED ) { - // In-process lock (auto-lock timer): soft lock sets authStatus and - // isSoftLocked in one atomic update so the tree never unmounts + // Auto-lock timer fired: soft lock atomically so the tree never unmounts await get().softLock(); return authStatus; } - // Never let a stale read downgrade an active soft lock back to - // AUTHENTICATED: a concurrent check that resolved just before the LOCKED - // persist landed could otherwise clobber the lock (authStatus would no - // longer reflect it for guards that read it). Only a real signIn clears - // isSoftLocked. Disk already holds LOCKED, so the next check self-heals. + // Don't let a stale read (resolved just before the LOCKED persist landed) + // downgrade an active soft lock to AUTHENTICATED. Only signIn clears + // isSoftLocked; disk already holds LOCKED, so the next check self-heals. if (get().isSoftLocked && authStatus === AUTH_STATUS.AUTHENTICATED) { return get().authStatus; } @@ -2760,14 +2740,11 @@ export const useAuthenticationStore = create()((set, get) => ({ set({ isLoadingAccount: true, accountError: null }); try { - // Check auth status through the store funnel so an auto-lock detected - // here produces the same atomic soft lock as the periodic checks - // (instead of racing them with a navigation reset) + // Via the store funnel so an auto-lock here soft-locks atomically + // rather than racing a navigation reset const authStatus = await get().getAuthStatus(); - // Security: Block access when hash key is expired or when locked - // LOCKED state requires password re-entry before accessing sensitive - // data. The funnel has already handled the lock transition/navigation. + // Block sensitive data while expired/locked (funnel already navigated) if ( authStatus === AUTH_STATUS.HASH_KEY_EXPIRED || authStatus === AUTH_STATUS.LOCKED @@ -2880,9 +2857,7 @@ export const useAuthenticationStore = create()((set, get) => ({ set({ isSwitchingAccount: true, error: null }); try { - // Security: Check auth status before allowing account switching. - // Goes through the store funnel so an auto-lock detected here produces - // the same atomic soft lock as the periodic checks. + // Via the store funnel so an auto-lock here soft-locks atomically const authStatus = await get().getAuthStatus(); if ( authStatus === AUTH_STATUS.HASH_KEY_EXPIRED || From 996b9a9c67390918805bb1adc10174a47b061390 Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Tue, 16 Jun 2026 12:16:31 -0300 Subject: [PATCH 03/19] add secure flags for app background state to avoid snapshotting --- .../java/com/freightermobile/MainActivity.kt | 7 +++++++ ios/freighter-mobile/AppDelegate.swift | 21 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/android/app/src/main/java/com/freightermobile/MainActivity.kt b/android/app/src/main/java/com/freightermobile/MainActivity.kt index 9f660439e..72aa13dba 100644 --- a/android/app/src/main/java/com/freightermobile/MainActivity.kt +++ b/android/app/src/main/java/com/freightermobile/MainActivity.kt @@ -2,6 +2,7 @@ package org.stellar.freighterwallet // this import is needed for the onCreate override function import android.os.Bundle; +import android.view.WindowManager import com.facebook.react.ReactActivity import com.facebook.react.ReactActivityDelegate @@ -32,6 +33,12 @@ class MainActivity : ReactActivity() { */ override fun onCreate(savedInstanceState: Bundle?) { RNBootSplash.init(this, R.style.BootTheme) + // Privacy shield: blank wallet content in the recents/app-switcher + // thumbnail (also blocks screenshots) when backgrounded + window.setFlags( + WindowManager.LayoutParams.FLAG_SECURE, + WindowManager.LayoutParams.FLAG_SECURE, + ) super.onCreate(null) } } \ No newline at end of file diff --git a/ios/freighter-mobile/AppDelegate.swift b/ios/freighter-mobile/AppDelegate.swift index 4462086bf..a440b0132 100644 --- a/ios/freighter-mobile/AppDelegate.swift +++ b/ios/freighter-mobile/AppDelegate.swift @@ -36,6 +36,27 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool { return RCTLinkingManager.application(app, open: url, options: options) } + + // Privacy shield: cover wallet content with the BootSplash when the app is + // backgrounded, so the OS app-switcher snapshot never reveals it. Uses + // didEnterBackground (not willResignActive) so it does NOT trigger for the + // Face ID prompt / control center, which only make the app inactive — the + // snapshot is still taken after didEnterBackground, so it's in time. + private var privacyWindow: UIWindow? + + func applicationDidEnterBackground(_ application: UIApplication) { + let overlay = UIWindow(frame: UIScreen.main.bounds) + overlay.windowLevel = .alert + 1 + overlay.rootViewController = UIStoryboard(name: "BootSplash", bundle: nil) + .instantiateInitialViewController() + overlay.isHidden = false + privacyWindow = overlay + } + + func applicationDidBecomeActive(_ application: UIApplication) { + privacyWindow?.isHidden = true + privacyWindow = nil + } } class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate { From 820f7dbbe346be670dece66ec79e5772ef0b8201 Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Tue, 16 Jun 2026 12:22:11 -0300 Subject: [PATCH 04/19] fix loading account states when coming from background --- .../screens/TransactionAmountScreen.tsx | 15 +++++++++++---- .../SwapScreen/screens/SwapAmountScreen.tsx | 8 ++++---- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx b/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx index 6eec4b033..41c0d6fbe 100644 --- a/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx +++ b/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx @@ -582,10 +582,16 @@ const TransactionAmountScreen: React.FC = ({ }; useEffect(() => { - // Skip while balances are loading/empty (e.g. refetching after the app - // returns from background) so a transient empty balance can't flash a - // false "amount too high" / "insufficient XLM" error until data arrives. - if (isLoadingBalances || balanceItems.length === 0) { + // Skip until balances AND the active account are loaded. After the app + // returns from background, balances refetch and account reloads (briefly + // null during signIn); spendableBalance is 0 without them, which would + // flash a false "amount too high" / "insufficient XLM" error + toast. + if ( + isLoadingBalances || + balanceItems.length === 0 || + !account || + !selectedBalance + ) { return; } @@ -642,6 +648,7 @@ const TransactionAmountScreen: React.FC = ({ spendableBalance, balanceItems, isLoadingBalances, + account, transactionFee, transactionHash, isCustomToken, diff --git a/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx b/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx index 51287a2b5..97977d3a9 100644 --- a/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx +++ b/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx @@ -150,9 +150,9 @@ const SwapAmountScreen: React.FC = ({ }, [sourceBalance, account, swapFee]); useEffect(() => { - // Skip while balances are loading (e.g. refetching after the app returns - // from background) so a transient balance can't flash a false error. - if (isLoadingBalances) { + // Skip while balances/account are loading (e.g. after the app returns from + // background) so a transient balance can't flash a false error. + if (isLoadingBalances || !account) { return; } @@ -205,7 +205,7 @@ const SwapAmountScreen: React.FC = ({ spendableAmount, sourceTokenSymbol, t, - account?.subentryCount, + account, swapFee, transactionHash, sourceBalance, From 14fcdc9f424ea6f8d54d25555842aab23ae82faf Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Tue, 16 Jun 2026 12:56:03 -0300 Subject: [PATCH 05/19] add auto lock timer in seconds debug option and verification for locked state mid-transaction --- jest.setup.js | 2 + .../screens/TransactionAmountScreen.tsx | 9 +- .../AutoLockTimerScreen.tsx | 92 +++++++- .../SwapScreen/hooks/useSwapTransaction.ts | 6 + src/ducks/auth.ts | 7 +- src/hooks/useGetActiveAccount.ts | 2 +- src/hooks/useManageTokens.ts | 15 ++ src/navigators/RootNavigator.tsx | 221 ++++++++++-------- src/services/autoLock.ts | 49 ++++ 9 files changed, 297 insertions(+), 106 deletions(-) diff --git a/jest.setup.js b/jest.setup.js index 37d6eee55..d9aef0d62 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -492,6 +492,8 @@ jest.mock("hooks/useGetActiveAccount", () => ({ refreshAccount: jest.fn(), signTransaction: jest.fn(), })), + // Named guard used at direct signing sites; default to "unlocked" in tests + isWalletUnlocked: jest.fn(() => true), })); jest.mock("hooks/useBalancesList", () => ({ diff --git a/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx b/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx index 41c0d6fbe..7addfe4df 100644 --- a/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx +++ b/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx @@ -66,7 +66,9 @@ import { useBlockaidTransaction } from "hooks/blockaid/useBlockaidTransaction"; import useAppTranslation from "hooks/useAppTranslation"; import { useBalancesList } from "hooks/useBalancesList"; import useColors from "hooks/useColors"; -import useGetActiveAccount from "hooks/useGetActiveAccount"; +import useGetActiveAccount, { + isWalletUnlocked, +} from "hooks/useGetActiveAccount"; import { useInitialRecommendedFee } from "hooks/useInitialRecommendedFee"; import { useNetworkFees } from "hooks/useNetworkFees"; import { useRightHeaderButton } from "hooks/useRightHeader"; @@ -823,6 +825,11 @@ const TransactionAmountScreen: React.FC = ({ throw new Error("Missing account or balance information"); } + // Block signing if an auto-lock engaged while on the review sheet + if (!isWalletUnlocked()) { + throw new Error("Wallet is locked"); + } + const { privateKey } = account; signTransaction({ diff --git a/src/components/screens/SettingsScreen/SecurityScreen/AutoLockTimerScreen/AutoLockTimerScreen.tsx b/src/components/screens/SettingsScreen/SecurityScreen/AutoLockTimerScreen/AutoLockTimerScreen.tsx index aae6f7a29..c35df7dd4 100644 --- a/src/components/screens/SettingsScreen/SecurityScreen/AutoLockTimerScreen/AutoLockTimerScreen.tsx +++ b/src/components/screens/SettingsScreen/SecurityScreen/AutoLockTimerScreen/AutoLockTimerScreen.tsx @@ -2,14 +2,21 @@ import { NativeStackScreenProps } from "@react-navigation/native-stack"; import { List } from "components/List"; import { BaseLayout } from "components/layout/BaseLayout"; import Icon from "components/sds/Icon"; +import { Input } from "components/sds/Input"; import { Text } from "components/sds/Typography"; import { AUTO_LOCK_TIMER } from "config/constants"; +import { logger } from "config/logger"; import { SETTINGS_ROUTES, SettingsStackParamList } from "config/routes"; import { usePreferencesStore } from "ducks/preferences"; import useAppTranslation from "hooks/useAppTranslation"; import useColors from "hooks/useColors"; -import React from "react"; +import { useToast } from "providers/ToastProvider"; +import React, { useState } from "react"; import { View } from "react-native"; +import { + setDevAutoLockTimerSeconds, + setDevHashKeyTtlSeconds, +} from "services/autoLock"; interface AutoLockTimerScreenProps extends NativeStackScreenProps< @@ -17,11 +24,60 @@ interface AutoLockTimerScreenProps typeof SETTINGS_ROUTES.AUTO_LOCK_TIMER_SCREEN > {} +// TODO/FIXME: dev-only testing labels — remove with the block below +const DEV_BANNER = "⚠️ DEV ONLY — remove before production"; +const DEV_TIMER_LABEL = "Auto-lock timer (seconds)"; +const DEV_TTL_LABEL = "Hash key TTL (seconds)"; +const DEV_APPLY = "Apply"; +const DEV_TIMER_PLACEHOLDER = "e.g. 10"; +const DEV_TTL_PLACEHOLDER = "e.g. 30"; + const AutoLockTimerScreen: React.FC = () => { const { t } = useAppTranslation(); const { themeColors } = useColors(); + const { showToast } = useToast(); const { autoLockTimer, setAutoLockTimer } = usePreferencesStore(); + // TODO/FIXME: dev-only state for the testing controls — remove before prod + const [devTimerSeconds, setDevTimerSecondsInput] = useState(""); + const [devTtlSeconds, setDevTtlSecondsInput] = useState(""); + + // TODO/FIXME: dev-only handlers — remove before prod + const applyDevTimer = () => { + const seconds = Number(devTimerSeconds); + if (!Number.isFinite(seconds) || seconds < 0) { + return; + } + setDevAutoLockTimerSeconds(seconds) + .then(() => + showToast({ + variant: "success", + title: `Auto-lock timer set to ${seconds}s`, + toastId: "dev-auto-lock-timer", + }), + ) + .catch((error) => + logger.error("AutoLockTimerScreen", "Failed to set dev timer", error), + ); + }; + const applyDevTtl = () => { + const seconds = Number(devTtlSeconds); + if (!Number.isFinite(seconds) || seconds < 0) { + return; + } + setDevHashKeyTtlSeconds(seconds) + .then(() => + showToast({ + variant: "success", + title: `Hash key TTL set to ${seconds}s`, + toastId: "dev-hash-key-ttl", + }), + ) + .catch((error) => + logger.error("AutoLockTimerScreen", "Failed to set dev TTL", error), + ); + }; + const timerLabels: Record = { [AUTO_LOCK_TIMER.IMMEDIATELY]: t("autoLockTimerScreen.options.immediately"), [AUTO_LOCK_TIMER.ONE_MINUTE]: t("autoLockTimerScreen.options.oneMinute"), @@ -60,6 +116,40 @@ const AutoLockTimerScreen: React.FC = () => { {t("autoLockTimerScreen.footer")} + + {/* + ==================================================================== + TODO / FIXME: TEMPORARY DEV-ONLY testing controls. + !!! REMOVE THIS ENTIRE BLOCK BEFORE MERGING TO PRODUCTION !!! + (also remove the dev helpers in services/autoLock.ts and the + getDevAutoLockTimerMs override in ducks/auth.ts) + Lets QA exercise the lock flows in seconds instead of minutes/hours. + ==================================================================== + */} + + + {DEV_BANNER} + + + + + {/* ================= END TEMPORARY DEV-ONLY BLOCK ================= */} ); diff --git a/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts b/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts index 74a824708..16c1f0891 100644 --- a/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts +++ b/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts @@ -17,6 +17,7 @@ import { useSwapSettingsStore } from "ducks/swapSettings"; import { useTransactionBuilderStore } from "ducks/transactionBuilder"; import { useBlockaidTransaction } from "hooks/blockaid/useBlockaidTransaction"; import useAppTranslation from "hooks/useAppTranslation"; +import { isWalletUnlocked } from "hooks/useGetActiveAccount"; import { useToast } from "providers/ToastProvider"; import { useState, useCallback } from "react"; import { analytics } from "services/analytics"; @@ -126,6 +127,11 @@ export const useSwapTransaction = ({ throw new Error("Destination token is required for swap transaction"); } + // Block signing if an auto-lock engaged after the swap was prepared + if (!isWalletUnlocked()) { + throw new Error("Wallet is locked"); + } + setIsProcessing(true); try { diff --git a/src/ducks/auth.ts b/src/ducks/auth.ts index 7d50fdc1f..c58e6eb42 100644 --- a/src/ducks/auth.ts +++ b/src/ducks/auth.ts @@ -58,6 +58,8 @@ import { clearBackgroundedAt, getAutoLockTimer, getBackgroundedAt, + // TODO/FIXME: dev-only auto-lock timer override — remove before production + getDevAutoLockTimerMs, getHashKeyExpirationMs, } from "services/autoLock"; import { getAccount } from "services/stellar"; @@ -514,7 +516,10 @@ const getAuthStatus = async (): Promise => { const backgroundedAt = await getBackgroundedAt(); if (backgroundedAt) { const autoLockTimer = await getAutoLockTimer(); - const autoLockTimerMs = AUTO_LOCK_TIMER_MS[autoLockTimer]; + // TODO/FIXME: dev-only override (seconds) — remove before production + const devAutoLockTimerMs = await getDevAutoLockTimerMs(); + const autoLockTimerMs = + devAutoLockTimerMs ?? AUTO_LOCK_TIMER_MS[autoLockTimer]; const elapsedInBackground = Date.now() - backgroundedAt; if ( diff --git a/src/hooks/useGetActiveAccount.ts b/src/hooks/useGetActiveAccount.ts index 191801f95..b6a88154a 100644 --- a/src/hooks/useGetActiveAccount.ts +++ b/src/hooks/useGetActiveAccount.ts @@ -17,7 +17,7 @@ import { analytics } from "services/analytics"; * material directly — never rely on the overlay alone. Read live from the * store so a lock that engaged after the callback was created is respected. */ -const isWalletUnlocked = (): boolean => { +export const isWalletUnlocked = (): boolean => { const { authStatus, isSoftLocked } = useAuthenticationStore.getState(); return authStatus === AUTH_STATUS.AUTHENTICATED && !isSoftLocked; }; diff --git a/src/hooks/useManageTokens.ts b/src/hooks/useManageTokens.ts index 5993476e8..a2d7ad262 100644 --- a/src/hooks/useManageTokens.ts +++ b/src/hooks/useManageTokens.ts @@ -15,6 +15,7 @@ import { import { ActiveAccount } from "ducks/auth"; import { formatTokenIdentifier } from "helpers/balances"; import useAppTranslation from "hooks/useAppTranslation"; +import { isWalletUnlocked } from "hooks/useGetActiveAccount"; import { ToastOptions, useToast } from "providers/ToastProvider"; import { useState } from "react"; import { analytics } from "services/analytics"; @@ -25,6 +26,8 @@ import { } from "services/stellar"; import { dataStorage } from "services/storage/storageFactory"; +const WALLET_LOCKED_ERROR = "Wallet is locked"; + interface UseManageTokensProps { network: NETWORKS; account: ActiveAccount | null; @@ -165,6 +168,12 @@ export const useManageTokens = ({ publicKey, }); + // Re-check auth after the async build: an auto-lock may have engaged + // mid-await, and this path signs with a captured privateKey + if (!isWalletUnlocked()) { + throw new Error(WALLET_LOCKED_ERROR); + } + const signedTx = signTransaction({ tx: addTokenTrustlineTx, secretKey: privateKey, @@ -287,6 +296,12 @@ export const useManageTokens = ({ isRemove: true, }); + // Re-check auth after the async build: an auto-lock may have engaged + // mid-await, and this path signs with a captured privateKey + if (!isWalletUnlocked()) { + throw new Error(WALLET_LOCKED_ERROR); + } + const signedTx = signTransaction({ tx: removeTokenTrustlineTx, secretKey: privateKey, diff --git a/src/navigators/RootNavigator.tsx b/src/navigators/RootNavigator.tsx index 0d8ff6af8..730fbab1d 100644 --- a/src/navigators/RootNavigator.tsx +++ b/src/navigators/RootNavigator.tsx @@ -56,9 +56,14 @@ import { } from "navigators"; import { TabNavigator } from "navigators/TabNavigator"; import React, { useEffect, useMemo, useState } from "react"; +import { StyleSheet, View } from "react-native"; import RNBootSplash from "react-native-bootsplash"; import { isInitialized as isAnalyticsInitialized } from "services/analytics/core"; +const styles = StyleSheet.create({ + flex: { flex: 1 }, +}); + const RootStack = createNativeStackNavigator< RootStackParamList & ManageTokensStackParamList & @@ -196,112 +201,124 @@ export const RootNavigator = () => { } return ( - - {showAuthenticatedStack ? ( - - - - - - - - - withTransitionOverride( - getScreenBottomNavigateOptions(t("accountQRCodeScreen.title")), - route, - ) - } - /> - - withTransitionOverride(getScreenOptionsNoHeader(), route) - } - /> - - - - - + + {showAuthenticatedStack ? ( + + + + + + + + + withTransitionOverride( + getScreenBottomNavigateOptions( + t("accountQRCodeScreen.title"), + ), + route, + ) + } + /> + + withTransitionOverride(getScreenOptionsNoHeader(), route) + } + /> + + + + + + + + + ) : authStatus === AUTH_STATUS.HASH_KEY_EXPIRED || + authStatus === AUTH_STATUS.LOCKED ? ( + ) : ( - - ) : authStatus === AUTH_STATUS.HASH_KEY_EXPIRED || - authStatus === AUTH_STATUS.LOCKED ? ( - - ) : ( - - )} - + )} + + ); }; diff --git a/src/services/autoLock.ts b/src/services/autoLock.ts index 19495256e..c7a0af238 100644 --- a/src/services/autoLock.ts +++ b/src/services/autoLock.ts @@ -120,6 +120,51 @@ const getBackgroundedAt = async (): Promise => { return parsedBackgroundedAt; }; +/* =========================================================================== + * TODO / FIXME: TEMPORARY DEV-ONLY AUTO-LOCK TESTING OVERRIDES. + * !!! REMOVE THIS ENTIRE BLOCK (and its UI on AutoLockTimerScreen + the + * getDevAutoLockTimerMs usage in ducks/auth.ts) BEFORE MERGING TO PRODUCTION. + * It lets QA set the auto-lock timer and hash-key TTL in seconds so the lock + * flows can be exercised in seconds instead of minutes/hours. + * =========================================================================== + */ +const DEV_AUTO_LOCK_TIMER_MS_KEY = "devAutoLockTimerMs"; + +/** TEMP/REMOVE: dev override for the auto-lock timer, in ms (null if unset). */ +const getDevAutoLockTimerMs = async (): Promise => { + const raw = await secureDataStorage.getItem(DEV_AUTO_LOCK_TIMER_MS_KEY); + const parsed = raw ? Number(raw) : NaN; + return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; +}; + +/** TEMP/REMOVE: set the dev auto-lock timer override from a seconds value. */ +const setDevAutoLockTimerSeconds = async (seconds: number): Promise => { + await secureDataStorage.setItem( + DEV_AUTO_LOCK_TIMER_MS_KEY, + String(Math.round(seconds * 1000)), + ); +}; + +/** + * TEMP/REMOVE: force the current hash key to expire in `seconds` by rewriting + * its expiresAt, so the hard-expiry (HASH_KEY_EXPIRED) backstop can be tested + * quickly. One-shot: the next unlock re-anchors the TTL to the normal value. + */ +const setDevHashKeyTtlSeconds = async (seconds: number): Promise => { + const hashKey = await getHashKey(); + if (!hashKey) { + return; + } + await secureDataStorage.setItem( + SENSITIVE_STORAGE_KEYS.HASH_KEY, + JSON.stringify({ + ...hashKey, + expiresAt: Date.now() + Math.round(seconds * 1000), + }), + ); +}; +/* ====================== END TEMPORARY DEV-ONLY BLOCK ====================== */ + export { getAutoLockTimer, persistAutoLockTimer, @@ -128,4 +173,8 @@ export { recordBackgroundedAt, getBackgroundedAt, clearBackgroundedAt, + // TODO/FIXME: remove these dev-only exports before production + getDevAutoLockTimerMs, + setDevAutoLockTimerSeconds, + setDevHashKeyTtlSeconds, }; From ba81a3c341a624f28548f775b2a81a25a2b37ab8 Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Tue, 16 Jun 2026 13:38:11 -0300 Subject: [PATCH 06/19] preserve sign in method from unlock and improve debug auto lock options --- jest.config.js | 1 + jest.setup.js | 5 ++++ .../AutoLockTimerScreen.tsx | 23 +++++++++++++++---- src/ducks/auth.ts | 4 ++++ 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/jest.config.js b/jest.config.js index 86059e5dc..46252b1b3 100644 --- a/jest.config.js +++ b/jest.config.js @@ -27,6 +27,7 @@ module.exports = { "react-native-reanimated", "@gorhom/bottom-sheet", "react-native-worklets", + "react-native-keyboard-controller", "react-native-qrcode-svg", "stellar-hd-wallet", "react-native-config", diff --git a/jest.setup.js b/jest.setup.js index d9aef0d62..af25573a2 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -496,6 +496,11 @@ jest.mock("hooks/useGetActiveAccount", () => ({ isWalletUnlocked: jest.fn(() => true), })); +// react-native-keyboard-controller ships a jest mock for its native bindings +jest.mock("react-native-keyboard-controller", () => + require("react-native-keyboard-controller/jest"), +); + jest.mock("hooks/useBalancesList", () => ({ useBalancesList: jest.fn(() => ({ balanceItems: [], diff --git a/src/components/screens/SettingsScreen/SecurityScreen/AutoLockTimerScreen/AutoLockTimerScreen.tsx b/src/components/screens/SettingsScreen/SecurityScreen/AutoLockTimerScreen/AutoLockTimerScreen.tsx index c35df7dd4..ae7bbde04 100644 --- a/src/components/screens/SettingsScreen/SecurityScreen/AutoLockTimerScreen/AutoLockTimerScreen.tsx +++ b/src/components/screens/SettingsScreen/SecurityScreen/AutoLockTimerScreen/AutoLockTimerScreen.tsx @@ -4,7 +4,7 @@ import { BaseLayout } from "components/layout/BaseLayout"; import Icon from "components/sds/Icon"; import { Input } from "components/sds/Input"; import { Text } from "components/sds/Typography"; -import { AUTO_LOCK_TIMER } from "config/constants"; +import { AUTO_LOCK_TIMER, DEFAULT_PADDING } from "config/constants"; import { logger } from "config/logger"; import { SETTINGS_ROUTES, SettingsStackParamList } from "config/routes"; import { usePreferencesStore } from "ducks/preferences"; @@ -13,6 +13,8 @@ import useColors from "hooks/useColors"; import { useToast } from "providers/ToastProvider"; import React, { useState } from "react"; import { View } from "react-native"; +// TODO/FIXME: only needed for the temporary dev inputs — remove with them +import { KeyboardAwareScrollView } from "react-native-keyboard-controller"; import { setDevAutoLockTimerSeconds, setDevHashKeyTtlSeconds, @@ -111,7 +113,17 @@ const AutoLockTimerScreen: React.FC = () => { return ( - + {/* + TODO / FIXME: the KeyboardAwareScrollView wrapper exists only so the + temporary dev inputs below aren't hidden behind the keyboard. + REVERT to the plain when removing the dev-only block. + */} + {t("autoLockTimerScreen.footer")} @@ -121,8 +133,9 @@ const AutoLockTimerScreen: React.FC = () => { ==================================================================== TODO / FIXME: TEMPORARY DEV-ONLY testing controls. !!! REMOVE THIS ENTIRE BLOCK BEFORE MERGING TO PRODUCTION !!! - (also remove the dev helpers in services/autoLock.ts and the - getDevAutoLockTimerMs override in ducks/auth.ts) + (also remove the dev helpers in services/autoLock.ts, the + getDevAutoLockTimerMs override in ducks/auth.ts, and revert the + KeyboardAwareScrollView wrapper above back to a plain ) Lets QA exercise the lock flows in seconds instead of minutes/hours. ==================================================================== */} @@ -150,7 +163,7 @@ const AutoLockTimerScreen: React.FC = () => { /> {/* ================= END TEMPORARY DEV-ONLY BLOCK ================= */} - + ); }; diff --git a/src/ducks/auth.ts b/src/ducks/auth.ts index c58e6eb42..0fd5740be 100644 --- a/src/ducks/auth.ts +++ b/src/ducks/auth.ts @@ -2241,6 +2241,10 @@ export const useAuthenticationStore = create()((set, get) => ({ // Preserve navigationRef: nothing remounts to re-set it after a soft // unlock, so later lock navigations would otherwise no-op navigationRef: get().navigationRef, + // Preserve signInMethod: resetting it to PASSWORD would make the + // still-mounted biometric buttons drop biometric enforcement after a + // soft unlock, letting transactions confirm without verification + signInMethod: get().signInMethod, authStatus: AUTH_STATUS.AUTHENTICATED, isLoading: false, isLoadingAccount: true, From 44f10d969aa4c9eccffbd20f93cada17f1a209d4 Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Tue, 16 Jun 2026 13:48:21 -0300 Subject: [PATCH 07/19] add visible countdown timers for debugging oh physical device --- .../components/screens/HomeScreen.test.tsx | 7 ++ .../AutoLockTimerScreen.test.tsx | 5 ++ __tests__/hooks/useAuthCheck.test.tsx | 28 ++++++- .../screens/HomeScreen/AutoLockDevTimers.tsx | 75 +++++++++++++++++++ .../screens/HomeScreen/HomeScreen.tsx | 4 + .../AutoLockTimerScreen.tsx | 37 +++++++-- src/hooks/useAuthCheck.ts | 19 ++++- src/services/autoLock.ts | 6 ++ 8 files changed, 170 insertions(+), 11 deletions(-) create mode 100644 src/components/screens/HomeScreen/AutoLockDevTimers.tsx diff --git a/__tests__/components/screens/HomeScreen.test.tsx b/__tests__/components/screens/HomeScreen.test.tsx index dcf99d3fd..df69a9ca6 100644 --- a/__tests__/components/screens/HomeScreen.test.tsx +++ b/__tests__/components/screens/HomeScreen.test.tsx @@ -39,6 +39,13 @@ jest.mock("components/analytics/DebugBottomSheet", () => ({ }, })); +// TODO/FIXME: dev-only countdown component (recurring timer) — remove with it +jest.mock("components/screens/HomeScreen/AutoLockDevTimers", () => ({ + AutoLockDevTimers: function MockAutoLockDevTimers() { + return null; + }, +})); + jest.mock("components/primitives/Menu", () => { const MenuRoot = ({ children }: { children: React.ReactNode }) => (
{children}
diff --git a/__tests__/components/screens/SettingsScreen/AutoLockTimerScreen.test.tsx b/__tests__/components/screens/SettingsScreen/AutoLockTimerScreen.test.tsx index 322e24c6b..21db7915f 100644 --- a/__tests__/components/screens/SettingsScreen/AutoLockTimerScreen.test.tsx +++ b/__tests__/components/screens/SettingsScreen/AutoLockTimerScreen.test.tsx @@ -10,6 +10,11 @@ import React from "react"; jest.mock("services/autoLock", () => ({ persistAutoLockTimer: jest.fn().mockResolvedValue(undefined), applyAutoLockTimerToHashKey: jest.fn().mockResolvedValue(undefined), + // TODO/FIXME: dev-only auto-lock testing helpers — remove with the feature + getDevAutoLockTimerMs: jest.fn().mockResolvedValue(null), + setDevAutoLockTimerSeconds: jest.fn().mockResolvedValue(undefined), + clearDevAutoLockTimer: jest.fn().mockResolvedValue(undefined), + setDevHashKeyTtlSeconds: jest.fn().mockResolvedValue(undefined), })); type AutoLockTimerScreenNavigationProp = NativeStackScreenProps< diff --git a/__tests__/hooks/useAuthCheck.test.tsx b/__tests__/hooks/useAuthCheck.test.tsx index 74ea0e115..a22f0ae46 100644 --- a/__tests__/hooks/useAuthCheck.test.tsx +++ b/__tests__/hooks/useAuthCheck.test.tsx @@ -4,10 +4,15 @@ import { AUTH_STATUS } from "config/types"; import { useAuthenticationStore } from "ducks/auth"; import useAuthCheck from "hooks/useAuthCheck"; import { AppState } from "react-native"; -import { getAutoLockTimer, recordBackgroundedAt } from "services/autoLock"; +import { + getAutoLockTimer, + getDevAutoLockTimerMs, + recordBackgroundedAt, +} from "services/autoLock"; jest.mock("services/autoLock", () => ({ getAutoLockTimer: jest.fn(), + getDevAutoLockTimerMs: jest.fn().mockResolvedValue(null), recordBackgroundedAt: jest.fn().mockResolvedValue(undefined), })); @@ -34,6 +39,7 @@ describe("useAuthCheck", () => { (getAutoLockTimer as jest.Mock).mockResolvedValue( AUTO_LOCK_TIMER.TWENTY_FOUR_HOURS, ); + (getDevAutoLockTimerMs as jest.Mock).mockResolvedValue(null); useAuthenticationStore.setState({ authStatus: AUTH_STATUS.AUTHENTICATED, @@ -131,6 +137,26 @@ describe("useAuthCheck", () => { unmount(); }); + // TODO/FIXME: dev-only override exclusivity — remove with the dev feature + it("does NOT instant-lock for IMMEDIATELY when a dev timer override is set", async () => { + (getAutoLockTimer as jest.Mock).mockResolvedValue( + AUTO_LOCK_TIMER.IMMEDIATELY, + ); + // A custom dev timer (20s) is active — it must win over IMMEDIATELY so the + // timed countdown governs instead of an instant lock + (getDevAutoLockTimerMs as jest.Mock).mockResolvedValue(20000); + const { handlers, unmount } = renderAuthCheck(); + + await act(async () => { + handlers.forEach((handler) => handler("background")); + await flushMicrotasks(); + }); + + expect(recordBackgroundedAt).toHaveBeenCalledTimes(1); + expect(mockSoftLock).not.toHaveBeenCalled(); + unmount(); + }); + it("delegates lock transitions to the store getAuthStatus funnel on foreground return", async () => { const { handlers, unmount } = renderAuthCheck(); diff --git a/src/components/screens/HomeScreen/AutoLockDevTimers.tsx b/src/components/screens/HomeScreen/AutoLockDevTimers.tsx new file mode 100644 index 000000000..0c3f90476 --- /dev/null +++ b/src/components/screens/HomeScreen/AutoLockDevTimers.tsx @@ -0,0 +1,75 @@ +/* =========================================================================== + * TODO / FIXME: TEMPORARY DEV-ONLY auto-lock countdown readout. + * !!! REMOVE THIS FILE (and its usage in HomeScreen.tsx) BEFORE MERGING TO + * PRODUCTION !!! Shows the configured auto-lock timer and the live countdown + * to the hash-key hard expiry so QA can watch the lock flows. + * =========================================================================== + */ +import { Text } from "components/sds/Typography"; +import { AUTO_LOCK_TIMER_MS } from "config/constants"; +import React, { useEffect, useState } from "react"; +import { View } from "react-native"; +import { getAutoLockTimer, getDevAutoLockTimerMs } from "services/autoLock"; +import { getHashKey } from "services/storage/helpers"; + +const TICK_MS = 1000; +const NEVER_LABEL = "Never"; +const EMPTY_LABEL = "—"; + +const formatSeconds = (ms: number | null): string => { + if (ms === null) { + return NEVER_LABEL; + } + return `${Math.max(0, Math.ceil(ms / 1000))}s`; +}; + +export const AutoLockDevTimers: React.FC = () => { + const [autoLockMs, setAutoLockMs] = useState(null); + const [hashExpiresAt, setHashExpiresAt] = useState(null); + const [now, setNow] = useState(Date.now()); + + useEffect(() => { + let mounted = true; + + const read = async () => { + const devMs = await getDevAutoLockTimerMs(); + const timer = await getAutoLockTimer(); + const hashKey = await getHashKey(); + if (!mounted) { + return; + } + setAutoLockMs(devMs ?? AUTO_LOCK_TIMER_MS[timer]); + setHashExpiresAt(hashKey?.expiresAt ?? null); + }; + + read(); + const id = setInterval(() => { + setNow(Date.now()); + read(); + }, TICK_MS); + + return () => { + mounted = false; + clearInterval(id); + }; + }, []); + + const hashRemainingMs = hashExpiresAt === null ? null : hashExpiresAt - now; + + return ( + + + {`auto-lock (bg): ${formatSeconds(autoLockMs)}`} + + + {`hash expires in: ${ + hashRemainingMs === null + ? EMPTY_LABEL + : formatSeconds(hashRemainingMs) + }`} + + + ); +}; + +export default AutoLockDevTimers; diff --git a/src/components/screens/HomeScreen/HomeScreen.tsx b/src/components/screens/HomeScreen/HomeScreen.tsx index 6ebc4dacc..bf91a7c7e 100644 --- a/src/components/screens/HomeScreen/HomeScreen.tsx +++ b/src/components/screens/HomeScreen/HomeScreen.tsx @@ -10,6 +10,8 @@ import { import { DebugBottomSheet } from "components/analytics/DebugBottomSheet"; import { DebugTrigger } from "components/debug/DebugTrigger"; import { BaseLayout } from "components/layout/BaseLayout"; +// TODO/FIXME: dev-only auto-lock countdown — remove before production +import { AutoLockDevTimers } from "components/screens/HomeScreen/AutoLockDevTimers"; import ManageAccounts from "components/screens/HomeScreen/ManageAccounts"; import WelcomeBannerBottomSheet from "components/screens/HomeScreen/WelcomeBannerBottomSheet"; import Avatar from "components/sds/Avatar"; @@ -289,6 +291,8 @@ export const HomeScreen: React.FC = React.memo( {formattedBalance} + {/* TODO/FIXME: dev-only auto-lock countdown — remove before prod */} + diff --git a/src/components/screens/SettingsScreen/SecurityScreen/AutoLockTimerScreen/AutoLockTimerScreen.tsx b/src/components/screens/SettingsScreen/SecurityScreen/AutoLockTimerScreen/AutoLockTimerScreen.tsx index ae7bbde04..a77223f36 100644 --- a/src/components/screens/SettingsScreen/SecurityScreen/AutoLockTimerScreen/AutoLockTimerScreen.tsx +++ b/src/components/screens/SettingsScreen/SecurityScreen/AutoLockTimerScreen/AutoLockTimerScreen.tsx @@ -11,11 +11,13 @@ import { usePreferencesStore } from "ducks/preferences"; import useAppTranslation from "hooks/useAppTranslation"; import useColors from "hooks/useColors"; import { useToast } from "providers/ToastProvider"; -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { View } from "react-native"; // TODO/FIXME: only needed for the temporary dev inputs — remove with them import { KeyboardAwareScrollView } from "react-native-keyboard-controller"; import { + clearDevAutoLockTimer, + getDevAutoLockTimerMs, setDevAutoLockTimerSeconds, setDevHashKeyTtlSeconds, } from "services/autoLock"; @@ -43,6 +45,15 @@ const AutoLockTimerScreen: React.FC = () => { // TODO/FIXME: dev-only state for the testing controls — remove before prod const [devTimerSeconds, setDevTimerSecondsInput] = useState(""); const [devTtlSeconds, setDevTtlSecondsInput] = useState(""); + // When a custom dev timer is active, the enum options are deselected so the + // UI reflects that the override (not a preset) governs the lock. + const [isDevTimerActive, setIsDevTimerActive] = useState(false); + + useEffect(() => { + getDevAutoLockTimerMs() + .then((ms) => setIsDevTimerActive(ms !== null)) + .catch(() => setIsDevTimerActive(false)); + }, []); // TODO/FIXME: dev-only handlers — remove before prod const applyDevTimer = () => { @@ -51,13 +62,14 @@ const AutoLockTimerScreen: React.FC = () => { return; } setDevAutoLockTimerSeconds(seconds) - .then(() => + .then(() => { + setIsDevTimerActive(true); showToast({ variant: "success", title: `Auto-lock timer set to ${seconds}s`, toastId: "dev-auto-lock-timer", - }), - ) + }); + }) .catch((error) => logger.error("AutoLockTimerScreen", "Failed to set dev timer", error), ); @@ -99,13 +111,26 @@ const AutoLockTimerScreen: React.FC = () => { [AUTO_LOCK_TIMER.NONE]: t("autoLockTimerScreen.options.none"), }; + const handleSelectOption = (option: AUTO_LOCK_TIMER) => { + setAutoLockTimer(option); + // TODO/FIXME: picking a preset clears the dev override so it takes effect + setIsDevTimerActive(false); + clearDevAutoLockTimer().catch((error) => + logger.error("AutoLockTimerScreen", "Failed to clear dev timer", error), + ); + }; + const listItems = Object.values(AUTO_LOCK_TIMER).map((option) => ({ title: timerLabels[option], titleColor: themeColors.text.primary, - onPress: () => setAutoLockTimer(option), + onPress: () => handleSelectOption(option), trailingContent: ( ), testID: `auto-lock-option-${option}`, diff --git a/src/hooks/useAuthCheck.ts b/src/hooks/useAuthCheck.ts index dcacfd42c..0e72b44e6 100644 --- a/src/hooks/useAuthCheck.ts +++ b/src/hooks/useAuthCheck.ts @@ -9,7 +9,12 @@ import { PanResponder, PanResponderInstance, } from "react-native"; -import { getAutoLockTimer, recordBackgroundedAt } from "services/autoLock"; +import { + getAutoLockTimer, + // TODO/FIXME: dev-only override — remove before production + getDevAutoLockTimerMs, + recordBackgroundedAt, +} from "services/autoLock"; // Constants for interval timings (in milliseconds) const BACKGROUND_CHECK_INTERVAL = 60000; // Check every minute when in background @@ -125,9 +130,15 @@ const useAuthCheck = () => { // Read from the secure-storage mirror (not the zustand store) so the // IMMEDIATELY lock also fires when backgrounding happens before // zustand rehydration completes. - getAutoLockTimer() - .then((autoLockTimer) => { - if (autoLockTimer === AUTO_LOCK_TIMER.IMMEDIATELY) { + // TODO/FIXME: getDevAutoLockTimerMs is a dev-only override — when set + // it must win over the IMMEDIATELY preset (exclusive), so the timed + // dev countdown in getAuthStatus governs instead of an instant lock. + Promise.all([getDevAutoLockTimerMs(), getAutoLockTimer()]) + .then(([devAutoLockTimerMs, autoLockTimer]) => { + if ( + devAutoLockTimerMs === null && + autoLockTimer === AUTO_LOCK_TIMER.IMMEDIATELY + ) { // Soft-lock right away: the overlay renders while the app is // backgrounded (no wallet content flashes on return) and the // navigation tree is preserved for after the unlock. diff --git a/src/services/autoLock.ts b/src/services/autoLock.ts index c7a0af238..81e1c1797 100644 --- a/src/services/autoLock.ts +++ b/src/services/autoLock.ts @@ -145,6 +145,11 @@ const setDevAutoLockTimerSeconds = async (seconds: number): Promise => { ); }; +/** TEMP/REMOVE: clear the dev auto-lock timer override (back to the enum). */ +const clearDevAutoLockTimer = async (): Promise => { + await secureDataStorage.remove(DEV_AUTO_LOCK_TIMER_MS_KEY); +}; + /** * TEMP/REMOVE: force the current hash key to expire in `seconds` by rewriting * its expiresAt, so the hard-expiry (HASH_KEY_EXPIRED) backstop can be tested @@ -176,5 +181,6 @@ export { // TODO/FIXME: remove these dev-only exports before production getDevAutoLockTimerMs, setDevAutoLockTimerSeconds, + clearDevAutoLockTimer, setDevHashKeyTtlSeconds, }; From 96ad730cea55e8f8fb35a4829d6f71d018e5def4 Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Tue, 16 Jun 2026 14:59:20 -0300 Subject: [PATCH 08/19] add ios deterministic privacy shield --- ios/Podfile | 4 +++ ios/Podfile.lock | 8 ++++- ios/PrivacyShield.m | 8 +++++ ios/PrivacyShield.podspec | 13 ++++++++ ios/PrivacyShield.swift | 35 ++++++++++++++++++++++ ios/freighter-mobile/AppDelegate.swift | 41 ++++++++++++++++++++++++++ src/helpers/privacyShield.ts | 21 +++++++++++++ src/hooks/useAuthCheck.ts | 25 +++++++++++++++- 8 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 ios/PrivacyShield.m create mode 100644 ios/PrivacyShield.podspec create mode 100644 ios/PrivacyShield.swift create mode 100644 src/helpers/privacyShield.ts diff --git a/ios/Podfile b/ios/Podfile index 06b6df974..cc908362b 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -54,6 +54,10 @@ target 'freighter-mobile' do # SecureClipboard is now part of the main project pod 'SecureClipboard', :path => './' + # PrivacyShield native module (local pod) — JS dismisses the native privacy + # shield once the auto-lock decision has finished + pod 'PrivacyShield', :path => './' + use_react_native!( :path => config[:reactNativePath], # An absolute path to your application root. diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 9769ceb38..29057cd95 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -37,6 +37,8 @@ PODS: - libwebp/sharpyuv (1.5.0) - libwebp/webp (1.5.0): - libwebp/sharpyuv + - PrivacyShield (1.0.0): + - React-Core - RCT-Folly (2024.11.18.00): - boost - DoubleConversion @@ -3386,6 +3388,7 @@ DEPENDENCIES: - glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`) - hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) - jail-monkey (from `../node_modules/jail-monkey`) + - PrivacyShield (from `./`) - RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) - RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`) - RCTRequired (from `../node_modules/react-native/Libraries/Required`) @@ -3528,6 +3531,8 @@ EXTERNAL SOURCES: :tag: hermes-2025-07-07-RNv0.81.0-e0fc67142ec0763c6b6153ca2bf96df815539782 jail-monkey: :path: "../node_modules/jail-monkey" + PrivacyShield: + :path: "./" RCT-Folly: :podspec: "../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec" RCTDeprecation: @@ -3749,6 +3754,7 @@ SPEC CHECKSUMS: libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7 libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 + PrivacyShield: df1c5e513b672f8725b5a4637726ee75ad5611e4 RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 RCTDeprecation: c0ed3249a97243002615517dff789bf4666cf585 RCTRequired: 58719f5124f9267b5f9649c08bf23d9aea845b23 @@ -3857,6 +3863,6 @@ SPEC CHECKSUMS: VisionCamera: 891edb31806dd3a239c8a9d6090d6ec78e11ee80 Yoga: a3ed390a19db0459bd6839823a6ac6d9c6db198d -PODFILE CHECKSUM: aa3474b4795f465757c119bd3e4b6cd20a1526f0 +PODFILE CHECKSUM: a88f7c96ae72d2da0c705ccdb51d43ec1391b5a7 COCOAPODS: 1.15.2 diff --git a/ios/PrivacyShield.m b/ios/PrivacyShield.m new file mode 100644 index 000000000..c207fbd1b --- /dev/null +++ b/ios/PrivacyShield.m @@ -0,0 +1,8 @@ +#import + +@interface RCT_EXTERN_MODULE(PrivacyShield, NSObject) + +RCT_EXTERN_METHOD(hide:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +@end diff --git a/ios/PrivacyShield.podspec b/ios/PrivacyShield.podspec new file mode 100644 index 000000000..ec3165fef --- /dev/null +++ b/ios/PrivacyShield.podspec @@ -0,0 +1,13 @@ +Pod::Spec.new do |s| + s.name = "PrivacyShield" + s.version = "1.0.0" + s.summary = "Privacy shield control for React Native" + s.description = "A React Native module that lets JS dismiss the native privacy shield once the auto-lock decision has finished" + s.homepage = "https://github.com/stellar/freighter-mobile" + s.license = "MIT" + s.author = { "Stellar Development Foundation" => "hello@stellar.org" } + s.platforms = { :ios => "11.0" } + s.source = { :git => "https://github.com/stellar/freighter-mobile.git" } + s.source_files = "PrivacyShield.{swift,m}" + s.dependency "React-Core" +end diff --git a/ios/PrivacyShield.swift b/ios/PrivacyShield.swift new file mode 100644 index 000000000..500b1b275 --- /dev/null +++ b/ios/PrivacyShield.swift @@ -0,0 +1,35 @@ +import Foundation +import React + +// PrivacyShield native module — lets JS dismiss the iOS privacy shield once +// the auto-lock decision has finished, so a soft-lock overlay can mount +// before the wallet is revealed on return from the background. +// +// Packaged as a local pod (separate compilation module), so it can't +// reference the app target's AppDelegate directly. It decouples via +// NotificationCenter: AppDelegate owns the shield window (it's alive from +// launch and shows the shield reliably on every background) and observes this +// notification to dismiss it. +@objc(PrivacyShield) +class PrivacyShield: NSObject { + + // Must match the name AppDelegate observes. + static let hideNotificationName = Notification.Name("FreighterPrivacyShieldHide") + + @objc + static func requiresMainQueueSetup() -> Bool { + return false + } + + @objc + func hide( + _ resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock + ) { + NotificationCenter.default.post( + name: PrivacyShield.hideNotificationName, + object: nil + ) + resolver(nil) + } +} diff --git a/ios/freighter-mobile/AppDelegate.swift b/ios/freighter-mobile/AppDelegate.swift index a440b0132..7d89e075d 100644 --- a/ios/freighter-mobile/AppDelegate.swift +++ b/ios/freighter-mobile/AppDelegate.swift @@ -30,8 +30,23 @@ class AppDelegate: UIResponder, UIApplicationDelegate { launchOptions: launchOptions ) + // The PrivacyShield native module (local pod) can't reference this class + // directly, so it requests dismissal via this notification. + NotificationCenter.default.addObserver( + self, + selector: #selector(handlePrivacyShieldHideRequest), + name: Notification.Name("FreighterPrivacyShieldHide"), + object: nil + ) + return true } + + @objc private func handlePrivacyShieldHideRequest() { + DispatchQueue.main.async { [weak self] in + self?.hidePrivacyShield() + } + } func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool { return RCTLinkingManager.application(app, open: url, options: options) @@ -42,9 +57,20 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // didEnterBackground (not willResignActive) so it does NOT trigger for the // Face ID prompt / control center, which only make the app inactive — the // snapshot is still taken after didEnterBackground, so it's in time. + // + // The shield stays up after the app becomes active until JS finishes the + // auto-lock decision and calls PrivacyShield.hide() (the PrivacyShield pod + // posts the FreighterPrivacyShieldHide notification observed above), so a + // soft-lock overlay can mount before the wallet is revealed (no flash of + // the unlocked screen). The fallback timer guarantees it can never get + // stuck if JS doesn't run, and degrades to a bounded splash if the + // PrivacyShield pod isn't installed (run `pod install`). private var privacyWindow: UIWindow? + private var privacyShieldFallbackTimer: Timer? + private static let privacyShieldFallbackTimeout: TimeInterval = 1.0 func applicationDidEnterBackground(_ application: UIApplication) { + guard privacyWindow == nil else { return } let overlay = UIWindow(frame: UIScreen.main.bounds) overlay.windowLevel = .alert + 1 overlay.rootViewController = UIStoryboard(name: "BootSplash", bundle: nil) @@ -54,6 +80,21 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } func applicationDidBecomeActive(_ application: UIApplication) { + guard privacyWindow != nil else { return } + privacyShieldFallbackTimer?.invalidate() + privacyShieldFallbackTimer = Timer.scheduledTimer( + withTimeInterval: Self.privacyShieldFallbackTimeout, + repeats: false + ) { [weak self] _ in + self?.hidePrivacyShield() + } + } + + // Called from the PrivacyShield native module once JS has settled the lock + // decision (and any lock overlay has mounted). Must run on the main thread. + func hidePrivacyShield() { + privacyShieldFallbackTimer?.invalidate() + privacyShieldFallbackTimer = nil privacyWindow?.isHidden = true privacyWindow = nil } diff --git a/src/helpers/privacyShield.ts b/src/helpers/privacyShield.ts new file mode 100644 index 000000000..5a5571fbe --- /dev/null +++ b/src/helpers/privacyShield.ts @@ -0,0 +1,21 @@ +import { NativeModules } from "react-native"; + +interface PrivacyShieldModule { + hide?: () => Promise; +} + +/** + * Dismisses the native privacy shield (iOS overlay window / Android overlay + * view) once the JS auto-lock decision has settled, so the wallet is revealed + * only after a soft-lock overlay (if any) has mounted. No-op if the native + * module isn't available — the native side has its own fallback timer. + */ +export const hidePrivacyShield = (): void => { + const privacyShield = NativeModules.PrivacyShield as + | PrivacyShieldModule + | undefined; + + privacyShield?.hide?.().catch(() => { + // Best effort — the native fallback timer removes the shield regardless + }); +}; diff --git a/src/hooks/useAuthCheck.ts b/src/hooks/useAuthCheck.ts index 0e72b44e6..dcbd06c73 100644 --- a/src/hooks/useAuthCheck.ts +++ b/src/hooks/useAuthCheck.ts @@ -2,6 +2,7 @@ import { AUTO_LOCK_TIMER } from "config/constants"; import { logger } from "config/logger"; import { AUTH_STATUS } from "config/types"; import { useAuthenticationStore } from "ducks/auth"; +import { hidePrivacyShield } from "helpers/privacyShield"; import { useEffect, useRef, useState, useCallback } from "react"; import { AppState, @@ -16,6 +17,10 @@ import { recordBackgroundedAt, } from "services/autoLock"; +// Delay before lifting the native privacy shield on foreground, giving a +// soft-lock overlay a frame to paint so the unlocked wallet never flashes +const SHIELD_REVEAL_DELAY = 50; + // Constants for interval timings (in milliseconds) const BACKGROUND_CHECK_INTERVAL = 60000; // Check every minute when in background const FOREGROUND_CHECK_INTERVAL = 10000; // Check every 10 seconds in foreground (inactive) @@ -155,11 +160,29 @@ const useAuthCheck = () => { ); } - // When returning to active state, allow a slight delay before checking auth + // When returning to active state, resolve the auto-lock decision and + // then dismiss the native privacy shield. Running getAuthStatus right + // away (instead of only the delayed check below) lets a soft-lock + // overlay mount before the shield lifts, so the unlocked wallet never + // flashes; the brief delay gives that overlay a frame to paint. if ( appState.current.match(/inactive|background/) && nextAppState === "active" ) { + useAuthenticationStore + .getState() + .getAuthStatus() + .catch((err) => + logger.error( + "handleAppStateChange", + "Error checking auth on foreground", + err, + ), + ) + .finally(() => { + setTimeout(hidePrivacyShield, SHIELD_REVEAL_DELAY); + }); + setTimeout(() => { checkAuth().catch((err) => logger.error("handleAppStateChange", "Error checking auth", err), From 5cf10fd718b12f73da5898ff1ca1c5331dfd6aa3 Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Tue, 16 Jun 2026 15:46:41 -0300 Subject: [PATCH 09/19] add android auto lock privacy screen --- .../java/com/freightermobile/MainActivity.kt | 71 ++++++++++++++++++- .../com/freightermobile/MainApplication.kt | 1 + .../freighterwallet/PrivacyShieldModule.kt | 26 +++++++ .../freighterwallet/PrivacyShieldPackage.kt | 16 +++++ 4 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 android/app/src/main/java/org/stellar/freighterwallet/PrivacyShieldModule.kt create mode 100644 android/app/src/main/java/org/stellar/freighterwallet/PrivacyShieldPackage.kt diff --git a/android/app/src/main/java/com/freightermobile/MainActivity.kt b/android/app/src/main/java/com/freightermobile/MainActivity.kt index 72aa13dba..c8393eb12 100644 --- a/android/app/src/main/java/com/freightermobile/MainActivity.kt +++ b/android/app/src/main/java/com/freightermobile/MainActivity.kt @@ -2,7 +2,15 @@ package org.stellar.freighterwallet // this import is needed for the onCreate override function import android.os.Bundle; +import android.os.Handler +import android.os.Looper +import android.view.Gravity +import android.view.View +import android.view.ViewGroup import android.view.WindowManager +import android.widget.FrameLayout +import android.widget.ImageView +import androidx.core.content.ContextCompat import com.facebook.react.ReactActivity import com.facebook.react.ReactActivityDelegate @@ -12,6 +20,20 @@ import com.zoontek.rnbootsplash.RNBootSplash class MainActivity : ReactActivity() { + // Privacy shield: a BootSplash-styled overlay added when the app is + // backgrounded (onStop, so it does NOT trigger for the biometric prompt / + // transient pauses) and kept up after return until JS finishes the + // auto-lock decision and calls PrivacyShield.hide() — so a soft-lock + // overlay can mount before the wallet is revealed. A fallback removes it if + // JS never calls. (FLAG_SECURE already blanks the recents thumbnail; this + // covers the brief on-return flash.) + private var privacyOverlay: View? = null + private val privacyHandler = Handler(Looper.getMainLooper()) + + companion object { + private const val PRIVACY_SHIELD_FALLBACK_MS = 1000L + } + /** * Returns the name of the main component registered from JavaScript. This is used to schedule * rendering of the component. @@ -33,12 +55,57 @@ class MainActivity : ReactActivity() { */ override fun onCreate(savedInstanceState: Bundle?) { RNBootSplash.init(this, R.style.BootTheme) - // Privacy shield: blank wallet content in the recents/app-switcher - // thumbnail (also blocks screenshots) when backgrounded + // Blank wallet content in the recents/app-switcher thumbnail (also blocks + // screenshots) when backgrounded window.setFlags( WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE, ) super.onCreate(null) } + + override fun onStop() { + showPrivacyShield() + super.onStop() + } + + override fun onResume() { + super.onResume() + // Fallback: ensure the shield can't get stuck if JS never calls hide() + privacyHandler.removeCallbacksAndMessages(null) + privacyHandler.postDelayed({ hidePrivacyShield() }, PRIVACY_SHIELD_FALLBACK_MS) + } + + fun showPrivacyShield() { + if (privacyOverlay != null) return + val decor = window.decorView as? ViewGroup ?: return + + val overlay = FrameLayout(this).apply { + setBackgroundColor( + ContextCompat.getColor(this@MainActivity, R.color.bootsplash_background), + ) + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + } + val logo = ImageView(this).apply { + setImageResource(R.drawable.bootsplash_logo) + layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + Gravity.CENTER, + ) + } + overlay.addView(logo) + decor.addView(overlay) + privacyOverlay = overlay + } + + fun hidePrivacyShield() { + privacyHandler.removeCallbacksAndMessages(null) + val overlay = privacyOverlay ?: return + (overlay.parent as? ViewGroup)?.removeView(overlay) + privacyOverlay = null + } } \ No newline at end of file diff --git a/android/app/src/main/java/com/freightermobile/MainApplication.kt b/android/app/src/main/java/com/freightermobile/MainApplication.kt index 91ff763ed..e19e975b9 100644 --- a/android/app/src/main/java/com/freightermobile/MainApplication.kt +++ b/android/app/src/main/java/com/freightermobile/MainApplication.kt @@ -22,6 +22,7 @@ class MainApplication : Application(), ReactApplication { // Packages that cannot be autolinked yet can be added manually here, for example: // add(MyReactNativePackage()) add(org.stellar.freighterwallet.SecureClipboardPackage()) + add(org.stellar.freighterwallet.PrivacyShieldPackage()) } override fun getJSMainModuleName(): String = "index" diff --git a/android/app/src/main/java/org/stellar/freighterwallet/PrivacyShieldModule.kt b/android/app/src/main/java/org/stellar/freighterwallet/PrivacyShieldModule.kt new file mode 100644 index 000000000..bd3fb9b40 --- /dev/null +++ b/android/app/src/main/java/org/stellar/freighterwallet/PrivacyShieldModule.kt @@ -0,0 +1,26 @@ +package org.stellar.freighterwallet + +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod + +/** + * Lets JS dismiss the Android privacy shield (managed by MainActivity) once + * the auto-lock decision has finished, so a soft-lock overlay can mount before + * the wallet is revealed on return from the background. + */ +class PrivacyShieldModule(private val reactContext: ReactApplicationContext) : + ReactContextBaseJavaModule(reactContext) { + + override fun getName(): String = "PrivacyShield" + + @ReactMethod + fun hide(promise: Promise) { + val activity = reactContext.currentActivity + if (activity is MainActivity) { + activity.runOnUiThread { activity.hidePrivacyShield() } + } + promise.resolve(null) + } +} diff --git a/android/app/src/main/java/org/stellar/freighterwallet/PrivacyShieldPackage.kt b/android/app/src/main/java/org/stellar/freighterwallet/PrivacyShieldPackage.kt new file mode 100644 index 000000000..86d45d4e3 --- /dev/null +++ b/android/app/src/main/java/org/stellar/freighterwallet/PrivacyShieldPackage.kt @@ -0,0 +1,16 @@ +package org.stellar.freighterwallet + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class PrivacyShieldPackage : ReactPackage { + override fun createNativeModules( + reactContext: ReactApplicationContext, + ): List = listOf(PrivacyShieldModule(reactContext)) + + override fun createViewManagers( + reactContext: ReactApplicationContext, + ): List> = emptyList() +} From 98de9640876d93e9dac4fce9d7db9b2d66b57673 Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Tue, 16 Jun 2026 16:52:26 -0300 Subject: [PATCH 10/19] adjust lock on foreground and background behaviors --- __tests__/ducks/auth.test.ts | 34 ++++++++ __tests__/hooks/useAuthCheck.test.tsx | 83 +++++++++++++++++++ .../screens/HomeScreen/AutoLockDevTimers.tsx | 34 +++++--- src/ducks/auth.ts | 6 ++ src/hooks/useAuthCheck.ts | 53 ++++++++++-- src/services/autoLock.ts | 11 +++ 6 files changed, 206 insertions(+), 15 deletions(-) diff --git a/__tests__/ducks/auth.test.ts b/__tests__/ducks/auth.test.ts index da4894100..6f83d2841 100644 --- a/__tests__/ducks/auth.test.ts +++ b/__tests__/ducks/auth.test.ts @@ -1795,6 +1795,40 @@ describe("auth duck", () => { ); }); + it("does NOT re-lock via getAuthStatus for IMMEDIATELY (background-only) — it consumes a lingering timestamp instead", async () => { + // IMMEDIATELY is locked proactively on backgrounding (useAuthCheck); + // its 0ms duration must NOT make getAuthStatus re-lock a fresh + // foreground session off a lingering backgrounded-at timestamp, which + // would make the app unusable after unlock. + const { result } = renderHook(() => useAuthenticationStore()); + restoreGetAuthStatus(); + + const previousAppState = AppState.currentState; + (AppState as { currentState: string }).currentState = "active"; + + mockAuthenticatedStorage({ + backgroundedAt: Date.now() - 60000, // a stale timestamp + autoLockTimer: AUTO_LOCK_TIMER.IMMEDIATELY, + }); + + await act(async () => { + const status = await result.current.getAuthStatus(); + expect(status).toBe(AUTH_STATUS.AUTHENTICATED); + }); + + // Must NOT persist LOCKED, and should consume the stale timestamp + expect(secureDataStorage.setItem).not.toHaveBeenCalledWith( + SENSITIVE_STORAGE_KEYS.AUTH_STATUS, + AUTH_STATUS.LOCKED, + ); + expect(secureDataStorage.remove).toHaveBeenCalledWith( + SENSITIVE_STORAGE_KEYS.AUTO_LOCK_BACKGROUNDED_AT, + ); + + (AppState as { currentState: typeof previousAppState }).currentState = + previousAppState; + }); + it("should default to 24 hours when no timer preference is persisted", async () => { const { result } = renderHook(() => useAuthenticationStore()); restoreGetAuthStatus(); diff --git a/__tests__/hooks/useAuthCheck.test.tsx b/__tests__/hooks/useAuthCheck.test.tsx index a22f0ae46..1bb755722 100644 --- a/__tests__/hooks/useAuthCheck.test.tsx +++ b/__tests__/hooks/useAuthCheck.test.tsx @@ -8,12 +8,14 @@ import { getAutoLockTimer, getDevAutoLockTimerMs, recordBackgroundedAt, + recordDevInteraction, } from "services/autoLock"; jest.mock("services/autoLock", () => ({ getAutoLockTimer: jest.fn(), getDevAutoLockTimerMs: jest.fn().mockResolvedValue(null), recordBackgroundedAt: jest.fn().mockResolvedValue(undefined), + recordDevInteraction: jest.fn(), })); const flushMicrotasks = async () => { @@ -172,4 +174,85 @@ describe("useAuthCheck", () => { expect(mockGetAuthStatus).toHaveBeenCalled(); unmount(); }); + + it("idle-locks while foregrounded after the timer with no interaction", async () => { + // Short timer (1s) via the dev override so idle elapses within the test + (getDevAutoLockTimerMs as jest.Mock).mockResolvedValue(1000); + const { unmount } = renderAuthCheck(); + + await act(async () => { + jest.advanceTimersByTime(400); // let the periodic check interval set up + await flushMicrotasks(); + }); + await act(async () => { + // No interaction; advance well past the 1s idle timeout so a periodic + // check observes the idle and soft-locks + jest.advanceTimersByTime(6000); + await flushMicrotasks(); + }); + + expect(mockSoftLock).toHaveBeenCalled(); + unmount(); + }); + + it("does NOT idle-lock before the timer elapses", async () => { + // Long timer (100s) so the elapsed idle stays under it + (getDevAutoLockTimerMs as jest.Mock).mockResolvedValue(100000); + const { unmount } = renderAuthCheck(); + + await act(async () => { + jest.advanceTimersByTime(400); + await flushMicrotasks(); + }); + await act(async () => { + jest.advanceTimersByTime(6000); + await flushMicrotasks(); + }); + + expect(mockSoftLock).not.toHaveBeenCalled(); + unmount(); + }); + + it("resets the idle clock when the wallet becomes unlocked", async () => { + // The lock screen / overlay sits outside this provider's PanResponder, so + // its touches don't update the idle clock — unlock must reset it (proxied + // here by recordDevInteraction, which the reset effect calls alongside + // resetting lastInteractionRef) or a fresh session would re-lock at once. + const { unmount } = renderAuthCheck(); + + await act(async () => { + useAuthenticationStore.setState({ authStatus: AUTH_STATUS.LOCKED }); + await flushMicrotasks(); + }); + (recordDevInteraction as jest.Mock).mockClear(); + + await act(async () => { + useAuthenticationStore.setState({ + authStatus: AUTH_STATUS.AUTHENTICATED, + }); + await flushMicrotasks(); + }); + + expect(recordDevInteraction).toHaveBeenCalled(); + unmount(); + }); + + it("does NOT idle-lock for the NONE / IMMEDIATELY presets", async () => { + // No dev override; NONE has a null duration → never idle-locks + (getDevAutoLockTimerMs as jest.Mock).mockResolvedValue(null); + (getAutoLockTimer as jest.Mock).mockResolvedValue(AUTO_LOCK_TIMER.NONE); + const { unmount } = renderAuthCheck(); + + await act(async () => { + jest.advanceTimersByTime(400); + await flushMicrotasks(); + }); + await act(async () => { + jest.advanceTimersByTime(6000); + await flushMicrotasks(); + }); + + expect(mockSoftLock).not.toHaveBeenCalled(); + unmount(); + }); }); diff --git a/src/components/screens/HomeScreen/AutoLockDevTimers.tsx b/src/components/screens/HomeScreen/AutoLockDevTimers.tsx index 0c3f90476..1341bb2f9 100644 --- a/src/components/screens/HomeScreen/AutoLockDevTimers.tsx +++ b/src/components/screens/HomeScreen/AutoLockDevTimers.tsx @@ -1,27 +1,29 @@ /* =========================================================================== * TODO / FIXME: TEMPORARY DEV-ONLY auto-lock countdown readout. * !!! REMOVE THIS FILE (and its usage in HomeScreen.tsx) BEFORE MERGING TO - * PRODUCTION !!! Shows the configured auto-lock timer and the live countdown - * to the hash-key hard expiry so QA can watch the lock flows. + * PRODUCTION !!! Shows the live idle countdown (time until the foreground + * auto-lock fires, resetting on interaction) and the countdown to the + * hash-key hard expiry, so QA can watch the lock flows. * =========================================================================== */ import { Text } from "components/sds/Typography"; import { AUTO_LOCK_TIMER_MS } from "config/constants"; import React, { useEffect, useState } from "react"; import { View } from "react-native"; -import { getAutoLockTimer, getDevAutoLockTimerMs } from "services/autoLock"; +import { + getAutoLockTimer, + getDevAutoLockTimerMs, + getDevLastInteractionAt, +} from "services/autoLock"; import { getHashKey } from "services/storage/helpers"; const TICK_MS = 1000; const NEVER_LABEL = "Never"; +const BACKGROUND_ONLY_LABEL = "on background"; const EMPTY_LABEL = "—"; -const formatSeconds = (ms: number | null): string => { - if (ms === null) { - return NEVER_LABEL; - } - return `${Math.max(0, Math.ceil(ms / 1000))}s`; -}; +const formatSeconds = (ms: number): string => + `${Math.max(0, Math.ceil(ms / 1000))}s`; export const AutoLockDevTimers: React.FC = () => { const [autoLockMs, setAutoLockMs] = useState(null); @@ -54,12 +56,24 @@ export const AutoLockDevTimers: React.FC = () => { }; }, []); + // Idle countdown: time left until the foreground auto-lock fires, measured + // from the last interaction. NONE never idle-locks; IMMEDIATELY is + // background-only (no foreground countdown). + let idleLabel: string; + if (autoLockMs === null) { + idleLabel = NEVER_LABEL; + } else if (autoLockMs === 0) { + idleLabel = BACKGROUND_ONLY_LABEL; + } else { + idleLabel = formatSeconds(autoLockMs - (now - getDevLastInteractionAt())); + } + const hashRemainingMs = hashExpiresAt === null ? null : hashExpiresAt - now; return ( - {`auto-lock (bg): ${formatSeconds(autoLockMs)}`} + {`idle lock in: ${idleLabel}`} {`hash expires in: ${ diff --git a/src/ducks/auth.ts b/src/ducks/auth.ts index 0fd5740be..68f1d7d2f 100644 --- a/src/ducks/auth.ts +++ b/src/ducks/auth.ts @@ -522,8 +522,14 @@ const getAuthStatus = async (): Promise => { devAutoLockTimerMs ?? AUTO_LOCK_TIMER_MS[autoLockTimer]; const elapsedInBackground = Date.now() - backgroundedAt; + // Only POSITIVE timed durations lock here. IMMEDIATELY (0) is + // background-only: it's locked proactively on the background transition + // (useAuthCheck) and persisted, so it must NOT be re-derived from a + // lingering timestamp — otherwise elapsed >= 0 would re-lock a fresh + // foreground session and make the app unusable. NONE (null) never locks. if ( autoLockTimerMs !== null && + autoLockTimerMs > 0 && elapsedInBackground >= autoLockTimerMs && temporaryStore ) { diff --git a/src/hooks/useAuthCheck.ts b/src/hooks/useAuthCheck.ts index dcbd06c73..02c049d80 100644 --- a/src/hooks/useAuthCheck.ts +++ b/src/hooks/useAuthCheck.ts @@ -1,4 +1,4 @@ -import { AUTO_LOCK_TIMER } from "config/constants"; +import { AUTO_LOCK_TIMER, AUTO_LOCK_TIMER_MS } from "config/constants"; import { logger } from "config/logger"; import { AUTH_STATUS } from "config/types"; import { useAuthenticationStore } from "ducks/auth"; @@ -15,6 +15,8 @@ import { // TODO/FIXME: dev-only override — remove before production getDevAutoLockTimerMs, recordBackgroundedAt, + // TODO/FIXME: dev-only idle-countdown readout — remove before production + recordDevInteraction, } from "services/autoLock"; // Delay before lifting the native privacy shield on foreground, giving a @@ -64,10 +66,33 @@ const useAuthCheck = () => { lastCheckRef.current = now; try { // The store action is the single funnel for lock transitions: it - // soft-locks atomically when the auto-lock timer fired (preserving the - // mounted screens under the overlay) and navigates to the lock screen - // when the hash key hard-expired. - await getAuthStatus(); + // soft-locks atomically when the background auto-lock timer fired + // (preserving the mounted screens under the overlay) and navigates to + // the lock screen when the hash key hard-expired. + const status = await getAuthStatus(); + + // Foreground-idle auto-lock: while the app is active, lock after the + // configured duration with no user interaction (touches reset + // lastInteractionRef via the app-wide PanResponder). Background time is + // handled by getAuthStatus above; here we cover an open-but-idle + // session. Only timed presets idle-lock — IMMEDIATELY (0, background- + // only) and NONE (null) are skipped. + if ( + status === AUTH_STATUS.AUTHENTICATED && + AppState.currentState === "active" + ) { + const devAutoLockTimerMs = await getDevAutoLockTimerMs(); + const autoLockTimer = await getAutoLockTimer(); + const timerMs = devAutoLockTimerMs ?? AUTO_LOCK_TIMER_MS[autoLockTimer]; + + if ( + timerMs !== null && + timerMs > 0 && + Date.now() - lastInteractionRef.current >= timerMs + ) { + await useAuthenticationStore.getState().softLock(); + } + } } catch (error) { logger.error( "useAuthCheck.checkAuth", @@ -209,6 +234,22 @@ const useAuthCheck = () => { }; }, [setupCheckInterval, checkAuth]); + /** + * Reset the idle clock whenever the wallet becomes unlocked. The user is + * actively present at unlock, but the lock screen / overlay sits outside + * this provider's PanResponder, so its touches don't update + * lastInteractionRef — without this the idle timer would still hold its + * pre-lock (often already-elapsed) value and immediately re-lock. + */ + useEffect(() => { + if (authStatus === AUTH_STATUS.AUTHENTICATED) { + lastInteractionRef.current = Date.now(); + setIsActive(true); + // TODO/FIXME: dev-only — keep the on-screen idle countdown in sync + recordDevInteraction(); + } + }, [authStatus]); + /** * Monitor user interaction and update active status accordingly. */ @@ -231,6 +272,8 @@ const useAuthCheck = () => { const updateLastInteraction = () => { lastInteractionRef.current = Date.now(); setIsActive(true); + // TODO/FIXME: dev-only — feeds the on-screen idle countdown readout + recordDevInteraction(); }; panResponderRef.current = PanResponder.create({ diff --git a/src/services/autoLock.ts b/src/services/autoLock.ts index 81e1c1797..d9be6d1e7 100644 --- a/src/services/autoLock.ts +++ b/src/services/autoLock.ts @@ -168,6 +168,15 @@ const setDevHashKeyTtlSeconds = async (seconds: number): Promise => { }), ); }; + +// TEMP/REMOVE: last user-interaction timestamp, mirrored from useAuthCheck so +// the on-screen dev readout can show the live idle countdown without forcing +// app-wide re-renders. Module-level on purpose (no React state). +let devLastInteractionAt = Date.now(); +const recordDevInteraction = (): void => { + devLastInteractionAt = Date.now(); +}; +const getDevLastInteractionAt = (): number => devLastInteractionAt; /* ====================== END TEMPORARY DEV-ONLY BLOCK ====================== */ export { @@ -183,4 +192,6 @@ export { setDevAutoLockTimerSeconds, clearDevAutoLockTimer, setDevHashKeyTtlSeconds, + recordDevInteraction, + getDevLastInteractionAt, }; From 8281249b68b8f3d2efb0e62f7d630e1b307fc912 Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Wed, 17 Jun 2026 10:50:26 -0300 Subject: [PATCH 11/19] capture presses and nav to reset idle lock count --- .../components/screens/LockScreen.test.tsx | 37 +++++++++++ __tests__/hooks/useAuthCheck.test.tsx | 51 +++++++++++++++ src/components/screens/LockScreen.tsx | 10 ++- src/ducks/auth.ts | 14 ++++- src/hooks/useAuthCheck.ts | 62 +++++++++++++------ 5 files changed, 151 insertions(+), 23 deletions(-) diff --git a/__tests__/components/screens/LockScreen.test.tsx b/__tests__/components/screens/LockScreen.test.tsx index 97d7a2ea1..74b7371b3 100644 --- a/__tests__/components/screens/LockScreen.test.tsx +++ b/__tests__/components/screens/LockScreen.test.tsx @@ -65,6 +65,7 @@ describe("LockScreen", () => { signInMethod: LoginType.FACE, isLoading: false, error: null, + isForegroundIdleLock: false, }); usePreferencesStore.setState({ isBiometricsEnabled: true }); }); @@ -102,6 +103,42 @@ describe("LockScreen", () => { expect(mockSignIn).not.toHaveBeenCalled(); }); + it("does not auto-prompt on mount for a foreground-idle lock", async () => { + // The user stayed in the app and idled out — no unprompted Face ID + useAuthenticationStore.setState({ isForegroundIdleLock: true }); + + renderLockScreen(); + + // Give the mount effect a chance to (not) fire + await waitFor(() => { + expect(mockSignIn).not.toHaveBeenCalled(); + }); + expect(mockVerifyActionWithBiometrics).not.toHaveBeenCalled(); + }); + + it("still re-prompts on return from background after a foreground-idle lock", async () => { + useAuthenticationStore.setState({ isForegroundIdleLock: true }); + + renderLockScreen(); + + // No mount prompt for the idle lock... + await waitFor(() => { + expect(mockVerifyActionWithBiometrics).not.toHaveBeenCalled(); + }); + + // ...but returning from the background still prompts (coming from bg) + const appStateHandlers = ( + AppState.addEventListener as jest.Mock + ).mock.calls.map(([, handler]) => handler as (state: string) => void); + + appStateHandlers.forEach((handler) => handler("background")); + appStateHandlers.forEach((handler) => handler("active")); + + await waitFor(() => { + expect(mockVerifyActionWithBiometrics).toHaveBeenCalledTimes(1); + }); + }); + it("re-prompts biometrics when the app returns from the background", async () => { renderLockScreen(); diff --git a/__tests__/hooks/useAuthCheck.test.tsx b/__tests__/hooks/useAuthCheck.test.tsx index 1bb755722..24cdde64c 100644 --- a/__tests__/hooks/useAuthCheck.test.tsx +++ b/__tests__/hooks/useAuthCheck.test.tsx @@ -18,6 +18,19 @@ jest.mock("services/autoLock", () => ({ recordDevInteraction: jest.fn(), })); +// components/App pulls in the full app tree (RootNavigator → native-only +// modules); stub it to just the navigationRef the hook subscribes to. +const mockNavUnsubscribe = jest.fn(); +const mockNavAddListener = jest.fn<() => void, [string, () => void]>( + () => mockNavUnsubscribe, +); +jest.mock("components/App", () => ({ + navigationRef: { + addListener: (event: string, callback: () => void) => + mockNavAddListener(event, callback), + }, +})); + const flushMicrotasks = async () => { await Promise.resolve(); await Promise.resolve(); @@ -237,6 +250,44 @@ describe("useAuthCheck", () => { unmount(); }); + it("subscribes to navigation changes as an interaction signal", () => { + const { unmount } = renderAuthCheck(); + + expect(mockNavAddListener).toHaveBeenCalledWith( + "state", + expect.any(Function), + ); + unmount(); + }); + + it("resets the idle clock on a navigation change so a multi-screen flow is not idle-locked", async () => { + // Short timer (1s); the user navigates just before it would elapse + (getDevAutoLockTimerMs as jest.Mock).mockResolvedValue(1000); + const { unmount } = renderAuthCheck(); + + // The hook registers a "state" listener; grab the callback the same way + // the navigation container would invoke it on a route change. + const navListener = mockNavAddListener.mock.calls.find( + ([eventName]) => eventName === "state", + )?.[1] as () => void; + + await act(async () => { + jest.advanceTimersByTime(800); + // A navigation occurs before the 1s idle elapses → resets the clock + navListener(); + await flushMicrotasks(); + }); + await act(async () => { + // Another 800ms (1.6s total, but only 800ms since the nav) — still under + // the timer, so no idle-lock + jest.advanceTimersByTime(800); + await flushMicrotasks(); + }); + + expect(mockSoftLock).not.toHaveBeenCalled(); + unmount(); + }); + it("does NOT idle-lock for the NONE / IMMEDIATELY presets", async () => { // No dev override; NONE has a null duration → never idle-locks (getDevAutoLockTimerMs as jest.Mock).mockResolvedValue(null); diff --git a/src/components/screens/LockScreen.tsx b/src/components/screens/LockScreen.tsx index 676131a72..4d5b2a525 100644 --- a/src/components/screens/LockScreen.tsx +++ b/src/components/screens/LockScreen.tsx @@ -179,9 +179,15 @@ export const LockScreenContent: React.FC = ({ ]); // Auto-prompt biometrics when landing on this screen with the app active - // (foreground auto-lock, manual lock, or cold start) + // (cold start or a lock that happened while backgrounded). Skipped for a + // foreground-idle lock: the user stayed in the app and idled out, so popping + // an unprompted Face ID would be jarring — they can tap to unlock, and the + // return-from-background effect below re-prompts on the next foreground. useEffect(() => { - if (AppState.currentState === "active") { + if ( + AppState.currentState === "active" && + !useAuthenticationStore.getState().isForegroundIdleLock + ) { attemptBiometricUnlock(); } }, [attemptBiometricUnlock]); diff --git a/src/ducks/auth.ts b/src/ducks/auth.ts index 68f1d7d2f..85cdc67b0 100644 --- a/src/ducks/auth.ts +++ b/src/ducks/auth.ts @@ -219,6 +219,12 @@ interface AuthState { // IMMEDIATELY on backgrounding): the navigation tree stays mounted under a // lock overlay so the user resumes exactly where they were after unlocking. isSoftLocked: boolean; + // True only when the soft lock was triggered by the foreground-idle timer + // (the user stayed in the app and let it idle out). The lock screen uses + // this to suppress the biometric auto-prompt: an unprompted Face ID popping + // up while the user is sitting in the app is jarring. Biometrics still + // auto-prompt on cold start and on return from the background. + isForegroundIdleLock: boolean; allAccounts: Account[]; // Active account state @@ -291,7 +297,7 @@ interface ImportSecretKeyParams { */ interface AuthActions { logout: (shouldWipeAllData?: boolean) => void; - softLock: () => Promise; + softLock: (options?: { foregroundIdle?: boolean }) => Promise; signUp: (params: SignUpParams) => Promise; signIn: (params: SignInParams) => Promise; importWallet: (params: ImportWalletParams) => Promise; @@ -354,6 +360,7 @@ const initialState: Omit = { error: null, authStatus: AUTH_STATUS.NOT_AUTHENTICATED, isSoftLocked: false, + isForegroundIdleLock: false, allAccounts: [], // Active account initial state account: null, @@ -2170,7 +2177,7 @@ export const useAuthenticationStore = create()((set, get) => ({ * errors; signing is independently blocked while LOCKED. Persisting LOCKED * covers cold starts (which fall back to the LockScreen route). */ - softLock: async () => { + softLock: async (options?: { foregroundIdle?: boolean }) => { Keyboard.dismiss(); // Atomic update: RootNavigator must never see LOCKED && !isSoftLocked, @@ -2178,6 +2185,9 @@ export const useAuthenticationStore = create()((set, get) => ({ set({ authStatus: AUTH_STATUS.LOCKED, isSoftLocked: true, + // Only a foreground-idle lock suppresses the lock screen's biometric + // auto-prompt; background / IMMEDIATELY / cold-start locks still prompt. + isForegroundIdleLock: options?.foregroundIdle ?? false, isLoading: false, }); diff --git a/src/hooks/useAuthCheck.ts b/src/hooks/useAuthCheck.ts index 02c049d80..c79072275 100644 --- a/src/hooks/useAuthCheck.ts +++ b/src/hooks/useAuthCheck.ts @@ -1,3 +1,4 @@ +import { navigationRef } from "components/App"; import { AUTO_LOCK_TIMER, AUTO_LOCK_TIMER_MS } from "config/constants"; import { logger } from "config/logger"; import { AUTH_STATUS } from "config/types"; @@ -48,6 +49,18 @@ const useAuthCheck = () => { const lastCheckRef = useRef(Date.now()); const panResponderRef = useRef(null); + /** + * Mark "now" as the last user interaction. Called from every activity + * signal (touches, navigation, unlock) so the foreground-idle timer only + * fires after a genuinely idle stretch. + */ + const recordInteraction = useCallback(() => { + lastInteractionRef.current = Date.now(); + setIsActive(true); + // TODO/FIXME: dev-only — feeds the on-screen idle countdown readout + recordDevInteraction(); + }, []); + /** * Check the authentication status and navigate to the lock screen if the auth hash is expired. */ @@ -90,7 +103,11 @@ const useAuthCheck = () => { timerMs > 0 && Date.now() - lastInteractionRef.current >= timerMs ) { - await useAuthenticationStore.getState().softLock(); + // foregroundIdle: the user stayed in the app and idled out — the + // lock screen suppresses its biometric auto-prompt for this case. + await useAuthenticationStore + .getState() + .softLock({ foregroundIdle: true }); } } } catch (error) { @@ -243,12 +260,21 @@ const useAuthCheck = () => { */ useEffect(() => { if (authStatus === AUTH_STATUS.AUTHENTICATED) { - lastInteractionRef.current = Date.now(); - setIsActive(true); - // TODO/FIXME: dev-only — keep the on-screen idle countdown in sync - recordDevInteraction(); + recordInteraction(); } - }, [authStatus]); + }, [authStatus, recordInteraction]); + + /** + * Treat navigation as user activity. Some controls (gesture-handler-based + * buttons, swipeables, bottom sheets) bypass the JS responder system and + * never reach the PanResponder below, so a route change is often the only + * reliable signal that the user acted — without this a user could move + * through several screens and still be idle-locked mid-flow. + */ + useEffect(() => { + const unsubscribe = navigationRef.addListener("state", recordInteraction); + return unsubscribe; + }, [recordInteraction]); /** * Monitor user interaction and update active status accordingly. @@ -266,23 +292,21 @@ const useAuthCheck = () => { }, []); /** - * Initialize PanResponder to capture touch interactions and update the last interaction timestamp. + * Initialize PanResponder to observe touch interactions and update the last + * interaction timestamp. The *Capture variants run during the capture phase + * (root → target) so the root sees EVERY touch start/move — including taps + * on buttons and list items that would otherwise claim the responder before + * a bubble-phase handler is asked. Returning false means it observes without + * stealing the gesture from the child. */ useEffect(() => { - const updateLastInteraction = () => { - lastInteractionRef.current = Date.now(); - setIsActive(true); - // TODO/FIXME: dev-only — feeds the on-screen idle countdown readout - recordDevInteraction(); - }; - panResponderRef.current = PanResponder.create({ - onStartShouldSetPanResponder: () => { - updateLastInteraction(); + onStartShouldSetPanResponderCapture: () => { + recordInteraction(); return false; }, - onMoveShouldSetPanResponder: () => { - updateLastInteraction(); + onMoveShouldSetPanResponderCapture: () => { + recordInteraction(); return false; }, onPanResponderTerminationRequest: () => true, @@ -296,7 +320,7 @@ const useAuthCheck = () => { }, INITIAL_SETUP_DELAY); return () => clearTimeout(initialCheckTimeout); - }, [checkAuth]); + }, [checkAuth, recordInteraction]); /** * Provide a function to manually trigger an auth check. From e781e130ca00a38f5d29e3d92322442623175561 Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Wed, 17 Jun 2026 11:20:50 -0300 Subject: [PATCH 12/19] adjust comments from codex for stale state --- src/ducks/auth.ts | 13 +++++++++---- src/hooks/useAuthCheck.ts | 10 +++++++++- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/ducks/auth.ts b/src/ducks/auth.ts index 85cdc67b0..cf351acc1 100644 --- a/src/ducks/auth.ts +++ b/src/ducks/auth.ts @@ -61,6 +61,7 @@ import { // TODO/FIXME: dev-only auto-lock timer override — remove before production getDevAutoLockTimerMs, getHashKeyExpirationMs, + persistAutoLockTimer, } from "services/autoLock"; import { getAccount } from "services/stellar"; import { @@ -2142,10 +2143,14 @@ export const useAuthenticationStore = create()((set, get) => ({ await dataStorage.remove(STORAGE_KEYS.COLLECTIBLES_LIST); // Reset auto-lock so the next wallet on this device doesn't - // inherit the previous user's timer - usePreferencesStore - .getState() - .setAutoLockTimer(DEFAULT_AUTO_LOCK_TIMER); + // inherit the previous user's timer. Await the secure-mirror write + // (the source of truth for getAuthStatus / generateHashKey) so an + // interrupted wipe can't leave a weaker policy — e.g. NONE's + // never-expire — behind for the next wallet. + usePreferencesStore.setState({ + autoLockTimer: DEFAULT_AUTO_LOCK_TIMER, + }); + await persistAutoLockTimer(DEFAULT_AUTO_LOCK_TIMER); await clearBackgroundedAt(); await clearBiometricsData(); diff --git a/src/hooks/useAuthCheck.ts b/src/hooks/useAuthCheck.ts index c79072275..768842b76 100644 --- a/src/hooks/useAuthCheck.ts +++ b/src/hooks/useAuthCheck.ts @@ -222,7 +222,15 @@ const useAuthCheck = () => { ), ) .finally(() => { - setTimeout(hidePrivacyShield, SHIELD_REVEAL_DELAY); + setTimeout(() => { + // If the user re-backgrounded before this resolved, the native + // side has re-shown the shield for the new background period — + // don't lift it, or the next resume would briefly reveal the + // unlocked tree while the lock decision runs. + if (AppState.currentState === "active") { + hidePrivacyShield(); + } + }, SHIELD_REVEAL_DELAY); }); setTimeout(() => { From 3d82b3cc9010b9cc9bc38e21422260bf259944be Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Wed, 17 Jun 2026 14:20:02 -0300 Subject: [PATCH 13/19] suprress biometric prompt on manual logout --- .../components/screens/LockScreen.test.tsx | 13 +++++----- __tests__/ducks/auth.test.ts | 5 ++++ src/components/screens/LockScreen.tsx | 11 ++++---- src/ducks/auth.ts | 26 +++++++++++-------- src/hooks/useAuthCheck.ts | 6 ++--- 5 files changed, 36 insertions(+), 25 deletions(-) diff --git a/__tests__/components/screens/LockScreen.test.tsx b/__tests__/components/screens/LockScreen.test.tsx index 74b7371b3..4f603793f 100644 --- a/__tests__/components/screens/LockScreen.test.tsx +++ b/__tests__/components/screens/LockScreen.test.tsx @@ -65,7 +65,7 @@ describe("LockScreen", () => { signInMethod: LoginType.FACE, isLoading: false, error: null, - isForegroundIdleLock: false, + suppressBiometricAutoPrompt: false, }); usePreferencesStore.setState({ isBiometricsEnabled: true }); }); @@ -103,9 +103,10 @@ describe("LockScreen", () => { expect(mockSignIn).not.toHaveBeenCalled(); }); - it("does not auto-prompt on mount for a foreground-idle lock", async () => { - // The user stayed in the app and idled out — no unprompted Face ID - useAuthenticationStore.setState({ isForegroundIdleLock: true }); + it("does not auto-prompt on mount when the lock was user-initiated (idle or manual)", async () => { + // The user stayed in the app and idled out, or locked manually — no + // unprompted Face ID + useAuthenticationStore.setState({ suppressBiometricAutoPrompt: true }); renderLockScreen(); @@ -116,8 +117,8 @@ describe("LockScreen", () => { expect(mockVerifyActionWithBiometrics).not.toHaveBeenCalled(); }); - it("still re-prompts on return from background after a foreground-idle lock", async () => { - useAuthenticationStore.setState({ isForegroundIdleLock: true }); + it("still re-prompts on return from background after a user-initiated lock", async () => { + useAuthenticationStore.setState({ suppressBiometricAutoPrompt: true }); renderLockScreen(); diff --git a/__tests__/ducks/auth.test.ts b/__tests__/ducks/auth.test.ts index 6f83d2841..23c7af3eb 100644 --- a/__tests__/ducks/auth.test.ts +++ b/__tests__/ducks/auth.test.ts @@ -1431,6 +1431,11 @@ describe("auth duck", () => { SENSITIVE_STORAGE_KEYS.AUTH_STATUS, AUTH_STATUS.LOCKED, ); + + // A manual lock suppresses the lock screen's biometric auto-prompt + expect( + useAuthenticationStore.getState().suppressBiometricAutoPrompt, + ).toBe(true); }); it("should wipe all data on logout when shouldWipeAllData is true", async () => { diff --git a/src/components/screens/LockScreen.tsx b/src/components/screens/LockScreen.tsx index 4d5b2a525..62a4efbeb 100644 --- a/src/components/screens/LockScreen.tsx +++ b/src/components/screens/LockScreen.tsx @@ -179,14 +179,15 @@ export const LockScreenContent: React.FC = ({ ]); // Auto-prompt biometrics when landing on this screen with the app active - // (cold start or a lock that happened while backgrounded). Skipped for a - // foreground-idle lock: the user stayed in the app and idled out, so popping - // an unprompted Face ID would be jarring — they can tap to unlock, and the - // return-from-background effect below re-prompts on the next foreground. + // (cold start or a lock that happened while backgrounded). Skipped when the + // user was actively present — a foreground-idle timeout or a manual + // lock/logout — since popping an unprompted Face ID is jarring there; they + // can tap to unlock, and the return-from-background effect below re-prompts + // on the next foreground. useEffect(() => { if ( AppState.currentState === "active" && - !useAuthenticationStore.getState().isForegroundIdleLock + !useAuthenticationStore.getState().suppressBiometricAutoPrompt ) { attemptBiometricUnlock(); } diff --git a/src/ducks/auth.ts b/src/ducks/auth.ts index cf351acc1..72925285a 100644 --- a/src/ducks/auth.ts +++ b/src/ducks/auth.ts @@ -220,12 +220,12 @@ interface AuthState { // IMMEDIATELY on backgrounding): the navigation tree stays mounted under a // lock overlay so the user resumes exactly where they were after unlocking. isSoftLocked: boolean; - // True only when the soft lock was triggered by the foreground-idle timer - // (the user stayed in the app and let it idle out). The lock screen uses - // this to suppress the biometric auto-prompt: an unprompted Face ID popping - // up while the user is sitting in the app is jarring. Biometrics still - // auto-prompt on cold start and on return from the background. - isForegroundIdleLock: boolean; + // True when the lock happened while the user was actively present in the + // app — a foreground-idle timeout or a manual lock/logout. The lock screen + // uses this to suppress the biometric auto-prompt: an unprompted Face ID is + // jarring when the user just locked it themselves or stepped away. The + // prompt still fires on cold start and on return from the background. + suppressBiometricAutoPrompt: boolean; allAccounts: Account[]; // Active account state @@ -298,7 +298,7 @@ interface ImportSecretKeyParams { */ interface AuthActions { logout: (shouldWipeAllData?: boolean) => void; - softLock: (options?: { foregroundIdle?: boolean }) => Promise; + softLock: (options?: { suppressBiometricPrompt?: boolean }) => Promise; signUp: (params: SignUpParams) => Promise; signIn: (params: SignInParams) => Promise; importWallet: (params: ImportWalletParams) => Promise; @@ -361,7 +361,7 @@ const initialState: Omit = { error: null, authStatus: AUTH_STATUS.NOT_AUTHENTICATED, isSoftLocked: false, - isForegroundIdleLock: false, + suppressBiometricAutoPrompt: false, allAccounts: [], // Active account initial state account: null, @@ -2087,6 +2087,10 @@ export const useAuthenticationStore = create()((set, get) => ({ isLoadingAccount: false, accountError: null, authStatus: AUTH_STATUS.LOCKED, + // The user locked the app themselves — don't auto-prompt + // biometrics on the lock screen; that only fires on cold start + // or return from the background. + suppressBiometricAutoPrompt: true, isLoading: false, }); @@ -2182,7 +2186,7 @@ export const useAuthenticationStore = create()((set, get) => ({ * errors; signing is independently blocked while LOCKED. Persisting LOCKED * covers cold starts (which fall back to the LockScreen route). */ - softLock: async (options?: { foregroundIdle?: boolean }) => { + softLock: async (options?: { suppressBiometricPrompt?: boolean }) => { Keyboard.dismiss(); // Atomic update: RootNavigator must never see LOCKED && !isSoftLocked, @@ -2190,9 +2194,9 @@ export const useAuthenticationStore = create()((set, get) => ({ set({ authStatus: AUTH_STATUS.LOCKED, isSoftLocked: true, - // Only a foreground-idle lock suppresses the lock screen's biometric + // A foreground-idle lock suppresses the lock screen's biometric // auto-prompt; background / IMMEDIATELY / cold-start locks still prompt. - isForegroundIdleLock: options?.foregroundIdle ?? false, + suppressBiometricAutoPrompt: options?.suppressBiometricPrompt ?? false, isLoading: false, }); diff --git a/src/hooks/useAuthCheck.ts b/src/hooks/useAuthCheck.ts index 768842b76..8beeea87f 100644 --- a/src/hooks/useAuthCheck.ts +++ b/src/hooks/useAuthCheck.ts @@ -103,11 +103,11 @@ const useAuthCheck = () => { timerMs > 0 && Date.now() - lastInteractionRef.current >= timerMs ) { - // foregroundIdle: the user stayed in the app and idled out — the - // lock screen suppresses its biometric auto-prompt for this case. + // The user stayed in the app and idled out — suppress the lock + // screen's biometric auto-prompt for this case. await useAuthenticationStore .getState() - .softLock({ foregroundIdle: true }); + .softLock({ suppressBiometricPrompt: true }); } } } catch (error) { From 9053a4ed5e27c4787625c7da0446f85d428ee8da Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Wed, 17 Jun 2026 15:35:11 -0300 Subject: [PATCH 14/19] wait for lock screen to show to ask for biometric prompt --- .../components/screens/LockScreen.test.tsx | 51 ++++++++++++++++ src/components/screens/LockScreen.tsx | 42 ++++++++++++-- src/helpers/privacyShield.ts | 58 +++++++++++++++++-- src/hooks/useAuthCheck.ts | 11 +++- 4 files changed, 151 insertions(+), 11 deletions(-) diff --git a/__tests__/components/screens/LockScreen.test.tsx b/__tests__/components/screens/LockScreen.test.tsx index 4f603793f..58af25ca5 100644 --- a/__tests__/components/screens/LockScreen.test.tsx +++ b/__tests__/components/screens/LockScreen.test.tsx @@ -22,6 +22,24 @@ jest.mock("services/autoLock", () => ({ applyAutoLockTimerToHashKey: jest.fn().mockResolvedValue(undefined), })); +// Controllable privacy-shield state so we can assert the prompt is held until +// the shield drops on return from background. +let mockShieldVisible = false; +const mockShieldHiddenListeners = new Set<() => void>(); +const emitShieldHidden = () => { + mockShieldVisible = false; + mockShieldHiddenListeners.forEach((listener) => listener()); +}; +jest.mock("helpers/privacyShield", () => ({ + isPrivacyShieldVisible: () => mockShieldVisible, + onPrivacyShieldHidden: (listener: () => void) => { + mockShieldHiddenListeners.add(listener); + return () => mockShieldHiddenListeners.delete(listener); + }, + markPrivacyShieldVisible: jest.fn(), + hidePrivacyShield: jest.fn(), +})); + type LockScreenNavigationProp = NativeStackScreenProps< RootStackParamList, typeof ROOT_NAVIGATOR_ROUTES.LOCK_SCREEN @@ -55,6 +73,8 @@ describe("LockScreen", () => { beforeEach(() => { jest.clearAllMocks(); (AppState as { currentState: string }).currentState = "active"; + mockShieldVisible = false; + mockShieldHiddenListeners.clear(); useAuthenticationStore.setState({ signIn: mockSignIn, @@ -160,6 +180,37 @@ describe("LockScreen", () => { }); }); + it("holds the biometric prompt until the privacy shield drops on return from background", async () => { + renderLockScreen(); + + // Mount prompt fires immediately — the shield isn't up at mount + await waitFor(() => { + expect(mockVerifyActionWithBiometrics).toHaveBeenCalledTimes(1); + }); + + // App backgrounds (shield raised) and returns to the foreground + mockShieldVisible = true; + const appStateHandlers = ( + AppState.addEventListener as jest.Mock + ).mock.calls.map(([, handler]) => handler as (state: string) => void); + + appStateHandlers.forEach((handler) => handler("background")); + appStateHandlers.forEach((handler) => handler("active")); + + // The prompt is held while the shield still covers the wallet + await waitFor(() => { + expect(mockVerifyActionWithBiometrics).toHaveBeenCalledTimes(1); + }); + + // Once the shield drops, the held prompt fires so Face ID appears over + // the now-visible lock screen + emitShieldHidden(); + + await waitFor(() => { + expect(mockVerifyActionWithBiometrics).toHaveBeenCalledTimes(2); + }); + }); + it("does not re-prompt on inactive-to-active transitions (e.g. the biometric overlay itself)", async () => { renderLockScreen(); diff --git a/src/components/screens/LockScreen.tsx b/src/components/screens/LockScreen.tsx index 62a4efbeb..0eb705843 100644 --- a/src/components/screens/LockScreen.tsx +++ b/src/components/screens/LockScreen.tsx @@ -6,6 +6,10 @@ import { ROOT_NAVIGATOR_ROUTES, RootStackParamList } from "config/routes"; import { AUTH_STATUS } from "config/types"; import { useAuthenticationStore, getActiveAccountPublicKey } from "ducks/auth"; import { usePreferencesStore } from "ducks/preferences"; +import { + isPrivacyShieldVisible, + onPrivacyShieldHidden, +} from "helpers/privacyShield"; import useAppTranslation from "hooks/useAppTranslation"; import { useToast } from "providers/ToastProvider"; import React, { useCallback, useEffect, useRef, useState } from "react"; @@ -87,6 +91,9 @@ export const LockScreenContent: React.FC = ({ // "inactive" is intentionally ignored: on iOS the biometric overlay itself // triggers inactive→active transitions, which would re-prompt on cancel. const wasBackgroundedRef = useRef(AppState.currentState !== "active"); + // True when a prompt was requested while the privacy shield was still up; it + // fires once the shield drops so the lock screen is visible behind Face ID. + const pendingPromptRef = useRef(false); // Monitor auth status changes to navigate when unlocked useEffect(() => { @@ -178,6 +185,32 @@ export const LockScreenContent: React.FC = ({ handleUnlock, ]); + /** + * Requests the biometric prompt, but holds it while the native privacy + * shield is still covering the app (return from background) so Face ID + * appears over the visible lock screen rather than the shield. When the + * shield is down it prompts immediately. + */ + const requestBiometricPrompt = useCallback(() => { + if (isPrivacyShieldVisible()) { + pendingPromptRef.current = true; + return; + } + attemptBiometricUnlock(); + }, [attemptBiometricUnlock]); + + // Fire a held prompt once the privacy shield drops. + useEffect(() => { + const unsubscribe = onPrivacyShieldHidden(() => { + if (pendingPromptRef.current) { + pendingPromptRef.current = false; + attemptBiometricUnlock(); + } + }); + + return unsubscribe; + }, [attemptBiometricUnlock]); + // Auto-prompt biometrics when landing on this screen with the app active // (cold start or a lock that happened while backgrounded). Skipped when the // user was actively present — a foreground-idle timeout or a manual @@ -189,9 +222,9 @@ export const LockScreenContent: React.FC = ({ AppState.currentState === "active" && !useAuthenticationStore.getState().suppressBiometricAutoPrompt ) { - attemptBiometricUnlock(); + requestBiometricPrompt(); } - }, [attemptBiometricUnlock]); + }, [requestBiometricPrompt]); // Re-prompt biometrics when the app returns from the background while this // screen is showing — like banking apps do @@ -206,14 +239,15 @@ export const LockScreenContent: React.FC = ({ // Re-prompting on EVERY return from the background is intentional // (banking-app behavior): the user landing on a locked wallet wants // to get in, and a cancelled prompt stays cancelled until they leave. + // Held until the privacy shield drops so the lock screen is visible. wasBackgroundedRef.current = false; hasAutoPromptedRef.current = false; - attemptBiometricUnlock(); + requestBiometricPrompt(); } }); return () => subscription.remove(); - }, [attemptBiometricUnlock]); + }, [requestBiometricPrompt]); const handleForgotPassword = useCallback(() => { setIsForgotPasswordModalVisible(true); diff --git a/src/helpers/privacyShield.ts b/src/helpers/privacyShield.ts index 5a5571fbe..102b1fe11 100644 --- a/src/helpers/privacyShield.ts +++ b/src/helpers/privacyShield.ts @@ -4,18 +4,64 @@ interface PrivacyShieldModule { hide?: () => Promise; } +const getModule = (): PrivacyShieldModule | undefined => + NativeModules.PrivacyShield as PrivacyShieldModule | undefined; + +// JS mirror of whether the native shield is currently covering the app. The +// native side shows it deterministically when the app backgrounds; callers +// mark it here so they can tell whether the wallet is still hidden (e.g. to +// hold a biometric prompt until the lock screen is actually visible). +let shieldVisible = false; +const shieldHiddenListeners = new Set<() => void>(); + +/** + * Records that the native shield was raised (call on backgrounding). No-op + * when the native module is unavailable so platforms without a shield don't + * report a phantom cover. + */ +export const markPrivacyShieldVisible = (): void => { + if (getModule()?.hide) { + shieldVisible = true; + } +}; + +/** Whether the native shield is believed to be covering the app right now. */ +export const isPrivacyShieldVisible = (): boolean => shieldVisible; + +/** + * Subscribes to the moment the shield is dismissed. Returns an unsubscribe + * function. Fires once per hidePrivacyShield call. + */ +export const onPrivacyShieldHidden = (listener: () => void): (() => void) => { + shieldHiddenListeners.add(listener); + return () => { + shieldHiddenListeners.delete(listener); + }; +}; + /** * Dismisses the native privacy shield (iOS overlay window / Android overlay * view) once the JS auto-lock decision has settled, so the wallet is revealed * only after a soft-lock overlay (if any) has mounted. No-op if the native * module isn't available — the native side has its own fallback timer. + * Notifies onPrivacyShieldHidden listeners once the shield is gone. */ export const hidePrivacyShield = (): void => { - const privacyShield = NativeModules.PrivacyShield as - | PrivacyShieldModule - | undefined; + const finish = () => { + shieldVisible = false; + shieldHiddenListeners.forEach((listener) => listener()); + }; + + const privacyShield = getModule(); + if (!privacyShield?.hide) { + finish(); + return; + } - privacyShield?.hide?.().catch(() => { - // Best effort — the native fallback timer removes the shield regardless - }); + privacyShield + .hide() + .catch(() => { + // Best effort — the native fallback timer removes the shield regardless + }) + .finally(finish); }; diff --git a/src/hooks/useAuthCheck.ts b/src/hooks/useAuthCheck.ts index 8beeea87f..218c7c1c0 100644 --- a/src/hooks/useAuthCheck.ts +++ b/src/hooks/useAuthCheck.ts @@ -3,7 +3,10 @@ import { AUTO_LOCK_TIMER, AUTO_LOCK_TIMER_MS } from "config/constants"; import { logger } from "config/logger"; import { AUTH_STATUS } from "config/types"; import { useAuthenticationStore } from "ducks/auth"; -import { hidePrivacyShield } from "helpers/privacyShield"; +import { + hidePrivacyShield, + markPrivacyShieldVisible, +} from "helpers/privacyShield"; import { useEffect, useRef, useState, useCallback } from "react"; import { AppState, @@ -153,6 +156,12 @@ const useAuthCheck = () => { */ useEffect(() => { const handleAppStateChange = (nextAppState: AppStateStatus) => { + // Mirror the native shield: it's raised whenever the app backgrounds, so + // the lock screen can hold its biometric prompt until the shield drops. + if (nextAppState === "background") { + markPrivacyShieldVisible(); + } + // Record when the app goes to the background so the auto-lock timer can // be evaluated on the next foreground or cold start. Intentionally NOT // on "inactive": iOS fires it for control center, app-switcher peeks, From 7ab2ad5d8294900371de428f5540f7a5fdcb9769 Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Wed, 17 Jun 2026 17:53:05 -0300 Subject: [PATCH 15/19] clear auto lock values on wallet import and new sign up --- __tests__/ducks/auth.test.ts | 8 ++ __tests__/hooks/useAuthCheck.test.tsx | 45 +++++++- src/components/screens/LockScreen.tsx | 16 +-- src/ducks/auth.ts | 37 +++++-- src/hooks/useAuthCheck.ts | 141 ++++++++++++++------------ src/services/autoLock.ts | 17 ++++ 6 files changed, 183 insertions(+), 81 deletions(-) diff --git a/__tests__/ducks/auth.test.ts b/__tests__/ducks/auth.test.ts index 23c7af3eb..f9d3d09f9 100644 --- a/__tests__/ducks/auth.test.ts +++ b/__tests__/ducks/auth.test.ts @@ -2,6 +2,7 @@ import { NavigationContainerRef } from "@react-navigation/native"; import { act, renderHook } from "@testing-library/react-hooks"; import { AUTO_LOCK_TIMER, + DEFAULT_AUTO_LOCK_TIMER, NETWORKS, STORAGE_KEYS, SENSITIVE_STORAGE_KEYS, @@ -116,6 +117,7 @@ jest.mock("ducks/preferences", () => ({ isBiometricsEnabled: false, setAutoLockTimer: jest.fn(), })), + setState: jest.fn(), }, })); @@ -1475,6 +1477,12 @@ describe("auth duck", () => { expect(dataStorage.remove).toHaveBeenCalledWith( STORAGE_KEYS.COLLECTIBLES_LIST, ); + + // Auto-lock is reset to the default so the next wallet doesn't inherit + // the wiped wallet's timer + expect(usePreferencesStore.setState).toHaveBeenCalledWith({ + autoLockTimer: DEFAULT_AUTO_LOCK_TIMER, + }); }); it("should call resetRoot to AUTH_STACK on logout(true) even though ...initialState clears navigationRef", async () => { diff --git a/__tests__/hooks/useAuthCheck.test.tsx b/__tests__/hooks/useAuthCheck.test.tsx index 24cdde64c..43530c69b 100644 --- a/__tests__/hooks/useAuthCheck.test.tsx +++ b/__tests__/hooks/useAuthCheck.test.tsx @@ -7,6 +7,7 @@ import { AppState } from "react-native"; import { getAutoLockTimer, getDevAutoLockTimerMs, + hasPersistedSession, recordBackgroundedAt, recordDevInteraction, } from "services/autoLock"; @@ -14,6 +15,7 @@ import { jest.mock("services/autoLock", () => ({ getAutoLockTimer: jest.fn(), getDevAutoLockTimerMs: jest.fn().mockResolvedValue(null), + hasPersistedSession: jest.fn().mockResolvedValue(false), recordBackgroundedAt: jest.fn().mockResolvedValue(undefined), recordDevInteraction: jest.fn(), })); @@ -32,8 +34,12 @@ jest.mock("components/App", () => ({ })); const flushMicrotasks = async () => { - await Promise.resolve(); - await Promise.resolve(); + // Several rounds so the async background handler (session check → record → + // timer read → soft lock) fully settles. + for (let i = 0; i < 6; i += 1) { + // eslint-disable-next-line no-await-in-loop + await Promise.resolve(); + } }; describe("useAuthCheck", () => { @@ -55,6 +61,7 @@ describe("useAuthCheck", () => { AUTO_LOCK_TIMER.TWENTY_FOUR_HOURS, ); (getDevAutoLockTimerMs as jest.Mock).mockResolvedValue(null); + (hasPersistedSession as jest.Mock).mockResolvedValue(false); useAuthenticationStore.setState({ authStatus: AUTH_STATUS.AUTHENTICATED, @@ -122,6 +129,40 @@ describe("useAuthCheck", () => { unmount(); }); + it("records the timestamp when a session is persisted but the status has not hydrated yet", async () => { + // Cold launch into an existing session, backgrounded before getAuthStatus + // runs: zustand still holds the initial NOT_AUTHENTICATED status. + useAuthenticationStore.setState({ + authStatus: AUTH_STATUS.NOT_AUTHENTICATED, + }); + (hasPersistedSession as jest.Mock).mockResolvedValue(true); + const { handlers, unmount } = renderAuthCheck(); + + await act(async () => { + handlers.forEach((handler) => handler("background")); + await flushMicrotasks(); + }); + + expect(recordBackgroundedAt).toHaveBeenCalledTimes(1); + unmount(); + }); + + it("does NOT record when not authenticated and no session is persisted", async () => { + useAuthenticationStore.setState({ + authStatus: AUTH_STATUS.NOT_AUTHENTICATED, + }); + (hasPersistedSession as jest.Mock).mockResolvedValue(false); + const { handlers, unmount } = renderAuthCheck(); + + await act(async () => { + handlers.forEach((handler) => handler("background")); + await flushMicrotasks(); + }); + + expect(recordBackgroundedAt).not.toHaveBeenCalled(); + unmount(); + }); + it("soft-locks immediately on backgrounding when the timer is IMMEDIATELY", async () => { (getAutoLockTimer as jest.Mock).mockResolvedValue( AUTO_LOCK_TIMER.IMMEDIATELY, diff --git a/src/components/screens/LockScreen.tsx b/src/components/screens/LockScreen.tsx index 0eb705843..e142d06e6 100644 --- a/src/components/screens/LockScreen.tsx +++ b/src/components/screens/LockScreen.tsx @@ -143,13 +143,15 @@ export const LockScreenContent: React.FC = ({ }, [error, t, showToast, clearError]); const handleUnlock = useCallback( - (password: string) => { + (password: string): Promise | undefined => { // Disable other navigation attempts while signing in - if (isSigningIn) return; + if (isSigningIn) return undefined; - // Try to sign in - error handling is in the auth store - signIn({ password }); - // Navigation will happen automatically through the authStatus effect + // Try to sign in - error handling is in the auth store. The promise is + // returned so the biometric auto-unlock can await/catch a rejection + // (e.g. a stale stored password) instead of leaving it unhandled. + // Navigation happens automatically through the authStatus effect. + return signIn({ password }); }, [signIn, isSigningIn], ); @@ -169,7 +171,9 @@ export const LockScreenContent: React.FC = ({ hasAutoPromptedRef.current = true; verifyActionWithBiometrics((password?: string) => { if (password) { - handleUnlock(password); + // Return the sign-in promise so a rejection is handled by the + // .catch() below rather than surfacing as an unhandled rejection. + return handleUnlock(password) ?? Promise.resolve(); } return Promise.resolve(); }).catch(() => { diff --git a/src/ducks/auth.ts b/src/ducks/auth.ts index 72925285a..679a71b96 100644 --- a/src/ducks/auth.ts +++ b/src/ducks/auth.ts @@ -1103,6 +1103,23 @@ const clearAllData = async (): Promise => { ]); }; +/** + * Resets the auto-lock setting to the default for a freshly established wallet + * (first install, sign up, import, or wipe). Writes the default to both the + * zustand store and the secure mirror and clears any stale background + * timestamp, so a new wallet never inherits the previous one's policy — e.g. a + * prior "None" would otherwise bake a never-expiring hash key. The secure + * write is awaited so generateHashKey reads the default expiry, and so first + * install lands on 24h even though the mirror was never written manually. + */ +const resetAutoLockForNewWallet = async (): Promise => { + usePreferencesStore.setState({ autoLockTimer: DEFAULT_AUTO_LOCK_TIMER }); + await Promise.all([ + persistAutoLockTimer(DEFAULT_AUTO_LOCK_TIMER), + clearBackgroundedAt(), + ]); +}; + const getKeyFromKeyManager = async ( password: string, activeAccountId?: string | null, @@ -2147,15 +2164,11 @@ export const useAuthenticationStore = create()((set, get) => ({ await dataStorage.remove(STORAGE_KEYS.COLLECTIBLES_LIST); // Reset auto-lock so the next wallet on this device doesn't - // inherit the previous user's timer. Await the secure-mirror write - // (the source of truth for getAuthStatus / generateHashKey) so an - // interrupted wipe can't leave a weaker policy — e.g. NONE's - // never-expire — behind for the next wallet. - usePreferencesStore.setState({ - autoLockTimer: DEFAULT_AUTO_LOCK_TIMER, - }); - await persistAutoLockTimer(DEFAULT_AUTO_LOCK_TIMER); - await clearBackgroundedAt(); + // inherit the previous user's timer. The awaited secure-mirror + // write (source of truth for getAuthStatus / generateHashKey) + // means an interrupted wipe can't leave a weaker policy — e.g. + // NONE's never-expire — behind for the next wallet. + await resetAutoLockForNewWallet(); await clearBiometricsData(); @@ -2221,6 +2234,9 @@ export const useAuthenticationStore = create()((set, get) => ({ signUp: async (params): Promise => { set((state) => ({ ...state, isLoading: true, error: null })); try { + // Fresh wallet: reset auto-lock to the default before the hash key is + // generated so it doesn't inherit a previous wallet's timer. + await resetAutoLockForNewWallet(); await signUp(params); set({ ...initialState, @@ -2658,6 +2674,9 @@ export const useAuthenticationStore = create()((set, get) => ({ set({ isLoading: true, error: null }); try { + // Re-import replaces the wallet: reset auto-lock to the default before + // the hash key is generated so it doesn't inherit the previous timer. + await resetAutoLockForNewWallet(); await importWallet(params); set({ ...initialState, diff --git a/src/hooks/useAuthCheck.ts b/src/hooks/useAuthCheck.ts index 218c7c1c0..327d8bbe3 100644 --- a/src/hooks/useAuthCheck.ts +++ b/src/hooks/useAuthCheck.ts @@ -7,17 +7,13 @@ import { hidePrivacyShield, markPrivacyShieldVisible, } from "helpers/privacyShield"; -import { useEffect, useRef, useState, useCallback } from "react"; -import { - AppState, - AppStateStatus, - PanResponder, - PanResponderInstance, -} from "react-native"; +import { useEffect, useRef, useState, useCallback, useMemo } from "react"; +import { AppState, AppStateStatus, PanResponder } from "react-native"; import { getAutoLockTimer, // TODO/FIXME: dev-only override — remove before production getDevAutoLockTimerMs, + hasPersistedSession, recordBackgroundedAt, // TODO/FIXME: dev-only idle-countdown readout — remove before production recordDevInteraction, @@ -45,12 +41,11 @@ const useAuthCheck = () => { const { getAuthStatus, authStatus } = useAuthenticationStore(); const [isActive, setIsActive] = useState(true); - // Refs to track app state, last interaction, auth check intervals, and pan responder instance + // Refs to track app state, last interaction, and auth check intervals const appState = useRef(AppState.currentState); const checkIntervalRef = useRef(null); const lastInteractionRef = useRef(Date.now()); const lastCheckRef = useRef(Date.now()); - const panResponderRef = useRef(null); /** * Mark "now" as the last user interaction. Called from every activity @@ -64,6 +59,32 @@ const useAuthCheck = () => { recordDevInteraction(); }, []); + /** + * PanResponder to observe touch interactions. Built during render (not in an + * effect) so panHandlers are attached to the provider's View on the very + * first render — otherwise touches wouldn't reset the idle clock until some + * unrelated re-render populated the handlers, letting a freshly active user + * be idle-locked from a stale timestamp. The *Capture variants run in the + * capture phase (root → target) so the root sees EVERY touch start/move, + * including taps on buttons/list items that would otherwise claim the + * responder first; returning false observes without stealing the gesture. + */ + const panResponder = useMemo( + () => + PanResponder.create({ + onStartShouldSetPanResponderCapture: () => { + recordInteraction(); + return false; + }, + onMoveShouldSetPanResponderCapture: () => { + recordInteraction(); + return false; + }, + onPanResponderTerminationRequest: () => true, + }), + [recordInteraction], + ); + /** * Check the authentication status and navigate to the lock screen if the auth hash is expired. */ @@ -171,44 +192,53 @@ const useAuthCheck = () => { // confirmations on specific devices. const { authStatus: currentAuthStatus, softLock } = useAuthenticationStore.getState(); - if ( - nextAppState === "background" && - currentAuthStatus === AUTH_STATUS.AUTHENTICATED - ) { - recordBackgroundedAt().catch((err) => + if (nextAppState === "background") { + // Gate on persisted session data, not only the zustand status: a cold + // launch into an existing session can be backgrounded before + // RootNavigator's async getAuthStatus sets AUTHENTICATED (the store is + // still on its initial NOT_AUTHENTICATED). Treat that unhydrated window + // as authenticated when a session exists on disk so the timestamp / + // IMMEDIATELY lock still fire. A hydrated LOCKED / HASH_KEY_EXPIRED + // status is respected (we don't re-record for an already-locked app). + (async () => { + const isSessionActive = + currentAuthStatus === AUTH_STATUS.AUTHENTICATED || + (currentAuthStatus === AUTH_STATUS.NOT_AUTHENTICATED && + (await hasPersistedSession())); + + if (!isSessionActive) { + return; + } + + await recordBackgroundedAt(); + + // Read from the secure-storage mirror (not the zustand store) so the + // IMMEDIATELY lock also fires when backgrounding happens before + // zustand rehydration completes. + // TODO/FIXME: getDevAutoLockTimerMs is a dev-only override — when set + // it must win over the IMMEDIATELY preset (exclusive), so the timed + // dev countdown in getAuthStatus governs instead of an instant lock. + const [devAutoLockTimerMs, autoLockTimer] = await Promise.all([ + getDevAutoLockTimerMs(), + getAutoLockTimer(), + ]); + + if ( + devAutoLockTimerMs === null && + autoLockTimer === AUTO_LOCK_TIMER.IMMEDIATELY + ) { + // Soft-lock right away: the overlay renders while the app is + // backgrounded (no wallet content flashes on return) and the + // navigation tree is preserved for after the unlock. + await softLock(); + } + })().catch((err) => logger.error( "handleAppStateChange", - "Error recording backgrounded-at timestamp", + "Error handling background auto-lock", err, ), ); - - // Read from the secure-storage mirror (not the zustand store) so the - // IMMEDIATELY lock also fires when backgrounding happens before - // zustand rehydration completes. - // TODO/FIXME: getDevAutoLockTimerMs is a dev-only override — when set - // it must win over the IMMEDIATELY preset (exclusive), so the timed - // dev countdown in getAuthStatus governs instead of an instant lock. - Promise.all([getDevAutoLockTimerMs(), getAutoLockTimer()]) - .then(([devAutoLockTimerMs, autoLockTimer]) => { - if ( - devAutoLockTimerMs === null && - autoLockTimer === AUTO_LOCK_TIMER.IMMEDIATELY - ) { - // Soft-lock right away: the overlay renders while the app is - // backgrounded (no wallet content flashes on return) and the - // navigation tree is preserved for after the unlock. - return softLock(); - } - return undefined; - }) - .catch((err) => - logger.error( - "handleAppStateChange", - "Error soft-locking on background", - err, - ), - ); } // When returning to active state, resolve the auto-lock decision and @@ -309,35 +339,18 @@ const useAuthCheck = () => { }, []); /** - * Initialize PanResponder to observe touch interactions and update the last - * interaction timestamp. The *Capture variants run during the capture phase - * (root → target) so the root sees EVERY touch start/move — including taps - * on buttons and list items that would otherwise claim the responder before - * a bubble-phase handler is asked. Returning false means it observes without - * stealing the gesture from the child. + * Perform an initial auth check after a short delay to avoid navigation race + * conditions during setup. */ useEffect(() => { - panResponderRef.current = PanResponder.create({ - onStartShouldSetPanResponderCapture: () => { - recordInteraction(); - return false; - }, - onMoveShouldSetPanResponderCapture: () => { - recordInteraction(); - return false; - }, - onPanResponderTerminationRequest: () => true, - }); - - // Perform an initial auth check after a short delay to avoid navigation race conditions const initialCheckTimeout = setTimeout(() => { checkAuth().catch((err) => - logger.error("initPanResponder", "Error checking auth", err), + logger.error("initialCheck", "Error checking auth", err), ); }, INITIAL_SETUP_DELAY); return () => clearTimeout(initialCheckTimeout); - }, [checkAuth, recordInteraction]); + }, [checkAuth]); /** * Provide a function to manually trigger an auth check. @@ -352,7 +365,7 @@ const useAuthCheck = () => { checkAuthNow, isActive, authStatus, - panHandlers: panResponderRef.current?.panHandlers, + panHandlers: panResponder.panHandlers, }; }; diff --git a/src/services/autoLock.ts b/src/services/autoLock.ts index d9be6d1e7..177aa59ac 100644 --- a/src/services/autoLock.ts +++ b/src/services/autoLock.ts @@ -120,6 +120,22 @@ const getBackgroundedAt = async (): Promise => { return parsedBackgroundedAt; }; +/** + * Whether an unlockable session is persisted on device (a hash key and a + * temporary store both exist). Lets the background handler decide whether to + * record/lock from disk state rather than the zustand auth status, which may + * not be hydrated yet — a cold launch into an existing session can be + * backgrounded before getAuthStatus runs. + */ +const hasPersistedSession = async (): Promise => { + const [hashKey, temporaryStore] = await Promise.all([ + getHashKey(), + secureDataStorage.getItem(SENSITIVE_STORAGE_KEYS.TEMPORARY_STORE), + ]); + + return Boolean(hashKey && temporaryStore); +}; + /* =========================================================================== * TODO / FIXME: TEMPORARY DEV-ONLY AUTO-LOCK TESTING OVERRIDES. * !!! REMOVE THIS ENTIRE BLOCK (and its UI on AutoLockTimerScreen + the @@ -187,6 +203,7 @@ export { recordBackgroundedAt, getBackgroundedAt, clearBackgroundedAt, + hasPersistedSession, // TODO/FIXME: remove these dev-only exports before production getDevAutoLockTimerMs, setDevAutoLockTimerSeconds, From c80f6024c765a9fd3e6a1d29a047e8678b3ffec2 Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Mon, 22 Jun 2026 10:48:37 -0300 Subject: [PATCH 16/19] harden background and lock security, change default to 12h and add more activity recording actions --- __tests__/ducks/preferences.test.ts | 43 +++++++++--- __tests__/services/autoLock.test.ts | 10 +-- .../java/com/freightermobile/MainActivity.kt | 14 ++++ ios/freighter-mobile/AppDelegate.swift | 3 + src/components/Modal.tsx | 23 +++---- .../screens/SendCollectibleReview.tsx | 11 ++- .../screens/TransactionAmountScreen.tsx | 6 ++ src/config/constants.ts | 10 ++- src/ducks/auth.ts | 68 +++++++++++++------ src/ducks/preferences.ts | 61 ++++++++++++----- src/helpers/userActivity.ts | 23 +++++++ src/hooks/useAuthCheck.ts | 28 ++++++-- src/navigators/RootNavigator.tsx | 5 ++ src/services/autoLock.ts | 15 ++-- 14 files changed, 241 insertions(+), 79 deletions(-) create mode 100644 src/helpers/userActivity.ts diff --git a/__tests__/ducks/preferences.test.ts b/__tests__/ducks/preferences.test.ts index 8850b50d6..abd4e86dc 100644 --- a/__tests__/ducks/preferences.test.ts +++ b/__tests__/ducks/preferences.test.ts @@ -9,33 +9,47 @@ import { jest.mock("services/autoLock", () => ({ persistAutoLockTimer: jest.fn().mockResolvedValue(undefined), applyAutoLockTimerToHashKey: jest.fn().mockResolvedValue(undefined), + getAutoLockTimer: jest + .fn() + .mockResolvedValue( + jest.requireActual("config/constants").DEFAULT_AUTO_LOCK_TIMER, + ), })); +// setAutoLockTimer sequences the persist + hash-TTL writes in an async IIFE, +// so let those microtasks settle before asserting. +const flushMicrotasks = async () => { + for (let i = 0; i < 5; i += 1) { + // eslint-disable-next-line no-await-in-loop + await Promise.resolve(); + } +}; + describe("preferences store", () => { beforeEach(() => { jest.clearAllMocks(); }); - it("defaults the auto-lock timer to 24 hours", () => { + it("defaults the auto-lock timer to 12 hours", () => { const { result } = renderHook(() => usePreferencesStore()); expect(result.current.autoLockTimer).toBe(DEFAULT_AUTO_LOCK_TIMER); - expect(result.current.autoLockTimer).toBe( - AUTO_LOCK_TIMER.TWENTY_FOUR_HOURS, - ); + expect(result.current.autoLockTimer).toBe(AUTO_LOCK_TIMER.TWELVE_HOURS); }); - it("updates the auto-lock timer and writes through to the mirror", () => { + it("updates the auto-lock timer and writes through to the mirror", async () => { const { result } = renderHook(() => usePreferencesStore()); - act(() => { + await act(async () => { result.current.setAutoLockTimer(AUTO_LOCK_TIMER.FIFTEEN_MINUTES); + await flushMicrotasks(); }); expect(result.current.autoLockTimer).toBe(AUTO_LOCK_TIMER.FIFTEEN_MINUTES); expect(persistAutoLockTimer).toHaveBeenCalledWith( AUTO_LOCK_TIMER.FIFTEEN_MINUTES, ); + // The hash-key TTL re-anchor is sequenced after the mirror write succeeds expect(applyAutoLockTimerToHashKey).toHaveBeenCalledWith( AUTO_LOCK_TIMER.FIFTEEN_MINUTES, ); @@ -44,8 +58,9 @@ describe("preferences store", () => { it("reverts the auto-lock timer when the mirror write fails", async () => { const { result } = renderHook(() => usePreferencesStore()); - act(() => { + await act(async () => { result.current.setAutoLockTimer(AUTO_LOCK_TIMER.ONE_HOUR); + await flushMicrotasks(); }); expect(result.current.autoLockTimer).toBe(AUTO_LOCK_TIMER.ONE_HOUR); @@ -56,10 +71,22 @@ describe("preferences store", () => { await act(async () => { result.current.setAutoLockTimer(AUTO_LOCK_TIMER.ONE_MINUTE); // Allow the rejected persist promise to settle and trigger the revert - await Promise.resolve(); + await flushMicrotasks(); }); // The displayed selection must never disagree with the enforced mirror expect(result.current.autoLockTimer).toBe(AUTO_LOCK_TIMER.ONE_HOUR); }); + + it("hydrates the auto-lock timer from the secure mirror", async () => { + const { getAutoLockTimer } = jest.requireMock("services/autoLock"); + getAutoLockTimer.mockResolvedValueOnce(AUTO_LOCK_TIMER.FIFTEEN_MINUTES); + const { result } = renderHook(() => usePreferencesStore()); + + await act(async () => { + await result.current.hydrateAutoLockTimer(); + }); + + expect(result.current.autoLockTimer).toBe(AUTO_LOCK_TIMER.FIFTEEN_MINUTES); + }); }); diff --git a/__tests__/services/autoLock.test.ts b/__tests__/services/autoLock.test.ts index 913de3c54..c5ec308f8 100644 --- a/__tests__/services/autoLock.test.ts +++ b/__tests__/services/autoLock.test.ts @@ -181,7 +181,10 @@ describe("autoLock service", () => { ); }); - it("cleans up and returns null for a future-dated timestamp", async () => { + it("forces a lock for a future-dated timestamp (clock anomaly)", async () => { + // A future-dated timestamp means the device clock moved backward; rather + // than skip the lock, treat it as an epoch-old background so any positive + // timer elapses and the wallet locks conservatively. const oneHourAhead = Date.now() + 3600000; (secureDataStorage.getItem as jest.Mock).mockResolvedValue( String(oneHourAhead), @@ -189,10 +192,7 @@ describe("autoLock service", () => { const backgroundedAt = await getBackgroundedAt(); - expect(backgroundedAt).toBeNull(); - expect(secureDataStorage.remove).toHaveBeenCalledWith( - SENSITIVE_STORAGE_KEYS.AUTO_LOCK_BACKGROUNDED_AT, - ); + expect(backgroundedAt).toBe(0); }); it("clears the persisted timestamp", async () => { diff --git a/android/app/src/main/java/com/freightermobile/MainActivity.kt b/android/app/src/main/java/com/freightermobile/MainActivity.kt index c8393eb12..c7e0e57b4 100644 --- a/android/app/src/main/java/com/freightermobile/MainActivity.kt +++ b/android/app/src/main/java/com/freightermobile/MainActivity.kt @@ -29,6 +29,10 @@ class MainActivity : ReactActivity() { // covers the brief on-return flash.) private var privacyOverlay: View? = null private val privacyHandler = Handler(Looper.getMainLooper()) + // Tracks whether the activity is currently resumed so a JS-triggered hide() + // that arrives after a re-background doesn't tear down a freshly-raised + // shield. + private var isActivityResumed = false companion object { private const val PRIVACY_SHIELD_FALLBACK_MS = 1000L @@ -64,6 +68,11 @@ class MainActivity : ReactActivity() { super.onCreate(null) } + override fun onPause() { + isActivityResumed = false + super.onPause() + } + override fun onStop() { showPrivacyShield() super.onStop() @@ -71,6 +80,7 @@ class MainActivity : ReactActivity() { override fun onResume() { super.onResume() + isActivityResumed = true // Fallback: ensure the shield can't get stuck if JS never calls hide() privacyHandler.removeCallbacksAndMessages(null) privacyHandler.postDelayed({ hidePrivacyShield() }, PRIVACY_SHIELD_FALLBACK_MS) @@ -103,6 +113,10 @@ class MainActivity : ReactActivity() { } fun hidePrivacyShield() { + // Only lift while resumed: a JS hide() arriving after a re-background + // (onStop re-raised the shield) would briefly expose wallet content. The + // next onResume reschedules the fallback. + if (!isActivityResumed) return privacyHandler.removeCallbacksAndMessages(null) val overlay = privacyOverlay ?: return (overlay.parent as? ViewGroup)?.removeView(overlay) diff --git a/ios/freighter-mobile/AppDelegate.swift b/ios/freighter-mobile/AppDelegate.swift index 7d89e075d..060cc717b 100644 --- a/ios/freighter-mobile/AppDelegate.swift +++ b/ios/freighter-mobile/AppDelegate.swift @@ -44,6 +44,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { @objc private func handlePrivacyShieldHideRequest() { DispatchQueue.main.async { [weak self] in + // Skip if the app re-backgrounded between JS calling hide() and this + // dispatch — a fresh shield was raised; don't tear it down mid-snapshot. + guard UIApplication.shared.applicationState == .active else { return } self?.hidePrivacyShield() } } diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index dc79d120b..45389f80e 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -1,3 +1,4 @@ +import { useAuthenticationStore } from "ducks/auth"; import React, { useEffect } from "react"; import { type StyleProp, @@ -6,7 +7,6 @@ import { Modal as RNModal, TouchableWithoutFeedback, KeyboardAvoidingView, - AppState, } from "react-native"; interface ModalProps { @@ -30,22 +30,15 @@ const Modal: React.FC = ({ contentStyle, testID, }) => { - // Dismiss on background: a native RN Modal renders above the in-tree lock - // overlay, so an open modal would otherwise stay on top of the lock screen. - // (Not "inactive" — avoids closing on control-center / app-switcher peeks.) + // Dismiss on soft lock: a native RN Modal renders above the in-tree lock + // overlay, so an open one would sit on top of the lock screen. Gated on + // isSoftLocked (not a raw background event) so a brief glance keeps state. + const isSoftLocked = useAuthenticationStore((state) => state.isSoftLocked); useEffect(() => { - if (!visible) { - return undefined; + if (visible && isSoftLocked) { + onClose(); } - - const subscription = AppState.addEventListener("change", (nextAppState) => { - if (nextAppState === "background") { - onClose(); - } - }); - - return () => subscription.remove(); - }, [visible, onClose]); + }, [visible, isSoftLocked, onClose]); return ( = ({ const handleNativeAmountChange = useCallback( (text: string) => { + // System-keyboard keystrokes don't reach the app-wide PanResponder, so + // record activity here to keep the foreground-idle auto-lock from firing + // while the user is actively entering an amount. + recordUserActivity(); + const sanitizedText = text.replace(/[^0-9.,]/g, ""); const previousText = previousNativeInputRef.current; diff --git a/src/config/constants.ts b/src/config/constants.ts index 7107e829e..af986c4b1 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -72,7 +72,11 @@ export const PASSWORD_MAX_LENGTH = 2048; export const ACCOUNT_NAME_MIN_LENGTH = 1; export const ACCOUNT_NAME_MAX_LENGTH = 24; export const ACCOUNTS_TO_VERIFY_ON_EXISTING_MNEMONIC_PHRASE = 6; -export const HASH_KEY_EXPIRATION_MS = 24 * 60 * 60 * 1000; // 24 hours +// Hard-expiry backstop: after this the session fully re-authenticates +// (HASH_KEY_EXPIRED). Must stay greater than the largest AUTO_LOCK_TIMER +// preset, or that preset's soft-lock fast path can never run (hard expiry +// fires first). +export const HASH_KEY_EXPIRATION_MS = 7 * 24 * 60 * 60 * 1000; // 7 days export const VISUAL_DELAY_MS = 500; const SECOND_IN_MS = 1000; @@ -99,7 +103,9 @@ export enum AUTO_LOCK_TIMER { NONE = "none", } -export const DEFAULT_AUTO_LOCK_TIMER = AUTO_LOCK_TIMER.TWENTY_FOUR_HOURS; +// 12h matches the extension's default (DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES=720) +// so both platforms ship the same cadence. +export const DEFAULT_AUTO_LOCK_TIMER = AUTO_LOCK_TIMER.TWELVE_HOURS; /** Toast ID used by the lock screen / overlay to surface unlock errors. */ export const UNLOCK_ERROR_TOAST_ID = "unlock-wallet-error"; diff --git a/src/ducks/auth.ts b/src/ducks/auth.ts index 679a71b96..5ebfce929 100644 --- a/src/ducks/auth.ts +++ b/src/ducks/auth.ts @@ -2202,26 +2202,37 @@ export const useAuthenticationStore = create()((set, get) => ({ softLock: async (options?: { suppressBiometricPrompt?: boolean }) => { Keyboard.dismiss(); - // Atomic update: RootNavigator must never see LOCKED && !isSoftLocked, - // which would unmount the preserved tree + // Atomic update: RootNavigator must never see LOCKED && !isSoftLocked + // (it would unmount the preserved tree). Clear account so the decrypted + // private key isn't held while locked — it re-populates on unlock. set({ authStatus: AUTH_STATUS.LOCKED, isSoftLocked: true, + account: null, // A foreground-idle lock suppresses the lock screen's biometric // auto-prompt; background / IMMEDIATELY / cold-start locks still prompt. suppressBiometricAutoPrompt: options?.suppressBiometricPrompt ?? false, isLoading: false, }); - // Persist LOCKED (covers tampering + cold starts). Awaited so an - // immediate post-background process kill still has the lock on disk. + // Persist LOCKED (covers tampering + cold starts). Retry once and rethrow + // on failure: a swallowed write would leave the wallet auto-unlockable on + // the next cold launch. try { await secureDataStorage.setItem( SENSITIVE_STORAGE_KEYS.AUTH_STATUS, AUTH_STATUS.LOCKED, ); - } catch (error) { - logger.error("softLock", "Failed to persist LOCKED status", error); + } catch (firstError) { + logger.error( + "softLock", + "Failed to persist LOCKED status, retrying", + firstError, + ); + await secureDataStorage.setItem( + SENSITIVE_STORAGE_KEYS.AUTH_STATUS, + AUTH_STATUS.LOCKED, + ); } }, @@ -2273,6 +2284,14 @@ export const useAuthenticationStore = create()((set, get) => ({ await signIn({ ...params, shouldCreateTempStore }); + // Clear the persisted LOCKED marker and stale backgrounded-at timestamp + // before flipping to AUTHENTICATED — awaited so the auto-lock funnel + // can't observe the old values and re-lock the fresh session. + await Promise.all([ + secureDataStorage.remove(SENSITIVE_STORAGE_KEYS.AUTH_STATUS), + clearBackgroundedAt(), + ]); + // Password verified — unblock navigation immediately. // getActiveAccount is slow (derivePrivateKeyFromMnemonic) so we run it // in the background and let the home screen render with isLoadingAccount=true. @@ -2294,11 +2313,14 @@ export const useAuthenticationStore = create()((set, get) => ({ getActiveAccount(AUTH_STATUS.AUTHENTICATED) .then((activeAccount) => { if (!activeAccount) { - // Sign-in succeeded but no active account was found — lock and - // surface an error so the user knows something went wrong. + // No active account after sign-in: lock and surface an error. + // isSoftLocked must accompany LOCKED (RootNavigator unmounts the + // tree on LOCKED && !isSoftLocked). set({ isLoadingAccount: false, authStatus: AUTH_STATUS.LOCKED, + isSoftLocked: true, + account: null, error: t("authStore.error.failedToLoadAccount"), }); get().navigateToLockScreen(); @@ -2315,23 +2337,12 @@ export const useAuthenticationStore = create()((set, get) => ({ set({ isLoadingAccount: false, authStatus: AUTH_STATUS.LOCKED, + isSoftLocked: true, + account: null, error: t("authStore.error.failedToLoadAccount"), }); get().navigateToLockScreen(); }); - - // Clear persisted auth status in background (non-blocking) - secureDataStorage - .remove(SENSITIVE_STORAGE_KEYS.AUTH_STATUS) - .catch((e) => - logger.debug("signIn", "Failed to clear persisted auth status", e), - ); - - // Clear any stale backgrounded-at timestamp so the auto-lock timer - // can't re-lock a freshly unlocked session (non-blocking) - clearBackgroundedAt().catch((e) => - logger.debug("signIn", "Failed to clear backgrounded-at timestamp", e), - ); } catch (error) { analytics.trackReAuthFail(); set({ @@ -2734,6 +2745,21 @@ export const useAuthenticationStore = create()((set, get) => ({ return get().authStatus; } + // A soft lock is only valid over an authenticated session. If the session + // is gone (wipe) or hard-expired, clear isSoftLocked too — otherwise the + // overlay stays stranded over an accountless/expired app until kill. + if ( + get().isSoftLocked && + (authStatus === AUTH_STATUS.NOT_AUTHENTICATED || + authStatus === AUTH_STATUS.HASH_KEY_EXPIRED) + ) { + set({ authStatus, isSoftLocked: false }); + if (authStatus === AUTH_STATUS.HASH_KEY_EXPIRED) { + get().navigateToLockScreen(); + } + return authStatus; + } + set({ authStatus }); // If the hash key is expired, navigate to lock screen diff --git a/src/ducks/preferences.ts b/src/ducks/preferences.ts index 66399fde7..39d2ed3b1 100644 --- a/src/ducks/preferences.ts +++ b/src/ducks/preferences.ts @@ -3,6 +3,7 @@ import { AUTO_LOCK_TIMER, DEFAULT_AUTO_LOCK_TIMER } from "config/constants"; import { logger } from "config/logger"; import { applyAutoLockTimerToHashKey, + getAutoLockTimer, persistAutoLockTimer, } from "services/autoLock"; import { create } from "zustand"; @@ -17,6 +18,7 @@ interface PreferencesState { setIsBiometricsEnabled: (isBiometricsEnabled: boolean) => void; autoLockTimer: AUTO_LOCK_TIMER; setAutoLockTimer: (autoLockTimer: AUTO_LOCK_TIMER) => void; + hydrateAutoLockTimer: () => Promise; } const INITIAL_PREFERENCES_STATE = { @@ -40,31 +42,56 @@ export const usePreferencesStore = create()( const previousAutoLockTimer = get().autoLockTimer; set({ autoLockTimer }); - // Write-through to the secure-storage mirror (read by getAuthStatus - // without depending on zustand rehydration) and re-anchor the hash - // key TTL so switching to/from NONE takes effect immediately. - // If the mirror write fails, revert the UI state so the displayed - // selection never disagrees with the enforced value. - persistAutoLockTimer(autoLockTimer).catch((error) => { + // Persist to the secure mirror, then re-anchor the hash-key TTL — + // sequenced, not raced. On failure, roll back the UI, mirror, and TTL + // together so the selection, enforcement, and expiry can't disagree. + (async () => { + try { + await persistAutoLockTimer(autoLockTimer); + await applyAutoLockTimerToHashKey(autoLockTimer); + } catch (error) { + logger.error( + "setAutoLockTimer", + "Failed to apply auto-lock timer; rolling back", + error, + ); + set({ autoLockTimer: previousAutoLockTimer }); + await Promise.allSettled([ + persistAutoLockTimer(previousAutoLockTimer), + applyAutoLockTimerToHashKey(previousAutoLockTimer), + ]); + } + })(); + }, + // Load autoLockTimer from the secure mirror (its single source of truth). + // Called once on app init since the value is intentionally not persisted + // to the unencrypted zustand store (see partialize below). + hydrateAutoLockTimer: async () => { + try { + const autoLockTimer = await getAutoLockTimer(); + set({ autoLockTimer }); + } catch (error) { logger.error( - "setAutoLockTimer", - "Failed to persist auto-lock timer", + "hydrateAutoLockTimer", + "Failed to hydrate auto-lock timer from secure storage", error, ); - set({ autoLockTimer: previousAutoLockTimer }); - }); - applyAutoLockTimerToHashKey(autoLockTimer).catch((error) => - logger.error( - "setAutoLockTimer", - "Failed to apply auto-lock timer to hash key", - error, - ), - ); + } }, }), { name: "preferences-storage", storage: createJSONStorage(() => AsyncStorage), + // autoLockTimer is intentionally NOT persisted here: the secure-storage + // mirror (read by getAuthStatus for enforcement) is its single source of + // truth. Keeping a second copy in unencrypted AsyncStorage would let the + // UI disagree with enforcement and expose the policy to tampering. It's + // hydrated from the mirror on app init via hydrateAutoLockTimer(). + partialize: (state) => ({ + isHideDustEnabled: state.isHideDustEnabled, + isMemoValidationEnabled: state.isMemoValidationEnabled, + isBiometricsEnabled: state.isBiometricsEnabled, + }), }, ), ); diff --git a/src/helpers/userActivity.ts b/src/helpers/userActivity.ts new file mode 100644 index 000000000..871a24273 --- /dev/null +++ b/src/helpers/userActivity.ts @@ -0,0 +1,23 @@ +/** + * Bridge for recording user activity from outside the AuthCheckProvider tree. + * + * The app-wide PanResponder can't see touches consumed by + * react-native-gesture-handler or system-keyboard keystrokes. Components + * handling those call `recordUserActivity()` to keep the foreground-idle clock + * fresh; useAuthCheck registers the actual recorder so the source stays there. + */ +type ActivityRecorder = () => void; + +let activityRecorder: ActivityRecorder | null = null; + +/** Registered by useAuthCheck; pass null on unmount. */ +export const setActivityRecorder = ( + recorder: ActivityRecorder | null, +): void => { + activityRecorder = recorder; +}; + +/** Marks "now" as user activity, resetting the foreground-idle auto-lock clock. */ +export const recordUserActivity = (): void => { + activityRecorder?.(); +}; diff --git a/src/hooks/useAuthCheck.ts b/src/hooks/useAuthCheck.ts index 327d8bbe3..ac0db245f 100644 --- a/src/hooks/useAuthCheck.ts +++ b/src/hooks/useAuthCheck.ts @@ -7,8 +7,9 @@ import { hidePrivacyShield, markPrivacyShieldVisible, } from "helpers/privacyShield"; +import { setActivityRecorder } from "helpers/userActivity"; import { useEffect, useRef, useState, useCallback, useMemo } from "react"; -import { AppState, AppStateStatus, PanResponder } from "react-native"; +import { AppState, AppStateStatus, Keyboard, PanResponder } from "react-native"; import { getAutoLockTimer, // TODO/FIXME: dev-only override — remove before production @@ -262,10 +263,9 @@ const useAuthCheck = () => { ) .finally(() => { setTimeout(() => { - // If the user re-backgrounded before this resolved, the native - // side has re-shown the shield for the new background period — - // don't lift it, or the next resume would briefly reveal the - // unlocked tree while the lock decision runs. + // If re-backgrounded before this resolved, the native side + // re-showed the shield — don't lift it, or the next resume flashes + // the unlocked tree. if (AppState.currentState === "active") { hidePrivacyShield(); } @@ -323,6 +323,24 @@ const useAuthCheck = () => { return unsubscribe; }, [recordInteraction]); + /** + * Keyboard and gesture-handler input don't reach the PanResponder, so count + * keyboard show/hide as interaction (otherwise a user entering an amount + * could be idle-locked mid-input) and expose recordInteraction via the + * userActivity bridge for components outside this tree (e.g. amount inputs). + */ + useEffect(() => { + const showSub = Keyboard.addListener("keyboardDidShow", recordInteraction); + const hideSub = Keyboard.addListener("keyboardDidHide", recordInteraction); + setActivityRecorder(recordInteraction); + + return () => { + showSub.remove(); + hideSub.remove(); + setActivityRecorder(null); + }; + }, [recordInteraction]); + /** * Monitor user interaction and update active status accordingly. */ diff --git a/src/navigators/RootNavigator.tsx b/src/navigators/RootNavigator.tsx index 730fbab1d..d9fd354ee 100644 --- a/src/navigators/RootNavigator.tsx +++ b/src/navigators/RootNavigator.tsx @@ -31,6 +31,7 @@ import { } from "config/routes"; import { AUTH_STATUS } from "config/types"; import { useAuthenticationStore } from "ducks/auth"; +import { usePreferencesStore } from "ducks/preferences"; import { useRemoteConfigStore } from "ducks/remoteConfig"; import { isDeviceJailbroken } from "helpers/deviceSecurity"; import { @@ -117,6 +118,10 @@ export const RootNavigator = () => { // so components always read the correct network from the start. await initializeNetwork(); + // Hydrate the auto-lock timer from its secure-storage source of truth + // (it's intentionally not kept in the unencrypted preferences store). + await usePreferencesStore.getState().hydrateAutoLockTimer(); + // Fetch the real auth status from storage and overwrite the initial // NOT_AUTHENTICATED default before any navigation decision is made. const freshAuthStatus = await getAuthStatus(); diff --git a/src/services/autoLock.ts b/src/services/autoLock.ts index 177aa59ac..9efc2dc6c 100644 --- a/src/services/autoLock.ts +++ b/src/services/autoLock.ts @@ -97,10 +97,10 @@ const clearBackgroundedAt = async (): Promise => { }; /** - * Returns the persisted backgrounded-at timestamp, or null when the app - * hasn't gone to the background since the last evaluation. Corrupt - * (non-numeric) and future-dated values are cleaned up and treated as - * absent — a future timestamp would otherwise stall the timer. + * Returns the persisted backgrounded-at timestamp, or null when none exists. + * A corrupt (non-numeric) value is cleared and treated as absent. A + * future-dated value (clock moved backward) returns 0 so any positive timer + * elapses and the wallet locks, rather than trusting it to skip the lock. */ const getBackgroundedAt = async (): Promise => { const backgroundedAt = await secureDataStorage.getItem( @@ -112,11 +112,16 @@ const getBackgroundedAt = async (): Promise => { } const parsedBackgroundedAt = Number(backgroundedAt); - if (Number.isNaN(parsedBackgroundedAt) || parsedBackgroundedAt > Date.now()) { + + if (Number.isNaN(parsedBackgroundedAt)) { await clearBackgroundedAt(); return null; } + if (parsedBackgroundedAt > Date.now()) { + return 0; + } + return parsedBackgroundedAt; }; From 5a5e9e21c36a2f335966197ccb500211989e9471 Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Mon, 22 Jun 2026 11:23:16 -0300 Subject: [PATCH 17/19] remove dev lock timers --- .../components/screens/HomeScreen.test.tsx | 7 - .../AutoLockTimerScreen.test.tsx | 5 - __tests__/hooks/useAuthCheck.test.tsx | 102 +++++++------ ios/freighter-mobile/AppDelegate.swift | 14 +- .../screens/HomeScreen/AutoLockDevTimers.tsx | 89 ------------ .../screens/HomeScreen/HomeScreen.tsx | 4 - .../AutoLockTimerScreen.tsx | 134 +----------------- .../SwapScreen/hooks/useSwapTransaction.ts | 13 +- src/ducks/auth.ts | 12 +- src/hooks/useAuthCheck.ts | 30 +--- src/services/autoLock.ts | 66 --------- 11 files changed, 85 insertions(+), 391 deletions(-) delete mode 100644 src/components/screens/HomeScreen/AutoLockDevTimers.tsx diff --git a/__tests__/components/screens/HomeScreen.test.tsx b/__tests__/components/screens/HomeScreen.test.tsx index df69a9ca6..dcf99d3fd 100644 --- a/__tests__/components/screens/HomeScreen.test.tsx +++ b/__tests__/components/screens/HomeScreen.test.tsx @@ -39,13 +39,6 @@ jest.mock("components/analytics/DebugBottomSheet", () => ({ }, })); -// TODO/FIXME: dev-only countdown component (recurring timer) — remove with it -jest.mock("components/screens/HomeScreen/AutoLockDevTimers", () => ({ - AutoLockDevTimers: function MockAutoLockDevTimers() { - return null; - }, -})); - jest.mock("components/primitives/Menu", () => { const MenuRoot = ({ children }: { children: React.ReactNode }) => (
{children}
diff --git a/__tests__/components/screens/SettingsScreen/AutoLockTimerScreen.test.tsx b/__tests__/components/screens/SettingsScreen/AutoLockTimerScreen.test.tsx index 21db7915f..322e24c6b 100644 --- a/__tests__/components/screens/SettingsScreen/AutoLockTimerScreen.test.tsx +++ b/__tests__/components/screens/SettingsScreen/AutoLockTimerScreen.test.tsx @@ -10,11 +10,6 @@ import React from "react"; jest.mock("services/autoLock", () => ({ persistAutoLockTimer: jest.fn().mockResolvedValue(undefined), applyAutoLockTimerToHashKey: jest.fn().mockResolvedValue(undefined), - // TODO/FIXME: dev-only auto-lock testing helpers — remove with the feature - getDevAutoLockTimerMs: jest.fn().mockResolvedValue(null), - setDevAutoLockTimerSeconds: jest.fn().mockResolvedValue(undefined), - clearDevAutoLockTimer: jest.fn().mockResolvedValue(undefined), - setDevHashKeyTtlSeconds: jest.fn().mockResolvedValue(undefined), })); type AutoLockTimerScreenNavigationProp = NativeStackScreenProps< diff --git a/__tests__/hooks/useAuthCheck.test.tsx b/__tests__/hooks/useAuthCheck.test.tsx index 43530c69b..2ce6d39d9 100644 --- a/__tests__/hooks/useAuthCheck.test.tsx +++ b/__tests__/hooks/useAuthCheck.test.tsx @@ -6,18 +6,14 @@ import useAuthCheck from "hooks/useAuthCheck"; import { AppState } from "react-native"; import { getAutoLockTimer, - getDevAutoLockTimerMs, hasPersistedSession, recordBackgroundedAt, - recordDevInteraction, } from "services/autoLock"; jest.mock("services/autoLock", () => ({ getAutoLockTimer: jest.fn(), - getDevAutoLockTimerMs: jest.fn().mockResolvedValue(null), hasPersistedSession: jest.fn().mockResolvedValue(false), recordBackgroundedAt: jest.fn().mockResolvedValue(undefined), - recordDevInteraction: jest.fn(), })); // components/App pulls in the full app tree (RootNavigator → native-only @@ -42,6 +38,10 @@ const flushMicrotasks = async () => { } }; +// Real durations from config/constants: ONE_MINUTE is the shortest timed +// preset, so idle tests step across the 60s boundary. +const ONE_MINUTE_MS = 60000; + describe("useAuthCheck", () => { const mockGetAuthStatus = jest .fn() @@ -60,7 +60,6 @@ describe("useAuthCheck", () => { (getAutoLockTimer as jest.Mock).mockResolvedValue( AUTO_LOCK_TIMER.TWENTY_FOUR_HOURS, ); - (getDevAutoLockTimerMs as jest.Mock).mockResolvedValue(null); (hasPersistedSession as jest.Mock).mockResolvedValue(false); useAuthenticationStore.setState({ @@ -193,26 +192,6 @@ describe("useAuthCheck", () => { unmount(); }); - // TODO/FIXME: dev-only override exclusivity — remove with the dev feature - it("does NOT instant-lock for IMMEDIATELY when a dev timer override is set", async () => { - (getAutoLockTimer as jest.Mock).mockResolvedValue( - AUTO_LOCK_TIMER.IMMEDIATELY, - ); - // A custom dev timer (20s) is active — it must win over IMMEDIATELY so the - // timed countdown governs instead of an instant lock - (getDevAutoLockTimerMs as jest.Mock).mockResolvedValue(20000); - const { handlers, unmount } = renderAuthCheck(); - - await act(async () => { - handlers.forEach((handler) => handler("background")); - await flushMicrotasks(); - }); - - expect(recordBackgroundedAt).toHaveBeenCalledTimes(1); - expect(mockSoftLock).not.toHaveBeenCalled(); - unmount(); - }); - it("delegates lock transitions to the store getAuthStatus funnel on foreground return", async () => { const { handlers, unmount } = renderAuthCheck(); @@ -230,18 +209,19 @@ describe("useAuthCheck", () => { }); it("idle-locks while foregrounded after the timer with no interaction", async () => { - // Short timer (1s) via the dev override so idle elapses within the test - (getDevAutoLockTimerMs as jest.Mock).mockResolvedValue(1000); + (getAutoLockTimer as jest.Mock).mockResolvedValue( + AUTO_LOCK_TIMER.ONE_MINUTE, + ); const { unmount } = renderAuthCheck(); await act(async () => { - jest.advanceTimersByTime(400); // let the periodic check interval set up + jest.advanceTimersByTime(1000); // let the periodic check interval set up await flushMicrotasks(); }); await act(async () => { - // No interaction; advance well past the 1s idle timeout so a periodic + // No interaction; advance past the 1-minute idle timeout so a periodic // check observes the idle and soft-locks - jest.advanceTimersByTime(6000); + jest.advanceTimersByTime(ONE_MINUTE_MS + 5000); await flushMicrotasks(); }); @@ -250,16 +230,18 @@ describe("useAuthCheck", () => { }); it("does NOT idle-lock before the timer elapses", async () => { - // Long timer (100s) so the elapsed idle stays under it - (getDevAutoLockTimerMs as jest.Mock).mockResolvedValue(100000); + (getAutoLockTimer as jest.Mock).mockResolvedValue( + AUTO_LOCK_TIMER.ONE_MINUTE, + ); const { unmount } = renderAuthCheck(); await act(async () => { - jest.advanceTimersByTime(400); + jest.advanceTimersByTime(1000); await flushMicrotasks(); }); await act(async () => { - jest.advanceTimersByTime(6000); + // Half the timeout — still under a minute, so no idle-lock + jest.advanceTimersByTime(ONE_MINUTE_MS / 2); await flushMicrotasks(); }); @@ -267,27 +249,42 @@ describe("useAuthCheck", () => { unmount(); }); - it("resets the idle clock when the wallet becomes unlocked", async () => { + it("resets the idle clock on unlock so it does not immediately re-lock", async () => { // The lock screen / overlay sits outside this provider's PanResponder, so - // its touches don't update the idle clock — unlock must reset it (proxied - // here by recordDevInteraction, which the reset effect calls alongside - // resetting lastInteractionRef) or a fresh session would re-lock at once. + // its touches don't update the idle clock — unlock must reset it or a + // fresh session would re-lock at once. + (getAutoLockTimer as jest.Mock).mockResolvedValue( + AUTO_LOCK_TIMER.ONE_MINUTE, + ); const { unmount } = renderAuthCheck(); + // Idle most of the way to the timeout while authenticated await act(async () => { - useAuthenticationStore.setState({ authStatus: AUTH_STATUS.LOCKED }); + jest.advanceTimersByTime(ONE_MINUTE_MS - 10000); await flushMicrotasks(); }); - (recordDevInteraction as jest.Mock).mockClear(); + // Lock, then unlock — the unlock-reset effect must restart the idle clock + await act(async () => { + useAuthenticationStore.setState({ authStatus: AUTH_STATUS.LOCKED }); + await flushMicrotasks(); + }); await act(async () => { useAuthenticationStore.setState({ authStatus: AUTH_STATUS.AUTHENTICATED, }); await flushMicrotasks(); }); + mockSoftLock.mockClear(); + + // Another 50s: ~100s since mount but only ~50s since the unlock reset + // (under the minute) → must NOT lock + await act(async () => { + jest.advanceTimersByTime(ONE_MINUTE_MS - 10000); + await flushMicrotasks(); + }); - expect(recordDevInteraction).toHaveBeenCalled(); + expect(mockSoftLock).not.toHaveBeenCalled(); unmount(); }); @@ -302,8 +299,9 @@ describe("useAuthCheck", () => { }); it("resets the idle clock on a navigation change so a multi-screen flow is not idle-locked", async () => { - // Short timer (1s); the user navigates just before it would elapse - (getDevAutoLockTimerMs as jest.Mock).mockResolvedValue(1000); + (getAutoLockTimer as jest.Mock).mockResolvedValue( + AUTO_LOCK_TIMER.ONE_MINUTE, + ); const { unmount } = renderAuthCheck(); // The hook registers a "state" listener; grab the callback the same way @@ -313,15 +311,15 @@ describe("useAuthCheck", () => { )?.[1] as () => void; await act(async () => { - jest.advanceTimersByTime(800); - // A navigation occurs before the 1s idle elapses → resets the clock + // Idle most of the way to the timeout, then navigate just before it + jest.advanceTimersByTime(ONE_MINUTE_MS - 10000); navListener(); await flushMicrotasks(); }); await act(async () => { - // Another 800ms (1.6s total, but only 800ms since the nav) — still under - // the timer, so no idle-lock - jest.advanceTimersByTime(800); + // Another ~50s: well over a minute since mount, but only ~50s since the + // navigation reset → no idle-lock + jest.advanceTimersByTime(ONE_MINUTE_MS - 10000); await flushMicrotasks(); }); @@ -329,18 +327,16 @@ describe("useAuthCheck", () => { unmount(); }); - it("does NOT idle-lock for the NONE / IMMEDIATELY presets", async () => { - // No dev override; NONE has a null duration → never idle-locks - (getDevAutoLockTimerMs as jest.Mock).mockResolvedValue(null); + it("does NOT idle-lock for the NONE preset", async () => { (getAutoLockTimer as jest.Mock).mockResolvedValue(AUTO_LOCK_TIMER.NONE); const { unmount } = renderAuthCheck(); await act(async () => { - jest.advanceTimersByTime(400); + jest.advanceTimersByTime(1000); await flushMicrotasks(); }); await act(async () => { - jest.advanceTimersByTime(6000); + jest.advanceTimersByTime(ONE_MINUTE_MS * 2); await flushMicrotasks(); }); diff --git a/ios/freighter-mobile/AppDelegate.swift b/ios/freighter-mobile/AppDelegate.swift index 060cc717b..7ac657938 100644 --- a/ios/freighter-mobile/AppDelegate.swift +++ b/ios/freighter-mobile/AppDelegate.swift @@ -43,10 +43,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } @objc private func handlePrivacyShieldHideRequest() { + // hidePrivacyShield no-ops unless the app is active, so a hide() that + // lands during a background bounce won't expose the wallet. DispatchQueue.main.async { [weak self] in - // Skip if the app re-backgrounded between JS calling hide() and this - // dispatch — a fresh shield was raised; don't tear it down mid-snapshot. - guard UIApplication.shared.applicationState == .active else { return } self?.hidePrivacyShield() } } @@ -73,6 +72,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { private static let privacyShieldFallbackTimeout: TimeInterval = 1.0 func applicationDidEnterBackground(_ application: UIApplication) { + // Cancel any fallback timer pending from a previous resume so it can't fire + // during this new background period and tear down the shield we just want + // to keep up. + privacyShieldFallbackTimer?.invalidate() + privacyShieldFallbackTimer = nil + guard privacyWindow == nil else { return } let overlay = UIWindow(frame: UIScreen.main.bounds) overlay.windowLevel = .alert + 1 @@ -96,6 +101,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Called from the PrivacyShield native module once JS has settled the lock // decision (and any lock overlay has mounted). Must run on the main thread. func hidePrivacyShield() { + // Only reveal while the app is actually active: a JS hide() or fallback + // timer that lands during a background bounce must not expose the wallet. + guard UIApplication.shared.applicationState == .active else { return } privacyShieldFallbackTimer?.invalidate() privacyShieldFallbackTimer = nil privacyWindow?.isHidden = true diff --git a/src/components/screens/HomeScreen/AutoLockDevTimers.tsx b/src/components/screens/HomeScreen/AutoLockDevTimers.tsx deleted file mode 100644 index 1341bb2f9..000000000 --- a/src/components/screens/HomeScreen/AutoLockDevTimers.tsx +++ /dev/null @@ -1,89 +0,0 @@ -/* =========================================================================== - * TODO / FIXME: TEMPORARY DEV-ONLY auto-lock countdown readout. - * !!! REMOVE THIS FILE (and its usage in HomeScreen.tsx) BEFORE MERGING TO - * PRODUCTION !!! Shows the live idle countdown (time until the foreground - * auto-lock fires, resetting on interaction) and the countdown to the - * hash-key hard expiry, so QA can watch the lock flows. - * =========================================================================== - */ -import { Text } from "components/sds/Typography"; -import { AUTO_LOCK_TIMER_MS } from "config/constants"; -import React, { useEffect, useState } from "react"; -import { View } from "react-native"; -import { - getAutoLockTimer, - getDevAutoLockTimerMs, - getDevLastInteractionAt, -} from "services/autoLock"; -import { getHashKey } from "services/storage/helpers"; - -const TICK_MS = 1000; -const NEVER_LABEL = "Never"; -const BACKGROUND_ONLY_LABEL = "on background"; -const EMPTY_LABEL = "—"; - -const formatSeconds = (ms: number): string => - `${Math.max(0, Math.ceil(ms / 1000))}s`; - -export const AutoLockDevTimers: React.FC = () => { - const [autoLockMs, setAutoLockMs] = useState(null); - const [hashExpiresAt, setHashExpiresAt] = useState(null); - const [now, setNow] = useState(Date.now()); - - useEffect(() => { - let mounted = true; - - const read = async () => { - const devMs = await getDevAutoLockTimerMs(); - const timer = await getAutoLockTimer(); - const hashKey = await getHashKey(); - if (!mounted) { - return; - } - setAutoLockMs(devMs ?? AUTO_LOCK_TIMER_MS[timer]); - setHashExpiresAt(hashKey?.expiresAt ?? null); - }; - - read(); - const id = setInterval(() => { - setNow(Date.now()); - read(); - }, TICK_MS); - - return () => { - mounted = false; - clearInterval(id); - }; - }, []); - - // Idle countdown: time left until the foreground auto-lock fires, measured - // from the last interaction. NONE never idle-locks; IMMEDIATELY is - // background-only (no foreground countdown). - let idleLabel: string; - if (autoLockMs === null) { - idleLabel = NEVER_LABEL; - } else if (autoLockMs === 0) { - idleLabel = BACKGROUND_ONLY_LABEL; - } else { - idleLabel = formatSeconds(autoLockMs - (now - getDevLastInteractionAt())); - } - - const hashRemainingMs = hashExpiresAt === null ? null : hashExpiresAt - now; - - return ( - - - {`idle lock in: ${idleLabel}`} - - - {`hash expires in: ${ - hashRemainingMs === null - ? EMPTY_LABEL - : formatSeconds(hashRemainingMs) - }`} - - - ); -}; - -export default AutoLockDevTimers; diff --git a/src/components/screens/HomeScreen/HomeScreen.tsx b/src/components/screens/HomeScreen/HomeScreen.tsx index 2888676f1..f17525d2d 100644 --- a/src/components/screens/HomeScreen/HomeScreen.tsx +++ b/src/components/screens/HomeScreen/HomeScreen.tsx @@ -10,8 +10,6 @@ import { import { DebugBottomSheet } from "components/analytics/DebugBottomSheet"; import { DebugTrigger } from "components/debug/DebugTrigger"; import { BaseLayout } from "components/layout/BaseLayout"; -// TODO/FIXME: dev-only auto-lock countdown — remove before production -import { AutoLockDevTimers } from "components/screens/HomeScreen/AutoLockDevTimers"; import ManageAccounts from "components/screens/HomeScreen/ManageAccounts"; import WelcomeBannerBottomSheet from "components/screens/HomeScreen/WelcomeBannerBottomSheet"; import Avatar from "components/sds/Avatar"; @@ -297,8 +295,6 @@ export const HomeScreen: React.FC = React.memo( {formattedBalance} - {/* TODO/FIXME: dev-only auto-lock countdown — remove before prod */} -
diff --git a/src/components/screens/SettingsScreen/SecurityScreen/AutoLockTimerScreen/AutoLockTimerScreen.tsx b/src/components/screens/SettingsScreen/SecurityScreen/AutoLockTimerScreen/AutoLockTimerScreen.tsx index a77223f36..7531d6840 100644 --- a/src/components/screens/SettingsScreen/SecurityScreen/AutoLockTimerScreen/AutoLockTimerScreen.tsx +++ b/src/components/screens/SettingsScreen/SecurityScreen/AutoLockTimerScreen/AutoLockTimerScreen.tsx @@ -2,25 +2,14 @@ import { NativeStackScreenProps } from "@react-navigation/native-stack"; import { List } from "components/List"; import { BaseLayout } from "components/layout/BaseLayout"; import Icon from "components/sds/Icon"; -import { Input } from "components/sds/Input"; import { Text } from "components/sds/Typography"; -import { AUTO_LOCK_TIMER, DEFAULT_PADDING } from "config/constants"; -import { logger } from "config/logger"; +import { AUTO_LOCK_TIMER } from "config/constants"; import { SETTINGS_ROUTES, SettingsStackParamList } from "config/routes"; import { usePreferencesStore } from "ducks/preferences"; import useAppTranslation from "hooks/useAppTranslation"; import useColors from "hooks/useColors"; -import { useToast } from "providers/ToastProvider"; -import React, { useEffect, useState } from "react"; +import React from "react"; import { View } from "react-native"; -// TODO/FIXME: only needed for the temporary dev inputs — remove with them -import { KeyboardAwareScrollView } from "react-native-keyboard-controller"; -import { - clearDevAutoLockTimer, - getDevAutoLockTimerMs, - setDevAutoLockTimerSeconds, - setDevHashKeyTtlSeconds, -} from "services/autoLock"; interface AutoLockTimerScreenProps extends NativeStackScreenProps< @@ -28,70 +17,11 @@ interface AutoLockTimerScreenProps typeof SETTINGS_ROUTES.AUTO_LOCK_TIMER_SCREEN > {} -// TODO/FIXME: dev-only testing labels — remove with the block below -const DEV_BANNER = "⚠️ DEV ONLY — remove before production"; -const DEV_TIMER_LABEL = "Auto-lock timer (seconds)"; -const DEV_TTL_LABEL = "Hash key TTL (seconds)"; -const DEV_APPLY = "Apply"; -const DEV_TIMER_PLACEHOLDER = "e.g. 10"; -const DEV_TTL_PLACEHOLDER = "e.g. 30"; - const AutoLockTimerScreen: React.FC = () => { const { t } = useAppTranslation(); const { themeColors } = useColors(); - const { showToast } = useToast(); const { autoLockTimer, setAutoLockTimer } = usePreferencesStore(); - // TODO/FIXME: dev-only state for the testing controls — remove before prod - const [devTimerSeconds, setDevTimerSecondsInput] = useState(""); - const [devTtlSeconds, setDevTtlSecondsInput] = useState(""); - // When a custom dev timer is active, the enum options are deselected so the - // UI reflects that the override (not a preset) governs the lock. - const [isDevTimerActive, setIsDevTimerActive] = useState(false); - - useEffect(() => { - getDevAutoLockTimerMs() - .then((ms) => setIsDevTimerActive(ms !== null)) - .catch(() => setIsDevTimerActive(false)); - }, []); - - // TODO/FIXME: dev-only handlers — remove before prod - const applyDevTimer = () => { - const seconds = Number(devTimerSeconds); - if (!Number.isFinite(seconds) || seconds < 0) { - return; - } - setDevAutoLockTimerSeconds(seconds) - .then(() => { - setIsDevTimerActive(true); - showToast({ - variant: "success", - title: `Auto-lock timer set to ${seconds}s`, - toastId: "dev-auto-lock-timer", - }); - }) - .catch((error) => - logger.error("AutoLockTimerScreen", "Failed to set dev timer", error), - ); - }; - const applyDevTtl = () => { - const seconds = Number(devTtlSeconds); - if (!Number.isFinite(seconds) || seconds < 0) { - return; - } - setDevHashKeyTtlSeconds(seconds) - .then(() => - showToast({ - variant: "success", - title: `Hash key TTL set to ${seconds}s`, - toastId: "dev-hash-key-ttl", - }), - ) - .catch((error) => - logger.error("AutoLockTimerScreen", "Failed to set dev TTL", error), - ); - }; - const timerLabels: Record = { [AUTO_LOCK_TIMER.IMMEDIATELY]: t("autoLockTimerScreen.options.immediately"), [AUTO_LOCK_TIMER.ONE_MINUTE]: t("autoLockTimerScreen.options.oneMinute"), @@ -113,11 +43,6 @@ const AutoLockTimerScreen: React.FC = () => { const handleSelectOption = (option: AUTO_LOCK_TIMER) => { setAutoLockTimer(option); - // TODO/FIXME: picking a preset clears the dev override so it takes effect - setIsDevTimerActive(false); - clearDevAutoLockTimer().catch((error) => - logger.error("AutoLockTimerScreen", "Failed to clear dev timer", error), - ); }; const listItems = Object.values(AUTO_LOCK_TIMER).map((option) => ({ @@ -126,11 +51,7 @@ const AutoLockTimerScreen: React.FC = () => { onPress: () => handleSelectOption(option), trailingContent: ( ), testID: `auto-lock-option-${option}`, @@ -138,57 +59,12 @@ const AutoLockTimerScreen: React.FC = () => { return ( - {/* - TODO / FIXME: the KeyboardAwareScrollView wrapper exists only so the - temporary dev inputs below aren't hidden behind the keyboard. - REVERT to the plain when removing the dev-only block. - */} - + {t("autoLockTimerScreen.footer")} - - {/* - ==================================================================== - TODO / FIXME: TEMPORARY DEV-ONLY testing controls. - !!! REMOVE THIS ENTIRE BLOCK BEFORE MERGING TO PRODUCTION !!! - (also remove the dev helpers in services/autoLock.ts, the - getDevAutoLockTimerMs override in ducks/auth.ts, and revert the - KeyboardAwareScrollView wrapper above back to a plain ) - Lets QA exercise the lock flows in seconds instead of minutes/hours. - ==================================================================== - */} - - - {DEV_BANNER} - - - - - {/* ================= END TEMPORARY DEV-ONLY BLOCK ================= */} - + ); }; diff --git a/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts b/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts index 16c1f0891..2b411d1d2 100644 --- a/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts +++ b/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts @@ -127,14 +127,17 @@ export const useSwapTransaction = ({ throw new Error("Destination token is required for swap transaction"); } - // Block signing if an auto-lock engaged after the swap was prepared - if (!isWalletUnlocked()) { - throw new Error("Wallet is locked"); - } - setIsProcessing(true); try { + // Block signing if an auto-lock engaged after the swap was prepared. + // Inside the try so the terminal catch runs its analytics/toast/error + // path — executeSwap is invoked fire-and-forget, so a throw before the + // try would surface as an unhandled rejection. + if (!isWalletUnlocked()) { + throw new Error("Wallet is locked"); + } + const signedXDR = signTransaction({ secretKey: account.privateKey, network, diff --git a/src/ducks/auth.ts b/src/ducks/auth.ts index 9580e0656..65bb4c93e 100644 --- a/src/ducks/auth.ts +++ b/src/ducks/auth.ts @@ -60,8 +60,6 @@ import { clearBackgroundedAt, getAutoLockTimer, getBackgroundedAt, - // TODO/FIXME: dev-only auto-lock timer override — remove before production - getDevAutoLockTimerMs, getHashKeyExpirationMs, persistAutoLockTimer, } from "services/autoLock"; @@ -525,12 +523,12 @@ const getAuthStatus = async (): Promise => { // via the fast unlock path. Uses wall-clock time, so a clock rollback can // dodge it — the hash-key expiry above still bounds the session. const backgroundedAt = await getBackgroundedAt(); - if (backgroundedAt) { + // Explicit null check, not truthiness: a clock-rollback returns 0 (epoch) + // to force a conservative lock, and 0 is falsey — `if (backgroundedAt)` + // would skip it and leave the session AUTHENTICATED. + if (backgroundedAt !== null) { const autoLockTimer = await getAutoLockTimer(); - // TODO/FIXME: dev-only override (seconds) — remove before production - const devAutoLockTimerMs = await getDevAutoLockTimerMs(); - const autoLockTimerMs = - devAutoLockTimerMs ?? AUTO_LOCK_TIMER_MS[autoLockTimer]; + const autoLockTimerMs = AUTO_LOCK_TIMER_MS[autoLockTimer]; const elapsedInBackground = Date.now() - backgroundedAt; // Only POSITIVE timed durations lock here. IMMEDIATELY (0) is diff --git a/src/hooks/useAuthCheck.ts b/src/hooks/useAuthCheck.ts index ac0db245f..361ff8eaf 100644 --- a/src/hooks/useAuthCheck.ts +++ b/src/hooks/useAuthCheck.ts @@ -12,12 +12,8 @@ import { useEffect, useRef, useState, useCallback, useMemo } from "react"; import { AppState, AppStateStatus, Keyboard, PanResponder } from "react-native"; import { getAutoLockTimer, - // TODO/FIXME: dev-only override — remove before production - getDevAutoLockTimerMs, hasPersistedSession, recordBackgroundedAt, - // TODO/FIXME: dev-only idle-countdown readout — remove before production - recordDevInteraction, } from "services/autoLock"; // Delay before lifting the native privacy shield on foreground, giving a @@ -56,8 +52,6 @@ const useAuthCheck = () => { const recordInteraction = useCallback(() => { lastInteractionRef.current = Date.now(); setIsActive(true); - // TODO/FIXME: dev-only — feeds the on-screen idle countdown readout - recordDevInteraction(); }, []); /** @@ -110,18 +104,17 @@ const useAuthCheck = () => { const status = await getAuthStatus(); // Foreground-idle auto-lock: while the app is active, lock after the - // configured duration with no user interaction (touches reset - // lastInteractionRef via the app-wide PanResponder). Background time is - // handled by getAuthStatus above; here we cover an open-but-idle + // configured duration with no user interaction (touches, navigation and + // keyboard reset lastInteractionRef via recordInteraction). Background + // time is handled by getAuthStatus above; here we cover an open-but-idle // session. Only timed presets idle-lock — IMMEDIATELY (0, background- // only) and NONE (null) are skipped. if ( status === AUTH_STATUS.AUTHENTICATED && AppState.currentState === "active" ) { - const devAutoLockTimerMs = await getDevAutoLockTimerMs(); const autoLockTimer = await getAutoLockTimer(); - const timerMs = devAutoLockTimerMs ?? AUTO_LOCK_TIMER_MS[autoLockTimer]; + const timerMs = AUTO_LOCK_TIMER_MS[autoLockTimer]; if ( timerMs !== null && @@ -216,18 +209,9 @@ const useAuthCheck = () => { // Read from the secure-storage mirror (not the zustand store) so the // IMMEDIATELY lock also fires when backgrounding happens before // zustand rehydration completes. - // TODO/FIXME: getDevAutoLockTimerMs is a dev-only override — when set - // it must win over the IMMEDIATELY preset (exclusive), so the timed - // dev countdown in getAuthStatus governs instead of an instant lock. - const [devAutoLockTimerMs, autoLockTimer] = await Promise.all([ - getDevAutoLockTimerMs(), - getAutoLockTimer(), - ]); - - if ( - devAutoLockTimerMs === null && - autoLockTimer === AUTO_LOCK_TIMER.IMMEDIATELY - ) { + const autoLockTimer = await getAutoLockTimer(); + + if (autoLockTimer === AUTO_LOCK_TIMER.IMMEDIATELY) { // Soft-lock right away: the overlay renders while the app is // backgrounded (no wallet content flashes on return) and the // navigation tree is preserved for after the unlock. diff --git a/src/services/autoLock.ts b/src/services/autoLock.ts index 9efc2dc6c..366abc67b 100644 --- a/src/services/autoLock.ts +++ b/src/services/autoLock.ts @@ -141,65 +141,6 @@ const hasPersistedSession = async (): Promise => { return Boolean(hashKey && temporaryStore); }; -/* =========================================================================== - * TODO / FIXME: TEMPORARY DEV-ONLY AUTO-LOCK TESTING OVERRIDES. - * !!! REMOVE THIS ENTIRE BLOCK (and its UI on AutoLockTimerScreen + the - * getDevAutoLockTimerMs usage in ducks/auth.ts) BEFORE MERGING TO PRODUCTION. - * It lets QA set the auto-lock timer and hash-key TTL in seconds so the lock - * flows can be exercised in seconds instead of minutes/hours. - * =========================================================================== - */ -const DEV_AUTO_LOCK_TIMER_MS_KEY = "devAutoLockTimerMs"; - -/** TEMP/REMOVE: dev override for the auto-lock timer, in ms (null if unset). */ -const getDevAutoLockTimerMs = async (): Promise => { - const raw = await secureDataStorage.getItem(DEV_AUTO_LOCK_TIMER_MS_KEY); - const parsed = raw ? Number(raw) : NaN; - return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; -}; - -/** TEMP/REMOVE: set the dev auto-lock timer override from a seconds value. */ -const setDevAutoLockTimerSeconds = async (seconds: number): Promise => { - await secureDataStorage.setItem( - DEV_AUTO_LOCK_TIMER_MS_KEY, - String(Math.round(seconds * 1000)), - ); -}; - -/** TEMP/REMOVE: clear the dev auto-lock timer override (back to the enum). */ -const clearDevAutoLockTimer = async (): Promise => { - await secureDataStorage.remove(DEV_AUTO_LOCK_TIMER_MS_KEY); -}; - -/** - * TEMP/REMOVE: force the current hash key to expire in `seconds` by rewriting - * its expiresAt, so the hard-expiry (HASH_KEY_EXPIRED) backstop can be tested - * quickly. One-shot: the next unlock re-anchors the TTL to the normal value. - */ -const setDevHashKeyTtlSeconds = async (seconds: number): Promise => { - const hashKey = await getHashKey(); - if (!hashKey) { - return; - } - await secureDataStorage.setItem( - SENSITIVE_STORAGE_KEYS.HASH_KEY, - JSON.stringify({ - ...hashKey, - expiresAt: Date.now() + Math.round(seconds * 1000), - }), - ); -}; - -// TEMP/REMOVE: last user-interaction timestamp, mirrored from useAuthCheck so -// the on-screen dev readout can show the live idle countdown without forcing -// app-wide re-renders. Module-level on purpose (no React state). -let devLastInteractionAt = Date.now(); -const recordDevInteraction = (): void => { - devLastInteractionAt = Date.now(); -}; -const getDevLastInteractionAt = (): number => devLastInteractionAt; -/* ====================== END TEMPORARY DEV-ONLY BLOCK ====================== */ - export { getAutoLockTimer, persistAutoLockTimer, @@ -209,11 +150,4 @@ export { getBackgroundedAt, clearBackgroundedAt, hasPersistedSession, - // TODO/FIXME: remove these dev-only exports before production - getDevAutoLockTimerMs, - setDevAutoLockTimerSeconds, - clearDevAutoLockTimer, - setDevHashKeyTtlSeconds, - recordDevInteraction, - getDevLastInteractionAt, }; From d6d05add9ec4c5227586a428e5e578b14b3a8f0e Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Mon, 22 Jun 2026 12:16:14 -0300 Subject: [PATCH 18/19] fix unlock toast error id --- src/components/screens/LockScreen.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/components/screens/LockScreen.tsx b/src/components/screens/LockScreen.tsx index e522f925e..a78632e55 100644 --- a/src/components/screens/LockScreen.tsx +++ b/src/components/screens/LockScreen.tsx @@ -1,7 +1,11 @@ import { NativeStackScreenProps } from "@react-navigation/native-stack"; import ForgotPasswordWarningModal from "components/screens/ForgotPasswordWarningModal"; import InputPasswordTemplate from "components/templates/InputPasswordTemplate"; -import { ERROR_TOAST_DURATION, LoginType } from "config/constants"; +import { + ERROR_TOAST_DURATION, + LoginType, + UNLOCK_ERROR_TOAST_ID, +} from "config/constants"; import { ROOT_NAVIGATOR_ROUTES, RootStackParamList } from "config/routes"; import { AUTH_STATUS } from "config/types"; import { useAuthenticationStore, getActiveAccountPublicKey } from "ducks/auth"; @@ -11,7 +15,6 @@ import { onPrivacyShieldHidden, } from "helpers/privacyShield"; import useAppTranslation from "hooks/useAppTranslation"; -import { AUTH_ERROR_TOAST_ID } from "hooks/useAuthErrorToast"; import { useToast } from "providers/ToastProvider"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { AppState } from "react-native"; @@ -159,11 +162,13 @@ export const LockScreenContent: React.FC = ({ } // Unlock failed after a successful biometric scan (e.g. a stale stored // password). Clear the store error so a biometric-derived invalidPassword - // doesn't bleed into the inline password field, and surface the single - // authoritative unlock-error toast (matches PR #890's biometric flow). + // doesn't bleed into the inline password field, and surface the unlock- + // error toast. Uses UNLOCK_ERROR_TOAST_ID because it's the only id + // SOFT_LOCK_ALLOWED_TOAST_IDS lets through while the soft-lock overlay is + // up — otherwise the failure would be silently suppressed. clearError(); showToast({ - toastId: AUTH_ERROR_TOAST_ID, + toastId: UNLOCK_ERROR_TOAST_ID, variant: "error", title: t("lockScreen.errorUnlockingWalletTitle"), message: t("lockScreen.errorUnlockingWalletMessage"), From 2e0f53882899c0fc1e26231dd573e74d83e4bd63 Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Mon, 29 Jun 2026 10:04:26 -0300 Subject: [PATCH 19/19] remove unintentional commited doc --- docs/auto-lock-code-review.md | 279 ---------------------------------- 1 file changed, 279 deletions(-) delete mode 100644 docs/auto-lock-code-review.md diff --git a/docs/auto-lock-code-review.md b/docs/auto-lock-code-review.md deleted file mode 100644 index 2cc921ee7..000000000 --- a/docs/auto-lock-code-review.md +++ /dev/null @@ -1,279 +0,0 @@ -# Auto-Lock Timer — Consolidated Code Review - -> Scope: all staged changes for the Auto-Lock Timer feature (issue #627), the -> soft-lock overlay, and the lock-screen biometric auto-prompt. Method: two -> independent reviews in isolated contexts — a **Security** review (application -> security engineer perspective) and a **Senior Developer** review (architecture -> / correctness / tests) — merged and deduplicated below. Attribution noted per -> finding. - -**Verdict (combined): NOT ready to merge.** Core design and happy paths are -solid and tested, but the sliding hash-key TTL regression, the -lock-policy-in-plain-storage tampering path, native modals rendering above the -overlay, and the navigation-reset race must be addressed first. - -## Resolution status (updated after fixes) - -| Finding | Status | -| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| C1 sliding TTL | ✅ **Fixed** — the foreground-return refresh was removed from `getAuthStatus`; `expiresAt` is now anchored ONLY at credential-verified moments (`signIn`, `generateHashKey`, `applyAutoLockTimerToHashKey` on in-app setting change). "None" keeps its never-expire TTL because it is also set only at those moments. Pinned by tests: "consume the timestamp WITHOUT refreshing the hash key TTL" and "HASH_KEY_EXPIRED even if within the timer". | -| C2 plain-storage tampering | ✅ **Fixed** — the timer setting and backgrounded-at timestamp moved to `SENSITIVE_STORAGE_KEYS`/`secureDataStorage`; future-dated timestamps rejected and cleaned up (+ tests). Combined with the C1 fix, AsyncStorage tampering can no longer weaken the lock or the key TTL. | -| C3 modals above overlay | ⚠️ **Accepted (product decision)** — a native-Modal-hosted overlay was implemented and reverted for visual/UX reasons. Residual risk: an RN `Modal` open at lock time stays visible/tappable above the overlay; mitigations in place: sensitive reads are gated while LOCKED (getActiveAccount, WalletKit), `account` is cleared on soft lock. Re-evaluate if a styling-acceptable native hosting is found. | -| I1 navigation-reset race | ✅ Fixed — store `getAuthStatus` is the single funnel: every caller (checkAuth, fetchActiveAccount, selectAccount) produces the same atomic soft lock; manual `set`/`navigateToLockScreen` calls removed from those callers | -| I2 non-atomic transition | ✅ Fixed — `softLock` sets `authStatus` + `isSoftLocked` (+ `account: null`) in ONE `set()`; pinned by a store-subscription test asserting `LOCKED && !isSoftLocked` is never observable | -| I3 Android back button | ✅ Fixed — `BackHandler` listener returns `true` while soft-locked | -| I4 in-memory secrets / JSDoc | ✅ Fixed — `softLock` clears `account` (private key) atomically; JSDoc corrected to name the real protections. `derivedKeyCache` intentionally kept (documented PR #664 fast-unlock design). `getTemporaryStore` LOCKED-allowance retained — required by the unlock path itself — now noted here as the invariant. | -| I5 fire-and-forget persist | ✅ Fixed — `softLock` is async and awaits the secure-storage `LOCKED` write | -| I6 snapshot/accessibility | ⚠️ **Deferred to follow-up** — `accessibilityViewIsModal` confines screen-reader focus to the overlay. App-switcher snapshot privacy must be handled natively (the only place it works, matching MetaMask/Rainbow/Coinbase): **iOS** AppDelegate splash overlay on `applicationWillResignActive`/`applicationDidBecomeActive`; **Android** `FLAG_SECURE` on the activity. A JS curtain was tried and removed (can't beat the OS snapshot, flashed on return). Native implementation is intentionally **not in this PR** — tracked as a follow-up. | -| I7 nulled navigationRef | ✅ Fixed — `signIn`/`signUp`/`importWallet` preserve `navigationRef` through the `initialState` spread | -| I8 missing tests | ✅ Mostly fixed — new `useAuthCheck` suite (background-only recording, IMMEDIATELY soft lock, funnel delegation), funnel atomicity test, corrupt + future-dated timestamp tests, softLock account-clearing assertion. Remaining gap: no render test for RootNavigator's `showAuthenticatedStack` conditional. | -| M1 NaN cleanup | ✅ Fixed — `getBackgroundedAt` clears corrupt values and returns null (+ test) | -| M2 mirror drift | ✅ Fixed — setter reverts UI state if the mirror write fails (+ test) | -| M3 wall-clock | ✅ Documented in `getAuthStatus` (accepted limitation; bounded by the hash-key expiry) | -| M4 OEM biometric AppState | ✅ Documented in `useAuthCheck`; remains a device-QA item (test plan §8.4) | -| M5 rehydration window | ✅ Fixed — IMMEDIATELY check reads the dataStorage mirror, not zustand state | -| M6 overlay subscription | ✅ Fixed — narrow `isSoftLocked` selector | -| M7 re-prompt fatigue | ✅ Documented as intentional banking-app behavior (explicit product request) | -| M8 timer survives wipe | ✅ Fixed — full wipe resets the preference to the 24h default via the setter (store + mirror) | - ---- - -## Strengths (both reviewers) - -- Locked-state key access properly gated at the data layer: `getActiveAccount` - hard-blocks `LOCKED` (`src/ducks/auth.ts:1714-1724`); WalletKit session - proposals/requests reject while `LOCKED`/`HASH_KEY_EXPIRED` - (`src/providers/WalletKitProvider.tsx:853-864, 946-958`). -- The locked state itself is tamper-resistant: persisted `AUTH_STATUS.LOCKED` - lives in secure storage and is checked first in `getAuthStatus` — AsyncStorage - tampering cannot _un-lock_ a locked session. -- Evaluation ordering is correct (persisted-LOCKED → timer → hash-key expiry); - the consume-only-when-`active` guard correctly survives Android's 60s - background check interval. -- Fail-safe preference parsing: corrupt/missing timer values degrade to the 24h - default (more locking, never less). -- The biometric auto-prompt refactor removes the cold-start double prompt by - deleting the dual-owner `useAppOpenBiometricsLogin`; the guard ordering in - `LockScreenContent` correctly handles async `signInMethod` resolution. -- Conventions respected: enums, no magic numbers, EN+PT translations, house - JSDoc style, ESLint clean; tests are largely behavioral and all pass. -- Bottom sheets are correctly covered by the overlay (rendered after - `BottomSheetModalProvider`); `Keyboard.dismiss()` on lock; signIn clears stale - timestamps. - ---- - -## Critical (must fix) - -### C1. Sliding hash-key TTL makes key-material lifetime unbounded without re-authentication — _Security (Critical) + Senior (Minor 7, same root)_ - -`src/ducks/auth.ts:524-537` - -The old model anchored `expiresAt` only at credential-verified moments -(`generateHashKey`, `signIn`), guaranteeing the hash key — and the temporary -store it decrypts (mnemonic, private keys, **plaintext password**, see -`auth.ts:2021`) — was dead ≤ 24h after the last password/biometric entry. The -new code rewrites `expiresAt = now + 24h` (or +100 years for NONE) inside -`getAuthStatus` on every foreground return, **with zero proof of user -presence**. - -- **Attack**: a thief holding a phone inside its auto-lock window reopens the - app once per timer period; the wallet never hard-expires and never demands the - password again, indefinitely. Previously bounded at 24h. -- This regresses **every** setting, including the default 24h that the PR claims - is "no behavior change" — today's behavior is a fixed cryptoperiod, not a - sliding one. Key rotation (previously forced by each full re-auth) also never - happens. -- **Fix**: refresh `expiresAt` only after successful credential verification - (keep it in `signIn`/`generateHashKey` only). For NONE, disable the _timer_ - but keep a bounded hash-key expiry — the fast-unlock LOCKED path already makes - the periodic re-auth cheap. If product insists NONE never re-prompts, that - trade-off must not leak into the other seven options via the unconditional - slide. - -### C2. Lock policy driven by unencrypted AsyncStorage; tampering it extends the _keychain_ TTL — _Security (Important #2, escalated by C1 interaction)_ - -`src/services/autoLock.ts:22-34, 79-101`; `src/ducks/auth.ts:505-537`; -`src/hooks/useAuthCheck.ts:128-134` - -`AUTO_LOCK_TIMER_SETTING`, `AUTO_LOCK_BACKGROUNDED_AT`, and the zustand -`preferences-storage` mirror (which gates IMMEDIATELY) all live in plain -AsyncStorage. An attacker with sandbox write access (rooted Android, backup -modify-and-restore, forensic tooling) can: - -- delete/garbage/future-date `backgroundedAt` (`Number(garbage)` → `NaN` - silently disables the comparison), or -- set the timer to `"none"`, after which the app _itself_ rewrites the keychain - hash key to `now + 100 years` on next foreground (`auth.ts:530-536`). - -This contradicts the codebase's own documented threat model ("Read from SECURE -storage (encrypted) to prevent tampering", `auth.ts:484`; "prevents tampering -via ADB or rooted devices", `auth.ts:2076`) — `AUTH_STATUS` was deliberately -moved to secure storage against exactly this attacker. - -- **Fix**: store the timer preference and timestamps in `secureDataStorage` - (tiny, low-frequency values); reject future-dated timestamps; treat - missing-timestamp-with-present-hash-key conservatively. Fixing C1 removes the - TTL-extension half. - -### C3. Native `Modal`s render **above** the soft-lock overlay and stay interactive while locked — _Security (Important #3)_ - -`src/components/Modal.tsx:32-41` vs `src/components/App.tsx:85-91` - -RN `Modal` hosts content in a separate native window that z-orders above every -in-root view, including `LockScreenOverlay` (a plain sibling `View`). If a modal -is open when the lock fires (e.g. IMMEDIATELY while `RenameAccountModal` / -`ConfirmationModal` / `PermissionModal` is up), on return it is fully visible -and **tappable on top of the lock** — content leaks and its actions execute -against the mounted authenticated tree without re-auth. - -- **Fix**: render the lock overlay in a top-level native `Modal` (with no-op - `onRequestClose`), or dismiss/gate all RN modals on `softLock` - (`!isSoftLocked`). - ---- - -## Important (should fix) - -### I1. Other `getAuthStatus` callers hard-reset navigation when the timer fires, racing the soft lock — _Senior (#1)_ - -`src/ducks/auth.ts:2714-2723` (`fetchActiveAccount`), `2836-2841` -(`selectAccount`) - -Only `useAuthCheck.checkAuth` routes timer-`LOCKED` to `softLock()`. If -`fetchActiveAccount` runs first on a foreground return after expiry, it sets -`LOCKED` + `navigateToLockScreen()` while `isSoftLocked` is still `false` → -`resetRoot` wipes the tree, defeating the feature's central guarantee (and can -then fight `checkAuth`'s later `softLock()`). - -- **Fix**: centralize the AUTHENTICATED→LOCKED transition (e.g. in the store - `getAuthStatus` action, the single funnel) so every caller produces a soft - lock; also fixes I2. - -### I2. `authStatus: LOCKED` and `isSoftLocked: true` are set in separate, non-atomic updates — _Senior (#2)_ - -`src/ducks/auth.ts:2650` + `src/hooks/useAuthCheck.ts:61-66` - -Today React batches them; any future `await` inserted between silently unmounts -the whole authenticated group (`RootNavigator` sees `LOCKED && !isSoftLocked`). -The no-unmount guarantee should not rest on scheduler timing. - -- **Fix**: one atomic `set()` for the lock transition (naturally falls out of - the I1 centralization). - -### I3. Android hardware back button is not intercepted by the overlay — _Senior (#3)_ - -`src/components/LockScreenOverlay.tsx:31-36` - -The overlay swallows touches but hardware back events reach the navigation -container underneath — while "locked", back presses pop screens in the hidden -tree (mutating the state being preserved) or exit the app. - -- **Fix**: `BackHandler` listener returning `true` while `isSoftLocked` (or the - native-Modal approach from C3, which solves both). - -### I4. In-memory secrets survive the soft lock; JSDoc overstates the protection — _Security (#6, #7) + Senior (#4), merged_ - -`src/ducks/auth.ts:2158-2174` (softLock), `619-636` (getTemporaryStore), -`account.privateKey` in store - -- `softLock()` leaves `account` (including `privateKey`, signing-capable via - `useGetActiveAccount.signTransaction`) in the zustand store, and the - `derivedKeyCache` warm — unlike `logout`'s soft path which clears `account`. -- `getTemporaryStore` explicitly **allows** `LOCKED` ("preserved session that - can be unlocked"), so any future caller running under the overlay could - decrypt the mnemonic/password. Not currently exploitable (callers are - authenticated-flow-only and WalletKit rejects while locked), but the invariant - is undocumented and untested. -- `softLock`'s JSDoc claims "the temporary store denies access while authStatus - is LOCKED" — **false**; the protection is the UI overlay plus - `getActiveAccount`'s gate. -- **Fix**: clear `account: null` (signIn repopulates it anyway) and consider - clearing `derivedKeyCache` for IMMEDIATELY; correct the JSDoc; add a test - pinning "no secret-bearing path succeeds while LOCKED". - -### I5. `softLock`'s secure-storage write is fire-and-forget — _Security (#5)_ - -`src/ducks/auth.ts:2158-2173` - -In-memory lock state is set synchronously but the persisted `LOCKED` write is -`.catch(log)`. If the process is killed before the write lands (most likely -exactly on the IMMEDIATELY path — lock fires on backgrounding), a cold start -within the timer window returns `AUTHENTICATED`, silently bypassing a lock the -user already saw. `logout` awaits the same write. - -- **Fix**: await the write (or rely on the already-persisted backgrounded-at - timestamp by recording it before/synchronously with the lock so the timer path - still catches the cold start). - -### I6. No screenshot/snapshot/accessibility protection for the mounted tree — _Security (#4)_ - -No `FLAG_SECURE` (Android), no iOS privacy snapshot/blur anywhere in the -codebase. For timed options the wallet is showing real content at backgrounding -time, so the app-switcher card captures the **unlocked** screen; the live -hierarchy under the overlay also remains exposed to the accessibility -tree/screen readers (plain sibling `View`, no `accessibilityViewIsModal` / -`importantForAccessibility="no-hide-descendants"`). - -- Pre-existing gap, but the "keep screens mounted" design makes it materially - worse. **Fix**: privacy snapshot/FLAG_SECURE on background regardless of - timer; hide the locked tree from accessibility. - -### I7. Resumed screens see `account: null` (and `navigationRef: null`) right after a soft unlock — _Senior (#5)_ - -`src/ducks/auth.ts:2224-2229` - -`signIn`'s `...initialState` spread nulls -`account`/`navigationRef`/`signInMethod` while the user resumes **mid-flow** in -screens that historically only mounted from a fresh stack. Until the background -`getActiveAccount` resolves, deep flows render their null path; `navigationRef` -stays null (nothing remounts to restore it), so later -`navigateToLockScreen`/`resetRoot` calls silently no-op. - -- **Fix**: preserve `navigationRef` explicitly in `signIn` (pattern already - exists in `logout`, `auth.ts:2095-2096`); QA resume-mid-send-flow with a slow - account load. - -### I8. Missing tests for the riskiest new logic — _both reviewers_ - -`useAuthCheck` (background-only recording, IMMEDIATELY soft-lock, -LOCKED→softLock dispatch) and `RootNavigator`'s `showAuthenticatedStack` -conditional are untested — I1/I2 regressions would not be caught today. Also -add: tamper tests (AsyncStorage edits can't unlock a persisted-LOCKED session), -"expiresAt does not advance without credential verification" (after C1), and -"open RN Modal not interactive while soft-locked" (after C3). - ---- - -## Minor (nice to have) - -| # | Finding | Source | Location | -| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ----------------------------------------- | -| M1 | `getBackgroundedAt` returns `NaN` for corrupt values — treated as absent (safe) but never cleaned up; return `null` on `Number.isNaN` and clear the key | Senior | `src/services/autoLock.ts:96-101` | -| M2 | Mirror drift on partial failure: UI checkmark and enforced mirror value can disagree if `persistAutoLockTimer` rejects (fire-and-forget) | Senior | `src/ducks/preferences.ts:39-58` | -| M3 | Wall-clock timer: rolling the device clock back dodges auto-lock; likely accept-and-document (no easy monotonic source in RN) | Senior | timer evaluation | -| M4 | Some Android OEMs emit `background` (not `inactive`) when BiometricPrompt appears → with IMMEDIATELY, an in-app biometric confirmation could soft-lock mid-action / loop the re-prompt. Verify on hardware | Senior | `useAuthCheck` + `LockScreen.tsx:191-206` | -| M5 | IMMEDIATELY before zustand rehydration completes reads the 24h default and skips the instant in-process lock; mirror still enforces on return (cosmetic) | Senior | `src/hooks/useAuthCheck.ts:128` | -| M6 | `LockScreenOverlay` subscribes to the whole auth store → re-renders on all auth churn; use `useAuthenticationStore((s) => s.isSoftLocked)` | Senior | `src/components/LockScreenOverlay.tsx:25` | -| M7 | Biometric re-prompt on every background→active return is a lock-fatigue nudge toward device-PIN fallback (`allowDeviceCredentials: true`); cosmetic | Security | `LockScreen.tsx:154-205` | -| M8 | `AUTO_LOCK_TIMER_SETTING` survives full wipe → the next wallet on the device inherits the previous user's (possibly attacker-set "none") preference; reset/re-validate on fresh sign-up | Security | `src/services/storage/helpers.ts:30-33` | - ---- - -## Consolidated recommendations - -1. **Decouple "auto-lock timer" (UX) from "hash-key cryptoperiod" (security - backstop)** — the conflation is the root of C1/C2. TTL anchored only at - credential entry; timer inputs in secure storage. -2. **Centralize the AUTHENTICATED→LOCKED soft-lock transition** in the store - `getAuthStatus` action (fixes I1+I2, removes module/action duplication). -3. **Harden the overlay**: native-Modal hosting or modal dismissal on lock (C3), - BackHandler (I3), accessibility hiding + FLAG_SECURE/privacy snapshot (I6), - `account: null` + JSDoc fix (I4). -4. **Await the `softLock` persist** (I5); preserve `navigationRef` through - `signIn` (I7). -5. **Add the missing test coverage** (I8) and the tamper/secret-path invariant - tests. -6. Device QA matrix: Android back button while overlaid; OEM biometric-prompt - AppState behavior with IMMEDIATELY; resume-mid-send-flow with slow account - load; iOS app-switcher snapshot content.