Add rudimentary link+sync support.

This commit is contained in:
Cody Henthorne
2025-08-06 12:51:09 -04:00
parent 1a68b8768d
commit 7ca1ac4efb
10 changed files with 240 additions and 5 deletions

View File

@@ -59,6 +59,7 @@ import org.thoughtcrime.securesms.backup.ArchiveUploadProgress
import org.thoughtcrime.securesms.backup.DeletionState
import org.thoughtcrime.securesms.backup.RestoreState
import org.thoughtcrime.securesms.backup.v2.BackupRepository.copyAttachmentToArchive
import org.thoughtcrime.securesms.backup.v2.BackupRepository.exportForDebugging
import org.thoughtcrime.securesms.backup.v2.importer.ChatItemArchiveImporter
import org.thoughtcrime.securesms.backup.v2.processor.AccountDataArchiveProcessor
import org.thoughtcrime.securesms.backup.v2.processor.AdHocCallArchiveProcessor
@@ -141,6 +142,7 @@ import org.whispersystems.signalservice.api.backup.MediaName
import org.whispersystems.signalservice.api.backup.MediaRootBackupKey
import org.whispersystems.signalservice.api.backup.MessageBackupKey
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil
import org.whispersystems.signalservice.api.link.TransferArchiveResponse
import org.whispersystems.signalservice.api.messages.AttachmentTransferProgress
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener
import org.whispersystems.signalservice.api.push.ServiceId.ACI
@@ -1092,6 +1094,30 @@ object BackupRepository {
}
}
/**
* Imports a link and sync backup stored on the transit CDN.
*
* @param backupKey The key used to encrypt the backup. If `null`, we assume that the file is plaintext.
*/
fun importLinkAndSyncSignalBackup(
length: Long,
inputStreamFactory: () -> InputStream,
selfData: SelfData,
backupKey: MessageBackupKey,
cancellationSignal: () -> Boolean = { false }
): ImportResult {
val frameReader = EncryptedBackupReader.createForLocalOrLinking(
key = backupKey,
aci = selfData.aci,
length = length,
dataStream = inputStreamFactory
)
return frameReader.use { reader ->
import(reader, selfData, cancellationSignal)
}
}
/**
* Imports a backup that was exported via [exportForDebugging].
*/
@@ -2078,6 +2104,82 @@ object BackupRepository {
return RemoteRestoreResult.Success
}
suspend fun restoreLinkAndSyncBackup(response: TransferArchiveResponse, ephemeralBackupKey: MessageBackupKey) {
val context = AppDependencies.application
SignalStore.backup.restoreState = RestoreState.PENDING
try {
DataRestoreConstraint.isRestoringData = true
return withContext(Dispatchers.IO) {
return@withContext BackupProgressService.start(context, context.getString(R.string.BackupProgressService_title)).use {
restoreLinkAndSyncBackup(response, ephemeralBackupKey, controller = it, cancellationSignal = { !isActive })
}
}
} finally {
DataRestoreConstraint.isRestoringData = false
}
}
private fun restoreLinkAndSyncBackup(response: TransferArchiveResponse, ephemeralBackupKey: MessageBackupKey, controller: BackupProgressService.Controller, cancellationSignal: () -> Boolean): RemoteRestoreResult {
SignalStore.backup.restoreState = RestoreState.RESTORING_DB
val progressListener = object : ProgressListener {
override fun onAttachmentProgress(progress: AttachmentTransferProgress) {
controller.update(
title = AppDependencies.application.getString(R.string.BackupProgressService_title_downloading),
progress = progress.value,
indeterminate = false
)
EventBus.getDefault().post(RestoreV2Event(RestoreV2Event.Type.PROGRESS_DOWNLOAD, progress.transmitted, progress.total))
}
override fun shouldCancel() = cancellationSignal()
}
Log.i(TAG, "[restoreLinkAndSyncBackup] Downloading backup")
val tempBackupFile = BlobProvider.getInstance().forNonAutoEncryptingSingleSessionOnDisk(AppDependencies.application)
when (val result = AppDependencies.signalServiceMessageReceiver.retrieveLinkAndSyncBackup(response.cdn, response.key, tempBackupFile, progressListener)) {
is NetworkResult.Success -> Log.i(TAG, "[restoreLinkAndSyncBackup] Download successful")
else -> {
Log.w(TAG, "[restoreLinkAndSyncBackup] Failed to download backup file", result.getCause())
return RemoteRestoreResult.NetworkError
}
}
if (cancellationSignal()) {
return RemoteRestoreResult.Canceled
}
controller.update(
title = AppDependencies.application.getString(R.string.BackupProgressService_title),
progress = 0f,
indeterminate = true
)
val self = Recipient.self()
val selfData = SelfData(self.aci.get(), self.pni.get(), self.e164.get(), ProfileKey(self.profileKey))
Log.i(TAG, "[restoreLinkAndSyncBackup] Importing backup")
val result = importLinkAndSyncSignalBackup(
length = tempBackupFile.length(),
inputStreamFactory = tempBackupFile::inputStream,
selfData = selfData,
backupKey = ephemeralBackupKey,
cancellationSignal = cancellationSignal
)
if (result == ImportResult.Failure) {
Log.w(TAG, "[restoreLinkAndSyncBackup] Failed to import backup")
return RemoteRestoreResult.Failure
}
SignalStore.backup.restoreState = RestoreState.RESTORING_MEDIA
AppDependencies.jobManager.add(BackupRestoreMediaJob())
Log.i(TAG, "[restoreLinkAndSyncBackup] Restore successful")
return RemoteRestoreResult.Success
}
private fun buildDebugInfo(): ByteString {
if (!RemoteConfig.internalUser) {
return ByteString.EMPTY

View File

@@ -52,6 +52,11 @@ class ArchiveBackupIdReservationJob private constructor(parameters: Parameters)
return Result.success()
}
if (SignalStore.account.isLinkedDevice) {
Log.i(TAG, "Linked device. Skipping.")
return Result.success()
}
return when (val result = BackupRepository.triggerBackupIdReservation()) {
is NetworkResult.Success -> Result.success()
is NetworkResult.NetworkError -> Result.retry(defaultBackoff())

View File

@@ -80,6 +80,11 @@ class BackupMessagesJob private constructor(
false
}
SignalStore.account.isLinkedDevice -> {
Log.i(TAG, "Backup not allowed: linked device.")
false
}
else -> true
}
}

