@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.
- package/CHANGELOG.md +16 -3
- package/README.md +17 -0
- package/android/build.gradle +3 -0
- package/android/src/main/java/co/algorand/passkeyautofill/CreatePasskeyActivity.kt +22 -8
- package/android/src/main/java/co/algorand/passkeyautofill/GetPasskeyActivity.kt +138 -12
- package/android/src/main/java/co/algorand/passkeyautofill/ReactNativePasskeyAutofillModule.kt +49 -0
- package/android/src/main/java/co/algorand/passkeyautofill/WebAuthn.kt +135 -0
- package/android/src/main/java/co/algorand/passkeyautofill/auth/BiometricRequirement.kt +86 -0
- package/android/src/main/java/co/algorand/passkeyautofill/credentials/CredentialRepository.kt +59 -18
- package/android/src/main/java/co/algorand/passkeyautofill/service/PasskeyAutofillCredentialProviderService.kt +27 -23
- package/app.plugin.js +102 -25
- package/build/ReactNativePasskeyAutofill.types.d.ts +2 -0
- package/build/ReactNativePasskeyAutofill.types.d.ts.map +1 -1
- package/build/ReactNativePasskeyAutofill.types.js.map +1 -1
- package/e2e/driver/capabilities.ts +3 -0
- package/e2e/driver/credentialProvider.ts +1 -1
- package/e2e/driver/fingerprint.ts +1 -1
- package/e2e/tests/passkey.test.ts +0 -1
- package/ios/AutofillCredentialProvider/BiometricRequirement.swift +34 -0
- package/ios/AutofillCredentialProvider/CredentialProviderViewController.swift +4 -2
- package/ios/AutofillCredentialProvider/PasskeyCredentialStore.swift +28 -0
- package/ios/AutofillCredentialProvider/WebAuthn.swift +14 -1
- package/ios/ReactNativePasskeyAutofillModule.swift +4 -0
- package/package.json +1 -1
- package/src/ReactNativePasskeyAutofill.types.ts +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,14 +1,27 @@
|
|
|
1
|
-
# [1.0.0-canary.
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
package/android/build.gradle
CHANGED
|
@@ -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
|
|
479
|
+
val promptInfoBuilder = BiometricPrompt.PromptInfo.Builder()
|
|
469
480
|
.setTitle("Create Passkey")
|
|
470
481
|
.setSubtitle("Authenticate to save your passkey")
|
|
471
|
-
.
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
629
|
+
val promptInfoBuilder = BiometricPrompt.PromptInfo.Builder()
|
|
506
630
|
.setTitle("Sign In")
|
|
507
631
|
.setSubtitle("Authenticate to use your passkey")
|
|
508
|
-
.
|
|
509
|
-
|
|
510
|
-
.
|
|
511
|
-
|
|
512
|
-
|
|
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)
|
package/android/src/main/java/co/algorand/passkeyautofill/ReactNativePasskeyAutofillModule.kt
CHANGED
|
@@ -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
|
+
}
|