@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,96 @@
|
|
|
1
|
+
package expo.modules.clerk.biometrics
|
|
2
|
+
|
|
3
|
+
import org.json.JSONArray
|
|
4
|
+
import org.json.JSONObject
|
|
5
|
+
|
|
6
|
+
/** The record shape the JS API uses, before the identifier hint is hashed for storage. */
|
|
7
|
+
internal data class BiometricCredentialRecordInput(
|
|
8
|
+
val id: String,
|
|
9
|
+
val localKeyId: String,
|
|
10
|
+
val userId: String,
|
|
11
|
+
val appIdentifier: String,
|
|
12
|
+
val identifierHint: String?,
|
|
13
|
+
val policy: String,
|
|
14
|
+
val createdAt: Double,
|
|
15
|
+
val updatedAt: Double,
|
|
16
|
+
) {
|
|
17
|
+
fun toLocalRecord(): BiometricCredentialLocalRecord {
|
|
18
|
+
for ((name, value) in listOf("id" to id, "localKeyId" to localKeyId, "userId" to userId, "appIdentifier" to appIdentifier)) {
|
|
19
|
+
if (value.isEmpty()) {
|
|
20
|
+
throw BiometricsError(BiometricsErrorCode.INVALID_ARGUMENT, "record.$name must be a non-empty string.")
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
val policy =
|
|
24
|
+
BiometricCredentialPolicy.fromValue(policy)
|
|
25
|
+
?: throw BiometricsError(BiometricsErrorCode.INVALID_ARGUMENT, "Unknown biometric credential policy '$policy'.")
|
|
26
|
+
if (!isValidTimestamp(createdAt) || !isValidTimestamp(updatedAt)) {
|
|
27
|
+
throw BiometricsError(
|
|
28
|
+
BiometricsErrorCode.INVALID_ARGUMENT,
|
|
29
|
+
"record.createdAt and record.updatedAt must be milliseconds since the Unix epoch.",
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
return BiometricCredentialLocalRecord(
|
|
33
|
+
id = id,
|
|
34
|
+
localKeyId = localKeyId,
|
|
35
|
+
userId = userId,
|
|
36
|
+
appIdentifier = appIdentifier,
|
|
37
|
+
identifierHintSha256 = BiometricCredentialCoding.hashIdentifierHint(identifierHint),
|
|
38
|
+
policy = policy,
|
|
39
|
+
createdAt = Math.round(createdAt),
|
|
40
|
+
updatedAt = Math.round(updatedAt),
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private fun isValidTimestamp(value: Double) = value.isFinite() && value >= 0 && value < 9.0e15
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The store operations exposed to JS, on top of the contract v2 file and the Keystore. */
|
|
48
|
+
internal class BiometricCredentialStore(
|
|
49
|
+
private val fileStore: BiometricCredentialFileStore,
|
|
50
|
+
private val deleteKey: (String) -> Unit,
|
|
51
|
+
) {
|
|
52
|
+
/** Every decodable record for every app, mapped to the JS field names, with unknown stored fields passed through. */
|
|
53
|
+
fun listRecordsJson(): String = JSONArray(fileStore.records().map(::bridgeJson)).toString()
|
|
54
|
+
|
|
55
|
+
fun save(input: BiometricCredentialRecordInput, removeOtherRecordsForApp: Boolean) {
|
|
56
|
+
val record = input.toLocalRecord()
|
|
57
|
+
fileStore.save(record)?.let { runCatching { deleteKey(it) } }
|
|
58
|
+
if (!removeOtherRecordsForApp) return
|
|
59
|
+
|
|
60
|
+
// Contract v2 section 5.3: only the same user's other credentials; other users' are left for reconciliation.
|
|
61
|
+
val removable =
|
|
62
|
+
fileStore
|
|
63
|
+
.records()
|
|
64
|
+
.map { it.record }
|
|
65
|
+
.filter { it.appIdentifier == record.appIdentifier && it.userId == record.userId && it.id != record.id }
|
|
66
|
+
.filter { it.localKeyId == record.localKeyId || runCatching { deleteKey(it.localKeyId) }.isSuccess }
|
|
67
|
+
.map { it.id }
|
|
68
|
+
runCatching { fileStore.delete(removable.toSet()) }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Deletes the key, then every record that references it. When the key cannot be deleted the records are kept. */
|
|
72
|
+
fun deleteRecords(localKeyId: String) {
|
|
73
|
+
deleteKey(localKeyId)
|
|
74
|
+
fileStore.delete(fileStore.records().filter { it.record.localKeyId == localKeyId }.map { it.record.id }.toSet())
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
companion object {
|
|
78
|
+
fun bridgeJson(stored: StoredBiometricCredentialRecord): JSONObject {
|
|
79
|
+
val json = JSONObject()
|
|
80
|
+
for (key in stored.json.keys()) {
|
|
81
|
+
if (key !in BiometricCredentialRecordJson.KNOWN_FIELDS) json.put(key, stored.json.get(key))
|
|
82
|
+
}
|
|
83
|
+
val record = stored.record
|
|
84
|
+
json.put("id", record.id)
|
|
85
|
+
json.put("localKeyId", record.localKeyId)
|
|
86
|
+
json.put("userId", record.userId)
|
|
87
|
+
json.put("appIdentifier", record.appIdentifier)
|
|
88
|
+
json.put("identifierHint", JSONObject.NULL)
|
|
89
|
+
json.put("identifierHintSha256", record.identifierHintSha256 ?: JSONObject.NULL)
|
|
90
|
+
json.put("policy", record.policy.value)
|
|
91
|
+
json.put("createdAt", record.createdAt)
|
|
92
|
+
json.put("updatedAt", record.updatedAt)
|
|
93
|
+
return json
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
package expo.modules.clerk.biometrics
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.os.Build
|
|
5
|
+
import android.security.keystore.KeyInfo
|
|
6
|
+
import android.security.keystore.KeyPermanentlyInvalidatedException
|
|
7
|
+
import android.security.keystore.KeyProperties
|
|
8
|
+
import androidx.annotation.RequiresApi
|
|
9
|
+
import androidx.biometric.BiometricManager
|
|
10
|
+
import androidx.biometric.BiometricManager.Authenticators
|
|
11
|
+
import androidx.biometric.BiometricPrompt
|
|
12
|
+
import androidx.core.content.ContextCompat
|
|
13
|
+
import androidx.fragment.app.FragmentActivity
|
|
14
|
+
import java.security.KeyFactory
|
|
15
|
+
import java.security.KeyPairGenerator
|
|
16
|
+
import java.security.KeyStore
|
|
17
|
+
import java.security.PrivateKey
|
|
18
|
+
import java.security.Signature
|
|
19
|
+
import java.security.interfaces.ECPublicKey
|
|
20
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
21
|
+
|
|
22
|
+
internal data class BiometricAvailability(
|
|
23
|
+
val biometryType: String,
|
|
24
|
+
val canEvaluateBiometrics: Boolean,
|
|
25
|
+
val canEvaluateDeviceOwner: Boolean,
|
|
26
|
+
val errorCode: BiometricsErrorCode?,
|
|
27
|
+
val secureKeyStorageAvailable: Boolean,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
internal data class BiometricCredentialKey(val localKeyId: String, val publicKeyJwk: String)
|
|
31
|
+
|
|
32
|
+
/** Android Keystore keys laid out as clerk-android's `DefaultBiometricCredentialKeyManager` creates them. */
|
|
33
|
+
internal class BiometricKeyManager {
|
|
34
|
+
fun availability(context: Context): BiometricAvailability {
|
|
35
|
+
if (!secureKeyStorageAvailable()) {
|
|
36
|
+
return BiometricAvailability("none", false, false, BiometricsErrorCode.BIOMETRY_NOT_AVAILABLE, false)
|
|
37
|
+
}
|
|
38
|
+
val manager = BiometricManager.from(context)
|
|
39
|
+
val strong = manager.canAuthenticate(Authenticators.BIOMETRIC_STRONG)
|
|
40
|
+
val canEvaluateBiometrics = strong == BiometricManager.BIOMETRIC_SUCCESS
|
|
41
|
+
// Keys only accept device credentials on API 30+; before that, device owner authentication cannot sign.
|
|
42
|
+
val canEvaluateDeviceOwner =
|
|
43
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
44
|
+
manager.canAuthenticate(Authenticators.BIOMETRIC_STRONG or Authenticators.DEVICE_CREDENTIAL) ==
|
|
45
|
+
BiometricManager.BIOMETRIC_SUCCESS
|
|
46
|
+
} else {
|
|
47
|
+
canEvaluateBiometrics
|
|
48
|
+
}
|
|
49
|
+
return BiometricAvailability(
|
|
50
|
+
biometryType = biometryType(strong),
|
|
51
|
+
canEvaluateBiometrics = canEvaluateBiometrics,
|
|
52
|
+
canEvaluateDeviceOwner = canEvaluateDeviceOwner,
|
|
53
|
+
errorCode = if (canEvaluateBiometrics) null else BiometricsError.forCanAuthenticate(strong).code,
|
|
54
|
+
secureKeyStorageAvailable = true,
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
fun createKey(context: Context, policy: BiometricCredentialPolicy): BiometricCredentialKey {
|
|
59
|
+
if (!secureKeyStorageAvailable()) {
|
|
60
|
+
throw BiometricsError(
|
|
61
|
+
BiometricsErrorCode.SECURE_KEY_STORAGE_UNAVAILABLE,
|
|
62
|
+
"Biometric credential keys require Android 9 (API 28) or later.",
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
val status = BiometricManager.from(context).canAuthenticate(promptAuthenticators(policy))
|
|
66
|
+
if (status != BiometricManager.BIOMETRIC_SUCCESS) {
|
|
67
|
+
throw BiometricsError.forCanAuthenticate(status)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
val localKeyId = BiometricCredentialCoding.makeLocalKeyId()
|
|
71
|
+
try {
|
|
72
|
+
val generator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, ANDROID_KEY_STORE)
|
|
73
|
+
generator.initialize(BiometricCredentialCoding.keyGenParameterSpec(localKeyId, policy))
|
|
74
|
+
val publicKey = generator.generateKeyPair().public as ECPublicKey
|
|
75
|
+
return BiometricCredentialKey(
|
|
76
|
+
localKeyId = localKeyId,
|
|
77
|
+
publicKeyJwk = BiometricCredentialCoding.publicKeyJwk(publicKey.w.affineX, publicKey.w.affineY),
|
|
78
|
+
)
|
|
79
|
+
} catch (e: Exception) {
|
|
80
|
+
runCatching { deleteKey(localKeyId) }
|
|
81
|
+
throw BiometricsError(
|
|
82
|
+
BiometricsErrorCode.KEY_GENERATION_FAILED,
|
|
83
|
+
e.message ?: "Unable to create the biometric credential key.",
|
|
84
|
+
e,
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
fun hasKey(localKeyId: String): Boolean =
|
|
90
|
+
storageCall { keyStore().containsAlias(BiometricCredentialCoding.keyAlias(localKeyId)) }
|
|
91
|
+
|
|
92
|
+
fun deleteKey(localKeyId: String) =
|
|
93
|
+
storageCall {
|
|
94
|
+
val keyStore = keyStore()
|
|
95
|
+
val alias = BiometricCredentialCoding.keyAlias(localKeyId)
|
|
96
|
+
if (keyStore.containsAlias(alias)) keyStore.deleteEntry(alias)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Shows the biometric prompt on [activity] and signs the UTF-8 bytes of [clientData] with the unlocked key. */
|
|
100
|
+
fun sign(
|
|
101
|
+
activity: FragmentActivity,
|
|
102
|
+
localKeyId: String,
|
|
103
|
+
clientData: String,
|
|
104
|
+
reason: String?,
|
|
105
|
+
onResult: (Result<String>) -> Unit,
|
|
106
|
+
) {
|
|
107
|
+
requireSupportedSdk()
|
|
108
|
+
val privateKey =
|
|
109
|
+
storageCall { keyStore().getKey(BiometricCredentialCoding.keyAlias(localKeyId), null) as? PrivateKey }
|
|
110
|
+
?: throw BiometricsError(BiometricsErrorCode.KEY_NOT_FOUND, "The biometric credential key was not found.")
|
|
111
|
+
val signature =
|
|
112
|
+
try {
|
|
113
|
+
Signature.getInstance(BiometricCredentialCoding.SIGNATURE_ALGORITHM).apply { initSign(privateKey) }
|
|
114
|
+
} catch (e: KeyPermanentlyInvalidatedException) {
|
|
115
|
+
throw BiometricsError(BiometricsErrorCode.KEY_INVALIDATED, "The biometric credential key was invalidated.", e)
|
|
116
|
+
} catch (e: Exception) {
|
|
117
|
+
throw BiometricsError(BiometricsErrorCode.SIGNING_FAILED, e.message ?: "Unable to initialize signing.", e)
|
|
118
|
+
}
|
|
119
|
+
val allowsDeviceCredential = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && keyAllowsDeviceCredential(privateKey)
|
|
120
|
+
val title = reason?.takeIf { it.isNotBlank() } ?: activity.applicationInfo.loadLabel(activity.packageManager).toString()
|
|
121
|
+
|
|
122
|
+
val completed = AtomicBoolean(false)
|
|
123
|
+
val complete = { result: Result<String> -> if (completed.compareAndSet(false, true)) onResult(result) }
|
|
124
|
+
val callback =
|
|
125
|
+
object : BiometricPrompt.AuthenticationCallback() {
|
|
126
|
+
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
|
127
|
+
val unlocked = result.cryptoObject?.signature ?: signature
|
|
128
|
+
complete(
|
|
129
|
+
runCatching {
|
|
130
|
+
unlocked.update(clientData.toByteArray(Charsets.UTF_8))
|
|
131
|
+
BiometricCredentialCoding.base64UrlEncode(BiometricCredentialCoding.rawES256SignatureFromDer(unlocked.sign()))
|
|
132
|
+
}.recoverCatching { e ->
|
|
133
|
+
throw e as? BiometricsError
|
|
134
|
+
?: BiometricsError(BiometricsErrorCode.SIGNING_FAILED, e.message ?: "Unable to sign the challenge.", e)
|
|
135
|
+
}
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
|
140
|
+
complete(Result.failure(BiometricsError.forPromptError(errorCode, errString)))
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
activity.runOnUiThread {
|
|
145
|
+
try {
|
|
146
|
+
val promptInfo =
|
|
147
|
+
BiometricPrompt.PromptInfo.Builder().setTitle(title).apply {
|
|
148
|
+
if (allowsDeviceCredential) {
|
|
149
|
+
setAllowedAuthenticators(Authenticators.BIOMETRIC_STRONG or Authenticators.DEVICE_CREDENTIAL)
|
|
150
|
+
} else {
|
|
151
|
+
setAllowedAuthenticators(Authenticators.BIOMETRIC_STRONG)
|
|
152
|
+
setNegativeButtonText(activity.getString(android.R.string.cancel))
|
|
153
|
+
}
|
|
154
|
+
}.build()
|
|
155
|
+
BiometricPrompt(activity, ContextCompat.getMainExecutor(activity), callback)
|
|
156
|
+
.authenticate(promptInfo, BiometricPrompt.CryptoObject(signature))
|
|
157
|
+
} catch (e: Exception) {
|
|
158
|
+
complete(
|
|
159
|
+
Result.failure(
|
|
160
|
+
BiometricsError(BiometricsErrorCode.AUTHENTICATION_FAILED, e.message ?: "Unable to show the prompt.", e)
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// The key's own protections decide whether a device credential can unlock it, so keys created for
|
|
168
|
+
// biometry_or_device_passcode on API 28-29 stay biometric-only after an OS upgrade.
|
|
169
|
+
@RequiresApi(Build.VERSION_CODES.R)
|
|
170
|
+
private fun keyAllowsDeviceCredential(key: PrivateKey): Boolean =
|
|
171
|
+
runCatching {
|
|
172
|
+
val info = KeyFactory.getInstance(key.algorithm, ANDROID_KEY_STORE).getKeySpec(key, KeyInfo::class.java)
|
|
173
|
+
info.userAuthenticationType and KeyProperties.AUTH_DEVICE_CREDENTIAL != 0
|
|
174
|
+
}.getOrDefault(false)
|
|
175
|
+
|
|
176
|
+
private fun requireSupportedSdk() {
|
|
177
|
+
if (Build.VERSION.SDK_INT < BiometricCredentialCoding.MIN_SDK) {
|
|
178
|
+
throw BiometricsError(BiometricsErrorCode.BIOMETRY_NOT_AVAILABLE, "Biometric credentials require Android 9 (API 28) or later.")
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private fun keyStore(): KeyStore = KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) }
|
|
183
|
+
|
|
184
|
+
private fun <T> storageCall(body: () -> T): T =
|
|
185
|
+
try {
|
|
186
|
+
body()
|
|
187
|
+
} catch (e: BiometricsError) {
|
|
188
|
+
throw e
|
|
189
|
+
} catch (e: Exception) {
|
|
190
|
+
throw BiometricsError(BiometricsErrorCode.STORAGE_FAILED, e.message ?: "Android Keystore access failed.", e)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
companion object {
|
|
194
|
+
private const val ANDROID_KEY_STORE = "AndroidKeyStore"
|
|
195
|
+
|
|
196
|
+
fun secureKeyStorageAvailable(sdkInt: Int = Build.VERSION.SDK_INT): Boolean = sdkInt >= BiometricCredentialCoding.MIN_SDK
|
|
197
|
+
|
|
198
|
+
/** Contract v2 section 2: device credentials only for biometry_or_device_passcode, and only on API 30+. */
|
|
199
|
+
fun promptAuthenticators(policy: BiometricCredentialPolicy, sdkInt: Int = Build.VERSION.SDK_INT): Int =
|
|
200
|
+
if (policy == BiometricCredentialPolicy.BIOMETRY_OR_DEVICE_PASSCODE && sdkInt >= Build.VERSION_CODES.R) {
|
|
201
|
+
Authenticators.BIOMETRIC_STRONG or Authenticators.DEVICE_CREDENTIAL
|
|
202
|
+
} else {
|
|
203
|
+
Authenticators.BIOMETRIC_STRONG
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Android does not report which biometric is Class 3, so any present strong biometric is `biometric`. */
|
|
207
|
+
fun biometryType(canAuthenticateStrong: Int): String =
|
|
208
|
+
when (canAuthenticateStrong) {
|
|
209
|
+
BiometricManager.BIOMETRIC_SUCCESS,
|
|
210
|
+
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED,
|
|
211
|
+
BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE,
|
|
212
|
+
BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> "biometric"
|
|
213
|
+
else -> "none"
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
package expo.modules.clerk.biometrics
|
|
2
|
+
|
|
3
|
+
import androidx.biometric.BiometricManager
|
|
4
|
+
import androidx.biometric.BiometricPrompt
|
|
5
|
+
|
|
6
|
+
internal enum class BiometricsErrorCode(val value: String) {
|
|
7
|
+
USER_CANCELED("user_canceled"),
|
|
8
|
+
SYSTEM_CANCELED("system_canceled"),
|
|
9
|
+
AUTHENTICATION_FAILED("authentication_failed"),
|
|
10
|
+
BIOMETRY_NOT_AVAILABLE("biometry_not_available"),
|
|
11
|
+
BIOMETRY_NOT_ENROLLED("biometry_not_enrolled"),
|
|
12
|
+
BIOMETRY_LOCKOUT("biometry_lockout"),
|
|
13
|
+
PASSCODE_NOT_SET("passcode_not_set"),
|
|
14
|
+
SECURE_KEY_STORAGE_UNAVAILABLE("secure_key_storage_unavailable"),
|
|
15
|
+
KEY_NOT_FOUND("key_not_found"),
|
|
16
|
+
KEY_INVALIDATED("key_invalidated"),
|
|
17
|
+
KEY_GENERATION_FAILED("key_generation_failed"),
|
|
18
|
+
SIGNING_FAILED("signing_failed"),
|
|
19
|
+
STORAGE_FAILED("storage_failed"),
|
|
20
|
+
INVALID_ARGUMENT("invalid_argument"),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
internal class BiometricsError(
|
|
24
|
+
val code: BiometricsErrorCode,
|
|
25
|
+
message: String,
|
|
26
|
+
cause: Throwable? = null,
|
|
27
|
+
) : Exception(message, cause) {
|
|
28
|
+
companion object {
|
|
29
|
+
fun forCanAuthenticate(status: Int): BiometricsError =
|
|
30
|
+
when (status) {
|
|
31
|
+
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED ->
|
|
32
|
+
BiometricsError(BiometricsErrorCode.BIOMETRY_NOT_ENROLLED, "No biometrics are enrolled on this device.")
|
|
33
|
+
else ->
|
|
34
|
+
BiometricsError(
|
|
35
|
+
BiometricsErrorCode.BIOMETRY_NOT_AVAILABLE,
|
|
36
|
+
"Strong biometric authentication is not available on this device (status $status).",
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
fun forPromptError(errorCode: Int, message: CharSequence?): BiometricsError {
|
|
41
|
+
val code =
|
|
42
|
+
when (errorCode) {
|
|
43
|
+
BiometricPrompt.ERROR_USER_CANCELED,
|
|
44
|
+
BiometricPrompt.ERROR_NEGATIVE_BUTTON -> BiometricsErrorCode.USER_CANCELED
|
|
45
|
+
BiometricPrompt.ERROR_CANCELED -> BiometricsErrorCode.SYSTEM_CANCELED
|
|
46
|
+
BiometricPrompt.ERROR_LOCKOUT,
|
|
47
|
+
BiometricPrompt.ERROR_LOCKOUT_PERMANENT -> BiometricsErrorCode.BIOMETRY_LOCKOUT
|
|
48
|
+
BiometricPrompt.ERROR_NO_BIOMETRICS -> BiometricsErrorCode.BIOMETRY_NOT_ENROLLED
|
|
49
|
+
BiometricPrompt.ERROR_HW_NOT_PRESENT,
|
|
50
|
+
BiometricPrompt.ERROR_HW_UNAVAILABLE,
|
|
51
|
+
BiometricPrompt.ERROR_SECURITY_UPDATE_REQUIRED -> BiometricsErrorCode.BIOMETRY_NOT_AVAILABLE
|
|
52
|
+
BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL -> BiometricsErrorCode.PASSCODE_NOT_SET
|
|
53
|
+
else -> BiometricsErrorCode.AUTHENTICATION_FAILED
|
|
54
|
+
}
|
|
55
|
+
return BiometricsError(code, message?.toString() ?: "Biometric authentication failed (error $errorCode).")
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
package expo.modules.clerk.biometrics
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import androidx.fragment.app.FragmentActivity
|
|
5
|
+
import expo.modules.kotlin.Promise
|
|
6
|
+
import expo.modules.kotlin.exception.CodedException
|
|
7
|
+
import expo.modules.kotlin.modules.Module
|
|
8
|
+
import expo.modules.kotlin.modules.ModuleDefinition
|
|
9
|
+
import expo.modules.kotlin.records.Field
|
|
10
|
+
import expo.modules.kotlin.records.Record
|
|
11
|
+
|
|
12
|
+
class ClerkBiometricsException internal constructor(error: BiometricsError) :
|
|
13
|
+
CodedException(error.code.value, error.message, error.cause)
|
|
14
|
+
|
|
15
|
+
class BiometricCredentialRecordArgument : Record {
|
|
16
|
+
@Field var id: String = ""
|
|
17
|
+
@Field var localKeyId: String = ""
|
|
18
|
+
@Field var userId: String = ""
|
|
19
|
+
@Field var appIdentifier: String = ""
|
|
20
|
+
@Field var identifierHint: String? = null
|
|
21
|
+
@Field var policy: String = ""
|
|
22
|
+
@Field var createdAt: Double = -1.0
|
|
23
|
+
@Field var updatedAt: Double = -1.0
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
class SaveRecordOptionsArgument : Record {
|
|
27
|
+
@Field var removeOtherRecordsForApp: Boolean = false
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
class ClerkExpoBiometricsModule : Module() {
|
|
31
|
+
private val keyManager = BiometricKeyManager()
|
|
32
|
+
private var cachedStore: BiometricCredentialStore? = null
|
|
33
|
+
|
|
34
|
+
override fun definition() = ModuleDefinition {
|
|
35
|
+
Name("ClerkExpoBiometrics")
|
|
36
|
+
|
|
37
|
+
Function("getAppIdentifier") { -> context().packageName }
|
|
38
|
+
|
|
39
|
+
Function("hashIdentifierHint") { hint: String -> BiometricCredentialCoding.hashIdentifierHint(hint) }
|
|
40
|
+
|
|
41
|
+
AsyncFunction("getAvailability") { ->
|
|
42
|
+
val availability = keyManager.availability(context())
|
|
43
|
+
mapOf(
|
|
44
|
+
"biometryType" to availability.biometryType,
|
|
45
|
+
"canEvaluateBiometrics" to availability.canEvaluateBiometrics,
|
|
46
|
+
"canEvaluateDeviceOwner" to availability.canEvaluateDeviceOwner,
|
|
47
|
+
"errorCode" to availability.errorCode?.value,
|
|
48
|
+
"secureKeyStorageAvailable" to availability.secureKeyStorageAvailable,
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
AsyncFunction("createKey") { policy: String ->
|
|
53
|
+
bridge {
|
|
54
|
+
val parsed =
|
|
55
|
+
BiometricCredentialPolicy.fromValue(policy)
|
|
56
|
+
?: throw BiometricsError(BiometricsErrorCode.INVALID_ARGUMENT, "Unknown biometric credential policy '$policy'.")
|
|
57
|
+
val key = keyManager.createKey(context(), parsed)
|
|
58
|
+
mapOf("localKeyId" to key.localKeyId, "publicKeyJwk" to key.publicKeyJwk)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
AsyncFunction("sign") { localKeyId: String, clientData: String, reason: String?, promise: Promise ->
|
|
63
|
+
try {
|
|
64
|
+
val activity =
|
|
65
|
+
appContext.currentActivity as? FragmentActivity
|
|
66
|
+
?: throw BiometricsError(BiometricsErrorCode.SIGNING_FAILED, "Signing requires a foreground FragmentActivity.")
|
|
67
|
+
keyManager.sign(activity, localKeyId, clientData, reason) { result ->
|
|
68
|
+
result.fold(promise::resolve) { error -> promise.reject(codedException(error)) }
|
|
69
|
+
}
|
|
70
|
+
} catch (e: Throwable) {
|
|
71
|
+
promise.reject(codedException(e))
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
AsyncFunction("hasKey") { localKeyId: String -> bridge { keyManager.hasKey(localKeyId) } }
|
|
76
|
+
|
|
77
|
+
AsyncFunction("deleteKey") { localKeyId: String -> bridge { keyManager.deleteKey(localKeyId) } }
|
|
78
|
+
|
|
79
|
+
AsyncFunction("listRecords") { -> storeCall { store().listRecordsJson() } }
|
|
80
|
+
|
|
81
|
+
AsyncFunction("saveRecord") { record: BiometricCredentialRecordArgument, options: SaveRecordOptionsArgument ->
|
|
82
|
+
storeCall {
|
|
83
|
+
val input =
|
|
84
|
+
BiometricCredentialRecordInput(
|
|
85
|
+
id = record.id,
|
|
86
|
+
localKeyId = record.localKeyId,
|
|
87
|
+
userId = record.userId,
|
|
88
|
+
appIdentifier = record.appIdentifier,
|
|
89
|
+
identifierHint = record.identifierHint,
|
|
90
|
+
policy = record.policy,
|
|
91
|
+
createdAt = record.createdAt,
|
|
92
|
+
updatedAt = record.updatedAt,
|
|
93
|
+
)
|
|
94
|
+
store().save(input, options.removeOtherRecordsForApp)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
AsyncFunction("deleteRecord") { localKeyId: String -> storeCall { store().deleteRecords(localKeyId) } }
|
|
99
|
+
|
|
100
|
+
// Uninstalling wipes both noBackupFilesDir and the app's Keystore keys, so no reinstall marker is needed.
|
|
101
|
+
AsyncFunction("ensureInstallationMarker") { -> mapOf("wiped" to false) }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private fun context(): Context =
|
|
105
|
+
appContext.reactContext?.applicationContext ?: throw CodedException("storage_failed", "The React context is not available.", null)
|
|
106
|
+
|
|
107
|
+
@Synchronized
|
|
108
|
+
private fun store(): BiometricCredentialStore =
|
|
109
|
+
cachedStore
|
|
110
|
+
?: BiometricCredentialStore(
|
|
111
|
+
BiometricCredentialFileStore.inDirectory(context().noBackupFilesDir),
|
|
112
|
+
keyManager::deleteKey,
|
|
113
|
+
).also { cachedStore = it }
|
|
114
|
+
|
|
115
|
+
private fun <T> bridge(body: () -> T): T =
|
|
116
|
+
try {
|
|
117
|
+
body()
|
|
118
|
+
} catch (e: BiometricsError) {
|
|
119
|
+
throw ClerkBiometricsException(e)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private fun <T> storeCall(body: () -> T): T =
|
|
123
|
+
try {
|
|
124
|
+
body()
|
|
125
|
+
} catch (e: CodedException) {
|
|
126
|
+
throw e
|
|
127
|
+
} catch (e: BiometricsError) {
|
|
128
|
+
throw ClerkBiometricsException(e)
|
|
129
|
+
} catch (e: Exception) {
|
|
130
|
+
throw CodedException("storage_failed", e.message ?: "The biometric credential store failed.", e)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private fun codedException(error: Throwable): CodedException =
|
|
134
|
+
when (error) {
|
|
135
|
+
is CodedException -> error
|
|
136
|
+
is BiometricsError -> ClerkBiometricsException(error)
|
|
137
|
+
else -> CodedException("unknown", error.message ?: "Biometric signing failed.", error)
|
|
138
|
+
}
|
|
139
|
+
}
|