@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,335 @@
|
|
|
1
|
+
import CryptoKit
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
// Mirrors the format pinned by clerk-ios BiometricCredentialStorageContractTests. Any change here must stay
|
|
5
|
+
// byte-compatible with ClerkKit, which reads and writes the same keys, Keychain item, and UserDefaults marker.
|
|
6
|
+
|
|
7
|
+
enum BiometricCredentialPolicy: String, CaseIterable {
|
|
8
|
+
case biometryCurrentSet = "biometry_current_set"
|
|
9
|
+
case biometryAny = "biometry_any"
|
|
10
|
+
case biometryOrDevicePasscode = "biometry_or_device_passcode"
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
enum BiometricCredentialCoding {
|
|
14
|
+
static let applicationTagPrefix = "dev.clerk.trusted_device"
|
|
15
|
+
static let metadataAccount = "trustedDeviceCredentials"
|
|
16
|
+
static let installationMarkerPrefix = "com.clerk.trusted-device-installation-marker"
|
|
17
|
+
|
|
18
|
+
static func makeLocalKeyId() -> String {
|
|
19
|
+
"tdlk_" + UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
static func applicationTag(localKeyId: String) -> Data {
|
|
23
|
+
Data("\(applicationTagPrefix).\(localKeyId)".utf8)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
static func metadataService(infoDictionaryService: String?, bundleIdentifier: String?) -> String {
|
|
27
|
+
if let infoDictionaryService, !infoDictionaryService.isEmpty {
|
|
28
|
+
return infoDictionaryService
|
|
29
|
+
}
|
|
30
|
+
return bundleIdentifier ?? ""
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
static func installationMarkerKey(service: String?, accessGroup: String?, appIdentifier: String?) -> String {
|
|
34
|
+
[
|
|
35
|
+
installationMarkerPrefix,
|
|
36
|
+
encodeInstallationMarkerComponent(service),
|
|
37
|
+
encodeInstallationMarkerComponent(accessGroup),
|
|
38
|
+
encodeInstallationMarkerComponent(appIdentifier),
|
|
39
|
+
].joined(separator: ".")
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private static func encodeInstallationMarkerComponent(_ value: String?) -> String {
|
|
43
|
+
guard let value else { return "n" }
|
|
44
|
+
return "s\(value.utf8.count):\(value)"
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// Lowercase hex SHA-256 of the normalized hint, as clerk-android's storage contract v2 stores it; `nil` when it is empty.
|
|
48
|
+
static func identifierHintSHA256(_ identifierHint: String?) -> String? {
|
|
49
|
+
guard let normalized = BiometricCredentialRecord.normalizedIdentifierHint(identifierHint) else { return nil }
|
|
50
|
+
return SHA256.hash(data: Data(normalized.utf8)).map { String(format: "%02x", $0) }.joined()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
static func base64URLEncodedString<D: DataProtocol>(_ data: D) -> String {
|
|
54
|
+
Data(data)
|
|
55
|
+
.base64EncodedString()
|
|
56
|
+
.replacingOccurrences(of: "+", with: "-")
|
|
57
|
+
.replacingOccurrences(of: "/", with: "_")
|
|
58
|
+
.replacingOccurrences(of: "=", with: "")
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
static func publicKeyJWK(fromX963Representation representation: Data) throws -> String {
|
|
62
|
+
let bytes = [UInt8](representation)
|
|
63
|
+
guard bytes.count == 65, bytes[0] == 0x04 else {
|
|
64
|
+
throw BiometricsError(.keyGenerationFailed, "The public key is not an uncompressed P-256 point.")
|
|
65
|
+
}
|
|
66
|
+
let x = base64URLEncodedString(bytes[1 ..< 33])
|
|
67
|
+
let y = base64URLEncodedString(bytes[33 ..< 65])
|
|
68
|
+
return #"{"kty":"EC","crv":"P-256","x":"\#(x)","y":"\#(y)","alg":"ES256"}"#
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
static func rawES256Signature(fromDEREncoded signature: Data) throws -> Data {
|
|
72
|
+
let bytes = [UInt8](signature)
|
|
73
|
+
var offset = 0
|
|
74
|
+
|
|
75
|
+
guard try readDERByte(bytes, offset: &offset) == 0x30 else {
|
|
76
|
+
throw invalidSignature()
|
|
77
|
+
}
|
|
78
|
+
let sequenceLength = try readDERLength(bytes, offset: &offset)
|
|
79
|
+
guard sequenceLength == bytes.count - offset else {
|
|
80
|
+
throw invalidSignature()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let r = try readDERInteger(bytes, offset: &offset)
|
|
84
|
+
let s = try readDERInteger(bytes, offset: &offset)
|
|
85
|
+
guard offset == bytes.count else {
|
|
86
|
+
throw invalidSignature()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
var raw = try paddedES256Component(r)
|
|
90
|
+
raw.append(try paddedES256Component(s))
|
|
91
|
+
return raw
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private static func readDERByte(_ bytes: [UInt8], offset: inout Int) throws -> UInt8 {
|
|
95
|
+
guard offset < bytes.count else { throw invalidSignature() }
|
|
96
|
+
let byte = bytes[offset]
|
|
97
|
+
offset += 1
|
|
98
|
+
return byte
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private static func readDERLength(_ bytes: [UInt8], offset: inout Int) throws -> Int {
|
|
102
|
+
let first = try readDERByte(bytes, offset: &offset)
|
|
103
|
+
if first & 0x80 == 0 {
|
|
104
|
+
return Int(first)
|
|
105
|
+
}
|
|
106
|
+
let byteCount = Int(first & 0x7F)
|
|
107
|
+
guard byteCount > 0, byteCount <= MemoryLayout<Int>.size, byteCount <= bytes.count - offset else {
|
|
108
|
+
throw invalidSignature()
|
|
109
|
+
}
|
|
110
|
+
var length = 0
|
|
111
|
+
for _ in 0 ..< byteCount {
|
|
112
|
+
length = (length << 8) | Int(try readDERByte(bytes, offset: &offset))
|
|
113
|
+
}
|
|
114
|
+
return length
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private static func readDERInteger(_ bytes: [UInt8], offset: inout Int) throws -> [UInt8] {
|
|
118
|
+
guard try readDERByte(bytes, offset: &offset) == 0x02 else { throw invalidSignature() }
|
|
119
|
+
let length = try readDERLength(bytes, offset: &offset)
|
|
120
|
+
guard length > 0, length <= bytes.count - offset else { throw invalidSignature() }
|
|
121
|
+
let value = Array(bytes[offset ..< offset + length])
|
|
122
|
+
offset += length
|
|
123
|
+
return value
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private static func paddedES256Component(_ bytes: [UInt8]) throws -> Data {
|
|
127
|
+
guard let first = bytes.first, first & 0x80 == 0 else { throw invalidSignature() }
|
|
128
|
+
var component = bytes
|
|
129
|
+
while component.first == 0x00, component.count > 32 {
|
|
130
|
+
component.removeFirst()
|
|
131
|
+
}
|
|
132
|
+
guard !component.isEmpty, component.count <= 32 else { throw invalidSignature() }
|
|
133
|
+
var padded = Data(repeating: 0x00, count: 32 - component.count)
|
|
134
|
+
padded.append(contentsOf: component)
|
|
135
|
+
return padded
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private static func invalidSignature() -> BiometricsError {
|
|
139
|
+
BiometricsError(.signingFailed, "Security returned an invalid ES256 signature.")
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/// A record in the metadata Keychain item.
|
|
144
|
+
struct BiometricCredentialRecord: Equatable {
|
|
145
|
+
enum Field {
|
|
146
|
+
static let id = "id"
|
|
147
|
+
static let localKeyId = "localKeyId"
|
|
148
|
+
static let userId = "userId"
|
|
149
|
+
static let appIdentifier = "appIdentifier"
|
|
150
|
+
static let identifierHint = "identifierHint"
|
|
151
|
+
static let identifierHintSha256 = "identifierHintSha256"
|
|
152
|
+
static let policy = "policy"
|
|
153
|
+
static let createdAt = "createdAt"
|
|
154
|
+
static let updatedAt = "updatedAt"
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let id: String
|
|
158
|
+
let localKeyId: String
|
|
159
|
+
let userId: String
|
|
160
|
+
let appIdentifier: String
|
|
161
|
+
let identifierHint: String?
|
|
162
|
+
let policy: BiometricCredentialPolicy
|
|
163
|
+
/// Milliseconds since the Unix epoch.
|
|
164
|
+
let createdAt: Double
|
|
165
|
+
/// Milliseconds since the Unix epoch.
|
|
166
|
+
let updatedAt: Double
|
|
167
|
+
|
|
168
|
+
init(
|
|
169
|
+
id: String,
|
|
170
|
+
localKeyId: String,
|
|
171
|
+
userId: String,
|
|
172
|
+
appIdentifier: String,
|
|
173
|
+
identifierHint: String?,
|
|
174
|
+
policy: BiometricCredentialPolicy,
|
|
175
|
+
createdAt: Double,
|
|
176
|
+
updatedAt: Double
|
|
177
|
+
) {
|
|
178
|
+
self.id = id
|
|
179
|
+
self.localKeyId = localKeyId
|
|
180
|
+
self.userId = userId
|
|
181
|
+
self.appIdentifier = appIdentifier
|
|
182
|
+
self.identifierHint = Self.normalizedIdentifierHint(identifierHint)
|
|
183
|
+
self.policy = policy
|
|
184
|
+
self.createdAt = createdAt
|
|
185
|
+
self.updatedAt = updatedAt
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/// Decodes a stored record, returning `nil` for records ClerkKit treats as malformed.
|
|
189
|
+
init?(jsonObject object: [String: Any]) {
|
|
190
|
+
guard
|
|
191
|
+
let id = object[Field.id] as? String,
|
|
192
|
+
let localKeyId = object[Field.localKeyId] as? String,
|
|
193
|
+
let userId = object[Field.userId] as? String,
|
|
194
|
+
let appIdentifier = object[Field.appIdentifier] as? String,
|
|
195
|
+
let policyValue = object[Field.policy] as? String,
|
|
196
|
+
let policy = BiometricCredentialPolicy(rawValue: policyValue),
|
|
197
|
+
let createdAt = Self.number(object[Field.createdAt]),
|
|
198
|
+
let updatedAt = Self.number(object[Field.updatedAt])
|
|
199
|
+
else {
|
|
200
|
+
return nil
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let identifierHint: String?
|
|
204
|
+
switch object[Field.identifierHint] {
|
|
205
|
+
case .none, is NSNull:
|
|
206
|
+
identifierHint = nil
|
|
207
|
+
case let value as String:
|
|
208
|
+
identifierHint = value
|
|
209
|
+
default:
|
|
210
|
+
return nil
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
self.init(
|
|
214
|
+
id: id,
|
|
215
|
+
localKeyId: localKeyId,
|
|
216
|
+
userId: userId,
|
|
217
|
+
appIdentifier: appIdentifier,
|
|
218
|
+
identifierHint: identifierHint,
|
|
219
|
+
policy: policy,
|
|
220
|
+
createdAt: createdAt,
|
|
221
|
+
updatedAt: updatedAt
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/// The object written to the Keychain: integer milliseconds, `identifierHint` omitted when absent.
|
|
226
|
+
var jsonObject: [String: Any] {
|
|
227
|
+
var object: [String: Any] = [
|
|
228
|
+
Field.id: id,
|
|
229
|
+
Field.localKeyId: localKeyId,
|
|
230
|
+
Field.userId: userId,
|
|
231
|
+
Field.appIdentifier: appIdentifier,
|
|
232
|
+
Field.policy: policy.rawValue,
|
|
233
|
+
Field.createdAt: Int64(createdAt.rounded()),
|
|
234
|
+
Field.updatedAt: Int64(updatedAt.rounded()),
|
|
235
|
+
]
|
|
236
|
+
if let identifierHint {
|
|
237
|
+
object[Field.identifierHint] = identifierHint
|
|
238
|
+
}
|
|
239
|
+
return object
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
static func normalizedIdentifierHint(_ identifierHint: String?) -> String? {
|
|
243
|
+
guard let identifierHint else { return nil }
|
|
244
|
+
let normalized = identifierHint.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
245
|
+
return normalized.isEmpty ? nil : normalized
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
static func isValidTimestamp(_ value: Double) -> Bool {
|
|
249
|
+
value.isFinite && value >= 0 && value < 9.0e15
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private static func number(_ value: Any?) -> Double? {
|
|
253
|
+
guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() else {
|
|
254
|
+
return nil
|
|
255
|
+
}
|
|
256
|
+
return number.doubleValue
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
enum BiometricCredentialRecordList {
|
|
261
|
+
/// 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.
|
|
262
|
+
static func decode(_ data: Data?) throws -> [[String: Any]] {
|
|
263
|
+
guard let data else { return [] }
|
|
264
|
+
let object: Any
|
|
265
|
+
do {
|
|
266
|
+
object = try JSONSerialization.jsonObject(with: data)
|
|
267
|
+
} catch {
|
|
268
|
+
throw BiometricsError(.storageFailed, "Biometric credential metadata is not valid JSON.")
|
|
269
|
+
}
|
|
270
|
+
guard let records = object as? [[String: Any]] else {
|
|
271
|
+
throw BiometricsError(.storageFailed, "Biometric credential metadata is not an array.")
|
|
272
|
+
}
|
|
273
|
+
return records
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/// Serializes the list, or returns `nil` when it is empty so the caller deletes the item instead of writing `[]`.
|
|
277
|
+
static func encode(_ records: [[String: Any]]) throws -> Data? {
|
|
278
|
+
guard !records.isEmpty else { return nil }
|
|
279
|
+
do {
|
|
280
|
+
return try JSONSerialization.data(withJSONObject: records)
|
|
281
|
+
} catch {
|
|
282
|
+
throw BiometricsError(.storageFailed, "Biometric credential metadata could not be encoded.")
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/// Well-formed records with their stored fields passed through, `identifierHint` normalized as ClerkKit reads it, and
|
|
287
|
+
/// its `identifierHintSha256`.
|
|
288
|
+
static func listable(_ records: [[String: Any]]) -> [[String: Any]] {
|
|
289
|
+
records.compactMap { object in
|
|
290
|
+
guard let record = BiometricCredentialRecord(jsonObject: object) else { return nil }
|
|
291
|
+
var listed = object
|
|
292
|
+
if let identifierHint = record.identifierHint {
|
|
293
|
+
listed[BiometricCredentialRecord.Field.identifierHint] = identifierHint
|
|
294
|
+
} else {
|
|
295
|
+
listed.removeValue(forKey: BiometricCredentialRecord.Field.identifierHint)
|
|
296
|
+
}
|
|
297
|
+
listed[BiometricCredentialRecord.Field.identifierHintSha256] =
|
|
298
|
+
BiometricCredentialCoding.identifierHintSHA256(record.identifierHint) ?? NSNull()
|
|
299
|
+
return listed
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
struct SaveResult {
|
|
304
|
+
let records: [[String: Any]]
|
|
305
|
+
/// Keys of records with the same `id` that pointed at a different key.
|
|
306
|
+
let replacedLocalKeyIds: [String]
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/// Replaces any record with the same `id`, drops malformed records for the same app, and keeps everything else untouched.
|
|
310
|
+
static func saving(_ record: BiometricCredentialRecord, into records: [[String: Any]]) -> SaveResult {
|
|
311
|
+
var kept: [[String: Any]] = []
|
|
312
|
+
var replacedLocalKeyIds: [String] = []
|
|
313
|
+
|
|
314
|
+
for object in records {
|
|
315
|
+
if object[BiometricCredentialRecord.Field.id] as? String == record.id {
|
|
316
|
+
if let localKeyId = object[BiometricCredentialRecord.Field.localKeyId] as? String,
|
|
317
|
+
localKeyId != record.localKeyId
|
|
318
|
+
{
|
|
319
|
+
replacedLocalKeyIds.append(localKeyId)
|
|
320
|
+
}
|
|
321
|
+
continue
|
|
322
|
+
}
|
|
323
|
+
guard object[BiometricCredentialRecord.Field.appIdentifier] as? String == record.appIdentifier else {
|
|
324
|
+
kept.append(object)
|
|
325
|
+
continue
|
|
326
|
+
}
|
|
327
|
+
if BiometricCredentialRecord(jsonObject: object) != nil {
|
|
328
|
+
kept.append(object)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
kept.append(record.jsonObject)
|
|
333
|
+
return SaveResult(records: kept, replacedLocalKeyIds: replacedLocalKeyIds)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Security
|
|
3
|
+
|
|
4
|
+
protocol BiometricCredentialMetadataStorage {
|
|
5
|
+
func read() throws -> Data?
|
|
6
|
+
func write(_ data: Data) throws
|
|
7
|
+
func delete() throws
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/// The generic-password item ClerkKit's `SystemKeychain` uses for biometric credential metadata, with no access group.
|
|
11
|
+
struct KeychainMetadataStorage: BiometricCredentialMetadataStorage {
|
|
12
|
+
let service: String
|
|
13
|
+
var account: String = BiometricCredentialCoding.metadataAccount
|
|
14
|
+
|
|
15
|
+
func read() throws -> Data? {
|
|
16
|
+
var query = baseQuery()
|
|
17
|
+
query[kSecReturnData as String] = true
|
|
18
|
+
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
|
19
|
+
|
|
20
|
+
var result: CFTypeRef?
|
|
21
|
+
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
22
|
+
switch status {
|
|
23
|
+
case errSecSuccess:
|
|
24
|
+
return result as? Data
|
|
25
|
+
case errSecItemNotFound:
|
|
26
|
+
return nil
|
|
27
|
+
default:
|
|
28
|
+
throw BiometricsError(.storageFailed, BiometricsError.statusMessage(status))
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func write(_ data: Data) throws {
|
|
33
|
+
var addQuery = baseQuery()
|
|
34
|
+
addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
|
35
|
+
addQuery[kSecValueData as String] = data
|
|
36
|
+
|
|
37
|
+
let status = SecItemAdd(addQuery as CFDictionary, nil)
|
|
38
|
+
switch status {
|
|
39
|
+
case errSecSuccess:
|
|
40
|
+
return
|
|
41
|
+
case errSecDuplicateItem:
|
|
42
|
+
let attributes: [String: Any] = [
|
|
43
|
+
kSecValueData as String: data,
|
|
44
|
+
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
|
45
|
+
]
|
|
46
|
+
let updateStatus = SecItemUpdate(baseQuery() as CFDictionary, attributes as CFDictionary)
|
|
47
|
+
guard updateStatus == errSecSuccess else {
|
|
48
|
+
throw BiometricsError(.storageFailed, BiometricsError.statusMessage(updateStatus))
|
|
49
|
+
}
|
|
50
|
+
default:
|
|
51
|
+
throw BiometricsError(.storageFailed, BiometricsError.statusMessage(status))
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
func delete() throws {
|
|
56
|
+
let status = SecItemDelete(baseQuery() as CFDictionary)
|
|
57
|
+
switch status {
|
|
58
|
+
case errSecSuccess, errSecItemNotFound:
|
|
59
|
+
return
|
|
60
|
+
default:
|
|
61
|
+
throw BiometricsError(.storageFailed, BiometricsError.statusMessage(status))
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
func baseQuery() -> [String: Any] {
|
|
66
|
+
[
|
|
67
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
68
|
+
kSecAttrService as String: service,
|
|
69
|
+
kSecAttrAccount as String: account,
|
|
70
|
+
]
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/// Read-modify-write operations on the shared metadata item. Every operation runs under one lock and first applies the
|
|
75
|
+
/// reinstall marker, so records written here are never wiped by ClerkKit's first configuration in this installation.
|
|
76
|
+
final class BiometricCredentialStore {
|
|
77
|
+
private let storage: BiometricCredentialMetadataStorage
|
|
78
|
+
private let deleteKey: (String) throws -> Void
|
|
79
|
+
private let userDefaults: UserDefaults
|
|
80
|
+
private let service: String
|
|
81
|
+
private let appIdentifier: String?
|
|
82
|
+
private let lock = NSRecursiveLock()
|
|
83
|
+
|
|
84
|
+
init(
|
|
85
|
+
storage: BiometricCredentialMetadataStorage,
|
|
86
|
+
deleteKey: @escaping (String) throws -> Void,
|
|
87
|
+
userDefaults: UserDefaults,
|
|
88
|
+
service: String,
|
|
89
|
+
appIdentifier: String?
|
|
90
|
+
) {
|
|
91
|
+
self.storage = storage
|
|
92
|
+
self.deleteKey = deleteKey
|
|
93
|
+
self.userDefaults = userDefaults
|
|
94
|
+
self.service = service
|
|
95
|
+
self.appIdentifier = appIdentifier
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
static func live(keyManager: BiometricKeyManager, bundle: Bundle = .main) -> BiometricCredentialStore {
|
|
99
|
+
let service = BiometricCredentialCoding.metadataService(
|
|
100
|
+
infoDictionaryService: bundle.object(forInfoDictionaryKey: "ClerkKeychainService") as? String,
|
|
101
|
+
bundleIdentifier: bundle.bundleIdentifier
|
|
102
|
+
)
|
|
103
|
+
return BiometricCredentialStore(
|
|
104
|
+
storage: KeychainMetadataStorage(service: service),
|
|
105
|
+
deleteKey: { try keyManager.deleteKey(localKeyId: $0) },
|
|
106
|
+
userDefaults: .standard,
|
|
107
|
+
service: service,
|
|
108
|
+
appIdentifier: bundle.bundleIdentifier
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
var installationMarkerKey: String {
|
|
113
|
+
BiometricCredentialCoding.installationMarkerKey(service: service, accessGroup: nil, appIdentifier: appIdentifier)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/// Returns `true` when the marker was missing and this app's records and keys from a previous installation were deleted.
|
|
117
|
+
@discardableResult
|
|
118
|
+
func ensureInstallationMarker() throws -> Bool {
|
|
119
|
+
lock.lock()
|
|
120
|
+
defer { lock.unlock() }
|
|
121
|
+
|
|
122
|
+
guard appIdentifier != nil else { return false }
|
|
123
|
+
let markerKey = installationMarkerKey
|
|
124
|
+
if userDefaults.object(forKey: markerKey) as? Bool == true {
|
|
125
|
+
return false
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let records = try readRecords()
|
|
129
|
+
var remaining: [[String: Any]] = []
|
|
130
|
+
var keyDeletionError: Error?
|
|
131
|
+
for record in records {
|
|
132
|
+
guard record[BiometricCredentialRecord.Field.appIdentifier] as? String == appIdentifier else {
|
|
133
|
+
remaining.append(record)
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
if let localKeyId = record[BiometricCredentialRecord.Field.localKeyId] as? String {
|
|
137
|
+
do {
|
|
138
|
+
try deleteKey(localKeyId)
|
|
139
|
+
} catch {
|
|
140
|
+
keyDeletionError = keyDeletionError ?? error
|
|
141
|
+
remaining.append(record)
|
|
142
|
+
continue
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if remaining.count != records.count {
|
|
148
|
+
try persist(remaining)
|
|
149
|
+
}
|
|
150
|
+
if let keyDeletionError {
|
|
151
|
+
throw keyDeletionError
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
userDefaults.set(true, forKey: markerKey)
|
|
155
|
+
return true
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/// JSON array of every well-formed record, for every app identifier.
|
|
159
|
+
func listRecordsJSON() throws -> String {
|
|
160
|
+
lock.lock()
|
|
161
|
+
defer { lock.unlock() }
|
|
162
|
+
|
|
163
|
+
try ensureInstallationMarker()
|
|
164
|
+
let data = try JSONSerialization.data(withJSONObject: BiometricCredentialRecordList.listable(readRecords()))
|
|
165
|
+
return String(decoding: data, as: UTF8.self)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
func save(_ record: BiometricCredentialRecord, removeOtherRecordsForApp: Bool) throws {
|
|
169
|
+
lock.lock()
|
|
170
|
+
defer { lock.unlock() }
|
|
171
|
+
|
|
172
|
+
try ensureInstallationMarker()
|
|
173
|
+
let result = BiometricCredentialRecordList.saving(record, into: try readRecords())
|
|
174
|
+
try persist(result.records)
|
|
175
|
+
|
|
176
|
+
for localKeyId in result.replacedLocalKeyIds where localKeyId != record.localKeyId {
|
|
177
|
+
try? deleteKey(localKeyId)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
guard removeOtherRecordsForApp else { return }
|
|
181
|
+
|
|
182
|
+
var remaining: [[String: Any]] = []
|
|
183
|
+
for object in result.records {
|
|
184
|
+
let isOtherRecordForApp = object[BiometricCredentialRecord.Field.appIdentifier] as? String == record.appIdentifier
|
|
185
|
+
&& object[BiometricCredentialRecord.Field.id] as? String != record.id
|
|
186
|
+
guard isOtherRecordForApp else {
|
|
187
|
+
remaining.append(object)
|
|
188
|
+
continue
|
|
189
|
+
}
|
|
190
|
+
if let localKeyId = object[BiometricCredentialRecord.Field.localKeyId] as? String, localKeyId != record.localKeyId {
|
|
191
|
+
do {
|
|
192
|
+
try deleteKey(localKeyId)
|
|
193
|
+
} catch {
|
|
194
|
+
remaining.append(object)
|
|
195
|
+
continue
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if remaining.count != result.records.count {
|
|
200
|
+
try? persist(remaining)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/// Deletes the key, then every record that references it. When the key cannot be deleted the records are kept.
|
|
205
|
+
func deleteRecords(localKeyId: String) throws {
|
|
206
|
+
lock.lock()
|
|
207
|
+
defer { lock.unlock() }
|
|
208
|
+
|
|
209
|
+
try ensureInstallationMarker()
|
|
210
|
+
try deleteKey(localKeyId)
|
|
211
|
+
|
|
212
|
+
let records = try readRecords()
|
|
213
|
+
let remaining = records.filter { $0[BiometricCredentialRecord.Field.localKeyId] as? String != localKeyId }
|
|
214
|
+
if remaining.count != records.count {
|
|
215
|
+
try persist(remaining)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private func readRecords() throws -> [[String: Any]] {
|
|
220
|
+
try BiometricCredentialRecordList.decode(storage.read())
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private func persist(_ records: [[String: Any]]) throws {
|
|
224
|
+
if let data = try BiometricCredentialRecordList.encode(records) {
|
|
225
|
+
try storage.write(data)
|
|
226
|
+
} else {
|
|
227
|
+
try storage.delete()
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|