mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 07:05:35 +03:00
BITAU-98 Add EncryptionUtils helper functions to the bridge SDK (#3888)
This commit is contained in:
parent
4c1d55e9fe
commit
d1f21d3585
4 changed files with 399 additions and 2 deletions
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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>,
|
||||
) {
|
||||
|
|
|
@ -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,
|
||||
)
|
|
@ -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"
|
Loading…
Reference in a new issue