@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.
- package/.nvmrc +1 -0
- package/.oxfmtrc.json +4 -0
- package/.oxlintrc.json +14 -0
- package/.releaserc +30 -0
- package/ARCHITECTURE.md +8 -0
- package/CHANGELOG.md +115 -0
- package/CONTRIBUTING.md +15 -5
- package/README.md +150 -11
- package/android/build.gradle +33 -10
- package/android/libs/repo/co/algorand/dP256Android/1.0.0/dP256Android-1.0.0.pom +9 -0
- package/android/src/main/AndroidManifest.xml +9 -3
- package/android/src/main/java/co/algorand/passkeyautofill/CreatePasskeyActivity.kt +396 -44
- package/android/src/main/java/co/algorand/passkeyautofill/GetPasskeyActivity.kt +583 -44
- package/android/src/main/java/co/algorand/passkeyautofill/Prf.kt +184 -0
- package/android/src/main/java/co/algorand/passkeyautofill/ReactNativePasskeyAutofillModule.kt +190 -2
- 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/Credential.kt +2 -1
- package/android/src/main/java/co/algorand/passkeyautofill/credentials/CredentialRepository.kt +314 -53
- package/android/src/main/java/co/algorand/passkeyautofill/service/PasskeyAutofillCredentialProviderService.kt +152 -38
- package/android/src/main/java/co/algorand/passkeyautofill/utils/PasskeyUtils.kt +119 -0
- package/android/src/main/res/values/styles.xml +10 -0
- package/app.plugin.js +593 -60
- package/assets/banner.png +0 -0
- package/build/ReactNativePasskeyAutofill.types.d.ts +41 -1
- 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/build/ReactNativePasskeyAutofillModule.d.ts +41 -4
- package/build/ReactNativePasskeyAutofillModule.d.ts.map +1 -1
- package/build/ReactNativePasskeyAutofillModule.js +21 -2
- package/build/ReactNativePasskeyAutofillModule.js.map +1 -1
- package/build/index.d.ts +2 -2
- package/build/index.d.ts.map +1 -1
- package/build/index.js +2 -2
- package/build/index.js.map +1 -1
- package/e2e/README.md +155 -0
- package/e2e/driver/adb.ts +73 -0
- package/e2e/driver/capabilities.ts +106 -0
- package/e2e/driver/credentialProvider.ts +104 -0
- package/e2e/driver/fingerprint.ts +885 -0
- package/e2e/driver/selectors.ts +103 -0
- package/e2e/driver/session.ts +25 -0
- package/e2e/jest.config.ts +20 -0
- package/e2e/package.json +11 -0
- package/e2e/tests/passkey.test.ts +190 -0
- package/e2e/tsconfig.json +16 -0
- package/ios/AutofillCredentialProvider/BiometricRequirement.swift +34 -0
- package/ios/AutofillCredentialProvider/CredentialProviderViewController.swift +570 -0
- package/ios/AutofillCredentialProvider/PasskeyAutofillCredentialProvider-Bridging-Header.h +1 -0
- package/ios/AutofillCredentialProvider/PasskeyCredentialStore.swift +612 -0
- package/ios/AutofillCredentialProvider/PasskeyKeystoreMMKV.h +25 -0
- package/ios/AutofillCredentialProvider/PasskeyKeystoreMMKV.mm +86 -0
- package/ios/AutofillCredentialProvider/Prf.swift +89 -0
- package/ios/AutofillCredentialProvider/WebAuthn.swift +186 -0
- package/ios/ReactNativePasskeyAutofill.podspec +11 -2
- package/ios/ReactNativePasskeyAutofillModule.swift +170 -15
- package/lefthook.yml +14 -0
- package/package.json +115 -25
- package/pnpm-workspace.yaml +36 -0
- package/src/ReactNativePasskeyAutofill.types.ts +38 -0
- package/src/ReactNativePasskeyAutofillModule.ts +66 -3
- package/src/index.ts +2 -2
- package/tsconfig.json +2 -2
- package/.eslintrc.js +0 -5
- /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 =
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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 =
|
|
61
|
-
|
|
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
|
|
66
|
-
text = "
|
|
67
|
-
|
|
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(
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
110
|
-
|
|
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
|
|
121
|
-
val
|
|
122
|
-
val
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
val
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
636
|
+
setupErrorUI(
|
|
637
|
+
"Something went wrong while signing in. Please try again.",
|
|
638
|
+
allowRetry = true,
|
|
639
|
+
)
|
|
640
|
+
isHandling = false
|
|
142
641
|
}
|
|
143
|
-
|
|
642
|
+
}
|
|
144
643
|
}
|
|
145
644
|
|
|
146
|
-
private fun
|
|
147
|
-
val
|
|
148
|
-
|
|
149
|
-
|
|
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
|
}
|