@algorandfoundation/react-native-passkey-autofill 0.0.1 → 1.0.0-canary.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/.eslintrc.js +8 -0
  2. package/.releaserc +23 -0
  3. package/ARCHITECTURE.md +67 -0
  4. package/CHANGELOG.md +42 -0
  5. package/CONTRIBUTING.md +82 -0
  6. package/LICENSE +201 -0
  7. package/README.md +135 -4
  8. package/android/build.gradle +86 -0
  9. package/android/libs/dP256Android-release.aar +0 -0
  10. package/android/src/main/AndroidManifest.xml +37 -0
  11. package/android/src/main/java/co/algorand/passkeyautofill/CreatePasskeyActivity.kt +318 -0
  12. package/android/src/main/java/co/algorand/passkeyautofill/GetPasskeyActivity.kt +327 -0
  13. package/android/src/main/java/co/algorand/passkeyautofill/ReactNativePasskeyAutofillModule.kt +84 -0
  14. package/android/src/main/java/co/algorand/passkeyautofill/credentials/Credential.kt +11 -0
  15. package/android/src/main/java/co/algorand/passkeyautofill/credentials/CredentialRepository.kt +506 -0
  16. package/android/src/main/java/co/algorand/passkeyautofill/service/PasskeyAutofillCredentialProviderService.kt +248 -0
  17. package/android/src/main/java/co/algorand/passkeyautofill/utils/PasskeyUtils.kt +7 -0
  18. package/android/src/main/res/values/strings.xml +3 -0
  19. package/android/src/main/res/values/styles.xml +10 -0
  20. package/android/src/main/res/xml/provider.xml +5 -0
  21. package/app.plugin.js +249 -0
  22. package/assets/banner.png +0 -0
  23. package/build/ReactNativePasskeyAutofill.types.d.ts +9 -0
  24. package/build/ReactNativePasskeyAutofill.types.d.ts.map +1 -0
  25. package/build/ReactNativePasskeyAutofill.types.js +2 -0
  26. package/build/ReactNativePasskeyAutofill.types.js.map +1 -0
  27. package/build/ReactNativePasskeyAutofillModule.d.ts +12 -0
  28. package/build/ReactNativePasskeyAutofillModule.d.ts.map +1 -0
  29. package/build/ReactNativePasskeyAutofillModule.js +4 -0
  30. package/build/ReactNativePasskeyAutofillModule.js.map +1 -0
  31. package/build/index.d.ts +3 -0
  32. package/build/index.d.ts.map +1 -0
  33. package/build/index.js +5 -0
  34. package/build/index.js.map +1 -0
  35. package/expo-module.config.json +10 -0
  36. package/ios/ReactNativePasskeyAutofill.podspec +29 -0
  37. package/ios/ReactNativePasskeyAutofillModule.swift +49 -0
  38. package/ios/ReactNativePasskeyAutofillView.swift +38 -0
  39. package/lefthook.yml +13 -0
  40. package/package.json +64 -8
  41. package/src/ReactNativePasskeyAutofill.types.ts +4 -0
  42. package/src/ReactNativePasskeyAutofillModule.ts +14 -0
  43. package/src/index.ts +4 -0
  44. package/tsconfig.json +9 -0
  45. package/index.js +0 -1
