@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,450 @@
1
+ package expo.modules.clerk.biometrics
2
+
3
+ import android.security.keystore.KeyProperties
4
+ import androidx.biometric.BiometricManager.Authenticators
5
+ import java.io.File
6
+ import java.io.IOException
7
+ import java.io.RandomAccessFile
8
+ import java.math.BigInteger
9
+ import java.security.KeyPairGenerator
10
+ import java.security.Signature
11
+ import java.security.spec.ECGenParameterSpec
12
+ import java.util.concurrent.CountDownLatch
13
+ import java.util.concurrent.Executors
14
+ import java.util.concurrent.TimeUnit
15
+ import java.util.concurrent.locks.ReentrantLock
16
+ import kotlin.concurrent.thread
17
+ import org.json.JSONArray
18
+ import org.json.JSONObject
19
+ import org.junit.Assert.assertArrayEquals
20
+ import org.junit.Assert.assertEquals
21
+ import org.junit.Assert.assertFalse
22
+ import org.junit.Assert.assertNull
23
+ import org.junit.Assert.assertThrows
24
+ import org.junit.Assert.assertTrue
25
+ import org.junit.Rule
26
+ import org.junit.Test
27
+ import org.junit.rules.TemporaryFolder
28
+ import org.junit.runner.RunWith
29
+ import org.robolectric.RobolectricTestRunner
30
+ import org.robolectric.annotation.Config
31
+
32
+ /**
33
+ * Pins clerk-android's biometric credential storage contract v2
34
+ * (`source/api/docs/biometric-credential-storage-contract.md`) with the same literals and fixture as its
35
+ * `BiometricCredentialStorageContractTest`. Update them only together with a contract version bump.
36
+ */
37
+ @RunWith(RobolectricTestRunner::class)
38
+ class BiometricCredentialStorageContractTest {
39
+ @get:Rule val temporaryFolder = TemporaryFolder()
40
+
41
+ private val directory by lazy { File(temporaryFolder.root, DIRECTORY_NAME) }
42
+ private val dataFile by lazy { File(directory, DATA_FILE_NAME) }
43
+
44
+ @Test
45
+ fun `store identifiers match the contract`() {
46
+ assertEquals(DIRECTORY_NAME, BiometricCredentialFileStore.DIRECTORY_NAME)
47
+ assertEquals(DATA_FILE_NAME, BiometricCredentialFileStore.DATA_FILE_NAME)
48
+ assertEquals("$DATA_FILE_NAME.tmp", BiometricCredentialFileStore.TEMP_FILE_NAME)
49
+ assertEquals(LOCK_FILE_NAME, BiometricCredentialFileStore.LOCK_FILE_NAME)
50
+ assertEquals(2, BiometricCredentialFileStore.STORE_VERSION)
51
+ assertEquals(KEY_ALIAS_PREFIX + LOCAL_KEY_ID, BiometricCredentialCoding.keyAlias(LOCAL_KEY_ID))
52
+
53
+ val store = BiometricCredentialFileStore.inDirectory(temporaryFolder.root)
54
+ assertEquals(dataFile, store.dataFile)
55
+ assertEquals(File(directory, LOCK_FILE_NAME), store.lockFile)
56
+ }
57
+
58
+ @Test
59
+ fun `v2 fixture written by clerk-android is readable`() {
60
+ writeFixture()
61
+
62
+ assertEquals(fixtureRecords, BiometricCredentialFileStore(directory).records().map { it.record })
63
+ }
64
+
65
+ @Test
66
+ fun `records written by this module match the schema`() {
67
+ val store = BiometricCredentialFileStore(directory)
68
+
69
+ fixtureRecords.forEach { store.save(it) }
70
+
71
+ assertEquals(
72
+ parse(
73
+ """{
74
+ "version":2,
75
+ "credentials":[
76
+ {"id":"td_current_set","local_key_id":"tdlk_0123456789abcdef0123456789abcdef",
77
+ "user_id":"user_1","app_identifier":"com.example.app",
78
+ "identifier_hint_sha256":"$USER_HINT_SHA256",
79
+ "policy":"biometry_current_set","created_at":1735689600000,"updated_at":1735689600001},
80
+ {"id":"td_any","local_key_id":"tdlk_fedcba9876543210fedcba9876543210",
81
+ "user_id":"user_2","app_identifier":"com.example.app",
82
+ "policy":"biometry_any","created_at":1735689700000,"updated_at":1735689700000},
83
+ {"id":"td_device_passcode","local_key_id":"tdlk_00000000000000000000000000000000",
84
+ "user_id":"user_3","app_identifier":"com.example.other",
85
+ "policy":"biometry_or_device_passcode","created_at":1735689800000,"updated_at":1735689800000}
86
+ ],
87
+ "pending_cleanup_user_ids":[]
88
+ }"""
89
+ ),
90
+ parse(dataFile.readText()),
91
+ )
92
+ assertTrue(dataFile.readText().startsWith("""{"version":2,"credentials":["""))
93
+ assertEquals(setOf(DATA_FILE_NAME, LOCK_FILE_NAME), directory.list()!!.toSet())
94
+ }
95
+
96
+ @Test
97
+ fun `rewrites preserve unknown fields, unknown policies, undecodable records and top-level keys`() {
98
+ writeFixture()
99
+ val store = BiometricCredentialFileStore(directory)
100
+
101
+ store.save(fixtureRecords.first())
102
+
103
+ assertEquals(parse(fixture()), parse(dataFile.readText()))
104
+
105
+ store.save(fixtureRecords.first().copy(updatedAt = 1_735_689_600_002))
106
+ store.delete(setOf("td_any"))
107
+
108
+ val root = JSONObject(dataFile.readText())
109
+ val credentials = root.getJSONArray("credentials")
110
+ assertEquals(
111
+ listOf("td_current_set", "td_device_passcode", "td_future_policy", "td_incomplete"),
112
+ List(credentials.length()) { credentials.getJSONObject(it).getString("id") },
113
+ )
114
+ val current = credentials.getJSONObject(0)
115
+ assertEquals(1_735_689_600_002, current.getLong("updated_at"))
116
+ assertEquals(parse("""{"nested":[1,2,3]}"""), toValue(current.get("future_field")))
117
+ assertEquals("some_future_policy", credentials.getJSONObject(2).getString("policy"))
118
+ assertEquals(parse("""{"id":"td_incomplete","user_id":"user_5"}"""), toValue(credentials.get(3)))
119
+ assertEquals(parse("""["user_6"]"""), toValue(root.get("pending_cleanup_user_ids")))
120
+ assertEquals(parse("""{"written_by":"a newer SDK"}"""), toValue(root.get("future_top_level_key")))
121
+ }
122
+
123
+ @Test
124
+ fun `pending cleanup ids are kept, sorted and de-duplicated on rewrite`() {
125
+ directory.mkdirs()
126
+ dataFile.writeText("""{"version":2,"credentials":[],"pending_cleanup_user_ids":["user_b"," ",3,"user_a","user_b"]}""")
127
+
128
+ BiometricCredentialFileStore(directory).save(fixtureRecords.first())
129
+
130
+ assertEquals(parse("""["user_a","user_b"]"""), toValue(JSONObject(dataFile.readText()).get("pending_cleanup_user_ids")))
131
+ }
132
+
133
+ @Test
134
+ fun `a different store version is read as empty and never modified`() {
135
+ for (future in listOf("""{"version":3,"credentials":[]}""", """{"version":"2","credentials":[]}""", """{"credentials":[]}""")) {
136
+ directory.mkdirs()
137
+ dataFile.writeText(future)
138
+ val store = BiometricCredentialFileStore(directory)
139
+
140
+ assertTrue(store.records().isEmpty())
141
+ assertThrows(IOException::class.java) { store.save(fixtureRecords.first()) }
142
+ assertThrows(IOException::class.java) { store.delete(setOf("td_any")) }
143
+ assertEquals(future, dataFile.readText())
144
+ }
145
+ }
146
+
147
+ @Test
148
+ fun `a malformed file reads as empty and is replaced by the next write`() {
149
+ directory.mkdirs()
150
+ dataFile.writeText("[not json")
151
+ val store = BiometricCredentialFileStore(directory)
152
+
153
+ assertTrue(store.records().isEmpty())
154
+ store.save(fixtureRecords.first())
155
+
156
+ assertEquals(listOf(fixtureRecords.first()), store.records().map { it.record })
157
+ }
158
+
159
+ @Test
160
+ fun `saving replaces the record with the same id and reports its previous key`() {
161
+ val store = BiometricCredentialFileStore(directory)
162
+ store.save(fixtureRecords.first())
163
+
164
+ assertNull(store.save(fixtureRecords.first()))
165
+ assertEquals(LOCAL_KEY_ID, store.save(fixtureRecords.first().copy(localKeyId = "tdlk_new")))
166
+ assertEquals(listOf("tdlk_new"), store.records().map { it.record.localKeyId })
167
+ }
168
+
169
+ @Test
170
+ fun `concurrent writers in independent store instances do not lose updates`() {
171
+ val stores = List(2) { BiometricCredentialFileStore(directory, processGuard = ReentrantLock()) }
172
+ val writersPerStore = 4
173
+ val recordsPerWriter = 15
174
+ val executor = Executors.newFixedThreadPool(stores.size * writersPerStore)
175
+ val start = CountDownLatch(1)
176
+ try {
177
+ val futures =
178
+ stores.flatMapIndexed { storeIndex, store ->
179
+ List(writersPerStore) { writer ->
180
+ executor.submit {
181
+ start.await()
182
+ repeat(recordsPerWriter) { index ->
183
+ store.save(fixtureRecords.first().copy(id = "td_${storeIndex}_${writer}_$index"))
184
+ }
185
+ }
186
+ }
187
+ }
188
+ start.countDown()
189
+ futures.forEach { it.get(60, TimeUnit.SECONDS) }
190
+ } finally {
191
+ executor.shutdownNow()
192
+ }
193
+
194
+ assertEquals(
195
+ stores.size * writersPerStore * recordsPerWriter,
196
+ BiometricCredentialFileStore(directory).records().map { it.record.id }.toSet().size,
197
+ )
198
+ assertFalse(File(directory, "$DATA_FILE_NAME.tmp").exists())
199
+ }
200
+
201
+ @Test
202
+ fun `writers wait for a lock held through another channel`() {
203
+ val store = BiometricCredentialFileStore(directory)
204
+ directory.mkdirs()
205
+ RandomAccessFile(File(directory, LOCK_FILE_NAME), "rw").channel.use { channel ->
206
+ val held = channel.lock()
207
+ val writer = thread { store.save(fixtureRecords.first()) }
208
+
209
+ writer.join(300)
210
+ assertTrue(writer.isAlive)
211
+ assertFalse(dataFile.exists())
212
+
213
+ held.release()
214
+ writer.join(5_000)
215
+ assertFalse(writer.isAlive)
216
+ }
217
+
218
+ assertEquals(listOf(fixtureRecords.first()), store.records().map { it.record })
219
+ }
220
+
221
+ @Test
222
+ fun `writers give up when the lock is not released in time`() {
223
+ val store = BiometricCredentialFileStore(directory, lockTimeoutMillis = 100)
224
+ directory.mkdirs()
225
+ RandomAccessFile(File(directory, LOCK_FILE_NAME), "rw").channel.use { channel ->
226
+ channel.lock().use { assertThrows(IOException::class.java) { store.save(fixtureRecords.first()) } }
227
+ }
228
+ assertFalse(dataFile.exists())
229
+ }
230
+
231
+ @Test
232
+ fun `identifier hints are hashed after trimming and lowercasing`() {
233
+ assertEquals(USER_HINT_SHA256, BiometricCredentialCoding.hashIdentifierHint(" User@Example.COM\n"))
234
+ assertEquals(USER_HINT_SHA256, BiometricCredentialCoding.hashIdentifierHint("user@example.com"))
235
+ assertNull(BiometricCredentialCoding.hashIdentifierHint(" \n\t"))
236
+ assertNull(BiometricCredentialCoding.hashIdentifierHint(""))
237
+ assertNull(BiometricCredentialCoding.hashIdentifierHint(null))
238
+ }
239
+
240
+ @Test
241
+ fun `local key ids are tdlk_ followed by 32 lowercase hex characters`() {
242
+ val localKeyId = BiometricCredentialCoding.makeLocalKeyId()
243
+
244
+ assertTrue(localKeyId, Regex("tdlk_[0-9a-f]{32}").matches(localKeyId))
245
+ }
246
+
247
+ @Test
248
+ @Config(sdk = [30])
249
+ fun `signing key parameters match the contract on Android 11 and later`() {
250
+ val expectations =
251
+ mapOf(
252
+ BiometricCredentialPolicy.BIOMETRY_CURRENT_SET to (KeyProperties.AUTH_BIOMETRIC_STRONG to true),
253
+ BiometricCredentialPolicy.BIOMETRY_ANY to (KeyProperties.AUTH_BIOMETRIC_STRONG to false),
254
+ BiometricCredentialPolicy.BIOMETRY_OR_DEVICE_PASSCODE to
255
+ ((KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL) to false),
256
+ )
257
+
258
+ expectations.forEach { (policy, expected) ->
259
+ val spec = BiometricCredentialCoding.keyGenParameterSpec(LOCAL_KEY_ID, policy)
260
+
261
+ assertEquals(KEY_ALIAS_PREFIX + LOCAL_KEY_ID, spec.keystoreAlias)
262
+ assertEquals(KeyProperties.PURPOSE_SIGN, spec.purposes)
263
+ assertArrayEquals(arrayOf(KeyProperties.DIGEST_SHA256), spec.digests)
264
+ assertEquals("secp256r1", (spec.algorithmParameterSpec as ECGenParameterSpec).name)
265
+ assertTrue(spec.isUserAuthenticationRequired)
266
+ assertEquals(0, spec.userAuthenticationValidityDurationSeconds)
267
+ assertEquals(expected.first, spec.userAuthenticationType)
268
+ assertEquals(expected.second, spec.isInvalidatedByBiometricEnrollment)
269
+ assertFalse(spec.isStrongBoxBacked)
270
+ assertFalse(spec.isUnlockedDeviceRequired)
271
+ assertNull(spec.attestationChallenge)
272
+ }
273
+ }
274
+
275
+ @Test
276
+ @Config(sdk = [28])
277
+ fun `signing key parameters match the contract before Android 11`() {
278
+ BiometricCredentialPolicy.entries.forEach { policy ->
279
+ val spec = BiometricCredentialCoding.keyGenParameterSpec(LOCAL_KEY_ID, policy)
280
+
281
+ assertEquals(KEY_ALIAS_PREFIX + LOCAL_KEY_ID, spec.keystoreAlias)
282
+ assertEquals(KeyProperties.PURPOSE_SIGN, spec.purposes)
283
+ assertArrayEquals(arrayOf(KeyProperties.DIGEST_SHA256), spec.digests)
284
+ assertEquals("secp256r1", (spec.algorithmParameterSpec as ECGenParameterSpec).name)
285
+ assertTrue(spec.isUserAuthenticationRequired)
286
+ assertEquals(-1, spec.userAuthenticationValidityDurationSeconds)
287
+ assertEquals(policy == BiometricCredentialPolicy.BIOMETRY_CURRENT_SET, spec.isInvalidatedByBiometricEnrollment)
288
+ }
289
+ }
290
+
291
+ @Test
292
+ fun `prompt authenticators allow device credentials only for the passcode policy on Android 11 and later`() {
293
+ val strong = Authenticators.BIOMETRIC_STRONG
294
+ for (policy in BiometricCredentialPolicy.entries) {
295
+ assertEquals(strong, BiometricKeyManager.promptAuthenticators(policy, sdkInt = 29))
296
+ }
297
+ assertEquals(strong, BiometricKeyManager.promptAuthenticators(BiometricCredentialPolicy.BIOMETRY_CURRENT_SET, sdkInt = 30))
298
+ assertEquals(strong, BiometricKeyManager.promptAuthenticators(BiometricCredentialPolicy.BIOMETRY_ANY, sdkInt = 30))
299
+ assertEquals(
300
+ strong or Authenticators.DEVICE_CREDENTIAL,
301
+ BiometricKeyManager.promptAuthenticators(BiometricCredentialPolicy.BIOMETRY_OR_DEVICE_PASSCODE, sdkInt = 30),
302
+ )
303
+ }
304
+
305
+ @Test
306
+ fun `policy values match the contract`() {
307
+ assertEquals(
308
+ listOf("biometry_current_set", "biometry_any", "biometry_or_device_passcode"),
309
+ BiometricCredentialPolicy.entries.map { it.value },
310
+ )
311
+ }
312
+
313
+ @Test
314
+ fun `public key JWK uses unpadded base64url 32-byte coordinates`() {
315
+ assertEquals(
316
+ """{"kty":"EC","crv":"P-256","x":"AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE","y":"AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI","alg":"ES256"}""",
317
+ BiometricCredentialCoding.publicKeyJwk(BigInteger(1, ByteArray(32) { 1 }), BigInteger(1, ByteArray(32) { 2 })),
318
+ )
319
+
320
+ val highBit = BigInteger(1, ByteArray(32) { 0xFF.toByte() })
321
+ val jwk = JSONObject(BiometricCredentialCoding.publicKeyJwk(highBit, BigInteger.ONE))
322
+ assertEquals("__________________________________________8", jwk.getString("x"))
323
+ assertEquals("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE", jwk.getString("y"))
324
+ }
325
+
326
+ @Test
327
+ fun `DER signatures convert to raw r and s`() {
328
+ val r = byteArrayOf(0x00, 0x80.toByte()) + ByteArray(31) { 0xAA.toByte() }
329
+ val s = ByteArray(31) { 0x11 }
330
+ val der = byteArrayOf(0x30, 0x44, 0x02, r.size.toByte()) + r + byteArrayOf(0x02, s.size.toByte()) + s
331
+
332
+ val raw = BiometricCredentialCoding.rawES256SignatureFromDer(der)
333
+
334
+ assertEquals(64, raw.size)
335
+ assertEquals(
336
+ "gKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqoAEREREREREREREREREREREREREREREREREREREREREQ",
337
+ BiometricCredentialCoding.base64UrlEncode(raw),
338
+ )
339
+ }
340
+
341
+ @Test
342
+ fun `malformed DER signatures are rejected`() {
343
+ val valid = byteArrayOf(0x30, 0x06, 0x02, 0x01, 0x01, 0x02, 0x01, 0x02)
344
+ BiometricCredentialCoding.rawES256SignatureFromDer(valid)
345
+
346
+ val malformed =
347
+ listOf(
348
+ byteArrayOf(),
349
+ byteArrayOf(0x31) + valid.copyOfRange(1, valid.size),
350
+ byteArrayOf(0x30, 0x07) + valid.copyOfRange(2, valid.size),
351
+ valid + byteArrayOf(0x00),
352
+ byteArrayOf(0x30, 0x06, 0x02, 0x01, 0x81.toByte(), 0x02, 0x01, 0x02),
353
+ byteArrayOf(0x30, 0x06, 0x02, 0x00, 0x02, 0x02, 0x01, 0x02),
354
+ byteArrayOf(0x30, 0x25, 0x02, 0x21, 0x01) + ByteArray(32) { 1 } + byteArrayOf(0x02, 0x01, 0x02),
355
+ )
356
+ for (bytes in malformed) {
357
+ val error = assertThrows(BiometricsError::class.java) { BiometricCredentialCoding.rawES256SignatureFromDer(bytes) }
358
+ assertEquals(BiometricsErrorCode.SIGNING_FAILED, error.code)
359
+ }
360
+ }
361
+
362
+ @Test
363
+ fun `raw signatures verify as IEEE P1363 over the UTF-8 client data`() {
364
+ val keyPair = KeyPairGenerator.getInstance("EC").apply { initialize(ECGenParameterSpec("secp256r1")) }.generateKeyPair()
365
+ val clientData = """{"challenge":"abc","nonce":"é"}"""
366
+
367
+ repeat(32) {
368
+ val der =
369
+ Signature.getInstance("SHA256withECDSA").run {
370
+ initSign(keyPair.private)
371
+ update(clientData.toByteArray(Charsets.UTF_8))
372
+ sign()
373
+ }
374
+ val raw = BiometricCredentialCoding.rawES256SignatureFromDer(der)
375
+
376
+ assertEquals(64, raw.size)
377
+ val verified =
378
+ Signature.getInstance("SHA256withECDSAinP1363Format").run {
379
+ initVerify(keyPair.public)
380
+ update(clientData.toByteArray(Charsets.UTF_8))
381
+ verify(raw)
382
+ }
383
+ assertTrue(verified)
384
+ }
385
+ }
386
+
387
+ private fun writeFixture() {
388
+ directory.mkdirs()
389
+ dataFile.writeText(fixture())
390
+ }
391
+
392
+ companion object {
393
+ const val DIRECTORY_NAME = "clerk"
394
+ const val DATA_FILE_NAME = "biometric_credentials.v2.json"
395
+ const val LOCK_FILE_NAME = "biometric_credentials.lock"
396
+ const val KEY_ALIAS_PREFIX = "com.clerk.trusted_device."
397
+ const val LOCAL_KEY_ID = "tdlk_0123456789abcdef0123456789abcdef"
398
+ const val USER_HINT_SHA256 = "b4c9a289323b21a01c3e940f150eb9b8c542587f1abfd8f0e1cc1ffc5e475514"
399
+
400
+ internal val fixtureRecords =
401
+ listOf(
402
+ BiometricCredentialLocalRecord(
403
+ id = "td_current_set",
404
+ localKeyId = LOCAL_KEY_ID,
405
+ userId = "user_1",
406
+ appIdentifier = "com.example.app",
407
+ identifierHintSha256 = USER_HINT_SHA256,
408
+ policy = BiometricCredentialPolicy.BIOMETRY_CURRENT_SET,
409
+ createdAt = 1_735_689_600_000,
410
+ updatedAt = 1_735_689_600_001,
411
+ ),
412
+ BiometricCredentialLocalRecord(
413
+ id = "td_any",
414
+ localKeyId = "tdlk_fedcba9876543210fedcba9876543210",
415
+ userId = "user_2",
416
+ appIdentifier = "com.example.app",
417
+ identifierHintSha256 = null,
418
+ policy = BiometricCredentialPolicy.BIOMETRY_ANY,
419
+ createdAt = 1_735_689_700_000,
420
+ updatedAt = 1_735_689_700_000,
421
+ ),
422
+ BiometricCredentialLocalRecord(
423
+ id = "td_device_passcode",
424
+ localKeyId = "tdlk_00000000000000000000000000000000",
425
+ userId = "user_3",
426
+ appIdentifier = "com.example.other",
427
+ identifierHintSha256 = null,
428
+ policy = BiometricCredentialPolicy.BIOMETRY_OR_DEVICE_PASSCODE,
429
+ createdAt = 1_735_689_800_000,
430
+ updatedAt = 1_735_689_800_000,
431
+ ),
432
+ )
433
+
434
+ fun fixture(): String =
435
+ checkNotNull(BiometricCredentialStorageContractTest::class.java.getResourceAsStream("/biometric-credential-storage/v2/biometric_credentials.v2.json"))
436
+ .use { it.readBytes().toString(Charsets.UTF_8) }
437
+
438
+ fun parse(json: String): Any? = toValue(org.json.JSONTokener(json).nextValue())
439
+
440
+ /** Converts org.json values to plain collections so equality ignores key order and Int/Long boxing. */
441
+ fun toValue(value: Any?): Any? =
442
+ when (value) {
443
+ is JSONObject -> value.keys().asSequence().associateWith { toValue(value.get(it)) }
444
+ is JSONArray -> List(value.length()) { toValue(value.get(it)) }
445
+ is Int -> value.toLong()
446
+ JSONObject.NULL -> null
447
+ else -> value
448
+ }
449
+ }
450
+ }
@@ -0,0 +1,179 @@
1
+ package expo.modules.clerk.biometrics
2
+
3
+ import expo.modules.clerk.biometrics.BiometricCredentialStorageContractTest.Companion.USER_HINT_SHA256
4
+ import expo.modules.clerk.biometrics.BiometricCredentialStorageContractTest.Companion.fixture
5
+ import expo.modules.clerk.biometrics.BiometricCredentialStorageContractTest.Companion.parse
6
+ import java.io.File
7
+ import java.io.IOException
8
+ import org.json.JSONObject
9
+ import org.junit.Assert.assertEquals
10
+ import org.junit.Assert.assertFalse
11
+ import org.junit.Assert.assertThrows
12
+ import org.junit.Assert.assertTrue
13
+ import org.junit.Rule
14
+ import org.junit.Test
15
+ import org.junit.rules.TemporaryFolder
16
+ import org.junit.runner.RunWith
17
+ import org.robolectric.RobolectricTestRunner
18
+
19
+ @RunWith(RobolectricTestRunner::class)
20
+ class BiometricCredentialStoreTest {
21
+ @get:Rule val temporaryFolder = TemporaryFolder()
22
+
23
+ private val directory by lazy { File(temporaryFolder.root, "clerk") }
24
+ private val dataFile by lazy { File(directory, "biometric_credentials.v2.json") }
25
+ private val fileStore by lazy { BiometricCredentialFileStore(directory) }
26
+ private val deletedKeys = mutableListOf<String>()
27
+ private val undeletableKeys = mutableSetOf<String>()
28
+ private val store by lazy {
29
+ BiometricCredentialStore(fileStore) { localKeyId ->
30
+ if (localKeyId in undeletableKeys) throw IOException("Keystore failure")
31
+ deletedKeys += localKeyId
32
+ }
33
+ }
34
+
35
+ @Test
36
+ fun `listRecords maps stored records to the JS shape and passes unknown fields through`() {
37
+ writeFixture()
38
+
39
+ assertEquals(
40
+ parse(
41
+ """[
42
+ {"id":"td_current_set","localKeyId":"tdlk_0123456789abcdef0123456789abcdef","userId":"user_1",
43
+ "appIdentifier":"com.example.app","identifierHint":null,"identifierHintSha256":"$USER_HINT_SHA256",
44
+ "policy":"biometry_current_set","createdAt":1735689600000,"updatedAt":1735689600001,
45
+ "future_field":{"nested":[1,2,3]}},
46
+ {"id":"td_any","localKeyId":"tdlk_fedcba9876543210fedcba9876543210","userId":"user_2",
47
+ "appIdentifier":"com.example.app","identifierHint":null,"identifierHintSha256":null,
48
+ "policy":"biometry_any","createdAt":1735689700000,"updatedAt":1735689700000},
49
+ {"id":"td_device_passcode","localKeyId":"tdlk_00000000000000000000000000000000","userId":"user_3",
50
+ "appIdentifier":"com.example.other","identifierHint":null,"identifierHintSha256":null,
51
+ "policy":"biometry_or_device_passcode","createdAt":1735689800000,"updatedAt":1735689800000}
52
+ ]"""
53
+ ),
54
+ parse(store.listRecordsJson()),
55
+ )
56
+ }
57
+
58
+ @Test
59
+ fun `listRecords is empty without a file`() {
60
+ assertEquals("[]", store.listRecordsJson())
61
+ }
62
+
63
+ @Test
64
+ fun `saveRecord stores only the hash of the identifier hint`() {
65
+ store.save(input(identifierHint = " User@Example.COM\n"), removeOtherRecordsForApp = false)
66
+
67
+ val stored = JSONObject(dataFile.readText()).getJSONArray("credentials").getJSONObject(0)
68
+ assertEquals(USER_HINT_SHA256, stored.getString("identifier_hint_sha256"))
69
+ assertFalse(dataFile.readText().contains("example.com", ignoreCase = true))
70
+ assertEquals(
71
+ setOf("id", "local_key_id", "user_id", "app_identifier", "identifier_hint_sha256", "policy", "created_at", "updated_at"),
72
+ stored.keys().asSequence().toSet(),
73
+ )
74
+ assertEquals(1_735_689_600_001, stored.getLong("created_at"))
75
+
76
+ store.save(input(identifierHint = " "), removeOtherRecordsForApp = false)
77
+ assertFalse(JSONObject(dataFile.readText()).getJSONArray("credentials").getJSONObject(0).has("identifier_hint_sha256"))
78
+ }
79
+
80
+ @Test
81
+ fun `saveRecord deletes the key of the record it replaces`() {
82
+ store.save(input(localKeyId = "tdlk_old"), removeOtherRecordsForApp = false)
83
+ store.save(input(localKeyId = "tdlk_new"), removeOtherRecordsForApp = false)
84
+
85
+ assertEquals(listOf("tdlk_old"), deletedKeys)
86
+ assertEquals(listOf("tdlk_new"), fileStore.records().map { it.record.localKeyId })
87
+ }
88
+
89
+ @Test
90
+ fun `removing other records deletes only the same user's records for the app`() {
91
+ store.save(input(id = "td_same_user", localKeyId = "tdlk_same_user"), removeOtherRecordsForApp = false)
92
+ store.save(input(id = "td_shared_key", localKeyId = "tdlk_new"), removeOtherRecordsForApp = false)
93
+ store.save(input(id = "td_other_user", localKeyId = "tdlk_other_user", userId = "user_2"), removeOtherRecordsForApp = false)
94
+ store.save(input(id = "td_other_app", localKeyId = "tdlk_other_app", appIdentifier = "com.example.other"), removeOtherRecordsForApp = false)
95
+ store.save(input(id = "td_stuck", localKeyId = "tdlk_stuck"), removeOtherRecordsForApp = false)
96
+ undeletableKeys += "tdlk_stuck"
97
+
98
+ store.save(input(id = "td_new", localKeyId = "tdlk_new"), removeOtherRecordsForApp = true)
99
+
100
+ assertEquals(listOf("tdlk_same_user"), deletedKeys)
101
+ assertEquals(
102
+ listOf("td_other_user", "td_other_app", "td_stuck", "td_new"),
103
+ fileStore.records().map { it.record.id },
104
+ )
105
+ }
106
+
107
+ @Test
108
+ fun `removing other records keeps records this module cannot decode`() {
109
+ writeFixture()
110
+
111
+ store.save(input(id = "td_new", localKeyId = "tdlk_new", userId = "user_4"), removeOtherRecordsForApp = true)
112
+
113
+ val ids = JSONObject(dataFile.readText()).getJSONArray("credentials").let { array -> List(array.length()) { array.getJSONObject(it).getString("id") } }
114
+ assertEquals(listOf("td_current_set", "td_any", "td_device_passcode", "td_future_policy", "td_incomplete", "td_new"), ids)
115
+ assertTrue(deletedKeys.isEmpty())
116
+ }
117
+
118
+ @Test
119
+ fun `deleteRecord deletes the key then the records that reference it`() {
120
+ writeFixture()
121
+
122
+ store.deleteRecords("tdlk_fedcba9876543210fedcba9876543210")
123
+
124
+ assertEquals(listOf("tdlk_fedcba9876543210fedcba9876543210"), deletedKeys)
125
+ assertEquals(listOf("td_current_set", "td_device_passcode"), fileStore.records().map { it.record.id })
126
+ assertTrue(dataFile.readText().contains("td_future_policy"))
127
+ }
128
+
129
+ @Test
130
+ fun `deleteRecord keeps the records when the key cannot be deleted`() {
131
+ writeFixture()
132
+ undeletableKeys += "tdlk_fedcba9876543210fedcba9876543210"
133
+
134
+ assertThrows(IOException::class.java) { store.deleteRecords("tdlk_fedcba9876543210fedcba9876543210") }
135
+
136
+ assertEquals(parse(fixture()), parse(dataFile.readText()))
137
+ }
138
+
139
+ @Test
140
+ fun `record input is validated`() {
141
+ val invalid =
142
+ listOf(
143
+ input(id = ""),
144
+ input(localKeyId = ""),
145
+ input(userId = ""),
146
+ input(appIdentifier = ""),
147
+ input(policy = "face_id"),
148
+ input(createdAt = -1.0),
149
+ input(updatedAt = Double.NaN),
150
+ input(createdAt = 9.0e15),
151
+ )
152
+ for (record in invalid) {
153
+ val error = assertThrows(BiometricsError::class.java) { store.save(record, removeOtherRecordsForApp = false) }
154
+ assertEquals(BiometricsErrorCode.INVALID_ARGUMENT, error.code)
155
+ }
156
+ assertFalse(dataFile.exists())
157
+ }
158
+
159
+ @Test
160
+ fun `timestamps are rounded to integer milliseconds`() {
161
+ assertEquals(1_714_000_000_001, input(createdAt = 1_714_000_000_000.5).toLocalRecord().createdAt)
162
+ }
163
+
164
+ private fun writeFixture() {
165
+ directory.mkdirs()
166
+ dataFile.writeText(fixture())
167
+ }
168
+
169
+ private fun input(
170
+ id: String = "td_1",
171
+ localKeyId: String = "tdlk_1",
172
+ userId: String = "user_1",
173
+ appIdentifier: String = "com.example.app",
174
+ identifierHint: String? = null,
175
+ policy: String = "biometry_current_set",
176
+ createdAt: Double = 1_735_689_600_001.0,
177
+ updatedAt: Double = 1_735_689_600_002.0,
178
+ ) = BiometricCredentialRecordInput(id, localKeyId, userId, appIdentifier, identifierHint, policy, createdAt, updatedAt)
179
+ }
@@ -0,0 +1,49 @@
1
+ package expo.modules.clerk.biometrics
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertFalse
5
+ import org.junit.Assert.assertThrows
6
+ import org.junit.Assert.assertTrue
7
+ import org.junit.Test
8
+ import org.junit.runner.RunWith
9
+ import org.robolectric.RobolectricTestRunner
10
+ import org.robolectric.RuntimeEnvironment
11
+ import org.robolectric.annotation.Config
12
+
13
+ @RunWith(RobolectricTestRunner::class)
14
+ class BiometricKeyManagerTest {
15
+ @Test
16
+ fun `secure key storage requires Android 9`() {
17
+ assertFalse(BiometricKeyManager.secureKeyStorageAvailable(sdkInt = 27))
18
+ assertTrue(BiometricKeyManager.secureKeyStorageAvailable(sdkInt = 28))
19
+ assertTrue(BiometricKeyManager.secureKeyStorageAvailable(sdkInt = 36))
20
+ }
21
+
22
+ @Test
23
+ @Config(sdk = [27])
24
+ fun `availability reports no secure key storage before Android 9`() {
25
+ val availability = BiometricKeyManager().availability(RuntimeEnvironment.getApplication())
26
+
27
+ assertFalse(availability.secureKeyStorageAvailable)
28
+ assertFalse(availability.canEvaluateBiometrics)
29
+ assertEquals(BiometricsErrorCode.BIOMETRY_NOT_AVAILABLE, availability.errorCode)
30
+ }
31
+
32
+ @Test
33
+ @Config(sdk = [28])
34
+ fun `availability reports secure key storage on Android 9 and later`() {
35
+ assertTrue(BiometricKeyManager().availability(RuntimeEnvironment.getApplication()).secureKeyStorageAvailable)
36
+ }
37
+
38
+ @Test
39
+ @Config(sdk = [27])
40
+ fun `createKey rejects with secure_key_storage_unavailable before Android 9`() {
41
+ for (policy in BiometricCredentialPolicy.entries) {
42
+ val error =
43
+ assertThrows(BiometricsError::class.java) {
44
+ BiometricKeyManager().createKey(RuntimeEnvironment.getApplication(), policy)
45
+ }
46
+ assertEquals(BiometricsErrorCode.SECURE_KEY_STORAGE_UNAVAILABLE, error.code)
47
+ }
48
+ }
49
+ }