· 5 min read Posted by Brady Aiello

Encrypted Key-Value Store in Kotlin Multiplatform

How to store encrypted key-value data with Kotlin Multiplatform on Android and iOS without having to reinvent the wheel.

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 APISecItemAdd() , 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:

Encrypted Settings Name

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.