@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.
- package/LICENSE +21 -0
- package/README.md +96 -0
- package/android/build.gradle +49 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/expo/modules/clerk/biometrics/BiometricCredentialCoding.kt +147 -0
- package/android/src/main/java/expo/modules/clerk/biometrics/BiometricCredentialFileStore.kt +304 -0
- package/android/src/main/java/expo/modules/clerk/biometrics/BiometricCredentialStore.kt +96 -0
- package/android/src/main/java/expo/modules/clerk/biometrics/BiometricKeyManager.kt +216 -0
- package/android/src/main/java/expo/modules/clerk/biometrics/BiometricsError.kt +58 -0
- package/android/src/main/java/expo/modules/clerk/biometrics/ClerkExpoBiometricsModule.kt +139 -0
- package/android/src/test/java/expo/modules/clerk/biometrics/BiometricCredentialStorageContractTest.kt +450 -0
- package/android/src/test/java/expo/modules/clerk/biometrics/BiometricCredentialStoreTest.kt +179 -0
- package/android/src/test/java/expo/modules/clerk/biometrics/BiometricKeyManagerTest.kt +49 -0
- package/android/src/test/resources/biometric-credential-storage/v2/biometric_credentials.v2.json +50 -0
- package/android/src/test/resources/robolectric.properties +1 -0
- package/dist/ClerkExpoBiometricsModule.d.ts +18 -0
- package/dist/ClerkExpoBiometricsModule.d.ts.map +1 -0
- package/dist/ClerkExpoBiometricsModule.js +26 -0
- package/dist/ClerkExpoBiometricsModule.js.map +1 -0
- package/dist/errors.d.ts +11 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +75 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +57 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +204 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +85 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +17 -0
- package/dist/types.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/BiometricCredentialCoding.swift +335 -0
- package/ios/BiometricCredentialStore.swift +230 -0
- package/ios/BiometricKeyManager.swift +228 -0
- package/ios/BiometricsError.swift +117 -0
- package/ios/ClerkExpoBiometrics.podspec +32 -0
- package/ios/ClerkExpoBiometricsModule.swift +164 -0
- package/ios/Tests/BiometricCredentialContractTests.swift +286 -0
- package/ios/Tests/BiometricCredentialStoreTests.swift +323 -0
- package/ios/Tests/BiometricSystemIntegrationTests.swift +169 -0
- package/ios/Tests/Resources/BiometricCredentialStorageContractV1.json +31 -0
- 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
|
+
}
|