@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,286 @@
1
+ import CryptoKit
2
+ import Foundation
3
+ import Security
4
+ import XCTest
5
+ @testable import ClerkExpoBiometrics
6
+
7
+ /// Mirrors clerk-ios `BiometricCredentialStorageContractTests` (contract version 1) with the same vectors and fixture.
8
+ final class BiometricCredentialContractTests: XCTestCase {
9
+ private let fixtureLocalKeyId = "tdlk_0123456789abcdef0123456789abcdef"
10
+
11
+ // MARK: - Secure Enclave key
12
+
13
+ func testPrivateKeyQueryUsesContractApplicationTagWithoutAccessGroup() {
14
+ let query = BiometricKeyManager.privateKeyQuery(localKeyId: fixtureLocalKeyId)
15
+
16
+ XCTAssertEqual(query[kSecClass as String] as? String, kSecClassKey as String)
17
+ XCTAssertEqual(query[kSecAttrKeyClass as String] as? String, kSecAttrKeyClassPrivate as String)
18
+ XCTAssertEqual(query[kSecAttrKeyType as String] as? String, kSecAttrKeyTypeECSECPrimeRandom as String)
19
+ XCTAssertEqual(
20
+ query[kSecAttrApplicationTag as String] as? Data,
21
+ Data("dev.clerk.trusted_device.tdlk_0123456789abcdef0123456789abcdef".utf8)
22
+ )
23
+ XCTAssertNil(query[kSecAttrAccessGroup as String])
24
+ XCTAssertNil(query[kSecAttrLabel as String])
25
+ XCTAssertNil(query[kSecAttrApplicationLabel as String])
26
+ XCTAssertEqual(query.count, 4)
27
+ }
28
+
29
+ func testPrivateKeyAttributesMatchContract() throws {
30
+ let attributes = BiometricKeyManager.privateKeyAttributes(
31
+ localKeyId: fixtureLocalKeyId,
32
+ accessControl: try BiometricKeyManager.accessControl(policy: .biometryCurrentSet)
33
+ )
34
+
35
+ XCTAssertEqual(attributes[kSecAttrKeyType as String] as? String, kSecAttrKeyTypeECSECPrimeRandom as String)
36
+ XCTAssertEqual(attributes[kSecAttrKeySizeInBits as String] as? Int, 256)
37
+ XCTAssertEqual(attributes[kSecAttrTokenID as String] as? String, kSecAttrTokenIDSecureEnclave as String)
38
+ XCTAssertNil(attributes[kSecAttrAccessGroup as String])
39
+ XCTAssertEqual(attributes.count, 4)
40
+
41
+ let privateKeyAttributes = try XCTUnwrap(attributes[kSecPrivateKeyAttrs as String] as? [String: Any])
42
+ XCTAssertEqual(privateKeyAttributes[kSecAttrIsPermanent as String] as? Bool, true)
43
+ XCTAssertEqual(
44
+ privateKeyAttributes[kSecAttrApplicationTag as String] as? Data,
45
+ Data("dev.clerk.trusted_device.tdlk_0123456789abcdef0123456789abcdef".utf8)
46
+ )
47
+ XCTAssertNotNil(privateKeyAttributes[kSecAttrAccessControl as String])
48
+ XCTAssertNil(privateKeyAttributes[kSecAttrAccessGroup as String])
49
+ XCTAssertEqual(privateKeyAttributes.count, 3)
50
+ }
51
+
52
+ func testPolicyRawValuesAndAccessControlFlagsMatchContract() throws {
53
+ XCTAssertEqual(BiometricCredentialPolicy.biometryCurrentSet.rawValue, "biometry_current_set")
54
+ XCTAssertEqual(BiometricCredentialPolicy.biometryAny.rawValue, "biometry_any")
55
+ XCTAssertEqual(BiometricCredentialPolicy.biometryOrDevicePasscode.rawValue, "biometry_or_device_passcode")
56
+
57
+ XCTAssertEqual(BiometricKeyManager.accessControlFlags(for: .biometryCurrentSet), [.privateKeyUsage, .biometryCurrentSet])
58
+ XCTAssertEqual(BiometricKeyManager.accessControlFlags(for: .biometryAny), [.privateKeyUsage, .biometryAny])
59
+ XCTAssertEqual(BiometricKeyManager.accessControlFlags(for: .biometryOrDevicePasscode), [.privateKeyUsage, .userPresence])
60
+
61
+ for policy in BiometricCredentialPolicy.allCases {
62
+ XCTAssertNoThrow(try BiometricKeyManager.accessControl(policy: policy))
63
+ }
64
+ }
65
+
66
+ func testLocalKeyIdFormat() {
67
+ let localKeyId = BiometricCredentialCoding.makeLocalKeyId()
68
+
69
+ XCTAssertTrue(localKeyId.hasPrefix("tdlk_"))
70
+ let suffix = localKeyId.dropFirst("tdlk_".count)
71
+ XCTAssertEqual(suffix.count, 32)
72
+ XCTAssertTrue(suffix.allSatisfy { "0123456789abcdef".contains($0) })
73
+ XCTAssertNotEqual(localKeyId, BiometricCredentialCoding.makeLocalKeyId())
74
+ }
75
+
76
+ // MARK: - Signing and public key
77
+
78
+ func testPublicKeyJWKMatchesContractFormat() throws {
79
+ var representation = Data([0x04])
80
+ representation.append(Data(repeating: 0x01, count: 32))
81
+ representation.append(Data(repeating: 0x02, count: 32))
82
+
83
+ XCTAssertEqual(
84
+ try BiometricCredentialCoding.publicKeyJWK(fromX963Representation: representation),
85
+ #"{"kty":"EC","crv":"P-256","x":"AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE","y":"AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI","alg":"ES256"}"#
86
+ )
87
+ }
88
+
89
+ func testPublicKeyJWKRejectsCompressedOrShortKeys() {
90
+ XCTAssertThrowsError(try BiometricCredentialCoding.publicKeyJWK(fromX963Representation: Data([0x02] + Array(repeating: 1, count: 32))))
91
+ XCTAssertThrowsError(try BiometricCredentialCoding.publicKeyJWK(fromX963Representation: Data([0x05] + Array(repeating: 1, count: 64))))
92
+ }
93
+
94
+ func testSignatureIsRawRAndSEncodedAsUnpaddedBase64URL() throws {
95
+ let rComponent: [UInt8] = [0x00, 0x80] + Array(repeating: 0xAA, count: 31)
96
+ let sComponent: [UInt8] = Array(repeating: 0x11, count: 31)
97
+ let der = Data([0x30, 0x44, 0x02, UInt8(rComponent.count)] + rComponent + [0x02, UInt8(sComponent.count)] + sComponent)
98
+
99
+ let raw = try BiometricCredentialCoding.rawES256Signature(fromDEREncoded: der)
100
+
101
+ XCTAssertEqual(raw.count, 64)
102
+ XCTAssertEqual(
103
+ BiometricCredentialCoding.base64URLEncodedString(raw),
104
+ "gKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqoAEREREREREREREREREREREREREREREREREREREREREQ"
105
+ )
106
+ }
107
+
108
+ func testRawSignatureRejectsMalformedDER() {
109
+ let valid: [UInt8] = [0x30, 0x06, 0x02, 0x01, 0x01, 0x02, 0x01, 0x02]
110
+ XCTAssertNoThrow(try BiometricCredentialCoding.rawES256Signature(fromDEREncoded: Data(valid)))
111
+
112
+ let malformed: [[UInt8]] = [
113
+ [],
114
+ [0x31] + valid.dropFirst(),
115
+ [0x30, 0x07] + valid.dropFirst(2),
116
+ valid + [0x00],
117
+ [0x30, 0x06, 0x02, 0x01, 0x81, 0x02, 0x01, 0x02],
118
+ [0x30, 0x06, 0x02, 0x00, 0x02, 0x02, 0x01, 0x02],
119
+ [0x30, 0x25, 0x02, 0x21, 0x01] + Array(repeating: 0x01, count: 32) + [0x02, 0x01, 0x02],
120
+ ]
121
+ for bytes in malformed {
122
+ XCTAssertThrowsError(try BiometricCredentialCoding.rawES256Signature(fromDEREncoded: Data(bytes)), "\(bytes)") { error in
123
+ XCTAssertEqual((error as? BiometricsError)?.code, .signingFailed)
124
+ }
125
+ }
126
+ }
127
+
128
+ func testSecuritySignatureConvertsToVerifiableRawSignature() throws {
129
+ var error: Unmanaged<CFError>?
130
+ let attributes: [String: Any] = [
131
+ kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
132
+ kSecAttrKeySizeInBits as String: 256,
133
+ ]
134
+ let privateKey = try XCTUnwrap(SecKeyCreateRandomKey(attributes as CFDictionary, &error))
135
+ let publicKey = try XCTUnwrap(SecKeyCopyPublicKey(privateKey))
136
+ let publicKeyData = try XCTUnwrap(SecKeyCopyExternalRepresentation(publicKey, &error) as Data?)
137
+ let clientData = #"{"challenge":"abc","nonce":"é"}"#
138
+
139
+ for _ in 0 ..< 32 {
140
+ let der = try XCTUnwrap(SecKeyCreateSignature(
141
+ privateKey,
142
+ .ecdsaSignatureMessageX962SHA256,
143
+ Data(clientData.utf8) as CFData,
144
+ &error
145
+ ) as Data?)
146
+ let raw = try BiometricCredentialCoding.rawES256Signature(fromDEREncoded: der)
147
+
148
+ XCTAssertEqual(raw.count, 64)
149
+ let signature = try P256.Signing.ECDSASignature(rawRepresentation: raw)
150
+ let verifyingKey = try P256.Signing.PublicKey(x963Representation: publicKeyData)
151
+ XCTAssertTrue(verifyingKey.isValidSignature(signature, for: Data(clientData.utf8)))
152
+ }
153
+
154
+ let jwk = try BiometricCredentialCoding.publicKeyJWK(fromX963Representation: publicKeyData)
155
+ let decoded = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(jwk.utf8)) as? [String: String])
156
+ XCTAssertEqual(decoded["x"], BiometricCredentialCoding.base64URLEncodedString(publicKeyData[1 ..< 33]))
157
+ XCTAssertEqual(decoded["y"], BiometricCredentialCoding.base64URLEncodedString(publicKeyData[33 ..< 65]))
158
+ XCTAssertFalse(jwk.contains("="))
159
+ }
160
+
161
+ // MARK: - Metadata Keychain item
162
+
163
+ func testMetadataItemQueryMatchesContract() {
164
+ let query = KeychainMetadataStorage(service: "com.clerk.example").baseQuery()
165
+
166
+ XCTAssertEqual(query[kSecClass as String] as? String, kSecClassGenericPassword as String)
167
+ XCTAssertEqual(query[kSecAttrService as String] as? String, "com.clerk.example")
168
+ XCTAssertEqual(query[kSecAttrAccount as String] as? String, "trustedDeviceCredentials")
169
+ XCTAssertNil(query[kSecAttrAccessGroup as String])
170
+ XCTAssertEqual(query.count, 3)
171
+ }
172
+
173
+ func testMetadataServicePrefersNonEmptyClerkKeychainService() {
174
+ XCTAssertEqual(BiometricCredentialCoding.metadataService(infoDictionaryService: "com.example.shared", bundleIdentifier: "com.example.app"), "com.example.shared")
175
+ XCTAssertEqual(BiometricCredentialCoding.metadataService(infoDictionaryService: "", bundleIdentifier: "com.example.app"), "com.example.app")
176
+ XCTAssertEqual(BiometricCredentialCoding.metadataService(infoDictionaryService: nil, bundleIdentifier: "com.example.app"), "com.example.app")
177
+ XCTAssertEqual(BiometricCredentialCoding.metadataService(infoDictionaryService: nil, bundleIdentifier: nil), "")
178
+ }
179
+
180
+ func testV1FixtureDecodesToExpectedRecords() throws {
181
+ let records = try BiometricCredentialRecordList.decode(Self.fixtureData())
182
+
183
+ XCTAssertEqual(records.compactMap(BiometricCredentialRecord.init(jsonObject:)), Self.fixtureRecords)
184
+ }
185
+
186
+ func testExpectedRecordsEncodeToV1Fixture() throws {
187
+ var records: [[String: Any]] = []
188
+ for record in Self.fixtureRecords {
189
+ records = BiometricCredentialRecordList.saving(record, into: records).records
190
+ }
191
+
192
+ let stored = try XCTUnwrap(BiometricCredentialRecordList.encode(records))
193
+ let storedJSON = try JSONSerialization.jsonObject(with: stored) as? NSArray
194
+ let fixtureJSON = try JSONSerialization.jsonObject(with: Self.fixtureData()) as? NSArray
195
+ XCTAssertNotNil(storedJSON)
196
+ XCTAssertEqual(storedJSON, fixtureJSON)
197
+ }
198
+
199
+ func testRecordFieldNamesAndIntegerMilliseconds() throws {
200
+ let data = try XCTUnwrap(BiometricCredentialRecordList.encode(Self.fixtureRecords.prefix(2).map(\.jsonObject)))
201
+ let records = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [[String: Any]])
202
+
203
+ XCTAssertEqual(Set(records[0].keys), ["id", "localKeyId", "userId", "appIdentifier", "identifierHint", "policy", "createdAt", "updatedAt"])
204
+ XCTAssertEqual(Set(records[1].keys), ["id", "localKeyId", "userId", "appIdentifier", "policy", "createdAt", "updatedAt"])
205
+ XCTAssertEqual((records[0]["createdAt"] as? NSNumber)?.int64Value, 1_714_000_000_500)
206
+ XCTAssertTrue(String(decoding: data, as: UTF8.self).contains(#""createdAt":1714000000500"#))
207
+ }
208
+
209
+ func testReadersIgnoreUnknownFieldsAndNormalizeIdentifierHints() throws {
210
+ let json = """
211
+ [{"id":"tdc_future","localKeyId":"tdlk_future","userId":"user_1","appIdentifier":"com.clerk.example",\
212
+ "identifierHint":" User@Example.COM ","policy":"biometry_any","createdAt":1714000000000.25,\
213
+ "updatedAt":1714000000000,"futureField":{"nested":true}}]
214
+ """
215
+ let listed = BiometricCredentialRecordList.listable(try BiometricCredentialRecordList.decode(Data(json.utf8)))
216
+
217
+ XCTAssertEqual(listed.count, 1)
218
+ XCTAssertEqual(listed[0]["identifierHint"] as? String, "user@example.com")
219
+ XCTAssertEqual(listed[0]["futureField"] as? [String: Bool], ["nested": true])
220
+ XCTAssertEqual((listed[0]["createdAt"] as? NSNumber)?.doubleValue, 1_714_000_000_000.25)
221
+ }
222
+
223
+ func testIdentifierHintHashMatchesAndroidContractVector() {
224
+ XCTAssertEqual(
225
+ BiometricCredentialCoding.identifierHintSHA256(" User@Example.COM\n"),
226
+ "b4c9a289323b21a01c3e940f150eb9b8c542587f1abfd8f0e1cc1ffc5e475514"
227
+ )
228
+ XCTAssertNil(BiometricCredentialCoding.identifierHintSHA256(" \n\t"))
229
+ XCTAssertNil(BiometricCredentialCoding.identifierHintSHA256(nil))
230
+ }
231
+
232
+ func testInstallationMarkerKeyMatchesContractFormat() {
233
+ XCTAssertEqual(
234
+ BiometricCredentialCoding.installationMarkerKey(service: "com.clerk.example", accessGroup: nil, appIdentifier: "com.clerk.example"),
235
+ "com.clerk.trusted-device-installation-marker.s17:com.clerk.example.n.s17:com.clerk.example"
236
+ )
237
+ XCTAssertEqual(
238
+ BiometricCredentialCoding.installationMarkerKey(service: "com.clerk.é", accessGroup: "TEAMID.com.clerk.shared", appIdentifier: "com.clerk.example"),
239
+ "com.clerk.trusted-device-installation-marker.s12:com.clerk.é.s23:TEAMID.com.clerk.shared.s17:com.clerk.example"
240
+ )
241
+ XCTAssertEqual(
242
+ BiometricCredentialCoding.installationMarkerKey(service: "com.clerk.example", accessGroup: "", appIdentifier: "com.clerk.example"),
243
+ "com.clerk.trusted-device-installation-marker.s17:com.clerk.example.s0:.s17:com.clerk.example"
244
+ )
245
+ }
246
+
247
+ static let fixtureRecords: [BiometricCredentialRecord] = [
248
+ BiometricCredentialRecord(
249
+ id: "tdc_contract_current_set",
250
+ localKeyId: "tdlk_0123456789abcdef0123456789abcdef",
251
+ userId: "user_contract_1",
252
+ appIdentifier: "com.clerk.example",
253
+ identifierHint: "user@example.com",
254
+ policy: .biometryCurrentSet,
255
+ createdAt: 1_714_000_000_500,
256
+ updatedAt: 1_714_000_001_500
257
+ ),
258
+ BiometricCredentialRecord(
259
+ id: "tdc_contract_any",
260
+ localKeyId: "tdlk_fedcba9876543210fedcba9876543210",
261
+ userId: "user_contract_2",
262
+ appIdentifier: "com.clerk.example",
263
+ identifierHint: nil,
264
+ policy: .biometryAny,
265
+ createdAt: 1_714_000_002_000,
266
+ updatedAt: 1_714_000_003_000
267
+ ),
268
+ BiometricCredentialRecord(
269
+ id: "tdc_contract_passcode",
270
+ localKeyId: "tdlk_00000000000000000000000000000000",
271
+ userId: "user_contract_1",
272
+ appIdentifier: "com.clerk.other",
273
+ identifierHint: "+15555550100",
274
+ policy: .biometryOrDevicePasscode,
275
+ createdAt: 1_714_000_004_000,
276
+ updatedAt: 1_714_000_004_000
277
+ ),
278
+ ]
279
+
280
+ static func fixtureData() throws -> Data {
281
+ let url = try XCTUnwrap(
282
+ Bundle(for: BiometricCredentialContractTests.self).url(forResource: "BiometricCredentialStorageContractV1", withExtension: "json")
283
+ )
284
+ return try Data(contentsOf: url)
285
+ }
286
+ }
@@ -0,0 +1,323 @@
1
+ import Foundation
2
+ import Security
3
+ import XCTest
4
+ @testable import ClerkExpoBiometrics
5
+
6
+ final class BiometricCredentialStoreTests: XCTestCase {
7
+ private final class InMemoryStorage: BiometricCredentialMetadataStorage {
8
+ var data: Data?
9
+ var writes = 0
10
+ var deletes = 0
11
+
12
+ func read() throws -> Data? { data }
13
+ func write(_ data: Data) throws {
14
+ writes += 1
15
+ self.data = data
16
+ }
17
+
18
+ func delete() throws {
19
+ deletes += 1
20
+ data = nil
21
+ }
22
+
23
+ func records() throws -> [[String: Any]] {
24
+ try BiometricCredentialRecordList.decode(data)
25
+ }
26
+
27
+ func ids() throws -> [String?] {
28
+ try records().map { $0["id"] as? String }
29
+ }
30
+ }
31
+
32
+ private var storage: InMemoryStorage!
33
+ private var userDefaults: UserDefaults!
34
+ private var suiteName: String!
35
+ private var deletedKeys: [String] = []
36
+ private var failingKeys: Set<String> = []
37
+
38
+ override func setUp() {
39
+ super.setUp()
40
+ storage = InMemoryStorage()
41
+ suiteName = "ClerkExpoBiometricsTests.\(UUID().uuidString)"
42
+ userDefaults = UserDefaults(suiteName: suiteName)
43
+ deletedKeys = []
44
+ failingKeys = []
45
+ }
46
+
47
+ override func tearDown() {
48
+ userDefaults.removePersistentDomain(forName: suiteName)
49
+ super.tearDown()
50
+ }
51
+
52
+ private func makeStore(appIdentifier: String? = "com.clerk.example") -> BiometricCredentialStore {
53
+ BiometricCredentialStore(
54
+ storage: storage,
55
+ deleteKey: { [unowned self] localKeyId in
56
+ if failingKeys.contains(localKeyId) {
57
+ throw BiometricsError(.storageFailed, "delete failed")
58
+ }
59
+ deletedKeys.append(localKeyId)
60
+ },
61
+ userDefaults: userDefaults,
62
+ service: "com.clerk.example",
63
+ appIdentifier: appIdentifier
64
+ )
65
+ }
66
+
67
+ private let markerKey = "com.clerk.trusted-device-installation-marker.s17:com.clerk.example.n.s17:com.clerk.example"
68
+
69
+ private func setMarker() {
70
+ userDefaults.set(true, forKey: markerKey)
71
+ }
72
+
73
+ private func record(
74
+ id: String,
75
+ localKeyId: String? = nil,
76
+ userId: String = "user_1",
77
+ appIdentifier: String = "com.clerk.example",
78
+ identifierHint: String? = nil
79
+ ) -> BiometricCredentialRecord {
80
+ BiometricCredentialRecord(
81
+ id: id,
82
+ localKeyId: localKeyId ?? "tdlk_\(id)",
83
+ userId: userId,
84
+ appIdentifier: appIdentifier,
85
+ identifierHint: identifierHint,
86
+ policy: .biometryCurrentSet,
87
+ createdAt: 1_714_000_000_000,
88
+ updatedAt: 1_714_000_000_000
89
+ )
90
+ }
91
+
92
+ // MARK: - Reinstall marker
93
+
94
+ func testMissingMarkerWipesOnlyThisAppsRecordsAndKeysThenSetsMarker() throws {
95
+ storage.data = try BiometricCredentialContractTests.fixtureData()
96
+ let store = makeStore()
97
+
98
+ XCTAssertEqual(store.installationMarkerKey, markerKey)
99
+ XCTAssertTrue(try store.ensureInstallationMarker())
100
+
101
+ XCTAssertEqual(deletedKeys, ["tdlk_0123456789abcdef0123456789abcdef", "tdlk_fedcba9876543210fedcba9876543210"])
102
+ XCTAssertEqual(try storage.ids(), ["tdc_contract_passcode"])
103
+ XCTAssertEqual(userDefaults.object(forKey: markerKey) as? Bool, true)
104
+ XCTAssertEqual(UserDefaults.standard.object(forKey: markerKey) as? Bool, nil)
105
+ }
106
+
107
+ func testMarkerIsIdempotent() throws {
108
+ let store = makeStore()
109
+
110
+ XCTAssertTrue(try store.ensureInstallationMarker())
111
+ storage.data = try BiometricCredentialContractTests.fixtureData()
112
+ XCTAssertFalse(try store.ensureInstallationMarker())
113
+
114
+ XCTAssertEqual(deletedKeys, [])
115
+ XCTAssertEqual(try storage.records().count, 3)
116
+ }
117
+
118
+ func testMarkerIsNotSetWhenAKeyCannotBeDeleted() throws {
119
+ storage.data = try BiometricCredentialContractTests.fixtureData()
120
+ failingKeys = ["tdlk_fedcba9876543210fedcba9876543210"]
121
+ let store = makeStore()
122
+
123
+ XCTAssertThrowsError(try store.ensureInstallationMarker())
124
+
125
+ XCTAssertEqual(try storage.ids(), ["tdc_contract_any", "tdc_contract_passcode"])
126
+ XCTAssertNil(userDefaults.object(forKey: markerKey))
127
+
128
+ failingKeys = []
129
+ XCTAssertTrue(try store.ensureInstallationMarker())
130
+ XCTAssertEqual(try storage.ids(), ["tdc_contract_passcode"])
131
+ }
132
+
133
+ func testMarkerWipesMalformedRecordsForThisApp() throws {
134
+ storage.data = Data(#"[{"appIdentifier":"com.clerk.example","localKeyId":"tdlk_bad"},{"appIdentifier":"com.clerk.example"}]"#.utf8)
135
+
136
+ XCTAssertTrue(try makeStore().ensureInstallationMarker())
137
+
138
+ XCTAssertEqual(deletedKeys, ["tdlk_bad"])
139
+ XCTAssertNil(storage.data)
140
+ XCTAssertEqual(storage.deletes, 1)
141
+ }
142
+
143
+ func testMarkerIsNotSetWhenMetadataIsNotAnArray() {
144
+ storage.data = Data(#"{"id":"tdc_1"}"#.utf8)
145
+
146
+ XCTAssertThrowsError(try makeStore().ensureInstallationMarker()) { error in
147
+ XCTAssertEqual((error as? BiometricsError)?.code, .storageFailed)
148
+ }
149
+ XCTAssertNil(userDefaults.object(forKey: markerKey))
150
+ }
151
+
152
+ func testMissingAppIdentifierSkipsTheMarker() throws {
153
+ storage.data = try BiometricCredentialContractTests.fixtureData()
154
+
155
+ XCTAssertFalse(try makeStore(appIdentifier: nil).ensureInstallationMarker())
156
+ XCTAssertEqual(try storage.records().count, 3)
157
+ }
158
+
159
+ func testStoreOperationsApplyTheMarkerFirst() throws {
160
+ storage.data = try BiometricCredentialContractTests.fixtureData()
161
+ let store = makeStore()
162
+
163
+ let listed = try JSONSerialization.jsonObject(with: Data(store.listRecordsJSON().utf8)) as? [[String: Any]]
164
+
165
+ XCTAssertEqual(listed?.map { $0["id"] as? String }, ["tdc_contract_passcode"])
166
+ XCTAssertEqual(userDefaults.object(forKey: markerKey) as? Bool, true)
167
+ }
168
+
169
+ // MARK: - List
170
+
171
+ func testListReturnsWellFormedRecordsForEveryAppWithUnknownFields() throws {
172
+ setMarker()
173
+ storage.data = Data("""
174
+ [{"id":"tdc_1","localKeyId":"tdlk_1","userId":"user_1","appIdentifier":"com.clerk.example","identifierHint":" A@B.co ",\
175
+ "policy":"biometry_any","createdAt":1714000000000,"updatedAt":1714000000001,"futureField":[1,true,null]},\
176
+ {"id":"tdc_2","localKeyId":"tdlk_2","userId":"user_2","appIdentifier":"com.clerk.other","identifierHint":" ",\
177
+ "policy":"biometry_current_set","createdAt":1714000000000,"updatedAt":1714000000000},\
178
+ {"id":"tdc_bad_policy","localKeyId":"tdlk_3","userId":"user_1","appIdentifier":"com.clerk.example",\
179
+ "policy":"face","createdAt":1,"updatedAt":1},\
180
+ {"id":"tdc_bool_date","localKeyId":"tdlk_4","userId":"user_1","appIdentifier":"com.clerk.example",\
181
+ "policy":"biometry_any","createdAt":true,"updatedAt":1},\
182
+ {"id":"tdc_missing","userId":"user_1","appIdentifier":"com.clerk.example","policy":"biometry_any","createdAt":1,"updatedAt":1},\
183
+ {"id":"tdc_hint_number","localKeyId":"tdlk_5","userId":"user_1","appIdentifier":"com.clerk.example",\
184
+ "identifierHint":5,"policy":"biometry_any","createdAt":1,"updatedAt":1},\
185
+ {"id":"tdc_null_hint","localKeyId":"tdlk_6","userId":"user_1","appIdentifier":"com.clerk.example",\
186
+ "identifierHint":null,"policy":"biometry_or_device_passcode","createdAt":1,"updatedAt":1}]
187
+ """.utf8)
188
+
189
+ let json = try makeStore().listRecordsJSON()
190
+ let listed = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [[String: Any]])
191
+
192
+ XCTAssertEqual(listed.map { $0["id"] as? String }, ["tdc_1", "tdc_2", "tdc_null_hint"])
193
+ XCTAssertEqual(listed[0]["identifierHint"] as? String, "a@b.co")
194
+ XCTAssertNil(listed[1]["identifierHint"])
195
+ XCTAssertNil(listed[2]["identifierHint"])
196
+ XCTAssertEqual(listed[0]["identifierHintSha256"] as? String, "80305c9bb1bb2480e03894350e0a8a366dcbdeb302e69e0817aa0743abd77054")
197
+ XCTAssertTrue(listed[1]["identifierHintSha256"] is NSNull)
198
+ XCTAssertTrue(listed[2]["identifierHintSha256"] is NSNull)
199
+ XCTAssertTrue(json.contains(#""futureField":[1,true,null]"#))
200
+ XCTAssertTrue(json.contains(#""updatedAt":1714000000001"#))
201
+ }
202
+
203
+ func testListIsEmptyWithoutAnItem() throws {
204
+ setMarker()
205
+ XCTAssertEqual(try makeStore().listRecordsJSON(), "[]")
206
+ }
207
+
208
+ // MARK: - Save
209
+
210
+ func testSaveAddsRecordAndPreservesOtherAppsAndUnknownFields() throws {
211
+ setMarker()
212
+ storage.data = Data("""
213
+ [{"id":"tdc_other","localKeyId":"tdlk_other","userId":"user_1","appIdentifier":"com.clerk.other",\
214
+ "policy":"biometry_any","createdAt":1714000000000,"updatedAt":1714000000000,"futureField":"kept"},\
215
+ {"id":"tdc_same","localKeyId":"tdlk_same","userId":"user_2","appIdentifier":"com.clerk.example",\
216
+ "policy":"biometry_any","createdAt":1714000000000,"updatedAt":1714000000000,"futureField":"also kept"}]
217
+ """.utf8)
218
+
219
+ try makeStore().save(record(id: "tdc_new", identifierHint: " New@Example.com"), removeOtherRecordsForApp: false)
220
+
221
+ let records = try storage.records()
222
+ XCTAssertEqual(records.map { $0["id"] as? String }, ["tdc_other", "tdc_same", "tdc_new"])
223
+ XCTAssertEqual(records[0]["futureField"] as? String, "kept")
224
+ XCTAssertEqual(records[1]["futureField"] as? String, "also kept")
225
+ XCTAssertEqual(records[2]["identifierHint"] as? String, "new@example.com")
226
+ XCTAssertEqual(deletedKeys, [])
227
+ }
228
+
229
+ func testSaveReplacesSameIdAndDeletesItsOldKey() throws {
230
+ setMarker()
231
+ let store = makeStore()
232
+ try store.save(record(id: "tdc_1", localKeyId: "tdlk_old"), removeOtherRecordsForApp: false)
233
+
234
+ try store.save(record(id: "tdc_1", localKeyId: "tdlk_new"), removeOtherRecordsForApp: false)
235
+
236
+ let records = try storage.records()
237
+ XCTAssertEqual(records.count, 1)
238
+ XCTAssertEqual(records[0]["localKeyId"] as? String, "tdlk_new")
239
+ XCTAssertEqual(deletedKeys, ["tdlk_old"])
240
+ }
241
+
242
+ func testSaveDropsMalformedRecordsForTheSameAppOnly() throws {
243
+ setMarker()
244
+ storage.data = Data("""
245
+ [{"id":"tdc_bad","appIdentifier":"com.clerk.example"},{"id":"tdc_bad_other","appIdentifier":"com.clerk.other"}]
246
+ """.utf8)
247
+
248
+ try makeStore().save(record(id: "tdc_1"), removeOtherRecordsForApp: false)
249
+
250
+ XCTAssertEqual(try storage.ids(), ["tdc_bad_other", "tdc_1"])
251
+ XCTAssertEqual(deletedKeys, [])
252
+ }
253
+
254
+ func testSaveRemovingOtherRecordsDeletesThisAppsOtherRecordsAndKeys() throws {
255
+ setMarker()
256
+ let store = makeStore()
257
+ try store.save(record(id: "tdc_a", userId: "user_1"), removeOtherRecordsForApp: false)
258
+ try store.save(record(id: "tdc_b", userId: "user_2"), removeOtherRecordsForApp: false)
259
+ try store.save(record(id: "tdc_other_app", appIdentifier: "com.clerk.other"), removeOtherRecordsForApp: false)
260
+ failingKeys = ["tdlk_tdc_b"]
261
+
262
+ try store.save(record(id: "tdc_new"), removeOtherRecordsForApp: true)
263
+
264
+ XCTAssertEqual(deletedKeys, ["tdlk_tdc_a"])
265
+ XCTAssertEqual(try storage.ids(), ["tdc_b", "tdc_other_app", "tdc_new"])
266
+ }
267
+
268
+ func testSaveAppliesTheMarkerBeforeWriting() throws {
269
+ storage.data = try BiometricCredentialContractTests.fixtureData()
270
+
271
+ try makeStore().save(record(id: "tdc_new"), removeOtherRecordsForApp: false)
272
+
273
+ XCTAssertEqual(try storage.ids(), ["tdc_contract_passcode", "tdc_new"])
274
+ XCTAssertEqual(userDefaults.object(forKey: markerKey) as? Bool, true)
275
+ }
276
+
277
+ func testSaveFailsWithoutWritingWhenTheMarkerCannotBeSet() throws {
278
+ storage.data = try BiometricCredentialContractTests.fixtureData()
279
+ failingKeys = ["tdlk_0123456789abcdef0123456789abcdef"]
280
+
281
+ XCTAssertThrowsError(try makeStore().save(record(id: "tdc_new"), removeOtherRecordsForApp: false))
282
+
283
+ XCTAssertFalse(try storage.ids().contains("tdc_new"))
284
+ }
285
+
286
+ // MARK: - Delete
287
+
288
+ func testDeleteRecordsDeletesKeyThenRecordsAndDropsTheItemWhenEmpty() throws {
289
+ setMarker()
290
+ let store = makeStore()
291
+ try store.save(record(id: "tdc_1", localKeyId: "tdlk_1"), removeOtherRecordsForApp: false)
292
+
293
+ try store.deleteRecords(localKeyId: "tdlk_1")
294
+
295
+ XCTAssertEqual(deletedKeys, ["tdlk_1"])
296
+ XCTAssertNil(storage.data)
297
+ XCTAssertEqual(storage.deletes, 1)
298
+ }
299
+
300
+ func testDeleteRecordsKeepsRecordsWhenTheKeyCannotBeDeleted() throws {
301
+ setMarker()
302
+ let store = makeStore()
303
+ try store.save(record(id: "tdc_1", localKeyId: "tdlk_1"), removeOtherRecordsForApp: false)
304
+ failingKeys = ["tdlk_1"]
305
+
306
+ XCTAssertThrowsError(try store.deleteRecords(localKeyId: "tdlk_1"))
307
+
308
+ XCTAssertEqual(try storage.ids(), ["tdc_1"])
309
+ }
310
+
311
+ func testDeleteRecordsPreservesUnknownFieldsOnOtherRecords() throws {
312
+ setMarker()
313
+ storage.data = Data("""
314
+ [{"id":"tdc_1","localKeyId":"tdlk_1","futureField":"kept"},{"id":"tdc_2","localKeyId":"tdlk_2"}]
315
+ """.utf8)
316
+
317
+ try makeStore().deleteRecords(localKeyId: "tdlk_2")
318
+
319
+ let records = try storage.records()
320
+ XCTAssertEqual(records.count, 1)
321
+ XCTAssertEqual(records[0]["futureField"] as? String, "kept")
322
+ }
323
+ }