@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
@@ -18,10 +23,20 @@ import androidx.credentials.provider.ProviderGetCredentialRequest
18
23
  import androidx.credentials.webauthn.AuthenticatorAssertionResponse
19
24
  import androidx.credentials.webauthn.FidoPublicKeyCredential
20
25
  import androidx.credentials.webauthn.PublicKeyCredentialRequestOptions
26
+ import co.algorand.passkeyautofill.auth.BiometricRequirement
21
27
  import co.algorand.passkeyautofill.credentials.CredentialRepository
22
28
  import co.algorand.passkeyautofill.credentials.Credential
29
+ import co.algorand.passkeyautofill.utils.PasskeyUtils
23
30
  import java.security.KeyPair
31
+ import java.security.MessageDigest
24
32
  import android.util.Base64 as AndroidBase64
33
+ import androidx.biometric.BiometricManager
34
+ import androidx.biometric.BiometricPrompt
35
+ import androidx.core.content.ContextCompat
36
+ import androidx.lifecycle.lifecycleScope
37
+ import kotlin.coroutines.resume
38
+ import kotlin.coroutines.suspendCoroutine
39
+ import kotlinx.coroutines.launch
25
40
  import org.json.JSONObject
26
41
 
27
42
  @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
@@ -33,70 +48,475 @@ class GetPasskeyActivity : AppCompatActivity() {
33
48
  }
34
49
 
35
50
  private var origin: String = "unknown-origin"
51
+ private var displayOrigin: String = "unknown-origin"
36
52
  private var userHandle: String = "unknown-user"
37
53
  private var credentialIdEnc: String? = null
54
+ private var userVerification: String = "preferred"
55
+ private var bundleRequestJson: String? = null
38
56
  private var request: ProviderGetCredentialRequest? = null
57
+ private var biometricPromptResult: Any? = null
58
+ private var systemUnlockedCipher: javax.crypto.Cipher? = null
59
+ private var isHandling: Boolean = false
39
60
 
