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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/.nvmrc +1 -0
  2. package/.oxfmtrc.json +4 -0
  3. package/.oxlintrc.json +14 -0
  4. package/.releaserc +30 -0
  5. package/ARCHITECTURE.md +8 -0
  6. package/CHANGELOG.md +115 -0
  7. package/CONTRIBUTING.md +15 -5
  8. package/README.md +150 -11
  9. package/android/build.gradle +33 -10
  10. package/android/libs/repo/co/algorand/dP256Android/1.0.0/dP256Android-1.0.0.pom +9 -0
  11. package/android/src/main/AndroidManifest.xml +9 -3
  12. package/android/src/main/java/co/algorand/passkeyautofill/CreatePasskeyActivity.kt +396 -44
  13. package/android/src/main/java/co/algorand/passkeyautofill/GetPasskeyActivity.kt +583 -44
  14. package/android/src/main/java/co/algorand/passkeyautofill/Prf.kt +184 -0
  15. package/android/src/main/java/co/algorand/passkeyautofill/ReactNativePasskeyAutofillModule.kt +190 -2
  16. package/android/src/main/java/co/algorand/passkeyautofill/WebAuthn.kt +135 -0
  17. package/android/src/main/java/co/algorand/passkeyautofill/auth/BiometricRequirement.kt +86 -0
  18. package/android/src/main/java/co/algorand/passkeyautofill/credentials/Credential.kt +2 -1
  19. package/android/src/main/java/co/algorand/passkeyautofill/credentials/CredentialRepository.kt +314 -53
  20. package/android/src/main/java/co/algorand/passkeyautofill/service/PasskeyAutofillCredentialProviderService.kt +152 -38
  21. package/android/src/main/java/co/algorand/passkeyautofill/utils/PasskeyUtils.kt +119 -0
  22. package/android/src/main/res/values/styles.xml +10 -0
  23. package/app.plugin.js +593 -60
  24. package/assets/banner.png +0 -0
  25. package/build/ReactNativePasskeyAutofill.types.d.ts +41 -1
  26. package/build/ReactNativePasskeyAutofill.types.d.ts.map +1 -1
  27. package/build/ReactNativePasskeyAutofill.types.js +3 -1
  28. package/build/ReactNativePasskeyAutofill.types.js.map +1 -1
  29. package/build/ReactNativePasskeyAutofillModule.d.ts +41 -4
  30. package/build/ReactNativePasskeyAutofillModule.d.ts.map +1 -1
  31. package/build/ReactNativePasskeyAutofillModule.js +21 -2
  32. package/build/ReactNativePasskeyAutofillModule.js.map +1 -1
  33. package/build/index.d.ts +2 -2
  34. package/build/index.d.ts.map +1 -1
  35. package/build/index.js +2 -2
  36. package/build/index.js.map +1 -1
  37. package/e2e/README.md +155 -0
  38. package/e2e/driver/adb.ts +73 -0
  39. package/e2e/driver/capabilities.ts +106 -0
  40. package/e2e/driver/credentialProvider.ts +104 -0
  41. package/e2e/driver/fingerprint.ts +885 -0
  42. package/e2e/driver/selectors.ts +103 -0
  43. package/e2e/driver/session.ts +25 -0
  44. package/e2e/jest.config.ts +20 -0
  45. package/e2e/package.json +11 -0
  46. package/e2e/tests/passkey.test.ts +190 -0
  47. package/e2e/tsconfig.json +16 -0
  48. package/ios/AutofillCredentialProvider/BiometricRequirement.swift +34 -0
  49. package/ios/AutofillCredentialProvider/CredentialProviderViewController.swift +570 -0
  50. package/ios/AutofillCredentialProvider/PasskeyAutofillCredentialProvider-Bridging-Header.h +1 -0
  51. package/ios/AutofillCredentialProvider/PasskeyCredentialStore.swift +612 -0
  52. package/ios/AutofillCredentialProvider/PasskeyKeystoreMMKV.h +25 -0
  53. package/ios/AutofillCredentialProvider/PasskeyKeystoreMMKV.mm +86 -0
  54. package/ios/AutofillCredentialProvider/Prf.swift +89 -0
  55. package/ios/AutofillCredentialProvider/WebAuthn.swift +186 -0
  56. package/ios/ReactNativePasskeyAutofill.podspec +11 -2
  57. package/ios/ReactNativePasskeyAutofillModule.swift +170 -15
  58. package/lefthook.yml +14 -0
  59. package/package.json +115 -25
  60. package/pnpm-workspace.yaml +36 -0
  61. package/src/ReactNativePasskeyAutofill.types.ts +38 -0
  62. package/src/ReactNativePasskeyAutofillModule.ts +66 -3
  63. package/src/index.ts +2 -2
  64. package/tsconfig.json +2 -2
  65. package/.eslintrc.js +0 -5
  66. /package/android/libs/{dP256Android-release.aar → repo/co/algorand/dP256Android/1.0.0/dP256Android-1.0.0.aar} +0 -0
