@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,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
+ }