40
61
  override fun onCreate(savedInstanceState: Bundle?) {
41
62
  super.onCreate(savedInstanceState)
63
+ Log.i(TAG, "onCreate started")
42
64
 
43
- request = PendingIntentHandler.retrieveProviderGetCredentialRequest(intent)
44
- val credentialData = intent.getBundleExtra("CREDENTIAL_DATA")
65
+ request = try {
66
+ PendingIntentHandler.retrieveProviderGetCredentialRequest(intent)
67
+ } catch (e: Exception) {
68
+ Log.e(TAG, "Error retrieving request from intent", e)
69
+ null
70
+ }
71
+ Log.i(TAG, "Retrieved request: $request")
45
72
 
46
- if (request != null && credentialData != null) {
47
- origin = credentialRepository.getOrigin(request!!.callingAppInfo)
73
+ // Check for system-provided biometric result (Single Tap flow)
74
+ try {
75
+ val biometricResult = request?.biometricPromptResult
76
+ Log.i(TAG, "biometricResult from system: $biometricResult")
77
+ if (biometricResult != null) {
78
+ this.biometricPromptResult = biometricResult
79
+ val authResult = biometricResult.authenticationResult
80
+ Log.i(TAG, "authResult from system: $authResult (${authResult?.javaClass?.name})")
81
+
82
+ // Also try to find it in the biometricResult object itself
83
+ systemUnlockedCipher = if (authResult != null) {
84
+ PasskeyUtils.extractCipher(authResult)
85
+ } else {
86
+ PasskeyUtils.extractCipher(biometricResult)
87
+ }
88
+ Log.i(TAG, "systemUnlockedCipher from system: $systemUnlockedCipher")
89
+ }
90
+ } catch (e: Exception) {
91
+ Log.e(TAG, "Error processing biometricPromptResult", e)
92
+ }
93
+
94
+ val credentialData = intent.getBundleExtra("CREDENTIAL_DATA")
95
+
96
+ if (credentialData != null) {
48
97
  credentialIdEnc = credentialData.getString("credentialId")
49
98
  userHandle = credentialData.getString("userHandle") ?: "unknown-user"
99
+ bundleRequestJson = credentialData.getString("requestJson")
100
+ userVerification = credentialData.getString("userVerification") ?: "preferred"
101
+ }
102
+
103
+ if (request != null) {
104
+ origin = credentialRepository.getOrigin(request!!.callingAppInfo)
105
+ displayOrigin = origin
106
+
107
+ // Try to extract rpId for better display
108
+ val rawJson = bundleRequestJson ?: (request?.credentialOptions?.get(0) as? GetPublicKeyCredentialOption)?.requestJson
109
+ if (rawJson != null) {
110
+ try {
111
+ val jsonObj = JSONObject(rawJson)
112
+ val pkJson = if (jsonObj.has("publicKey")) jsonObj.getJSONObject("publicKey") else jsonObj
113
+ val rpId = pkJson.optString("rpId")
114
+ if (rpId.isNotEmpty()) {
115
+ displayOrigin = rpId
116
+ }
117
+ } catch (e: Exception) {
118
+ // ignore
119
+ }
120
+ }
50
121
  }
51
122
 
52
- // Simple UI
53
- val layout = android.widget.LinearLayout(this).apply {
54
- orientation = android.widget.LinearLayout.VERTICAL
55
- setPadding(50, 50, 50, 50)
123
+ // If the system already showed a biometric prompt (Single Tap), proceed automatically
124
+ if (biometricPromptResult != null) {
125
+ Log.d(TAG, "System already showed biometric prompt (Single Tap), proceeding automatically")
126
+ handleAssertion()
127
+ return
56
128
  }
129
+
130
+ // Auto-trigger assertion flow
131
+ handleAssertion()
132
+ }
133
+
134
+ private fun setupUI() {
135
+ if (isFinishing || isDestroyed) return
57
136
 
137
+ // Improved UI
138
+ val layout = LinearLayout(this).apply {
139
+ val padding = (32 * resources.displayMetrics.density).toInt()
140
+ orientation = LinearLayout.VERTICAL
141
+ setPadding(padding, padding, padding, padding)
142
+ gravity = Gravity.CENTER_HORIZONTAL
143
+ setBackgroundColor(Color.WHITE)
144
+ }
145
+
146
+ // Header with App Icon and Provider Label
147
+ try {
148
+ val appInfo = packageManager.getApplicationInfo(packageName, 0)
149
+ val appIcon = packageManager.getApplicationIcon(appInfo)
150
+ val appLabel = packageManager.getApplicationLabel(appInfo)
151
+
152
+ val density = resources.displayMetrics.density
153
+ val iconSize = (48 * density).toInt()
154
+ val iconMargin = (12 * density).toInt()
155
+ val headerPadding = (40 * density).toInt()
156
+
157
+ val header = LinearLayout(this).apply {
158
+ orientation = LinearLayout.HORIZONTAL
159
+ gravity = Gravity.CENTER_VERTICAL
160
+ setPadding(0, 0, 0, headerPadding)
161
+ }
162
+
163
+ val iconView = ImageView(this).apply {
164
+ setImageDrawable(appIcon)
165
+ layoutParams = LinearLayout.LayoutParams(iconSize, iconSize).apply {
166
+ marginEnd = iconMargin
167
+ }
168
+ }
169
+ header.addView(iconView)
170
+
171
+ val providerLabel = TextView(this).apply {
172
+ text = appLabel
173
+ textSize = 16f
174
+ typeface = Typeface.DEFAULT_BOLD
175
+ setTextColor(Color.DKGRAY)
176
+ }
177
+ header.addView(providerLabel)
178
+ layout.addView(header)
179
+ } catch (e: Exception) {
180
+ // Fallback if app info cannot be loaded
181
+ }
182
+
58
183
  val title = TextView(this).apply {
59
184
  text = "Use Passkey"
60
- textSize = 24f
61
- setPadding(0, 0, 0, 50)
185
+ textSize = 28f
186
+ typeface = Typeface.DEFAULT_BOLD
187
+ setPadding(0, 0, 0, (8 * resources.displayMetrics.density).toInt())
188
+ setTextColor(Color.BLACK)
62
189
  }
63
190
  layout.addView(title)
64
191
 
65
- val info = TextView(this).apply {
66
- text = "User: $userHandle\nAt: $origin"
67
- setPadding(0, 0, 0, 50)
192
+ val description = TextView(this).apply {
193
+ text = "Sign in to $displayOrigin"
194
+ textSize = 16f
195
+ setPadding(0, 0, 0, (40 * resources.displayMetrics.density).toInt())
196
+ gravity = Gravity.CENTER_HORIZONTAL
197
+ setTextColor(Color.GRAY)
68
198
  }
69
- layout.addView(info)
70
-
199
+ layout.addView(description)
200
+
201
+ val userInfoContainer = LinearLayout(this).apply {
202
+ orientation = LinearLayout.VERTICAL
203
+ setPadding(0, 0, 0, (50 * resources.displayMetrics.density).toInt())
204
+ gravity = Gravity.CENTER_HORIZONTAL
205
+ }
206
+
207
+ val userLabel = TextView(this).apply {
208
+ text = "ACCOUNT"
209
+ textSize = 12f
210
+ typeface = Typeface.DEFAULT_BOLD
211
+ setTextColor(Color.GRAY)
212
+ setPadding(0, 0, 0, (4 * resources.displayMetrics.density).toInt())
213
+ }
214
+ userInfoContainer.addView(userLabel)
215
+
216
+ val userValue = TextView(this).apply {
217
+ text = userHandle
218
+ textSize = 20f
219
+ typeface = Typeface.DEFAULT_BOLD
220
+ setTextColor(Color.BLACK)
221
+ }
222
+ userInfoContainer.addView(userValue)
223
+ layout.addView(userInfoContainer)
224
+
71
225
  val confirmButton = Button(this).apply {
72
226
  text = "Sign In"
227
+ // Stable accessibility id for E2E tests (`~get-passkey-confirm`).
228
+ contentDescription = "get-passkey-confirm"
73
229
  setOnClickListener {
74
230
  handleAssertion()
75
231
  }
232
+ layoutParams = LinearLayout.LayoutParams(
233
+ LinearLayout.LayoutParams.MATCH_PARENT,
234
+ LinearLayout.LayoutParams.WRAP_CONTENT
235
+ ).apply {
236
+ setMargins(0, 0, 0, (8 * resources.displayMetrics.density).toInt())
237
+ }
76
238
  }
77
239
  layout.addView(confirmButton)
78
240
 
241
+ val cancelButton = Button(this, null, android.R.attr.borderlessButtonStyle).apply {
242
+ text = "Cancel"
243
+ contentDescription = "get-passkey-cancel"
244
+ setOnClickListener {
245
+ setResult(RESULT_CANCELED)
246
+ finish()
247
+ }
248
+ layoutParams = LinearLayout.LayoutParams(
249
+ LinearLayout.LayoutParams.MATCH_PARENT,
250
+ LinearLayout.LayoutParams.WRAP_CONTENT
251
+ )
252
+ }
253
+ layout.addView(cancelButton)
254
+
255
+ setContentView(layout)
256
+
257
+ // Ensure the activity is focusable and can receive touches
258
+ layout.isFocusable = true
259
+ layout.isFocusableInTouchMode = true
260
+ layout.requestFocus()
261
+ }
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
+
79
358
  setContentView(layout)
359
+
360
+ layout.isFocusable = true
361
+ layout.isFocusableInTouchMode = true
362
+ layout.requestFocus()
80
363
  }
81
364
 
82
365
  @SuppressLint("RestrictedApi")
83
366
  private fun handleAssertion() {
84
- try {
85
- val req = request ?: throw IllegalStateException("No request found")
86
- val option = req.credentialOptions[0] as GetPublicKeyCredentialOption
87
- val requestOptions = PublicKeyCredentialRequestOptions(option.requestJson)
367
+ if (isHandling) return
368
+ isHandling = true
369
+ Log.d(TAG, "handleAssertion started, userVerification=$userVerification")
370
+ lifecycleScope.launch {
371
+ val credentialData = intent.getBundleExtra("CREDENTIAL_DATA")
372
+ val biometricIv = credentialData?.getString("biometricIv")
373
+ Log.d(TAG, "biometricIv from bundle: $biometricIv")
88
374
 
89
- val credId = AndroidBase64.decode(credentialIdEnc!!, AndroidBase64.DEFAULT)
90
- val clientDataHash = option.requestData.getByteArray("androidx.credentials.BUNDLE_KEY_CLIENT_DATA_HASH")
91
- ?: throw IllegalStateException("Missing clientDataHash")
375
+ val cipherToUse = systemUnlockedCipher ?: run {
376
+ if (biometricPromptResult != null) {
377
+ try {
378
+ val requirement = BiometricRequirement.resolve(this@GetPasskeyActivity)
379
+ val fallback = if (biometricIv != null) {
380
+ credentialRepository.getBiometricCipherForDecryption(this@GetPasskeyActivity, AndroidBase64.decode(biometricIv, AndroidBase64.DEFAULT), requirement)
381
+ } else {
382
+ credentialRepository.getBiometricCipherForEncryption(this@GetPasskeyActivity, requirement)
383
+ }
384
+ Log.i(TAG, "Successfully obtained fallback cipher from repository (Single Tap timeout)")
385
+ fallback
386
+ } catch (e: Exception) {
387
+ Log.d(TAG, "Fallback cipher failed: ${e.message}")
388
+ null
389
+ }
390
+ } else {
391
+ null
392
+ }
393
+ } ?: run {
394
+ // Only prompt for manual biometrics if userVerification is "required"
395
+ // OR if the key was saved with biometric protection (biometricIv != null)
396
+ if (userVerification == "required" || biometricIv != null) {
397
+ Log.i(TAG, "Manual biometrics required (userVerification=$userVerification, biometricIv present=${biometricIv != null})")
398
+ val result = biometrics(biometricIv)
399
+ Log.d(TAG, "Manual biometrics result: $result")
400
+ if (result == null) {
401
+ Log.w(TAG, "Biometrics failed or was canceled")
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
+ }
416
+ isHandling = false
417
+ return@launch
418
+ }
419
+ result.cryptoObject?.cipher
420
+ } else {
421
+ Log.d(TAG, "userVerification is $userVerification and key is not locked, skipping manual biometrics")
422
+ null
423
+ }
424
+ }
425
+
426
+ Log.d(TAG, "cipherToUse: $cipherToUse")
92
427
 
93
- val dbCred = credentialRepository.getCredential(this, credId)
94
- ?: throw IllegalStateException("Credential not found")
428
+ var finalCipher = cipherToUse
95
429
 
430
+ try {
431
+ val req = request ?: throw IllegalStateException("No request found")
432
+ Log.d(TAG, "Request found, origin: $origin")
433
+
434
+ // Prefer using the request JSON from the bundle if available, as it's specifically for this entry
435
+ val rawRequestJson = bundleRequestJson ?: run {
436
+ val option = req.credentialOptions[0] as GetPublicKeyCredentialOption
437
+ option.requestJson
438
+ }
439
+
440
+ val requestJson = JSONObject(rawRequestJson)
441
+ val passkeyReqJson = if (requestJson.has("publicKey")) {
442
+ requestJson.getJSONObject("publicKey").toString()
443
+ } else {
444
+ rawRequestJson
445
+ }
446
+ Log.d(TAG, "Passkey request JSON: $passkeyReqJson")
447
+ val requestOptions = try {
448
+ PublicKeyCredentialRequestOptions(passkeyReqJson)
449
+ } catch (e: org.json.JSONException) {
450
+ Log.e(TAG, "Invalid passkey request JSON: $passkeyReqJson")
451
+ throw e
452
+ }
453
+
454
+ val credId = AndroidBase64.decode(credentialIdEnc!!, AndroidBase64.DEFAULT)
455
+ Log.d(TAG, "Credential ID decoded")
456
+
457
+ val passkeyRequestJsonObj = JSONObject(passkeyReqJson)
458
+ val challenge = if (passkeyRequestJsonObj.has("challenge")) {
459
+ passkeyRequestJsonObj.getString("challenge")
460
+ } else {
461
+ throw org.json.JSONException("No value for challenge in requestJson: $passkeyReqJson")
462
+ }
463
+ val sanitizedOrigin = origin.replace(Regex("/$"), "")
464
+
465
+ // Look for system-provided clientDataHash (e.g. from Chrome)
466
+ val systemClientDataHash = run {
467
+ val option = req.credentialOptions.find { opt ->
468
+ opt is GetPublicKeyCredentialOption && (opt.requestJson == rawRequestJson ||
469
+ try { JSONObject(opt.requestJson).toString() == JSONObject(rawRequestJson!!).toString() } catch(e: Exception) { false })
470
+ } as? GetPublicKeyCredentialOption
471
+ option?.requestData?.getByteArray("androidx.credentials.BUNDLE_KEY_CLIENT_DATA_HASH")
472
+ }
473
+ Log.d(TAG, "systemClientDataHash present: ${systemClientDataHash != null}")
474
+
475
+ Log.d(TAG, "Building clientDataJSON, challenge: $challenge, origin: $sanitizedOrigin")
476
+ val clientDataJSONString = if (sanitizedOrigin.startsWith("https://") || sanitizedOrigin.startsWith("http://")) {
477
+ // Compact JSON for web origins to match browser hashing (no spaces, specific order)
478
+ "{\"type\":\"webauthn.get\",\"challenge\":\"$challenge\",\"origin\":\"$sanitizedOrigin\",\"crossOrigin\":false}"
479
+ } else {
480
+ val json = JSONObject()
481
+ json.put("type", "webauthn.get")
482
+ json.put("challenge", challenge)
483
+ json.put("origin", sanitizedOrigin)
484
+ json.put("crossOrigin", false)
485
+ if (sanitizedOrigin.startsWith("android:apk-key-hash:")) {
486
+ json.put("androidPackageName", req.callingAppInfo.packageName)
487
+ }
488
+ json.toString()
489
+ }
490
+
491
+ val clientDataHash = systemClientDataHash ?: MessageDigest.getInstance("SHA-256").digest(clientDataJSONString.toByteArray(Charsets.UTF_8))
492
+
493
+ Log.d(TAG, "Getting credential from repository")
494
+ val dbCred = try {
495
+ credentialRepository.getCredential(this@GetPasskeyActivity, credId, finalCipher)
496
+ ?: throw IllegalStateException("Credential not found")
497
+ } catch (e: Exception) {
498
+ if (e.message?.contains("user not authenticated", ignoreCase = true) == true ||
499
+ e.cause?.message?.contains("user not authenticated", ignoreCase = true) == true) {
500
+ Log.i(TAG, "Key is locked, triggering manual biometric prompt")
501
+ val result = biometrics(biometricIv)
502
+ if (result != null) {
503
+ finalCipher = result.cryptoObject?.cipher
504
+ Log.i(TAG, "Retrying getCredential with manual biometric cipher")
505
+ credentialRepository.getCredential(this@GetPasskeyActivity, credId, finalCipher)
506
+ ?: throw IllegalStateException("Credential not found after manual prompt")
507
+ } else {
508
+ throw e
509
+ }
510
+ } else {
511
+ throw e
512
+ }
513
+ }
514
+
515
+ Log.d(TAG, "Building AuthenticatorAssertionResponse")
96
516
  val response = AuthenticatorAssertionResponse(
97
517
  requestOptions = requestOptions,
98
518
  credentialId = credId,
99
- origin = credentialRepository.getOrigin(req.callingAppInfo),
519
+ origin = sanitizedOrigin,
100
520
  up = true,
101
521
  uv = true,
102
522
  be = true,
@@ -106,9 +526,30 @@ class GetPasskeyActivity : AppCompatActivity() {
106
526
  clientDataHash = clientDataHash
107
527
  )
108
528
 
109
- val keyPair = credentialRepository.getKeyPair(this, credId)
110
- ?: throw IllegalStateException("No keypair found")
529
+ Log.d(TAG, "Getting key pair for signing")
530
+ val keyPair = try {
531
+ credentialRepository.getKeyPair(this@GetPasskeyActivity, credId, finalCipher)
532
+ ?: throw IllegalStateException("No keypair found")
533
+ } catch (e: Exception) {
534
+ // If we get here, it means getCredential succeeded but something went wrong with getKeyPair.
535
+ // We shouldn't need a second prompt here if we already got the cipher, but for safety:
536
+ if (e.message?.contains("user not authenticated", ignoreCase = true) == true ||
537
+ e.cause?.message?.contains("user not authenticated", ignoreCase = true) == true) {
538
+ Log.i(TAG, "Key is locked for signing, triggering manual biometric prompt")
539
+ val result = biometrics(biometricIv)
540
+ if (result != null) {
541
+ finalCipher = result.cryptoObject?.cipher
542
+ credentialRepository.getKeyPair(this@GetPasskeyActivity, credId, finalCipher)
543
+ ?: throw IllegalStateException("No keypair found after manual prompt")
544
+ } else {
545
+ throw e
546
+ }
547
+ } else {
548
+ throw e
549
+ }
550
+ }
111
551
 
552
+ Log.d(TAG, "Signing response")
112
553
  response.signature = credentialRepository.sign(keyPair, response.dataToSign())
113
554
 
114
555
  val fidoCredential = FidoPublicKeyCredential(
@@ -117,35 +558,133 @@ class GetPasskeyActivity : AppCompatActivity() {
117
558
  authenticatorAttachment = "platform"
118
559
  )
119
560
 
120
- // Manual addition of clientDataJSON if needed by some RPs, following reference
121
- val requestJson = JSONObject(option.requestJson)
122
- val challenge = requestJson.getString("challenge")
123
- val clientDataJSONb64 = getClientDataJSONb64(origin, challenge)
124
-
125
- val delimiter = "response\":{"
126
- val credentialJson = fidoCredential.json().substringBeforeLast(delimiter) + delimiter +
127
- "\"clientDataJSON\":\"$clientDataJSONb64\"," +
128
- fidoCredential.json().substringAfterLast(delimiter)
561
+ // Manual addition of clientDataJSON and signature to the response JSON
562
+ val clientDataJSONb64 = AndroidBase64.encodeToString(clientDataJSONString.toByteArray(), AndroidBase64.URL_SAFE or AndroidBase64.NO_WRAP or AndroidBase64.NO_PADDING)
563
+ val signatureb64 = AndroidBase64.encodeToString(response.signature, AndroidBase64.URL_SAFE or AndroidBase64.NO_WRAP or AndroidBase64.NO_PADDING)
564
+
565
+ val fullJson = JSONObject(fidoCredential.json())
566
+ val respJson = fullJson.getJSONObject("response")
567
+ respJson.put("clientDataJSON", clientDataJSONb64)
568
+ respJson.put("signature", signatureb64)
569
+
570
+ // Add clientExtensionResults as seen in the example
571
+ val clientExtensionResults = JSONObject()
572
+ val credProps = JSONObject()
573
+ credProps.put("rk", true)
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
+
613
+ fullJson.put("clientExtensionResults", clientExtensionResults)
614
+
615
+ val credentialJson = fullJson.toString()
616
+ Log.d(TAG, "Final credential JSON: $credentialJson")
129
617
 
130
618
  val resultIntent = Intent()
131
619
  val passkeyCredential = PublicKeyCredential(credentialJson)
132
-
620
+
133
621
  PendingIntentHandler.setGetCredentialResponse(
134
622
  resultIntent,
135
623
  GetCredentialResponse(passkeyCredential)
136
624
  )
137
-
625
+
138
626
  setResult(Activity.RESULT_OK, resultIntent)
627
+ Log.d(TAG, "Result set to OK")
628
+ credentialRepository.recordCredentialUsage(this@GetPasskeyActivity, credId)
629
+ ReactNativePasskeyAutofillModule.instance?.sendEvent("onPasskeyAuthenticated", Bundle().apply {
630
+ putBoolean("success", true)
631
+ putString("credentialId", credentialIdEnc)
632
+ })
633
+ finish()
139
634
  } catch (e: Exception) {
140
635
  Log.e(TAG, "Error during passkey assertion", e)
141
- setResult(Activity.RESULT_CANCELED)
636
+ setupErrorUI(
637
+ "Something went wrong while signing in. Please try again.",
638
+ allowRetry = true,
639
+ )
640
+ isHandling = false
142
641
  }
143
- finish()
642
+ }
144
643
  }
145
644
 
146
- private fun getClientDataJSONb64(origin: String, challenge: String): String {
147
- val sanitizedOrigin = origin.replace(Regex("/$"), "")
148
- val jsonString = "{\"type\":\"webauthn.get\",\"challenge\":\"$challenge\",\"origin\":\"$sanitizedOrigin\",\"crossOrigin\":false}"
149
- return AndroidBase64.encodeToString(jsonString.toByteArray(), AndroidBase64.NO_WRAP)
645
+ private suspend fun biometrics(iv: String?): BiometricPrompt.AuthenticationResult? {
646
+ val requirement = BiometricRequirement.resolve(this)
647
+ return suspendCoroutine { continuation ->
648
+ val biometricPrompt = BiometricPrompt(
649
+ this,
650
+ ContextCompat.getMainExecutor(this),
651
+ object : BiometricPrompt.AuthenticationCallback() {
652
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
653
+ super.onAuthenticationSucceeded(result)
654
+ continuation.resume(result)
655
+ }
656
+
657
+ override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
658
+ super.onAuthenticationError(errorCode, errString)
659
+ continuation.resume(null)
660
+ }
661
+
662
+ override fun onAuthenticationFailed() {
663
+ super.onAuthenticationFailed()
664
+ }
665
+ }
666
+ )
667
+ val promptInfoBuilder = BiometricPrompt.PromptInfo.Builder()
668
+ .setTitle("Sign In")
669
+ .setSubtitle("Authenticate to use your passkey")
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) {
677
+ try {
678
+ val ivBytes = AndroidBase64.decode(iv, AndroidBase64.DEFAULT)
679
+ val cipher = credentialRepository.getBiometricCipherForDecryption(this, ivBytes, requirement)
680
+ biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
681
+ } catch (e: Exception) {
682
+ Log.e(TAG, "Failed to initialize biometric prompt with cipher", e)
683
+ biometricPrompt.authenticate(promptInfo)
684
+ }
685
+ } else {
686
+ biometricPrompt.authenticate(promptInfo)
687
+ }
688
+ }
150
689
  }
151
690
  }