mirror of
https://github.com/bitwarden/android.git
synced 2026-08-01 03:05:47 -05:00
PM-40727: feat: Add encryption layer for new KeystoreEncryptedSharedPreferences (#7191)
This commit is contained in:
@@ -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>
|
||||
}
|
||||
+79
@@ -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
|
||||
}
|
||||
+121
@@ -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"
|
||||
}
|
||||
+151
@@ -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()
|
||||
|
||||
// 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() }
|
||||
+262
@@ -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"
|
||||
+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