@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.
Files changed (66) hide show
  1. package/.nvmrc +1 -0
  2. package/.oxfmtrc.json +4 -0
  3. package/.oxlintrc.json +14 -0
  4. package/.releaserc +30 -0
  5. package/ARCHITECTURE.md +8 -0
  6. package/CHANGELOG.md +115 -0
  7. package/CONTRIBUTING.md +15 -5
  8. package/README.md +150 -11
  9. package/android/build.gradle +33 -10
  10. package/android/libs/repo/co/algorand/dP256Android/1.0.0/dP256Android-1.0.0.pom +9 -0
  11. package/android/src/main/AndroidManifest.xml +9 -3
  12. package/android/src/main/java/co/algorand/passkeyautofill/CreatePasskeyActivity.kt +396 -44
  13. package/android/src/main/java/co/algorand/passkeyautofill/GetPasskeyActivity.kt +583 -44
  14. package/android/src/main/java/co/algorand/passkeyautofill/Prf.kt +184 -0
  15. package/android/src/main/java/co/algorand/passkeyautofill/ReactNativePasskeyAutofillModule.kt +190 -2
  16. package/android/src/main/java/co/algorand/passkeyautofill/WebAuthn.kt +135 -0
  17. package/android/src/main/java/co/algorand/passkeyautofill/auth/BiometricRequirement.kt +86 -0
  18. package/android/src/main/java/co/algorand/passkeyautofill/credentials/Credential.kt +2 -1
  19. package/android/src/main/java/co/algorand/passkeyautofill/credentials/CredentialRepository.kt +314 -53
  20. package/android/src/main/java/co/algorand/passkeyautofill/service/PasskeyAutofillCredentialProviderService.kt +152 -38
  21. package/android/src/main/java/co/algorand/passkeyautofill/utils/PasskeyUtils.kt +119 -0
  22. package/android/src/main/res/values/styles.xml +10 -0
  23. package/app.plugin.js +593 -60
  24. package/assets/banner.png +0 -0
  25. package/build/ReactNativePasskeyAutofill.types.d.ts +41 -1
  26. package/build/ReactNativePasskeyAutofill.types.d.ts.map +1 -1
  27. package/build/ReactNativePasskeyAutofill.types.js +3 -1
  28. package/build/ReactNativePasskeyAutofill.types.js.map +1 -1
  29. package/build/ReactNativePasskeyAutofillModule.d.ts +41 -4
  30. package/build/ReactNativePasskeyAutofillModule.d.ts.map +1 -1
  31. package/build/ReactNativePasskeyAutofillModule.js +21 -2
  32. package/build/ReactNativePasskeyAutofillModule.js.map +1 -1
  33. package/build/index.d.ts +2 -2
  34. package/build/index.d.ts.map +1 -1
  35. package/build/index.js +2 -2
  36. package/build/index.js.map +1 -1
  37. package/e2e/README.md +155 -0
  38. package/e2e/driver/adb.ts +73 -0
  39. package/e2e/driver/capabilities.ts +106 -0
  40. package/e2e/driver/credentialProvider.ts +104 -0
  41. package/e2e/driver/fingerprint.ts +885 -0
  42. package/e2e/driver/selectors.ts +103 -0
  43. package/e2e/driver/session.ts +25 -0
  44. package/e2e/jest.config.ts +20 -0
  45. package/e2e/package.json +11 -0
  46. package/e2e/tests/passkey.test.ts +190 -0
  47. package/e2e/tsconfig.json +16 -0
  48. package/ios/AutofillCredentialProvider/BiometricRequirement.swift +34 -0
  49. package/ios/AutofillCredentialProvider/CredentialProviderViewController.swift +570 -0
  50. package/ios/AutofillCredentialProvider/PasskeyAutofillCredentialProvider-Bridging-Header.h +1 -0
  51. package/ios/AutofillCredentialProvider/PasskeyCredentialStore.swift +612 -0
  52. package/ios/AutofillCredentialProvider/PasskeyKeystoreMMKV.h +25 -0
  53. package/ios/AutofillCredentialProvider/PasskeyKeystoreMMKV.mm +86 -0
  54. package/ios/AutofillCredentialProvider/Prf.swift +89 -0
  55. package/ios/AutofillCredentialProvider/WebAuthn.swift +186 -0
  56. package/ios/ReactNativePasskeyAutofill.podspec +11 -2
  57. package/ios/ReactNativePasskeyAutofillModule.swift +170 -15
  58. package/lefthook.yml +14 -0
  59. package/package.json +115 -25
  60. package/pnpm-workspace.yaml +36 -0
  61. package/src/ReactNativePasskeyAutofill.types.ts +38 -0
  62. package/src/ReactNativePasskeyAutofillModule.ts +66 -3
  63. package/src/index.ts +2 -2
  64. package/tsconfig.json +2 -2
  65. package/.eslintrc.js +0 -5
  66. /package/android/libs/{dP256Android-release.aar → repo/co/algorand/dP256Android/1.0.0/dP256Android-1.0.0.aar} +0 -0
