@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
|
@@ -24,10 +24,17 @@ import androidx.credentials.provider.CreateEntry
|
|
|
24
24
|
import androidx.credentials.provider.CredentialProviderService
|
|
25
25
|
import androidx.credentials.provider.ProviderClearCredentialStateRequest
|
|
26
26
|
import androidx.credentials.provider.PublicKeyCredentialEntry
|
|
27
|
+
import androidx.credentials.provider.BiometricPromptData
|
|
28
|
+
import androidx.biometric.BiometricPrompt
|
|
29
|
+
import co.algorand.passkeyautofill.auth.BiometricRequirement
|
|
27
30
|
import co.algorand.passkeyautofill.credentials.CredentialRepository
|
|
28
31
|
import co.algorand.passkeyautofill.credentials.Credential
|
|
32
|
+
import co.algorand.passkeyautofill.utils.PasskeyUtils
|
|
33
|
+
import android.util.Base64 as AndroidBase64
|
|
29
34
|
import com.tencent.mmkv.MMKV
|
|
35
|
+
import javax.crypto.Cipher
|
|
30
36
|
import kotlinx.coroutines.*
|
|
37
|
+
import org.json.JSONArray
|
|
31
38
|
import org.json.JSONObject
|
|
32
39
|
|
|
33
40
|
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
|
@@ -43,6 +50,25 @@ class PasskeyAutofillCredentialProviderService: CredentialProviderService() {
|
|
|
43
50
|
const val CREATE_PASSKEY_INTENT = 2
|
|
44
51
|
const val DEFAULT_GET_PASSKEY_ACTION = "co.algorand.passkeyautofill.GET_PASSKEY"
|
|
45
52
|
const val DEFAULT_CREATE_PASSKEY_ACTION = "co.algorand.passkeyautofill.CREATE_PASSKEY"
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* MMKV key holding the last time Android routed a credential request to
|
|
56
|
+
* this service. Set from both `onBeginCreate` and `onBeginGet` callbacks.
|
|
57
|
+
* Consumed by `ReactNativePasskeyAutofillModule.isProviderActive` as a
|
|
58
|
+
* reliable "this app is a registered provider" signal, since the raw
|
|
59
|
+
* `Settings.Secure("credential_service")` key is @hide-restricted on
|
|
60
|
+
* Android 12+ and not readable from regular apps.
|
|
61
|
+
*/
|
|
62
|
+
const val KEY_LAST_INVOKED_AT_MS = "provider_last_invoked_at_ms"
|
|
63
|
+
|
|
64
|
+
internal fun stampActivated(context: android.content.Context) {
|
|
65
|
+
try {
|
|
66
|
+
MMKV.initialize(context)
|
|
67
|
+
MMKV.defaultMMKV()?.encode(KEY_LAST_INVOKED_AT_MS, System.currentTimeMillis())
|
|
68
|
+
} catch (e: Exception) {
|
|
69
|
+
Log.w(TAG, "Failed to stamp provider activation: ${e.message}")
|
|
70
|
+
}
|
|
71
|
+
}
|
|
46
72
|
}
|
|
47
73
|
/**
|
|
48
74
|
* Handle Create Credential Requests
|
|
@@ -53,6 +79,7 @@ class PasskeyAutofillCredentialProviderService: CredentialProviderService() {
|
|
|
53
79
|
cancellationSignal: CancellationSignal,
|
|
54
80
|
callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>
|
|
55
81
|
) {
|
|
82
|
+
stampActivated(this)
|
|
56
83
|
val response: BeginCreateCredentialResponse? = processCreateCredentialRequest(request)
|
|
57
84
|
if (response != null) {
|
|
58
85
|
callback.onResult(response)
|
|
@@ -89,21 +116,43 @@ class PasskeyAutofillCredentialProviderService: CredentialProviderService() {
|
|
|
89
116
|
return BeginCreateCredentialResponse(createEntries)
|
|
90
117
|
}
|
|
91
118
|
|
|
92
|
-
val
|
|
119
|
+
val pkJson = JSONObject(request.requestJson)
|
|
120
|
+
val pk = if (pkJson.has("publicKey")) pkJson.getJSONObject("publicKey") else pkJson
|
|
121
|
+
val userJson = pk.optJSONObject("user")
|
|
93
122
|
val name = userJson?.optString("name") ?: "New Passkey"
|
|
123
|
+
|
|
124
|
+
val authenticatorSelection = pk.optJSONObject("authenticatorSelection")
|
|
125
|
+
val userVerification = authenticatorSelection?.optString("userVerification") ?: "preferred"
|
|
126
|
+
Log.d(TAG, "handleCreatePasskeyQuery: userVerification=$userVerification")
|
|
94
127
|
|
|
95
128
|
val action = credentialRepository.getCreatePasskeyAction(this) ?: DEFAULT_CREATE_PASSKEY_ACTION
|
|
96
129
|
|
|
97
130
|
val data = Bundle()
|
|
98
131
|
data.putString("requestJson", request.requestJson)
|
|
132
|
+
data.putString("userVerification", userVerification)
|
|
99
133
|
|
|
100
|
-
val
|
|
134
|
+
val builder = CreateEntry.Builder(
|
|
101
135
|
name,
|
|
102
136
|
createNewPendingIntent(action, CREATE_PASSKEY_INTENT, data)
|
|
103
137
|
).setIcon(Icon.createWithResource(this, android.R.drawable.ic_menu_add))
|
|
104
|
-
|
|
138
|
+
|
|
139
|
+
if (userVerification != "discouraged") {
|
|
140
|
+
try {
|
|
141
|
+
val requirement = BiometricRequirement.resolve(this)
|
|
142
|
+
val biometricPromptDataBuilder = BiometricPromptData.Builder()
|
|
143
|
+
.setAllowedAuthenticators(requirement.allowedAuthenticators)
|
|
144
|
+
if (requirement.isCryptoBound) {
|
|
145
|
+
val cipher = credentialRepository.getBiometricCipherForEncryption(this, requirement)
|
|
146
|
+
biometricPromptDataBuilder.setCryptoObject(BiometricPrompt.CryptoObject(cipher))
|
|
147
|
+
}
|
|
148
|
+
builder.setBiometricPromptData(biometricPromptDataBuilder.build())
|
|
149
|
+
Log.d(TAG, "Set BiometricPromptData for CreateEntry")
|
|
150
|
+
} catch (e: Exception) {
|
|
151
|
+
Log.e(TAG, "Failed to set BiometricPromptData for CreateEntry", e)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
105
154
|
|
|
106
|
-
createEntries.add(
|
|
155
|
+
createEntries.add(builder.build())
|
|
107
156
|
return BeginCreateCredentialResponse(createEntries)
|
|
108
157
|
}
|
|
109
158
|
/**
|
|
@@ -115,6 +164,7 @@ class PasskeyAutofillCredentialProviderService: CredentialProviderService() {
|
|
|
115
164
|
cancellationSignal: CancellationSignal,
|
|
116
165
|
callback: OutcomeReceiver<BeginGetCredentialResponse, GetCredentialException>,
|
|
117
166
|
) {
|
|
167
|
+
stampActivated(this)
|
|
118
168
|
try {
|
|
119
169
|
val response = processGetCredentialRequest(request)
|
|
120
170
|
callback.onResult(response)
|
|
@@ -127,7 +177,7 @@ class PasskeyAutofillCredentialProviderService: CredentialProviderService() {
|
|
|
127
177
|
/**
|
|
128
178
|
* Fake a list of available PublicKeyCredential Entries
|
|
129
179
|
*/
|
|
130
|
-
private fun processGetCredentialRequest(request: BeginGetCredentialRequest): BeginGetCredentialResponse{
|
|
180
|
+
private fun processGetCredentialRequest(request: BeginGetCredentialRequest): BeginGetCredentialResponse {
|
|
131
181
|
// Ensure we have a master key available. If not, we can't sign anything, so don't show entries.
|
|
132
182
|
if (!credentialRepository.isMasterKeyAvailable(this)) {
|
|
133
183
|
return BeginGetCredentialResponse(emptyList())
|
|
@@ -138,42 +188,101 @@ class PasskeyAutofillCredentialProviderService: CredentialProviderService() {
|
|
|
138
188
|
}
|
|
139
189
|
|
|
140
190
|
val action = credentialRepository.getGetPasskeyAction(this) ?: DEFAULT_GET_PASSKEY_ACTION
|
|
191
|
+
val allEntries = mutableListOf<PublicKeyCredentialEntry>()
|
|
141
192
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
// Find suitable option for this entry
|
|
145
|
-
val option = request.beginGetCredentialOptions.find { opt ->
|
|
146
|
-
if (opt !is BeginGetPublicKeyCredentialOption) return@find false
|
|
147
|
-
|
|
148
|
-
// In a real app, we'd check if this credential's origin matches the request's origin
|
|
149
|
-
// For now, we return all credentials that match the requested type.
|
|
150
|
-
true
|
|
151
|
-
} as? BeginGetPublicKeyCredentialOption
|
|
152
|
-
|
|
153
|
-
if (option == null) {
|
|
154
|
-
return@mapNotNull null
|
|
155
|
-
}
|
|
193
|
+
for (option in request.beginGetCredentialOptions) {
|
|
194
|
+
if (option !is BeginGetPublicKeyCredentialOption) continue
|
|
156
195
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
option
|
|
167
|
-
)
|
|
168
|
-
.setIcon(Icon.createWithResource(this@PasskeyAutofillCredentialProviderService, android.R.drawable.ic_lock_lock))
|
|
169
|
-
.build()
|
|
196
|
+
val requestJsonStr = option.requestJson
|
|
197
|
+
var allowCredentials: JSONArray? = null
|
|
198
|
+
var userVerification = "preferred"
|
|
199
|
+
try {
|
|
200
|
+
val json = JSONObject(requestJsonStr)
|
|
201
|
+
val pk = if (json.has("publicKey")) json.getJSONObject("publicKey") else json
|
|
202
|
+
allowCredentials = pk.optJSONArray("allowCredentials")
|
|
203
|
+
userVerification = pk.optString("userVerification", "preferred")
|
|
204
|
+
Log.d(TAG, "handleGetPasskeyQuery: userVerification=$userVerification")
|
|
170
205
|
} catch (e: Exception) {
|
|
171
|
-
Log.e(TAG, "Error
|
|
206
|
+
Log.e(TAG, "Error parsing requestJson", e)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
val allowedIds = if (allowCredentials != null && allowCredentials.length() > 0) {
|
|
210
|
+
val ids = mutableSetOf<String>()
|
|
211
|
+
for (i in 0 until allowCredentials.length()) {
|
|
212
|
+
ids.add(allowCredentials.getJSONObject(i).getString("id"))
|
|
213
|
+
}
|
|
214
|
+
ids
|
|
215
|
+
} else {
|
|
172
216
|
null
|
|
173
217
|
}
|
|
218
|
+
|
|
219
|
+
for (credential in credentials) {
|
|
220
|
+
if (allowedIds != null) {
|
|
221
|
+
val isAllowed = allowedIds.any { allowedId ->
|
|
222
|
+
allowedId == credential.credentialId ||
|
|
223
|
+
PasskeyUtils.normalizeBase64(allowedId) == PasskeyUtils.normalizeBase64(credential.credentialId)
|
|
224
|
+
}
|
|
225
|
+
if (!isAllowed) continue
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
val data = Bundle()
|
|
230
|
+
data.putString("credentialId", credential.credentialId)
|
|
231
|
+
data.putString("userHandle", credential.userHandle)
|
|
232
|
+
data.putString("requestJson", option.requestJson)
|
|
233
|
+
data.putString("userVerification", userVerification)
|
|
234
|
+
credential.biometricIv?.let { data.putString("biometricIv", it) }
|
|
235
|
+
|
|
236
|
+
// Use a unique requestCode for each PendingIntent to avoid data being overwritten
|
|
237
|
+
val uniqueRequestCode = (credential.credentialId + option.requestJson).hashCode()
|
|
238
|
+
|
|
239
|
+
val entryBuilder = PublicKeyCredentialEntry.Builder(
|
|
240
|
+
this,
|
|
241
|
+
credential.userHandle,
|
|
242
|
+
createNewPendingIntent(action, uniqueRequestCode, data),
|
|
243
|
+
option
|
|
244
|
+
).setIcon(Icon.createWithResource(this, android.R.drawable.ic_lock_lock))
|
|
245
|
+
|
|
246
|
+
if (userVerification != "discouraged") {
|
|
247
|
+
try {
|
|
248
|
+
val requirement = BiometricRequirement.resolve(this)
|
|
249
|
+
val biometricPromptDataBuilder = BiometricPromptData.Builder()
|
|
250
|
+
.setAllowedAuthenticators(requirement.allowedAuthenticators)
|
|
251
|
+
|
|
252
|
+
if (requirement.isCryptoBound) {
|
|
253
|
+
val iv = credential.biometricIv
|
|
254
|
+
if (iv != null) {
|
|
255
|
+
val ivBytes = AndroidBase64.decode(iv, AndroidBase64.DEFAULT)
|
|
256
|
+
val cipher = credentialRepository.getBiometricCipherForDecryption(this, ivBytes, requirement)
|
|
257
|
+
biometricPromptDataBuilder.setCryptoObject(BiometricPrompt.CryptoObject(cipher))
|
|
258
|
+
} else {
|
|
259
|
+
// Even if the key isn't locked, provide a CryptoObject to enable Single Tap
|
|
260
|
+
// and ensure the user is authenticated for this operation.
|
|
261
|
+
try {
|
|
262
|
+
val cipher = credentialRepository.getBiometricCipherForEncryption(this, requirement)
|
|
263
|
+
biometricPromptDataBuilder.setCryptoObject(BiometricPrompt.CryptoObject(cipher))
|
|
264
|
+
} catch (e: Exception) {
|
|
265
|
+
Log.d(TAG, "Could not get encryption cipher for Single Tap: ${e.message}")
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
entryBuilder.setBiometricPromptData(biometricPromptDataBuilder.build())
|
|
271
|
+
Log.d(TAG, "Set BiometricPromptData for entry ${credential.userHandle}")
|
|
272
|
+
} catch (e: Exception) {
|
|
273
|
+
Log.e(TAG, "Failed to set BiometricPromptData for entry", e)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
allEntries.add(entryBuilder.build())
|
|
278
|
+
} catch (e: Exception) {
|
|
279
|
+
Log.e(TAG, "Error building PublicKeyCredentialEntry", e)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
174
282
|
}
|
|
175
|
-
return BeginGetCredentialResponse(
|
|
283
|
+
return BeginGetCredentialResponse(allEntries)
|
|
176
284
|
}
|
|
285
|
+
|
|
177
286
|
override fun onClearCredentialStateRequest(
|
|
178
287
|
request: ProviderClearCredentialStateRequest,
|
|
179
288
|
cancellationSignal: CancellationSignal,
|
|
@@ -186,12 +295,17 @@ class PasskeyAutofillCredentialProviderService: CredentialProviderService() {
|
|
|
186
295
|
private fun createNewPendingIntent(action: String, requestCode: Int, extra: Bundle?): PendingIntent{
|
|
187
296
|
MMKV.initialize(this)
|
|
188
297
|
val intent = Intent(action)
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
298
|
+
|
|
299
|
+
// Determine component based on action instead of requestCode
|
|
300
|
+
val getAction = credentialRepository.getGetPasskeyAction(this) ?: DEFAULT_GET_PASSKEY_ACTION
|
|
301
|
+
val createAction = credentialRepository.getCreatePasskeyAction(this) ?: DEFAULT_CREATE_PASSKEY_ACTION
|
|
302
|
+
|
|
303
|
+
val componentName = when(action) {
|
|
304
|
+
getAction -> android.content.ComponentName(packageName, "co.algorand.passkeyautofill.GetPasskeyActivity")
|
|
305
|
+
createAction -> android.content.ComponentName(packageName, "co.algorand.passkeyautofill.CreatePasskeyActivity")
|
|
193
306
|
else -> null
|
|
194
307
|
}
|
|
308
|
+
|
|
195
309
|
if (componentName != null) {
|
|
196
310
|
intent.component = componentName
|
|
197
311
|
} else {
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
package co.algorand.passkeyautofill.utils
|
|
2
|
+
|
|
3
|
+
import android.util.Log
|
|
4
|
+
import javax.crypto.Cipher
|
|
5
|
+
|
|
6
|
+
object PasskeyUtils {
|
|
7
|
+
private const val TAG = "PasskeyUtils"
|
|
8
|
+
|
|
9
|
+
fun normalizeBase64(s: String): String {
|
|
10
|
+
return s.replace("-", "+").replace("_", "/").replace("=", "")
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Extracts a Cipher from a BiometricPrompt.AuthenticationResult or similar objects
|
|
15
|
+
* using reflection. This is necessary because the Credentials library uses
|
|
16
|
+
* hidden or internal wrappers for platform/androidx biometric results.
|
|
17
|
+
*/
|
|
18
|
+
fun extractCipher(authResult: Any?): Cipher? {
|
|
19
|
+
if (authResult == null) return null
|
|
20
|
+
return try {
|
|
21
|
+
Log.i(TAG, "Attempting to extract cipher from: ${authResult.javaClass.name}")
|
|
22
|
+
|
|
23
|
+
// 1. Try direct getCryptoObject (works for androidx.biometric or platform)
|
|
24
|
+
var cryptoObject = authResult.javaClass.methods.find { it.name == "getCryptoObject" }?.invoke(authResult)
|
|
25
|
+
|
|
26
|
+
// 2. If not found, check if it's a wrapper like androidx.credentials.provider.AuthenticationResult
|
|
27
|
+
if (cryptoObject == null) {
|
|
28
|
+
val unwrappingMethods = listOf(
|
|
29
|
+
"getFrameworkAuthenticationResult",
|
|
30
|
+
"getBiometricPromptAuthenticationResult",
|
|
31
|
+
"getAuthenticationResult"
|
|
32
|
+
)
|
|
33
|
+
for (methodName in unwrappingMethods) {
|
|
34
|
+
try {
|
|
35
|
+
val method = authResult.javaClass.methods.find { it.name == methodName }
|
|
36
|
+
if (method != null) {
|
|
37
|
+
val innerResult = method.invoke(authResult)
|
|
38
|
+
if (innerResult != null && innerResult !== authResult) {
|
|
39
|
+
Log.i(TAG, "Unwrapped via $methodName to ${innerResult.javaClass.name}")
|
|
40
|
+
cryptoObject = innerResult.javaClass.methods.find { it.name == "getCryptoObject" }?.invoke(innerResult)
|
|
41
|
+
if (cryptoObject != null) break
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
} catch (e: Exception) { /* ignore */ }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 2.5 Try to find hidden fields directly if methods fail
|
|
49
|
+
if (cryptoObject == null) {
|
|
50
|
+
val hiddenFields = listOf("mFrameworkAuthenticationResult", "mBiometricPromptAuthenticationResult")
|
|
51
|
+
for (fieldName in hiddenFields) {
|
|
52
|
+
try {
|
|
53
|
+
val field = authResult.javaClass.getDeclaredField(fieldName)
|
|
54
|
+
field.isAccessible = true
|
|
55
|
+
val innerResult = field.get(authResult)
|
|
56
|
+
if (innerResult != null) {
|
|
57
|
+
Log.i(TAG, "Unwrapped via field $fieldName to ${innerResult.javaClass.name}")
|
|
58
|
+
cryptoObject = innerResult.javaClass.methods.find { it.name == "getCryptoObject" }?.invoke(innerResult)
|
|
59
|
+
if (cryptoObject != null) break
|
|
60
|
+
}
|
|
61
|
+
} catch (e: Exception) { /* ignore */ }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 3. Exhaustive search for any method returning something that has getCryptoObject
|
|
66
|
+
if (cryptoObject == null) {
|
|
67
|
+
for (method in authResult.javaClass.methods) {
|
|
68
|
+
if (method.parameterCount == 0 && method.name.startsWith("get") && method.returnType != Void.TYPE) {
|
|
69
|
+
try {
|
|
70
|
+
val candidate = method.invoke(authResult) ?: continue
|
|
71
|
+
if (candidate === authResult) continue
|
|
72
|
+
|
|
73
|
+
val getCrypto = candidate.javaClass.methods.find { it.name == "getCryptoObject" }
|
|
74
|
+
if (getCrypto != null) {
|
|
75
|
+
cryptoObject = getCrypto.invoke(candidate)
|
|
76
|
+
if (cryptoObject != null) {
|
|
77
|
+
Log.i(TAG, "Found cryptoObject via ${method.name} -> ${candidate.javaClass.name}")
|
|
78
|
+
break
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
} catch (e: Exception) { continue }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 4. Try searching for fields too
|
|
87
|
+
if (cryptoObject == null) {
|
|
88
|
+
for (field in authResult.javaClass.declaredFields) {
|
|
89
|
+
try {
|
|
90
|
+
field.isAccessible = true
|
|
91
|
+
val candidate = field.get(authResult) ?: continue
|
|
92
|
+
val getCrypto = candidate.javaClass.methods.find { it.name == "getCryptoObject" }
|
|
93
|
+
if (getCrypto != null) {
|
|
94
|
+
cryptoObject = getCrypto.invoke(candidate)
|
|
95
|
+
if (cryptoObject != null) {
|
|
96
|
+
Log.i(TAG, "Found cryptoObject in field ${field.name}")
|
|
97
|
+
break
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} catch (e: Exception) { continue }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (cryptoObject == null) {
|
|
105
|
+
Log.w(TAG, "Could not find CryptoObject in ${authResult.javaClass.name}")
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 5. Finally extract the Cipher from the CryptoObject
|
|
110
|
+
val getCipher = cryptoObject.javaClass.methods.find { it.name == "getCipher" }
|
|
111
|
+
val cipher = getCipher?.invoke(cryptoObject) as? Cipher
|
|
112
|
+
Log.i(TAG, "Extracted cipher: $cipher")
|
|
113
|
+
cipher
|
|
114
|
+
} catch (e: Exception) {
|
|
115
|
+
Log.w(TAG, "Failed to extract cipher via reflection", e)
|
|
116
|
+
null
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<resources>
|
|
2
|
+
<style name="Theme.PasskeyAutofill.Dialog" parent="Theme.AppCompat.Light.Dialog.MinWidth">
|
|
3
|
+
<item name="android:windowIsTranslucent">true</item>
|
|
4
|
+
<item name="android:windowBackground">@android:color/transparent</item>
|
|
5
|
+
<item name="android:windowContentOverlay">@null</item>
|
|
6
|
+
<item name="android:windowNoTitle">true</item>
|
|
7
|
+
<item name="android:windowIsFloating">true</item>
|
|
8
|
+
<item name="android:backgroundDimEnabled">false</item>
|
|
9
|
+
</style>
|
|
10
|
+
</resources>
|