@@ -0,0 +1,84 @@
1
+ package co.algorand.passkeyautofill
2
+
3
+ import expo.modules.kotlin.modules.Module
4
+ import expo.modules.kotlin.modules.ModuleDefinition
5
+ import co.algorand.passkeyautofill.credentials.CredentialRepository
6
+ import com.tencent.mmkv.MMKV
7
+ import android.content.Context
8
+ import android.util.Log
9
+ import org.bouncycastle.jce.provider.BouncyCastleProvider
10
+ import java.security.Security
11
+
12
+ class ReactNativePasskeyAutofillModule : Module() {
13
+ private val credentialRepository = CredentialRepository()
14
+
15
+ companion object {
16
+ var instance: ReactNativePasskeyAutofillModule? = null
17
+ }
18
+
19
+ init {
20
+ Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME)
21
+ Security.insertProviderAt(BouncyCastleProvider(), 1)
22
+ }
23
+
24
+ // Each module class must implement the definition function. The definition consists of components
25
+ // that describes the module's functionality and behavior.
26
+ // See https://docs.expo.dev/modules/module-api for more details about available components.
27
+ override fun definition() = ModuleDefinition {
28
+ // Sets the name of the module that JavaScript code will use to refer to the module. Takes a string as an argument.
29
+ // Can be inferred from module's class name, but it's recommended to set it explicitly for clarity.
30
+ // The module will be accessible from `requireNativeModule('ReactNativeLiquidAuth')` in JavaScript.
31
+ Name("ReactNativePasskeyAutofill")
32
+
33
+ OnCreate {
34
+ instance = this@ReactNativePasskeyAutofillModule
35
+ }
36
+
37
+ OnDestroy {
38
+ instance = null
39
+ }
40
+
41
+ Events("onPasskeyAdded", "onPasskeyAuthenticated")
42
+
43
+ AsyncFunction("setMasterKey") { secret: String ->
44
+ val context = (appContext.reactContext ?: appContext.hostingRuntimeContext) as? Context
45
+ if (context != null) {
46
+ credentialRepository.saveMasterKey(context, secret)
47
+ } else {
48
+ Log.e(CredentialRepository.TAG, "Could not get context to save master key")
49
+ }
50
+ }
51
+
52
+ AsyncFunction("setHdRootKeyId") { id: String ->
53
+ val context = (appContext.reactContext ?: appContext.hostingRuntimeContext) as? Context
54
+ if (context != null) {
55
+ credentialRepository.saveHdRootKeyId(context, id)
56
+ } else {
57
+ Log.e(CredentialRepository.TAG, "Could not get context to save HD root key ID")
58
+ }
59
+ }
60
+
61
+ AsyncFunction("getHdRootKeyId") {
62
+ val context = (appContext.reactContext ?: appContext.hostingRuntimeContext) as? Context
63
+ if (context != null) {
64
+ credentialRepository.getHdRootKeyId(context)
65
+ } else {
66
+ Log.e(CredentialRepository.TAG, "Could not get context to get HD root key ID")
67
+ null
68
+ }
69
+ }
70
+
71
+ AsyncFunction("clearCredentials") {
72
+ val context = (appContext.reactContext ?: appContext.hostingRuntimeContext) as? Context
73
+ ?: return@AsyncFunction Unit
74
+ credentialRepository.clearCredentials(context)
75
+ }
76
+
77
+ AsyncFunction("configureIntentActions") { getPasskeyAction: String, createPasskeyAction: String ->
78
+ val context = (appContext.reactContext ?: appContext.hostingRuntimeContext) as? Context
79
+ if (context != null) {
80
+ credentialRepository.configureIntentActions(context, getPasskeyAction, createPasskeyAction)
81
+ }
82
+ }
83
+ }
84
+ }
@@ -0,0 +1,11 @@
1
+ package co.algorand.passkeyautofill.credentials
2
+
3
+ data class Credential(
4
+ val credentialId: String,
5
+ val origin: String,
6
+ val userHandle: String,
7
+ val userId: String,
8
+ val publicKey: String,
9
+ val privateKey: String,
10
+ val count: Int
11
+ )
@@ -0,0 +1,506 @@
1
+ package co.algorand.passkeyautofill.credentials
2
+
3
+ import android.content.Context
4
+ import android.content.SharedPreferences
5
+ import android.os.Build
6
+ import android.security.keystore.KeyGenParameterSpec
7
+ import android.security.keystore.KeyProperties
8
+ import android.util.Log
9
+ import androidx.annotation.RequiresApi
10
+ import androidx.credentials.provider.CallingAppInfo
11
+ import com.tencent.mmkv.MMKV
12
+ import foundation.algorand.deterministicP256.DeterministicP256
13
+ import java.security.*
14
+ import java.security.spec.*
15
+ import javax.crypto.Cipher
16
+ import javax.crypto.CipherOutputStream
17
+ import javax.crypto.KeyGenerator
18
+ import javax.crypto.SecretKey
19
+ import javax.crypto.spec.GCMParameterSpec
20
+ import org.json.JSONArray
21
+ import org.json.JSONObject
22
+ import android.util.Base64 as AndroidBase64
23
+ import java.io.ByteArrayOutputStream
24
+ import java.security.SecureRandom
25
+
26
+
27
+ interface CredentialRepository {
28
+ val keyStore: KeyStore
29
+ fun saveCredential(context: Context, credential: Credential)
30
+ fun generateCredentialId(keyPair: KeyPair): ByteArray
31
+ fun getKeyPair(context: Context, credentialId: ByteArray): KeyPair?
32
+ fun createDeterministicKeyPair(context: Context, origin: String, userHandle: String): KeyPair
33
+ fun getOrigin(info: CallingAppInfo): String
34
+ fun appInfoToOrigin(info: CallingAppInfo): String
35
+ fun getCredential(context: Context, credentialId: ByteArray): Credential?
36
+ fun getCredentialByOrigin(context: Context, origin: String): Credential?
37
+ fun getAllCredentials(context: Context): List<Credential>
38
+ fun getPublicKeyFromKeyPair(keyPair: KeyPair?): ByteArray
39
+ fun sign(keyPair: KeyPair, payload: ByteArray): ByteArray
40
+ fun isMasterKeyAvailable(context: Context): Boolean
41
+ fun saveMasterKey(context: Context, secret: String)
42
+ fun saveHdRootKeyId(context: Context, id: String)
43
+ fun getHdRootKeyId(context: Context): String?
44
+ fun configureIntentActions(context: Context, getPasskeyAction: String, createPasskeyAction: String)
45
+ fun getCreatePasskeyAction(context: Context): String?
46
+ fun getGetPasskeyAction(context: Context): String?
47
+ fun clearCredentials(context: Context)
48
+
49
+ companion object {
50
+ const val TAG = "CredentialRepository"
51
+ const val PASSKEYS_MMKV_ID = "keystore"
52
+ const val CREDENTIALS_KEY = "credentials"
53
+ const val GET_PASSKEY_ACTION_KEY = "get_passkey_action"
54
+ const val CREATE_PASSKEY_ACTION_KEY = "create_passkey_action"
55
+ const val MASTER_KEY_ALIAS = "co.algorand.passkeyautofill.masterkey"
56
+ const val KEYCHAIN_STORAGE_NAME = "PasskeyAutofillKeychain"
57
+ const val PASSKEY_AUTOFILL_MMKV_ID = "passkey_autofill"
58
+ const val HD_ROOT_KEY_ID_KEY = "hd_root_key_id"
59
+ }
60
+ }
61
+
62
+ fun CredentialRepository(): CredentialRepository = Repository()
63
+
64
+ class Repository() : CredentialRepository {
65
+ override var keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore")
66
+ private var dP256: DeterministicP256 = DeterministicP256()
67
+
68
+ init {
69
+ keyStore.load(null)
70
+ }
71
+
72
+ private fun getPasskeysMMKV(context: Context): MMKV {
73
+ MMKV.initialize(context)
74
+ // Entry-level encryption (AES-256-GCM) is used for individual keys.
75
+ return MMKV.mmkvWithID(CredentialRepository.PASSKEYS_MMKV_ID, MMKV.MULTI_PROCESS_MODE)
76
+ }
77
+
78
+ private fun getAutofillMMKV(context: Context): MMKV {
79
+ MMKV.initialize(context)
80
+ return MMKV.mmkvWithID(CredentialRepository.PASSKEY_AUTOFILL_MMKV_ID, MMKV.MULTI_PROCESS_MODE)
81
+ }
82
+
83
+ override fun saveCredential(context: Context, credential: Credential) {
84
+ val mmkv = getPasskeysMMKV(context)
85
+
86
+ // 1. Create KeyData matching @algorandfoundation/keystore
87
+ val keyData = JSONObject()
88
+ keyData.put("id", credential.credentialId)
89
+ keyData.put("type", "hd-derived-p256")
90
+ keyData.put("algorithm", "P256")
91
+ keyData.put("extractable", false)
92
+ keyData.put("keyUsages", JSONArray(listOf("sign")))
93
+ keyData.put("name", "Passkey: ${credential.origin}")
94
+ keyData.put("privateKey", JSONArray(AndroidBase64.decode(credential.privateKey, AndroidBase64.DEFAULT).map { it.toInt() and 0xFF }))
95
+ keyData.put("publicKey", JSONArray(AndroidBase64.decode(credential.publicKey, AndroidBase64.DEFAULT).map { it.toInt() and 0xFF }))
96
+
97
+ // Custom fields for our use
98
+ val metadata = JSONObject()
99
+ metadata.put("origin", credential.origin)
100
+ metadata.put("userHandle", credential.userHandle)
101
+ metadata.put("userId", credential.userId)
102
+ metadata.put("count", credential.count)
103
+ keyData.put("metadata", metadata)
104
+
105
+ // 2. Encode matching react-native-keystore's encode()
106
+ val jsonString = keyData.toString()
107
+ val base64urlJson = AndroidBase64.encodeToString(jsonString.toByteArray(Charsets.UTF_8), AndroidBase64.URL_SAFE or AndroidBase64.NO_WRAP)
108
+
109
+ // 3. Encrypt matching react-native-keystore's commit()
110
+ val masterKey = getMasterKey(context)
111
+ if (masterKey != null) {
112
+ val encryptedPayload = encryptData(masterKey, base64urlJson)
113
+ mmkv.encode(credential.credentialId, encryptedPayload)
114
+ } else {
115
+ Log.w(CredentialRepository.TAG, "Master key not available for encrypting credential, saving encoded only")
116
+ mmkv.encode(credential.credentialId, base64urlJson)
117
+ }
118
+ }
119
+
120
+ override fun getAllCredentials(context: Context): List<Credential> {
121
+ val mmkv = getPasskeysMMKV(context)
122
+ val allKeys = mmkv.allKeys() ?: return emptyList()
123
+ val credentials = mutableListOf<Credential>()
124
+
125
+ val masterKey = getMasterKey(context) ?: return emptyList()
126
+
127
+ for (key in allKeys) {
128
+ val payload = mmkv.decodeString(key) ?: continue
129
+ try {
130
+ val json = decodeKeyData(payload, masterKey)
131
+ // Basic validation to ensure it's a credential
132
+ if (json.has("id") && (json.has("origin") || json.has("metadata"))) {
133
+ val metadata = json.optJSONObject("metadata")
134
+ credentials.add(Credential(
135
+ credentialId = json.getString("id"),
136
+ origin = metadata?.optString("origin") ?: json.optString("origin", ""),
137
+ userHandle = metadata?.optString("userHandle") ?: json.optString("userHandle", ""),
138
+ userId = metadata?.optString("userId") ?: json.optString("userId", ""),
139
+ 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)
142
+ ))
143
+ }
144
+ } catch (e: Exception) {
145
+ // Not a JSON or not a credential or decryption failed, skip
146
+ continue
147
+ }
148
+ }
149
+ return credentials
150
+ }
151
+
152
+ private fun jsonArrayToByteArray(array: JSONArray): ByteArray {
153
+ val bytes = ByteArray(array.length())
154
+ for (i in 0 until array.length()) {
155
+ bytes[i] = array.getInt(i).toByte()
156
+ }
157
+ return bytes
158
+ }
159
+
160
+ override fun generateCredentialId(keyPair: KeyPair): ByteArray {
161
+ val publicKeyBytes = keyPair.public.encoded
162
+ val messageDigest = MessageDigest.getInstance("SHA-256")
163
+ return messageDigest.digest(publicKeyBytes)
164
+ }
165
+
166
+ override fun getCredential(context: Context, credentialId: ByteArray): Credential? {
167
+ val id = AndroidBase64.encodeToString(credentialId, AndroidBase64.DEFAULT).trim()
168
+ val mmkv = getPasskeysMMKV(context)
169
+ val payload = mmkv.decodeString(id) ?: return null
170
+ val masterKey = getMasterKey(context) ?: return null
171
+ return try {
172
+ val json = decodeKeyData(payload, masterKey)
173
+ val metadata = json.optJSONObject("metadata")
174
+ Credential(
175
+ credentialId = json.getString("id"),
176
+ origin = metadata?.optString("origin") ?: json.optString("origin", ""),
177
+ userHandle = metadata?.optString("userHandle") ?: json.optString("userHandle", ""),
178
+ userId = metadata?.optString("userId") ?: json.optString("userId", ""),
179
+ publicKey = AndroidBase64.encodeToString(jsonArrayToByteArray(json.getJSONArray("publicKey")), AndroidBase64.DEFAULT),
180
+ privateKey = AndroidBase64.encodeToString(jsonArrayToByteArray(json.getJSONArray("privateKey")), AndroidBase64.DEFAULT),
181
+ count = metadata?.optInt("count") ?: json.optInt("count", 0)
182
+ )
183
+ } catch (e: Exception) {
184
+ null
185
+ }
186
+ }
187
+
188
+ override fun getCredentialByOrigin(context: Context, origin: String): Credential? {
189
+ return getAllCredentials(context).find { it.origin == origin }
190
+ }
191
+
192
+ fun getKeyPairFromCredential(credential: Credential): KeyPair? {
193
+ val publicKeyBytes = AndroidBase64.decode(credential.publicKey, AndroidBase64.DEFAULT)
194
+ val privateKeyBytes = AndroidBase64.decode(credential.privateKey, AndroidBase64.DEFAULT)
195
+ val factory = KeyFactory.getInstance("EC")
196
+ val publicKey = factory.generatePublic(X509EncodedKeySpec(publicKeyBytes))
197
+ val privateKey = factory.generatePrivate(PKCS8EncodedKeySpec(privateKeyBytes))
198
+ return KeyPair(publicKey, privateKey)
199
+ }
200
+
201
+ override fun getKeyPair(context: Context, credentialId: ByteArray): KeyPair? {
202
+ val credential = getCredential(context, credentialId)
203
+ return credential?.let { getKeyPairFromCredential(it) }
204
+ }
205
+
206
+ override fun createDeterministicKeyPair(
207
+ context: Context,
208
+ origin: String,
209
+ userHandle: String
210
+ ): KeyPair {
211
+ val masterKey = getMasterKey(context) ?: throw IllegalStateException("Master key not found in Keystore. Ensure you have called setMasterKey(key) from JavaScript.")
212
+
213
+ val mmkvAutofill = getAutofillMMKV(context)
214
+ val hdRootKeyId = mmkvAutofill.decodeString(CredentialRepository.HD_ROOT_KEY_ID_KEY) ?: throw IllegalStateException("HD Root Key ID not found. Ensure you have called setHdRootKeyId(id) from JavaScript.")
215
+
216
+ val mmkvKeystore = getPasskeysMMKV(context)
217
+ val hdRootKeyPayload = mmkvKeystore.decodeString(hdRootKeyId) ?: throw IllegalStateException("HD Root Key not found in keystore for ID: $hdRootKeyId")
218
+
219
+ val hdRootKeyData = decodeKeyData(hdRootKeyPayload, masterKey)
220
+
221
+ // In react-native-keystore, the private key/seed are stored as arrays of numbers in JSON
222
+ // 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
+ val seedArray = hdRootKeyData.optJSONArray("seed") ?: hdRootKeyData.optJSONArray("privateKey")
226
+ val derivedParentSecret = if (seedArray != null) {
227
+ val bytes = ByteArray(seedArray.length())
228
+ for (i in 0 until seedArray.length()) {
229
+ bytes[i] = seedArray.getInt(i).toByte()
230
+ }
231
+ bytes
232
+ } else {
233
+ val seed = (if (hdRootKeyData.has("seed")) hdRootKeyData.getString("seed") else null)
234
+ ?: (if (hdRootKeyData.has("privateKey")) hdRootKeyData.getString("privateKey") else null)
235
+ ?: throw IllegalStateException("HD Root Key does not contain a seed or privateKey")
236
+
237
+ if (seed.startsWith("0x")) {
238
+ hexToBytes(seed.substring(2))
239
+ } else {
240
+ // It might be base64url encoded or just hex
241
+ try {
242
+ hexToBytes(seed)
243
+ } catch (e: Exception) {
244
+ AndroidBase64.decode(seed, AndroidBase64.URL_SAFE or AndroidBase64.NO_WRAP)
245
+ }
246
+ }
247
+ }
248
+
249
+ return dP256.genDomainSpecificKeypair(derivedParentSecret, origin, userHandle.lowercase())
250
+ }
251
+
252
+ private fun encryptData(key: ByteArray, data: String): String {
253
+ val cipher = Cipher.getInstance("AES/GCM/NoPadding")
254
+ val iv = ByteArray(12)
255
+ SecureRandom().nextBytes(iv)
256
+
257
+ val keySpec = javax.crypto.spec.SecretKeySpec(key, "AES")
258
+ val gcmSpec = GCMParameterSpec(128, iv)
259
+ cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec)
260
+
261
+ val encryptedWithTag = cipher.doFinal(data.toByteArray(Charsets.UTF_8))
262
+
263
+ // In GCM mode, the tag is at the end of the ciphertext returned by doFinal
264
+ val tagSize = 16
265
+ val contentSize = encryptedWithTag.size - tagSize
266
+ val content = encryptedWithTag.sliceArray(0 until contentSize)
267
+ val tag = encryptedWithTag.sliceArray(contentSize until encryptedWithTag.size)
268
+
269
+ val json = JSONObject()
270
+ // Use NO_WRAP consistently to avoid newlines, but ensure standard base64 for compatibility
271
+ json.put("iv", AndroidBase64.encodeToString(iv, AndroidBase64.NO_WRAP))
272
+ json.put("tag", AndroidBase64.encodeToString(tag, AndroidBase64.NO_WRAP))
273
+ json.put("content", AndroidBase64.encodeToString(content, AndroidBase64.NO_WRAP))
274
+
275
+ return json.toString()
276
+ }
277
+
278
+ private fun decodeKeyData(payload: String, masterKey: ByteArray?): JSONObject {
279
+ try {
280
+ // Check if it's the old encrypted format (starts with { and has iv, tag, content)
281
+ if (payload.startsWith("{")) {
282
+ val json = JSONObject(payload)
283
+ if (json.has("iv") && json.has("tag") && json.has("content")) {
284
+ if (masterKey == null) throw IllegalStateException("Master key required for legacy decryption")
285
+
286
+ val iv = AndroidBase64.decode(json.getString("iv"), AndroidBase64.DEFAULT)
287
+ val tag = AndroidBase64.decode(json.getString("tag"), AndroidBase64.DEFAULT)
288
+ val content = AndroidBase64.decode(json.getString("content"), AndroidBase64.DEFAULT)
289
+
290
+ val keySpec = javax.crypto.spec.SecretKeySpec(masterKey, "AES")
291
+ val cipher = Cipher.getInstance("AES/GCM/NoPadding")
292
+ val gcmSpec = GCMParameterSpec(128, iv)
293
+ cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec)
294
+
295
+ val combined = content + tag
296
+ val decryptedBytes = cipher.doFinal(combined)
297
+ val decryptedString = String(decryptedBytes, Charsets.UTF_8)
298
+
299
+ // The decrypted bytes are base64url encoded JSON
300
+ val decodedJsonBytes = try {
301
+ AndroidBase64.decode(decryptedString, AndroidBase64.URL_SAFE or AndroidBase64.NO_WRAP)
302
+ } catch (e: Exception) {
303
+ decryptedString.toByteArray(Charsets.UTF_8)
304
+ }
305
+ return JSONObject(String(decodedJsonBytes, Charsets.UTF_8))
306
+ }
307
+ return json
308
+ }
309
+
310
+ // New format: base64url encoded JSON (MMKV handles the encryption/decryption of this string)
311
+ val decodedBytes = AndroidBase64.decode(payload, AndroidBase64.URL_SAFE or AndroidBase64.NO_WRAP)
312
+ return JSONObject(String(decodedBytes, Charsets.UTF_8))
313
+ } catch (e: Exception) {
314
+ Log.e(CredentialRepository.TAG, "Failed to decode payload", e)
315
+ throw e
316
+ }
317
+ }
318
+
319
+ override fun saveMasterKey(context: Context, secret: String) {
320
+
321
+ // Convert hex string to bytes if it's a valid hex string of appropriate length
322
+ val keyBytes = try {
323
+ if ((secret.length == 64 || secret.length == 32) && secret.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }) {
324
+ hexToBytes(secret.trim())
325
+ } else {
326
+ secret.toByteArray(Charsets.UTF_8)
327
+ }
328
+ } catch (e: Exception) {
329
+ secret.toByteArray(Charsets.UTF_8)
330
+ }
331
+
332
+ // Store in our separate Keychain for persistence
333
+ try {
334
+ encryptToKeychain(context, keyBytes)
335
+ } catch (e: Exception) {
336
+ Log.e(CredentialRepository.TAG, "Failed to save master key to Keychain", e)
337
+ }
338
+ }
339
+
340
+ private fun getMasterKey(context: Context): ByteArray? {
341
+ // Try our separate Keychain storage
342
+ return try {
343
+ val decrypted = decryptFromKeychain(context)
344
+ if (decrypted != null) {
345
+ // Return the raw bytes directly as they were already processed during save
346
+ decrypted
347
+ } else {
348
+ null
349
+ }
350
+ } catch (e: Exception) {
351
+ Log.e(CredentialRepository.TAG, "Failed to get master key from Keychain", e)
352
+ null
353
+ }
354
+ }
355
+
356
+ private fun getSecretKey(): SecretKey {
357
+ val ks = KeyStore.getInstance("AndroidKeyStore")
358
+ ks.load(null)
359
+ if (!ks.containsAlias(CredentialRepository.MASTER_KEY_ALIAS)) {
360
+ val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
361
+ keyGenerator.init(
362
+ KeyGenParameterSpec.Builder(CredentialRepository.MASTER_KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
363
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
364
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
365
+ .setKeySize(256)
366
+ .build()
367
+ )
368
+ keyGenerator.generateKey()
369
+ }
370
+ return ks.getKey(CredentialRepository.MASTER_KEY_ALIAS, null) as SecretKey
371
+ }
372
+
373
+ private fun encryptToKeychain(context: Context, data: ByteArray) {
374
+ val cipher = Cipher.getInstance("AES/GCM/NoPadding")
375
+ cipher.init(Cipher.ENCRYPT_MODE, getSecretKey())
376
+ val iv = cipher.iv
377
+ val encryptedData = cipher.doFinal(data)
378
+
379
+ val prefs = context.getSharedPreferences(CredentialRepository.KEYCHAIN_STORAGE_NAME, Context.MODE_PRIVATE)
380
+ prefs.edit()
381
+ .putString("iv", AndroidBase64.encodeToString(iv, AndroidBase64.NO_WRAP))
382
+ .putString("content", AndroidBase64.encodeToString(encryptedData, AndroidBase64.NO_WRAP))
383
+ .apply()
384
+ }
385
+
386
+ private fun decryptFromKeychain(context: Context): ByteArray? {
387
+ val prefs = context.getSharedPreferences(CredentialRepository.KEYCHAIN_STORAGE_NAME, Context.MODE_PRIVATE)
388
+ val ivStr = prefs.getString("iv", null) ?: return null
389
+ val contentStr = prefs.getString("content", null) ?: return null
390
+
391
+ val iv = AndroidBase64.decode(ivStr, AndroidBase64.NO_WRAP)
392
+ val content = AndroidBase64.decode(contentStr, AndroidBase64.NO_WRAP)
393
+
394
+ val cipher = Cipher.getInstance("AES/GCM/NoPadding")
395
+ val gcmSpec = GCMParameterSpec(128, iv)
396
+ cipher.init(Cipher.DECRYPT_MODE, getSecretKey(), gcmSpec)
397
+
398
+ return cipher.doFinal(content)
399
+ }
400
+
401
+ override fun isMasterKeyAvailable(context: Context): Boolean {
402
+ return getMasterKey(context) != null
403
+ }
404
+
405
+ override fun saveHdRootKeyId(context: Context, id: String) {
406
+ val mmkv = getAutofillMMKV(context)
407
+ mmkv.encode(CredentialRepository.HD_ROOT_KEY_ID_KEY, id)
408
+ }
409
+
410
+ override fun getHdRootKeyId(context: Context): String? {
411
+ val mmkv = getAutofillMMKV(context)
412
+ return mmkv.decodeString(CredentialRepository.HD_ROOT_KEY_ID_KEY)
413
+ }
414
+
415
+ override fun configureIntentActions(context: Context, getPasskeyAction: String, createPasskeyAction: String) {
416
+ val mmkv = getAutofillMMKV(context)
417
+ mmkv.encode(CredentialRepository.GET_PASSKEY_ACTION_KEY, getPasskeyAction)
418
+ mmkv.encode(CredentialRepository.CREATE_PASSKEY_ACTION_KEY, createPasskeyAction)
419
+ }
420
+
421
+ override fun getCreatePasskeyAction(context: Context): String? {
422
+ val mmkv = getAutofillMMKV(context)
423
+ return mmkv.decodeString(CredentialRepository.CREATE_PASSKEY_ACTION_KEY)
424
+ }
425
+
426
+ override fun getGetPasskeyAction(context: Context): String? {
427
+ val mmkv = getAutofillMMKV(context)
428
+ return mmkv.decodeString(CredentialRepository.GET_PASSKEY_ACTION_KEY)
429
+ }
430
+
431
+ override fun clearCredentials(context: Context) {
432
+ try {
433
+ val mmkvAutofill = getAutofillMMKV(context)
434
+ mmkvAutofill.clearAll()
435
+
436
+ val mmkvPasskeys = getPasskeysMMKV(context)
437
+ mmkvPasskeys.clearAll()
438
+
439
+ } catch (e: Exception) {
440
+ Log.e(CredentialRepository.TAG, "Error clearing credentials and secrets", e)
441
+ }
442
+ }
443
+
444
+ private fun hexToBytes(hex: String): ByteArray {
445
+ val result = ByteArray(hex.length / 2)
446
+ for (i in 0 until hex.length step 2) {
447
+ val firstIndex = Character.digit(hex[i], 16)
448
+ val secondIndex = Character.digit(hex[i + 1], 16)
449
+ val octet = firstIndex shl 4 or secondIndex
450
+ result[i / 2] = octet.toByte()
451
+ }
452
+ return result
453
+ }
454
+
455
+ private fun bytesToHex(bytes: ByteArray): String {
456
+ return bytes.joinToString("") { "%02x".format(it) }
457
+ }
458
+
459
+ override fun sign(keyPair: KeyPair, payload: ByteArray): ByteArray {
460
+ return dP256.signWithDomainSpecificKeyPair(keyPair, payload)
461
+ }
462
+
463
+ override fun getPublicKeyFromKeyPair(keyPair: KeyPair?): ByteArray {
464
+ if (keyPair == null) return ByteArray(0)
465
+ if (keyPair.public !is java.security.interfaces.ECPublicKey) return ByteArray(0)
466
+
467
+ val ecPubKey = keyPair.public as java.security.interfaces.ECPublicKey
468
+ val ecPoint: java.security.spec.ECPoint = ecPubKey.w
469
+
470
+ // for now, only covers ES256
471
+ if (ecPoint.affineX.bitLength() > 256 || ecPoint.affineY.bitLength() > 256) return ByteArray(0)
472
+
473
+ val byteX = bigIntToByteArray32(ecPoint.affineX)
474
+ val byteY = bigIntToByteArray32(ecPoint.affineY)
475
+
476
+ // refer to RFC9052 Section 7 for details
477
+ return "A5010203262001215820".chunked(2).map { it.toInt(16).toByte() }.toByteArray() + byteX + "225820".chunked(2).map { it.toInt(16).toByte() }.toByteArray() + byteY
478
+ }
479
+
480
+ private fun bigIntToByteArray32(bigInteger: java.math.BigInteger): ByteArray {
481
+ var ba = bigInteger.toByteArray()
482
+ if (ba.size < 32) {
483
+ ba = ByteArray(32 - ba.size) + ba
484
+ }
485
+ return ba.copyOfRange(ba.size - 32, ba.size)
486
+ }
487
+
488
+ @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
489
+ override fun getOrigin(info: CallingAppInfo): String {
490
+ return try {
491
+ info.origin ?: appInfoToOrigin(info)
492
+ } catch (e: NoSuchMethodError) {
493
+ appInfoToOrigin(info)
494
+ } catch (e: Exception) {
495
+ appInfoToOrigin(info)
496
+ }
497
+ }
498
+
499
+ @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
500
+ override fun appInfoToOrigin(info: CallingAppInfo): String {
501
+ val cert = info.signingInfo.apkContentsSigners[0].toByteArray()
502
+ val md = MessageDigest.getInstance("SHA-256")
503
+ val certHash = md.digest(cert)
504
+ return "android:apk-key-hash:${AndroidBase64.encodeToString(certHash, AndroidBase64.URL_SAFE or AndroidBase64.NO_WRAP or AndroidBase64.NO_PADDING)}"
505
+ }
506
+ }