@algorandfoundation/react-native-passkey-autofill 1.0.0-canary.16 → 1.0.0-canary.17

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 (25) hide show
  1. package/CHANGELOG.md +16 -3
  2. package/README.md +17 -0
  3. package/android/build.gradle +3 -0
  4. package/android/src/main/java/co/algorand/passkeyautofill/CreatePasskeyActivity.kt +22 -8
  5. package/android/src/main/java/co/algorand/passkeyautofill/GetPasskeyActivity.kt +138 -12
  6. package/android/src/main/java/co/algorand/passkeyautofill/ReactNativePasskeyAutofillModule.kt +49 -0
  7. package/android/src/main/java/co/algorand/passkeyautofill/WebAuthn.kt +135 -0
  8. package/android/src/main/java/co/algorand/passkeyautofill/auth/BiometricRequirement.kt +86 -0
  9. package/android/src/main/java/co/algorand/passkeyautofill/credentials/CredentialRepository.kt +59 -18
  10. package/android/src/main/java/co/algorand/passkeyautofill/service/PasskeyAutofillCredentialProviderService.kt +27 -23
  11. package/app.plugin.js +102 -25
  12. package/build/ReactNativePasskeyAutofill.types.d.ts +2 -0
  13. package/build/ReactNativePasskeyAutofill.types.d.ts.map +1 -1
  14. package/build/ReactNativePasskeyAutofill.types.js.map +1 -1
  15. package/e2e/driver/capabilities.ts +3 -0
  16. package/e2e/driver/credentialProvider.ts +1 -1
  17. package/e2e/driver/fingerprint.ts +1 -1
  18. package/e2e/tests/passkey.test.ts +0 -1
  19. package/ios/AutofillCredentialProvider/BiometricRequirement.swift +34 -0
  20. package/ios/AutofillCredentialProvider/CredentialProviderViewController.swift +4 -2
  21. package/ios/AutofillCredentialProvider/PasskeyCredentialStore.swift +28 -0
  22. package/ios/AutofillCredentialProvider/WebAuthn.swift +14 -1
  23. package/ios/ReactNativePasskeyAutofillModule.swift +4 -0
  24. package/package.json +1 -1
  25. package/src/ReactNativePasskeyAutofill.types.ts +2 -1
package/CHANGELOG.md CHANGED
@@ -1,14 +1,27 @@
1
- # [1.0.0-canary.16](https://github.com/algorandfoundation/react-native-passkey-autofill/compare/v1.0.0-canary.15...v1.0.0-canary.16) (2026-05-15)
1
+ # [1.0.0-canary.17](https://github.com/algorandfoundation/react-native-passkey-autofill/compare/v1.0.0-canary.16...v1.0.0-canary.17) (2026-05-28)
2
2
 
3
3
 
4
4
  ### Bug Fixes
5
5
 
6
- * align ios autofill root key flow ([1287f57](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/1287f57952b41829032ef368bad826624375e9a3))
6
+ * allow configurable AAGUID ([d5a8176](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/d5a81765ba150e9873e18875f0ac9f7bc4aede93))
7
+ * **Android:** add NoOp methods for iOS-specific functionality ([e2c64db](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/e2c64db479cda70c7c61b7ab3021b70db949a15b))
8
+ * compile issue ([16b939c](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/16b939cb555e75bd052ce57a41a838810a2770cd))
9
+
10
+
11
+ ### Features
12
+
13
+ * configurable credential requirement ([dc09916](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/dc09916d1ee5c8c9d33e53b57f00171472f4f665))
14
+ * record lastUsedAt/count and fix error handling ([47b40ab](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/47b40abf692c0f7cffee6f2266c66f61f8006a65))
15
+
16
+ # [1.0.0-canary.16](https://github.com/algorandfoundation/react-native-passkey-autofill/compare/v1.0.0-canary.15...v1.0.0-canary.16) (2026-05-15)
17
+
18
+ ### Bug Fixes
7
19
 
20
+ - align ios autofill root key flow ([1287f57](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/1287f57952b41829032ef368bad826624375e9a3))
8
21
 
9
22
  ### Features
10
23
 
11
- * add iOS passkey autofill provider ([9aad8f8](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/9aad8f8ceb2ad54cf53847b60dd6e70d5b2a1f2b))
24
+ - add iOS passkey autofill provider ([9aad8f8](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/9aad8f8ceb2ad54cf53847b60dd6e70d5b2a1f2b))
12
25
 
