@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
|
@@ -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
|
+
}
|
package/android/src/main/java/co/algorand/passkeyautofill/ReactNativePasskeyAutofillModule.kt
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
+
}
|