Site icon Developers Breach

Encrypting, Testing SharedPreferences – Learn Tink & Internals in Android

What is Tink and how it powers Encryption in Preferences?

Tink is a multi-language cryptographic library from Google, designed to make common encryption tasks safer and simpler. Within EncryptedSharedPreferences, Tink handles both key/value encryption:

Because Tink manages the encryption/decryption logic under the hood, no plaintext is ever saved on disk.

1. Encrypting Your SharedPreferences

EncryptedSharedPreferences from Jetpack Security’s androidx.security:security-crypto library works similarly to regular SharedPreferences but encrypts each key/value before writing to disk. Under the hood:

  1. Keys get deterministically encrypted with a DeterministicAead (AES256_SIV).
  2. Values get AES256 GCM encryption via an Aead instance.

After exploring library’s source code I understood below things,

Aead – Interface for Authenticated Encryption with Associated Data (AEAD).
DeterministicAead – Interface for Deterministic Authenticated Encryption with Associated Data (Deterministic AEAD).

It uses Tink to retrieve or generate the necessary keysets, storing them in your SharedPreferences file. The MasterKey you provide references an Android Keystore, ensuring any master encryption keys are securely handled at the OS level.

1.1. Setup: Dependencies & MasterKey :
dependencies {
    implementation 'androidx.security:security-crypto:1.1.0-alpha06'
}

Build or retrieve a MasterKey :

val masterKey = MasterKey.Builder(context)
    .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
    .build()

Internally, MasterKey.Builder sets up an AES256-GCM key in the Android Keystore. The final alias is then used when creating the Tink keysets for both deterministic and non-deterministic encryption schemes.

1.2. Encryption Flow :

Representation of how plaintext is transformed into ciphertext on write, and back on read:

flowchart TB
    A[Plaintext Key/Value] --> B(DeterministicAead / Aead)
    B --> C(".putString(encryptedValue)") 
    C --> D(Saved on Disk in Encrypted Form)
    D --> E(".getString(encryptedValue)")
    E --> F(Decryption using MasterKey-based Keysets)
    F --> G[Plaintext Key/Value Returned]

This is how we use regular shared preferences, we just call getSharedPreferences using context.

class GameDifficultyPref(
    context: Context
) {
    private val sharedPref = context.getSharedPreferences(
        PREFERENCE_KEY_GAME_DIFFICULTY,
        Context.MODE_PRIVATE
    )
}

Now lets build the Encryption for SharedPreferences, the below implementation allows us to reuse it’s functionality across all preferences in your code while simply passing name which key for the preference.

1.3. Apply Encryption :
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey

class GetEncryptedSharedPreferences(
    private val context: Context
) {
    operator fun invoke(
        preferenceName: String
    ): SharedPreferences {
        val masterKey = MasterKey.Builder(context)
            .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
            .build()

        return EncryptedSharedPreferences.create(
            /* context = */ context,
            /* fileName = */ preferenceName,
            /* masterKey = */ masterKey,
            /* prefKeyEncryptionScheme = */ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
            /* prefValueEncryptionScheme = */ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
        )
    }
}

This returns an EncryptedSharedPreferences instance using MasterKey approach.

Example :
A usage example that reads/writes a game difficulty. Instead of normal getSharedPreferences, it calls GetEncryptedSharedPreferences(context)(preferenceName).

import android.content.Context

class GameDifficultyPref(
    context: Context
) {
    private val sharedPref = GetEncryptedSharedPreferences(context).invoke(
        preferenceName = PREFERENCE_KEY_GAME_DIFFICULTY
    )
    ...
}

Internally wraps our preference with a new EncryptedSharedPreferences instance.

2. Migration from Unencrypted Preferences

If you have an old SharedPreferences implementation and want to encrypt them:

  1. Read existing values from that preference.
  2. Write them to your new EncryptedSharedPreferences.
  3. Clear the old values.
