Skip to content

Commit ce8ac2f

Browse files
mikepitreclaude
andcommitted
feat(expo-biometrics): add Android support
Implement the Android side of the module against clerk-android's biometric credential storage contract v2, and add hashIdentifierHint() plus identifierHintSha256 on listed records on both platforms. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 0505abb commit ce8ac2f

21 files changed

Lines changed: 1770 additions & 37 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/expo-biometrics': minor
3+
---
4+
5+
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).

‎packages/expo-biometrics/README.md‎

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,15 @@
2828
> [!WARNING]
2929
> This package is experimental. Pin its version, as breaking changes can happen in minor releases.
3030
31-
The native building block for Clerk biometric credentials in Expo apps. It creates hardware-backed signing keys, signs challenges behind a Face ID / Touch ID 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.
31+
The native building block for Clerk biometric credentials in Expo apps. It creates hardware-backed signing keys, signs challenges behind a Face ID / Touch ID or Android biometric prompt, and stores the on-device records that link each key to a Clerk credential. It does not talk to Clerk's API; `@clerk/expo` builds the sign-in and enrollment flows on top of it.
3232

33-
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.
33+
The key and record layout is shared with the Clerk iOS and Android SDKs, so credentials enrolled by either SDK in the same app are visible to both.
3434

3535
### Prerequisites
3636

3737
- Expo SDK 54 or later, in a development build (the module is not available in Expo Go or on the web)
38-
- iOS. Android support is not implemented yet: every call rejects with `not_implemented`.
39-
- `NSFaceIDUsageDescription` in your `Info.plist`. The `@clerk/expo` config plugin sets it through its `faceIDPermission` option.
38+
- iOS: `NSFaceIDUsageDescription` in your `Info.plist`. The `@clerk/expo` config plugin sets it through its `faceIDPermission` option.
39+
- Android 9 (API level 28) or later with a strong (Class 3) biometric. On older versions every key operation rejects with `biometry_not_available`.
4040

4141
## Installation
4242

