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..58af25ca5 --- /dev/null +++ b/__tests__/components/screens/LockScreen.test.tsx @@ -0,0 +1,232 @@ +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), +})); + +// 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 +>["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"; + mockShieldVisible = false; + mockShieldHiddenListeners.clear(); + + useAuthenticationStore.setState({ + signIn: mockSignIn, + verifyActionWithBiometrics: + mockVerifyActionWithBiometrics as unknown as ReturnType< + typeof useAuthenticationStore.getState + >["verifyActionWithBiometrics"], + signInMethod: LoginType.FACE, + isLoading: false, + error: null, + suppressBiometricAutoPrompt: false, + }); + 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("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(); + + // 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 user-initiated lock", async () => { + useAuthenticationStore.setState({ suppressBiometricAutoPrompt: 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(); + + 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("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(); + + 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 074afb574..0bb337a96 100644 --- a/__tests__/ducks/auth.test.ts +++ b/__tests__/ducks/auth.test.ts @@ -1,6 +1,8 @@ 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, @@ -30,6 +32,7 @@ import { } from "helpers/encryptPassword"; import { createKeyManager } from "helpers/keyManager/keyManager"; import { clearScreenshotDek } from "helpers/screenshotCrypto"; +import { AppState } from "react-native"; import { getSupportedBiometryType, BIOMETRY_TYPE } from "react-native-keychain"; import { clearNonSensitiveData, @@ -111,7 +114,11 @@ jest.mock("services/storage/secureStorage", () => ({ jest.mock("ducks/preferences", () => ({ usePreferencesStore: { - getState: jest.fn(), + getState: jest.fn(() => ({ + isBiometricsEnabled: false, + setAutoLockTimer: jest.fn(), + })), + setState: jest.fn(), }, })); @@ -266,6 +273,7 @@ describe("auth duck", () => { setNavigationRef: useAuthenticationStore.getState().setNavigationRef, signIn: useAuthenticationStore.getState().signIn, initializeNetwork: useAuthenticationStore.getState().initializeNetwork, + softLock: useAuthenticationStore.getState().softLock, }; beforeEach(() => { @@ -1433,6 +1441,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 () => { @@ -1473,6 +1486,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 () => { @@ -1610,6 +1629,378 @@ 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 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(); + + 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("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(); + + 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..abd4e86dc --- /dev/null +++ b/__tests__/ducks/preferences.test.ts @@ -0,0 +1,92 @@ +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), + 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 12 hours", () => { + const { result } = renderHook(() => usePreferencesStore()); + + expect(result.current.autoLockTimer).toBe(DEFAULT_AUTO_LOCK_TIMER); + expect(result.current.autoLockTimer).toBe(AUTO_LOCK_TIMER.TWELVE_HOURS); + }); + + it("updates the auto-lock timer and writes through to the mirror", async () => { + const { result } = renderHook(() => usePreferencesStore()); + + 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, + ); + }); + + it("reverts the auto-lock timer when the mirror write fails", async () => { + const { result } = renderHook(() => usePreferencesStore()); + + await act(async () => { + result.current.setAutoLockTimer(AUTO_LOCK_TIMER.ONE_HOUR); + await flushMicrotasks(); + }); + 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 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__/hooks/useAuthCheck.test.tsx b/__tests__/hooks/useAuthCheck.test.tsx new file mode 100644 index 000000000..2ce6d39d9 --- /dev/null +++ b/__tests__/hooks/useAuthCheck.test.tsx @@ -0,0 +1,346 @@ +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, + hasPersistedSession, + recordBackgroundedAt, +} from "services/autoLock"; + +jest.mock("services/autoLock", () => ({ + getAutoLockTimer: jest.fn(), + hasPersistedSession: jest.fn().mockResolvedValue(false), + recordBackgroundedAt: jest.fn().mockResolvedValue(undefined), +})); + +// 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 () => { + // 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(); + } +}; + +// 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() + .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, + ); + (hasPersistedSession as jest.Mock).mockResolvedValue(false); + + 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("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, + ); + 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(); + }); + + it("idle-locks while foregrounded after the timer with no interaction", async () => { + (getAutoLockTimer as jest.Mock).mockResolvedValue( + AUTO_LOCK_TIMER.ONE_MINUTE, + ); + const { unmount } = renderAuthCheck(); + + await act(async () => { + jest.advanceTimersByTime(1000); // let the periodic check interval set up + await flushMicrotasks(); + }); + await act(async () => { + // No interaction; advance past the 1-minute idle timeout so a periodic + // check observes the idle and soft-locks + jest.advanceTimersByTime(ONE_MINUTE_MS + 5000); + await flushMicrotasks(); + }); + + expect(mockSoftLock).toHaveBeenCalled(); + unmount(); + }); + + it("does NOT idle-lock before the timer elapses", async () => { + (getAutoLockTimer as jest.Mock).mockResolvedValue( + AUTO_LOCK_TIMER.ONE_MINUTE, + ); + const { unmount } = renderAuthCheck(); + + await act(async () => { + jest.advanceTimersByTime(1000); + await flushMicrotasks(); + }); + await act(async () => { + // Half the timeout — still under a minute, so no idle-lock + jest.advanceTimersByTime(ONE_MINUTE_MS / 2); + await flushMicrotasks(); + }); + + expect(mockSoftLock).not.toHaveBeenCalled(); + unmount(); + }); + + 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 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 () => { + jest.advanceTimersByTime(ONE_MINUTE_MS - 10000); + await flushMicrotasks(); + }); + + // 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(mockSoftLock).not.toHaveBeenCalled(); + 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 () => { + (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 + // 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 () => { + // 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 ~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(); + }); + + expect(mockSoftLock).not.toHaveBeenCalled(); + unmount(); + }); + + 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(1000); + await flushMicrotasks(); + }); + await act(async () => { + jest.advanceTimersByTime(ONE_MINUTE_MS * 2); + await flushMicrotasks(); + }); + + expect(mockSoftLock).not.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..c5ec308f8 --- /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("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), + ); + + const backgroundedAt = await getBackgroundedAt(); + + expect(backgroundedAt).toBe(0); + }); + + it("clears the persisted timestamp", async () => { + await clearBackgroundedAt(); + + expect(secureDataStorage.remove).toHaveBeenCalledWith( + SENSITIVE_STORAGE_KEYS.AUTO_LOCK_BACKGROUNDED_AT, + ); + }); + }); +}); diff --git a/android/app/src/main/java/com/freightermobile/MainActivity.kt b/android/app/src/main/java/com/freightermobile/MainActivity.kt index 9f660439e..c7e0e57b4 100644 --- a/android/app/src/main/java/com/freightermobile/MainActivity.kt +++ b/android/app/src/main/java/com/freightermobile/MainActivity.kt @@ -2,6 +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 @@ -11,6 +20,24 @@ 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()) + // 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 + } + /** * Returns the name of the main component registered from JavaScript. This is used to schedule * rendering of the component. @@ -32,6 +59,67 @@ class MainActivity : ReactActivity() { */ override fun onCreate(savedInstanceState: Bundle?) { RNBootSplash.init(this, R.style.BootTheme) + // 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 onPause() { + isActivityResumed = false + super.onPause() + } + + override fun onStop() { + showPrivacyShield() + super.onStop() + } + + 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) + } + + 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() { + // 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) + 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() +} 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 a28d80c06..34f2a8b6d 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -66,6 +66,8 @@ PODS: - SocketRocket - Yoga - OpenSSL-Universal (3.6.2000) + - PrivacyShield (1.0.0): + - React-Core - QuickCrypto (1.1.5): - boost - DoubleConversion @@ -3446,6 +3448,7 @@ DEPENDENCIES: - hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) - jail-monkey (from `../node_modules/jail-monkey`) - NitroModules (from `../node_modules/react-native-nitro-modules`) + - PrivacyShield (from `./`) - QuickCrypto (from `../node_modules/react-native-quick-crypto`) - RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) - RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`) @@ -3591,6 +3594,8 @@ EXTERNAL SOURCES: :path: "../node_modules/jail-monkey" NitroModules: :path: "../node_modules/react-native-nitro-modules" + PrivacyShield: + :path: "./" QuickCrypto: :path: "../node_modules/react-native-quick-crypto" RCT-Folly: @@ -3815,6 +3820,7 @@ SPEC CHECKSUMS: libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 NitroModules: 1ef0796714251dbdaea49af187e291d8b6a7c5ea OpenSSL-Universal: ecee7b138fa75a74ecf00d7ffd248fb584739b9e + PrivacyShield: df1c5e513b672f8725b5a4637726ee75ad5611e4 QuickCrypto: fbb50daf9313753153af0e4f67a9f14ec1a26979 RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 RCTDeprecation: c0ed3249a97243002615517dff789bf4666cf585 @@ -3924,6 +3930,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 4462086bf..7ac657938 100644 --- a/ios/freighter-mobile/AppDelegate.swift +++ b/ios/freighter-mobile/AppDelegate.swift @@ -30,12 +30,85 @@ 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() { + // 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 + self?.hidePrivacyShield() + } + } 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. + // + // 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) { + // 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 + overlay.rootViewController = UIStoryboard(name: "BootSplash", bundle: nil) + .instantiateInitialViewController() + overlay.isHidden = false + privacyWindow = overlay + } + + 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() { + // 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 + privacyWindow = nil + } } class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate { diff --git a/jest.config.js b/jest.config.js index 714244a37..526325f77 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", // v16 ships ESM-only deps (@noble/*, smol-toml, uint8array-extras, diff --git a/jest.setup.js b/jest.setup.js index b2b7a80c4..59d009ce7 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -475,8 +475,15 @@ 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), })); +// 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/App.tsx b/src/components/App.tsx index 823e9fed4..8c0f1631b 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -5,6 +5,7 @@ import { } from "@react-navigation/native"; import * as Sentry from "@sentry/react-native"; import { AuthErrorToastListener } from "components/AuthErrorToastListener"; +import { LockScreenOverlay } from "components/LockScreenOverlay"; import { initializeSentryLogger } from "config/logger"; import { NAVIGATION_THEME } from "config/navigationTheme"; import { RootStackParamList } from "config/routes"; @@ -88,6 +89,12 @@ export const App = (): React.JSX.Element => { + {/* Soft-lock overlay: rendered after the bottom sheet provider so + 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 new file mode 100644 index 000000000..c742bab8a --- /dev/null +++ b/src/components/LockScreenOverlay.tsx @@ -0,0 +1,56 @@ +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 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: avoid re-rendering on unrelated auth-store churn + const isSoftLocked = useAuthenticationStore((state) => state.isSoftLocked); + + // Swallow the Android back button while locked so it can't pop the hidden + // tree 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/Modal.tsx b/src/components/Modal.tsx index 13917eb53..45389f80e 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -1,4 +1,5 @@ -import React from "react"; +import { useAuthenticationStore } from "ducks/auth"; +import React, { useEffect } from "react"; import { type StyleProp, View, @@ -28,41 +29,54 @@ const Modal: React.FC = ({ contentClassName, contentStyle, testID, -}) => ( - { +}) => { + // 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 && isSoftLocked) { onClose(); - }} - > - - { - if (closeOnOverlayPress) { - onClose(); - } - }} - > - - + } + }, [visible, isSoftLocked, onClose]); - - { + onClose(); + }} + > + + { + if (closeOnOverlayPress) { + onClose(); + } + }} > - {children} + + + + + + {children} + - - - -); + + + ); +}; export default Modal; diff --git a/src/components/screens/LockScreen.tsx b/src/components/screens/LockScreen.tsx index ba7433fa8..a78632e55 100644 --- a/src/components/screens/LockScreen.tsx +++ b/src/components/screens/LockScreen.tsx @@ -1,18 +1,50 @@ import { NativeStackScreenProps } from "@react-navigation/native-stack"; import ForgotPasswordWarningModal from "components/screens/ForgotPasswordWarningModal"; import InputPasswordTemplate from "components/templates/InputPasswordTemplate"; +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"; +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"; +import { AppState } from "react-native"; type LockScreenProps = NativeStackScreenProps< RootStackParamList, typeof ROOT_NAVIGATOR_ROUTES.LOCK_SCREEN >; -export const LockScreen: React.FC = ({ navigation }) => { +// Small delay to ensure state is settled before navigating after unlock +const UNLOCK_NAVIGATION_DELAY_MS = 100; + +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, @@ -20,8 +52,12 @@ export const LockScreen: React.FC = ({ navigation }) => { authStatus, logout, clearError, + signInMethod, + verifyActionWithBiometrics, } = useAuthenticationStore(); + const { isBiometricsEnabled } = usePreferencesStore(); const { t } = useAppTranslation(); + const { showToast } = useToast(); const [publicKey, setPublicKey] = useState(null); const [isForgotPasswordModalVisible, setIsForgotPasswordModalVisible] = useState(false); @@ -37,20 +73,32 @@ export const LockScreen: React.FC = ({ navigation }) => { // for invalid-password, or app-wide by AuthErrorToastListener). 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"); + // 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(() => { - 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 () => { @@ -71,17 +119,138 @@ export const LockScreen: React.FC = ({ navigation }) => { }, [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], ); + /** + * 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; + let didAttemptUnlock = false; + verifyActionWithBiometrics((password?: string) => { + if (password) { + didAttemptUnlock = true; + // 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(() => { + if (!didAttemptUnlock) { + // Biometric prompt cancelled/failed before any unlock attempt — the + // user can still unlock manually; we re-prompt on the next foreground. + return; + } + // 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 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: UNLOCK_ERROR_TOAST_ID, + variant: "error", + title: t("lockScreen.errorUnlockingWalletTitle"), + message: t("lockScreen.errorUnlockingWalletMessage"), + duration: ERROR_TOAST_DURATION, + }); + }); + }, [ + isSigningIn, + isForgotPasswordModalVisible, + isBiometricsEnabled, + signInMethod, + verifyActionWithBiometrics, + handleUnlock, + clearError, + showToast, + t, + ]); + + /** + * 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 + // 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().suppressBiometricAutoPrompt + ) { + requestBiometricPrompt(); + } + }, [requestBiometricPrompt]); + + // 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. + // Held until the privacy shield drops so the lock screen is visible. + wasBackgroundedRef.current = false; + hasAutoPromptedRef.current = false; + requestBiometricPrompt(); + } + }); + + return () => subscription.remove(); + }, [requestBiometricPrompt]); + const handleForgotPassword = useCallback(() => { setIsForgotPasswordModalVisible(true); }, []); @@ -115,3 +284,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/SendScreen/screens/SendCollectibleReview.tsx b/src/components/screens/SendScreen/screens/SendCollectibleReview.tsx index 4f2fe2817..18205d150 100644 --- a/src/components/screens/SendScreen/screens/SendCollectibleReview.tsx +++ b/src/components/screens/SendScreen/screens/SendCollectibleReview.tsx @@ -51,7 +51,9 @@ import { isMuxedAccount } from "helpers/stellar"; import { useBlockaidTransaction } from "hooks/blockaid/useBlockaidTransaction"; import useAppTranslation from "hooks/useAppTranslation"; 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"; @@ -361,6 +363,13 @@ const SendCollectibleReviewScreen: React.FC< throw new Error("Missing account or collectible information"); } + // Refuse to sign if the wallet locked between opening the review sheet + // and confirming (e.g. an IMMEDIATELY auto-lock fired) — every other + // signing path enforces the same guard. + if (!isWalletUnlocked()) { + throw new Error("Wallet is locked"); + } + const { privateKey } = account; signTransaction({ diff --git a/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx b/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx index d498557a6..169985ec2 100644 --- a/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx +++ b/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx @@ -65,7 +65,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"; @@ -317,7 +319,7 @@ const TransactionAmountScreen: React.FC = ({ }); }; - const { balanceItems } = useBalancesList({ + const { balanceItems, isLoading: isLoadingBalances } = useBalancesList({ publicKey: publicKey ?? "", network, }); @@ -507,6 +509,19 @@ const TransactionAmountScreen: React.FC = ({ }; useEffect(() => { + // Skip until balances AND the active account are loaded. After the app + // returns from background, balances refetch and the 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; + } + const currentTokenAmount = BigNumber(tokenAmount); if (!hasXLMForFees(balanceItems, transactionFee)) { @@ -559,6 +574,8 @@ const TransactionAmountScreen: React.FC = ({ tokenAmount, spendableBalance, balanceItems, + isLoadingBalances, + account, transactionFee, transactionHash, isCustomToken, @@ -733,6 +750,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 new file mode 100644 index 000000000..7531d6840 --- /dev/null +++ b/src/components/screens/SettingsScreen/SecurityScreen/AutoLockTimerScreen/AutoLockTimerScreen.tsx @@ -0,0 +1,72 @@ +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 handleSelectOption = (option: AUTO_LOCK_TIMER) => { + setAutoLockTimer(option); + }; + + const listItems = Object.values(AUTO_LOCK_TIMER).map((option) => ({ + title: timerLabels[option], + titleColor: themeColors.text.primary, + onPress: () => handleSelectOption(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/components/screens/SwapScreen/hooks/useSwapTransaction.ts b/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts index 7f3d175d0..ee3c4f1c6 100644 --- a/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts +++ b/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts @@ -21,6 +21,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 { useCallback, useEffect, useRef, useState } from "react"; import { analytics } from "services/analytics"; @@ -184,6 +185,14 @@ export const useSwapTransaction = ({ 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/config/constants.ts b/src/config/constants.ts index 536d24a9f..a6aedc956 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -89,9 +89,75 @@ 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; +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", +} + +// 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"; + +/** + * 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; @@ -331,6 +397,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", @@ -339,6 +407,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 4800557cf..c620cdc8e 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 = { @@ -210,6 +211,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 61546263d..65bb4c93e 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, @@ -51,9 +52,17 @@ import { clearScreenshotDek } from "helpers/screenshotCrypto"; import { scrubStrKeys } from "helpers/stellarStrKey"; 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, + persistAutoLockTimer, +} from "services/autoLock"; import { getAccount } from "services/stellar"; import { clearNonSensitiveData, @@ -207,6 +216,16 @@ 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; + // 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 @@ -217,7 +236,6 @@ interface AuthState { // Biometric authentication state signInMethod: LoginType; - hasTriggeredAppOpenBiometricsLogin: boolean; } /** @@ -280,6 +298,7 @@ interface ImportSecretKeyParams { */ interface AuthActions { logout: (shouldWipeAllData?: boolean) => void; + softLock: (options?: { suppressBiometricPrompt?: boolean }) => Promise; signUp: (params: SignUpParams) => Promise; signIn: (params: SignInParams) => Promise; importWallet: (params: ImportWalletParams) => Promise; @@ -320,7 +339,6 @@ interface AuthActions { // Biometric authentication actions setSignInMethod: (method: LoginType) => void; - setHasTriggeredAppOpenBiometricsLogin: (hasTriggered: boolean) => void; } /** @@ -343,6 +361,8 @@ const initialState: Omit = { isSwitchingAccount: false, error: null, authStatus: AUTH_STATUS.NOT_AUTHENTICATED, + isSoftLocked: false, + suppressBiometricAutoPrompt: false, allAccounts: [], // Active account initial state account: null, @@ -351,7 +371,6 @@ const initialState: Omit = { navigationRef: null, // Biometric authentication initial state signInMethod: LoginType.PASSWORD, - hasTriggeredAppOpenBiometricsLogin: false, }; /** @@ -491,11 +510,58 @@ const getAuthStatus = async (): Promise => { return AUTH_STATUS.HASH_KEY_EXPIRED; } - // Check if hash key is expired (only relevant when not LOCKED) + // 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(); + // 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(); + const autoLockTimerMs = 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 + ) { + await secureDataStorage.setItem( + SENSITIVE_STORAGE_KEYS.AUTH_STATUS, + AUTH_STATUS.LOCKED, + ); + await clearBackgroundedAt(); + return AUTH_STATUS.LOCKED; + } + + if (AppState.currentState === "active") { + // 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. + } + // All conditions for authentication are met return AUTH_STATUS.AUTHENTICATED; } catch (error) { @@ -804,8 +870,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 { @@ -1037,6 +1104,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, @@ -1397,12 +1481,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 { @@ -2059,6 +2145,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, }); @@ -2118,6 +2208,13 @@ export const useAuthenticationStore = create()((set, get) => ({ await clearNonSensitiveData(); 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. 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(); set({ isLoading: false }); @@ -2134,6 +2231,53 @@ export const useAuthenticationStore = create()((set, get) => ({ }, 0); }, + /** + * Soft-locks the wallet in-process (auto-lock timer / IMMEDIATELY option). + * + * 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 (options?: { suppressBiometricPrompt?: boolean }) => { + Keyboard.dismiss(); + + // 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). 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 (firstError) { + logger.error( + "softLock", + "Failed to persist LOCKED status, retrying", + firstError, + ); + await secureDataStorage.setItem( + SENSITIVE_STORAGE_KEYS.AUTH_STATUS, + AUTH_STATUS.LOCKED, + ); + } + }, + /** * Signs up a new user with the provided credentials * @@ -2143,9 +2287,13 @@ 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, + navigationRef: get().navigationRef, isLoading: false, authStatus: AUTH_STATUS.AUTHENTICATED, }); @@ -2177,12 +2325,27 @@ 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. analytics.trackReAuthSuccess(); set({ ...initialState, + // 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, @@ -2191,11 +2354,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(); @@ -2212,17 +2378,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), - ); } catch (error) { analytics.trackReAuthFail(); logger.error("useAuthenticationStore.signIn", "Sign in failed", error); @@ -2563,9 +2724,13 @@ 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, + navigationRef: get().navigationRef, authStatus: AUTH_STATUS.AUTHENTICATED, isLoading: false, }); @@ -2591,14 +2756,49 @@ export const useAuthenticationStore = create()((set, get) => ({ }, /** - * Gets the current authentication status + * 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(); + + if ( + authStatus === AUTH_STATUS.LOCKED && + previousAuthStatus === AUTH_STATUS.AUTHENTICATED + ) { + // Auto-lock timer fired: soft lock atomically so the tree never unmounts + await get().softLock(); + return authStatus; + } + + // 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; + } + + // 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 @@ -2624,6 +2824,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 @@ -2652,22 +2858,15 @@ export const useAuthenticationStore = create()((set, get) => ({ set({ isLoadingAccount: true, accountError: null }); try { - // Check auth status first - const authStatus = await getAuthStatus(); + // 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 + // Block sensitive data while expired/locked (funnel already navigated) if ( authStatus === AUTH_STATUS.HASH_KEY_EXPIRED || authStatus === AUTH_STATUS.LOCKED ) { - set({ - authStatus, - }); - - // Navigate to lock screen - get().navigateToLockScreen(); - set({ isLoadingAccount: false }); return null; } @@ -2792,15 +2991,12 @@ export const useAuthenticationStore = create()((set, get) => ({ selectAccount: async (publicKey: string) => { 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(); + // Via the store funnel so an auto-lock here soft-locks atomically + const authStatus = await get().getAuthStatus(); if ( authStatus === AUTH_STATUS.HASH_KEY_EXPIRED || authStatus === AUTH_STATUS.LOCKED ) { - set({ authStatus }); - get().navigateToLockScreen(); set({ isSwitchingAccount: false }); return; } @@ -2889,8 +3085,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..39d2ed3b1 100644 --- a/src/ducks/preferences.ts +++ b/src/ducks/preferences.ts @@ -1,4 +1,11 @@ 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, + getAutoLockTimer, + persistAutoLockTimer, +} from "services/autoLock"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; @@ -9,17 +16,21 @@ interface PreferencesState { setIsMemoValidationEnabled: (isMemoValidationEnabled: boolean) => void; isBiometricsEnabled: boolean | undefined; setIsBiometricsEnabled: (isBiometricsEnabled: boolean) => void; + autoLockTimer: AUTO_LOCK_TIMER; + setAutoLockTimer: (autoLockTimer: AUTO_LOCK_TIMER) => void; + hydrateAutoLockTimer: () => Promise; } 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,10 +38,60 @@ export const usePreferencesStore = create()( set({ isMemoValidationEnabled }), setIsBiometricsEnabled: (isBiometricsEnabled: boolean) => set({ isBiometricsEnabled }), + setAutoLockTimer: (autoLockTimer: AUTO_LOCK_TIMER) => { + const previousAutoLockTimer = get().autoLockTimer; + set({ autoLockTimer }); + + // 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( + "hydrateAutoLockTimer", + "Failed to hydrate auto-lock timer from secure storage", + 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/privacyShield.ts b/src/helpers/privacyShield.ts new file mode 100644 index 000000000..102b1fe11 --- /dev/null +++ b/src/helpers/privacyShield.ts @@ -0,0 +1,67 @@ +import { NativeModules } from "react-native"; + +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 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 + }) + .finally(finish); +}; 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/useAppOpenBiometricsLogin.ts b/src/hooks/useAppOpenBiometricsLogin.ts deleted file mode 100644 index 258bd3543..000000000 --- a/src/hooks/useAppOpenBiometricsLogin.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { ERROR_TOAST_DURATION } from "config/constants"; -import { AUTH_STATUS } from "config/types"; -import { useAuthenticationStore } from "ducks/auth"; -import useAppTranslation from "hooks/useAppTranslation"; -import { AUTH_ERROR_TOAST_ID } from "hooks/useAuthErrorToast"; -import { useBiometrics } from "hooks/useBiometrics"; -import { useToast } from "providers/ToastProvider"; -import { useEffect } from "react"; - -export const useAppOpenBiometricsLogin = (initializing: boolean) => { - const { - authStatus, - verifyActionWithBiometrics, - signIn, - clearError, - 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(() => { - // Clear any store error set by the underlying signIn so the global - // AuthErrorToastListener doesn't race this toast on the shared id (and - // a biometric-derived invalidPassword doesn't bleed into LockScreen's - // inline field). This biometric toast is the single authoritative one. - clearError(); - showToast({ - toastId: AUTH_ERROR_TOAST_ID, - variant: "error", - title: t("lockScreen.errorUnlockingWalletTitle"), - message: t("lockScreen.errorUnlockingWalletMessage"), - duration: ERROR_TOAST_DURATION, - }); - }); - } - }, [ - authStatus, - isBiometricsEnabled, - initializing, - hasTriggeredAppOpenBiometricsLogin, - setHasTriggeredAppOpenBiometricsLogin, - verifyActionWithBiometrics, - signIn, - clearError, - showToast, - t, - ]); -}; diff --git a/src/hooks/useAuthCheck.ts b/src/hooks/useAuthCheck.ts index f6c8c44c0..361ff8eaf 100644 --- a/src/hooks/useAuthCheck.ts +++ b/src/hooks/useAuthCheck.ts @@ -1,13 +1,24 @@ +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"; import { useAuthenticationStore } from "ducks/auth"; -import { useEffect, useRef, useState, useCallback } from "react"; import { - AppState, - AppStateStatus, - PanResponder, - PanResponderInstance, -} from "react-native"; + hidePrivacyShield, + markPrivacyShieldVisible, +} from "helpers/privacyShield"; +import { setActivityRecorder } from "helpers/userActivity"; +import { useEffect, useRef, useState, useCallback, useMemo } from "react"; +import { AppState, AppStateStatus, Keyboard, PanResponder } from "react-native"; +import { + getAutoLockTimer, + hasPersistedSession, + 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 @@ -24,16 +35,50 @@ 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 + // 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 + * 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); + }, []); + + /** + * 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. @@ -52,9 +97,36 @@ const useAuthCheck = () => { lastCheckRef.current = now; try { + // The store action is the single funnel for lock transitions: it + // 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(); - if (status === AUTH_STATUS.HASH_KEY_EXPIRED) { - navigateToLockScreen(); + + // Foreground-idle auto-lock: while the app is active, lock after the + // 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 autoLockTimer = await getAutoLockTimer(); + const timerMs = AUTO_LOCK_TIMER_MS[autoLockTimer]; + + if ( + timerMs !== null && + timerMs > 0 && + Date.now() - lastInteractionRef.current >= timerMs + ) { + // The user stayed in the app and idled out — suppress the lock + // screen's biometric auto-prompt for this case. + await useAuthenticationStore + .getState() + .softLock({ suppressBiometricPrompt: true }); + } } } catch (error) { logger.error( @@ -63,7 +135,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,11 +171,91 @@ const useAuthCheck = () => { */ useEffect(() => { const handleAppStateChange = (nextAppState: AppStateStatus) => { - // When returning to active state, allow a slight delay before checking auth + // 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, + // 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") { + // 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. + 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. + await softLock(); + } + })().catch((err) => + logger.error( + "handleAppStateChange", + "Error handling background auto-lock", + err, + ), + ); + } + + // 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(() => { + // 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(); + } + }, SHIELD_REVEAL_DELAY); + }); + setTimeout(() => { checkAuth().catch((err) => logger.error("handleAppStateChange", "Error checking auth", err), @@ -130,6 +282,49 @@ 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) { + recordInteraction(); + } + }, [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]); + + /** + * 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. */ @@ -146,30 +341,13 @@ const useAuthCheck = () => { }, []); /** - * Initialize PanResponder to capture touch interactions and update the last interaction timestamp. + * Perform an initial auth check after a short delay to avoid navigation race + * conditions during setup. */ useEffect(() => { - const updateLastInteraction = () => { - lastInteractionRef.current = Date.now(); - setIsActive(true); - }; - - panResponderRef.current = PanResponder.create({ - onStartShouldSetPanResponder: () => { - updateLastInteraction(); - return false; - }, - onMoveShouldSetPanResponder: () => { - updateLastInteraction(); - 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); @@ -189,7 +367,7 @@ const useAuthCheck = () => { checkAuthNow, isActive, authStatus, - panHandlers: panResponderRef.current?.panHandlers, + panHandlers: panResponder.panHandlers, }; }; diff --git a/src/hooks/useGetActiveAccount.ts b/src/hooks/useGetActiveAccount.ts index bc9b24dae..b6a88154a 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. + */ +export 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/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/hooks/useTokenFiatConverter/index.ts b/src/hooks/useTokenFiatConverter/index.ts index 62cd21607..8695b93f3 100644 --- a/src/hooks/useTokenFiatConverter/index.ts +++ b/src/hooks/useTokenFiatConverter/index.ts @@ -3,6 +3,7 @@ import { CLASSIC_TOKEN_MAX_AMOUNT, DEFAULT_DECIMALS } from "config/constants"; import { PricedBalance } from "config/types"; import { hasDecimals } from "helpers/balances"; import { formatBigNumberForDisplay } from "helpers/formatAmount"; +import { recordUserActivity } from "helpers/userActivity"; import { createTokenFiatConverterReducer, initialState, @@ -269,6 +270,10 @@ export const useTokenFiatConverter = ({ }, []); const setDisplayAmountFromText = 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 a send/swap amount. + recordUserActivity(); dispatch({ type: TokenFiatConverterActionType.SET_DISPLAY_AMOUNT_FROM_TEXT, payload: { text }, diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index a3a3ce6b8..80f6d0c27 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -963,6 +963,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.", @@ -1006,6 +1007,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 dc2a3a518..303c4d7e4 100644 --- a/src/i18n/locales/pt/translations.json +++ b/src/i18n/locales/pt/translations.json @@ -963,6 +963,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.", @@ -1006,6 +1007,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..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 { @@ -41,7 +42,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"; @@ -57,9 +57,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 & @@ -75,7 +80,7 @@ export const RootNavigator = () => { useNavigation< NativeStackNavigationProp >(); - const { authStatus, getAuthStatus, initializeNetwork } = + const { authStatus, isSoftLocked, getAuthStatus, initializeNetwork } = useAuthenticationStore(); const remoteConfigInitialized = useRemoteConfigStore( (state) => state.isInitialized, @@ -94,8 +99,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 @@ -115,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(); @@ -152,9 +159,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 +178,7 @@ export const RootNavigator = () => { } return ROOT_NAVIGATOR_ROUTES.AUTH_STACK; - }, [authStatus]); + }, [authStatus, showAuthenticatedStack]); if (isJailbroken) { return ; @@ -194,112 +206,124 @@ export const RootNavigator = () => { } return ( - - {authStatus === AUTH_STATUS.AUTHENTICATED ? ( - - - - - - - - - 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/navigators/SettingsNavigator.tsx b/src/navigators/SettingsNavigator.tsx index 9707e884d..038bf3ae4 100644 --- a/src/navigators/SettingsNavigator.tsx +++ b/src/navigators/SettingsNavigator.tsx @@ -7,6 +7,7 @@ import SettingsScreen from "components/screens/SettingsScreen"; import AboutScreen from "components/screens/SettingsScreen/AboutScreen"; import PreferencesScreen from "components/screens/SettingsScreen/PreferencesScreen"; import SecurityScreen from "components/screens/SettingsScreen/SecurityScreen"; +import AutoLockTimerScreen from "components/screens/SettingsScreen/SecurityScreen/AutoLockTimerScreen"; import BiometricsSettingsScreen from "components/screens/SettingsScreen/SecurityScreen/BiometricsSettingsScreen"; import ShowRecoveryPhraseScreen from "components/screens/SettingsScreen/SecurityScreen/ShowRecoveryPhraseScreen"; import YourRecoveryPhraseScreen from "components/screens/SettingsScreen/SecurityScreen/YourRecoveryPhraseScreen"; @@ -110,6 +111,13 @@ export const SettingsStackNavigator = () => { 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..366abc67b --- /dev/null +++ b/src/services/autoLock.ts @@ -0,0 +1,153 @@ +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 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( + SENSITIVE_STORAGE_KEYS.AUTO_LOCK_BACKGROUNDED_AT, + ); + + if (!backgroundedAt) { + return null; + } + + const parsedBackgroundedAt = Number(backgroundedAt); + + if (Number.isNaN(parsedBackgroundedAt)) { + await clearBackgroundedAt(); + return null; + } + + if (parsedBackgroundedAt > Date.now()) { + return 0; + } + + 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); +}; + +export { + getAutoLockTimer, + persistAutoLockTimer, + getHashKeyExpirationMs, + applyAutoLockTimerToHashKey, + recordBackgroundedAt, + getBackgroundedAt, + clearBackgroundedAt, + hasPersistedSession, +};