Add archive media apis.

This commit is contained in:
Cody Henthorne
2024-03-05 11:00:29 -05:00
committed by Alex Hart
parent ccc9752485
commit 218964cbda
18 changed files with 785 additions and 62 deletions

View File

@@ -125,7 +125,7 @@ class ArchiveApi(
* Retrieves all media items in the user's archive. Note that this could be a very large number of items, making this only suitable for debugging.
* Use [getArchiveMediaItemsPage] in production.
*/
fun debugGetUploadedMediaItemMetadata(backupKey: BackupKey, serviceCredential: ArchiveServiceCredential): NetworkResult<List<ArchiveGetMediaItemsResponse.StoredMediaObject>> {
fun debugGetUploadedMediaItemMetadata(backupKey: BackupKey, serviceCredential: ArchiveServiceCredential): NetworkResult<List<StoredMediaObject>> {
return NetworkResult.fromFetch {
val zkCredential = getZkCredential(backupKey, serviceCredential)
val presentationData = CredentialPresentationData.from(backupKey, zkCredential, backupServerPublicParams)
@@ -154,7 +154,58 @@ class ArchiveApi(
val zkCredential = getZkCredential(backupKey, serviceCredential)
val presentationData = CredentialPresentationData.from(backupKey, zkCredential, backupServerPublicParams)
pushServiceSocket.getArchiveMediaItemsPage(presentationData.toArchiveCredentialPresentation(), 512, cursor)
pushServiceSocket.getArchiveMediaItemsPage(presentationData.toArchiveCredentialPresentation(), limit, cursor)
}
}
/**
* Copy and re-encrypt media from the attachments cdn into the backup cdn.
*/
fun archiveAttachmentMedia(
backupKey: BackupKey,
serviceCredential: ArchiveServiceCredential,
item: ArchiveMediaRequest
): NetworkResult<ArchiveMediaResponse> {
return NetworkResult.fromFetch {
val zkCredential = getZkCredential(backupKey, serviceCredential)
val presentationData = CredentialPresentationData.from(backupKey, zkCredential, backupServerPublicParams)
pushServiceSocket.archiveAttachmentMedia(presentationData.toArchiveCredentialPresentation(), item)
}
}
/**
* Copy and re-encrypt media from the attachments cdn into the backup cdn.
*/
fun archiveAttachmentMedia(
backupKey: BackupKey,
serviceCredential: ArchiveServiceCredential,
items: List<ArchiveMediaRequest>
): NetworkResult<BatchArchiveMediaResponse> {
return NetworkResult.fromFetch {
val zkCredential = getZkCredential(backupKey, serviceCredential)
val presentationData = CredentialPresentationData.from(backupKey, zkCredential, backupServerPublicParams)
val request = BatchArchiveMediaRequest(items = items)
pushServiceSocket.archiveAttachmentMedia(presentationData.toArchiveCredentialPresentation(), request)
}
}
/**
* Delete media from the backup cdn.
*/
fun deleteArchivedMedia(
backupKey: BackupKey,
serviceCredential: ArchiveServiceCredential,
mediaToDelete: List<DeleteArchivedMediaRequest.ArchivedMediaObject>
): NetworkResult<Unit> {
return NetworkResult.fromFetch {
val zkCredential = getZkCredential(backupKey, serviceCredential)
val presentationData = CredentialPresentationData.from(backupKey, zkCredential, backupServerPublicParams)
val request = DeleteArchivedMediaRequest(mediaToDelete = mediaToDelete)
pushServiceSocket.deleteArchivedMedia(presentationData.toArchiveCredentialPresentation(), request)
}
}

View File

@@ -5,10 +5,19 @@
package org.whispersystems.signalservice.api.archive
import org.signal.core.util.Base64
/**
* Acts as credentials for various archive operations.
*/
class ArchiveCredentialPresentation(
val presentation: ByteArray,
val signedPresentation: ByteArray
)
) {
fun toHeaders(): MutableMap<String, String> {
return mutableMapOf(
"X-Signal-ZK-Auth" to Base64.encodeWithPadding(presentation),
"X-Signal-ZK-Auth-Signature" to Base64.encodeWithPadding(signedPresentation)
)
}
}

View File

@@ -0,0 +1,25 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.archive
import com.fasterxml.jackson.annotation.JsonProperty
/**
* Request to copy and re-encrypt media from the attachments cdn into the backup cdn.
*/
class ArchiveMediaRequest(
@JsonProperty val sourceAttachment: SourceAttachment,
@JsonProperty val objectLength: Int,
@JsonProperty val mediaId: String,
@JsonProperty val hmacKey: String,
@JsonProperty val encryptionKey: String,
@JsonProperty val iv: String
) {
class SourceAttachment(
@JsonProperty val cdn: Int,
@JsonProperty val key: String
)
}

View File

@@ -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
/**
* Response to archiving media, backup CDN number where media is located.
*/
class ArchiveMediaResponse(
@JsonProperty val cdn: Int
)

View File

@@ -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
/**
* Request to copy and re-encrypt media from the attachments cdn into the backup cdn.
*/
class BatchArchiveMediaRequest(
@JsonProperty val items: List<ArchiveMediaRequest>
)

View File

@@ -0,0 +1,22 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.archive
import com.fasterxml.jackson.annotation.JsonProperty
/**
* Multi-response data for a batch archive media operation.
*/
class BatchArchiveMediaResponse(
@JsonProperty val responses: List<BatchArchiveMediaItemResponse>
) {
class BatchArchiveMediaItemResponse(
@JsonProperty val status: Int?,
@JsonProperty val failureReason: String?,
@JsonProperty val cdn: Int?,
@JsonProperty val mediaId: String
)
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.archive
import com.fasterxml.jackson.annotation.JsonProperty
/**
* Delete media from the backup cdn.
*/
class DeleteArchivedMediaRequest(
@JsonProperty val mediaToDelete: List<ArchivedMediaObject>
) {
class ArchivedMediaObject(
@JsonProperty val cdn: Int,
@JsonProperty val mediaId: String
)
}

View File

@@ -16,7 +16,7 @@ class BackupKey(val value: ByteArray) {
require(value.size == 32) { "Backup key must be 32 bytes!" }
}
fun deriveSecrets(aci: ACI): KeyMaterial {
fun deriveSecrets(aci: ACI): KeyMaterial<BackupId> {
val backupId = BackupId(
HKDF.deriveSecrets(this.value, aci.toByteArray(), "20231003_Signal_Backups_GenerateBackupId".toByteArray(), 16)
)
@@ -24,15 +24,32 @@ class BackupKey(val value: ByteArray) {
val extendedKey = HKDF.deriveSecrets(this.value, backupId.value, "20231003_Signal_Backups_EncryptMessageBackup".toByteArray(), 80)
return KeyMaterial(
backupId = backupId,
id = backupId,
macKey = extendedKey.copyOfRange(0, 32),
cipherKey = extendedKey.copyOfRange(32, 64),
iv = extendedKey.copyOfRange(64, 80)
)
}
class KeyMaterial(
val backupId: BackupId,
fun deriveMediaId(dataHash: ByteArray): MediaId {
return MediaId(HKDF.deriveSecrets(value, dataHash, "Media ID".toByteArray(), 15))
}
fun deriveMediaSecrets(dataHash: ByteArray): KeyMaterial<MediaId> {
val mediaId = deriveMediaId(dataHash)
val extendedKey = HKDF.deriveSecrets(this.value, mediaId.value, "20231003_Signal_Backups_EncryptMedia".toByteArray(), 80)
return KeyMaterial(
id = mediaId,
macKey = extendedKey.copyOfRange(0, 32),
cipherKey = extendedKey.copyOfRange(32, 64),
iv = extendedKey.copyOfRange(64, 80)
)
}
class KeyMaterial<Id> (
val id: Id,
val macKey: ByteArray,
val cipherKey: ByteArray,
val iv: ByteArray

View File

@@ -0,0 +1,23 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.backup
import org.signal.core.util.Base64
/**
* Safe typing around a mediaId, which is a 15-byte array.
*/
@JvmInline
value class MediaId(val value: ByteArray) {
init {
require(value.size == 15) { "MediaId must be 15 bytes!" }
}
override fun toString(): String {
return Base64.encodeUrlSafeWithPadding(value)
}
}

View File

@@ -19,7 +19,6 @@ import org.signal.libsignal.protocol.ecc.ECPublicKey;
import org.signal.libsignal.protocol.kem.KEMPublicKey;
import org.signal.libsignal.protocol.logging.Log;
import org.signal.libsignal.protocol.state.PreKeyBundle;
import org.signal.libsignal.protocol.state.SignedPreKeyRecord;
import org.signal.libsignal.protocol.util.Pair;
import org.signal.libsignal.usernames.BaseUsernameException;
import org.signal.libsignal.usernames.Username;
@@ -50,10 +49,15 @@ import org.whispersystems.signalservice.api.account.PreKeyUpload;
import org.whispersystems.signalservice.api.archive.ArchiveCredentialPresentation;
import org.whispersystems.signalservice.api.archive.ArchiveGetBackupInfoResponse;
import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse;
import org.whispersystems.signalservice.api.archive.ArchiveMediaRequest;
import org.whispersystems.signalservice.api.archive.ArchiveMediaResponse;
import org.whispersystems.signalservice.api.archive.ArchiveMessageBackupUploadFormResponse;
import org.whispersystems.signalservice.api.archive.ArchiveServiceCredentialsResponse;
import org.whispersystems.signalservice.api.archive.ArchiveSetBackupIdRequest;
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.crypto.UnidentifiedAccess;
import org.whispersystems.signalservice.api.groupsv2.CredentialResponse;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2AuthorizationString;
@@ -305,11 +309,15 @@ public class PushServiceSocket {
private static final String BACKUP_AUTH_CHECK = "/v2/backup/auth/check";
private static final String ARCHIVE_CREDENTIALS = "/v1/archives/auth?redemptionStartSeconds=%d&redemptionEndSeconds=%d";
private static final String ARCHIVE_READ_CREDENTIALS = "/v1/archives/auth/read";
private static final String ARCHIVE_BACKUP_ID = "/v1/archives/backupid";
private static final String ARCHIVE_PUBLIC_KEY = "/v1/archives/keys";
private static final String ARCHIVE_INFO = "/v1/archives";
private static final String ARCHIVE_MESSAGE_UPLOAD_FORM = "/v1/archives/upload/form";
private static final String ARCHIVE_MEDIA = "/v1/archives/media";
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 CALL_LINK_CREATION_AUTH = "/v1/call-link/create-auth";
private static final String SERVER_DELIVERED_TIMESTAMP_HEADER = "X-Signal-Timestamp";
@@ -494,18 +502,14 @@ public class PushServiceSocket {
}
public void setArchivePublicKey(ECPublicKey publicKey, ArchiveCredentialPresentation credentialPresentation) throws IOException {
Map<String, String> headers = new HashMap<>();
headers.put("X-Signal-ZK-Auth", Base64.encodeWithPadding(credentialPresentation.getPresentation()));
headers.put("X-Signal-ZK-Auth-Signature", Base64.encodeWithPadding(credentialPresentation.getSignedPresentation()));
Map<String, String> headers = credentialPresentation.toHeaders();
String body = JsonUtil.toJson(new ArchiveSetPublicKeyRequest(publicKey));
makeServiceRequestWithoutAuthentication(ARCHIVE_PUBLIC_KEY, "PUT", body, headers, NO_HANDLER);
}
public ArchiveGetBackupInfoResponse getArchiveBackupInfo(ArchiveCredentialPresentation credentialPresentation) throws IOException {
Map<String, String> headers = new HashMap<>();
headers.put("X-Signal-ZK-Auth", Base64.encodeWithPadding(credentialPresentation.getPresentation()));
headers.put("X-Signal-ZK-Auth-Signature", Base64.encodeWithPadding(credentialPresentation.getSignedPresentation()));
Map<String, String> headers = credentialPresentation.toHeaders();
String response = makeServiceRequestWithoutAuthentication(ARCHIVE_INFO, "GET", null, headers, NO_HANDLER);
return JsonUtil.fromJson(response, ArchiveGetBackupInfoResponse.class);
@@ -529,9 +533,7 @@ public class PushServiceSocket {
* @param cursor A token that can be read from your previous response, telling the server where to start the next page.
*/
public ArchiveGetMediaItemsResponse getArchiveMediaItemsPage(ArchiveCredentialPresentation credentialPresentation, int limit, String cursor) throws IOException {
Map<String, String> headers = new HashMap<>();
headers.put("X-Signal-ZK-Auth", Base64.encodeWithPadding(credentialPresentation.getPresentation()));
headers.put("X-Signal-ZK-Auth-Signature", Base64.encodeWithPadding(credentialPresentation.getSignedPresentation()));
Map<String, String> headers = credentialPresentation.toHeaders();
String url = String.format(Locale.US, ARCHIVE_MEDIA_LIST, limit);
@@ -544,10 +546,39 @@ public class PushServiceSocket {
return JsonUtil.fromJson(response, ArchiveGetMediaItemsResponse.class);
}
/**
* Copy and re-encrypt media from the attachments cdn into the backup cdn.
*/
public ArchiveMediaResponse archiveAttachmentMedia(@Nonnull ArchiveCredentialPresentation credentialPresentation, @Nonnull ArchiveMediaRequest request) throws IOException {
Map<String, String> headers = credentialPresentation.toHeaders();
String response = makeServiceRequestWithoutAuthentication(ARCHIVE_MEDIA, "PUT", JsonUtil.toJson(request), headers, NO_HANDLER);
return JsonUtil.fromJson(response, ArchiveMediaResponse.class);
}
/**
* Copy and re-encrypt media from the attachments cdn into the backup cdn.
*/
public BatchArchiveMediaResponse archiveAttachmentMedia(@Nonnull ArchiveCredentialPresentation credentialPresentation, @Nonnull BatchArchiveMediaRequest request) throws IOException {
Map<String, String> headers = credentialPresentation.toHeaders();
String response = makeServiceRequestWithoutAuthentication(ARCHIVE_MEDIA_BATCH, "PUT", JsonUtil.toJson(request), headers, NO_HANDLER);
return JsonUtil.fromJson(response, BatchArchiveMediaResponse.class);
}
/**
* Delete media from the backup cdn.
*/
public void deleteArchivedMedia(@Nonnull ArchiveCredentialPresentation credentialPresentation, @Nonnull DeleteArchivedMediaRequest request) throws IOException {
Map<String, String> headers = credentialPresentation.toHeaders();
makeServiceRequestWithoutAuthentication(ARCHIVE_MEDIA_DELETE, "POST", JsonUtil.toJson(request), headers, NO_HANDLER);
}
public ArchiveMessageBackupUploadFormResponse getArchiveMessageBackupUploadForm(ArchiveCredentialPresentation credentialPresentation) throws IOException {
Map<String, String> headers = new HashMap<>();
headers.put("X-Signal-ZK-Auth", Base64.encodeWithPadding(credentialPresentation.getPresentation()));
headers.put("X-Signal-ZK-Auth-Signature", Base64.encodeWithPadding(credentialPresentation.getSignedPresentation()));
Map<String, String> headers = credentialPresentation.toHeaders();
String response = makeServiceRequestWithoutAuthentication(ARCHIVE_MESSAGE_UPLOAD_FORM, "GET", null, headers, NO_HANDLER);
return JsonUtil.fromJson(response, ArchiveMessageBackupUploadFormResponse.class);
@@ -2126,7 +2157,7 @@ public class PushServiceSocket {
throw new ServerRejectedException();
}
if (responseCode != 200 && responseCode != 202 && responseCode != 204) {
if (responseCode != 200 && responseCode != 202 && responseCode != 204 && responseCode != 207) {
throw new NonSuccessfulResponseCodeException(responseCode, "Bad response: " + responseCode + " " + responseMessage);
}