BITAU-98 Add EncryptionUtils helper functions to the bridge SDK (#3888)

This commit is contained in:
Andrew Haisting 2024-09-12 15:02:01 -05:00 committed by GitHub
parent 4c1d55e9fe
commit d1f21d3585
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 399 additions and 2 deletions

View file

@ -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,
)

View file

@ -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<AccountJson>,
) {

View file

@ -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<SecretKey> = 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<ByteArray> = 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<EncryptedSharedAccountData> = 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<SharedAccountData> = 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<SharedAccountDataJson>(
cipher.doFinal(this.encryptedAccountsJson.byteArray).decodeToString()
)
decryptedModel.toDomainModel()
}
/**
* Encrypt [AddTotpLoginItemData].
*
* @param symmetricEncryptionKeyData Symmetric key used for encryption.
*/
internal fun AddTotpLoginItemData.encrypt(
symmetricEncryptionKeyData: SymmetricEncryptionKeyData,
): Result<EncryptedAddTotpLoginItemData> = 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<AddTotpLoginItemData> = 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<AddTotpLoginItemDataJson>(
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,
)

View file

@ -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"