@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.
- package/.eslintrc.js +8 -0
- package/.releaserc +23 -0
- package/ARCHITECTURE.md +67 -0
- package/CHANGELOG.md +42 -0
- package/CONTRIBUTING.md +82 -0
- package/LICENSE +201 -0
- package/README.md +135 -4
- package/android/build.gradle +86 -0
- package/android/libs/dP256Android-release.aar +0 -0
- package/android/src/main/AndroidManifest.xml +37 -0
- package/android/src/main/java/co/algorand/passkeyautofill/CreatePasskeyActivity.kt +318 -0
- package/android/src/main/java/co/algorand/passkeyautofill/GetPasskeyActivity.kt +327 -0
- package/android/src/main/java/co/algorand/passkeyautofill/ReactNativePasskeyAutofillModule.kt +84 -0
- package/android/src/main/java/co/algorand/passkeyautofill/credentials/Credential.kt +11 -0
- package/android/src/main/java/co/algorand/passkeyautofill/credentials/CredentialRepository.kt +506 -0
- package/android/src/main/java/co/algorand/passkeyautofill/service/PasskeyAutofillCredentialProviderService.kt +248 -0
- package/android/src/main/java/co/algorand/passkeyautofill/utils/PasskeyUtils.kt +7 -0
- package/android/src/main/res/values/strings.xml +3 -0
- package/android/src/main/res/values/styles.xml +10 -0
- package/android/src/main/res/xml/provider.xml +5 -0
- package/app.plugin.js +249 -0
- package/assets/banner.png +0 -0
- package/build/ReactNativePasskeyAutofill.types.d.ts +9 -0
- package/build/ReactNativePasskeyAutofill.types.d.ts.map +1 -0
- package/build/ReactNativePasskeyAutofill.types.js +2 -0
- package/build/ReactNativePasskeyAutofill.types.js.map +1 -0
- package/build/ReactNativePasskeyAutofillModule.d.ts +12 -0
- package/build/ReactNativePasskeyAutofillModule.d.ts.map +1 -0
- package/build/ReactNativePasskeyAutofillModule.js +4 -0
- package/build/ReactNativePasskeyAutofillModule.js.map +1 -0
- package/build/index.d.ts +3 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +5 -0
- package/build/index.js.map +1 -0
- package/expo-module.config.json +10 -0
- package/ios/ReactNativePasskeyAutofill.podspec +29 -0
- package/ios/ReactNativePasskeyAutofillModule.swift +49 -0
- package/ios/ReactNativePasskeyAutofillView.swift +38 -0
- package/lefthook.yml +13 -0
- package/package.json +64 -8
- package/src/ReactNativePasskeyAutofill.types.ts +4 -0
- package/src/ReactNativePasskeyAutofillModule.ts +14 -0
- package/src/index.ts +4 -0
- package/tsconfig.json +9 -0
- 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
|
+
}
|