Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
5a78af5
feat: install react-native-keychain
OtavioStasiak May 27, 2026
8691f37
feat(biometric-trust): add enrollment-bound trust store and Option C …
OtavioStasiak May 27, 2026
3a59438
feat(biometric-trust): invalidate biometric trust on enrollment change
OtavioStasiak May 27, 2026
d384c0c
feat(biometric-trust): show explanatory subtitle on enrollment-change…
OtavioStasiak May 27, 2026
c35c0c6
feat(biometric-trust): silent-bind migration for existing biometry us…
OtavioStasiak May 27, 2026
7ec65a4
feat: i18n translation
OtavioStasiak May 27, 2026
f091568
podfile
OtavioStasiak May 27, 2026
8b84535
Merge branch 'develop' into feat.authentication-bypass-via-biometric-…
OtavioStasiak May 28, 2026
898b8aa
fix(biometric-trust): revert ScreenLockConfig toggle when enrol fails
OtavioStasiak May 28, 2026
3dbb3e3
fix(screen-lock): defer modal resolve until close animation finishes
OtavioStasiak May 28, 2026
1565c0d
feat: add e2e tests
OtavioStasiak May 28, 2026
d01f17a
chore: format code and fix lint issues
OtavioStasiak May 28, 2026
76da4bc
Merge branch 'develop' into feat.authentication-bypass-via-biometric-…
OtavioStasiak May 28, 2026
f6f59cb
fix: e2e tests
OtavioStasiak May 29, 2026
952f38a
fix: test flow
OtavioStasiak May 29, 2026
fe0d66b
refactor: encapsulate biometric trust state and speed up screen-lock E2E
OtavioStasiak Jun 2, 2026
13136b6
refactor: route biometric enabled flag through trust store API
OtavioStasiak Jun 2, 2026
68a257f
Merge branch 'develop' into feat.authentication-bypass-via-biometric-…
OtavioStasiak Jun 2, 2026
fdb2cc1
refactor(biometric-trust): encapsulate biometry toggle and clarify tr…
OtavioStasiak Jun 2, 2026
256e90b
refactor(biometric-trust): type unlock outcome as discriminated union…
OtavioStasiak Jun 2, 2026
bc0bd7b
refactor(biometric-trust): remove dead mount-time auto-biometry and v…
OtavioStasiak Jun 2, 2026
063ec62
fix(screen-lock): prompt biometry from behind the passcode modal to s…
OtavioStasiak Jun 2, 2026
4e6a86e
fix(biometric-trust): mark install trust-initialized on enrol to clos…
OtavioStasiak Jun 2, 2026
66ab2f8
fix(biometric-trust): restore biometry opt-in prompt on first-passcod…
OtavioStasiak Jun 2, 2026
5969082
fix(biometric-trust): clear enabled flag on unavailable to fix iOS en…
OtavioStasiak Jun 2, 2026
b7e21c0
Merge branch 'develop' into feat.authentication-bypass-via-biometric-…
OtavioStasiak Jun 2, 2026
f9c2f86
chore: biometricTrustStore docs
OtavioStasiak Jun 2, 2026
43c18f4
Merge branch 'develop' into feat.authentication-bypass-via-biometric-…
OtavioStasiak Jun 2, 2026
80f688f
chore: format code and fix lint issues
OtavioStasiak Jun 2, 2026
ad26251
chore: doc improvement
OtavioStasiak Jun 2, 2026
dcff7c3
Merge branch 'develop' into feat.authentication-bypass-via-biometric-…
OtavioStasiak Jun 2, 2026
b566a47
chore: code improvements
OtavioStasiak Jun 2, 2026
fbfd4e2
chore: format code and fix lint issues
OtavioStasiak Jun 2, 2026
050b438
Merge branch 'develop' into feat.authentication-bypass-via-biometric-…
OtavioStasiak Jun 3, 2026
122bc6a
fix: persist passcode attempts across re-renders, guard toggle double…
OtavioStasiak Jun 3, 2026
6bd28b6
fix: reset attempts deterministically on lockout expiry and handle mo…
OtavioStasiak Jun 3, 2026
410d8b0
Merge branch 'develop' into feat.authentication-bypass-via-biometric-…
OtavioStasiak Jun 3, 2026
bb51c78
fix: passcode unlock and deep link cancellation regressions
OtavioStasiak Jun 3, 2026
f886398
Merge branch 'develop' into feat.authentication-bypass-via-biometric-…
OtavioStasiak Jun 11, 2026
31d068f
Merge branch 'develop' into feat.authentication-bypass-via-biometric-…
OtavioStasiak Jun 12, 2026
793262d
fix: restore screen lock and biometric trust safeguards
OtavioStasiak Jun 3, 2026
e752780
fix: test
OtavioStasiak Jun 12, 2026
5e2c094
Merge branch 'develop' into feat.authentication-bypass-via-biometric-…
OtavioStasiak Jun 15, 2026
8878a40
Merge branch 'develop' into feat.authentication-bypass-via-biometric-…
OtavioStasiak Jun 16, 2026
2d03eac
fix(screen-lock): persist authentication time on unlock, not pre-moda…
OtavioStasiak Jun 16, 2026
dcb1087
Merge branch 'develop' into feat.authentication-bypass-via-biometric-…
OtavioStasiak Jun 16, 2026
4ce34cc
Merge branch 'develop' into feat.authentication-bypass-via-biometric-…
OtavioStasiak Jun 17, 2026
a9a2091
chore: improve readability
OtavioStasiak Jun 17, 2026
b11be3c
fix: remove anti pattern useeffect and rename keys
OtavioStasiak Jun 17, 2026
62c07d8
fix: md
OtavioStasiak Jun 17, 2026
86d2752
fix: test
OtavioStasiak Jun 17, 2026
73f8707
chore: code improvements
OtavioStasiak Jun 18, 2026
3bc3409
fix: dont eject user when a cold-boot unlock is superseded
OtavioStasiak Jun 18, 2026
1306620
chore: format code and fix lint issues
OtavioStasiak Jun 18, 2026
e776dca
fix: test
OtavioStasiak Jun 18, 2026
ece1cda
Merge branch 'develop' into feat.authentication-bypass-via-biometric-…
OtavioStasiak Jun 24, 2026
6b0b888
Merge branch 'develop' into feat.authentication-bypass-via-biometric-…
OtavioStasiak Jun 25, 2026
4664e51
fix: enforce screen lock on biometric enrollment change regardless of…
OtavioStasiak Jun 26, 2026
28dc057
fix: force passcode after biometric enrollment change regardless of a…
OtavioStasiak Jun 26, 2026
4d292b7
feat(android): silent keystore probe to force screen lock on biometri…
OtavioStasiak Jun 26, 2026
6574cf1
fix: subtitle cutted
OtavioStasiak Jun 26, 2026
926b1f8
action: organized translations
OtavioStasiak Jun 26, 2026
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
Prev Previous commit
Next Next commit
feat(biometric-trust): add enrollment-bound trust store and Option C …
…plumbing

