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