AndroidX PreferenceFragmentCompat scaffolded SharedPreferences encrypted with Jetpack Security

Aug 16, 2020

Let me start by disadvising anybody from developing their own encryption algorithm, encryption protocol, or encryption schemes while trying to increase security of an application. Always use well known encryption libraries and such best practice protocols and techniques wich are advised by experts. Otherwise you can easily make a tiny mistake which could compromise your whole scheme.

This is why I’m so happy that Android Jetpack has a Security feature set which offers encryption / decryption of SharedPreferences and file streams. The fact that it’s developed by Google engineers gives a peace of mind that it’ll pass a high standard of quality and since it’s backed by Google it is safe to rely on it. The default advised usage uses latest algorithms and modes like AES256, GCM and AES-GCM-SIV. Complex scenarios can be configured using KeyGenParameterSpec, key authentication expiration can be specified and JetSec is also capable of using Titan security module which elevates the security to hardware token level (Titan basically integrates the hardware security token into the phone).

Be careful when reading blogs about JetSec because it’s still in alpha phase so the API can still change. Good example for that is the MasterKey: MasterKeys (plural) is deprecated now, so if you read stuff like MasterKeys.getOrCreate that’s an older alpha version. You’d want to look for MasterKey.Builder. This blog post won’t talk about file encryption, I want to focus on SharedPreferences encryption and specifically Kotlin - which is now the primary first class over Java citizen in the Android ecosystem. Here is a code snippet how you can easily obtain an encrypted SharedPreferences instance:

val masterKey = MasterKey.Builder(applicationContext, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
  .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
  .build()

val preferences = EncryptedSharedPreferences.create(
  applicationContext,
  SHARED_PREFERENCES_NAME,
  masterKey,
  EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
  EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)

Once you have that it can be used just as if it would be a regular SharedPreferences. Once you’ve changed your code the saved cleartext data won’t be compatible with the new scheme - this is not a surprise. I have to talk about PreferenceFragmentCompat though: another basic thumb rule (besides do not write your own encryption) is to avoid plumbing code by all means. Less plumbing code means less maintenance and smaller bug surface area. Android Jetpack provides a really nice declarative way of defining your Preferences UI. That does all the weight lifting for you but it works with a non encrypted SharedPreferences by default. Even if you take a peek at the decompiled classes you may not see how JetSec encryption can be introduced. Here is a five step guide where only the last two steps have encryption related extras.

  1. Add implementation "androidx.security:security-crypto-ktx:1.1.0-alpha02" for JetSec to you gradle.build file.
  2. You need to add implementation "androidx.preference:preference:1.1.1" to you gradle.build file for PreferenceFragmentCompat and additional Jetpack preferences helper functionality.
  3. Define your preferences UI in a declarative way in res/xml/preferences.xml file.
  4. Add a small SettingsActivity stub.
  5. Add a small SettingsFragment stub.
  6. Supply the EncryptedPreferenceDataStore which provides the encrypted provider for preferences data.

Here is a snippet of the res/xml/preferences.xml:

<PreferenceScreen
  xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto"
  xmlns:tools="http://schemas.android.com/tools"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  tools:context=".ui.SettingsFragment">

  <EditTextPreference
    app:key="project_id"
    app:title="@string/project_id_title"
    app:summary="@string/project_id_help"
    android:icon="@drawable/ic_one"/>

  <EditTextPreference
    app:key="application_id"
    app:title="@string/application_id_title"
    app:summary="@string/application_id_help"
    android:icon="@drawable/ic_two"/>

  <!-- ... -->

</PreferenceScreen>

SettingsActivity.kt:

package your.package.path.ui

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import your.package.path.R

class SettingsActivity : AppCompatActivity() {
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_settings)
    supportFragmentManager
      .beginTransaction()
      .replace(R.id.settings_container, SettingsFragment())
      .commit()
  }
}

SettingsFragment.kt:

package your.package.path.ui

import android.os.Bundle
import androidx.preference.PreferenceFragmentCompat
import your.package.path.data.EncryptedPreferenceDataStore
import your.package.path.R

class SettingsFragment : PreferenceFragmentCompat() {
  override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
    // The following instruction makes the difference between the encrypted and the normal version:
    preferenceManager.preferenceDataStore =
      EncryptedPreferenceDataStore.getInstance(requireContext())
    setPreferencesFromResource(R.xml.preferences, rootKey)
  }
}

The most important code in the last snippet. The only distinction between the encrypted and the non encrypted variation is the preferenceManager.preferenceDataStore = EncryptedPreferenceDataStore.getInstance(requireContext()) line. By supplying the PreferenceDataStore we can influence the underlying logic to use JetSec encryption. Here is the EncryptedPreferenceDataStore:

package your.package.path.data

import android.content.Context
import android.content.SharedPreferences
import androidx.preference.PreferenceDataStore
import androidx.preference.PreferenceManager
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey

class EncryptedPreferenceDataStore private constructor(context: Context) : PreferenceDataStore() {
  companion object {
    private const val SHARED_PREFERENCES_NAME = "secret_shared_preferences"

    @Volatile private var INSTANCE: EncryptedPreferenceDataStore? = null

    fun getInstance(context: Context): EncryptedPreferenceDataStore =
      INSTANCE ?: synchronized(this) {
        INSTANCE ?: EncryptedPreferenceDataStore(context).also { INSTANCE = it }
      }
  }

  private var mSharedPreferences: SharedPreferences
  private lateinit var mContext: Context

  init {
    try {
      mContext = context
      val masterKey = MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
        .build()

      mSharedPreferences = EncryptedSharedPreferences.create(
        context,
        SHARED_PREFERENCES_NAME,
        masterKey,
        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
      )
    } catch (e: Exception) {
      // Fallback, default mode is Context.MODE_PRIVATE!
      mSharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
    }
  }

  override fun putString(key: String, value: String?) {
    mSharedPreferences.edit().putString(key, value).apply()
  }

  override fun putStringSet(key: String, values: Set<String>?) {
    mSharedPreferences.edit().putStringSet(key, values).apply()
  }

  override fun putInt(key: String, value: Int) {
    mSharedPreferences.edit().putInt(key, value).apply()
  }

  override fun putLong(key: String, value: Long) {
    mSharedPreferences.edit().putLong(key, value).apply()
  }

  override fun putFloat(key: String, value: Float) {
    mSharedPreferences.edit().putFloat(key, value).apply()
  }

  override fun putBoolean(key: String, value: Boolean) {
    mSharedPreferences.edit().putBoolean(key, value).apply()
  }

  override fun getString(key: String, defValue: String?): String? {
    return mSharedPreferences.getString(key, defValue)
  }

  override fun getStringSet(key: String, defValues: Set<String>?): Set<String>? {
    return mSharedPreferences.getStringSet(key, defValues)
  }

  override fun getInt(key: String, defValue: Int): Int {
    return mSharedPreferences.getInt(key, defValue)
  }

  override fun getLong(key: String, defValue: Long): Long {
    return mSharedPreferences.getLong(key, defValue)
  }

  override fun getFloat(key: String, defValue: Float): Float {
    return mSharedPreferences.getFloat(key, defValue)
  }

  override fun getBoolean(key: String, defValue: Boolean): Boolean {
    return mSharedPreferences.getBoolean(key, defValue)
  }
}

For Java version please look at this StackOverflow post.

Comments loading...