13
26
  # [1.0.0-canary.15](https://github.com/algorandfoundation/react-native-passkey-autofill/compare/v1.0.0-canary.14...v1.0.0-canary.15) (2026-04-10)
14
27
 
package/README.md CHANGED
@@ -44,6 +44,23 @@ If you are using Expo, you can configure the plugin in your `app.json` or `app.c
44
44
 
45
45
  - `site`: The URL of your FIDO server (default: `https://debug.liquidauth.com`).
46
46
  - `label`: The name of the credential provider as it appears in Android settings (default: `My Credential Provider`).
47
+ - `aaguid`: Optional authenticator AAGUID (UUID string) embedded in attestation responses to identify your authenticator to relying parties. When omitted, iOS uses the module's built-in default and Android uses the all-zero AAGUID emitted by the platform. Set the same value across all your apps (iOS, Android, web) so they present one identity.
48
+ - `biometricRequirement`: Controls which authenticators satisfy passkey user verification. See [Biometric Requirement](#biometric-requirement) below.
49
+
50
+ #### Biometric Requirement
51
+
52
+ `biometricRequirement` is an optional string property that controls which authenticators are accepted for user verification during passkey creation and authentication.
53
+
54
+ | Value | Android | iOS |
55
+ | ------------------------------ | ---------------------------------------------------------------------------------------- | ----------------------------------- |
56
+ | `strong` | Strong biometric only | Biometrics only (passcode rejected) |
57
+ | `strongOrCredential` (default) | Strong biometric or device PIN/pattern/password | Biometrics or device passcode |
58
+ | `weakOrCredential` | Weak biometric or device credential (key is **not** crypto-bound — a security trade-off) | Biometrics or device passcode |
59
+
60
+ **Notes:**
61
+
62
+ - Android cannot gate a hardware-backed key on weak biometrics, so `weakOrCredential` stores the key without user-authentication binding and uses the biometric prompt as a UI gate only. Use it only when you accept that trade-off.
63
+ - The default `strongOrCredential` is more permissive than the previous strong-only behavior; integrators upgrading will additionally allow the device credential on Android (iOS behavior is unchanged).
47
64
 
48
65
  ### Configure for iOS
49
66
 
@@ -46,6 +46,9 @@ android {
46
46
  }
47
47
  testOptions {
48
48
  unitTests.includeAndroidResources = true
49
+ // Let unmocked android.jar calls (e.g. android.util.Log) return defaults instead of throwing
50
+ // "Stub!" in JVM unit tests.
51
+ unitTests.returnDefaultValues = true
49
52
  }
50
53
  packagingOptions {
51
54
  pickFirst '**/libc++_shared.so'
@@ -22,6 +22,7 @@ import androidx.credentials.provider.ProviderCreateCredentialRequest
22
22
  import androidx.credentials.webauthn.AuthenticatorAttestationResponse
23
23
  import androidx.credentials.webauthn.FidoPublicKeyCredential
24
24
  import androidx.credentials.webauthn.PublicKeyCredentialCreationOptions
25
+ import co.algorand.passkeyautofill.auth.BiometricRequirement
25
26
  import co.algorand.passkeyautofill.credentials.CredentialRepository
26
27
  import co.algorand.passkeyautofill.credentials.Credential
27
28
  import co.algorand.passkeyautofill.utils.PasskeyUtils
@@ -282,7 +283,7 @@ class CreatePasskeyActivity : AppCompatActivity() {
282
283
  val cipherToUse = systemUnlockedCipher ?: run {
283
284
  if (biometricPromptResult != null) {
284
285
  try {
285
- val fallback = credentialRepository.getBiometricCipherForEncryption()
286
+ val fallback = credentialRepository.getBiometricCipherForEncryption(this@CreatePasskeyActivity, BiometricRequirement.resolve(this@CreatePasskeyActivity))
286
287
  Log.i(TAG, "Successfully obtained fallback cipher from repository (Single Tap timeout)")
287
288
  fallback
288
289
  } catch (e: Exception) {
@@ -424,6 +425,15 @@ class CreatePasskeyActivity : AppCompatActivity() {
424
425
  val clientDataJSONb64 = AndroidBase64.encodeToString(clientDataJSONString.toByteArray(), AndroidBase64.URL_SAFE or AndroidBase64.NO_WRAP or AndroidBase64.NO_PADDING)
425
426
  respJson.put("clientDataJSON", clientDataJSONb64)
426
427
 
428
+ WebAuthn.configuredAaguid(this@CreatePasskeyActivity)?.let { aaguid ->
429
+ try {
430
+ val patched = WebAuthn.injectAaguid(respJson.getString("attestationObject"), aaguid)
431
+ respJson.put("attestationObject", patched)
432
+ } catch (e: Exception) {
433
+ Log.w(TAG, "Failed to inject AAGUID into attestationObject", e)
434
+ }
435
+ }
436
+
427
437
  val createResponse = CreatePublicKeyCredentialResponse(fullJson.toString())
428
438
  Log.d(TAG, "CreatePublicKeyCredentialResponse: ${fullJson.toString()}")
429
439
 
@@ -447,6 +457,7 @@ class CreatePasskeyActivity : AppCompatActivity() {
447
457
  }
448
458
 
449
459
  private suspend fun biometrics(needsCipher: Boolean): BiometricPrompt.AuthenticationResult? {
460
+ val requirement = BiometricRequirement.resolve(this)
450
461
  return suspendCoroutine { continuation ->
451
462
  val biometricPrompt = BiometricPrompt(
452
463
  this,
@@ -465,16 +476,19 @@ class CreatePasskeyActivity : AppCompatActivity() {
465
476
  }
466
477
  }
467
478
  )
468
- val promptInfo = BiometricPrompt.PromptInfo.Builder()
479
+ val promptInfoBuilder = BiometricPrompt.PromptInfo.Builder()
469
480
  .setTitle("Create Passkey")
470
481
  .setSubtitle("Authenticate to save your passkey")
471
- .setNegativeButtonText("Cancel")
472
- .setAllowedAuthenticators(androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG)
473
- .build()
474
-
475
- if (needsCipher) {
482
+ .setAllowedAuthenticators(requirement.allowedAuthenticators)
483
+ // A negative button is illegal when DEVICE_CREDENTIAL is allowed; the system shows its own.
484
+ if (!requirement.allowsDeviceCredential) {
485
+ promptInfoBuilder.setNegativeButtonText("Cancel")
486
+ }
487
+ val promptInfo = promptInfoBuilder.build()
488
+
489
+ if (needsCipher && requirement.isCryptoBound) {
476
490
  try {
477
- val cipher = credentialRepository.getBiometricCipherForEncryption()
491
+ val cipher = credentialRepository.getBiometricCipherForEncryption(this, requirement)
478
492
  biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
479
493
  } catch (e: Exception) {
480
494
  Log.e(TAG, "Failed to initialize biometric prompt with cipher", e)
@@ -23,12 +23,14 @@ import androidx.credentials.provider.ProviderGetCredentialRequest
23
23
  import androidx.credentials.webauthn.AuthenticatorAssertionResponse
24
24
  import androidx.credentials.webauthn.FidoPublicKeyCredential
25
25
  import androidx.credentials.webauthn.PublicKeyCredentialRequestOptions
26
+ import co.algorand.passkeyautofill.auth.BiometricRequirement
26
27
  import co.algorand.passkeyautofill.credentials.CredentialRepository
27
28
  import co.algorand.passkeyautofill.credentials.Credential
28
29
  import co.algorand.passkeyautofill.utils.PasskeyUtils
29
30
  import java.security.KeyPair
30
31
  import java.security.MessageDigest
31
32
  import android.util.Base64 as AndroidBase64
33
+ import androidx.biometric.BiometricManager
32
34
  import androidx.biometric.BiometricPrompt
33
35
  import androidx.core.content.ContextCompat
34
36
  import androidx.lifecycle.lifecycleScope
@@ -251,13 +253,115 @@ class GetPasskeyActivity : AppCompatActivity() {
251
253
  layout.addView(cancelButton)
252
254
 
253
255
  setContentView(layout)
254
-
256
+
255
257
  // Ensure the activity is focusable and can receive touches
256
258
  layout.isFocusable = true
257
259
  layout.isFocusableInTouchMode = true
258
260
  layout.requestFocus()
259
261
  }
260
262
 
263
+ private fun setupErrorUI(message: String, allowRetry: Boolean) {
264
+ if (isFinishing || isDestroyed) return
265
+
266
+ val layout = LinearLayout(this).apply {
267
+ val padding = (32 * resources.displayMetrics.density).toInt()
268
+ orientation = LinearLayout.VERTICAL
269
+ setPadding(padding, padding, padding, padding)
270
+ gravity = Gravity.CENTER_HORIZONTAL
271
+ setBackgroundColor(Color.WHITE)
272
+ }
273
+
274
+ try {
275
+ val appInfo = packageManager.getApplicationInfo(packageName, 0)
276
+ val appIcon = packageManager.getApplicationIcon(appInfo)
277
+ val appLabel = packageManager.getApplicationLabel(appInfo)
278
+
279
+ val density = resources.displayMetrics.density
280
+ val iconSize = (48 * density).toInt()
281
+ val iconMargin = (12 * density).toInt()
282
+ val headerPadding = (40 * density).toInt()
283
+
284
+ val header = LinearLayout(this).apply {
285
+ orientation = LinearLayout.HORIZONTAL
286
+ gravity = Gravity.CENTER_VERTICAL
287
+ setPadding(0, 0, 0, headerPadding)
288
+ }
289
+
290
+ val iconView = ImageView(this).apply {
291
+ setImageDrawable(appIcon)
292
+ layoutParams = LinearLayout.LayoutParams(iconSize, iconSize).apply {
293
+ marginEnd = iconMargin
294
+ }
295
+ }
296
+ header.addView(iconView)
297
+
298
+ val providerLabel = TextView(this).apply {
299
+ text = appLabel
300
+ textSize = 16f
301
+ typeface = Typeface.DEFAULT_BOLD
302
+ setTextColor(Color.DKGRAY)
303
+ }
304
+ header.addView(providerLabel)
305
+ layout.addView(header)
306
+ } catch (e: Exception) {
307
+ }
308
+
309
+ val title = TextView(this).apply {
310
+ text = "Couldn't sign in"
311
+ textSize = 28f
312
+ typeface = Typeface.DEFAULT_BOLD
313
+ setPadding(0, 0, 0, (8 * resources.displayMetrics.density).toInt())
314
+ setTextColor(Color.BLACK)
315
+ }
316
+ layout.addView(title)
317
+
318
+ val description = TextView(this).apply {
319
+ text = message
320
+ textSize = 16f
321
+ setPadding(0, 0, 0, (40 * resources.displayMetrics.density).toInt())
322
+ gravity = Gravity.CENTER_HORIZONTAL
323
+ setTextColor(Color.GRAY)
324
+ }
325
+ layout.addView(description)
326
+
327
+ if (allowRetry) {
328
+ val retryButton = Button(this).apply {
329
+ text = "Try Again"
330
+ contentDescription = "get-passkey-retry"
331
+ setOnClickListener {
332
+ handleAssertion()
333
+ }
334
+ layoutParams = LinearLayout.LayoutParams(
335
+ LinearLayout.LayoutParams.MATCH_PARENT,
336
+ LinearLayout.LayoutParams.WRAP_CONTENT
337
+ ).apply {
338
+ setMargins(0, 0, 0, (8 * resources.displayMetrics.density).toInt())
339
+ }
340
+ }
341
+ layout.addView(retryButton)
342
+ }
343
+
344
+ val closeButton = Button(this, null, android.R.attr.borderlessButtonStyle).apply {
345
+ text = "Close"
346
+ contentDescription = "get-passkey-cancel"
347
+ setOnClickListener {
348
+ setResult(RESULT_CANCELED)
349
+ finish()
350
+ }
351
+ layoutParams = LinearLayout.LayoutParams(
352
+ LinearLayout.LayoutParams.MATCH_PARENT,
353
+ LinearLayout.LayoutParams.WRAP_CONTENT
354
+ )
355
+ }
356
+ layout.addView(closeButton)
357
+
358
+ setContentView(layout)
359
+
360
+ layout.isFocusable = true
361
+ layout.isFocusableInTouchMode = true
362
+ layout.requestFocus()
363
+ }
364
+
261
365
  @SuppressLint("RestrictedApi")
262
366
  private fun handleAssertion() {
263
367
  if (isHandling) return
@@ -271,10 +375,11 @@ class GetPasskeyActivity : AppCompatActivity() {
271
375
  val cipherToUse = systemUnlockedCipher ?: run {
272
376
  if (biometricPromptResult != null) {
273
377
  try {
378
+ val requirement = BiometricRequirement.resolve(this@GetPasskeyActivity)
274
379
  val fallback = if (biometricIv != null) {
275
- credentialRepository.getBiometricCipherForDecryption(AndroidBase64.decode(biometricIv, AndroidBase64.DEFAULT))
380
+ credentialRepository.getBiometricCipherForDecryption(this@GetPasskeyActivity, AndroidBase64.decode(biometricIv, AndroidBase64.DEFAULT), requirement)
276
381
  } else {
277
- credentialRepository.getBiometricCipherForEncryption()
382
+ credentialRepository.getBiometricCipherForEncryption(this@GetPasskeyActivity, requirement)
278
383
  }
279
384
  Log.i(TAG, "Successfully obtained fallback cipher from repository (Single Tap timeout)")
280
385
  fallback
@@ -294,7 +399,20 @@ class GetPasskeyActivity : AppCompatActivity() {
294
399
  Log.d(TAG, "Manual biometrics result: $result")
295
400
  if (result == null) {
296
401
  Log.w(TAG, "Biometrics failed or was canceled")
297
- setupUI() // Show UI as fallback
402
+ val requirement = BiometricRequirement.resolve(this@GetPasskeyActivity)
403
+ val canUseBiometrics =
404
+ BiometricManager.from(this@GetPasskeyActivity)
405
+ .canAuthenticate(
406
+ requirement.allowedAuthenticators,
407
+ ) == BiometricManager.BIOMETRIC_SUCCESS
408
+ if (canUseBiometrics) {
409
+ setupUI()
410
+ } else {
411
+ setupErrorUI(
412
+ "This device has no screen lock or biometric set up, which passkeys require. Add one in your device settings, then try again.",
413
+ allowRetry = false,
414
+ )
415
+ }
298
416
  isHandling = false
299
417
  return@launch
300
418
  }
@@ -469,19 +587,25 @@ class GetPasskeyActivity : AppCompatActivity() {
469
587
 
470
588
  setResult(Activity.RESULT_OK, resultIntent)
471
589
  Log.d(TAG, "Result set to OK")
590
+ credentialRepository.recordCredentialUsage(this@GetPasskeyActivity, credId)
472
591
  ReactNativePasskeyAutofillModule.instance?.sendEvent("onPasskeyAuthenticated", Bundle().apply {
473
592
  putBoolean("success", true)
593
+ putString("credentialId", credentialIdEnc)
474
594
  })
475
595
  finish()
476
596
  } catch (e: Exception) {
477
597
  Log.e(TAG, "Error during passkey assertion", e)
478
- setupUI() // Show UI on error
598
+ setupErrorUI(
599
+ "Something went wrong while signing in. Please try again.",
600
+ allowRetry = true,
601
+ )
479
602
  isHandling = false
480
603
  }
481
604
  }
482
605
  }
483
606
 
484
607
  private suspend fun biometrics(iv: String?): BiometricPrompt.AuthenticationResult? {
608
+ val requirement = BiometricRequirement.resolve(this)
485
609
  return suspendCoroutine { continuation ->
486
610
  val biometricPrompt = BiometricPrompt(
487
611
  this,
@@ -502,17 +626,19 @@ class GetPasskeyActivity : AppCompatActivity() {
502
626
  }
503
627
  }
504
628
  )
505
- val promptInfo = BiometricPrompt.PromptInfo.Builder()
629
+ val promptInfoBuilder = BiometricPrompt.PromptInfo.Builder()
506
630
  .setTitle("Sign In")
507
631
  .setSubtitle("Authenticate to use your passkey")
508
- .setNegativeButtonText("Cancel")
509
- .setAllowedAuthenticators(androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG)
510
- .build()
511
-
512
- if (iv != null) {
632
+ .setAllowedAuthenticators(requirement.allowedAuthenticators)
633
+ if (!requirement.allowsDeviceCredential) {
634
+ promptInfoBuilder.setNegativeButtonText("Cancel")
635
+ }
636
+ val promptInfo = promptInfoBuilder.build()
637
+
638
+ if (iv != null && requirement.isCryptoBound) {
513
639
  try {
514
640
  val ivBytes = AndroidBase64.decode(iv, AndroidBase64.DEFAULT)
515
- val cipher = credentialRepository.getBiometricCipherForDecryption(ivBytes)
641
+ val cipher = credentialRepository.getBiometricCipherForDecryption(this, ivBytes, requirement)
516
642
  biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
517
643
  } catch (e: Exception) {
518
644
  Log.e(TAG, "Failed to initialize biometric prompt with cipher", e)
@@ -93,6 +93,33 @@ class ReactNativePasskeyAutofillModule : Module() {
93
93
  }
94
94
  }
95
95
 
96
+ // NoOp on Android. iOS uses ASCredentialIdentityStore to advertise
97
+ // credentials to the system AutoFill UI; Android's Credential Manager
98
+ // queries the provider service on demand instead, so there is no
99
+ // equivalent identity store to populate.
100
+ AsyncFunction("replaceCredentialIdentities") { _: List<Map<String, Any?>> ->
101
+ // No-op: see comment above.
102
+ }
103
+
104
+ // NoOp on Android. See `replaceCredentialIdentities` above.
105
+ AsyncFunction("refreshCredentialIdentities") {
106
+ // No-op: see comment above.
107
+ }
108
+
109
+ // NoOp on Android. The iOS implementation returns credentials stored in
110
+ // the shared App Group keychain used by the AutoFill extension. On
111
+ // Android, credentials are managed by the CredentialProviderService and
112
+ // are not exposed back to JS through this module.
113
+ AsyncFunction("getStoredCredentials") {
114
+ emptyList<Map<String, Any?>>()
115
+ }
116
+
117
+ // NoOp on Android. iOS exposes diagnostics from the shared App Group
118
+ // store; there is no equivalent on Android yet.
119
+ AsyncFunction("getDiagnostics") {
120
+ emptyList<String>()
121
+ }
122
+
96
123
  AsyncFunction("isProviderActive") {
97
124
  val context = (appContext.reactContext ?: appContext.hostingRuntimeContext) as? Context
98
125
  ?: return@AsyncFunction false
@@ -104,6 +131,28 @@ class ReactNativePasskeyAutofillModule : Module() {
104
131
  ?: return@AsyncFunction false
105
132
  openCredentialProviderSettings(context)
106
133
  }
134
+
135
+ AsyncFunction("getStoredCredentials") {
136
+ val context = (appContext.reactContext ?: appContext.hostingRuntimeContext) as? Context
137
+ ?: return@AsyncFunction emptyList<Map<String, Any>>()
138
+ credentialRepository.getAllCredentials(context).map { credential ->
139
+ mapOf(
140
+ "credentialId" to credential.credentialId,
141
+ "relyingPartyIdentifier" to credential.origin,
142
+ "userName" to credential.userHandle,
143
+ "userHandle" to credential.userHandle,
144
+ "publicKey" to credential.publicKey,
145
+ )
146
+ }
147
+ }
148
+
149
+ // The iOS AutoFill identity store (ASCredentialIdentityStore) has no
150
+ // Android analogue: our CredentialProviderService answers each
151
+ // BeginGetCredentialRequest from MMKV on demand, so there is no store to
152
+ // pre-populate. This is a no-op purely to satisfy the shared JS API.
153
+ AsyncFunction("refreshCredentialIdentities") {
154
+ // no-op on Android
155
+ }
107
156
  }
108
157
 
109
158
  /**
@@ -0,0 +1,135 @@
1
+ package co.algorand.passkeyautofill
2
+
3
+ import android.content.Context
4
+ import android.util.Base64
5
+ import java.nio.ByteBuffer
6
+ import java.util.UUID
7
+
8
+ /**
9
+ * WebAuthn attestation helpers.
10
+ *
11
+ * The AndroidX credentials library (`AuthenticatorAttestationResponse`) always emits an
12
+ * all-zero AAGUID. To present the same authenticator identity as the iOS provider and the
13
+ * native Pera apps, we splice a configured AAGUID into the attestationObject after AndroidX
14
+ * builds it. The value is injected by the Expo config plugin (`aaguid` prop) as the
15
+ * `passkey_autofill_aaguid` string resource.
16
+ */
17
+ object WebAuthn {
18
+ private const val AAGUID_RESOURCE = "passkey_autofill_aaguid"
19
+ private const val AAGUID_LENGTH = 16
20
+
21
+ // authData layout: rpIdHash(32) + flags(1) + signCount(4) then attestedCredentialData,
22
+ // which begins with the 16-byte AAGUID.
23
+ private const val AAGUID_OFFSET_IN_AUTH_DATA = 37
24
+
25
+ private const val BASE64_FLAGS = Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING
26
+
27
+ private val ZERO_AAGUID = ByteArray(AAGUID_LENGTH)
28
+
29
+ /** Returns the configured AAGUID bytes, or null when unset/blank/all-zero/malformed. */
30
+ fun configuredAaguid(context: Context): ByteArray? {
31
+ val resId = context.resources.getIdentifier(AAGUID_RESOURCE, "string", context.packageName)
32
+ if (resId == 0) return null
33
+ val raw = context.getString(resId).trim()
34
+ if (raw.isEmpty()) return null
35
+ val bytes = try {
36
+ uuidToBytes(UUID.fromString(raw))
37
+ } catch (e: IllegalArgumentException) {
38
+ return null
39
+ }
40
+ return if (bytes.contentEquals(ZERO_AAGUID)) null else bytes
41
+ }
42
+
43
+ /**
44
+ * Overwrites the 16-byte AAGUID inside the attestationObject's authData with [aaguid].
45
+ * Returns the re-encoded (base64url, no padding) attestationObject.
46
+ */
47
+ fun injectAaguid(attestationObjectB64Url: String, aaguid: ByteArray): String {
48
+ require(aaguid.size == AAGUID_LENGTH) { "AAGUID must be $AAGUID_LENGTH bytes" }
49
+ val bytes = Base64.decode(attestationObjectB64Url, BASE64_FLAGS)
50
+ val authDataStart = findAuthDataContentStart(bytes)
51
+ val offset = authDataStart + AAGUID_OFFSET_IN_AUTH_DATA
52
+ require(offset + AAGUID_LENGTH <= bytes.size) {
53
+ "attestationObject is too short to contain an AAGUID"
54
+ }
55
+ System.arraycopy(aaguid, 0, bytes, offset, AAGUID_LENGTH)
56
+ return Base64.encodeToString(bytes, BASE64_FLAGS)
57
+ }
58
+
59
+ private fun uuidToBytes(uuid: UUID): ByteArray =
60
+ ByteBuffer.allocate(AAGUID_LENGTH)
61
+ .putLong(uuid.mostSignificantBits)
62
+ .putLong(uuid.leastSignificantBits)
63
+ .array()
64
+
65
+ // --- Minimal CBOR reader: locate the "authData" byte-string value in the top-level map ---
66
+
67
+ private class Cursor(val buf: ByteArray) {
68
+ var pos = 0
69
+ }
70
+
71
+ private fun findAuthDataContentStart(buf: ByteArray): Int {
72
+ val c = Cursor(buf)
73
+ val (major, count) = readHead(c)
74
+ require(major == 5) { "attestationObject is not a CBOR map" }
75
+ repeat(count.toInt()) {
76
+ val key = readTextString(c)
77
+ if (key == "authData") {
78
+ val (valueMajor, valueLen) = readHead(c)
79
+ require(valueMajor == 2) { "authData is not a CBOR byte string" }
80
+ val start = c.pos
81
+ c.pos += valueLen.toInt()
82
+ return start
83
+ }
84
+ skipItem(c)
85
+ }
86
+ throw IllegalArgumentException("authData not found in attestationObject")
87
+ }
88
+
89
+ /** Reads a CBOR item head, returning (majorType, argument) and advancing the cursor. */
90
+ private fun readHead(c: Cursor): Pair<Int, Long> {
91
+ val initial = c.buf[c.pos].toInt() and 0xff
92
+ c.pos += 1
93
+ val major = initial ushr 5
94
+ val additional = initial and 0x1f
95
+ val argument = when {
96
+ additional < 24 -> additional.toLong()
97
+ additional == 24 -> readUInt(c, 1)
98
+ additional == 25 -> readUInt(c, 2)
99
+ additional == 26 -> readUInt(c, 4)
100
+ additional == 27 -> readUInt(c, 8)
101
+ else -> throw IllegalArgumentException("Unsupported CBOR additional info: $additional")
102
+ }
103
+ return Pair(major, argument)
104
+ }
105
+
106
+ private fun readUInt(c: Cursor, byteCount: Int): Long {
107
+ var value = 0L
108
+ repeat(byteCount) {
109
+ value = (value shl 8) or (c.buf[c.pos].toLong() and 0xff)
110
+ c.pos += 1
111
+ }
112
+ return value
113
+ }
114
+
115
+ private fun readTextString(c: Cursor): String {
116
+ val (major, length) = readHead(c)
117
+ require(major == 3) { "Expected a CBOR text-string map key" }
118
+ val value = String(c.buf, c.pos, length.toInt(), Charsets.UTF_8)
119
+ c.pos += length.toInt()
120
+ return value
121
+ }
122
+
123
+ private fun skipItem(c: Cursor) {
124
+ val (major, argument) = readHead(c)
125
+ when (major) {
126
+ 0, 1 -> Unit // unsigned/negative integer: value is the head argument
127
+ 2, 3 -> c.pos += argument.toInt() // byte/text string
128
+ 4 -> repeat(argument.toInt()) { skipItem(c) } // array
129
+ 5 -> repeat(argument.toInt()) { skipItem(c); skipItem(c) } // map: key + value
130
+ 6 -> skipItem(c) // tag: skip the tagged item
131
+ 7 -> Unit // simple value / float: width already consumed by readHead
132
+ else -> throw IllegalArgumentException("Unsupported CBOR major type: $major")
133
+ }
134
+ }
135
+ }
@@ -0,0 +1,86 @@
1
+ package co.algorand.passkeyautofill.auth
2
+
3
+ import android.content.Context
4
+ import android.content.pm.PackageManager
5
+ import android.security.keystore.KeyProperties
6
+ import android.util.Log
7
+ import androidx.biometric.BiometricManager.Authenticators
8
+
9
+ /**
10
+ * The set of authenticators a passkey operation will accept. Configured at build time via the
11
+ * `biometricRequirement` config-plugin prop and read from app `<meta-data>` at runtime.
12
+ */
13
+ enum class BiometricRequirement {
14
+ /** Strong biometric only. Crypto-bound key. */
15
+ STRONG,
16
+
17
+ /** Strong biometric OR device credential (PIN/pattern/password). Crypto-bound key. Default. */
18
+ STRONG_OR_CREDENTIAL,
19
+
20
+ /**
21
+ * Weak biometric OR device credential. NOT crypto-bound: Android cannot gate a Keystore key on
22
+ * weak biometrics, so the key is not user-auth-required and the prompt is a UI-only gate.
23
+ */
24
+ WEAK_OR_CREDENTIAL;
25
+
26
+ /** Authenticators to pass to `BiometricPrompt.PromptInfo` / `BiometricPromptData`. */
27
+ val allowedAuthenticators: Int
28
+ get() = when (this) {
29
+ STRONG -> Authenticators.BIOMETRIC_STRONG
30
+ STRONG_OR_CREDENTIAL -> Authenticators.BIOMETRIC_STRONG or Authenticators.DEVICE_CREDENTIAL
31
+ WEAK_OR_CREDENTIAL -> Authenticators.BIOMETRIC_WEAK or Authenticators.DEVICE_CREDENTIAL
32
+ }
33
+
34
+ /** True when device credential is accepted; a negative button must NOT be set in that case. */
35
+ val allowsDeviceCredential: Boolean
36
+ get() = when (this) {
37
+ STRONG -> false
38
+ STRONG_OR_CREDENTIAL, WEAK_OR_CREDENTIAL -> true
39
+ }
40
+
41
+ /** True when the Keystore key is user-auth-bound and prompts use a `CryptoObject`. */
42
+ val isCryptoBound: Boolean
43
+ get() = when (this) {
44
+ STRONG, STRONG_OR_CREDENTIAL -> true
45
+ WEAK_OR_CREDENTIAL -> false
46
+ }
47
+
48
+ /** Auth type for `KeyGenParameterSpec.setUserAuthenticationParameters` (only when [isCryptoBound]). */
49
+ val keystoreAuthType: Int
50
+ get() = when (this) {
51
+ STRONG -> KeyProperties.AUTH_BIOMETRIC_STRONG
52
+ STRONG_OR_CREDENTIAL -> KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL
53
+ WEAK_OR_CREDENTIAL -> 0 // no auth binding — key is not user-auth-required
54
+ }
55
+
56
+ companion object {
57
+ private const val TAG = "BiometricRequirement"
58
+ const val META_DATA_KEY = "co.algorand.passkeyautofill.BIOMETRIC_REQUIREMENT"
59
+
60
+ fun fromValue(value: String?): BiometricRequirement = when (val trimmed = value?.trim()) {
61
+ "strong" -> STRONG
62
+ "strongOrCredential" -> STRONG_OR_CREDENTIAL
63
+ "weakOrCredential" -> WEAK_OR_CREDENTIAL
64
+ else -> {
65
+ if (!trimmed.isNullOrEmpty()) {
66
+ Log.w(TAG, "Unknown biometricRequirement value '$trimmed', using default STRONG_OR_CREDENTIAL")
67
+ }
68
+ STRONG_OR_CREDENTIAL
69
+ }
70
+ }
71
+
72
+ /** Reads the build-time configured requirement from app `<meta-data>`; defaults on any failure. */
73
+ fun resolve(context: Context): BiometricRequirement {
74
+ val value = try {
75
+ context.packageManager
76
+ .getApplicationInfo(context.packageName, PackageManager.GET_META_DATA)
77
+ .metaData
78
+ ?.getString(META_DATA_KEY)
79
+ } catch (e: Exception) {
80
+ Log.w(TAG, "Failed to read $META_DATA_KEY meta-data, using default", e)
81
+ null
82
+ }
83
+ return fromValue(value)
84
+ }
85
+ }
86
+ }