Allow rotating a single backup-id at a time

This commit is contained in:
ravi-signal
2025-10-06 12:18:31 -05:00
committed by GitHub
parent e0eaa76ebf
commit d6c15ef1d5
7 changed files with 165 additions and 115 deletions

View File

@@ -17,7 +17,6 @@ import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
import javax.annotation.Nullable;
import org.signal.libsignal.zkgroup.GenericServerSecretParams;
@@ -101,40 +100,59 @@ public class BackupAuthManager {
public CompletableFuture<Void> commitBackupId(
final Account account,
final Device device,
final BackupAuthCredentialRequest messagesBackupCredentialRequest,
final BackupAuthCredentialRequest mediaBackupCredentialRequest) {
final Optional<BackupAuthCredentialRequest> messagesBackupCredentialRequest,
final Optional<BackupAuthCredentialRequest> mediaBackupCredentialRequest) {
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();
final boolean messageCredentialRequestMatches = account.getBackupCredentialRequest(BackupCredentialType.MESSAGES)
.map(storedCredentialRequest -> MessageDigest.isEqual(storedCredentialRequest, serializedMessageCredentialRequest))
.orElse(false);
if (messagesBackupCredentialRequest.isEmpty() && mediaBackupCredentialRequest.isEmpty()) {
throw Status.INVALID_ARGUMENT
.withDescription("Must set at least one of message/media credential requests")
.asRuntimeException();
}
final boolean mediaCredentialRequestMatches = account.getBackupCredentialRequest(BackupCredentialType.MEDIA)
.map(storedCredentialRequest -> MessageDigest.isEqual(storedCredentialRequest, serializedMediaCredentialRequest))
.orElse(false);
final byte[] storedMessageCredentialRequest = account.getBackupCredentialRequest(BackupCredentialType.MESSAGES)
.orElse(null);
final byte[] storedMediaCredentialRequest = account.getBackupCredentialRequest(BackupCredentialType.MEDIA)
.orElse(null);
if (messageCredentialRequestMatches && mediaCredentialRequestMatches) {
// If the provided credential request is null, we want to set to the existing request
final byte[] targetMessageCredentialRequest = messagesBackupCredentialRequest
.map(BackupAuthCredentialRequest::serialize)
.orElse(storedMessageCredentialRequest);
final byte[] targetMediaCredentialRequest = mediaBackupCredentialRequest
.map(BackupAuthCredentialRequest::serialize)
.orElse(storedMediaCredentialRequest);
final boolean requiresMessageRotation =
!MessageDigest.isEqual(targetMessageCredentialRequest, storedMessageCredentialRequest);
final boolean requiresMediaRotation =
!MessageDigest.isEqual(targetMediaCredentialRequest, storedMediaCredentialRequest);
if (!requiresMessageRotation && !requiresMediaRotation) {
// No need to update or enforce rate limits, this is the credential that the user has already
// committed to.
return CompletableFuture.completedFuture(null);
}
CompletionStage<Void> rateLimitFuture = rateLimiters
.forDescriptor(RateLimiters.For.SET_BACKUP_ID)
.validateAsync(account.getUuid());
CompletableFuture<Void> rateLimitFuture = CompletableFuture.completedFuture(null);
if (!mediaCredentialRequestMatches && hasActiveVoucher(account)) {
if (requiresMessageRotation) {
rateLimitFuture = rateLimitFuture.thenCombine(
rateLimiters.forDescriptor(RateLimiters.For.SET_BACKUP_ID).validateAsync(account.getUuid()),
(_, _) -> null);
}
if (requiresMediaRotation && hasActiveVoucher(account)) {
rateLimitFuture = rateLimitFuture.thenCombine(
rateLimiters.forDescriptor(RateLimiters.For.SET_PAID_MEDIA_BACKUP_ID).validateAsync(account.getUuid()),
(ignore1, ignore2) -> null);
(_, _) -> null);
}
return rateLimitFuture.thenCompose(ignored -> this.accountsManager
.updateAsync(account, a -> a.setBackupCredentialRequests(serializedMessageCredentialRequest, serializedMediaCredentialRequest))
.updateAsync(account, a ->
a.setBackupCredentialRequests(targetMessageCredentialRequest, targetMediaCredentialRequest))
.thenRun(Util.NOOP))
.toCompletableFuture();
}

View File

@@ -114,19 +114,20 @@ public class ArchiveController {
@Schema(description = """
A BackupAuthCredentialRequest containing a blinded encrypted backup-id, encoded in standard padded base64.
This backup-id should be used for message backups only, and must have the message backup type set on the
credential.
credential. If absent, the message credential request will not be updated.
""", implementation = String.class)
@JsonDeserialize(using = BackupAuthCredentialAdapter.CredentialRequestDeserializer.class)
@JsonSerialize(using = BackupAuthCredentialAdapter.CredentialRequestSerializer.class)
@NotNull BackupAuthCredentialRequest messagesBackupAuthCredentialRequest,
BackupAuthCredentialRequest messagesBackupAuthCredentialRequest,
@Schema(description = """
A BackupAuthCredentialRequest containing a blinded encrypted backup-id, encoded in standard padded base64.
This backup-id should be used for media only, and must have the media type set on the credential.
This backup-id should be used for media only, and must have the media type set on the credential. If absent,
only the media credential request will not be updated.
""", implementation = String.class)
@JsonDeserialize(using = BackupAuthCredentialAdapter.CredentialRequestDeserializer.class)
@JsonSerialize(using = BackupAuthCredentialAdapter.CredentialRequestSerializer.class)
@NotNull BackupAuthCredentialRequest mediaBackupAuthCredentialRequest) {}
BackupAuthCredentialRequest mediaBackupAuthCredentialRequest) {}
@PUT
@@ -136,11 +137,13 @@ public class ArchiveController {
@Operation(
summary = "Set backup id",
description = """
Set a (blinded) backup-id for the account. Each account may have a single active backup-id that can be used
to store and retrieve backups. Once the backup-id is set, BackupAuthCredentials can be generated
using /v1/archives/auth.
Set (blinded) backup-id(s) for the account. Each account may have a single active backup-id for each
credential type that can be used to store and retrieve backups. Once the backup-id is set,
BackupAuthCredentials can be generated using /v1/archives/auth.
The blinded backup-id and the key-pair used to blind it should be derived from a recoverable secret.
At least one of `messagesBackupAuthCredentialRequest`, `mediaBackupAuthCredentialRequest` must be set.
""")
@ApiResponse(responseCode = "204", description = "The backup-id was set")
@ApiResponse(responseCode = "400", description = "The provided backup auth credential request was invalid")
@@ -159,8 +162,9 @@ public class ArchiveController {
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
return backupAuthManager
.commitBackupId(account, device, setBackupIdRequest.messagesBackupAuthCredentialRequest,
setBackupIdRequest.mediaBackupAuthCredentialRequest)
.commitBackupId(account, device,
Optional.ofNullable(setBackupIdRequest.messagesBackupAuthCredentialRequest),
Optional.ofNullable(setBackupIdRequest.mediaBackupAuthCredentialRequest))
.thenApply(Util.ASYNC_EMPTY_RESPONSE);
});
}

