mirror of
https://github.com/bitwarden/android.git
synced 2026-08-29 10:17:56 -05:00
PM-40727: feat: Add encryption layer for new KeystoreEncryptedSharedPreferences (#7191)
This commit is contained in:
+175
@@ -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",
|
||||
)
|
||||
}
|
||||
+11
@@ -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
|
||||
|
||||
+184
@@ -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 “”"
|
||||
|
||||
// 0x80–0x8F 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() }
|
||||
Reference in New Issue
Block a user