@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.
- package/.eslintrc.js +8 -0
- package/.releaserc +23 -0
- package/ARCHITECTURE.md +67 -0
- package/CHANGELOG.md +42 -0
- package/CONTRIBUTING.md +82 -0
- package/LICENSE +201 -0
- package/README.md +135 -4
- package/android/build.gradle +86 -0
- package/android/libs/dP256Android-release.aar +0 -0
- package/android/src/main/AndroidManifest.xml +37 -0
- package/android/src/main/java/co/algorand/passkeyautofill/CreatePasskeyActivity.kt +318 -0
- package/android/src/main/java/co/algorand/passkeyautofill/GetPasskeyActivity.kt +327 -0
- package/android/src/main/java/co/algorand/passkeyautofill/ReactNativePasskeyAutofillModule.kt +84 -0
- package/android/src/main/java/co/algorand/passkeyautofill/credentials/Credential.kt +11 -0
- package/android/src/main/java/co/algorand/passkeyautofill/credentials/CredentialRepository.kt +506 -0
- package/android/src/main/java/co/algorand/passkeyautofill/service/PasskeyAutofillCredentialProviderService.kt +248 -0
- package/android/src/main/java/co/algorand/passkeyautofill/utils/PasskeyUtils.kt +7 -0
- package/android/src/main/res/values/strings.xml +3 -0
- package/android/src/main/res/values/styles.xml +10 -0
- package/android/src/main/res/xml/provider.xml +5 -0
- package/app.plugin.js +249 -0
- package/assets/banner.png +0 -0
- package/build/ReactNativePasskeyAutofill.types.d.ts +9 -0
- package/build/ReactNativePasskeyAutofill.types.d.ts.map +1 -0
- package/build/ReactNativePasskeyAutofill.types.js +2 -0
- package/build/ReactNativePasskeyAutofill.types.js.map +1 -0
- package/build/ReactNativePasskeyAutofillModule.d.ts +12 -0
- package/build/ReactNativePasskeyAutofillModule.d.ts.map +1 -0
- package/build/ReactNativePasskeyAutofillModule.js +4 -0
- package/build/ReactNativePasskeyAutofillModule.js.map +1 -0
- package/build/index.d.ts +3 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +5 -0
- package/build/index.js.map +1 -0
- package/expo-module.config.json +10 -0
- package/ios/ReactNativePasskeyAutofill.podspec +29 -0
- package/ios/ReactNativePasskeyAutofillModule.swift +49 -0
- package/ios/ReactNativePasskeyAutofillView.swift +38 -0
- package/lefthook.yml +13 -0
- package/package.json +64 -8
- package/src/ReactNativePasskeyAutofill.types.ts +4 -0
- package/src/ReactNativePasskeyAutofillModule.ts +14 -0
- package/src/index.ts +4 -0
- package/tsconfig.json +9 -0
- 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,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
|
+
}
|