@@ -6,7 +6,12 @@ import android.content.Intent
6
6
  import android.os.Build
7
7
  import android.os.Bundle
8
8
  import android.util.Log
9
+ import android.graphics.Color
10
+ import android.graphics.Typeface
11
+ import android.view.Gravity
9
12
  import android.widget.Button
13
+ import android.widget.ImageView
14
+ import android.widget.LinearLayout
10
15
  import android.widget.TextView
11
16
  import androidx.annotation.RequiresApi
12
17
  import androidx.appcompat.app.AppCompatActivity
@@ -17,10 +22,18 @@ import androidx.credentials.provider.ProviderCreateCredentialRequest
17
22
  import androidx.credentials.webauthn.AuthenticatorAttestationResponse
18
23
  import androidx.credentials.webauthn.FidoPublicKeyCredential
19
24
  import androidx.credentials.webauthn.PublicKeyCredentialCreationOptions
25
+ import co.algorand.passkeyautofill.auth.BiometricRequirement
20
26
  import co.algorand.passkeyautofill.credentials.CredentialRepository
21
27
  import co.algorand.passkeyautofill.credentials.Credential
28
+ import co.algorand.passkeyautofill.utils.PasskeyUtils
22
29
  import java.security.KeyPair
23
30
  import android.util.Base64 as AndroidBase64
31
+ import androidx.biometric.BiometricPrompt
32
+ import androidx.core.content.ContextCompat
33
+ import androidx.lifecycle.lifecycleScope
34
+ import kotlin.coroutines.resume
35
+ import kotlin.coroutines.suspendCoroutine
36
+ import kotlinx.coroutines.launch
24
37
  import org.json.JSONObject
25
38
 
26
39
  @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
@@ -31,72 +44,306 @@ class CreatePasskeyActivity : AppCompatActivity() {
31
44
  const val TAG = "CreatePasskeyActivity"
32
45
  }
33
46
 
34
- private var origin: String = "unknown-origin"
47
+ private var origin: String = "unknown-origin" // Derivation origin (rpId or hash)
48
+ private var displayOrigin: String = "unknown-origin"
35
49
  private var userHandle: String = "unknown-user"
36
50
  private var userName: String = "User"
37
51
  private var userId: String = "unknown-id"
52
+ private var userVerification: String = "preferred"
53
+ private var bundleRequestJson: String? = null
38
54
  private var request: ProviderCreateCredentialRequest? = null
55
+ private var biometricPromptResult: Any? = null
56
+ private var systemUnlockedCipher: javax.crypto.Cipher? = null
57
+ private var isHandling: Boolean = false
39
58
 
