@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
|
|
@@ -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 =
|
|
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
|
|
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 =
|
|
51
|
-
|
|
111
|
+
val rpId = pkJson.optJSONObject("rp")?.optString("id")
|
|
112
|
+
if (rpId != null && rpId.isNotEmpty()) {
|
|
113
|
+
origin = rpId
|
|
114
|
+
displayOrigin = rpId
|
|
115
|
+
}
|
|
52
116
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
70
|
-
textSize =
|
|
71
|
-
|
|
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
|
|
76
|
-
text = "
|
|
77
|
-
|
|
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(
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
135
|
-
val
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
val
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
}
|