Compare commits

..

2 Commits

Author SHA1 Message Date
David Perez
bb8fd1753d Prep work for AGP v9.0 2026-01-23 16:14:57 -06:00
Patrick Honkonen
2e311b6c4a [PM-30899] Store account keys upon SSO user creation (#6384) 2026-01-23 19:51:25 +00:00
17 changed files with 227 additions and 81 deletions

View File

@@ -10,7 +10,9 @@ android {
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
minSdk = libs.versions.minSdkBwa.get().toInt()
minSdk {
version = release(libs.versions.minSdkBwa.get().toInt())
}
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")

View File

@@ -1,4 +1,4 @@
import com.android.build.gradle.internal.api.BaseVariantOutputImpl
import com.android.build.api.variant.impl.VariantOutputImpl
import com.android.utils.cxx.io.removeExtensionIfPresent
import com.google.firebase.crashlytics.buildtools.gradle.tasks.InjectMappingFileIdTask
import com.google.firebase.crashlytics.buildtools.gradle.tasks.UploadMappingFileTask
@@ -45,7 +45,9 @@ val ciProperties = Properties().apply {
android {
namespace = "com.x8bit.bitwarden"
compileSdk = libs.versions.compileSdk.get().toInt()
compileSdk {
version = release(libs.versions.compileSdk.get().toInt())
}
room {
schemaDirectory("$projectDir/schemas")
@@ -53,8 +55,12 @@ android {
defaultConfig {
applicationId = "com.x8bit.bitwarden"
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
minSdk {
version = release(libs.versions.minSdk.get().toInt())
}
targetSdk {
version = release(libs.versions.targetSdk.get().toInt())
}
versionCode = libs.versions.appVersionCode.get().toInt()
versionName = libs.versions.appVersionName.get()
@@ -141,36 +147,40 @@ android {
}
}
applicationVariants.all {
androidComponents.onVariants { appVariant ->
val bundlesDir = "${layout.buildDirectory.get()}/outputs/bundle"
outputs
.mapNotNull { it as? BaseVariantOutputImpl }
val applicationId = appVariant.applicationId.get()
val flavorName = appVariant.flavorName
val variantName = appVariant.name
val renameTaskName = "rename${variantName.capitalize()}AabFiles"
val buildType = appVariant.buildType
appVariant
.outputs
.mapNotNull { it as? VariantOutputImpl }
.forEach { output ->
val fileNameWithoutExtension = when (flavorName) {
"fdroid" -> "$applicationId-$flavorName"
"standard" -> "$applicationId"
else -> output.outputFileName.removeExtensionIfPresent(".apk")
"standard" -> applicationId
else -> output.outputFileName.get().removeExtensionIfPresent(".apk")
}
// Set the APK output filename.
output.outputFileName = "$fileNameWithoutExtension.apk"
output.outputFileName.set("$fileNameWithoutExtension.apk")
val variantName = name
val renameTaskName = "rename${variantName.capitalize()}AabFiles"
tasks.register(renameTaskName) {
group = "build"
description = "Renames the bundle files for $variantName variant"
doLast {
renameFile(
"$bundlesDir/$variantName/$namespace-$flavorName-${buildType.name}.aab",
"$bundlesDir/$variantName/$namespace-$flavorName-$buildType.aab",
"$fileNameWithoutExtension.aab",
)
}
}
// Force renaming task to execute after the variant is built.
tasks
.getByName("bundle${variantName.capitalize()}")
.finalizedBy(renameTaskName)
.matching { it.name == "bundle${variantName.capitalize()}" }
.forEach { it.finalizedBy(renameTaskName) }
}
}

View File

@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.auth.repository
import com.bitwarden.core.AuthRequestMethod
import com.bitwarden.core.InitUserCryptoMethod
import com.bitwarden.core.RegisterTdeKeyResponse
import com.bitwarden.core.WrappedAccountCryptographicState
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.repository.error.MissingPropertyException
@@ -14,6 +15,7 @@ import com.bitwarden.crypto.Kdf
import com.bitwarden.data.datasource.disk.ConfigDiskSource
import com.bitwarden.data.repository.util.toEnvironmentUrls
import com.bitwarden.data.repository.util.toEnvironmentUrlsOrDefault
import com.bitwarden.network.model.CreateAccountKeysResponseJson
import com.bitwarden.network.model.DeleteAccountResponseJson
import com.bitwarden.network.model.GetTokenResponseJson
import com.bitwarden.network.model.IdentityTokenAuthModel
@@ -460,42 +462,32 @@ class AuthRepositoryImpl(
.getShouldTrustDevice(userId = userId) == true,
)
}
.flatMap { keys ->
.flatMap { registerTdeKeyResponse ->
accountsService
.createAccountKeys(
publicKey = keys.publicKey,
encryptedPrivateKey = keys.privateKey,
publicKey = registerTdeKeyResponse.publicKey,
encryptedPrivateKey = registerTdeKeyResponse.privateKey,
)
.map { keys }
.map { createAccountKeysResponse ->
registerTdeKeyResponse to createAccountKeysResponse
}
}
.flatMap { keys ->
.flatMap { (registerTdeKeyResponse, createAccountKeysResponse) ->
organizationService
.organizationResetPasswordEnroll(
organizationId = orgAutoEnrollStatus.organizationId,
userId = userId,
passwordHash = null,
resetPasswordKey = keys.adminReset,
resetPasswordKey = registerTdeKeyResponse.adminReset,
)
.map { keys }
.map { registerTdeKeyResponse to createAccountKeysResponse }
}
.onSuccess { keys ->
// TDE and SSO user creation still uses crypto-v1. These users are not
// expected to have the AEAD keys so we only store the private key for now.
// See https://github.com/bitwarden/android/pull/5682#discussion_r2273940332
// for more details.
authDiskSource.storePrivateKey(
.onSuccess { (registerTdeKeyResponse, createAccountKeysResponse) ->
createNewSsoUserSuccess(
userId = userId,
privateKey = keys.privateKey,
createAccountKeysResponse = createAccountKeysResponse,
registerTdeKeyResponse = registerTdeKeyResponse,
)
// Order matters here, we need to make sure that the vault is unlocked
// before we trust the device, to avoid state-base navigation issues.
vaultRepository.syncVaultState(userId = userId)
keys.deviceKey?.let { trustDeviceResponse ->
trustedDeviceManager.trustThisDevice(
userId = userId,
trustDeviceResponse = trustDeviceResponse,
)
}
}
}
.fold(
@@ -504,6 +496,37 @@ class AuthRepositoryImpl(
)
}
/**
* Stores all the relevant data from a successful creation of an SSO user. The data is stored
* while in an [UserStateManager.userStateTransaction] to ensure the `UserState` is only
* updated once after data stored.
*/
private suspend fun createNewSsoUserSuccess(
userId: String,
createAccountKeysResponse: CreateAccountKeysResponseJson,
registerTdeKeyResponse: RegisterTdeKeyResponse,
): Unit = userStateManager.userStateTransaction {
authDiskSource.storeAccountKeys(
userId = userId,
accountKeys = createAccountKeysResponse.accountKeys,
)
// TDE and SSO user creation still uses crypto-v1. These users are not
// expected to have the AEAD keys so we only store the private key for now.
// See https://github.com/bitwarden/android/pull/5682#discussion_r2273940332
// for more details.
authDiskSource.storePrivateKey(
userId = userId,
privateKey = registerTdeKeyResponse.privateKey,
)
vaultRepository.syncVaultState(userId = userId)
registerTdeKeyResponse.deviceKey?.let { trustDeviceResponse ->
trustedDeviceManager.trustThisDevice(
userId = userId,
trustDeviceResponse = trustDeviceResponse,
)
}
}
override suspend fun completeTdeLogin(
requestPrivateKey: String,
asymmetricalKey: String,

View File

@@ -26,6 +26,7 @@ import com.bitwarden.data.datasource.disk.model.ServerConfig
import com.bitwarden.data.datasource.disk.util.FakeConfigDiskSource
import com.bitwarden.data.repository.model.Environment
import com.bitwarden.network.model.ConfigResponseJson
import com.bitwarden.network.model.CreateAccountKeysResponseJson
import com.bitwarden.network.model.DeleteAccountResponseJson
import com.bitwarden.network.model.GetTokenResponseJson
import com.bitwarden.network.model.IdentityTokenAuthModel
@@ -1137,7 +1138,12 @@ class AuthRepositoryTest {
publicKey = userPublicKey,
encryptedPrivateKey = userPrivateKey,
)
} returns Unit.asSuccess()
} returns CreateAccountKeysResponseJson(
key = null,
publicKey = userPublicKey,
privateKey = userPrivateKey,
accountKeys = null,
).asSuccess()
coEvery {
organizationService.organizationResetPasswordEnroll(
organizationId = orgId,
@@ -1222,7 +1228,12 @@ class AuthRepositoryTest {
publicKey = userPublicKey,
encryptedPrivateKey = userPrivateKey,
)
} returns Unit.asSuccess()
} returns CreateAccountKeysResponseJson(
key = null,
publicKey = userPublicKey,
privateKey = userPrivateKey,
accountKeys = ACCOUNT_KEYS,
).asSuccess()
coEvery {
organizationService.organizationResetPasswordEnroll(
organizationId = orgId,
@@ -1236,6 +1247,7 @@ class AuthRepositoryTest {
val result = repository.createNewSsoUser()
fakeAuthDiskSource.assertPrivateKey(userId = USER_ID_1, privateKey = userPrivateKey)
fakeAuthDiskSource.assertAccountKeys(userId = USER_ID_1, accountKeys = ACCOUNT_KEYS)
assertEquals(NewSsoUserResult.Success, result)
coVerify(exactly = 1) {
organizationService.getOrganizationAutoEnrollStatus(orgIdentifier)
@@ -1310,7 +1322,12 @@ class AuthRepositoryTest {
publicKey = userPublicKey,
encryptedPrivateKey = userPrivateKey,
)
} returns Unit.asSuccess()
} returns CreateAccountKeysResponseJson(
key = null,
publicKey = userPublicKey,
privateKey = userPrivateKey,
accountKeys = ACCOUNT_KEYS,
).asSuccess()
coEvery {
organizationService.organizationResetPasswordEnroll(
organizationId = orgId,
@@ -1330,6 +1347,7 @@ class AuthRepositoryTest {
val result = repository.createNewSsoUser()
fakeAuthDiskSource.assertPrivateKey(userId = USER_ID_1, privateKey = userPrivateKey)
fakeAuthDiskSource.assertAccountKeys(userId = USER_ID_1, accountKeys = ACCOUNT_KEYS)
assertEquals(NewSsoUserResult.Success, result)
coVerify(exactly = 1) {
organizationService.getOrganizationAutoEnrollStatus(orgIdentifier)

View File

@@ -1,4 +1,4 @@
import com.android.build.gradle.internal.api.BaseVariantOutputImpl
import com.android.build.api.variant.impl.VariantOutputImpl
import com.google.protobuf.gradle.proto
import dagger.hilt.android.plugin.util.capitalize
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
@@ -32,7 +32,9 @@ val ciProperties = Properties().apply {
android {
namespace = "com.bitwarden.authenticator"
compileSdk = libs.versions.compileSdk.get().toInt()
compileSdk {
version = release(libs.versions.compileSdk.get().toInt())
}
room {
schemaDirectory("$projectDir/schemas")
@@ -40,8 +42,12 @@ android {
defaultConfig {
applicationId = "com.bitwarden.authenticator"
minSdk = libs.versions.minSdkBwa.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
minSdk {
version = release(libs.versions.minSdkBwa.get().toInt())
}
targetSdk {
version = release(libs.versions.targetSdk.get().toInt())
}
versionCode = libs.versions.appVersionCode.get().toInt()
versionName = libs.versions.appVersionName.get()
@@ -109,30 +115,33 @@ android {
buildConfigField(type = "boolean", name = "HAS_DEBUG_MENU", value = "false")
}
}
applicationVariants.all {
androidComponents.onVariants { appVariant ->
val bundlesDir = "${layout.buildDirectory.get()}/outputs/bundle"
outputs
.mapNotNull { it as? BaseVariantOutputImpl }
val applicationId = appVariant.applicationId.get()
val variantName = appVariant.name
val renameTaskName = "rename${variantName.capitalize()}AabFiles"
val buildType = appVariant.buildType
appVariant
.outputs
.mapNotNull { it as? VariantOutputImpl }
.forEach { output ->
// Set the APK output filename.
output.outputFileName = "$applicationId.apk"
output.outputFileName.set("$applicationId.apk")
val variantName = name
val renameTaskName = "rename${variantName.capitalize()}AabFiles"
tasks.register(renameTaskName) {
group = "build"
description = "Renames the bundle files for $variantName variant"
doLast {
renameFile(
"$bundlesDir/$variantName/$namespace-${buildType.name}.aab",
"$bundlesDir/$variantName/$namespace-$buildType.aab",
"$applicationId.aab",
)
}
}
// Force renaming task to execute after the variant is built.
tasks
.getByName("bundle${variantName.capitalize()}")
.finalizedBy(renameTaskName)
.matching { it.name == "bundle${variantName.capitalize()}" }
.forEach { it.finalizedBy(renameTaskName) }
}
}
compileOptions {

View File

@@ -12,11 +12,15 @@ plugins {
android {
namespace = "com.bitwarden.authenticatorbridge"
compileSdk = libs.versions.compileSdk.get().toInt()
compileSdk {
version = release(libs.versions.compileSdk.get().toInt())
}
defaultConfig {
// This min value is selected to accommodate known consumers
minSdk = libs.versions.minSdkBwa.get().toInt()
minSdk {
version = release(libs.versions.minSdkBwa.get().toInt())
}
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")

View File

@@ -10,12 +10,16 @@ plugins {
android {
namespace = "com.bitwarden.core"
compileSdk = libs.versions.compileSdk.get().toInt()
compileSdk {
version = release(libs.versions.compileSdk.get().toInt())
}
defaultConfig {
// Set the minimum SDK version to the SDK version used by Authenticator, which is the lowest
// universally supported SDK version.
minSdk = libs.versions.minSdkBwa.get().toInt()
minSdk {
version = release(libs.versions.minSdkBwa.get().toInt())
}
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")

View File

@@ -10,10 +10,14 @@ plugins {
android {
namespace = "com.bitwarden.cxf"
compileSdk = libs.versions.compileSdk.get().toInt()
compileSdk {
version = release(libs.versions.compileSdk.get().toInt())
}
defaultConfig {
minSdk = libs.versions.minSdkBwa.get().toInt()
minSdk {
version = release(libs.versions.minSdkBwa.get().toInt())
}
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")

View File

@@ -10,10 +10,14 @@ plugins {
android {
namespace = "com.bitwarden.data"
compileSdk = libs.versions.compileSdk.get().toInt()
compileSdk {
version = release(libs.versions.compileSdk.get().toInt())
}
defaultConfig {
minSdk = libs.versions.minSdkBwa.get().toInt()
minSdk {
version = release(libs.versions.minSdkBwa.get().toInt())
}
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")

View File

@@ -9,10 +9,14 @@ plugins {
android {
namespace = "com.bitwarden.network"
compileSdk = libs.versions.compileSdk.get().toInt()
compileSdk {
version = release(libs.versions.compileSdk.get().toInt())
}
defaultConfig {
minSdk = libs.versions.minSdkBwa.get().toInt()
minSdk {
version = release(libs.versions.minSdkBwa.get().toInt())
}
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")

View File

@@ -1,6 +1,7 @@
package com.bitwarden.network.api
import com.bitwarden.network.model.CreateAccountKeysRequest
import com.bitwarden.network.model.CreateAccountKeysResponseJson
import com.bitwarden.network.model.DeleteAccountRequestJson
import com.bitwarden.network.model.NetworkResult
import com.bitwarden.network.model.ResetPasswordRequestJson
@@ -26,7 +27,9 @@ internal interface AuthenticatedAccountsApi {
* Creates the keys for the current account.
*/
@POST("/accounts/keys")
suspend fun createAccountKeys(@Body body: CreateAccountKeysRequest): NetworkResult<Unit>
suspend fun createAccountKeys(
@Body body: CreateAccountKeysRequest,
): NetworkResult<CreateAccountKeysResponseJson>
/**
* Deletes the current account.

View File

@@ -0,0 +1,27 @@
package com.bitwarden.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Response object returned when creating account keys.
*
* @property key The user key (nullable).
* @property publicKey The public key for the account.
* @property privateKey The encrypted private key for the account.
* @property accountKeys The account keys containing encryption key pairs and security state.
*/
@Serializable
data class CreateAccountKeysResponseJson(
@SerialName("key")
val key: String?,
@SerialName("publicKey")
val publicKey: String?,
@SerialName("privateKey")
val privateKey: String?,
@SerialName("accountKeys")
val accountKeys: AccountKeysJson?,
)

View File

@@ -1,5 +1,6 @@
package com.bitwarden.network.service
import com.bitwarden.network.model.CreateAccountKeysResponseJson
import com.bitwarden.network.model.DeleteAccountResponseJson
import com.bitwarden.network.model.KeyConnectorKeyRequestJson
import com.bitwarden.network.model.KeyConnectorMasterKeyResponseJson
@@ -26,7 +27,10 @@ interface AccountsService {
/**
* Creates a new account's keys.
*/
suspend fun createAccountKeys(publicKey: String, encryptedPrivateKey: String): Result<Unit>
suspend fun createAccountKeys(
publicKey: String,
encryptedPrivateKey: String,
): Result<CreateAccountKeysResponseJson>
/**
* Make delete account request.

View File

@@ -5,6 +5,7 @@ import com.bitwarden.network.api.AuthenticatedKeyConnectorApi
import com.bitwarden.network.api.UnauthenticatedAccountsApi
import com.bitwarden.network.api.UnauthenticatedKeyConnectorApi
import com.bitwarden.network.model.CreateAccountKeysRequest
import com.bitwarden.network.model.CreateAccountKeysResponseJson
import com.bitwarden.network.model.DeleteAccountRequestJson
import com.bitwarden.network.model.DeleteAccountResponseJson
import com.bitwarden.network.model.KeyConnectorKeyRequestJson
@@ -50,7 +51,7 @@ internal class AccountsServiceImpl(
override suspend fun createAccountKeys(
publicKey: String,
encryptedPrivateKey: String,
): Result<Unit> =
): Result<CreateAccountKeysResponseJson> =
authenticatedAccountsApi
.createAccountKeys(
body = CreateAccountKeysRequest(

View File

@@ -53,11 +53,10 @@ class AccountsServiceTest : BaseServiceTest() {
}
@Test
fun `createAccountKeys with empty response is success`() = runTest {
fun `createAccountKeys success response should return Success`() = runTest {
val publicKey = "publicKey"
val encryptedPrivateKey = "encryptedPrivateKey"
val json = ""
val response = MockResponse().setBody(json)
val response = MockResponse().setBody(CREATE_ACCOUNT_KEYS_REQUEST_RESPONSE)
server.enqueue(response)
val result = service.createAccountKeys(
@@ -368,3 +367,22 @@ private val UPDATE_KDF_REQUEST = UpdateKdfJsonRequest(
salt = "mockSalt",
),
)
private val CREATE_ACCOUNT_KEYS_REQUEST_RESPONSE = """
{
"key": null,
"publicKey": "mockPublicKey-1",
"privateKey": "mockPrivateKey-1",
"accountKeys": {
"signatureKeyPair": null,
"publicKeyEncryptionKeyPair": {
"wrappedPrivateKey": "mockWrappedPrivateKey-1",
"publicKey": "mockPublicKey-1",
"signedPublicKey": null,
"object": "publicKeyEncryptionKeyPair"
},
"securityState": null,
"object": "privateKeys"
},
"object": "keys"
}
""".trimIndent()

View File

@@ -1,4 +1,4 @@
import com.android.build.gradle.internal.api.BaseVariantOutputImpl
import com.android.build.api.variant.impl.VariantOutputImpl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
@@ -13,13 +13,19 @@ plugins {
android {
namespace = "com.bitwarden.testharness"
compileSdk = libs.versions.compileSdk.get().toInt()
compileSdk {
version = release(libs.versions.compileSdk.get().toInt())
}
defaultConfig {
applicationId = "com.bitwarden.testharness"
// API 28 - CredentialManager with Play Services support
minSdk = libs.versions.minSdkBwa.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
minSdk {
version = release(libs.versions.minSdkBwa.get().toInt())
}
targetSdk {
version = release(libs.versions.targetSdk.get().toInt())
}
versionCode = libs.versions.appVersionCode.get().toInt()
versionName = libs.versions.appVersionName.get()
@@ -40,12 +46,13 @@ android {
}
}
applicationVariants.all {
outputs
.mapNotNull { it as? BaseVariantOutputImpl }
androidComponents.onVariants { appVariant ->
appVariant
.outputs
.mapNotNull { it as? VariantOutputImpl }
.forEach { output ->
// Set the APK output filename.
output.outputFileName = "$applicationId.apk"
output.outputFileName.set("${appVariant.applicationId.get()}.apk")
}
}

View File

@@ -10,10 +10,14 @@ plugins {
android {
namespace = "com.bitwarden.ui"
compileSdk = libs.versions.compileSdk.get().toInt()
compileSdk {
version = release(libs.versions.compileSdk.get().toInt())
}
defaultConfig {
minSdk = libs.versions.minSdkBwa.get().toInt()
minSdk {
version = release(libs.versions.minSdkBwa.get().toInt())
}
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")