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 c44102a7f..d7972402b 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupAuthManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupAuthManager.java @@ -34,6 +34,7 @@ import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager; import org.whispersystems.textsecuregcm.limits.RateLimiters; import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.AccountsManager; +import org.whispersystems.textsecuregcm.storage.Device; import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager; import org.whispersystems.textsecuregcm.util.Util; @@ -85,6 +86,7 @@ public class BackupAuthManager { * Store credential requests containing blinded backup-ids for future use. * * @param account The account using the backup-id + * @param device The device setting the account backup-id * @param messagesBackupCredentialRequest A request containing the blinded backup-id the client will use to upload * message backups * @param mediaBackupCredentialRequest A request containing the blinded backup-id the client will use to upload @@ -92,12 +94,17 @@ public class BackupAuthManager { * @return A future that completes when the credentialRequest has been stored * @throws RateLimitExceededException If too many backup-ids have been committed */ - public CompletableFuture commitBackupId(final Account account, + public CompletableFuture commitBackupId( + final Account account, + final Device device, final BackupAuthCredentialRequest messagesBackupCredentialRequest, final BackupAuthCredentialRequest mediaBackupCredentialRequest) { if (configuredBackupLevel(account).isEmpty()) { throw Status.PERMISSION_DENIED.withDescription("Backups not allowed on account").asRuntimeException(); } + if (!device.isPrimary()) { + throw Status.PERMISSION_DENIED.withDescription("Only primary device can set backup-id").asRuntimeException(); + } final byte[] serializedMessageCredentialRequest = messagesBackupCredentialRequest.serialize(); final byte[] serializedMediaCredentialRequest = mediaBackupCredentialRequest.serialize(); 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 ca4ce1727..d1411d721 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ArchiveController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ArchiveController.java @@ -135,13 +135,14 @@ public class ArchiveController { """) @ApiResponse(responseCode = "204", description = "The backup-id was set") @ApiResponse(responseCode = "400", description = "The provided backup auth credential request was invalid") + @ApiResponse(responseCode = "403", description = "The device did not have permission to set the backup-id. Only the primary device can set the backup-id for an account") @ApiResponse(responseCode = "429", description = "Rate limited. Too many attempts to change the backup-id have been made") public CompletionStage setBackupId( @Mutable @Auth final AuthenticatedDevice account, @Valid @NotNull final SetBackupIdRequest setBackupIdRequest) throws RateLimitExceededException { - return this.backupAuthManager - .commitBackupId(account.getAccount(), setBackupIdRequest.messagesBackupAuthCredentialRequest, + .commitBackupId(account.getAccount(), account.getAuthenticatedDevice(), + setBackupIdRequest.messagesBackupAuthCredentialRequest, setBackupIdRequest.mediaBackupAuthCredentialRequest) .thenApply(Util.ASYNC_EMPTY_RESPONSE); } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/BackupsGrpcService.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/BackupsGrpcService.java index eec156d7d..9b8716ed9 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/BackupsGrpcService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/BackupsGrpcService.java @@ -32,6 +32,7 @@ import org.whispersystems.textsecuregcm.metrics.BackupMetrics; import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil; import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.AccountsManager; +import org.whispersystems.textsecuregcm.storage.Device; import reactor.core.publisher.Mono; import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name; @@ -60,9 +61,15 @@ public class BackupsGrpcService extends ReactorBackupsGrpc.BackupsImplBase { BackupAuthCredentialRequest::new, request.getMediaBackupAuthCredentialRequest().toByteArray()); + final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice(); return authenticatedAccount() - .flatMap(account -> Mono.fromFuture( - backupAuthManager.commitBackupId(account, messagesCredentialRequest, mediaCredentialRequest))) + .flatMap(account -> { + final Device device = account + .getDevice(authenticatedDevice.deviceId()) + .orElseThrow(Status.UNAUTHENTICATED::asRuntimeException); + return Mono.fromFuture( + backupAuthManager.commitBackupId(account, device, messagesCredentialRequest, mediaCredentialRequest)); + }) .thenReturn(SetBackupIdResponse.getDefaultInstance()); } 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 8afca6483..c4b44a9d4 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/backup/BackupAuthManagerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/backup/BackupAuthManagerTest.java @@ -61,6 +61,7 @@ import org.whispersystems.textsecuregcm.limits.RateLimiter; import org.whispersystems.textsecuregcm.limits.RateLimiters; import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.AccountsManager; +import org.whispersystems.textsecuregcm.storage.Device; import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager; import org.whispersystems.textsecuregcm.tests.util.ExperimentHelper; import org.whispersystems.textsecuregcm.util.CompletableFutureTestUtil; @@ -119,7 +120,7 @@ public class BackupAuthManagerTest { final BackupAuthCredentialRequest messagesCredentialRequest = backupAuthTestUtil.getRequest(messagesBackupKey, aci); final BackupAuthCredentialRequest mediaCredentialRequest = backupAuthTestUtil.getRequest(mediaBackupKey, aci); - authManager.commitBackupId(account, messagesCredentialRequest, mediaCredentialRequest).join(); + authManager.commitBackupId(account, primaryDevice(), messagesCredentialRequest, mediaCredentialRequest).join(); verify(account).setBackupCredentialRequests(messagesCredentialRequest.serialize(), mediaCredentialRequest.serialize()); } @@ -135,6 +136,7 @@ public class BackupAuthManagerTest { final ThrowableAssert.ThrowingCallable commit = () -> authManager.commitBackupId(account, + primaryDevice(), backupAuthTestUtil.getRequest(messagesBackupKey, aci), backupAuthTestUtil.getRequest(mediaBackupKey, aci)).join(); if (backupLevel == null) { @@ -147,6 +149,24 @@ public class BackupAuthManagerTest { } } + @Test + void commitRequiresPrimary() { + final BackupAuthManager authManager = create(BackupLevel.FREE); + final Account account = mock(Account.class); + when(account.getUuid()).thenReturn(aci); + when(accountsManager.updateAsync(any(), any())).thenReturn(CompletableFuture.completedFuture(account)); + + final ThrowableAssert.ThrowingCallable commit = () -> + authManager.commitBackupId(account, + linkedDevice(), + backupAuthTestUtil.getRequest(messagesBackupKey, aci), + backupAuthTestUtil.getRequest(mediaBackupKey, aci)).join(); + assertThatExceptionOfType(StatusRuntimeException.class) + .isThrownBy(commit) + .extracting(ex -> ex.getStatus().getCode()) + .isEqualTo(Status.Code.PERMISSION_DENIED); + } + @CartesianTest void getBackupAuthCredentials(@CartesianTest.Enum final BackupLevel backupLevel, @CartesianTest.Enum final BackupCredentialType credentialType) { @@ -504,7 +524,7 @@ public class BackupAuthManagerTest { : storedMediaCredential; final boolean expectRateLimit = (changeMedia || changeMessage) && rateLimitBackupId; - final CompletableFuture future = authManager.commitBackupId(account, newMessagesCredential, newMediaCredential); + final CompletableFuture future = authManager.commitBackupId(account, primaryDevice(), newMessagesCredential, newMediaCredential); if (expectRateLimit) { CompletableFutureTestUtil.assertFailsWithCause(RateLimitExceededException.class, future); } else { @@ -538,7 +558,7 @@ public class BackupAuthManagerTest { // We should get rate limited iff we are out of paid media changes and we changed the media backup-id final boolean expectRateLimit = changeMedia && paid && rateLimitPaidMedia; - final CompletableFuture future = authManager.commitBackupId(account, newMessagesCredential, newMediaCredential); + final CompletableFuture future = authManager.commitBackupId(account, primaryDevice(), newMessagesCredential, newMediaCredential); if (expectRateLimit) { CompletableFutureTestUtil.assertFailsWithCause(RateLimitExceededException.class, future); } else { @@ -562,6 +582,17 @@ public class BackupAuthManagerTest { return account; } + private Device primaryDevice() { + final Device device = mock(Device.class); + when(device.isPrimary()).thenReturn(true); + return device; + } + + private Device linkedDevice() { + final Device device = mock(Device.class); + when(device.isPrimary()).thenReturn(false); + return device; + } private static String experimentName(@Nullable BackupLevel backupLevel) { return switch (backupLevel) { diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ArchiveControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ArchiveControllerTest.java index be34a1f5e..7200a6317 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ArchiveControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ArchiveControllerTest.java @@ -157,7 +157,7 @@ public class ArchiveControllerTest { @Test public void setBackupId() { - when(backupAuthManager.commitBackupId(any(), any(), any())).thenReturn(CompletableFuture.completedFuture(null)); + when(backupAuthManager.commitBackupId(any(), any(), any(), any())).thenReturn(CompletableFuture.completedFuture(null)); final Response response = resources.getJerseyTest() .target("v1/archives/backupid") @@ -170,7 +170,7 @@ public class ArchiveControllerTest { assertThat(response.getStatus()).isEqualTo(204); - verify(backupAuthManager).commitBackupId(AuthHelper.VALID_ACCOUNT, + verify(backupAuthManager).commitBackupId(AuthHelper.VALID_ACCOUNT, AuthHelper.VALID_DEVICE, backupAuthTestUtil.getRequest(messagesBackupKey, aci), backupAuthTestUtil.getRequest(mediaBackupKey, aci)); } @@ -275,9 +275,9 @@ public class ArchiveControllerTest { @MethodSource public void setBackupIdException(final Exception ex, final boolean sync, final int expectedStatus) { if (sync) { - when(backupAuthManager.commitBackupId(any(), any(), any())).thenThrow(ex); + when(backupAuthManager.commitBackupId(any(), any(), any(), any())).thenThrow(ex); } else { - when(backupAuthManager.commitBackupId(any(), any(), any())).thenReturn(CompletableFuture.failedFuture(ex)); + when(backupAuthManager.commitBackupId(any(), any(), any(), any())).thenReturn(CompletableFuture.failedFuture(ex)); } final Response response = resources.getJerseyTest() .target("v1/archives/backupid") diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/BackupsGrpcServiceTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/BackupsGrpcServiceTest.java index 5fee5ee00..0a5f1a8d6 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/BackupsGrpcServiceTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/BackupsGrpcServiceTest.java @@ -57,6 +57,7 @@ import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; import org.whispersystems.textsecuregcm.metrics.BackupMetrics; import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.AccountsManager; +import org.whispersystems.textsecuregcm.storage.Device; import org.whispersystems.textsecuregcm.util.EnumMapUtil; import org.whispersystems.textsecuregcm.util.TestRandomUtil; @@ -69,7 +70,8 @@ class BackupsGrpcServiceTest extends SimpleBaseGrpcTest