diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 9f053acb17..fdf05ef9e5 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -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) diff --git a/core/src/main/kotlin/com/bitwarden/core/data/manager/di/CoreManagerModule.kt b/core/src/main/kotlin/com/bitwarden/core/data/manager/di/CoreManagerModule.kt new file mode 100644 index 0000000000..0e83022650 --- /dev/null +++ b/core/src/main/kotlin/com/bitwarden/core/data/manager/di/CoreManagerModule.kt @@ -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, + ) +} diff --git a/core/src/main/kotlin/com/bitwarden/core/data/manager/encryption/EncryptionManager.kt b/core/src/main/kotlin/com/bitwarden/core/data/manager/encryption/EncryptionManager.kt new file mode 100644 index 0000000000..b2fe3f6095 --- /dev/null +++ b/core/src/main/kotlin/com/bitwarden/core/data/manager/encryption/EncryptionManager.kt @@ -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 + + /** + * Encrypts the string with the Secret associated with the [alias]. + */ + fun encrypt(alias: String, bytes: ByteArray): Result +} diff --git a/core/src/main/kotlin/com/bitwarden/core/data/manager/encryption/EncryptionManagerImpl.kt b/core/src/main/kotlin/com/bitwarden/core/data/manager/encryption/EncryptionManagerImpl.kt new file mode 100644 index 0000000000..d87c0ed663 --- /dev/null +++ b/core/src/main/kotlin/com/bitwarden/core/data/manager/encryption/EncryptionManagerImpl.kt @@ -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 = 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 = 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) + } + } diff --git a/core/src/main/kotlin/com/bitwarden/core/data/manager/encryption/KeystoreManager.kt b/core/src/main/kotlin/com/bitwarden/core/data/manager/encryption/KeystoreManager.kt new file mode 100644 index 0000000000..107ae27967 --- /dev/null +++ b/core/src/main/kotlin/com/bitwarden/core/data/manager/encryption/KeystoreManager.kt @@ -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 + + /** + * 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 + + /** + * 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 +} diff --git a/core/src/main/kotlin/com/bitwarden/core/data/manager/encryption/KeystoreManagerImpl.kt b/core/src/main/kotlin/com/bitwarden/core/data/manager/encryption/KeystoreManagerImpl.kt new file mode 100644 index 0000000000..8a1f634de7 --- /dev/null +++ b/core/src/main/kotlin/com/bitwarden/core/data/manager/encryption/KeystoreManagerImpl.kt @@ -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 = 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 = + 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 { + 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" +} diff --git a/core/src/test/kotlin/com/bitwarden/core/data/manager/encryption/EncryptionManagerTest.kt b/core/src/test/kotlin/com/bitwarden/core/data/manager/encryption/EncryptionManagerTest.kt new file mode 100644 index 0000000000..d0a44c6eb5 --- /dev/null +++ b/core/src/test/kotlin/com/bitwarden/core/data/manager/encryption/EncryptionManagerTest.kt @@ -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() + private val mockKeystoreManager = mockk { + 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 { + every { init(Cipher.ENCRYPT_MODE, mockSecretKey) } just runs + every { init(Cipher.DECRYPT_MODE, mockSecretKey, any()) } just runs + every { iv } returns IV + every { doFinal(any()) } answers { firstArg().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 { 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() + +// 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(12) { (0x80 + it).toByte() } diff --git a/core/src/test/kotlin/com/bitwarden/core/data/manager/encryption/KeystoreManagerTest.kt b/core/src/test/kotlin/com/bitwarden/core/data/manager/encryption/KeystoreManagerTest.kt new file mode 100644 index 0000000000..5e75201ac6 --- /dev/null +++ b/core/src/test/kotlin/com/bitwarden/core/data/manager/encryption/KeystoreManagerTest.kt @@ -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(name = "MockAndroidKeyStore") + private val mockKeyGenerator = mockk() + private val mockKeyGenParameterSpec = mockk() + private val mockBuildInfoManager = mockk { + 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() + 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() + every { mockAndroidKeyStore.getKey(NAMESPACED_ALIAS, null) } returns mockSecretKey + + assertEquals(mockSecretKey.asSuccess(), keystoreManager.getOrCreateKey(alias = ALIAS)) + verify(exactly = 0) { + KeyGenerator.getInstance(any(), any()) + } + } + + @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(), any()) + } + } + + @Test + fun `getOrCreateKey should generate and return a new AES key when none exists`() { + val mockSecretKey = mockk() + 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() + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + anyConstructed() + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + anyConstructed().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().setBlockModes(any()) + } answers { self as KeyGenParameterSpec.Builder } + every { + anyConstructed().setEncryptionPaddings(any()) + } answers { self as KeyGenParameterSpec.Builder } + every { + anyConstructed().setDigests(any()) + } answers { self as KeyGenParameterSpec.Builder } + every { + anyConstructed().build() + } returns mockKeyGenParameterSpec + every { + anyConstructed().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" diff --git a/data/src/main/kotlin/com/bitwarden/data/datasource/disk/KeystoreEncryptedSharedPreferences.kt b/data/src/main/kotlin/com/bitwarden/data/datasource/disk/KeystoreEncryptedSharedPreferences.kt new file mode 100644 index 0000000000..caf5158ded --- /dev/null +++ b/data/src/main/kotlin/com/bitwarden/data/datasource/disk/KeystoreEncryptedSharedPreferences.kt @@ -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 = 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 = 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?): Set? { + 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?): 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", + ) +} diff --git a/data/src/main/kotlin/com/bitwarden/data/datasource/disk/di/KeystoreEncryptedPreferences.kt b/data/src/main/kotlin/com/bitwarden/data/datasource/disk/di/KeystoreEncryptedPreferences.kt new file mode 100644 index 0000000000..c8754f9306 --- /dev/null +++ b/data/src/main/kotlin/com/bitwarden/data/datasource/disk/di/KeystoreEncryptedPreferences.kt @@ -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 diff --git a/data/src/main/kotlin/com/bitwarden/data/datasource/disk/di/PreferenceModule.kt b/data/src/main/kotlin/com/bitwarden/data/datasource/disk/di/PreferenceModule.kt index cbb559714b..8207158158 100644 --- a/data/src/main/kotlin/com/bitwarden/data/datasource/disk/di/PreferenceModule.kt +++ b/data/src/main/kotlin/com/bitwarden/data/datasource/disk/di/PreferenceModule.kt @@ -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 diff --git a/data/src/test/kotlin/com/bitwarden/data/datasource/disk/KeystoreEncryptedSharedPreferencesTest.kt b/data/src/test/kotlin/com/bitwarden/data/datasource/disk/KeystoreEncryptedSharedPreferencesTest.kt new file mode 100644 index 0000000000..6f3d4b7497 --- /dev/null +++ b/data/src/test/kotlin/com/bitwarden/data/datasource/disk/KeystoreEncryptedSharedPreferencesTest.kt @@ -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 { + 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 { + every { encrypt(alias = ALIAS, bytes = any()) } answers { + (IV + secondArg().toggleBits()).asSuccess() + } + every { decrypt(alias = ALIAS, bytes = any()) } answers { + secondArg() + .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 { + keystoreEncryptedSharedPreferences.getBoolean(KEY, false) + } + assertThrows { + keystoreEncryptedSharedPreferences.getInt(KEY, 0) + } + assertThrows { + keystoreEncryptedSharedPreferences.getLong(KEY, 0L) + } + assertThrows { + keystoreEncryptedSharedPreferences.getFloat(KEY, 0f) + } + assertThrows { + keystoreEncryptedSharedPreferences.getStringSet(KEY, null) + } + } + + @Test + fun `unsupported put operations should throw UnsupportedOperationException`() { + val editor = keystoreEncryptedSharedPreferences.edit() + assertThrows { editor.putBoolean(KEY, false) } + assertThrows { editor.putInt(KEY, 0) } + assertThrows { editor.putLong(KEY, 0L) } + assertThrows { editor.putFloat(KEY, 0f) } + assertThrows { 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() }