diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/BackupsAnonymousGrpcService.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/BackupsAnonymousGrpcService.java index 3fa856b90..7e13c34b4 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/BackupsAnonymousGrpcService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/BackupsAnonymousGrpcService.java @@ -16,9 +16,10 @@ import org.signal.chat.backup.DeleteMediaItem; import org.signal.chat.backup.DeleteMediaRequest; import org.signal.chat.backup.DeleteMediaResponse; import org.signal.chat.backup.GetBackupInfoRequest; -import org.signal.chat.backup.GetBackupInfoResponse; import org.signal.chat.backup.GetCdnCredentialsRequest; import org.signal.chat.backup.GetCdnCredentialsResponse; +import org.signal.chat.backup.GetMediaBackupInfoResponse; +import org.signal.chat.backup.GetMessageBackupInfoResponse; import org.signal.chat.backup.GetSvrBCredentialsRequest; import org.signal.chat.backup.GetSvrBCredentialsResponse; import org.signal.chat.backup.GetUploadFormRequest; @@ -37,6 +38,7 @@ import org.signal.libsignal.protocol.InvalidKeyException; import org.signal.libsignal.protocol.ecc.ECPublicKey; import org.signal.libsignal.zkgroup.InvalidInputException; import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialPresentation; +import org.signal.libsignal.zkgroup.backups.BackupCredentialType; import org.whispersystems.textsecuregcm.auth.AuthenticatedBackupUser; import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials; import org.whispersystems.textsecuregcm.backup.BackupFailedZkAuthenticationException; @@ -99,19 +101,40 @@ public class BackupsAnonymousGrpcService extends SimpleBackupsAnonymousGrpc.Back } @Override - public GetBackupInfoResponse getBackupInfo(final GetBackupInfoRequest request) throws BackupPermissionException { + public GetMessageBackupInfoResponse getMessageBackupInfo(final GetBackupInfoRequest request) throws BackupPermissionException { try { final AuthenticatedBackupUser backupUser = authenticateBackupUser(request.getSignedPresentation()); + if (backupUser.credentialType() != BackupCredentialType.MESSAGES) { + throw GrpcExceptions.badAuthentication("credential type for message backup info must be 'messages'"); + } final BackupManager.BackupInfo info = backupManager.backupInfo(backupUser); - return GetBackupInfoResponse.newBuilder().setBackupInfo(GetBackupInfoResponse.BackupInfo.newBuilder() + return GetMessageBackupInfoResponse.newBuilder().setBackupInfo(GetMessageBackupInfoResponse.MessageBackupInfo.newBuilder() .setBackupName(info.messageBackupKey()) .setCdn(info.cdn()) .setBackupDir(info.backupSubdir()) - .setMediaDir(info.mediaSubdir()) - .setUsedSpace(info.mediaUsedSpace().orElse(0L))) - .build(); + .build()).build(); } catch (BackupFailedZkAuthenticationException e) { - return GetBackupInfoResponse.newBuilder() + return GetMessageBackupInfoResponse.newBuilder() + .setFailedAuthentication(FailedZkAuthentication.newBuilder().setDescription(e.getMessage()).build()) + .build(); + } + } + + @Override + public GetMediaBackupInfoResponse getMediaBackupInfo(final GetBackupInfoRequest request) throws BackupPermissionException { + try { + final AuthenticatedBackupUser backupUser = authenticateBackupUser(request.getSignedPresentation()); + if (backupUser.credentialType() != BackupCredentialType.MEDIA) { + throw GrpcExceptions.badAuthentication("credential type for media backup info must be 'media'"); + } + final BackupManager.BackupInfo info = backupManager.backupInfo(backupUser); + return GetMediaBackupInfoResponse.newBuilder().setBackupInfo(GetMediaBackupInfoResponse.MediaBackupInfo.newBuilder() + .setBackupDir(info.backupSubdir()) + .setMediaDir(info.mediaSubdir()) + .setUsedSpace(info.mediaUsedSpace().orElse(0L)) + .build()).build(); + } catch (BackupFailedZkAuthenticationException e) { + return GetMediaBackupInfoResponse.newBuilder() .setFailedAuthentication(FailedZkAuthentication.newBuilder().setDescription(e.getMessage()).build()) .build(); } diff --git a/service/src/main/proto/org/signal/chat/backups.proto b/service/src/main/proto/org/signal/chat/backups.proto index f3660ba3b..e81790c6f 100644 --- a/service/src/main/proto/org/signal/chat/backups.proto +++ b/service/src/main/proto/org/signal/chat/backups.proto @@ -152,8 +152,11 @@ service BackupsAnonymous { // Retrieve credentials used to interact with the SecureValueRecoveryB service rpc GetSvrBCredentials(GetSvrBCredentialsRequest) returns (GetSvrBCredentialsResponse) {} - // Retrieve information about the currently stored backup - rpc GetBackupInfo(GetBackupInfoRequest) returns (GetBackupInfoResponse) {} + // Retrieve information about the currently stored message backup + rpc GetMessageBackupInfo(GetBackupInfoRequest) returns (GetMessageBackupInfoResponse) {} + + // Retrieve information about the currently stored media backup + rpc GetMediaBackupInfo(GetBackupInfoRequest) returns (GetMediaBackupInfoResponse) {} // Permanently set the public key of an ED25519 key-pair for the backup-id. // All requests (including this one!) must sign their BackupAuthCredential @@ -277,11 +280,34 @@ message GetSvrBCredentialsResponse { message GetBackupInfoRequest { SignedPresentation signed_presentation = 1; } -message GetBackupInfoResponse { - message BackupInfo { +message GetMessageBackupInfoResponse { + message MessageBackupInfo { // The base directory of your backup data on the cdn. The message backup can - // be found in the returned cdn at /backup_dir/backup_name and stored media can - // be found at /backup_dir/media_dir/media_id + // be found in the returned cdn at /backup_dir/backup_name + string backup_dir = 1; + + // The CDN type where the message backup is stored. Media may be stored + // elsewhere. + uint32 cdn = 2; + + // The location of the message backup on the cdn. If a backup was previously + // uploaded and unexpired, it can be found at /backup_dir/backup_name. + string backup_name = 3; + } + + oneof outcome { + MessageBackupInfo backup_info = 1; + + // The provided backup auth credential presentation could not be + // authenticated. Either, the presentation could not be verified, or + // the public key signature was invalid, or there is no backup associated + // with the backup-id in the presentation. + errors.FailedZkAuthentication failed_authentication = 2 [(tag.reason) = "failed_authentication"]; + } +} +message GetMediaBackupInfoResponse { + message MediaBackupInfo { + // The base directory of your backup data on the cdn. string backup_dir = 1; // The prefix path component for media objects on a cdn. Stored media for a @@ -289,20 +315,12 @@ message GetBackupInfoResponse { // is encoded in unpadded url-safe base64. string media_dir = 2; - // The CDN type where the message backup is stored. Media may be stored - // elsewhere. If absent, no message backup currently exists. - optional uint32 cdn = 3; - - // The name of the most recent message backup on the cdn. The backup is at - // /backup_dir/backup_name. If absent, no message backup currently exists. - optional string backup_name = 4; - // The amount of space used to store media - uint64 used_space = 5; + uint64 used_space = 3; } oneof outcome { - BackupInfo backup_info = 1; + MediaBackupInfo backup_info = 1; // The provided backup auth credential presentation could not be // authenticated. Either, the presentation could not be verified, or diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/BackupsAnonymousGrpcServiceTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/BackupsAnonymousGrpcServiceTest.java index c8852565b..bb2bae0cc 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/BackupsAnonymousGrpcServiceTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/BackupsAnonymousGrpcServiceTest.java @@ -31,6 +31,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.MethodSource; import org.junitpioneer.jupiter.cartesian.CartesianTest; import org.mockito.Mock; @@ -41,9 +42,10 @@ import org.signal.chat.backup.CopyMediaResponse; import org.signal.chat.backup.DeleteMediaItem; import org.signal.chat.backup.DeleteMediaRequest; import org.signal.chat.backup.GetBackupInfoRequest; -import org.signal.chat.backup.GetBackupInfoResponse; import org.signal.chat.backup.GetCdnCredentialsRequest; import org.signal.chat.backup.GetCdnCredentialsResponse; +import org.signal.chat.backup.GetMediaBackupInfoResponse; +import org.signal.chat.backup.GetMessageBackupInfoResponse; import org.signal.chat.backup.GetUploadFormRequest; import org.signal.chat.backup.GetUploadFormResponse; import org.signal.chat.backup.ListMediaRequest; @@ -212,17 +214,51 @@ class BackupsAnonymousGrpcServiceTest extends } @Test - void getBackupInfo() throws BackupException { + void getMessageBackupInfo() throws BackupException { when(backupManager.backupInfo(any())) .thenReturn(new BackupManager.BackupInfo(1, "myBackupDir", "myMediaDir", "filename", Optional.empty())); - final GetBackupInfoResponse response = unauthenticatedServiceStub().getBackupInfo(GetBackupInfoRequest.newBuilder() + final GetMessageBackupInfoResponse response = unauthenticatedServiceStub().getMessageBackupInfo(GetBackupInfoRequest.newBuilder() .setSignedPresentation(signedPresentation(presentation)) .build()); assertThat(response.getBackupInfo().getBackupDir()).isEqualTo("myBackupDir"); assertThat(response.getBackupInfo().getBackupName()).isEqualTo("filename"); assertThat(response.getBackupInfo().getCdn()).isEqualTo(1); - assertThat(response.getBackupInfo().getUsedSpace()).isEqualTo(0L); + } + + @Test + void getMediaBackupInfo() throws BackupException { + when(backupManager.authenticateBackupUser(any(), any(), any())) + .thenReturn(backupUser(presentation.getBackupId(), BackupCredentialType.MEDIA, BackupLevel.PAID)); + when(backupManager.backupInfo(any())) + .thenReturn(new BackupManager.BackupInfo(1, "myBackupDir", "myMediaDir", "filename", Optional.of(123L))); + + final GetMediaBackupInfoResponse response = unauthenticatedServiceStub().getMediaBackupInfo(GetBackupInfoRequest.newBuilder() + .setSignedPresentation(signedPresentation(presentation)) + .build()); + assertThat(response.getBackupInfo().getBackupDir()).isEqualTo("myBackupDir"); + assertThat(response.getBackupInfo().getMediaDir()).isEqualTo("myMediaDir"); + assertThat(response.getBackupInfo().getUsedSpace()).isEqualTo(123); + } + + @ParameterizedTest + @EnumSource(BackupCredentialType.class) + void getBackupInfoWrongCredentialType(BackupCredentialType credentialType) + throws BackupFailedZkAuthenticationException { + when(backupManager.authenticateBackupUser(any(), any(), any())) + .thenReturn(backupUser(presentation.getBackupId(), credentialType, BackupLevel.PAID)); + assertThatExceptionOfType(StatusRuntimeException.class).isThrownBy(() -> { + switch (credentialType) { + case MEDIA -> unauthenticatedServiceStub().getMessageBackupInfo(GetBackupInfoRequest.newBuilder() + .setSignedPresentation(signedPresentation(presentation)) + .build()); + case MESSAGES -> unauthenticatedServiceStub().getMediaBackupInfo(GetBackupInfoRequest.newBuilder() + .setSignedPresentation(signedPresentation(presentation)) + .build()); + } + }) + .matches(ex -> ex.getStatus().getCode() == Status.Code.INVALID_ARGUMENT) + .matches(ex -> GrpcTestUtils.extractErrorInfo(ex).getReason().equals("BAD_AUTHENTICATION")); }