@algorandfoundation/react-native-passkey-autofill 1.0.0-canary.2 → 1.0.0-canary.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/.nvmrc +1 -0
  2. package/.oxfmtrc.json +4 -0
  3. package/.oxlintrc.json +14 -0
  4. package/.releaserc +30 -0
  5. package/ARCHITECTURE.md +8 -0
  6. package/CHANGELOG.md +115 -0
  7. package/CONTRIBUTING.md +15 -5
  8. package/README.md +150 -11
  9. package/android/build.gradle +33 -10
  10. package/android/libs/repo/co/algorand/dP256Android/1.0.0/dP256Android-1.0.0.pom +9 -0
  11. package/android/src/main/AndroidManifest.xml +9 -3
  12. package/android/src/main/java/co/algorand/passkeyautofill/CreatePasskeyActivity.kt +396 -44
  13. package/android/src/main/java/co/algorand/passkeyautofill/GetPasskeyActivity.kt +583 -44
  14. package/android/src/main/java/co/algorand/passkeyautofill/Prf.kt +184 -0
  15. package/android/src/main/java/co/algorand/passkeyautofill/ReactNativePasskeyAutofillModule.kt +190 -2
  16. package/android/src/main/java/co/algorand/passkeyautofill/WebAuthn.kt +135 -0
  17. package/android/src/main/java/co/algorand/passkeyautofill/auth/BiometricRequirement.kt +86 -0
  18. package/android/src/main/java/co/algorand/passkeyautofill/credentials/Credential.kt +2 -1
  19. package/android/src/main/java/co/algorand/passkeyautofill/credentials/CredentialRepository.kt +314 -53
  20. package/android/src/main/java/co/algorand/passkeyautofill/service/PasskeyAutofillCredentialProviderService.kt +152 -38
  21. package/android/src/main/java/co/algorand/passkeyautofill/utils/PasskeyUtils.kt +119 -0
  22. package/android/src/main/res/values/styles.xml +10 -0
  23. package/app.plugin.js +593 -60
  24. package/assets/banner.png +0 -0
  25. package/build/ReactNativePasskeyAutofill.types.d.ts +41 -1
  26. package/build/ReactNativePasskeyAutofill.types.d.ts.map +1 -1
  27. package/build/ReactNativePasskeyAutofill.types.js +3 -1
  28. package/build/ReactNativePasskeyAutofill.types.js.map +1 -1
  29. package/build/ReactNativePasskeyAutofillModule.d.ts +41 -4
  30. package/build/ReactNativePasskeyAutofillModule.d.ts.map +1 -1
  31. package/build/ReactNativePasskeyAutofillModule.js +21 -2
  32. package/build/ReactNativePasskeyAutofillModule.js.map +1 -1
  33. package/build/index.d.ts +2 -2
  34. package/build/index.d.ts.map +1 -1
  35. package/build/index.js +2 -2
  36. package/build/index.js.map +1 -1
  37. package/e2e/README.md +155 -0
  38. package/e2e/driver/adb.ts +73 -0
  39. package/e2e/driver/capabilities.ts +106 -0
  40. package/e2e/driver/credentialProvider.ts +104 -0
  41. package/e2e/driver/fingerprint.ts +885 -0
  42. package/e2e/driver/selectors.ts +103 -0
  43. package/e2e/driver/session.ts +25 -0
  44. package/e2e/jest.config.ts +20 -0
  45. package/e2e/package.json +11 -0
  46. package/e2e/tests/passkey.test.ts +190 -0
  47. package/e2e/tsconfig.json +16 -0
  48. package/ios/AutofillCredentialProvider/BiometricRequirement.swift +34 -0
  49. package/ios/AutofillCredentialProvider/CredentialProviderViewController.swift +570 -0
  50. package/ios/AutofillCredentialProvider/PasskeyAutofillCredentialProvider-Bridging-Header.h +1 -0
  51. package/ios/AutofillCredentialProvider/PasskeyCredentialStore.swift +612 -0
  52. package/ios/AutofillCredentialProvider/PasskeyKeystoreMMKV.h +25 -0
  53. package/ios/AutofillCredentialProvider/PasskeyKeystoreMMKV.mm +86 -0
  54. package/ios/AutofillCredentialProvider/Prf.swift +89 -0
  55. package/ios/AutofillCredentialProvider/WebAuthn.swift +186 -0
  56. package/ios/ReactNativePasskeyAutofill.podspec +11 -2
  57. package/ios/ReactNativePasskeyAutofillModule.swift +170 -15
  58. package/lefthook.yml +14 -0
  59. package/package.json +115 -25
  60. package/pnpm-workspace.yaml +36 -0
  61. package/src/ReactNativePasskeyAutofill.types.ts +38 -0
  62. package/src/ReactNativePasskeyAutofillModule.ts +66 -3
  63. package/src/index.ts +2 -2
  64. package/tsconfig.json +2 -2
  65. package/.eslintrc.js +0 -5
  66. /package/android/libs/{dP256Android-release.aar → repo/co/algorand/dP256Android/1.0.0/dP256Android-1.0.0.aar} +0 -0
