mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-02 00:17:41 +01:00
Extract base archive classes into their own module.
This commit is contained in:
committed by
Cody Henthorne
parent
08491579dd
commit
8a887b65a1
27
lib/archive/build.gradle.kts
Normal file
27
lib/archive/build.gradle.kts
Normal file
@@ -0,0 +1,27 @@
|
||||
plugins {
|
||||
id("signal-library")
|
||||
id("com.squareup.wire")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "org.signal.archive"
|
||||
}
|
||||
|
||||
wire {
|
||||
kotlin {
|
||||
javaInterop = true
|
||||
}
|
||||
|
||||
sourcePath {
|
||||
srcDir("src/main/protowire")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:util"))
|
||||
implementation(project(":core:models-jvm"))
|
||||
implementation(project(":lib:libsignal-service"))
|
||||
|
||||
implementation(libs.libsignal.android)
|
||||
implementation(libs.google.guava.android)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.archive
|
||||
|
||||
/**
|
||||
* Metadata about a local backup file available for restore.
|
||||
*/
|
||||
data class LocalBackupMetadata(
|
||||
/** Size of the backup in bytes. */
|
||||
val sizeBytes: Long,
|
||||
/** Timestamp when the backup was created, in milliseconds since epoch. */
|
||||
val timestampMs: Long
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.archive
|
||||
|
||||
/**
|
||||
* Represents the progress of a local backup restore operation.
|
||||
* Emitted as a flow from the storage controller during restore.
|
||||
*/
|
||||
sealed interface LocalBackupRestoreProgress {
|
||||
/** The restore is being prepared (e.g., reading metadata, validating). */
|
||||
data object Preparing : LocalBackupRestoreProgress
|
||||
|
||||
/** The restore is actively in progress. */
|
||||
data class InProgress(
|
||||
val bytesRead: Long,
|
||||
val totalBytes: Long
|
||||
) : LocalBackupRestoreProgress {
|
||||
val progressFraction: Float
|
||||
get() = if (totalBytes > 0) (bytesRead.toFloat() / totalBytes.toFloat()).coerceIn(0f, 1f) else 0f
|
||||
}
|
||||
|
||||
/** The restore completed successfully. */
|
||||
data object Complete : LocalBackupRestoreProgress
|
||||
|
||||
/** The restore failed with an error. */
|
||||
data class Error(val cause: Throwable) : LocalBackupRestoreProgress
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.archive.local
|
||||
|
||||
import org.signal.archive.local.proto.FilesFrame
|
||||
import org.signal.core.util.readNBytesOrThrow
|
||||
import org.signal.core.util.readVarInt32
|
||||
import java.io.EOFException
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* Reads [FilesFrame] protos encoded with their length.
|
||||
*/
|
||||
class ArchivedFilesReader(private val inputStream: InputStream) : Iterator<FilesFrame>, AutoCloseable {
|
||||
|
||||
private var next: FilesFrame? = null
|
||||
|
||||
init {
|
||||
next = read()
|
||||
}
|
||||
|
||||
override fun hasNext(): Boolean {
|
||||
return next != null
|
||||
}
|
||||
|
||||
override fun next(): FilesFrame {
|
||||
next?.let { out ->
|
||||
next = read()
|
||||
return out
|
||||
} ?: throw NoSuchElementException()
|
||||
}
|
||||
|
||||
private fun read(): FilesFrame? {
|
||||
try {
|
||||
val length = inputStream.readVarInt32().also { if (it < 0) return null }
|
||||
val frameBytes: ByteArray = inputStream.readNBytesOrThrow(length)
|
||||
|
||||
return FilesFrame.ADAPTER.decode(frameBytes)
|
||||
} catch (e: EOFException) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
inputStream.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.archive.local
|
||||
|
||||
import org.signal.archive.local.proto.FilesFrame
|
||||
import org.signal.core.util.writeVarInt32
|
||||
import java.io.IOException
|
||||
import java.io.OutputStream
|
||||
|
||||
/**
|
||||
* Write [FilesFrame] protos encoded with their length.
|
||||
*/
|
||||
class ArchivedFilesWriter(private val output: OutputStream) : AutoCloseable {
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun write(frame: FilesFrame) {
|
||||
val bytes = frame.encode()
|
||||
output.writeVarInt32(bytes.size)
|
||||
output.write(bytes)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
output.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.archive.stream
|
||||
|
||||
import org.signal.archive.proto.BackupInfo
|
||||
import org.signal.archive.proto.Frame
|
||||
|
||||
interface BackupExportWriter : AutoCloseable {
|
||||
fun write(header: BackupInfo)
|
||||
fun write(frame: Frame)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.archive.stream
|
||||
|
||||
import org.signal.archive.proto.Frame
|
||||
|
||||
/**
|
||||
* An interface that lets sub-processors emit [Frame]s as they export data.
|
||||
*/
|
||||
fun interface BackupFrameEmitter {
|
||||
fun emit(frame: Frame)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.archive.stream
|
||||
|
||||
import org.signal.archive.proto.BackupInfo
|
||||
import org.signal.archive.proto.Frame
|
||||
|
||||
interface BackupImportReader : Iterator<Frame>, AutoCloseable {
|
||||
fun getHeader(): BackupInfo?
|
||||
fun getBytesRead(): Long
|
||||
fun getStreamLength(): Long
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.archive.stream
|
||||
|
||||
import org.signal.archive.proto.Frame
|
||||
|
||||
interface BackupImportStream {
|
||||
fun read(): Frame?
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.archive.stream
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.google.common.io.CountingInputStream
|
||||
import org.signal.archive.proto.BackupInfo
|
||||
import org.signal.archive.proto.Frame
|
||||
import org.signal.core.models.ServiceId.ACI
|
||||
import org.signal.core.models.backup.BackupId
|
||||
import org.signal.core.models.backup.MessageBackupKey
|
||||
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 java.io.ByteArrayOutputStream
|
||||
import java.io.EOFException
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.util.zip.GZIPInputStream
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.CipherInputStream
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/**
|
||||
* Provides the ability to read backup frames in a streaming fashion from a target [InputStream].
|
||||
* 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 private constructor(
|
||||
keyMaterial: MessageBackupKey.BackupKeyMaterial,
|
||||
val length: Long,
|
||||
dataStream: () -> InputStream
|
||||
) : BackupImportReader {
|
||||
|
||||
@VisibleForTesting
|
||||
val backupInfo: BackupInfo?
|
||||
private var next: Frame? = null
|
||||
private val stream: InputStream
|
||||
private val countingStream: CountingInputStream
|
||||
|
||||
companion object {
|
||||
const val MAC_SIZE = 32
|
||||
|
||||
/**
|
||||
* Estimated upperbound need to read backup secrecy metadata from the start of a file.
|
||||
*
|
||||
* Magic Number size + ~varint size (5) + forward secrecy metadata size estimate (200)
|
||||
*/
|
||||
val BACKUP_SECRET_METADATA_UPPERBOUND = EncryptedBackupWriter.MAGIC_NUMBER.size + 5 + 200
|
||||
|
||||
/**
|
||||
* 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 createForSignalBackup(key, key.deriveBackupId(aci), forwardSecrecyToken, length, dataStream)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a reader for a backup from the archive CDN, using a [BackupId] directly
|
||||
* instead of deriving it from an ACI.
|
||||
*/
|
||||
fun createForSignalBackup(
|
||||
key: MessageBackupKey,
|
||||
backupId: BackupId,
|
||||
forwardSecrecyToken: BackupForwardSecrecyToken,
|
||||
length: Long,
|
||||
dataStream: () -> InputStream
|
||||
): EncryptedBackupReader {
|
||||
return EncryptedBackupReader(
|
||||
keyMaterial = key.deriveBackupSecrets(backupId, 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 createForLocalOrLinking(key, key.deriveBackupId(aci), length, dataStream)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a reader for a local backup or for a transfer to a linked device, using a [BackupId] directly
|
||||
* instead of deriving it from an ACI.
|
||||
*/
|
||||
fun createForLocalOrLinking(key: MessageBackupKey, backupId: BackupId, length: Long, dataStream: () -> InputStream): EncryptedBackupReader {
|
||||
return EncryptedBackupReader(
|
||||
keyMaterial = key.deriveBackupSecrets(backupId, 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 {
|
||||
val forwardSecrecyMetadata = dataStream().use { readForwardSecrecyMetadata(it) }
|
||||
|
||||
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 {
|
||||
init(Cipher.DECRYPT_MODE, SecretKeySpec(keyMaterial.aesKey, "AES"), IvParameterSpec(iv))
|
||||
}
|
||||
|
||||
stream = GZIPInputStream(
|
||||
CipherInputStream(
|
||||
LimitedInputStream(
|
||||
wrapped = countingStream,
|
||||
maxBytes = encryptedLength - MAC_SIZE
|
||||
),
|
||||
cipher
|
||||
)
|
||||
)
|
||||
backupInfo = readHeader()
|
||||
next = read()
|
||||
}
|
||||
|
||||
override fun getHeader(): BackupInfo? {
|
||||
return backupInfo
|
||||
}
|
||||
|
||||
override fun getBytesRead() = countingStream.count
|
||||
|
||||
override fun getStreamLength() = length
|
||||
|
||||
override fun hasNext(): Boolean {
|
||||
return next != null
|
||||
}
|
||||
|
||||
override fun next(): Frame {
|
||||
next?.let { out ->
|
||||
next = read()
|
||||
return out
|
||||
} ?: throw NoSuchElementException()
|
||||
}
|
||||
|
||||
private fun readHeader(): BackupInfo? {
|
||||
try {
|
||||
val length = stream.readVarInt32().takeIf { it >= 0 } ?: return null
|
||||
val headerBytes: ByteArray = stream.readNBytesOrThrow(length)
|
||||
|
||||
return BackupInfo.ADAPTER.decode(headerBytes)
|
||||
} catch (e: EOFException) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private fun read(): Frame? {
|
||||
try {
|
||||
val length = stream.readVarInt32().also { if (it < 0) return null }
|
||||
val frameBytes: ByteArray = stream.readNBytesOrThrow(length)
|
||||
|
||||
return Frame.ADAPTER.decode(frameBytes)
|
||||
} catch (e: EOFException) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
stream.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.archive.stream
|
||||
|
||||
import org.signal.archive.proto.BackupInfo
|
||||
import org.signal.archive.proto.Frame
|
||||
import org.signal.archive.stream.EncryptedBackupReader.Companion.createForSignalBackup
|
||||
import org.signal.core.models.ServiceId.ACI
|
||||
import org.signal.core.models.backup.BackupId
|
||||
import org.signal.core.models.backup.MessageBackupKey
|
||||
import org.signal.core.util.Util
|
||||
import org.signal.core.util.stream.MacOutputStream
|
||||
import org.signal.core.util.writeVarInt32
|
||||
import org.signal.libsignal.messagebackup.BackupForwardSecrecyToken
|
||||
import java.io.IOException
|
||||
import java.io.OutputStream
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.CipherOutputStream
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/**
|
||||
* Provides the ability to write backup frames in a streaming fashion to a target [OutputStream].
|
||||
* As it's being written, it will be both encrypted and compressed. Specifically, the backup frames
|
||||
* 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 private constructor(
|
||||
keyMaterial: MessageBackupKey.BackupKeyMaterial,
|
||||
forwardSecrecyToken: BackupForwardSecrecyToken?,
|
||||
forwardSecrecyMetadata: ByteArray?,
|
||||
private val outputStream: OutputStream,
|
||||
private val append: (ByteArray) -> Unit
|
||||
) : BackupExportWriter {
|
||||
|
||||
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 createForSignalBackup(key, key.deriveBackupId(aci), forwardSecrecyToken, forwardSecrecyMetadata, outputStream, append)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a writer for a backup from the archive CDN, using a [BackupId] directly
|
||||
* instead of deriving it from an ACI.
|
||||
*/
|
||||
fun createForSignalBackup(
|
||||
key: MessageBackupKey,
|
||||
backupId: BackupId,
|
||||
forwardSecrecyToken: BackupForwardSecrecyToken,
|
||||
forwardSecrecyMetadata: ByteArray,
|
||||
outputStream: OutputStream,
|
||||
append: (ByteArray) -> Unit
|
||||
): EncryptedBackupWriter {
|
||||
return EncryptedBackupWriter(
|
||||
keyMaterial = key.deriveBackupSecrets(backupId, forwardSecrecyToken),
|
||||
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 createForLocalOrLinking(key, key.deriveBackupId(aci), outputStream, append)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a writer for a local backup or for a transfer to a linked device, using a [BackupId] directly
|
||||
* instead of deriving it from an ACI.
|
||||
*/
|
||||
fun createForLocalOrLinking(
|
||||
key: MessageBackupKey,
|
||||
backupId: BackupId,
|
||||
outputStream: OutputStream,
|
||||
append: (ByteArray) -> Unit
|
||||
): EncryptedBackupWriter {
|
||||
return EncryptedBackupWriter(
|
||||
keyMaterial = key.deriveBackupSecrets(backupId, forwardSecrecyToken = null),
|
||||
forwardSecrecyToken = null,
|
||||
forwardSecrecyMetadata = null,
|
||||
outputStream = outputStream,
|
||||
append = append
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
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 iv: ByteArray = Util.getSecretBytes(16)
|
||||
outputStream.write(iv)
|
||||
outputStream.flush()
|
||||
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding").apply {
|
||||
init(Cipher.ENCRYPT_MODE, SecretKeySpec(keyMaterial.aesKey, "AES"), IvParameterSpec(iv))
|
||||
}
|
||||
|
||||
val mac = Mac.getInstance("HmacSHA256").apply {
|
||||
init(SecretKeySpec(keyMaterial.macKey, "HmacSHA256"))
|
||||
update(iv)
|
||||
}
|
||||
|
||||
macStream = MacOutputStream(outputStream, mac)
|
||||
val cipherStream = CipherOutputStream(macStream, cipher)
|
||||
|
||||
mainStream = PaddedGzipOutputStream(cipherStream)
|
||||
}
|
||||
|
||||
override fun write(header: BackupInfo) {
|
||||
val headerBytes = header.encode()
|
||||
|
||||
mainStream.writeVarInt32(headerBytes.size)
|
||||
mainStream.write(headerBytes)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun write(frame: Frame) {
|
||||
val frameBytes: ByteArray = frame.encode()
|
||||
|
||||
mainStream.writeVarInt32(frameBytes.size)
|
||||
mainStream.write(frameBytes)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun close() {
|
||||
// We need to close the main stream in order for the gzip and all the cipher operations to fully finish before
|
||||
// we can calculate the MAC. Unfortunately flush()/finish() is not sufficient. So we have to defer to the
|
||||
// caller to append the bytes to the end of the data however they see fit (like appending to a file).
|
||||
mainStream.close()
|
||||
val mac = macStream.mac.doFinal()
|
||||
append(mac)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.archive.stream
|
||||
|
||||
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
|
||||
import java.io.FilterOutputStream
|
||||
import java.io.OutputStream
|
||||
import java.util.zip.GZIPOutputStream
|
||||
|
||||
/**
|
||||
* GZIPs the content of the provided [outputStream], but also adds padding to the end of the stream using the same algorithm as [PaddingInputStream].
|
||||
* We do this to fit files into a smaller number of size buckets to avoid fingerprinting. And it turns out that bolting on zeros to the end of a GZIP stream is
|
||||
* fine, because GZIP is smart enough to ignore it. This means readers of this data don't have to do anything special.
|
||||
*/
|
||||
class PaddedGzipOutputStream private constructor(private val outputStream: SizeObservingOutputStream) : GZIPOutputStream(outputStream) {
|
||||
|
||||
constructor(outputStream: OutputStream) : this(SizeObservingOutputStream(outputStream))
|
||||
|
||||
override fun finish() {
|
||||
super.finish()
|
||||
|
||||
val totalLength = outputStream.size
|
||||
val paddedSize: Long = PaddingInputStream.getPaddedSize(totalLength)
|
||||
val paddingToAdd: Int = (paddedSize - totalLength).toInt()
|
||||
|
||||
outputStream.write(ByteArray(paddingToAdd))
|
||||
}
|
||||
|
||||
/**
|
||||
* We need to know the size of the *compressed* stream to know how much padding to add at the end.
|
||||
*/
|
||||
private class SizeObservingOutputStream(val wrapped: OutputStream) : FilterOutputStream(wrapped) {
|
||||
|
||||
var size: Long = 0L
|
||||
private set
|
||||
|
||||
override fun write(b: Int) {
|
||||
wrapped.write(b)
|
||||
size++
|
||||
}
|
||||
|
||||
override fun write(b: ByteArray) {
|
||||
wrapped.write(b)
|
||||
size += b.size
|
||||
}
|
||||
|
||||
override fun write(b: ByteArray, off: Int, len: Int) {
|
||||
wrapped.write(b, off, len)
|
||||
size += len
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.archive.stream
|
||||
|
||||
import com.google.common.io.CountingInputStream
|
||||
import org.signal.archive.proto.BackupInfo
|
||||
import org.signal.archive.proto.Frame
|
||||
import org.signal.core.util.readNBytesOrThrow
|
||||
import org.signal.core.util.readVarInt32
|
||||
import java.io.EOFException
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* Reads a plaintext backup import stream one frame at a time.
|
||||
*/
|
||||
class PlainTextBackupReader(val dataStream: InputStream, val length: Long) : BackupImportReader {
|
||||
|
||||
val backupInfo: BackupInfo?
|
||||
var next: Frame? = null
|
||||
val inputStream: CountingInputStream
|
||||
|
||||
init {
|
||||
inputStream = CountingInputStream(dataStream)
|
||||
backupInfo = readHeader()
|
||||
next = read()
|
||||
}
|
||||
|
||||
override fun getHeader(): BackupInfo? {
|
||||
return backupInfo
|
||||
}
|
||||
|
||||
override fun getBytesRead() = inputStream.count
|
||||
|
||||
override fun getStreamLength() = length
|
||||
|
||||
override fun hasNext(): Boolean {
|
||||
return next != null
|
||||
}
|
||||
|
||||
override fun next(): Frame {
|
||||
next?.let { out ->
|
||||
next = read()
|
||||
return out
|
||||
} ?: throw NoSuchElementException()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
inputStream.close()
|
||||
}
|
||||
|
||||
private fun readHeader(): BackupInfo? {
|
||||
try {
|
||||
val length = inputStream.readVarInt32().takeIf { it >= 0 } ?: return null
|
||||
val headerBytes: ByteArray = inputStream.readNBytesOrThrow(length)
|
||||
|
||||
return BackupInfo.ADAPTER.decode(headerBytes)
|
||||
} catch (e: EOFException) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private fun read(): Frame? {
|
||||
try {
|
||||
val length = inputStream.readVarInt32().also { if (it < 0) return null }
|
||||
val frameBytes: ByteArray = inputStream.readNBytesOrThrow(length)
|
||||
|
||||
return Frame.ADAPTER.decode(frameBytes)
|
||||
} catch (e: EOFException) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.archive.stream
|
||||
|
||||
import org.signal.archive.proto.BackupInfo
|
||||
import org.signal.archive.proto.Frame
|
||||
import org.signal.core.util.writeVarInt32
|
||||
import java.io.IOException
|
||||
import java.io.OutputStream
|
||||
|
||||
/**
|
||||
* Writes backup frames to the wrapped stream in plain text. Only for testing!
|
||||
*/
|
||||
class PlainTextBackupWriter(private val outputStream: OutputStream) : BackupExportWriter {
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun write(header: BackupInfo) {
|
||||
val headerBytes: ByteArray = header.encode()
|
||||
|
||||
outputStream.writeVarInt32(headerBytes.size)
|
||||
outputStream.write(headerBytes)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun write(frame: Frame) {
|
||||
val frameBytes: ByteArray = frame.encode()
|
||||
|
||||
outputStream.writeVarInt32(frameBytes.size)
|
||||
outputStream.write(frameBytes)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
outputStream.close()
|
||||
}
|
||||
}
|
||||
1454
lib/archive/src/main/protowire/Backup.proto
Normal file
1454
lib/archive/src/main/protowire/Backup.proto
Normal file
File diff suppressed because it is too large
Load Diff
18
lib/archive/src/main/protowire/BackupDebugInfo.proto
Normal file
18
lib/archive/src/main/protowire/BackupDebugInfo.proto
Normal file
@@ -0,0 +1,18 @@
|
||||
syntax = "proto3";
|
||||
|
||||
option java_package = "org.signal.archive.proto";
|
||||
|
||||
message BackupDebugInfo {
|
||||
message AttachmentDetails {
|
||||
uint32 notStartedCount = 1;
|
||||
uint32 uploadInProgressCount = 2;
|
||||
uint32 copyPendingCount = 3;
|
||||
uint32 finishedCount = 4;
|
||||
uint32 permanentFailureCount = 5;
|
||||
uint32 temporaryFailureCount = 6;
|
||||
}
|
||||
|
||||
string debuglogUrl = 1;
|
||||
AttachmentDetails attachmentDetails = 2;
|
||||
bool usingPaidTier = 3;
|
||||
}
|
||||
24
lib/archive/src/main/protowire/LocalArchive.proto
Normal file
24
lib/archive/src/main/protowire/LocalArchive.proto
Normal file
@@ -0,0 +1,24 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package signal.backup.local;
|
||||
|
||||
option java_package = "org.signal.archive.local.proto";
|
||||
option swift_prefix = "LocalBackupProto_";
|
||||
|
||||
message Metadata {
|
||||
message EncryptedBackupId {
|
||||
bytes iv = 1; // 12 bytes, randomly generated
|
||||
bytes encryptedId = 2; // AES-256-CTR, key = local backup metadata key, message = backup ID bytes
|
||||
// local backup metadata key = hkdf(input: K_B, info: UTF8("20241011_SIGNAL_LOCAL_BACKUP_METADATA_KEY"), length: 32)
|
||||
// No hash of the ID; if it's decrypted incorrectly, the main backup will fail to decrypt anyway.
|
||||
}
|
||||
|
||||
uint32 version = 1;
|
||||
EncryptedBackupId backupId = 2; // used to decrypt the backup file knowing only the Account Entropy Pool
|
||||
}
|
||||
|
||||
message FilesFrame {
|
||||
oneof item {
|
||||
string mediaName = 1;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user