Add initial SVRB support.

This commit is contained in:
Greyson Parrelli
2025-08-01 16:33:57 -04:00
committed by Cody Henthorne
parent f6ab408fc8
commit 5aeca1deb1
29 changed files with 763 additions and 185 deletions

View File

@@ -9,6 +9,9 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.github.difflib.DiffUtils
import com.github.difflib.UnifiedDiffUtils
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@@ -23,6 +26,7 @@ import org.thoughtcrime.securesms.backup.v2.proto.Frame
import org.thoughtcrime.securesms.backup.v2.stream.PlainTextBackupReader
import org.thoughtcrime.securesms.database.KeyValueDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.InternalValues
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.whispersystems.signalservice.api.push.ServiceId
import java.io.ByteArrayInputStream
@@ -44,6 +48,10 @@ class ArchiveImportExportTests {
@Before
fun setup() {
AppDependencies.jobManager.shutdown()
mockkObject(SignalStore)
every { SignalStore.internal } returns mockk<InternalValues>(relaxed = true) {
every { includeDebuglogInBackup } returns false
}
}
@Test
@@ -285,7 +293,7 @@ class ArchiveImportExportTests {
assertTrue(importResult is ImportResult.Success)
val success = importResult as ImportResult.Success
val generatedBackupData = BackupRepository.debugExport(plaintext = true, currentTime = success.backupTime)
val generatedBackupData = BackupRepository.exportInMemoryForTests(plaintext = true, currentTime = success.backupTime)
checkEquivalent(filename, inputFileBytes, generatedBackupData)?.let { return it }
return TestResult.Success(filename)
@@ -307,11 +315,10 @@ class ArchiveImportExportTests {
}
private fun import(importData: ByteArray): ImportResult {
return BackupRepository.import(
return BackupRepository.importPlaintextTest(
length = importData.size.toLong(),
inputStreamFactory = { ByteArrayInputStream(importData) },
selfData = BackupRepository.SelfData(SELF_ACI, SELF_PNI, SELF_E164, ProfileKey(SELF_PROFILE_KEY)),
backupKey = null
selfData = BackupRepository.SelfData(SELF_ACI, SELF_PNI, SELF_E164, ProfileKey(SELF_PROFILE_KEY))
)
}
@@ -328,13 +335,14 @@ class ArchiveImportExportTests {
return TestResult.Failure(testName, "Exported backup hit a validation error: ${e.message}")
}
if (importComparable.unknownFieldMessages.isNotEmpty()) {
return TestResult.Failure(testName, "Imported backup contains unknown fields: ${importComparable.unknownFieldMessages.contentToString()}")
}
if (exportComparable.unknownFieldMessages.isNotEmpty()) {
return TestResult.Failure(testName, "Imported backup contains unknown fields: ${importComparable.unknownFieldMessages.contentToString()}")
}
// Do we actually need this?
// if (importComparable.unknownFieldMessages.isNotEmpty()) {
// return TestResult.Failure(testName, "Imported backup contains unknown fields: ${importComparable.unknownFieldMessages.contentToString()}")
// }
//
// if (exportComparable.unknownFieldMessages.isNotEmpty()) {
// return TestResult.Failure(testName, "Imported backup contains unknown fields: ${importComparable.unknownFieldMessages.contentToString()}")
// }
val canonicalImport = importComparable.comparableString
val canonicalExport = exportComparable.comparableString

View File

@@ -6,6 +6,7 @@
package org.thoughtcrime.securesms.backup.v2
import org.signal.core.util.isNotNullOrBlank
import org.signal.libsignal.messagebackup.BackupForwardSecrecyToken
import org.signal.libsignal.messagebackup.MessageBackup
import org.signal.libsignal.messagebackup.ValidationError
import org.thoughtcrime.securesms.database.SignalDatabase
@@ -22,17 +23,25 @@ import org.signal.libsignal.messagebackup.MessageBackupKey as LibSignalMessageBa
object ArchiveValidator {
fun validateSignalBackup(backupFile: File, backupKey: MessageBackupKey, backupForwardSecrecyToken: BackupForwardSecrecyToken): ValidationResult {
return validate(backupFile, backupKey, backupForwardSecrecyToken, forTransfer = false)
}
fun validateLocalOrLinking(backupFile: File, backupKey: MessageBackupKey, forTransfer: Boolean): ValidationResult {
return validate(backupFile, backupKey, forwardSecrecyToken = null, forTransfer)
}
/**
* Validates the provided [backupFile] that is encrypted with the provided [backupKey].
*/
fun validate(backupFile: File, backupKey: MessageBackupKey, forTransfer: Boolean): ValidationResult {
fun validate(backupFile: File, backupKey: MessageBackupKey, forwardSecrecyToken: BackupForwardSecrecyToken?, forTransfer: Boolean): ValidationResult {
return try {
val backupId = backupKey.deriveBackupId(SignalStore.account.requireAci())
val libSignalBackupKey = LibSignalBackupKey(backupKey.value)
val backupKey = LibSignalMessageBackupKey(libSignalBackupKey, backupId.value)
val libSignalMessageBackupKey = LibSignalMessageBackupKey(libSignalBackupKey, backupId.value, forwardSecrecyToken)
MessageBackup.validate(
backupKey,
libSignalMessageBackupKey,
if (forTransfer) MessageBackup.Purpose.DEVICE_TRANSFER else MessageBackup.Purpose.REMOTE_BACKUP,
{ backupFile.inputStream() },
backupFile.length()

View File

@@ -39,12 +39,14 @@ import org.signal.core.util.getAllTableDefinitions
import org.signal.core.util.getAllTriggerDefinitions
import org.signal.core.util.getForeignKeyViolations
import org.signal.core.util.logging.Log
import org.signal.core.util.logging.logW
import org.signal.core.util.money.FiatMoney
import org.signal.core.util.requireIntOrNull
import org.signal.core.util.requireNonNullString
import org.signal.core.util.stream.NonClosingOutputStream
import org.signal.core.util.urlEncode
import org.signal.core.util.withinTransaction
import org.signal.libsignal.messagebackup.BackupForwardSecrecyToken
import org.signal.libsignal.zkgroup.VerificationFailedException
import org.signal.libsignal.zkgroup.backups.BackupLevel
import org.signal.libsignal.zkgroup.profiles.ProfileKey
@@ -144,8 +146,10 @@ import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.Pro
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.push.ServiceId.PNI
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
import org.whispersystems.signalservice.api.svr.SvrBApi
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
import org.whispersystems.signalservice.internal.push.AttachmentUploadForm
import org.whispersystems.signalservice.internal.push.AuthCredentials
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
import java.io.ByteArrayOutputStream
import java.io.File
@@ -722,20 +726,27 @@ object BackupRepository {
}
@WorkerThread
fun localExport(
fun exportForLocalBackup(
main: OutputStream,
localBackupProgressEmitter: ExportProgressListener,
cancellationSignal: () -> Boolean = { false },
archiveAttachment: (AttachmentTable.LocalArchivableAttachment, () -> InputStream?) -> Unit
) {
val writer = EncryptedBackupWriter(
val writer = EncryptedBackupWriter.createForLocalOrLinking(
key = SignalStore.backup.messageBackupKey,
aci = SignalStore.account.aci!!,
outputStream = NonClosingOutputStream(main),
append = { main.write(it) }
)
export(currentTime = System.currentTimeMillis(), isLocal = true, writer = writer, progressEmitter = localBackupProgressEmitter, cancellationSignal = cancellationSignal, mediaBackupEnabled = false) { dbSnapshot ->
export(
currentTime = System.currentTimeMillis(),
isLocal = true,
writer = writer,
progressEmitter = localBackupProgressEmitter,
cancellationSignal = cancellationSignal,
forTransfer = false
) { dbSnapshot ->
val localArchivableAttachments = dbSnapshot
.attachmentTable
.getLocalArchivableAttachments()
@@ -758,24 +769,86 @@ object BackupRepository {
}
}
/**
* Export a backup that will be uploaded to the archive CDN.
*/
fun exportForSignalBackup(
outputStream: OutputStream,
append: (ByteArray) -> Unit,
messageBackupKey: MessageBackupKey,
forwardSecrecyToken: BackupForwardSecrecyToken,
forwardSecrecyMetadata: ByteArray,
currentTime: Long,
progressEmitter: ExportProgressListener? = null,
cancellationSignal: () -> Boolean = { false },
extraExportOperations: ((SignalDatabase) -> Unit)?
) {
val writer = EncryptedBackupWriter.createForSignalBackup(
key = messageBackupKey,
aci = SignalStore.account.aci!!,
outputStream = outputStream,
forwardSecrecyToken = forwardSecrecyToken,
forwardSecrecyMetadata = forwardSecrecyMetadata,
append = append
)
return export(
currentTime = currentTime,
isLocal = false,
writer = writer,
forTransfer = false,
progressEmitter = progressEmitter,
cancellationSignal = cancellationSignal,
extraExportOperations = extraExportOperations
)
}
/**
* Export a backup that will be uploaded to the archive CDN.
*/
fun exportForLinkAndSync(
outputStream: OutputStream,
append: (ByteArray) -> Unit,
messageBackupKey: MessageBackupKey,
currentTime: Long,
progressEmitter: ExportProgressListener? = null,
cancellationSignal: () -> Boolean = { false }
) {
val writer = EncryptedBackupWriter.createForLocalOrLinking(
key = messageBackupKey,
aci = SignalStore.account.aci!!,
outputStream = outputStream,
append = append
)
return export(
currentTime = currentTime,
isLocal = false,
writer = writer,
forTransfer = true,
progressEmitter = progressEmitter,
cancellationSignal = cancellationSignal,
extraExportOperations = null
)
}
@WorkerThread
@JvmOverloads
fun export(
fun exportForDebugging(
outputStream: OutputStream,
append: (ByteArray) -> Unit,
messageBackupKey: MessageBackupKey = SignalStore.backup.messageBackupKey,
plaintext: Boolean = false,
currentTime: Long = System.currentTimeMillis(),
skipMediaBackup: Boolean = false,
forTransfer: Boolean = false,
progressEmitter: ExportProgressListener? = null,
cancellationSignal: () -> Boolean = { false },
exportExtras: ((SignalDatabase) -> Unit)? = null
extraExportOperations: ((SignalDatabase) -> Unit)? = null
) {
val writer: BackupExportWriter = if (plaintext) {
PlainTextBackupWriter(outputStream)
} else {
EncryptedBackupWriter(
EncryptedBackupWriter.createForLocalOrLinking(
key = messageBackupKey,
aci = SignalStore.account.aci!!,
outputStream = outputStream,
@@ -783,21 +856,14 @@ object BackupRepository {
)
}
val mediaBackupEnabled = if (skipMediaBackup || !SignalStore.backup.areBackupsEnabled) {
false
} else {
getBackupTier().successOrThrow() == MessageBackupTier.PAID
}
export(
currentTime = currentTime,
isLocal = false,
writer = writer,
progressEmitter = progressEmitter,
mediaBackupEnabled = mediaBackupEnabled,
forTransfer = forTransfer,
progressEmitter = progressEmitter,
cancellationSignal = cancellationSignal,
exportExtras = exportExtras
extraExportOperations = extraExportOperations
)
}
@@ -805,9 +871,9 @@ object BackupRepository {
* Exports to a blob in memory. Should only be used for testing.
*/
@WorkerThread
fun debugExport(plaintext: Boolean = false, currentTime: Long = System.currentTimeMillis()): ByteArray {
fun exportInMemoryForTests(plaintext: Boolean = false, currentTime: Long = System.currentTimeMillis()): ByteArray {
val outputStream = ByteArrayOutputStream()
export(outputStream = outputStream, append = { mac -> outputStream.write(mac) }, plaintext = plaintext, currentTime = currentTime, skipMediaBackup = true)
exportForDebugging(outputStream = outputStream, append = { mac -> outputStream.write(mac) }, plaintext = plaintext, currentTime = currentTime)
return outputStream.toByteArray()
}
@@ -816,11 +882,10 @@ object BackupRepository {
currentTime: Long,
isLocal: Boolean,
writer: BackupExportWriter,
mediaBackupEnabled: Boolean,
forTransfer: Boolean = false,
progressEmitter: ExportProgressListener? = null,
cancellationSignal: () -> Boolean = { false },
exportExtras: ((SignalDatabase) -> Unit)? = null
forTransfer: Boolean,
progressEmitter: ExportProgressListener?,
cancellationSignal: () -> Boolean,
extraExportOperations: ((SignalDatabase) -> Unit)?
) {
val eventTimer = EventTimer()
val mainDbName = if (isLocal) LOCAL_MAIN_DB_SNAPSHOT_NAME else REMOTE_MAIN_DB_SNAPSHOT_NAME
@@ -833,7 +898,7 @@ object BackupRepository {
val signalStoreSnapshot: SignalStore = createSignalStoreSnapshot(keyValueDbName)
eventTimer.emit("store-db-snapshot")
val exportState = ExportState(backupTime = currentTime, mediaBackupEnabled = mediaBackupEnabled, forTransfer = forTransfer)
val exportState = ExportState(backupTime = currentTime, forTransfer = forTransfer)
val selfAci = signalStoreSnapshot.accountValues.aci!!
val selfRecipientId = dbSnapshot.recipientTable.getByAci(selfAci).get().toLong().let { RecipientId.from(it) }
@@ -953,7 +1018,7 @@ object BackupRepository {
}
}
exportExtras?.invoke(dbSnapshot)
extraExportOperations?.invoke(dbSnapshot)
Log.d(TAG, "[export] totalFrames: $frameCount | ${eventTimer.stop().summary}")
} finally {
@@ -962,11 +1027,14 @@ object BackupRepository {
}
}
fun localImport(mainStreamFactory: () -> InputStream, mainStreamLength: Long, selfData: SelfData): ImportResult {
/**
* Imports a local backup file that was exported to disk.
*/
fun importLocal(mainStreamFactory: () -> InputStream, mainStreamLength: Long, selfData: SelfData): ImportResult {
val backupKey = SignalStore.backup.messageBackupKey
val frameReader = try {
EncryptedBackupReader(
EncryptedBackupReader.createForLocalOrLinking(
key = backupKey,
aci = selfData.aci,
length = mainStreamLength,
@@ -983,9 +1051,39 @@ object BackupRepository {
}
/**
* Imports a backup stored on the archive CDN.
*
* @param backupKey The key used to encrypt the backup. If `null`, we assume that the file is plaintext.
*/
fun import(
fun importSignalBackup(
length: Long,
inputStreamFactory: () -> InputStream,
selfData: SelfData,
backupKey: MessageBackupKey?,
forwardSecrecyToken: BackupForwardSecrecyToken,
cancellationSignal: () -> Boolean = { false }
): ImportResult {
val frameReader = if (backupKey == null) {
PlainTextBackupReader(inputStreamFactory(), length)
} else {
EncryptedBackupReader.createForSignalBackup(
key = backupKey,
aci = selfData.aci,
forwardSecrecyToken = forwardSecrecyToken,
length = length,
dataStream = inputStreamFactory
)
}
return frameReader.use { reader ->
import(reader, selfData, cancellationSignal)
}
}
/**
* Imports a backup that was exported via [exportForDebugging].
*/
fun importForDebugging(
length: Long,
inputStreamFactory: () -> InputStream,
selfData: SelfData,
@@ -995,7 +1093,7 @@ object BackupRepository {
val frameReader = if (backupKey == null) {
PlainTextBackupReader(inputStreamFactory(), length)
} else {
EncryptedBackupReader(
EncryptedBackupReader.createForLocalOrLinking(
key = backupKey,
aci = selfData.aci,
length = length,
@@ -1008,6 +1106,22 @@ object BackupRepository {
}
}
/**
* Imports a plaintext backup only used for testing.
*/
fun importPlaintextTest(
length: Long,
inputStreamFactory: () -> InputStream,
selfData: SelfData,
cancellationSignal: () -> Boolean = { false }
): ImportResult {
val frameReader = PlainTextBackupReader(inputStreamFactory(), length)
return frameReader.use { reader ->
import(reader, selfData, cancellationSignal)
}
}
private fun import(
frameReader: BackupImportReader,
selfData: SelfData,
@@ -1732,6 +1846,14 @@ object BackupRepository {
}
}
/**
* See [org.whispersystems.signalservice.api.archive.ArchiveApi.getSvrBAuthorization].
*/
fun getSvrBAuth(): NetworkResult<AuthCredentials> {
return initBackupAndFetchAuth()
.then { SignalNetwork.archive.getSvrBAuthorization(SignalStore.account.requireAci(), it.messageBackupAccess) }
}
/**
* During normal operation, ensures that the backupId has been reserved and that your public key has been set,
* while also returning an archive access data. Should be the basis of all backup operations.
@@ -1887,10 +2009,50 @@ object BackupRepository {
indeterminate = true
)
val forwardSecrecyMetadata = EncryptedBackupReader.readForwardSecrecyMetadata(tempBackupFile.inputStream())
if (forwardSecrecyMetadata == null) {
Log.w(TAG, "Failed to read forward secrecy metadata!")
return RemoteRestoreResult.Failure
}
val messageBackupKey = SignalStore.backup.messageBackupKey
Log.i(TAG, "[remoteRestore] Fetching SVRB data")
val svrBAuth = when (val result = BackupRepository.getSvrBAuth()) {
is NetworkResult.Success -> result.result
is NetworkResult.NetworkError -> return RemoteRestoreResult.NetworkError.logW(TAG, "[remoteRestore] Network error when getting SVRB auth.", result.getCause())
is NetworkResult.StatusCodeError -> return RemoteRestoreResult.NetworkError.logW(TAG, "[remoteRestore] Status code error when getting SVRB auth.", result.getCause())
is NetworkResult.ApplicationError -> throw result.throwable
}
val forwardSecrecyToken = when (val result = SignalNetwork.svrB.restore(svrBAuth, messageBackupKey, forwardSecrecyMetadata)) {
is SvrBApi.RestoreResult.Success -> {
SignalStore.backup.nextBackupSecretData = result.data.nextBackupSecretData
result.data.forwardSecrecyToken
}
is SvrBApi.RestoreResult.NetworkError -> {
return RemoteRestoreResult.NetworkError.logW(TAG, "[remoteRestore] Network error during SVRB.", result.exception)
}
SvrBApi.RestoreResult.DataMissingError,
is SvrBApi.RestoreResult.RestoreFailedError,
is SvrBApi.RestoreResult.SvrError,
is SvrBApi.RestoreResult.UnknownError -> {
Log.w(TAG, "[remoteRestore] Failed to fetch SVRB data: $result")
return RemoteRestoreResult.Failure
}
}
val self = Recipient.self()
val selfData = SelfData(self.aci.get(), self.pni.get(), self.e164.get(), ProfileKey(self.profileKey))
Log.i(TAG, "[remoteRestore] Importing backup")
val result = import(length = tempBackupFile.length(), inputStreamFactory = tempBackupFile::inputStream, selfData = selfData, backupKey = SignalStore.backup.messageBackupKey, cancellationSignal = cancellationSignal)
val result = importSignalBackup(
length = tempBackupFile.length(),
inputStreamFactory = tempBackupFile::inputStream,
selfData = selfData,
backupKey = SignalStore.backup.messageBackupKey,
forwardSecrecyToken = forwardSecrecyToken,
cancellationSignal = cancellationSignal
)
if (result == ImportResult.Failure) {
Log.w(TAG, "[remoteRestore] Failed to import backup")
return RemoteRestoreResult.Failure
@@ -1962,7 +2124,6 @@ data class ArchivedMediaObject(val mediaId: String, val cdn: Int)
class ExportState(
val backupTime: Long,
val mediaBackupEnabled: Boolean,
val forTransfer: Boolean
) {
val recipientIds: MutableSet<Long> = hashSetOf()

View File

@@ -23,7 +23,7 @@ import kotlin.time.Duration.Companion.days
private val TAG = "MessageTableArchiveExtensions"
fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, mediaBackupEnabled: Boolean, selfRecipientId: RecipientId, exportState: ExportState): ChatItemArchiveExporter {
fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, selfRecipientId: RecipientId, exportState: ExportState): ChatItemArchiveExporter {
// We create a covering index for the query to drastically speed up perf here.
// Remember that we're working on a temporary snapshot of the database, so we can create an index and not worry about cleaning it up.
val startTime = System.currentTimeMillis()
@@ -88,11 +88,10 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, medi
return ChatItemArchiveExporter(
db = db,
backupStartTime = backupTime,
batchSize = 10_000,
mediaArchiveEnabled = mediaBackupEnabled,
selfRecipientId = selfRecipientId,
noteToSelfThreadId = db.threadTable.getThreadIdFor(selfRecipientId) ?: -1L,
backupStartTime = backupTime,
batchSize = 10_000,
exportState = exportState,
cursorGenerator = { lastSeenReceivedTime, count ->
readableDatabase

View File

@@ -127,7 +127,6 @@ class ChatItemArchiveExporter(
private val noteToSelfThreadId: Long,
private val backupStartTime: Long,
private val batchSize: Int,
private val mediaArchiveEnabled: Boolean,
private val exportState: ExportState,
private val cursorGenerator: (Long, Int) -> Cursor
) : Iterator<ChatItem?>, Closeable {
@@ -353,7 +352,7 @@ class ChatItemArchiveExporter(
}
!record.sharedContacts.isNullOrEmpty() -> {
builder.contactMessage = record.toRemoteContactMessage(mediaArchiveEnabled = mediaArchiveEnabled, reactionRecords = extraData.reactionsById[id], attachments = extraData.attachmentsById[id]) ?: continue
builder.contactMessage = record.toRemoteContactMessage(reactionRecords = extraData.reactionsById[id], attachments = extraData.attachmentsById[id]) ?: continue
transformTimer.emit("contact")
}
@@ -367,7 +366,7 @@ class ChatItemArchiveExporter(
Log.w(TAG, ExportSkips.directStoryReplyInNoteToSelf(record.dateSent))
continue
}
builder.directStoryReplyMessage = record.toRemoteDirectStoryReplyMessage(mediaArchiveEnabled = mediaArchiveEnabled, reactionRecords = extraData.reactionsById[id], attachments = extraData.attachmentsById[record.id]) ?: continue
builder.directStoryReplyMessage = record.toRemoteDirectStoryReplyMessage(reactionRecords = extraData.reactionsById[id], attachments = extraData.attachmentsById[record.id]) ?: continue
transformTimer.emit("story")
}
@@ -376,11 +375,10 @@ class ChatItemArchiveExporter(
val sticker = attachments?.firstOrNull { dbAttachment -> dbAttachment.isSticker }
if (sticker?.stickerLocator != null) {
builder.stickerMessage = sticker.toRemoteStickerMessage(sentTimestamp = record.dateSent, mediaArchiveEnabled = mediaArchiveEnabled, reactions = extraData.reactionsById[id])
builder.stickerMessage = sticker.toRemoteStickerMessage(sentTimestamp = record.dateSent, reactions = extraData.reactionsById[id])
} else {
val standardMessage = record.toRemoteStandardMessage(
exportState = exportState,
mediaArchiveEnabled = mediaArchiveEnabled,
reactionRecords = extraData.reactionsById[id],
mentions = extraData.mentionsById[id],
attachments = extraData.attachmentsById[record.id]
@@ -856,11 +854,11 @@ private fun BackupMessageRecord.toRemoteLinkPreviews(attachments: List<DatabaseA
return emptyList()
}
private fun LinkPreview.toRemoteLinkPreview(mediaArchiveEnabled: Boolean): org.thoughtcrime.securesms.backup.v2.proto.LinkPreview {
private fun LinkPreview.toRemoteLinkPreview(): org.thoughtcrime.securesms.backup.v2.proto.LinkPreview {
return org.thoughtcrime.securesms.backup.v2.proto.LinkPreview(
url = url,
title = title.nullIfEmpty(),
image = (thumbnail.orNull() as? DatabaseAttachment)?.toRemoteMessageAttachment(mediaArchiveEnabled)?.pointer,
image = (thumbnail.orNull() as? DatabaseAttachment)?.toRemoteMessageAttachment()?.pointer,
description = description.nullIfEmpty(),
date = date.clampToValidBackupRange()
)
@@ -871,7 +869,7 @@ private fun BackupMessageRecord.toRemoteViewOnceMessage(exportState: ExportState
attachments
?.firstOrNull()
?.takeUnless { !it.hasData && it.size == 0L && it.remoteDigest == null && it.width == 0 && it.height == 0 && it.blurHash == null }
?.toRemoteMessageAttachment(mediaArchiveEnabled = false)
?.toRemoteMessageAttachment()
} else {
null
}
@@ -882,13 +880,13 @@ private fun BackupMessageRecord.toRemoteViewOnceMessage(exportState: ExportState
)
}
private fun BackupMessageRecord.toRemoteContactMessage(mediaArchiveEnabled: Boolean, reactionRecords: List<ReactionRecord>?, attachments: List<DatabaseAttachment>?): ContactMessage? {
private fun BackupMessageRecord.toRemoteContactMessage(reactionRecords: List<ReactionRecord>?, attachments: List<DatabaseAttachment>?): ContactMessage? {
val sharedContact = toRemoteSharedContact(attachments) ?: return null
return ContactMessage(
contact = ContactAttachment(
name = sharedContact.name.toRemote(),
avatar = (sharedContact.avatar?.attachment as? DatabaseAttachment)?.toRemoteMessageAttachment(mediaArchiveEnabled)?.pointer,
avatar = (sharedContact.avatar?.attachment as? DatabaseAttachment)?.toRemoteMessageAttachment()?.pointer,
organization = sharedContact.organization ?: "",
number = sharedContact.phoneNumbers.mapNotNull { phone ->
ContactAttachment.Phone(
@@ -969,7 +967,7 @@ private fun Contact.PostalAddress.Type.toRemote(): ContactAttachment.PostalAddre
}
}
private fun BackupMessageRecord.toRemoteDirectStoryReplyMessage(mediaArchiveEnabled: Boolean, reactionRecords: List<ReactionRecord>?, attachments: List<DatabaseAttachment>?): DirectStoryReplyMessage? {
private fun BackupMessageRecord.toRemoteDirectStoryReplyMessage(reactionRecords: List<ReactionRecord>?, attachments: List<DatabaseAttachment>?): DirectStoryReplyMessage? {
if (this.body.isNullOrBlank()) {
Log.w(TAG, ExportSkips.directStoryReplyHasNoBody(this.dateSent))
return null
@@ -991,7 +989,7 @@ private fun BackupMessageRecord.toRemoteDirectStoryReplyMessage(mediaArchiveEnab
body = bodyText,
bodyRanges = this.bodyRanges?.toRemoteBodyRanges(this.dateSent) ?: emptyList()
),
longText = longTextAttachment?.toRemoteFilePointer(mediaArchiveEnabled)
longText = longTextAttachment?.toRemoteFilePointer()
)
} else {
null
@@ -1000,7 +998,7 @@ private fun BackupMessageRecord.toRemoteDirectStoryReplyMessage(mediaArchiveEnab
)
}
private fun BackupMessageRecord.toRemoteStandardMessage(exportState: ExportState, mediaArchiveEnabled: Boolean, reactionRecords: List<ReactionRecord>?, mentions: List<Mention>?, attachments: List<DatabaseAttachment>?): StandardMessage {
private fun BackupMessageRecord.toRemoteStandardMessage(exportState: ExportState, reactionRecords: List<ReactionRecord>?, mentions: List<Mention>?, attachments: List<DatabaseAttachment>?): StandardMessage {
val linkPreviews = this.toRemoteLinkPreviews(attachments)
val linkPreviewAttachments = linkPreviews.mapNotNull { it.thumbnail.orElse(null) }.toSet()
val quotedAttachments = attachments?.filter { it.quote } ?: emptyList()
@@ -1021,11 +1019,11 @@ private fun BackupMessageRecord.toRemoteStandardMessage(exportState: ExportState
}
return StandardMessage(
quote = this.toRemoteQuote(exportState, mediaArchiveEnabled, quotedAttachments),
quote = this.toRemoteQuote(exportState, quotedAttachments),
text = text.takeUnless { hasVoiceNote },
attachments = messageAttachments.toRemoteAttachments(mediaArchiveEnabled).withFixedVoiceNotes(textPresent = text != null || longTextAttachment != null),
linkPreview = linkPreviews.map { it.toRemoteLinkPreview(mediaArchiveEnabled) },
longText = longTextAttachment?.toRemoteFilePointer(mediaArchiveEnabled),
attachments = messageAttachments.toRemoteAttachments().withFixedVoiceNotes(textPresent = text != null || longTextAttachment != null),
linkPreview = linkPreviews.map { it.toRemoteLinkPreview() },
longText = longTextAttachment?.toRemoteFilePointer(),
reactions = reactionRecords.toRemote()
)
}
@@ -1064,7 +1062,7 @@ private fun BackupMessageRecord.getBodyText(attachments: List<DatabaseAttachment
return trimmed to null
}
private fun BackupMessageRecord.toRemoteQuote(exportState: ExportState, mediaArchiveEnabled: Boolean, attachments: List<DatabaseAttachment>? = null): Quote? {
private fun BackupMessageRecord.toRemoteQuote(exportState: ExportState, attachments: List<DatabaseAttachment>? = null): Quote? {
if (this.quoteTargetSentTimestamp == MessageTable.QUOTE_NOT_PRESENT_ID || this.quoteAuthor <= 0 || exportState.groupRecipientIds.contains(this.quoteAuthor)) {
return null
}
@@ -1091,7 +1089,7 @@ private fun BackupMessageRecord.toRemoteQuote(exportState: ExportState, mediaArc
val attachments = if (remoteType == Quote.Type.VIEW_ONCE) {
emptyList()
} else {
attachments?.toRemoteQuoteAttachments(mediaArchiveEnabled) ?: emptyList()
attachments?.toRemoteQuoteAttachments() ?: emptyList()
}
if (remoteType == Quote.Type.NORMAL && body == null && attachments.isEmpty()) {
@@ -1127,7 +1125,7 @@ private fun BackupMessageRecord.toRemoteGiftBadgeUpdate(): BackupGiftBadge? {
)
}
private fun DatabaseAttachment.toRemoteStickerMessage(sentTimestamp: Long, mediaArchiveEnabled: Boolean, reactions: List<ReactionRecord>?): StickerMessage? {
private fun DatabaseAttachment.toRemoteStickerMessage(sentTimestamp: Long, reactions: List<ReactionRecord>?): StickerMessage? {
val stickerLocator = this.stickerLocator!!
val packId = try {
@@ -1150,19 +1148,18 @@ private fun DatabaseAttachment.toRemoteStickerMessage(sentTimestamp: Long, media
packKey = packKey.toByteString(),
stickerId = stickerLocator.stickerId,
emoji = stickerLocator.emoji,
data_ = this.toRemoteMessageAttachment(mediaArchiveEnabled).pointer
data_ = this.toRemoteMessageAttachment().pointer
),
reactions = reactions.toRemote()
)
}
private fun List<DatabaseAttachment>.toRemoteQuoteAttachments(mediaArchiveEnabled: Boolean): List<Quote.QuotedAttachment> {
private fun List<DatabaseAttachment>.toRemoteQuoteAttachments(): List<Quote.QuotedAttachment> {
return this.map { attachment ->
Quote.QuotedAttachment(
contentType = attachment.contentType,
fileName = attachment.fileName,
thumbnail = attachment.toRemoteMessageAttachment(
mediaArchiveEnabled = mediaArchiveEnabled,
flagOverride = MessageAttachment.Flag.NONE,
contentTypeOverride = "image/jpeg"
)
@@ -1170,9 +1167,9 @@ private fun List<DatabaseAttachment>.toRemoteQuoteAttachments(mediaArchiveEnable
}
}
private fun DatabaseAttachment.toRemoteMessageAttachment(mediaArchiveEnabled: Boolean, flagOverride: MessageAttachment.Flag? = null, contentTypeOverride: String? = null): MessageAttachment {
private fun DatabaseAttachment.toRemoteMessageAttachment(flagOverride: MessageAttachment.Flag? = null, contentTypeOverride: String? = null): MessageAttachment {
return MessageAttachment(
pointer = this.toRemoteFilePointer(mediaArchiveEnabled, contentTypeOverride),
pointer = this.toRemoteFilePointer(contentTypeOverride),
wasDownloaded = this.transferState == AttachmentTable.TRANSFER_PROGRESS_DONE || this.transferState == AttachmentTable.TRANSFER_NEEDS_RESTORE,
flag = if (flagOverride != null) {
flagOverride
@@ -1189,9 +1186,9 @@ private fun DatabaseAttachment.toRemoteMessageAttachment(mediaArchiveEnabled: Bo
)
}
private fun List<DatabaseAttachment>.toRemoteAttachments(mediaArchiveEnabled: Boolean): List<MessageAttachment> {
private fun List<DatabaseAttachment>.toRemoteAttachments(): List<MessageAttachment> {
return this.map { attachment ->
attachment.toRemoteMessageAttachment(mediaArchiveEnabled)
attachment.toRemoteMessageAttachment()
}
}

View File

@@ -32,7 +32,7 @@ object ChatArchiveImporter {
val chatColor = chat.style?.toLocal(importState)
val wallpaperAttachmentId: AttachmentId? = chat.style?.wallpaperPhoto?.let { filePointer ->
filePointer.toLocalAttachment(importState)?.let {
filePointer.toLocalAttachment()?.let {
SignalDatabase.attachments.restoreWallpaperAttachment(it)
}
}

View File

@@ -350,7 +350,7 @@ class ChatItemArchiveImporter(
address.country
)
},
Contact.Avatar(null, backupContact.avatar.toLocalAttachment(importState = importState, voiceNote = false, borderless = false, gif = false, wasDownloaded = true), true)
Contact.Avatar(null, backupContact.avatar.toLocalAttachment(voiceNote = false, borderless = false, gif = false, wasDownloaded = true), true)
)
}
@@ -475,7 +475,7 @@ class ChatItemArchiveImporter(
*/
private fun StandardMessage.parseBodyText(importState: ImportState): Pair<String?, Attachment?> {
if (this.longText != null) {
return null to this.longText.toLocalAttachment(importState, contentType = "text/x-signal-plain")
return null to this.longText.toLocalAttachment(contentType = "text/x-signal-plain")
}
if (this.text?.body == null) {
@@ -499,7 +499,7 @@ class ChatItemArchiveImporter(
*/
private fun DirectStoryReplyMessage.parseBodyText(importState: ImportState): Pair<String?, Attachment?> {
if (this.textReply?.longText != null) {
return null to this.textReply.longText.toLocalAttachment(importState, contentType = "text/x-signal-plain")
return null to this.textReply.longText.toLocalAttachment(contentType = "text/x-signal-plain")
}
if (this.textReply?.text == null) {
@@ -1113,10 +1113,9 @@ class ChatItemArchiveImporter(
if (this == null) return null
return data_.toLocalAttachment(
importState = importState,
voiceNote = false,
gif = false,
borderless = false,
gif = false,
wasDownloaded = true,
stickerLocator = StickerLocator(
packId = Hex.toStringCondensed(packId.toByteArray()),
@@ -1133,16 +1132,15 @@ class ChatItemArchiveImporter(
this.title ?: "",
this.description ?: "",
this.date ?: 0,
Optional.ofNullable(this.image?.toLocalAttachment(importState = importState, voiceNote = false, borderless = false, gif = false, wasDownloaded = true))
Optional.ofNullable(this.image?.toLocalAttachment(voiceNote = false, borderless = false, gif = false, wasDownloaded = true))
)
}
private fun MessageAttachment.toLocalAttachment(quote: Boolean = false, contentType: String? = pointer?.contentType): Attachment? {
return pointer?.toLocalAttachment(
importState = importState,
voiceNote = flag == MessageAttachment.Flag.VOICE_MESSAGE,
gif = flag == MessageAttachment.Flag.GIF,
borderless = flag == MessageAttachment.Flag.BORDERLESS,
gif = flag == MessageAttachment.Flag.GIF,
wasDownloaded = wasDownloaded,
contentType = contentType,
fileName = pointer.fileName,

View File

@@ -58,9 +58,9 @@ object LocalArchiver {
val mediaNames: MutableSet<MediaName> = Collections.synchronizedSet(HashSet())
Log.i(TAG, "Starting frame export")
BackupRepository.localExport(mainStream, LocalExportProgressListener(), cancellationSignal) { attachment, source ->
BackupRepository.exportForLocalBackup(mainStream, LocalExportProgressListener(), cancellationSignal) { attachment, source ->
if (cancellationSignal()) {
return@localExport
return@exportForLocalBackup
}
val mediaName = MediaName.fromPlaintextHashAndRemoteKey(attachment.plaintextHash, attachment.remoteKey)
@@ -126,7 +126,7 @@ object LocalArchiver {
val mainStreamLength = snapshotFileSystem.mainLength() ?: return ArchiveResult.failure(FailureCause.MAIN_STREAM)
BackupRepository.localImport(
BackupRepository.importLocal(
mainStreamFactory = { snapshotFileSystem.mainInputStream()!! },
mainStreamLength = mainStreamLength,
selfData = selfData

View File

@@ -248,7 +248,7 @@ object AccountDataArchiveProcessor {
SignalStore.chatColors.chatColors = chatColors
val wallpaperAttachmentId: AttachmentId? = settings.defaultChatStyle.wallpaperPhoto?.let { filePointer ->
filePointer.toLocalAttachment(importState)?.let {
filePointer.toLocalAttachment()?.let {
SignalDatabase.attachments.restoreWallpaperAttachment(it)
}
}

View File

@@ -24,7 +24,7 @@ object ChatItemArchiveProcessor {
val TAG = Log.tag(ChatItemArchiveProcessor::class.java)
fun export(db: SignalDatabase, exportState: ExportState, selfRecipientId: RecipientId, cancellationSignal: () -> Boolean, emitter: BackupFrameEmitter) {
db.messageTable.getMessagesForBackup(db, exportState.backupTime, exportState.mediaBackupEnabled, selfRecipientId, exportState).use { chatItems ->
db.messageTable.getMessagesForBackup(db, exportState.backupTime, selfRecipientId, exportState).use { chatItems ->
var count = 0
while (chatItems.hasNext()) {
if (count % 1000 == 0 && cancellationSignal()) {

View File

@@ -5,16 +5,20 @@
package org.thoughtcrime.securesms.backup.v2.stream
import androidx.annotation.VisibleForTesting
import com.google.common.io.CountingInputStream
import org.signal.core.util.readFully
import org.signal.core.util.readNBytesOrThrow
import org.signal.core.util.readVarInt32
import org.signal.core.util.stream.LimitedInputStream
import org.signal.core.util.stream.MacInputStream
import org.signal.core.util.writeVarInt32
import org.signal.libsignal.messagebackup.BackupForwardSecrecyToken
import org.thoughtcrime.securesms.backup.v2.proto.BackupInfo
import org.thoughtcrime.securesms.backup.v2.proto.Frame
import org.whispersystems.signalservice.api.backup.MessageBackupKey
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import java.io.ByteArrayOutputStream
import java.io.EOFException
import java.io.IOException
import java.io.InputStream
@@ -30,25 +34,116 @@ import javax.crypto.spec.SecretKeySpec
* As it's being read, it will be both decrypted and uncompressed. Specifically, the data is decrypted,
* that decrypted data is gunzipped, then that data is read as frames.
*/
class EncryptedBackupReader(
class EncryptedBackupReader private constructor(
keyMaterial: MessageBackupKey.BackupKeyMaterial,
val length: Long,
dataStream: () -> InputStream
) : BackupImportReader {
@VisibleForTesting
val backupInfo: BackupInfo?
var next: Frame? = null
val stream: InputStream
val countingStream: CountingInputStream
private var next: Frame? = null
private val stream: InputStream
private val countingStream: CountingInputStream
constructor(key: MessageBackupKey, aci: ACI, length: Long, dataStream: () -> InputStream) :
this(key.deriveBackupSecrets(aci), length, dataStream) {
companion object {
const val MAC_SIZE = 32
/**
* Create a reader for a backup from the archive CDN.
* The key difference is that we require forward secrecy data.
*/
fun createForSignalBackup(
key: MessageBackupKey,
aci: ACI,
forwardSecrecyToken: BackupForwardSecrecyToken,
length: Long,
dataStream: () -> InputStream
): EncryptedBackupReader {
return EncryptedBackupReader(
keyMaterial = key.deriveBackupSecrets(aci, forwardSecrecyToken),
length = length,
dataStream = dataStream
)
}
/**
* Create a reader for a local backup or for a transfer to a linked device. Basically everything that isn't [createForSignalBackup].
* The key difference is that we don't require forward secrecy data.
*/
fun createForLocalOrLinking(key: MessageBackupKey, aci: ACI, length: Long, dataStream: () -> InputStream): EncryptedBackupReader {
return EncryptedBackupReader(
keyMaterial = key.deriveBackupSecrets(aci, forwardSecrecyToken = null),
length = length,
dataStream = dataStream
)
}
/**
* Returns the size of the entire forward secrecy prefix. Includes the magic number, varint, and the length of the forward secrecy metadata itself.
*/
fun getForwardSecrecyPrefixDataLength(stream: InputStream): Int {
val metadataLength = readForwardSecrecyMetadata(stream)?.size ?: return 0
return EncryptedBackupWriter.MAGIC_NUMBER.size + metadataLength.lengthAsVarInt32() + metadataLength
}
fun readForwardSecrecyMetadata(stream: InputStream): ByteArray? {
val potentialMagicNumber = stream.readNBytesOrThrow(8)
if (!EncryptedBackupWriter.MAGIC_NUMBER.contentEquals(potentialMagicNumber)) {
return null
}
val metadataLength = stream.readVarInt32()
return stream.readNBytesOrThrow(metadataLength)
}
private fun validateMac(macKey: ByteArray, streamLength: Long, dataStream: InputStream) {
val mac = Mac.getInstance("HmacSHA256").apply {
init(SecretKeySpec(macKey, "HmacSHA256"))
}
val macStream = MacInputStream(
wrapped = LimitedInputStream(dataStream, maxBytes = streamLength - MAC_SIZE),
mac = mac
)
macStream.readFully(false)
val calculatedMac = macStream.mac.doFinal()
val expectedMac = dataStream.readNBytesOrThrow(MAC_SIZE)
if (!calculatedMac.contentEquals(expectedMac)) {
throw IOException("Invalid MAC!")
}
}
private fun Int.lengthAsVarInt32(): Int {
return ByteArrayOutputStream().apply {
writeVarInt32(this@lengthAsVarInt32)
}.toByteArray().size
}
}
init {
dataStream().use { validateMac(keyMaterial.macKey, length, it) }
val forwardSecrecyMetadata = dataStream().use { readForwardSecrecyMetadata(it) }
countingStream = CountingInputStream(dataStream())
val encryptedLength = if (forwardSecrecyMetadata != null) {
val prefixLength = EncryptedBackupWriter.MAGIC_NUMBER.size + forwardSecrecyMetadata.size + forwardSecrecyMetadata.size.lengthAsVarInt32()
length - prefixLength
} else {
length
}
val prefixSkippingStream = {
if (forwardSecrecyMetadata == null) {
dataStream()
} else {
dataStream().also { readForwardSecrecyMetadata(it) }
}
}
prefixSkippingStream().use { validateMac(keyMaterial.macKey, encryptedLength, it) }
countingStream = CountingInputStream(prefixSkippingStream())
val iv = countingStream.readNBytesOrThrow(16)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding").apply {
@@ -59,7 +154,7 @@ class EncryptedBackupReader(
CipherInputStream(
LimitedInputStream(
wrapped = countingStream,
maxBytes = length - MAC_SIZE
maxBytes = encryptedLength - MAC_SIZE
),
cipher
)
@@ -112,28 +207,4 @@ class EncryptedBackupReader(
override fun close() {
stream.close()
}
companion object {
const val MAC_SIZE = 32
fun validateMac(macKey: ByteArray, streamLength: Long, dataStream: InputStream) {
val mac = Mac.getInstance("HmacSHA256").apply {
init(SecretKeySpec(macKey, "HmacSHA256"))
}
val macStream = MacInputStream(
wrapped = LimitedInputStream(dataStream, maxBytes = streamLength - MAC_SIZE),
mac = mac
)
macStream.readFully(false)
val calculatedMac = macStream.mac.doFinal()
val expectedMac = dataStream.readNBytesOrThrow(MAC_SIZE)
if (!calculatedMac.contentEquals(expectedMac)) {
throw IOException("Invalid MAC!")
}
}
}
}

View File

@@ -7,8 +7,10 @@ package org.thoughtcrime.securesms.backup.v2.stream
import org.signal.core.util.stream.MacOutputStream
import org.signal.core.util.writeVarInt32
import org.signal.libsignal.messagebackup.BackupForwardSecrecyToken
import org.thoughtcrime.securesms.backup.v2.proto.BackupInfo
import org.thoughtcrime.securesms.backup.v2.proto.Frame
import org.thoughtcrime.securesms.backup.v2.stream.EncryptedBackupReader.Companion.createForSignalBackup
import org.thoughtcrime.securesms.util.Util
import org.whispersystems.signalservice.api.backup.MessageBackupKey
import org.whispersystems.signalservice.api.push.ServiceId.ACI
@@ -26,9 +28,11 @@ import javax.crypto.spec.SecretKeySpec
* are gzipped, that gzipped data is encrypted, and then an HMAC of the encrypted data is appended
* to the end of the [outputStream].
*/
class EncryptedBackupWriter(
class EncryptedBackupWriter private constructor(
key: MessageBackupKey,
aci: ACI,
forwardSecrecyToken: BackupForwardSecrecyToken?,
forwardSecrecyMetadata: ByteArray?,
private val outputStream: OutputStream,
private val append: (ByteArray) -> Unit
) : BackupExportWriter {
@@ -36,8 +40,66 @@ class EncryptedBackupWriter(
private val mainStream: PaddedGzipOutputStream
private val macStream: MacOutputStream
companion object {
val MAGIC_NUMBER = "SBACKUP".toByteArray(Charsets.UTF_8) + 0x01
/**
* Create a writer for a backup from the archive CDN.
* The key difference is that we require forward secrecy data.
*/
fun createForSignalBackup(
key: MessageBackupKey,
aci: ACI,
forwardSecrecyToken: BackupForwardSecrecyToken,
forwardSecrecyMetadata: ByteArray,
outputStream: OutputStream,
append: (ByteArray) -> Unit
): EncryptedBackupWriter {
return EncryptedBackupWriter(
key = key,
aci = aci,
forwardSecrecyToken = forwardSecrecyToken,
forwardSecrecyMetadata = forwardSecrecyMetadata,
outputStream = outputStream,
append = append
)
}
/**
* Create a writer for a local backup or for a transfer to a linked device. Basically everything that isn't [createForSignalBackup].
* The key difference is that we don't require forward secrecy data.
*/
fun createForLocalOrLinking(
key: MessageBackupKey,
aci: ACI,
outputStream: OutputStream,
append: (ByteArray) -> Unit
): EncryptedBackupWriter {
return EncryptedBackupWriter(
key = key,
aci = aci,
forwardSecrecyToken = null,
forwardSecrecyMetadata = null,
outputStream = outputStream,
append = append
)
}
}
init {
val keyMaterial = key.deriveBackupSecrets(aci)
check(
(forwardSecrecyToken != null && forwardSecrecyMetadata != null) ||
(forwardSecrecyToken == null && forwardSecrecyMetadata == null)
)
if (forwardSecrecyMetadata != null) {
outputStream.write(MAGIC_NUMBER)
outputStream.writeVarInt32(forwardSecrecyMetadata.size)
outputStream.write(forwardSecrecyMetadata)
outputStream.flush()
}
val keyMaterial = key.deriveBackupSecrets(aci, forwardSecrecyToken)
val iv: ByteArray = Util.getSecretBytes(16)
outputStream.write(iv)

View File

@@ -9,7 +9,6 @@ import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.signal.core.util.Base64
import org.signal.core.util.Hex
import org.signal.core.util.emptyIfNull
import org.signal.core.util.isNotNullOrBlank
import org.signal.core.util.nullIfBlank
import org.signal.core.util.orNull
@@ -19,8 +18,6 @@ import org.thoughtcrime.securesms.attachments.Cdn
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.attachments.PointerAttachment
import org.thoughtcrime.securesms.attachments.TombstoneAttachment
import org.thoughtcrime.securesms.backup.v2.ImportState
import org.thoughtcrime.securesms.backup.v2.getMediaName
import org.thoughtcrime.securesms.backup.v2.proto.FilePointer
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.database.AttachmentTable
@@ -35,7 +32,6 @@ import org.thoughtcrime.securesms.backup.v2.proto.AvatarColor as RemoteAvatarCol
* Converts a [FilePointer] to a local [Attachment] object for inserting into the database.
*/
fun FilePointer?.toLocalAttachment(
importState: ImportState,
voiceNote: Boolean = false,
borderless: Boolean = false,
gif: Boolean = false,
@@ -132,7 +128,7 @@ fun FilePointer?.toLocalAttachment(
/**
* @param mediaArchiveEnabled True if this user has enable media backup, otherwise false.
*/
fun DatabaseAttachment.toRemoteFilePointer(mediaArchiveEnabled: Boolean, contentTypeOverride: String? = null): FilePointer {
fun DatabaseAttachment.toRemoteFilePointer(contentTypeOverride: String? = null): FilePointer {
val builder = FilePointer.Builder()
builder.contentType = contentTypeOverride ?: this.contentType?.takeUnless { it.isBlank() }
builder.incrementalMac = this.incrementalDigest?.takeIf { it.isNotEmpty() && this.incrementalMacChunkSize > 0 }?.toByteString()
@@ -143,13 +139,13 @@ fun DatabaseAttachment.toRemoteFilePointer(mediaArchiveEnabled: Boolean, content
builder.caption = this.caption
builder.blurHash = this.blurHash?.hash
builder.setLegacyLocators(this, mediaArchiveEnabled)
builder.setLegacyLocators(this)
builder.locatorInfo = this.toLocatorInfo()
return builder.build()
}
fun FilePointer.Builder.setLegacyLocators(attachment: DatabaseAttachment, mediaArchiveEnabled: Boolean) {
fun FilePointer.Builder.setLegacyLocators(attachment: DatabaseAttachment) {
if (attachment.remoteKey.isNullOrBlank() || attachment.remoteDigest == null || attachment.size == 0L) {
this.invalidAttachmentLocator = FilePointer.InvalidAttachmentLocator()
return
@@ -160,25 +156,6 @@ fun FilePointer.Builder.setLegacyLocators(attachment: DatabaseAttachment, mediaA
return
}
val pending = attachment.archiveTransferState != AttachmentTable.ArchiveTransferState.FINISHED && (attachment.transferState != AttachmentTable.TRANSFER_PROGRESS_DONE && attachment.transferState != AttachmentTable.TRANSFER_RESTORE_OFFLOADED)
if (mediaArchiveEnabled && !pending) {
val transitCdnKey = attachment.remoteLocation?.nullIfBlank()
val transitCdnNumber = attachment.cdn.cdnNumber.takeIf { transitCdnKey != null }
val archiveMediaName = attachment.getMediaName()?.toString()
this.backupLocator = FilePointer.BackupLocator(
mediaName = archiveMediaName.emptyIfNull(),
cdnNumber = attachment.archiveCdn.takeIf { archiveMediaName != null },
key = Base64.decode(attachment.remoteKey).toByteString(),
size = attachment.size.toInt(),
digest = attachment.remoteDigest.toByteString(),
transitCdnNumber = transitCdnNumber,
transitCdnKey = transitCdnKey
)
return
}
if (attachment.remoteLocation.isNullOrBlank()) {
this.invalidAttachmentLocator = FilePointer.InvalidAttachmentLocator()
return

View File

@@ -251,7 +251,7 @@ private fun Wallpaper.LinearGradient.toRemoteWallpaperPreset(): ChatStyle.Wallpa
private fun Wallpaper.File.toFilePointer(db: SignalDatabase): FilePointer? {
val attachmentId: AttachmentId = UriUtil.parseOrNull(this.uri)?.let { PartUriParser(it).partId } ?: return null
val attachment = db.attachmentTable.getAttachment(attachmentId)
return attachment?.toRemoteFilePointer(mediaArchiveEnabled = true)
return attachment?.toRemoteFilePointer()
}
private fun ChatStyle.Builder.hasBubbleColorSet(): Boolean {

View File

@@ -45,6 +45,7 @@ import org.thoughtcrime.securesms.backup.v2.local.ArchiveResult
import org.thoughtcrime.securesms.backup.v2.local.LocalArchiver
import org.thoughtcrime.securesms.backup.v2.local.LocalArchiver.FailureCause
import org.thoughtcrime.securesms.backup.v2.local.SnapshotFileSystem
import org.thoughtcrime.securesms.backup.v2.stream.EncryptedBackupReader
import org.thoughtcrime.securesms.backup.v2.stream.EncryptedBackupReader.Companion.MAC_SIZE
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.AttachmentTable.DebugAttachmentStats
@@ -53,11 +54,13 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.BackupMessagesJob
import org.thoughtcrime.securesms.jobs.RestoreLocalAttachmentJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.backup.MessageBackupKey
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.svr.SvrBApi
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
@@ -99,7 +102,7 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
_state.value = _state.value.copy(statusMessage = "Exporting encrypted backup to disk...")
disposables += Single
.fromCallable {
BackupRepository.export(
BackupRepository.exportForDebugging(
outputStream = openStream(),
append = { bytes -> appendStream().use { it.write(bytes) } }
)
@@ -115,7 +118,7 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
_state.value = _state.value.copy(statusMessage = "Exporting plaintext backup to disk...")
disposables += Single
.fromCallable {
BackupRepository.export(
BackupRepository.exportForDebugging(
outputStream = openStream(),
append = { bytes -> appendStream().use { it.write(bytes) } },
plaintext = true
@@ -134,12 +137,12 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
disposables += Single
.fromCallable {
BackupRepository.export(
BackupRepository.exportForDebugging(
outputStream = FileOutputStream(tempFile),
append = { bytes -> tempFile.appendBytes(bytes) }
)
_state.value = _state.value.copy(statusMessage = "Export complete! Validating...")
ArchiveValidator.validate(tempFile, SignalStore.backup.messageBackupKey, forTransfer = false)
ArchiveValidator.validateLocalOrLinking(tempFile, SignalStore.backup.messageBackupKey, forTransfer = false)
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
@@ -181,7 +184,7 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
val selfData = BackupRepository.SelfData(aci, self.pni.get(), self.e164.get(), ProfileKey(self.profileKey))
val backupKey = customCredentials?.messageBackupKey ?: SignalStore.backup.messageBackupKey
disposables += Single.fromCallable { BackupRepository.import(length, inputStreamFactory, selfData, backupKey) }
disposables += Single.fromCallable { BackupRepository.importForDebugging(length, inputStreamFactory, selfData, backupKey) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy {
@@ -234,10 +237,27 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
}
}
val encryptedStream = tempBackupFile.inputStream()
val forwardSecrecyMetadata = tempBackupFile.inputStream().use { EncryptedBackupReader.readForwardSecrecyMetadata(it) }
if (forwardSecrecyMetadata == null) {
throw IOException("Failed to read forward secrecy metadata!")
}
val svrBAuth = when (val result = BackupRepository.getSvrBAuth()) {
is NetworkResult.Success -> result.result
else -> throw IOException("Failed to read forward secrecy metadata!")
}
val forwardSecrecyToken = when (val result = SignalNetwork.svrB.restore(svrBAuth, SignalStore.backup.messageBackupKey, forwardSecrecyMetadata)) {
is SvrBApi.RestoreResult.Success -> result.data.forwardSecrecyToken
else -> throw IOException("Failed to read forward secrecy metadata!")
}
val encryptedStream = tempBackupFile.inputStream().apply {
EncryptedBackupReader.readForwardSecrecyMetadata(this)
}
val iv = encryptedStream.readNBytesOrThrow(16)
val backupKey = SignalStore.backup.messageBackupKey
val keyMaterial = backupKey.deriveBackupSecrets(Recipient.self().aci.get())
val keyMaterial = backupKey.deriveBackupSecrets(Recipient.self().aci.get(), forwardSecrecyToken)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding").apply {
init(Cipher.DECRYPT_MODE, SecretKeySpec(keyMaterial.aesKey, "AES"), IvParameterSpec(iv))
}
@@ -246,7 +266,7 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
CipherInputStream(
LimitedInputStream(
wrapped = encryptedStream,
maxBytes = tempBackupFile.length() - MAC_SIZE
maxBytes = tempBackupFile.length() - MAC_SIZE - tempBackupFile.inputStream().use { EncryptedBackupReader.getForwardSecrecyPrefixDataLength(it) }
),
cipher
)

View File

@@ -64,6 +64,7 @@ import org.whispersystems.signalservice.api.remoteconfig.RemoteConfigApi
import org.whispersystems.signalservice.api.services.DonationsService
import org.whispersystems.signalservice.api.services.ProfileService
import org.whispersystems.signalservice.api.storage.StorageServiceApi
import org.whispersystems.signalservice.api.svr.SvrBApi
import org.whispersystems.signalservice.api.username.UsernameApi
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
@@ -326,6 +327,9 @@ object AppDependencies {
val usernameApi: UsernameApi
get() = networkModule.usernameApi
val svrBApi: SvrBApi
get() = networkModule.svrBApi
val callingApi: CallingApi
get() = networkModule.callingApi
@@ -448,5 +452,6 @@ object AppDependencies {
fun provideProfileApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket, pushServiceSocket: PushServiceSocket, clientZkProfileOperations: ClientZkProfileOperations): ProfileApi
fun provideRemoteConfigApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, pushServiceSocket: PushServiceSocket): RemoteConfigApi
fun provideDonationsApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket): DonationsApi
fun provideSvrBApi(libSignalNetwork: Network): SvrBApi
}
}

View File

@@ -8,6 +8,7 @@ import android.os.HandlerThread;
import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import org.jetbrains.annotations.NotNull;
import org.signal.billing.BillingFactory;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.billing.BillingApi;
@@ -105,6 +106,7 @@ import org.whispersystems.signalservice.api.remoteconfig.RemoteConfigApi;
import org.whispersystems.signalservice.api.services.DonationsService;
import org.whispersystems.signalservice.api.services.ProfileService;
import org.whispersystems.signalservice.api.storage.StorageServiceApi;
import org.whispersystems.signalservice.api.svr.SvrBApi;
import org.whispersystems.signalservice.api.username.UsernameApi;
import org.whispersystems.signalservice.api.util.CredentialsProvider;
import org.whispersystems.signalservice.api.util.SleepTimer;
@@ -573,6 +575,11 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider {
return new DonationsApi(authWebSocket, unauthWebSocket);
}
@Override
public @NonNull SvrBApi provideSvrBApi(@NotNull Network libSignalNetwork) {
return new SvrBApi(libSignalNetwork);
}
@VisibleForTesting
static class DynamicCredentialsProvider implements CredentialsProvider {

View File

@@ -48,6 +48,7 @@ import org.whispersystems.signalservice.api.remoteconfig.RemoteConfigApi
import org.whispersystems.signalservice.api.services.DonationsService
import org.whispersystems.signalservice.api.services.ProfileService
import org.whispersystems.signalservice.api.storage.StorageServiceApi
import org.whispersystems.signalservice.api.svr.SvrBApi
import org.whispersystems.signalservice.api.username.UsernameApi
import org.whispersystems.signalservice.api.util.Tls12SocketFactory
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
@@ -218,6 +219,10 @@ class NetworkDependenciesModule(
provider.provideDonationsApi(authWebSocket, unauthWebSocket)
}
val svrBApi: SvrBApi by lazy {
provider.provideSvrBApi(libsignalNetwork)
}
val okHttpClient: OkHttpClient by lazy {
OkHttpClient.Builder()
.addInterceptor(StandardUserAgentInterceptor())

View File

@@ -8,6 +8,9 @@ package org.thoughtcrime.securesms.jobs
import org.signal.core.util.Stopwatch
import org.signal.core.util.isNotNullOrBlank
import org.signal.core.util.logging.Log
import org.signal.core.util.logging.logW
import org.signal.libsignal.messagebackup.BackupForwardSecrecyToken
import org.signal.libsignal.net.SvrBStoreResponse
import org.signal.protos.resumableuploads.ResumableUpload
import org.thoughtcrime.securesms.backup.ArchiveUploadProgress
import org.thoughtcrime.securesms.backup.RestoreState
@@ -30,6 +33,7 @@ import org.thoughtcrime.securesms.util.RemoteConfig
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.messages.AttachmentTransferProgress
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment
import org.whispersystems.signalservice.api.svr.SvrBApi
import org.whispersystems.signalservice.internal.push.AttachmentUploadForm
import java.io.File
import java.io.FileInputStream
@@ -141,6 +145,30 @@ class BackupMessagesJob private constructor(
val stopwatch = Stopwatch("BackupMessagesJob")
val auth = when (val result = BackupRepository.getSvrBAuth()) {
is NetworkResult.Success -> result.result
is NetworkResult.NetworkError -> return Result.retry(defaultBackoff()).logW(TAG, "Network error when getting SVRB auth.", result.getCause())
is NetworkResult.StatusCodeError -> return Result.retry(defaultBackoff()).logW(TAG, "Status code error when getting SVRB auth.", result.getCause())
is NetworkResult.ApplicationError -> throw result.throwable
}
val backupSecretData = SignalStore.backup.nextBackupSecretData ?: run {
Log.i(TAG, "First SVRB backup! Creating new backup chain.")
val secretData = SignalNetwork.svrB.createNewBackupChain(auth, SignalStore.backup.messageBackupKey)
SignalStore.backup.nextBackupSecretData = secretData
secretData
}
val svrBMetadata: SvrBStoreResponse = when (val result = SignalNetwork.svrB.store(auth, SignalStore.backup.messageBackupKey, backupSecretData)) {
is SvrBApi.StoreResult.Success -> result.data
is SvrBApi.StoreResult.NetworkError -> return Result.retry(defaultBackoff()).logW(TAG, "SVRB transient network error.", result.exception)
is SvrBApi.StoreResult.SvrError -> return Result.retry(defaultBackoff()).logW(TAG, "SVRB error.", result.throwable)
is SvrBApi.StoreResult.UnknownError -> return Result.fatalFailure(RuntimeException(result.throwable))
}
Log.i(TAG, "Successfully stored data on SVRB.")
stopwatch.split("svrb")
SignalDatabase.attachments.createRemoteKeyForAttachmentsThatNeedArchiveUpload().takeIf { it > 0 }?.let { count -> Log.w(TAG, "Needed to create $count remote keys.") }
stopwatch.split("keygen")
@@ -148,7 +176,7 @@ class BackupMessagesJob private constructor(
return Result.failure()
}
val (tempBackupFile, currentTime) = when (val generateBackupFileResult = getOrCreateBackupFile(stopwatch)) {
val (tempBackupFile, currentTime) = when (val generateBackupFileResult = getOrCreateBackupFile(stopwatch, svrBMetadata.forwardSecrecyToken, svrBMetadata.metadata)) {
is BackupFileResult.Success -> generateBackupFileResult
BackupFileResult.Failure -> return Result.failure()
BackupFileResult.Retry -> return Result.retry(defaultBackoff())
@@ -242,6 +270,8 @@ class BackupMessagesJob private constructor(
}
stopwatch.split("upload")
SignalStore.backup.nextBackupSecretData = svrBMetadata.nextBackupSecretData
SignalStore.backup.lastBackupProtoSize = tempBackupFile.length()
if (!tempBackupFile.delete()) {
Log.e(TAG, "Failed to delete temp backup file")
@@ -275,7 +305,9 @@ class BackupMessagesJob private constructor(
}
private fun getOrCreateBackupFile(
stopwatch: Stopwatch
stopwatch: Stopwatch,
forwardSecrecyToken: BackupForwardSecrecyToken,
forwardSecrecyMetadata: ByteArray
): BackupFileResult {
if (System.currentTimeMillis() > syncTime && syncTime > 0L && dataFile.isNotNullOrBlank()) {
val file = File(dataFile)
@@ -294,7 +326,17 @@ class BackupMessagesJob private constructor(
val outputStream = FileOutputStream(tempBackupFile)
val backupKey = SignalStore.backup.messageBackupKey
val currentTime = System.currentTimeMillis()
BackupRepository.export(outputStream = outputStream, messageBackupKey = backupKey, progressEmitter = ArchiveUploadProgress.ArchiveBackupProgressListener, append = { tempBackupFile.appendBytes(it) }, plaintext = false, cancellationSignal = { this.isCanceled }, currentTime = currentTime) {
BackupRepository.exportForSignalBackup(
outputStream = outputStream,
messageBackupKey = backupKey,
forwardSecrecyMetadata = forwardSecrecyMetadata,
forwardSecrecyToken = forwardSecrecyToken,
progressEmitter = ArchiveUploadProgress.ArchiveBackupProgressListener,
append = { tempBackupFile.appendBytes(it) },
cancellationSignal = { this.isCanceled },
currentTime = currentTime
) {
writeMediaCursorToTemporaryTable(it, currentTime = currentTime, mediaBackupEnabled = SignalStore.backup.backsUpMedia)
}
@@ -304,7 +346,7 @@ class BackupMessagesJob private constructor(
stopwatch.split("export")
when (val result = ArchiveValidator.validate(tempBackupFile, backupKey, forTransfer = false)) {
when (val result = ArchiveValidator.validateSignalBackup(tempBackupFile, backupKey, forwardSecrecyToken)) {
ArchiveValidator.ValidationResult.Success -> {
Log.d(TAG, "Successfully passed validation.")
}

View File

@@ -88,6 +88,8 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
private const val KEY_HAS_SNOOZED_VERIFY = "backup.has_snoozed_verify"
private const val KEY_HAS_VERIFIED_BEFORE = "backup.has_verified_before"
private const val KEY_NEXT_BACKUP_SECRET_DATA = "backup.next_backup_secret_data"
private val cachedCdnCredentialsExpiresIn: Duration = 12.hours
private val lock = ReentrantLock()
@@ -367,6 +369,9 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
/** Checks if they have ever verified their backup key before **/
var hasVerifiedBefore by booleanValue(KEY_HAS_VERIFIED_BEFORE, false)
/** The value from the last successful SVRB operation that must be passed to the next SVRB operation. */
var nextBackupSecretData by nullableBlobValue(KEY_NEXT_BACKUP_SECRET_DATA, null)
/**
* If true, it means we have been told that remote storage is full, but we have not yet run any of our "garbage collection" tasks, like committing deletes
* or pruning orphaned media.

View File

@@ -262,12 +262,11 @@ object LinkDeviceRepository {
try {
Log.d(TAG, "[createAndUploadArchive] Starting the export.")
BackupRepository.export(
BackupRepository.exportForLinkAndSync(
currentTime = System.currentTimeMillis(),
outputStream = outputStream,
append = { tempBackupFile.appendBytes(it) },
messageBackupKey = ephemeralMessageBackupKey,
skipMediaBackup = true,
forTransfer = true,
cancellationSignal = cancellationSignal
)
} catch (e: Exception) {
@@ -288,7 +287,7 @@ object LinkDeviceRepository {
return LinkUploadArchiveResult.BackupCreationCancelled
}
when (val result = ArchiveValidator.validate(tempBackupFile, ephemeralMessageBackupKey, forTransfer = true)) {
when (val result = ArchiveValidator.validateLocalOrLinking(tempBackupFile, ephemeralMessageBackupKey, forTransfer = true)) {
ArchiveValidator.ValidationResult.Success -> {
Log.d(TAG, "[createAndUploadArchive] Successfully passed validation.")
}

View File

@@ -21,6 +21,7 @@ import org.whispersystems.signalservice.api.provisioning.ProvisioningApi
import org.whispersystems.signalservice.api.ratelimit.RateLimitChallengeApi
import org.whispersystems.signalservice.api.remoteconfig.RemoteConfigApi
import org.whispersystems.signalservice.api.storage.StorageServiceApi
import org.whispersystems.signalservice.api.svr.SvrBApi
import org.whispersystems.signalservice.api.username.UsernameApi
/**
@@ -94,4 +95,7 @@ object SignalNetwork {
@get:JvmName("username")
val username: UsernameApi
get() = AppDependencies.usernameApi
val svrB: SvrBApi
get() = AppDependencies.svrBApi
}

View File

@@ -9,6 +9,7 @@ import org.junit.Assert.assertEquals
import org.junit.Test
import org.signal.core.util.Base64
import org.signal.core.util.Hex
import org.signal.libsignal.messagebackup.BackupForwardSecrecyToken
import org.thoughtcrime.securesms.backup.v2.proto.AccountData
import org.thoughtcrime.securesms.backup.v2.proto.BackupInfo
import org.thoughtcrime.securesms.backup.v2.proto.Frame
@@ -28,7 +29,7 @@ class EncryptedBackupReaderWriterTest {
val outputStream = ByteArrayOutputStream()
val frameCount = 10_000
EncryptedBackupWriter(key, aci, outputStream, append = { outputStream.write(it) }).use { writer ->
EncryptedBackupWriter.createForLocalOrLinking(key, aci, outputStream, append = { outputStream.write(it) }).use { writer ->
writer.write(BackupInfo(version = 1, backupTimeMs = 1000L))
for (i in 0 until frameCount) {
@@ -39,7 +40,7 @@ class EncryptedBackupReaderWriterTest {
val ciphertext: ByteArray = outputStream.toByteArray()
println(ciphertext.size)
val frames: List<Frame> = EncryptedBackupReader(key, aci, ciphertext.size.toLong()) { ciphertext.inputStream() }.use { reader ->
val frames: List<Frame> = EncryptedBackupReader.createForLocalOrLinking(key, aci, ciphertext.size.toLong()) { ciphertext.inputStream() }.use { reader ->
assertEquals(reader.backupInfo?.version, 1L)
assertEquals(reader.backupInfo?.backupTimeMs, 1000L)
reader.asSequence().toList()
@@ -61,7 +62,7 @@ class EncryptedBackupReaderWriterTest {
.map { frameCount ->
val outputStream = ByteArrayOutputStream()
EncryptedBackupWriter(key, aci, outputStream, append = { outputStream.write(it) }).use { writer ->
EncryptedBackupWriter.createForLocalOrLinking(key, aci, outputStream, append = { outputStream.write(it) }).use { writer ->
writer.write(BackupInfo(version = 1, backupTimeMs = 1000L))
for (i in 0 until frameCount) {
@@ -86,7 +87,7 @@ class EncryptedBackupReaderWriterTest {
.map {
val outputStream = ByteArrayOutputStream()
EncryptedBackupWriter(key, aci, outputStream, append = { outputStream.write(it) }).use { writer ->
EncryptedBackupWriter.createForLocalOrLinking(key, aci, outputStream, append = { outputStream.write(it) }).use { writer ->
writer.write(BackupInfo(version = 1, backupTimeMs = 1000L))
writer.write(Frame(account = AccountData(username = "static-data")))
}
@@ -98,4 +99,45 @@ class EncryptedBackupReaderWriterTest {
assertEquals(count, uniqueOutputs.size)
}
@Test
fun `can read back all of the frames we write - forward secrecy`() {
val key = MessageBackupKey(Util.getSecretBytes(32))
val aci = ACI.from(UUID.randomUUID())
val outputStream = ByteArrayOutputStream()
val forwardSecrecyToken = BackupForwardSecrecyToken(Util.getSecretBytes(32))
val frameCount = 10_000
EncryptedBackupWriter.createForSignalBackup(
key = key,
aci = aci,
forwardSecrecyToken = forwardSecrecyToken,
forwardSecrecyMetadata = Util.getSecretBytes(64),
outputStream = outputStream,
append = { outputStream.write(it) }
).use { writer ->
writer.write(BackupInfo(version = 1, backupTimeMs = 1000L))
for (i in 0 until frameCount) {
writer.write(Frame(account = AccountData(username = "username-$i")))
}
}
val ciphertext: ByteArray = outputStream.toByteArray()
println(ciphertext.size)
val frames: List<Frame> = EncryptedBackupReader.createForSignalBackup(key, aci, forwardSecrecyToken, ciphertext.size.toLong()) { ciphertext.inputStream() }.use { reader ->
assertEquals(reader.backupInfo?.version, 1L)
assertEquals(reader.backupInfo?.backupTimeMs, 1000L)
reader.asSequence().toList()
}
assertEquals(frameCount, frames.size)
for (i in 0 until frameCount) {
assertEquals("username-$i", frames[i].account?.username)
}
}
}

View File

@@ -56,6 +56,7 @@ import org.whispersystems.signalservice.api.remoteconfig.RemoteConfigApi
import org.whispersystems.signalservice.api.services.DonationsService
import org.whispersystems.signalservice.api.services.ProfileService
import org.whispersystems.signalservice.api.storage.StorageServiceApi
import org.whispersystems.signalservice.api.svr.SvrBApi
import org.whispersystems.signalservice.api.username.UsernameApi
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
@@ -300,4 +301,8 @@ class MockApplicationDependencyProvider : AppDependencies.Provider {
override fun provideDonationsApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket): DonationsApi {
return mockk(relaxed = true)
}
override fun provideSvrBApi(libSignalNetwork: Network): SvrBApi {
return mockk(relaxed = true)
}
}

View File

@@ -13,7 +13,7 @@ androidx-window = "1.3.0"
glide = "4.15.1"
gradle = "8.9.0"
kotlin = "2.1.0"
libsignal-client = "0.77.1"
libsignal-client = "0.78.0"
mp4parser = "1.9.39"
android-gradle-plugin = "8.7.2"
accompanist = "0.28.0"

View File

@@ -7374,20 +7374,20 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="57b3cf8f247f1990211110734a7d1af413db145c8f17eb1b2cdc9b9321188c2b" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.signal" name="libsignal-android" version="0.77.1">
<artifact name="libsignal-android-0.77.1.aar">
<sha256 value="0310ef720832957996ef68a5186288418d2c7683d2557b6875970affe146c6dd" origin="Generated by Gradle"/>
<component group="org.signal" name="libsignal-android" version="0.78.0">
<artifact name="libsignal-android-0.78.0.aar">
<sha256 value="29b9f18df8b89dbde7ea239475c73aa9518c8dad7b75211ef88e120970a7bb0b" origin="Generated by Gradle"/>
</artifact>
<artifact name="libsignal-android-0.77.1.module">
<sha256 value="e2ae1c49162078756fbd0dff62c11754d3bee9f5757330ded20ae5578673a81e" origin="Generated by Gradle"/>
<artifact name="libsignal-android-0.78.0.module">
<sha256 value="7bf3c5f7dbb417076aca6ca50bc8cb3129397e2d5a50cd5ad408d31536c2b6ed" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.signal" name="libsignal-client" version="0.77.1">
<artifact name="libsignal-client-0.77.1.jar">
<sha256 value="2ca65f3f05b05112fcef61d15db7cc11f3bf844e2f8e126e7713e6e77701d6ea" origin="Generated by Gradle"/>
<component group="org.signal" name="libsignal-client" version="0.78.0">
<artifact name="libsignal-client-0.78.0.jar">
<sha256 value="f60d185af7d6f14f316749d4fbcddfaad91cca59cc54acef5cddfdb0a6fad0e4" origin="Generated by Gradle"/>
</artifact>
<artifact name="libsignal-client-0.77.1.module">
<sha256 value="bc8708d10e89320afdbfbf818b15a164098ffc78eb642ad5c64e6a2d6557f4ab" origin="Generated by Gradle"/>
<artifact name="libsignal-client-0.78.0.module">
<sha256 value="d79581c29aadd593486d96c119bcbf23be7accb10ad01962ae069321936e1241" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.signal" name="ringrtc-android" version="2.56.0">

View File

@@ -24,6 +24,7 @@ import org.whispersystems.signalservice.internal.delete
import org.whispersystems.signalservice.internal.get
import org.whispersystems.signalservice.internal.post
import org.whispersystems.signalservice.internal.push.AttachmentUploadForm
import org.whispersystems.signalservice.internal.push.AuthCredentials
import org.whispersystems.signalservice.internal.push.PushServiceSocket
import org.whispersystems.signalservice.internal.put
import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage
@@ -362,6 +363,24 @@ class ArchiveApi(
}
}
/**
* Retrieves auth credentials that can be used to perform SVRB operations.
*
* GET /v1/archives/auth/svrb
* - 200: Success
* - 400: Bad arguments, or made on an authenticated channel
* - 401: Bad presentation, invalid public key signature, no matching backupId on the server, or the credential was of the wrong type (messages/media)
* - 403: Forbidden
*/
fun getSvrBAuthorization(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<MessageBackupKey>): NetworkResult<AuthCredentials> {
return getCredentialPresentation(aci, archiveServiceAccess)
.map { it.toArchiveCredentialPresentation().toHeaders() }
.then { headers ->
val request = WebSocketRequestMessage.get("/v1/archives/auth/svrb", headers)
NetworkResult.fromWebSocketRequest(unauthWebSocket, request, AuthCredentials::class)
}
}
private fun getCredentialPresentation(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): NetworkResult<CredentialPresentationData> {
return NetworkResult.fromLocal {
val zkCredential = getZkCredential(aci, archiveServiceAccess)

View File

@@ -5,6 +5,7 @@
package org.whispersystems.signalservice.api.backup
import org.signal.libsignal.messagebackup.BackupForwardSecrecyToken
import org.signal.libsignal.protocol.ecc.ECPrivateKey
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.signal.libsignal.messagebackup.BackupKey as LibSignalBackupKey
@@ -28,11 +29,13 @@ class MessageBackupKey(override val value: ByteArray) : BackupKey {
/**
* The cryptographic material used to encrypt a backup.
*
* @param forwardSecrecyToken Should be present for any backup located on the archive CDN. Absent for other uses (i.e. link+sync).
*/
fun deriveBackupSecrets(aci: ACI): BackupKeyMaterial {
fun deriveBackupSecrets(aci: ACI, forwardSecrecyToken: BackupForwardSecrecyToken?): BackupKeyMaterial {
val backupId = deriveBackupId(aci)
val libsignalBackupKey = LibSignalBackupKey(value)
val libsignalMessageMessageBackupKey = LibSignalMessageBackupKey(libsignalBackupKey, backupId.value)
val libsignalMessageMessageBackupKey = LibSignalMessageBackupKey(libsignalBackupKey, backupId.value, forwardSecrecyToken)
return BackupKeyMaterial(
id = backupId,

View File

@@ -0,0 +1,140 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.svr
import org.signal.libsignal.attest.AttestationFailedException
import org.signal.libsignal.internal.CompletableFuture
import org.signal.libsignal.messagebackup.BackupKey
import org.signal.libsignal.net.Network
import org.signal.libsignal.net.NetworkException
import org.signal.libsignal.net.NetworkProtocolException
import org.signal.libsignal.net.SvrB
import org.signal.libsignal.net.SvrBRestoreResponse
import org.signal.libsignal.net.SvrBStoreResponse
import org.signal.libsignal.svr.DataMissingException
import org.signal.libsignal.svr.RestoreFailedException
import org.signal.libsignal.svr.SvrException
import org.whispersystems.signalservice.api.CancelationException
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.backup.MessageBackupKey
import org.whispersystems.signalservice.internal.push.AuthCredentials
import java.io.IOException
import java.util.concurrent.ExecutionException
/**
* A collection of operations for interacting with SVRB, the SVR enclave that provides forward secrecy for backups.
*/
class SvrBApi(private val network: Network) {
/**
* See [SvrB.createNewBackupChain].
*
* Call this the first time you ever interact with SVRB. Gives you a secret data to persist and use for future calls.
*
* Note that this doesn't actually make a network call, it just needs a [Network] to get the environment.
*/
fun createNewBackupChain(auth: AuthCredentials, backupKey: MessageBackupKey): ByteArray {
return network
.svrB(auth.username(), auth.password())
.createNewBackupChain(BackupKey(backupKey.value))
}
/**
* See [SvrB.store].
*
* Handling this one is funny because the underlying protocols don't use status codes, instead favoring complex results.
* As a result, responses are only [NetworkResult.Success] and [NetworkResult.NetworkError], with errors being accounted for
* in the success case via the sealed result class.
*/
fun store(auth: AuthCredentials, backupKey: MessageBackupKey, previousSecretData: ByteArray): StoreResult {
return try {
val result = network
.svrB(auth.username(), auth.password())
.store(BackupKey(backupKey.value), previousSecretData)
.get()
when (val exception = result.exceptionOrNull()) {
null -> StoreResult.Success(result.getOrThrow())
is NetworkException -> StoreResult.NetworkError(exception)
is NetworkProtocolException -> StoreResult.NetworkError(exception)
is SvrException -> StoreResult.SvrError(exception)
else -> StoreResult.UnknownError(exception)
}
} catch (e: CancelationException) {
StoreResult.UnknownError(e)
} catch (e: ExecutionException) {
StoreResult.UnknownError(e)
} catch (e: InterruptedException) {
StoreResult.UnknownError(e)
}
}
/**
* See [SvrB.restore]
*
* Handling this one is funny because the underlying protocols don't use status codes, instead favoring complex results.
* As a result, responses are only [NetworkResult.Success] and [NetworkResult.NetworkError], with errors being accounted for
* in the success case via the sealed result class.
*/
fun restore(auth: AuthCredentials, backupKey: MessageBackupKey, forwardSecrecyMetadata: ByteArray): RestoreResult {
return try {
val result = network
.svrB(auth.username(), auth.password())
.restore(BackupKey(backupKey.value), forwardSecrecyMetadata)
.get()
when (val exception = result.exceptionOrNull()) {
null -> RestoreResult.Success(result.getOrThrow())
is NetworkException -> RestoreResult.NetworkError(exception)
is NetworkProtocolException -> RestoreResult.NetworkError(exception)
is DataMissingException -> RestoreResult.DataMissingError
is RestoreFailedException -> RestoreResult.RestoreFailedError(exception.triesRemaining)
is SvrException -> RestoreResult.SvrError(exception)
is AttestationFailedException -> RestoreResult.SvrError(exception)
else -> RestoreResult.UnknownError(exception)
}
} catch (e: CancelationException) {
RestoreResult.UnknownError(e)
} catch (e: ExecutionException) {
RestoreResult.UnknownError(e)
} catch (e: InterruptedException) {
RestoreResult.UnknownError(e)
}
}
private fun <ResultType, FinalType> CompletableFuture<Result<ResultType>>.toNetworkResult(resultMapper: (Result<ResultType>) -> FinalType): NetworkResult<FinalType> {
return try {
val result = this.get()
return when (val exception = result.exceptionOrNull()) {
is NetworkException -> NetworkResult.NetworkError(exception)
is NetworkProtocolException -> NetworkResult.NetworkError(exception)
else -> NetworkResult.Success(resultMapper(result))
}
} catch (e: CancelationException) {
NetworkResult.Success(resultMapper(Result.failure(e)))
} catch (e: ExecutionException) {
NetworkResult.Success(resultMapper(Result.failure(e)))
} catch (e: InterruptedException) {
NetworkResult.Success(resultMapper(Result.failure(e)))
}
}
sealed class StoreResult {
data class Success(val data: SvrBStoreResponse) : StoreResult()
data class NetworkError(val exception: IOException) : StoreResult()
data class SvrError(val throwable: Throwable) : StoreResult()
data class UnknownError(val throwable: Throwable) : StoreResult()
}
sealed class RestoreResult {
data class Success(val data: SvrBRestoreResponse) : RestoreResult()
data class NetworkError(val exception: IOException) : RestoreResult()
data object DataMissingError : RestoreResult()
data class RestoreFailedError(val triesRemaining: Int) : RestoreResult()
data class SvrError(val throwable: Throwable) : RestoreResult()
data class UnknownError(val throwable: Throwable) : RestoreResult()
}
}