40
59
  override fun onCreate(savedInstanceState: Bundle?) {
41
60
  super.onCreate(savedInstanceState)
61
+ Log.i(TAG, "onCreate started")
42
62
 
43
- request = PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)
63
+ request = try {
64
+ PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)
65
+ } catch (e: Exception) {
66
+ Log.e(TAG, "Error retrieving request from intent", e)
67
+ null
68
+ }
69
+ Log.i(TAG, "Retrieved request: $request")
70
+
71
+ // Check for system-provided biometric result (Single Tap flow)
72
+ try {
73
+ val biometricResult = request?.biometricPromptResult
74
+ Log.i(TAG, "biometricResult from system: $biometricResult")
75
+ if (biometricResult != null) {
76
+ this.biometricPromptResult = biometricResult
77
+ val authResult = biometricResult.authenticationResult
78
+ Log.i(TAG, "authResult from system: $authResult (${authResult?.javaClass?.name})")
79
+
80
+ // Also try to find it in the biometricResult object itself
81
+ systemUnlockedCipher = if (authResult != null) {
82
+ PasskeyUtils.extractCipher(authResult)
83
+ } else {
84
+ PasskeyUtils.extractCipher(biometricResult)
85
+ }
86
+ Log.i(TAG, "systemUnlockedCipher from system: $systemUnlockedCipher")
87
+ }
88
+ } catch (e: Exception) {
89
+ Log.e(TAG, "Error processing biometricPromptResult", e)
90
+ }
91
+
92
+ val credentialData = intent.getBundleExtra("CREDENTIAL_DATA")
93
+
94
+ if (credentialData != null) {
95
+ bundleRequestJson = credentialData.getString("requestJson")
96
+ userVerification = credentialData.getString("userVerification") ?: "preferred"
97
+ }
98
+
44
99
  if (request != null) {
45
100
  try {
101
+ val systemOrigin = credentialRepository.getOrigin(request!!.callingAppInfo)
102
+ origin = systemOrigin
103
+ displayOrigin = systemOrigin
104
+
46
105
  val publicKeyRequest = request!!.callingRequest as CreatePublicKeyCredentialRequest
47
- val requestJson = JSONObject(publicKeyRequest.requestJson)
106
+ val rawJson = bundleRequestJson ?: publicKeyRequest.requestJson
107
+ val requestJson = JSONObject(rawJson)
108
+ val pkJson = if (requestJson.has("publicKey")) requestJson.getJSONObject("publicKey") else requestJson
48
109
 
49
110
  // Use RP ID as origin for derivation if available, otherwise fallback to app signature
50
- val rpId = requestJson.optJSONObject("rp")?.optString("id")
51
- origin = rpId ?: credentialRepository.getOrigin(request!!.callingAppInfo)
111
+ val rpId = pkJson.optJSONObject("rp")?.optString("id")
112
+ if (rpId != null && rpId.isNotEmpty()) {
113
+ origin = rpId
114
+ displayOrigin = rpId
115
+ }
52
116
 
53
- val userJson = requestJson.optJSONObject("user")
54
- userHandle = userJson?.optString("name") ?: "unknown-user"
55
- userName = userJson?.optString("displayName") ?: userHandle
56
- userId = userJson?.optString("id") ?: "unknown-id"
117
+ userHandle = pkJson.optJSONObject("user")?.optString("name") ?: "unknown-user"
118
+ userName = pkJson.optJSONObject("user")?.optString("displayName") ?: userHandle
119
+ userId = pkJson.optJSONObject("user")?.optString("id") ?: "unknown-id"
57
120
  } catch (e: Exception) {
58
121
  Log.e(TAG, "Error parsing request", e)
59
122
  }
60
123
  }
61
124
 
