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