-
Notifications
You must be signed in to change notification settings - Fork 1.5k
feat: Authentication bypass via biometric enrollment change #7351
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
OtavioStasiak
wants to merge
62
commits into
develop
Choose a base branch
from
feat.authentication-bypass-via-biometric-enrollment-change
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+2,699
−91
Open
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 8691f37
feat(biometric-trust): add enrollment-bound trust store and Option C …
OtavioStasiak 3a59438
feat(biometric-trust): invalidate biometric trust on enrollment change
OtavioStasiak d384c0c
feat(biometric-trust): show explanatory subtitle on enrollment-change…
OtavioStasiak c35c0c6
feat(biometric-trust): silent-bind migration for existing biometry us…
OtavioStasiak 7ec65a4
feat: i18n translation
OtavioStasiak f091568
podfile
OtavioStasiak 8b84535
Merge branch 'develop' into feat.authentication-bypass-via-biometric-…
OtavioStasiak 898b8aa
fix(biometric-trust): revert ScreenLockConfig toggle when enrol fails
OtavioStasiak 3dbb3e3
fix(screen-lock): defer modal resolve until close animation finishes
OtavioStasiak 1565c0d
feat: add e2e tests
OtavioStasiak d01f17a
chore: format code and fix lint issues
OtavioStasiak 76da4bc
Merge branch 'develop' into feat.authentication-bypass-via-biometric-…
OtavioStasiak f6f59cb
fix: e2e tests
OtavioStasiak 952f38a
fix: test flow
OtavioStasiak fe0d66b
refactor: encapsulate biometric trust state and speed up screen-lock E2E
OtavioStasiak 13136b6
refactor: route biometric enabled flag through trust store API
OtavioStasiak 68a257f
Merge branch 'develop' into feat.authentication-bypass-via-biometric-…
OtavioStasiak fdb2cc1
refactor(biometric-trust): encapsulate biometry toggle and clarify tr…
OtavioStasiak 256e90b
refactor(biometric-trust): type unlock outcome as discriminated union…
OtavioStasiak bc0bd7b
refactor(biometric-trust): remove dead mount-time auto-biometry and v…
OtavioStasiak 063ec62
fix(screen-lock): prompt biometry from behind the passcode modal to s…
OtavioStasiak 4e6a86e
fix(biometric-trust): mark install trust-initialized on enrol to clos…
OtavioStasiak 66ab2f8
fix(biometric-trust): restore biometry opt-in prompt on first-passcod…
OtavioStasiak 5969082
fix(biometric-trust): clear enabled flag on unavailable to fix iOS en…
OtavioStasiak b7e21c0
Merge branch 'develop' into feat.authentication-bypass-via-biometric-…
OtavioStasiak f9c2f86
chore: biometricTrustStore docs
OtavioStasiak 43c18f4
Merge branch 'develop' into feat.authentication-bypass-via-biometric-…
OtavioStasiak 80f688f
chore: format code and fix lint issues
OtavioStasiak ad26251
chore: doc improvement
OtavioStasiak dcff7c3
Merge branch 'develop' into feat.authentication-bypass-via-biometric-…
OtavioStasiak b566a47
chore: code improvements
OtavioStasiak fbfd4e2
chore: format code and fix lint issues
OtavioStasiak 050b438
Merge branch 'develop' into feat.authentication-bypass-via-biometric-…
OtavioStasiak 122bc6a
fix: persist passcode attempts across re-renders, guard toggle double…
OtavioStasiak 6bd28b6
fix: reset attempts deterministically on lockout expiry and handle mo…
OtavioStasiak 410d8b0
Merge branch 'develop' into feat.authentication-bypass-via-biometric-…
OtavioStasiak bb51c78
fix: passcode unlock and deep link cancellation regressions
OtavioStasiak f886398
Merge branch 'develop' into feat.authentication-bypass-via-biometric-…
OtavioStasiak 31d068f
Merge branch 'develop' into feat.authentication-bypass-via-biometric-…
OtavioStasiak 793262d
fix: restore screen lock and biometric trust safeguards
OtavioStasiak e752780
fix: test
OtavioStasiak 5e2c094
Merge branch 'develop' into feat.authentication-bypass-via-biometric-…
OtavioStasiak 8878a40
Merge branch 'develop' into feat.authentication-bypass-via-biometric-…
OtavioStasiak 2d03eac
fix(screen-lock): persist authentication time on unlock, not pre-moda…
OtavioStasiak dcb1087
Merge branch 'develop' into feat.authentication-bypass-via-biometric-…
OtavioStasiak 4ce34cc
Merge branch 'develop' into feat.authentication-bypass-via-biometric-…
OtavioStasiak a9a2091
chore: improve readability
OtavioStasiak b11be3c
fix: remove anti pattern useeffect and rename keys
OtavioStasiak 62c07d8
fix: md
OtavioStasiak 86d2752
fix: test
OtavioStasiak 73f8707
chore: code improvements
OtavioStasiak 3bc3409
fix: dont eject user when a cold-boot unlock is superseded
OtavioStasiak 1306620
chore: format code and fix lint issues
OtavioStasiak e776dca
fix: test
OtavioStasiak ece1cda
Merge branch 'develop' into feat.authentication-bypass-via-biometric-…
OtavioStasiak 6b0b888
Merge branch 'develop' into feat.authentication-bypass-via-biometric-…
OtavioStasiak 4664e51
fix: enforce screen lock on biometric enrollment change regardless of…
OtavioStasiak 28dc057
fix: force passcode after biometric enrollment change regardless of a…
OtavioStasiak 4d292b7
feat(android): silent keystore probe to force screen lock on biometri…
OtavioStasiak 6574cf1
fix: subtitle cutted
OtavioStasiak 926b1f8
action: organized translations
OtavioStasiak File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
commit 8691f3749c5ebe3ced6f5d71692cf423601d90a3
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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' } }); | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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>; | ||
| } | ||
|
OtavioStasiak marked this conversation as resolved.
Outdated
|
||
|
|
||
| const SENTINEL_SERVICE = 'chat.rocket.reactnative.biometric-trust'; | ||
| const SENTINEL_USERNAME = 'biometric-trust'; | ||
| const SENTINEL_VALUE = 'v1'; | ||
|
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() { | ||
|
OtavioStasiak marked this conversation as resolved.
Outdated
|
||
| try { | ||
| const result = await Keychain.hasGenericPassword({ service: SENTINEL_SERVICE }); | ||
| return !!result; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
| }; | ||
|
|
||
| export default biometricTrustStore; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.