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

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 (29) hide show
  1. package/.releaserc +6 -0
  2. package/CHANGELOG.md +20 -3
  3. package/README.md +17 -0
  4. package/android/build.gradle +3 -0
  5. package/android/src/main/java/co/algorand/passkeyautofill/CreatePasskeyActivity.kt +38 -8
  6. package/android/src/main/java/co/algorand/passkeyautofill/GetPasskeyActivity.kt +176 -12
  7. package/android/src/main/java/co/algorand/passkeyautofill/Prf.kt +184 -0
  8. package/android/src/main/java/co/algorand/passkeyautofill/ReactNativePasskeyAutofillModule.kt +49 -0
  9. package/android/src/main/java/co/algorand/passkeyautofill/WebAuthn.kt +135 -0
  10. package/android/src/main/java/co/algorand/passkeyautofill/auth/BiometricRequirement.kt +86 -0
  11. package/android/src/main/java/co/algorand/passkeyautofill/credentials/CredentialRepository.kt +96 -44
  12. package/android/src/main/java/co/algorand/passkeyautofill/service/PasskeyAutofillCredentialProviderService.kt +27 -23
  13. package/app.plugin.js +102 -25
  14. package/build/ReactNativePasskeyAutofill.types.d.ts +17 -0
  15. package/build/ReactNativePasskeyAutofill.types.d.ts.map +1 -1
  16. package/build/ReactNativePasskeyAutofill.types.js +3 -1
  17. package/build/ReactNativePasskeyAutofill.types.js.map +1 -1
  18. package/e2e/driver/capabilities.ts +3 -0
  19. package/e2e/driver/credentialProvider.ts +1 -1
  20. package/e2e/driver/fingerprint.ts +1 -1
  21. package/e2e/tests/passkey.test.ts +0 -1
  22. package/ios/AutofillCredentialProvider/BiometricRequirement.swift +34 -0
  23. package/ios/AutofillCredentialProvider/CredentialProviderViewController.swift +136 -5
  24. package/ios/AutofillCredentialProvider/PasskeyCredentialStore.swift +28 -0
  25. package/ios/AutofillCredentialProvider/Prf.swift +89 -0
  26. package/ios/AutofillCredentialProvider/WebAuthn.swift +14 -1
  27. package/ios/ReactNativePasskeyAutofillModule.swift +4 -0
  28. package/package.json +2 -1
  29. package/src/ReactNativePasskeyAutofill.types.ts +21 -1
package/.releaserc CHANGED
@@ -11,6 +11,12 @@
11
11
  "@semantic-release/commit-analyzer",
12
12
  "@semantic-release/release-notes-generator",
13
13
  "@semantic-release/changelog",
14
+ [
15
+ "@semantic-release/exec",
16
+ {
17
+ "prepareCmd": "pnpm fmt"
18
+ }
19
+ ],
14
20
  "@semantic-release/npm",
