@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Clerk, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,96 @@
1
+ <p align="center">
2
+ <a href="https://clerk.com?utm_source=github&utm_medium=clerk_expo_biometrics" target="_blank" rel="noopener noreferrer">
3
+ <picture>
4
+ <source media="(prefers-color-scheme: dark)" srcset="https://images.clerk.com/static/logo-dark-mode-400x400.png">
5
+ <img src="https://images.clerk.com/static/logo-light-mode-400x400.png" height="64">
6
+ </picture>
7
+ </a>
8
+ <br />
9
+ <h1 align="center">@clerk/expo-biometrics</h1>
10
+ </p>
11
+
12
+ <div align="center">
13
+
14
+ [![Chat on Discord](https://img.shields.io/discord/856971667393609759.svg?logo=discord)](https://clerk.com/discord)
15
+ [![Clerk documentation](https://img.shields.io/badge/documentation-clerk-green.svg)](https://clerk.com/docs?utm_source=github&utm_medium=expo_biometrics)
16
+ [![Follow on X](https://img.shields.io/twitter/follow/clerk?style=social)](https://x.com/intent/follow?screen_name=clerk)
17
+
18
+ [Changelog](https://github.com/clerk/javascript/blob/main/packages/expo-biometrics/CHANGELOG.md)
19
+ ·
20
+ [Report a Bug](https://github.com/clerk/javascript/issues/new?assignees=&labels=needs-triage&projects=&template=BUG_REPORT.yml)
21
+ ·
22
+ [Request a Feature](https://feedback.clerk.com/roadmap)
23
+ ·
24
+ [Get help](https://clerk.com/contact/support?utm_source=github&utm_medium=expo_biometrics)
25
+
26
+ </div>
27
+
28
+ > [!NOTE]
29
+ > Install this package to use `useBiometricCredentials()` from `@clerk/expo`. Its JavaScript API is used by `@clerk/expo` and isn't meant to be called directly.
30
+
31
+ The native building block for Clerk biometric credentials in Expo apps. It creates hardware-backed signing keys, signs challenges behind a Face ID / Touch ID or Android biometric prompt, and stores the on-device records that link each key to a Clerk credential. It does not talk to Clerk's API; `@clerk/expo` builds the sign-in and enrollment flows on top of it.
32
+
33
+ The key and record layout is shared with the Clerk iOS and Android SDKs, so credentials enrolled by either SDK in the same app are visible to both.
34
+
35
+ ### Prerequisites
36
+
37
+ - Expo SDK 54 or later, in a development build (the module is not available in Expo Go or on the web)
38
+ - iOS: `NSFaceIDUsageDescription` in your `Info.plist`. The `@clerk/expo` config plugin sets it through its `faceIDPermission` option.
39
+ - iOS: a device with a Secure Enclave. The iOS Simulator has none, so `createKey()` rejects there with `secure_key_storage_unavailable`.
40
+ - Android 9 (API level 28) or later with a strong (Class 3) biometric. On older versions `getAvailability()` reports `secureKeyStorageAvailable: false`, `createKey()` rejects with `secure_key_storage_unavailable`, and `sign()` with `biometry_not_available`.
41
+
42
+ ## Installation
43
+
44
+ ```sh
45
+ npx expo install @clerk/expo-biometrics
46
+ ```
47
+
48
+ Then rebuild your native app.
49
+
50
+ ## API
51
+
52
+ For reference only. `@clerk/expo` calls these for you.
53
+
54
+ ```ts
55
+ import {
56
+ createKey,
57
+ deleteKey,
58
+ deleteRecord,
59
+ ensureInstallationMarker,
60
+ getAppIdentifier,
61
+ getAvailability,
62
+ hashIdentifierHint,
63
+ hasKey,
64
+ listRecords,
65
+ saveRecord,
66
+ sign,
67
+ } from '@clerk/expo-biometrics';
68
+ ```
69
+
70
+ | Function | Description |
71
+ | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
72
+ | `getAppIdentifier()` | The app identifier sent to Clerk as `app_identifier` (the iOS bundle identifier or the Android package name). |
73
+ | `hashIdentifierHint(hint)` | The SHA-256 of the trimmed, lowercased hint as lowercase hex, or `null` when it is empty. |
74
+ | `getAvailability()` | The device's biometry type, whether biometrics or device owner authentication can be evaluated, and secure key storage. |
75
+ | `createKey(policy)` | Creates a Secure Enclave or Android Keystore P-256 key and returns its `localKeyId` and public key JWK. |
76
+ | `sign(localKeyId, clientData, reason?)` | Prompts for authentication and returns an ES256 signature over `clientData` (raw `r \|\| s`, base64url without padding). |
77
+ | `hasKey(localKeyId)` / `deleteKey(localKeyId)` | Checks for or deletes a key. |
78
+ | `listRecords()` | Every stored credential record, for every app identifier, with its `identifierHintSha256`. |
79
+ | `saveRecord(record, options)` | Saves a record. With `removeOtherRecordsForApp: true`, deletes the app's other records and their keys. |
80
+ | `deleteRecord(localKeyId)` | Deletes a key, then the records that reference it. |
81
+ | `ensureInstallationMarker()` | Deletes records and keys left behind by a previous installation of the app. The store functions call it for you. |
82
+
83
+ ### Platform differences
84
+
85
+ - Android stores only the hash of the identifier hint, so its records have `identifierHint: null`. Match hints by comparing `hashIdentifierHint(hint)` with `identifierHintSha256`, which both platforms return.
86
+ - On Android, `removeOtherRecordsForApp` deletes only the same user's other records; other users' records are left for the next sign-in to reconcile.
87
+ - Android reports `biometryType: 'biometric'`, since it does not say which sensor is a strong biometric.
88
+ - Android's `ensureInstallationMarker()` always resolves `{ wiped: false }`: uninstalling the app already deletes its records and keys.
89
+
90
+ Every error is a `ClerkBiometricsError` with a stable `code`, such as `user_canceled`, `biometry_not_enrolled`, `biometry_lockout`, `key_not_found`, or `storage_failed`.
91
+
92
+ ## License
93
+
94
+ This project is licensed under the **MIT license**.
95
+
96
+ See [LICENSE](https://github.com/clerk/javascript/blob/main/packages/expo-biometrics/LICENSE) for more information.
@@ -0,0 +1,49 @@
1
+ apply plugin: 'com.android.library'
2
+ // AGP 9+ registers the `kotlin` extension itself, and applying kotlin-android on top of that fails configuration.
3
+ if (project.extensions.findByName('kotlin') == null) {
4
+ apply plugin: 'kotlin-android'
5
+ }
6
+
7
+ apply plugin: 'maven-publish'
8
+
9
+ group = 'expo.modules.clerk.biometrics'
10
+ version = '1.0.0'
11
+
12
+ def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
13
+ apply from: expoModulesCorePlugin
14
+ applyKotlinExpoModulesCorePlugin()
15
+ useCoreDependencies()
16
+ useExpoPublishing()
17
+
18
+ buildscript {
19
+ ext.safeExtGet = { prop, fallback ->
20
+ rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
21
+ }
22
+ }
23
+
24
+ android {
25
+ namespace "expo.modules.clerk.biometrics"
26
+
27
+ compileSdkVersion safeExtGet("compileSdkVersion", 36)
28
+
29
+ defaultConfig {
30
+ minSdkVersion safeExtGet("minSdkVersion", 24)
31
+ targetSdkVersion safeExtGet("targetSdkVersion", 36)
32
+ versionCode 1
33
+ versionName "1.0.0"
34
+ }
35
+
36
+ testOptions {
37
+ unitTests {
38
+ includeAndroidResources = true
39
+ }
40
+ }
41
+ }
42
+
43
+ dependencies {
44
+ implementation project(':expo-modules-core')
45
+ implementation "androidx.biometric:biometric:1.1.0"
46
+
47
+ testImplementation "junit:junit:4.13.2"
48
+ testImplementation "org.robolectric:robolectric:4.16"
49
+ }
@@ -0,0 +1,2 @@
1
+ <manifest>
2
+ </manifest>
@@ -0,0 +1,147 @@
1
+ package expo.modules.clerk.biometrics
2
+
3
+ import android.os.Build
4
+ import android.security.keystore.KeyGenParameterSpec
5
+ import android.security.keystore.KeyProperties
6
+ import android.util.Base64
7
+ import java.math.BigInteger
8
+ import java.security.MessageDigest
9
+ import java.security.spec.ECGenParameterSpec
10
+ import java.util.UUID
11
+
12
+ // Implements clerk-android source/api/docs/biometric-credential-storage-contract.md (version 2). Any change here must
13
+ // stay compatible with clerk-android, which reads and writes the same Keystore aliases and metadata file.
14
+
15
+ internal enum class BiometricCredentialPolicy(val value: String) {
16
+ BIOMETRY_CURRENT_SET("biometry_current_set"),
17
+ BIOMETRY_ANY("biometry_any"),
18
+ BIOMETRY_OR_DEVICE_PASSCODE("biometry_or_device_passcode");
19
+
20
+ companion object {
21
+ fun fromValue(value: String?): BiometricCredentialPolicy? = entries.firstOrNull { it.value == value }
22
+ }
23
+ }
24
+
25
+ internal object BiometricCredentialCoding {
26
+ const val KEY_ALIAS_PREFIX = "com.clerk.trusted_device."
27
+ const val LOCAL_KEY_ID_PREFIX = "tdlk_"
28
+ const val SIGNATURE_ALGORITHM = "SHA256withECDSA"
29
+ const val EC_CURVE = "secp256r1"
30
+ const val MIN_SDK = Build.VERSION_CODES.P
31
+
32
+ private const val COORDINATE_SIZE = 32
33
+ private const val HEX = "0123456789abcdef"
34
+
35
+ fun makeLocalKeyId(): String = LOCAL_KEY_ID_PREFIX + UUID.randomUUID().toString().replace("-", "").lowercase()
36
+
37
+ fun keyAlias(localKeyId: String): String = KEY_ALIAS_PREFIX + localKeyId
38
+
39
+ /** Lowercase hex SHA-256 of the trimmed, locale-independently lowercased hint, or `null` when it is empty. */
40
+ fun hashIdentifierHint(hint: String?): String? {
41
+ val normalized = hint?.trim()?.lowercase() ?: return null
42
+ if (normalized.isEmpty()) return null
43
+ val digest = MessageDigest.getInstance("SHA-256").digest(normalized.toByteArray(Charsets.UTF_8))
44
+ return buildString(digest.size * 2) {
45
+ for (byte in digest) {
46
+ val value = byte.toInt() and 0xFF
47
+ append(HEX[value ushr 4])
48
+ append(HEX[value and 0x0F])
49
+ }
50
+ }
51
+ }
52
+
53
+ fun keyAuthenticators(policy: BiometricCredentialPolicy): Int =
54
+ if (policy == BiometricCredentialPolicy.BIOMETRY_OR_DEVICE_PASSCODE) {
55
+ KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL
56
+ } else {
57
+ KeyProperties.AUTH_BIOMETRIC_STRONG
58
+ }
59
+
60
+ fun keyGenParameterSpec(localKeyId: String, policy: BiometricCredentialPolicy): KeyGenParameterSpec {
61
+ val builder =
62
+ KeyGenParameterSpec.Builder(keyAlias(localKeyId), KeyProperties.PURPOSE_SIGN)
63
+ .setAlgorithmParameterSpec(ECGenParameterSpec(EC_CURVE))
64
+ .setDigests(KeyProperties.DIGEST_SHA256)
65
+ .setUserAuthenticationRequired(true)
66
+ .setInvalidatedByBiometricEnrollment(policy == BiometricCredentialPolicy.BIOMETRY_CURRENT_SET)
67
+
68
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
69
+ builder.setUserAuthenticationParameters(0, keyAuthenticators(policy))
70
+ } else {
71
+ @Suppress("DEPRECATION")
72
+ builder.setUserAuthenticationValidityDurationSeconds(-1)
73
+ }
74
+ return builder.build()
75
+ }
76
+
77
+ fun base64UrlEncode(bytes: ByteArray): String =
78
+ Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
79
+
80
+ fun publicKeyJwk(x: BigInteger, y: BigInteger): String {
81
+ val encodedX = base64UrlEncode(fixedWidthCoordinate(x))
82
+ val encodedY = base64UrlEncode(fixedWidthCoordinate(y))
83
+ return """{"kty":"EC","crv":"P-256","x":"$encodedX","y":"$encodedY","alg":"ES256"}"""
84
+ }
85
+
86
+ private fun fixedWidthCoordinate(coordinate: BigInteger): ByteArray {
87
+ val bytes = coordinate.toByteArray()
88
+ return when {
89
+ bytes.size == COORDINATE_SIZE -> bytes
90
+ bytes.size > COORDINATE_SIZE -> bytes.copyOfRange(bytes.size - COORDINATE_SIZE, bytes.size)
91
+ else -> ByteArray(COORDINATE_SIZE - bytes.size) + bytes
92
+ }
93
+ }
94
+
95
+ fun rawES256SignatureFromDer(signature: ByteArray): ByteArray {
96
+ val reader = DerReader(signature)
97
+ if (reader.readByte() != 0x30) throw invalidSignature()
98
+ if (reader.readLength() != reader.remaining) throw invalidSignature()
99
+ val r = reader.readInteger()
100
+ val s = reader.readInteger()
101
+ if (reader.remaining != 0) throw invalidSignature()
102
+ return paddedComponent(r) + paddedComponent(s)
103
+ }
104
+
105
+ private fun paddedComponent(component: ByteArray): ByteArray {
106
+ if (component.isEmpty() || component[0].toInt() and 0x80 != 0) throw invalidSignature()
107
+ var start = 0
108
+ while (component.size - start > COORDINATE_SIZE && component[start].toInt() == 0) {
109
+ start += 1
110
+ }
111
+ val size = component.size - start
112
+ if (size !in 1..COORDINATE_SIZE) throw invalidSignature()
113
+ return ByteArray(COORDINATE_SIZE).also { component.copyInto(it, COORDINATE_SIZE - size, start) }
114
+ }
115
+
116
+ private fun invalidSignature() =
117
+ BiometricsError(BiometricsErrorCode.SIGNING_FAILED, "Android Keystore returned an invalid ES256 signature.")
118
+
119
+ private class DerReader(private val bytes: ByteArray) {
120
+ private var offset = 0
121
+
122
+ val remaining: Int
123
+ get() = bytes.size - offset
124
+
125
+ fun readByte(): Int {
126
+ if (offset >= bytes.size) throw invalidSignature()
127
+ return bytes[offset++].toInt() and 0xFF
128
+ }
129
+
130
+ fun readLength(): Int {
131
+ val first = readByte()
132
+ if (first and 0x80 == 0) return first
133
+ val byteCount = first and 0x7F
134
+ if (byteCount == 0 || byteCount > Int.SIZE_BYTES || byteCount > remaining) throw invalidSignature()
135
+ var length = 0
136
+ repeat(byteCount) { length = (length shl 8) or readByte() }
137
+ return length
138
+ }
139
+
140
+ fun readInteger(): ByteArray {
141
+ if (readByte() != 0x02) throw invalidSignature()
142
+ val length = readLength()
143
+ if (length <= 0 || length > remaining) throw invalidSignature()
144
+ return bytes.copyOfRange(offset, offset + length).also { offset += length }
145
+ }
146
+ }
147
+ }
@@ -0,0 +1,304 @@
1
+ package expo.modules.clerk.biometrics
2
+
3
+ import android.system.Os
4
+ import android.system.OsConstants
5
+ import java.io.File
6
+ import java.io.FileOutputStream
7
+ import java.io.IOException
8
+ import java.io.RandomAccessFile
9
+ import java.nio.channels.FileChannel
10
+ import java.nio.channels.FileLock
11
+ import java.nio.channels.OverlappingFileLockException
12
+ import java.util.concurrent.ConcurrentHashMap
13
+ import java.util.concurrent.TimeUnit
14
+ import java.util.concurrent.locks.ReentrantLock
15
+ import org.json.JSONArray
16
+ import org.json.JSONException
17
+ import org.json.JSONObject
18
+
19
+ /** A decodable credential record with a known policy (contract v2, section 3.3). */
20
+ internal data class BiometricCredentialLocalRecord(
21
+ val id: String,
22
+ val localKeyId: String,
23
+ val userId: String,
24
+ val appIdentifier: String,
25
+ val identifierHintSha256: String?,
26
+ val policy: BiometricCredentialPolicy,
27
+ val createdAt: Long,
28
+ val updatedAt: Long,
29
+ )
30
+
31
+ /** A decoded record and the stored JSON object it came from, including fields this module does not know. */
32
+ internal class StoredBiometricCredentialRecord(val record: BiometricCredentialLocalRecord, val json: JSONObject)
33
+
34
+ /**
35
+ * The contract v2 metadata file shared with clerk-android: plaintext JSON in `noBackupFilesDir/clerk`, changed only by
36
+ * read-modify-writes under an exclusive lock on a sibling lock file and replaced atomically. Everything this module
37
+ * does not understand is kept on rewrite.
38
+ */
39
+ internal class BiometricCredentialFileStore(
40
+ private val directory: File,
41
+ private val processGuard: ReentrantLock = PROCESS_GUARD,
42
+ private val lockTimeoutMillis: Long = LOCK_TIMEOUT_MILLIS,
43
+ ) {
44
+ val dataFile = File(directory, DATA_FILE_NAME)
45
+ val lockFile = File(directory, LOCK_FILE_NAME)
46
+ private val tempFile = File(directory, TEMP_FILE_NAME)
47
+
48
+ /** Reads without the lock: the atomic rename means a reader sees either the previous or the next complete file. */
49
+ fun records(): List<StoredBiometricCredentialRecord> {
50
+ val document = readDocument()
51
+ if (!document.writable) return emptyList()
52
+ return document.credentials.mapNotNull { element ->
53
+ val json = element as? JSONObject ?: return@mapNotNull null
54
+ BiometricCredentialRecordJson.decode(json)?.let { StoredBiometricCredentialRecord(it, json) }
55
+ }
56
+ }
57
+
58
+ /** Replaces the record with the same `id`, or appends it. Returns the replaced record's key when it differs. */
59
+ fun save(record: BiometricCredentialLocalRecord): String? {
60
+ var replacedLocalKeyId: String? = null
61
+ update { credentials ->
62
+ val index = credentials.indexOfFirst { recordId(it) == record.id }
63
+ val existing = credentials.getOrNull(index) as? JSONObject
64
+ val encoded = BiometricCredentialRecordJson.encode(record, preserving = existing)
65
+ if (index < 0) {
66
+ credentials.add(encoded)
67
+ } else {
68
+ replacedLocalKeyId =
69
+ (existing?.opt(BiometricCredentialRecordJson.LOCAL_KEY_ID) as? String)?.takeIf { it != record.localKeyId }
70
+ credentials[index] = encoded
71
+ for (i in credentials.lastIndex downTo index + 1) {
72
+ if (recordId(credentials[i]) == record.id) credentials.removeAt(i)
73
+ }
74
+ }
75
+ true
76
+ }
77
+ return replacedLocalKeyId
78
+ }
79
+
80
+ /** Removes every record, decodable or not, whose `id` is in [ids]. */
81
+ fun delete(ids: Set<String>) {
82
+ if (ids.isEmpty()) return
83
+ update { credentials -> credentials.removeAll { recordId(it) in ids } }
84
+ }
85
+
86
+ private fun update(transform: (MutableList<Any?>) -> Boolean) {
87
+ withExclusiveLock {
88
+ val document = readDocument()
89
+ if (!document.writable) {
90
+ throw IOException("The biometric credential store uses an unsupported version.")
91
+ }
92
+ val credentials = document.credentials.toMutableList()
93
+ if (transform(credentials)) {
94
+ writeDocument(document.serialized(credentials))
95
+ }
96
+ }
97
+ }
98
+
99
+ // Only a missing file is an empty store; other read errors must not let a writer replace it.
100
+ private fun readDocument(): StoreDocument {
101
+ if (!dataFile.exists()) return StoreDocument(JSONObject(), writable = true)
102
+ val text = dataFile.readText(Charsets.UTF_8)
103
+ val root =
104
+ try {
105
+ JSONObject(text)
106
+ } catch (_: JSONException) {
107
+ return StoreDocument(JSONObject(), writable = true)
108
+ }
109
+ val version = root.opt(KEY_VERSION)
110
+ val writable = (version is Int || version is Long) && (version as Number).toLong() == STORE_VERSION.toLong()
111
+ return StoreDocument(root, writable)
112
+ }
113
+
114
+ private fun writeDocument(text: String) {
115
+ ensureDirectory()
116
+ FileOutputStream(tempFile).use { output ->
117
+ output.write(text.toByteArray(Charsets.UTF_8))
118
+ output.flush()
119
+ output.fd.sync()
120
+ }
121
+ if (!tempFile.renameTo(dataFile)) {
122
+ tempFile.delete()
123
+ throw IOException("Failed to replace the biometric credential store.")
124
+ }
125
+ syncDirectory()
126
+ }
127
+
128
+ private fun syncDirectory() {
129
+ try {
130
+ val fd = Os.open(directory.path, OsConstants.O_RDONLY, 0)
131
+ try {
132
+ Os.fsync(fd)
133
+ } finally {
134
+ Os.close(fd)
135
+ }
136
+ } catch (_: Throwable) {
137
+ }
138
+ }
139
+
140
+ private fun ensureDirectory() {
141
+ if (!directory.isDirectory && !directory.mkdirs() && !directory.isDirectory) {
142
+ throw IOException("Failed to create the biometric credential store directory.")
143
+ }
144
+ }
145
+
146
+ private fun <T> withExclusiveLock(block: () -> T): T {
147
+ if (!processGuard.tryLock(lockTimeoutMillis, TimeUnit.MILLISECONDS)) {
148
+ throw IOException("Timed out waiting for the biometric credential store lock.")
149
+ }
150
+ try {
151
+ val fileLock = acquireFileLock()
152
+ try {
153
+ return block()
154
+ } finally {
155
+ fileLock.release()
156
+ }
157
+ } finally {
158
+ processGuard.unlock()
159
+ }
160
+ }
161
+
162
+ // Another SDK in this process holding the lock surfaces as OverlappingFileLockException, another process as null.
163
+ private fun acquireFileLock(): FileLock {
164
+ val channel = lockChannel()
165
+ val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(lockTimeoutMillis)
166
+ var backoffMillis = INITIAL_LOCK_BACKOFF_MILLIS
167
+ while (true) {
168
+ val lock =
169
+ try {
170
+ channel.tryLock()
171
+ } catch (_: OverlappingFileLockException) {
172
+ null
173
+ }
174
+ if (lock != null) return lock
175
+ if (System.nanoTime() >= deadline) {
176
+ throw IOException("Timed out waiting for the biometric credential store lock.")
177
+ }
178
+ Thread.sleep(backoffMillis)
179
+ backoffMillis = (backoffMillis * 2).coerceAtMost(MAX_LOCK_BACKOFF_MILLIS)
180
+ }
181
+ }
182
+
183
+ // Closing any descriptor of the lock file can release this process's POSIX lock, so channels are never closed.
184
+ private fun lockChannel(): FileChannel {
185
+ val path = lockFile.absolutePath
186
+ LOCK_CHANNELS[path]?.let { return it }
187
+ synchronized(LOCK_CHANNELS) {
188
+ return LOCK_CHANNELS.getOrPut(path) {
189
+ ensureDirectory()
190
+ RandomAccessFile(lockFile, "rw").channel
191
+ }
192
+ }
193
+ }
194
+
195
+ private class StoreDocument(val root: JSONObject, val writable: Boolean) {
196
+ val credentials: List<Any?> = (root.opt(KEY_CREDENTIALS) as? JSONArray).elements()
197
+
198
+ /** Known keys first, then unknown top-level keys in their original order. */
199
+ fun serialized(credentials: List<Any?>): String {
200
+ val pending =
201
+ (root.opt(KEY_PENDING_CLEANUP_USER_IDS) as? JSONArray)
202
+ .elements()
203
+ .filterIsInstance<String>()
204
+ .filter { it.isNotBlank() }
205
+ .toSortedSet()
206
+ val output = JSONObject()
207
+ output.put(KEY_VERSION, STORE_VERSION)
208
+ output.put(KEY_CREDENTIALS, JSONArray(credentials))
209
+ output.put(KEY_PENDING_CLEANUP_USER_IDS, JSONArray(pending.toList()))
210
+ for (key in root.keys()) {
211
+ if (key !in TOP_LEVEL_KEYS) output.put(key, root.get(key))
212
+ }
213
+ return output.toString()
214
+ }
215
+ }
216
+
217
+ companion object {
218
+ const val DIRECTORY_NAME = "clerk"
219
+ const val DATA_FILE_NAME = "biometric_credentials.v2.json"
220
+ const val TEMP_FILE_NAME = "biometric_credentials.v2.json.tmp"
221
+ const val LOCK_FILE_NAME = "biometric_credentials.lock"
222
+ const val STORE_VERSION = 2
223
+
224
+ const val KEY_VERSION = "version"
225
+ const val KEY_CREDENTIALS = "credentials"
226
+ const val KEY_PENDING_CLEANUP_USER_IDS = "pending_cleanup_user_ids"
227
+ private val TOP_LEVEL_KEYS = setOf(KEY_VERSION, KEY_CREDENTIALS, KEY_PENDING_CLEANUP_USER_IDS)
228
+
229
+ private const val LOCK_TIMEOUT_MILLIS = 5_000L
230
+ private const val INITIAL_LOCK_BACKOFF_MILLIS = 2L
231
+ private const val MAX_LOCK_BACKOFF_MILLIS = 50L
232
+
233
+ private val PROCESS_GUARD = ReentrantLock()
234
+ private val LOCK_CHANNELS = ConcurrentHashMap<String, FileChannel>()
235
+
236
+ fun inDirectory(noBackupFilesDir: File) = BiometricCredentialFileStore(File(noBackupFilesDir, DIRECTORY_NAME))
237
+
238
+ private fun recordId(element: Any?): String? = (element as? JSONObject)?.opt(BiometricCredentialRecordJson.ID) as? String
239
+
240
+ private fun JSONArray?.elements(): List<Any?> = if (this == null) emptyList() else List(length()) { opt(it) }
241
+ }
242
+ }
243
+
244
+ /** Contract v2 JSON encoding of a single credential record. */
245
+ internal object BiometricCredentialRecordJson {
246
+ const val ID = "id"
247
+ const val LOCAL_KEY_ID = "local_key_id"
248
+ const val USER_ID = "user_id"
249
+ const val APP_IDENTIFIER = "app_identifier"
250
+ const val IDENTIFIER_HINT_SHA256 = "identifier_hint_sha256"
251
+ const val POLICY = "policy"
252
+ const val CREATED_AT = "created_at"
253
+ const val UPDATED_AT = "updated_at"
254
+
255
+ val KNOWN_FIELDS = setOf(ID, LOCAL_KEY_ID, USER_ID, APP_IDENTIFIER, IDENTIFIER_HINT_SHA256, POLICY, CREATED_AT, UPDATED_AT)
256
+
257
+ /** Returns `null` for records that are malformed or use a policy this module does not know. */
258
+ fun decode(json: JSONObject): BiometricCredentialLocalRecord? {
259
+ val hint =
260
+ when (val value = json.opt(IDENTIFIER_HINT_SHA256)) {
261
+ null, JSONObject.NULL -> null
262
+ is String -> value
263
+ else -> return null
264
+ }
265
+ return BiometricCredentialLocalRecord(
266
+ id = json.string(ID) ?: return null,
267
+ localKeyId = json.string(LOCAL_KEY_ID) ?: return null,
268
+ userId = json.string(USER_ID) ?: return null,
269
+ appIdentifier = json.string(APP_IDENTIFIER) ?: return null,
270
+ identifierHintSha256 = hint,
271
+ policy = BiometricCredentialPolicy.fromValue(json.string(POLICY)) ?: return null,
272
+ createdAt = json.long(CREATED_AT) ?: return null,
273
+ updatedAt = json.long(UPDATED_AT) ?: return null,
274
+ )
275
+ }
276
+
277
+ /** Encodes [record], keeping every field of [preserving] that is not part of the schema. */
278
+ fun encode(record: BiometricCredentialLocalRecord, preserving: JSONObject?): JSONObject {
279
+ val json = JSONObject()
280
+ json.put(ID, record.id)
281
+ json.put(LOCAL_KEY_ID, record.localKeyId)
282
+ json.put(USER_ID, record.userId)
283
+ json.put(APP_IDENTIFIER, record.appIdentifier)
284
+ record.identifierHintSha256?.let { json.put(IDENTIFIER_HINT_SHA256, it) }
285
+ json.put(POLICY, record.policy.value)
286
+ json.put(CREATED_AT, record.createdAt)
287
+ json.put(UPDATED_AT, record.updatedAt)
288
+ if (preserving != null) {
289
+ for (key in preserving.keys()) {
290
+ if (key !in KNOWN_FIELDS) json.put(key, preserving.get(key))
291
+ }
292
+ }
293
+ return json
294
+ }
295
+
296
+ private fun JSONObject.string(key: String): String? = opt(key) as? String
297
+
298
+ private fun JSONObject.long(key: String): Long? =
299
+ when (val value = opt(key)) {
300
+ is Int -> value.toLong()
301
+ is Long -> value
302
+ else -> null
303
+ }
304
+ }