PM-40727: feat: Add encryption layer for new KeystoreEncryptedSharedPreferences (#7191)

This commit is contained in:
David Perez
2026-07-24 14:30:48 +00:00
committed by GitHub
parent 79d515f49e
commit 445f2dd14b
12 changed files with 1083 additions and 0 deletions
@@ -0,0 +1,175 @@
package com.bitwarden.data.datasource.disk
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.core.data.manager.encryption.EncryptionManager
import timber.log.Timber
import java.util.Base64
private typealias OnChangeListener = SharedPreferences.OnSharedPreferenceChangeListener
private const val ALIAS: String = "KeystoreEncryptedSharedPreferences"
/**
* An implementation of [SharedPreferences] that encrypts the values using the AndroidKeystore.
*/
@OmitFromCoverage
@Suppress("TooManyFunctions")
internal class KeystoreEncryptedSharedPreferences(
app: Application,
private val encryptionManager: EncryptionManager,
) : SharedPreferences {
private val sharedPreferences: SharedPreferences = app.getSharedPreferences(
"${app.packageName}_keystore_encrypted_preferences",
Context.MODE_PRIVATE,
)
private val wrappedListeners: MutableMap<OnChangeListener, OnChangeListener> = mutableMapOf()
override fun contains(key: String): Boolean = sharedPreferences.contains(key)
override fun edit(): SharedPreferences.Editor = Editor(
encryptionManager = encryptionManager,
editor = sharedPreferences.edit(),
)
override fun getAll(): Map<String, *> = sharedPreferences
.all
.mapValues { (key, value) ->
// Value is always a string since we always encode data to a string.
(value as? String)
?.let { Base64.getDecoder().decode(it) }
?.let { bytes ->
encryptionManager
.decrypt(alias = ALIAS, bytes = bytes)
.map { it.decodeToString() }
.onFailure { Timber.e(it, "Failed to decrypt value for key: $key") }
.getOrNull()
}
}
override fun getString(
key: String,
defValue: String?,
): String? = decryptAndGetByteArray(key = key)?.decodeToString() ?: defValue
override fun getBoolean(key: String, defValue: Boolean): Boolean {
unsupportedFeature("getBoolean")
}
override fun getFloat(key: String, defValue: Float): Float {
unsupportedFeature("getFloat")
}
override fun getInt(key: String, defValue: Int): Int {
unsupportedFeature("getInt")
}
override fun getLong(key: String, defValue: Long): Long {
unsupportedFeature("getLong")
}
override fun getStringSet(key: String, defValues: Set<String>?): Set<String>? {
unsupportedFeature("getStringSet")
}
override fun registerOnSharedPreferenceChangeListener(listener: OnChangeListener) {
val wrappedListener = wrappedListeners.getOrPut(key = listener) { listener.wrap() }
sharedPreferences.registerOnSharedPreferenceChangeListener(wrappedListener)
}
override fun unregisterOnSharedPreferenceChangeListener(listener: OnChangeListener) {
wrappedListeners.remove(listener)?.let { wrappedListener ->
sharedPreferences.unregisterOnSharedPreferenceChangeListener(wrappedListener)
}
}
private fun OnChangeListener.wrap(): OnChangeListener = OnChangeListener { _, key ->
this.onSharedPreferenceChanged(this@KeystoreEncryptedSharedPreferences, key)
}
private fun decryptAndGetByteArray(
key: String,
): ByteArray? = sharedPreferences
.getString(key, null)
?.let { Base64.getDecoder().decode(it) }
?.let { bytes ->
encryptionManager
.decrypt(alias = ALIAS, bytes = bytes)
.onFailure { Timber.e(it, "Failed to decrypt value for key: $key") }
.getOrNull()
}
}
@Suppress("TooManyFunctions")
private class Editor(
private val encryptionManager: EncryptionManager,
private val editor: SharedPreferences.Editor,
) : SharedPreferences.Editor {
override fun apply(): Unit = editor.apply()
override fun clear(): SharedPreferences.Editor {
editor.clear()
// Always return `this` to ensure any chaining uses the editor that handles encryption.
return this
}
override fun commit(): Boolean = editor.commit()
override fun putString(
key: String,
value: String?,
): SharedPreferences.Editor = value
?.let { encryptAndPutByteArray(key = key, value = it.encodeToByteArray()) }
?: remove(key = key)
override fun putBoolean(key: String, value: Boolean): SharedPreferences.Editor {
unsupportedFeature("putBoolean")
}
override fun putFloat(key: String, value: Float): SharedPreferences.Editor {
unsupportedFeature("putFloat")
}
override fun putInt(key: String, value: Int): SharedPreferences.Editor {
unsupportedFeature("putInt")
}
override fun putLong(key: String, value: Long): SharedPreferences.Editor {
unsupportedFeature("putLong")
}
override fun putStringSet(key: String, values: Set<String>?): SharedPreferences.Editor {
unsupportedFeature("putStringSet")
}
override fun remove(key: String): SharedPreferences.Editor {
editor.remove(key)
// Always return `this` to ensure any chaining uses the editor that handles encryption.
return this
}
private fun encryptAndPutByteArray(
key: String,
value: ByteArray,
): SharedPreferences.Editor {
editor.putString(
key,
encryptionManager
.encrypt(alias = ALIAS, bytes = value)
.map { Base64.getEncoder().encodeToString(it) }
.onFailure { Timber.e(it, "Failed to encrypt value for key: $key") }
.getOrThrow(),
)
// Always return `this` to ensure any chaining uses the editor that handles encryption.
return this
}
}
private fun unsupportedFeature(name: String): Nothing {
throw UnsupportedOperationException(
"$name is not supported by KeystoreEncryptedSharedPreferences",
)
}
@@ -0,0 +1,11 @@
package com.bitwarden.data.datasource.disk.di
import javax.inject.Qualifier
/**
* Used to denote an instance of [android.content.SharedPreferences] that encrypts its data using
* the Keystore.
*/
@Qualifier
@Retention(AnnotationRetention.RUNTIME)
annotation class KeystoreEncryptedPreferences
@@ -7,6 +7,8 @@ import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.bitwarden.core.data.manager.encryption.EncryptionManager
import com.bitwarden.data.datasource.disk.KeystoreEncryptedSharedPreferences
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -33,6 +35,17 @@ object PreferenceModule {
Context.MODE_PRIVATE,
)
@Provides
@Singleton
@KeystoreEncryptedPreferences
fun provideKeystoreEncryptedPreferences(
application: Application,
encryptionManager: EncryptionManager,
): SharedPreferences = KeystoreEncryptedSharedPreferences(
app = application,
encryptionManager = encryptionManager,
)
@Provides
@Singleton
@EncryptedPreferences
@@ -0,0 +1,184 @@
package com.bitwarden.data.datasource.disk
import android.app.Application
import android.content.Context
import com.bitwarden.core.data.manager.encryption.EncryptionManager
import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.data.datasource.disk.base.FakeSharedPreferences
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Assertions.assertArrayEquals
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import java.util.Base64
class KeystoreEncryptedSharedPreferencesTest {
private val fakeSharedPreferences = FakeSharedPreferences()
private val mockApplication = mockk<Application> {
every { packageName } returns PACKAGE_NAME
every {
getSharedPreferences(
"${PACKAGE_NAME}_keystore_encrypted_preferences",
Context.MODE_PRIVATE,
)
} returns fakeSharedPreferences
}
// The EncryptionManager is mocked with a deterministic, self-inverting byte transformation
// that, like real ciphertext, produces bytes that are not valid UTF-8.
private val mockEncryptionManager = mockk<EncryptionManager> {
every { encrypt(alias = ALIAS, bytes = any()) } answers {
(IV + secondArg<ByteArray>().toggleBits()).asSuccess()
}
every { decrypt(alias = ALIAS, bytes = any()) } answers {
secondArg<ByteArray>()
.let { it.copyOfRange(fromIndex = IV.size, toIndex = it.size) }
.toggleBits()
.asSuccess()
}
}
private val keystoreEncryptedSharedPreferences = KeystoreEncryptedSharedPreferences(
app = mockApplication,
encryptionManager = mockEncryptionManager,
)
@Test
fun `putString followed by getString should return the original value`() {
keystoreEncryptedSharedPreferences.edit().putString(KEY, VALUE).apply()
assertEquals(
VALUE,
keystoreEncryptedSharedPreferences.getString(KEY, null),
)
}
@Test
fun `putString should store a base64 encoded encrypted value instead of the plaintext value`() {
keystoreEncryptedSharedPreferences.edit().putString(KEY, VALUE).apply()
val storedValue = requireNotNull(fakeSharedPreferences.getString(KEY, null))
assertNotEquals(VALUE, storedValue)
assertArrayEquals(
IV + VALUE.encodeToByteArray().toggleBits(),
Base64.getDecoder().decode(storedValue),
)
}
@Test
fun `putString with a null value should remove the stored value`() {
keystoreEncryptedSharedPreferences.edit().putString(KEY, VALUE).apply()
keystoreEncryptedSharedPreferences.edit().putString(KEY, null).apply()
assertFalse(keystoreEncryptedSharedPreferences.contains(KEY))
assertNull(keystoreEncryptedSharedPreferences.getString(KEY, null))
}
@Test
fun `getString should return the default value when no value is stored`() {
assertEquals(
"mockDefault",
keystoreEncryptedSharedPreferences.getString(KEY, "mockDefault"),
)
assertNull(keystoreEncryptedSharedPreferences.getString(KEY, null))
}
@Test
fun `getString should return the default value when the value cannot be decrypted`() {
keystoreEncryptedSharedPreferences.edit().putString(KEY, VALUE).apply()
every {
mockEncryptionManager.decrypt(alias = ALIAS, bytes = any())
} returns Throwable().asFailure()
assertEquals(
"mockDefault",
keystoreEncryptedSharedPreferences.getString(KEY, "mockDefault"),
)
}
@Test
fun `contains should reflect the underlying preferences`() {
assertFalse(keystoreEncryptedSharedPreferences.contains(KEY))
keystoreEncryptedSharedPreferences.edit().putString(KEY, VALUE).apply()
assertTrue(keystoreEncryptedSharedPreferences.contains(KEY))
}
@Test
fun `getAll should return the decrypted values`() {
keystoreEncryptedSharedPreferences.edit().putString(KEY, VALUE).apply()
val all = keystoreEncryptedSharedPreferences.all
assertEquals(setOf(KEY), all.keys)
assertEquals(VALUE, all[KEY] as String)
}
@Test
fun `getAll should return a null value when the value cannot be decrypted`() {
keystoreEncryptedSharedPreferences.edit().putString(KEY, VALUE).apply()
every {
mockEncryptionManager.decrypt(alias = ALIAS, bytes = any())
} returns Throwable().asFailure()
val all = keystoreEncryptedSharedPreferences.all
assertEquals(setOf(KEY), all.keys)
assertNull(all[KEY])
}
@Test
fun `unsupported get operations should throw UnsupportedOperationException`() {
assertThrows<UnsupportedOperationException> {
keystoreEncryptedSharedPreferences.getBoolean(KEY, false)
}
assertThrows<UnsupportedOperationException> {
keystoreEncryptedSharedPreferences.getInt(KEY, 0)
}
assertThrows<UnsupportedOperationException> {
keystoreEncryptedSharedPreferences.getLong(KEY, 0L)
}
assertThrows<UnsupportedOperationException> {
keystoreEncryptedSharedPreferences.getFloat(KEY, 0f)
}
assertThrows<UnsupportedOperationException> {
keystoreEncryptedSharedPreferences.getStringSet(KEY, null)
}
}
@Test
fun `unsupported put operations should throw UnsupportedOperationException`() {
val editor = keystoreEncryptedSharedPreferences.edit()
assertThrows<UnsupportedOperationException> { editor.putBoolean(KEY, false) }
assertThrows<UnsupportedOperationException> { editor.putInt(KEY, 0) }
assertThrows<UnsupportedOperationException> { editor.putLong(KEY, 0L) }
assertThrows<UnsupportedOperationException> { editor.putFloat(KEY, 0f) }
assertThrows<UnsupportedOperationException> { editor.putStringSet(KEY, null) }
}
}
/**
* A self-inverting stand-in for encryption that, like real ciphertext, produces bytes that are
* not valid UTF-8.
*/
private fun ByteArray.toggleBits(): ByteArray = this
.map { (it.toInt() xor 0xAA).toByte() }
.toByteArray()
private const val PACKAGE_NAME: String = "com.mock.app"
private const val ALIAS: String = "KeystoreEncryptedSharedPreferences"
private const val KEY: String = "mockKey"
private const val VALUE: String = "mockValue with unicode 🔐 and quotes “”"
// 0x800x8F are UTF-8 continuation bytes, which are invalid as leading bytes and would be
// destroyed by a UTF-8 decode/encode round trip.
private val IV: ByteArray = ByteArray(16) { (0x80 + it).toByte() }