Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ function useSentryUserContext() {

function RootLayout() {
const [i18nReady, setI18nReady] = useState(false);
const [securityHydrated, setSecurityHydrated] = useState(false);
const hydrate = useAuthStore((s) => s.hydrate);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const isLoading = useAuthStore((s) => s.isLoading);
Expand Down Expand Up @@ -99,13 +100,19 @@ function RootLayout() {
useEffect(() => {
void hydrate();
void useUserStore.getState().hydrate();
useSecurityStore
.getState()
.hydrate()
.finally(() => setSecurityHydrated(true));
initPromise.then(() => setI18nReady(true));
}, [hydrate]);

useAuthGuard();
useSentryUserContext();

useEffect(() => {
if (!securityHydrated) return;

if (!isLoading && isAuthenticated && !biometricCheckDone) {
useSecurityStore.getState().markBiometricCheckDone();
biometricService.isBiometricsEnabled().then((enabled) => {
Expand All @@ -118,7 +125,7 @@ function RootLayout() {
if (!isAuthenticated) {
useSecurityStore.getState().reset();
}
}, [isLoading, isAuthenticated, biometricCheckDone]);
}, [isLoading, isAuthenticated, biometricCheckDone, securityHydrated]);

useEffect(() => {
const subscription = AppState.addEventListener('change', (state) => {
Expand Down Expand Up @@ -172,7 +179,7 @@ function RootLayout() {
return () => unsubscribe();
}, []);

if (isLoading || !i18nReady) {
if (isLoading || !i18nReady || !securityHydrated) {
return null;
}

Expand Down
11 changes: 5 additions & 6 deletions src/components/BiometricGate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,11 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import { colors } from '../../constants/colors';
import { biometricService } from '../security/biometric.service';
import { useSecurityStore } from '../security/security.store';
import { evaluateFailedAttempt } from '../security/lockout';
import { useAuthStore } from '../../stores/auth.store';
import { useUserStore } from '../../stores/user.store';
import { useWalletStore } from '../../stores/wallet.store';

const MAX_FAILED_ATTEMPTS = 3;

type GateMode = 'loading' | 'biometric' | 'pin' | 'error';

export function BiometricGate() {
Expand All @@ -31,14 +30,14 @@ export function BiometricGate() {

const handleFailure = useCallback(async () => {
const store = useSecurityStore.getState();
const newCount = store.failedAttempts + 1;
const outcome = evaluateFailedAttempt(store.failedAttempts);
store.incrementFailedAttempts();
if (newCount >= MAX_FAILED_ATTEMPTS) {
if (outcome.lockedOut) {
await handleLogout();
} else {
const remaining = MAX_FAILED_ATTEMPTS - newCount;
const { remainingAttempts } = outcome;
setErrorMessage(
`Verification failed. ${remaining} attempt${remaining > 1 ? 's' : ''} remaining.`
`Verification failed. ${remainingAttempts} attempt${remainingAttempts > 1 ? 's' : ''} remaining.`
);
}
}, [handleLogout]);
Expand Down
60 changes: 60 additions & 0 deletions src/security/lockout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { MAX_FAILED_ATTEMPTS, evaluateFailedAttempt } from './lockout';

declare const describe: any;
declare const it: any;
declare const expect: any;

describe('lockout', () => {
it('exposes a three-attempt threshold', () => {
expect(MAX_FAILED_ATTEMPTS).toBe(3);
});

describe('evaluateFailedAttempt', () => {
it('counts up and reports remaining attempts before the threshold', () => {
expect(evaluateFailedAttempt(0)).toEqual({
failedAttempts: 1,
lockedOut: false,
remainingAttempts: 2,
});
expect(evaluateFailedAttempt(1)).toEqual({
failedAttempts: 2,
lockedOut: false,
remainingAttempts: 1,
});
});

it('locks out once the threshold is reached', () => {
expect(evaluateFailedAttempt(2)).toEqual({
failedAttempts: 3,
lockedOut: true,
remainingAttempts: 0,
});
});

it('stays locked out past the threshold without negative remaining', () => {
expect(evaluateFailedAttempt(5)).toEqual({
failedAttempts: 6,
lockedOut: true,
remainingAttempts: 0,
});
});

it('treats negative, fractional, or non-finite counts as zero', () => {
expect(evaluateFailedAttempt(-4)).toEqual({
failedAttempts: 1,
lockedOut: false,
remainingAttempts: 2,
});
expect(evaluateFailedAttempt(1.9)).toEqual({
failedAttempts: 2,
lockedOut: false,
remainingAttempts: 1,
});
expect(evaluateFailedAttempt(Number.NaN)).toEqual({
failedAttempts: 1,
lockedOut: false,
remainingAttempts: 2,
});
});
});
});
34 changes: 34 additions & 0 deletions src/security/lockout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Lockout policy for the biometric/PIN gate.
*
* Extracted as a pure, dependency-free module so the attempt-counting logic can
* be unit-tested and shared, rather than living inline in the UI. The count it
* evaluates is persisted by the security store, so the lockout survives an app
* relaunch (a user cannot reset their remaining attempts by killing the app).
*/

export const MAX_FAILED_ATTEMPTS = 3;

export interface AttemptOutcome {
/** The failed-attempt count after applying this attempt. */
failedAttempts: number;
/** True once the threshold is reached; the caller should force sign-out. */
lockedOut: boolean;
/** Attempts remaining before lockout (never negative). */
remainingAttempts: number;
}

/**
* Evaluate a single failed unlock attempt against the current count. Negative or
* malformed input is treated as zero so a corrupted persisted value can never
* grant extra attempts.
*/
export const evaluateFailedAttempt = (currentFailedAttempts: number): AttemptOutcome => {
const safeCurrent = Number.isFinite(currentFailedAttempts)
? Math.max(0, Math.floor(currentFailedAttempts))
: 0;
const failedAttempts = safeCurrent + 1;
const lockedOut = failedAttempts >= MAX_FAILED_ATTEMPTS;
const remainingAttempts = Math.max(0, MAX_FAILED_ATTEMPTS - failedAttempts);
return { failedAttempts, lockedOut, remainingAttempts };
};
76 changes: 70 additions & 6 deletions src/security/security.store.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,41 @@
import { Platform } from 'react-native';
import * as SecureStore from 'expo-secure-store';
import { create } from 'zustand';

const LOCKED_KEY = 'stepfi.security.isLocked';
const FAILED_ATTEMPTS_KEY = 'stepfi.security.failedAttempts';

// Mirror of the auth store's storage shape: SecureStore on native, localStorage
// on web. Only the lockout-relevant fields (isLocked, failedAttempts) are
// persisted so the gate cannot be reset by relaunching the app.
const storage = {
async getItem(key: string): Promise<string | null> {
if (Platform.OS === 'web') {
return localStorage.getItem(key);
}
return SecureStore.getItemAsync(key);
},
async setItem(key: string, value: string): Promise<void> {
if (Platform.OS === 'web') {
localStorage.setItem(key, value);
return;
}
await SecureStore.setItemAsync(key, value);
},
async removeItem(key: string): Promise<void> {
if (Platform.OS === 'web') {
localStorage.removeItem(key);
return;
}
await SecureStore.deleteItemAsync(key);
},
};

interface SecurityState {
isLocked: boolean;
failedAttempts: number;
biometricCheckDone: boolean;
hydrate: () => Promise<void>;
lock: () => void;
unlock: () => void;
incrementFailedAttempts: () => void;
Expand All @@ -17,20 +49,52 @@ export const useSecurityStore = create<SecurityState>((set) => ({
failedAttempts: 0,
biometricCheckDone: false,

lock: () => set({ isLocked: true }),
// Restore the persisted lockout state on launch. Must resolve before the app
// renders so a locked session is not briefly shown as unlocked.
hydrate: async () => {
const [lockedRaw, attemptsRaw] = await Promise.all([
storage.getItem(LOCKED_KEY),
storage.getItem(FAILED_ATTEMPTS_KEY),
]);
const parsedAttempts = Number.parseInt(attemptsRaw ?? '', 10);
set({
isLocked: lockedRaw === 'true',
failedAttempts: Number.isNaN(parsedAttempts) ? 0 : Math.max(0, parsedAttempts),
});
},

lock: () => {
set({ isLocked: true });
void storage.setItem(LOCKED_KEY, 'true');
},

unlock: () => set({ isLocked: false, failedAttempts: 0 }),
unlock: () => {
set({ isLocked: false, failedAttempts: 0 });
void storage.setItem(LOCKED_KEY, 'false');
void storage.setItem(FAILED_ATTEMPTS_KEY, '0');
},

incrementFailedAttempts: () => set((state) => ({ failedAttempts: state.failedAttempts + 1 })),
incrementFailedAttempts: () =>
set((state) => {
const failedAttempts = state.failedAttempts + 1;
void storage.setItem(FAILED_ATTEMPTS_KEY, String(failedAttempts));
return { failedAttempts };
}),

resetFailedAttempts: () => set({ failedAttempts: 0 }),
resetFailedAttempts: () => {
set({ failedAttempts: 0 });
void storage.setItem(FAILED_ATTEMPTS_KEY, '0');
},

markBiometricCheckDone: () => set({ biometricCheckDone: true }),

reset: () =>
// Cleared on sign-out: drop the persisted lockout so the next user starts fresh.
reset: () => {
set({
isLocked: false,
failedAttempts: 0,
biometricCheckDone: false,
}),
});
void Promise.all([storage.removeItem(LOCKED_KEY), storage.removeItem(FAILED_ATTEMPTS_KEY)]);
},
}));
Loading