@@ -0,0 +1,184 @@
1
+ package co.algorand.passkeyautofill
2
+
3
+ import android.util.Base64 as AndroidBase64
4
+ import java.security.MessageDigest
5
+ import javax.crypto.Mac
6
+ import javax.crypto.spec.SecretKeySpec
7
+ import org.json.JSONObject
8
+
9
+ /**
10
+ * WebAuthn PRF (`prf`) extension helpers, backed by the authenticator
11
+ * `hmac-secret` mechanism (CTAP2.1).
12
+ *
13
+ * Output formula per the WebAuthn spec:
14
+ *
15
+ * output = HMAC-SHA256(credRandom, SHA256("WebAuthn PRF" || 0x00 || salt))
16
+ *
17
+ * `credRandom` is a per-credential 32-byte secret. We derive it
18
+ * deterministically (HKDF-SHA256) from the wallet HD root secret, the
19
+ * relying-party identifier, and the user handle — the same inputs that
20
+ * produce the deterministic signing key. This avoids a storage migration
21
+ * and ensures restoring the wallet seed reproduces the same PRF outputs
22
+ * on another device.
23
+ */
24
+ object Prf {
25
+ private const val CRED_RANDOM_INFO = "WebAuthn-PRF-credRandom"
26
+ private val SALT_PREFIX: ByteArray = "WebAuthn PRF".toByteArray(Charsets.UTF_8) + byteArrayOf(0x00)
27
+
28
+ /**
29
+ * Derive a 32-byte per-credential PRF secret from the HD root secret.
30
+ * Deterministic over `(hdRootSecret, rpId, userHandle)`.
31
+ */
32
+ fun credRandom(
33
+ hdRootSecret: ByteArray,
34
+ relyingPartyIdentifier: String,
35
+ userHandle: String,
36
+ ): ByteArray {
37
+ val salt = relyingPartyIdentifier.lowercase().toByteArray(Charsets.UTF_8) +
38
+ byteArrayOf(0x00) +
39
+ userHandle.lowercase().toByteArray(Charsets.UTF_8)
40
+ return hkdfSha256(
41
+ ikm = hdRootSecret,
42
+ salt = salt,
43
+ info = CRED_RANDOM_INFO.toByteArray(Charsets.UTF_8),
44
+ length = 32,
45
+ )
46
+ }
47
+
48
+ /**
49
+ * Evaluate the PRF for a single salt. Returns 32 bytes of output material.
50
+ *
51
+ * Per the WebAuthn spec the value the authenticator HMACs is
52
+ * `SHA-256("WebAuthn PRF" || 0x00 || rpSalt)`. The Chromium-on-Android
53
+ * client manager performs this hashing on behalf of the RP and passes the
54
+ * result through the `prfAlreadyHashed` extension; in that case the
55
+ * `salt` is already the inner hash and we must NOT hash again. Standard
56
+ * `prf` requests send the raw RP salt and require hashing.
57
+ */
58
+ fun evaluate(credRandom: ByteArray, salt: ByteArray, alreadyHashed: Boolean = true): ByteArray {
59
+ val macInput = if (alreadyHashed) {
60
+ salt
61
+ } else {
62
+ MessageDigest.getInstance("SHA-256").digest(SALT_PREFIX + salt)
63
+ }
64
+ val mac = Mac.getInstance("HmacSHA256").apply {
65
+ init(SecretKeySpec(credRandom, "HmacSHA256"))
66
+ }
67
+ return mac.doFinal(macInput)
68
+ }
69
+
70
+ /**
71
+ * Parsed PRF inputs from the relying party. Reduced to at most two salts
72
+ * (first + optional second) — already resolved against `evalByCredential`
73
+ * for the credential the user is asserting with.
74
+ *
75
+ * @property alreadyHashed `true` when the salts have already been passed
76
+ * through `SHA-256("WebAuthn PRF" || 0x00 || ...)` by the platform (the
77
+ * case for both the Chromium `prfAlreadyHashed` extension on Android
78
+ * and Apple's `ASAuthorizationPublicKeyCredentialPRFAssertionInput`).
79
+ */
80
+ data class Input(val first: ByteArray, val second: ByteArray?, val alreadyHashed: Boolean)
81
+
82
+ /**
83
+ * Parse PRF eval salts out of a WebAuthn request JSON.
84
+ *
85
+ * Recognises:
86
+ * - `extensions.prf.eval` / `extensions.prf.evalByCredential[<credId>]`
87
+ * — raw RP-supplied salts that still need to be hashed before HMAC.
88
+ * - `extensions.prfAlreadyHashed.eval` /
89
+ * `extensions.prfAlreadyHashed.evalByCredential[<credId>]` — Chromium
90
+ * on Android emits this when forwarding a `prf` request to a credential
91
+ * provider so the provider doesn't need to know the spec-mandated hash
92
+ * prefix. The salts here are already 32-byte SHA-256 outputs and must
93
+ * not be re-hashed.
94
+ *
95
+ * @param requestJson the inner `publicKey` request JSON object.
96
+ * @param credentialIdB64Url base64url-encoded credentialId we're asserting
97
+ * with (used to look up the per-credential salts).
98
+ */
99
+ fun parseInput(requestJson: JSONObject, credentialIdB64Url: String?): Input? {
100
+ val extensions = requestJson.optJSONObject("extensions") ?: return null
101
+
102
+ // Pre-hashed form wins — when Chromium sends both for compatibility,
103
+ // honour the already-hashed values to match what the browser expects.
104
+ extensions.optJSONObject("prfAlreadyHashed")?.let { node ->
105
+ parseExtensionNode(node, credentialIdB64Url, alreadyHashed = true)?.let { return it }
106
+ }
107
+ extensions.optJSONObject("prf")?.let { node ->
108
+ parseExtensionNode(node, credentialIdB64Url, alreadyHashed = false)?.let { return it }
109
+ }
110
+ return null
111
+ }
112
+
113
+ private fun parseExtensionNode(
114
+ node: JSONObject,
115
+ credentialIdB64Url: String?,
116
+ alreadyHashed: Boolean,
117
+ ): Input? {
118
+ // Per-credential salts win over the global `eval` block.
119
+ if (credentialIdB64Url != null) {
120
+ val byCred = node.optJSONObject("evalByCredential")
121
+ if (byCred != null) {
122
+ for (key in byCred.keys()) {
123
+ if (normalizeBase64Url(key) == normalizeBase64Url(credentialIdB64Url)) {
124
+ byCred.optJSONObject(key)?.let { return parseSalts(it, alreadyHashed) }
125
+ }
126
+ }
127
+ }
128
+ }
129
+ return node.optJSONObject("eval")?.let { parseSalts(it, alreadyHashed) }
130
+ }
131
+
132
+ private fun parseSalts(obj: JSONObject, alreadyHashed: Boolean): Input? {
133
+ val first = obj.optString("first", "").takeIf { it.isNotEmpty() } ?: return null
134
+ val firstBytes = decodeBase64UrlOrBase64(first)
135
+ val secondBytes = obj.optString("second", "")
136
+ .takeIf { it.isNotEmpty() }
137
+ ?.let { decodeBase64UrlOrBase64(it) }
138
+ return Input(firstBytes, secondBytes, alreadyHashed)
139
+ }
140
+
141
+ private fun normalizeBase64Url(value: String): String =
142
+ value.trim().trimEnd('=').replace('+', '-').replace('/', '_')
143
+
144
+ private fun decodeBase64UrlOrBase64(value: String): ByteArray {
145
+ val flags = AndroidBase64.URL_SAFE or AndroidBase64.NO_WRAP or AndroidBase64.NO_PADDING
146
+ return try {
147
+ AndroidBase64.decode(value, flags)
148
+ } catch (e: IllegalArgumentException) {
149
+ AndroidBase64.decode(value, AndroidBase64.NO_WRAP)
150
+ }
151
+ }
152
+
153
+ /** Encode a 32-byte PRF output for inclusion in a WebAuthn response JSON. */
154
+ fun encodeOutput(bytes: ByteArray): String =
155
+ AndroidBase64.encodeToString(bytes, AndroidBase64.URL_SAFE or AndroidBase64.NO_WRAP or AndroidBase64.NO_PADDING)
156
+
157
+ // RFC 5869 HKDF-SHA256.
158
+ private fun hkdfSha256(ikm: ByteArray, salt: ByteArray, info: ByteArray, length: Int): ByteArray {
159
+ val mac = Mac.getInstance("HmacSHA256")
160
+ val effectiveSalt = if (salt.isEmpty()) ByteArray(32) else salt
161
+ // Extract.
162
+ mac.init(SecretKeySpec(effectiveSalt, "HmacSHA256"))
163
+ val prk = mac.doFinal(ikm)
164
+ // Expand.
165
+ mac.init(SecretKeySpec(prk, "HmacSHA256"))
166
+ val output = ByteArray(length)
167
+ var t = ByteArray(0)
168
+ var offset = 0
169
+ var counter: Byte = 1
170
+ while (offset < length) {
171
+ mac.reset()
172
+ mac.init(SecretKeySpec(prk, "HmacSHA256"))
173
+ mac.update(t)
174
+ mac.update(info)
175
+ mac.update(counter)
176
+ t = mac.doFinal()
177
+ val take = minOf(t.size, length - offset)
178
+ System.arraycopy(t, 0, output, offset, take)
179
+ offset += take
180
+ counter++
181
+ }
182
+ return output
183
+ }
184
+ }
@@ -3,8 +3,13 @@ package co.algorand.passkeyautofill
3
3
  import expo.modules.kotlin.modules.Module
