Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/expo-biometrics-android.md
Original file line number Diff line number Diff line change
@@ -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).
25 changes: 17 additions & 8 deletions packages/expo-biometrics/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -57,6 +57,7 @@ import {
ensureInstallationMarker,
getAppIdentifier,
getAvailability,
hashIdentifierHint,
hasKey,
listRecords,
saveRecord,
Expand All @@ -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
Expand Down
10 changes: 10 additions & 0 deletions packages/expo-biometrics/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Original file line number Diff line number Diff line change
@@ -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 }
}
}
}
Loading
Loading