SecureAndroidPersist — Android Persistence and Encryption Library
Introduction
In modern mobile apps, safeguarding user data is crucial. Whether it’s user credentials, API tokens, or any sensitive information, SecurePersist allows developers to implement robust encryption and persistence with minimal effort.
You can find the AndroidSerurePersist library here
but I urge you to read through before doing so.
What this library does
This library allows out of the box with zero-configuration encrypting and decrypting preferences using SharedPreferences
and DataStore
, supports serialization of complex data types, and provides robust raw data and file encryption capabilities.
So this library makes it easy for developers to
- implement comprehensive encrypted storage solutions
- encrypt raw data and files with ease
Features
This library offers a wide range of features for securely persisting data, while also providing encryption services for raw data and files when needed.
Feature Set 1 — Encrypted Persistence
- Secure Preferences Management: Easily encrypts and decrypts preferences utilizing
SharedPreferences
andDataStore
. - Support for Complex Data Types: Automatically serializes and securely stores complex objects, including custom classes and collections.
- File Encryption and Decryption: Securely encrypts and decrypts files, ensuring sensitive data remains protected even when stored externally.
- Property Delegation: Uses Kotlin property delegation for seamless integration of encrypted preferences.
- Jetpack Compose State Persistence: Seamlessly integrates with Jetpack Compose by providing
MutableState
delegates that automatically persist and restore UI state. This ensures your Compose components maintain consistent and secure state across recompositions and app restarts without additional boilerplate. - Asynchronous Operations: Efficiently handles preferences with non-blocking operations using
DataStore
.
Feature Set 2 — Raw Encryption
- Raw Data Encryption: Directly encrypts and decrypts raw data and files using
EncryptionManager
for additional flexibility. - External Key Management: Allows for custom external keys for scenarios requiring cross-device data decryption or storing the key on a remote server.
Why Use SecurePersist?
- Security: Protects sensitive data with robust encryption techniques, including complex objects and files.
- Ease of Use: Simplifies the process of managing encrypted preferences and data with a user-friendly API.
- Versatility: Supports a variety of data types, including primitives, complex objects, and files, integrating seamlessly with existing Android components.
- Performance: Ensures non-blocking operations for a smooth user experience.
- Flexibility: Allows for external key management, enabling secure data storage and retrieval across devices or from remote servers.
Library installation
This library is served via Jitpack
repository which is not included by default in android projects. To allow for libraries to be fetched via jitpack
you need include jitpack in the list of repositories.
So you need to add Jitpack to your settings.gradle
(or at Project's build.gradle
for older Android projects) for gradle to know how to fetch dependencies served by that repository:
// at your settings.gradle you have this code
repositories {
google()
mavenCentral()
maven(url = "https://jitpack.io") // <-- add this line
}
// core library
implementation("com.github.ioannisa.secured-android-persist:secure-persist:2.4.0")
// additional library to automatically handle state in Jetpack Compose
implementation("com.github.ioannisa.secured-android-persist:secure-persist-compose:2.4.0")
Code Samples — Encrypted Persistence
First, you need to initialize the PersistManager
class which requires a context
.
val persistManager = PersistManager(context)
The library allows you to take two paths to handling encrypted persisted data, and that is via the sharedPrefs
and the dataStorePrefs
instance variables.
sharedPrefs
and dataStorePrefs instance variable
This allows you full access to the EncryptedSharedPreferences
with zero-configuration.
Using the
SharedPrefferences
with this library utilizes theEncryptedSharePreferences
for native security, thus this library does not include non-encrypted usage ofSharedPreferences
sharedPrefs
directlysharedPrefs
via property delegation
You will come to see that using sharedPrefs
via property delegation is very easy and the defacto way to go when using this library.
SharedPrefs
directly
Let the following code snippets show you how to use the sharedPrefs
instance directly:
sharedPrefs
:
For sharedPrefs things are pretty straight-forward:
// "put" an encrypted sharedPref for an integer for key "myKey"
persistManager.sharedPrefs.put(key="myKey", value="string value")
// "get" an encrypted sharedPref's value
persistManager.sharedPrefs.get(key="myKey", defaultValue="not found")
// delete a key from sharedPreferenes
persistManager.sharedPrefs.delete(key="myKey")
// you can also add and delete complex objects with automatic serialization
data class AuthInfo(
val accessToken: String = "",
val refreshToken: String = "",
val expiresIn: Long = 0L
)
// "put"
persistManager.sharedPrefs.put(key="authKey", value=AuthInfo())
// "get"
val auth = persistManager.sharedPrefs.get("authKey", defaultValue=AuthInfo())
dataStorePrefs
:
If you know DataStore
, then you know it uses coroutines
. We take two routes. One is to use normally via coroutines, and the other is directly with coroutines working behind the scenes for your comfort.
dataStorePrefs
uses encryption by default, unless you set encrypted=false
.
dataStorePrefs
using coroutines:
using coroutines we have the following functions
put
to put dataget
to get datadelete
to delete data
// Encrypt and save a preference
CoroutineScope(Dispatchers.IO).launch {
persistManager.dataStorePrefs.put("key1", "secureValue")
}
// Decrypt and retrieve a preference
CoroutineScope(Dispatchers.IO).launch {
val value = persistManager.dataStorePrefs.get("key1", "defaultValue")
println("Retrieved value: $value")
}
// Delete a preference
CoroutineScope(Dispatchers.IO).launch {
persistManager.dataStorePrefs.delete("key1")
}
dataStorePrefs
handling coroutines on the background:
For that we can use the “direct” variant of the dataStorePrefs. avoiding to execute from a suspended function.
putDirect
to put data (non-blocking)getDirect
to get data (blocking)deleteDirec
to delete data (non-blocking)
// assuming encryption - Encrypt and store in a non-blocking way to DataStore
persistManager.dataStorePrefs.putDirect("key1", "secureValue")
// assuming encryption - Decrypt and get in a blocking way the value from DataStore
val value = persistManager.dataStorePrefs.getDirect("key1", "defaultValue")
// no encryption - store unencrypted in a non-blocking way to DataStore
persistManager.dataStorePrefs.putDirect("key1", "secureValue", encrypted = false)
// no encryption - get unencrypted in a blocking way the value from DataStore
val value = persistManager.dataStorePrefs.getDirect("key1", "defaultValue", encrypted = false)
// Delete the DataStore preference without using coroutines
persistManager.dataStorePrefs.deleteDirect("key1")
SharedPrefs and dataStorePrefs
delegated
Let the following code snippets show you how to use the sharedPrefs
instance via property delegation and as you see it is the preferred way to go.
Here the logic is “intuition”. We treat the preferences as normal variables. Here the idea is identical for both the sharedPrefs
and for dataStorePrefs
// lets make a counter using sharedPref
var pref1 by persistManager.sharedPrefs.preference(1000)
pref1 = 100
pref1++
pref1++
// lets make a counter using dataStorePref
var pref1 by persistManager.dataStorePrefs.preference(1000)
pref1 = 100
pref1++
pref1++
// note that for dataStorePref you need to add a slight delay
// as incremeting and setting values calls "put" which is non-blocking
Persitence — Jetpack Compose State
The library supports seamless integration also with jetpack compose’s state by allowing you to create mutable state properties that utilize the underlaying structure seamlessly.
You can literally say from the following example that persisting securely to EncryptedSharedPreferences
and DataStore Preferences
using state
cannot get any easier!
import androidx.lifecycle.ViewModel
import eu.anifantakis.lib.securepersist.PersistManager
import eu.anifantakis.lib.securepersist.compose.mutableStateOf
class CounterViewModel(
persistManager: PersistManager
) : ViewModel() {
// If key is unspecified, property name becomes the key
// Defaults to EncryptedSharedPreferences and uses the property name as the key
var count1 by persistManager.sharedPrefs.mutableStateOf(1000)
private set
// Sets a custom key and uses DataStorePreferences with encryption
var count2 by persistManager.dataStorePrefs.mutableStateOf(
defaultValue = 2000,
key = "counter2Key"
)
private set
// Uses the property name as the key and sets storage to Unencrypted DataStorePreferences
var count3 by persistManager.dataStorePrefs.mutableStateOf(
defaultValue = 3000,
encrypted = false
)
private set
fun increment() {
count1++
count2++
count3++
}
}
Code Samples — Raw Encryption
The encryption algorithms used inside PersistManager
have been exposed to you directly so you can utilize them for raw-data encryption, and file encryption.
Encryption utilizes KeyStore
for securely storing your keys into your phone’s chip for maximum protection, but also allows you to generate and export/import custom keys incase you want to save raw encrypted data to some remote server.
Initializing is also simple here. For KeyStore
it is pretty simple
//initializing for KeyStore
val encryptionManager = EncryptionManager(context)
If you want to have a key that can be stored outside your phone so you can reuse encrypted data stored on some server on multiple devices, you need to generate first that “exportable-key” which can be sent to that remote server, and then initialize EncryptionManager
with that key.
// First, generate an external key:
val externalKey = EncryptionManager.generateExternalKey()
// Then, use the generated key to create an instance of EncryptionManager:
val encryptionManager = EncryptionManager(context, externalKey)
Example Encrypting/Decrypting Raw data with KeyStore
val encryptionManager = EncryptionManager(context)
// Encrypt data
val encryptedData = encryptionManager.encryptData("plainText")
// Decrypt data
val decryptedData = encryptionManager.decryptData(encryptedData)
val plainText = String(decryptedData, Charsets.UTF_8)
// Encrypt a value and encode it to a Base64 string
val encryptedValue = encryptionManager.encryptValue("valueToEncrypt")
// Decrypt a Base64 encoded string and return the original value
val decryptedValue: String = encryptionManager.decryptValue(encryptedValue, "defaultValue")
Storing the externalKey (SecretKey) to some other medium
Other than using instance of EncryptionManager, you can use it in a static context too in order to handle some data that was encrypted/decrypted with some other key without having to create more instances.
In this example we will combine this static context demo with SecretKey
usage.
Scenario:
— Generate a SecretKey, use it in static context to encrypt some data.
— Create an encoding of that key so you can save it as String to a remote server
— Retrieve the encoded secret-key string from the remote server and decode it to build back again a SecretKey binary.
— Use it to decode the data that was encoded with that key
If you have generated a key and want to securely transmit it to some API or retrieve it, then this library provides two convenience static methods for encoding
and decoding
that key to a string so you can easily transfer it.
Exporting custom key to save it to some server
// generate a key
val originalKey = EncryptionManager.generateExternalKey()
// encrypt your data with that external key
val encryptedData = EncryptionManager.encryptData("Hello, Secure World!", originalKey)
// encode key to stringify it so you can send it to some server
val encodedKey: String = EncryptionManager.encodeSecretKey(originalKey)
// make network call and save the encoded key string to some server
// ...
Retrieving the custom key from the server to use it in the app
val encodedKey = ... // fetch the string with encoded key from a remote server
// construct a SecretKey from that encodedKey retrieved from the server
val decodedKey: SecretKey = EncryptionManager.decodeSecretKey(encodedKey)
// as you can see, you can decode the encrypted data using the key that was reconstructed from the encoded string
val decryptedText = EncryptionManager.decryptData(encryptedData, decodedKey)
FILES Encryption/Decription
Additionally, EncryptionManager
offers encryptFile
and decryptFile
functions, enabling you to securely encrypt data and store it in a file, as well as read and decrypt the data from the file when needed.
These functions provide a seamless way to handle file-level encryption and decryption, ensuring that sensitive data remains secure during both storage and retrieval.
// Create an EncryptionManager instance
val encryptionManager = EncryptionManager(context, "your_key_alias")
// Specify the input file and the name for the encrypted file
val testFile = File(context.filesDir, "plain.txt")
val encryptedFileName = "encrypted.dat"
// Encrypt the file
encryptionManager.encryptFile(testFile, encryptedFileName)
// Decrypt the file
val decryptedContent: ByteArray = encryptionManager.decryptFile(encryptedFileName)
val decryptedText = String(decryptedContent)