From 3dbc87c731c741669714080c3a654e9bd07efa37 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:13:47 -0400 Subject: [PATCH 1/3] feat(expo-biometrics): add biometric credential native module Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/expo-biometrics-package.md | 7 + .github/workflows/expo-native-build.yml | 10 +- packages/expo-biometrics/.gitignore | 58 ++++ packages/expo-biometrics/.npmignore | 14 + packages/expo-biometrics/LICENSE | 21 ++ packages/expo-biometrics/README.md | 84 +++++ packages/expo-biometrics/android/build.gradle | 39 +++ .../android/src/main/AndroidManifest.xml | 2 + .../biometrics/ClerkExpoBiometricsModule.kt | 33 ++ .../expo-biometrics/expo-module.config.json | 9 + .../ios/BiometricCredentialCoding.swift | 324 ++++++++++++++++++ .../ios/BiometricCredentialStore.swift | 230 +++++++++++++ .../ios/BiometricKeyManager.swift | 206 +++++++++++ .../expo-biometrics/ios/BiometricsError.swift | 116 +++++++ .../ios/ClerkExpoBiometrics.podspec | 32 ++ .../ios/ClerkExpoBiometricsModule.swift | 158 +++++++++ .../BiometricCredentialContractTests.swift | 277 +++++++++++++++ .../Tests/BiometricCredentialStoreTests.swift | 320 +++++++++++++++++ .../BiometricSystemIntegrationTests.swift | 146 ++++++++ .../BiometricCredentialStorageContractV1.json | 31 ++ packages/expo-biometrics/package.json | 53 +++ .../src/ClerkExpoBiometricsModule.ts | 26 ++ .../src/__tests__/index.test.ts | 272 +++++++++++++++ packages/expo-biometrics/src/errors.ts | 48 +++ packages/expo-biometrics/src/index.ts | 207 +++++++++++ packages/expo-biometrics/src/types.ts | 86 +++++ .../tsconfig.declarations.json | 15 + packages/expo-biometrics/tsconfig.json | 27 ++ packages/expo-biometrics/tsup.config.ts | 19 + packages/expo-biometrics/vitest.config.mts | 7 + pnpm-lock.yaml | 6 + 31 files changed, 2880 insertions(+), 3 deletions(-) create mode 100644 .changeset/expo-biometrics-package.md create mode 100644 packages/expo-biometrics/.gitignore create mode 100644 packages/expo-biometrics/.npmignore create mode 100644 packages/expo-biometrics/LICENSE create mode 100644 packages/expo-biometrics/README.md create mode 100644 packages/expo-biometrics/android/build.gradle create mode 100644 packages/expo-biometrics/android/src/main/AndroidManifest.xml create mode 100644 packages/expo-biometrics/android/src/main/java/expo/modules/clerk/biometrics/ClerkExpoBiometricsModule.kt create mode 100644 packages/expo-biometrics/expo-module.config.json create mode 100644 packages/expo-biometrics/ios/BiometricCredentialCoding.swift create mode 100644 packages/expo-biometrics/ios/BiometricCredentialStore.swift create mode 100644 packages/expo-biometrics/ios/BiometricKeyManager.swift create mode 100644 packages/expo-biometrics/ios/BiometricsError.swift create mode 100644 packages/expo-biometrics/ios/ClerkExpoBiometrics.podspec create mode 100644 packages/expo-biometrics/ios/ClerkExpoBiometricsModule.swift create mode 100644 packages/expo-biometrics/ios/Tests/BiometricCredentialContractTests.swift create mode 100644 packages/expo-biometrics/ios/Tests/BiometricCredentialStoreTests.swift create mode 100644 packages/expo-biometrics/ios/Tests/BiometricSystemIntegrationTests.swift create mode 100644 packages/expo-biometrics/ios/Tests/Resources/BiometricCredentialStorageContractV1.json create mode 100644 packages/expo-biometrics/package.json create mode 100644 packages/expo-biometrics/src/ClerkExpoBiometricsModule.ts create mode 100644 packages/expo-biometrics/src/__tests__/index.test.ts create mode 100644 packages/expo-biometrics/src/errors.ts create mode 100644 packages/expo-biometrics/src/index.ts create mode 100644 packages/expo-biometrics/src/types.ts create mode 100644 packages/expo-biometrics/tsconfig.declarations.json create mode 100644 packages/expo-biometrics/tsconfig.json create mode 100644 packages/expo-biometrics/tsup.config.ts create mode 100644 packages/expo-biometrics/vitest.config.mts diff --git a/.changeset/expo-biometrics-package.md b/.changeset/expo-biometrics-package.md new file mode 100644 index 00000000000..0e3340cf7a9 --- /dev/null +++ b/.changeset/expo-biometrics-package.md @@ -0,0 +1,7 @@ +--- +'@clerk/expo-biometrics': minor +--- + +Add `@clerk/expo-biometrics`, an experimental Expo native module that creates and signs with the device-bound keys behind Clerk biometric credentials and manages their on-device records. It is iOS-only for now; on Android every call rejects with `not_implemented`. + +If you try this out, make sure to pin your version as breaking changes can happen in minors. diff --git a/.github/workflows/expo-native-build.yml b/.github/workflows/expo-native-build.yml index cd69db5c340..aad2a09afa6 100644 --- a/.github/workflows/expo-native-build.yml +++ b/.github/workflows/expo-native-build.yml @@ -10,6 +10,7 @@ on: - 'integration/templates/expo-native/**' - 'integration/tests/expo-native/**' - 'packages/expo/**' + - 'packages/expo-biometrics/**' - 'packages/expo-google-signin/**' - 'packages/expo-native-components/**' workflow_dispatch: @@ -85,6 +86,7 @@ jobs: turbo.json \ packages/clerk-js \ packages/expo \ + packages/expo-biometrics \ packages/expo-google-signin \ packages/expo-native-components \ packages/react \ @@ -123,11 +125,12 @@ jobs: - name: Build and pack Clerk packages if: steps.native-build-cache.outputs.cache-hit != 'true' run: | - pnpm --filter @clerk/expo... build + pnpm --filter @clerk/expo... --filter @clerk/expo-biometrics build mkdir -p "$SDK_PACK_DIR" pnpm --filter @clerk/expo pack --pack-destination "$SDK_PACK_DIR" pnpm --filter @clerk/expo-google-signin pack --pack-destination "$SDK_PACK_DIR" pnpm --filter @clerk/expo-native-components pack --pack-destination "$SDK_PACK_DIR" + pnpm --filter @clerk/expo-biometrics pack --pack-destination "$SDK_PACK_DIR" - name: Install fixture dependencies if: steps.native-build-cache.outputs.cache-hit != 'true' @@ -138,11 +141,12 @@ jobs: run: | cp "package.sdk-$EXPO_SDK.json" package.json pnpm install --no-frozen-lockfile - # [0-9] keeps this glob off the clerk-expo-google-signin and clerk-expo-native tarballs. + # [0-9] keeps this glob off the other clerk-expo-* tarballs. SDK_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-[0-9]*.tgz)" GOOGLE_SIGNIN_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-google-signin-*.tgz)" NATIVE_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-native-components-*.tgz)" - pnpm add "$SDK_TARBALL" "$GOOGLE_SIGNIN_TARBALL" "$NATIVE_TARBALL" -w + BIOMETRICS_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-biometrics-*.tgz)" + pnpm add "$SDK_TARBALL" "$GOOGLE_SIGNIN_TARBALL" "$NATIVE_TARBALL" "$BIOMETRICS_TARBALL" -w # expo-dev-client makes even release builds boot into the dev # launcher (unreachable Metro in CI), which stalls every Maestro # flow on a blank screen. Skip it on e2e jobs only. diff --git a/packages/expo-biometrics/.gitignore b/packages/expo-biometrics/.gitignore new file mode 100644 index 00000000000..3dde874d50d --- /dev/null +++ b/packages/expo-biometrics/.gitignore @@ -0,0 +1,58 @@ +# OSX +# +.DS_Store + +# VSCode +.vscode/ +jsconfig.json + +# Xcode +# +build/ +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata +*.xccheckout +*.moved-aside +DerivedData +*.hmap +*.ipa +*.xcuserstate +project.xcworkspace + +# Android/IJ +# +.classpath +.cxx +.gradle +.idea +.project +.settings +local.properties +android.iml +android/app/libs +android/keystores/debug.keystore + +# Cocoapods +# +example/ios/Pods + +# Ruby +example/vendor/ + +# node.js +# +node_modules/ +npm-debug.log +yarn-debug.log +yarn-error.log + +# Expo +.expo/* +.env \ No newline at end of file diff --git a/packages/expo-biometrics/.npmignore b/packages/expo-biometrics/.npmignore new file mode 100644 index 00000000000..937d158a8ea --- /dev/null +++ b/packages/expo-biometrics/.npmignore @@ -0,0 +1,14 @@ +# Exclude all top-level hidden directories by convention +/.*/ + +# Exclude tarballs generated by `npm pack` +/*.tgz + +__mocks__ +__tests__ + +/babel.config.js +/android/src/androidTest/ +/android/src/test/ +/android/build/ +/example/ diff --git a/packages/expo-biometrics/LICENSE b/packages/expo-biometrics/LICENSE new file mode 100644 index 00000000000..daceccfbc84 --- /dev/null +++ b/packages/expo-biometrics/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Clerk, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/expo-biometrics/README.md b/packages/expo-biometrics/README.md new file mode 100644 index 00000000000..dcac2a997f6 --- /dev/null +++ b/packages/expo-biometrics/README.md @@ -0,0 +1,84 @@ +

+ + + + + + +
+

@clerk/expo-biometrics

+

