mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-05-02 06:33:38 +01:00
Add initial support for backup and restore of message and media to staging.
Co-authored-by: Cody Henthorne <cody@signal.org>
This commit is contained in:
@@ -6,13 +6,17 @@
|
||||
|
||||
package org.whispersystems.signalservice.api;
|
||||
|
||||
import org.signal.core.util.StreamUtil;
|
||||
import org.signal.core.util.concurrent.FutureTransformers;
|
||||
import org.signal.core.util.concurrent.ListenableFuture;
|
||||
import org.signal.core.util.concurrent.SettableFuture;
|
||||
import org.signal.libsignal.protocol.InvalidMessageException;
|
||||
import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations;
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
|
||||
import org.whispersystems.signalservice.api.backup.BackupKey;
|
||||
import org.whispersystems.signalservice.api.backup.MediaId;
|
||||
import org.whispersystems.signalservice.api.crypto.AttachmentCipherInputStream;
|
||||
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil;
|
||||
import org.whispersystems.signalservice.api.crypto.ProfileCipherInputStream;
|
||||
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener;
|
||||
@@ -27,6 +31,7 @@ import org.whispersystems.signalservice.api.push.exceptions.MissingConfiguration
|
||||
import org.whispersystems.signalservice.api.util.CredentialsProvider;
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration;
|
||||
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream;
|
||||
import org.whispersystems.signalservice.internal.push.IdentityCheckRequest;
|
||||
import org.whispersystems.signalservice.internal.push.IdentityCheckResponse;
|
||||
import org.whispersystems.signalservice.internal.push.PushServiceSocket;
|
||||
@@ -36,14 +41,18 @@ import org.whispersystems.signalservice.internal.websocket.ResponseMapper;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
|
||||
@@ -159,10 +168,60 @@ public class SignalServiceMessageReceiver {
|
||||
throws IOException, InvalidMessageException, MissingConfigurationException {
|
||||
if (!pointer.getDigest().isPresent()) throw new InvalidMessageException("No attachment digest!");
|
||||
|
||||
socket.retrieveAttachment(pointer.getCdnNumber(), pointer.getRemoteId(), destination, maxSizeBytes, listener);
|
||||
socket.retrieveAttachment(pointer.getCdnNumber(), Collections.emptyMap(), pointer.getRemoteId(), destination, maxSizeBytes, listener);
|
||||
return AttachmentCipherInputStream.createForAttachment(destination, pointer.getSize().orElse(0), pointer.getKey(), pointer.getDigest().get(), null, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves an archived media attachment.
|
||||
*
|
||||
* @param archivedMediaKeyMaterial Decryption key material for decrypting outer layer of archived media.
|
||||
* @param readCredentialHeaders Headers to pass to the backup CDN to authorize the download
|
||||
* @param archiveDestination The download destination for archived attachment. If this file exists, download will resume.
|
||||
* @param pointer The {@link SignalServiceAttachmentPointer} received in a {@link SignalServiceDataMessage}.
|
||||
* @param attachmentDestination The download destination for this attachment. If this file exists, it is assumed that this is previously-downloaded content that can be resumed.
|
||||
* @param listener An optional listener (may be null) to receive callbacks on download progress.
|
||||
*
|
||||
* @return An InputStream that streams the plaintext attachment contents.
|
||||
*/
|
||||
public InputStream retrieveArchivedAttachment(@Nonnull BackupKey.KeyMaterial<MediaId> archivedMediaKeyMaterial,
|
||||
@Nonnull Map<String, String> readCredentialHeaders,
|
||||
@Nonnull File archiveDestination,
|
||||
@Nonnull SignalServiceAttachmentPointer pointer,
|
||||
@Nonnull File attachmentDestination,
|
||||
long maxSizeBytes,
|
||||
@Nullable ProgressListener listener)
|
||||
throws IOException, InvalidMessageException, MissingConfigurationException
|
||||
{
|
||||
if (pointer.getDigest().isEmpty()) {
|
||||
throw new InvalidMessageException("No attachment digest!");
|
||||
}
|
||||
|
||||
socket.retrieveAttachment(pointer.getCdnNumber(), readCredentialHeaders, pointer.getRemoteId(), archiveDestination, maxSizeBytes, listener);
|
||||
|
||||
long originalCipherLength = pointer.getSize()
|
||||
.filter(s -> s > 0)
|
||||
.map(s -> AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(s)))
|
||||
.orElse(0L);
|
||||
|
||||
try (InputStream backupDecrypted = AttachmentCipherInputStream.createForArchivedMedia(archivedMediaKeyMaterial, archiveDestination, originalCipherLength)) {
|
||||
try (FileOutputStream fos = new FileOutputStream(attachmentDestination)) {
|
||||
StreamUtil.copy(backupDecrypted, fos);
|
||||
}
|
||||
}
|
||||
|
||||
return AttachmentCipherInputStream.createForAttachment(attachmentDestination,
|
||||
pointer.getSize().orElse(0),
|
||||
pointer.getKey(),
|
||||
pointer.getDigest().get(),
|
||||
null,
|
||||
0);
|
||||
}
|
||||
|
||||
public void retrieveBackup(int cdnNumber, Map<String, String> headers, String cdnPath, File destination, ProgressListener listener) throws MissingConfigurationException, IOException {
|
||||
socket.retrieveBackup(cdnNumber, headers, cdnPath, destination, 1_000_000_000L, listener);
|
||||
}
|
||||
|
||||
public InputStream retrieveSticker(byte[] packId, byte[] packKey, int stickerId)
|
||||
throws IOException, InvalidMessageException
|
||||
{
|
||||
|
||||
@@ -841,7 +841,7 @@ public class SignalServiceMessageSender {
|
||||
Pair<Long, AttachmentDigest> attachmentIdAndDigest = socket.uploadAttachment(attachmentData, v2UploadAttributes);
|
||||
|
||||
return new SignalServiceAttachmentPointer(0,
|
||||
new SignalServiceAttachmentRemoteId(attachmentIdAndDigest.first()),
|
||||
new SignalServiceAttachmentRemoteId.V2(attachmentIdAndDigest.first()),
|
||||
attachment.getContentType(),
|
||||
attachmentKey,
|
||||
Optional.of(Util.toIntExact(attachment.getLength())),
|
||||
@@ -882,7 +882,7 @@ public class SignalServiceMessageSender {
|
||||
private SignalServiceAttachmentPointer uploadAttachmentV4(SignalServiceAttachmentStream attachment, byte[] attachmentKey, PushAttachmentData attachmentData) throws IOException {
|
||||
AttachmentDigest digest = socket.uploadAttachment(attachmentData);
|
||||
return new SignalServiceAttachmentPointer(attachmentData.getResumableUploadSpec().getCdnNumber(),
|
||||
new SignalServiceAttachmentRemoteId(attachmentData.getResumableUploadSpec().getCdnKey()),
|
||||
new SignalServiceAttachmentRemoteId.V4(attachmentData.getResumableUploadSpec().getCdnKey()),
|
||||
attachment.getContentType(),
|
||||
attachmentKey,
|
||||
Optional.of(Util.toIntExact(attachment.getLength())),
|
||||
|
||||
@@ -55,6 +55,15 @@ class ArchiveApi(
|
||||
}
|
||||
}
|
||||
|
||||
fun getCdnReadCredentials(backupKey: BackupKey, serviceCredential: ArchiveServiceCredential): NetworkResult<GetArchiveCdnCredentialsResponse> {
|
||||
return NetworkResult.fromFetch {
|
||||
val zkCredential = getZkCredential(backupKey, serviceCredential)
|
||||
val presentationData = CredentialPresentationData.from(backupKey, zkCredential, backupServerPublicParams)
|
||||
|
||||
pushServiceSocket.getArchiveCdnReadCredentials(presentationData.toArchiveCredentialPresentation())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that you reserve a backupId on the service. This must be done before any other
|
||||
* backup-related calls. You only need to do it once, but repeated calls are safe.
|
||||
|
||||
@@ -16,6 +16,8 @@ data class ArchiveGetBackupInfoResponse(
|
||||
@JsonProperty
|
||||
val backupDir: String?,
|
||||
@JsonProperty
|
||||
val mediaDir: String?,
|
||||
@JsonProperty
|
||||
val backupName: String?,
|
||||
@JsonProperty
|
||||
val usedSpace: Long?
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.archive
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
/**
|
||||
* Get response with headers to use to read from archive cdn.
|
||||
*/
|
||||
class GetArchiveCdnCredentialsResponse(
|
||||
@JsonProperty val headers: Map<String, String>
|
||||
)
|
||||
@@ -5,6 +5,9 @@
|
||||
|
||||
package org.whispersystems.signalservice.api.backup
|
||||
|
||||
import org.signal.core.util.Base64
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* Safe typing around a backupId, which is a 16-byte array.
|
||||
*/
|
||||
@@ -14,4 +17,9 @@ value class BackupId(val value: ByteArray) {
|
||||
init {
|
||||
require(value.size == 16) { "BackupId must be 16 bytes!" }
|
||||
}
|
||||
|
||||
/** Encode backup-id for use in a URL/request */
|
||||
fun encode(): String {
|
||||
return Base64.encodeUrlSafeWithPadding(MessageDigest.getInstance("SHA-256").digest(value).copyOfRange(0, 16))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,10 +16,14 @@ class BackupKey(val value: ByteArray) {
|
||||
require(value.size == 32) { "Backup key must be 32 bytes!" }
|
||||
}
|
||||
|
||||
fun deriveSecrets(aci: ACI): KeyMaterial<BackupId> {
|
||||
val backupId = BackupId(
|
||||
fun deriveBackupId(aci: ACI): BackupId {
|
||||
return BackupId(
|
||||
HKDF.deriveSecrets(this.value, aci.toByteArray(), "20231003_Signal_Backups_GenerateBackupId".toByteArray(), 16)
|
||||
)
|
||||
}
|
||||
|
||||
fun deriveSecrets(aci: ACI): KeyMaterial<BackupId> {
|
||||
val backupId = deriveBackupId(aci)
|
||||
|
||||
val extendedKey = HKDF.deriveSecrets(this.value, backupId.value, "20231003_Signal_Backups_EncryptMessageBackup".toByteArray(), 80)
|
||||
|
||||
@@ -31,13 +35,15 @@ class BackupKey(val value: ByteArray) {
|
||||
)
|
||||
}
|
||||
|
||||
fun deriveMediaId(dataHash: ByteArray): MediaId {
|
||||
return MediaId(HKDF.deriveSecrets(value, dataHash, "Media ID".toByteArray(), 15))
|
||||
fun deriveMediaId(mediaName: MediaName): MediaId {
|
||||
return MediaId(HKDF.deriveSecrets(value, mediaName.toByteArray(), "Media ID".toByteArray(), 15))
|
||||
}
|
||||
|
||||
fun deriveMediaSecrets(dataHash: ByteArray): KeyMaterial<MediaId> {
|
||||
val mediaId = deriveMediaId(dataHash)
|
||||
fun deriveMediaSecrets(mediaName: MediaName): KeyMaterial<MediaId> {
|
||||
return deriveMediaSecrets(deriveMediaId(mediaName))
|
||||
}
|
||||
|
||||
fun deriveMediaSecrets(mediaId: MediaId): KeyMaterial<MediaId> {
|
||||
val extendedKey = HKDF.deriveSecrets(this.value, mediaId.value, "20231003_Signal_Backups_EncryptMedia".toByteArray(), 80)
|
||||
|
||||
return KeyMaterial(
|
||||
@@ -53,5 +59,17 @@ class BackupKey(val value: ByteArray) {
|
||||
val macKey: ByteArray,
|
||||
val cipherKey: ByteArray,
|
||||
val iv: ByteArray
|
||||
)
|
||||
) {
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun forMedia(id: ByteArray, keyMac: ByteArray, iv: ByteArray): KeyMaterial<MediaId> {
|
||||
return KeyMaterial(
|
||||
MediaId(id),
|
||||
keyMac.copyOfRange(32, 64),
|
||||
keyMac.copyOfRange(0, 32),
|
||||
iv
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,14 @@ import org.signal.core.util.Base64
|
||||
@JvmInline
|
||||
value class MediaId(val value: ByteArray) {
|
||||
|
||||
constructor(mediaId: String) : this(Base64.decode(mediaId))
|
||||
|
||||
init {
|
||||
require(value.size == 15) { "MediaId must be 15 bytes!" }
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
/** Encode media-id for use in a URL/request */
|
||||
fun encode(): String {
|
||||
return Base64.encodeUrlSafeWithPadding(value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.backup
|
||||
|
||||
import org.signal.core.util.Base64
|
||||
|
||||
/**
|
||||
* Represent a media name for the various types of media that can be archived.
|
||||
*/
|
||||
@JvmInline
|
||||
value class MediaName(val name: String) {
|
||||
|
||||
companion object {
|
||||
fun fromDigest(digest: ByteArray) = MediaName(Base64.encodeWithoutPadding(digest))
|
||||
fun fromDigestForThumbnail(digest: ByteArray) = MediaName("${Base64.encodeWithoutPadding(digest)}_thumbnail")
|
||||
}
|
||||
|
||||
fun toByteArray(): ByteArray {
|
||||
return name.toByteArray()
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,9 @@ import org.signal.libsignal.protocol.InvalidMacException;
|
||||
import org.signal.libsignal.protocol.InvalidMessageException;
|
||||
import org.signal.libsignal.protocol.incrementalmac.ChunkSizeChoice;
|
||||
import org.signal.libsignal.protocol.incrementalmac.IncrementalMacInputStream;
|
||||
import org.signal.libsignal.protocol.kdf.HKDFv3;
|
||||
import org.signal.libsignal.protocol.kdf.HKDF;
|
||||
import org.whispersystems.signalservice.api.backup.BackupKey;
|
||||
import org.whispersystems.signalservice.api.backup.MediaId;
|
||||
import org.whispersystems.signalservice.internal.util.ContentLengthInputStream;
|
||||
import org.whispersystems.signalservice.internal.util.Util;
|
||||
|
||||
@@ -26,6 +28,8 @@ import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Arrays;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import javax.crypto.BadPaddingException;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.IllegalBlockSizeException;
|
||||
@@ -47,9 +51,10 @@ public class AttachmentCipherInputStream extends FilterInputStream {
|
||||
private static final int CIPHER_KEY_SIZE = 32;
|
||||
private static final int MAC_KEY_SIZE = 32;
|
||||
|
||||
private Cipher cipher;
|
||||
private final Cipher cipher;
|
||||
private final long totalDataSize;
|
||||
|
||||
private boolean done;
|
||||
private long totalDataSize;
|
||||
private long totalRead;
|
||||
private byte[] overflowBuffer;
|
||||
|
||||
@@ -102,11 +107,43 @@ public class AttachmentCipherInputStream extends FilterInputStream {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt archived media to it's original attachment encrypted blob.
|
||||
*/
|
||||
public static InputStream createForArchivedMedia(BackupKey.KeyMaterial<MediaId> archivedMediaKeyMaterial, File file, long originalCipherTextLength)
|
||||
throws InvalidMessageException, IOException
|
||||
{
|
||||
try {
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(archivedMediaKeyMaterial.getMacKey(), "HmacSHA256"));
|
||||
|
||||
if (file.length() <= BLOCK_SIZE + mac.getMacLength()) {
|
||||
throw new InvalidMessageException("Message shorter than crypto overhead!");
|
||||
}
|
||||
|
||||
try (FileInputStream macVerificationStream = new FileInputStream(file)) {
|
||||
verifyMac(macVerificationStream, file.length(), mac, null);
|
||||
}
|
||||
|
||||
InputStream inputStream = new AttachmentCipherInputStream(new FileInputStream(file), archivedMediaKeyMaterial.getCipherKey(), file.length() - BLOCK_SIZE - mac.getMacLength());
|
||||
|
||||
if (originalCipherTextLength != 0) {
|
||||
inputStream = new ContentLengthInputStream(inputStream, originalCipherTextLength);
|
||||
}
|
||||
|
||||
return inputStream;
|
||||
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
|
||||
throw new AssertionError(e);
|
||||
} catch (InvalidMacException e) {
|
||||
throw new InvalidMessageException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static InputStream createForStickerData(byte[] data, byte[] packKey)
|
||||
throws InvalidMessageException, IOException
|
||||
{
|
||||
try {
|
||||
byte[] combinedKeyMaterial = new HKDFv3().deriveSecrets(packKey, "Sticker Pack".getBytes(), 64);
|
||||
byte[] combinedKeyMaterial = HKDF.deriveSecrets(packKey, "Sticker Pack".getBytes(), 64);
|
||||
byte[][] parts = Util.split(combinedKeyMaterial, CIPHER_KEY_SIZE, MAC_KEY_SIZE);
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(parts[1], "HmacSHA256"));
|
||||
@@ -159,12 +196,12 @@ public class AttachmentCipherInputStream extends FilterInputStream {
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read(byte[] buffer) throws IOException {
|
||||
public int read(@Nonnull byte[] buffer) throws IOException {
|
||||
return read(buffer, 0, buffer.length);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read(byte[] buffer, int offset, int length) throws IOException {
|
||||
public int read(@Nonnull byte[] buffer, int offset, int length) throws IOException {
|
||||
if (totalRead != totalDataSize) {
|
||||
return readIncremental(buffer, offset, length);
|
||||
} else if (!done) {
|
||||
@@ -256,7 +293,7 @@ public class AttachmentCipherInputStream extends FilterInputStream {
|
||||
}
|
||||
}
|
||||
|
||||
private static void verifyMac(InputStream inputStream, long length, Mac mac, byte[] theirDigest)
|
||||
private static void verifyMac(@Nonnull InputStream inputStream, long length, @Nonnull Mac mac, @Nullable byte[] theirDigest)
|
||||
throws InvalidMacException
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
package org.whispersystems.signalservice.api.messages;
|
||||
|
||||
import org.whispersystems.signalservice.api.InvalidMessageStructureException;
|
||||
import org.whispersystems.signalservice.internal.push.AttachmentPointer;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Represents a signal service attachment identifier. This can be either a CDN key or a long, but
|
||||
* not both at once. Attachments V2 used a long as an attachment identifier. This lacks sufficient
|
||||
* entropy to reduce the likelihood of any two uploads going to the same location within a 30-day
|
||||
* window. Attachments V3 uses an opaque string as an attachment identifier which provides more
|
||||
* flexibility in the amount of entropy present.
|
||||
*/
|
||||
public final class SignalServiceAttachmentRemoteId {
|
||||
private final Optional<Long> v2;
|
||||
private final Optional<String> v3;
|
||||
|
||||
public SignalServiceAttachmentRemoteId(long v2) {
|
||||
this.v2 = Optional.of(v2);
|
||||
this.v3 = Optional.empty();
|
||||
}
|
||||
|
||||
public SignalServiceAttachmentRemoteId(String v3) {
|
||||
this.v2 = Optional.empty();
|
||||
this.v3 = Optional.of(v3);
|
||||
}
|
||||
|
||||
public Optional<Long> getV2() {
|
||||
return v2;
|
||||
}
|
||||
|
||||
public Optional<String> getV3() {
|
||||
return v3;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
if (v2.isPresent()) {
|
||||
return v2.get().toString();
|
||||
} else {
|
||||
return v3.get();
|
||||
}
|
||||
}
|
||||
|
||||
public static SignalServiceAttachmentRemoteId from(AttachmentPointer attachmentPointer) throws InvalidMessageStructureException {
|
||||
if (attachmentPointer.cdnKey != null) {
|
||||
return new SignalServiceAttachmentRemoteId(attachmentPointer.cdnKey);
|
||||
} else if (attachmentPointer.cdnId != null && attachmentPointer.cdnId > 0) {
|
||||
return new SignalServiceAttachmentRemoteId(attachmentPointer.cdnId);
|
||||
} else {
|
||||
throw new InvalidMessageStructureException("AttachmentPointer CDN location not set");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Guesses that strings which contain values parseable to {@code long} should use an id-based
|
||||
* CDN path. Otherwise, use key-based CDN path.
|
||||
*/
|
||||
public static SignalServiceAttachmentRemoteId from(String string) {
|
||||
try {
|
||||
return new SignalServiceAttachmentRemoteId(Long.parseLong(string));
|
||||
} catch (NumberFormatException e) {
|
||||
return new SignalServiceAttachmentRemoteId(string);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package org.whispersystems.signalservice.api.messages
|
||||
|
||||
import org.whispersystems.signalservice.api.InvalidMessageStructureException
|
||||
import org.whispersystems.signalservice.internal.push.AttachmentPointer
|
||||
|
||||
/**
|
||||
* Represents a signal service attachment identifier. This can be either a CDN key or a long, but
|
||||
* not both at once. Attachments V2 used a long as an attachment identifier. This lacks sufficient
|
||||
* entropy to reduce the likelihood of any two uploads going to the same location within a 30-day
|
||||
* window. Attachments V4 (backwards compatible with V3) uses an opaque string as an attachment
|
||||
* identifier which provides more flexibility in the amount of entropy present.
|
||||
*/
|
||||
sealed interface SignalServiceAttachmentRemoteId {
|
||||
|
||||
object S3 : SignalServiceAttachmentRemoteId {
|
||||
override fun toString() = ""
|
||||
}
|
||||
|
||||
data class V2(val cdnId: Long) : SignalServiceAttachmentRemoteId {
|
||||
override fun toString() = cdnId.toString()
|
||||
}
|
||||
|
||||
data class V4(val cdnKey: String) : SignalServiceAttachmentRemoteId {
|
||||
override fun toString() = cdnKey
|
||||
}
|
||||
|
||||
data class Backup(val backupDir: String, val mediaDir: String, val mediaId: String) : SignalServiceAttachmentRemoteId {
|
||||
override fun toString() = mediaId
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmStatic
|
||||
@Throws(InvalidMessageStructureException::class)
|
||||
fun from(attachmentPointer: AttachmentPointer): SignalServiceAttachmentRemoteId {
|
||||
return if (attachmentPointer.cdnKey != null) {
|
||||
V4(attachmentPointer.cdnKey)
|
||||
} else if (attachmentPointer.cdnId != null && attachmentPointer.cdnId > 0) {
|
||||
V2(attachmentPointer.cdnId)
|
||||
} else {
|
||||
throw InvalidMessageStructureException("AttachmentPointer CDN location not set")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Guesses that strings which contain values parseable to `long` should use an id-based
|
||||
* CDN path. Otherwise, use key-based CDN path.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun from(string: String): SignalServiceAttachmentRemoteId {
|
||||
return string.toLongOrNull()?.let { V2(it) } ?: V4(string)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,12 +56,12 @@ public final class AttachmentPointerUtil {
|
||||
builder.incrementalMacChunkSize(attachment.getIncrementalMacChunkSize());
|
||||
}
|
||||
|
||||
if (attachment.getRemoteId().getV2().isPresent()) {
|
||||
builder.cdnId(attachment.getRemoteId().getV2().get());
|
||||
if (attachment.getRemoteId() instanceof SignalServiceAttachmentRemoteId.V2) {
|
||||
builder.cdnId(((SignalServiceAttachmentRemoteId.V2) attachment.getRemoteId()).getCdnId());
|
||||
}
|
||||
|
||||
if (attachment.getRemoteId().getV3().isPresent()) {
|
||||
builder.cdnKey(attachment.getRemoteId().getV3().get());
|
||||
if (attachment.getRemoteId() instanceof SignalServiceAttachmentRemoteId.V4) {
|
||||
builder.cdnKey(((SignalServiceAttachmentRemoteId.V4) attachment.getRemoteId()).getCdnKey());
|
||||
}
|
||||
|
||||
if (attachment.getFileName().isPresent()) {
|
||||
|
||||
@@ -58,6 +58,7 @@ import org.whispersystems.signalservice.api.archive.ArchiveSetPublicKeyRequest;
|
||||
import org.whispersystems.signalservice.api.archive.BatchArchiveMediaRequest;
|
||||
import org.whispersystems.signalservice.api.archive.BatchArchiveMediaResponse;
|
||||
import org.whispersystems.signalservice.api.archive.DeleteArchivedMediaRequest;
|
||||
import org.whispersystems.signalservice.api.archive.GetArchiveCdnCredentialsResponse;
|
||||
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
|
||||
import org.whispersystems.signalservice.api.groupsv2.CredentialResponse;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupsV2AuthorizationString;
|
||||
@@ -319,6 +320,7 @@ public class PushServiceSocket {
|
||||
private static final String ARCHIVE_MEDIA_LIST = "/v1/archives/media?limit=%d";
|
||||
private static final String ARCHIVE_MEDIA_BATCH = "/v1/archives/media/batch";
|
||||
private static final String ARCHIVE_MEDIA_DELETE = "/v1/archives/media/delete";
|
||||
private static final String ARCHIVE_MEDIA_DOWNLOAD_PATH = "backups/%s/%s/%s";
|
||||
|
||||
private static final String CALL_LINK_CREATION_AUTH = "/v1/call-link/create-auth";
|
||||
private static final String SERVER_DELIVERED_TIMESTAMP_HEADER = "X-Signal-Timestamp";
|
||||
@@ -585,6 +587,16 @@ public class PushServiceSocket {
|
||||
return JsonUtil.fromJson(response, ArchiveMessageBackupUploadFormResponse.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy and re-encrypt media from the attachments cdn into the backup cdn.
|
||||
*/
|
||||
public GetArchiveCdnCredentialsResponse getArchiveCdnReadCredentials(@Nonnull ArchiveCredentialPresentation credentialPresentation) throws IOException {
|
||||
Map<String, String> headers = credentialPresentation.toHeaders();
|
||||
|
||||
String response = makeServiceRequestWithoutAuthentication(ARCHIVE_READ_CREDENTIALS, "GET", null, headers, NO_HANDLER);
|
||||
|
||||
return JsonUtil.fromJson(response, GetArchiveCdnCredentialsResponse.class);
|
||||
}
|
||||
|
||||
public VerifyAccountResponse changeNumber(@Nonnull ChangePhoneNumberRequest changePhoneNumberRequest)
|
||||
throws IOException
|
||||
@@ -919,16 +931,27 @@ public class PushServiceSocket {
|
||||
}, Optional.empty());
|
||||
}
|
||||
|
||||
public void retrieveAttachment(int cdnNumber, SignalServiceAttachmentRemoteId cdnPath, File destination, long maxSizeBytes, ProgressListener listener)
|
||||
public void retrieveBackup(int cdnNumber, Map<String, String> headers, String cdnPath, File destination, long maxSizeBytes, ProgressListener listener)
|
||||
throws MissingConfigurationException, IOException
|
||||
{
|
||||
downloadFromCdn(destination, cdnNumber, headers, cdnPath, maxSizeBytes, listener);
|
||||
}
|
||||
|
||||
public void retrieveAttachment(int cdnNumber, Map<String, String> headers, SignalServiceAttachmentRemoteId cdnPath, File destination, long maxSizeBytes, ProgressListener listener)
|
||||
throws IOException, MissingConfigurationException
|
||||
{
|
||||
final String path;
|
||||
if (cdnPath.getV2().isPresent()) {
|
||||
path = String.format(Locale.US, ATTACHMENT_ID_DOWNLOAD_PATH, cdnPath.getV2().get());
|
||||
if (cdnPath instanceof SignalServiceAttachmentRemoteId.V2) {
|
||||
path = String.format(Locale.US, ATTACHMENT_ID_DOWNLOAD_PATH, ((SignalServiceAttachmentRemoteId.V2) cdnPath).getCdnId());
|
||||
} else if (cdnPath instanceof SignalServiceAttachmentRemoteId.V4) {
|
||||
path = String.format(Locale.US, ATTACHMENT_KEY_DOWNLOAD_PATH, ((SignalServiceAttachmentRemoteId.V4) cdnPath).getCdnKey());
|
||||
} else if (cdnPath instanceof SignalServiceAttachmentRemoteId.Backup) {
|
||||
SignalServiceAttachmentRemoteId.Backup backupCdnId = (SignalServiceAttachmentRemoteId.Backup) cdnPath;
|
||||
path = String.format(Locale.US, ARCHIVE_MEDIA_DOWNLOAD_PATH, backupCdnId.getBackupDir(), backupCdnId.getMediaDir(), backupCdnId.getMediaId());
|
||||
} else {
|
||||
path = String.format(Locale.US, ATTACHMENT_KEY_DOWNLOAD_PATH, cdnPath.getV3().get());
|
||||
throw new IllegalArgumentException("Invalid cdnPath type: " + cdnPath.getClass().getSimpleName());
|
||||
}
|
||||
downloadFromCdn(destination, cdnNumber, path, maxSizeBytes, listener);
|
||||
downloadFromCdn(destination, cdnNumber, headers, path, maxSizeBytes, listener);
|
||||
}
|
||||
|
||||
public byte[] retrieveSticker(byte[] packId, int stickerId)
|
||||
@@ -937,7 +960,7 @@ public class PushServiceSocket {
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
|
||||
try {
|
||||
downloadFromCdn(output, 0, 0, String.format(Locale.US, STICKER_PATH, hexPackId, stickerId), 1024 * 1024, null);
|
||||
downloadFromCdn(output, 0, 0, Collections.emptyMap(), String.format(Locale.US, STICKER_PATH, hexPackId, stickerId), 1024 * 1024, null);
|
||||
} catch (MissingConfigurationException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
@@ -951,7 +974,7 @@ public class PushServiceSocket {
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
|
||||
try {
|
||||
downloadFromCdn(output, 0, 0, String.format(STICKER_MANIFEST_PATH, hexPackId), 1024 * 1024, null);
|
||||
downloadFromCdn(output, 0, 0, Collections.emptyMap(), String.format(STICKER_MANIFEST_PATH, hexPackId), 1024 * 1024, null);
|
||||
} catch (MissingConfigurationException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
@@ -1029,7 +1052,7 @@ public class PushServiceSocket {
|
||||
throws IOException
|
||||
{
|
||||
try {
|
||||
downloadFromCdn(destination, 0, path, maxSizeBytes, null);
|
||||
downloadFromCdn(destination, 0, Collections.emptyMap(), path, maxSizeBytes, null);
|
||||
} catch (MissingConfigurationException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
@@ -1577,15 +1600,15 @@ public class PushServiceSocket {
|
||||
}
|
||||
}
|
||||
|
||||
private void downloadFromCdn(File destination, int cdnNumber, String path, long maxSizeBytes, ProgressListener listener)
|
||||
private void downloadFromCdn(File destination, int cdnNumber, Map<String, String> headers, String path, long maxSizeBytes, ProgressListener listener)
|
||||
throws IOException, MissingConfigurationException
|
||||
{
|
||||
try (FileOutputStream outputStream = new FileOutputStream(destination, true)) {
|
||||
downloadFromCdn(outputStream, destination.length(), cdnNumber, path, maxSizeBytes, listener);
|
||||
downloadFromCdn(outputStream, destination.length(), cdnNumber, headers, path, maxSizeBytes, listener);
|
||||
}
|
||||
}
|
||||
|
||||
private void downloadFromCdn(OutputStream outputStream, long offset, int cdnNumber, String path, long maxSizeBytes, ProgressListener listener)
|
||||
private void downloadFromCdn(OutputStream outputStream, long offset, int cdnNumber, Map<String, String> headers, String path, long maxSizeBytes, ProgressListener listener)
|
||||
throws PushNetworkException, NonSuccessfulResponseCodeException, MissingConfigurationException {
|
||||
ConnectionHolder[] cdnNumberClients = cdnClientsMap.get(cdnNumber);
|
||||
if (cdnNumberClients == null) {
|
||||
@@ -1604,6 +1627,10 @@ public class PushServiceSocket {
|
||||
request.addHeader("Host", connectionHolder.getHostHeader().get());
|
||||
}
|
||||
|
||||
for (Map.Entry<String, String> header : headers.entrySet()) {
|
||||
request.addHeader(header.getKey(), header.getValue());
|
||||
}
|
||||
|
||||
if (offset > 0) {
|
||||
Log.i(TAG, "Starting download from CDN with offset " + offset);
|
||||
request.addHeader("Range", "bytes=" + offset + "-");
|
||||
|
||||
@@ -300,7 +300,7 @@ public class WebSocketConnection extends WebSocketListener {
|
||||
OutgoingRequest listener = outgoingRequests.remove(message.response.id);
|
||||
if (listener != null) {
|
||||
listener.onSuccess(new WebsocketResponse(message.response.status,
|
||||
new String(message.response.body.toByteArray()),
|
||||
message.response.body == null ? "" : new String(message.response.body.toByteArray()),
|
||||
message.response.headers,
|
||||
!credentialsProvider.isPresent()));
|
||||
if (message.response.status >= 400) {
|
||||
|
||||
Reference in New Issue
Block a user