fun migratePrefs(context: Context) {
    val old = context.getSharedPreferences("previous_prefs_key", Context.MODE_PRIVATE)
    val oldValue = old.getString("game_difficulty_result", null)
    if (oldValue != null) {
        val securePrefs = GetEncryptedSharedPreferences(context)("new_prefs_key")
        securePrefs.edit()
            .putString("game_difficulty_result", oldValue)
            .apply()
        old.edit().clear().apply()
    }
}

This ensures the user’s existing data is seamlessly encrypted going forward.

3. Testing encrypted shared preferences

When you attempt to use EncryptedSharedPreferences in a test, you’ll encounter a KeyStoreException: AndroidKeyStore not found.

AndroidKeyStore not found
java.security.KeyStoreException: AndroidKeyStore not found
	at java.base/java.security.KeyStore.getInstance(KeyStore.java:872)

Robolectric lacks real Android Keystore support, so the library’s code cannot generate or retrieve the master key.

Solution: Instrumentation Tests

By running instrumentation tests (in androidTest/), you rely on a real device/emulator environment, which does provide the AndroidKeyStore. This way, MasterKey.Builder() and EncryptedSharedPreferences.create() function without exceptions.

Setup : We need to get context and instantiate our preferences class

import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class GetEncryptedSharedPreferencesTest {

    private lateinit var context: Context
    private lateinit var getEncryptedSharedPreferences: GetEncryptedSharedPreferences

    @Before
    fun setup() {
        context = ApplicationProvider.getApplicationContext()
        getEncryptedSharedPreferences = GetEncryptedSharedPreferences(context)
    }
}

Test case 1 :
Confirm function can write a string into Preference and read it back.
Proves encryption logic doesn’t block normal usage both the key and value are stored.

@Test
fun canStoreAndRetrieveData() {
    val prefs = getEncryptedSharedPreferences("preference_game_key")

    // Put a string value
    prefs.edit().putString("game_difficulty_result", GameDifficulty.HARD.name).apply()

    // Retrieve it
    val result = prefs.getString("game_difficulty_result", null)
    assertEquals(GameDifficulty.HARD.name, result)
}

Test case 2 :
Writes MEDIUM as the difficulty.
Reads it back, ensuring we get MEDIUM.
Demonstrates it handles the master key behind the scenes with no extra steps needed.

@Test
fun masterKeyIsCreatedAutomaticallyByLibrary() {
    //  the library calls MasterKey.Builder behind the scenes.)
    val prefs = getEncryptedSharedPreferences("preference_game_key")

    prefs.edit().putString("game_difficulty_result", GameDifficulty.MEDIUM.name).apply()
    assertEquals("MEDIUM", prefs.getString("game_difficulty_result", null))
}

Let’s write tests for actual preference feature which depends on encrypting the preferences.

import android.content.Context

class GamePref(
    context: Context
) {
    private val sharedPref = GetEncryptedSharedPreferences(context).invoke(
        preferenceName = PREFERENCE_KEY_GAME_DIFFICULTY
    )

    fun getGameDifficultyPref(): GameDifficulty {
        val difficulty = sharedPref.getString(PREFERENCE_RESULT_GAME_DIFFICULTY, defaultDifficulty)
        return GameDifficulty.valueOf(difficulty ?: defaultDifficulty)
    }

    fun updateGameDifficultyPref(
        gameDifficulty: GameDifficulty
    ) {
        sharedPref
            .edit()
            .putString(PREFERENCE_RESULT_GAME_DIFFICULTY, gameDifficulty.name)
            .apply()
    }

    companion object {
        private const val PREFERENCE_KEY_GAME_DIFFICULTY = "preference_game_key"
        private const val PREFERENCE_RESULT_GAME_DIFFICULTY = "game_difficulty_result"
        private val defaultDifficulty = GameDifficulty.EASY.name
    }
}

Setup :

import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class GamePrefTest {

    private lateinit var context: Context
    private lateinit var gamePref: GamePref

    @Before
    fun setup() {
        context = ApplicationProvider.getApplicationContext()
        gamePref = GamePref(context)
    }
}

Test case 1 :
No updates yet, the default should be EASY