View File

@@ -66,6 +66,11 @@ class PostRegistrationBackupRedemptionJob : CoroutineJob {
return Result.success()
}
if (SignalStore.account.isLinkedDevice) {
info("Linked device. Exiting.")
return Result.success()
}
if (SignalStore.backup.deletionState != DeletionState.NONE) {
info("User is in the process of or has delete their backup. Exiting.")
return Result.success()

View File

@@ -12,6 +12,8 @@ import androidx.annotation.WorkerThread
import androidx.core.app.NotificationManagerCompat
import com.google.android.gms.auth.api.phone.SmsRetriever
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
@@ -40,6 +42,7 @@ import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob
import org.thoughtcrime.securesms.jobs.RotateCertificateJob
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.notifications.NotificationIds
import org.thoughtcrime.securesms.pin.Svr3Migration
import org.thoughtcrime.securesms.pin.SvrRepository
@@ -76,6 +79,7 @@ import org.whispersystems.signalservice.api.backup.MediaRootBackupKey
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess
import org.whispersystems.signalservice.api.kbs.MasterKey
import org.whispersystems.signalservice.api.kbs.PinHashUtil
import org.whispersystems.signalservice.api.link.TransferArchiveResponse
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.push.ServiceId.PNI
@@ -93,6 +97,8 @@ import java.util.Locale
import java.util.Optional
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.coroutines.coroutineContext
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
@@ -253,6 +259,7 @@ object RegistrationRepository {
// TODO [linked-device] May want to have a different opt out mechanism for linked devices
SvrRepository.optOutOfPin()
SignalStore.account.isMultiDevice = true
SignalStore.registration.hasUploadedProfile = true
jobManager.runJobBlocking(RefreshOwnProfileJob(), 30.seconds)
@@ -676,6 +683,51 @@ object RegistrationRepository {
return Recipient.self().profileName.isEmpty || !AvatarHelper.hasAvatar(AppDependencies.application, Recipient.self().id)
}
suspend fun waitForLinkAndSyncBackupDetails(maxWaitTime: Duration = 60.seconds): TransferArchiveResponse? {
val startTime = System.currentTimeMillis()
var timeRemaining = maxWaitTime.inWholeMilliseconds
while (timeRemaining > 0 && coroutineContext.isActive) {
Log.d(TAG, "[waitForLinkAndSyncBackupDetails] Willing to wait for $timeRemaining ms...")
when (val result = SignalNetwork.linkDevice.waitForPrimaryDevice(timeout = 60.seconds)) {
is NetworkResult.Success -> {
Log.i(TAG, "[waitForLinkAndSyncBackupDetails] Transfer archive data provided by primary")
return result.result
}
is NetworkResult.ApplicationError -> {
Log.e(TAG, "[waitForLinkAndSyncBackupDetails] Application error!", result.throwable)
throw result.throwable
}
is NetworkResult.NetworkError -> {
Log.w(TAG, "[waitForLinkAndSyncBackupDetails] Hit a network error while waiting for linking. Will try to wait again.", result.exception)
}
is NetworkResult.StatusCodeError -> {
when (result.code) {
400 -> {
Log.w(TAG, "[waitForLinkAndSyncBackupDetails] Invalid timeout!")
return null
}
429 -> {
Log.w(TAG, "[waitForLinkAndSyncBackupDetails] Hit a rate-limit. Will try to wait again after delay: ${result.retryAfter()}.")
result.retryAfter()?.let { retryAfter ->
delay(retryAfter)
}
}
else -> {
Log.w(TAG, "[waitForLinkAndSyncBackupDetails] Hit an unknown status code of ${result.code}. Will try to wait again.")
}
}
}
}
timeRemaining = maxWaitTime.inWholeMilliseconds - (System.currentTimeMillis() - startTime)
}
Log.w(TAG, "[waitForLinkAndSyncBackupDetails] Failed to get transfer archive data from primary")
return null
}
fun interface MasterKeyProducer {
@Throws(IOException::class, SvrWrongPinException::class, SvrNoDataException::class)
fun produceMasterKey(): MasterKey

View File

@@ -85,6 +85,7 @@ import org.thoughtcrime.securesms.util.dualsim.MccMncProducer
import org.whispersystems.signalservice.api.AccountEntropyPool
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.SvrNoDataException
import org.whispersystems.signalservice.api.backup.MessageBackupKey
import org.whispersystems.signalservice.api.kbs.MasterKey
import org.whispersystems.signalservice.api.messages.multidevice.RequestMessage
import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage
@@ -1134,6 +1135,19 @@ class RegistrationViewModel : ViewModel() {
refreshRemoteConfig()
if (message.ephemeralBackupKey != null) {
Log.i(TAG, "Primary has given Linked device an ephemeral backup key, waiting for backup...")
val result = RegistrationRepository.waitForLinkAndSyncBackupDetails()
if (result != null) {
BackupRepository.restoreLinkAndSyncBackup(result, MessageBackupKey(message.ephemeralBackupKey!!.toByteArray()))
} else {
Log.w(TAG, "Unable to get transfer archive data, continuing with linking process")
}
// TODO [linked-device] Reapply opt-out, backup restore sets pin, may want to have a different opt out mechanism for linked devices
SvrRepository.optOutOfPin()
}
for (type in SyncMessage.Request.Type.entries) {
if (type == SyncMessage.Request.Type.UNKNOWN) {
continue

View File

@@ -7,7 +7,6 @@
package org.whispersystems.signalservice.api;
import org.signal.core.util.StreamUtil;
import org.signal.core.util.logging.Log;
import org.signal.libsignal.protocol.InvalidMessageException;
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.whispersystems.signalservice.api.backup.MediaRootBackupKey;
@@ -17,6 +16,7 @@ import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil;
import org.whispersystems.signalservice.api.crypto.ProfileCipherInputStream;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId;
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceStickerManifest;
import org.whispersystems.signalservice.api.push.exceptions.MissingConfigurationException;
@@ -38,6 +38,8 @@ import java.util.Map;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import kotlin.Unit;
/**
* The primary interface for receiving Signal Service messages.
*
@@ -200,6 +202,16 @@ public class SignalServiceMessageReceiver {
socket.retrieveBackup(cdnNumber, headers, cdnPath, destination, 1_000_000_000L, listener);
}
/**
* Retrieves a link+sync backup file. The data is written to @{code destination}.
*/
public @Nonnull NetworkResult<Unit> retrieveLinkAndSyncBackup(int cdn, @Nonnull String key, @Nonnull File destination, @Nullable ProgressListener listener) {
return NetworkResult.fromFetch(() -> {
socket.retrieveAttachment(cdn, Collections.emptyMap(), new SignalServiceAttachmentRemoteId.V4(key), destination, 1_000_000_000L, listener);
return Unit.INSTANCE;
});
}
public @Nonnull ZonedDateTime getCdnLastModifiedTime(int cdnNumber, Map<String, String> headers, String cdnPath) throws MissingConfigurationException, IOException {
return socket.getCdnLastModifiedTime(cdnNumber, headers, cdnPath);
}

View File

@@ -212,4 +212,27 @@ class LinkDeviceApi(
val request = WebSocketRequestMessage.put("/v1/accounts/name?deviceId=$deviceId", SetDeviceNameRequest(encryptedDeviceName))
return NetworkResult.fromWebSocketRequest(authWebSocket, request)
}
/**
* A "long-polling" endpoint that will return once the primary device has successfully sent sync data.
*
* @param timeout The max amount of time to wait. Capped at 30 seconds.
*
* GET /v1/devices/transfer_archive?timeout=[timeout]
*
* - 200: Success, the primary device was sent backup sync data.
* - 204: The primary didn't provide data before the max waiting time elapsed.
* - 400: Invalid timeout.
* - 429: Rate-limited.
*/
fun waitForPrimaryDevice(timeout: Duration = 30.seconds): NetworkResult<TransferArchiveResponse> {
val request = WebSocketRequestMessage.get("/v1/devices/transfer_archive?timeout=${timeout.inWholeSeconds}")
return NetworkResult
.fromWebSocketRequest(
signalWebSocket = authWebSocket,
request = request,
timeout = timeout,
webSocketResponseConverter = NetworkResult.LongPollingWebSocketConverter(TransferArchiveResponse::class)
)
}
}

View File

@@ -0,0 +1,17 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.link
import com.fasterxml.jackson.annotation.JsonCreator
import com.fasterxml.jackson.annotation.JsonProperty
/**
* Data from primary on where to find link+sync backup file.
*/
data class TransferArchiveResponse @JsonCreator constructor(
@JsonProperty val cdn: Int,
@JsonProperty val key: String
)

View File

@@ -249,7 +249,7 @@ class ProvisioningSocket<T> private constructor(
private fun generateProvisioningUrl(deviceAddress: String): String {
val encodedDeviceId = URLEncoder.encode(deviceAddress, "UTF-8")
val encodedPubKey: String = URLEncoder.encode(Base64.encodeWithoutPadding(cipher.secondaryDevicePublicKey.serialize()), "UTF-8")
return "sgnl://${mode.host}?uuid=$encodedDeviceId&pub_key=$encodedPubKey"
return "sgnl://${mode.host}?uuid=$encodedDeviceId&pub_key=$encodedPubKey${mode.params}"
}
private suspend fun keepAlive(webSocket: WebSocket) {
@@ -288,9 +288,9 @@ class ProvisioningSocket<T> private constructor(
}
}
enum class Mode(val host: String) {
REREG("rereg"),
LINK("linkdevice")
enum class Mode(val host: String, val params: String) {
REREG("rereg", ""),
LINK("linkdevice", "&capabilities=backup4")
}
fun interface ProvisioningSocketExceptionHandler {