Add encrypted datastore (#36359)
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
package com.fleetdm.agent
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class KeystoreManagerTest {
|
||||
|
||||
@Test
|
||||
fun testEncryptDecrypt() {
|
||||
val originalText = "test_api_key_12345"
|
||||
|
||||
val encrypted = KeystoreManager.encrypt(originalText)
|
||||
assertNotEquals(originalText, encrypted)
|
||||
|
||||
val decrypted = KeystoreManager.decrypt(encrypted)
|
||||
assertEquals(originalText, decrypted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEncryptProducesDifferentCiphertext() {
|
||||
val originalText = "test_api_key_12345"
|
||||
|
||||
val encrypted1 = KeystoreManager.encrypt(originalText)
|
||||
val encrypted2 = KeystoreManager.encrypt(originalText)
|
||||
|
||||
assertNotEquals(encrypted1, encrypted2)
|
||||
|
||||
assertEquals(originalText, KeystoreManager.decrypt(encrypted1))
|
||||
assertEquals(originalText, KeystoreManager.decrypt(encrypted2))
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun testDecryptInvalidFormat() {
|
||||
KeystoreManager.decrypt("invalid_format")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEncryptEmptyString() {
|
||||
val originalText = ""
|
||||
|
||||
val encrypted = KeystoreManager.encrypt(originalText)
|
||||
val decrypted = KeystoreManager.decrypt(encrypted)
|
||||
|
||||
assertEquals(originalText, decrypted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEncryptLongString() {
|
||||
val originalText = "a".repeat(10000)
|
||||
|
||||
val encrypted = KeystoreManager.encrypt(originalText)
|
||||
val decrypted = KeystoreManager.decrypt(encrypted)
|
||||
|
||||
assertEquals(originalText, decrypted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEncryptSpecialCharacters() {
|
||||
val originalText = "!@#\$%^&*()_+-=[]{}|;':\",./<>?~`"
|
||||
|
||||
val encrypted = KeystoreManager.encrypt(originalText)
|
||||
val decrypted = KeystoreManager.decrypt(encrypted)
|
||||
|
||||
assertEquals(originalText, decrypted)
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ object ApiClient {
|
||||
|
||||
private suspend fun setApiKey(key: String) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[API_KEY] = key
|
||||
preferences[API_KEY] = KeystoreManager.encrypt(key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,14 @@ object ApiClient {
|
||||
|
||||
val apiKeyFlow: Flow<String?>
|
||||
get() = dataStore.data.map { preferences ->
|
||||
preferences[API_KEY]
|
||||
preferences[API_KEY]?.let { encrypted ->
|
||||
try {
|
||||
KeystoreManager.decrypt(encrypted)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ApiClient", "Failed to decrypt API key", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val baseUrlFlow: Flow<String?>
|
||||
@@ -65,7 +72,15 @@ object ApiClient {
|
||||
preferences[BASE_URL_KEY]
|
||||
}
|
||||
|
||||
suspend fun getApiKey(): String? = dataStore.data.first()[API_KEY]
|
||||
suspend fun getApiKey(): String? {
|
||||
val encrypted = dataStore.data.first()[API_KEY] ?: return null
|
||||
return try {
|
||||
KeystoreManager.decrypt(encrypted)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ApiClient", "Failed to decrypt API key", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getBaseUrl(): String? = dataStore.data.first()[BASE_URL_KEY]
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.fleetdm.agent
|
||||
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import java.security.KeyStore
|
||||
|
||||
object KeystoreManager {
|
||||
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
|
||||
private const val KEY_ALIAS = "fleet_api_key_encryption"
|
||||
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||
private const val GCM_TAG_LENGTH = 128
|
||||
private const val IV_SEPARATOR = "]"
|
||||
|
||||
private fun getOrCreateKey(): SecretKey {
|
||||
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply {
|
||||
load(null)
|
||||
}
|
||||
|
||||
if (!keyStore.containsAlias(KEY_ALIAS)) {
|
||||
val keyGenerator = KeyGenerator.getInstance(
|
||||
KeyProperties.KEY_ALGORITHM_AES,
|
||||
ANDROID_KEYSTORE,
|
||||
)
|
||||
|
||||
val keyGenParameterSpec = KeyGenParameterSpec.Builder(
|
||||
KEY_ALIAS,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
|
||||
)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setUserAuthenticationRequired(false)
|
||||
.setRandomizedEncryptionRequired(true)
|
||||
.build()
|
||||
|
||||
keyGenerator.init(keyGenParameterSpec)
|
||||
return keyGenerator.generateKey()
|
||||
}
|
||||
|
||||
return keyStore.getKey(KEY_ALIAS, null) as SecretKey
|
||||
}
|
||||
|
||||
fun encrypt(plaintext: String): String {
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
|
||||
|
||||
val iv = cipher.iv
|
||||
val encryptedBytes = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
|
||||
|
||||
val ivBase64 = Base64.encodeToString(iv, Base64.NO_WRAP)
|
||||
val encryptedBase64 = Base64.encodeToString(encryptedBytes, Base64.NO_WRAP)
|
||||
|
||||
return "$ivBase64$IV_SEPARATOR$encryptedBase64"
|
||||
}
|
||||
|
||||
fun decrypt(ciphertext: String): String {
|
||||
val parts = ciphertext.split(IV_SEPARATOR)
|
||||
require(parts.size == 2) { "Invalid ciphertext format" }
|
||||
|
||||
val iv = Base64.decode(parts[0], Base64.NO_WRAP)
|
||||
val encryptedBytes = Base64.decode(parts[1], Base64.NO_WRAP)
|
||||
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
val spec = GCMParameterSpec(GCM_TAG_LENGTH, iv)
|
||||
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), spec)
|
||||
|
||||
val decryptedBytes = cipher.doFinal(encryptedBytes)
|
||||
return String(decryptedBytes, Charsets.UTF_8)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user