@@ -22,17 +22,18 @@ import org.json.JSONObject
22
22
  import android.util.Base64 as AndroidBase64
23
23
  import java.io.ByteArrayOutputStream
24
24
  import java.security.SecureRandom
25
+ import co.algorand.passkeyautofill.auth.BiometricRequirement
25
26
 
26
27
 
27
28
  interface CredentialRepository {
28
29
  val keyStore: KeyStore
29
- fun saveCredential(context: Context, credential: Credential)
30
+ fun saveCredential(context: Context, credential: Credential, biometricCipher: Cipher? = null)
30
31
  fun generateCredentialId(keyPair: KeyPair): ByteArray
31
- fun getKeyPair(context: Context, credentialId: ByteArray): KeyPair?
32
- fun createDeterministicKeyPair(context: Context, origin: String, userHandle: String): KeyPair
32
+ fun getKeyPair(context: Context, credentialId: ByteArray, biometricCipher: Cipher? = null): KeyPair?
33
+ fun createDeterministicKeyPair(context: Context, origin: String, userHandle: String, biometricCipher: Cipher? = null): KeyPair
33
34
  fun getOrigin(info: CallingAppInfo): String
34
35
  fun appInfoToOrigin(info: CallingAppInfo): String
35
- fun getCredential(context: Context, credentialId: ByteArray): Credential?
36
+ fun getCredential(context: Context, credentialId: ByteArray, biometricCipher: Cipher? = null): Credential?
36
37
  fun getCredentialByOrigin(context: Context, origin: String): Credential?
37
38
  fun getAllCredentials(context: Context): List<Credential>
38
39
  fun getPublicKeyFromKeyPair(keyPair: KeyPair?): ByteArray
@@ -45,6 +46,18 @@ interface CredentialRepository {
45
46
  fun getCreatePasskeyAction(context: Context): String?
46
47
  fun getGetPasskeyAction(context: Context): String?
47
48
  fun clearCredentials(context: Context)
49
+ fun deleteCredential(context: Context, credentialId: String)
50
+ fun recordCredentialUsage(context: Context, credentialId: ByteArray)
51
+
52
+ fun getBiometricCipherForEncryption(context: Context, requirement: BiometricRequirement): Cipher
53
+ fun getBiometricCipherForDecryption(context: Context, iv: ByteArray, requirement: BiometricRequirement): Cipher
54
+
55
+ /**
56
+ * Returns the raw HD root secret (seed bytes) that is used to derive
57
+ * domain-specific signing keys. Required by the PRF extension to
58
+ * deterministically derive the per-credential `credRandom` secret.
59
+ */
60
+ fun getHdRootSecret(context: Context): ByteArray?
48
61
 
49
62
  companion object {
50
63
  const val TAG = "CredentialRepository"
@@ -53,9 +66,20 @@ interface CredentialRepository {
53
66
  const val GET_PASSKEY_ACTION_KEY = "get_passkey_action"
54
67
  const val CREATE_PASSKEY_ACTION_KEY = "create_passkey_action"
55
68
  const val MASTER_KEY_ALIAS = "co.algorand.passkeyautofill.masterkey"
69
+ const val BIOMETRIC_KEY_ALIAS = "co.algorand.passkeyautofill.biometric.v2"
56
70
  const val KEYCHAIN_STORAGE_NAME = "PasskeyAutofillKeychain"
57
71
  const val PASSKEY_AUTOFILL_MMKV_ID = "passkey_autofill"
58
72
  const val HD_ROOT_KEY_ID_KEY = "hd_root_key_id"
73
+ const val BIOMETRIC_KEY_LEVEL_KEY = "biometric_key_level"
74
+
75
+ /**
76
+ * JCE provider that exposes AndroidKeyStore-backed symmetric Cipher
77
+ * transformations (including AES/GCM). Pinning to this provider avoids
78
+ * BouncyCastle — which this module installs at position 1 — trying to
79
+ * extract raw bytes from a hardware-bound SecretKey and crashing with
80
+ * NullPointerException in `KeyParameter.<init>`.
81
+ */
82
+ const val ANDROID_KEYSTORE_CIPHER_PROVIDER = "AndroidKeyStoreBCWorkaround"
59
83
  }
60
84
  }
61
85
 
@@ -80,7 +104,8 @@ class Repository() : CredentialRepository {
80
104
  return MMKV.mmkvWithID(CredentialRepository.PASSKEY_AUTOFILL_MMKV_ID, MMKV.MULTI_PROCESS_MODE)
81
105
  }
82
106
 
83
- override fun saveCredential(context: Context, credential: Credential) {
107
+ override fun saveCredential(context: Context, credential: Credential, biometricCipher: Cipher?) {
108
+ Log.d(CredentialRepository.TAG, "saveCredential started for ${credential.origin}, userHandle: ${credential.userHandle}")
84
109
  val mmkv = getPasskeysMMKV(context)
85
110
 
86
111
  // 1. Create KeyData matching @algorandfoundation/keystore
@@ -91,7 +116,20 @@ class Repository() : CredentialRepository {
91
116
  keyData.put("extractable", false)
92
117
  keyData.put("keyUsages", JSONArray(listOf("sign")))
93
118
  keyData.put("name", "Passkey: ${credential.origin}")
94
- keyData.put("privateKey", JSONArray(AndroidBase64.decode(credential.privateKey, AndroidBase64.DEFAULT).map { it.toInt() and 0xFF }))
119
+
120
+ val privateKeyBytes = AndroidBase64.decode(credential.privateKey, AndroidBase64.DEFAULT)
121
+ if (biometricCipher != null) {
122
+ Log.d(CredentialRepository.TAG, "Using biometricCipher for encryption")
123
+ val encryptedBytes = biometricCipher.doFinal(privateKeyBytes)
124
+ val encJson = JSONObject()
125
+ encJson.put("iv", AndroidBase64.encodeToString(biometricCipher.iv, AndroidBase64.NO_WRAP))
126
+ encJson.put("data", AndroidBase64.encodeToString(encryptedBytes, AndroidBase64.NO_WRAP))
127
+ keyData.put("privateKeyEnc", encJson)
128
+ } else {
129
+ Log.d(CredentialRepository.TAG, "No biometricCipher, saving privateKey in plain (base64 in JSON)")
130
+ keyData.put("privateKey", JSONArray(privateKeyBytes.map { it.toInt() and 0xFF }))
131
+ }
132
+
95
133
  keyData.put("publicKey", JSONArray(AndroidBase64.decode(credential.publicKey, AndroidBase64.DEFAULT).map { it.toInt() and 0xFF }))
96
134
 
97
135
  // Custom fields for our use
@@ -128,17 +166,24 @@ class Repository() : CredentialRepository {
128
166
  val payload = mmkv.decodeString(key) ?: continue
129
167
  try {
130
168
  val json = decodeKeyData(payload, masterKey)
131
- // Basic validation to ensure it's a credential
169
+ // Only treat entries as passkey credentials that match p256
170
+ val keyType = json.optString("type", "")
171
+ if (keyType != "hd-derived-p256" && keyType != "xhd-derived-p256") {
172
+ continue
173
+ }
132
174
  if (json.has("id") && (json.has("origin") || json.has("metadata"))) {
133
175
  val metadata = json.optJSONObject("metadata")
176
+ val encJson = json.optJSONObject("privateKeyEnc")
177
+
134
178
  credentials.add(Credential(
135
179
  credentialId = json.getString("id"),
136
180
  origin = metadata?.optString("origin") ?: json.optString("origin", ""),
137
181
  userHandle = metadata?.optString("userHandle") ?: json.optString("userHandle", ""),
138
182
  userId = metadata?.optString("userId") ?: json.optString("userId", ""),
139
183
  publicKey = AndroidBase64.encodeToString(jsonArrayToByteArray(json.getJSONArray("publicKey")), AndroidBase64.DEFAULT),
140
- privateKey = AndroidBase64.encodeToString(jsonArrayToByteArray(json.getJSONArray("privateKey")), AndroidBase64.DEFAULT),
141
- count = metadata?.optInt("count") ?: json.optInt("count", 0)
184
+ privateKey = if (json.has("privateKey")) AndroidBase64.encodeToString(jsonArrayToByteArray(json.getJSONArray("privateKey")), AndroidBase64.DEFAULT) else "",
185
+ count = metadata?.optInt("count") ?: json.optInt("count", 0),
186
+ biometricIv = encJson?.optString("iv")
142
187
  ))
143
188
  }
144
189
  } catch (e: Exception) {
@@ -163,22 +208,45 @@ class Repository() : CredentialRepository {
163
208
  return messageDigest.digest(publicKeyBytes)
164
209
  }
165
210
 
166
- override fun getCredential(context: Context, credentialId: ByteArray): Credential? {
211
+ override fun getCredential(context: Context, credentialId: ByteArray, biometricCipher: Cipher?): Credential? {
167
212
  val id = AndroidBase64.encodeToString(credentialId, AndroidBase64.DEFAULT).trim()
213
+ Log.d(CredentialRepository.TAG, "getCredential started for id: $id")
168
214
  val mmkv = getPasskeysMMKV(context)
169
- val payload = mmkv.decodeString(id) ?: return null
170
- val masterKey = getMasterKey(context) ?: return null
215
+ val payload = mmkv.decodeString(id) ?: run {
216
+ Log.w(CredentialRepository.TAG, "No payload found for id: $id")
217
+ return null
218
+ }
219
+ val masterKey = getMasterKey(context) ?: run {
220
+ Log.e(CredentialRepository.TAG, "Master key not found")
221
+ return null
222
+ }
171
223
  return try {
172
224
  val json = decodeKeyData(payload, masterKey)
173
225
  val metadata = json.optJSONObject("metadata")
226
+ val encJson = json.optJSONObject("privateKeyEnc")
227
+
228
+ val privateKey = if (encJson != null && biometricCipher != null) {
229
+ Log.d(CredentialRepository.TAG, "Decrypting privateKey with biometricCipher")
230
+ val data = AndroidBase64.decode(encJson.getString("data"), AndroidBase64.DEFAULT)
231
+ val decrypted = biometricCipher.doFinal(data)
232
+ AndroidBase64.encodeToString(decrypted, AndroidBase64.DEFAULT)
233
+ } else if (json.has("privateKey")) {
234
+ Log.d(CredentialRepository.TAG, "Using plain privateKey from JSON")
235
+ AndroidBase64.encodeToString(jsonArrayToByteArray(json.getJSONArray("privateKey")), AndroidBase64.DEFAULT)
236
+ } else {
237
+ Log.w(CredentialRepository.TAG, "No privateKey found in JSON")
238
+ ""
239
+ }
240
+
174
241
  Credential(
175
242
  credentialId = json.getString("id"),
176
243
  origin = metadata?.optString("origin") ?: json.optString("origin", ""),
177
244
  userHandle = metadata?.optString("userHandle") ?: json.optString("userHandle", ""),
178
245
  userId = metadata?.optString("userId") ?: json.optString("userId", ""),
179
246
  publicKey = AndroidBase64.encodeToString(jsonArrayToByteArray(json.getJSONArray("publicKey")), AndroidBase64.DEFAULT),
180
- privateKey = AndroidBase64.encodeToString(jsonArrayToByteArray(json.getJSONArray("privateKey")), AndroidBase64.DEFAULT),
181
- count = metadata?.optInt("count") ?: json.optInt("count", 0)
247
+ privateKey = privateKey,
248
+ count = metadata?.optInt("count") ?: json.optInt("count", 0),
249
+ biometricIv = encJson?.optString("iv")
182
250
  )
183
251
  } catch (e: Exception) {
184
252
  null
@@ -190,63 +258,129 @@ class Repository() : CredentialRepository {
190
258
  }
191
259
 
192
260
  fun getKeyPairFromCredential(credential: Credential): KeyPair? {
261
+ if (credential.privateKey.isEmpty()) return null
193
262
  val publicKeyBytes = AndroidBase64.decode(credential.publicKey, AndroidBase64.DEFAULT)
194
263
  val privateKeyBytes = AndroidBase64.decode(credential.privateKey, AndroidBase64.DEFAULT)
195
- val factory = KeyFactory.getInstance("EC")
196
- val publicKey = factory.generatePublic(X509EncodedKeySpec(publicKeyBytes))
197
- val privateKey = factory.generatePrivate(PKCS8EncodedKeySpec(privateKeyBytes))
198
- return KeyPair(publicKey, privateKey)
264
+
265
+ return try {
266
+ // First try DER-encoded format (X509/PKCS8)
267
+ val factory = KeyFactory.getInstance("EC")
268
+ val publicKey = factory.generatePublic(X509EncodedKeySpec(publicKeyBytes))
269
+ val privateKey = factory.generatePrivate(PKCS8EncodedKeySpec(privateKeyBytes))
270
+ KeyPair(publicKey, privateKey)
271
+ } catch (e: Exception) {
272
+ // Fall back to raw EC bytes
273
+ try {
274
+ // Get the P-256 curve parameters
275
+ val ecGenSpec = ECGenParameterSpec("secp256r1")
276
+ val keyPairGen = KeyPairGenerator.getInstance("EC")
277
+ keyPairGen.initialize(ecGenSpec)
278
+ val paramSpec = (keyPairGen.generateKeyPair().public as java.security.interfaces.ECPublicKey).params
279
+ val factory = KeyFactory.getInstance("EC")
280
+
281
+ val publicKeyFromX509 = try {
282
+ factory.generatePublic(X509EncodedKeySpec(publicKeyBytes))
283
+ } catch (_: Exception) {
284
+ null
285
+ }
286
+
287
+ if (publicKeyFromX509 != null && privateKeyBytes.size == 32) {
288
+ val s = java.math.BigInteger(1, privateKeyBytes)
289
+ val privSpec = ECPrivateKeySpec(s, paramSpec)
290
+ val privateKey = factory.generatePrivate(privSpec)
291
+ return KeyPair(publicKeyFromX509, privateKey)
292
+ }
293
+
294
+ // privateKey is 32-byte scalar
295
+ val rawXY: Pair<ByteArray, ByteArray>? = when {
296
+ // 65-byte uncompressed point: 04 || x(32) || y(32)
297
+ publicKeyBytes.size == 65 && publicKeyBytes[0] == 0x04.toByte() ->
298
+ Pair(publicKeyBytes.copyOfRange(1, 33), publicKeyBytes.copyOfRange(33, 65))
299
+ // 64-byte raw x || y (no prefix)
300
+ publicKeyBytes.size == 64 ->
301
+ Pair(publicKeyBytes.copyOfRange(0, 32), publicKeyBytes.copyOfRange(32, 64))
302
+ else -> null
303
+ }
304
+
305
+ if (rawXY != null) {
306
+ val x = java.math.BigInteger(1, rawXY.first)
307
+ val y = java.math.BigInteger(1, rawXY.second)
308
+ val pubSpec = ECPublicKeySpec(ECPoint(x, y), paramSpec)
309
+ val publicKey = factory.generatePublic(pubSpec)
310
+
311
+ val s = java.math.BigInteger(1, privateKeyBytes)
312
+ val privSpec = ECPrivateKeySpec(s, paramSpec)
313
+ val privateKey = factory.generatePrivate(privSpec)
314
+
315
+ KeyPair(publicKey, privateKey)
316
+ } else {
317
+ Log.e(CredentialRepository.TAG, "Unrecognized key format: publicKey size=${publicKeyBytes.size}", e)
318
+ null
319
+ }
320
+ } catch (e2: Exception) {
321
+ Log.e(CredentialRepository.TAG, "Failed to restore key from raw bytes", e2)
322
+ null
323
+ }
324
+ }
199
325
  }
200
326
 
201
- override fun getKeyPair(context: Context, credentialId: ByteArray): KeyPair? {
202
- val credential = getCredential(context, credentialId)
327
+ override fun getKeyPair(context: Context, credentialId: ByteArray, biometricCipher: Cipher?): KeyPair? {
328
+ val credential = getCredential(context, credentialId, biometricCipher)
203
329
  return credential?.let { getKeyPairFromCredential(it) }
204
330
  }
205
331
 
206
332
  override fun createDeterministicKeyPair(
207
333
  context: Context,
208
334
  origin: String,
209
- userHandle: String
335
+ userHandle: String,
336
+ biometricCipher: Cipher?
210
337
  ): KeyPair {
211
- val masterKey = getMasterKey(context) ?: throw IllegalStateException("Master key not found in Keystore. Ensure you have called setMasterKey(key) from JavaScript.")
212
-
338
+ Log.d(CredentialRepository.TAG, "createDeterministicKeyPair for origin: $origin, userHandle: $userHandle")
339
+ val derivedParentSecret = getHdRootSecret(context)
340
+ ?: throw IllegalStateException("HD Root Key not available. Ensure setMasterKey(key) and setHdRootKeyId(id) have been called.")
341
+ return dP256.genDomainSpecificKeypair(derivedParentSecret, origin, userHandle.lowercase())
342
+ }
343
+
344
+ override fun getHdRootSecret(context: Context): ByteArray? {
345
+ val masterKey = getMasterKey(context) ?: return null
213
346
  val mmkvAutofill = getAutofillMMKV(context)
214
- val hdRootKeyId = mmkvAutofill.decodeString(CredentialRepository.HD_ROOT_KEY_ID_KEY) ?: throw IllegalStateException("HD Root Key ID not found. Ensure you have called setHdRootKeyId(id) from JavaScript.")
215
-
347
+ val hdRootKeyId = mmkvAutofill.decodeString(CredentialRepository.HD_ROOT_KEY_ID_KEY) ?: return null
216
348
  val mmkvKeystore = getPasskeysMMKV(context)
217
- val hdRootKeyPayload = mmkvKeystore.decodeString(hdRootKeyId) ?: throw IllegalStateException("HD Root Key not found in keystore for ID: $hdRootKeyId")
218
-
219
- val hdRootKeyData = decodeKeyData(hdRootKeyPayload, masterKey)
220
-
349
+ val hdRootKeyPayload = mmkvKeystore.decodeString(hdRootKeyId) ?: return null
350
+ val hdRootKeyData = try {
351
+ decodeKeyData(hdRootKeyPayload, masterKey)
352
+ } catch (e: Exception) {
353
+ Log.w(CredentialRepository.TAG, "Failed to decode HD root key", e)
354
+ return null
355
+ }
356
+
221
357
  // In react-native-keystore, the private key/seed are stored as arrays of numbers in JSON
222
358
  // due to how JSON.stringify handles Uint8Array.
223
- // Our decode method in decryptHdRootKey might have already handled it if it matched react-native-keystore's decode.
224
-
225
359
  val seedArray = hdRootKeyData.optJSONArray("seed") ?: hdRootKeyData.optJSONArray("privateKey")
226
- val derivedParentSecret = if (seedArray != null) {
360
+ if (seedArray != null) {
227
361
  val bytes = ByteArray(seedArray.length())
228
362
  for (i in 0 until seedArray.length()) {
229
363
  bytes[i] = seedArray.getInt(i).toByte()
230
364
  }
231
- bytes
365
+ return bytes
366
+ }
367
+
368
+ val seed = (if (hdRootKeyData.has("seed")) hdRootKeyData.getString("seed") else null)
369
+ ?: (if (hdRootKeyData.has("privateKey")) hdRootKeyData.getString("privateKey") else null)
370
+ ?: return null
371
+ return if (seed.startsWith("0x")) {
372
+ hexToBytes(seed.substring(2))
232
373
  } else {
233
- val seed = (if (hdRootKeyData.has("seed")) hdRootKeyData.getString("seed") else null)
234
- ?: (if (hdRootKeyData.has("privateKey")) hdRootKeyData.getString("privateKey") else null)
235
- ?: throw IllegalStateException("HD Root Key does not contain a seed or privateKey")
236
-
237
- if (seed.startsWith("0x")) {
238
- hexToBytes(seed.substring(2))
239
- } else {
240
- // It might be base64url encoded or just hex
374
+ try {
375
+ hexToBytes(seed)
376
+ } catch (e: Exception) {
241
377
  try {
242
- hexToBytes(seed)
243
- } catch (e: Exception) {
244
378
  AndroidBase64.decode(seed, AndroidBase64.URL_SAFE or AndroidBase64.NO_WRAP)
379
+ } catch (e2: Exception) {
380
+ null
245
381
  }
246
382
  }
247
383
  }
248
-
249
- return dP256.genDomainSpecificKeypair(derivedParentSecret, origin, userHandle.lowercase())
250
384
  }
251
385
 
252
386
  private fun encryptData(key: ByteArray, data: String): String {
@@ -441,6 +575,59 @@ class Repository() : CredentialRepository {
441
575
  }
442
576
  }
443
577
 
578
+ override fun deleteCredential(context: Context, credentialId: String) {
579
+ try {
580
+ val mmkvPasskeys = getPasskeysMMKV(context)
581
+ for (candidateId in credentialIdCandidates(credentialId)) {
582
+ mmkvPasskeys.removeValueForKey(candidateId)
583
+ }
584
+ } catch (e: Exception) {
585
+ Log.e(CredentialRepository.TAG, "Error deleting credential", e)
586
+ }
587
+ }
588
+
589
+ override fun recordCredentialUsage(context: Context, credentialId: ByteArray) {
590
+ val id = AndroidBase64.encodeToString(credentialId, AndroidBase64.DEFAULT).trim()
591
+ try {
592
+ val mmkv = getPasskeysMMKV(context)
593
+ val payload = mmkv.decodeString(id) ?: return
594
+ val masterKey = getMasterKey(context) ?: return
595
+
596
+ val json = decodeKeyData(payload, masterKey)
597
+ val metadata = json.optJSONObject("metadata") ?: JSONObject()
598
+ metadata.put("lastUsedAt", System.currentTimeMillis())
599
+ metadata.put("count", metadata.optInt("count", 0) + 1)
600
+ json.put("metadata", metadata)
601
+
602
+ val base64urlJson = AndroidBase64.encodeToString(
603
+ json.toString().toByteArray(Charsets.UTF_8),
604
+ AndroidBase64.URL_SAFE or AndroidBase64.NO_WRAP,
605
+ )
606
+ mmkv.encode(id, encryptData(masterKey, base64urlJson))
607
+ } catch (e: Exception) {
608
+ Log.e(CredentialRepository.TAG, "Failed to record credential usage", e)
609
+ }
610
+ }
611
+
612
+ private fun credentialIdCandidates(id: String): Set<String> {
613
+ val candidates = mutableSetOf(id)
614
+ val decoded = try {
615
+ AndroidBase64.decode(id, AndroidBase64.URL_SAFE or AndroidBase64.NO_WRAP)
616
+ } catch (_: Exception) {
617
+ try {
618
+ AndroidBase64.decode(id, AndroidBase64.DEFAULT)
619
+ } catch (_: Exception) {
620
+ null
621
+ }
622
+ }
623
+
624
+ if (decoded != null) {
625
+ candidates.add(AndroidBase64.encodeToString(decoded, AndroidBase64.DEFAULT).trim())
626
+ candidates.add(AndroidBase64.encodeToString(decoded, AndroidBase64.URL_SAFE or AndroidBase64.NO_WRAP or AndroidBase64.NO_PADDING))
627
+ }
628
+ return candidates
629
+ }
630
+
444
631
  private fun hexToBytes(hex: String): ByteArray {
445
632
  val result = ByteArray(hex.length / 2)
446
633
  for (i in 0 until hex.length step 2) {
@@ -456,6 +643,86 @@ class Repository() : CredentialRepository {
456
643
  return bytes.joinToString("") { "%02x".format(it) }
457
644
  }
458
645
 
646
+ private fun getBiometricSecretKey(context: Context, requirement: BiometricRequirement): SecretKey {
647
+ // The key's auth binding is fixed at creation. If a new build changed the configured
648
+ // level, regenerate the key so the prompt's allowed authenticators stay compatible.
649
+ // Passkey private keys are deterministically re-derivable, so this is recoverable.
650
+ val mmkv = getAutofillMMKV(context)
651
+ val storedLevel = mmkv.decodeString(CredentialRepository.BIOMETRIC_KEY_LEVEL_KEY)
652
+ if (keyStore.containsAlias(CredentialRepository.BIOMETRIC_KEY_ALIAS) &&
653
+ storedLevel != requirement.name
654
+ ) {
655
+ Log.i(CredentialRepository.TAG, "Biometric requirement changed ($storedLevel -> ${requirement.name}); regenerating key")
656
+ keyStore.deleteEntry(CredentialRepository.BIOMETRIC_KEY_ALIAS)
657
+ }
658
+
659
+ if (!keyStore.containsAlias(CredentialRepository.BIOMETRIC_KEY_ALIAS)) {
660
+ val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
661
+ val builder = KeyGenParameterSpec.Builder(
662
+ CredentialRepository.BIOMETRIC_KEY_ALIAS,
663
+ KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
664
+ )
665
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
666
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
667
+ .setKeySize(256)
668
+ .setUserAuthenticationRequired(requirement.isCryptoBound)
669
+
670
+ // Biometric-enrollment invalidation and auth params only apply to user-auth-bound keys.
671
+ if (requirement.isCryptoBound) {
672
+ builder.setInvalidatedByBiometricEnrollment(true)
673
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
674
+ builder.setUserAuthenticationParameters(60, requirement.keystoreAuthType)
675
+ } else {
676
+ @Suppress("DEPRECATION")
677
+ builder.setUserAuthenticationValidityDurationSeconds(60)
678
+ }
679
+ }
680
+
681
+ keyGenerator.init(builder.build())
682
+ keyGenerator.generateKey()
683
+ mmkv.encode(CredentialRepository.BIOMETRIC_KEY_LEVEL_KEY, requirement.name)
684
+ }
685
+ return keyStore.getKey(CredentialRepository.BIOMETRIC_KEY_ALIAS, null) as SecretKey
686
+ }
687
+
688
+ /**
689
+ * Build an AES/GCM Cipher tied to the AndroidKeyStore-backed biometric key.
690
+ *
691
+ * We request the provider explicitly because this module installs
692
+ * BouncyCastle at position 1 (see [co.algorand.passkeyautofill.ReactNativePasskeyAutofillModule]).
693
+ * Without a pinned provider, [Cipher.getInstance] resolves to BouncyCastle, which
694
+ * then tries to extract raw key material from an AndroidKeyStore SecretKey
695
+ * (whose `getEncoded()` is null for hardware-bound keys) and crashes with:
696
+ *
697
+ * java.lang.NullPointerException: Attempt to get length of null array
698
+ * at org.bouncycastle.crypto.params.KeyParameter.<init>(...)
699
+ *
700
+ * AndroidKeyStore AES keys are served by the "AndroidKeyStoreBCWorkaround"
701
+ * JCE provider on all supported Android versions.
702
+ */
703
+ private fun newAndroidKeyStoreAesGcmCipher(): Cipher {
704
+ return try {
705
+ Cipher.getInstance("AES/GCM/NoPadding", CredentialRepository.ANDROID_KEYSTORE_CIPHER_PROVIDER)
706
+ } catch (e: NoSuchProviderException) {
707
+ // Extremely unlikely on stock Android, but fall back to provider discovery
708
+ // by key rather than by name so we never hand the key to BouncyCastle.
709
+ Log.w(CredentialRepository.TAG, "${CredentialRepository.ANDROID_KEYSTORE_CIPHER_PROVIDER} unavailable, falling back to default provider resolution", e)
710
+ Cipher.getInstance("AES/GCM/NoPadding")
711
+ }
712
+ }
713
+
714
+ override fun getBiometricCipherForEncryption(context: Context, requirement: BiometricRequirement): Cipher {
715
+ val cipher = newAndroidKeyStoreAesGcmCipher()
716
+ cipher.init(Cipher.ENCRYPT_MODE, getBiometricSecretKey(context, requirement))
717
+ return cipher
718
+ }
719
+
720
+ override fun getBiometricCipherForDecryption(context: Context, iv: ByteArray, requirement: BiometricRequirement): Cipher {
721
+ val cipher = newAndroidKeyStoreAesGcmCipher()
722
+ cipher.init(Cipher.DECRYPT_MODE, getBiometricSecretKey(context, requirement), GCMParameterSpec(128, iv))
723
+ return cipher
724
+ }
725
+
459
726
  override fun sign(keyPair: KeyPair, payload: ByteArray): ByteArray {
460
727
  return dP256.signWithDomainSpecificKeyPair(keyPair, payload)
461
728
  }
@@ -487,13 +754,7 @@ class Repository() : CredentialRepository {
487
754
 
488
755
  @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
489
756
  override fun getOrigin(info: CallingAppInfo): String {
490
- return try {
491
- info.origin ?: appInfoToOrigin(info)
492
- } catch (e: NoSuchMethodError) {
493
- appInfoToOrigin(info)
494
- } catch (e: Exception) {
495
- appInfoToOrigin(info)
496
- }
757
+ return appInfoToOrigin(info)
497
758
  }
498
759
 
499
760
  @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
@@ -503,4 +764,4 @@ class Repository() : CredentialRepository {
503
764
  val certHash = md.digest(cert)
504
765
  return "android:apk-key-hash:${AndroidBase64.encodeToString(certHash, AndroidBase64.URL_SAFE or AndroidBase64.NO_WRAP or AndroidBase64.NO_PADDING)}"
505
766
  }
506
- }
767
+ }