@algorandfoundation/react-native-passkey-autofill 0.0.1 → 1.0.0-canary.10

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 (45) hide show
  1. package/.eslintrc.js +8 -0
  2. package/.releaserc +23 -0
  3. package/ARCHITECTURE.md +67 -0
  4. package/CHANGELOG.md +42 -0
  5. package/CONTRIBUTING.md +82 -0
  6. package/LICENSE +201 -0
  7. package/README.md +135 -4
  8. package/android/build.gradle +86 -0
  9. package/android/libs/dP256Android-release.aar +0 -0
  10. package/android/src/main/AndroidManifest.xml +37 -0
  11. package/android/src/main/java/co/algorand/passkeyautofill/CreatePasskeyActivity.kt +318 -0
  12. package/android/src/main/java/co/algorand/passkeyautofill/GetPasskeyActivity.kt +327 -0
  13. package/android/src/main/java/co/algorand/passkeyautofill/ReactNativePasskeyAutofillModule.kt +84 -0
  14. package/android/src/main/java/co/algorand/passkeyautofill/credentials/Credential.kt +11 -0
  15. package/android/src/main/java/co/algorand/passkeyautofill/credentials/CredentialRepository.kt +506 -0
  16. package/android/src/main/java/co/algorand/passkeyautofill/service/PasskeyAutofillCredentialProviderService.kt +248 -0
  17. package/android/src/main/java/co/algorand/passkeyautofill/utils/PasskeyUtils.kt +7 -0
  18. package/android/src/main/res/values/strings.xml +3 -0
  19. package/android/src/main/res/values/styles.xml +10 -0
  20. package/android/src/main/res/xml/provider.xml +5 -0
  21. package/app.plugin.js +249 -0
  22. package/assets/banner.png +0 -0
  23. package/build/ReactNativePasskeyAutofill.types.d.ts +9 -0
  24. package/build/ReactNativePasskeyAutofill.types.d.ts.map +1 -0
  25. package/build/ReactNativePasskeyAutofill.types.js +2 -0
  26. package/build/ReactNativePasskeyAutofill.types.js.map +1 -0
  27. package/build/ReactNativePasskeyAutofillModule.d.ts +12 -0
  28. package/build/ReactNativePasskeyAutofillModule.d.ts.map +1 -0
  29. package/build/ReactNativePasskeyAutofillModule.js +4 -0
  30. package/build/ReactNativePasskeyAutofillModule.js.map +1 -0
  31. package/build/index.d.ts +3 -0
  32. package/build/index.d.ts.map +1 -0
  33. package/build/index.js +5 -0
  34. package/build/index.js.map +1 -0
  35. package/expo-module.config.json +10 -0
  36. package/ios/ReactNativePasskeyAutofill.podspec +29 -0
  37. package/ios/ReactNativePasskeyAutofillModule.swift +49 -0
  38. package/ios/ReactNativePasskeyAutofillView.swift +38 -0
  39. package/lefthook.yml +13 -0
  40. package/package.json +64 -8
  41. package/src/ReactNativePasskeyAutofill.types.ts +4 -0
  42. package/src/ReactNativePasskeyAutofillModule.ts +14 -0
  43. package/src/index.ts +4 -0
  44. package/tsconfig.json +9 -0
  45. package/index.js +0 -1