@Test
fun defaultDifficultyShouldBeEasy() {
    val actualDifficulty = gamePref.getGameDifficultyPref()

    assertEquals(GameDifficulty.EASY, actualDifficulty)
}

Test case 2 :
Update and verify preference difficulty to HARD

@Test
fun canStoreAndRetrieveGameDifficulty() {
    gamePref.updateGameDifficultyPref(GameDifficulty.HARD)

    val storedDifficulty = gamePref.getGameDifficultyPref()

    assertEquals(GameDifficulty.HARD, storedDifficulty)
}

Test case 3 :
Update and verify preferences repeatedly

@Test
fun updateDifficultyMultipleTimes() {
    // Start and verify with MEDIUM
    gamePref.updateGameDifficultyPref(GameDifficulty.MEDIUM)
    var currentDiff = gamePref.getGameDifficultyPref()
    assertEquals(GameDifficulty.MEDIUM, currentDiff)

    // Update and verify preference to HARD
    gamePref.updateGameDifficultyPref(GameDifficulty.HARD)
    currentDiff = gamePref.getGameDifficultyPref()
    assertEquals(GameDifficulty.HARD, currentDiff)

    // Update and verify preference set EASY
    gamePref.updateGameDifficultyPref(GameDifficulty.EASY)
    currentDiff = gamePref.getGameDifficultyPref()
    assertEquals(GameDifficulty.EASY, currentDiff)
}

4. Internals of EncryptedSharedPreferences

Information outlined here is referring internal implementation of actual code in library.

4.1. Sequence of Operations

First create() function is called from our encrypted shared preference, internally being called this function which actually calls standard shared preference which is referred below at applicationContext.getSharedPreferences

public static SharedPreferences create(
        @NonNull String fileName,
        @NonNull String masterKeyAlias,
        @NonNull Context context,
        ...
) throws GeneralSecurityException, IOException {
    ...
    // here is standard SharedPreferences call
    SharedPreferences realPrefs = 
        applicationContext.getSharedPreferences(fileName, Context.MODE_PRIVATE);
    return new EncryptedSharedPreferences(
            fileName,
            masterKeyAlias,
            realPrefs,
            aead,
            daead
    );
}

realPrefs is just a normal SharedPreferences instance that references an XML file in /data/data/<our_package>/shared_prefs/<fileName>.xml.

4.2. Returns instance of EncryptedSharedPreferences
flowchart LR
    subgraph creation
    A["create()"] --> B["2 Tink keysets: Key + Value"]
    B --> C[EncryptedSharedPreferences constructor]
    C --> D("Stores references to realPrefs + aead + daead")
    end
flowchart LR
    subgraph writing
    E(".putString(userKey, someValue)") --> F("encryptKey -> encryptedKey")
    F --> G("encryptValue -> encryptedValue")
    G --> H("call realPrefs.putString(encryptedKey, encryptedValue)")
    H --> I(".apply/.commit -> normal SP disk write")
    end

Writing sequence : once .putString("userKey", "someValue") is called

  1. Key is deterministically encrypted with AES-SIVencryptedKey.
  2. Value is non-deterministically encrypted with AES-GCMencryptedValue.
  3. Then calls realPrefs.edit().putString(encryptedKey, encryptedValue).
  4. Finally .apply() or .commit() writes ciphertext to the file normal SharedPreferences uses.

4.3. Underlying Data Structures

In-Memory Map:

File Storage: The result is an XML file with ciphertext pairs, e.g

<map>
  <string name="ENCRYPTED_KEY_ABC..">"ENCRYPTED_VALUE_XYZ.."</string>
</map>

So the key in the map is also cryptographically scrambled, ensuring an attacker can’t guess the real preference name.

Reference :
Android documentation – Save simple data with SharedPreferences
Android documentation – SharedPreferences methods
GitHub project with code examples

I’ve written this article to the best of my knowledge based on the current documentation and best practices. However, if you spot any errors, missing details, or have suggestions for improvement, please let me know so I can correct them. The goal is to keep this information as accurate and helpful as possible!

Author

Exit mobile version