15
21
  [
16
22
  "@semantic-release/git",
package/CHANGELOG.md CHANGED
@@ -1,14 +1,31 @@
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.18](https://github.com/algorandfoundation/react-native-passkey-autofill/compare/v1.0.0-canary.17...v1.0.0-canary.18) (2026-06-09)
2
+
3
+ ### Features
2
4
 
5
+ - PRF extension support for deterministically derived secrets ([4b5b05a](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/4b5b05ab146e39b191855ccb01fd4aca5ed32819))
6
+
7
+ # [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)
3
8
 
4
9
  ### Bug Fixes
5
10
 
6
- * align ios autofill root key flow ([1287f57](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/1287f57952b41829032ef368bad826624375e9a3))
11
+ - allow configurable AAGUID ([d5a8176](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/d5a81765ba150e9873e18875f0ac9f7bc4aede93))
12
+ - **Android:** add NoOp methods for iOS-specific functionality ([e2c64db](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/e2c64db479cda70c7c61b7ab3021b70db949a15b))
13
+ - compile issue ([16b939c](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/16b939cb555e75bd052ce57a41a838810a2770cd))
14
+
15
+ ### Features
16
+
17
+ - configurable credential requirement ([dc09916](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/dc09916d1ee5c8c9d33e53b57f00171472f4f665))
18
+ - record lastUsedAt/count and fix error handling ([47b40ab](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/47b40abf692c0f7cffee6f2266c66f61f8006a65))
19
+
20
+ # [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)
21
+
22
+ ### Bug Fixes
7
23
 
24
+ - align ios autofill root key flow ([1287f57](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/1287f57952b41829032ef368bad826624375e9a3))
8
25
 
9
26
  ### Features
10
27
 
11
- * add iOS passkey autofill provider ([9aad8f8](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/9aad8f8ceb2ad54cf53847b60dd6e70d5b2a1f2b))
28
+ - add iOS passkey autofill provider ([9aad8f8](https://github.com/algorandfoundation/react-native-passkey-autofill/commit/9aad8f8ceb2ad54cf53847b60dd6e70d5b2a1f2b))
12
29
 
13
30
  # [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
31
 
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,31 @@ 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
+
437
+ // WebAuthn `prf` extension (hmac-secret).
438
+ // On registration we advertise `enabled = true` whenever the RP
439
+ // asked for the extension, signalling that the credential can be
440
+ // used as a PRF source on subsequent assertions. We deliberately
441
+ // do not evaluate salts on create — this matches the behaviour of
442
+ // most platform authenticators.
443
+ val requestExtensions = requestJson.optJSONObject("publicKey")?.optJSONObject("extensions")
444
+ ?: requestJson.optJSONObject("extensions")
445
+ if (requestExtensions?.has("prf") == true || requestExtensions?.has("prfAlreadyHashed") == true) {
446
+ val clientExtensionResults = fullJson.optJSONObject("clientExtensionResults") ?: JSONObject()
447
+ val prfResult = JSONObject().put("enabled", true)
448
+ clientExtensionResults.put("prf", prfResult)
449
+ fullJson.put("clientExtensionResults", clientExtensionResults)
450
+ Log.d(TAG, "Advertised PRF support on registration")
451
+ }
452
+
427
453
  val createResponse = CreatePublicKeyCredentialResponse(fullJson.toString())
428
454
  Log.d(TAG, "CreatePublicKeyCredentialResponse: ${fullJson.toString()}")
429
455
 
@@ -447,6 +473,7 @@ class CreatePasskeyActivity : AppCompatActivity() {
447
473
  }
448
474
 
449
475
  private suspend fun biometrics(needsCipher: Boolean): BiometricPrompt.AuthenticationResult? {
476
+ val requirement = BiometricRequirement.resolve(this)
450
477
  return suspendCoroutine { continuation ->
451
478
  val biometricPrompt = BiometricPrompt(
452
479
  this,
@@ -465,16 +492,19 @@ class CreatePasskeyActivity : AppCompatActivity() {
465
492
  }
466
493
  }
467
494
  )
468
- val promptInfo = BiometricPrompt.PromptInfo.Builder()
495
+ val promptInfoBuilder = BiometricPrompt.PromptInfo.Builder()
469
496
  .setTitle("Create Passkey")
470
497
  .setSubtitle("Authenticate to save your passkey")
471
- .setNegativeButtonText("Cancel")
472
- .setAllowedAuthenticators(androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG)
473
- .build()
474
-
475
- if (needsCipher) {
498
+ .setAllowedAuthenticators(requirement.allowedAuthenticators)
499
+ // A negative button is illegal when DEVICE_CREDENTIAL is allowed; the system shows its own.
500
+ if (!requirement.allowsDeviceCredential) {
501
+ promptInfoBuilder.setNegativeButtonText("Cancel")
502
+ }
503
+ val promptInfo = promptInfoBuilder.build()
504
+
505
+ if (needsCipher && requirement.isCryptoBound) {
476
506
  try {
477
- val cipher = credentialRepository.getBiometricCipherForEncryption()
507
+ val cipher = credentialRepository.getBiometricCipherForEncryption(this, requirement)
478
508
  biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
479
509
  } catch (e: Exception) {
480
510
  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
  }
@@ -454,6 +572,44 @@ class GetPasskeyActivity : AppCompatActivity() {
454
572
  val credProps = JSONObject()
455
573
  credProps.put("rk", true)
456
574
  clientExtensionResults.put("credProps", credProps)
575
+
576
+ // WebAuthn `prf` extension (hmac-secret). If the RP requested PRF
577
+ // evaluation for this assertion, derive the per-credential
578
+ // `credRandom` from the wallet HD root secret and compute
579
+ // HMAC-SHA256-based outputs for the supplied salt(s).
580
+ try {
581
+ val credentialIdB64Url = AndroidBase64.encodeToString(
582
+ credId,
583
+ AndroidBase64.URL_SAFE or AndroidBase64.NO_WRAP or AndroidBase64.NO_PADDING,
584
+ )
585
+ val prfInput = Prf.parseInput(passkeyRequestJsonObj, credentialIdB64Url)
586
+ if (prfInput != null) {
587
+ val hdRootSecret = credentialRepository.getHdRootSecret(this@GetPasskeyActivity)
588
+ if (hdRootSecret == null) {
589
+ Log.w(TAG, "PRF input present but HD root secret unavailable; skipping PRF output")
590
+ } else {
591
+ val credRandom = Prf.credRandom(
592
+ hdRootSecret = hdRootSecret,
593
+ relyingPartyIdentifier = displayOrigin,
594
+ userHandle = dbCred.userHandle,
595
+ )
596
+ val first = Prf.evaluate(credRandom, prfInput.first, prfInput.alreadyHashed)
597
+ val second = prfInput.second?.let { Prf.evaluate(credRandom, it, prfInput.alreadyHashed) }
598
+
599
+ val prfResults = JSONObject().put("first", Prf.encodeOutput(first))
600
+ if (second != null) {
601
+ prfResults.put("second", Prf.encodeOutput(second))
602
+ }
603
+ clientExtensionResults.put("prf", JSONObject().put("results", prfResults))
604
+ Log.d(TAG, "Computed PRF assertion output (second present=${second != null})")
605
+ }
606
+ }
607
+ } catch (e: Exception) {
608
+ // Never fail the assertion because of a PRF error; just log
609
+ // and omit the extension result.
610
+ Log.w(TAG, "Failed to compute PRF assertion output", e)
611
+ }
612
+
457
613
  fullJson.put("clientExtensionResults", clientExtensionResults)
458
614
 
459
615
  val credentialJson = fullJson.toString()
@@ -469,19 +625,25 @@ class GetPasskeyActivity : AppCompatActivity() {
469
625
 
470
626
  setResult(Activity.RESULT_OK, resultIntent)
471
627
  Log.d(TAG, "Result set to OK")
628
+ credentialRepository.recordCredentialUsage(this@GetPasskeyActivity, credId)
472
629
  ReactNativePasskeyAutofillModule.instance?.sendEvent("onPasskeyAuthenticated", Bundle().apply {
473
630
  putBoolean("success", true)
631
+ putString("credentialId", credentialIdEnc)
474
632
  })
475
633
  finish()
476
634
  } catch (e: Exception) {
477
635
  Log.e(TAG, "Error during passkey assertion", e)
478
- setupUI() // Show UI on error
636
+ setupErrorUI(
637
+ "Something went wrong while signing in. Please try again.",
638
+ allowRetry = true,
639
+ )
479
640
  isHandling = false
480
641
  }
481
642
  }
482
643
  }
483
644
 
484
645
  private suspend fun biometrics(iv: String?): BiometricPrompt.AuthenticationResult? {
646
+ val requirement = BiometricRequirement.resolve(this)
485
647
  return suspendCoroutine { continuation ->
486
648
  val biometricPrompt = BiometricPrompt(
487
649
  this,
@@ -502,17 +664,19 @@ class GetPasskeyActivity : AppCompatActivity() {
502
664
  }
503
665
  }
504
666
  )
505
- val promptInfo = BiometricPrompt.PromptInfo.Builder()
667
+ val promptInfoBuilder = BiometricPrompt.PromptInfo.Builder()
506
668
  .setTitle("Sign In")
507
669
  .setSubtitle("Authenticate to use your passkey")
508
- .setNegativeButtonText("Cancel")
509
- .setAllowedAuthenticators(androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG)
510
- .build()
511
-
512
- if (iv != null) {
670
+ .setAllowedAuthenticators(requirement.allowedAuthenticators)
671
+ if (!requirement.allowsDeviceCredential) {
672
+ promptInfoBuilder.setNegativeButtonText("Cancel")
673
+ }
674
+ val promptInfo = promptInfoBuilder.build()
675
+
676
+ if (iv != null && requirement.isCryptoBound) {
513
677
  try {
514
678
  val ivBytes = AndroidBase64.decode(iv, AndroidBase64.DEFAULT)
515
- val cipher = credentialRepository.getBiometricCipherForDecryption(ivBytes)
679
+ val cipher = credentialRepository.getBiometricCipherForDecryption(this, ivBytes, requirement)
516
680
  biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
517
681
  } catch (e: Exception) {
518
682
  Log.e(TAG, "Failed to initialize biometric prompt with cipher", e)
@@ -0,0 +1,184 @@
1
+ package co.algorand.passkeyautofill
2
+
3
+ import android.util.Base64 as AndroidBase64
4
+ import java.security.MessageDigest
5
+ import javax.crypto.Mac
6
+ import javax.crypto.spec.SecretKeySpec
7
+ import org.json.JSONObject
8
+
9
+ /**
10
+ * WebAuthn PRF (`prf`) extension helpers, backed by the authenticator
11
+ * `hmac-secret` mechanism (CTAP2.1).
12
+ *
13
+ * Output formula per the WebAuthn spec:
14
+ *
15
+ * output = HMAC-SHA256(credRandom, SHA256("WebAuthn PRF" || 0x00 || salt))
16
+ *
17
+ * `credRandom` is a per-credential 32-byte secret. We derive it
18
+ * deterministically (HKDF-SHA256) from the wallet HD root secret, the
19
+ * relying-party identifier, and the user handle — the same inputs that
20
+ * produce the deterministic signing key. This avoids a storage migration
21
+ * and ensures restoring the wallet seed reproduces the same PRF outputs
22
+ * on another device.
23
+ */
24
+ object Prf {
25
+ private const val CRED_RANDOM_INFO = "WebAuthn-PRF-credRandom"
26
+ private val SALT_PREFIX: ByteArray = "WebAuthn PRF".toByteArray(Charsets.UTF_8) + byteArrayOf(0x00)
27
+
28
+ /**
29
+ * Derive a 32-byte per-credential PRF secret from the HD root secret.
30
+ * Deterministic over `(hdRootSecret, rpId, userHandle)`.
31
+ */
32
+ fun credRandom(
33
+ hdRootSecret: ByteArray,
34
+ relyingPartyIdentifier: String,
35
+ userHandle: String,
36
+ ): ByteArray {
37
+ val salt = relyingPartyIdentifier.lowercase().toByteArray(Charsets.UTF_8) +
38
+ byteArrayOf(0x00) +
39
+ userHandle.lowercase().toByteArray(Charsets.UTF_8)
40
+ return hkdfSha256(
41
+ ikm = hdRootSecret,
42
+ salt = salt,
43
+ info = CRED_RANDOM_INFO.toByteArray(Charsets.UTF_8),
44
+ length = 32,
45
+ )
46
+ }
47
+
48
+ /**
49
+ * Evaluate the PRF for a single salt. Returns 32 bytes of output material.
50
+ *
51
+ * Per the WebAuthn spec the value the authenticator HMACs is
52
+ * `SHA-256("WebAuthn PRF" || 0x00 || rpSalt)`. The Chromium-on-Android
53
+ * client manager performs this hashing on behalf of the RP and passes the
54
+ * result through the `prfAlreadyHashed` extension; in that case the
55
+ * `salt` is already the inner hash and we must NOT hash again. Standard
56
+ * `prf` requests send the raw RP salt and require hashing.
57
+ */
58
+ fun evaluate(credRandom: ByteArray, salt: ByteArray, alreadyHashed: Boolean = true): ByteArray {
59
+ val macInput = if (alreadyHashed) {
60
+ salt
61
+ } else {
62
+ MessageDigest.getInstance("SHA-256").digest(SALT_PREFIX + salt)
63
+ }
64
+ val mac = Mac.getInstance("HmacSHA256").apply {
65
+ init(SecretKeySpec(credRandom, "HmacSHA256"))
66
+ }
67
+ return mac.doFinal(macInput)
68
+ }
69
+
70
+ /**
71
+ * Parsed PRF inputs from the relying party. Reduced to at most two salts
72
+ * (first + optional second) — already resolved against `evalByCredential`
73
+ * for the credential the user is asserting with.
74
+ *
75
+ * @property alreadyHashed `true` when the salts have already been passed
76
+ * through `SHA-256("WebAuthn PRF" || 0x00 || ...)` by the platform (the
77
+ * case for both the Chromium `prfAlreadyHashed` extension on Android
78
+ * and Apple's `ASAuthorizationPublicKeyCredentialPRFAssertionInput`).
79
+ */
80
+ data class Input(val first: ByteArray, val second: ByteArray?, val alreadyHashed: Boolean)
81
+
82
+ /**
83
+ * Parse PRF eval salts out of a WebAuthn request JSON.
84
+ *
85
+ * Recognises:
86
+ * - `extensions.prf.eval` / `extensions.prf.evalByCredential[<credId>]`
87
+ * — raw RP-supplied salts that still need to be hashed before HMAC.
88
+ * - `extensions.prfAlreadyHashed.eval` /
89
+ * `extensions.prfAlreadyHashed.evalByCredential[<credId>]` — Chromium
90
+ * on Android emits this when forwarding a `prf` request to a credential
91
+ * provider so the provider doesn't need to know the spec-mandated hash
92
+ * prefix. The salts here are already 32-byte SHA-256 outputs and must
93
+ * not be re-hashed.
94
+ *
95
+ * @param requestJson the inner `publicKey` request JSON object.
96
+ * @param credentialIdB64Url base64url-encoded credentialId we're asserting
97
+ * with (used to look up the per-credential salts).
98
+ */
99
+ fun parseInput(requestJson: JSONObject, credentialIdB64Url: String?): Input? {
100
+ val extensions = requestJson.optJSONObject("extensions") ?: return null
101
+
102
+ // Pre-hashed form wins — when Chromium sends both for compatibility,
103
+ // honour the already-hashed values to match what the browser expects.
104
+ extensions.optJSONObject("prfAlreadyHashed")?.let { node ->
105
+ parseExtensionNode(node, credentialIdB64Url, alreadyHashed = true)?.let { return it }
106
+ }
107
+ extensions.optJSONObject("prf")?.let { node ->
108
+ parseExtensionNode(node, credentialIdB64Url, alreadyHashed = false)?.let { return it }
109
+ }
110
+ return null
111
+ }
112
+
113
+ private fun parseExtensionNode(
114
+ node: JSONObject,
115
+ credentialIdB64Url: String?,
116
+ alreadyHashed: Boolean,
117
+ ): Input? {
118
+ // Per-credential salts win over the global `eval` block.
119
+ if (credentialIdB64Url != null) {
120
+ val byCred = node.optJSONObject("evalByCredential")
121
+ if (byCred != null) {
122
+ for (key in byCred.keys()) {
123
+ if (normalizeBase64Url(key) == normalizeBase64Url(credentialIdB64Url)) {
124
+ byCred.optJSONObject(key)?.let { return parseSalts(it, alreadyHashed) }
125
+ }
126
+ }
127
+ }
128
+ }
129
+ return node.optJSONObject("eval")?.let { parseSalts(it, alreadyHashed) }
130
+ }
131
+
132
+ private fun parseSalts(obj: JSONObject, alreadyHashed: Boolean): Input? {
133
+ val first = obj.optString("first", "").takeIf { it.isNotEmpty() } ?: return null
134
+ val firstBytes = decodeBase64UrlOrBase64(first)
135
+ val secondBytes = obj.optString("second", "")
136
+ .takeIf { it.isNotEmpty() }
137
+ ?.let { decodeBase64UrlOrBase64(it) }
138
+ return Input(firstBytes, secondBytes, alreadyHashed)
139
+ }
140
+
141
+ private fun normalizeBase64Url(value: String): String =
142
+ value.trim().trimEnd('=').replace('+', '-').replace('/', '_')
143
+
144
+ private fun decodeBase64UrlOrBase64(value: String): ByteArray {
145
+ val flags = AndroidBase64.URL_SAFE or AndroidBase64.NO_WRAP or AndroidBase64.NO_PADDING
146
+ return try {
147
+ AndroidBase64.decode(value, flags)
148
+ } catch (e: IllegalArgumentException) {
149
+ AndroidBase64.decode(value, AndroidBase64.NO_WRAP)
150
+ }
151
+ }
152
+
153
+ /** Encode a 32-byte PRF output for inclusion in a WebAuthn response JSON. */
154
+ fun encodeOutput(bytes: ByteArray): String =
155
+ AndroidBase64.encodeToString(bytes, AndroidBase64.URL_SAFE or AndroidBase64.NO_WRAP or AndroidBase64.NO_PADDING)
156
+
157
+ // RFC 5869 HKDF-SHA256.
158
+ private fun hkdfSha256(ikm: ByteArray, salt: ByteArray, info: ByteArray, length: Int): ByteArray {
159
+ val mac = Mac.getInstance("HmacSHA256")
160
+ val effectiveSalt = if (salt.isEmpty()) ByteArray(32) else salt
161
+ // Extract.
162
+ mac.init(SecretKeySpec(effectiveSalt, "HmacSHA256"))
163
+ val prk = mac.doFinal(ikm)
164
+ // Expand.
165
+ mac.init(SecretKeySpec(prk, "HmacSHA256"))
166
+ val output = ByteArray(length)
167
+ var t = ByteArray(0)
168
+ var offset = 0
169
+ var counter: Byte = 1
170
+ while (offset < length) {
171
+ mac.reset()
172
+ mac.init(SecretKeySpec(prk, "HmacSHA256"))
173
+ mac.update(t)
174
+ mac.update(info)
175
+ mac.update(counter)
176
+ t = mac.doFinal()
177
+ val take = minOf(t.size, length - offset)
178
+ System.arraycopy(t, 0, output, offset, take)
179
+ offset += take
180
+ counter++
181
+ }
182
+ return output
183
+ }
184
+ }