diff --git a/android/app/src/androidTest/java/com/fleetdm/agent/KeystoreManagerTest.kt b/android/app/src/androidTest/java/com/fleetdm/agent/KeystoreManagerTest.kt new file mode 100644 index 0000000000..aef053ab16 --- /dev/null +++ b/android/app/src/androidTest/java/com/fleetdm/agent/KeystoreManagerTest.kt @@ -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) + } +} diff --git a/android/app/src/main/java/com/fleetdm/agent/ApiClient.kt b/android/app/src/main/java/com/fleetdm/agent/ApiClient.kt index b381b88965..0416fa9fb9 100644 --- a/android/app/src/main/java/com/fleetdm/agent/ApiClient.kt +++ b/android/app/src/main/java/com/fleetdm/agent/ApiClient.kt @@ -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 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 @@ -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] diff --git a/android/app/src/main/java/com/fleetdm/agent/KeystoreManager.kt b/android/app/src/main/java/com/fleetdm/agent/KeystoreManager.kt new file mode 100644 index 0000000000..9eaf105445 --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/KeystoreManager.kt @@ -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) + } +}