62
- // Simple UI for demonstration
63
- val layout = android.widget.LinearLayout(this).apply {
64
- orientation = android.widget.LinearLayout.VERTICAL
65
- setPadding(50, 50, 50, 50)
125
+ // If the system already showed a biometric prompt (Single Tap), proceed automatically
126
+ if (biometricPromptResult != null) {
127
+ Log.d(TAG, "System already showed biometric prompt (Single Tap), proceeding automatically")
128
+ handleCreation()
129
+ return
130
+ }
131
+
132
+ // Auto-trigger creation flow
133
+ handleCreation()
134
+ }
135
+
136
+ private fun setupUI() {
137
+ if (isFinishing || isDestroyed) return
138
+
139
+ // Improved UI
140
+ val layout = LinearLayout(this).apply {
141
+ val padding = (32 * resources.displayMetrics.density).toInt()
142
+ orientation = LinearLayout.VERTICAL
143
+ setPadding(padding, padding, padding, padding)
144
+ gravity = Gravity.CENTER_HORIZONTAL
145
+ setBackgroundColor(Color.WHITE)
66
146
  }
67
147
 
148
+ // Header with App Icon and Provider Label
149
+ try {
150
+ val appInfo = packageManager.getApplicationInfo(packageName, 0)
151
+ val appIcon = packageManager.getApplicationIcon(appInfo)
152
+ val appLabel = packageManager.getApplicationLabel(appInfo)
153
+
154
+ val density = resources.displayMetrics.density
155
+ val iconSize = (48 * density).toInt()
156
+ val iconMargin = (12 * density).toInt()
157
+ val headerPadding = (40 * density).toInt()
158
+
159
+ val header = LinearLayout(this).apply {
160
+ orientation = LinearLayout.HORIZONTAL
161
+ gravity = Gravity.CENTER_VERTICAL
162
+ setPadding(0, 0, 0, headerPadding)
163
+ }
164
+
165
+ val iconView = ImageView(this).apply {
166
+ setImageDrawable(appIcon)
167
+ layoutParams = LinearLayout.LayoutParams(iconSize, iconSize).apply {
168
+ marginEnd = iconMargin
169
+ }
170
+ }
171
+ header.addView(iconView)
172
+
173
+ val providerLabel = TextView(this).apply {
174
+ text = appLabel
175
+ textSize = 16f
176
+ typeface = Typeface.DEFAULT_BOLD
177
+ setTextColor(Color.DKGRAY)
178
+ }
179
+ header.addView(providerLabel)
180
+ layout.addView(header)
181
+ } catch (e: Exception) {
182
+ // Fallback if app info cannot be loaded
183
+ }
184
+
68
185
  val title = TextView(this).apply {
69
- text = "Create New Passkey"
70
- textSize = 24f
71
- setPadding(0, 0, 0, 50)
186
+ text = "Create Passkey"
187
+ textSize = 28f
188
+ typeface = Typeface.DEFAULT_BOLD
189
+ setPadding(0, 0, 0, (8 * resources.displayMetrics.density).toInt())
190
+ setTextColor(Color.BLACK)
72
191
  }
73
192
  layout.addView(title)
74
193
 
75
- val info = TextView(this).apply {
76
- text = "For: $userName\nAt: $origin"
77
- setPadding(0, 0, 0, 50)
194
+ val description = TextView(this).apply {
195
+ text = "Create a new passkey for $displayOrigin"
196
+ textSize = 16f
197
+ setPadding(0, 0, 0, (40 * resources.displayMetrics.density).toInt())
198
+ gravity = Gravity.CENTER_HORIZONTAL
199
+ setTextColor(Color.GRAY)
78
200
  }
79
- layout.addView(info)
201
+ layout.addView(description)
202
+
203
+ val userInfoContainer = LinearLayout(this).apply {
204
+ orientation = LinearLayout.VERTICAL
205
+ setPadding(0, 0, 0, (50 * resources.displayMetrics.density).toInt())
206
+ gravity = Gravity.CENTER_HORIZONTAL
207
+ }
208
+
209
+ val userLabel = TextView(this).apply {
210
+ text = "ACCOUNT"
211
+ textSize = 12f
212
+ typeface = Typeface.DEFAULT_BOLD
213
+ setTextColor(Color.GRAY)
214
+ setPadding(0, 0, 0, (4 * resources.displayMetrics.density).toInt())
215
+ }
216
+ userInfoContainer.addView(userLabel)
217
+
218
+ val userValue = TextView(this).apply {
219
+ text = userName
220
+ textSize = 20f
221
+ typeface = Typeface.DEFAULT_BOLD
222
+ setTextColor(Color.BLACK)
223
+ }
224
+ userInfoContainer.addView(userValue)
225
+
226
+ if (userHandle != userName) {
227
+ val handleValue = TextView(this).apply {
228
+ text = userHandle
229
+ textSize = 14f
230
+ setTextColor(Color.GRAY)
231
+ }
232
+ userInfoContainer.addView(handleValue)
233
+ }
234
+ layout.addView(userInfoContainer)
80
235
 
81
236
  val confirmButton = Button(this).apply {
82
237
  text = "Create Passkey"
238
+ // Stable accessibility id for E2E tests (`~create-passkey-confirm`).
239
+ // The Create Passkey activity intermittently appears on top of the
240
+ // system biometric prompt, so tests drive this explicit id rather
241
+ // than depending on localised button text.
242
+ contentDescription = "create-passkey-confirm"
83
243
  setOnClickListener {
84
244
  handleCreation()
85
245
  }
246
+ layoutParams = LinearLayout.LayoutParams(
247
+ LinearLayout.LayoutParams.MATCH_PARENT,
248
+ LinearLayout.LayoutParams.WRAP_CONTENT
249
+ ).apply {
250
+ setMargins(0, 0, 0, (8 * resources.displayMetrics.density).toInt())
251
+ }
86
252
  }
87
253
  layout.addView(confirmButton)
88
254
 
255
+ val cancelButton = Button(this, null, android.R.attr.borderlessButtonStyle).apply {
256
+ text = "Cancel"
257
+ contentDescription = "create-passkey-cancel"
258
+ setOnClickListener {
259
+ setResult(RESULT_CANCELED)
260
+ finish()
261
+ }
262
+ layoutParams = LinearLayout.LayoutParams(
263
+ LinearLayout.LayoutParams.MATCH_PARENT,
264
+ LinearLayout.LayoutParams.WRAP_CONTENT
265
+ )
266
+ }
267
+ layout.addView(cancelButton)
268
+
89
269
  setContentView(layout)
270
+
271
+ // Ensure the activity is focusable and can receive touches
272
+ layout.isFocusable = true
273
+ layout.isFocusableInTouchMode = true
274
+ layout.requestFocus()
90
275
  }
91
276
 
92
277
  @SuppressLint("RestrictedApi")
93
278
  private fun handleCreation() {
94
- try {
95
- val req = request ?: throw IllegalStateException("No request found")
96
- val publicKeyRequest = req.callingRequest as CreatePublicKeyCredentialRequest
97
- val requestOptions = PublicKeyCredentialCreationOptions(publicKeyRequest.requestJson)
279
+ if (isHandling) return
280
+ isHandling = true
281
+ Log.d(TAG, "handleCreation started, userVerification=$userVerification")
282
+ lifecycleScope.launch {
283
+ val cipherToUse = systemUnlockedCipher ?: run {
284
+ if (biometricPromptResult != null) {
285
+ try {
286
+ val fallback = credentialRepository.getBiometricCipherForEncryption(this@CreatePasskeyActivity, BiometricRequirement.resolve(this@CreatePasskeyActivity))
287
+ Log.i(TAG, "Successfully obtained fallback cipher from repository (Single Tap timeout)")
288
+ fallback
289
+ } catch (e: Exception) {
290
+ Log.d(TAG, "Fallback cipher failed: ${e.message}")
291
+ null
292
+ }
293
+ } else {
294
+ null
295
+ }
296
+ } ?: run {
297
+ // Only prompt for biometrics if userVerification is "required"
298
+ // For "preferred" and "discouraged", we can proceed without a manual biometric prompt
299
+ if (userVerification == "required") {
300
+ Log.i(TAG, "userVerification is required, running manual biometrics")
301
+ val result = biometrics(true)
302
+ Log.d(TAG, "Manual biometrics result: $result")
303
+ if (result == null) {
304
+ Log.w(TAG, "Biometrics failed or was canceled")
305
+ setupUI() // Show UI as fallback
306
+ isHandling = false
307
+ return@launch
308
+ }
309
+ result.cryptoObject?.cipher
310
+ } else {
311
+ Log.d(TAG, "userVerification is $userVerification, skipping manual biometrics")
312
+ null
313
+ }
314
+ }
315
+
316
+ Log.d(TAG, "cipherToUse: $cipherToUse")
317
+
318
+ var finalCipher = cipherToUse
98
319
 
99
- val keyPair: KeyPair = credentialRepository.createDeterministicKeyPair(this, origin, userHandle)
320
+ try {
321
+ val req = request ?: throw IllegalStateException("No request found")
322
+ Log.d(TAG, "Request found, origin: $origin")
323
+
324
+ // Prefer using the request JSON from the bundle if available
325
+ val rawRequestJson = bundleRequestJson ?: run {
326
+ val publicKeyRequest = req.callingRequest as CreatePublicKeyCredentialRequest
327
+ publicKeyRequest.requestJson
328
+ }
329
+
330
+ val requestJson = JSONObject(rawRequestJson)
331
+ val passkeyReqJson = if (requestJson.has("publicKey")) {
332
+ requestJson.getJSONObject("publicKey").toString()
333
+ } else {
334
+ rawRequestJson
335
+ }
336
+ Log.d(TAG, "Passkey request JSON: $passkeyReqJson")
337
+ val requestOptions = try {
338
+ PublicKeyCredentialCreationOptions(passkeyReqJson)
339
+ } catch (e: org.json.JSONException) {
340
+ Log.e(TAG, "Invalid passkey creation request JSON: $passkeyReqJson")
341
+ throw e
342
+ }
343
+
344
+ Log.d(TAG, "Creating deterministic key pair")
345
+ val keyPair: KeyPair = credentialRepository.createDeterministicKeyPair(this@CreatePasskeyActivity, origin, userHandle)
346
+ Log.d(TAG, "Generating credential ID")
100
347
  val credentialId = credentialRepository.generateCredentialId(keyPair)
101
348
  val credentialIdBase64 = AndroidBase64.encodeToString(credentialId, AndroidBase64.NO_WRAP)
102
349
 
@@ -110,8 +357,27 @@ class CreatePasskeyActivity : AppCompatActivity() {
110
357
  count = 0
111
358
  )
112
359
 
113
- credentialRepository.saveCredential(this, credential)
360
+ Log.d(TAG, "Saving credential to repository")
361
+ try {
362
+ credentialRepository.saveCredential(this@CreatePasskeyActivity, credential, finalCipher)
363
+ } catch (e: Exception) {
364
+ if (e.message?.contains("user not authenticated", ignoreCase = true) == true ||
365
+ e.cause?.message?.contains("user not authenticated", ignoreCase = true) == true) {
366
+ Log.i(TAG, "Key is locked, triggering manual biometric prompt")
367
+ val result = biometrics(true)
368
+ if (result != null) {
369
+ finalCipher = result.cryptoObject?.cipher
370
+ Log.i(TAG, "Retrying save with manual biometric cipher")
371
+ credentialRepository.saveCredential(this@CreatePasskeyActivity, credential, finalCipher)
372
+ } else {
373
+ throw e
374
+ }
375
+ } else {
376
+ throw e
377
+ }
378
+ }
114
379
 
380
+ Log.d(TAG, "Building AuthenticatorAttestationResponse")
115
381
  val response = AuthenticatorAttestationResponse(
116
382
  requestOptions = requestOptions,
117
383
  credentialId = credentialId,
@@ -131,25 +397,61 @@ class CreatePasskeyActivity : AppCompatActivity() {
131
397
  )
132
398
 
133
399
  val resultIntent = Intent()
134
- val responseJson = JSONObject(fidoCredential.json())
135
- val passkeyDetails = JSONObject()
136
-
137
- val ecPubKey = keyPair.public as java.security.interfaces.ECPublicKey
138
- val ecPrivKey = keyPair.private as java.security.interfaces.ECPrivateKey
139
-
140
- fun to32(bi: java.math.BigInteger): ByteArray {
141
- var ba = bi.toByteArray()
142
- if (ba.size < 32) {
143
- ba = ByteArray(32 - ba.size) + ba
400
+ val fullJson = JSONObject(fidoCredential.json())
401
+ val respJson = fullJson.getJSONObject("response")
402
+
403
+ // Ensure compact clientDataJSON for registration too
404
+ val sanitizedOrigin = credentialRepository.getOrigin(req.callingAppInfo).replace(Regex("/$"), "")
405
+ val challenge = if (requestJson.has("publicKey")) {
406
+ requestJson.getJSONObject("publicKey").getString("challenge")
407
+ } else {
408
+ requestJson.getString("challenge")
409
+ }
410
+
411
+ Log.d(TAG, "Building clientDataJSON, challenge: $challenge, origin: $sanitizedOrigin")
412
+ val clientDataJSONString = if (sanitizedOrigin.startsWith("https://") || sanitizedOrigin.startsWith("http://")) {
413
+ "{\"type\":\"webauthn.create\",\"challenge\":\"$challenge\",\"origin\":\"$sanitizedOrigin\",\"crossOrigin\":false}"
414
+ } else {
415
+ val json = JSONObject()
416
+ json.put("type", "webauthn.create")
417
+ json.put("challenge", challenge)
418
+ json.put("origin", sanitizedOrigin)
419
+ json.put("crossOrigin", false)
420
+ if (sanitizedOrigin.startsWith("android:apk-key-hash:")) {
421
+ json.put("androidPackageName", req.callingAppInfo.packageName)
144
422
  }
145
- return ba.copyOfRange(ba.size - 32, ba.size)
423
+ json.toString()
424
+ }
425
+ val clientDataJSONb64 = AndroidBase64.encodeToString(clientDataJSONString.toByteArray(), AndroidBase64.URL_SAFE or AndroidBase64.NO_WRAP or AndroidBase64.NO_PADDING)
426
+ respJson.put("clientDataJSON", clientDataJSONb64)
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")
146
451
  }
147
-
148
- val x = to32(ecPubKey.w.affineX)
149
- val y = to32(ecPubKey.w.affineY)
150
- val s = to32(ecPrivKey.s)
151
452
 
152
- val createResponse = CreatePublicKeyCredentialResponse(responseJson.toString())
453
+ val createResponse = CreatePublicKeyCredentialResponse(fullJson.toString())
454
+ Log.d(TAG, "CreatePublicKeyCredentialResponse: ${fullJson.toString()}")
153
455
 
154
456
  PendingIntentHandler.setCreateCredentialResponse(
155
457
  resultIntent,
@@ -157,10 +459,60 @@ class CreatePasskeyActivity : AppCompatActivity() {
157
459
  )
158
460
 
159
461
  setResult(Activity.RESULT_OK, resultIntent)
462
+ Log.d(TAG, "Result set to OK")
463
+ ReactNativePasskeyAutofillModule.instance?.sendEvent("onPasskeyAdded", Bundle().apply {
464
+ putBoolean("success", true)
465
+ })
466
+ finish()
160
467
  } catch (e: Exception) {
161
468
  Log.e(TAG, "Error during passkey creation", e)
162
- setResult(Activity.RESULT_CANCELED)
469
+ setupUI() // Show UI on error
470
+ isHandling = false
471
+ }
472
+ }
473
+ }
474
+
475
+ private suspend fun biometrics(needsCipher: Boolean): BiometricPrompt.AuthenticationResult? {
476
+ val requirement = BiometricRequirement.resolve(this)
477
+ return suspendCoroutine { continuation ->
478
+ val biometricPrompt = BiometricPrompt(
479
+ this,
480
+ ContextCompat.getMainExecutor(this),
481
+ object : BiometricPrompt.AuthenticationCallback() {
482
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
483
+ super.onAuthenticationSucceeded(result)
484
+ continuation.resume(result)
485
+ }
486
+ override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
487
+ super.onAuthenticationError(errorCode, errString)
488
+ continuation.resume(null)
489
+ }
490
+ override fun onAuthenticationFailed() {
491
+ super.onAuthenticationFailed()
492
+ }
493
+ }
494
+ )
495
+ val promptInfoBuilder = BiometricPrompt.PromptInfo.Builder()
496
+ .setTitle("Create Passkey")
497
+ .setSubtitle("Authenticate to save your passkey")
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) {
506
+ try {
507
+ val cipher = credentialRepository.getBiometricCipherForEncryption(this, requirement)
508
+ biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
509
+ } catch (e: Exception) {
510
+ Log.e(TAG, "Failed to initialize biometric prompt with cipher", e)
511
+ biometricPrompt.authenticate(promptInfo)
512
+ }
513
+ } else {
514
+ biometricPrompt.authenticate(promptInfo)
515
+ }
163
516
  }
164
- finish()
165
517
  }
166
518
  }