· 5 min read Posted by Brady Aiello
Encrypted Key-Value Store in Kotlin Multiplatform
Intro
At some point you will probably need to store a user’s auth token on the device, so they won’t need to auth whenever they use your app. This is a solved problem on Android with EncryptedSharedPreferences and iOS with the Keychain Services API. But if you’re creating a mobile app using Kotlin Multiplatform, the KMP solution might not be as clear. Today we’ll go over how to store encrypted key-value data with KMP without having to reinvent the wheel.
Enter Multiplatform Settings
Multiplatform Settings is a solid multiplatform key-value store, created by Touchlab’s own Russell Wolf, used extensively at Touchlab, as well as in Jetbrains’ KMM Production Sample. There is a Settings interface that is implemented for Android, iOS, MacOS, and JVM platforms. At first glance, it doesn’t look like Multiplatform Settings offers any encrypted storage. But thanks to the abstraction that Android has built into SharedPreferences
and Multiplatform Settings’ support for saving to the iOS keychain, our work is pretty easy.
KMP Encrypted Settings on Android
Let’s first take a look at the default non-encrypted AndroidSettings implementation. Usually we’ll use the builder, but the public constructor is really helpful here.
public class AndroidSettings @JvmOverloads public constructor(
private val delegate: SharedPreferences,
private val commit : Boolean = false
) : ObservableSettings
EncryptedSharedPreferences implements the SharedPreferences interface. So all we need to do is create one, and initialize an instance of AndroidSettings
with it:
AndroidSettings(EncryptedSharedPreferences.create(
get(),
"MyEncryptedSettings",
MasterKey.Builder(get())
.setKeyScheme(MasterKey.KeyScheme._AES256_GCM_)
.build(),
EncryptedSharedPreferences.PrefKeyEncryptionScheme._AES256_SIV_,
EncryptedSharedPreferences.PrefValueEncryptionScheme._AES256_GCM_
), false)
Because the SharedPreferences
delegate instance is a constructor argument rather than hardcoded, we can swap it out during testing, or if we want to provide an alternate implementation like we have here.
KMP Encrypted Settings on iOS
Multiplatform Settings already has an encrypted implementation of Settings in the form of KeychainSettings. It uses the iOS platform Security API, SecItemAdd()
, SecItemDelete()
, etc. We can use it thus:
KeychainSettings(
service = "MyEncryptedSettings"
)
That was easy! It is marked with an @ExperimentalSettingsImplementation
annotation, but I have been using it to build a client project that will soon be used by millions, and have not had any issues. If you do, please open an issue.
Providing the Right Implementation
If you plan on using both an unencrypted Settings and an encrypted Settings in shared code, you have a couple options. You can create a special wrapper class to disambiguate unencrypted and encrypted settings instances like this:
// Grab the application context from your DI library, such as Hilt
// Or give EncryptedSettingsHolder a factory constructor
val context: Context =
actual class EncryptedSettingsHolder() {
actual val encryptedSettings = AndroidSettings(EncryptedSharedPreferences.create(
context,
SharedSettingsHelper.ENCRYPTED_DATABASE_NAME,
MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build(),
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
), false)
}
View Raw
expect class EncryptedSettingsHolder() {
val encryptedSettings: Settings
}
View Raw
actual class EncryptedSettingsHolder() {
@ExperimentalSettingsImplementation
actual val encryptedSettings = KeychainSettings(service = SharedSettingsHelper.ENCRYPTED_DATABASE_NAME)
}
View Raw
Otherwise, you will need use a named instance in your preferred dependency injection or service locator library. Here is an example using Koin, a multiplatform service locator:
// In commonMain
fun initKoin(appDeclaration: KoinAppDeclaration = {}) = startKoin {
appDeclaration()
modules(
platformModule,
coreModule
)
}
private val coreModule = module {
single {
SharedSettingsHelper(
getWith("SharedSettingsHelper"),
get(named(SharedSettingsHelper.encryptedSettingsName))
)
}
}
inline fun <reified T> Scope.getWith(vararg params: Any?): T {
return get(parameters = { parametersOf(*params) })
}
expect val platformModule: Module
View Raw
// In androidMain
fun startSdk(app:Application){
initKoin {
modules(
module {
single<Context> { app }
val baseKermit =
Kermit(
LogcatLogger()
).withTag("MySampleApp")
factory { (tag: String?) -> if (tag != null) baseKermit.withTag(tag) else baseKermit }
}
)
}
}
actual val platformModule: Module = module {
single<Settings>(named(SharedSettingsHelper.encryptedSettingsName)) {
AndroidSettings(EncryptedSharedPreferences.create(
get(),
SharedSettingsHelper.ENCRYPTED_DATABASE_NAME,
MasterKey.Builder(get())
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build(),
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
), false)
}
single<Settings>(named(SharedSettingsHelper.unencryptedSettingsName)) {
AndroidSettings.Factory(get()).create(SharedSettingsHelper.DATABASE_NAME)
}
}
View Raw
// In iosMain
fun startSdk(){
initKoin {
modules(
module {
val baseKermit =
Kermit(
CommonLogger()
).withTag("MySampleApp")
factory { (tag: String?) -> if (tag != null) baseKermit.withTag(tag) else baseKermit }
}
)
}
}
@ExperimentalSettingsImplementation
actual val platformModule: Module = module {
single<Settings>(named(SharedSettingsHelper.unencryptedSettingsName)) {
AppleSettings.Factory().create(SharedSettingsHelper.DATABASE_NAME)
}
single<Settings>(named(SharedSettingsHelper.encryptedSettingsName)) {
KeychainSettings(service = SharedSettingsHelper.ENCRYPTED_DATABASE_NAME)
}
}
View Raw
// In commonMain
class SharedSettingsHelper(
private val log: Kermit,
private val encryptedSettings: Settings,
) {
var token: String?
get() {
val tk : String? = encryptedSettings[TOKEN_NAME]
log.d { "Getting token $tk" }
return tk
}
set(value) {
log.d {"Setting token $value"}
encryptedSettings[TOKEN_NAME] = value
}
companion object {
const val DATABASE_NAME = "UNENCRYPTED_SETTINGS"
const val ENCRYPTED_DATABASE_NAME = "ENCRYPTED_SETTINGS"
const val encryptedSettingsName = "encryptedSettings"
const val unencryptedSettingsName = "unencryptedSettings"
const val TOKEN_NAME = "TOKEN"
}
}
View Raw
Notice that the name of SharedSettingsHelper
‘s constructor parameterencryptedSettings
matches the name specified in our Koin setup; it must be exact for Koin to know which one you want. If you’re using platform-specific DI / service locator solutions, they will also have options for providing named instances, like Dagger’s support of the Javax@Named
annotation.
Once you have it all working, you can inspect the encrypted data storage on Android, by going to Android Studio, opening Device File Explorer, and navigating data
→ data
→ <Your Package Name> → shared_prefs
→ <Your Encrypted Settings Name> and you may see something like this:
Sweet! The keys and values are encrypted! Now you can read and write encrypted key-value data from common Kotlin code, maximizing code sharing.
Conclusion
If you’re using KMP, you don’t need to create your own multiplatform encrypted settings solution. In fact, if you’re already using Multiplatform Settings, you don’t even need another multiplatform dependency. How are you doing encrypted key-value stores in Kotlin Multiplatform? Do you like this approach? Let me know in the comments.