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
+1
View File
@@ -49,6 +49,7 @@ dependencies {
implementation(libs.kotlinx.collections.immutable)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization)
implementation(libs.timber)
testImplementation(platform(libs.junit.bom))
testRuntimeOnly(libs.junit.platform.launcher)
@@ -0,0 +1,36 @@
package com.bitwarden.core.data.manager.di
import com.bitwarden.core.data.manager.BuildInfoManager
import com.bitwarden.core.data.manager.encryption.EncryptionManager
import com.bitwarden.core.data.manager.encryption.EncryptionManagerImpl
import com.bitwarden.core.data.manager.encryption.KeystoreManager
import com.bitwarden.core.data.manager.encryption.KeystoreManagerImpl
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/**
* Provides managers in the core module.
*/
@Module
@InstallIn(SingletonComponent::class)
object CoreManagerModule {
@Provides
@Singleton
fun provideEncryptionManager(
keystoreManager: KeystoreManager,
): EncryptionManager = EncryptionManagerImpl(
keystoreManager = keystoreManager,
)
@Provides
@Singleton
fun provideKeystoreManager(
buildInfoManager: BuildInfoManager,
): KeystoreManager = KeystoreManagerImpl(
buildInfoManager = buildInfoManager,
)
}
@@ -0,0 +1,16 @@
package com.bitwarden.core.data.manager.encryption
/**
* A manager responsible for encrypting and decrypting data.
*/
interface EncryptionManager {
/**
* Decrypts the string with the Secret associated with the [alias].
*/
fun decrypt(alias: String, bytes: ByteArray): Result<ByteArray>
/**
* Encrypts the string with the Secret associated with the [alias].
*/
fun encrypt(alias: String, bytes: ByteArray): Result<ByteArray>
}
@@ -0,0 +1,79 @@
package com.bitwarden.core.data.manager.encryption
import android.security.keystore.KeyProperties
import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.core.data.util.flatMap
import java.security.Key
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
private const val IV_SIZE_BYTES: Int = 12
private const val CIPHER_TRANSFORMATION: String = KeyProperties.KEY_ALGORITHM_AES + "/" +
KeyProperties.BLOCK_MODE_GCM + "/" +
KeyProperties.ENCRYPTION_PADDING_NONE
private const val TAG_LENGTH: Int = 128
/**
* The default implementation of [EncryptionManager].
*/
internal class EncryptionManagerImpl(
private val keystoreManager: KeystoreManager,
) : EncryptionManager {
override fun decrypt(
alias: String,
bytes: ByteArray,
): Result<ByteArray> = keystoreManager
.getKeyOrNull(alias = alias)
.flatMap {
it?.asSuccess() ?: IllegalStateException("Alias does not exist").asFailure()
}
.mapCatching {
val iv = bytes.copyOfRange(fromIndex = 0, toIndex = IV_SIZE_BYTES)
it.generateCipher(initCipher = InitCipher.Decrypt(iv = iv))
}
.mapCatching {
val cipherText = bytes.copyOfRange(fromIndex = IV_SIZE_BYTES, toIndex = bytes.size)
it.doFinal(cipherText)
}
override fun encrypt(
alias: String,
bytes: ByteArray,
): Result<ByteArray> = keystoreManager
.getOrCreateKey(alias = alias)
.mapCatching { it.generateCipher(initCipher = InitCipher.Encrypt) }
.mapCatching { it.iv + it.doFinal(bytes) }
}
private sealed class InitCipher {
data class Decrypt(val iv: ByteArray) : InitCipher() {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
return iv.contentEquals(other = (other as Decrypt).iv)
}
override fun hashCode(): Int = iv.contentHashCode()
}
data object Encrypt : InitCipher()
}
private fun Key.generateCipher(
initCipher: InitCipher,
): Cipher = Cipher
.getInstance(CIPHER_TRANSFORMATION)
.apply {
when (initCipher) {
is InitCipher.Decrypt -> {
init(
Cipher.DECRYPT_MODE,
this@generateCipher,
GCMParameterSpec(TAG_LENGTH, initCipher.iv),
)
}
is InitCipher.Encrypt -> init(Cipher.ENCRYPT_MODE, this@generateCipher)
}
}
@@ -0,0 +1,34 @@
package com.bitwarden.core.data.manager.encryption
import java.security.Key
/**
* Responsible for storing, retrieving, and removing symmetric keys in the Android Keystore.
*
* All aliases are automatically namespaced with the application ID before being written to or
* read from the keystore.
*/
interface KeystoreManager {
/**
* Returns the AES [Key] stored under [alias], generating and storing a new key if one
* does not already exist.
*/
fun getOrCreateKey(alias: String): Result<Key>
/**
* Returns the AES [Key] stored under [alias], or `null` if no key exists or the key
* could not be recovered.
*/
fun getKeyOrNull(alias: String): Result<Key?>
/**
* Returns `true` if a key exists in the keystore under [alias].
*/
fun hasKey(alias: String): Boolean
/**
* Removes the key stored under [alias] from the keystore. Returns `true` if the entry was
* removed successfully or `false` if the operation failed.
*/
fun removeKey(alias: String): Boolean
}
@@ -0,0 +1,121 @@
package com.bitwarden.core.data.manager.encryption
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import com.bitwarden.core.data.manager.BuildInfoManager
import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.core.data.util.flatMap
import timber.log.Timber
import java.security.InvalidAlgorithmParameterException
import java.security.Key
import java.security.KeyStore
import java.security.KeyStoreException
import java.security.NoSuchAlgorithmException
import java.security.NoSuchProviderException
import java.security.ProviderException
import java.security.UnrecoverableKeyException
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
private const val KEYSTORE_TYPE_ANDROID: String = "AndroidKeyStore"
private const val KEY_SIZE: Int = 256
/**
* Default implementation of [KeystoreManager].
*/
internal class KeystoreManagerImpl(
private val buildInfoManager: BuildInfoManager,
) : KeystoreManager {
private val androidKeystore: KeyStore by lazy {
KeyStore
.getInstance(KEYSTORE_TYPE_ANDROID)
.also { it.load(null) }
}
override fun getOrCreateKey(
alias: String,
): Result<Key> = this
.getKeyOrNull(alias = alias)
.flatMap {
// If the key is null, then it did not exist and we should generate one.
// If an error occurred, then we should just send back the error.
it?.asSuccess() ?: generateKeyOrNull(alias = alias)
}
override fun getKeyOrNull(alias: String): Result<Key?> =
try {
androidKeystore.getKey(alias.formatAlias(), null).asSuccess()
} catch (e: KeyStoreException) {
// Keystore was not loaded
Timber.w(e, "getKey failed to retrieve secret key")
e.asFailure()
} catch (e: NoSuchAlgorithmException) {
// Keystore algorithm cannot be found
Timber.w(e, "getKey failed to retrieve secret key")
e.asFailure()
} catch (e: UnrecoverableKeyException) {
// Key could not be recovered
Timber.w(e, "getKey failed to retrieve secret key")
e.asFailure()
}
override fun hasKey(
alias: String,
): Boolean = androidKeystore.containsAlias(alias.formatAlias())
override fun removeKey(alias: String): Boolean =
try {
androidKeystore.deleteEntry(alias.formatAlias())
true
} catch (e: KeyStoreException) {
Timber.e(e, "removeKey failed to delete keystore entry")
false
}
/**
* Generates and stores a new AES [SecretKey] under [alias], or `null` if a key cannot be
* generated.
*/
private fun generateKeyOrNull(alias: String): Result<Key> {
val keyGen = try {
KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_TYPE_ANDROID)
} catch (e: NoSuchAlgorithmException) {
Timber.w(e, "generateKeyOrNull failed to get key generator instance")
return e.asFailure()
} catch (e: NoSuchProviderException) {
Timber.w(e, "generateKeyOrNull failed to get key generator instance")
return e.asFailure()
} catch (e: IllegalArgumentException) {
Timber.w(e, "generateKeyOrNull failed to get key generator instance")
return e.asFailure()
}
return try {
keyGen.init(getKeyGenParameterSpec(alias = alias))
keyGen.generateKey().asSuccess()
} catch (e: InvalidAlgorithmParameterException) {
Timber.w(e, "generateKeyOrNull failed to initialize and generate key")
e.asFailure()
} catch (e: ProviderException) {
Timber.w(e, "generateKeyOrNull failed to initialize and generate key")
e.asFailure()
}
}
private fun getKeyGenParameterSpec(
alias: String,
): KeyGenParameterSpec =
KeyGenParameterSpec
.Builder(
alias.formatAlias(),
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(KEY_SIZE)
.build()
private fun String.formatAlias(): String = "${buildInfoManager.applicationId}.$this"
}
@@ -0,0 +1,151 @@
package com.bitwarden.core.data.manager.encryption
import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkStatic
import io.mockk.verify
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertArrayEquals
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import javax.crypto.Cipher
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
class EncryptionManagerTest {
private val mockSecretKey = mockk<SecretKey>()
private val mockKeystoreManager = mockk<KeystoreManager> {
every { getOrCreateKey(alias = ALIAS) } returns mockSecretKey.asSuccess()
every { getKeyOrNull(alias = ALIAS) } returns mockSecretKey.asSuccess()
}
// Real Cipher code cannot run in a JVM unit test (the "NoPadding" transformation is only
// registered on Android and MockK cannot call originals on the JPMS-protected
// javax.crypto.Cipher), so the cipher is fully mocked with a symmetric byte transformation
// that produces non-UTF-8 bytes, just like real ciphertext.
private val mockCipher = mockk<Cipher> {
every { init(Cipher.ENCRYPT_MODE, mockSecretKey) } just runs
every { init(Cipher.DECRYPT_MODE, mockSecretKey, any<GCMParameterSpec>()) } just runs
every { iv } returns IV
every { doFinal(any()) } answers { firstArg<ByteArray>().toggleBits() }
}
private val encryptionManager: EncryptionManager = EncryptionManagerImpl(
keystoreManager = mockKeystoreManager,
)
@BeforeEach
fun setUp() {
mockkStatic(Cipher::class)
every { Cipher.getInstance("AES/GCM/NoPadding") } returns mockCipher
}
@AfterEach
fun tearDown() {
unmockkStatic(Cipher::class)
}
@Test
fun `encrypt should return the IV followed by the ciphertext`() {
val result = encryptionManager.encrypt(alias = ALIAS, bytes = PLAINTEXT_BYTES)
val encryptedBytes = result.getOrThrow()
assertArrayEquals(IV, encryptedBytes.copyOfRange(fromIndex = 0, toIndex = IV.size))
assertArrayEquals(
PLAINTEXT_BYTES.toggleBits(),
encryptedBytes.copyOfRange(fromIndex = IV.size, toIndex = encryptedBytes.size),
)
verify(exactly = 1) {
mockKeystoreManager.getOrCreateKey(alias = ALIAS)
mockCipher.init(Cipher.ENCRYPT_MODE, mockSecretKey)
}
}
@Test
fun `encrypt should return failure when the key cannot be retrieved or created`() {
val error = Throwable()
every { mockKeystoreManager.getOrCreateKey(alias = ALIAS) } returns error.asFailure()
assertEquals(
error.asFailure(),
encryptionManager.encrypt(alias = ALIAS, bytes = PLAINTEXT_BYTES),
)
verify(exactly = 0) {
Cipher.getInstance(any())
}
}
@Test
fun `decrypt should strip the IV and return the decrypted bytes`() {
val cipherText = PLAINTEXT_BYTES.toggleBits()
val result = encryptionManager.decrypt(alias = ALIAS, bytes = IV + cipherText)
assertArrayEquals(PLAINTEXT_BYTES, result.getOrThrow())
verify(exactly = 1) {
mockCipher.init(
Cipher.DECRYPT_MODE,
mockSecretKey,
match<GCMParameterSpec> { it.iv.contentEquals(IV) },
)
mockCipher.doFinal(match { it.contentEquals(cipherText) })
}
}
@Test
fun `decrypt should return failure when no key exists`() {
every { mockKeystoreManager.getKeyOrNull(alias = ALIAS) } returns null.asSuccess()
val result = encryptionManager.decrypt(alias = ALIAS, bytes = IV + PLAINTEXT_BYTES)
assertTrue(result.isFailure)
assertTrue(result.exceptionOrNull() is IllegalStateException)
}
@Test
fun `decrypt should return failure when the key cannot be retrieved`() {
val error = Throwable()
every { mockKeystoreManager.getKeyOrNull(alias = ALIAS) } returns error.asFailure()
assertEquals(
error.asFailure(),
encryptionManager.decrypt(alias = ALIAS, bytes = IV + PLAINTEXT_BYTES),
)
}
@Test
fun `decrypt should recover the exact bytes passed to encrypt`() {
val encryptedBytes = encryptionManager
.encrypt(alias = ALIAS, bytes = PLAINTEXT_BYTES)
.getOrThrow()
assertArrayEquals(
PLAINTEXT_BYTES,
encryptionManager.decrypt(alias = ALIAS, bytes = encryptedBytes).getOrThrow(),
)
}
}
/**
* 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 ALIAS: String = "mockAlias"
private val PLAINTEXT_BYTES: ByteArray = "mockValue with unicode 🔐".encodeToByteArray()
// 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(12) { (0x80 + it).toByte() }
@@ -0,0 +1,262 @@
package com.bitwarden.core.data.manager.encryption
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import com.bitwarden.core.data.manager.BuildInfoManager
import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkConstructor
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkConstructor
import io.mockk.unmockkStatic
import io.mockk.verify
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.security.InvalidAlgorithmParameterException
import java.security.KeyStore
import java.security.KeyStoreException
import java.security.NoSuchAlgorithmException
import java.security.NoSuchProviderException
import java.security.ProviderException
import java.security.UnrecoverableKeyException
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
class KeystoreManagerTest {
private val mockAndroidKeyStore = mockk<KeyStore>(name = "MockAndroidKeyStore")
private val mockKeyGenerator = mockk<KeyGenerator>()
private val mockKeyGenParameterSpec = mockk<KeyGenParameterSpec>()
private val mockBuildInfoManager = mockk<BuildInfoManager> {
every { applicationId } returns APPLICATION_ID
}
private val keystoreManager: KeystoreManager = KeystoreManagerImpl(
buildInfoManager = mockBuildInfoManager,
)
@BeforeEach
fun setUp() {
mockkStatic(KeyStore::class, KeyGenerator::class)
mockkConstructor(KeyGenParameterSpec.Builder::class)
every { KeyStore.getInstance("AndroidKeyStore") } returns mockAndroidKeyStore
every { mockAndroidKeyStore.load(null) } just runs
}
@AfterEach
fun tearDown() {
unmockkStatic(KeyStore::class, KeyGenerator::class)
unmockkConstructor(KeyGenParameterSpec.Builder::class)
}
@Test
fun `getKey should return success with key stored in keystore`() {
val mockSecretKey = mockk<SecretKey>()
every { mockAndroidKeyStore.getKey(NAMESPACED_ALIAS, null) } returns mockSecretKey
assertEquals(mockSecretKey.asSuccess(), keystoreManager.getKeyOrNull(alias = ALIAS))
verify(exactly = 1) {
mockAndroidKeyStore.getKey(NAMESPACED_ALIAS, null)
}
}
@Test
fun `getKey should return success with null when no key exists`() {
every { mockAndroidKeyStore.getKey(NAMESPACED_ALIAS, null) } returns null
assertEquals(null.asSuccess(), keystoreManager.getKeyOrNull(alias = ALIAS))
}
@Test
fun `getKey should return failure when keystore throws KeyStoreException`() {
val error = KeyStoreException()
every { mockAndroidKeyStore.getKey(NAMESPACED_ALIAS, null) } throws error
assertEquals(error.asFailure(), keystoreManager.getKeyOrNull(alias = ALIAS))
}
@Test
fun `getKey should return failure when keystore throws NoSuchAlgorithmException`() {
val error = NoSuchAlgorithmException()
every { mockAndroidKeyStore.getKey(NAMESPACED_ALIAS, null) } throws error
assertEquals(error.asFailure(), keystoreManager.getKeyOrNull(alias = ALIAS))
}
@Test
fun `getKey should return failure when keystore throws UnrecoverableKeyException`() {
val error = UnrecoverableKeyException()
every { mockAndroidKeyStore.getKey(NAMESPACED_ALIAS, null) } throws error
assertEquals(error.asFailure(), keystoreManager.getKeyOrNull(alias = ALIAS))
}
@Test
fun `getOrCreateKey should return existing key without generating a new one`() {
val mockSecretKey = mockk<SecretKey>()
every { mockAndroidKeyStore.getKey(NAMESPACED_ALIAS, null) } returns mockSecretKey
assertEquals(mockSecretKey.asSuccess(), keystoreManager.getOrCreateKey(alias = ALIAS))
verify(exactly = 0) {
KeyGenerator.getInstance(any<String>(), any<String>())
}
}
@Test
fun `getOrCreateKey should return failure when retrieving the existing key fails`() {
val error = KeyStoreException()
every { mockAndroidKeyStore.getKey(NAMESPACED_ALIAS, null) } throws error
assertEquals(error.asFailure(), keystoreManager.getOrCreateKey(alias = ALIAS))
verify(exactly = 0) {
KeyGenerator.getInstance(any<String>(), any<String>())
}
}
@Test
fun `getOrCreateKey should generate and return a new AES key when none exists`() {
val mockSecretKey = mockk<SecretKey>()
every { mockAndroidKeyStore.getKey(NAMESPACED_ALIAS, null) } returns null
setupMockKeyGenParameterSpecBuilder()
setupMockKeyGenerator()
every { mockKeyGenerator.generateKey() } returns mockSecretKey
assertEquals(mockSecretKey.asSuccess(), keystoreManager.getOrCreateKey(alias = ALIAS))
verify(exactly = 1) {
anyConstructed<KeyGenParameterSpec.Builder>()
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
anyConstructed<KeyGenParameterSpec.Builder>()
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
anyConstructed<KeyGenParameterSpec.Builder>().setKeySize(256)
mockKeyGenerator.init(mockKeyGenParameterSpec)
mockKeyGenerator.generateKey()
}
}
@Suppress("MaxLineLength")
@Test
fun `getOrCreateKey should return failure when key generator instance throws NoSuchAlgorithmException`() {
val error = NoSuchAlgorithmException()
every { mockAndroidKeyStore.getKey(NAMESPACED_ALIAS, null) } returns null
every {
KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
} throws error
assertEquals(error.asFailure(), keystoreManager.getOrCreateKey(alias = ALIAS))
}
@Suppress("MaxLineLength")
@Test
fun `getOrCreateKey should return failure when key generator instance throws NoSuchProviderException`() {
val error = NoSuchProviderException()
every { mockAndroidKeyStore.getKey(NAMESPACED_ALIAS, null) } returns null
every {
KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
} throws error
assertEquals(error.asFailure(), keystoreManager.getOrCreateKey(alias = ALIAS))
}
@Suppress("MaxLineLength")
@Test
fun `getOrCreateKey should return failure when key generator instance throws IllegalArgumentException`() {
val error = IllegalArgumentException()
every { mockAndroidKeyStore.getKey(NAMESPACED_ALIAS, null) } returns null
every {
KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
} throws error
assertEquals(error.asFailure(), keystoreManager.getOrCreateKey(alias = ALIAS))
}
@Suppress("MaxLineLength")
@Test
fun `getOrCreateKey should return failure when key generator init throws InvalidAlgorithmParameterException`() {
val error = InvalidAlgorithmParameterException()
every { mockAndroidKeyStore.getKey(NAMESPACED_ALIAS, null) } returns null
setupMockKeyGenParameterSpecBuilder()
setupMockKeyGenerator()
every { mockKeyGenerator.init(mockKeyGenParameterSpec) } throws error
assertEquals(error.asFailure(), keystoreManager.getOrCreateKey(alias = ALIAS))
}
@Test
fun `getOrCreateKey should return failure when generateKey throws ProviderException`() {
val error = ProviderException()
every { mockAndroidKeyStore.getKey(NAMESPACED_ALIAS, null) } returns null
setupMockKeyGenParameterSpecBuilder()
setupMockKeyGenerator()
every { mockKeyGenerator.generateKey() } throws error
assertEquals(error.asFailure(), keystoreManager.getOrCreateKey(alias = ALIAS))
}
@Test
fun `hasKey should return true when keystore contains alias`() {
every { mockAndroidKeyStore.containsAlias(NAMESPACED_ALIAS) } returns true
assertTrue(keystoreManager.hasKey(alias = ALIAS))
}
@Test
fun `hasKey should return false when keystore does not contain alias`() {
every { mockAndroidKeyStore.containsAlias(NAMESPACED_ALIAS) } returns false
assertFalse(keystoreManager.hasKey(alias = ALIAS))
}
@Test
fun `removeKey should delete entry and return true`() {
every { mockAndroidKeyStore.deleteEntry(NAMESPACED_ALIAS) } just runs
assertTrue(keystoreManager.removeKey(alias = ALIAS))
verify(exactly = 1) {
mockAndroidKeyStore.deleteEntry(NAMESPACED_ALIAS)
}
}
@Test
fun `removeKey should return false when keystore throws KeyStoreException`() {
every { mockAndroidKeyStore.deleteEntry(NAMESPACED_ALIAS) } throws KeyStoreException()
assertFalse(keystoreManager.removeKey(alias = ALIAS))
}
private fun setupMockKeyGenerator() {
every {
KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
} returns mockKeyGenerator
every { mockKeyGenerator.init(mockKeyGenParameterSpec) } just runs
}
private fun setupMockKeyGenParameterSpecBuilder() {
every {
anyConstructed<KeyGenParameterSpec.Builder>().setBlockModes(any())
} answers { self as KeyGenParameterSpec.Builder }
every {
anyConstructed<KeyGenParameterSpec.Builder>().setEncryptionPaddings(any())
} answers { self as KeyGenParameterSpec.Builder }
every {
anyConstructed<KeyGenParameterSpec.Builder>().setDigests(any())
} answers { self as KeyGenParameterSpec.Builder }
every {
anyConstructed<KeyGenParameterSpec.Builder>().build()
} returns mockKeyGenParameterSpec
every {
anyConstructed<KeyGenParameterSpec.Builder>().setKeySize(any())
} answers { self as KeyGenParameterSpec.Builder }
}
}
private const val APPLICATION_ID: String = "com.mock.app"
private const val ALIAS: String = "mockAlias"
private const val NAMESPACED_ALIAS: String = "$APPLICATION_ID.$ALIAS"
@@ -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() }