diff --git a/.changeset/expo-biometrics-android.md b/.changeset/expo-biometrics-android.md new file mode 100644 index 00000000000..a29b0ea93a3 --- /dev/null +++ b/.changeset/expo-biometrics-android.md @@ -0,0 +1,5 @@ +--- +'@clerk/expo-biometrics': minor +--- + +Add Android support. Keys live in the Android Keystore and records in the storage shared with the Clerk Android SDK, so credentials enrolled by either SDK in the same app are visible to both. Add `hashIdentifierHint()` and an `identifierHintSha256` field on the records returned by `listRecords()`, so identifier hints can be matched on both platforms (Android stores only the hash). diff --git a/packages/expo-biometrics/README.md b/packages/expo-biometrics/README.md index 5c39cf5754b..7951e11ec65 100644 --- a/packages/expo-biometrics/README.md +++ b/packages/expo-biometrics/README.md @@ -28,16 +28,16 @@ > [!WARNING] > This package is experimental. Pin its version, as breaking changes can happen in minor releases. -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 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. +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. -The key and record layout is shared with the Clerk iOS SDK, so credentials enrolled by either SDK in the same app are visible to both. +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. ### Prerequisites - Expo SDK 54 or later, in a development build (the module is not available in Expo Go or on the web) -- iOS. Android support is not implemented yet: every call rejects with `not_implemented`. -- `NSFaceIDUsageDescription` in your `Info.plist`. The `@clerk/expo` config plugin sets it through its `faceIDPermission` option. -- A device with a Secure Enclave. The iOS Simulator has none, so `createKey()` rejects there with `secure_key_storage_unavailable`. +- iOS: `NSFaceIDUsageDescription` in your `Info.plist`. The `@clerk/expo` config plugin sets it through its `faceIDPermission` option. +- iOS: a device with a Secure Enclave. The iOS Simulator has none, so `createKey()` rejects there with `secure_key_storage_unavailable`. +- 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`. ## Installation @@ -57,6 +57,7 @@ import { ensureInstallationMarker, getAppIdentifier, getAvailability, + hashIdentifierHint, hasKey, listRecords, saveRecord, @@ -66,16 +67,24 @@ import { | Function | Description | | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | -| `getAppIdentifier()` | The app identifier sent to Clerk as `app_identifier` (the iOS bundle identifier). | +| `getAppIdentifier()` | The app identifier sent to Clerk as `app_identifier` (the iOS bundle identifier or the Android package name). | +| `hashIdentifierHint(hint)` | The SHA-256 of the trimmed, lowercased hint as lowercase hex, or `null` when it is empty. | | `getAvailability()` | The device's biometry type, whether biometrics or device owner authentication can be evaluated, and secure key storage. | -| `createKey(policy)` | Creates a Secure Enclave P-256 key and returns its `localKeyId` and public key JWK. | +| `createKey(policy)` | Creates a Secure Enclave or Android Keystore P-256 key and returns its `localKeyId` and public key JWK. | | `sign(localKeyId, clientData, reason?)` | Prompts for authentication and returns an ES256 signature over `clientData` (raw `r \|\| s`, base64url without padding). | | `hasKey(localKeyId)` / `deleteKey(localKeyId)` | Checks for or deletes a key. | -| `listRecords()` | Every stored credential record, for every app identifier. | +| `listRecords()` | Every stored credential record, for every app identifier, with its `identifierHintSha256`. | | `saveRecord(record, options)` | Saves a record. With `removeOtherRecordsForApp: true`, deletes the app's other records and their keys. | | `deleteRecord(localKeyId)` | Deletes a key, then the records that reference it. | | `ensureInstallationMarker()` | Deletes records and keys left behind by a previous installation of the app. The store functions call it for you. | +### Platform differences + +- 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. +- On Android, `removeOtherRecordsForApp` deletes only the same user's other records; other users' records are left for the next sign-in to reconcile. +- Android reports `biometryType: 'biometric'`, since it does not say which sensor is a strong biometric. +- Android's `ensureInstallationMarker()` always resolves `{ wiped: false }`: uninstalling the app already deletes its records and keys. + Every error is a `ClerkBiometricsError` with a stable `code`, such as `user_canceled`, `biometry_not_enrolled`, `biometry_lockout`, `key_not_found`, or `storage_failed`. ## License diff --git a/packages/expo-biometrics/android/build.gradle b/packages/expo-biometrics/android/build.gradle index a40c50f6d7b..992ea003e89 100644 --- a/packages/expo-biometrics/android/build.gradle +++ b/packages/expo-biometrics/android/build.gradle @@ -32,8 +32,18 @@ android { versionCode 1 versionName "1.0.0" } + + testOptions { + unitTests { + includeAndroidResources = true + } + } } dependencies { implementation project(':expo-modules-core') + implementation "androidx.biometric:biometric:1.1.0" + + testImplementation "junit:junit:4.13.2" + testImplementation "org.robolectric:robolectric:4.16" } diff --git a/packages/expo-biometrics/android/src/main/java/expo/modules/clerk/biometrics/BiometricCredentialCoding.kt b/packages/expo-biometrics/android/src/main/java/expo/modules/clerk/biometrics/BiometricCredentialCoding.kt new file mode 100644 index 00000000000..06cc64d7532 --- /dev/null +++ b/packages/expo-biometrics/android/src/main/java/expo/modules/clerk/biometrics/BiometricCredentialCoding.kt @@ -0,0 +1,147 @@ +package expo.modules.clerk.biometrics + +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import java.math.BigInteger +import java.security.MessageDigest +import java.security.spec.ECGenParameterSpec +import java.util.UUID + +// Implements clerk-android source/api/docs/biometric-credential-storage-contract.md (version 2). Any change here must +// stay compatible with clerk-android, which reads and writes the same Keystore aliases and metadata file. + +internal enum class BiometricCredentialPolicy(val value: String) { + BIOMETRY_CURRENT_SET("biometry_current_set"), + BIOMETRY_ANY("biometry_any"), + BIOMETRY_OR_DEVICE_PASSCODE("biometry_or_device_passcode"); + + companion object { + fun fromValue(value: String?): BiometricCredentialPolicy? = entries.firstOrNull { it.value == value } + } +} + +internal object BiometricCredentialCoding { + const val KEY_ALIAS_PREFIX = "com.clerk.trusted_device." + const val LOCAL_KEY_ID_PREFIX = "tdlk_" + const val SIGNATURE_ALGORITHM = "SHA256withECDSA" + const val EC_CURVE = "secp256r1" + const val MIN_SDK = Build.VERSION_CODES.P + + private const val COORDINATE_SIZE = 32 + private const val HEX = "0123456789abcdef" + + fun makeLocalKeyId(): String = LOCAL_KEY_ID_PREFIX + UUID.randomUUID().toString().replace("-", "").lowercase() + + fun keyAlias(localKeyId: String): String = KEY_ALIAS_PREFIX + localKeyId + + /** Lowercase hex SHA-256 of the trimmed, locale-independently lowercased hint, or `null` when it is empty. */ + fun hashIdentifierHint(hint: String?): String? { + val normalized = hint?.trim()?.lowercase() ?: return null + if (normalized.isEmpty()) return null + val digest = MessageDigest.getInstance("SHA-256").digest(normalized.toByteArray(Charsets.UTF_8)) + return buildString(digest.size * 2) { + for (byte in digest) { + val value = byte.toInt() and 0xFF + append(HEX[value ushr 4]) + append(HEX[value and 0x0F]) + } + } + } + + fun keyAuthenticators(policy: BiometricCredentialPolicy): Int = + if (policy == BiometricCredentialPolicy.BIOMETRY_OR_DEVICE_PASSCODE) { + KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL + } else { + KeyProperties.AUTH_BIOMETRIC_STRONG + } + + fun keyGenParameterSpec(localKeyId: String, policy: BiometricCredentialPolicy): KeyGenParameterSpec { + val builder = + KeyGenParameterSpec.Builder(keyAlias(localKeyId), KeyProperties.PURPOSE_SIGN) + .setAlgorithmParameterSpec(ECGenParameterSpec(EC_CURVE)) + .setDigests(KeyProperties.DIGEST_SHA256) + .setUserAuthenticationRequired(true) + .setInvalidatedByBiometricEnrollment(policy == BiometricCredentialPolicy.BIOMETRY_CURRENT_SET) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + builder.setUserAuthenticationParameters(0, keyAuthenticators(policy)) + } else { + @Suppress("DEPRECATION") + builder.setUserAuthenticationValidityDurationSeconds(-1) + } + return builder.build() + } + + fun base64UrlEncode(bytes: ByteArray): String = + Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) + + fun publicKeyJwk(x: BigInteger, y: BigInteger): String { + val encodedX = base64UrlEncode(fixedWidthCoordinate(x)) + val encodedY = base64UrlEncode(fixedWidthCoordinate(y)) + return """{"kty":"EC","crv":"P-256","x":"$encodedX","y":"$encodedY","alg":"ES256"}""" + } + + private fun fixedWidthCoordinate(coordinate: BigInteger): ByteArray { + val bytes = coordinate.toByteArray() + return when { + bytes.size == COORDINATE_SIZE -> bytes + bytes.size > COORDINATE_SIZE -> bytes.copyOfRange(bytes.size - COORDINATE_SIZE, bytes.size) + else -> ByteArray(COORDINATE_SIZE - bytes.size) + bytes + } + } + + fun rawES256SignatureFromDer(signature: ByteArray): ByteArray { + val reader = DerReader(signature) + if (reader.readByte() != 0x30) throw invalidSignature() + if (reader.readLength() != reader.remaining) throw invalidSignature() + val r = reader.readInteger() + val s = reader.readInteger() + if (reader.remaining != 0) throw invalidSignature() + return paddedComponent(r) + paddedComponent(s) + } + + private fun paddedComponent(component: ByteArray): ByteArray { + if (component.isEmpty() || component[0].toInt() and 0x80 != 0) throw invalidSignature() + var start = 0 + while (component.size - start > COORDINATE_SIZE && component[start].toInt() == 0) { + start += 1 + } + val size = component.size - start + if (size !in 1..COORDINATE_SIZE) throw invalidSignature() + return ByteArray(COORDINATE_SIZE).also { component.copyInto(it, COORDINATE_SIZE - size, start) } + } + + private fun invalidSignature() = + BiometricsError(BiometricsErrorCode.SIGNING_FAILED, "Android Keystore returned an invalid ES256 signature.") + + private class DerReader(private val bytes: ByteArray) { + private var offset = 0 + + val remaining: Int + get() = bytes.size - offset + + fun readByte(): Int { + if (offset >= bytes.size) throw invalidSignature() + return bytes[offset++].toInt() and 0xFF + } + + fun readLength(): Int { + val first = readByte() + if (first and 0x80 == 0) return first + val byteCount = first and 0x7F + if (byteCount == 0 || byteCount > Int.SIZE_BYTES || byteCount > remaining) throw invalidSignature() + var length = 0 + repeat(byteCount) { length = (length shl 8) or readByte() } + return length + } + + fun readInteger(): ByteArray { + if (readByte() != 0x02) throw invalidSignature() + val length = readLength() + if (length <= 0 || length > remaining) throw invalidSignature() + return bytes.copyOfRange(offset, offset + length).also { offset += length } + } + } +} diff --git a/packages/expo-biometrics/android/src/main/java/expo/modules/clerk/biometrics/BiometricCredentialFileStore.kt b/packages/expo-biometrics/android/src/main/java/expo/modules/clerk/biometrics/BiometricCredentialFileStore.kt new file mode 100644 index 00000000000..cc8d70c4db6 --- /dev/null +++ b/packages/expo-biometrics/android/src/main/java/expo/modules/clerk/biometrics/BiometricCredentialFileStore.kt @@ -0,0 +1,304 @@ +package expo.modules.clerk.biometrics + +import android.system.Os +import android.system.OsConstants +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.RandomAccessFile +import java.nio.channels.FileChannel +import java.nio.channels.FileLock +import java.nio.channels.OverlappingFileLockException +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit +import java.util.concurrent.locks.ReentrantLock +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject + +/** A decodable credential record with a known policy (contract v2, section 3.3). */ +internal data class BiometricCredentialLocalRecord( + val id: String, + val localKeyId: String, + val userId: String, + val appIdentifier: String, + val identifierHintSha256: String?, + val policy: BiometricCredentialPolicy, + val createdAt: Long, + val updatedAt: Long, +) + +/** A decoded record and the stored JSON object it came from, including fields this module does not know. */ +internal class StoredBiometricCredentialRecord(val record: BiometricCredentialLocalRecord, val json: JSONObject) + +/** + * The contract v2 metadata file shared with clerk-android: plaintext JSON in `noBackupFilesDir/clerk`, changed only by + * read-modify-writes under an exclusive lock on a sibling lock file and replaced atomically. Everything this module + * does not understand is kept on rewrite. + */ +internal class BiometricCredentialFileStore( + private val directory: File, + private val processGuard: ReentrantLock = PROCESS_GUARD, + private val lockTimeoutMillis: Long = LOCK_TIMEOUT_MILLIS, +) { + val dataFile = File(directory, DATA_FILE_NAME) + val lockFile = File(directory, LOCK_FILE_NAME) + private val tempFile = File(directory, TEMP_FILE_NAME) + + /** Reads without the lock: the atomic rename means a reader sees either the previous or the next complete file. */ + fun records(): List { + val document = readDocument() + if (!document.writable) return emptyList() + return document.credentials.mapNotNull { element -> + val json = element as? JSONObject ?: return@mapNotNull null + BiometricCredentialRecordJson.decode(json)?.let { StoredBiometricCredentialRecord(it, json) } + } + } + + /** Replaces the record with the same `id`, or appends it. Returns the replaced record's key when it differs. */ + fun save(record: BiometricCredentialLocalRecord): String? { + var replacedLocalKeyId: String? = null + update { credentials -> + val index = credentials.indexOfFirst { recordId(it) == record.id } + val existing = credentials.getOrNull(index) as? JSONObject + val encoded = BiometricCredentialRecordJson.encode(record, preserving = existing) + if (index < 0) { + credentials.add(encoded) + } else { + replacedLocalKeyId = + (existing?.opt(BiometricCredentialRecordJson.LOCAL_KEY_ID) as? String)?.takeIf { it != record.localKeyId } + credentials[index] = encoded + for (i in credentials.lastIndex downTo index + 1) { + if (recordId(credentials[i]) == record.id) credentials.removeAt(i) + } + } + true + } + return replacedLocalKeyId + } + + /** Removes every record, decodable or not, whose `id` is in [ids]. */ + fun delete(ids: Set) { + if (ids.isEmpty()) return + update { credentials -> credentials.removeAll { recordId(it) in ids } } + } + + private fun update(transform: (MutableList) -> Boolean) { + withExclusiveLock { + val document = readDocument() + if (!document.writable) { + throw IOException("The biometric credential store uses an unsupported version.") + } + val credentials = document.credentials.toMutableList() + if (transform(credentials)) { + writeDocument(document.serialized(credentials)) + } + } + } + + // Only a missing file is an empty store; other read errors must not let a writer replace it. + private fun readDocument(): StoreDocument { + if (!dataFile.exists()) return StoreDocument(JSONObject(), writable = true) + val text = dataFile.readText(Charsets.UTF_8) + val root = + try { + JSONObject(text) + } catch (_: JSONException) { + return StoreDocument(JSONObject(), writable = true) + } + val version = root.opt(KEY_VERSION) + val writable = (version is Int || version is Long) && (version as Number).toLong() == STORE_VERSION.toLong() + return StoreDocument(root, writable) + } + + private fun writeDocument(text: String) { + ensureDirectory() + FileOutputStream(tempFile).use { output -> + output.write(text.toByteArray(Charsets.UTF_8)) + output.flush() + output.fd.sync() + } + if (!tempFile.renameTo(dataFile)) { + tempFile.delete() + throw IOException("Failed to replace the biometric credential store.") + } + syncDirectory() + } + + private fun syncDirectory() { + try { + val fd = Os.open(directory.path, OsConstants.O_RDONLY, 0) + try { + Os.fsync(fd) + } finally { + Os.close(fd) + } + } catch (_: Throwable) { + } + } + + private fun ensureDirectory() { + if (!directory.isDirectory && !directory.mkdirs() && !directory.isDirectory) { + throw IOException("Failed to create the biometric credential store directory.") + } + } + + private fun withExclusiveLock(block: () -> T): T { + if (!processGuard.tryLock(lockTimeoutMillis, TimeUnit.MILLISECONDS)) { + throw IOException("Timed out waiting for the biometric credential store lock.") + } + try { + val fileLock = acquireFileLock() + try { + return block() + } finally { + fileLock.release() + } + } finally { + processGuard.unlock() + } + } + + // Another SDK in this process holding the lock surfaces as OverlappingFileLockException, another process as null. + private fun acquireFileLock(): FileLock { + val channel = lockChannel() + val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(lockTimeoutMillis) + var backoffMillis = INITIAL_LOCK_BACKOFF_MILLIS + while (true) { + val lock = + try { + channel.tryLock() + } catch (_: OverlappingFileLockException) { + null + } + if (lock != null) return lock + if (System.nanoTime() >= deadline) { + throw IOException("Timed out waiting for the biometric credential store lock.") + } + Thread.sleep(backoffMillis) + backoffMillis = (backoffMillis * 2).coerceAtMost(MAX_LOCK_BACKOFF_MILLIS) + } + } + + // Closing any descriptor of the lock file can release this process's POSIX lock, so channels are never closed. + private fun lockChannel(): FileChannel { + val path = lockFile.absolutePath + LOCK_CHANNELS[path]?.let { return it } + synchronized(LOCK_CHANNELS) { + return LOCK_CHANNELS.getOrPut(path) { + ensureDirectory() + RandomAccessFile(lockFile, "rw").channel + } + } + } + + private class StoreDocument(val root: JSONObject, val writable: Boolean) { + val credentials: List = (root.opt(KEY_CREDENTIALS) as? JSONArray).elements() + + /** Known keys first, then unknown top-level keys in their original order. */ + fun serialized(credentials: List): String { + val pending = + (root.opt(KEY_PENDING_CLEANUP_USER_IDS) as? JSONArray) + .elements() + .filterIsInstance() + .filter { it.isNotBlank() } + .toSortedSet() + val output = JSONObject() + output.put(KEY_VERSION, STORE_VERSION) + output.put(KEY_CREDENTIALS, JSONArray(credentials)) + output.put(KEY_PENDING_CLEANUP_USER_IDS, JSONArray(pending.toList())) + for (key in root.keys()) { + if (key !in TOP_LEVEL_KEYS) output.put(key, root.get(key)) + } + return output.toString() + } + } + + companion object { + const val DIRECTORY_NAME = "clerk" + const val DATA_FILE_NAME = "biometric_credentials.v2.json" + const val TEMP_FILE_NAME = "biometric_credentials.v2.json.tmp" + const val LOCK_FILE_NAME = "biometric_credentials.lock" + const val STORE_VERSION = 2 + + const val KEY_VERSION = "version" + const val KEY_CREDENTIALS = "credentials" + const val KEY_PENDING_CLEANUP_USER_IDS = "pending_cleanup_user_ids" + private val TOP_LEVEL_KEYS = setOf(KEY_VERSION, KEY_CREDENTIALS, KEY_PENDING_CLEANUP_USER_IDS) + + private const val LOCK_TIMEOUT_MILLIS = 5_000L + private const val INITIAL_LOCK_BACKOFF_MILLIS = 2L + private const val MAX_LOCK_BACKOFF_MILLIS = 50L + + private val PROCESS_GUARD = ReentrantLock() + private val LOCK_CHANNELS = ConcurrentHashMap() + + fun inDirectory(noBackupFilesDir: File) = BiometricCredentialFileStore(File(noBackupFilesDir, DIRECTORY_NAME)) + + private fun recordId(element: Any?): String? = (element as? JSONObject)?.opt(BiometricCredentialRecordJson.ID) as? String + + private fun JSONArray?.elements(): List = if (this == null) emptyList() else List(length()) { opt(it) } + } +} + +/** Contract v2 JSON encoding of a single credential record. */ +internal object BiometricCredentialRecordJson { + const val ID = "id" + const val LOCAL_KEY_ID = "local_key_id" + const val USER_ID = "user_id" + const val APP_IDENTIFIER = "app_identifier" + const val IDENTIFIER_HINT_SHA256 = "identifier_hint_sha256" + const val POLICY = "policy" + const val CREATED_AT = "created_at" + const val UPDATED_AT = "updated_at" + + val KNOWN_FIELDS = setOf(ID, LOCAL_KEY_ID, USER_ID, APP_IDENTIFIER, IDENTIFIER_HINT_SHA256, POLICY, CREATED_AT, UPDATED_AT) + + /** Returns `null` for records that are malformed or use a policy this module does not know. */ + fun decode(json: JSONObject): BiometricCredentialLocalRecord? { + val hint = + when (val value = json.opt(IDENTIFIER_HINT_SHA256)) { + null, JSONObject.NULL -> null + is String -> value + else -> return null + } + return BiometricCredentialLocalRecord( + id = json.string(ID) ?: return null, + localKeyId = json.string(LOCAL_KEY_ID) ?: return null, + userId = json.string(USER_ID) ?: return null, + appIdentifier = json.string(APP_IDENTIFIER) ?: return null, + identifierHintSha256 = hint, + policy = BiometricCredentialPolicy.fromValue(json.string(POLICY)) ?: return null, + createdAt = json.long(CREATED_AT) ?: return null, + updatedAt = json.long(UPDATED_AT) ?: return null, + ) + } + + /** Encodes [record], keeping every field of [preserving] that is not part of the schema. */ + fun encode(record: BiometricCredentialLocalRecord, preserving: JSONObject?): JSONObject { + val json = JSONObject() + json.put(ID, record.id) + json.put(LOCAL_KEY_ID, record.localKeyId) + json.put(USER_ID, record.userId) + json.put(APP_IDENTIFIER, record.appIdentifier) + record.identifierHintSha256?.let { json.put(IDENTIFIER_HINT_SHA256, it) } + json.put(POLICY, record.policy.value) + json.put(CREATED_AT, record.createdAt) + json.put(UPDATED_AT, record.updatedAt) + if (preserving != null) { + for (key in preserving.keys()) { + if (key !in KNOWN_FIELDS) json.put(key, preserving.get(key)) + } + } + return json + } + + private fun JSONObject.string(key: String): String? = opt(key) as? String + + private fun JSONObject.long(key: String): Long? = + when (val value = opt(key)) { + is Int -> value.toLong() + is Long -> value + else -> null + } +} diff --git a/packages/expo-biometrics/android/src/main/java/expo/modules/clerk/biometrics/BiometricCredentialStore.kt b/packages/expo-biometrics/android/src/main/java/expo/modules/clerk/biometrics/BiometricCredentialStore.kt new file mode 100644 index 00000000000..7a194e0668e --- /dev/null +++ b/packages/expo-biometrics/android/src/main/java/expo/modules/clerk/biometrics/BiometricCredentialStore.kt @@ -0,0 +1,96 @@ +package expo.modules.clerk.biometrics + +import org.json.JSONArray +import org.json.JSONObject + +/** The record shape the JS API uses, before the identifier hint is hashed for storage. */ +internal data class BiometricCredentialRecordInput( + val id: String, + val localKeyId: String, + val userId: String, + val appIdentifier: String, + val identifierHint: String?, + val policy: String, + val createdAt: Double, + val updatedAt: Double, +) { + fun toLocalRecord(): BiometricCredentialLocalRecord { + for ((name, value) in listOf("id" to id, "localKeyId" to localKeyId, "userId" to userId, "appIdentifier" to appIdentifier)) { + if (value.isEmpty()) { + throw BiometricsError(BiometricsErrorCode.INVALID_ARGUMENT, "record.$name must be a non-empty string.") + } + } + val policy = + BiometricCredentialPolicy.fromValue(policy) + ?: throw BiometricsError(BiometricsErrorCode.INVALID_ARGUMENT, "Unknown biometric credential policy '$policy'.") + if (!isValidTimestamp(createdAt) || !isValidTimestamp(updatedAt)) { + throw BiometricsError( + BiometricsErrorCode.INVALID_ARGUMENT, + "record.createdAt and record.updatedAt must be milliseconds since the Unix epoch.", + ) + } + return BiometricCredentialLocalRecord( + id = id, + localKeyId = localKeyId, + userId = userId, + appIdentifier = appIdentifier, + identifierHintSha256 = BiometricCredentialCoding.hashIdentifierHint(identifierHint), + policy = policy, + createdAt = Math.round(createdAt), + updatedAt = Math.round(updatedAt), + ) + } + + private fun isValidTimestamp(value: Double) = value.isFinite() && value >= 0 && value < 9.0e15 +} + +/** The store operations exposed to JS, on top of the contract v2 file and the Keystore. */ +internal class BiometricCredentialStore( + private val fileStore: BiometricCredentialFileStore, + private val deleteKey: (String) -> Unit, +) { + /** Every decodable record for every app, mapped to the JS field names, with unknown stored fields passed through. */ + fun listRecordsJson(): String = JSONArray(fileStore.records().map(::bridgeJson)).toString() + + fun save(input: BiometricCredentialRecordInput, removeOtherRecordsForApp: Boolean) { + val record = input.toLocalRecord() + fileStore.save(record)?.let { runCatching { deleteKey(it) } } + if (!removeOtherRecordsForApp) return + + // Contract v2 section 5.3: only the same user's other credentials; other users' are left for reconciliation. + val removable = + fileStore + .records() + .map { it.record } + .filter { it.appIdentifier == record.appIdentifier && it.userId == record.userId && it.id != record.id } + .filter { it.localKeyId == record.localKeyId || runCatching { deleteKey(it.localKeyId) }.isSuccess } + .map { it.id } + runCatching { fileStore.delete(removable.toSet()) } + } + + /** Deletes the key, then every record that references it. When the key cannot be deleted the records are kept. */ + fun deleteRecords(localKeyId: String) { + deleteKey(localKeyId) + fileStore.delete(fileStore.records().filter { it.record.localKeyId == localKeyId }.map { it.record.id }.toSet()) + } + + companion object { + fun bridgeJson(stored: StoredBiometricCredentialRecord): JSONObject { + val json = JSONObject() + for (key in stored.json.keys()) { + if (key !in BiometricCredentialRecordJson.KNOWN_FIELDS) json.put(key, stored.json.get(key)) + } + val record = stored.record + json.put("id", record.id) + json.put("localKeyId", record.localKeyId) + json.put("userId", record.userId) + json.put("appIdentifier", record.appIdentifier) + json.put("identifierHint", JSONObject.NULL) + json.put("identifierHintSha256", record.identifierHintSha256 ?: JSONObject.NULL) + json.put("policy", record.policy.value) + json.put("createdAt", record.createdAt) + json.put("updatedAt", record.updatedAt) + return json + } + } +} diff --git a/packages/expo-biometrics/android/src/main/java/expo/modules/clerk/biometrics/BiometricKeyManager.kt b/packages/expo-biometrics/android/src/main/java/expo/modules/clerk/biometrics/BiometricKeyManager.kt new file mode 100644 index 00000000000..c1b9f304364 --- /dev/null +++ b/packages/expo-biometrics/android/src/main/java/expo/modules/clerk/biometrics/BiometricKeyManager.kt @@ -0,0 +1,216 @@ +package expo.modules.clerk.biometrics + +import android.content.Context +import android.os.Build +import android.security.keystore.KeyInfo +import android.security.keystore.KeyPermanentlyInvalidatedException +import android.security.keystore.KeyProperties +import androidx.annotation.RequiresApi +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricManager.Authenticators +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import java.security.KeyFactory +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.PrivateKey +import java.security.Signature +import java.security.interfaces.ECPublicKey +import java.util.concurrent.atomic.AtomicBoolean + +internal data class BiometricAvailability( + val biometryType: String, + val canEvaluateBiometrics: Boolean, + val canEvaluateDeviceOwner: Boolean, + val errorCode: BiometricsErrorCode?, + val secureKeyStorageAvailable: Boolean, +) + +internal data class BiometricCredentialKey(val localKeyId: String, val publicKeyJwk: String) + +/** Android Keystore keys laid out as clerk-android's `DefaultBiometricCredentialKeyManager` creates them. */ +internal class BiometricKeyManager { + fun availability(context: Context): BiometricAvailability { + if (!secureKeyStorageAvailable()) { + return BiometricAvailability("none", false, false, BiometricsErrorCode.BIOMETRY_NOT_AVAILABLE, false) + } + val manager = BiometricManager.from(context) + val strong = manager.canAuthenticate(Authenticators.BIOMETRIC_STRONG) + val canEvaluateBiometrics = strong == BiometricManager.BIOMETRIC_SUCCESS + // Keys only accept device credentials on API 30+; before that, device owner authentication cannot sign. + val canEvaluateDeviceOwner = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + manager.canAuthenticate(Authenticators.BIOMETRIC_STRONG or Authenticators.DEVICE_CREDENTIAL) == + BiometricManager.BIOMETRIC_SUCCESS + } else { + canEvaluateBiometrics + } + return BiometricAvailability( + biometryType = biometryType(strong), + canEvaluateBiometrics = canEvaluateBiometrics, + canEvaluateDeviceOwner = canEvaluateDeviceOwner, + errorCode = if (canEvaluateBiometrics) null else BiometricsError.forCanAuthenticate(strong).code, + secureKeyStorageAvailable = true, + ) + } + + fun createKey(context: Context, policy: BiometricCredentialPolicy): BiometricCredentialKey { + if (!secureKeyStorageAvailable()) { + throw BiometricsError( + BiometricsErrorCode.SECURE_KEY_STORAGE_UNAVAILABLE, + "Biometric credential keys require Android 9 (API 28) or later.", + ) + } + val status = BiometricManager.from(context).canAuthenticate(promptAuthenticators(policy)) + if (status != BiometricManager.BIOMETRIC_SUCCESS) { + throw BiometricsError.forCanAuthenticate(status) + } + + val localKeyId = BiometricCredentialCoding.makeLocalKeyId() + try { + val generator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, ANDROID_KEY_STORE) + generator.initialize(BiometricCredentialCoding.keyGenParameterSpec(localKeyId, policy)) + val publicKey = generator.generateKeyPair().public as ECPublicKey + return BiometricCredentialKey( + localKeyId = localKeyId, + publicKeyJwk = BiometricCredentialCoding.publicKeyJwk(publicKey.w.affineX, publicKey.w.affineY), + ) + } catch (e: Exception) { + runCatching { deleteKey(localKeyId) } + throw BiometricsError( + BiometricsErrorCode.KEY_GENERATION_FAILED, + e.message ?: "Unable to create the biometric credential key.", + e, + ) + } + } + + fun hasKey(localKeyId: String): Boolean = + storageCall { keyStore().containsAlias(BiometricCredentialCoding.keyAlias(localKeyId)) } + + fun deleteKey(localKeyId: String) = + storageCall { + val keyStore = keyStore() + val alias = BiometricCredentialCoding.keyAlias(localKeyId) + if (keyStore.containsAlias(alias)) keyStore.deleteEntry(alias) + } + + /** Shows the biometric prompt on [activity] and signs the UTF-8 bytes of [clientData] with the unlocked key. */ + fun sign( + activity: FragmentActivity, + localKeyId: String, + clientData: String, + reason: String?, + onResult: (Result) -> Unit, + ) { + requireSupportedSdk() + val privateKey = + storageCall { keyStore().getKey(BiometricCredentialCoding.keyAlias(localKeyId), null) as? PrivateKey } + ?: throw BiometricsError(BiometricsErrorCode.KEY_NOT_FOUND, "The biometric credential key was not found.") + val signature = + try { + Signature.getInstance(BiometricCredentialCoding.SIGNATURE_ALGORITHM).apply { initSign(privateKey) } + } catch (e: KeyPermanentlyInvalidatedException) { + throw BiometricsError(BiometricsErrorCode.KEY_INVALIDATED, "The biometric credential key was invalidated.", e) + } catch (e: Exception) { + throw BiometricsError(BiometricsErrorCode.SIGNING_FAILED, e.message ?: "Unable to initialize signing.", e) + } + val allowsDeviceCredential = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && keyAllowsDeviceCredential(privateKey) + val title = reason?.takeIf { it.isNotBlank() } ?: activity.applicationInfo.loadLabel(activity.packageManager).toString() + + val completed = AtomicBoolean(false) + val complete = { result: Result -> if (completed.compareAndSet(false, true)) onResult(result) } + val callback = + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + val unlocked = result.cryptoObject?.signature ?: signature + complete( + runCatching { + unlocked.update(clientData.toByteArray(Charsets.UTF_8)) + BiometricCredentialCoding.base64UrlEncode(BiometricCredentialCoding.rawES256SignatureFromDer(unlocked.sign())) + }.recoverCatching { e -> + throw e as? BiometricsError + ?: BiometricsError(BiometricsErrorCode.SIGNING_FAILED, e.message ?: "Unable to sign the challenge.", e) + } + ) + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + complete(Result.failure(BiometricsError.forPromptError(errorCode, errString))) + } + } + + activity.runOnUiThread { + try { + val promptInfo = + BiometricPrompt.PromptInfo.Builder().setTitle(title).apply { + if (allowsDeviceCredential) { + setAllowedAuthenticators(Authenticators.BIOMETRIC_STRONG or Authenticators.DEVICE_CREDENTIAL) + } else { + setAllowedAuthenticators(Authenticators.BIOMETRIC_STRONG) + setNegativeButtonText(activity.getString(android.R.string.cancel)) + } + }.build() + BiometricPrompt(activity, ContextCompat.getMainExecutor(activity), callback) + .authenticate(promptInfo, BiometricPrompt.CryptoObject(signature)) + } catch (e: Exception) { + complete( + Result.failure( + BiometricsError(BiometricsErrorCode.AUTHENTICATION_FAILED, e.message ?: "Unable to show the prompt.", e) + ) + ) + } + } + } + + // The key's own protections decide whether a device credential can unlock it, so keys created for + // biometry_or_device_passcode on API 28-29 stay biometric-only after an OS upgrade. + @RequiresApi(Build.VERSION_CODES.R) + private fun keyAllowsDeviceCredential(key: PrivateKey): Boolean = + runCatching { + val info = KeyFactory.getInstance(key.algorithm, ANDROID_KEY_STORE).getKeySpec(key, KeyInfo::class.java) + info.userAuthenticationType and KeyProperties.AUTH_DEVICE_CREDENTIAL != 0 + }.getOrDefault(false) + + private fun requireSupportedSdk() { + if (Build.VERSION.SDK_INT < BiometricCredentialCoding.MIN_SDK) { + throw BiometricsError(BiometricsErrorCode.BIOMETRY_NOT_AVAILABLE, "Biometric credentials require Android 9 (API 28) or later.") + } + } + + private fun keyStore(): KeyStore = KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) } + + private fun storageCall(body: () -> T): T = + try { + body() + } catch (e: BiometricsError) { + throw e + } catch (e: Exception) { + throw BiometricsError(BiometricsErrorCode.STORAGE_FAILED, e.message ?: "Android Keystore access failed.", e) + } + + companion object { + private const val ANDROID_KEY_STORE = "AndroidKeyStore" + + fun secureKeyStorageAvailable(sdkInt: Int = Build.VERSION.SDK_INT): Boolean = sdkInt >= BiometricCredentialCoding.MIN_SDK + + /** Contract v2 section 2: device credentials only for biometry_or_device_passcode, and only on API 30+. */ + fun promptAuthenticators(policy: BiometricCredentialPolicy, sdkInt: Int = Build.VERSION.SDK_INT): Int = + if (policy == BiometricCredentialPolicy.BIOMETRY_OR_DEVICE_PASSCODE && sdkInt >= Build.VERSION_CODES.R) { + Authenticators.BIOMETRIC_STRONG or Authenticators.DEVICE_CREDENTIAL + } else { + Authenticators.BIOMETRIC_STRONG + } + + /** Android does not report which biometric is Class 3, so any present strong biometric is `biometric`. */ + fun biometryType(canAuthenticateStrong: Int): String = + when (canAuthenticateStrong) { + BiometricManager.BIOMETRIC_SUCCESS, + BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED, + BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE, + BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> "biometric" + else -> "none" + } + } +} diff --git a/packages/expo-biometrics/android/src/main/java/expo/modules/clerk/biometrics/BiometricsError.kt b/packages/expo-biometrics/android/src/main/java/expo/modules/clerk/biometrics/BiometricsError.kt new file mode 100644 index 00000000000..a501e7b731b --- /dev/null +++ b/packages/expo-biometrics/android/src/main/java/expo/modules/clerk/biometrics/BiometricsError.kt @@ -0,0 +1,58 @@ +package expo.modules.clerk.biometrics + +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt + +internal enum class BiometricsErrorCode(val value: String) { + USER_CANCELED("user_canceled"), + SYSTEM_CANCELED("system_canceled"), + AUTHENTICATION_FAILED("authentication_failed"), + BIOMETRY_NOT_AVAILABLE("biometry_not_available"), + BIOMETRY_NOT_ENROLLED("biometry_not_enrolled"), + BIOMETRY_LOCKOUT("biometry_lockout"), + PASSCODE_NOT_SET("passcode_not_set"), + SECURE_KEY_STORAGE_UNAVAILABLE("secure_key_storage_unavailable"), + KEY_NOT_FOUND("key_not_found"), + KEY_INVALIDATED("key_invalidated"), + KEY_GENERATION_FAILED("key_generation_failed"), + SIGNING_FAILED("signing_failed"), + STORAGE_FAILED("storage_failed"), + INVALID_ARGUMENT("invalid_argument"), +} + +internal class BiometricsError( + val code: BiometricsErrorCode, + message: String, + cause: Throwable? = null, +) : Exception(message, cause) { + companion object { + fun forCanAuthenticate(status: Int): BiometricsError = + when (status) { + BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> + BiometricsError(BiometricsErrorCode.BIOMETRY_NOT_ENROLLED, "No biometrics are enrolled on this device.") + else -> + BiometricsError( + BiometricsErrorCode.BIOMETRY_NOT_AVAILABLE, + "Strong biometric authentication is not available on this device (status $status).", + ) + } + + fun forPromptError(errorCode: Int, message: CharSequence?): BiometricsError { + val code = + when (errorCode) { + BiometricPrompt.ERROR_USER_CANCELED, + BiometricPrompt.ERROR_NEGATIVE_BUTTON -> BiometricsErrorCode.USER_CANCELED + BiometricPrompt.ERROR_CANCELED -> BiometricsErrorCode.SYSTEM_CANCELED + BiometricPrompt.ERROR_LOCKOUT, + BiometricPrompt.ERROR_LOCKOUT_PERMANENT -> BiometricsErrorCode.BIOMETRY_LOCKOUT + BiometricPrompt.ERROR_NO_BIOMETRICS -> BiometricsErrorCode.BIOMETRY_NOT_ENROLLED + BiometricPrompt.ERROR_HW_NOT_PRESENT, + BiometricPrompt.ERROR_HW_UNAVAILABLE, + BiometricPrompt.ERROR_SECURITY_UPDATE_REQUIRED -> BiometricsErrorCode.BIOMETRY_NOT_AVAILABLE + BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL -> BiometricsErrorCode.PASSCODE_NOT_SET + else -> BiometricsErrorCode.AUTHENTICATION_FAILED + } + return BiometricsError(code, message?.toString() ?: "Biometric authentication failed (error $errorCode).") + } + } +} diff --git a/packages/expo-biometrics/android/src/main/java/expo/modules/clerk/biometrics/ClerkExpoBiometricsModule.kt b/packages/expo-biometrics/android/src/main/java/expo/modules/clerk/biometrics/ClerkExpoBiometricsModule.kt index a0fd2a58942..ff0a8cc69be 100644 --- a/packages/expo-biometrics/android/src/main/java/expo/modules/clerk/biometrics/ClerkExpoBiometricsModule.kt +++ b/packages/expo-biometrics/android/src/main/java/expo/modules/clerk/biometrics/ClerkExpoBiometricsModule.kt @@ -1,33 +1,139 @@ package expo.modules.clerk.biometrics +import android.content.Context +import androidx.fragment.app.FragmentActivity import expo.modules.kotlin.Promise import expo.modules.kotlin.exception.CodedException import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition +import expo.modules.kotlin.records.Field +import expo.modules.kotlin.records.Record -class NotImplementedException : - CodedException("not_implemented", "@clerk/expo-biometrics is not supported on Android yet.", null) +class ClerkBiometricsException internal constructor(error: BiometricsError) : + CodedException(error.code.value, error.message, error.cause) + +class BiometricCredentialRecordArgument : Record { + @Field var id: String = "" + @Field var localKeyId: String = "" + @Field var userId: String = "" + @Field var appIdentifier: String = "" + @Field var identifierHint: String? = null + @Field var policy: String = "" + @Field var createdAt: Double = -1.0 + @Field var updatedAt: Double = -1.0 +} + +class SaveRecordOptionsArgument : Record { + @Field var removeOtherRecordsForApp: Boolean = false +} class ClerkExpoBiometricsModule : Module() { + private val keyManager = BiometricKeyManager() + private var cachedStore: BiometricCredentialStore? = null + override fun definition() = ModuleDefinition { Name("ClerkExpoBiometrics") - Function("getAppIdentifier") { -> notImplemented() } - - AsyncFunction("getAvailability") { promise: Promise -> reject(promise) } - AsyncFunction("createKey") { _: String, promise: Promise -> reject(promise) } - AsyncFunction("sign") { _: String, _: String, _: String?, promise: Promise -> reject(promise) } - AsyncFunction("hasKey") { _: String, promise: Promise -> reject(promise) } - AsyncFunction("deleteKey") { _: String, promise: Promise -> reject(promise) } - AsyncFunction("listRecords") { promise: Promise -> reject(promise) } - AsyncFunction("saveRecord") { _: Map, _: Map, promise: Promise -> reject(promise) } - AsyncFunction("deleteRecord") { _: String, promise: Promise -> reject(promise) } - AsyncFunction("ensureInstallationMarker") { promise: Promise -> reject(promise) } - } + Function("getAppIdentifier") { -> context().packageName } + + Function("hashIdentifierHint") { hint: String -> BiometricCredentialCoding.hashIdentifierHint(hint) } + + AsyncFunction("getAvailability") { -> + val availability = keyManager.availability(context()) + mapOf( + "biometryType" to availability.biometryType, + "canEvaluateBiometrics" to availability.canEvaluateBiometrics, + "canEvaluateDeviceOwner" to availability.canEvaluateDeviceOwner, + "errorCode" to availability.errorCode?.value, + "secureKeyStorageAvailable" to availability.secureKeyStorageAvailable, + ) + } + + AsyncFunction("createKey") { policy: String -> + bridge { + val parsed = + BiometricCredentialPolicy.fromValue(policy) + ?: throw BiometricsError(BiometricsErrorCode.INVALID_ARGUMENT, "Unknown biometric credential policy '$policy'.") + val key = keyManager.createKey(context(), parsed) + mapOf("localKeyId" to key.localKeyId, "publicKeyJwk" to key.publicKeyJwk) + } + } + + AsyncFunction("sign") { localKeyId: String, clientData: String, reason: String?, promise: Promise -> + try { + val activity = + appContext.currentActivity as? FragmentActivity + ?: throw BiometricsError(BiometricsErrorCode.SIGNING_FAILED, "Signing requires a foreground FragmentActivity.") + keyManager.sign(activity, localKeyId, clientData, reason) { result -> + result.fold(promise::resolve) { error -> promise.reject(codedException(error)) } + } + } catch (e: Throwable) { + promise.reject(codedException(e)) + } + } - private fun reject(promise: Promise) { - promise.reject(NotImplementedException()) + AsyncFunction("hasKey") { localKeyId: String -> bridge { keyManager.hasKey(localKeyId) } } + + AsyncFunction("deleteKey") { localKeyId: String -> bridge { keyManager.deleteKey(localKeyId) } } + + AsyncFunction("listRecords") { -> storeCall { store().listRecordsJson() } } + + AsyncFunction("saveRecord") { record: BiometricCredentialRecordArgument, options: SaveRecordOptionsArgument -> + storeCall { + val input = + BiometricCredentialRecordInput( + id = record.id, + localKeyId = record.localKeyId, + userId = record.userId, + appIdentifier = record.appIdentifier, + identifierHint = record.identifierHint, + policy = record.policy, + createdAt = record.createdAt, + updatedAt = record.updatedAt, + ) + store().save(input, options.removeOtherRecordsForApp) + } + } + + AsyncFunction("deleteRecord") { localKeyId: String -> storeCall { store().deleteRecords(localKeyId) } } + + // Uninstalling wipes both noBackupFilesDir and the app's Keystore keys, so no reinstall marker is needed. + AsyncFunction("ensureInstallationMarker") { -> mapOf("wiped" to false) } } - private fun notImplemented(): T = throw NotImplementedException() + private fun context(): Context = + appContext.reactContext?.applicationContext ?: throw CodedException("storage_failed", "The React context is not available.", null) + + @Synchronized + private fun store(): BiometricCredentialStore = + cachedStore + ?: BiometricCredentialStore( + BiometricCredentialFileStore.inDirectory(context().noBackupFilesDir), + keyManager::deleteKey, + ).also { cachedStore = it } + + private fun bridge(body: () -> T): T = + try { + body() + } catch (e: BiometricsError) { + throw ClerkBiometricsException(e) + } + + private fun storeCall(body: () -> T): T = + try { + body() + } catch (e: CodedException) { + throw e + } catch (e: BiometricsError) { + throw ClerkBiometricsException(e) + } catch (e: Exception) { + throw CodedException("storage_failed", e.message ?: "The biometric credential store failed.", e) + } + + private fun codedException(error: Throwable): CodedException = + when (error) { + is CodedException -> error + is BiometricsError -> ClerkBiometricsException(error) + else -> CodedException("unknown", error.message ?: "Biometric signing failed.", error) + } } diff --git a/packages/expo-biometrics/android/src/test/java/expo/modules/clerk/biometrics/BiometricCredentialStorageContractTest.kt b/packages/expo-biometrics/android/src/test/java/expo/modules/clerk/biometrics/BiometricCredentialStorageContractTest.kt new file mode 100644 index 00000000000..94956152d00 --- /dev/null +++ b/packages/expo-biometrics/android/src/test/java/expo/modules/clerk/biometrics/BiometricCredentialStorageContractTest.kt @@ -0,0 +1,450 @@ +package expo.modules.clerk.biometrics + +import android.security.keystore.KeyProperties +import androidx.biometric.BiometricManager.Authenticators +import java.io.File +import java.io.IOException +import java.io.RandomAccessFile +import java.math.BigInteger +import java.security.KeyPairGenerator +import java.security.Signature +import java.security.spec.ECGenParameterSpec +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.thread +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Pins clerk-android's biometric credential storage contract v2 + * (`source/api/docs/biometric-credential-storage-contract.md`) with the same literals and fixture as its + * `BiometricCredentialStorageContractTest`. Update them only together with a contract version bump. + */ +@RunWith(RobolectricTestRunner::class) +class BiometricCredentialStorageContractTest { + @get:Rule val temporaryFolder = TemporaryFolder() + + private val directory by lazy { File(temporaryFolder.root, DIRECTORY_NAME) } + private val dataFile by lazy { File(directory, DATA_FILE_NAME) } + + @Test + fun `store identifiers match the contract`() { + assertEquals(DIRECTORY_NAME, BiometricCredentialFileStore.DIRECTORY_NAME) + assertEquals(DATA_FILE_NAME, BiometricCredentialFileStore.DATA_FILE_NAME) + assertEquals("$DATA_FILE_NAME.tmp", BiometricCredentialFileStore.TEMP_FILE_NAME) + assertEquals(LOCK_FILE_NAME, BiometricCredentialFileStore.LOCK_FILE_NAME) + assertEquals(2, BiometricCredentialFileStore.STORE_VERSION) + assertEquals(KEY_ALIAS_PREFIX + LOCAL_KEY_ID, BiometricCredentialCoding.keyAlias(LOCAL_KEY_ID)) + + val store = BiometricCredentialFileStore.inDirectory(temporaryFolder.root) + assertEquals(dataFile, store.dataFile) + assertEquals(File(directory, LOCK_FILE_NAME), store.lockFile) + } + + @Test + fun `v2 fixture written by clerk-android is readable`() { + writeFixture() + + assertEquals(fixtureRecords, BiometricCredentialFileStore(directory).records().map { it.record }) + } + + @Test + fun `records written by this module match the schema`() { + val store = BiometricCredentialFileStore(directory) + + fixtureRecords.forEach { store.save(it) } + + assertEquals( + parse( + """{ + "version":2, + "credentials":[ + {"id":"td_current_set","local_key_id":"tdlk_0123456789abcdef0123456789abcdef", + "user_id":"user_1","app_identifier":"com.example.app", + "identifier_hint_sha256":"$USER_HINT_SHA256", + "policy":"biometry_current_set","created_at":1735689600000,"updated_at":1735689600001}, + {"id":"td_any","local_key_id":"tdlk_fedcba9876543210fedcba9876543210", + "user_id":"user_2","app_identifier":"com.example.app", + "policy":"biometry_any","created_at":1735689700000,"updated_at":1735689700000}, + {"id":"td_device_passcode","local_key_id":"tdlk_00000000000000000000000000000000", + "user_id":"user_3","app_identifier":"com.example.other", + "policy":"biometry_or_device_passcode","created_at":1735689800000,"updated_at":1735689800000} + ], + "pending_cleanup_user_ids":[] + }""" + ), + parse(dataFile.readText()), + ) + assertTrue(dataFile.readText().startsWith("""{"version":2,"credentials":[""")) + assertEquals(setOf(DATA_FILE_NAME, LOCK_FILE_NAME), directory.list()!!.toSet()) + } + + @Test + fun `rewrites preserve unknown fields, unknown policies, undecodable records and top-level keys`() { + writeFixture() + val store = BiometricCredentialFileStore(directory) + + store.save(fixtureRecords.first()) + + assertEquals(parse(fixture()), parse(dataFile.readText())) + + store.save(fixtureRecords.first().copy(updatedAt = 1_735_689_600_002)) + store.delete(setOf("td_any")) + + val root = JSONObject(dataFile.readText()) + val credentials = root.getJSONArray("credentials") + assertEquals( + listOf("td_current_set", "td_device_passcode", "td_future_policy", "td_incomplete"), + List(credentials.length()) { credentials.getJSONObject(it).getString("id") }, + ) + val current = credentials.getJSONObject(0) + assertEquals(1_735_689_600_002, current.getLong("updated_at")) + assertEquals(parse("""{"nested":[1,2,3]}"""), toValue(current.get("future_field"))) + assertEquals("some_future_policy", credentials.getJSONObject(2).getString("policy")) + assertEquals(parse("""{"id":"td_incomplete","user_id":"user_5"}"""), toValue(credentials.get(3))) + assertEquals(parse("""["user_6"]"""), toValue(root.get("pending_cleanup_user_ids"))) + assertEquals(parse("""{"written_by":"a newer SDK"}"""), toValue(root.get("future_top_level_key"))) + } + + @Test + fun `pending cleanup ids are kept, sorted and de-duplicated on rewrite`() { + directory.mkdirs() + dataFile.writeText("""{"version":2,"credentials":[],"pending_cleanup_user_ids":["user_b"," ",3,"user_a","user_b"]}""") + + BiometricCredentialFileStore(directory).save(fixtureRecords.first()) + + assertEquals(parse("""["user_a","user_b"]"""), toValue(JSONObject(dataFile.readText()).get("pending_cleanup_user_ids"))) + } + + @Test + fun `a different store version is read as empty and never modified`() { + for (future in listOf("""{"version":3,"credentials":[]}""", """{"version":"2","credentials":[]}""", """{"credentials":[]}""")) { + directory.mkdirs() + dataFile.writeText(future) + val store = BiometricCredentialFileStore(directory) + + assertTrue(store.records().isEmpty()) + assertThrows(IOException::class.java) { store.save(fixtureRecords.first()) } + assertThrows(IOException::class.java) { store.delete(setOf("td_any")) } + assertEquals(future, dataFile.readText()) + } + } + + @Test + fun `a malformed file reads as empty and is replaced by the next write`() { + directory.mkdirs() + dataFile.writeText("[not json") + val store = BiometricCredentialFileStore(directory) + + assertTrue(store.records().isEmpty()) + store.save(fixtureRecords.first()) + + assertEquals(listOf(fixtureRecords.first()), store.records().map { it.record }) + } + + @Test + fun `saving replaces the record with the same id and reports its previous key`() { + val store = BiometricCredentialFileStore(directory) + store.save(fixtureRecords.first()) + + assertNull(store.save(fixtureRecords.first())) + assertEquals(LOCAL_KEY_ID, store.save(fixtureRecords.first().copy(localKeyId = "tdlk_new"))) + assertEquals(listOf("tdlk_new"), store.records().map { it.record.localKeyId }) + } + + @Test + fun `concurrent writers in independent store instances do not lose updates`() { + val stores = List(2) { BiometricCredentialFileStore(directory, processGuard = ReentrantLock()) } + val writersPerStore = 4 + val recordsPerWriter = 15 + val executor = Executors.newFixedThreadPool(stores.size * writersPerStore) + val start = CountDownLatch(1) + try { + val futures = + stores.flatMapIndexed { storeIndex, store -> + List(writersPerStore) { writer -> + executor.submit { + start.await() + repeat(recordsPerWriter) { index -> + store.save(fixtureRecords.first().copy(id = "td_${storeIndex}_${writer}_$index")) + } + } + } + } + start.countDown() + futures.forEach { it.get(60, TimeUnit.SECONDS) } + } finally { + executor.shutdownNow() + } + + assertEquals( + stores.size * writersPerStore * recordsPerWriter, + BiometricCredentialFileStore(directory).records().map { it.record.id }.toSet().size, + ) + assertFalse(File(directory, "$DATA_FILE_NAME.tmp").exists()) + } + + @Test + fun `writers wait for a lock held through another channel`() { + val store = BiometricCredentialFileStore(directory) + directory.mkdirs() + RandomAccessFile(File(directory, LOCK_FILE_NAME), "rw").channel.use { channel -> + val held = channel.lock() + val writer = thread { store.save(fixtureRecords.first()) } + + writer.join(300) + assertTrue(writer.isAlive) + assertFalse(dataFile.exists()) + + held.release() + writer.join(5_000) + assertFalse(writer.isAlive) + } + + assertEquals(listOf(fixtureRecords.first()), store.records().map { it.record }) + } + + @Test + fun `writers give up when the lock is not released in time`() { + val store = BiometricCredentialFileStore(directory, lockTimeoutMillis = 100) + directory.mkdirs() + RandomAccessFile(File(directory, LOCK_FILE_NAME), "rw").channel.use { channel -> + channel.lock().use { assertThrows(IOException::class.java) { store.save(fixtureRecords.first()) } } + } + assertFalse(dataFile.exists()) + } + + @Test + fun `identifier hints are hashed after trimming and lowercasing`() { + assertEquals(USER_HINT_SHA256, BiometricCredentialCoding.hashIdentifierHint(" User@Example.COM\n")) + assertEquals(USER_HINT_SHA256, BiometricCredentialCoding.hashIdentifierHint("user@example.com")) + assertNull(BiometricCredentialCoding.hashIdentifierHint(" \n\t")) + assertNull(BiometricCredentialCoding.hashIdentifierHint("")) + assertNull(BiometricCredentialCoding.hashIdentifierHint(null)) + } + + @Test + fun `local key ids are tdlk_ followed by 32 lowercase hex characters`() { + val localKeyId = BiometricCredentialCoding.makeLocalKeyId() + + assertTrue(localKeyId, Regex("tdlk_[0-9a-f]{32}").matches(localKeyId)) + } + + @Test + @Config(sdk = [30]) + fun `signing key parameters match the contract on Android 11 and later`() { + val expectations = + mapOf( + BiometricCredentialPolicy.BIOMETRY_CURRENT_SET to (KeyProperties.AUTH_BIOMETRIC_STRONG to true), + BiometricCredentialPolicy.BIOMETRY_ANY to (KeyProperties.AUTH_BIOMETRIC_STRONG to false), + BiometricCredentialPolicy.BIOMETRY_OR_DEVICE_PASSCODE to + ((KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL) to false), + ) + + expectations.forEach { (policy, expected) -> + val spec = BiometricCredentialCoding.keyGenParameterSpec(LOCAL_KEY_ID, policy) + + assertEquals(KEY_ALIAS_PREFIX + LOCAL_KEY_ID, spec.keystoreAlias) + assertEquals(KeyProperties.PURPOSE_SIGN, spec.purposes) + assertArrayEquals(arrayOf(KeyProperties.DIGEST_SHA256), spec.digests) + assertEquals("secp256r1", (spec.algorithmParameterSpec as ECGenParameterSpec).name) + assertTrue(spec.isUserAuthenticationRequired) + assertEquals(0, spec.userAuthenticationValidityDurationSeconds) + assertEquals(expected.first, spec.userAuthenticationType) + assertEquals(expected.second, spec.isInvalidatedByBiometricEnrollment) + assertFalse(spec.isStrongBoxBacked) + assertFalse(spec.isUnlockedDeviceRequired) + assertNull(spec.attestationChallenge) + } + } + + @Test + @Config(sdk = [28]) + fun `signing key parameters match the contract before Android 11`() { + BiometricCredentialPolicy.entries.forEach { policy -> + val spec = BiometricCredentialCoding.keyGenParameterSpec(LOCAL_KEY_ID, policy) + + assertEquals(KEY_ALIAS_PREFIX + LOCAL_KEY_ID, spec.keystoreAlias) + assertEquals(KeyProperties.PURPOSE_SIGN, spec.purposes) + assertArrayEquals(arrayOf(KeyProperties.DIGEST_SHA256), spec.digests) + assertEquals("secp256r1", (spec.algorithmParameterSpec as ECGenParameterSpec).name) + assertTrue(spec.isUserAuthenticationRequired) + assertEquals(-1, spec.userAuthenticationValidityDurationSeconds) + assertEquals(policy == BiometricCredentialPolicy.BIOMETRY_CURRENT_SET, spec.isInvalidatedByBiometricEnrollment) + } + } + + @Test + fun `prompt authenticators allow device credentials only for the passcode policy on Android 11 and later`() { + val strong = Authenticators.BIOMETRIC_STRONG + for (policy in BiometricCredentialPolicy.entries) { + assertEquals(strong, BiometricKeyManager.promptAuthenticators(policy, sdkInt = 29)) + } + assertEquals(strong, BiometricKeyManager.promptAuthenticators(BiometricCredentialPolicy.BIOMETRY_CURRENT_SET, sdkInt = 30)) + assertEquals(strong, BiometricKeyManager.promptAuthenticators(BiometricCredentialPolicy.BIOMETRY_ANY, sdkInt = 30)) + assertEquals( + strong or Authenticators.DEVICE_CREDENTIAL, + BiometricKeyManager.promptAuthenticators(BiometricCredentialPolicy.BIOMETRY_OR_DEVICE_PASSCODE, sdkInt = 30), + ) + } + + @Test + fun `policy values match the contract`() { + assertEquals( + listOf("biometry_current_set", "biometry_any", "biometry_or_device_passcode"), + BiometricCredentialPolicy.entries.map { it.value }, + ) + } + + @Test + fun `public key JWK uses unpadded base64url 32-byte coordinates`() { + assertEquals( + """{"kty":"EC","crv":"P-256","x":"AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE","y":"AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI","alg":"ES256"}""", + BiometricCredentialCoding.publicKeyJwk(BigInteger(1, ByteArray(32) { 1 }), BigInteger(1, ByteArray(32) { 2 })), + ) + + val highBit = BigInteger(1, ByteArray(32) { 0xFF.toByte() }) + val jwk = JSONObject(BiometricCredentialCoding.publicKeyJwk(highBit, BigInteger.ONE)) + assertEquals("__________________________________________8", jwk.getString("x")) + assertEquals("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE", jwk.getString("y")) + } + + @Test + fun `DER signatures convert to raw r and s`() { + val r = byteArrayOf(0x00, 0x80.toByte()) + ByteArray(31) { 0xAA.toByte() } + val s = ByteArray(31) { 0x11 } + val der = byteArrayOf(0x30, 0x44, 0x02, r.size.toByte()) + r + byteArrayOf(0x02, s.size.toByte()) + s + + val raw = BiometricCredentialCoding.rawES256SignatureFromDer(der) + + assertEquals(64, raw.size) + assertEquals( + "gKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqoAEREREREREREREREREREREREREREREREREREREREREQ", + BiometricCredentialCoding.base64UrlEncode(raw), + ) + } + + @Test + fun `malformed DER signatures are rejected`() { + val valid = byteArrayOf(0x30, 0x06, 0x02, 0x01, 0x01, 0x02, 0x01, 0x02) + BiometricCredentialCoding.rawES256SignatureFromDer(valid) + + val malformed = + listOf( + byteArrayOf(), + byteArrayOf(0x31) + valid.copyOfRange(1, valid.size), + byteArrayOf(0x30, 0x07) + valid.copyOfRange(2, valid.size), + valid + byteArrayOf(0x00), + byteArrayOf(0x30, 0x06, 0x02, 0x01, 0x81.toByte(), 0x02, 0x01, 0x02), + byteArrayOf(0x30, 0x06, 0x02, 0x00, 0x02, 0x02, 0x01, 0x02), + byteArrayOf(0x30, 0x25, 0x02, 0x21, 0x01) + ByteArray(32) { 1 } + byteArrayOf(0x02, 0x01, 0x02), + ) + for (bytes in malformed) { + val error = assertThrows(BiometricsError::class.java) { BiometricCredentialCoding.rawES256SignatureFromDer(bytes) } + assertEquals(BiometricsErrorCode.SIGNING_FAILED, error.code) + } + } + + @Test + fun `raw signatures verify as IEEE P1363 over the UTF-8 client data`() { + val keyPair = KeyPairGenerator.getInstance("EC").apply { initialize(ECGenParameterSpec("secp256r1")) }.generateKeyPair() + val clientData = """{"challenge":"abc","nonce":"é"}""" + + repeat(32) { + val der = + Signature.getInstance("SHA256withECDSA").run { + initSign(keyPair.private) + update(clientData.toByteArray(Charsets.UTF_8)) + sign() + } + val raw = BiometricCredentialCoding.rawES256SignatureFromDer(der) + + assertEquals(64, raw.size) + val verified = + Signature.getInstance("SHA256withECDSAinP1363Format").run { + initVerify(keyPair.public) + update(clientData.toByteArray(Charsets.UTF_8)) + verify(raw) + } + assertTrue(verified) + } + } + + private fun writeFixture() { + directory.mkdirs() + dataFile.writeText(fixture()) + } + + companion object { + const val DIRECTORY_NAME = "clerk" + const val DATA_FILE_NAME = "biometric_credentials.v2.json" + const val LOCK_FILE_NAME = "biometric_credentials.lock" + const val KEY_ALIAS_PREFIX = "com.clerk.trusted_device." + const val LOCAL_KEY_ID = "tdlk_0123456789abcdef0123456789abcdef" + const val USER_HINT_SHA256 = "b4c9a289323b21a01c3e940f150eb9b8c542587f1abfd8f0e1cc1ffc5e475514" + + internal val fixtureRecords = + listOf( + BiometricCredentialLocalRecord( + id = "td_current_set", + localKeyId = LOCAL_KEY_ID, + userId = "user_1", + appIdentifier = "com.example.app", + identifierHintSha256 = USER_HINT_SHA256, + policy = BiometricCredentialPolicy.BIOMETRY_CURRENT_SET, + createdAt = 1_735_689_600_000, + updatedAt = 1_735_689_600_001, + ), + BiometricCredentialLocalRecord( + id = "td_any", + localKeyId = "tdlk_fedcba9876543210fedcba9876543210", + userId = "user_2", + appIdentifier = "com.example.app", + identifierHintSha256 = null, + policy = BiometricCredentialPolicy.BIOMETRY_ANY, + createdAt = 1_735_689_700_000, + updatedAt = 1_735_689_700_000, + ), + BiometricCredentialLocalRecord( + id = "td_device_passcode", + localKeyId = "tdlk_00000000000000000000000000000000", + userId = "user_3", + appIdentifier = "com.example.other", + identifierHintSha256 = null, + policy = BiometricCredentialPolicy.BIOMETRY_OR_DEVICE_PASSCODE, + createdAt = 1_735_689_800_000, + updatedAt = 1_735_689_800_000, + ), + ) + + fun fixture(): String = + checkNotNull(BiometricCredentialStorageContractTest::class.java.getResourceAsStream("/biometric-credential-storage/v2/biometric_credentials.v2.json")) + .use { it.readBytes().toString(Charsets.UTF_8) } + + fun parse(json: String): Any? = toValue(org.json.JSONTokener(json).nextValue()) + + /** Converts org.json values to plain collections so equality ignores key order and Int/Long boxing. */ + fun toValue(value: Any?): Any? = + when (value) { + is JSONObject -> value.keys().asSequence().associateWith { toValue(value.get(it)) } + is JSONArray -> List(value.length()) { toValue(value.get(it)) } + is Int -> value.toLong() + JSONObject.NULL -> null + else -> value + } + } +} diff --git a/packages/expo-biometrics/android/src/test/java/expo/modules/clerk/biometrics/BiometricCredentialStoreTest.kt b/packages/expo-biometrics/android/src/test/java/expo/modules/clerk/biometrics/BiometricCredentialStoreTest.kt new file mode 100644 index 00000000000..e4270c0dc6a --- /dev/null +++ b/packages/expo-biometrics/android/src/test/java/expo/modules/clerk/biometrics/BiometricCredentialStoreTest.kt @@ -0,0 +1,179 @@ +package expo.modules.clerk.biometrics + +import expo.modules.clerk.biometrics.BiometricCredentialStorageContractTest.Companion.USER_HINT_SHA256 +import expo.modules.clerk.biometrics.BiometricCredentialStorageContractTest.Companion.fixture +import expo.modules.clerk.biometrics.BiometricCredentialStorageContractTest.Companion.parse +import java.io.File +import java.io.IOException +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class BiometricCredentialStoreTest { + @get:Rule val temporaryFolder = TemporaryFolder() + + private val directory by lazy { File(temporaryFolder.root, "clerk") } + private val dataFile by lazy { File(directory, "biometric_credentials.v2.json") } + private val fileStore by lazy { BiometricCredentialFileStore(directory) } + private val deletedKeys = mutableListOf() + private val undeletableKeys = mutableSetOf() + private val store by lazy { + BiometricCredentialStore(fileStore) { localKeyId -> + if (localKeyId in undeletableKeys) throw IOException("Keystore failure") + deletedKeys += localKeyId + } + } + + @Test + fun `listRecords maps stored records to the JS shape and passes unknown fields through`() { + writeFixture() + + assertEquals( + parse( + """[ + {"id":"td_current_set","localKeyId":"tdlk_0123456789abcdef0123456789abcdef","userId":"user_1", + "appIdentifier":"com.example.app","identifierHint":null,"identifierHintSha256":"$USER_HINT_SHA256", + "policy":"biometry_current_set","createdAt":1735689600000,"updatedAt":1735689600001, + "future_field":{"nested":[1,2,3]}}, + {"id":"td_any","localKeyId":"tdlk_fedcba9876543210fedcba9876543210","userId":"user_2", + "appIdentifier":"com.example.app","identifierHint":null,"identifierHintSha256":null, + "policy":"biometry_any","createdAt":1735689700000,"updatedAt":1735689700000}, + {"id":"td_device_passcode","localKeyId":"tdlk_00000000000000000000000000000000","userId":"user_3", + "appIdentifier":"com.example.other","identifierHint":null,"identifierHintSha256":null, + "policy":"biometry_or_device_passcode","createdAt":1735689800000,"updatedAt":1735689800000} + ]""" + ), + parse(store.listRecordsJson()), + ) + } + + @Test + fun `listRecords is empty without a file`() { + assertEquals("[]", store.listRecordsJson()) + } + + @Test + fun `saveRecord stores only the hash of the identifier hint`() { + store.save(input(identifierHint = " User@Example.COM\n"), removeOtherRecordsForApp = false) + + val stored = JSONObject(dataFile.readText()).getJSONArray("credentials").getJSONObject(0) + assertEquals(USER_HINT_SHA256, stored.getString("identifier_hint_sha256")) + assertFalse(dataFile.readText().contains("example.com", ignoreCase = true)) + assertEquals( + setOf("id", "local_key_id", "user_id", "app_identifier", "identifier_hint_sha256", "policy", "created_at", "updated_at"), + stored.keys().asSequence().toSet(), + ) + assertEquals(1_735_689_600_001, stored.getLong("created_at")) + + store.save(input(identifierHint = " "), removeOtherRecordsForApp = false) + assertFalse(JSONObject(dataFile.readText()).getJSONArray("credentials").getJSONObject(0).has("identifier_hint_sha256")) + } + + @Test + fun `saveRecord deletes the key of the record it replaces`() { + store.save(input(localKeyId = "tdlk_old"), removeOtherRecordsForApp = false) + store.save(input(localKeyId = "tdlk_new"), removeOtherRecordsForApp = false) + + assertEquals(listOf("tdlk_old"), deletedKeys) + assertEquals(listOf("tdlk_new"), fileStore.records().map { it.record.localKeyId }) + } + + @Test + fun `removing other records deletes only the same user's records for the app`() { + store.save(input(id = "td_same_user", localKeyId = "tdlk_same_user"), removeOtherRecordsForApp = false) + store.save(input(id = "td_shared_key", localKeyId = "tdlk_new"), removeOtherRecordsForApp = false) + store.save(input(id = "td_other_user", localKeyId = "tdlk_other_user", userId = "user_2"), removeOtherRecordsForApp = false) + store.save(input(id = "td_other_app", localKeyId = "tdlk_other_app", appIdentifier = "com.example.other"), removeOtherRecordsForApp = false) + store.save(input(id = "td_stuck", localKeyId = "tdlk_stuck"), removeOtherRecordsForApp = false) + undeletableKeys += "tdlk_stuck" + + store.save(input(id = "td_new", localKeyId = "tdlk_new"), removeOtherRecordsForApp = true) + + assertEquals(listOf("tdlk_same_user"), deletedKeys) + assertEquals( + listOf("td_other_user", "td_other_app", "td_stuck", "td_new"), + fileStore.records().map { it.record.id }, + ) + } + + @Test + fun `removing other records keeps records this module cannot decode`() { + writeFixture() + + store.save(input(id = "td_new", localKeyId = "tdlk_new", userId = "user_4"), removeOtherRecordsForApp = true) + + val ids = JSONObject(dataFile.readText()).getJSONArray("credentials").let { array -> List(array.length()) { array.getJSONObject(it).getString("id") } } + assertEquals(listOf("td_current_set", "td_any", "td_device_passcode", "td_future_policy", "td_incomplete", "td_new"), ids) + assertTrue(deletedKeys.isEmpty()) + } + + @Test + fun `deleteRecord deletes the key then the records that reference it`() { + writeFixture() + + store.deleteRecords("tdlk_fedcba9876543210fedcba9876543210") + + assertEquals(listOf("tdlk_fedcba9876543210fedcba9876543210"), deletedKeys) + assertEquals(listOf("td_current_set", "td_device_passcode"), fileStore.records().map { it.record.id }) + assertTrue(dataFile.readText().contains("td_future_policy")) + } + + @Test + fun `deleteRecord keeps the records when the key cannot be deleted`() { + writeFixture() + undeletableKeys += "tdlk_fedcba9876543210fedcba9876543210" + + assertThrows(IOException::class.java) { store.deleteRecords("tdlk_fedcba9876543210fedcba9876543210") } + + assertEquals(parse(fixture()), parse(dataFile.readText())) + } + + @Test + fun `record input is validated`() { + val invalid = + listOf( + input(id = ""), + input(localKeyId = ""), + input(userId = ""), + input(appIdentifier = ""), + input(policy = "face_id"), + input(createdAt = -1.0), + input(updatedAt = Double.NaN), + input(createdAt = 9.0e15), + ) + for (record in invalid) { + val error = assertThrows(BiometricsError::class.java) { store.save(record, removeOtherRecordsForApp = false) } + assertEquals(BiometricsErrorCode.INVALID_ARGUMENT, error.code) + } + assertFalse(dataFile.exists()) + } + + @Test + fun `timestamps are rounded to integer milliseconds`() { + assertEquals(1_714_000_000_001, input(createdAt = 1_714_000_000_000.5).toLocalRecord().createdAt) + } + + private fun writeFixture() { + directory.mkdirs() + dataFile.writeText(fixture()) + } + + private fun input( + id: String = "td_1", + localKeyId: String = "tdlk_1", + userId: String = "user_1", + appIdentifier: String = "com.example.app", + identifierHint: String? = null, + policy: String = "biometry_current_set", + createdAt: Double = 1_735_689_600_001.0, + updatedAt: Double = 1_735_689_600_002.0, + ) = BiometricCredentialRecordInput(id, localKeyId, userId, appIdentifier, identifierHint, policy, createdAt, updatedAt) +} diff --git a/packages/expo-biometrics/android/src/test/java/expo/modules/clerk/biometrics/BiometricKeyManagerTest.kt b/packages/expo-biometrics/android/src/test/java/expo/modules/clerk/biometrics/BiometricKeyManagerTest.kt new file mode 100644 index 00000000000..b7bd525467e --- /dev/null +++ b/packages/expo-biometrics/android/src/test/java/expo/modules/clerk/biometrics/BiometricKeyManagerTest.kt @@ -0,0 +1,49 @@ +package expo.modules.clerk.biometrics + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +class BiometricKeyManagerTest { + @Test + fun `secure key storage requires Android 9`() { + assertFalse(BiometricKeyManager.secureKeyStorageAvailable(sdkInt = 27)) + assertTrue(BiometricKeyManager.secureKeyStorageAvailable(sdkInt = 28)) + assertTrue(BiometricKeyManager.secureKeyStorageAvailable(sdkInt = 36)) + } + + @Test + @Config(sdk = [27]) + fun `availability reports no secure key storage before Android 9`() { + val availability = BiometricKeyManager().availability(RuntimeEnvironment.getApplication()) + + assertFalse(availability.secureKeyStorageAvailable) + assertFalse(availability.canEvaluateBiometrics) + assertEquals(BiometricsErrorCode.BIOMETRY_NOT_AVAILABLE, availability.errorCode) + } + + @Test + @Config(sdk = [28]) + fun `availability reports secure key storage on Android 9 and later`() { + assertTrue(BiometricKeyManager().availability(RuntimeEnvironment.getApplication()).secureKeyStorageAvailable) + } + + @Test + @Config(sdk = [27]) + fun `createKey rejects with secure_key_storage_unavailable before Android 9`() { + for (policy in BiometricCredentialPolicy.entries) { + val error = + assertThrows(BiometricsError::class.java) { + BiometricKeyManager().createKey(RuntimeEnvironment.getApplication(), policy) + } + assertEquals(BiometricsErrorCode.SECURE_KEY_STORAGE_UNAVAILABLE, error.code) + } + } +} diff --git a/packages/expo-biometrics/android/src/test/resources/biometric-credential-storage/v2/biometric_credentials.v2.json b/packages/expo-biometrics/android/src/test/resources/biometric-credential-storage/v2/biometric_credentials.v2.json new file mode 100644 index 00000000000..21e9526106c --- /dev/null +++ b/packages/expo-biometrics/android/src/test/resources/biometric-credential-storage/v2/biometric_credentials.v2.json @@ -0,0 +1,50 @@ +{ + "version": 2, + "credentials": [ + { + "id": "td_current_set", + "local_key_id": "tdlk_0123456789abcdef0123456789abcdef", + "user_id": "user_1", + "app_identifier": "com.example.app", + "identifier_hint_sha256": "b4c9a289323b21a01c3e940f150eb9b8c542587f1abfd8f0e1cc1ffc5e475514", + "policy": "biometry_current_set", + "created_at": 1735689600000, + "updated_at": 1735689600001, + "future_field": { "nested": [1, 2, 3] } + }, + { + "id": "td_any", + "local_key_id": "tdlk_fedcba9876543210fedcba9876543210", + "user_id": "user_2", + "app_identifier": "com.example.app", + "policy": "biometry_any", + "created_at": 1735689700000, + "updated_at": 1735689700000 + }, + { + "id": "td_device_passcode", + "local_key_id": "tdlk_00000000000000000000000000000000", + "user_id": "user_3", + "app_identifier": "com.example.other", + "identifier_hint_sha256": null, + "policy": "biometry_or_device_passcode", + "created_at": 1735689800000, + "updated_at": 1735689800000 + }, + { + "id": "td_future_policy", + "local_key_id": "tdlk_11111111111111111111111111111111", + "user_id": "user_4", + "app_identifier": "com.example.app", + "policy": "some_future_policy", + "created_at": 1735689900000, + "updated_at": 1735689900000 + }, + { + "id": "td_incomplete", + "user_id": "user_5" + } + ], + "pending_cleanup_user_ids": ["user_6"], + "future_top_level_key": { "written_by": "a newer SDK" } +} diff --git a/packages/expo-biometrics/android/src/test/resources/robolectric.properties b/packages/expo-biometrics/android/src/test/resources/robolectric.properties new file mode 100644 index 00000000000..28a4ed295fa --- /dev/null +++ b/packages/expo-biometrics/android/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=30 diff --git a/packages/expo-biometrics/ios/BiometricCredentialCoding.swift b/packages/expo-biometrics/ios/BiometricCredentialCoding.swift index d741022f3c3..45a744975bd 100644 --- a/packages/expo-biometrics/ios/BiometricCredentialCoding.swift +++ b/packages/expo-biometrics/ios/BiometricCredentialCoding.swift @@ -1,3 +1,4 @@ +import CryptoKit import Foundation // Mirrors the format pinned by clerk-ios BiometricCredentialStorageContractTests. Any change here must stay @@ -43,6 +44,12 @@ enum BiometricCredentialCoding { return "s\(value.utf8.count):\(value)" } + /// Lowercase hex SHA-256 of the normalized hint, as clerk-android's storage contract v2 stores it; `nil` when it is empty. + static func identifierHintSHA256(_ identifierHint: String?) -> String? { + guard let normalized = BiometricCredentialRecord.normalizedIdentifierHint(identifierHint) else { return nil } + return SHA256.hash(data: Data(normalized.utf8)).map { String(format: "%02x", $0) }.joined() + } + static func base64URLEncodedString(_ data: D) -> String { Data(data) .base64EncodedString() @@ -141,6 +148,7 @@ struct BiometricCredentialRecord: Equatable { static let userId = "userId" static let appIdentifier = "appIdentifier" static let identifierHint = "identifierHint" + static let identifierHintSha256 = "identifierHintSha256" static let policy = "policy" static let createdAt = "createdAt" static let updatedAt = "updatedAt" @@ -275,7 +283,8 @@ enum BiometricCredentialRecordList { } } - /// Well-formed records with their stored fields passed through and `identifierHint` normalized as ClerkKit reads it. + /// Well-formed records with their stored fields passed through, `identifierHint` normalized as ClerkKit reads it, and + /// its `identifierHintSha256`. static func listable(_ records: [[String: Any]]) -> [[String: Any]] { records.compactMap { object in guard let record = BiometricCredentialRecord(jsonObject: object) else { return nil } @@ -285,6 +294,8 @@ enum BiometricCredentialRecordList { } else { listed.removeValue(forKey: BiometricCredentialRecord.Field.identifierHint) } + listed[BiometricCredentialRecord.Field.identifierHintSha256] = + BiometricCredentialCoding.identifierHintSHA256(record.identifierHint) ?? NSNull() return listed } } diff --git a/packages/expo-biometrics/ios/ClerkExpoBiometricsModule.swift b/packages/expo-biometrics/ios/ClerkExpoBiometricsModule.swift index aefc24929b2..72de2d4e7d3 100644 --- a/packages/expo-biometrics/ios/ClerkExpoBiometricsModule.swift +++ b/packages/expo-biometrics/ios/ClerkExpoBiometricsModule.swift @@ -58,6 +58,10 @@ public final class ClerkExpoBiometricsModule: Module { Bundle.main.bundleIdentifier ?? "" } + Function("hashIdentifierHint") { (hint: String) -> String? in + BiometricCredentialCoding.identifierHintSHA256(hint) + } + AsyncFunction("getAvailability") { () -> BiometricAvailabilityResult in let availability = self.keyManager.availability() let result = BiometricAvailabilityResult() diff --git a/packages/expo-biometrics/ios/Tests/BiometricCredentialContractTests.swift b/packages/expo-biometrics/ios/Tests/BiometricCredentialContractTests.swift index c4461614eb8..519f17ce291 100644 --- a/packages/expo-biometrics/ios/Tests/BiometricCredentialContractTests.swift +++ b/packages/expo-biometrics/ios/Tests/BiometricCredentialContractTests.swift @@ -220,6 +220,15 @@ final class BiometricCredentialContractTests: XCTestCase { XCTAssertEqual((listed[0]["createdAt"] as? NSNumber)?.doubleValue, 1_714_000_000_000.25) } + func testIdentifierHintHashMatchesAndroidContractVector() { + XCTAssertEqual( + BiometricCredentialCoding.identifierHintSHA256(" User@Example.COM\n"), + "b4c9a289323b21a01c3e940f150eb9b8c542587f1abfd8f0e1cc1ffc5e475514" + ) + XCTAssertNil(BiometricCredentialCoding.identifierHintSHA256(" \n\t")) + XCTAssertNil(BiometricCredentialCoding.identifierHintSHA256(nil)) + } + func testInstallationMarkerKeyMatchesContractFormat() { XCTAssertEqual( BiometricCredentialCoding.installationMarkerKey(service: "com.clerk.example", accessGroup: nil, appIdentifier: "com.clerk.example"), diff --git a/packages/expo-biometrics/ios/Tests/BiometricCredentialStoreTests.swift b/packages/expo-biometrics/ios/Tests/BiometricCredentialStoreTests.swift index 63efe800fdb..aef10fbfd2f 100644 --- a/packages/expo-biometrics/ios/Tests/BiometricCredentialStoreTests.swift +++ b/packages/expo-biometrics/ios/Tests/BiometricCredentialStoreTests.swift @@ -193,6 +193,9 @@ final class BiometricCredentialStoreTests: XCTestCase { XCTAssertEqual(listed[0]["identifierHint"] as? String, "a@b.co") XCTAssertNil(listed[1]["identifierHint"]) XCTAssertNil(listed[2]["identifierHint"]) + XCTAssertEqual(listed[0]["identifierHintSha256"] as? String, "80305c9bb1bb2480e03894350e0a8a366dcbdeb302e69e0817aa0743abd77054") + XCTAssertTrue(listed[1]["identifierHintSha256"] is NSNull) + XCTAssertTrue(listed[2]["identifierHintSha256"] is NSNull) XCTAssertTrue(json.contains(#""futureField":[1,true,null]"#)) XCTAssertTrue(json.contains(#""updatedAt":1714000000001"#)) } diff --git a/packages/expo-biometrics/src/ClerkExpoBiometricsModule.ts b/packages/expo-biometrics/src/ClerkExpoBiometricsModule.ts index a9adc9aafc8..83ab05b6956 100644 --- a/packages/expo-biometrics/src/ClerkExpoBiometricsModule.ts +++ b/packages/expo-biometrics/src/ClerkExpoBiometricsModule.ts @@ -11,6 +11,7 @@ import type { export interface ClerkExpoBiometricsNativeModule { getAppIdentifier(): string; + hashIdentifierHint(hint: string): string | null; getAvailability(): Promise; createKey(policy: BiometricCredentialPolicy): Promise; sign(localKeyId: string, clientData: string, reason: string | null): Promise; diff --git a/packages/expo-biometrics/src/__tests__/index.test.ts b/packages/expo-biometrics/src/__tests__/index.test.ts index d214347149b..d5f27595608 100644 --- a/packages/expo-biometrics/src/__tests__/index.test.ts +++ b/packages/expo-biometrics/src/__tests__/index.test.ts @@ -14,8 +14,11 @@ vi.mock('expo', () => ({ }, })); +const HINT_SHA256 = 'b4c9a289323b21a01c3e940f150eb9b8c542587f1abfd8f0e1cc1ffc5e475514'; + const createNativeModule = () => ({ getAppIdentifier: vi.fn().mockReturnValue('com.clerk.example'), + hashIdentifierHint: vi.fn().mockReturnValue(HINT_SHA256), getAvailability: vi.fn().mockResolvedValue({ biometryType: 'faceID', canEvaluateBiometrics: true, @@ -94,6 +97,44 @@ describe('@clerk/expo-biometrics', () => { ); await expect(biometrics.getAvailability()).rejects.toMatchObject({ code: 'native_module_unavailable' }); await expect(biometrics.listRecords()).rejects.toMatchObject({ code: 'native_module_unavailable' }); + expect(() => biometrics.hashIdentifierHint('user@example.com')).toThrow( + expect.objectContaining({ code: 'native_module_unavailable' }), + ); + }); + + describe('hashIdentifierHint', () => { + test('returns the native hash synchronously', async () => { + const biometrics = await load(); + + expect(biometrics.hashIdentifierHint(' User@Example.COM\n')).toBe(HINT_SHA256); + expect(native.hashIdentifierHint).toHaveBeenCalledWith(' User@Example.COM\n'); + }); + + test('passes through null for empty hints', async () => { + native.hashIdentifierHint.mockReturnValueOnce(null); + const biometrics = await load(); + + expect(biometrics.hashIdentifierHint(' ')).toBeNull(); + }); + + test('rejects non-string hints without calling native', async () => { + const biometrics = await load(); + + // @ts-expect-error testing a non-string hint + expect(() => biometrics.hashIdentifierHint(null)).toThrow(expect.objectContaining({ code: 'invalid_argument' })); + expect(native.hashIdentifierHint).not.toHaveBeenCalled(); + }); + + test('wraps native errors', async () => { + native.hashIdentifierHint.mockImplementationOnce(() => { + throw nativeError('ERR_ARGUMENT_CAST'); + }); + const biometrics = await load(); + + expect(() => biometrics.hashIdentifierHint('user@example.com')).toThrow( + expect.objectContaining({ name: 'ClerkBiometricsError', code: 'unknown' }), + ); + }); }); describe('keys', () => { @@ -212,13 +253,24 @@ describe('@clerk/expo-biometrics', () => { describe('store', () => { test('listRecords parses native JSON and passes unknown fields through', async () => { native.listRecords.mockResolvedValueOnce( - JSON.stringify([{ ...record, futureField: { nested: [1, true, null] } }]), + JSON.stringify([{ ...record, identifierHintSha256: HINT_SHA256, futureField: { nested: [1, true, null] } }]), ); const biometrics = await load(); const records = await biometrics.listRecords(); - expect(records).toEqual([{ ...record, futureField: { nested: [1, true, null] } }]); + expect(records).toEqual([ + { ...record, identifierHintSha256: HINT_SHA256, futureField: { nested: [1, true, null] } }, + ]); + }); + + test('listRecords returns Android records with only the hashed identifier hint', async () => { + const { identifierHint: _, ...withoutHint } = record; + const androidRecord = { ...withoutHint, identifierHint: null, identifierHintSha256: HINT_SHA256 }; + native.listRecords.mockResolvedValueOnce(JSON.stringify([androidRecord])); + const biometrics = await load(); + + await expect(biometrics.listRecords()).resolves.toEqual([androidRecord]); }); test('listRecords rejects with storage_failed on invalid native output', async () => { diff --git a/packages/expo-biometrics/src/index.ts b/packages/expo-biometrics/src/index.ts index 7000dfdbb30..eeb11dea379 100644 --- a/packages/expo-biometrics/src/index.ts +++ b/packages/expo-biometrics/src/index.ts @@ -87,18 +87,33 @@ function assertRecord(record: BiometricCredentialRecord): void { assertTimestamp(record.updatedAt, 'record.updatedAt'); } -/** - * Returns the identifier Clerk uses as the credential's `app_identifier` (the iOS bundle identifier). - */ -export function getAppIdentifier(): string { +function callNativeSync(fn: (module: ClerkExpoBiometricsNativeModule) => T): T { const module = nativeModule(); try { - return module.getAppIdentifier(); + return fn(module); } catch (error) { throw toClerkBiometricsError(error); } } +/** + * Returns the identifier Clerk uses as the credential's `app_identifier` (the iOS bundle identifier or the Android package name). + */ +export function getAppIdentifier(): string { + return callNativeSync(module => module.getAppIdentifier()); +} + +/** + * Returns the SHA-256 of `hint` after trimming and lowercasing it, as 64 lowercase hex characters, or `null` when the + * normalized hint is empty. Compare it with a record's `identifierHintSha256` to match an identifier on every platform. + */ +export function hashIdentifierHint(hint: string): string | null { + if (typeof hint !== 'string') { + throw invalidArgument('hint must be a string.'); + } + return callNativeSync(module => module.hashIdentifierHint(hint)); +} + /** * Reports which biometry the device supports and whether it can be used right now. */ @@ -120,7 +135,7 @@ export async function createKey(policy: BiometricCredentialPolicy): Promise { assertNonEmptyString(localKeyId, 'localKeyId'); @@ -202,6 +217,7 @@ export async function deleteRecord(localKeyId: string): Promise { /** * Detects a new installation and deletes the records and keys a previous installation of this app left in the Keychain. * Safe to call repeatedly. The store operations above call it before they read or write. + * On Android it always resolves `{ wiped: false }`: uninstalling the app deletes its records and keys. */ export function ensureInstallationMarker(): Promise { return callNative(module => module.ensureInstallationMarker()); diff --git a/packages/expo-biometrics/src/types.ts b/packages/expo-biometrics/src/types.ts index 6c537067e71..5030d1dd18c 100644 --- a/packages/expo-biometrics/src/types.ts +++ b/packages/expo-biometrics/src/types.ts @@ -7,7 +7,11 @@ */ export type BiometricCredentialPolicy = 'biometry_current_set' | 'biometry_any' | 'biometry_or_device_passcode'; -export type BiometryType = 'faceID' | 'touchID' | 'opticID' | 'none'; +/** + * The biometry the device supports. iOS reports `faceID`, `touchID`, or `opticID`. Android cannot tell which sensor is a + * strong (Class 3) biometric, so it reports `biometric` whenever one is present. + */ +export type BiometryType = 'faceID' | 'touchID' | 'opticID' | 'biometric' | 'none'; export interface BiometricAvailability { /** The biometry the device supports, or `none`. */ @@ -19,7 +23,8 @@ export interface BiometricAvailability { /** Why biometric authentication cannot be evaluated, or `null` when it can. */ errorCode: BiometricsErrorCode | null; /** - * Whether the device has hardware-backed key storage (the Secure Enclave on iOS). `false` on the iOS Simulator. + * Whether the device has hardware-backed key storage: the Secure Enclave on iOS, which the iOS Simulator lacks, or the + * Android Keystore on Android 9 (API level 28) and later. * `createKey()` rejects with `secure_key_storage_unavailable` when this is `false`. */ secureKeyStorageAvailable: boolean; @@ -44,7 +49,10 @@ export interface BiometricCredentialRecord { userId: string; /** App identifier the credential was enrolled for (see `getAppIdentifier()`). */ appIdentifier: string; - /** Local-only identifier hint. Normalized (trimmed, lowercased) when stored; empty values are omitted. */ + /** + * Local-only identifier hint. Normalized (trimmed, lowercased) when stored; empty values are omitted. + * iOS stores the normalized hint; Android stores only its hash (see `hashIdentifierHint()`). + */ identifierHint?: string; policy: BiometricCredentialPolicy; /** Server credential creation time, in milliseconds since the Unix epoch. */ @@ -56,18 +64,28 @@ export interface BiometricCredentialRecord { /** * A record read from the store. Fields written by other SDK versions are passed through unchanged. */ -export type StoredBiometricCredentialRecord = BiometricCredentialRecord & { readonly [field: string]: unknown }; +export type StoredBiometricCredentialRecord = Omit & { + /** The normalized identifier hint on iOS, when one was stored. Always `null` on Android, which stores only the hash. */ + identifierHint?: string | null; + /** `hashIdentifierHint()` of the stored identifier hint, or `null` when there is none. Compare hints with this field. */ + identifierHintSha256: string | null; + readonly [field: string]: unknown; +}; export interface SaveRecordOptions { /** * Delete every other record for the same `appIdentifier`, along with its private key, after the record is saved. * Set this after a successful enrollment, since the server has already replaced those credentials. + * On Android only the same `userId`'s other records are deleted, as the shared storage contract requires. */ removeOtherRecordsForApp: boolean; } export interface InstallationMarkerResult { - /** `true` when this is a new installation and the records left behind by a previous one were deleted. */ + /** + * `true` when this is a new installation and the records left behind by a previous one were deleted. + * Always `false` on Android, where uninstalling the app already deletes its records and keys. + */ wiped: boolean; }