4
4
  import expo.modules.kotlin.modules.ModuleDefinition
5
5
  import co.algorand.passkeyautofill.credentials.CredentialRepository
6
- import com.tencent.mmkv.MMKV
6
+ import co.algorand.passkeyautofill.service.PasskeyAutofillCredentialProviderService
7
+ import android.content.ComponentName
7
8
  import android.content.Context
9
+ import android.content.Intent
10
+ import android.net.Uri
11
+ import android.os.Build
12
+ import android.provider.Settings
8
13
  import android.util.Log
9
14
  import org.bouncycastle.jce.provider.BouncyCastleProvider
10
15
  import java.security.Security
@@ -12,9 +17,13 @@ import java.security.Security
12
17
  class ReactNativePasskeyAutofillModule : Module() {
13
18
  private val credentialRepository = CredentialRepository()
14
19
 
20
+ companion object {
21
+ var instance: ReactNativePasskeyAutofillModule? = null
22
+ }
23
+
15
24
  init {
16
25
  Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME)
17
- Security.addProvider(BouncyCastleProvider())
26
+ Security.insertProviderAt(BouncyCastleProvider(), 1)
18
27
  }
19
28
 
20
29
  // Each module class must implement the definition function. The definition consists of components
@@ -26,6 +35,16 @@ class ReactNativePasskeyAutofillModule : Module() {
26
35
  // The module will be accessible from `requireNativeModule('ReactNativeLiquidAuth')` in JavaScript.
27
36
  Name("ReactNativePasskeyAutofill")
28
37
 
38
+ OnCreate {
39
+ instance = this@ReactNativePasskeyAutofillModule
40
+ }
41
+
42
+ OnDestroy {
43
+ instance = null
44
+ }
45
+
46
+ Events("onPasskeyAdded", "onPasskeyAuthenticated")
47
+
29
48
  AsyncFunction("setMasterKey") { secret: String ->
30
49
  val context = (appContext.reactContext ?: appContext.hostingRuntimeContext) as? Context
31
50
  if (context != null) {
@@ -60,11 +79,180 @@ class ReactNativePasskeyAutofillModule : Module() {
60
79
  credentialRepository.clearCredentials(context)
61
80
  }
62
81
 
82
+ AsyncFunction("deleteCredential") { credentialId: String ->
83
+ val context = (appContext.reactContext ?: appContext.hostingRuntimeContext) as? Context
84
+ ?: return@AsyncFunction Unit
85
+ credentialRepository.deleteCredential(context, credentialId)
86
+ }
87
+
63
88
  AsyncFunction("configureIntentActions") { getPasskeyAction: String, createPasskeyAction: String ->
64
89
  val context = (appContext.reactContext ?: appContext.hostingRuntimeContext) as? Context
65
90
  if (context != null) {
66
91
  credentialRepository.configureIntentActions(context, getPasskeyAction, createPasskeyAction)
67
92
  }
68
93
  }
94
+
95
+ // NoOp on Android. iOS uses ASCredentialIdentityStore to advertise
96
+ // credentials to the system AutoFill UI; Android's Credential Manager
97
+ // queries the provider service on demand instead, so there is no
98
+ // equivalent identity store to populate.
99
+ AsyncFunction("replaceCredentialIdentities") { _: List<Map<String, Any?>> ->
100
+ // No-op: see comment above.
101
+ }
102
+
103
+ // NoOp on Android. See `replaceCredentialIdentities` above.
104
+ AsyncFunction("refreshCredentialIdentities") {
105
+ // No-op: see comment above.
106
+ }
107
+
108
+ // NoOp on Android. The iOS implementation returns credentials stored in
109
+ // the shared App Group keychain used by the AutoFill extension. On
110
+ // Android, credentials are managed by the CredentialProviderService and
111
+ // are not exposed back to JS through this module.
112
+ AsyncFunction("getStoredCredentials") {
113
+ emptyList<Map<String, Any?>>()
114
+ }
115
+
116
+ // NoOp on Android. iOS exposes diagnostics from the shared App Group
117
+ // store; there is no equivalent on Android yet.
118
+ AsyncFunction("getDiagnostics") {
119
+ emptyList<String>()
120
+ }
121
+
122
+ AsyncFunction("isProviderActive") {
123
+ val context = (appContext.reactContext ?: appContext.hostingRuntimeContext) as? Context
124
+ ?: return@AsyncFunction false
125
+ isProviderEnabled(context)
126
+ }
127
+
128
+ AsyncFunction("openProviderSettings") {
129
+ val context = (appContext.reactContext ?: appContext.hostingRuntimeContext) as? Context
130
+ ?: return@AsyncFunction false
131
+ openCredentialProviderSettings(context)
132
+ }
133
+
134
+ AsyncFunction("getStoredCredentials") {
135
+ val context = (appContext.reactContext ?: appContext.hostingRuntimeContext) as? Context
136
+ ?: return@AsyncFunction emptyList<Map<String, Any>>()
137
+ credentialRepository.getAllCredentials(context).map { credential ->
138
+ mapOf(
139
+ "credentialId" to credential.credentialId,
140
+ "relyingPartyIdentifier" to credential.origin,
141
+ "userName" to credential.userHandle,
142
+ "userHandle" to credential.userHandle,
143
+ "publicKey" to credential.publicKey,
144
+ )
145
+ }
146
+ }
147
+
148
+ // The iOS AutoFill identity store (ASCredentialIdentityStore) has no
149
+ // Android analogue: our CredentialProviderService answers each
150
+ // BeginGetCredentialRequest from MMKV on demand, so there is no store to
151
+ // pre-populate. This is a no-op purely to satisfy the shared JS API.
152
+ AsyncFunction("refreshCredentialIdentities") {
153
+ // no-op on Android
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Returns `true` when this app's [PasskeyAutofillCredentialProviderService]
159
+ * is registered as an active credential provider for the current user.
160
+ *
161
+ * On API 34+ (UpsideDownCake) we use the official
162
+ * [android.credentials.CredentialManager.isEnabledCredentialProviderService]
163
+ * API, which reflects the user's current toggle in Settings in real time.
164
+ *
165
+ * On older devices we fall back to a best-effort read of the `@hide`
166
+ * `credential_service` / `credential_service_primary` Secure settings. These
167
+ * typically throw `SecurityException` for non-system apps on Android 12+,
168
+ * in which case we conservatively return `false` rather than guess.
169
+ */
170
+ private fun isProviderEnabled(context: Context): Boolean {
171
+ val component = ComponentName(
172
+ context.packageName,
173
+ PasskeyAutofillCredentialProviderService::class.java.name,
174
+ )
175
+ val expected = component.flattenToString()
176
+
177
+ // Preferred path (API 34+, UpsideDownCake): ask the platform
178
+ // `CredentialManager` system service whether our component is enabled.
179
+ // This is the official, real-time-accurate signal and is trusted
180
+ // exclusively on supported OS versions.
181
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
182
+ return try {
183
+ val cm = context.getSystemService(android.credentials.CredentialManager::class.java)
184
+ cm != null && cm.isEnabledCredentialProviderService(component)
185
+ } catch (e: Throwable) {
186
+ Log.d("ReactNativePasskeyAutofill", "CredentialManager.isEnabledCredentialProviderService failed: ${e.message}")
187
+ false
188
+ }
189
+ }
190
+
191
+ // Pre-API-34 best-effort: try the (often `@hide`) Secure settings keys.
192
+ val resolver = context.contentResolver
193
+ val keys = arrayOf("credential_service", "credential_service_primary")
194
+ for (key in keys) {
195
+ val value = try {
196
+ Settings.Secure.getString(resolver, key)
197
+ } catch (e: SecurityException) {
198
+ Log.d("ReactNativePasskeyAutofill", "Secure key $key not readable: ${e.message}")
199
+ null
200
+ } ?: continue
201
+ if (value.isEmpty()) continue
202
+ val enabled = value.split(':').any { it.equals(expected, ignoreCase = true) } ||
203
+ value.contains(expected, ignoreCase = true)
204
+ if (enabled) return true
205
+ }
206
+ return false
207
+ }
208
+
209
+ /**
210
+ * Best-effort deep link into the user's credential-provider preferences so
211
+ * they can toggle our service on. Falls back to the app-details page when
212
+ * the credential provider screen is not available on the device.
213
+ */
214
+ private fun openCredentialProviderSettings(context: Context): Boolean {
215
+ // The system action for the Credential Manager provider picker is
216
+ // `android.settings.CREDENTIAL_PROVIDER` (`Settings.ACTION_CREDENTIAL_PROVIDER`,
217
+ // API 34+). Some OEM Settings builds additionally accept a
218
+ // `:settings:fragment_args_key` extra so the screen scrolls directly to
219
+ // our app's row instead of the generic list. We also include a legacy
220
+ // autofill-service picker fallback for devices where the Credential
221
+ // Manager screen isn't a directly launchable activity.
222
+ val component = ComponentName(
223
+ context.packageName,
224
+ PasskeyAutofillCredentialProviderService::class.java.name,
225
+ ).flattenToString()
226
+ val intents = listOf(
227
+ // Preferred: Credential Manager provider settings, deep-linked to our row.
228
+ Intent("android.settings.CREDENTIAL_PROVIDER").apply {
229
+ putExtra(":settings:fragment_args_key", component)
230
+ putExtra(
231
+ ":settings:show_fragment_args",
232
+ android.os.Bundle().apply { putString(":settings:fragment_args_key", component) },
233
+ )
234
+ },
235
+ // Same screen without the deep-link extras (some OEMs ignore them).
236
+ Intent("android.settings.CREDENTIAL_PROVIDER"),
237
+ // Legacy / fallback: the system autofill provider picker, which on
238
+ // pre-14 devices is the closest "pick a passkey provider" screen.
239
+ Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE).apply {
240
+ data = Uri.parse("package:${context.packageName}")
241
+ },
242
+ // Last-resort fallback: this app's details page.
243
+ Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
244
+ data = Uri.fromParts("package", context.packageName, null)
245
+ },
246
+ )
247
+ for (intent in intents) {
248
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
249
+ try {
250
+ context.startActivity(intent)
251
+ return true
252
+ } catch (e: Exception) {
253
+ Log.w("ReactNativePasskeyAutofill", "Failed to open ${intent.action}: ${e.message}")
254
+ }
255
+ }
256
+ return false
69
257
  }
70
258
  }
