|
| 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