+ +
+ +[![Chat on Discord](https://img.shields.io/discord/856971667393609759.svg?logo=discord)](https://clerk.com/discord) +[![Clerk documentation](https://img.shields.io/badge/documentation-clerk-green.svg)](https://clerk.com/docs?utm_source=github&utm_medium=expo_biometrics) +[![Follow on X](https://img.shields.io/twitter/follow/clerk?style=social)](https://x.com/intent/follow?screen_name=clerk) + +[Changelog](https://github.com/clerk/javascript/blob/main/packages/expo-biometrics/CHANGELOG.md) +· +[Report a Bug](https://github.com/clerk/javascript/issues/new?assignees=&labels=needs-triage&projects=&template=BUG_REPORT.yml) +· +[Request a Feature](https://feedback.clerk.com/roadmap) +· +[Get help](https://clerk.com/contact/support?utm_source=github&utm_medium=expo_biometrics) + +
+ +> [!WARNING] +> This package is experimental. Pin its version, as breaking changes can happen in minor releases. + +The native building block for Clerk biometric credentials in Expo apps. It creates hardware-backed signing keys, signs challenges behind a Face ID / Touch ID prompt, and stores the on-device records that link each key to a Clerk credential. It does not talk to Clerk's API; `@clerk/expo` builds the sign-in and enrollment flows on top of it. + +The key and record layout is shared with the Clerk iOS SDK, so credentials enrolled by either SDK in the same app are visible to both. + +### Prerequisites + +- Expo SDK 54 or later, in a development build (the module is not available in Expo Go or on the web) +- iOS. Android support is not implemented yet: every call rejects with `not_implemented`. +- `NSFaceIDUsageDescription` in your `Info.plist`. The `@clerk/expo` config plugin sets it through its `faceIDPermission` option. + +## Installation + +```sh +npx expo install @clerk/expo-biometrics +``` + +Then rebuild your native app. + +## API + +```ts +import { + createKey, + deleteKey, + deleteRecord, + ensureInstallationMarker, + getAppIdentifier, + getAvailability, + hasKey, + listRecords, + saveRecord, + sign, +} from '@clerk/expo-biometrics'; +``` + +| Function | Description | +| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `getAppIdentifier()` | The app identifier sent to Clerk as `app_identifier` (the iOS bundle identifier). | +| `getAvailability()` | The device's biometry type and whether biometrics or device owner authentication can be evaluated. | +| `createKey(policy)` | Creates a Secure Enclave P-256 key and returns its `localKeyId` and public key JWK. | +| `sign(localKeyId, clientData, reason?)` | Prompts for authentication and returns an ES256 signature over `clientData` (raw `r \|\| s`, base64url without padding). | +| `hasKey(localKeyId)` / `deleteKey(localKeyId)` | Checks for or deletes a key. | +| `listRecords()` | Every stored credential record, for every app identifier. | +| `saveRecord(record, options)` | Saves a record. With `removeOtherRecordsForApp: true`, deletes the app's other records and their keys. | +| `deleteRecord(localKeyId)` | Deletes a key, then the records that reference it. | +| `ensureInstallationMarker()` | Deletes records and keys left behind by a previous installation of the app. The store functions call it for you. | + +Every error is a `ClerkBiometricsError` with a stable `code`, such as `user_canceled`, `biometry_not_enrolled`, `biometry_lockout`, `key_not_found`, or `storage_failed`. + +## License + +This project is licensed under the **MIT license**. + +See [LICENSE](https://github.com/clerk/javascript/blob/main/packages/expo-biometrics/LICENSE) for more information. diff --git a/packages/expo-biometrics/android/build.gradle b/packages/expo-biometrics/android/build.gradle new file mode 100644 index 00000000000..a40c50f6d7b --- /dev/null +++ b/packages/expo-biometrics/android/build.gradle @@ -0,0 +1,39 @@ +apply plugin: 'com.android.library' +// AGP 9+ registers the `kotlin` extension itself, and applying kotlin-android on top of that fails configuration. +if (project.extensions.findByName('kotlin') == null) { + apply plugin: 'kotlin-android' +} + +apply plugin: 'maven-publish' + +group = 'expo.modules.clerk.biometrics' +version = '1.0.0' + +def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") +apply from: expoModulesCorePlugin +applyKotlinExpoModulesCorePlugin() +useCoreDependencies() +useExpoPublishing() + +buildscript { + ext.safeExtGet = { prop, fallback -> + rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback + } +} + +android { + namespace "expo.modules.clerk.biometrics" + + compileSdkVersion safeExtGet("compileSdkVersion", 36) + + defaultConfig { + minSdkVersion safeExtGet("minSdkVersion", 24) + targetSdkVersion safeExtGet("targetSdkVersion", 36) + versionCode 1 + versionName "1.0.0" + } +} + +dependencies { + implementation project(':expo-modules-core') +} diff --git a/packages/expo-biometrics/android/src/main/AndroidManifest.xml b/packages/expo-biometrics/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..bdae66c8f5a --- /dev/null +++ b/packages/expo-biometrics/android/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/packages/expo-biometrics/android/src/main/java/expo/modules/clerk/biometrics/ClerkExpoBiometricsModule.kt b/packages/expo-biometrics/android/src/main/java/expo/modules/clerk/biometrics/ClerkExpoBiometricsModule.kt new file mode 100644 index 00000000000..a0fd2a58942 --- /dev/null +++ b/packages/expo-biometrics/android/src/main/java/expo/modules/clerk/biometrics/ClerkExpoBiometricsModule.kt @@ -0,0 +1,33 @@ +package expo.modules.clerk.biometrics + +import expo.modules.kotlin.Promise +import expo.modules.kotlin.exception.CodedException +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class NotImplementedException : + CodedException("not_implemented", "@clerk/expo-biometrics is not supported on Android yet.", null) + +class ClerkExpoBiometricsModule : Module() { + override fun definition() = ModuleDefinition { + Name("ClerkExpoBiometrics") + + Function("getAppIdentifier") { -> notImplemented() } + + AsyncFunction("getAvailability") { promise: Promise -> reject(promise) } + AsyncFunction("createKey") { _: String, promise: Promise -> reject(promise) } + AsyncFunction("sign") { _: String, _: String, _: String?, promise: Promise -> reject(promise) } + AsyncFunction("hasKey") { _: String, promise: Promise -> reject(promise) } + AsyncFunction("deleteKey") { _: String, promise: Promise -> reject(promise) } + AsyncFunction("listRecords") { promise: Promise -> reject(promise) } + AsyncFunction("saveRecord") { _: Map, _: Map, promise: Promise -> reject(promise) } + AsyncFunction("deleteRecord") { _: String, promise: Promise -> reject(promise) } + AsyncFunction("ensureInstallationMarker") { promise: Promise -> reject(promise) } + } + + private fun reject(promise: Promise) { + promise.reject(NotImplementedException()) + } + + private fun notImplemented(): T = throw NotImplementedException() +} diff --git a/packages/expo-biometrics/expo-module.config.json b/packages/expo-biometrics/expo-module.config.json new file mode 100644 index 00000000000..08bfbbf215e --- /dev/null +++ b/packages/expo-biometrics/expo-module.config.json @@ -0,0 +1,9 @@ +{ + "platforms": ["apple", "android"], + "apple": { + "modules": ["ClerkExpoBiometricsModule"] + }, + "android": { + "modules": ["expo.modules.clerk.biometrics.ClerkExpoBiometricsModule"] + } +} diff --git a/packages/expo-biometrics/ios/BiometricCredentialCoding.swift b/packages/expo-biometrics/ios/BiometricCredentialCoding.swift new file mode 100644 index 00000000000..441245f9ec0 --- /dev/null +++ b/packages/expo-biometrics/ios/BiometricCredentialCoding.swift @@ -0,0 +1,324 @@ +import Foundation + +// Implements clerk-ios Documentation/BiometricCredentialStorageContract.md (version 1). Any change here must stay +// byte-compatible with ClerkKit, which reads and writes the same keys, Keychain item, and UserDefaults marker. + +enum BiometricCredentialPolicy: String, CaseIterable { + case biometryCurrentSet = "biometry_current_set" + case biometryAny = "biometry_any" + case biometryOrDevicePasscode = "biometry_or_device_passcode" +} + +enum BiometricCredentialCoding { + static let applicationTagPrefix = "dev.clerk.trusted_device" + static let metadataAccount = "trustedDeviceCredentials" + static let installationMarkerPrefix = "com.clerk.trusted-device-installation-marker" + + static func makeLocalKeyId() -> String { + "tdlk_" + UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() + } + + static func applicationTag(localKeyId: String) -> Data { + Data("\(applicationTagPrefix).\(localKeyId)".utf8) + } + + static func metadataService(infoDictionaryService: String?, bundleIdentifier: String?) -> String { + if let infoDictionaryService, !infoDictionaryService.isEmpty { + return infoDictionaryService + } + return bundleIdentifier ?? "" + } + + static func installationMarkerKey(service: String?, accessGroup: String?, appIdentifier: String?) -> String { + [ + installationMarkerPrefix, + encodeInstallationMarkerComponent(service), + encodeInstallationMarkerComponent(accessGroup), + encodeInstallationMarkerComponent(appIdentifier), + ].joined(separator: ".") + } + + private static func encodeInstallationMarkerComponent(_ value: String?) -> String { + guard let value else { return "n" } + return "s\(value.utf8.count):\(value)" + } + + static func base64URLEncodedString(_ data: D) -> String { + Data(data) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + static func publicKeyJWK(fromX963Representation representation: Data) throws -> String { + let bytes = [UInt8](representation) + guard bytes.count == 65, bytes[0] == 0x04 else { + throw BiometricsError(.keyGenerationFailed, "The public key is not an uncompressed P-256 point.") + } + let x = base64URLEncodedString(bytes[1 ..< 33]) + let y = base64URLEncodedString(bytes[33 ..< 65]) + return #"{"kty":"EC","crv":"P-256","x":"\#(x)","y":"\#(y)","alg":"ES256"}"# + } + + static func rawES256Signature(fromDEREncoded signature: Data) throws -> Data { + let bytes = [UInt8](signature) + var offset = 0 + + guard try readDERByte(bytes, offset: &offset) == 0x30 else { + throw invalidSignature() + } + let sequenceLength = try readDERLength(bytes, offset: &offset) + guard sequenceLength == bytes.count - offset else { + throw invalidSignature() + } + + let r = try readDERInteger(bytes, offset: &offset) + let s = try readDERInteger(bytes, offset: &offset) + guard offset == bytes.count else { + throw invalidSignature() + } + + var raw = try paddedES256Component(r) + raw.append(try paddedES256Component(s)) + return raw + } + + private static func readDERByte(_ bytes: [UInt8], offset: inout Int) throws -> UInt8 { + guard offset < bytes.count else { throw invalidSignature() } + let byte = bytes[offset] + offset += 1 + return byte + } + + private static func readDERLength(_ bytes: [UInt8], offset: inout Int) throws -> Int { + let first = try readDERByte(bytes, offset: &offset) + if first & 0x80 == 0 { + return Int(first) + } + let byteCount = Int(first & 0x7F) + guard byteCount > 0, byteCount <= MemoryLayout.size, byteCount <= bytes.count - offset else { + throw invalidSignature() + } + var length = 0 + for _ in 0 ..< byteCount { + length = (length << 8) | Int(try readDERByte(bytes, offset: &offset)) + } + return length + } + + private static func readDERInteger(_ bytes: [UInt8], offset: inout Int) throws -> [UInt8] { + guard try readDERByte(bytes, offset: &offset) == 0x02 else { throw invalidSignature() } + let length = try readDERLength(bytes, offset: &offset) + guard length > 0, length <= bytes.count - offset else { throw invalidSignature() } + let value = Array(bytes[offset ..< offset + length]) + offset += length + return value + } + + private static func paddedES256Component(_ bytes: [UInt8]) throws -> Data { + guard let first = bytes.first, first & 0x80 == 0 else { throw invalidSignature() } + var component = bytes + while component.first == 0x00, component.count > 32 { + component.removeFirst() + } + guard !component.isEmpty, component.count <= 32 else { throw invalidSignature() } + var padded = Data(repeating: 0x00, count: 32 - component.count) + padded.append(contentsOf: component) + return padded + } + + private static func invalidSignature() -> BiometricsError { + BiometricsError(.signingFailed, "Security returned an invalid ES256 signature.") + } +} + +/// A record in the metadata Keychain item. +struct BiometricCredentialRecord: Equatable { + enum Field { + static let id = "id" + static let localKeyId = "localKeyId" + static let userId = "userId" + static let appIdentifier = "appIdentifier" + static let identifierHint = "identifierHint" + static let policy = "policy" + static let createdAt = "createdAt" + static let updatedAt = "updatedAt" + } + + let id: String + let localKeyId: String + let userId: String + let appIdentifier: String + let identifierHint: String? + let policy: BiometricCredentialPolicy + /// Milliseconds since the Unix epoch. + let createdAt: Double + /// Milliseconds since the Unix epoch. + let updatedAt: Double + + init( + id: String, + localKeyId: String, + userId: String, + appIdentifier: String, + identifierHint: String?, + policy: BiometricCredentialPolicy, + createdAt: Double, + updatedAt: Double + ) { + self.id = id + self.localKeyId = localKeyId + self.userId = userId + self.appIdentifier = appIdentifier + self.identifierHint = Self.normalizedIdentifierHint(identifierHint) + self.policy = policy + self.createdAt = createdAt + self.updatedAt = updatedAt + } + + /// Decodes a stored record, returning `nil` for records ClerkKit treats as malformed. + init?(jsonObject object: [String: Any]) { + guard + let id = object[Field.id] as? String, + let localKeyId = object[Field.localKeyId] as? String, + let userId = object[Field.userId] as? String, + let appIdentifier = object[Field.appIdentifier] as? String, + let policyValue = object[Field.policy] as? String, + let policy = BiometricCredentialPolicy(rawValue: policyValue), + let createdAt = Self.number(object[Field.createdAt]), + let updatedAt = Self.number(object[Field.updatedAt]) + else { + return nil + } + + let identifierHint: String? + switch object[Field.identifierHint] { + case .none, is NSNull: + identifierHint = nil + case let value as String: + identifierHint = value + default: + return nil + } + + self.init( + id: id, + localKeyId: localKeyId, + userId: userId, + appIdentifier: appIdentifier, + identifierHint: identifierHint, + policy: policy, + createdAt: createdAt, + updatedAt: updatedAt + ) + } + + /// The object written to the Keychain: integer milliseconds, `identifierHint` omitted when absent. + var jsonObject: [String: Any] { + var object: [String: Any] = [ + Field.id: id, + Field.localKeyId: localKeyId, + Field.userId: userId, + Field.appIdentifier: appIdentifier, + Field.policy: policy.rawValue, + Field.createdAt: Int64(createdAt.rounded()), + Field.updatedAt: Int64(updatedAt.rounded()), + ] + if let identifierHint { + object[Field.identifierHint] = identifierHint + } + return object + } + + static func normalizedIdentifierHint(_ identifierHint: String?) -> String? { + guard let identifierHint else { return nil } + let normalized = identifierHint.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return normalized.isEmpty ? nil : normalized + } + + static func isValidTimestamp(_ value: Double) -> Bool { + value.isFinite && value >= 0 && value < 9.0e15 + } + + private static func number(_ value: Any?) -> Double? { + guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() else { + return nil + } + return number.doubleValue + } +} + +enum BiometricCredentialRecordList { + /// Parses the metadata item. A missing item is an empty list; a top-level value that is not an array of objects is an error. + static func decode(_ data: Data?) throws -> [[String: Any]] { + guard let data else { return [] } + let object: Any + do { + object = try JSONSerialization.jsonObject(with: data) + } catch { + throw BiometricsError(.storageFailed, "Biometric credential metadata is not valid JSON.") + } + guard let records = object as? [[String: Any]] else { + throw BiometricsError(.storageFailed, "Biometric credential metadata is not an array.") + } + return records + } + + /// Serializes the list, or returns `nil` when it is empty so the caller deletes the item instead of writing `[]`. + static func encode(_ records: [[String: Any]]) throws -> Data? { + guard !records.isEmpty else { return nil } + do { + return try JSONSerialization.data(withJSONObject: records) + } catch { + throw BiometricsError(.storageFailed, "Biometric credential metadata could not be encoded.") + } + } + + /// Well-formed records with their stored fields passed through and `identifierHint` normalized as ClerkKit reads it. + static func listable(_ records: [[String: Any]]) -> [[String: Any]] { + records.compactMap { object in + guard let record = BiometricCredentialRecord(jsonObject: object) else { return nil } + var listed = object + if let identifierHint = record.identifierHint { + listed[BiometricCredentialRecord.Field.identifierHint] = identifierHint + } else { + listed.removeValue(forKey: BiometricCredentialRecord.Field.identifierHint) + } + return listed + } + } + + struct SaveResult { + let records: [[String: Any]] + /// Keys of records with the same `id` that pointed at a different key. + let replacedLocalKeyIds: [String] + } + + /// Replaces any record with the same `id`, drops malformed records for the same app, and keeps everything else untouched. + static func saving(_ record: BiometricCredentialRecord, into records: [[String: Any]]) -> SaveResult { + var kept: [[String: Any]] = [] + var replacedLocalKeyIds: [String] = [] + + for object in records { + if object[BiometricCredentialRecord.Field.id] as? String == record.id { + if let localKeyId = object[BiometricCredentialRecord.Field.localKeyId] as? String, + localKeyId != record.localKeyId + { + replacedLocalKeyIds.append(localKeyId) + } + continue + } + guard object[BiometricCredentialRecord.Field.appIdentifier] as? String == record.appIdentifier else { + kept.append(object) + continue + } + if BiometricCredentialRecord(jsonObject: object) != nil { + kept.append(object) + } + } + + kept.append(record.jsonObject) + return SaveResult(records: kept, replacedLocalKeyIds: replacedLocalKeyIds) + } +} diff --git a/packages/expo-biometrics/ios/BiometricCredentialStore.swift b/packages/expo-biometrics/ios/BiometricCredentialStore.swift new file mode 100644 index 00000000000..252a381f9c8 --- /dev/null +++ b/packages/expo-biometrics/ios/BiometricCredentialStore.swift @@ -0,0 +1,230 @@ +import Foundation +import Security + +protocol BiometricCredentialMetadataStorage { + func read() throws -> Data? + func write(_ data: Data) throws + func delete() throws +} + +/// The generic-password item ClerkKit's `SystemKeychain` uses for biometric credential metadata, with no access group. +struct KeychainMetadataStorage: BiometricCredentialMetadataStorage { + let service: String + var account: String = BiometricCredentialCoding.metadataAccount + + func read() throws -> Data? { + var query = baseQuery() + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + switch status { + case errSecSuccess: + return result as? Data + case errSecItemNotFound: + return nil + default: + throw BiometricsError(.storageFailed, BiometricsError.statusMessage(status)) + } + } + + func write(_ data: Data) throws { + var addQuery = baseQuery() + addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + addQuery[kSecValueData as String] = data + + let status = SecItemAdd(addQuery as CFDictionary, nil) + switch status { + case errSecSuccess: + return + case errSecDuplicateItem: + let attributes: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + let updateStatus = SecItemUpdate(baseQuery() as CFDictionary, attributes as CFDictionary) + guard updateStatus == errSecSuccess else { + throw BiometricsError(.storageFailed, BiometricsError.statusMessage(updateStatus)) + } + default: + throw BiometricsError(.storageFailed, BiometricsError.statusMessage(status)) + } + } + + func delete() throws { + let status = SecItemDelete(baseQuery() as CFDictionary) + switch status { + case errSecSuccess, errSecItemNotFound: + return + default: + throw BiometricsError(.storageFailed, BiometricsError.statusMessage(status)) + } + } + + func baseQuery() -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + } +} + +/// Read-modify-write operations on the shared metadata item. Every operation runs under one lock and first applies the +/// reinstall marker, so records written here are never wiped by ClerkKit's first configuration in this installation. +final class BiometricCredentialStore { + private let storage: BiometricCredentialMetadataStorage + private let deleteKey: (String) throws -> Void + private let userDefaults: UserDefaults + private let service: String + private let appIdentifier: String? + private let lock = NSRecursiveLock() + + init( + storage: BiometricCredentialMetadataStorage, + deleteKey: @escaping (String) throws -> Void, + userDefaults: UserDefaults, + service: String, + appIdentifier: String? + ) { + self.storage = storage + self.deleteKey = deleteKey + self.userDefaults = userDefaults + self.service = service + self.appIdentifier = appIdentifier + } + + static func live(keyManager: BiometricKeyManager, bundle: Bundle = .main) -> BiometricCredentialStore { + let service = BiometricCredentialCoding.metadataService( + infoDictionaryService: bundle.object(forInfoDictionaryKey: "ClerkKeychainService") as? String, + bundleIdentifier: bundle.bundleIdentifier + ) + return BiometricCredentialStore( + storage: KeychainMetadataStorage(service: service), + deleteKey: { try keyManager.deleteKey(localKeyId: $0) }, + userDefaults: .standard, + service: service, + appIdentifier: bundle.bundleIdentifier + ) + } + + var installationMarkerKey: String { + BiometricCredentialCoding.installationMarkerKey(service: service, accessGroup: nil, appIdentifier: appIdentifier) + } + + /// Returns `true` when the marker was missing and this app's records and keys from a previous installation were deleted. + @discardableResult + func ensureInstallationMarker() throws -> Bool { + lock.lock() + defer { lock.unlock() } + + guard appIdentifier != nil else { return false } + let markerKey = installationMarkerKey + if userDefaults.object(forKey: markerKey) as? Bool == true { + return false + } + + let records = try readRecords() + var remaining: [[String: Any]] = [] + var keyDeletionError: Error? + for record in records { + guard record[BiometricCredentialRecord.Field.appIdentifier] as? String == appIdentifier else { + remaining.append(record) + continue + } + if let localKeyId = record[BiometricCredentialRecord.Field.localKeyId] as? String { + do { + try deleteKey(localKeyId) + } catch { + keyDeletionError = keyDeletionError ?? error + remaining.append(record) + continue + } + } + } + + if remaining.count != records.count { + try persist(remaining) + } + if let keyDeletionError { + throw keyDeletionError + } + + userDefaults.set(true, forKey: markerKey) + return true + } + + /// JSON array of every well-formed record, for every app identifier. + func listRecordsJSON() throws -> String { + lock.lock() + defer { lock.unlock() } + + try ensureInstallationMarker() + let data = try JSONSerialization.data(withJSONObject: BiometricCredentialRecordList.listable(readRecords())) + return String(decoding: data, as: UTF8.self) + } + + func save(_ record: BiometricCredentialRecord, removeOtherRecordsForApp: Bool) throws { + lock.lock() + defer { lock.unlock() } + + try ensureInstallationMarker() + let result = BiometricCredentialRecordList.saving(record, into: try readRecords()) + try persist(result.records) + + for localKeyId in result.replacedLocalKeyIds where localKeyId != record.localKeyId { + try? deleteKey(localKeyId) + } + + guard removeOtherRecordsForApp else { return } + + var remaining: [[String: Any]] = [] + for object in result.records { + let isOtherRecordForApp = object[BiometricCredentialRecord.Field.appIdentifier] as? String == record.appIdentifier + && object[BiometricCredentialRecord.Field.id] as? String != record.id + guard isOtherRecordForApp else { + remaining.append(object) + continue + } + if let localKeyId = object[BiometricCredentialRecord.Field.localKeyId] as? String, localKeyId != record.localKeyId { + do { + try deleteKey(localKeyId) + } catch { + remaining.append(object) + continue + } + } + } + if remaining.count != result.records.count { + try? persist(remaining) + } + } + + /// Deletes the key, then every record that references it. When the key cannot be deleted the records are kept. + func deleteRecords(localKeyId: String) throws { + lock.lock() + defer { lock.unlock() } + + try ensureInstallationMarker() + try deleteKey(localKeyId) + + let records = try readRecords() + let remaining = records.filter { $0[BiometricCredentialRecord.Field.localKeyId] as? String != localKeyId } + if remaining.count != records.count { + try persist(remaining) + } + } + + private func readRecords() throws -> [[String: Any]] { + try BiometricCredentialRecordList.decode(storage.read()) + } + + private func persist(_ records: [[String: Any]]) throws { + if let data = try BiometricCredentialRecordList.encode(records) { + try storage.write(data) + } else { + try storage.delete() + } + } +} diff --git a/packages/expo-biometrics/ios/BiometricKeyManager.swift b/packages/expo-biometrics/ios/BiometricKeyManager.swift new file mode 100644 index 00000000000..53e306a8b2a --- /dev/null +++ b/packages/expo-biometrics/ios/BiometricKeyManager.swift @@ -0,0 +1,206 @@ +import Foundation +import LocalAuthentication +import Security + +struct BiometricAvailability: Equatable { + let biometryType: String + let canEvaluateBiometrics: Bool + let canEvaluateDeviceOwner: Bool + let errorCode: BiometricsError.Code? +} + +struct BiometricCredentialKey: Equatable { + let localKeyId: String + let publicKeyJWK: String +} + +/// Secure Enclave keys laid out as ClerkKit's `BiometricCredentialKeyManager` creates them. +final class BiometricKeyManager { + func availability() -> BiometricAvailability { + let context = LAContext() + var biometricsError: NSError? + let canEvaluateBiometrics = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &biometricsError) + // biometryType is only populated after canEvaluatePolicy has run. + let biometryType = Self.biometryTypeName(context.biometryType) + var deviceOwnerError: NSError? + let canEvaluateDeviceOwner = context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &deviceOwnerError) + + return BiometricAvailability( + biometryType: biometryType, + canEvaluateBiometrics: canEvaluateBiometrics, + canEvaluateDeviceOwner: canEvaluateDeviceOwner, + errorCode: canEvaluateBiometrics + ? nil + : BiometricsError.localAuthentication(biometricsError, fallback: .biometryNotAvailable).code + ) + } + + func createKey(policy: BiometricCredentialPolicy) throws -> BiometricCredentialKey { + let context = LAContext() + var laError: NSError? + guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &laError) else { + throw BiometricsError.localAuthentication(laError, fallback: .biometryNotAvailable) + } + + let localKeyId = BiometricCredentialCoding.makeLocalKeyId() + let attributes = Self.privateKeyAttributes(localKeyId: localKeyId, accessControl: try Self.accessControl(policy: policy)) + + var error: Unmanaged? + guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else { + throw Self.cfError(error, fallback: .keyGenerationFailed) + } + + do { + return BiometricCredentialKey(localKeyId: localKeyId, publicKeyJWK: try Self.publicKeyJWK(for: privateKey)) + } catch { + try? deleteKey(localKeyId: localKeyId) + throw error + } + } + + func sign(localKeyId: String, clientData: String, reason: String?) throws -> String { + let privateKey = try privateKey(localKeyId: localKeyId, reason: reason) + let algorithm = SecKeyAlgorithm.ecdsaSignatureMessageX962SHA256 + guard SecKeyIsAlgorithmSupported(privateKey, .sign, algorithm) else { + throw BiometricsError(.signingFailed, "The key does not support ES256 signing.") + } + + var error: Unmanaged? + guard let signature = SecKeyCreateSignature(privateKey, algorithm, Data(clientData.utf8) as CFData, &error) as Data? else { + throw Self.cfError(error, fallback: .signingFailed) + } + + return BiometricCredentialCoding.base64URLEncodedString( + try BiometricCredentialCoding.rawES256Signature(fromDEREncoded: signature) + ) + } + + func hasKey(localKeyId: String) throws -> Bool { + var query = Self.privateKeyQuery(localKeyId: localKeyId) + query[kSecMatchLimit as String] = kSecMatchLimitOne + + let status = SecItemCopyMatching(query as CFDictionary, nil) + switch status { + case errSecSuccess: + return true + case errSecItemNotFound: + return false + default: + throw BiometricsError(.storageFailed, BiometricsError.statusMessage(status)) + } + } + + func deleteKey(localKeyId: String) throws { + let status = SecItemDelete(Self.privateKeyQuery(localKeyId: localKeyId) as CFDictionary) + switch status { + case errSecSuccess, errSecItemNotFound: + return + default: + throw BiometricsError(.storageFailed, BiometricsError.statusMessage(status)) + } + } + + private func privateKey(localKeyId: String, reason: String?) throws -> SecKey { + let context = LAContext() + if let reason { + context.localizedReason = reason + } + + var query = Self.privateKeyQuery(localKeyId: localKeyId) + query[kSecReturnRef as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + query[kSecUseAuthenticationContext as String] = context + + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + switch status { + case errSecSuccess: + guard let result, CFGetTypeID(result) == SecKeyGetTypeID() else { + throw BiometricsError(.keyNotFound, "The biometric credential key was not found.") + } + return result as! SecKey + case errSecItemNotFound: + throw BiometricsError(.keyNotFound, "The biometric credential key was not found.") + default: + throw BiometricsError.status(status, fallback: .signingFailed) + } + } + + static func accessControlFlags(for policy: BiometricCredentialPolicy) -> SecAccessControlCreateFlags { + switch policy { + case .biometryCurrentSet: + return [.privateKeyUsage, .biometryCurrentSet] + case .biometryAny: + return [.privateKeyUsage, .biometryAny] + case .biometryOrDevicePasscode: + return [.privateKeyUsage, .userPresence] + } + } + + static func accessControl(policy: BiometricCredentialPolicy) throws -> SecAccessControl { + var error: Unmanaged? + guard let accessControl = SecAccessControlCreateWithFlags( + kCFAllocatorDefault, + kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, + accessControlFlags(for: policy), + &error + ) else { + throw cfError(error, fallback: .keyGenerationFailed) + } + return accessControl + } + + static func privateKeyAttributes(localKeyId: String, accessControl: SecAccessControl) -> [String: Any] { + [ + kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, + kSecAttrKeySizeInBits as String: 256, + kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave, + kSecPrivateKeyAttrs as String: [ + kSecAttrIsPermanent as String: true, + kSecAttrApplicationTag as String: BiometricCredentialCoding.applicationTag(localKeyId: localKeyId), + kSecAttrAccessControl as String: accessControl, + ] as [String: Any], + ] + } + + static func privateKeyQuery(localKeyId: String) -> [String: Any] { + [ + kSecClass as String: kSecClassKey, + kSecAttrKeyClass as String: kSecAttrKeyClassPrivate, + kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, + kSecAttrApplicationTag as String: BiometricCredentialCoding.applicationTag(localKeyId: localKeyId), + ] + } + + static func biometryTypeName(_ type: LABiometryType) -> String { + switch type { + case .faceID: + return "faceID" + case .touchID: + return "touchID" + default: + if #available(iOS 17.0, *), type == .opticID { + return "opticID" + } + return "none" + } + } + + private static func publicKeyJWK(for privateKey: SecKey) throws -> String { + guard let publicKey = SecKeyCopyPublicKey(privateKey) else { + throw BiometricsError(.keyGenerationFailed, "Unable to copy the public key.") + } + var error: Unmanaged? + guard let representation = SecKeyCopyExternalRepresentation(publicKey, &error) as Data? else { + throw cfError(error, fallback: .keyGenerationFailed) + } + return try BiometricCredentialCoding.publicKeyJWK(fromX963Representation: representation) + } + + private static func cfError(_ error: Unmanaged?, fallback: BiometricsError.Code) -> BiometricsError { + guard let error else { + return BiometricsError(fallback, "Unknown Security framework error.") + } + return BiometricsError.security(error.takeRetainedValue() as Error, fallback: fallback) + } +} diff --git a/packages/expo-biometrics/ios/BiometricsError.swift b/packages/expo-biometrics/ios/BiometricsError.swift new file mode 100644 index 00000000000..f8e5bb82375 --- /dev/null +++ b/packages/expo-biometrics/ios/BiometricsError.swift @@ -0,0 +1,116 @@ +import Foundation +import LocalAuthentication +import Security + +struct BiometricsError: Error, Equatable { + enum Code: String { + case userCanceled = "user_canceled" + case systemCanceled = "system_canceled" + case userFallback = "user_fallback" + case authenticationFailed = "authentication_failed" + case biometryNotAvailable = "biometry_not_available" + case biometryNotEnrolled = "biometry_not_enrolled" + case biometryLockout = "biometry_lockout" + case passcodeNotSet = "passcode_not_set" + case keyNotFound = "key_not_found" + case keyInvalidated = "key_invalidated" + case keyGenerationFailed = "key_generation_failed" + case signingFailed = "signing_failed" + case storageFailed = "storage_failed" + case invalidArgument = "invalid_argument" + } + + let code: Code + let message: String + + init(_ code: Code, _ message: String) { + self.code = code + self.message = message + } + + static func localAuthentication(_ error: Error?, fallback: Code) -> BiometricsError { + guard let error = error as NSError?, error.domain == LAErrorDomain else { + return BiometricsError(fallback, error?.localizedDescription ?? "Local authentication failed.") + } + return BiometricsError(code(forLAErrorCode: error.code) ?? fallback, error.localizedDescription) + } + + static func security(_ error: Error, fallback: Code) -> BiometricsError { + let nsError = error as NSError + let code: Code? + switch nsError.domain { + case LAErrorDomain: + code = Self.code(forLAErrorCode: nsError.code) + case NSOSStatusErrorDomain: + code = Self.code(forStatus: OSStatus(truncatingIfNeeded: nsError.code)) + case cryptoTokenKitErrorDomain: + code = Self.code(forCryptoTokenKitErrorCode: nsError.code) + default: + code = nil + } + return BiometricsError(code ?? fallback, nsError.localizedDescription) + } + + static func status(_ status: OSStatus, fallback: Code) -> BiometricsError { + BiometricsError(code(forStatus: status) ?? fallback, statusMessage(status)) + } + + static func statusMessage(_ status: OSStatus) -> String { + let description = SecCopyErrorMessageString(status, nil) as String? ?? "Unknown error" + return "\(description) (OSStatus \(status))" + } + + static func code(forLAErrorCode rawValue: Int) -> Code? { + switch LAError.Code(rawValue: rawValue) { + case .userCancel: + return .userCanceled + case .appCancel, .systemCancel: + return .systemCanceled + case .userFallback: + return .userFallback + case .authenticationFailed: + return .authenticationFailed + case .biometryNotAvailable: + return .biometryNotAvailable + case .biometryNotEnrolled: + return .biometryNotEnrolled + case .biometryLockout: + return .biometryLockout + case .passcodeNotSet: + return .passcodeNotSet + default: + return nil + } + } + + static func code(forStatus status: OSStatus) -> Code? { + switch status { + case errSecUserCanceled: + return .userCanceled + case errSecAuthFailed: + return .authenticationFailed + case errSecInteractionNotAllowed: + return .biometryNotAvailable + case errSecItemNotFound: + return .keyNotFound + default: + return nil + } + } + + private static let cryptoTokenKitErrorDomain = "CryptoTokenKit" + + // TKError.Code values; the Secure Enclave reports these when the key is unusable. + static func code(forCryptoTokenKitErrorCode rawValue: Int) -> Code? { + switch rawValue { + case -4: + return .userCanceled + case -5: + return .authenticationFailed + case -6: + return .keyInvalidated + default: + return nil + } + } +} diff --git a/packages/expo-biometrics/ios/ClerkExpoBiometrics.podspec b/packages/expo-biometrics/ios/ClerkExpoBiometrics.podspec new file mode 100644 index 00000000000..bce85d59e29 --- /dev/null +++ b/packages/expo-biometrics/ios/ClerkExpoBiometrics.podspec @@ -0,0 +1,32 @@ +require 'json' + +package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json'))) + +Pod::Spec.new do |s| + s.name = 'ClerkExpoBiometrics' + s.version = package['version'] + s.summary = package['description'] + s.description = package['description'] + s.license = package['license'] + s.author = package['author'] + s.homepage = package['homepage'] + s.platforms = { :ios => '15.1' } + s.swift_version = '5.9' + s.source = { git: 'https://github.com/clerk/javascript.git' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + s.frameworks = 'LocalAuthentication', 'Security' + + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'SWIFT_COMPILATION_MODE' => 'wholemodule' + } + + s.source_files = '*.swift' + + s.test_spec 'Tests' do |test_spec| + test_spec.source_files = 'Tests/**/*.swift' + test_spec.resources = 'Tests/Resources/*.json' + end +end diff --git a/packages/expo-biometrics/ios/ClerkExpoBiometricsModule.swift b/packages/expo-biometrics/ios/ClerkExpoBiometricsModule.swift new file mode 100644 index 00000000000..ce017190500 --- /dev/null +++ b/packages/expo-biometrics/ios/ClerkExpoBiometricsModule.swift @@ -0,0 +1,158 @@ +import ExpoModulesCore +import Foundation + +final class ClerkBiometricsException: Exception { + private let errorCode: String + private let message: String + + init(_ error: BiometricsError) { + errorCode = error.code.rawValue + message = error.message + super.init() + } + + override var code: String { errorCode } + override var reason: String { message } +} + +struct BiometricAvailabilityResult: Record { + @Field var biometryType: String = "none" + @Field var canEvaluateBiometrics: Bool = false + @Field var canEvaluateDeviceOwner: Bool = false + @Field var errorCode: String? +} + +struct BiometricCredentialKeyResult: Record { + @Field var localKeyId: String = "" + @Field var publicKeyJwk: String = "" +} + +struct BiometricCredentialRecordInput: Record { + @Field var id: String = "" + @Field var localKeyId: String = "" + @Field var userId: String = "" + @Field var appIdentifier: String = "" + @Field var identifierHint: String? + @Field var policy: String = "" + @Field var createdAt: Double = -1 + @Field var updatedAt: Double = -1 +} + +struct SaveRecordOptions: Record { + @Field var removeOtherRecordsForApp: Bool = false +} + +struct InstallationMarkerResult: Record { + @Field var wiped: Bool = false +} + +public final class ClerkExpoBiometricsModule: Module { + private let keyManager = BiometricKeyManager() + private lazy var store = BiometricCredentialStore.live(keyManager: keyManager) + + public func definition() -> ModuleDefinition { + Name("ClerkExpoBiometrics") + + Function("getAppIdentifier") { () -> String in + Bundle.main.bundleIdentifier ?? "" + } + + AsyncFunction("getAvailability") { () -> BiometricAvailabilityResult in + let availability = self.keyManager.availability() + let result = BiometricAvailabilityResult() + result.biometryType = availability.biometryType + result.canEvaluateBiometrics = availability.canEvaluateBiometrics + result.canEvaluateDeviceOwner = availability.canEvaluateDeviceOwner + result.errorCode = availability.errorCode?.rawValue + return result + } + + AsyncFunction("createKey") { (policy: String) throws -> BiometricCredentialKeyResult in + try Self.bridge { + guard let policy = BiometricCredentialPolicy(rawValue: policy) else { + throw BiometricsError(.invalidArgument, "Unknown biometric credential policy '\(policy)'.") + } + let key = try self.keyManager.createKey(policy: policy) + let result = BiometricCredentialKeyResult() + result.localKeyId = key.localKeyId + result.publicKeyJwk = key.publicKeyJWK + return result + } + } + + AsyncFunction("sign") { (localKeyId: String, clientData: String, reason: String?) throws -> String in + try Self.bridge { + try self.keyManager.sign(localKeyId: localKeyId, clientData: clientData, reason: reason) + } + } + + AsyncFunction("hasKey") { (localKeyId: String) throws -> Bool in + try Self.bridge { try self.keyManager.hasKey(localKeyId: localKeyId) } + } + + AsyncFunction("deleteKey") { (localKeyId: String) throws in + try Self.bridge { try self.keyManager.deleteKey(localKeyId: localKeyId) } + } + + // Store operations run on the main queue so they cannot interleave with ClerkKit's @MainActor store access. + AsyncFunction("listRecords") { () throws -> String in + try Self.bridge { try self.store.listRecordsJSON() } + }.runOnQueue(.main) + + AsyncFunction("saveRecord") { (input: BiometricCredentialRecordInput, options: SaveRecordOptions) throws in + try Self.bridge { + try self.store.save(Self.record(from: input), removeOtherRecordsForApp: options.removeOtherRecordsForApp) + } + }.runOnQueue(.main) + + AsyncFunction("deleteRecord") { (localKeyId: String) throws in + try Self.bridge { try self.store.deleteRecords(localKeyId: localKeyId) } + }.runOnQueue(.main) + + AsyncFunction("ensureInstallationMarker") { () throws -> InstallationMarkerResult in + try Self.bridge { + let result = InstallationMarkerResult() + result.wiped = try self.store.ensureInstallationMarker() + return result + } + }.runOnQueue(.main) + } + + static func record(from input: BiometricCredentialRecordInput) throws -> BiometricCredentialRecord { + for (name, value) in [ + ("id", input.id), + ("localKeyId", input.localKeyId), + ("userId", input.userId), + ("appIdentifier", input.appIdentifier), + ] where value.isEmpty { + throw BiometricsError(.invalidArgument, "record.\(name) must be a non-empty string.") + } + guard let policy = BiometricCredentialPolicy(rawValue: input.policy) else { + throw BiometricsError(.invalidArgument, "Unknown biometric credential policy '\(input.policy)'.") + } + guard + BiometricCredentialRecord.isValidTimestamp(input.createdAt), + BiometricCredentialRecord.isValidTimestamp(input.updatedAt) + else { + throw BiometricsError(.invalidArgument, "record.createdAt and record.updatedAt must be milliseconds since the Unix epoch.") + } + return BiometricCredentialRecord( + id: input.id, + localKeyId: input.localKeyId, + userId: input.userId, + appIdentifier: input.appIdentifier, + identifierHint: input.identifierHint, + policy: policy, + createdAt: input.createdAt, + updatedAt: input.updatedAt + ) + } + + private static func bridge(_ body: () throws -> T) throws -> T { + do { + return try body() + } catch let error as BiometricsError { + throw ClerkBiometricsException(error) + } + } +} diff --git a/packages/expo-biometrics/ios/Tests/BiometricCredentialContractTests.swift b/packages/expo-biometrics/ios/Tests/BiometricCredentialContractTests.swift new file mode 100644 index 00000000000..c4461614eb8 --- /dev/null +++ b/packages/expo-biometrics/ios/Tests/BiometricCredentialContractTests.swift @@ -0,0 +1,277 @@ +import CryptoKit +import Foundation +import Security +import XCTest +@testable import ClerkExpoBiometrics + +/// Mirrors clerk-ios `BiometricCredentialStorageContractTests` (contract version 1) with the same vectors and fixture. +final class BiometricCredentialContractTests: XCTestCase { + private let fixtureLocalKeyId = "tdlk_0123456789abcdef0123456789abcdef" + + // MARK: - Secure Enclave key + + func testPrivateKeyQueryUsesContractApplicationTagWithoutAccessGroup() { + let query = BiometricKeyManager.privateKeyQuery(localKeyId: fixtureLocalKeyId) + + XCTAssertEqual(query[kSecClass as String] as? String, kSecClassKey as String) + XCTAssertEqual(query[kSecAttrKeyClass as String] as? String, kSecAttrKeyClassPrivate as String) + XCTAssertEqual(query[kSecAttrKeyType as String] as? String, kSecAttrKeyTypeECSECPrimeRandom as String) + XCTAssertEqual( + query[kSecAttrApplicationTag as String] as? Data, + Data("dev.clerk.trusted_device.tdlk_0123456789abcdef0123456789abcdef".utf8) + ) + XCTAssertNil(query[kSecAttrAccessGroup as String]) + XCTAssertNil(query[kSecAttrLabel as String]) + XCTAssertNil(query[kSecAttrApplicationLabel as String]) + XCTAssertEqual(query.count, 4) + } + + func testPrivateKeyAttributesMatchContract() throws { + let attributes = BiometricKeyManager.privateKeyAttributes( + localKeyId: fixtureLocalKeyId, + accessControl: try BiometricKeyManager.accessControl(policy: .biometryCurrentSet) + ) + + XCTAssertEqual(attributes[kSecAttrKeyType as String] as? String, kSecAttrKeyTypeECSECPrimeRandom as String) + XCTAssertEqual(attributes[kSecAttrKeySizeInBits as String] as? Int, 256) + XCTAssertEqual(attributes[kSecAttrTokenID as String] as? String, kSecAttrTokenIDSecureEnclave as String) + XCTAssertNil(attributes[kSecAttrAccessGroup as String]) + XCTAssertEqual(attributes.count, 4) + + let privateKeyAttributes = try XCTUnwrap(attributes[kSecPrivateKeyAttrs as String] as? [String: Any]) + XCTAssertEqual(privateKeyAttributes[kSecAttrIsPermanent as String] as? Bool, true) + XCTAssertEqual( + privateKeyAttributes[kSecAttrApplicationTag as String] as? Data, + Data("dev.clerk.trusted_device.tdlk_0123456789abcdef0123456789abcdef".utf8) + ) + XCTAssertNotNil(privateKeyAttributes[kSecAttrAccessControl as String]) + XCTAssertNil(privateKeyAttributes[kSecAttrAccessGroup as String]) + XCTAssertEqual(privateKeyAttributes.count, 3) + } + + func testPolicyRawValuesAndAccessControlFlagsMatchContract() throws { + XCTAssertEqual(BiometricCredentialPolicy.biometryCurrentSet.rawValue, "biometry_current_set") + XCTAssertEqual(BiometricCredentialPolicy.biometryAny.rawValue, "biometry_any") + XCTAssertEqual(BiometricCredentialPolicy.biometryOrDevicePasscode.rawValue, "biometry_or_device_passcode") + + XCTAssertEqual(BiometricKeyManager.accessControlFlags(for: .biometryCurrentSet), [.privateKeyUsage, .biometryCurrentSet]) + XCTAssertEqual(BiometricKeyManager.accessControlFlags(for: .biometryAny), [.privateKeyUsage, .biometryAny]) + XCTAssertEqual(BiometricKeyManager.accessControlFlags(for: .biometryOrDevicePasscode), [.privateKeyUsage, .userPresence]) + + for policy in BiometricCredentialPolicy.allCases { + XCTAssertNoThrow(try BiometricKeyManager.accessControl(policy: policy)) + } + } + + func testLocalKeyIdFormat() { + let localKeyId = BiometricCredentialCoding.makeLocalKeyId() + + XCTAssertTrue(localKeyId.hasPrefix("tdlk_")) + let suffix = localKeyId.dropFirst("tdlk_".count) + XCTAssertEqual(suffix.count, 32) + XCTAssertTrue(suffix.allSatisfy { "0123456789abcdef".contains($0) }) + XCTAssertNotEqual(localKeyId, BiometricCredentialCoding.makeLocalKeyId()) + } + + // MARK: - Signing and public key + + func testPublicKeyJWKMatchesContractFormat() throws { + var representation = Data([0x04]) + representation.append(Data(repeating: 0x01, count: 32)) + representation.append(Data(repeating: 0x02, count: 32)) + + XCTAssertEqual( + try BiometricCredentialCoding.publicKeyJWK(fromX963Representation: representation), + #"{"kty":"EC","crv":"P-256","x":"AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE","y":"AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI","alg":"ES256"}"# + ) + } + + func testPublicKeyJWKRejectsCompressedOrShortKeys() { + XCTAssertThrowsError(try BiometricCredentialCoding.publicKeyJWK(fromX963Representation: Data([0x02] + Array(repeating: 1, count: 32)))) + XCTAssertThrowsError(try BiometricCredentialCoding.publicKeyJWK(fromX963Representation: Data([0x05] + Array(repeating: 1, count: 64)))) + } + + func testSignatureIsRawRAndSEncodedAsUnpaddedBase64URL() throws { + let rComponent: [UInt8] = [0x00, 0x80] + Array(repeating: 0xAA, count: 31) + let sComponent: [UInt8] = Array(repeating: 0x11, count: 31) + let der = Data([0x30, 0x44, 0x02, UInt8(rComponent.count)] + rComponent + [0x02, UInt8(sComponent.count)] + sComponent) + + let raw = try BiometricCredentialCoding.rawES256Signature(fromDEREncoded: der) + + XCTAssertEqual(raw.count, 64) + XCTAssertEqual( + BiometricCredentialCoding.base64URLEncodedString(raw), + "gKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqoAEREREREREREREREREREREREREREREREREREREREREQ" + ) + } + + func testRawSignatureRejectsMalformedDER() { + let valid: [UInt8] = [0x30, 0x06, 0x02, 0x01, 0x01, 0x02, 0x01, 0x02] + XCTAssertNoThrow(try BiometricCredentialCoding.rawES256Signature(fromDEREncoded: Data(valid))) + + let malformed: [[UInt8]] = [ + [], + [0x31] + valid.dropFirst(), + [0x30, 0x07] + valid.dropFirst(2), + valid + [0x00], + [0x30, 0x06, 0x02, 0x01, 0x81, 0x02, 0x01, 0x02], + [0x30, 0x06, 0x02, 0x00, 0x02, 0x02, 0x01, 0x02], + [0x30, 0x25, 0x02, 0x21, 0x01] + Array(repeating: 0x01, count: 32) + [0x02, 0x01, 0x02], + ] + for bytes in malformed { + XCTAssertThrowsError(try BiometricCredentialCoding.rawES256Signature(fromDEREncoded: Data(bytes)), "\(bytes)") { error in + XCTAssertEqual((error as? BiometricsError)?.code, .signingFailed) + } + } + } + + func testSecuritySignatureConvertsToVerifiableRawSignature() throws { + var error: Unmanaged? + let attributes: [String: Any] = [ + kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, + kSecAttrKeySizeInBits as String: 256, + ] + let privateKey = try XCTUnwrap(SecKeyCreateRandomKey(attributes as CFDictionary, &error)) + let publicKey = try XCTUnwrap(SecKeyCopyPublicKey(privateKey)) + let publicKeyData = try XCTUnwrap(SecKeyCopyExternalRepresentation(publicKey, &error) as Data?) + let clientData = #"{"challenge":"abc","nonce":"é"}"# + + for _ in 0 ..< 32 { + let der = try XCTUnwrap(SecKeyCreateSignature( + privateKey, + .ecdsaSignatureMessageX962SHA256, + Data(clientData.utf8) as CFData, + &error + ) as Data?) + let raw = try BiometricCredentialCoding.rawES256Signature(fromDEREncoded: der) + + XCTAssertEqual(raw.count, 64) + let signature = try P256.Signing.ECDSASignature(rawRepresentation: raw) + let verifyingKey = try P256.Signing.PublicKey(x963Representation: publicKeyData) + XCTAssertTrue(verifyingKey.isValidSignature(signature, for: Data(clientData.utf8))) + } + + let jwk = try BiometricCredentialCoding.publicKeyJWK(fromX963Representation: publicKeyData) + let decoded = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(jwk.utf8)) as? [String: String]) + XCTAssertEqual(decoded["x"], BiometricCredentialCoding.base64URLEncodedString(publicKeyData[1 ..< 33])) + XCTAssertEqual(decoded["y"], BiometricCredentialCoding.base64URLEncodedString(publicKeyData[33 ..< 65])) + XCTAssertFalse(jwk.contains("=")) + } + + // MARK: - Metadata Keychain item + + func testMetadataItemQueryMatchesContract() { + let query = KeychainMetadataStorage(service: "com.clerk.example").baseQuery() + + XCTAssertEqual(query[kSecClass as String] as? String, kSecClassGenericPassword as String) + XCTAssertEqual(query[kSecAttrService as String] as? String, "com.clerk.example") + XCTAssertEqual(query[kSecAttrAccount as String] as? String, "trustedDeviceCredentials") + XCTAssertNil(query[kSecAttrAccessGroup as String]) + XCTAssertEqual(query.count, 3) + } + + func testMetadataServicePrefersNonEmptyClerkKeychainService() { + XCTAssertEqual(BiometricCredentialCoding.metadataService(infoDictionaryService: "com.example.shared", bundleIdentifier: "com.example.app"), "com.example.shared") + XCTAssertEqual(BiometricCredentialCoding.metadataService(infoDictionaryService: "", bundleIdentifier: "com.example.app"), "com.example.app") + XCTAssertEqual(BiometricCredentialCoding.metadataService(infoDictionaryService: nil, bundleIdentifier: "com.example.app"), "com.example.app") + XCTAssertEqual(BiometricCredentialCoding.metadataService(infoDictionaryService: nil, bundleIdentifier: nil), "") + } + + func testV1FixtureDecodesToExpectedRecords() throws { + let records = try BiometricCredentialRecordList.decode(Self.fixtureData()) + + XCTAssertEqual(records.compactMap(BiometricCredentialRecord.init(jsonObject:)), Self.fixtureRecords) + } + + func testExpectedRecordsEncodeToV1Fixture() throws { + var records: [[String: Any]] = [] + for record in Self.fixtureRecords { + records = BiometricCredentialRecordList.saving(record, into: records).records + } + + let stored = try XCTUnwrap(BiometricCredentialRecordList.encode(records)) + let storedJSON = try JSONSerialization.jsonObject(with: stored) as? NSArray + let fixtureJSON = try JSONSerialization.jsonObject(with: Self.fixtureData()) as? NSArray + XCTAssertNotNil(storedJSON) + XCTAssertEqual(storedJSON, fixtureJSON) + } + + func testRecordFieldNamesAndIntegerMilliseconds() throws { + let data = try XCTUnwrap(BiometricCredentialRecordList.encode(Self.fixtureRecords.prefix(2).map(\.jsonObject))) + let records = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [[String: Any]]) + + XCTAssertEqual(Set(records[0].keys), ["id", "localKeyId", "userId", "appIdentifier", "identifierHint", "policy", "createdAt", "updatedAt"]) + XCTAssertEqual(Set(records[1].keys), ["id", "localKeyId", "userId", "appIdentifier", "policy", "createdAt", "updatedAt"]) + XCTAssertEqual((records[0]["createdAt"] as? NSNumber)?.int64Value, 1_714_000_000_500) + XCTAssertTrue(String(decoding: data, as: UTF8.self).contains(#""createdAt":1714000000500"#)) + } + + func testReadersIgnoreUnknownFieldsAndNormalizeIdentifierHints() throws { + let json = """ + [{"id":"tdc_future","localKeyId":"tdlk_future","userId":"user_1","appIdentifier":"com.clerk.example",\ + "identifierHint":" User@Example.COM ","policy":"biometry_any","createdAt":1714000000000.25,\ + "updatedAt":1714000000000,"futureField":{"nested":true}}] + """ + let listed = BiometricCredentialRecordList.listable(try BiometricCredentialRecordList.decode(Data(json.utf8))) + + XCTAssertEqual(listed.count, 1) + XCTAssertEqual(listed[0]["identifierHint"] as? String, "user@example.com") + XCTAssertEqual(listed[0]["futureField"] as? [String: Bool], ["nested": true]) + XCTAssertEqual((listed[0]["createdAt"] as? NSNumber)?.doubleValue, 1_714_000_000_000.25) + } + + func testInstallationMarkerKeyMatchesContractFormat() { + XCTAssertEqual( + BiometricCredentialCoding.installationMarkerKey(service: "com.clerk.example", accessGroup: nil, appIdentifier: "com.clerk.example"), + "com.clerk.trusted-device-installation-marker.s17:com.clerk.example.n.s17:com.clerk.example" + ) + XCTAssertEqual( + BiometricCredentialCoding.installationMarkerKey(service: "com.clerk.é", accessGroup: "TEAMID.com.clerk.shared", appIdentifier: "com.clerk.example"), + "com.clerk.trusted-device-installation-marker.s12:com.clerk.é.s23:TEAMID.com.clerk.shared.s17:com.clerk.example" + ) + XCTAssertEqual( + BiometricCredentialCoding.installationMarkerKey(service: "com.clerk.example", accessGroup: "", appIdentifier: "com.clerk.example"), + "com.clerk.trusted-device-installation-marker.s17:com.clerk.example.s0:.s17:com.clerk.example" + ) + } + + static let fixtureRecords: [BiometricCredentialRecord] = [ + BiometricCredentialRecord( + id: "tdc_contract_current_set", + localKeyId: "tdlk_0123456789abcdef0123456789abcdef", + userId: "user_contract_1", + appIdentifier: "com.clerk.example", + identifierHint: "user@example.com", + policy: .biometryCurrentSet, + createdAt: 1_714_000_000_500, + updatedAt: 1_714_000_001_500 + ), + BiometricCredentialRecord( + id: "tdc_contract_any", + localKeyId: "tdlk_fedcba9876543210fedcba9876543210", + userId: "user_contract_2", + appIdentifier: "com.clerk.example", + identifierHint: nil, + policy: .biometryAny, + createdAt: 1_714_000_002_000, + updatedAt: 1_714_000_003_000 + ), + BiometricCredentialRecord( + id: "tdc_contract_passcode", + localKeyId: "tdlk_00000000000000000000000000000000", + userId: "user_contract_1", + appIdentifier: "com.clerk.other", + identifierHint: "+15555550100", + policy: .biometryOrDevicePasscode, + createdAt: 1_714_000_004_000, + updatedAt: 1_714_000_004_000 + ), + ] + + static func fixtureData() throws -> Data { + let url = try XCTUnwrap( + Bundle(for: BiometricCredentialContractTests.self).url(forResource: "BiometricCredentialStorageContractV1", withExtension: "json") + ) + return try Data(contentsOf: url) + } +} diff --git a/packages/expo-biometrics/ios/Tests/BiometricCredentialStoreTests.swift b/packages/expo-biometrics/ios/Tests/BiometricCredentialStoreTests.swift new file mode 100644 index 00000000000..63efe800fdb --- /dev/null +++ b/packages/expo-biometrics/ios/Tests/BiometricCredentialStoreTests.swift @@ -0,0 +1,320 @@ +import Foundation +import Security +import XCTest +@testable import ClerkExpoBiometrics + +final class BiometricCredentialStoreTests: XCTestCase { + private final class InMemoryStorage: BiometricCredentialMetadataStorage { + var data: Data? + var writes = 0 + var deletes = 0 + + func read() throws -> Data? { data } + func write(_ data: Data) throws { + writes += 1 + self.data = data + } + + func delete() throws { + deletes += 1 + data = nil + } + + func records() throws -> [[String: Any]] { + try BiometricCredentialRecordList.decode(data) + } + + func ids() throws -> [String?] { + try records().map { $0["id"] as? String } + } + } + + private var storage: InMemoryStorage! + private var userDefaults: UserDefaults! + private var suiteName: String! + private var deletedKeys: [String] = [] + private var failingKeys: Set = [] + + override func setUp() { + super.setUp() + storage = InMemoryStorage() + suiteName = "ClerkExpoBiometricsTests.\(UUID().uuidString)" + userDefaults = UserDefaults(suiteName: suiteName) + deletedKeys = [] + failingKeys = [] + } + + override func tearDown() { + userDefaults.removePersistentDomain(forName: suiteName) + super.tearDown() + } + + private func makeStore(appIdentifier: String? = "com.clerk.example") -> BiometricCredentialStore { + BiometricCredentialStore( + storage: storage, + deleteKey: { [unowned self] localKeyId in + if failingKeys.contains(localKeyId) { + throw BiometricsError(.storageFailed, "delete failed") + } + deletedKeys.append(localKeyId) + }, + userDefaults: userDefaults, + service: "com.clerk.example", + appIdentifier: appIdentifier + ) + } + + private let markerKey = "com.clerk.trusted-device-installation-marker.s17:com.clerk.example.n.s17:com.clerk.example" + + private func setMarker() { + userDefaults.set(true, forKey: markerKey) + } + + private func record( + id: String, + localKeyId: String? = nil, + userId: String = "user_1", + appIdentifier: String = "com.clerk.example", + identifierHint: String? = nil + ) -> BiometricCredentialRecord { + BiometricCredentialRecord( + id: id, + localKeyId: localKeyId ?? "tdlk_\(id)", + userId: userId, + appIdentifier: appIdentifier, + identifierHint: identifierHint, + policy: .biometryCurrentSet, + createdAt: 1_714_000_000_000, + updatedAt: 1_714_000_000_000 + ) + } + + // MARK: - Reinstall marker + + func testMissingMarkerWipesOnlyThisAppsRecordsAndKeysThenSetsMarker() throws { + storage.data = try BiometricCredentialContractTests.fixtureData() + let store = makeStore() + + XCTAssertEqual(store.installationMarkerKey, markerKey) + XCTAssertTrue(try store.ensureInstallationMarker()) + + XCTAssertEqual(deletedKeys, ["tdlk_0123456789abcdef0123456789abcdef", "tdlk_fedcba9876543210fedcba9876543210"]) + XCTAssertEqual(try storage.ids(), ["tdc_contract_passcode"]) + XCTAssertEqual(userDefaults.object(forKey: markerKey) as? Bool, true) + XCTAssertEqual(UserDefaults.standard.object(forKey: markerKey) as? Bool, nil) + } + + func testMarkerIsIdempotent() throws { + let store = makeStore() + + XCTAssertTrue(try store.ensureInstallationMarker()) + storage.data = try BiometricCredentialContractTests.fixtureData() + XCTAssertFalse(try store.ensureInstallationMarker()) + + XCTAssertEqual(deletedKeys, []) + XCTAssertEqual(try storage.records().count, 3) + } + + func testMarkerIsNotSetWhenAKeyCannotBeDeleted() throws { + storage.data = try BiometricCredentialContractTests.fixtureData() + failingKeys = ["tdlk_fedcba9876543210fedcba9876543210"] + let store = makeStore() + + XCTAssertThrowsError(try store.ensureInstallationMarker()) + + XCTAssertEqual(try storage.ids(), ["tdc_contract_any", "tdc_contract_passcode"]) + XCTAssertNil(userDefaults.object(forKey: markerKey)) + + failingKeys = [] + XCTAssertTrue(try store.ensureInstallationMarker()) + XCTAssertEqual(try storage.ids(), ["tdc_contract_passcode"]) + } + + func testMarkerWipesMalformedRecordsForThisApp() throws { + storage.data = Data(#"[{"appIdentifier":"com.clerk.example","localKeyId":"tdlk_bad"},{"appIdentifier":"com.clerk.example"}]"#.utf8) + + XCTAssertTrue(try makeStore().ensureInstallationMarker()) + + XCTAssertEqual(deletedKeys, ["tdlk_bad"]) + XCTAssertNil(storage.data) + XCTAssertEqual(storage.deletes, 1) + } + + func testMarkerIsNotSetWhenMetadataIsNotAnArray() { + storage.data = Data(#"{"id":"tdc_1"}"#.utf8) + + XCTAssertThrowsError(try makeStore().ensureInstallationMarker()) { error in + XCTAssertEqual((error as? BiometricsError)?.code, .storageFailed) + } + XCTAssertNil(userDefaults.object(forKey: markerKey)) + } + + func testMissingAppIdentifierSkipsTheMarker() throws { + storage.data = try BiometricCredentialContractTests.fixtureData() + + XCTAssertFalse(try makeStore(appIdentifier: nil).ensureInstallationMarker()) + XCTAssertEqual(try storage.records().count, 3) + } + + func testStoreOperationsApplyTheMarkerFirst() throws { + storage.data = try BiometricCredentialContractTests.fixtureData() + let store = makeStore() + + let listed = try JSONSerialization.jsonObject(with: Data(store.listRecordsJSON().utf8)) as? [[String: Any]] + + XCTAssertEqual(listed?.map { $0["id"] as? String }, ["tdc_contract_passcode"]) + XCTAssertEqual(userDefaults.object(forKey: markerKey) as? Bool, true) + } + + // MARK: - List + + func testListReturnsWellFormedRecordsForEveryAppWithUnknownFields() throws { + setMarker() + storage.data = Data(""" + [{"id":"tdc_1","localKeyId":"tdlk_1","userId":"user_1","appIdentifier":"com.clerk.example","identifierHint":" A@B.co ",\ + "policy":"biometry_any","createdAt":1714000000000,"updatedAt":1714000000001,"futureField":[1,true,null]},\ + {"id":"tdc_2","localKeyId":"tdlk_2","userId":"user_2","appIdentifier":"com.clerk.other","identifierHint":" ",\ + "policy":"biometry_current_set","createdAt":1714000000000,"updatedAt":1714000000000},\ + {"id":"tdc_bad_policy","localKeyId":"tdlk_3","userId":"user_1","appIdentifier":"com.clerk.example",\ + "policy":"face","createdAt":1,"updatedAt":1},\ + {"id":"tdc_bool_date","localKeyId":"tdlk_4","userId":"user_1","appIdentifier":"com.clerk.example",\ + "policy":"biometry_any","createdAt":true,"updatedAt":1},\ + {"id":"tdc_missing","userId":"user_1","appIdentifier":"com.clerk.example","policy":"biometry_any","createdAt":1,"updatedAt":1},\ + {"id":"tdc_hint_number","localKeyId":"tdlk_5","userId":"user_1","appIdentifier":"com.clerk.example",\ + "identifierHint":5,"policy":"biometry_any","createdAt":1,"updatedAt":1},\ + {"id":"tdc_null_hint","localKeyId":"tdlk_6","userId":"user_1","appIdentifier":"com.clerk.example",\ + "identifierHint":null,"policy":"biometry_or_device_passcode","createdAt":1,"updatedAt":1}] + """.utf8) + + let json = try makeStore().listRecordsJSON() + let listed = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [[String: Any]]) + + XCTAssertEqual(listed.map { $0["id"] as? String }, ["tdc_1", "tdc_2", "tdc_null_hint"]) + XCTAssertEqual(listed[0]["identifierHint"] as? String, "a@b.co") + XCTAssertNil(listed[1]["identifierHint"]) + XCTAssertNil(listed[2]["identifierHint"]) + XCTAssertTrue(json.contains(#""futureField":[1,true,null]"#)) + XCTAssertTrue(json.contains(#""updatedAt":1714000000001"#)) + } + + func testListIsEmptyWithoutAnItem() throws { + setMarker() + XCTAssertEqual(try makeStore().listRecordsJSON(), "[]") + } + + // MARK: - Save + + func testSaveAddsRecordAndPreservesOtherAppsAndUnknownFields() throws { + setMarker() + storage.data = Data(""" + [{"id":"tdc_other","localKeyId":"tdlk_other","userId":"user_1","appIdentifier":"com.clerk.other",\ + "policy":"biometry_any","createdAt":1714000000000,"updatedAt":1714000000000,"futureField":"kept"},\ + {"id":"tdc_same","localKeyId":"tdlk_same","userId":"user_2","appIdentifier":"com.clerk.example",\ + "policy":"biometry_any","createdAt":1714000000000,"updatedAt":1714000000000,"futureField":"also kept"}] + """.utf8) + + try makeStore().save(record(id: "tdc_new", identifierHint: " New@Example.com"), removeOtherRecordsForApp: false) + + let records = try storage.records() + XCTAssertEqual(records.map { $0["id"] as? String }, ["tdc_other", "tdc_same", "tdc_new"]) + XCTAssertEqual(records[0]["futureField"] as? String, "kept") + XCTAssertEqual(records[1]["futureField"] as? String, "also kept") + XCTAssertEqual(records[2]["identifierHint"] as? String, "new@example.com") + XCTAssertEqual(deletedKeys, []) + } + + func testSaveReplacesSameIdAndDeletesItsOldKey() throws { + setMarker() + let store = makeStore() + try store.save(record(id: "tdc_1", localKeyId: "tdlk_old"), removeOtherRecordsForApp: false) + + try store.save(record(id: "tdc_1", localKeyId: "tdlk_new"), removeOtherRecordsForApp: false) + + let records = try storage.records() + XCTAssertEqual(records.count, 1) + XCTAssertEqual(records[0]["localKeyId"] as? String, "tdlk_new") + XCTAssertEqual(deletedKeys, ["tdlk_old"]) + } + + func testSaveDropsMalformedRecordsForTheSameAppOnly() throws { + setMarker() + storage.data = Data(""" + [{"id":"tdc_bad","appIdentifier":"com.clerk.example"},{"id":"tdc_bad_other","appIdentifier":"com.clerk.other"}] + """.utf8) + + try makeStore().save(record(id: "tdc_1"), removeOtherRecordsForApp: false) + + XCTAssertEqual(try storage.ids(), ["tdc_bad_other", "tdc_1"]) + XCTAssertEqual(deletedKeys, []) + } + + func testSaveRemovingOtherRecordsDeletesThisAppsOtherRecordsAndKeys() throws { + setMarker() + let store = makeStore() + try store.save(record(id: "tdc_a", userId: "user_1"), removeOtherRecordsForApp: false) + try store.save(record(id: "tdc_b", userId: "user_2"), removeOtherRecordsForApp: false) + try store.save(record(id: "tdc_other_app", appIdentifier: "com.clerk.other"), removeOtherRecordsForApp: false) + failingKeys = ["tdlk_tdc_b"] + + try store.save(record(id: "tdc_new"), removeOtherRecordsForApp: true) + + XCTAssertEqual(deletedKeys, ["tdlk_tdc_a"]) + XCTAssertEqual(try storage.ids(), ["tdc_b", "tdc_other_app", "tdc_new"]) + } + + func testSaveAppliesTheMarkerBeforeWriting() throws { + storage.data = try BiometricCredentialContractTests.fixtureData() + + try makeStore().save(record(id: "tdc_new"), removeOtherRecordsForApp: false) + + XCTAssertEqual(try storage.ids(), ["tdc_contract_passcode", "tdc_new"]) + XCTAssertEqual(userDefaults.object(forKey: markerKey) as? Bool, true) + } + + func testSaveFailsWithoutWritingWhenTheMarkerCannotBeSet() throws { + storage.data = try BiometricCredentialContractTests.fixtureData() + failingKeys = ["tdlk_0123456789abcdef0123456789abcdef"] + + XCTAssertThrowsError(try makeStore().save(record(id: "tdc_new"), removeOtherRecordsForApp: false)) + + XCTAssertFalse(try storage.ids().contains("tdc_new")) + } + + // MARK: - Delete + + func testDeleteRecordsDeletesKeyThenRecordsAndDropsTheItemWhenEmpty() throws { + setMarker() + let store = makeStore() + try store.save(record(id: "tdc_1", localKeyId: "tdlk_1"), removeOtherRecordsForApp: false) + + try store.deleteRecords(localKeyId: "tdlk_1") + + XCTAssertEqual(deletedKeys, ["tdlk_1"]) + XCTAssertNil(storage.data) + XCTAssertEqual(storage.deletes, 1) + } + + func testDeleteRecordsKeepsRecordsWhenTheKeyCannotBeDeleted() throws { + setMarker() + let store = makeStore() + try store.save(record(id: "tdc_1", localKeyId: "tdlk_1"), removeOtherRecordsForApp: false) + failingKeys = ["tdlk_1"] + + XCTAssertThrowsError(try store.deleteRecords(localKeyId: "tdlk_1")) + + XCTAssertEqual(try storage.ids(), ["tdc_1"]) + } + + func testDeleteRecordsPreservesUnknownFieldsOnOtherRecords() throws { + setMarker() + storage.data = Data(""" + [{"id":"tdc_1","localKeyId":"tdlk_1","futureField":"kept"},{"id":"tdc_2","localKeyId":"tdlk_2"}] + """.utf8) + + try makeStore().deleteRecords(localKeyId: "tdlk_2") + + let records = try storage.records() + XCTAssertEqual(records.count, 1) + XCTAssertEqual(records[0]["futureField"] as? String, "kept") + } +} diff --git a/packages/expo-biometrics/ios/Tests/BiometricSystemIntegrationTests.swift b/packages/expo-biometrics/ios/Tests/BiometricSystemIntegrationTests.swift new file mode 100644 index 00000000000..06d43ec04bb --- /dev/null +++ b/packages/expo-biometrics/ios/Tests/BiometricSystemIntegrationTests.swift @@ -0,0 +1,146 @@ +import Foundation +import LocalAuthentication +import Security +import XCTest +@testable import ClerkExpoBiometrics + +/// Skips tests that need the real Keychain when the test bundle runs without a host app. +func skipUnlessKeychainIsAvailable() throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "ClerkExpoBiometricsTests.probe", + ] + let status = SecItemCopyMatching(query as CFDictionary, nil) + try XCTSkipIf(status == errSecMissingEntitlement, "The Keychain is only available to tests running in a host app.") +} + +final class KeychainMetadataStorageTests: XCTestCase { + private var storage: KeychainMetadataStorage! + + override func setUpWithError() throws { + try super.setUpWithError() + try skipUnlessKeychainIsAvailable() + storage = KeychainMetadataStorage(service: "ClerkExpoBiometricsTests.\(UUID().uuidString)") + } + + override func tearDownWithError() throws { + try storage?.delete() + try super.tearDownWithError() + } + + func testWriteAddsThenUpdatesTheGenericPasswordItem() throws { + XCTAssertNil(try storage.read()) + + try storage.write(Data("[1]".utf8)) + try storage.write(Data("[2]".utf8)) + + XCTAssertEqual(try storage.read(), Data("[2]".utf8)) + + var query = storage.baseQuery() + query[kSecReturnAttributes as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitAll + var result: CFTypeRef? + XCTAssertEqual(SecItemCopyMatching(query as CFDictionary, &result), errSecSuccess) + let items = try XCTUnwrap(result as? [[String: Any]]) + XCTAssertEqual(items.count, 1) + XCTAssertEqual(items[0][kSecAttrAccessible as String] as? String, kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String) + XCTAssertEqual(items[0][kSecAttrAccount as String] as? String, "trustedDeviceCredentials") + } + + func testDeleteIsIdempotent() throws { + try storage.write(Data("[]".utf8)) + try storage.delete() + try storage.delete() + + XCTAssertNil(try storage.read()) + } +} + +final class BiometricKeyManagerTests: XCTestCase { + func testMissingKeyIsReportedWithoutPrompting() throws { + try skipUnlessKeychainIsAvailable() + let keyManager = BiometricKeyManager() + let localKeyId = BiometricCredentialCoding.makeLocalKeyId() + + XCTAssertFalse(try keyManager.hasKey(localKeyId: localKeyId)) + XCTAssertNoThrow(try keyManager.deleteKey(localKeyId: localKeyId)) + XCTAssertThrowsError(try keyManager.sign(localKeyId: localKeyId, clientData: "data", reason: "Sign in")) { error in + XCTAssertEqual((error as? BiometricsError)?.code, .keyNotFound) + } + } + + func testAvailabilityReportsAKnownBiometryType() { + let availability = BiometricKeyManager().availability() + + XCTAssertTrue(["faceID", "touchID", "opticID", "none"].contains(availability.biometryType)) + XCTAssertEqual(availability.errorCode == nil, availability.canEvaluateBiometrics) + } + + func testErrorMapping() { + XCTAssertEqual(BiometricsError.code(forLAErrorCode: LAError.Code.userCancel.rawValue), .userCanceled) + XCTAssertEqual(BiometricsError.code(forLAErrorCode: LAError.Code.systemCancel.rawValue), .systemCanceled) + XCTAssertEqual(BiometricsError.code(forLAErrorCode: LAError.Code.appCancel.rawValue), .systemCanceled) + XCTAssertEqual(BiometricsError.code(forLAErrorCode: LAError.Code.userFallback.rawValue), .userFallback) + XCTAssertEqual(BiometricsError.code(forLAErrorCode: LAError.Code.authenticationFailed.rawValue), .authenticationFailed) + XCTAssertEqual(BiometricsError.code(forLAErrorCode: LAError.Code.biometryNotAvailable.rawValue), .biometryNotAvailable) + XCTAssertEqual(BiometricsError.code(forLAErrorCode: LAError.Code.biometryNotEnrolled.rawValue), .biometryNotEnrolled) + XCTAssertEqual(BiometricsError.code(forLAErrorCode: LAError.Code.biometryLockout.rawValue), .biometryLockout) + XCTAssertEqual(BiometricsError.code(forLAErrorCode: LAError.Code.passcodeNotSet.rawValue), .passcodeNotSet) + + XCTAssertEqual(BiometricsError.code(forStatus: errSecUserCanceled), .userCanceled) + XCTAssertEqual(BiometricsError.code(forStatus: errSecAuthFailed), .authenticationFailed) + XCTAssertEqual(BiometricsError.code(forStatus: errSecInteractionNotAllowed), .biometryNotAvailable) + XCTAssertEqual(BiometricsError.code(forStatus: errSecItemNotFound), .keyNotFound) + XCTAssertNil(BiometricsError.code(forStatus: errSecParam)) + + let laError = NSError(domain: LAErrorDomain, code: LAError.Code.biometryLockout.rawValue) + XCTAssertEqual(BiometricsError.security(laError, fallback: .signingFailed).code, .biometryLockout) + XCTAssertEqual(BiometricsError.localAuthentication(laError, fallback: .biometryNotAvailable).code, .biometryLockout) + XCTAssertEqual(BiometricsError.localAuthentication(nil, fallback: .biometryNotAvailable).code, .biometryNotAvailable) + let statusError = NSError(domain: NSOSStatusErrorDomain, code: Int(errSecUserCanceled)) + XCTAssertEqual(BiometricsError.security(statusError, fallback: .signingFailed).code, .userCanceled) + let tokenError = NSError(domain: "CryptoTokenKit", code: -6) + XCTAssertEqual(BiometricsError.security(tokenError, fallback: .signingFailed).code, .keyInvalidated) + let unknownError = NSError(domain: "Other", code: 1) + XCTAssertEqual(BiometricsError.security(unknownError, fallback: .signingFailed).code, .signingFailed) + } + + // Record fields are reference-backed, so each case builds a fresh input. + private func makeInput(_ configure: (BiometricCredentialRecordInput) -> Void = { _ in }) -> BiometricCredentialRecordInput { + let input = BiometricCredentialRecordInput() + input.id = "tdc_1" + input.localKeyId = "tdlk_1" + input.userId = "user_1" + input.appIdentifier = "com.clerk.example" + input.identifierHint = " Hint " + input.policy = "biometry_any" + input.createdAt = 1_714_000_000_000.4 + input.updatedAt = 1_714_000_000_001 + configure(input) + return input + } + + func testRecordInputValidation() throws { + let record = try ClerkExpoBiometricsModule.record(from: makeInput()) + XCTAssertEqual(record.identifierHint, "hint") + XCTAssertEqual(record.policy, .biometryAny) + XCTAssertEqual(record.jsonObject["createdAt"] as? Int64, 1_714_000_000_000) + XCTAssertEqual(record.jsonObject["updatedAt"] as? Int64, 1_714_000_000_001) + + let invalidInputs: [(String, (BiometricCredentialRecordInput) -> Void)] = [ + ("policy", { $0.policy = "face_id" }), + ("id", { $0.id = "" }), + ("localKeyId", { $0.localKeyId = "" }), + ("userId", { $0.userId = "" }), + ("appIdentifier", { $0.appIdentifier = "" }), + ("nan createdAt", { $0.createdAt = .nan }), + ("negative createdAt", { $0.createdAt = -1 }), + ("infinite updatedAt", { $0.updatedAt = .infinity }), + ] + for (name, configure) in invalidInputs { + XCTAssertThrowsError(try ClerkExpoBiometricsModule.record(from: makeInput(configure)), name) { error in + XCTAssertEqual((error as? BiometricsError)?.code, .invalidArgument, name) + } + } + } +} diff --git a/packages/expo-biometrics/ios/Tests/Resources/BiometricCredentialStorageContractV1.json b/packages/expo-biometrics/ios/Tests/Resources/BiometricCredentialStorageContractV1.json new file mode 100644 index 00000000000..0bbdf1f1a63 --- /dev/null +++ b/packages/expo-biometrics/ios/Tests/Resources/BiometricCredentialStorageContractV1.json @@ -0,0 +1,31 @@ +[ + { + "id": "tdc_contract_current_set", + "localKeyId": "tdlk_0123456789abcdef0123456789abcdef", + "userId": "user_contract_1", + "appIdentifier": "com.clerk.example", + "identifierHint": "user@example.com", + "policy": "biometry_current_set", + "createdAt": 1714000000500, + "updatedAt": 1714000001500 + }, + { + "id": "tdc_contract_any", + "localKeyId": "tdlk_fedcba9876543210fedcba9876543210", + "userId": "user_contract_2", + "appIdentifier": "com.clerk.example", + "policy": "biometry_any", + "createdAt": 1714000002000, + "updatedAt": 1714000003000 + }, + { + "id": "tdc_contract_passcode", + "localKeyId": "tdlk_00000000000000000000000000000000", + "userId": "user_contract_1", + "appIdentifier": "com.clerk.other", + "identifierHint": "+15555550100", + "policy": "biometry_or_device_passcode", + "createdAt": 1714000004000, + "updatedAt": 1714000004000 + } +] diff --git a/packages/expo-biometrics/package.json b/packages/expo-biometrics/package.json new file mode 100644 index 00000000000..759bbe24955 --- /dev/null +++ b/packages/expo-biometrics/package.json @@ -0,0 +1,53 @@ +{ + "name": "@clerk/expo-biometrics", + "version": "0.0.0", + "description": "Native biometric credential module to be used with Clerk for Expo", + "keywords": [ + "react-native", + "expo", + "biometrics", + "face-id", + "touch-id", + "ClerkExpoBiometrics", + "clerk" + ], + "homepage": "https://clerk.com/", + "bugs": { + "url": "https://github.com/clerk/javascript/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/clerk/javascript.git", + "directory": "packages/expo-biometrics" + }, + "license": "MIT", + "author": "Clerk", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist", + "android", + "ios", + "expo-module.config.json" + ], + "scripts": { + "build": "tsup", + "build:declarations": "tsc -p tsconfig.declarations.json", + "clean": "rimraf ./dist", + "dev": "tsup --watch", + "format": "node ../../scripts/format-package.mjs", + "format:check": "node ../../scripts/format-package.mjs --check", + "lint": "eslint src", + "test": "vitest run", + "test:watch": "vitest watch" + }, + "devDependencies": { + "expo": "~54.0.36" + }, + "peerDependencies": { + "expo": "catalog:peer-expo" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/expo-biometrics/src/ClerkExpoBiometricsModule.ts b/packages/expo-biometrics/src/ClerkExpoBiometricsModule.ts new file mode 100644 index 00000000000..a9adc9aafc8 --- /dev/null +++ b/packages/expo-biometrics/src/ClerkExpoBiometricsModule.ts @@ -0,0 +1,26 @@ +import { requireOptionalNativeModule } from 'expo'; + +import type { + BiometricAvailability, + BiometricCredentialKey, + BiometricCredentialPolicy, + BiometricCredentialRecord, + InstallationMarkerResult, + SaveRecordOptions, +} from './types'; + +export interface ClerkExpoBiometricsNativeModule { + getAppIdentifier(): string; + getAvailability(): Promise; + createKey(policy: BiometricCredentialPolicy): Promise; + sign(localKeyId: string, clientData: string, reason: string | null): Promise; + hasKey(localKeyId: string): Promise; + deleteKey(localKeyId: string): Promise; + /** JSON-encoded array of records, so fields the bridge cannot represent survive unchanged. */ + listRecords(): Promise; + saveRecord(record: BiometricCredentialRecord, options: SaveRecordOptions): Promise; + deleteRecord(localKeyId: string): Promise; + ensureInstallationMarker(): Promise; +} + +export default requireOptionalNativeModule('ClerkExpoBiometrics'); diff --git a/packages/expo-biometrics/src/__tests__/index.test.ts b/packages/expo-biometrics/src/__tests__/index.test.ts new file mode 100644 index 00000000000..fb78fe7178b --- /dev/null +++ b/packages/expo-biometrics/src/__tests__/index.test.ts @@ -0,0 +1,272 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import type { BiometricCredentialRecord } from '../types'; + +const mocks = vi.hoisted(() => ({ + nativeModule: null as Record> | null, + requestedName: null as string | null, +})); + +vi.mock('expo', () => ({ + requireOptionalNativeModule: (name: string) => { + mocks.requestedName = name; + return mocks.nativeModule; + }, +})); + +const createNativeModule = () => ({ + getAppIdentifier: vi.fn().mockReturnValue('com.clerk.example'), + getAvailability: vi.fn().mockResolvedValue({ + biometryType: 'faceID', + canEvaluateBiometrics: true, + canEvaluateDeviceOwner: true, + errorCode: null, + }), + createKey: vi.fn().mockResolvedValue({ localKeyId: 'tdlk_1', publicKeyJwk: '{"kty":"EC"}' }), + sign: vi.fn().mockResolvedValue('c2lnbmF0dXJl'), + hasKey: vi.fn().mockResolvedValue(true), + deleteKey: vi.fn().mockResolvedValue(undefined), + listRecords: vi.fn().mockResolvedValue('[]'), + saveRecord: vi.fn().mockResolvedValue(undefined), + deleteRecord: vi.fn().mockResolvedValue(undefined), + ensureInstallationMarker: vi.fn().mockResolvedValue({ wiped: false }), +}); + +const nativeError = (code: string, message = 'native failure') => Object.assign(new Error(message), { code }); + +const record: BiometricCredentialRecord = { + id: 'tdc_1', + localKeyId: 'tdlk_1', + userId: 'user_1', + appIdentifier: 'com.clerk.example', + identifierHint: 'user@example.com', + policy: 'biometry_current_set', + createdAt: 1714000000500, + updatedAt: 1714000001500, +}; + +const load = async () => import('../index.js'); + +describe('@clerk/expo-biometrics', () => { + let native: ReturnType; + + beforeEach(() => { + vi.resetModules(); + native = createNativeModule(); + mocks.nativeModule = native; + mocks.requestedName = null; + }); + + test('loads the ClerkExpoBiometrics native module', async () => { + const biometrics = await load(); + + expect(mocks.requestedName).toBe('ClerkExpoBiometrics'); + expect(biometrics.getAppIdentifier()).toBe('com.clerk.example'); + await expect(biometrics.getAvailability()).resolves.toEqual({ + biometryType: 'faceID', + canEvaluateBiometrics: true, + canEvaluateDeviceOwner: true, + errorCode: null, + }); + }); + + test('rejects with native_module_unavailable when the native module is missing', async () => { + mocks.nativeModule = null; + const biometrics = await load(); + + expect(() => biometrics.getAppIdentifier()).toThrow( + expect.objectContaining({ name: 'ClerkBiometricsError', code: 'native_module_unavailable' }), + ); + await expect(biometrics.getAvailability()).rejects.toMatchObject({ code: 'native_module_unavailable' }); + await expect(biometrics.listRecords()).rejects.toMatchObject({ code: 'native_module_unavailable' }); + }); + + describe('keys', () => { + test('createKey forwards the policy', async () => { + const biometrics = await load(); + + await expect(biometrics.createKey('biometry_or_device_passcode')).resolves.toEqual({ + localKeyId: 'tdlk_1', + publicKeyJwk: '{"kty":"EC"}', + }); + expect(native.createKey).toHaveBeenCalledWith('biometry_or_device_passcode'); + }); + + test('createKey rejects unknown policies without calling native', async () => { + const biometrics = await load(); + + // @ts-expect-error testing an invalid policy + await expect(biometrics.createKey('face_id')).rejects.toMatchObject({ code: 'invalid_argument' }); + expect(native.createKey).not.toHaveBeenCalled(); + }); + + test('sign forwards arguments and defaults the reason to null', async () => { + const biometrics = await load(); + + await expect(biometrics.sign('tdlk_1', 'client-data', 'Sign in')).resolves.toBe('c2lnbmF0dXJl'); + await biometrics.sign('tdlk_1', 'client-data'); + + expect(native.sign).toHaveBeenNthCalledWith(1, 'tdlk_1', 'client-data', 'Sign in'); + expect(native.sign).toHaveBeenNthCalledWith(2, 'tdlk_1', 'client-data', null); + }); + + test('sign validates its arguments', async () => { + const biometrics = await load(); + + await expect(biometrics.sign('', 'client-data')).rejects.toMatchObject({ code: 'invalid_argument' }); + // @ts-expect-error testing a non-string client data + await expect(biometrics.sign('tdlk_1', 42)).rejects.toMatchObject({ code: 'invalid_argument' }); + expect(native.sign).not.toHaveBeenCalled(); + }); + + test('hasKey and deleteKey forward the local key id', async () => { + const biometrics = await load(); + + await expect(biometrics.hasKey('tdlk_1')).resolves.toBe(true); + await expect(biometrics.deleteKey('tdlk_1')).resolves.toBeUndefined(); + expect(native.hasKey).toHaveBeenCalledWith('tdlk_1'); + expect(native.deleteKey).toHaveBeenCalledWith('tdlk_1'); + }); + }); + + describe('errors', () => { + test.each([ + 'user_canceled', + 'system_canceled', + 'user_fallback', + 'authentication_failed', + 'biometry_not_available', + 'biometry_not_enrolled', + 'biometry_lockout', + 'passcode_not_set', + 'key_not_found', + 'key_invalidated', + 'key_generation_failed', + 'signing_failed', + 'storage_failed', + 'not_implemented', + ])('preserves the native %s code', async code => { + native.sign.mockRejectedValueOnce(nativeError(code, 'details')); + const biometrics = await load(); + + const error = await biometrics.sign('tdlk_1', 'client-data').catch((e: unknown) => e); + + expect(biometrics.isClerkBiometricsError(error)).toBe(true); + expect(error).toMatchObject({ code, message: 'details' }); + expect((error as Error).cause).toBeInstanceOf(Error); + }); + + test('maps unrecognized native codes to unknown', async () => { + native.hasKey.mockRejectedValueOnce(nativeError('ERR_ARGUMENT_CAST')); + const biometrics = await load(); + + await expect(biometrics.hasKey('tdlk_1')).rejects.toMatchObject({ code: 'unknown' }); + }); + + test('wraps synchronous native errors', async () => { + native.getAppIdentifier.mockImplementationOnce(() => { + throw nativeError('not_implemented'); + }); + const biometrics = await load(); + + expect(() => biometrics.getAppIdentifier()).toThrow(expect.objectContaining({ code: 'not_implemented' })); + }); + + test('isBiometricsErrorCode recognizes known codes', async () => { + const { isBiometricsErrorCode } = await load(); + + expect(isBiometricsErrorCode('biometry_lockout')).toBe(true); + expect(isBiometricsErrorCode('ERR_UNKNOWN')).toBe(false); + expect(isBiometricsErrorCode(undefined)).toBe(false); + }); + }); + + describe('store', () => { + test('listRecords parses native JSON and passes unknown fields through', async () => { + native.listRecords.mockResolvedValueOnce( + JSON.stringify([{ ...record, futureField: { nested: [1, true, null] } }]), + ); + const biometrics = await load(); + + const records = await biometrics.listRecords(); + + expect(records).toEqual([{ ...record, futureField: { nested: [1, true, null] } }]); + }); + + test('listRecords rejects with storage_failed on invalid native output', async () => { + native.listRecords.mockResolvedValueOnce('not json'); + native.listRecords.mockResolvedValueOnce('{}'); + const biometrics = await load(); + + await expect(biometrics.listRecords()).rejects.toMatchObject({ code: 'storage_failed' }); + await expect(biometrics.listRecords()).rejects.toMatchObject({ code: 'storage_failed' }); + }); + + test('saveRecord sends only contract fields', async () => { + const biometrics = await load(); + + await biometrics.saveRecord({ ...record, extra: 'dropped' } as BiometricCredentialRecord, { + removeOtherRecordsForApp: true, + }); + + expect(native.saveRecord).toHaveBeenCalledWith(record, { removeOtherRecordsForApp: true }); + }); + + test('saveRecord omits an absent identifier hint', async () => { + const biometrics = await load(); + const { identifierHint: _, ...withoutHint } = record; + + await biometrics.saveRecord(withoutHint, { removeOtherRecordsForApp: false }); + + expect(native.saveRecord.mock.calls[0][0]).not.toHaveProperty('identifierHint'); + }); + + test.each<[string, Partial>]>([ + ['an empty id', { id: '' }], + ['a missing localKeyId', { localKeyId: undefined }], + ['a non-string userId', { userId: 1 }], + ['an empty appIdentifier', { appIdentifier: '' }], + ['a non-string identifierHint', { identifierHint: 1 }], + ['an unknown policy', { policy: 'face_id' }], + ['a negative createdAt', { createdAt: -1 }], + ['a non-finite updatedAt', { updatedAt: Number.NaN }], + ['a string createdAt', { createdAt: '1714000000500' }], + ])('saveRecord rejects %s', async (_, override) => { + const biometrics = await load(); + + await expect( + biometrics.saveRecord({ ...record, ...override } as BiometricCredentialRecord, { + removeOtherRecordsForApp: false, + }), + ).rejects.toMatchObject({ code: 'invalid_argument' }); + expect(native.saveRecord).not.toHaveBeenCalled(); + }); + + test('saveRecord requires removeOtherRecordsForApp', async () => { + const biometrics = await load(); + + // @ts-expect-error testing missing options + await expect(biometrics.saveRecord(record)).rejects.toMatchObject({ code: 'invalid_argument' }); + }); + + test('deleteRecord and ensureInstallationMarker forward to native', async () => { + native.ensureInstallationMarker.mockResolvedValueOnce({ wiped: true }); + const biometrics = await load(); + + await biometrics.deleteRecord('tdlk_1'); + await expect(biometrics.ensureInstallationMarker()).resolves.toEqual({ wiped: true }); + + expect(native.deleteRecord).toHaveBeenCalledWith('tdlk_1'); + await expect(biometrics.deleteRecord('')).rejects.toMatchObject({ code: 'invalid_argument' }); + }); + + test('store errors keep their native code', async () => { + native.saveRecord.mockRejectedValueOnce(nativeError('storage_failed')); + const biometrics = await load(); + + await expect(biometrics.saveRecord(record, { removeOtherRecordsForApp: false })).rejects.toMatchObject({ + code: 'storage_failed', + }); + }); + }); +}); diff --git a/packages/expo-biometrics/src/errors.ts b/packages/expo-biometrics/src/errors.ts new file mode 100644 index 00000000000..ef47e236418 --- /dev/null +++ b/packages/expo-biometrics/src/errors.ts @@ -0,0 +1,48 @@ +import type { BiometricsErrorCode } from './types'; + +const ERROR_CODES: ReadonlySet = new Set([ + 'user_canceled', + 'system_canceled', + 'user_fallback', + 'authentication_failed', + 'biometry_not_available', + 'biometry_not_enrolled', + 'biometry_lockout', + 'passcode_not_set', + 'key_not_found', + 'key_invalidated', + 'key_generation_failed', + 'signing_failed', + 'storage_failed', + 'invalid_argument', + 'not_implemented', + 'native_module_unavailable', + 'unknown', +]); + +export class ClerkBiometricsError extends Error { + readonly code: BiometricsErrorCode; + + constructor(code: BiometricsErrorCode, message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = 'ClerkBiometricsError'; + this.code = code; + } +} + +export function isClerkBiometricsError(error: unknown): error is ClerkBiometricsError { + return error instanceof ClerkBiometricsError; +} + +export function isBiometricsErrorCode(value: unknown): value is BiometricsErrorCode { + return typeof value === 'string' && ERROR_CODES.has(value); +} + +export function toClerkBiometricsError(error: unknown): ClerkBiometricsError { + if (error instanceof ClerkBiometricsError) { + return error; + } + const code = (error as { code?: unknown } | null)?.code; + const message = error instanceof Error ? error.message : String(error); + return new ClerkBiometricsError(isBiometricsErrorCode(code) ? code : 'unknown', message, { cause: error }); +} diff --git a/packages/expo-biometrics/src/index.ts b/packages/expo-biometrics/src/index.ts new file mode 100644 index 00000000000..94347e54fbb --- /dev/null +++ b/packages/expo-biometrics/src/index.ts @@ -0,0 +1,207 @@ +import type { ClerkExpoBiometricsNativeModule } from './ClerkExpoBiometricsModule'; +import ClerkExpoBiometrics from './ClerkExpoBiometricsModule'; +import { ClerkBiometricsError, toClerkBiometricsError } from './errors'; +import type { + BiometricAvailability, + BiometricCredentialKey, + BiometricCredentialPolicy, + BiometricCredentialRecord, + InstallationMarkerResult, + SaveRecordOptions, + StoredBiometricCredentialRecord, +} from './types'; + +export { ClerkBiometricsError, isBiometricsErrorCode, isClerkBiometricsError } from './errors'; +export type { + BiometricAvailability, + BiometricCredentialKey, + BiometricCredentialPolicy, + BiometricCredentialRecord, + BiometricsErrorCode, + BiometryType, + InstallationMarkerResult, + SaveRecordOptions, + StoredBiometricCredentialRecord, +} from './types'; + +const POLICIES: ReadonlySet = new Set([ + 'biometry_current_set', + 'biometry_any', + 'biometry_or_device_passcode', +]); + +function nativeModule(): ClerkExpoBiometricsNativeModule { + if (!ClerkExpoBiometrics) { + throw new ClerkBiometricsError( + 'native_module_unavailable', + 'The ClerkExpoBiometrics native module is not available. Rebuild your app with @clerk/expo-biometrics installed; it cannot run in Expo Go or on the web.', + ); + } + return ClerkExpoBiometrics; +} + +async function callNative(fn: (module: ClerkExpoBiometricsNativeModule) => Promise): Promise { + const module = nativeModule(); + try { + return await fn(module); + } catch (error) { + throw toClerkBiometricsError(error); + } +} + +function invalidArgument(message: string): ClerkBiometricsError { + return new ClerkBiometricsError('invalid_argument', message); +} + +function assertNonEmptyString(value: unknown, name: string): asserts value is string { + if (typeof value !== 'string' || value.length === 0) { + throw invalidArgument(`${name} must be a non-empty string.`); + } +} + +function assertPolicy(value: unknown): asserts value is BiometricCredentialPolicy { + if (typeof value !== 'string' || !POLICIES.has(value)) { + throw invalidArgument(`policy must be one of ${[...POLICIES].join(', ')}.`); + } +} + +function assertTimestamp(value: unknown, name: string): asserts value is number { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { + throw invalidArgument(`${name} must be a non-negative number of milliseconds since the Unix epoch.`); + } +} + +function assertRecord(record: BiometricCredentialRecord): void { + if (typeof record !== 'object' || record === null) { + throw invalidArgument('record must be an object.'); + } + assertNonEmptyString(record.id, 'record.id'); + assertNonEmptyString(record.localKeyId, 'record.localKeyId'); + assertNonEmptyString(record.userId, 'record.userId'); + assertNonEmptyString(record.appIdentifier, 'record.appIdentifier'); + if (record.identifierHint !== undefined && typeof record.identifierHint !== 'string') { + throw invalidArgument('record.identifierHint must be a string when provided.'); + } + assertPolicy(record.policy); + assertTimestamp(record.createdAt, 'record.createdAt'); + assertTimestamp(record.updatedAt, 'record.updatedAt'); +} + +/** + * Returns the identifier Clerk uses as the credential's `app_identifier` (the iOS bundle identifier). + */ +export function getAppIdentifier(): string { + const module = nativeModule(); + try { + return module.getAppIdentifier(); + } catch (error) { + throw toClerkBiometricsError(error); + } +} + +/** + * Reports which biometry the device supports and whether it can be used right now. + */ +export function getAvailability(): Promise { + return callNative(module => module.getAvailability()); +} + +/** + * Creates a new hardware-backed P-256 private key protected by `policy`. + * Rejects with `biometry_not_available`, `biometry_not_enrolled`, or `biometry_lockout` when biometrics cannot be used. + */ +export async function createKey(policy: BiometricCredentialPolicy): Promise { + assertPolicy(policy); + return callNative(module => module.createKey(policy)); +} + +/** + * Prompts for local authentication and signs the UTF-8 bytes of `clientData` with ES256. + * Resolves with the raw `r || s` signature, base64url-encoded without padding. + * + * @param reason - The message shown in the authentication prompt. + */ +export async function sign(localKeyId: string, clientData: string, reason: string | null = null): Promise { + assertNonEmptyString(localKeyId, 'localKeyId'); + if (typeof clientData !== 'string') { + throw invalidArgument('clientData must be a string.'); + } + return callNative(module => module.sign(localKeyId, clientData, reason)); +} + +/** + * Resolves whether the private key for `localKeyId` exists. Does not prompt for authentication. + */ +export async function hasKey(localKeyId: string): Promise { + assertNonEmptyString(localKeyId, 'localKeyId'); + return callNative(module => module.hasKey(localKeyId)); +} + +/** + * Deletes the private key for `localKeyId`. Resolves when the key does not exist. + */ +export async function deleteKey(localKeyId: string): Promise { + assertNonEmptyString(localKeyId, 'localKeyId'); + return callNative(module => module.deleteKey(localKeyId)); +} + +/** + * Lists every well-formed credential record on the device, for every app identifier. + */ +export async function listRecords(): Promise { + const json = await callNative(module => module.listRecords()); + let records: unknown; + try { + records = JSON.parse(json); + } catch (error) { + throw new ClerkBiometricsError('storage_failed', 'The native module returned invalid record data.', { + cause: error, + }); + } + if (!Array.isArray(records)) { + throw new ClerkBiometricsError('storage_failed', 'The native module returned invalid record data.'); + } + return records as StoredBiometricCredentialRecord[]; +} + +/** + * Saves `record`, replacing any record with the same `id`. When the replaced record used a different key, that key is deleted. + */ +export async function saveRecord(record: BiometricCredentialRecord, options: SaveRecordOptions): Promise { + assertRecord(record); + if (typeof options?.removeOtherRecordsForApp !== 'boolean') { + throw invalidArgument('options.removeOtherRecordsForApp must be a boolean.'); + } + const nativeRecord: BiometricCredentialRecord = { + id: record.id, + localKeyId: record.localKeyId, + userId: record.userId, + appIdentifier: record.appIdentifier, + policy: record.policy, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + }; + if (record.identifierHint !== undefined) { + nativeRecord.identifierHint = record.identifierHint; + } + return callNative(module => + module.saveRecord(nativeRecord, { removeOtherRecordsForApp: options.removeOtherRecordsForApp }), + ); +} + +/** + * Deletes the private key for `localKeyId`, then every record that references it. + * When the key cannot be deleted the records are kept and the promise rejects. + */ +export async function deleteRecord(localKeyId: string): Promise { + assertNonEmptyString(localKeyId, 'localKeyId'); + return callNative(module => module.deleteRecord(localKeyId)); +} + +/** + * Detects a new installation and deletes the records and keys a previous installation of this app left in the Keychain. + * Safe to call repeatedly. The store operations above call it before they read or write. + */ +export function ensureInstallationMarker(): Promise { + return callNative(module => module.ensureInstallationMarker()); +} diff --git a/packages/expo-biometrics/src/types.ts b/packages/expo-biometrics/src/types.ts new file mode 100644 index 00000000000..9a5673fb971 --- /dev/null +++ b/packages/expo-biometrics/src/types.ts @@ -0,0 +1,86 @@ +/** + * The local authentication policy that protects a biometric credential's private key. + * + * - `biometry_current_set`: requires a biometric from the currently enrolled set. Adding or removing a Face ID / Touch ID enrollment invalidates the key. + * - `biometry_any`: requires a biometric, and survives biometric enrollment changes. + * - `biometry_or_device_passcode`: requires biometrics to be available at creation, then accepts a biometric or the device passcode. + */ +export type BiometricCredentialPolicy = 'biometry_current_set' | 'biometry_any' | 'biometry_or_device_passcode'; + +export type BiometryType = 'faceID' | 'touchID' | 'opticID' | 'none'; + +export interface BiometricAvailability { + /** The biometry the device supports, or `none`. */ + biometryType: BiometryType; + /** Whether biometric authentication can be evaluated now. Key creation requires this for every policy. */ + canEvaluateBiometrics: boolean; + /** Whether device owner authentication (biometrics or passcode) can be evaluated now. */ + canEvaluateDeviceOwner: boolean; + /** Why biometric authentication cannot be evaluated, or `null` when it can. */ + errorCode: BiometricsErrorCode | null; +} + +export interface BiometricCredentialKey { + /** Opaque identifier of the private key. Stored in the credential record as `localKeyId`. */ + localKeyId: string; + /** P-256 public key as a compact JWK string: `{"kty":"EC","crv":"P-256","x":"…","y":"…","alg":"ES256"}`. */ + publicKeyJwk: string; +} + +/** + * On-device metadata linking a Clerk biometric credential to its private key. + */ +export interface BiometricCredentialRecord { + /** Server credential ID. */ + id: string; + /** Identifier of the private key returned by `createKey()`. */ + localKeyId: string; + /** Clerk user ID that enrolled the credential. */ + userId: string; + /** App identifier the credential was enrolled for (see `getAppIdentifier()`). */ + appIdentifier: string; + /** Local-only identifier hint. Normalized (trimmed, lowercased) when stored; empty values are omitted. */ + identifierHint?: string; + policy: BiometricCredentialPolicy; + /** Server credential creation time, in milliseconds since the Unix epoch. */ + createdAt: number; + /** Server credential update time, in milliseconds since the Unix epoch. */ + updatedAt: number; +} + +/** + * A record read from the store. Fields written by other SDK versions are passed through unchanged. + */ +export type StoredBiometricCredentialRecord = BiometricCredentialRecord & { readonly [field: string]: unknown }; + +export interface SaveRecordOptions { + /** + * Delete every other record for the same `appIdentifier`, along with its private key, after the record is saved. + * Set this after a successful enrollment, since the server has already replaced those credentials. + */ + removeOtherRecordsForApp: boolean; +} + +export interface InstallationMarkerResult { + /** `true` when this is a new installation and the records left behind by a previous one were deleted. */ + wiped: boolean; +} + +export type BiometricsErrorCode = + | 'user_canceled' + | 'system_canceled' + | 'user_fallback' + | 'authentication_failed' + | 'biometry_not_available' + | 'biometry_not_enrolled' + | 'biometry_lockout' + | 'passcode_not_set' + | 'key_not_found' + | 'key_invalidated' + | 'key_generation_failed' + | 'signing_failed' + | 'storage_failed' + | 'invalid_argument' + | 'not_implemented' + | 'native_module_unavailable' + | 'unknown'; diff --git a/packages/expo-biometrics/tsconfig.declarations.json b/packages/expo-biometrics/tsconfig.declarations.json new file mode 100644 index 00000000000..30037049bb1 --- /dev/null +++ b/packages/expo-biometrics/tsconfig.declarations.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "incremental": false, + "skipLibCheck": true, + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "declarationMap": true, + "sourceMap": false, + "declarationDir": "./dist" + }, + "exclude": ["**/__tests__/**/*"] +} diff --git a/packages/expo-biometrics/tsconfig.json b/packages/expo-biometrics/tsconfig.json new file mode 100644 index 00000000000..ba72a15ff1d --- /dev/null +++ b/packages/expo-biometrics/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "allowJs": true, + "declaration": true, + "declarationMap": false, + "esModuleInterop": true, + "importHelpers": true, + "incremental": true, + "jsx": "react-jsx", + "lib": ["ESNext", "dom"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmitOnError": false, + "noImplicitReturns": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "outDir": "dist", + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "ES2019", + "types": ["node"], + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/packages/expo-biometrics/tsup.config.ts b/packages/expo-biometrics/tsup.config.ts new file mode 100644 index 00000000000..f705ef7fde7 --- /dev/null +++ b/packages/expo-biometrics/tsup.config.ts @@ -0,0 +1,19 @@ +import type { Options } from 'tsup'; +import { defineConfig } from 'tsup'; + +import { runAfterLast } from '../../scripts/utils'; + +export default defineConfig(() => { + const options: Options = { + format: 'cjs', + outDir: './dist', + entry: ['./src/**/*.{ts,tsx,js,jsx}', '!./src/**/__tests__/**'], + bundle: false, + clean: true, + minify: false, + sourcemap: true, + legacyOutput: true, + }; + + return runAfterLast(['pnpm build:declarations'])(options); +}); diff --git a/packages/expo-biometrics/vitest.config.mts b/packages/expo-biometrics/vitest.config.mts new file mode 100644 index 00000000000..4ac6027d578 --- /dev/null +++ b/packages/expo-biometrics/vitest.config.mts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d37b3a454fa..a3084ae1dec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -702,6 +702,12 @@ importers: specifier: ^0.86.0 version: 0.86.0(@babel/core@7.29.7)(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(utf-8-validate@5.0.10) + packages/expo-biometrics: + devDependencies: + expo: + specifier: ~54.0.36 + version: 54.0.36(@babel/core@7.29.7)(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7)(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10) + packages/expo-google-signin: devDependencies: '@expo/config-plugins': From 15c36e348b580c182fa1a01143280322d1b92774 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:03:06 -0400 Subject: [PATCH 2/3] chore(expo-biometrics): point iOS storage comment at the clerk-ios contract tests Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/expo-biometrics/ios/BiometricCredentialCoding.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/expo-biometrics/ios/BiometricCredentialCoding.swift b/packages/expo-biometrics/ios/BiometricCredentialCoding.swift index 441245f9ec0..d741022f3c3 100644 --- a/packages/expo-biometrics/ios/BiometricCredentialCoding.swift +++ b/packages/expo-biometrics/ios/BiometricCredentialCoding.swift @@ -1,6 +1,6 @@ import Foundation -// Implements clerk-ios Documentation/BiometricCredentialStorageContract.md (version 1). Any change here must stay +// Mirrors the format pinned by clerk-ios BiometricCredentialStorageContractTests. Any change here must stay // byte-compatible with ClerkKit, which reads and writes the same keys, Keychain item, and UserDefaults marker. enum BiometricCredentialPolicy: String, CaseIterable { From ca1e44bbe2f83c6d5f831e93f91e4df95cbed36d Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:00:43 -0400 Subject: [PATCH 3/3] 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) --- packages/expo-biometrics/README.md | 3 +- .../ios/BiometricKeyManager.swift | 24 +++++++++++++++- .../expo-biometrics/ios/BiometricsError.swift | 1 + .../ios/ClerkExpoBiometrics.podspec | 2 +- .../ios/ClerkExpoBiometricsModule.swift | 2 ++ .../BiometricSystemIntegrationTests.swift | 23 +++++++++++++++ .../src/__tests__/index.test.ts | 28 +++++++++++++++++++ packages/expo-biometrics/src/errors.ts | 1 + packages/expo-biometrics/src/index.ts | 3 +- packages/expo-biometrics/src/types.ts | 6 ++++ 10 files changed, 89 insertions(+), 4 deletions(-) diff --git a/packages/expo-biometrics/README.md b/packages/expo-biometrics/README.md index dcac2a997f6..5c39cf5754b 100644 --- a/packages/expo-biometrics/README.md +++ b/packages/expo-biometrics/README.md @@ -37,6 +37,7 @@ The key and record layout is shared with the Clerk iOS SDK, so credentials enrol - Expo SDK 54 or later, in a development build (the module is not available in Expo Go or on the web) - iOS. Android support is not implemented yet: every call rejects with `not_implemented`. - `NSFaceIDUsageDescription` in your `Info.plist`. The `@clerk/expo` config plugin sets it through its `faceIDPermission` option. +- A device with a Secure Enclave. The iOS Simulator has none, so `createKey()` rejects there with `secure_key_storage_unavailable`. ## Installation @@ -66,7 +67,7 @@ import { | Function | Description | | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `getAppIdentifier()` | The app identifier sent to Clerk as `app_identifier` (the iOS bundle identifier). | -| `getAvailability()` | The device's biometry type and whether biometrics or device owner authentication can be evaluated. | +| `getAvailability()` | The device's biometry type, whether biometrics or device owner authentication can be evaluated, and secure key storage. | | `createKey(policy)` | Creates a Secure Enclave P-256 key and returns its `localKeyId` and public key JWK. | | `sign(localKeyId, clientData, reason?)` | Prompts for authentication and returns an ES256 signature over `clientData` (raw `r \|\| s`, base64url without padding). | | `hasKey(localKeyId)` / `deleteKey(localKeyId)` | Checks for or deletes a key. | diff --git a/packages/expo-biometrics/ios/BiometricKeyManager.swift b/packages/expo-biometrics/ios/BiometricKeyManager.swift index 53e306a8b2a..ff4d34bf893 100644 --- a/packages/expo-biometrics/ios/BiometricKeyManager.swift +++ b/packages/expo-biometrics/ios/BiometricKeyManager.swift @@ -1,3 +1,4 @@ +import CryptoKit import Foundation import LocalAuthentication import Security @@ -7,6 +8,7 @@ struct BiometricAvailability: Equatable { let canEvaluateBiometrics: Bool let canEvaluateDeviceOwner: Bool let errorCode: BiometricsError.Code? + let secureKeyStorageAvailable: Bool } struct BiometricCredentialKey: Equatable { @@ -16,6 +18,21 @@ struct BiometricCredentialKey: Equatable { /// Secure Enclave keys laid out as ClerkKit's `BiometricCredentialKeyManager` creates them. final class BiometricKeyManager { + private let isSecureEnclaveAvailable: () -> Bool + + init(isSecureEnclaveAvailable: @escaping () -> Bool = BiometricKeyManager.deviceHasSecureEnclave) { + self.isSecureEnclaveAvailable = isSecureEnclaveAvailable + } + + static func deviceHasSecureEnclave() -> Bool { + // SecureEnclave.isAvailable is true on the Simulator, where Secure Enclave keys still fail with errSecAuthFailed. + #if targetEnvironment(simulator) + return false + #else + return SecureEnclave.isAvailable + #endif + } + func availability() -> BiometricAvailability { let context = LAContext() var biometricsError: NSError? @@ -31,11 +48,16 @@ final class BiometricKeyManager { canEvaluateDeviceOwner: canEvaluateDeviceOwner, errorCode: canEvaluateBiometrics ? nil - : BiometricsError.localAuthentication(biometricsError, fallback: .biometryNotAvailable).code + : BiometricsError.localAuthentication(biometricsError, fallback: .biometryNotAvailable).code, + secureKeyStorageAvailable: isSecureEnclaveAvailable() ) } func createKey(policy: BiometricCredentialPolicy) throws -> BiometricCredentialKey { + guard isSecureEnclaveAvailable() else { + throw BiometricsError(.secureKeyStorageUnavailable, "This device has no Secure Enclave to hold the biometric credential key.") + } + let context = LAContext() var laError: NSError? guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &laError) else { diff --git a/packages/expo-biometrics/ios/BiometricsError.swift b/packages/expo-biometrics/ios/BiometricsError.swift index f8e5bb82375..c33ddf13561 100644 --- a/packages/expo-biometrics/ios/BiometricsError.swift +++ b/packages/expo-biometrics/ios/BiometricsError.swift @@ -12,6 +12,7 @@ struct BiometricsError: Error, Equatable { case biometryNotEnrolled = "biometry_not_enrolled" case biometryLockout = "biometry_lockout" case passcodeNotSet = "passcode_not_set" + case secureKeyStorageUnavailable = "secure_key_storage_unavailable" case keyNotFound = "key_not_found" case keyInvalidated = "key_invalidated" case keyGenerationFailed = "key_generation_failed" diff --git a/packages/expo-biometrics/ios/ClerkExpoBiometrics.podspec b/packages/expo-biometrics/ios/ClerkExpoBiometrics.podspec index bce85d59e29..05c8cecf532 100644 --- a/packages/expo-biometrics/ios/ClerkExpoBiometrics.podspec +++ b/packages/expo-biometrics/ios/ClerkExpoBiometrics.podspec @@ -16,7 +16,7 @@ Pod::Spec.new do |s| s.static_framework = true s.dependency 'ExpoModulesCore' - s.frameworks = 'LocalAuthentication', 'Security' + s.frameworks = 'CryptoKit', 'LocalAuthentication', 'Security' s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', diff --git a/packages/expo-biometrics/ios/ClerkExpoBiometricsModule.swift b/packages/expo-biometrics/ios/ClerkExpoBiometricsModule.swift index ce017190500..aefc24929b2 100644 --- a/packages/expo-biometrics/ios/ClerkExpoBiometricsModule.swift +++ b/packages/expo-biometrics/ios/ClerkExpoBiometricsModule.swift @@ -20,6 +20,7 @@ struct BiometricAvailabilityResult: Record { @Field var canEvaluateBiometrics: Bool = false @Field var canEvaluateDeviceOwner: Bool = false @Field var errorCode: String? + @Field var secureKeyStorageAvailable: Bool = false } struct BiometricCredentialKeyResult: Record { @@ -64,6 +65,7 @@ public final class ClerkExpoBiometricsModule: Module { result.canEvaluateBiometrics = availability.canEvaluateBiometrics result.canEvaluateDeviceOwner = availability.canEvaluateDeviceOwner result.errorCode = availability.errorCode?.rawValue + result.secureKeyStorageAvailable = availability.secureKeyStorageAvailable return result } diff --git a/packages/expo-biometrics/ios/Tests/BiometricSystemIntegrationTests.swift b/packages/expo-biometrics/ios/Tests/BiometricSystemIntegrationTests.swift index 06d43ec04bb..0dd3bfcb402 100644 --- a/packages/expo-biometrics/ios/Tests/BiometricSystemIntegrationTests.swift +++ b/packages/expo-biometrics/ios/Tests/BiometricSystemIntegrationTests.swift @@ -76,6 +76,29 @@ final class BiometricKeyManagerTests: XCTestCase { XCTAssertEqual(availability.errorCode == nil, availability.canEvaluateBiometrics) } + func testSimulatorReportsNoSecureKeyStorage() throws { + #if targetEnvironment(simulator) + XCTAssertFalse(BiometricKeyManager().availability().secureKeyStorageAvailable) + #else + throw XCTSkip("Only the Simulator is known to lack a Secure Enclave.") + #endif + } + + func testAvailabilityReportsSecureKeyStorage() { + XCTAssertTrue(BiometricKeyManager(isSecureEnclaveAvailable: { true }).availability().secureKeyStorageAvailable) + XCTAssertFalse(BiometricKeyManager(isSecureEnclaveAvailable: { false }).availability().secureKeyStorageAvailable) + } + + func testCreateKeyRejectsWithoutSecureKeyStorage() { + let keyManager = BiometricKeyManager(isSecureEnclaveAvailable: { false }) + + for policy in BiometricCredentialPolicy.allCases { + XCTAssertThrowsError(try keyManager.createKey(policy: policy)) { error in + XCTAssertEqual((error as? BiometricsError)?.code, .secureKeyStorageUnavailable) + } + } + } + func testErrorMapping() { XCTAssertEqual(BiometricsError.code(forLAErrorCode: LAError.Code.userCancel.rawValue), .userCanceled) XCTAssertEqual(BiometricsError.code(forLAErrorCode: LAError.Code.systemCancel.rawValue), .systemCanceled) diff --git a/packages/expo-biometrics/src/__tests__/index.test.ts b/packages/expo-biometrics/src/__tests__/index.test.ts index fb78fe7178b..d214347149b 100644 --- a/packages/expo-biometrics/src/__tests__/index.test.ts +++ b/packages/expo-biometrics/src/__tests__/index.test.ts @@ -21,6 +21,7 @@ const createNativeModule = () => ({ canEvaluateBiometrics: true, canEvaluateDeviceOwner: true, errorCode: null, + secureKeyStorageAvailable: true, }), createKey: vi.fn().mockResolvedValue({ localKeyId: 'tdlk_1', publicKeyJwk: '{"kty":"EC"}' }), sign: vi.fn().mockResolvedValue('c2lnbmF0dXJl'), @@ -67,9 +68,23 @@ describe('@clerk/expo-biometrics', () => { canEvaluateBiometrics: true, canEvaluateDeviceOwner: true, errorCode: null, + secureKeyStorageAvailable: true, }); }); + test('getAvailability reports missing secure key storage', async () => { + native.getAvailability.mockResolvedValueOnce({ + biometryType: 'faceID', + canEvaluateBiometrics: true, + canEvaluateDeviceOwner: true, + errorCode: null, + secureKeyStorageAvailable: false, + }); + const biometrics = await load(); + + await expect(biometrics.getAvailability()).resolves.toMatchObject({ secureKeyStorageAvailable: false }); + }); + test('rejects with native_module_unavailable when the native module is missing', async () => { mocks.nativeModule = null; const biometrics = await load(); @@ -92,6 +107,17 @@ describe('@clerk/expo-biometrics', () => { expect(native.createKey).toHaveBeenCalledWith('biometry_or_device_passcode'); }); + test('createKey preserves secure_key_storage_unavailable', async () => { + native.createKey.mockRejectedValueOnce(nativeError('secure_key_storage_unavailable', 'no Secure Enclave')); + const biometrics = await load(); + + await expect(biometrics.createKey('biometry_current_set')).rejects.toMatchObject({ + name: 'ClerkBiometricsError', + code: 'secure_key_storage_unavailable', + message: 'no Secure Enclave', + }); + }); + test('createKey rejects unknown policies without calling native', async () => { const biometrics = await load(); @@ -139,6 +165,7 @@ describe('@clerk/expo-biometrics', () => { 'biometry_not_enrolled', 'biometry_lockout', 'passcode_not_set', + 'secure_key_storage_unavailable', 'key_not_found', 'key_invalidated', 'key_generation_failed', @@ -176,6 +203,7 @@ describe('@clerk/expo-biometrics', () => { const { isBiometricsErrorCode } = await load(); expect(isBiometricsErrorCode('biometry_lockout')).toBe(true); + expect(isBiometricsErrorCode('secure_key_storage_unavailable')).toBe(true); expect(isBiometricsErrorCode('ERR_UNKNOWN')).toBe(false); expect(isBiometricsErrorCode(undefined)).toBe(false); }); diff --git a/packages/expo-biometrics/src/errors.ts b/packages/expo-biometrics/src/errors.ts index ef47e236418..3b7552aac9b 100644 --- a/packages/expo-biometrics/src/errors.ts +++ b/packages/expo-biometrics/src/errors.ts @@ -9,6 +9,7 @@ const ERROR_CODES: ReadonlySet = new Set([ 'biometry_not_enrolled', 'biometry_lockout', 'passcode_not_set', + 'secure_key_storage_unavailable', 'key_not_found', 'key_invalidated', 'key_generation_failed', diff --git a/packages/expo-biometrics/src/index.ts b/packages/expo-biometrics/src/index.ts index 94347e54fbb..7000dfdbb30 100644 --- a/packages/expo-biometrics/src/index.ts +++ b/packages/expo-biometrics/src/index.ts @@ -108,7 +108,8 @@ export function getAvailability(): Promise { /** * Creates a new hardware-backed P-256 private key protected by `policy`. - * Rejects with `biometry_not_available`, `biometry_not_enrolled`, or `biometry_lockout` when biometrics cannot be used. + * Rejects with `secure_key_storage_unavailable` when the device has no hardware-backed key storage (see `getAvailability()`), + * and with `biometry_not_available`, `biometry_not_enrolled`, or `biometry_lockout` when biometrics cannot be used. */ export async function createKey(policy: BiometricCredentialPolicy): Promise { assertPolicy(policy); diff --git a/packages/expo-biometrics/src/types.ts b/packages/expo-biometrics/src/types.ts index 9a5673fb971..6c537067e71 100644 --- a/packages/expo-biometrics/src/types.ts +++ b/packages/expo-biometrics/src/types.ts @@ -18,6 +18,11 @@ export interface BiometricAvailability { canEvaluateDeviceOwner: boolean; /** Why biometric authentication cannot be evaluated, or `null` when it can. */ errorCode: BiometricsErrorCode | null; + /** + * Whether the device has hardware-backed key storage (the Secure Enclave on iOS). `false` on the iOS Simulator. + * `createKey()` rejects with `secure_key_storage_unavailable` when this is `false`. + */ + secureKeyStorageAvailable: boolean; } export interface BiometricCredentialKey { @@ -75,6 +80,7 @@ export type BiometricsErrorCode = | 'biometry_not_enrolled' | 'biometry_lockout' | 'passcode_not_set' + | 'secure_key_storage_unavailable' | 'key_not_found' | 'key_invalidated' | 'key_generation_failed'