Skip to content

Commit 4455c35

Browse files
mikepitreclaude
andcommitted
fix(expo-biometrics): report missing secure key storage
Add secureKeyStorageAvailable to getAvailability() and reject createKey() with secure_key_storage_unavailable when the device has no Secure Enclave, such as the iOS Simulator, instead of failing inside SecKeyCreateRandomKey. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 984b1ea commit 4455c35

10 files changed

Lines changed: 89 additions & 4 deletions

‎packages/expo-biometrics/README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ The key and record layout is shared with the Clerk iOS SDK, so credentials enrol
3737
- Expo SDK 54 or later, in a development build (the module is not available in Expo Go or on the web)
3838
- iOS. Android support is not implemented yet: every call rejects with `not_implemented`.
3939
- `NSFaceIDUsageDescription` in your `Info.plist`. The `@clerk/expo` config plugin sets it through its `faceIDPermission` option.
40+
- A device with a Secure Enclave. The iOS Simulator has none, so `createKey()` rejects there with `secure_key_storage_unavailable`.
4041

4142
## Installation
4243

@@ -66,7 +67,7 @@ import {
6667
| Function | Description |
6768
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
6869
| `getAppIdentifier()` | The app identifier sent to Clerk as `app_identifier` (the iOS bundle identifier). |
69-
| `getAvailability()` | The device's biometry type and whether biometrics or device owner authentication can be evaluated. |
70+
| `getAvailability()` | The device's biometry type, whether biometrics or device owner authentication can be evaluated, and secure key storage. |
7071
| `createKey(policy)` | Creates a Secure Enclave P-256 key and returns its `localKeyId` and public key JWK. |
7172
| `sign(localKeyId, clientData, reason?)` | Prompts for authentication and returns an ES256 signature over `clientData` (raw `r \|\| s`, base64url without padding). |
7273
| `hasKey(localKeyId)` / `deleteKey(localKeyId)` | Checks for or deletes a key. |

‎packages/expo-biometrics/ios/BiometricKeyManager.swift‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import CryptoKit
12
import Foundation
23
import LocalAuthentication
34
import Security
@@ -7,6 +8,7 @@ struct BiometricAvailability: Equatable {
78
let canEvaluateBiometrics: Bool
89
let canEvaluateDeviceOwner: Bool
910
let errorCode: BiometricsError.Code?
11+
let secureKeyStorageAvailable: Bool
1012
}
1113

1214
struct BiometricCredentialKey: Equatable {
@@ -16,6 +18,21 @@ struct BiometricCredentialKey: Equatable {
1618

1719
/// Secure Enclave keys laid out as ClerkKit's `BiometricCredentialKeyManager` creates them.
1820
final class BiometricKeyManager {
21+
private let isSecureEnclaveAvailable: () -> Bool
22+
23+
init(isSecureEnclaveAvailable: @escaping () -> Bool = BiometricKeyManager.deviceHasSecureEnclave) {
24+
self.isSecureEnclaveAvailable = isSecureEnclaveAvailable
25+
}
26+
27+
static func deviceHasSecureEnclave() -> Bool {
28+
// SecureEnclave.isAvailable is true on the Simulator, where Secure Enclave keys still fail with errSecAuthFailed.
29+
#if targetEnvironment(simulator)
30+
return false
31+
#else
32+
return SecureEnclave.isAvailable
33+
#endif
34+
}
35+
1936
func availability() -> BiometricAvailability {
2037
let context = LAContext()
2138
var biometricsError: NSError?
@@ -31,11 +48,16 @@ final class BiometricKeyManager {
3148
canEvaluateDeviceOwner: canEvaluateDeviceOwner,
3249
errorCode: canEvaluateBiometrics
3350
? nil
34-
: BiometricsError.localAuthentication(biometricsError, fallback: .biometryNotAvailable).code
51+
: BiometricsError.localAuthentication(biometricsError, fallback: .biometryNotAvailable).code,
52+
secureKeyStorageAvailable: isSecureEnclaveAvailable()
3553
)
3654
}
3755

3856
func createKey(policy: BiometricCredentialPolicy) throws -> BiometricCredentialKey {
57+
guard isSecureEnclaveAvailable() else {
58+
throw BiometricsError(.secureKeyStorageUnavailable, "This device has no Secure Enclave to hold the biometric credential key.")
59+
}
60+
3961
let context = LAContext()
4062
var laError: NSError?
4163
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &laError) else {

‎packages/expo-biometrics/ios/BiometricsError.swift‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ struct BiometricsError: Error, Equatable {
1212
case biometryNotEnrolled = "biometry_not_enrolled"
1313
case biometryLockout = "biometry_lockout"
1414
case passcodeNotSet = "passcode_not_set"
15+
case secureKeyStorageUnavailable = "secure_key_storage_unavailable"
1516
case keyNotFound = "key_not_found"
1617
case keyInvalidated = "key_invalidated"
1718
case keyGenerationFailed = "key_generation_failed"

‎packages/expo-biometrics/ios/ClerkExpoBiometrics.podspec‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Pod::Spec.new do |s|
1616
s.static_framework = true
1717

1818
s.dependency 'ExpoModulesCore'
19-
s.frameworks = 'LocalAuthentication', 'Security'
19+
s.frameworks = 'CryptoKit', 'LocalAuthentication', 'Security'
2020

2121
s.pod_target_xcconfig = {
2222
'DEFINES_MODULE' => 'YES',

‎packages/expo-biometrics/ios/ClerkExpoBiometricsModule.swift‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ struct BiometricAvailabilityResult: Record {
2020
@Field var canEvaluateBiometrics: Bool = false
2121
@Field var canEvaluateDeviceOwner: Bool = false
2222
@Field var errorCode: String?
23+
@Field var secureKeyStorageAvailable: Bool = false
2324
}
2425

2526
struct BiometricCredentialKeyResult: Record {
@@ -64,6 +65,7 @@ public final class ClerkExpoBiometricsModule: Module {
6465
result.canEvaluateBiometrics = availability.canEvaluateBiometrics
6566
result.canEvaluateDeviceOwner = availability.canEvaluateDeviceOwner
6667
result.errorCode = availability.errorCode?.rawValue
68+
result.secureKeyStorageAvailable = availability.secureKeyStorageAvailable
6769
return result
6870
}
6971

‎packages/expo-biometrics/ios/Tests/BiometricSystemIntegrationTests.swift‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,29 @@ final class BiometricKeyManagerTests: XCTestCase {
7676
XCTAssertEqual(availability.errorCode == nil, availability.canEvaluateBiometrics)
7777
}
7878

79+
func testSimulatorReportsNoSecureKeyStorage() throws {
80+
#if targetEnvironment(simulator)
81+
XCTAssertFalse(BiometricKeyManager().availability().secureKeyStorageAvailable)
82+
#else
83+
throw XCTSkip("Only the Simulator is known to lack a Secure Enclave.")
84+
#endif
85+
}
86+
87+
func testAvailabilityReportsSecureKeyStorage() {
88+
XCTAssertTrue(BiometricKeyManager(isSecureEnclaveAvailable: { true }).availability().secureKeyStorageAvailable)
89+
XCTAssertFalse(BiometricKeyManager(isSecureEnclaveAvailable: { false }).availability().secureKeyStorageAvailable)
90+
}
91+
92+
func testCreateKeyRejectsWithoutSecureKeyStorage() {
93+
let keyManager = BiometricKeyManager(isSecureEnclaveAvailable: { false })
94+
95+
for policy in BiometricCredentialPolicy.allCases {
96+
XCTAssertThrowsError(try keyManager.createKey(policy: policy)) { error in
97+
XCTAssertEqual((error as? BiometricsError)?.code, .secureKeyStorageUnavailable)
98+
}
99+
}
100+
}
101+
79102
func testErrorMapping() {
80103
XCTAssertEqual(BiometricsError.code(forLAErrorCode: LAError.Code.userCancel.rawValue), .userCanceled)
81104
XCTAssertEqual(BiometricsError.code(forLAErrorCode: LAError.Code.systemCancel.rawValue), .systemCanceled)

‎packages/expo-biometrics/src/__tests__/index.test.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const createNativeModule = () => ({
2121
canEvaluateBiometrics: true,
2222
canEvaluateDeviceOwner: true,
2323
errorCode: null,
24+
secureKeyStorageAvailable: true,
2425
}),
2526
createKey: vi.fn().mockResolvedValue({ localKeyId: 'tdlk_1', publicKeyJwk: '{"kty":"EC"}' }),
2627
sign: vi.fn().mockResolvedValue('c2lnbmF0dXJl'),
@@ -67,9 +68,23 @@ describe('@clerk/expo-biometrics', () => {
6768
canEvaluateBiometrics: true,
6869
canEvaluateDeviceOwner: true,
6970
errorCode: null,
71+
secureKeyStorageAvailable: true,
7072
});
7173
});
7274

75+
test('getAvailability reports missing secure key storage', async () => {
76+
native.getAvailability.mockResolvedValueOnce({
77+
biometryType: 'faceID',
78+
canEvaluateBiometrics: true,
79+
canEvaluateDeviceOwner: true,
80+
errorCode: null,
81+
secureKeyStorageAvailable: false,
82+
});
83+
const biometrics = await load();
84+
85+
await expect(biometrics.getAvailability()).resolves.toMatchObject({ secureKeyStorageAvailable: false });
86+
});
87+
7388
test('rejects with native_module_unavailable when the native module is missing', async () => {
7489
mocks.nativeModule = null;
7590
const biometrics = await load();
@@ -92,6 +107,17 @@ describe('@clerk/expo-biometrics', () => {
92107
expect(native.createKey).toHaveBeenCalledWith('biometry_or_device_passcode');
93108
});
94109

110+
test('createKey preserves secure_key_storage_unavailable', async () => {
111+
native.createKey.mockRejectedValueOnce(nativeError('secure_key_storage_unavailable', 'no Secure Enclave'));
112+
const biometrics = await load();
113+
114+
await expect(biometrics.createKey('biometry_current_set')).rejects.toMatchObject({
115+
name: 'ClerkBiometricsError',
116+
code: 'secure_key_storage_unavailable',
117+
message: 'no Secure Enclave',
118+
});
119+
});
120+
95121
test('createKey rejects unknown policies without calling native', async () => {
96122
const biometrics = await load();
97123

@@ -139,6 +165,7 @@ describe('@clerk/expo-biometrics', () => {
139165
'biometry_not_enrolled',
140166
'biometry_lockout',
141167
'passcode_not_set',
168+
'secure_key_storage_unavailable',
142169
'key_not_found',
143170
'key_invalidated',
144171
'key_generation_failed',
@@ -176,6 +203,7 @@ describe('@clerk/expo-biometrics', () => {
176203
const { isBiometricsErrorCode } = await load();
177204

178205
expect(isBiometricsErrorCode('biometry_lockout')).toBe(true);
206+
expect(isBiometricsErrorCode('secure_key_storage_unavailable')).toBe(true);
179207
expect(isBiometricsErrorCode('ERR_UNKNOWN')).toBe(false);
180208
expect(isBiometricsErrorCode(undefined)).toBe(false);
181209
});

‎packages/expo-biometrics/src/errors.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const ERROR_CODES: ReadonlySet<string> = new Set<BiometricsErrorCode>([
99
'biometry_not_enrolled',
1010
'biometry_lockout',
1111
'passcode_not_set',
12+
'secure_key_storage_unavailable',
1213
'key_not_found',
1314
'key_invalidated',
1415
'key_generation_failed',

‎packages/expo-biometrics/src/index.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,8 @@ export function getAvailability(): Promise<BiometricAvailability> {
108108

109109
/**
110110
* Creates a new hardware-backed P-256 private key protected by `policy`.
111-
* Rejects with `biometry_not_available`, `biometry_not_enrolled`, or `biometry_lockout` when biometrics cannot be used.
111+
* Rejects with `secure_key_storage_unavailable` when the device has no hardware-backed key storage (see `getAvailability()`),
112+
* and with `biometry_not_available`, `biometry_not_enrolled`, or `biometry_lockout` when biometrics cannot be used.
112113
*/
113114
export async function createKey(policy: BiometricCredentialPolicy): Promise<BiometricCredentialKey> {
114115
assertPolicy(policy);

‎packages/expo-biometrics/src/types.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ export interface BiometricAvailability {
1818
canEvaluateDeviceOwner: boolean;
1919
/** Why biometric authentication cannot be evaluated, or `null` when it can. */
2020
errorCode: BiometricsErrorCode | null;
21+
/**
22+
* Whether the device has hardware-backed key storage (the Secure Enclave on iOS). `false` on the iOS Simulator.
23+
* `createKey()` rejects with `secure_key_storage_unavailable` when this is `false`.
24+
*/
25+
secureKeyStorageAvailable: boolean;
2126
}
2227

2328
export interface BiometricCredentialKey {
@@ -75,6 +80,7 @@ export type BiometricsErrorCode =
7580
| 'biometry_not_enrolled'
7681
| 'biometry_lockout'
7782
| 'passcode_not_set'
83+
| 'secure_key_storage_unavailable'
7884
| 'key_not_found'
7985
| 'key_invalidated'
8086
| 'key_generation_failed'

0 commit comments

Comments
 (0)