@@ -56,6 +56,7 @@ import {
5656
ensureInstallationMarker,
5757
getAppIdentifier,
5858
getAvailability,
59+
hashIdentifierHint,
5960
hasKey,
6061
listRecords,
6162
saveRecord,
@@ -65,16 +66,24 @@ import {
6566

6667
| Function | Description |
6768
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
68-
| `getAppIdentifier()` | The app identifier sent to Clerk as `app_identifier` (the iOS bundle identifier). |
69+
| `getAppIdentifier()` | The app identifier sent to Clerk as `app_identifier` (the iOS bundle identifier or the Android package name). |
70+
| `hashIdentifierHint(hint)` | The SHA-256 of the trimmed, lowercased hint as lowercase hex, or `null` when it is empty. |
6971
| `getAvailability()` | The device's biometry type and whether biometrics or device owner authentication can be evaluated. |
70-
| `createKey(policy)` | Creates a Secure Enclave P-256 key and returns its `localKeyId` and public key JWK. |
72+
| `createKey(policy)` | Creates a Secure Enclave or Android Keystore P-256 key and returns its `localKeyId` and public key JWK. |
7173
| `sign(localKeyId, clientData, reason?)` | Prompts for authentication and returns an ES256 signature over `clientData` (raw `r \|\| s`, base64url without padding). |
7274
| `hasKey(localKeyId)` / `deleteKey(localKeyId)` | Checks for or deletes a key. |
73-
| `listRecords()` | Every stored credential record, for every app identifier. |
75+
| `listRecords()` | Every stored credential record, for every app identifier, with its `identifierHintSha256`. |
7476
| `saveRecord(record, options)` | Saves a record. With `removeOtherRecordsForApp: true`, deletes the app's other records and their keys. |
7577
| `deleteRecord(localKeyId)` | Deletes a key, then the records that reference it. |
7678
| `ensureInstallationMarker()` | Deletes records and keys left behind by a previous installation of the app. The store functions call it for you. |
7779

80+
### Platform differences
81+
82+
- 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.
83+
- On Android, `removeOtherRecordsForApp` deletes only the same user's other records; other users' records are left for the next sign-in to reconcile.
84+
- Android reports `biometryType: 'biometric'`, since it does not say which sensor is a strong biometric.
85+
- Android's `ensureInstallationMarker()` always resolves `{ wiped: false }`: uninstalling the app already deletes its records and keys.
86+
7887
Every error is a `ClerkBiometricsError` with a stable `code`, such as `user_canceled`, `biometry_not_enrolled`, `biometry_lockout`, `key_not_found`, or `storage_failed`.
7988

8089
## License

‎packages/expo-biometrics/android/build.gradle‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,18 @@ android {
3232
versionCode 1
3333
versionName "1.0.0"
3434
}
35+
36+
testOptions {
37+
unitTests {
38+
includeAndroidResources = true
39+
}
40+
}
3541
}
3642

3743
dependencies {
3844
implementation project(':expo-modules-core')
45+
implementation "androidx.biometric:biometric:1.1.0"
46+
47+
testImplementation "junit:junit:4.13.2"
48+
testImplementation "org.robolectric:robolectric:4.16"
3949
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package expo.modules.clerk.biometrics
2+
3+
import android.os.Build
4+
import android.security.keystore.KeyGenParameterSpec
5+
import android.security.keystore.KeyProperties
6+
import android.util.Base64
7+
import java.math.BigInteger
8+
import java.security.MessageDigest
9+
import java.security.spec.ECGenParameterSpec
10+
import java.util.UUID
11+
12+
// Implements clerk-android source/api/docs/biometric-credential-storage-contract.md (version 2). Any change here must
13+
// stay compatible with clerk-android, which reads and writes the same Keystore aliases and metadata file.
14+
15+
internal enum class BiometricCredentialPolicy(val value: String) {
16+
BIOMETRY_CURRENT_SET("biometry_current_set"),
17+
BIOMETRY_ANY("biometry_any"),
18+
BIOMETRY_OR_DEVICE_PASSCODE("biometry_or_device_passcode");
19+
20+
companion object {
21+
fun fromValue(value: String?): BiometricCredentialPolicy? = entries.firstOrNull { it.value == value }
22+
}
23+
}
24+
25+
internal object BiometricCredentialCoding {
26+
const val KEY_ALIAS_PREFIX = "com.clerk.trusted_device."
27+
const val LOCAL_KEY_ID_PREFIX = "tdlk_"
28+
const val SIGNATURE_ALGORITHM = "SHA256withECDSA"
29+
const val EC_CURVE = "secp256r1"
30+
const val MIN_SDK = Build.VERSION_CODES.P
31+
32+
private const val COORDINATE_SIZE = 32
33+
private const val HEX = "0123456789abcdef"
34+
35+
fun makeLocalKeyId(): String = LOCAL_KEY_ID_PREFIX + UUID.randomUUID().toString().replace("-", "").lowercase()
36+
37+
fun keyAlias(localKeyId: String): String = KEY_ALIAS_PREFIX + localKeyId
38+
39+
/** Lowercase hex SHA-256 of the trimmed, locale-independently lowercased hint, or `null` when it is empty. */
40+
fun hashIdentifierHint(hint: String?): String? {
41+
val normalized = hint?.trim()?.lowercase() ?: return null
42+
if (normalized.isEmpty()) return null
43+
val digest = MessageDigest.getInstance("SHA-256").digest(normalized.toByteArray(Charsets.UTF_8))
44+
return buildString(digest.size * 2) {
45+
for (byte in digest) {
46+
val value = byte.toInt() and 0xFF
47+
append(HEX[value ushr 4])
48+
append(HEX[value and 0x0F])
49+
}
50+
}
51+
}
52+
53+
fun keyAuthenticators(policy: BiometricCredentialPolicy): Int =
54+
if (policy == BiometricCredentialPolicy.BIOMETRY_OR_DEVICE_PASSCODE) {
55+
KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL
56+
} else {
57+
KeyProperties.AUTH_BIOMETRIC_STRONG
58+
}
59+
60+
fun keyGenParameterSpec(localKeyId: String, policy: BiometricCredentialPolicy): KeyGenParameterSpec {
61+
val builder =
62+
KeyGenParameterSpec.Builder(keyAlias(localKeyId), KeyProperties.PURPOSE_SIGN)
63+
.setAlgorithmParameterSpec(ECGenParameterSpec(EC_CURVE))
64+
.setDigests(KeyProperties.DIGEST_SHA256)
65+
.setUserAuthenticationRequired(true)
66+
.setInvalidatedByBiometricEnrollment(policy == BiometricCredentialPolicy.BIOMETRY_CURRENT_SET)
67+
68+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
69+
builder.setUserAuthenticationParameters(0, keyAuthenticators(policy))
70+
} else {
71+
@Suppress("DEPRECATION")
72+
builder.setUserAuthenticationValidityDurationSeconds(-1)
73+
}
74+
return builder.build()
75+
}
76+
77+
fun base64UrlEncode(bytes: ByteArray): String =
78+
Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
79+
80+
fun publicKeyJwk(x: BigInteger, y: BigInteger): String {
81+
val encodedX = base64UrlEncode(fixedWidthCoordinate(x))
82+
val encodedY = base64UrlEncode(fixedWidthCoordinate(y))
83+
return """{"kty":"EC","crv":"P-256","x":"$encodedX","y":"$encodedY","alg":"ES256"}"""
84+
}
85+
86+
private fun fixedWidthCoordinate(coordinate: BigInteger): ByteArray {
87+
val bytes = coordinate.toByteArray()
88+
return when {
89+
bytes.size == COORDINATE_SIZE -> bytes
90+
bytes.size > COORDINATE_SIZE -> bytes.copyOfRange(bytes.size - COORDINATE_SIZE, bytes.size)
91+
else -> ByteArray(COORDINATE_SIZE - bytes.size) + bytes
92+
}
93+
}
94+
95+
fun rawES256SignatureFromDer(signature: ByteArray): ByteArray {
96+
val reader = DerReader(signature)
97+
if (reader.readByte() != 0x30) throw invalidSignature()
98+
if (reader.readLength() != reader.remaining) throw invalidSignature()
99+
val r = reader.readInteger()
100+
val s = reader.readInteger()
101+
if (reader.remaining != 0) throw invalidSignature()
102+
return paddedComponent(r) + paddedComponent(s)
103+
}
104+
105+
private fun paddedComponent(component: ByteArray): ByteArray {
106+
if (component.isEmpty() || component[0].toInt() and 0x80 != 0) throw invalidSignature()
107+
var start = 0
108+
while (component.size - start > COORDINATE_SIZE && component[start].toInt() == 0) {
109+
start += 1
110+
}
111+
val size = component.size - start
112+
if (size !in 1..COORDINATE_SIZE) throw invalidSignature()
113+
return ByteArray(COORDINATE_SIZE).also { component.copyInto(it, COORDINATE_SIZE - size, start) }
114+
}
115+
116+
private fun invalidSignature() =
117+
BiometricsError(BiometricsErrorCode.SIGNING_FAILED, "Android Keystore returned an invalid ES256 signature.")
118+
119+
private class DerReader(private val bytes: ByteArray) {
120+
private var offset = 0
121+
122+
val remaining: Int
123+
get() = bytes.size - offset
124+
125+
fun readByte(): Int {
126+
if (offset >= bytes.size) throw invalidSignature()
127+
return bytes[offset++].toInt() and 0xFF
128+
}
129+
130+
fun readLength(): Int {
131+
val first = readByte()
132+
if (first and 0x80 == 0) return first
133+
val byteCount = first and 0x7F
134+
if (byteCount == 0 || byteCount > Int.SIZE_BYTES || byteCount > remaining) throw invalidSignature()
135+
var length = 0
136+
repeat(byteCount) { length = (length shl 8) or readByte() }
137+
return length
138+
}
139+
140+
fun readInteger(): ByteArray {
141+
if (readByte() != 0x02) throw invalidSignature()
142+
val length = readLength()
143+
if (length <= 0 || length > remaining) throw invalidSignature()
144+
return bytes.copyOfRange(offset, offset + length).also { offset += length }
145+
}
146+
}
147+
}

0 commit comments

Comments
 (0)