@clerk/expo-biometrics 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +96 -0
  3. package/android/build.gradle +49 -0
  4. package/android/src/main/AndroidManifest.xml +2 -0
  5. package/android/src/main/java/expo/modules/clerk/biometrics/BiometricCredentialCoding.kt +147 -0
  6. package/android/src/main/java/expo/modules/clerk/biometrics/BiometricCredentialFileStore.kt +304 -0
  7. package/android/src/main/java/expo/modules/clerk/biometrics/BiometricCredentialStore.kt +96 -0
  8. package/android/src/main/java/expo/modules/clerk/biometrics/BiometricKeyManager.kt +216 -0
  9. package/android/src/main/java/expo/modules/clerk/biometrics/BiometricsError.kt +58 -0
  10. package/android/src/main/java/expo/modules/clerk/biometrics/ClerkExpoBiometricsModule.kt +139 -0
  11. package/android/src/test/java/expo/modules/clerk/biometrics/BiometricCredentialStorageContractTest.kt +450 -0
  12. package/android/src/test/java/expo/modules/clerk/biometrics/BiometricCredentialStoreTest.kt +179 -0
  13. package/android/src/test/java/expo/modules/clerk/biometrics/BiometricKeyManagerTest.kt +49 -0
  14. package/android/src/test/resources/biometric-credential-storage/v2/biometric_credentials.v2.json +50 -0
  15. package/android/src/test/resources/robolectric.properties +1 -0
  16. package/dist/ClerkExpoBiometricsModule.d.ts +18 -0
  17. package/dist/ClerkExpoBiometricsModule.d.ts.map +1 -0
  18. package/dist/ClerkExpoBiometricsModule.js +26 -0
  19. package/dist/ClerkExpoBiometricsModule.js.map +1 -0
  20. package/dist/errors.d.ts +11 -0
  21. package/dist/errors.d.ts.map +1 -0
  22. package/dist/errors.js +75 -0
  23. package/dist/errors.js.map +1 -0
  24. package/dist/index.d.ts +57 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +204 -0
  27. package/dist/index.js.map +1 -0
  28. package/dist/types.d.ts +85 -0
  29. package/dist/types.d.ts.map +1 -0
  30. package/dist/types.js +17 -0
  31. package/dist/types.js.map +1 -0
  32. package/expo-module.config.json +9 -0
  33. package/ios/BiometricCredentialCoding.swift +335 -0
  34. package/ios/BiometricCredentialStore.swift +230 -0
  35. package/ios/BiometricKeyManager.swift +228 -0
  36. package/ios/BiometricsError.swift +117 -0
  37. package/ios/ClerkExpoBiometrics.podspec +32 -0
  38. package/ios/ClerkExpoBiometricsModule.swift +164 -0
  39. package/ios/Tests/BiometricCredentialContractTests.swift +286 -0
  40. package/ios/Tests/BiometricCredentialStoreTests.swift +323 -0
  41. package/ios/Tests/BiometricSystemIntegrationTests.swift +169 -0
  42. package/ios/Tests/Resources/BiometricCredentialStorageContractV1.json +31 -0
  43. package/package.json +53 -0
