From d1f21d35850c5e692e6aa73a5748af92ae8b3df5 Mon Sep 17 00:00:00 2001 From: Andrew Haisting <142518658+ahaisting-livefront@users.noreply.github.com> Date: Thu, 12 Sep 2024 15:02:01 -0500 Subject: [PATCH] BITAU-98 Add EncryptionUtils helper functions to the bridge SDK (#3888) --- .../bridge/model/AddTotpLoginItemDataJson.kt | 2 +- .../bridge/model/SharedAccountDataJson.kt | 2 +- .../bitwarden/bridge/util/EncryptionUtils.kt | 204 ++++++++++++++++++ .../bridge/util/EncryptionUtilTest.kt | 193 +++++++++++++++++ 4 files changed, 399 insertions(+), 2 deletions(-) create mode 100644 bridge/src/main/java/com/bitwarden/bridge/util/EncryptionUtils.kt create mode 100644 bridge/src/test/java/com/bitwarden/bridge/util/EncryptionUtilTest.kt diff --git a/bridge/src/main/java/com/bitwarden/bridge/model/AddTotpLoginItemDataJson.kt b/bridge/src/main/java/com/bitwarden/bridge/model/AddTotpLoginItemDataJson.kt index 76eeee524..006a0c5d1 100644 --- a/bridge/src/main/java/com/bitwarden/bridge/model/AddTotpLoginItemDataJson.kt +++ b/bridge/src/main/java/com/bitwarden/bridge/model/AddTotpLoginItemDataJson.kt @@ -10,7 +10,7 @@ import kotlinx.serialization.Serializable * @param totpUri A TOTP code URI to be added to the Bitwarden app. */ @Serializable -data class AddTotpLoginItemDataJson( +internal data class AddTotpLoginItemDataJson( @SerialName("totpUri") val totpUri: String, ) diff --git a/bridge/src/main/java/com/bitwarden/bridge/model/SharedAccountDataJson.kt b/bridge/src/main/java/com/bitwarden/bridge/model/SharedAccountDataJson.kt index bcb2a29f2..a85601c33 100644 --- a/bridge/src/main/java/com/bitwarden/bridge/model/SharedAccountDataJson.kt +++ b/bridge/src/main/java/com/bitwarden/bridge/model/SharedAccountDataJson.kt @@ -13,7 +13,7 @@ import java.time.Instant * @param accounts The list of shared accounts. */ @Serializable -data class SharedAccountDataJson( +internal data class SharedAccountDataJson( @SerialName("accounts") val accounts: List, ) { diff --git a/bridge/src/main/java/com/bitwarden/bridge/util/EncryptionUtils.kt b/bridge/src/main/java/com/bitwarden/bridge/util/EncryptionUtils.kt new file mode 100644 index 000000000..707b58fd5 --- /dev/null +++ b/bridge/src/main/java/com/bitwarden/bridge/util/EncryptionUtils.kt @@ -0,0 +1,204 @@ +package com.bitwarden.bridge.util + +import android.security.keystore.KeyProperties +import com.bitwarden.bridge.IBridgeService +import com.bitwarden.bridge.model.AddTotpLoginItemData +import com.bitwarden.bridge.model.AddTotpLoginItemDataJson +import com.bitwarden.bridge.model.EncryptedAddTotpLoginItemData +import com.bitwarden.bridge.model.EncryptedSharedAccountData +import com.bitwarden.bridge.model.SharedAccountData +import com.bitwarden.bridge.model.SharedAccountDataJson +import com.bitwarden.bridge.model.SymmetricEncryptionKeyData +import com.bitwarden.bridge.model.toByteArrayContainer +import kotlinx.serialization.encodeToString +import java.security.MessageDigest +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +/** + * Generate a symmetric [SecretKey] that will used for encrypting IPC traffic. + * + * This is intended to be used for implementing [IBridgeService.getSymmetricEncryptionKeyData]. + */ +fun generateSecretKey(): Result = runCatching { + val keygen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES) + keygen.init(256) + keygen.generateKey() +} + +/** + * Generate a fingerprint for the given symmetric key. + * + * This is intended to be used for implementing + * [IBridgeService.checkSymmetricEncryptionKeyFingerprint], which allows callers of the service + * to verify that they have the correct symmetric key without actually having to send the key. + */ +fun SymmetricEncryptionKeyData.toFingerprint(): Result = runCatching { + val messageDigest = MessageDigest.getInstance(KeyProperties.DIGEST_SHA256) + messageDigest.reset() + messageDigest.update(this.symmetricEncryptionKey.byteArray) + messageDigest.digest() +} + +/** + * Encrypt [SharedAccountData]. + * + * This is intended to be used by the main Bitwarden app during a [IBridgeService.syncAccounts] call. + * + * @param symmetricEncryptionKeyData Symmetric key used for encryption. + */ +fun SharedAccountData.encrypt( + symmetricEncryptionKeyData: SymmetricEncryptionKeyData, +): Result = runCatching { + val encodedKey = symmetricEncryptionKeyData.symmetricEncryptionKey.byteArray + val key = encodedKey.toSecretKey() + val cipher = generateCipher() + cipher.init(Cipher.ENCRYPT_MODE, key) + val jsonString = JSON.encodeToString(this.toJsonModel()) + val encryptedJsonString = cipher.doFinal(jsonString.encodeToByteArray()).toByteArrayContainer() + + EncryptedSharedAccountData( + initializationVector = cipher.iv.toByteArrayContainer(), + encryptedAccountsJson = encryptedJsonString, + ) +} + +/** + * Decrypt [EncryptedSharedAccountData]. + * + * @param symmetricEncryptionKeyData Symmetric key used for decryption. + */ +internal fun EncryptedSharedAccountData.decrypt( + symmetricEncryptionKeyData: SymmetricEncryptionKeyData, +): Result = runCatching { + val encodedKey = symmetricEncryptionKeyData + .symmetricEncryptionKey + .byteArray + val key = encodedKey.toSecretKey() + + val iv = IvParameterSpec(this.initializationVector.byteArray) + val cipher = generateCipher() + cipher.init(Cipher.DECRYPT_MODE, key, iv) + val decryptedModel = JSON.decodeFromString( + cipher.doFinal(this.encryptedAccountsJson.byteArray).decodeToString() + ) + decryptedModel.toDomainModel() +} + +/** + * Encrypt [AddTotpLoginItemData]. + * + * @param symmetricEncryptionKeyData Symmetric key used for encryption. + */ +internal fun AddTotpLoginItemData.encrypt( + symmetricEncryptionKeyData: SymmetricEncryptionKeyData, +): Result = runCatching { + val encodedKey = symmetricEncryptionKeyData.symmetricEncryptionKey.byteArray + val key = encodedKey.toSecretKey() + val cipher = generateCipher() + cipher.init(Cipher.ENCRYPT_MODE, key) + val encryptedJsonString = + cipher.doFinal(JSON.encodeToString(this.toJsonModel()).encodeToByteArray()) + + EncryptedAddTotpLoginItemData( + initializationVector = cipher.iv.toByteArrayContainer(), + encryptedTotpUriJson = encryptedJsonString.toByteArrayContainer(), + ) +} + +/** + * Decrypt [EncryptedSharedAccountData]. + * + * @param symmetricEncryptionKeyData Symmetric key used for decryption. + */ +internal fun EncryptedAddTotpLoginItemData.decrypt( + symmetricEncryptionKeyData: SymmetricEncryptionKeyData, +): Result = runCatching { + val encodedKey = symmetricEncryptionKeyData + .symmetricEncryptionKey + .byteArray + val key = encodedKey.toSecretKey() + + val iv = IvParameterSpec(this.initializationVector.byteArray) + val cipher = generateCipher() + cipher.init(Cipher.DECRYPT_MODE, key, iv) + val decryptedModel = JSON.decodeFromString( + cipher.doFinal(this.encryptedTotpUriJson.byteArray).decodeToString() + ) + decryptedModel.toDomainModel() +} + +/** + * Helper function for converting a [ByteArray] to a type safe [SymmetricEncryptionKeyData]. + * + * This is useful since callers may be storing encryption key data as a [ByteArray] under the hood + * and must convert to a [SymmetricEncryptionKeyData] to use the SDK's encryption APIs. + */ +fun ByteArray.toSymmetricEncryptionKeyData(): SymmetricEncryptionKeyData = + SymmetricEncryptionKeyData(toByteArrayContainer()) + +/** + * Convert the given [ByteArray] to a [SecretKey]. + */ +private fun ByteArray.toSecretKey(): SecretKey = + SecretKeySpec(this, 0, this.size, KeyProperties.KEY_ALGORITHM_AES) + +/** + * Helper function for generating a [Cipher] that can be used for encrypting/decrypting using + * [SymmetricEncryptionKeyData]. + */ +private fun generateCipher(): Cipher = + Cipher.getInstance( + KeyProperties.KEY_ALGORITHM_AES + "/" + + KeyProperties.BLOCK_MODE_CBC + "/" + + "PKCS5PADDING" + ) + +/** + * Helper function for converting [SharedAccountData] to a serializable [SharedAccountDataJson]. + */ +private fun SharedAccountData.toJsonModel() = SharedAccountDataJson( + accounts = this.accounts.map { account -> + SharedAccountDataJson.AccountJson( + userId = account.userId, + name = account.name, + environmentLabel = account.environmentLabel, + email = account.email, + totpUris = account.totpUris, + lastSyncTime = account.lastSyncTime + ) + } +) + +/** + * Helper function for converting [SharedAccountDataJson] to a [SharedAccountData]. + */ +private fun SharedAccountDataJson.toDomainModel() = SharedAccountData( + accounts = this.accounts.map { account -> + SharedAccountData.Account( + userId = account.userId, + name = account.name, + environmentLabel = account.environmentLabel, + email = account.email, + totpUris = account.totpUris, + lastSyncTime = account.lastSyncTime + ) + } +) + +/** + * Helper function for converting [AddTotpLoginItemDataJson] to a [AddTotpLoginItemData]. + */ +private fun AddTotpLoginItemDataJson.toDomainModel() = AddTotpLoginItemData( + totpUri = totpUri, +) + +/** + * Helper function for converting [AddTotpLoginItemData] to a serializable [AddTotpLoginItemDataJson]. + */ +private fun AddTotpLoginItemData.toJsonModel() = AddTotpLoginItemDataJson( + totpUri = totpUri, +) diff --git a/bridge/src/test/java/com/bitwarden/bridge/util/EncryptionUtilTest.kt b/bridge/src/test/java/com/bitwarden/bridge/util/EncryptionUtilTest.kt new file mode 100644 index 000000000..6419f3a8d --- /dev/null +++ b/bridge/src/test/java/com/bitwarden/bridge/util/EncryptionUtilTest.kt @@ -0,0 +1,193 @@ +package com.bitwarden.bridge.util + +import android.security.keystore.KeyProperties +import com.bitwarden.bridge.model.AddTotpLoginItemData +import com.bitwarden.bridge.model.SharedAccountData +import com.bitwarden.bridge.model.SymmetricEncryptionKeyData +import com.bitwarden.bridge.model.toByteArrayContainer +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException +import java.time.Instant +import javax.crypto.Cipher +import javax.crypto.KeyGenerator + +class EncryptionUtilTest { + + @Test + fun `generateSecretKey should return success when there are no internal exceptions`() { + val secretKey = generateSecretKey() + assertTrue(secretKey.isSuccess) + assertNotNull(secretKey.getOrNull()) + } + + @Test + fun `generateSecretKey should return failure when KeyGenerator getInstance throws`() { + mockkStatic(KeyGenerator::class) + every { KeyGenerator.getInstance("AES") } throws NoSuchAlgorithmException() + val secretKey = generateSecretKey() + assertTrue(secretKey.isFailure) + unmockkStatic(KeyGenerator::class) + } + + @Test + fun `toFingerprint should return success when there are no internal exceptions`() { + val keyData = SymmetricEncryptionKeyData( + symmetricEncryptionKey = generateSecretKey().getOrThrow().encoded.toByteArrayContainer() + ) + val result = keyData.toFingerprint() + assertTrue(result.isSuccess) + } + + @Test + fun `toFingerprint should return failure when MessageDigest getInstance fails`() { + mockkStatic(MessageDigest::class) + every { MessageDigest.getInstance("SHA-256") } throws NoSuchAlgorithmException() + val keyData = SymmetricEncryptionKeyData( + symmetricEncryptionKey = generateSecretKey().getOrThrow().encoded.toByteArrayContainer() + ) + val result = keyData.toFingerprint() + assertTrue(result.isFailure) + unmockkStatic(MessageDigest::class) + } + + @Test + fun `encrypt SharedAccountData should return success when there are no internal exceptions`() { + val result = SHARED_ACCOUNT_DATA.encrypt(SYMMETRIC_KEY) + assertTrue(result.isSuccess) + } + + @Test + fun `encrypt SharedAccountData should return failure when generateCipher fails`() { + mockkStatic(Cipher::class) + every { + Cipher.getInstance(CIPHER_TRANSFORMATION) + } throws NoSuchAlgorithmException() + val result = SHARED_ACCOUNT_DATA.encrypt(SYMMETRIC_KEY) + assertTrue(result.isFailure) + unmockkStatic(Cipher::class) + } + + @Test + @Suppress("MaxLineLength") + fun `decrypt EncryptedSharedAccountData should return success when there are no internal exceptions`() { + val result = ENCRYPTED_SHARED_ACCOUNT_DATA.decrypt(SYMMETRIC_KEY) + assertTrue(result.isSuccess) + assertEquals(SHARED_ACCOUNT_DATA, result.getOrThrow()) + } + + @Test + fun `decrypt EncryptedSharedAccountData should return failure when generateCipher fails`() { + mockkStatic(Cipher::class) + every { + Cipher.getInstance(CIPHER_TRANSFORMATION) + } throws NoSuchAlgorithmException() + val result = ENCRYPTED_SHARED_ACCOUNT_DATA.decrypt(SYMMETRIC_KEY) + assertTrue(result.isFailure) + unmockkStatic(Cipher::class) + } + + @Test + fun `encrypting and decrypting SharedAccountData should leave the data untransformed`() { + val result = SHARED_ACCOUNT_DATA + .encrypt(SYMMETRIC_KEY) + .getOrThrow() + .decrypt(SYMMETRIC_KEY) + assertEquals( + SHARED_ACCOUNT_DATA, + result.getOrThrow() + ) + } + + @Test + @Suppress("MaxLineLength") + fun `encrypt AddTotpLoginItemData should return success wwhen there are no internal exceptions`() { + val result = ADD_TOTP_ITEM.encrypt(SYMMETRIC_KEY) + assertTrue(result.isSuccess) + } + + @Test + fun `encrypt AddTotpLoginItemData should return failure when generateCipher fails`() { + mockkStatic(Cipher::class) + every { + Cipher.getInstance(CIPHER_TRANSFORMATION) + } throws NoSuchAlgorithmException() + val result = ADD_TOTP_ITEM.encrypt(SYMMETRIC_KEY) + assertTrue(result.isFailure) + unmockkStatic(Cipher::class) + } + + @Test + @Suppress("MaxLIneLength") + fun `decrypt EncryptedAddTotpLoginItemData should return success when there are no internal exceptions`() { + val result = ENCRYPTED_ADD_TOTP_ITEM.decrypt(SYMMETRIC_KEY) + assertTrue(result.isSuccess) + } + + @Test + fun `decrypt EncryptedAddTotpLoginItemData should return failure when generateCipher fails`() { + mockkStatic(Cipher::class) + every { + Cipher.getInstance(CIPHER_TRANSFORMATION) + } throws NoSuchAlgorithmException() + val result = ENCRYPTED_ADD_TOTP_ITEM.decrypt(SYMMETRIC_KEY) + assertTrue(result.isFailure) + unmockkStatic(Cipher::class) + } + + @Test + fun `encrypting and decrypting AddTotpLoginItemData should leave the data untransformed`() { + val result = ADD_TOTP_ITEM + .encrypt(SYMMETRIC_KEY) + .getOrThrow() + .decrypt(SYMMETRIC_KEY) + assertEquals( + ADD_TOTP_ITEM, + result.getOrThrow() + ) + } + + @Test + fun `toSymmetricEncryptionKeyData should wrap the given ByteArray`() { + val sourceArray = generateSecretKey().getOrThrow().encoded + val wrappedArray = sourceArray.toSymmetricEncryptionKeyData() + assertTrue(sourceArray.contentEquals(wrappedArray.symmetricEncryptionKey.byteArray)) + } +} + +private val SHARED_ACCOUNT_DATA = SharedAccountData( + accounts = listOf( + SharedAccountData.Account( + userId = "userId", + name = "Johnny Appleseed", + email = "johnyapples@test.com", + environmentLabel = "bitwarden.com", + totpUris = listOf("test.com"), + lastSyncTime = Instant.parse("2024-09-10T10:15:30.00Z") + ) + ) +) + +private val ADD_TOTP_ITEM = AddTotpLoginItemData( + totpUri = "test.com" +) + +private val SYMMETRIC_KEY = SymmetricEncryptionKeyData( + symmetricEncryptionKey = generateSecretKey().getOrThrow().encoded.toByteArrayContainer() +) + +private val ENCRYPTED_SHARED_ACCOUNT_DATA = + SHARED_ACCOUNT_DATA.encrypt(SYMMETRIC_KEY).getOrThrow() + +private val ENCRYPTED_ADD_TOTP_ITEM = ADD_TOTP_ITEM.encrypt(SYMMETRIC_KEY).getOrThrow() + +private const val CIPHER_TRANSFORMATION = KeyProperties.KEY_ALGORITHM_AES + "/" + + KeyProperties.BLOCK_MODE_CBC + "/" + + "PKCS5PADDING"