@@ -0,0 +1,37 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android"
2
+ xmlns:tools="http://schemas.android.com/tools">
3
+ <application>
4
+ <service
5
+ android:name=".service.PasskeyAutofillCredentialProviderService"
6
+ android:enabled="true"
7
+ android:exported="true"
8
+ android:label="@string/passkey_autofill_label"
9
+ android:permission="android.permission.BIND_CREDENTIAL_PROVIDER_SERVICE"
10
+ tools:targetApi="upside_down_cake">
11
+ <intent-filter>
12
+ <action android:name="android.service.credentials.CredentialProviderService" />
13
+ </intent-filter>
14
+ <meta-data
15
+ android:name="android.credentials.provider"
16
+ android:resource="@xml/provider" />
17
+ </service>
18
+ <activity android:name="co.algorand.passkeyautofill.GetPasskeyActivity"
19
+ android:exported="true"
20
+ android:launchMode="singleTop"
21
+ android:theme="@style/Theme.PasskeyAutofill.Dialog">
22
+ <intent-filter>
23
+ <action android:name="co.algorand.passkeyautofill.GET_PASSKEY"/>
24
+ <category android:name="android.intent.category.DEFAULT"/>
25
+ </intent-filter>
26
+ </activity>
27
+ <activity android:name="co.algorand.passkeyautofill.CreatePasskeyActivity"
28
+ android:exported="true"
29
+ android:launchMode="singleTop"
30
+ android:theme="@style/Theme.PasskeyAutofill.Dialog">
31
+ <intent-filter>
32
+ <action android:name="co.algorand.passkeyautofill.CREATE_PASSKEY"/>
33
+ <category android:name="android.intent.category.DEFAULT"/>
34
+ </intent-filter>
35
+ </activity>
36
+ </application>
37
+ </manifest>
@@ -0,0 +1,318 @@
1
+ package co.algorand.passkeyautofill
2
+
3
+ import android.annotation.SuppressLint
4
+ import android.app.Activity
5
+ import android.content.Intent
6
+ import android.os.Build
7
+ import android.os.Bundle
8
+ import android.util.Log
9
+ import android.graphics.Color
10
+ import android.graphics.Typeface
11
+ import android.view.Gravity
12
+ import android.widget.Button
13
+ import android.widget.ImageView
14
+ import android.widget.LinearLayout
15
+ import android.widget.TextView
16
+ import androidx.annotation.RequiresApi
17
+ import androidx.appcompat.app.AppCompatActivity
18
+ import androidx.credentials.CreatePublicKeyCredentialRequest
19
+ import androidx.credentials.CreatePublicKeyCredentialResponse
20
+ import androidx.credentials.provider.PendingIntentHandler
21
+ import androidx.credentials.provider.ProviderCreateCredentialRequest
22
+ import androidx.credentials.webauthn.AuthenticatorAttestationResponse
23
+ import androidx.credentials.webauthn.FidoPublicKeyCredential
24
+ import androidx.credentials.webauthn.PublicKeyCredentialCreationOptions
25
+ import co.algorand.passkeyautofill.credentials.CredentialRepository
26
+ import co.algorand.passkeyautofill.credentials.Credential
27
+ import java.security.KeyPair
28
+ import android.util.Base64 as AndroidBase64
29
+ import org.json.JSONObject
30
+
31
+ @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
32
+ class CreatePasskeyActivity : AppCompatActivity() {
33
+ private val credentialRepository = CredentialRepository()
34
+
35
+ companion object {
36
+ const val TAG = "CreatePasskeyActivity"
37
+ }
38
+
39
+ private var origin: String = "unknown-origin" // Derivation origin (rpId or hash)
40
+ private var displayOrigin: String = "unknown-origin"
41
+ private var userHandle: String = "unknown-user"
42
+ private var userName: String = "User"
43
+ private var userId: String = "unknown-id"
44
+ private var bundleRequestJson: String? = null
45
+ private var request: ProviderCreateCredentialRequest? = null
46
+
47
+ override fun onCreate(savedInstanceState: Bundle?) {
48
+ super.onCreate(savedInstanceState)
49
+
50
+ request = PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)
51
+ val credentialData = intent.getBundleExtra("CREDENTIAL_DATA")
52
+
53
+ if (credentialData != null) {
54
+ bundleRequestJson = credentialData.getString("requestJson")
55
+ }
56
+
57
+ if (request != null) {
58
+ try {
59
+ val systemOrigin = credentialRepository.getOrigin(request!!.callingAppInfo)
60
+ origin = systemOrigin
61
+ displayOrigin = systemOrigin
62
+
63
+ val publicKeyRequest = request!!.callingRequest as CreatePublicKeyCredentialRequest
64
+ val rawJson = bundleRequestJson ?: publicKeyRequest.requestJson
65
+ val requestJson = JSONObject(rawJson)
66
+ val pkJson = if (requestJson.has("publicKey")) requestJson.getJSONObject("publicKey") else requestJson
67
+
68
+ // Use RP ID as origin for derivation if available, otherwise fallback to app signature
69
+ val rpId = pkJson.optJSONObject("rp")?.optString("id")
70
+ if (rpId != null && rpId.isNotEmpty()) {
71
+ origin = rpId
72
+ displayOrigin = rpId
73
+ }
74
+
75
+ userHandle = pkJson.optJSONObject("user")?.optString("name") ?: "unknown-user"
76
+ userName = pkJson.optJSONObject("user")?.optString("displayName") ?: userHandle
77
+ userId = pkJson.optJSONObject("user")?.optString("id") ?: "unknown-id"
78
+ } catch (e: Exception) {
79
+ Log.e(TAG, "Error parsing request", e)
80
+ }
81
+ }
82
+
83
+ // Improved UI
84
+ val layout = LinearLayout(this).apply {
85
+ val padding = (32 * resources.displayMetrics.density).toInt()
86
+ orientation = LinearLayout.VERTICAL
87
+ setPadding(padding, padding, padding, padding)
88
+ gravity = Gravity.CENTER_HORIZONTAL
89
+ setBackgroundColor(Color.WHITE)
90
+ }
91
+
92
+ // Header with App Icon and Provider Label
93
+ try {
94
+ val appInfo = packageManager.getApplicationInfo(packageName, 0)
95
+ val appIcon = packageManager.getApplicationIcon(appInfo)
96
+ val appLabel = packageManager.getApplicationLabel(appInfo)
97
+
98
+ val density = resources.displayMetrics.density
99
+ val iconSize = (48 * density).toInt()
100
+ val iconMargin = (12 * density).toInt()
101
+ val headerPadding = (40 * density).toInt()
102
+
103
+ val header = LinearLayout(this).apply {
104
+ orientation = LinearLayout.HORIZONTAL
105
+ gravity = Gravity.CENTER_VERTICAL
106
+ setPadding(0, 0, 0, headerPadding)
107
+ }
108
+
109
+ val iconView = ImageView(this).apply {
110
+ setImageDrawable(appIcon)
111
+ layoutParams = LinearLayout.LayoutParams(iconSize, iconSize).apply {
112
+ marginEnd = iconMargin
113
+ }
114
+ }
115
+ header.addView(iconView)
116
+
117
+ val providerLabel = TextView(this).apply {
118
+ text = appLabel
119
+ textSize = 16f
120
+ typeface = Typeface.DEFAULT_BOLD
121
+ setTextColor(Color.DKGRAY)
122
+ }
123
+ header.addView(providerLabel)
124
+ layout.addView(header)
125
+ } catch (e: Exception) {
126
+ // Fallback if app info cannot be loaded
127
+ }
128
+
129
+ val title = TextView(this).apply {
130
+ text = "Create Passkey"
131
+ textSize = 28f
132
+ typeface = Typeface.DEFAULT_BOLD
133
+ setPadding(0, 0, 0, (8 * resources.displayMetrics.density).toInt())
134
+ setTextColor(Color.BLACK)
135
+ }
136
+ layout.addView(title)
137
+
138
+ val description = TextView(this).apply {
139
+ text = "Create a new passkey for $displayOrigin"
140
+ textSize = 16f
141
+ setPadding(0, 0, 0, (40 * resources.displayMetrics.density).toInt())
142
+ gravity = Gravity.CENTER_HORIZONTAL
143
+ setTextColor(Color.GRAY)
144
+ }
145
+ layout.addView(description)
146
+
147
+ val userInfoContainer = LinearLayout(this).apply {
148
+ orientation = LinearLayout.VERTICAL
149
+ setPadding(0, 0, 0, (50 * resources.displayMetrics.density).toInt())
150
+ gravity = Gravity.CENTER_HORIZONTAL
151
+ }
152
+
153
+ val userLabel = TextView(this).apply {
154
+ text = "ACCOUNT"
155
+ textSize = 12f
156
+ typeface = Typeface.DEFAULT_BOLD
157
+ setTextColor(Color.GRAY)
158
+ setPadding(0, 0, 0, (4 * resources.displayMetrics.density).toInt())
159
+ }
160
+ userInfoContainer.addView(userLabel)
161
+
162
+ val userValue = TextView(this).apply {
163
+ text = userName
164
+ textSize = 20f
165
+ typeface = Typeface.DEFAULT_BOLD
166
+ setTextColor(Color.BLACK)
167
+ }
168
+ userInfoContainer.addView(userValue)
169
+
170
+ if (userHandle != userName) {
171
+ val handleValue = TextView(this).apply {
172
+ text = userHandle
173
+ textSize = 14f
174
+ setTextColor(Color.GRAY)
175
+ }
176
+ userInfoContainer.addView(handleValue)
177
+ }
178
+ layout.addView(userInfoContainer)
179
+
180
+ val confirmButton = Button(this).apply {
181
+ text = "Create Passkey"
182
+ setOnClickListener {
183
+ handleCreation()
184
+ }
185
+ layoutParams = LinearLayout.LayoutParams(
186
+ LinearLayout.LayoutParams.MATCH_PARENT,
187
+ LinearLayout.LayoutParams.WRAP_CONTENT
188
+ ).apply {
189
+ setMargins(0, 0, 0, (8 * resources.displayMetrics.density).toInt())
190
+ }
191
+ }
192
+ layout.addView(confirmButton)
193
+
194
+ val cancelButton = Button(this, null, android.R.attr.borderlessButtonStyle).apply {
195
+ text = "Cancel"
196
+ setOnClickListener {
197
+ setResult(RESULT_CANCELED)
198
+ finish()
199
+ }
200
+ layoutParams = LinearLayout.LayoutParams(
201
+ LinearLayout.LayoutParams.MATCH_PARENT,
202
+ LinearLayout.LayoutParams.WRAP_CONTENT
203
+ )
204
+ }
205
+ layout.addView(cancelButton)
206
+
207
+ setContentView(layout)
208
+
209
+ // Ensure the activity is focusable and can receive touches
210
+ layout.isFocusable = true
211
+ layout.isFocusableInTouchMode = true
212
+ layout.requestFocus()
213
+ }
214
+
215
+ @SuppressLint("RestrictedApi")
216
+ private fun handleCreation() {
217
+ try {
218
+ val req = request ?: throw IllegalStateException("No request found")
219
+
220
+ // Prefer using the request JSON from the bundle if available
221
+ val rawRequestJson = bundleRequestJson ?: run {
222
+ val publicKeyRequest = req.callingRequest as CreatePublicKeyCredentialRequest
223
+ publicKeyRequest.requestJson
224
+ }
225
+
226
+ val requestJson = JSONObject(rawRequestJson)
227
+ val passkeyReqJson = if (requestJson.has("publicKey")) {
228
+ requestJson.getJSONObject("publicKey").toString()
229
+ } else {
230
+ rawRequestJson
231
+ }
232
+ val requestOptions = try {
233
+ PublicKeyCredentialCreationOptions(passkeyReqJson)
234
+ } catch (e: org.json.JSONException) {
235
+ Log.e(TAG, "Invalid passkey creation request JSON: $passkeyReqJson")
236
+ throw e
237
+ }
238
+
239
+ val keyPair: KeyPair = credentialRepository.createDeterministicKeyPair(this, origin, userHandle)
240
+ val credentialId = credentialRepository.generateCredentialId(keyPair)
241
+ val credentialIdBase64 = AndroidBase64.encodeToString(credentialId, AndroidBase64.NO_WRAP)
242
+
243
+ val credential = Credential(
244
+ credentialId = credentialIdBase64,
245
+ origin = origin,
246
+ userHandle = userHandle,
247
+ userId = userId,
248
+ publicKey = AndroidBase64.encodeToString(keyPair.public.encoded, AndroidBase64.NO_WRAP),
249
+ privateKey = AndroidBase64.encodeToString(keyPair.private.encoded, AndroidBase64.NO_WRAP),
250
+ count = 0
251
+ )
252
+
253
+ credentialRepository.saveCredential(this, credential)
254
+
255
+ val response = AuthenticatorAttestationResponse(
256
+ requestOptions = requestOptions,
257
+ credentialId = credentialId,
258
+ credentialPublicKey = credentialRepository.getPublicKeyFromKeyPair(keyPair),
259
+ origin = credentialRepository.getOrigin(req.callingAppInfo),
260
+ up = true,
261
+ uv = true,
262
+ be = true,
263
+ bs = true,
264
+ packageName = req.callingAppInfo.packageName
265
+ )
266
+
267
+ val fidoCredential = FidoPublicKeyCredential(
268
+ rawId = credentialId,
269
+ response = response,
270
+ authenticatorAttachment = "platform"
271
+ )
272
+
273
+ val resultIntent = Intent()
274
+ val fullJson = JSONObject(fidoCredential.json())
275
+ val respJson = fullJson.getJSONObject("response")
276
+
277
+ // Ensure compact clientDataJSON for registration too
278
+ val sanitizedOrigin = credentialRepository.getOrigin(req.callingAppInfo).replace(Regex("/$"), "")
279
+ val challenge = if (requestJson.has("publicKey")) {
280
+ requestJson.getJSONObject("publicKey").getString("challenge")
281
+ } else {
282
+ requestJson.getString("challenge")
283
+ }
284
+
285
+ val clientDataJSONString = if (sanitizedOrigin.startsWith("https://") || sanitizedOrigin.startsWith("http://")) {
286
+ "{\"type\":\"webauthn.create\",\"challenge\":\"$challenge\",\"origin\":\"$sanitizedOrigin\",\"crossOrigin\":false}"
287
+ } else {
288
+ val json = JSONObject()
289
+ json.put("type", "webauthn.create")
290
+ json.put("challenge", challenge)
291
+ json.put("origin", sanitizedOrigin)
292
+ json.put("crossOrigin", false)
293
+ if (sanitizedOrigin.startsWith("android:apk-key-hash:")) {
294
+ json.put("androidPackageName", req.callingAppInfo.packageName)
295
+ }
296
+ json.toString()
297
+ }
298
+ val clientDataJSONb64 = AndroidBase64.encodeToString(clientDataJSONString.toByteArray(), AndroidBase64.URL_SAFE or AndroidBase64.NO_WRAP or AndroidBase64.NO_PADDING)
299
+ respJson.put("clientDataJSON", clientDataJSONb64)
300
+
301
+ val createResponse = CreatePublicKeyCredentialResponse(fullJson.toString())
302
+
303
+ PendingIntentHandler.setCreateCredentialResponse(
304
+ resultIntent,
305
+ createResponse
306
+ )
307
+
308
+ setResult(Activity.RESULT_OK, resultIntent)
309
+ ReactNativePasskeyAutofillModule.instance?.sendEvent("onPasskeyAdded", Bundle().apply {
310
+ putBoolean("success", true)
311
+ })
312
+ } catch (e: Exception) {
313
+ Log.e(TAG, "Error during passkey creation", e)
314
+ setResult(Activity.RESULT_CANCELED)
315
+ }
316
+ finish()
317
+ }
318
+ }
@@ -0,0 +1,327 @@
1
+ package co.algorand.passkeyautofill
2
+
3
+ import android.annotation.SuppressLint
4
+ import android.app.Activity
5
+ import android.content.Intent
6
+ import android.os.Build
7
+ import android.os.Bundle
8
+ import android.util.Log
9
+ import android.graphics.Color
10
+ import android.graphics.Typeface
11
+ import android.view.Gravity
12
+ import android.widget.Button
13
+ import android.widget.ImageView
14
+ import android.widget.LinearLayout
15
+ import android.widget.TextView
16
+ import androidx.annotation.RequiresApi
17
+ import androidx.appcompat.app.AppCompatActivity
18
+ import androidx.credentials.GetCredentialResponse
19
+ import androidx.credentials.GetPublicKeyCredentialOption
20
+ import androidx.credentials.PublicKeyCredential
21
+ import androidx.credentials.provider.PendingIntentHandler
22
+ import androidx.credentials.provider.ProviderGetCredentialRequest
23
+ import androidx.credentials.webauthn.AuthenticatorAssertionResponse
24
+ import androidx.credentials.webauthn.FidoPublicKeyCredential
25
+ import androidx.credentials.webauthn.PublicKeyCredentialRequestOptions
26
+ import co.algorand.passkeyautofill.credentials.CredentialRepository
27
+ import co.algorand.passkeyautofill.credentials.Credential
28
+ import java.security.KeyPair
29
+ import java.security.MessageDigest
30
+ import android.util.Base64 as AndroidBase64
31
+ import org.json.JSONObject
32
+
33
+ @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
34
+ class GetPasskeyActivity : AppCompatActivity() {
35
+ private val credentialRepository = CredentialRepository()
36
+
37
+ companion object {
38
+ const val TAG = "GetPasskeyActivity"
39
+ }
40
+
41
+ private var origin: String = "unknown-origin"
42
+ private var displayOrigin: String = "unknown-origin"
43
+ private var userHandle: String = "unknown-user"
44
+ private var credentialIdEnc: String? = null
45
+ private var bundleRequestJson: String? = null
46
+ private var request: ProviderGetCredentialRequest? = null
47
+
48
+ override fun onCreate(savedInstanceState: Bundle?) {
49
+ super.onCreate(savedInstanceState)
50
+
51
+ request = PendingIntentHandler.retrieveProviderGetCredentialRequest(intent)
52
+ val credentialData = intent.getBundleExtra("CREDENTIAL_DATA")
53
+
54
+ if (credentialData != null) {
55
+ credentialIdEnc = credentialData.getString("credentialId")
56
+ userHandle = credentialData.getString("userHandle") ?: "unknown-user"
57
+ bundleRequestJson = credentialData.getString("requestJson")
58
+ }
59
+
60
+ if (request != null) {
61
+ origin = credentialRepository.getOrigin(request!!.callingAppInfo)
62
+ displayOrigin = origin
63
+
64
+ // Try to extract rpId for better display
65
+ val rawJson = bundleRequestJson ?: (request?.credentialOptions?.get(0) as? GetPublicKeyCredentialOption)?.requestJson
66
+ if (rawJson != null) {
67
+ try {
68
+ val jsonObj = JSONObject(rawJson)
69
+ val pkJson = if (jsonObj.has("publicKey")) jsonObj.getJSONObject("publicKey") else jsonObj
70
+ val rpId = pkJson.optString("rpId")
71
+ if (rpId.isNotEmpty()) {
72
+ displayOrigin = rpId
73
+ }
74
+ } catch (e: Exception) {
75
+ // ignore
76
+ }
77
+ }
78
+ }
79
+
80
+ // Improved UI
81
+ val layout = LinearLayout(this).apply {
82
+ val padding = (32 * resources.displayMetrics.density).toInt()
83
+ orientation = LinearLayout.VERTICAL
84
+ setPadding(padding, padding, padding, padding)
85
+ gravity = Gravity.CENTER_HORIZONTAL
86
+ setBackgroundColor(Color.WHITE)
87
+ }
88
+
89
+ // Header with App Icon and Provider Label
90
+ try {
91
+ val appInfo = packageManager.getApplicationInfo(packageName, 0)
92
+ val appIcon = packageManager.getApplicationIcon(appInfo)
93
+ val appLabel = packageManager.getApplicationLabel(appInfo)
94
+
95
+ val density = resources.displayMetrics.density
96
+ val iconSize = (48 * density).toInt()
97
+ val iconMargin = (12 * density).toInt()
98
+ val headerPadding = (40 * density).toInt()
99
+
100
+ val header = LinearLayout(this).apply {
101
+ orientation = LinearLayout.HORIZONTAL
102
+ gravity = Gravity.CENTER_VERTICAL
103
+ setPadding(0, 0, 0, headerPadding)
104
+ }
105
+
106
+ val iconView = ImageView(this).apply {
107
+ setImageDrawable(appIcon)
108
+ layoutParams = LinearLayout.LayoutParams(iconSize, iconSize).apply {
109
+ marginEnd = iconMargin
110
+ }
111
+ }
112
+ header.addView(iconView)
113
+
114
+ val providerLabel = TextView(this).apply {
115
+ text = appLabel
116
+ textSize = 16f
117
+ typeface = Typeface.DEFAULT_BOLD
118
+ setTextColor(Color.DKGRAY)
119
+ }
120
+ header.addView(providerLabel)
121
+ layout.addView(header)
122
+ } catch (e: Exception) {
123
+ // Fallback if app info cannot be loaded
124
+ }
125
+
126
+ val title = TextView(this).apply {
127
+ text = "Use Passkey"
128
+ textSize = 28f
129
+ typeface = Typeface.DEFAULT_BOLD
130
+ setPadding(0, 0, 0, (8 * resources.displayMetrics.density).toInt())
131
+ setTextColor(Color.BLACK)
132
+ }
133
+ layout.addView(title)
134
+
135
+ val description = TextView(this).apply {
136
+ text = "Sign in to $displayOrigin"
137
+ textSize = 16f
138
+ setPadding(0, 0, 0, (40 * resources.displayMetrics.density).toInt())
139
+ gravity = Gravity.CENTER_HORIZONTAL
140
+ setTextColor(Color.GRAY)
141
+ }
142
+ layout.addView(description)
143
+
144
+ val userInfoContainer = LinearLayout(this).apply {
145
+ orientation = LinearLayout.VERTICAL
146
+ setPadding(0, 0, 0, (50 * resources.displayMetrics.density).toInt())
147
+ gravity = Gravity.CENTER_HORIZONTAL
148
+ }
149
+
150
+ val userLabel = TextView(this).apply {
151
+ text = "ACCOUNT"
152
+ textSize = 12f
153
+ typeface = Typeface.DEFAULT_BOLD
154
+ setTextColor(Color.GRAY)
155
+ setPadding(0, 0, 0, (4 * resources.displayMetrics.density).toInt())
156
+ }
157
+ userInfoContainer.addView(userLabel)
158
+
159
+ val userValue = TextView(this).apply {
160
+ text = userHandle
161
+ textSize = 20f
162
+ typeface = Typeface.DEFAULT_BOLD
163
+ setTextColor(Color.BLACK)
164
+ }
165
+ userInfoContainer.addView(userValue)
166
+ layout.addView(userInfoContainer)
167
+
168
+ val confirmButton = Button(this).apply {
169
+ text = "Sign In"
170
+ setOnClickListener {
171
+ handleAssertion()
172
+ }
173
+ layoutParams = LinearLayout.LayoutParams(
174
+ LinearLayout.LayoutParams.MATCH_PARENT,
175
+ LinearLayout.LayoutParams.WRAP_CONTENT
176
+ ).apply {
177
+ setMargins(0, 0, 0, (8 * resources.displayMetrics.density).toInt())
178
+ }
179
+ }
180
+ layout.addView(confirmButton)
181
+
182
+ val cancelButton = Button(this, null, android.R.attr.borderlessButtonStyle).apply {
183
+ text = "Cancel"
184
+ setOnClickListener {
185
+ setResult(RESULT_CANCELED)
186
+ finish()
187
+ }
188
+ layoutParams = LinearLayout.LayoutParams(
189
+ LinearLayout.LayoutParams.MATCH_PARENT,
190
+ LinearLayout.LayoutParams.WRAP_CONTENT
191
+ )
192
+ }
193
+ layout.addView(cancelButton)
194
+
195
+ setContentView(layout)
196
+
197
+ // Ensure the activity is focusable and can receive touches
198
+ layout.isFocusable = true
199
+ layout.isFocusableInTouchMode = true
200
+ layout.requestFocus()
201
+ }
202
+
203
+ @SuppressLint("RestrictedApi")
204
+ private fun handleAssertion() {
205
+ try {
206
+ val req = request ?: throw IllegalStateException("No request found")
207
+
208
+ // Prefer using the request JSON from the bundle if available, as it's specifically for this entry
209
+ val rawRequestJson = bundleRequestJson ?: run {
210
+ val option = req.credentialOptions[0] as GetPublicKeyCredentialOption
211
+ option.requestJson
212
+ }
213
+
214
+ val requestJson = JSONObject(rawRequestJson)
215
+ val passkeyReqJson = if (requestJson.has("publicKey")) {
216
+ requestJson.getJSONObject("publicKey").toString()
217
+ } else {
218
+ rawRequestJson
219
+ }
220
+ val requestOptions = try {
221
+ PublicKeyCredentialRequestOptions(passkeyReqJson)
222
+ } catch (e: org.json.JSONException) {
223
+ Log.e(TAG, "Invalid passkey request JSON: $passkeyReqJson")
224
+ throw e
225
+ }
226
+
227
+ val credId = AndroidBase64.decode(credentialIdEnc!!, AndroidBase64.DEFAULT)
228
+
229
+ val passkeyRequestJsonObj = JSONObject(passkeyReqJson)
230
+ val challenge = if (passkeyRequestJsonObj.has("challenge")) {
231
+ passkeyRequestJsonObj.getString("challenge")
232
+ } else {
233
+ throw org.json.JSONException("No value for challenge in requestJson: $passkeyReqJson")
234
+ }
235
+ val sanitizedOrigin = origin.replace(Regex("/$"), "")
236
+
237
+ // Look for system-provided clientDataHash (e.g. from Chrome)
238
+ val systemClientDataHash = run {
239
+ val option = req.credentialOptions.find { opt ->
240
+ opt is GetPublicKeyCredentialOption && (opt.requestJson == rawRequestJson ||
241
+ try { JSONObject(opt.requestJson).toString() == JSONObject(rawRequestJson!!).toString() } catch(e: Exception) { false })
242
+ } as? GetPublicKeyCredentialOption
243
+ option?.requestData?.getByteArray("androidx.credentials.BUNDLE_KEY_CLIENT_DATA_HASH")
244
+ }
245
+
246
+ val clientDataJSONString = if (sanitizedOrigin.startsWith("https://") || sanitizedOrigin.startsWith("http://")) {
247
+ // Compact JSON for web origins to match browser hashing (no spaces, specific order)
248
+ "{\"type\":\"webauthn.get\",\"challenge\":\"$challenge\",\"origin\":\"$sanitizedOrigin\",\"crossOrigin\":false}"
249
+ } else {
250
+ val json = JSONObject()
251
+ json.put("type", "webauthn.get")
252
+ json.put("challenge", challenge)
253
+ json.put("origin", sanitizedOrigin)
254
+ json.put("crossOrigin", false)
255
+ if (sanitizedOrigin.startsWith("android:apk-key-hash:")) {
256
+ json.put("androidPackageName", req.callingAppInfo.packageName)
257
+ }
258
+ json.toString()
259
+ }
260
+
261
+ val clientDataHash = systemClientDataHash ?: MessageDigest.getInstance("SHA-256").digest(clientDataJSONString.toByteArray(Charsets.UTF_8))
262
+
263
+ val dbCred = credentialRepository.getCredential(this, credId)
264
+ ?: throw IllegalStateException("Credential not found")
265
+
266
+ val response = AuthenticatorAssertionResponse(
267
+ requestOptions = requestOptions,
268
+ credentialId = credId,
269
+ origin = sanitizedOrigin,
270
+ up = true,
271
+ uv = true,
272
+ be = true,
273
+ bs = true,
274
+ userHandle = AndroidBase64.decode(dbCred.userId, AndroidBase64.URL_SAFE),
275
+ packageName = req.callingAppInfo.packageName,
276
+ clientDataHash = clientDataHash
277
+ )
278
+
279
+ val keyPair = credentialRepository.getKeyPair(this, credId)
280
+ ?: throw IllegalStateException("No keypair found")
281
+
282
+ response.signature = credentialRepository.sign(keyPair, response.dataToSign())
283
+
284
+ val fidoCredential = FidoPublicKeyCredential(
285
+ rawId = credId,
286
+ response = response,
287
+ authenticatorAttachment = "platform"
288
+ )
289
+
290
+ // Manual addition of clientDataJSON and signature to the response JSON
291
+ val clientDataJSONb64 = AndroidBase64.encodeToString(clientDataJSONString.toByteArray(), AndroidBase64.URL_SAFE or AndroidBase64.NO_WRAP or AndroidBase64.NO_PADDING)
292
+ val signatureb64 = AndroidBase64.encodeToString(response.signature, AndroidBase64.URL_SAFE or AndroidBase64.NO_WRAP or AndroidBase64.NO_PADDING)
293
+
294
+ val fullJson = JSONObject(fidoCredential.json())
295
+ val respJson = fullJson.getJSONObject("response")
296
+ respJson.put("clientDataJSON", clientDataJSONb64)
297
+ respJson.put("signature", signatureb64)
298
+
299
+ // Add clientExtensionResults as seen in the example
300
+ val clientExtensionResults = JSONObject()
301
+ val credProps = JSONObject()
302
+ credProps.put("rk", true)
303
+ clientExtensionResults.put("credProps", credProps)
304
+ fullJson.put("clientExtensionResults", clientExtensionResults)
305
+
306
+ val credentialJson = fullJson.toString()
307
+
308
+ val resultIntent = Intent()
309
+ val passkeyCredential = PublicKeyCredential(credentialJson)
310
+
311
+ PendingIntentHandler.setGetCredentialResponse(
312
+ resultIntent,
313
+ GetCredentialResponse(passkeyCredential)
314
+ )
315
+
316
+ setResult(Activity.RESULT_OK, resultIntent)
317
+ ReactNativePasskeyAutofillModule.instance?.sendEvent("onPasskeyAuthenticated", Bundle().apply {
318
+ putBoolean("success", true)
319
+ })
320
+ } catch (e: Exception) {
321
+ Log.e(TAG, "Error during passkey assertion", e)
322
+ setResult(Activity.RESULT_CANCELED)
323
+ }
324
+ finish()
325
+ }
326
+
327
+ }