diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupAuthManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupAuthManager.java index fdb661167..c44102a7f 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupAuthManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupAuthManager.java @@ -236,6 +236,12 @@ public class BackupAuthManager { .asRuntimeException(); } + if (account.getBackupCredentialRequest(BackupCredentialType.MEDIA).isEmpty()) { + throw Status.ABORTED + .withDescription("account must have a backup-id commitment") + .asRuntimeException(); + } + return redeemedReceiptsManager .put(receiptSerial, receiptExpiration.getEpochSecond(), receiptLevel, account.getUuid()) .thenCompose(receiptAllowed -> { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ArchiveController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ArchiveController.java index 8ca188a0f..16cf5255f 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ArchiveController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ArchiveController.java @@ -173,9 +173,13 @@ public class ArchiveController { After successful redemption, subsequent requests to /v1/archive/auth will return credentials with the level on the provided receipt until the expiration time on the receipt. + + Accounts must have an existing backup credential request in order to redeem a receipt. This request will fail + if the account has not already set a backup credential request via PUT `/v1/archives/backupid`. """) @ApiResponse(responseCode = "204", description = "The receipt was redeemed") @ApiResponse(responseCode = "400", description = "The provided presentation or receipt was invalid") + @ApiResponse(responseCode = "409", description = "The target account does not have a backup-id commitment") @ApiResponse(responseCode = "429", description = "Rate limited.") public CompletionStage redeemReceipt( @Mutable @Auth final AuthenticatedDevice account, diff --git a/service/src/main/proto/org/signal/chat/backups.proto b/service/src/main/proto/org/signal/chat/backups.proto index 636a9b7f2..5b409c637 100644 --- a/service/src/main/proto/org/signal/chat/backups.proto +++ b/service/src/main/proto/org/signal/chat/backups.proto @@ -55,6 +55,9 @@ service Backups { * After successful redemption, subsequent requests to * GetBackupAuthCredentials will return credentials with the level on the * provided receipt until the expiration time on the receipt. + * + * errors: + * ABORTED: The target account does not have a backup-id commitment */ rpc RedeemReceipt(RedeemReceiptRequest) returns (RedeemReceiptResponse) {} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/backup/BackupAuthManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/backup/BackupAuthManagerTest.java index b1871422c..8afca6483 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/backup/BackupAuthManagerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/backup/BackupAuthManagerTest.java @@ -348,6 +348,7 @@ public class BackupAuthManagerTest { final BackupAuthManager authManager = create(BackupLevel.FREE); final Account account = mock(Account.class); when(account.getUuid()).thenReturn(aci); + when(account.getBackupCredentialRequest(BackupCredentialType.MEDIA)).thenReturn(Optional.of(new byte[0])); clock.pin(Instant.EPOCH.plus(Duration.ofDays(1))); when(accountsManager.updateAsync(any(), any())).thenReturn(CompletableFuture.completedFuture(account)); @@ -357,6 +358,24 @@ public class BackupAuthManagerTest { verify(accountsManager, times(1)).updateAsync(any(), any()); } + @Test + void redeemReceiptNoBackupRequest() { + final Instant expirationTime = Instant.EPOCH.plus(Duration.ofDays(1)); + final BackupAuthManager authManager = create(BackupLevel.FREE); + final Account account = mock(Account.class); + when(account.getUuid()).thenReturn(aci); + when(account.getBackupCredentialRequest(BackupCredentialType.MEDIA)).thenReturn(Optional.empty()); + + clock.pin(Instant.EPOCH.plus(Duration.ofDays(1))); + when(redeemedReceiptsManager.put(any(), eq(expirationTime.getEpochSecond()), eq(201L), eq(aci))) + .thenReturn(CompletableFuture.completedFuture(true)); + assertThatExceptionOfType(StatusRuntimeException.class) + .isThrownBy(() -> + authManager.redeemReceipt(account, receiptPresentation(201, expirationTime)).join()) + .extracting(ex -> ex.getStatus().getCode()) + .isEqualTo(Status.Code.ABORTED); + } + @Test void mergeRedemptions() throws InvalidInputException, VerificationFailedException { final Instant newExpirationTime = Instant.EPOCH.plus(Duration.ofDays(1)); @@ -365,6 +384,7 @@ public class BackupAuthManagerTest { final BackupAuthManager authManager = create(BackupLevel.FREE); final Account account = mock(Account.class); when(account.getUuid()).thenReturn(aci); + when(account.getBackupCredentialRequest(BackupCredentialType.MEDIA)).thenReturn(Optional.of(new byte[0])); // The account has an existing voucher with a later expiration date when(account.getBackupVoucher()).thenReturn(new Account.BackupVoucher(201, existingExpirationTime)); @@ -429,6 +449,7 @@ public class BackupAuthManagerTest { final BackupAuthManager authManager = create(BackupLevel.FREE); final Account account = mock(Account.class); when(account.getUuid()).thenReturn(aci); + when(account.getBackupCredentialRequest(BackupCredentialType.MEDIA)).thenReturn(Optional.of(new byte[0])); clock.pin(Instant.EPOCH.plus(Duration.ofDays(1))); when(accountsManager.updateAsync(any(), any())).thenReturn(CompletableFuture.completedFuture(account));