@@ -0,0 +1,135 @@
1
+ package co.algorand.passkeyautofill
2
+
3
+ import android.content.Context
4
+ import android.util.Base64
5
+ import java.nio.ByteBuffer
6
+ import java.util.UUID
7
+
8
+ /**
9
+ * WebAuthn attestation helpers.
10
+ *
11
+ * The AndroidX credentials library (`AuthenticatorAttestationResponse`) always emits an
12
+ * all-zero AAGUID. To present the same authenticator identity as the iOS provider and the
13
+ * native Pera apps, we splice a configured AAGUID into the attestationObject after AndroidX
14
+ * builds it. The value is injected by the Expo config plugin (`aaguid` prop) as the
15
+ * `passkey_autofill_aaguid` string resource.
16
+ */
17
+ object WebAuthn {
18
+ private const val AAGUID_RESOURCE = "passkey_autofill_aaguid"
19
+ private const val AAGUID_LENGTH = 16
20
+
21
+ // authData layout: rpIdHash(32) + flags(1) + signCount(4) then attestedCredentialData,
22
+ // which begins with the 16-byte AAGUID.
23
+ private const val AAGUID_OFFSET_IN_AUTH_DATA = 37
24
+
25
+ private const val BASE64_FLAGS = Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING
26
+
27
+ private val ZERO_AAGUID = ByteArray(AAGUID_LENGTH)
28
+
29
+ /** Returns the configured AAGUID bytes, or null when unset/blank/all-zero/malformed. */
30
+ fun configuredAaguid(context: Context): ByteArray? {
31
+ val resId = context.resources.getIdentifier(AAGUID_RESOURCE, "string", context.packageName)
32
+ if (resId == 0) return null
33
+ val raw = context.getString(resId).trim()
34
+ if (raw.isEmpty()) return null
35
+ val bytes = try {
36
+ uuidToBytes(UUID.fromString(raw))
37
+ } catch (e: IllegalArgumentException) {
38
+ return null
39
+ }
40
+ return if (bytes.contentEquals(ZERO_AAGUID)) null else bytes
41
+ }
42
+
43
+ /**
44
+ * Overwrites the 16-byte AAGUID inside the attestationObject's authData with [aaguid].
45
+ * Returns the re-encoded (base64url, no padding) attestationObject.
46
+ */
47
+ fun injectAaguid(attestationObjectB64Url: String, aaguid: ByteArray): String {
48
+ require(aaguid.size == AAGUID_LENGTH) { "AAGUID must be $AAGUID_LENGTH bytes" }
49
+ val bytes = Base64.decode(attestationObjectB64Url, BASE64_FLAGS)
50
+ val authDataStart = findAuthDataContentStart(bytes)
51
+ val offset = authDataStart + AAGUID_OFFSET_IN_AUTH_DATA
52
+ require(offset + AAGUID_LENGTH <= bytes.size) {
53
+ "attestationObject is too short to contain an AAGUID"
54
+ }
55
+ System.arraycopy(aaguid, 0, bytes, offset, AAGUID_LENGTH)
56
+ return Base64.encodeToString(bytes, BASE64_FLAGS)
57
+ }
58
+
59
+ private fun uuidToBytes(uuid: UUID): ByteArray =
60
+ ByteBuffer.allocate(AAGUID_LENGTH)
61
+ .putLong(uuid.mostSignificantBits)
62
+ .putLong(uuid.leastSignificantBits)
63
+ .array()
64
+
65
+ // --- Minimal CBOR reader: locate the "authData" byte-string value in the top-level map ---
66
+
67
+ private class Cursor(val buf: ByteArray) {
68
+ var pos = 0
69
+ }
70
+
71
+ private fun findAuthDataContentStart(buf: ByteArray): Int {
72
+ val c = Cursor(buf)
73
+ val (major, count) = readHead(c)
74
+ require(major == 5) { "attestationObject is not a CBOR map" }
75
+ repeat(count.toInt()) {
76
+ val key = readTextString(c)
77
+ if (key == "authData") {
78
+ val (valueMajor, valueLen) = readHead(c)
79
+ require(valueMajor == 2) { "authData is not a CBOR byte string" }
80
+ val start = c.pos
81
+ c.pos += valueLen.toInt()
82
+ return start
83
+ }
84
+ skipItem(c)
85
+ }
86
+ throw IllegalArgumentException("authData not found in attestationObject")
87
+ }
88
+
89
+ /** Reads a CBOR item head, returning (majorType, argument) and advancing the cursor. */
90
+ private fun readHead(c: Cursor): Pair<Int, Long> {
91
+ val initial = c.buf[c.pos].toInt() and 0xff
92
+ c.pos += 1
93
+ val major = initial ushr 5
94
+ val additional = initial and 0x1f
95
+ val argument = when {
96
+ additional < 24 -> additional.toLong()
97
+ additional == 24 -> readUInt(c, 1)
98
+ additional == 25 -> readUInt(c, 2)
99
+ additional == 26 -> readUInt(c, 4)
100
+ additional == 27 -> readUInt(c, 8)
101
+ else -> throw IllegalArgumentException("Unsupported CBOR additional info: $additional")
102
+ }
103
+ return Pair(major, argument)
104
+ }
105
+
106
+ private fun readUInt(c: Cursor, byteCount: Int): Long {
107
+ var value = 0L
108
+ repeat(byteCount) {
109
+ value = (value shl 8) or (c.buf[c.pos].toLong() and 0xff)
110
+ c.pos += 1
111
+ }
112
+ return value
113
+ }
114
+
115
+ private fun readTextString(c: Cursor): String {
116
+ val (major, length) = readHead(c)
117
+ require(major == 3) { "Expected a CBOR text-string map key" }
118
+ val value = String(c.buf, c.pos, length.toInt(), Charsets.UTF_8)
119
+ c.pos += length.toInt()
120
+ return value
121
+ }
122
+
123
+ private fun skipItem(c: Cursor) {
124
+ val (major, argument) = readHead(c)
125
+ when (major) {
126
+ 0, 1 -> Unit // unsigned/negative integer: value is the head argument
127
+ 2, 3 -> c.pos += argument.toInt() // byte/text string
128
+ 4 -> repeat(argument.toInt()) { skipItem(c) } // array
129
+ 5 -> repeat(argument.toInt()) { skipItem(c); skipItem(c) } // map: key + value
130
+ 6 -> skipItem(c) // tag: skip the tagged item
131
+ 7 -> Unit // simple value / float: width already consumed by readHead
132
+ else -> throw IllegalArgumentException("Unsupported CBOR major type: $major")
133
+ }
134
+ }
135
+ }
@@ -0,0 +1,86 @@
1
+ package co.algorand.passkeyautofill.auth
2
+
3
+ import android.content.Context
4
+ import android.content.pm.PackageManager
5
+ import android.security.keystore.KeyProperties
6
+ import android.util.Log
7
+ import androidx.biometric.BiometricManager.Authenticators
8
+
9
+ /**
10
+ * The set of authenticators a passkey operation will accept. Configured at build time via the
11
+ * `biometricRequirement` config-plugin prop and read from app `<meta-data>` at runtime.
12
+ */
13
+ enum class BiometricRequirement {
14
+ /** Strong biometric only. Crypto-bound key. */
15
+ STRONG,
16
+
17
+ /** Strong biometric OR device credential (PIN/pattern/password). Crypto-bound key. Default. */
18
+ STRONG_OR_CREDENTIAL,
19
+
20
+ /**
21
+ * Weak biometric OR device credential. NOT crypto-bound: Android cannot gate a Keystore key on
22
+ * weak biometrics, so the key is not user-auth-required and the prompt is a UI-only gate.
23
+ */
24
+ WEAK_OR_CREDENTIAL;
25
+
26
+ /** Authenticators to pass to `BiometricPrompt.PromptInfo` / `BiometricPromptData`. */
27
+ val allowedAuthenticators: Int
28
+ get() = when (this) {
29
+ STRONG -> Authenticators.BIOMETRIC_STRONG
30
+ STRONG_OR_CREDENTIAL -> Authenticators.BIOMETRIC_STRONG or Authenticators.DEVICE_CREDENTIAL
31
+ WEAK_OR_CREDENTIAL -> Authenticators.BIOMETRIC_WEAK or Authenticators.DEVICE_CREDENTIAL
32
+ }
33
+
34
+ /** True when device credential is accepted; a negative button must NOT be set in that case. */
35
+ val allowsDeviceCredential: Boolean
36
+ get() = when (this) {
37
+ STRONG -> false
38
+ STRONG_OR_CREDENTIAL, WEAK_OR_CREDENTIAL -> true
39
+ }
40
+
41
+ /** True when the Keystore key is user-auth-bound and prompts use a `CryptoObject`. */
42
+ val isCryptoBound: Boolean
43
+ get() = when (this) {
44
+ STRONG, STRONG_OR_CREDENTIAL -> true
45
+ WEAK_OR_CREDENTIAL -> false
46
+ }
47
+
48
+ /** Auth type for `KeyGenParameterSpec.setUserAuthenticationParameters` (only when [isCryptoBound]). */
49
+ val keystoreAuthType: Int
50
+ get() = when (this) {
51
+ STRONG -> KeyProperties.AUTH_BIOMETRIC_STRONG
52
+ STRONG_OR_CREDENTIAL -> KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL
53
+ WEAK_OR_CREDENTIAL -> 0 // no auth binding — key is not user-auth-required
54
+ }
55
+
56
+ companion object {
57
+ private const val TAG = "BiometricRequirement"
58
+ const val META_DATA_KEY = "co.algorand.passkeyautofill.BIOMETRIC_REQUIREMENT"
59
+
60
+ fun fromValue(value: String?): BiometricRequirement = when (val trimmed = value?.trim()) {
61
+ "strong" -> STRONG
62
+ "strongOrCredential" -> STRONG_OR_CREDENTIAL
63
+ "weakOrCredential" -> WEAK_OR_CREDENTIAL
64
+ else -> {
65
+ if (!trimmed.isNullOrEmpty()) {
66
+ Log.w(TAG, "Unknown biometricRequirement value '$trimmed', using default STRONG_OR_CREDENTIAL")
67
+ }
68
+ STRONG_OR_CREDENTIAL
69
+ }
70
+ }
71
+
72
+ /** Reads the build-time configured requirement from app `<meta-data>`; defaults on any failure. */
73
+ fun resolve(context: Context): BiometricRequirement {
74
+ val value = try {
75
+ context.packageManager
76
+ .getApplicationInfo(context.packageName, PackageManager.GET_META_DATA)
77
+ .metaData
78
+ ?.getString(META_DATA_KEY)
79
+ } catch (e: Exception) {
80
+ Log.w(TAG, "Failed to read $META_DATA_KEY meta-data, using default", e)
81
+ null
82
+ }
83
+ return fromValue(value)
84
+ }
85
+ }
86
+ }
@@ -7,5 +7,6 @@ data class Credential(
7
7
  val userId: String,
8
8
  val publicKey: String,
9
9
  val privateKey: String,
10
- val count: Int
10
+ val count: Int,
11
+ val biometricIv: String? = null
11
12
  )