View File

@@ -10,6 +10,7 @@ import io.micrometer.core.instrument.Tag;
import java.time.Clock;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import org.signal.chat.backup.GetBackupAuthCredentialsRequest;
import org.signal.chat.backup.GetBackupAuthCredentialsResponse;
@@ -46,17 +47,16 @@ public class BackupsGrpcService extends ReactorBackupsGrpc.BackupsImplBase {
this.backupMetrics = backupMetrics;
}
@Override
public Mono<SetBackupIdResponse> setBackupId(SetBackupIdRequest request) {
final BackupAuthCredentialRequest messagesCredentialRequest = deserialize(
final Optional<BackupAuthCredentialRequest> messagesCredentialRequest = deserializeWithEmptyPresenceCheck(
BackupAuthCredentialRequest::new,
request.getMessagesBackupAuthCredentialRequest().toByteArray());
request.getMessagesBackupAuthCredentialRequest());
final BackupAuthCredentialRequest mediaCredentialRequest = deserialize(
final Optional<BackupAuthCredentialRequest> mediaCredentialRequest = deserializeWithEmptyPresenceCheck(
BackupAuthCredentialRequest::new,
request.getMediaBackupAuthCredentialRequest().toByteArray());
request.getMediaBackupAuthCredentialRequest());
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
return authenticatedAccount()
@@ -137,6 +137,13 @@ public class BackupsGrpcService extends ReactorBackupsGrpc.BackupsImplBase {
T deserialize(byte[] bytes) throws InvalidInputException;
}
private <T> Optional<T> deserializeWithEmptyPresenceCheck(Deserializer<T> deserializer, ByteString byteString) {
if (byteString.isEmpty()) {
return Optional.empty();
}
return Optional.of(deserialize(deserializer, byteString.toByteArray()));
}
private <T> T deserialize(Deserializer<T> deserializer, byte[] bytes) {
try {
return deserializer.deserialize(bytes);