Fix linked android device improperly reading/writing backup level in storage service.

This commit is contained in:
Cody Henthorne
2026-08-19 19:05:50 -04:00
parent a790dc4403
commit 6ad453e551
8 changed files with 398 additions and 11 deletions
@@ -0,0 +1,121 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.jobs
import androidx.annotation.VisibleForTesting
import arrow.core.Either
import org.signal.core.util.logging.Log
import org.signal.network.service.ArchiveError
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.CoroutineJob
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
import org.thoughtcrime.securesms.jobs.protos.BackupTierDowngradeCheckJobData
import org.thoughtcrime.securesms.keyvalue.SignalStore
import kotlin.time.Duration.Companion.days
/**
* Asks the service what tier we're actually on after a storage service account record told us our backup tier went down.
*
* [remoteBackupTier] is the zkgroup backup level from that record, which we fall back to when the service can't give us a usable answer.
*/
class BackupTierDowngradeCheckJob private constructor(
private val remoteBackupTier: Long?,
parameters: Parameters
) : CoroutineJob(parameters) {
companion object {
private val TAG = Log.tag(BackupTierDowngradeCheckJob::class)
const val KEY = "BackupTierDowngradeCheckJob"
fun enqueue(remoteBackupTier: Long?) {
AppDependencies.jobManager.add(create(remoteBackupTier))
}
@VisibleForTesting
fun create(remoteBackupTier: Long?): BackupTierDowngradeCheckJob {
return BackupTierDowngradeCheckJob(
remoteBackupTier = remoteBackupTier,
parameters = Parameters.Builder()
.addConstraint(NetworkConstraint.KEY)
.setMaxAttempts(Parameters.UNLIMITED)
.setLifespan(3.days.inWholeMilliseconds)
.setMaxInstancesForFactory(1)
.build()
)
}
}
override suspend fun doRun(): Result {
if (!SignalStore.account.isRegistered) {
Log.i(TAG, "Not registered. Nothing to confirm.")
return Result.success()
}
if (SignalStore.account.isPrimaryDevice) {
Log.i(TAG, "Primary device owns its own tier. Nothing to confirm.")
return Result.success()
}
if (SignalStore.backup.backupTier == null) {
Log.i(TAG, "We already have no tier. Nothing to confirm.")
return Result.success()
}
return when (val result = BackupRepository.getBackupTierWithoutDowngrade()) {
is Either.Right -> {
Log.i(TAG, "Service says we're on ${result.value}. Applying it over our local tier of ${SignalStore.backup.backupTier}.", true)
SignalStore.backup.backupTier = result.value
Result.success()
}
is Either.Left -> handleError(result.value)
}
}
private fun handleError(error: ArchiveError.CredentialError): Result {
return when (error) {
is ArchiveError.NetworkError -> {
Log.w(TAG, "Network error. Retrying later.")
Result.retry(defaultBackoff())
}
is ArchiveError.CredentialError.RateLimited -> {
Log.w(TAG, "Rate limited. Retrying later.")
Result.retry(error.retryAfter?.inWholeMilliseconds ?: defaultBackoff())
}
is ArchiveError.CredentialError.NotFound,
is ArchiveError.CredentialError.Unauthorized,
is ArchiveError.CredentialError.InvalidRequest,
is ArchiveError.CredentialError.ZkVerificationFailed,
is ArchiveError.ApplicationError -> {
val tier = MessageBackupTier.fromBackupLevel(remoteBackupTier)
Log.w(TAG, "Service could not tell us our tier ($error). Deferring to the account record and applying $tier over ${SignalStore.backup.backupTier}.", true)
SignalStore.backup.backupTier = tier
Result.success()
}
}
}
override fun serialize(): ByteArray {
return BackupTierDowngradeCheckJobData(remoteBackupTier = remoteBackupTier).encode()
}
override fun getFactoryKey(): String = KEY
override fun onFailure() = Unit
class Factory : Job.Factory<BackupTierDowngradeCheckJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): BackupTierDowngradeCheckJob {
val data = BackupTierDowngradeCheckJobData.ADAPTER.decode(serializedData!!)
return BackupTierDowngradeCheckJob(remoteBackupTier = data.remoteBackupTier, parameters = parameters)
}
}
}
@@ -157,6 +157,7 @@ public final class JobManagerFactories {
put(BackupMessagesJob.KEY, new BackupMessagesJob.Factory());
put(BackupRestoreMediaJob.KEY, new BackupRestoreMediaJob.Factory());
put(BackupSubscriptionCheckJob.KEY, new BackupSubscriptionCheckJob.Factory());
put(BackupTierDowngradeCheckJob.KEY, new BackupTierDowngradeCheckJob.Factory());
put(BuildExpirationConfirmationJob.KEY, new BuildExpirationConfirmationJob.Factory());
put(CallingAssetsDownloadJob.KEY, new CallingAssetsDownloadJob.Factory());
put(CallLinkPeekJob.KEY, new CallLinkPeekJob.Factory());
@@ -141,7 +141,7 @@ class AccountRecordProcessor(
username = remote.proto.username
usernameLink = remote.proto.usernameLink
notificationProfileManualOverride = remote.proto.notificationProfileManualOverride
backupTier = local.proto.backupTier ?: remote.proto.backupTier
backupTier = if (SignalStore.account.isPrimaryDevice) local.proto.backupTier ?: remote.proto.backupTier else remote.proto.backupTier
avatarColor = if (SignalStore.account.isPrimaryDevice) local.proto.avatarColor else remote.proto.avatarColor
automaticKeyVerificationDisabled = remote.proto.automaticKeyVerificationDisabled
hasSeenAdminDeleteEducationDialog = remote.proto.hasSeenAdminDeleteEducationDialog
@@ -22,6 +22,7 @@ import org.thoughtcrime.securesms.database.model.KeyTransparencyStore
import org.thoughtcrime.securesms.database.model.RecipientRecord
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.dependencies.KeyTransparencyApi
import org.thoughtcrime.securesms.jobs.BackupTierDowngradeCheckJob
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob
import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob
import org.thoughtcrime.securesms.jobs.StorageSyncJob
@@ -196,7 +197,6 @@ object StorageSyncHelper {
}
backupTier = when {
SignalStore.account.isLinkedDevice -> null
SignalStore.backup.areBackupsEnabled && SignalStore.backup.backupTier != null -> SignalStore.backup.backupTier!!.toBackupLevel()
SignalStore.backup.backupTierInternalOverride != null -> SignalStore.backup.backupTierInternalOverride!!.toBackupLevel()
else -> null
@@ -310,8 +310,15 @@ object StorageSyncHelper {
if (SignalStore.account.isLinkedDevice) {
val remoteBackupTier = MessageBackupTier.fromBackupLevel(update.new.proto.backupTier)
if (remoteBackupTier != SignalStore.backup.backupTier) {
SignalStore.backup.backupTier = remoteBackupTier
val localBackupTier = SignalStore.backup.backupTier
if (remoteBackupTier != localBackupTier) {
if (isBackupTierDowngrade(from = localBackupTier, to = remoteBackupTier)) {
Log.w(TAG, "Remote account record downgrades our backup tier ($localBackupTier -> $remoteBackupTier). Confirming with the service before applying it.")
BackupTierDowngradeCheckJob.enqueue(update.new.proto.backupTier)
} else {
SignalStore.backup.backupTier = remoteBackupTier
}
}
}
@@ -408,6 +415,14 @@ object StorageSyncHelper {
}
}
private fun isBackupTierDowngrade(from: MessageBackupTier?, to: MessageBackupTier?): Boolean {
return when (from) {
null -> false
MessageBackupTier.FREE -> to == null
MessageBackupTier.PAID -> to == null || to == MessageBackupTier.FREE
}
}
class IdDifferenceResult(
@JvmField val remoteOnlyIds: List<StorageId>,
@JvmField val localOnlyIds: List<StorageId>,
+4
View File
@@ -292,3 +292,7 @@ message IndividualSendJobV2Data {
message MultiDeviceAttachmentBackfillRequestJobData {
uint64 messageId = 1;
}
message BackupTierDowngradeCheckJobData {
optional uint64 remoteBackupTier = 1;
}
@@ -0,0 +1,150 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.jobs
import arrow.core.left
import arrow.core.right
import io.mockk.every
import io.mockk.mockkObject
import io.mockk.unmockkObject
import io.mockk.verify
import org.junit.After
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.signal.core.util.logging.Log
import org.signal.libsignal.zkgroup.VerificationFailedException
import org.signal.network.service.ArchiveError
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.testutil.MockSignalStoreRule
import org.thoughtcrime.securesms.testutil.SystemOutLogger
import org.thoughtcrime.securesms.util.RemoteConfig
import java.io.IOException
import kotlin.time.Duration.Companion.minutes
class BackupTierDowngradeCheckJobTest {
companion object {
private val FREE_TIER = MessageBackupTier.FREE.toBackupLevel()
}
@get:Rule
val signalStore = MockSignalStoreRule()
@Before
fun setUp() {
Log.initialize(SystemOutLogger())
every { signalStore.account.isRegistered } returns true
every { signalStore.account.isPrimaryDevice } returns false
every { signalStore.backup.backupTier } returns MessageBackupTier.PAID
mockkObject(BackupRepository)
mockkObject(RemoteConfig)
every { RemoteConfig.defaultMaxBackoff } returns 1.minutes.inWholeMilliseconds
}
@After
fun tearDown() {
unmockkObject(BackupRepository)
unmockkObject(RemoteConfig)
}
@Test
fun `given the service says paid, when I run, then I keep the paid tier`() {
every { BackupRepository.getBackupTierWithoutDowngrade() } returns MessageBackupTier.PAID.right()
val result = BackupTierDowngradeCheckJob.create(FREE_TIER).run()
assertTrue(result.isSuccess)
verify { signalStore.backup.backupTier = MessageBackupTier.PAID }
}
@Test
fun `given the service says free, when I run, then I downgrade to free`() {
every { BackupRepository.getBackupTierWithoutDowngrade() } returns MessageBackupTier.FREE.right()
val result = BackupTierDowngradeCheckJob.create(FREE_TIER).run()
assertTrue(result.isSuccess)
verify { signalStore.backup.backupTier = MessageBackupTier.FREE }
}
@Test
fun `given a network error, when I run, then I retry and leave our tier alone`() {
every { BackupRepository.getBackupTierWithoutDowngrade() } returns ArchiveError.NetworkError(IOException()).left()
val result = BackupTierDowngradeCheckJob.create(FREE_TIER).run()
assertTrue(result.isRetry)
verify(exactly = 0) { signalStore.backup.backupTier = any() }
}
@Test
fun `given the service rejects our credential, when I run, then I take the tier from the record`() {
every { BackupRepository.getBackupTierWithoutDowngrade() } returns ArchiveError.CredentialError.Unauthorized().left()
val result = BackupTierDowngradeCheckJob.create(FREE_TIER).run()
assertTrue(result.isSuccess)
verify { signalStore.backup.backupTier = MessageBackupTier.FREE }
}
@Test
fun `given the service has no backup for us and the record had no tier, when I run, then I clear our tier`() {
every { BackupRepository.getBackupTierWithoutDowngrade() } returns ArchiveError.CredentialError.NotFound().left()
val result = BackupTierDowngradeCheckJob.create(null).run()
assertTrue(result.isSuccess)
verify { signalStore.backup.backupTier = null }
}
@Test
fun `given we cannot verify our own credential, when I run, then I take the tier from the record`() {
every { BackupRepository.getBackupTierWithoutDowngrade() } returns ArchiveError.CredentialError.ZkVerificationFailed(VerificationFailedException()).left()
val result = BackupTierDowngradeCheckJob.create(null).run()
assertTrue(result.isSuccess)
verify { signalStore.backup.backupTier = null }
}
@Test
fun `given a job carrying a tier, when I serialize and restore it, then I keep that tier`() {
every { BackupRepository.getBackupTierWithoutDowngrade() } returns ArchiveError.CredentialError.NotFound().left()
val job = BackupTierDowngradeCheckJob.create(FREE_TIER)
val restored = BackupTierDowngradeCheckJob.Factory().create(job.parameters, job.serialize())
val result = restored.run()
assertTrue(result.isSuccess)
verify { signalStore.backup.backupTier = MessageBackupTier.FREE }
}
@Test
fun `given we are not registered, when I run, then I leave our tier alone`() {
every { signalStore.account.isRegistered } returns false
val result = BackupTierDowngradeCheckJob.create(FREE_TIER).run()
assertTrue(result.isSuccess)
verify(exactly = 0) { signalStore.backup.backupTier = any() }
}
@Test
fun `given we are the primary, when I run, then I leave our tier alone`() {
every { signalStore.account.isPrimaryDevice } returns true
val result = BackupTierDowngradeCheckJob.create(FREE_TIER).run()
assertTrue(result.isSuccess)
verify(exactly = 0) { signalStore.backup.backupTier = any() }
}
}
@@ -35,6 +35,7 @@ import org.signal.core.util.logging.Log
import org.signal.core.util.update
import org.signal.core.util.withinTransaction
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.database.IssueReporter
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.SignalDatabase
@@ -54,6 +55,7 @@ import org.thoughtcrime.securesms.testutil.SystemOutLogger
import org.thoughtcrime.securesms.util.RemoteConfig
import org.whispersystems.signalservice.api.storage.SignalStorageRecord
import org.whispersystems.signalservice.api.storage.StorageId
import org.whispersystems.signalservice.internal.storage.protos.AccountRecord
import org.whispersystems.signalservice.internal.storage.protos.ContactRecord
import org.whispersystems.signalservice.internal.storage.protos.GroupV1Record
import org.whispersystems.signalservice.internal.storage.protos.StickerPackRecord
@@ -375,6 +377,81 @@ class StorageSyncJobTest {
assertTrue(remoteStorage.manifest!!.storageIds.contains(StorageId.forAccount(selfStorageId!!)))
}
@Test
fun `given a linked device with a tier, when I write our account record, then I include our tier`() {
stubLinkedDevice()
every { recipients.signalStore.backup.areBackupsEnabled } returns true
every { recipients.signalStore.backup.backupTier } returns MessageBackupTier.PAID
markSelfNeedsSync()
val result = runJob(StorageSyncJob.forLocalChange())
assertTrue(result.isSuccess)
assertEquals(MessageBackupTier.PAID.toBackupLevel(), remoteAccountRecord().backupTier)
}
@Test
fun `given a linked device and a remote record with a higher tier, when I run, then I apply it`() {
stubLinkedDevice()
remoteStorage.setRemoteState(listOf(accountRecordWithBackupTier(MessageBackupTier.PAID.toBackupLevel())), version = BASE_MANIFEST_VERSION + 1)
val jobManager = AppDependencies.jobManager
val result = runJob(StorageSyncJob.forRemoteChange())
assertTrue(result.isSuccess)
verify { recipients.signalStore.backup.backupTier = MessageBackupTier.PAID }
verify(exactly = 0) { jobManager.add(ofType<BackupTierDowngradeCheckJob>()) }
}
@Test
fun `given a linked device and a remote record without a tier, when I run, then I keep our tier and confirm with the service`() {
stubLinkedDevice()
every { recipients.signalStore.backup.areBackupsEnabled } returns true
every { recipients.signalStore.backup.backupTier } returns MessageBackupTier.PAID
remoteStorage.setRemoteState(listOf(accountRecordWithBackupTier(null)), version = BASE_MANIFEST_VERSION + 1)
val jobManager = AppDependencies.jobManager
val result = runJob(StorageSyncJob.forRemoteChange())
assertTrue(result.isSuccess)
verify(exactly = 0) { recipients.signalStore.backup.backupTier = null }
verify { jobManager.add(ofType<BackupTierDowngradeCheckJob>()) }
}
@Test
fun `given a linked device on paid and a remote record on free, when I run, then I keep our tier and confirm with the service`() {
stubLinkedDevice()
every { recipients.signalStore.backup.areBackupsEnabled } returns true
every { recipients.signalStore.backup.backupTier } returns MessageBackupTier.PAID
remoteStorage.setRemoteState(listOf(accountRecordWithBackupTier(MessageBackupTier.FREE.toBackupLevel())), version = BASE_MANIFEST_VERSION + 1)
val jobManager = AppDependencies.jobManager
val result = runJob(StorageSyncJob.forRemoteChange())
assertTrue(result.isSuccess)
verify(exactly = 0) { recipients.signalStore.backup.backupTier = MessageBackupTier.FREE }
verify { jobManager.add(ofType<BackupTierDowngradeCheckJob>()) }
}
@Test
fun `given a primary with a tier and a remote record without one, when I run, then I write our tier back and confirm nothing`() {
every { recipients.signalStore.backup.areBackupsEnabled } returns true
every { recipients.signalStore.backup.backupTier } returns MessageBackupTier.PAID
remoteStorage.setRemoteState(listOf(accountRecordWithBackupTier(null)), version = BASE_MANIFEST_VERSION + 1)
val jobManager = AppDependencies.jobManager
val result = runJob(StorageSyncJob.forRemoteChange())
assertTrue(result.isSuccess)
assertEquals(MessageBackupTier.PAID.toBackupLevel(), remoteAccountRecord().backupTier)
verify(exactly = 0) { jobManager.add(ofType<BackupTierDowngradeCheckJob>()) }
}
@Test
fun `given a contact was unregistered long ago, when I run, then I remove their storage id`() {
val contact = recipients.createRecipient("Local Contact")
@@ -669,4 +746,29 @@ class StorageSyncJobTest {
private fun bumpRemoteManifestWithoutTouchingAccountRecord() {
remoteStorage.addRemoteRecords(listOf(contactRecord(ACI.from(UUID.randomUUID()), ProfileName.fromParts("Remote", "Contact"))))
}
private fun markSelfNeedsSync() {
SignalDatabase.recipients.markNeedsSync(recipients.self)
Recipient.self().live().refresh()
}
private fun remoteAccountRecord(): AccountRecord {
return remoteStorage.records.mapNotNull { it.proto.account }.single()
}
private fun stubLinkedDevice() {
every { recipients.signalStore.account.isLinkedDevice } returns true
every { recipients.signalStore.account.isPrimaryDevice } returns false
every { recipients.signalStore.account.restoredAccountEntropyPoolFromPrimary } returns true
}
/** Our account record as another device would have rewritten it: same contents, [backupTier] swapped in, under a fresh storage id so that we see it as remote-only. */
private fun accountRecordWithBackupTier(backupTier: Long?): SignalStorageRecord {
val record = StorageSyncHelper.buildAccountRecord(ApplicationProvider.getApplicationContext(), Recipient.self())
return record.copy(
id = StorageId.forAccount(StorageSyncHelper.generateKey()),
proto = record.proto.copy(account = record.proto.account!!.copy(backupTier = backupTier))
)
}
}
@@ -237,12 +237,6 @@ message AccountRecord {
}
}
message BackupTierHistory {
// See zkgroup for integer particular values. Unset if backups are not enabled.
optional uint64 backupTier = 1;
optional uint64 endedAtTimestamp = 2;
}
message NotificationProfileManualOverride {
message ManuallyEnabled {
bytes id = 1;
@@ -299,7 +293,7 @@ message AccountRecord {
optional uint64 backupTier = 40; // See zkgroup for integer particular values. Unset if backups are not enabled.
IAPSubscriberData backupSubscriberData = 41;
optional AvatarColor avatarColor = 42;
BackupTierHistory backupTierHistory = 43;
reserved /* backupTierHistory */ 43;
NotificationProfileManualOverride notificationProfileManualOverride = 44;
bool notificationProfileSyncDisabled = 45;
bool automaticKeyVerificationDisabled = 46;