@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
package/android/src/main/java/co/algorand/passkeyautofill/credentials/CredentialRepository.kt
CHANGED
|
@@ -22,17 +22,18 @@ import org.json.JSONObject
|
|
|
22
22
|
import android.util.Base64 as AndroidBase64
|
|
23
23
|
import java.io.ByteArrayOutputStream
|
|
24
24
|
import java.security.SecureRandom
|
|
25
|
+
import co.algorand.passkeyautofill.auth.BiometricRequirement
|
|
25
26
|
|
|
26
27
|
|
|
27
28
|
interface CredentialRepository {
|
|
28
29
|
val keyStore: KeyStore
|
|
29
|
-
fun saveCredential(context: Context, credential: Credential)
|
|
30
|
+
fun saveCredential(context: Context, credential: Credential, biometricCipher: Cipher? = null)
|
|
30
31
|
fun generateCredentialId(keyPair: KeyPair): ByteArray
|
|
31
|
-
fun getKeyPair(context: Context, credentialId: ByteArray): KeyPair?
|
|
32
|
-
fun createDeterministicKeyPair(context: Context, origin: String, userHandle: String): KeyPair
|
|
32
|
+
fun getKeyPair(context: Context, credentialId: ByteArray, biometricCipher: Cipher? = null): KeyPair?
|
|
33
|
+
fun createDeterministicKeyPair(context: Context, origin: String, userHandle: String, biometricCipher: Cipher? = null): KeyPair
|
|
33
34
|
fun getOrigin(info: CallingAppInfo): String
|
|
34
35
|
fun appInfoToOrigin(info: CallingAppInfo): String
|
|
35
|
-
fun getCredential(context: Context, credentialId: ByteArray): Credential?
|
|
36
|
+
fun getCredential(context: Context, credentialId: ByteArray, biometricCipher: Cipher? = null): Credential?
|
|
36
37
|
fun getCredentialByOrigin(context: Context, origin: String): Credential?
|
|
37
38
|
fun getAllCredentials(context: Context): List<Credential>
|
|
38
39
|
fun getPublicKeyFromKeyPair(keyPair: KeyPair?): ByteArray
|
|
@@ -45,6 +46,18 @@ interface CredentialRepository {
|
|
|
45
46
|
fun getCreatePasskeyAction(context: Context): String?
|
|
46
47
|
fun getGetPasskeyAction(context: Context): String?
|
|
47
48
|
fun clearCredentials(context: Context)
|
|
49
|
+
fun deleteCredential(context: Context, credentialId: String)
|
|
50
|
+
fun recordCredentialUsage(context: Context, credentialId: ByteArray)
|
|
51
|
+
|
|
52
|
+
fun getBiometricCipherForEncryption(context: Context, requirement: BiometricRequirement): Cipher
|
|
53
|
+
fun getBiometricCipherForDecryption(context: Context, iv: ByteArray, requirement: BiometricRequirement): Cipher
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Returns the raw HD root secret (seed bytes) that is used to derive
|
|
57
|
+
* domain-specific signing keys. Required by the PRF extension to
|
|
58
|
+
* deterministically derive the per-credential `credRandom` secret.
|
|
59
|
+
*/
|
|
60
|
+
fun getHdRootSecret(context: Context): ByteArray?
|
|
48
61
|
|
|
49
62
|
companion object {
|
|
50
63
|
const val TAG = "CredentialRepository"
|
|
@@ -53,9 +66,20 @@ interface CredentialRepository {
|
|
|
53
66
|
const val GET_PASSKEY_ACTION_KEY = "get_passkey_action"
|
|
54
67
|
const val CREATE_PASSKEY_ACTION_KEY = "create_passkey_action"
|
|
55
68
|
const val MASTER_KEY_ALIAS = "co.algorand.passkeyautofill.masterkey"
|
|
69
|
+
const val BIOMETRIC_KEY_ALIAS = "co.algorand.passkeyautofill.biometric.v2"
|
|
56
70
|
const val KEYCHAIN_STORAGE_NAME = "PasskeyAutofillKeychain"
|
|
57
71
|
const val PASSKEY_AUTOFILL_MMKV_ID = "passkey_autofill"
|
|
58
72
|
const val HD_ROOT_KEY_ID_KEY = "hd_root_key_id"
|
|
73
|
+
const val BIOMETRIC_KEY_LEVEL_KEY = "biometric_key_level"
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* JCE provider that exposes AndroidKeyStore-backed symmetric Cipher
|
|
77
|
+
* transformations (including AES/GCM). Pinning to this provider avoids
|
|
78
|
+
* BouncyCastle — which this module installs at position 1 — trying to
|
|
79
|
+
* extract raw bytes from a hardware-bound SecretKey and crashing with
|
|
80
|
+
* NullPointerException in `KeyParameter.<init>`.
|
|
81
|
+
*/
|
|
82
|
+
const val ANDROID_KEYSTORE_CIPHER_PROVIDER = "AndroidKeyStoreBCWorkaround"
|
|
59
83
|
}
|
|
60
84
|
}
|
|
61
85
|
|
|
@@ -80,7 +104,8 @@ class Repository() : CredentialRepository {
|
|
|
80
104
|
return MMKV.mmkvWithID(CredentialRepository.PASSKEY_AUTOFILL_MMKV_ID, MMKV.MULTI_PROCESS_MODE)
|
|
81
105
|
}
|
|
82
106
|
|
|
83
|
-
override fun saveCredential(context: Context, credential: Credential) {
|
|
107
|
+
override fun saveCredential(context: Context, credential: Credential, biometricCipher: Cipher?) {
|
|
108
|
+
Log.d(CredentialRepository.TAG, "saveCredential started for ${credential.origin}, userHandle: ${credential.userHandle}")
|
|
84
109
|
val mmkv = getPasskeysMMKV(context)
|
|
85
110
|
|
|
86
111
|
// 1. Create KeyData matching @algorandfoundation/keystore
|
|
@@ -91,7 +116,20 @@ class Repository() : CredentialRepository {
|
|
|
91
116
|
keyData.put("extractable", false)
|
|
92
117
|
keyData.put("keyUsages", JSONArray(listOf("sign")))
|
|
93
118
|
keyData.put("name", "Passkey: ${credential.origin}")
|
|
94
|
-
|
|
119
|
+
|
|
120
|
+
val privateKeyBytes = AndroidBase64.decode(credential.privateKey, AndroidBase64.DEFAULT)
|
|
121
|
+
if (biometricCipher != null) {
|
|
122
|
+
Log.d(CredentialRepository.TAG, "Using biometricCipher for encryption")
|
|
123
|
+
val encryptedBytes = biometricCipher.doFinal(privateKeyBytes)
|
|
124
|
+
val encJson = JSONObject()
|
|
125
|
+
encJson.put("iv", AndroidBase64.encodeToString(biometricCipher.iv, AndroidBase64.NO_WRAP))
|
|
126
|
+
encJson.put("data", AndroidBase64.encodeToString(encryptedBytes, AndroidBase64.NO_WRAP))
|
|
127
|
+
keyData.put("privateKeyEnc", encJson)
|
|
128
|
+
} else {
|
|
129
|
+
Log.d(CredentialRepository.TAG, "No biometricCipher, saving privateKey in plain (base64 in JSON)")
|
|
130
|
+
keyData.put("privateKey", JSONArray(privateKeyBytes.map { it.toInt() and 0xFF }))
|
|
131
|
+
}
|
|
132
|
+
|
|
95
133
|
keyData.put("publicKey", JSONArray(AndroidBase64.decode(credential.publicKey, AndroidBase64.DEFAULT).map { it.toInt() and 0xFF }))
|
|
96
134
|
|
|
97
135
|
// Custom fields for our use
|
|
@@ -128,17 +166,24 @@ class Repository() : CredentialRepository {
|
|
|
128
166
|
val payload = mmkv.decodeString(key) ?: continue
|
|
129
167
|
try {
|
|
130
168
|
val json = decodeKeyData(payload, masterKey)
|
|
131
|
-
//
|
|
169
|
+
// Only treat entries as passkey credentials that match p256
|
|
170
|
+
val keyType = json.optString("type", "")
|
|
171
|
+
if (keyType != "hd-derived-p256" && keyType != "xhd-derived-p256") {
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
132
174
|
if (json.has("id") && (json.has("origin") || json.has("metadata"))) {
|
|
133
175
|
val metadata = json.optJSONObject("metadata")
|
|
176
|
+
val encJson = json.optJSONObject("privateKeyEnc")
|
|
177
|
+
|
|
134
178
|
credentials.add(Credential(
|
|
135
179
|
credentialId = json.getString("id"),
|
|
136
180
|
origin = metadata?.optString("origin") ?: json.optString("origin", ""),
|
|
137
181
|
userHandle = metadata?.optString("userHandle") ?: json.optString("userHandle", ""),
|
|
138
182
|
userId = metadata?.optString("userId") ?: json.optString("userId", ""),
|
|
139
183
|
publicKey = AndroidBase64.encodeToString(jsonArrayToByteArray(json.getJSONArray("publicKey")), AndroidBase64.DEFAULT),
|
|
140
|
-
privateKey = AndroidBase64.encodeToString(jsonArrayToByteArray(json.getJSONArray("privateKey")), AndroidBase64.DEFAULT),
|
|
141
|
-
count = metadata?.optInt("count") ?: json.optInt("count", 0)
|
|
184
|
+
privateKey = if (json.has("privateKey")) AndroidBase64.encodeToString(jsonArrayToByteArray(json.getJSONArray("privateKey")), AndroidBase64.DEFAULT) else "",
|
|
185
|
+
count = metadata?.optInt("count") ?: json.optInt("count", 0),
|
|
186
|
+
biometricIv = encJson?.optString("iv")
|
|
142
187
|
))
|
|
143
188
|
}
|
|
144
189
|
} catch (e: Exception) {
|
|
@@ -163,22 +208,45 @@ class Repository() : CredentialRepository {
|
|
|
163
208
|
return messageDigest.digest(publicKeyBytes)
|
|
164
209
|
}
|
|
165
210
|
|
|
166
|
-
override fun getCredential(context: Context, credentialId: ByteArray): Credential? {
|
|
211
|
+
override fun getCredential(context: Context, credentialId: ByteArray, biometricCipher: Cipher?): Credential? {
|
|
167
212
|
val id = AndroidBase64.encodeToString(credentialId, AndroidBase64.DEFAULT).trim()
|
|
213
|
+
Log.d(CredentialRepository.TAG, "getCredential started for id: $id")
|
|
168
214
|
val mmkv = getPasskeysMMKV(context)
|
|
169
|
-
val payload = mmkv.decodeString(id) ?:
|
|
170
|
-
|
|
215
|
+
val payload = mmkv.decodeString(id) ?: run {
|
|
216
|
+
Log.w(CredentialRepository.TAG, "No payload found for id: $id")
|
|
217
|
+
return null
|
|
218
|
+
}
|
|
219
|
+
val masterKey = getMasterKey(context) ?: run {
|
|
220
|
+
Log.e(CredentialRepository.TAG, "Master key not found")
|
|
221
|
+
return null
|
|
222
|
+
}
|
|
171
223
|
return try {
|
|
172
224
|
val json = decodeKeyData(payload, masterKey)
|
|
173
225
|
val metadata = json.optJSONObject("metadata")
|
|
226
|
+
val encJson = json.optJSONObject("privateKeyEnc")
|
|
227
|
+
|
|
228
|
+
val privateKey = if (encJson != null && biometricCipher != null) {
|
|
229
|
+
Log.d(CredentialRepository.TAG, "Decrypting privateKey with biometricCipher")
|
|
230
|
+
val data = AndroidBase64.decode(encJson.getString("data"), AndroidBase64.DEFAULT)
|
|
231
|
+
val decrypted = biometricCipher.doFinal(data)
|
|
232
|
+
AndroidBase64.encodeToString(decrypted, AndroidBase64.DEFAULT)
|
|
233
|
+
} else if (json.has("privateKey")) {
|
|
234
|
+
Log.d(CredentialRepository.TAG, "Using plain privateKey from JSON")
|
|
235
|
+
AndroidBase64.encodeToString(jsonArrayToByteArray(json.getJSONArray("privateKey")), AndroidBase64.DEFAULT)
|
|
236
|
+
} else {
|
|
237
|
+
Log.w(CredentialRepository.TAG, "No privateKey found in JSON")
|
|
238
|
+
""
|
|
239
|
+
}
|
|
240
|
+
|
|
174
241
|
Credential(
|
|
175
242
|
credentialId = json.getString("id"),
|
|
176
243
|
origin = metadata?.optString("origin") ?: json.optString("origin", ""),
|
|
177
244
|
userHandle = metadata?.optString("userHandle") ?: json.optString("userHandle", ""),
|
|
178
245
|
userId = metadata?.optString("userId") ?: json.optString("userId", ""),
|
|
179
246
|
publicKey = AndroidBase64.encodeToString(jsonArrayToByteArray(json.getJSONArray("publicKey")), AndroidBase64.DEFAULT),
|
|
180
|
-
privateKey =
|
|
181
|
-
count = metadata?.optInt("count") ?: json.optInt("count", 0)
|
|
247
|
+
privateKey = privateKey,
|
|
248
|
+
count = metadata?.optInt("count") ?: json.optInt("count", 0),
|
|
249
|
+
biometricIv = encJson?.optString("iv")
|
|
182
250
|
)
|
|
183
251
|
} catch (e: Exception) {
|
|
184
252
|
null
|
|
@@ -190,63 +258,129 @@ class Repository() : CredentialRepository {
|
|
|
190
258
|
}
|
|
191
259
|
|
|
192
260
|
fun getKeyPairFromCredential(credential: Credential): KeyPair? {
|
|
261
|
+
if (credential.privateKey.isEmpty()) return null
|
|
193
262
|
val publicKeyBytes = AndroidBase64.decode(credential.publicKey, AndroidBase64.DEFAULT)
|
|
194
263
|
val privateKeyBytes = AndroidBase64.decode(credential.privateKey, AndroidBase64.DEFAULT)
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
264
|
+
|
|
265
|
+
return try {
|
|
266
|
+
// First try DER-encoded format (X509/PKCS8)
|
|
267
|
+
val factory = KeyFactory.getInstance("EC")
|
|
268
|
+
val publicKey = factory.generatePublic(X509EncodedKeySpec(publicKeyBytes))
|
|
269
|
+
val privateKey = factory.generatePrivate(PKCS8EncodedKeySpec(privateKeyBytes))
|
|
270
|
+
KeyPair(publicKey, privateKey)
|
|
271
|
+
} catch (e: Exception) {
|
|
272
|
+
// Fall back to raw EC bytes
|
|
273
|
+
try {
|
|
274
|
+
// Get the P-256 curve parameters
|
|
275
|
+
val ecGenSpec = ECGenParameterSpec("secp256r1")
|
|
276
|
+
val keyPairGen = KeyPairGenerator.getInstance("EC")
|
|
277
|
+
keyPairGen.initialize(ecGenSpec)
|
|
278
|
+
val paramSpec = (keyPairGen.generateKeyPair().public as java.security.interfaces.ECPublicKey).params
|
|
279
|
+
val factory = KeyFactory.getInstance("EC")
|
|
280
|
+
|
|
281
|
+
val publicKeyFromX509 = try {
|
|
282
|
+
factory.generatePublic(X509EncodedKeySpec(publicKeyBytes))
|
|
283
|
+
} catch (_: Exception) {
|
|
284
|
+
null
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (publicKeyFromX509 != null && privateKeyBytes.size == 32) {
|
|
288
|
+
val s = java.math.BigInteger(1, privateKeyBytes)
|
|
289
|
+
val privSpec = ECPrivateKeySpec(s, paramSpec)
|
|
290
|
+
val privateKey = factory.generatePrivate(privSpec)
|
|
291
|
+
return KeyPair(publicKeyFromX509, privateKey)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// privateKey is 32-byte scalar
|
|
295
|
+
val rawXY: Pair<ByteArray, ByteArray>? = when {
|
|
296
|
+
// 65-byte uncompressed point: 04 || x(32) || y(32)
|
|
297
|
+
publicKeyBytes.size == 65 && publicKeyBytes[0] == 0x04.toByte() ->
|
|
298
|
+
Pair(publicKeyBytes.copyOfRange(1, 33), publicKeyBytes.copyOfRange(33, 65))
|
|
299
|
+
// 64-byte raw x || y (no prefix)
|
|
300
|
+
publicKeyBytes.size == 64 ->
|
|
301
|
+
Pair(publicKeyBytes.copyOfRange(0, 32), publicKeyBytes.copyOfRange(32, 64))
|
|
302
|
+
else -> null
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (rawXY != null) {
|
|
306
|
+
val x = java.math.BigInteger(1, rawXY.first)
|
|
307
|
+
val y = java.math.BigInteger(1, rawXY.second)
|
|
308
|
+
val pubSpec = ECPublicKeySpec(ECPoint(x, y), paramSpec)
|
|
309
|
+
val publicKey = factory.generatePublic(pubSpec)
|
|
310
|
+
|
|
311
|
+
val s = java.math.BigInteger(1, privateKeyBytes)
|
|
312
|
+
val privSpec = ECPrivateKeySpec(s, paramSpec)
|
|
313
|
+
val privateKey = factory.generatePrivate(privSpec)
|
|
314
|
+
|
|
315
|
+
KeyPair(publicKey, privateKey)
|
|
316
|
+
} else {
|
|
317
|
+
Log.e(CredentialRepository.TAG, "Unrecognized key format: publicKey size=${publicKeyBytes.size}", e)
|
|
318
|
+
null
|
|
319
|
+
}
|
|
320
|
+
} catch (e2: Exception) {
|
|
321
|
+
Log.e(CredentialRepository.TAG, "Failed to restore key from raw bytes", e2)
|
|
322
|
+
null
|
|
323
|
+
}
|
|
324
|
+
}
|
|
199
325
|
}
|
|
200
326
|
|
|
201
|
-
override fun getKeyPair(context: Context, credentialId: ByteArray): KeyPair? {
|
|
202
|
-
val credential = getCredential(context, credentialId)
|
|
327
|
+
override fun getKeyPair(context: Context, credentialId: ByteArray, biometricCipher: Cipher?): KeyPair? {
|
|
328
|
+
val credential = getCredential(context, credentialId, biometricCipher)
|
|
203
329
|
return credential?.let { getKeyPairFromCredential(it) }
|
|
204
330
|
}
|
|
205
331
|
|
|
206
332
|
override fun createDeterministicKeyPair(
|
|
207
333
|
context: Context,
|
|
208
334
|
origin: String,
|
|
209
|
-
userHandle: String
|
|
335
|
+
userHandle: String,
|
|
336
|
+
biometricCipher: Cipher?
|
|
210
337
|
): KeyPair {
|
|
211
|
-
|
|
212
|
-
|
|
338
|
+
Log.d(CredentialRepository.TAG, "createDeterministicKeyPair for origin: $origin, userHandle: $userHandle")
|
|
339
|
+
val derivedParentSecret = getHdRootSecret(context)
|
|
340
|
+
?: throw IllegalStateException("HD Root Key not available. Ensure setMasterKey(key) and setHdRootKeyId(id) have been called.")
|
|
341
|
+
return dP256.genDomainSpecificKeypair(derivedParentSecret, origin, userHandle.lowercase())
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
override fun getHdRootSecret(context: Context): ByteArray? {
|
|
345
|
+
val masterKey = getMasterKey(context) ?: return null
|
|
213
346
|
val mmkvAutofill = getAutofillMMKV(context)
|
|
214
|
-
val hdRootKeyId = mmkvAutofill.decodeString(CredentialRepository.HD_ROOT_KEY_ID_KEY) ?:
|
|
215
|
-
|
|
347
|
+
val hdRootKeyId = mmkvAutofill.decodeString(CredentialRepository.HD_ROOT_KEY_ID_KEY) ?: return null
|
|
216
348
|
val mmkvKeystore = getPasskeysMMKV(context)
|
|
217
|
-
val hdRootKeyPayload = mmkvKeystore.decodeString(hdRootKeyId) ?:
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
349
|
+
val hdRootKeyPayload = mmkvKeystore.decodeString(hdRootKeyId) ?: return null
|
|
350
|
+
val hdRootKeyData = try {
|
|
351
|
+
decodeKeyData(hdRootKeyPayload, masterKey)
|
|
352
|
+
} catch (e: Exception) {
|
|
353
|
+
Log.w(CredentialRepository.TAG, "Failed to decode HD root key", e)
|
|
354
|
+
return null
|
|
355
|
+
}
|
|
356
|
+
|
|
221
357
|
// In react-native-keystore, the private key/seed are stored as arrays of numbers in JSON
|
|
222
358
|
// due to how JSON.stringify handles Uint8Array.
|
|
223
|
-
// Our decode method in decryptHdRootKey might have already handled it if it matched react-native-keystore's decode.
|
|
224
|
-
|
|
225
359
|
val seedArray = hdRootKeyData.optJSONArray("seed") ?: hdRootKeyData.optJSONArray("privateKey")
|
|
226
|
-
|
|
360
|
+
if (seedArray != null) {
|
|
227
361
|
val bytes = ByteArray(seedArray.length())
|
|
228
362
|
for (i in 0 until seedArray.length()) {
|
|
229
363
|
bytes[i] = seedArray.getInt(i).toByte()
|
|
230
364
|
}
|
|
231
|
-
bytes
|
|
365
|
+
return bytes
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
val seed = (if (hdRootKeyData.has("seed")) hdRootKeyData.getString("seed") else null)
|
|
369
|
+
?: (if (hdRootKeyData.has("privateKey")) hdRootKeyData.getString("privateKey") else null)
|
|
370
|
+
?: return null
|
|
371
|
+
return if (seed.startsWith("0x")) {
|
|
372
|
+
hexToBytes(seed.substring(2))
|
|
232
373
|
} else {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
if (seed.startsWith("0x")) {
|
|
238
|
-
hexToBytes(seed.substring(2))
|
|
239
|
-
} else {
|
|
240
|
-
// It might be base64url encoded or just hex
|
|
374
|
+
try {
|
|
375
|
+
hexToBytes(seed)
|
|
376
|
+
} catch (e: Exception) {
|
|
241
377
|
try {
|
|
242
|
-
hexToBytes(seed)
|
|
243
|
-
} catch (e: Exception) {
|
|
244
378
|
AndroidBase64.decode(seed, AndroidBase64.URL_SAFE or AndroidBase64.NO_WRAP)
|
|
379
|
+
} catch (e2: Exception) {
|
|
380
|
+
null
|
|
245
381
|
}
|
|
246
382
|
}
|
|
247
383
|
}
|
|
248
|
-
|
|
249
|
-
return dP256.genDomainSpecificKeypair(derivedParentSecret, origin, userHandle.lowercase())
|
|
250
384
|
}
|
|
251
385
|
|
|
252
386
|
private fun encryptData(key: ByteArray, data: String): String {
|
|
@@ -441,6 +575,59 @@ class Repository() : CredentialRepository {
|
|
|
441
575
|
}
|
|
442
576
|
}
|
|
443
577
|
|
|
578
|
+
override fun deleteCredential(context: Context, credentialId: String) {
|
|
579
|
+
try {
|
|
580
|
+
val mmkvPasskeys = getPasskeysMMKV(context)
|
|
581
|
+
for (candidateId in credentialIdCandidates(credentialId)) {
|
|
582
|
+
mmkvPasskeys.removeValueForKey(candidateId)
|
|
583
|
+
}
|
|
584
|
+
} catch (e: Exception) {
|
|
585
|
+
Log.e(CredentialRepository.TAG, "Error deleting credential", e)
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
override fun recordCredentialUsage(context: Context, credentialId: ByteArray) {
|
|
590
|
+
val id = AndroidBase64.encodeToString(credentialId, AndroidBase64.DEFAULT).trim()
|
|
591
|
+
try {
|
|
592
|
+
val mmkv = getPasskeysMMKV(context)
|
|
593
|
+
val payload = mmkv.decodeString(id) ?: return
|
|
594
|
+
val masterKey = getMasterKey(context) ?: return
|
|
595
|
+
|
|
596
|
+
val json = decodeKeyData(payload, masterKey)
|
|
597
|
+
val metadata = json.optJSONObject("metadata") ?: JSONObject()
|
|
598
|
+
metadata.put("lastUsedAt", System.currentTimeMillis())
|
|
599
|
+
metadata.put("count", metadata.optInt("count", 0) + 1)
|
|
600
|
+
json.put("metadata", metadata)
|
|
601
|
+
|
|
602
|
+
val base64urlJson = AndroidBase64.encodeToString(
|
|
603
|
+
json.toString().toByteArray(Charsets.UTF_8),
|
|
604
|
+
AndroidBase64.URL_SAFE or AndroidBase64.NO_WRAP,
|
|
605
|
+
)
|
|
606
|
+
mmkv.encode(id, encryptData(masterKey, base64urlJson))
|
|
607
|
+
} catch (e: Exception) {
|
|
608
|
+
Log.e(CredentialRepository.TAG, "Failed to record credential usage", e)
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
private fun credentialIdCandidates(id: String): Set<String> {
|
|
613
|
+
val candidates = mutableSetOf(id)
|
|
614
|
+
val decoded = try {
|
|
615
|
+
AndroidBase64.decode(id, AndroidBase64.URL_SAFE or AndroidBase64.NO_WRAP)
|
|
616
|
+
} catch (_: Exception) {
|
|
617
|
+
try {
|
|
618
|
+
AndroidBase64.decode(id, AndroidBase64.DEFAULT)
|
|
619
|
+
} catch (_: Exception) {
|
|
620
|
+
null
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (decoded != null) {
|
|
625
|
+
candidates.add(AndroidBase64.encodeToString(decoded, AndroidBase64.DEFAULT).trim())
|
|
626
|
+
candidates.add(AndroidBase64.encodeToString(decoded, AndroidBase64.URL_SAFE or AndroidBase64.NO_WRAP or AndroidBase64.NO_PADDING))
|
|
627
|
+
}
|
|
628
|
+
return candidates
|
|
629
|
+
}
|
|
630
|
+
|
|
444
631
|
private fun hexToBytes(hex: String): ByteArray {
|
|
445
632
|
val result = ByteArray(hex.length / 2)
|
|
446
633
|
for (i in 0 until hex.length step 2) {
|
|
@@ -456,6 +643,86 @@ class Repository() : CredentialRepository {
|
|
|
456
643
|
return bytes.joinToString("") { "%02x".format(it) }
|
|
457
644
|
}
|
|
458
645
|
|
|
646
|
+
private fun getBiometricSecretKey(context: Context, requirement: BiometricRequirement): SecretKey {
|
|
647
|
+
// The key's auth binding is fixed at creation. If a new build changed the configured
|
|
648
|
+
// level, regenerate the key so the prompt's allowed authenticators stay compatible.
|
|
649
|
+
// Passkey private keys are deterministically re-derivable, so this is recoverable.
|
|
650
|
+
val mmkv = getAutofillMMKV(context)
|
|
651
|
+
val storedLevel = mmkv.decodeString(CredentialRepository.BIOMETRIC_KEY_LEVEL_KEY)
|
|
652
|
+
if (keyStore.containsAlias(CredentialRepository.BIOMETRIC_KEY_ALIAS) &&
|
|
653
|
+
storedLevel != requirement.name
|
|
654
|
+
) {
|
|
655
|
+
Log.i(CredentialRepository.TAG, "Biometric requirement changed ($storedLevel -> ${requirement.name}); regenerating key")
|
|
656
|
+
keyStore.deleteEntry(CredentialRepository.BIOMETRIC_KEY_ALIAS)
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (!keyStore.containsAlias(CredentialRepository.BIOMETRIC_KEY_ALIAS)) {
|
|
660
|
+
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
|
|
661
|
+
val builder = KeyGenParameterSpec.Builder(
|
|
662
|
+
CredentialRepository.BIOMETRIC_KEY_ALIAS,
|
|
663
|
+
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
|
664
|
+
)
|
|
665
|
+
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
|
666
|
+
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
|
667
|
+
.setKeySize(256)
|
|
668
|
+
.setUserAuthenticationRequired(requirement.isCryptoBound)
|
|
669
|
+
|
|
670
|
+
// Biometric-enrollment invalidation and auth params only apply to user-auth-bound keys.
|
|
671
|
+
if (requirement.isCryptoBound) {
|
|
672
|
+
builder.setInvalidatedByBiometricEnrollment(true)
|
|
673
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
674
|
+
builder.setUserAuthenticationParameters(60, requirement.keystoreAuthType)
|
|
675
|
+
} else {
|
|
676
|
+
@Suppress("DEPRECATION")
|
|
677
|
+
builder.setUserAuthenticationValidityDurationSeconds(60)
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
keyGenerator.init(builder.build())
|
|
682
|
+
keyGenerator.generateKey()
|
|
683
|
+
mmkv.encode(CredentialRepository.BIOMETRIC_KEY_LEVEL_KEY, requirement.name)
|
|
684
|
+
}
|
|
685
|
+
return keyStore.getKey(CredentialRepository.BIOMETRIC_KEY_ALIAS, null) as SecretKey
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Build an AES/GCM Cipher tied to the AndroidKeyStore-backed biometric key.
|
|
690
|
+
*
|
|
691
|
+
* We request the provider explicitly because this module installs
|
|
692
|
+
* BouncyCastle at position 1 (see [co.algorand.passkeyautofill.ReactNativePasskeyAutofillModule]).
|
|
693
|
+
* Without a pinned provider, [Cipher.getInstance] resolves to BouncyCastle, which
|
|
694
|
+
* then tries to extract raw key material from an AndroidKeyStore SecretKey
|
|
695
|
+
* (whose `getEncoded()` is null for hardware-bound keys) and crashes with:
|
|
696
|
+
*
|
|
697
|
+
* java.lang.NullPointerException: Attempt to get length of null array
|
|
698
|
+
* at org.bouncycastle.crypto.params.KeyParameter.<init>(...)
|
|
699
|
+
*
|
|
700
|
+
* AndroidKeyStore AES keys are served by the "AndroidKeyStoreBCWorkaround"
|
|
701
|
+
* JCE provider on all supported Android versions.
|
|
702
|
+
*/
|
|
703
|
+
private fun newAndroidKeyStoreAesGcmCipher(): Cipher {
|
|
704
|
+
return try {
|
|
705
|
+
Cipher.getInstance("AES/GCM/NoPadding", CredentialRepository.ANDROID_KEYSTORE_CIPHER_PROVIDER)
|
|
706
|
+
} catch (e: NoSuchProviderException) {
|
|
707
|
+
// Extremely unlikely on stock Android, but fall back to provider discovery
|
|
708
|
+
// by key rather than by name so we never hand the key to BouncyCastle.
|
|
709
|
+
Log.w(CredentialRepository.TAG, "${CredentialRepository.ANDROID_KEYSTORE_CIPHER_PROVIDER} unavailable, falling back to default provider resolution", e)
|
|
710
|
+
Cipher.getInstance("AES/GCM/NoPadding")
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
override fun getBiometricCipherForEncryption(context: Context, requirement: BiometricRequirement): Cipher {
|
|
715
|
+
val cipher = newAndroidKeyStoreAesGcmCipher()
|
|
716
|
+
cipher.init(Cipher.ENCRYPT_MODE, getBiometricSecretKey(context, requirement))
|
|
717
|
+
return cipher
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
override fun getBiometricCipherForDecryption(context: Context, iv: ByteArray, requirement: BiometricRequirement): Cipher {
|
|
721
|
+
val cipher = newAndroidKeyStoreAesGcmCipher()
|
|
722
|
+
cipher.init(Cipher.DECRYPT_MODE, getBiometricSecretKey(context, requirement), GCMParameterSpec(128, iv))
|
|
723
|
+
return cipher
|
|
724
|
+
}
|
|
725
|
+
|
|
459
726
|
override fun sign(keyPair: KeyPair, payload: ByteArray): ByteArray {
|
|
460
727
|
return dP256.signWithDomainSpecificKeyPair(keyPair, payload)
|
|
461
728
|
}
|
|
@@ -487,13 +754,7 @@ class Repository() : CredentialRepository {
|
|
|
487
754
|
|
|
488
755
|
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
|
489
756
|
override fun getOrigin(info: CallingAppInfo): String {
|
|
490
|
-
return
|
|
491
|
-
info.origin ?: appInfoToOrigin(info)
|
|
492
|
-
} catch (e: NoSuchMethodError) {
|
|
493
|
-
appInfoToOrigin(info)
|
|
494
|
-
} catch (e: Exception) {
|
|
495
|
-
appInfoToOrigin(info)
|
|
496
|
-
}
|
|
757
|
+
return appInfoToOrigin(info)
|
|
497
758
|
}
|
|
498
759
|
|
|
499
760
|
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
|
@@ -503,4 +764,4 @@ class Repository() : CredentialRepository {
|
|
|
503
764
|
val certHash = md.digest(cert)
|
|
504
765
|
return "android:apk-key-hash:${AndroidBase64.encodeToString(certHash, AndroidBase64.URL_SAFE or AndroidBase64.NO_WRAP or AndroidBase64.NO_PADDING)}"
|
|
505
766
|
}
|
|
506
|
-
}
|
|
767
|
+
}
|