Introduce app/lib/biometricTrustStore: a keychain-backed sentinel bound to
ACCESS_CONTROL.BIOMETRY_CURRENT_SET so the OS invalidates it when the device's
enrolment set changes (iOS errSecItemNotFound, Android
KeyPermanentlyInvalidatedException). The store exposes enrol/disenrol/verify/
probeExists and classifies platform errors into a TrustResult union.

Wire the store into handleLocalAuthentication via the Option C pattern: the
upstream verify() runs before the modal opens and its outcome decides whether
to unlock (success), open the passcode modal with biometry available but
auto-prompt suppressed (canceled/error), or fall back to passcode-only
(unavailable / enrollmentChanged — slice 02 will add the disenrol + flag-clear
side effects). PasscodeEnter and ScreenLockedView take a new skipAutoBiometry
prop carried over LOCAL_AUTHENTICATE_EMITTER so the biometry button stays
visible without re-firing the prompt the user just dismissed.

Screen-lock toggle now enrols/disenrols the sentinel alongside flipping
BIOMETRY_ENABLED_KEY so the keychain item and the flag stay in lockstep.

Part of VLN-216.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
  • Loading branch information
OtavioStasiak and claude committed May 27, 2026
commit 8691f3749c5ebe3ced6f5d71692cf423601d90a3
9 changes: 6 additions & 3 deletions app/containers/Passcode/PasscodeEnter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@ import I18n from '../../i18n';

