-
Notifications
You must be signed in to change notification settings - Fork 839
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
BITAU-98 Add EncryptionUtils helper functions to the bridge SDK
- Loading branch information
1 parent
647b3e9
commit d985e90
Showing
4 changed files
with
382 additions
and
1 deletion.
There are no files selected for viewing
2 changes: 2 additions & 0 deletions
2
bridge/src/main/java/com/bitwarden/bridge/model/SharedAccountData.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
201 changes: 201 additions & 0 deletions
201
bridge/src/main/java/com/bitwarden/bridge/util/EncryptionUtils.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
178
bridge/src/test/java/com/bitwarden/bridge/util/EncryptionUtilTest.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
) | ||
} | ||
|
||
} |