BITAU-172 Rename Authenticator Bridge SDK (#3959)

This commit is contained in:
Andrew Haisting
2024-09-24 17:09:27 -05:00
committed by GitHub
parent 3d0dd2996e
commit 4f34f6da21
48 changed files with 212 additions and 207 deletions

1
authenticatorbridge/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

View File

@@ -0,0 +1,8 @@
v0.1.0 (pending)
--------
### API Changes
### Breaking Changes
### Bug Fixes

View File

@@ -0,0 +1,36 @@
# CHANGELOG Format
## Contents
* [Template](#template)
* [Sections](#sections)
## Template
Each new version of the SDK should be accompanied by a new `CHANGELOG` entry with the following template:
```
vX.Y.0 (pending)
--------
### API Changes
### Breaking Changes
### Bug Fixes
```
The `(pending)` label, as well as any unused sections, should be removed prior to creating a release.
## Sections
One or more `CHANGELOG` entries should be added for each PR that makes a change that either adds / updates the exposed API of the SDK or changes code internally in a way that is detectable as a behavior change by external consumers. These entries should fall into one of the following sections:
- **API Changes**: Any update to existing code that affects the exposed API or behavior of the SDK in a backwards-compatible way should be included in this section.
- **Breaking Changes**: Any code introduced in a **backwards-incompatible** manner that could in principle affect compilation of existing code should be included in this section. Examples of this include changing return types, changing function / constructor parameter lists, and changing function / class names. Each entry in this list should include detailed instructions where necessary for how a caller should address any related compilation issues.
- **Bug Fixes**: Any update that is performed in order to fix a known bug should be included in this section.
If an PR exhibits multiple kinds of changes then there should be multiple corresponding entries.

View File

@@ -0,0 +1,79 @@
# Authenticator Bridge SDK
## Contents
- [Compatibility](#compatibility)
- [Building](#building)
- [Versioning](#versioning)
- [Dependencies](#dependencies)
## Other Documents
- [Changelog](CHANGELOG.md)
- [Changelog Format Guide](CHANGELOG_FORMAT.MD)
## Compatibility
- **Minimum SDK**: 28
- **Target SDK**: 34
## Building
To build an AAR for inclusion in consumer applications, run:
```sh
$ ./gradlew authenticatorbridge:assembleRelease
```
## Versioning
This repository conforms to the following versioning convention:
**v[MAJOR].[MINOR].[PATCH]**
```
where [RELEASE] is incremented to represent major milestones that indicate a significant change in the library.
[MINOR] is incremented when any standard changes (breaking or otherwise) are introduced to the library.
[PATCH] is incremented when a hot-fix patch is required to an existing minor
release due to a bug introduced in that release.
```
Some things to note:
- All updates should have a corresponding `CHANGELOG.md` entry that at a high-level describes what is being newly introduced in it. For more info, see [Changelog Format Guide](CHANGELOG_FORMAT.MD)
- When incrementing a level any lower-levels should always reset to 0.
## Dependencies
### Application Dependencies
The following is a list of all third-party dependencies required by the SDK.
> [!IMPORTANT]
> The SDK does not come packaged with these dependencies, so consumers of the SDK must provide them.
- **kotlinx.serialization**
- https://github.com/Kotlin/kotlinx.serialization/
- Purpose: JSON serialization library for Kotlin.
- License: Apache 2.0
### Development Environment Dependencies
The following is a list of additional third-party dependencies used as part of the local development environment. This includes test-related artifacts as well as tools related to code quality and linting. These are not present in the final packaged SDK.
- **JUnit 5**
- https://github.com/junit-team/junit5
- Purpose: Unit Testing framework for testing SDK code.
- License: Eclipse Public License 2.0
- **MockK**
- https://github.com/mockk/mockk
- Purpose: Kotlin-friendly mocking library.
- License: Apache 2.0
- **Turbine**
- https://github.com/cashapp/turbine
- Purpose: A small testing library for kotlinx.coroutine's Flow.
- License: Apache 2.0

View File

@@ -0,0 +1,75 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
// For more info on versioning, see the README.
val version = "0.1.0"
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "com.bitwarden.authenticatorbridge"
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
// This min value is selected to accommodate known consumers
minSdk = 28
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
buildConfigField("String", "VERSION", "\"$version\"")
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility(libs.versions.jvmTarget.get())
targetCompatibility(libs.versions.jvmTarget.get())
}
buildFeatures {
buildConfig = true
aidl = true
}
// Add version name to the output .aar file:
libraryVariants.all {
val variant = this
outputs
.map { it as com.android.build.gradle.internal.api.BaseVariantOutputImpl }
.forEach { output ->
val outputFileName = "authenticatorbridge-${version}-SNAPSHOT-${variant.baseName}.aar"
output.outputFileName = outputFileName
}
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.fromTarget(libs.versions.jvmTarget.get()))
}
}
dependencies {
// SDK dependencies:
implementation(libs.kotlinx.serialization)
// Test environment dependencies:
testImplementation(libs.junit.junit5)
testImplementation(libs.mockk.mockk)
testImplementation(libs.square.turbine)
}
tasks {
withType<Test> {
useJUnitPlatform()
}
}

View File

21
authenticatorbridge/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

View File

@@ -0,0 +1,61 @@
package com.bitwarden.authenticatorbridge;
import com.bitwarden.authenticatorbridge.model.EncryptedAddTotpLoginItemData;
import com.bitwarden.authenticatorbridge.model.SymmetricEncryptionKeyData;
import com.bitwarden.authenticatorbridge.model.SymmetricEncryptionKeyFingerprintData;
import com.bitwarden.authenticatorbridge.IAuthenticatorBridgeServiceCallback;
interface IAuthenticatorBridgeService {
// ==============
// Configuration
// ==============
// Returns the version number string of the Authenticator Bridge SDK. This is useful so that
// callers can compare the version of their Authenticator Bridge SDK with this value and
// ensure that the two are compatible.
//
// For more info about versioning the Authenticator Bridge SDK, see the README.
String getVersionNumber();
// Returns true when the given symmetric fingerprint data matches that contained by the SDK.
boolean checkSymmetricEncryptionKeyFingerprint(in SymmetricEncryptionKeyFingerprintData symmetricKeyFingerprint);
// Returns a symmetric key that will be used for encypting all IPC traffic.
//
// Consumers should only call this function once to limit the number of times this key is
// sent via IPC. Additionally, once the key is shared, checkSymmetricEncryptionKeyFingerprint
// should be used to safely confirm that the key is valid.
@nullable SymmetricEncryptionKeyData getSymmetricEncryptionKeyData();
// ==============
// Registration
// ==============
// Register the given callback to receive updates after syncAccounts is called.
void registerBridgeServiceCallback(IAuthenticatorBridgeServiceCallback callback);
// Unregister the given callback from receiving updates.
void unregisterBridgeServiceCallback(IAuthenticatorBridgeServiceCallback callback);
// ==============
// Data Syncing
// ==============
// Sync available accounts. Callers should register a callback via
// registerBridgeServiceCallback before calling this function.
void syncAccounts();
// ==============
// Add TOTP Item
// ==============
// Returns an intent that can be launched to navigate the user to the add Totp item flow
// in the main password manager app.
Intent createAddTotpLoginItemIntent();
// Give the given TOTP item data to the main Bitwarden app before launching the add TOTP
// item flow. This should be called before launching the intent returned from
// createAddTotpLoginItemIntent().
void setPendingAddTotpLoginItemData(in EncryptedAddTotpLoginItemData data);
}

View File

@@ -0,0 +1,10 @@
package com.bitwarden.authenticatorbridge;
import com.bitwarden.authenticatorbridge.model.EncryptedSharedAccountData;
interface IAuthenticatorBridgeServiceCallback {
// This function will be called when there is updated shared account data.
void onAccountsSync(in EncryptedSharedAccountData data);
}

View File

@@ -0,0 +1,3 @@
package com.bitwarden.authenticatorbridge.model;
parcelable EncryptedAddTotpLoginItemData;

View File

@@ -0,0 +1,3 @@
package com.bitwarden.authenticatorbridge.model;
parcelable EncryptedSharedAccountData;

View File

@@ -0,0 +1,3 @@
package com.bitwarden.authenticatorbridge.model;
parcelable SymmetricEncryptionKeyData;

View File

@@ -0,0 +1,3 @@
package com.bitwarden.authenticatorbridge.model;
parcelable SymmetricEncryptionKeyFingerprintData;

View File

@@ -0,0 +1,10 @@
package com.bitwarden.authenticatorbridge.model
/**
* Domain level model for a TOTP item to be added to the Bitwarden app.
*
* @param totpUri A TOTP code URI to be added to the Bitwarden app.
*/
data class AddTotpLoginItemData(
val totpUri: String,
)

View File

@@ -0,0 +1,16 @@
package com.bitwarden.authenticatorbridge.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Serializable model for a TOTP item to be added to the Bitwarden app. For domain level
* model, see [AddTotpLoginItemData].
*
* @param totpUri A TOTP code URI to be added to the Bitwarden app.
*/
@Serializable
internal data class AddTotpLoginItemDataJson(
@SerialName("totpUri")
val totpUri: String,
)

View File

@@ -0,0 +1,34 @@
package com.bitwarden.authenticatorbridge.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
/**
* Wraps a [ByteArray] and implements [equals], [hashCode], and [Parcelable] so that it can more
* easily be included in [Parcelable] models.
*
* @param byteArray Wrapped byte array
*/
@Parcelize
data class ByteArrayContainer(
val byteArray: ByteArray,
) : Parcelable {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ByteArrayContainer
return byteArray.contentEquals(other.byteArray)
}
override fun hashCode(): Int {
return byteArray.contentHashCode()
}
}
/**
* Helper function for converting [ByteArray] to [ByteArrayContainer].
*/
fun ByteArray.toByteArrayContainer(): ByteArrayContainer =
ByteArrayContainer(this)

View File

@@ -0,0 +1,17 @@
package com.bitwarden.authenticatorbridge.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
/**
* Models an encrypted totp item to be added to the Bitwarden app.
*
* @param initializationVector Cryptographic initialization vector.
* @param encryptedTotpUriJson Encrypted JSON string containing TOTP URI info. See
* [AddTotpLoginItemDataJson] for the json structure of the string.
*/
@Parcelize
data class EncryptedAddTotpLoginItemData(
val initializationVector: ByteArrayContainer,
val encryptedTotpUriJson: ByteArrayContainer,
) : Parcelable

View File

@@ -0,0 +1,17 @@
package com.bitwarden.authenticatorbridge.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
/**
* Models data that will be sent to calling application via IPC.
*
* @param initializationVector Cryptographic initialization vector.
* @param encryptedAccountsJson Encrypted JSON blob containing shared account data. See
* [SharedAccountDataJson] For the serializable model contained in the blob.
*/
@Parcelize
data class EncryptedSharedAccountData(
val initializationVector: ByteArrayContainer,
val encryptedAccountsJson: ByteArrayContainer,
) : Parcelable

View File

@@ -0,0 +1,32 @@
package com.bitwarden.authenticatorbridge.model
import java.time.Instant
/**
* Domain level model representing shared account data.
*
* @param accounts The list of shared accounts.
*/
data class SharedAccountData(
val accounts: List<Account>,
) {
/**
* Models a single shared account.
*
* @param userId user ID tied to the account.
* @param name name associated with the account.
* @param email email associated with the account.
* @param environmentLabel environment associated with the account.
* @param totpUris list of totp URIs associated with the account.
* @param lastSyncTime the last time the account was synced by the main Bitwarden app.
*/
data class Account(
val userId: String,
val name: String?,
val email: String,
val environmentLabel: String,
val totpUris: List<String>,
val lastSyncTime: Instant,
)
}

View File

@@ -0,0 +1,54 @@
package com.bitwarden.authenticatorbridge.model
import kotlinx.serialization.Contextual
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.time.Instant
/**
* Models a serializable list of shared accounts to be shared with other applications.
*
* For domain level model, see [SharedAccountData].
*
* @param accounts The list of shared accounts.
*/
@Serializable
internal data class SharedAccountDataJson(
@SerialName("accounts")
val accounts: List<AccountJson>,
) {
/**
* Models a single shared account in a serializable format.
*
* @param userId user ID tied to the account.
* @param name name associated with the account.
* @param email email associated with the account.
* @param environmentLabel environment associated with the account.
* @param totpUris list of totp URIs associated with the account.
* @param lastSyncTime the last time the account was synced by the main Bitwarden app.
*/
@Serializable
data class AccountJson(
@SerialName("userId")
val userId: String,
@SerialName("name")
val name: String?,
@SerialName("email")
val email: String,
@SerialName("environmentLabel")
val environmentLabel: String,
@SerialName("totpUris")
val totpUris: List<String>,
@SerialName("lastSyncTime")
@Contextual
val lastSyncTime: Instant,
)
}

View File

@@ -0,0 +1,14 @@
package com.bitwarden.authenticatorbridge.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
/**
* Wrapper for a symmetric encryption key.
*
* @param symmetricEncryptionKey The symmetric encryption key.
*/
@Parcelize
data class SymmetricEncryptionKeyData(
val symmetricEncryptionKey: ByteArrayContainer,
) : Parcelable

View File

@@ -0,0 +1,15 @@
package com.bitwarden.authenticatorbridge.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
/**
* Wrapper for a symmetric encryption fingerprint (hash) that can be used
* to verify if callers have the correct symmetric key for encrypting/decrypting IPC data.
*
* @param symmetricEncryptionKeyFingerprint The fingerprint of the symmetric encryption key.
*/
@Parcelize
data class SymmetricEncryptionKeyFingerprintData(
val symmetricEncryptionKeyFingerprint: ByteArrayContainer,
) : Parcelable

View File

@@ -0,0 +1,52 @@
package com.bitwarden.authenticatorbridge.util
import com.bitwarden.authenticatorbridge.BuildConfig
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.contextual
import java.time.Instant
/**
* Version of the Authenticator Bridge SDK.
*/
const val AUTHENTICATOR_BRIDGE_SDK_VERSION = BuildConfig.VERSION
/**
* Common instance of [Json] that should be used throughout the SDK.
*/
internal val JSON = Json {
// If there are keys returned by the server not modeled by a serializable class,
// ignore them.
// This makes additive server changes non-breaking.
ignoreUnknownKeys = true
// We allow for nullable values to have keys missing in the JSON response.
explicitNulls = false
// Add serializer for Instant serialization.
serializersModule = SerializersModule {
contextual(InstantSerializer)
}
// Respect model default property values.
coerceInputValues = true
}
/**
* A simple serializer for serializing [Instant].
*/
private object InstantSerializer : KSerializer<Instant> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("java.time.Instant", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Instant) =
encoder.encodeString(value.toString())
override fun deserialize(decoder: Decoder): Instant = Instant.parse(decoder.decodeString())
}

View File

@@ -0,0 +1,208 @@
package com.bitwarden.authenticatorbridge.util
import android.security.keystore.KeyProperties
import com.bitwarden.authenticatorbridge.IAuthenticatorBridgeService
import com.bitwarden.authenticatorbridge.model.AddTotpLoginItemData
import com.bitwarden.authenticatorbridge.model.AddTotpLoginItemDataJson
import com.bitwarden.authenticatorbridge.model.EncryptedAddTotpLoginItemData
import com.bitwarden.authenticatorbridge.model.EncryptedSharedAccountData
import com.bitwarden.authenticatorbridge.model.SharedAccountData
import com.bitwarden.authenticatorbridge.model.SharedAccountDataJson
import com.bitwarden.authenticatorbridge.model.SymmetricEncryptionKeyData
import com.bitwarden.authenticatorbridge.model.SymmetricEncryptionKeyFingerprintData
import com.bitwarden.authenticatorbridge.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
* [IAuthenticatorBridgeService.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
* [IAuthenticatorBridgeService.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<SymmetricEncryptionKeyFingerprintData> =
runCatching {
val messageDigest = MessageDigest.getInstance(KeyProperties.DIGEST_SHA256)
messageDigest.reset()
messageDigest.update(this.symmetricEncryptionKey.byteArray)
SymmetricEncryptionKeyFingerprintData(messageDigest.digest().toByteArrayContainer())
}
/**
* Encrypt [SharedAccountData].
*
* This is intended to be used by the main Bitwarden app during a
* [IAuthenticatorBridgeService.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,192 @@
package com.bitwarden.authenticatorbridge.util
import android.security.keystore.KeyProperties
import com.bitwarden.authenticatorbridge.model.AddTotpLoginItemData
import com.bitwarden.authenticatorbridge.model.SharedAccountData
import com.bitwarden.authenticatorbridge.model.SymmetricEncryptionKeyData
import com.bitwarden.authenticatorbridge.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 {
@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"