@@ -0,0 +1,228 @@
1
+ import CryptoKit
2
+ import Foundation
3
+ import LocalAuthentication
4
+ import Security
5
+
6
+ struct BiometricAvailability: Equatable {
7
+ let biometryType: String
8
+ let canEvaluateBiometrics: Bool
9
+ let canEvaluateDeviceOwner: Bool
10
+ let errorCode: BiometricsError.Code?
11
+ let secureKeyStorageAvailable: Bool
12
+ }
13
+
14
+ struct BiometricCredentialKey: Equatable {
15
+ let localKeyId: String
16
+ let publicKeyJWK: String
17
+ }
18
+
19
+ /// Secure Enclave keys laid out as ClerkKit's `BiometricCredentialKeyManager` creates them.
20
+ final class BiometricKeyManager {
21
+ private let isSecureEnclaveAvailable: () -> Bool
22
+
23
+ init(isSecureEnclaveAvailable: @escaping () -> Bool = BiometricKeyManager.deviceHasSecureEnclave) {
24
+ self.isSecureEnclaveAvailable = isSecureEnclaveAvailable
25
+ }
26
+
27
+ static func deviceHasSecureEnclave() -> Bool {
28
+ // SecureEnclave.isAvailable is true on the Simulator, where Secure Enclave keys still fail with errSecAuthFailed.
29
+ #if targetEnvironment(simulator)
30
+ return false
31
+ #else
32
+ return SecureEnclave.isAvailable
33
+ #endif
34
+ }
35
+
36
+ func availability() -> BiometricAvailability {
37
+ let context = LAContext()
38
+ var biometricsError: NSError?
39
+ let canEvaluateBiometrics = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &biometricsError)
40
+ // biometryType is only populated after canEvaluatePolicy has run.
41
+ let biometryType = Self.biometryTypeName(context.biometryType)
42
+ var deviceOwnerError: NSError?
43
+ let canEvaluateDeviceOwner = context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &deviceOwnerError)
44
+
45
+ return BiometricAvailability(
46
+ biometryType: biometryType,
47
+ canEvaluateBiometrics: canEvaluateBiometrics,
48
+ canEvaluateDeviceOwner: canEvaluateDeviceOwner,
49
+ errorCode: canEvaluateBiometrics
50
+ ? nil
51
+ : BiometricsError.localAuthentication(biometricsError, fallback: .biometryNotAvailable).code,
52
+ secureKeyStorageAvailable: isSecureEnclaveAvailable()
53
+ )
54
+ }
55
+
56
+ func createKey(policy: BiometricCredentialPolicy) throws -> BiometricCredentialKey {
57
+ guard isSecureEnclaveAvailable() else {
58
+ throw BiometricsError(.secureKeyStorageUnavailable, "This device has no Secure Enclave to hold the biometric credential key.")
59
+ }
60
+
61
+ let context = LAContext()
62
+ var laError: NSError?
63
+ guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &laError) else {
64
+ throw BiometricsError.localAuthentication(laError, fallback: .biometryNotAvailable)
65
+ }
66
+
67
+ let localKeyId = BiometricCredentialCoding.makeLocalKeyId()
68
+ let attributes = Self.privateKeyAttributes(localKeyId: localKeyId, accessControl: try Self.accessControl(policy: policy))
69
+
70
+ var error: Unmanaged<CFError>?
71
+ guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else {
72
+ throw Self.cfError(error, fallback: .keyGenerationFailed)
73
+ }
74
+
75
+ do {
76
+ return BiometricCredentialKey(localKeyId: localKeyId, publicKeyJWK: try Self.publicKeyJWK(for: privateKey))
77
+ } catch {
78
+ try? deleteKey(localKeyId: localKeyId)
79
+ throw error
80
+ }
81
+ }
82
+
83
+ func sign(localKeyId: String, clientData: String, reason: String?) throws -> String {
84
+ let privateKey = try privateKey(localKeyId: localKeyId, reason: reason)
85
+ let algorithm = SecKeyAlgorithm.ecdsaSignatureMessageX962SHA256
86
+ guard SecKeyIsAlgorithmSupported(privateKey, .sign, algorithm) else {
87
+ throw BiometricsError(.signingFailed, "The key does not support ES256 signing.")
88
+ }
89
+
90
+ var error: Unmanaged<CFError>?
91
+ guard let signature = SecKeyCreateSignature(privateKey, algorithm, Data(clientData.utf8) as CFData, &error) as Data? else {
92
+ throw Self.cfError(error, fallback: .signingFailed)
93
+ }
94
+
95
+ return BiometricCredentialCoding.base64URLEncodedString(
96
+ try BiometricCredentialCoding.rawES256Signature(fromDEREncoded: signature)
97
+ )
98
+ }
99
+
100
+ func hasKey(localKeyId: String) throws -> Bool {
101
+ var query = Self.privateKeyQuery(localKeyId: localKeyId)
102
+ query[kSecMatchLimit as String] = kSecMatchLimitOne
103
+
104
+ let status = SecItemCopyMatching(query as CFDictionary, nil)
105
+ switch status {
106
+ case errSecSuccess:
107
+ return true
108
+ case errSecItemNotFound:
109
+ return false
110
+ default:
111
+ throw BiometricsError(.storageFailed, BiometricsError.statusMessage(status))
112
+ }
113
+ }
114
+
115
+ func deleteKey(localKeyId: String) throws {
116
+ let status = SecItemDelete(Self.privateKeyQuery(localKeyId: localKeyId) as CFDictionary)
117
+ switch status {
118
+ case errSecSuccess, errSecItemNotFound:
119
+ return
120
+ default:
121
+ throw BiometricsError(.storageFailed, BiometricsError.statusMessage(status))
122
+ }
123
+ }
124
+
125
+ private func privateKey(localKeyId: String, reason: String?) throws -> SecKey {
126
+ let context = LAContext()
127
+ if let reason {
128
+ context.localizedReason = reason
129
+ }
130
+
131
+ var query = Self.privateKeyQuery(localKeyId: localKeyId)
132
+ query[kSecReturnRef as String] = true
133
+ query[kSecMatchLimit as String] = kSecMatchLimitOne
134
+ query[kSecUseAuthenticationContext as String] = context
135
+
136
+ var result: CFTypeRef?
137
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
138
+ switch status {
139
+ case errSecSuccess:
140
+ guard let result, CFGetTypeID(result) == SecKeyGetTypeID() else {
141
+ throw BiometricsError(.keyNotFound, "The biometric credential key was not found.")
142
+ }
143
+ return result as! SecKey
144
+ case errSecItemNotFound:
145
+ throw BiometricsError(.keyNotFound, "The biometric credential key was not found.")
146
+ default:
147
+ throw BiometricsError.status(status, fallback: .signingFailed)
148
+ }
149
+ }
150
+
151
+ static func accessControlFlags(for policy: BiometricCredentialPolicy) -> SecAccessControlCreateFlags {
152
+ switch policy {
153
+ case .biometryCurrentSet:
154
+ return [.privateKeyUsage, .biometryCurrentSet]
155
+ case .biometryAny:
156
+ return [.privateKeyUsage, .biometryAny]
157
+ case .biometryOrDevicePasscode:
158
+ return [.privateKeyUsage, .userPresence]
159
+ }
160
+ }
161
+
162
+ static func accessControl(policy: BiometricCredentialPolicy) throws -> SecAccessControl {
163
+ var error: Unmanaged<CFError>?
164
+ guard let accessControl = SecAccessControlCreateWithFlags(
165
+ kCFAllocatorDefault,
166
+ kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
167
+ accessControlFlags(for: policy),
168
+ &error
169
+ ) else {
170
+ throw cfError(error, fallback: .keyGenerationFailed)
171
+ }
172
+ return accessControl
173
+ }
174
+
175
+ static func privateKeyAttributes(localKeyId: String, accessControl: SecAccessControl) -> [String: Any] {
176
+ [
177
+ kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
178
+ kSecAttrKeySizeInBits as String: 256,
179
+ kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,
180
+ kSecPrivateKeyAttrs as String: [
181
+ kSecAttrIsPermanent as String: true,
182
+ kSecAttrApplicationTag as String: BiometricCredentialCoding.applicationTag(localKeyId: localKeyId),
183
+ kSecAttrAccessControl as String: accessControl,
184
+ ] as [String: Any],
185
+ ]
186
+ }
187
+
188
+ static func privateKeyQuery(localKeyId: String) -> [String: Any] {
189
+ [
190
+ kSecClass as String: kSecClassKey,
191
+ kSecAttrKeyClass as String: kSecAttrKeyClassPrivate,
192
+ kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
193
+ kSecAttrApplicationTag as String: BiometricCredentialCoding.applicationTag(localKeyId: localKeyId),
194
+ ]
195
+ }
196
+
197
+ static func biometryTypeName(_ type: LABiometryType) -> String {
198
+ switch type {
199
+ case .faceID:
200
+ return "faceID"
201
+ case .touchID:
202
+ return "touchID"
203
+ default:
204
+ if #available(iOS 17.0, *), type == .opticID {
205
+ return "opticID"
206
+ }
207
+ return "none"
208
+ }
209
+ }
210
+
211
+ private static func publicKeyJWK(for privateKey: SecKey) throws -> String {
212
+ guard let publicKey = SecKeyCopyPublicKey(privateKey) else {
213
+ throw BiometricsError(.keyGenerationFailed, "Unable to copy the public key.")
214
+ }
215
+ var error: Unmanaged<CFError>?
216
+ guard let representation = SecKeyCopyExternalRepresentation(publicKey, &error) as Data? else {
217
+ throw cfError(error, fallback: .keyGenerationFailed)
218
+ }
219
+ return try BiometricCredentialCoding.publicKeyJWK(fromX963Representation: representation)
220
+ }
221
+
222
+ private static func cfError(_ error: Unmanaged<CFError>?, fallback: BiometricsError.Code) -> BiometricsError {
223
+ guard let error else {
224
+ return BiometricsError(fallback, "Unknown Security framework error.")
225
+ }
226
+ return BiometricsError.security(error.takeRetainedValue() as Error, fallback: fallback)
227
+ }
228
+ }
@@ -0,0 +1,117 @@
1
+ import Foundation
2
+ import LocalAuthentication
3
+ import Security
4
+
5
+ struct BiometricsError: Error, Equatable {
6
+ enum Code: String {
7
+ case userCanceled = "user_canceled"
8
+ case systemCanceled = "system_canceled"
9
+ case userFallback = "user_fallback"
10
+ case authenticationFailed = "authentication_failed"
11
+ case biometryNotAvailable = "biometry_not_available"
12
+ case biometryNotEnrolled = "biometry_not_enrolled"
13
+ case biometryLockout = "biometry_lockout"
14
+ case passcodeNotSet = "passcode_not_set"
15
+ case secureKeyStorageUnavailable = "secure_key_storage_unavailable"
16
+ case keyNotFound = "key_not_found"
17
+ case keyInvalidated = "key_invalidated"
18
+ case keyGenerationFailed = "key_generation_failed"
19
+ case signingFailed = "signing_failed"
20
+ case storageFailed = "storage_failed"
21
+ case invalidArgument = "invalid_argument"
22
+ }
23
+
24
+ let code: Code
25
+ let message: String
26
+
27
+ init(_ code: Code, _ message: String) {
28
+ self.code = code
29
+ self.message = message
30
+ }
31
+
32
+ static func localAuthentication(_ error: Error?, fallback: Code) -> BiometricsError {
33
+ guard let error = error as NSError?, error.domain == LAErrorDomain else {
34
+ return BiometricsError(fallback, error?.localizedDescription ?? "Local authentication failed.")
35
+ }
36
+ return BiometricsError(code(forLAErrorCode: error.code) ?? fallback, error.localizedDescription)
37
+ }
38
+
39
+ static func security(_ error: Error, fallback: Code) -> BiometricsError {
40
+ let nsError = error as NSError
41
+ let code: Code?
42
+ switch nsError.domain {
43
+ case LAErrorDomain:
44
+ code = Self.code(forLAErrorCode: nsError.code)
45
+ case NSOSStatusErrorDomain:
46
+ code = Self.code(forStatus: OSStatus(truncatingIfNeeded: nsError.code))
47
+ case cryptoTokenKitErrorDomain:
48
+ code = Self.code(forCryptoTokenKitErrorCode: nsError.code)
49
+ default:
50
+ code = nil
51
+ }
52
+ return BiometricsError(code ?? fallback, nsError.localizedDescription)
53
+ }
54
+
55
+ static func status(_ status: OSStatus, fallback: Code) -> BiometricsError {
56
+ BiometricsError(code(forStatus: status) ?? fallback, statusMessage(status))
57
+ }
58
+
59
+ static func statusMessage(_ status: OSStatus) -> String {
60
+ let description = SecCopyErrorMessageString(status, nil) as String? ?? "Unknown error"
61
+ return "\(description) (OSStatus \(status))"
62
+ }
63
+
64
+ static func code(forLAErrorCode rawValue: Int) -> Code? {
65
+ switch LAError.Code(rawValue: rawValue) {
66
+ case .userCancel:
67
+ return .userCanceled
68
+ case .appCancel, .systemCancel:
69
+ return .systemCanceled
70
+ case .userFallback:
71
+ return .userFallback
72
+ case .authenticationFailed:
73
+ return .authenticationFailed
74
+ case .biometryNotAvailable:
75
+ return .biometryNotAvailable
76
+ case .biometryNotEnrolled:
77
+ return .biometryNotEnrolled
78
+ case .biometryLockout:
79
+ return .biometryLockout
80
+ case .passcodeNotSet:
81
+ return .passcodeNotSet
82
+ default:
83
+ return nil
84
+ }
85
+ }
86
+
87
+ static func code(forStatus status: OSStatus) -> Code? {
88
+ switch status {
89
+ case errSecUserCanceled:
90
+ return .userCanceled
91
+ case errSecAuthFailed:
92
+ return .authenticationFailed
93
+ case errSecInteractionNotAllowed:
94
+ return .biometryNotAvailable
95
+ case errSecItemNotFound:
96
+ return .keyNotFound
97
+ default:
98
+ return nil
99
+ }
100
+ }
101
+
102
+ private static let cryptoTokenKitErrorDomain = "CryptoTokenKit"
103
+
104
+ // TKError.Code values; the Secure Enclave reports these when the key is unusable.
105
+ static func code(forCryptoTokenKitErrorCode rawValue: Int) -> Code? {
106
+ switch rawValue {
107
+ case -4:
108
+ return .userCanceled
109
+ case -5:
110
+ return .authenticationFailed
111
+ case -6:
112
+ return .keyInvalidated
113
+ default:
114
+ return nil
115
+ }
116
+ }
117
+ }
@@ -0,0 +1,32 @@
1
+ require 'json'
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = 'ClerkExpoBiometrics'
7
+ s.version = package['version']
8
+ s.summary = package['description']
9
+ s.description = package['description']
10
+ s.license = package['license']
11
+ s.author = package['author']
12
+ s.homepage = package['homepage']
13
+ s.platforms = { :ios => '15.1' }
14
+ s.swift_version = '5.9'
15
+ s.source = { git: 'https://github.com/clerk/javascript.git' }
16
+ s.static_framework = true
17
+
18
+ s.dependency 'ExpoModulesCore'
19
+ s.frameworks = 'CryptoKit', 'LocalAuthentication', 'Security'
20
+
21
+ s.pod_target_xcconfig = {
22
+ 'DEFINES_MODULE' => 'YES',
23
+ 'SWIFT_COMPILATION_MODE' => 'wholemodule'
24
+ }
25
+
26
+ s.source_files = '*.swift'
27
+
28
+ s.test_spec 'Tests' do |test_spec|
29
+ test_spec.source_files = 'Tests/**/*.swift'
30
+ test_spec.resources = 'Tests/Resources/*.json'
31
+ end
32
+ end
@@ -0,0 +1,164 @@
1
+ import ExpoModulesCore
2
+ import Foundation
3
+
4
+ final class ClerkBiometricsException: Exception {
5
+ private let errorCode: String
6
+ private let message: String
7
+
8
+ init(_ error: BiometricsError) {
9
+ errorCode = error.code.rawValue
10
+ message = error.message
11
+ super.init()
12
+ }
13
+
14
+ override var code: String { errorCode }
15
+ override var reason: String { message }
16
+ }
17
+
18
+ struct BiometricAvailabilityResult: Record {
19
+ @Field var biometryType: String = "none"
20
+ @Field var canEvaluateBiometrics: Bool = false
21
+ @Field var canEvaluateDeviceOwner: Bool = false
22
+ @Field var errorCode: String?
23
+ @Field var secureKeyStorageAvailable: Bool = false
24
+ }
25
+
26
+ struct BiometricCredentialKeyResult: Record {
27
+ @Field var localKeyId: String = ""
28
+ @Field var publicKeyJwk: String = ""
29
+ }
30
+
31
+ struct BiometricCredentialRecordInput: Record {
32
+ @Field var id: String = ""
33
+ @Field var localKeyId: String = ""
34
+ @Field var userId: String = ""
35
+ @Field var appIdentifier: String = ""
36
+ @Field var identifierHint: String?
37
+ @Field var policy: String = ""
38
+ @Field var createdAt: Double = -1
39
+ @Field var updatedAt: Double = -1
40
+ }
41
+
42
+ struct SaveRecordOptions: Record {
43
+ @Field var removeOtherRecordsForApp: Bool = false
44
+ }
45
+
46
+ struct InstallationMarkerResult: Record {
47
+ @Field var wiped: Bool = false
48
+ }
49
+
50
+ public final class ClerkExpoBiometricsModule: Module {
51
+ private let keyManager = BiometricKeyManager()
52
+ private lazy var store = BiometricCredentialStore.live(keyManager: keyManager)
53
+
54
+ public func definition() -> ModuleDefinition {
55
+ Name("ClerkExpoBiometrics")
56
+
57
+ Function("getAppIdentifier") { () -> String in
58
+ Bundle.main.bundleIdentifier ?? ""
59
+ }
60
+
61
+ Function("hashIdentifierHint") { (hint: String) -> String? in
62
+ BiometricCredentialCoding.identifierHintSHA256(hint)
63
+ }
64
+
65
+ AsyncFunction("getAvailability") { () -> BiometricAvailabilityResult in
66
+ let availability = self.keyManager.availability()
67
+ let result = BiometricAvailabilityResult()
68
+ result.biometryType = availability.biometryType
69
+ result.canEvaluateBiometrics = availability.canEvaluateBiometrics
70
+ result.canEvaluateDeviceOwner = availability.canEvaluateDeviceOwner
71
+ result.errorCode = availability.errorCode?.rawValue
72
+ result.secureKeyStorageAvailable = availability.secureKeyStorageAvailable
73
+ return result
74
+ }
75
+
76
+ AsyncFunction("createKey") { (policy: String) throws -> BiometricCredentialKeyResult in
77
+ try Self.bridge {
78
+ guard let policy = BiometricCredentialPolicy(rawValue: policy) else {
79
+ throw BiometricsError(.invalidArgument, "Unknown biometric credential policy '\(policy)'.")
80
+ }
81
+ let key = try self.keyManager.createKey(policy: policy)
82
+ let result = BiometricCredentialKeyResult()
83
+ result.localKeyId = key.localKeyId
84
+ result.publicKeyJwk = key.publicKeyJWK
85
+ return result
86
+ }
87
+ }
88
+
89
+ AsyncFunction("sign") { (localKeyId: String, clientData: String, reason: String?) throws -> String in
90
+ try Self.bridge {
91
+ try self.keyManager.sign(localKeyId: localKeyId, clientData: clientData, reason: reason)
92
+ }
93
+ }
94
+
95
+ AsyncFunction("hasKey") { (localKeyId: String) throws -> Bool in
96
+ try Self.bridge { try self.keyManager.hasKey(localKeyId: localKeyId) }
97
+ }
98
+
99
+ AsyncFunction("deleteKey") { (localKeyId: String) throws in
100
+ try Self.bridge { try self.keyManager.deleteKey(localKeyId: localKeyId) }
101
+ }
102
+
103
+ // Store operations run on the main queue so they cannot interleave with ClerkKit's @MainActor store access.
104
+ AsyncFunction("listRecords") { () throws -> String in
105
+ try Self.bridge { try self.store.listRecordsJSON() }
106
+ }.runOnQueue(.main)
107
+
108
+ AsyncFunction("saveRecord") { (input: BiometricCredentialRecordInput, options: SaveRecordOptions) throws in
109
+ try Self.bridge {
110
+ try self.store.save(Self.record(from: input), removeOtherRecordsForApp: options.removeOtherRecordsForApp)
111
+ }
112
+ }.runOnQueue(.main)
113
+
114
+ AsyncFunction("deleteRecord") { (localKeyId: String) throws in
115
+ try Self.bridge { try self.store.deleteRecords(localKeyId: localKeyId) }
116
+ }.runOnQueue(.main)
117
+
118
+ AsyncFunction("ensureInstallationMarker") { () throws -> InstallationMarkerResult in
119
+ try Self.bridge {
120
+ let result = InstallationMarkerResult()
121
+ result.wiped = try self.store.ensureInstallationMarker()
122
+ return result
123
+ }
124
+ }.runOnQueue(.main)
125
+ }
126
+
127
+ static func record(from input: BiometricCredentialRecordInput) throws -> BiometricCredentialRecord {
128
+ for (name, value) in [
129
+ ("id", input.id),
130
+ ("localKeyId", input.localKeyId),
131
+ ("userId", input.userId),
132
+ ("appIdentifier", input.appIdentifier),
133
+ ] where value.isEmpty {
134
+ throw BiometricsError(.invalidArgument, "record.\(name) must be a non-empty string.")
135
+ }
136
+ guard let policy = BiometricCredentialPolicy(rawValue: input.policy) else {
137
+ throw BiometricsError(.invalidArgument, "Unknown biometric credential policy '\(input.policy)'.")
138
+ }
139
+ guard
140
+ BiometricCredentialRecord.isValidTimestamp(input.createdAt),
141
+ BiometricCredentialRecord.isValidTimestamp(input.updatedAt)
142
+ else {
143
+ throw BiometricsError(.invalidArgument, "record.createdAt and record.updatedAt must be milliseconds since the Unix epoch.")
144
+ }
145
+ return BiometricCredentialRecord(
146
+ id: input.id,
147
+ localKeyId: input.localKeyId,
148
+ userId: input.userId,
149
+ appIdentifier: input.appIdentifier,
150
+ identifierHint: input.identifierHint,
151
+ policy: policy,
152
+ createdAt: input.createdAt,
153
+ updatedAt: input.updatedAt
154
+ )
155
+ }
156
+
157
+ private static func bridge<T>(_ body: () throws -> T) throws -> T {
158
+ do {
159
+ return try body()
160
+ } catch let error as BiometricsError {
161
+ throw ClerkBiometricsException(error)
162
+ }
163
+ }
164
+ }