Securing your data in Android DataStore
In cases where you are storing sensitive information you would prefer to save them in an encrypted manner to avoid any information breach.
Let’s look at the process from bird’s eye with a simple string
The idea here is to convert our entire data into a JSON string and write it in the datastore.
While we encrypt and decrypt it using AES encryption in this example you can use others as well.
We store the keys in Android KeyStore while encrypting and fetch that key at runtime from keystore and decrypt the data.
The process would require us to save our IV’s (Intitialization Vector) as well hence we store that alongside our data while keeping our keys in keystore
Why Android KeyStore to store keys
Android KeyStore provides a secure storage environment backed by hardware-based security measures, such as Trusted Execution Environments (TEEs) or Secure Elements (SEs), depending on the device. This helps protect sensitive information from unauthorized access, even if the device is compromised.
Let’s begin with our utility class that would help us do the job
First we need to create key that we will use in encryption and decryption
@Synchronized
private fun generateSecretKey(keyAlias: String): SecretKey {
val keyentry = keyStore.getEntry(keyAlias, null) as? KeyStore.SecretKeyEntry
return keyentry?.secretKey ?: run {
keyGenerator.apply {
init(
KeyGenParameterSpec
.Builder(keyAlias, PURPOSE_ENCRYPT or PURPOSE_DECRYPT)
.setBlockModes(BLOCK_MODE_GCM)
.setEncryptionPaddings(ENCRYPTION_PADDING_NONE)
.build()
)
}.generateKey()
}
}
We are creating a new key with ENCRYPT and DECRYPT features and GCM with no padding. We also check if the key already exists with use it else we create a new key.
Let’s begin with encryption
@Synchronized
fun encryptData(keyAlias: String, text: String): Pair<ByteArray, ByteArray> {
val secretKey = generateSecretKey(keyAlias)
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
val encryptedData = cipher.doFinal(text.toByteArray(charset))
val iv = cipher.iv
return Pair(iv, encryptedData)
}
Here we fetch/generate the key from the AndroidKeyStore using it’s keyalias (It’s like a unique name). We create the key only once and reuse it if it already exists.
We set the cipher to encrypt mode with our key
post encryption we also extract the iv as we will need it while decryption
Now let’s take a look into our decryption
@Synchronized
fun decryptData(keyAlias: String, iv: ByteArray, encryptedData: ByteArray): String {
val secretKey = getSecretKey(keyAlias)
val gcmParameterSpec = GCMParameterSpec(128, iv)
cipher.init(Cipher.DECRYPT_MODE, secretKey, gcmParameterSpec)
return cipher.doFinal(encryptedData).toString(charset)
}
We init the cipher in decrypt mode and use the key from keystore along with the iv we got from the encryption.
Keystore is not thread safe hence we need to synchronize
Woaaah done !!!
Let’s put it all together
class SecurityUtil {
private val provider = "AndroidKeyStore"
private val cipher by lazy {
Cipher.getInstance("AES/GCM/NoPadding")
}
private val charset by lazy {
charset("UTF-8")
}
private val keyStore by lazy {
KeyStore.getInstance(provider).apply {
load(null)
}
}
private val keyGenerator by lazy {
KeyGenerator.getInstance(KEY_ALGORITHM_AES, provider)
}
fun encryptData(keyAlias: String, text: String): Pair<ByteArray, ByteArray> {
val secretKey = generateSecretKey(keyAlias)
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
val encryptedData = cipher.doFinal(text.toByteArray(charset))
val iv = cipher.iv
return Pair(iv, encryptedData)
}
fun decryptData(keyAlias: String, iv: ByteArray, encryptedData: ByteArray): String {
val secretKey = getSecretKey(keyAlias)
val gcmParameterSpec = GCMParameterSpec(128, iv)
cipher.init(Cipher.DECRYPT_MODE, secretKey, gcmParameterSpec)
return cipher.doFinal(encryptedData).toString(charset)
}
private fun generateSecretKey(keyAlias: String): SecretKey {
val keyentry = keyStore.getEntry(keyAlias, null)
if (keyentry== null)
{
return keyGenerator.apply {
init(
KeyGenParameterSpec
.Builder(keyAlias, PURPOSE_ENCRYPT or PURPOSE_DECRYPT)
.setBlockModes(BLOCK_MODE_GCM)
.setEncryptionPaddings(ENCRYPTION_PADDING_NONE)
.build()
)
}.generateKey()
}
else
{
return getSecretKey(keyAlias)
}
}
private fun getSecretKey(keyAlias: String) =
(keyStore.getEntry(keyAlias, null) as KeyStore.SecretKeyEntry).secretKey
}
Saving to DataStore
We serialize the input into a json string using json and then store the data along with iv with a seperator so that we can decrypt it back when needed
suspend fun <T> putSecurePreference(key: Preferences.Key<String>, value: T) {
dataStore.edit { preferences ->
val serializedInput = gson.toJson(value)
val (iv, secureByteArray) = securityUtil.encryptData(keyAlias, serializedInput)
val secureString = iv.joinToString(bytesToStringSeperator) + ivToStringSeparator + secureByteArray.joinToString(bytesToStringSeperator)
preferences[key] = secureString
}
}
Reading back from datastore
suspend inline fun <reified T> getSecurePreference(
key: Preferences.Key<String>,
defaultValue: T
):
Flow<T> = dataStore.data.catch { exception ->
if (exception is IOException) {
emit(emptyPreferences())
} else {
throw exception
}
}.map { preferences ->
val secureString = preferences[key] ?: return@map defaultValue
val (ivString, encryptedString) = secureString.split(ivToStringSeparator, limit = 2)
val iv = ivString.split(bytesToStringSeperator).map { it.toByte() }.toByteArray()
val encryptedData = encryptedString.split(bytesToStringSeperator).map { it.toByte() }.toByteArray()
val decryptedValue = securityUtil.decryptData(keyAlias, iv, encryptedData)
val type = object : TypeToken<T>() {}.type
gson.fromJson(decryptedValue, type) as T
}
Here we use our separator as delimiter to split our string into data and iv and then we use our decryption class to get it back. We emit this data back as a flow for downstream to consume
Putting it all together
private val Context.dataStore by preferencesDataStore(
name = "app_user_preferences"
)
class DataStorePreferences(
context: Context,
val securityUtil: SecurityUtil,
val gson: Gson
) {
val bytesToStringSeperator = "|"
val keyAlias = "appkey"
val dataStore = context.dataStore
val ivToStringSeparator= ":iv:"
suspend fun <T> getPreference(key: Preferences.Key<T>, defaultValue: T):
Flow<T> = dataStore.data.catch { exception ->
if (exception is IOException) {
emit(emptyPreferences())
} else {
throw exception
}
}.map { preferences ->
val result = preferences[key] ?: defaultValue
result
}
suspend fun <T> putPreference(key: Preferences.Key<T>, value: T) {
dataStore.edit { preferences ->
preferences[key] = value
}
}
suspend fun <T> putSecurePreference(key: Preferences.Key<String>, value: T) {
dataStore.edit { preferences ->
val serializedInput = gson.toJson(value)
val (iv, secureByteArray) = securityUtil.encryptData(keyAlias, serializedInput)
val secureString = iv.joinToString(bytesToStringSeperator) + ivToStringSeparator + secureByteArray.joinToString(bytesToStringSeperator)
preferences[key] = secureString
}
}
suspend inline fun <reified T> getSecurePreference(
key: Preferences.Key<String>,
defaultValue: T
):
Flow<T> = dataStore.data.catch { exception ->
if (exception is IOException) {
emit(emptyPreferences())
} else {
throw exception
}
}.map { preferences ->
val secureString = preferences[key] ?: return@map defaultValue
val (ivString, encryptedString) = secureString.split(ivToStringSeparator, limit = 2)
val iv = ivString.split(bytesToStringSeperator).map { it.toByte() }.toByteArray()
val encryptedData = encryptedString.split(bytesToStringSeperator).map { it.toByte() }.toByteArray()
val decryptedValue = securityUtil.decryptData(keyAlias, iv, encryptedData)
val type = object : TypeToken<T>() {}.type
gson.fromJson(decryptedValue, type) as T
}
suspend fun <T> removePreference(key: Preferences.Key<T>) {
dataStore.edit {
it.remove(key)
}
}
suspend fun clearAllPreference() {
dataStore.edit { preferences ->
preferences.clear()
}
}
}
You can find the full source code here
https://github.com/RaghavAwasthi/EncryptedDataStoreExample