Skip to content

Commit

Permalink
BITAU-98 Add EncryptionUtils helper functions to the bridge SDK
Browse files Browse the repository at this point in the history
  • Loading branch information
ahaisting-livefront committed Sep 10, 2024
1 parent 647b3e9 commit d985e90
Show file tree
Hide file tree
Showing 4 changed files with 382 additions and 1 deletion.
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.bitwarden.bridge.model

import kotlinx.serialization.Contextual
import kotlinx.serialization.Serializable
import java.time.Instant

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import java.time.Instant
* @param accounts The list of shared accounts.
*/
@Serializable
data class SharedAccountDataJson(
internal class SharedAccountDataJson(
@SerialName("accounts")
val accounts: List<AccountJson>,
) {
Expand Down
201 changes: 201 additions & 0 deletions bridge/src/main/java/com/bitwarden/bridge/util/EncryptionUtils.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
package com.bitwarden.bridge.util

import com.bitwarden.bridge.IBridgeService
import com.bitwarden.bridge.IBridgeServiceCallback
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 java.security.SecureRandom
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("AES")
keygen.init(256, SecureRandom())
return@runCatching 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("SHA-256")
messageDigest.reset()
messageDigest.update(this.symmetricEncryptionKey.byteArray)
return@runCatching messageDigest.digest()
}

/**
* Encrypt [SharedAccountData].
*
* @param symmetricEncryptionKeyData Symmetric key used for encryption.
*
* This is intended to be used by the main Bitwarden app during a [IBridgeService.syncAccounts] call.
*/
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()

return@runCatching EncryptedSharedAccountData(
initializationVector = cipher.iv.toByteArrayContainer(),
encryptedAccountsJson = encryptedJsonString,
)
}

/**
* Decrypt [EncryptedSharedAccountData].
*
* @param symmetricEncryptionKeyData Symmetric key used for decryption.
*
* This is intended to be used by the Bitwarden Authenticator app after receiving an update via
* [IBridgeServiceCallback].
*/
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()
)
return@runCatching decryptedModel.toDomainModel()
}

/**
* Encrypt [AddTotpLoginItemData].
*
* @param symmetricEncryptionKeyData Symmetric key used for encryption.
*
* This is intended to be used by the Bitwarden Authenticator app before requesting a new TOTP
* item be added to the main Bitwarden app.
*/
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())

return@runCatching EncryptedAddTotpLoginItemData(
initializationVector = cipher.iv.toByteArrayContainer(),
encryptedTotpUriJson = encryptedJsonString.toByteArrayContainer(),
)
}

/**
* Decrypt [EncryptedSharedAccountData].
*
* @param symmetricEncryptionKeyData Symmetric key used for decryption.
*
* This is intended to be used by the main Bitwarden app after receiving a request to add a new
* TOTP item.
*/
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()
)
return@runCatching decryptedModel.toDomainModel()
}

/**
* Convert the given [ByteArray] to a [SecretKey].
*/
private fun ByteArray.toSecretKey(): SecretKey =
SecretKeySpec(this, 0, this.size, "AES")

/**
* Helper function for generating a [Cipher] that can be used for encrypting/decrypting using
* [SymmetricEncryptionKeyData].
*/
private fun generateCipher(): Cipher =
Cipher.getInstance("AES/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,
)
178 changes: 178 additions & 0 deletions bridge/src/test/java/com/bitwarden/bridge/util/EncryptionUtilTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
package com.bitwarden.bridge.util

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.Test
import java.security.MessageDigest
import java.security.NoSuchAlgorithmException
import java.time.Instant
import javax.crypto.Cipher
import javax.crypto.KeyGenerator

