PM-39902: feat: Update app to support blob encryption (#7173)

This commit is contained in:
David Perez authored and GitHub committed 2026-07-21 17:14:45 +00:00
1 parent a89da00f9f
commit 4fe8d81021
13 files changed
+310 -37

No files matched your search

@@ -0,0 +1,272 @@
{
"formatVersion": 1,
"database": {
"version": 10,
"identityHash": "67e32a70f154b819751a225259e8222b",
"entities": [
{
"tableName": "ciphers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `cipher_type` TEXT NOT NULL, `cipher_json` TEXT NOT NULL, `organization_id` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "cipherType",
"columnName": "cipher_type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "cipherJson",
"columnName": "cipher_json",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "organizationId",
"columnName": "organization_id",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_ciphers_user_id",
"unique": false,
"columnNames": [
"user_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_ciphers_user_id` ON `${TABLE_NAME}` (`user_id`)"
},
{
"name": "index_ciphers_user_id_organization_id",
"unique": false,
"columnNames": [
"user_id",
"organization_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_ciphers_user_id_organization_id` ON `${TABLE_NAME}` (`user_id`, `organization_id`)"
}
]
},
{
"tableName": "collections",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `organization_id` TEXT NOT NULL, `should_hide_passwords` INTEGER NOT NULL, `name` TEXT NOT NULL, `external_id` TEXT, `read_only` INTEGER NOT NULL, `manage` INTEGER, `default_user_collection_email` TEXT, `type` TEXT NOT NULL DEFAULT '0', PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "organizationId",
"columnName": "organization_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "shouldHidePasswords",
"columnName": "should_hide_passwords",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "externalId",
"columnName": "external_id",
"affinity": "TEXT"
},
{
"fieldPath": "isReadOnly",
"columnName": "read_only",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "canManage",
"columnName": "manage",
"affinity": "INTEGER"
},
{
"fieldPath": "defaultUserCollectionEmail",
"columnName": "default_user_collection_email",
"affinity": "TEXT"
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'0'"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_collections_user_id",
"unique": false,
"columnNames": [
"user_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collections_user_id` ON `${TABLE_NAME}` (`user_id`)"
}
]
},
{
"tableName": "domains",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `domains_json` TEXT, PRIMARY KEY(`user_id`))",
"fields": [
{
"fieldPath": "userId",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "domainsJson",
"columnName": "domains_json",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"user_id"
]
}
},
{
"tableName": "folders",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `name` TEXT, `revision_date` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT"
},
{
"fieldPath": "revisionDate",
"columnName": "revision_date",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_folders_user_id",
"unique": false,
"columnNames": [
"user_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_folders_user_id` ON `${TABLE_NAME}` (`user_id`)"
}
]
},
{
"tableName": "sends",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `send_type` TEXT NOT NULL, `send_json` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sendType",
"columnName": "send_type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sendJson",
"columnName": "send_json",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_sends_user_id",
"unique": false,
"columnNames": [
"user_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_sends_user_id` ON `${TABLE_NAME}` (`user_id`)"
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '67e32a70f154b819751a225259e8222b')"
]
}
}
@@ -88,7 +88,7 @@ class AuthenticatorBridgeRepositoryImpl(
// Vault is unlocked, query vault disk source for totp logins:
val cipherData = vaultDiskSource
.getTotpCiphers(userId = userId)
.getLoginCiphers(userId = userId)
// Filter out any deleted and archived ciphers.
.filter { it.deletedDate == null && it.archivedDate == null }
.mapNotNull {
@@ -43,9 +43,9 @@ interface VaultDiskSource {
): List<SyncResponseJson.Cipher>
/**
* Retrieves all ciphers from the data source for a given [userId] that contain TOTP codes.
* Retrieves all login ciphers from the data source for a given [userId].
*/
suspend fun getTotpCiphers(userId: String): List<SyncResponseJson.Cipher>
suspend fun getLoginCiphers(userId: String): List<SyncResponseJson.Cipher>
/**
* Retrieves a cipher from the data source for a given [userId] and [cipherId].
@@ -4,7 +4,6 @@ import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.core.data.util.decodeFromStringWithErrorCallback
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.ui.platform.base.util.orNullIfBlank
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.CiphersDao
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.CollectionsDao
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.DomainsDao
@@ -53,7 +52,6 @@ class VaultDiskSourceImpl(
CipherEntity(
id = cipher.id,
userId = userId,
hasTotp = cipher.login?.totp != null,
cipherType = json.encodeToString(cipher.type),
cipherJson = json.encodeToString(cipher),
organizationId = cipher.organizationId,
@@ -120,8 +118,8 @@ class VaultDiskSourceImpl(
}
}
override suspend fun getTotpCiphers(userId: String): List<SyncResponseJson.Cipher> {
val entities = ciphersDao.getAllTotpCiphers(userId = userId)
override suspend fun getLoginCiphers(userId: String): List<SyncResponseJson.Cipher> {
val entities = ciphersDao.getAllLoginCiphers(userId = userId)
return withContext(context = dispatcherManager.default) {
entities
.map { entity ->
@@ -132,11 +130,6 @@ class VaultDiskSourceImpl(
}
}
.awaitAll()
.filter {
// A safety-check since after the DB migration, we will temporarily think
// all ciphers contain a totp code
it.login?.totp.orNullIfBlank() != null
}
}
}
@@ -167,7 +160,6 @@ class VaultDiskSourceImpl(
CipherEntity(
id = cipher.id,
userId = userId,
hasTotp = cipher.login?.totp.orNullIfBlank() != null,
cipherType = json.encodeToString(cipher.type),
cipherJson = json.encodeToString(cipher),
organizationId = cipher.organizationId,
@@ -369,7 +361,6 @@ class VaultDiskSourceImpl(
CipherEntity(
id = cipher.id,
userId = userId,
hasTotp = cipher.login?.totp != null,
cipherType = json.encodeToString(cipher.type),
cipherJson = json.encodeToString(cipher),
organizationId = cipher.organizationId,
@@ -48,10 +48,10 @@ interface CiphersDao {
): List<CipherEntity>
/**
* Retrieves all ciphers from the database for a given [userId].
* Retrieves all login type ciphers from the database for a given [userId].
*/
@Query("SELECT * FROM ciphers WHERE user_id = :userId AND has_totp = 1")
suspend fun getAllTotpCiphers(
@Query("SELECT * FROM ciphers WHERE user_id = :userId AND cipher_type = 1")
suspend fun getAllLoginCiphers(
userId: String,
): List<CipherEntity>
@@ -2,8 +2,10 @@ package com.x8bit.bitwarden.data.vault.datasource.disk.database
import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.DeleteColumn
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.AutoMigrationSpec
import com.x8bit.bitwarden.data.vault.datasource.disk.convertor.InstantTypeConverter
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.CiphersDao
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.CollectionsDao
@@ -27,11 +29,13 @@ import com.x8bit.bitwarden.data.vault.datasource.disk.entity.SendEntity
FolderEntity::class,
SendEntity::class,
],
version = 9,
version = 10,
exportSchema = true,
autoMigrations = [
AutoMigration(from = 6, to = 7),
AutoMigration(from = 7, to = 8),
AutoMigration(from = 8, to = 9),
AutoMigration(from = 9, to = 10, RemoveTotpAutoMigration::class),
],
)
@TypeConverters(InstantTypeConverter::class)
@@ -62,3 +66,9 @@ abstract class VaultDatabase : RoomDatabase() {
*/
abstract fun sendsDao(): SendsDao
}
/**
* A defined migration to remove the `has_totp` field from the `cipher` table.
*/
@DeleteColumn.Entries(DeleteColumn(tableName = "ciphers", columnName = "has_totp"))
class RemoveTotpAutoMigration : AutoMigrationSpec
@@ -23,11 +23,6 @@ data class CipherEntity(
@ColumnInfo(name = "user_id")
val userId: String,
// Default to true for initial migration.
// Subsequent syncs will populate with correct values for optimizations.
@ColumnInfo(name = "has_totp", defaultValue = "1")
val hasTotp: Boolean,
@ColumnInfo(name = "cipher_type")
val cipherType: String,
@@ -117,6 +117,7 @@ fun Cipher.toEncryptedNetworkCipherResponse(
key = key,
encryptedFor = encryptedFor,
archivedDate = archivedDate,
data = data,
)
/**
@@ -490,7 +491,7 @@ fun SyncResponseJson.Cipher.toEncryptedSdkCipher(): Cipher =
deletedDate = deletedDate,
revisionDate = revisionDate,
archivedDate = archivedDate,
data = null,
data = data,
)
/**
@@ -143,8 +143,8 @@ class AuthenticatorBridgeRepositoryTest {
// Add some ciphers to vaultDiskSource for each user,
// and setup mock decryption for them:
coEvery { vaultDiskSource.getTotpCiphers(USER_1_ID) } returns USER_1_CIPHERS
coEvery { vaultDiskSource.getTotpCiphers(USER_2_ID) } returns USER_2_CIPHERS
coEvery { vaultDiskSource.getLoginCiphers(USER_1_ID) } returns USER_1_CIPHERS
coEvery { vaultDiskSource.getLoginCiphers(USER_2_ID) } returns USER_2_CIPHERS
mockkStatic(
SyncResponseJson.Cipher::toEncryptedSdkCipher,
EnvironmentUrlDataJson::toEnvironmentUrlsOrDefault,
@@ -213,7 +213,7 @@ class AuthenticatorBridgeRepositoryTest {
userId = USER_2_ID,
request = InitOrgCryptoRequest(organizationKeys = USER_2_ORG_KEYS),
)
vaultDiskSource.getTotpCiphers(userId = USER_2_ID)
vaultDiskSource.getLoginCiphers(userId = USER_2_ID)
scopedVaultSdkSource.decryptCipher(
userId = USER_2_ID,
cipher = USER_2_ENCRYPTED_SDK_TOTP_CIPHER,
@@ -251,7 +251,7 @@ class AuthenticatorBridgeRepositoryTest {
userId = USER_1_ID,
request = InitOrgCryptoRequest(organizationKeys = USER_1_ORG_KEYS),
)
vaultDiskSource.getTotpCiphers(userId = USER_1_ID)
vaultDiskSource.getLoginCiphers(userId = USER_1_ID)
scopedVaultSdkSource.decryptCipher(
userId = USER_1_ID,
cipher = USER_1_ENCRYPTED_SDK_TOTP_CIPHER,
@@ -273,7 +273,7 @@ class AuthenticatorBridgeRepositoryTest {
userId = USER_2_ID,
request = InitOrgCryptoRequest(organizationKeys = USER_2_ORG_KEYS),
)
vaultDiskSource.getTotpCiphers(userId = USER_2_ID)
vaultDiskSource.getLoginCiphers(userId = USER_2_ID)
scopedVaultSdkSource.decryptCipher(
userId = USER_2_ID,
cipher = USER_2_ENCRYPTED_SDK_TOTP_CIPHER,
@@ -341,7 +341,7 @@ class AuthenticatorBridgeRepositoryTest {
userId = USER_2_ID,
request = InitOrgCryptoRequest(organizationKeys = USER_2_ORG_KEYS),
)
vaultDiskSource.getTotpCiphers(userId = USER_2_ID)
vaultDiskSource.getLoginCiphers(userId = USER_2_ID)
scopedVaultSdkSource.decryptCipher(
userId = USER_2_ID,
cipher = USER_2_ENCRYPTED_SDK_TOTP_CIPHER,
@@ -123,17 +123,17 @@ class VaultDiskSourceTest {
}
@Test
fun `getTotpCiphers should return all CiphersDao totp ciphers`() = runTest {
fun `getLoginCiphers should return all CiphersDao login ciphers`() = runTest {
val cipherEntities = listOf(
CIPHER_ENTITY,
CIPHER_ENTITY.copy(id = "otherCipherId", hasTotp = false),
CIPHER_ENTITY.copy(id = "otherCipherId", cipherType = "2"),
)
val ciphers = listOf(CIPHER_1)
val result1 = vaultDiskSource.getTotpCiphers(USER_ID)
val result1 = vaultDiskSource.getLoginCiphers(USER_ID)
assertEquals(emptyList<SyncResponseJson.Cipher>(), result1)
ciphersDao.insertCiphers(cipherEntities)
val result2 = vaultDiskSource.getTotpCiphers(USER_ID)
val result2 = vaultDiskSource.getLoginCiphers(USER_ID)
assertEquals(ciphers, result2)
}
@@ -638,7 +638,6 @@ private const val CIPHER_JSON = """
private val CIPHER_ENTITY = CipherEntity(
id = "mockId-1",
userId = USER_ID,
hasTotp = true,
cipherType = "1",
cipherJson = CIPHER_JSON,
organizationId = "mockOrganizationId-1",
@@ -56,8 +56,8 @@ class FakeCiphersDao : CiphersDao {
): List<CipherEntity> =
storedCiphers.filter { it.userId == userId && it.id in cipherIds }
override suspend fun getAllTotpCiphers(userId: String): List<CipherEntity> =
storedCiphers.filter { it.userId == userId && it.hasTotp }
override suspend fun getAllLoginCiphers(userId: String): List<CipherEntity> =
storedCiphers.filter { it.userId == userId && it.cipherType == "1" }
override suspend fun getCipher(userId: String, cipherId: String): CipherEntity? =
storedCiphers.find { it.userId == userId && it.id == cipherId }
@@ -774,6 +774,9 @@ data class SyncResponseJson(
@SerialName("archivedDate")
@Contextual
val archivedDate: Instant?,
@SerialName("data")
val data: String?,
) {
/**
* Represents an attachment in the vault response.
@@ -50,6 +50,7 @@ fun createMockCipher(
shouldViewPassword: Boolean = false,
key: String? = "mockKey-$number",
encryptedFor: String? = "mockEncryptedFor-$number",
data: String? = null,
): SyncResponseJson.Cipher =
SyncResponseJson.Cipher(
id = id,
@@ -82,6 +83,7 @@ fun createMockCipher(
shouldViewPassword = shouldViewPassword,
key = key,
encryptedFor = encryptedFor,
data = data,
)
/**