Skip to content
Open
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
20 changes: 16 additions & 4 deletions packages/core-mobile/app/utils/BiometricsSDK.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { commonStorage } from 'utils/mmkv'
import { StorageKey } from 'resources/Constants'
import { decrypt, encrypt } from 'utils/EncryptionHelper'
import Logger from 'utils/Logger'
import * as LocalAuthentication from 'expo-local-authentication'

// Mock dependencies
jest.mock('react-native-keychain', () => ({
Expand Down Expand Up @@ -64,12 +65,21 @@ jest.mock('utils/Logger', () => ({
error: jest.fn()
}))

jest.mock('expo-local-authentication', () => ({
hasHardwareAsync: jest.fn(),
isEnrolledAsync: jest.fn(),
supportedAuthenticationTypesAsync: jest.fn()
}))

// Cast mocks for type safety
const mockKeychain = Keychain as jest.Mocked<typeof Keychain>
const mockCommonStorage = commonStorage as jest.Mocked<typeof commonStorage>
const mockEncrypt = encrypt as jest.Mock
const mockDecrypt = decrypt as jest.Mock
const mockLogger = Logger as jest.Mocked<typeof Logger>
const mockLocalAuth = LocalAuthentication as jest.Mocked<
typeof LocalAuthentication
>

enum STORAGE_TYPE {
AES_CBC = 'KeystoreAESCBC',
Expand Down Expand Up @@ -394,18 +404,19 @@ describe('BiometricsSDK', () => {
})

it('should check if biometry can be used', async () => {
mockKeychain.getSupportedBiometryType.mockResolvedValue(
BIOMETRY_TYPE.FACE_ID
)
mockLocalAuth.hasHardwareAsync.mockResolvedValue(true)
mockLocalAuth.isEnrolledAsync.mockResolvedValue(true)
const result = await BiometricsSDK.canUseBiometry()
expect(result).toBe(true)

mockKeychain.getSupportedBiometryType.mockResolvedValue(null)
mockLocalAuth.hasHardwareAsync.mockResolvedValue(true)
mockLocalAuth.isEnrolledAsync.mockResolvedValue(false)
const secondResult = await BiometricsSDK.canUseBiometry()
expect(secondResult).toBe(false)
})

it('should get biometry type', async () => {
mockLocalAuth.supportedAuthenticationTypesAsync.mockResolvedValue([])
mockKeychain.getSupportedBiometryType.mockResolvedValue(
BIOMETRY_TYPE.FACE_ID
)
Expand Down Expand Up @@ -436,6 +447,7 @@ describe('BiometricsSDK', () => {
expect(await BiometricsSDK.getBiometryType()).toBe(BiometricType.IRIS)

mockKeychain.getSupportedBiometryType.mockResolvedValue(null)
mockLocalAuth.supportedAuthenticationTypesAsync.mockResolvedValue([])
expect(await BiometricsSDK.getBiometryType()).toBe(BiometricType.NONE)
})

Expand Down
59 changes: 42 additions & 17 deletions packages/core-mobile/app/utils/BiometricsSDK.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as LocalAuthentication from 'expo-local-authentication'
import { AuthenticationType } from 'expo-local-authentication'
import { StorageKey } from 'resources/Constants'
import { commonStorage } from 'utils/mmkv'
import { decrypt, encrypt } from 'utils/EncryptionHelper'
Expand Down Expand Up @@ -343,26 +344,50 @@ class BiometricsSDK {
}

async canUseBiometry(): Promise<boolean> {
return getSupportedBiometryType().then(value => {
return value !== null
})
try {
const hasHardware = await LocalAuthentication.hasHardwareAsync()
if (!hasHardware) return false
return await LocalAuthentication.isEnrolledAsync()
} catch (e) {
Logger.error('Failed to check biometric availability', e)
return false
}
}

async getBiometryType(): Promise<BiometricType> {
const bioType = await getSupportedBiometryType()
if (!bioType) return BiometricType.NONE
switch (bioType) {
case Keychain.BIOMETRY_TYPE.TOUCH_ID:
return BiometricType.TOUCH_ID
case Keychain.BIOMETRY_TYPE.FACE_ID:
return BiometricType.FACE_ID
case Keychain.BIOMETRY_TYPE.FINGERPRINT:
case Keychain.BIOMETRY_TYPE.FACE:
return BiometricType.BIOMETRICS
case Keychain.BIOMETRY_TYPE.IRIS:
return BiometricType.IRIS
case Keychain.BIOMETRY_TYPE.OPTIC_ID:
throw new Error('BiometricType.OPTIC_ID is not supported')
try {
// Prefer Keychain mapping first (legacy behavior / more specific types).
// On iOS lockout this can temporarily report null -> then we fall back to Expo below.
const bioType = await getSupportedBiometryType()
if (bioType) {
switch (bioType) {
case Keychain.BIOMETRY_TYPE.TOUCH_ID:
return BiometricType.TOUCH_ID
case Keychain.BIOMETRY_TYPE.FACE_ID:
return BiometricType.FACE_ID
case Keychain.BIOMETRY_TYPE.FINGERPRINT:
case Keychain.BIOMETRY_TYPE.FACE:
return BiometricType.BIOMETRICS
case Keychain.BIOMETRY_TYPE.IRIS:
return BiometricType.IRIS
case Keychain.BIOMETRY_TYPE.OPTIC_ID:
throw new Error('BiometricType.OPTIC_ID is not supported')
}
}

// Fallback: Expo types (helps during iOS biometry lockout).
const types =
await LocalAuthentication.supportedAuthenticationTypesAsync()
if (types.includes(AuthenticationType.FACIAL_RECOGNITION))
return iOS ? BiometricType.FACE_ID : BiometricType.BIOMETRICS
if (types.includes(AuthenticationType.FINGERPRINT))
return iOS ? BiometricType.TOUCH_ID : BiometricType.BIOMETRICS
if (types.includes(AuthenticationType.IRIS)) return BiometricType.IRIS

return BiometricType.NONE
} catch (e) {
Logger.error('Failed to get biometric type', e)
return BiometricType.NONE
}
}

Expand Down
Loading