class EncryptionUtilTest {

private val TEST_SHARED_ACCOUNT_DATA = SharedAccountData(
accounts = listOf(
SharedAccountData.Account(
userId = "userId",
name = "Johnny Appleseed",
email = "[email protected]",
environmentLabel = "bitwarden.com",
totpUris = listOf("test.com"),
lastSyncTime = Instant.parse("2024-09-10T10:15:30.00Z")
)
)
)

private val TEST_ADD_TOTP_ITEM = AddTotpLoginItemData(
totpUri = "test.com"
)

private val TEST_SYMMETRIC_KEY = SymmetricEncryptionKeyData(
symmetricEncryptionKey = generateSecretKey().getOrThrow().encoded.toByteArrayContainer()
)

private val TEST_ENCRYPTED_SHARED_ACCOUNT_DATA =
TEST_SHARED_ACCOUNT_DATA.encrypt(TEST_SYMMETRIC_KEY).getOrThrow()

private val TEST_ENCRYPTED_ADD_TOTP_ITEM = TEST_ADD_TOTP_ITEM.encrypt(TEST_SYMMETRIC_KEY).getOrThrow()

@Test
fun `generateSecretKey should return success`() {
val secretKey = generateSecretKey()
assert(secretKey.isSuccess)
assertNotNull(secretKey.getOrNull())
}

@Test
fun `when KeyGenerator getInstance throws, generateSecretKey should return failure`() {
mockkStatic(KeyGenerator::class)
every { KeyGenerator.getInstance("AES") } throws NoSuchAlgorithmException()
val secretKey = generateSecretKey()
assert(secretKey.isFailure)
unmockkStatic(KeyGenerator::class)
}

@Test
fun `toFingerprint should return success`() {
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`() {
val result = TEST_SHARED_ACCOUNT_DATA.encrypt(TEST_SYMMETRIC_KEY)
assertTrue(result.isSuccess)
}

@Test
fun `encrypt SharedAccountData should return failure when generateCipher fails`() {
mockkStatic(Cipher::class)
every {
Cipher.getInstance("AES/CBC/PKCS5PADDING")
} throws NoSuchAlgorithmException()
val result = TEST_SHARED_ACCOUNT_DATA.encrypt(TEST_SYMMETRIC_KEY)
assertTrue(result.isFailure)
unmockkStatic(Cipher::class)
}

@Test
fun `decrypt EncryptedSharedAccountData should return success`() {
val result = TEST_ENCRYPTED_SHARED_ACCOUNT_DATA.decrypt(TEST_SYMMETRIC_KEY)
assertTrue(result.isSuccess)
assertEquals(TEST_SHARED_ACCOUNT_DATA, result.getOrThrow())
}

@Test
fun `decrypt EncryptedSharedAccountData should return failure when generateCipher fails`() {
mockkStatic(Cipher::class)
every {
Cipher.getInstance("AES/CBC/PKCS5PADDING")
} throws NoSuchAlgorithmException()
val result = TEST_ENCRYPTED_SHARED_ACCOUNT_DATA.decrypt(TEST_SYMMETRIC_KEY)
assertTrue(result.isFailure)
unmockkStatic(Cipher::class)
}

@Test
fun `encrypting and decrypting SharedAccountData should leave the data untransformed`() {
val result = TEST_SHARED_ACCOUNT_DATA
.encrypt(TEST_SYMMETRIC_KEY)
.getOrThrow()
.decrypt(TEST_SYMMETRIC_KEY)
assertEquals(
TEST_SHARED_ACCOUNT_DATA,
result.getOrThrow()
)
}

@Test
fun `encrypt AddTotpLoginItemData should return success`() {
val result = TEST_ADD_TOTP_ITEM.encrypt(TEST_SYMMETRIC_KEY)
assertTrue(result.isSuccess)
}

@Test
fun `encrypt AddTotpLoginItemData should return failure when generateCipher fails`() {
mockkStatic(Cipher::class)
every {
Cipher.getInstance("AES/CBC/PKCS5PADDING")
} throws NoSuchAlgorithmException()
val result = TEST_ADD_TOTP_ITEM.encrypt(TEST_SYMMETRIC_KEY)
assertTrue(result.isFailure)
unmockkStatic(Cipher::class)
}

@Test
fun `decrypt EncryptedAddTotpLoginItemData should return success`() {
val result = TEST_ENCRYPTED_ADD_TOTP_ITEM.decrypt(TEST_SYMMETRIC_KEY)
assertTrue(result.isSuccess)
}

@Test
fun `decrypt EncryptedAddTotpLoginItemData should return failure when generateCipher fails`() {
mockkStatic(Cipher::class)
every {
Cipher.getInstance("AES/CBC/PKCS5PADDING")
} throws NoSuchAlgorithmException()
val result = TEST_ENCRYPTED_ADD_TOTP_ITEM.decrypt(TEST_SYMMETRIC_KEY)
assertTrue(result.isFailure)
unmockkStatic(Cipher::class)
}

@Test
fun `encrypting and decrypting AddTotpLoginItemData should leave the data untransformed`() {
val result = TEST_ADD_TOTP_ITEM
.encrypt(TEST_SYMMETRIC_KEY)
.getOrThrow()
.decrypt(TEST_SYMMETRIC_KEY)
assertEquals(
TEST_ADD_TOTP_ITEM,
result.getOrThrow()
)
}

}

0 comments on commit d985e90

Please sign in to comment.