@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.
- package/.releaserc +6 -0
- package/CHANGELOG.md +20 -3
- package/README.md +17 -0
- package/android/build.gradle +3 -0
- package/android/src/main/java/co/algorand/passkeyautofill/CreatePasskeyActivity.kt +38 -8
- package/android/src/main/java/co/algorand/passkeyautofill/GetPasskeyActivity.kt +176 -12
- package/android/src/main/java/co/algorand/passkeyautofill/Prf.kt +184 -0
- 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 +96 -44
- 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 +17 -0
- package/build/ReactNativePasskeyAutofill.types.d.ts.map +1 -1
- package/build/ReactNativePasskeyAutofill.types.js +3 -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 +136 -5
- package/ios/AutofillCredentialProvider/PasskeyCredentialStore.swift +28 -0
- package/ios/AutofillCredentialProvider/Prf.swift +89 -0
- package/ios/AutofillCredentialProvider/WebAuthn.swift +14 -1
- package/ios/ReactNativePasskeyAutofillModule.swift +4 -0
- package/package.json +2 -1
- 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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,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
|
|
495
|
+
val promptInfoBuilder = BiometricPrompt.PromptInfo.Builder()
|
|
469
496
|
.setTitle("Create Passkey")
|
|
470
497
|
.setSubtitle("Authenticate to save your passkey")
|
|
471
|
-
.
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
667
|
+
val promptInfoBuilder = BiometricPrompt.PromptInfo.Builder()
|
|
506
668
|
.setTitle("Sign In")
|
|
507
669
|
.setSubtitle("Authenticate to use your passkey")
|
|
508
|
-
.
|
|
509
|
-
|
|
510
|
-
.
|
|
511
|
-
|
|
512
|
-
|
|
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
|
+
}
|