Add encrypted datastore (#36359)

This commit is contained in:
Dante Catalfamo
2025-12-02 16:50:23 -05:00
committed by GitHub
parent b00d1e4c9c
commit d93afe6a5a
3 changed files with 162 additions and 3 deletions
@@ -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)
}
}