interface IPasscodePasscodeEnter {
hasBiometry: boolean;
skipAutoBiometry?: boolean;
finishProcess: Function;
}

const PasscodeEnter = ({ hasBiometry, finishProcess }: IPasscodePasscodeEnter) => {
const PasscodeEnter = ({ hasBiometry, skipAutoBiometry = false, finishProcess }: IPasscodePasscodeEnter) => {
const ref = useRef<IBase>(null);
let attempts = 0;
let lockedUntil: any = false;
Expand All @@ -30,7 +31,7 @@ const PasscodeEnter = ({ hasBiometry, finishProcess }: IPasscodePasscodeEnter) =
const biometry = async () => {
if (hasBiometry && status === TYPE.ENTER) {
const result = await biometryAuth();
if (result?.success) {
if (result.kind === 'success') {
finishProcess();
}
}
Expand All @@ -49,7 +50,9 @@ const PasscodeEnter = ({ hasBiometry, finishProcess }: IPasscodePasscodeEnter) =
} else {
setStatus(TYPE.ENTER);
}
biometry();
if (!skipAutoBiometry) {
biometry();
}
Comment thread
OtavioStasiak marked this conversation as resolved.
Outdated
};

useEffect(() => {
Expand Down
152 changes: 152 additions & 0 deletions app/lib/biometricTrustStore/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import * as Keychain from 'react-native-keychain';

import { biometricTrustStore, classifyError } from './index';

const mockedKeychain = Keychain as jest.Mocked<typeof Keychain>;

const promptCopy = { title: 'Authenticate', cancel: 'Cancel' };

describe('biometricTrustStore', () => {
beforeEach(() => {
jest.clearAllMocks();
});

describe('classifyError', () => {
it('maps Android KeyPermanentlyInvalidatedException to enrollmentChanged', () => {
expect(classifyError({ message: 'android.security.keystore.KeyPermanentlyInvalidatedException: ...' })).toEqual({
kind: 'enrollmentChanged'
});
});

it('maps iOS errSecItemNotFound (-25300) to enrollmentChanged', () => {
expect(classifyError({ code: '-25300', message: 'errSecItemNotFound' })).toEqual({ kind: 'enrollmentChanged' });
});

it('maps errSecUserCancel to canceled', () => {
expect(classifyError({ message: 'errSecUserCancel' })).toEqual({ kind: 'canceled' });
});

it('maps Android user cancellation to canceled', () => {
expect(classifyError({ message: 'AuthenticationCanceled' })).toEqual({ kind: 'canceled' });
});

it('falls back to error with original cause for unknown failures', () => {
const cause = new Error('boom');
expect(classifyError(cause)).toEqual({ kind: 'error', cause });
});
});

describe('enrol', () => {
it('writes sentinel with BIOMETRY_CURRENT_SET and WHEN_UNLOCKED_THIS_DEVICE_ONLY', async () => {
mockedKeychain.setGenericPassword.mockResolvedValueOnce(true as any);

const result = await biometricTrustStore.enrol();

expect(result).toEqual({ kind: 'success' });
expect(mockedKeychain.setGenericPassword).toHaveBeenCalledTimes(1);
const [, , options] = mockedKeychain.setGenericPassword.mock.calls[0];
expect(options).toMatchObject({
accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET,
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY
});
});

it('classifies setGenericPassword failures', async () => {
mockedKeychain.setGenericPassword.mockRejectedValueOnce(new Error('errSecUserCancel'));
expect(await biometricTrustStore.enrol()).toEqual({ kind: 'canceled' });
});
});

describe('disenrol', () => {
it('deletes the sentinel via resetGenericPassword', async () => {
mockedKeychain.resetGenericPassword.mockResolvedValueOnce(true as any);

await biometricTrustStore.disenrol();

expect(mockedKeychain.resetGenericPassword).toHaveBeenCalledTimes(1);
});

it('swallows errors so a missing sentinel is not fatal', async () => {
mockedKeychain.resetGenericPassword.mockRejectedValueOnce(new Error('not found'));
await expect(biometricTrustStore.disenrol()).resolves.toBeUndefined();
});
});

describe('probeExists', () => {
it('uses hasGenericPassword and does not prompt', async () => {
mockedKeychain.hasGenericPassword.mockResolvedValueOnce(true);

const exists = await biometricTrustStore.probeExists();

expect(exists).toBe(true);
expect(mockedKeychain.hasGenericPassword).toHaveBeenCalledTimes(1);
expect(mockedKeychain.getGenericPassword).not.toHaveBeenCalled();
});

it('returns false when probe throws', async () => {
mockedKeychain.hasGenericPassword.mockRejectedValueOnce(new Error('broken'));
expect(await biometricTrustStore.probeExists()).toBe(false);
});
});

describe('verify', () => {
it('returns unavailable when sentinel does not exist (no prompt)', async () => {
mockedKeychain.hasGenericPassword.mockResolvedValueOnce(false);

const result = await biometricTrustStore.verify({ promptCopy });

expect(result).toEqual({ kind: 'unavailable' });
expect(mockedKeychain.getGenericPassword).not.toHaveBeenCalled();
});

it('returns success when sentinel matches', async () => {
mockedKeychain.hasGenericPassword.mockResolvedValueOnce(true);
mockedKeychain.getGenericPassword.mockResolvedValueOnce({
service: 'svc',
username: 'biometric-trust',
password: 'v1',
storage: 'keychain'
} as any);

const result = await biometricTrustStore.verify({ promptCopy });

expect(result).toEqual({ kind: 'success' });
});

it('returns enrollmentChanged when Android raises KeyPermanentlyInvalidatedException', async () => {
mockedKeychain.hasGenericPassword.mockResolvedValueOnce(true);
mockedKeychain.getGenericPassword.mockRejectedValueOnce(new Error('KeyPermanentlyInvalidatedException'));

expect(await biometricTrustStore.verify({ promptCopy })).toEqual({ kind: 'enrollmentChanged' });
});

it('returns enrollmentChanged when iOS raises errSecItemNotFound after the prompt', async () => {
mockedKeychain.hasGenericPassword.mockResolvedValueOnce(true);
mockedKeychain.getGenericPassword.mockRejectedValueOnce({ code: '-25300', message: 'errSecItemNotFound' });

expect(await biometricTrustStore.verify({ promptCopy })).toEqual({ kind: 'enrollmentChanged' });
});

it('returns canceled when the user dismisses the prompt', async () => {
mockedKeychain.hasGenericPassword.mockResolvedValueOnce(true);
mockedKeychain.getGenericPassword.mockRejectedValueOnce({ message: 'errSecUserCancel' });

expect(await biometricTrustStore.verify({ promptCopy })).toEqual({ kind: 'canceled' });
});

it('forwards the prompt copy to keychain', async () => {
mockedKeychain.hasGenericPassword.mockResolvedValueOnce(true);
mockedKeychain.getGenericPassword.mockResolvedValueOnce({
service: 'svc',
username: 'biometric-trust',
password: 'v1',
storage: 'keychain'
} as any);

await biometricTrustStore.verify({ promptCopy });

const [options] = mockedKeychain.getGenericPassword.mock.calls[0];
expect(options).toMatchObject({ authenticationPrompt: { title: 'Authenticate', cancel: 'Cancel' } });
});
});
});
107 changes: 107 additions & 0 deletions app/lib/biometricTrustStore/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import * as Keychain from 'react-native-keychain';

export type TrustResult =
| { kind: 'success' }
| { kind: 'canceled' }
| { kind: 'enrollmentChanged' }
| { kind: 'unavailable' }
| { kind: 'error'; cause: unknown };

export interface IBiometricTrustStore {
enrol(): Promise<TrustResult>;
disenrol(): Promise<void>;
verify(opts: { promptCopy: { title: string; cancel: string } }): Promise<TrustResult>;
probeExists(): Promise<boolean>;
}
Comment thread
OtavioStasiak marked this conversation as resolved.
Outdated

const SENTINEL_SERVICE = 'chat.rocket.reactnative.biometric-trust';
const SENTINEL_USERNAME = 'biometric-trust';
const SENTINEL_VALUE = 'v1';
Comment thread
OtavioStasiak marked this conversation as resolved.
Outdated

// BIOMETRY_CURRENT_SET binds the item to the *current* biometric enrolment on both platforms; iOS
// invalidates the keychain entry when the enrolment set changes (errSecItemNotFound on read), and
// Android raises KeyPermanentlyInvalidatedException. That invalidation signal is the security
// primitive this whole module exists for.
const writeOptions = (): Keychain.SetOptions => ({
service: SENTINEL_SERVICE,
accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET,
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY
});

const readOptions = (promptCopy: { title: string; cancel: string }): Keychain.GetOptions => ({
service: SENTINEL_SERVICE,
authenticationPrompt: {
title: promptCopy.title,
cancel: promptCopy.cancel
}
});

// errSecUserCancel — biometric prompt dismissed by the user.
// errSecItemNotFound (-25300) when raised *after* the OS prompt indicates the keychain item was
// invalidated by an enrollment change on iOS.
// KeyPermanentlyInvalidatedException is the Android signal for the same condition.
export const classifyError = (e: unknown): TrustResult => {
const err = e as { code?: string | number; name?: string; message?: string } | null | undefined;
const code = err?.code != null ? String(err.code) : '';
const name = err?.name ?? '';
const message = err?.message ?? '';
const blob = `${code} ${name} ${message}`;

if (/errSecUserCancel|UserCancel|user.?cancel|AuthenticationCanceled|-128\b/i.test(blob)) {
return { kind: 'canceled' };
}
if (/KeyPermanentlyInvalidatedException/i.test(blob)) {
return { kind: 'enrollmentChanged' };
}
if (/errSecItemNotFound|-25300/i.test(blob)) {
return { kind: 'enrollmentChanged' };
}
return { kind: 'error', cause: e };
};

export const biometricTrustStore: IBiometricTrustStore = {
async enrol() {
try {
await Keychain.setGenericPassword(SENTINEL_USERNAME, SENTINEL_VALUE, writeOptions());
return { kind: 'success' };
} catch (e) {
return classifyError(e);
}
},

async disenrol() {
try {
await Keychain.resetGenericPassword({ service: SENTINEL_SERVICE });
} catch {
// best-effort delete; sentinel may already be absent
}
},

async verify({ promptCopy }) {
const exists = await biometricTrustStore.probeExists();
if (!exists) {
return { kind: 'unavailable' };
}
try {
const result = await Keychain.getGenericPassword(readOptions(promptCopy));
if (result && result.password === SENTINEL_VALUE) {
return { kind: 'success' };
}
// OS prompt succeeded but the sentinel is gone — treat as enrollment change.
return { kind: 'enrollmentChanged' };
} catch (e) {
return classifyError(e);
}
},

async probeExists() {
Comment thread
OtavioStasiak marked this conversation as resolved.
Outdated
try {
const result = await Keychain.hasGenericPassword({ service: SENTINEL_SERVICE });
return !!result;
} catch {
return false;
}
}
};

export default biometricTrustStore;
1 change: 1 addition & 0 deletions app/lib/methods/helpers/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ type TEventEmitterEmmitArgs =
| { invalid: boolean }
| { force: boolean }
| { hasBiometry: boolean }
| { skipAutoBiometry: boolean }
| { visible: boolean; onCancel?: null | Function }
| { cancel: () => void }
| { submit: (param: string) => void }
Expand Down
51 changes: 34 additions & 17 deletions app/lib/methods/helpers/localAuthentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import UserPreferences from '../userPreferences';
import { store } from '../../store/auxStore';
import database from '../../database';
import { getServerTimeSync } from '../../services/getServerTimeSync';
import { biometricTrustStore, type TrustResult } from '../../biometricTrustStore';
import {
ATTEMPTS_KEY,
BIOMETRY_ENABLED_KEY,
Expand Down Expand Up @@ -50,12 +51,13 @@ export const saveLastLocalAuthenticationSession = async (

export const resetAttempts = (): Promise<void> => AsyncStorage.multiRemove([LOCKED_OUT_TIMER_KEY, ATTEMPTS_KEY]);

const openModal = (hasBiometry: boolean, force?: boolean) =>
const openModal = (hasBiometry: boolean, force?: boolean, skipAutoBiometry?: boolean) =>
new Promise<void>((resolve, reject) => {
EventEmitter.emit(LOCAL_AUTHENTICATE_EMITTER, {
submit: () => resolve(),
hasBiometry,
force,
skipAutoBiometry,
cancel: () => reject()
});
});
Expand All @@ -74,20 +76,21 @@ export const changePasscode = async ({ force = false }: { force: boolean }): Pro
UserPreferences.setString(PASSCODE_KEY, sha256(passcode));
};

export const biometryAuth = (force?: boolean): Promise<LocalAuthentication.LocalAuthenticationResult> =>
LocalAuthentication.authenticateAsync({
disableDeviceFallback: true,
cancelLabel: force ? I18n.t('Dont_activate') : I18n.t('Local_authentication_biometry_fallback'),
promptMessage: I18n.t('Local_authentication_biometry_title')
});
const buildPromptCopy = (force?: boolean) => ({
title: I18n.t('Local_authentication_biometry_title'),
cancel: force ? I18n.t('Dont_activate') : I18n.t('Local_authentication_biometry_fallback')
});

export const biometryAuth = (force?: boolean): Promise<TrustResult> =>
biometricTrustStore.verify({ promptCopy: buildPromptCopy(force) });

/*
* It'll help us to get the permission to use FaceID
* and enable/disable the biometry when user put their first passcode
*/
const checkBiometry = async () => {
const result = await biometryAuth(true);
const isBiometryEnabled = !!result?.success;
const result = await biometricTrustStore.enrol();
Comment thread
OtavioStasiak marked this conversation as resolved.
Outdated
const isBiometryEnabled = result.kind === 'success';
UserPreferences.setBool(BIOMETRY_ENABLED_KEY, isBiometryEnabled);
return isBiometryEnabled;
};
Expand All @@ -111,17 +114,31 @@ const hideSplashScreen = async () => {
};

export const handleLocalAuthentication = async (canCloseModal = false) => {
// let hasBiometry = false;
let hasBiometry = UserPreferences.getBool(BIOMETRY_ENABLED_KEY) ?? false;
const biometryEnabled = UserPreferences.getBool(BIOMETRY_ENABLED_KEY) ?? false;
Comment thread
OtavioStasiak marked this conversation as resolved.
Outdated

if (!biometryEnabled) {
await openModal(false, canCloseModal);
return;
}

const result = await biometricTrustStore.verify({ promptCopy: buildPromptCopy() });
Comment thread
OtavioStasiak marked this conversation as resolved.
Outdated

// success → unlocked, no modal
if (result.kind === 'success') {
return;
}

// if biometry is enabled on the app
if (hasBiometry) {
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
hasBiometry = isEnrolled;
// canceled / error → user dismissed or the OS prompt failed; keep biometry available on the
// modal but skip the auto-prompt so we don't immediately re-fire the same prompt the user
// just dismissed.
if (result.kind === 'canceled' || result.kind === 'error') {
await openModal(true, canCloseModal, true);
return;
}

// Authenticate
await openModal(hasBiometry, canCloseModal);
// unavailable / enrollmentChanged → no usable sentinel; passcode-only modal. Slice 02 will
// add disenrol() + flag-clear + an explanatory reason for the enrollmentChanged case.
await openModal(false, canCloseModal);
};

export const localAuthenticate = async (server: string): Promise<void> => {
Expand Down
Loading