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
fix: enforce screen lock on biometric enrollment change regardless of…
… auto-lock window
  • Loading branch information
OtavioStasiak committed Jun 26, 2026
commit 4664e5120151c5c1ba80cc237c19190f0e021f4d
27 changes: 27 additions & 0 deletions app/lib/methods/helpers/localAuthentication.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const mockedEnroll = biometricTrustStore.enroll as jest.Mock;
const mockedDisenroll = biometricTrustStore.disenroll as jest.Mock;
const mockedSetEnabled = biometricTrustStore.setEnabled as jest.Mock;
const mockedIsEnabled = biometricTrustStore.isEnabled as jest.Mock;
const mockedHasEnrollment = biometricTrustStore.hasEnrollment as jest.Mock;
const mockedIsEnrolled = LocalAuthentication.isEnrolledAsync as jest.Mock;

const lastEmitPayload = () => {
Expand All @@ -66,6 +67,10 @@ describe('handleLocalAuthentication', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedIsEnrolled.mockResolvedValue(true);
// Sentinel present by default → no enrollment change. Tests that exercise the invalidation path
// override this per-case.
mockedHasEnrollment.mockResolvedValue(true);
mockedDisenroll.mockResolvedValue(undefined);
mockedEmit.mockImplementation((event, payload) => {
if (event === LOCAL_AUTHENTICATE_EMITTER && payload?.submit) {
setImmediate(() => payload.submit());
Expand All @@ -91,6 +96,28 @@ describe('handleLocalAuthentication', () => {
expect(mockedVerify).not.toHaveBeenCalled();
});

it('biometry enabled but sentinel gone (enrollment changed) → forces passcode, disables biometry, sets reason', async () => {
mockedIsEnabled.mockReturnValue(true);
mockedHasEnrollment.mockResolvedValueOnce(false);

await handleLocalAuthentication();

// Modal opens with biometry hidden and the enrollment-changed notice...
expect(lastEmitPayload()).toMatchObject({ hasBiometry: false, reason: 'enrollmentChanged' });
// ...trust state torn down (disenroll before clearing the flag) and no biometric prompt fired.
expect(mockedDisenroll).toHaveBeenCalledTimes(1);
expect(mockedSetEnabled).toHaveBeenCalledWith(false);
expect(mockedVerify).not.toHaveBeenCalled();
});

it('biometry disabled → does not probe the sentinel', async () => {
mockedIsEnabled.mockReturnValue(false);

await handleLocalAuthentication();

expect(mockedHasEnrollment).not.toHaveBeenCalled();
});

it('biometry enabled with an enrolled unlabeled biometric type → still opens modal with hasBiometry: true', async () => {
mockedIsEnabled.mockReturnValue(true);
(LocalAuthentication.supportedAuthenticationTypesAsync as jest.Mock).mockResolvedValueOnce([
Expand Down
38 changes: 36 additions & 2 deletions app/lib/methods/helpers/localAuthentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,10 +142,38 @@ const hasSupportedBiometry = async (): Promise<boolean> => {
}
};

// Non-prompting detection of a biometric enrollment change. BIOMETRY_CURRENT_SET binds the sentinel
// to the current enrollment, so the OS drops it when the enrollment set changes; an enabled flag with
// a missing sentinel therefore means the user re-enrolled (or otherwise invalidated) their biometrics.
// iOS surfaces this without an OS prompt — see PLATFORMS.md for the Android caveat (its keystore key
// only throws on read, so the change is caught later, from behind the modal in PasscodeEnter).
//
// This check is the security primitive that lets an enrollment change FORCE the passcode even inside
// the auto-lock window: without it, re-enrolling a face/fingerprint and returning before the window
// elapses would keep the session unlocked — the very bypass screen lock exists to prevent.
const hasBiometricEnrollmentChanged = async (): Promise<boolean> => {
if (!biometricTrustStore.isEnabled()) {
return false;
}
return !(await biometricTrustStore.hasEnrollment());
};

export const handleLocalAuthentication = async (canCloseModal = false) => {
// Check the cheap persisted flag first; passcode-only users shouldn't pay the native capability
// check on every lock event.
const biometryEnabled = biometricTrustStore.isEnabled();

// An enrollment change invalidates the sentinel silently, so we catch it here and surface it
// explicitly: tear down our trust state (mirroring resolveBiometricTrust's invalidation path) and
// show the passcode with the "enrollment changed" notice and biometry hidden, rather than letting
// PasscodeEnter rediscover it as a generic `unavailable` outcome that carries no reason subtitle.
if (biometryEnabled && (await hasBiometricEnrollmentChanged())) {
await biometricTrustStore.disenroll();
biometricTrustStore.setEnabled(false);
await openModal(false, canCloseModal, 'enrollmentChanged');
return;
}

const hasBiometry = biometryEnabled && (await hasSupportedBiometry());

// Open the passcode modal first so it covers the app, then let PasscodeEnter prompt biometry from
Expand Down Expand Up @@ -190,8 +218,14 @@ export const localAuthenticate = async (server: string): Promise<void> => {
// During E2E runs we use a shorter threshold so tests don't have to wait past the smallest user-facing option (60s)
const autoLockTime = process.env.RUNNING_E2E_TESTS === 'true' ? E2E_TESTS_AUTO_LOCK_TIME : serverRecord?.autoLockTime;

// if it was not possible to get `timesync` from server or the last authenticated session is older than the configured auto lock time, authentication is required
if (!timesync || (autoLockTime && diffToLastSession >= autoLockTime)) {
// A biometric enrollment change must force the lock screen regardless of how recently the user
// authenticated — otherwise re-enrolling a face/fingerprint inside the auto-lock window would
// bypass authentication entirely. handleLocalAuthentication re-detects this and shows the
// passcode with biometry disabled and the enrollment-changed notice.
const enrollmentChanged = await hasBiometricEnrollmentChanged();

// if it was not possible to get `timesync` from server, the biometric enrollment changed, or the last authenticated session is older than the configured auto lock time, authentication is required
if (!timesync || enrollmentChanged || (autoLockTime && diffToLastSession >= autoLockTime)) {
await hideSplashScreen();

// set isLocalAuthenticated to false
Expand Down
Loading