mirror of
https://github.com/signalapp/Signal-Server
synced 2026-04-19 18:48:03 +01:00
Forgive some clock skew when requesting ZK credentials
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Iterator;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Stream;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
/// A validated range of days for which a credential may be issued.
|
||||
public class RedemptionRange implements Iterable<Instant> {
|
||||
|
||||
public static final Duration MAX_REDEMPTION_DURATION = Duration.ofDays(7);
|
||||
|
||||
/// The first day for which a credential should be issued
|
||||
private final LocalDate from;
|
||||
|
||||
/// The last day for which a credential should be issued
|
||||
private final LocalDate end;
|
||||
|
||||
private RedemptionRange(final LocalDate from, final LocalDate end) {
|
||||
this.from = from;
|
||||
this.end = end;
|
||||
}
|
||||
|
||||
/// Construct a {@link RedemptionRange} if the provided day bounds are valid.
|
||||
///
|
||||
/// The redemption bounds must satisfy:
|
||||
/// - `redemptionEnd` >= `redemptionStart`
|
||||
/// - `redemptionStart` and `redemptionEnd` are day-aligned
|
||||
/// - `redemptionStart` is yesterday or later
|
||||
/// - `redemptionEnd` is tomorrow + `MAX_REDEMPTION_DURATION` or earlier
|
||||
/// - The number of days requested is less than `MAX_REDEMPTION_DURATION`
|
||||
///
|
||||
/// @param clock Clock to use to get current day
|
||||
/// @param redemptionStart The first day included in the range
|
||||
/// @param redemptionEnd The last day included in the range
|
||||
/// @return A {@link RedemptionRange} that can be used to iterate each day between `redemptionStart` and
|
||||
/// `redemptionEnd`
|
||||
/// @throws IllegalArgumentException if the redemption bounds were not valid
|
||||
public static RedemptionRange inclusive(Clock clock, Instant redemptionStart, Instant redemptionEnd)
|
||||
throws IllegalArgumentException {
|
||||
final Instant today = clock.instant().truncatedTo(ChronoUnit.DAYS);
|
||||
final Instant yesterday = today.minus(Duration.ofDays(1));
|
||||
|
||||
if (redemptionStart.isAfter(redemptionEnd)) {
|
||||
throw new IllegalArgumentException("end of range must be after start of range");
|
||||
}
|
||||
|
||||
if (!redemptionStart.truncatedTo(ChronoUnit.DAYS).equals(redemptionStart)
|
||||
|| !redemptionEnd.truncatedTo(ChronoUnit.DAYS).equals(redemptionEnd)) {
|
||||
throw new IllegalArgumentException("timestamps must be day aligned");
|
||||
}
|
||||
|
||||
if (redemptionStart.isBefore(yesterday)) {
|
||||
throw new IllegalArgumentException("start of range too far in the past");
|
||||
}
|
||||
|
||||
if (redemptionEnd.isAfter(today.plus(MAX_REDEMPTION_DURATION).plus(Duration.ofDays(1)))) {
|
||||
throw new IllegalArgumentException("end of range too far in the future");
|
||||
}
|
||||
|
||||
if (redemptionEnd.isAfter(redemptionStart.plus(MAX_REDEMPTION_DURATION))) {
|
||||
throw new IllegalArgumentException("redemption window too large");
|
||||
}
|
||||
|
||||
return new RedemptionRange(
|
||||
LocalDate.ofInstant(redemptionStart, ZoneOffset.UTC),
|
||||
LocalDate.ofInstant(redemptionEnd, ZoneOffset.UTC));
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull Iterator<Instant> iterator() {
|
||||
final Instant fromInstant = from.atStartOfDay(ZoneOffset.UTC).toInstant();
|
||||
final Instant endInstant = end.atStartOfDay(ZoneOffset.UTC).toInstant();
|
||||
return Stream
|
||||
.iterate(fromInstant, redemptionTime -> redemptionTime.plus(Duration.ofDays(1)))
|
||||
.takeWhile(redemptionTime -> !redemptionTime.isAfter(endInstant))
|
||||
.iterator();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(final Object o) {
|
||||
if (o == null || getClass() != o.getClass()) {
|
||||
return false;
|
||||
}
|
||||
RedemptionRange that = (RedemptionRange) o;
|
||||
return Objects.equals(from, that.from) && Objects.equals(end, that.end);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(from, end);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ 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;
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||
@@ -31,6 +32,7 @@ import org.signal.libsignal.zkgroup.receipts.ReceiptSerial;
|
||||
import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.auth.RedemptionRange;
|
||||
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
|
||||
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiter;
|
||||
@@ -177,15 +179,13 @@ public class BackupAuthManager {
|
||||
*
|
||||
* @param account The account to create the credentials for
|
||||
* @param credentialType The type of backup credentials to create
|
||||
* @param redemptionStart The day (must be truncated to a day boundary) the first credential should be valid
|
||||
* @param redemptionEnd The day (must be truncated to a day boundary) the last credential should be valid
|
||||
* @param redemptionRange The time range to return credentials for
|
||||
* @return Credentials and the day on which they may be redeemed
|
||||
*/
|
||||
public CompletableFuture<List<Credential>> getBackupAuthCredentials(
|
||||
final Account account,
|
||||
final BackupCredentialType credentialType,
|
||||
final Instant redemptionStart,
|
||||
final Instant redemptionEnd) {
|
||||
final RedemptionRange redemptionRange) {
|
||||
|
||||
// If the account has an expired payment, clear it before continuing
|
||||
if (hasExpiredVoucher(account)) {
|
||||
@@ -194,17 +194,7 @@ public class BackupAuthManager {
|
||||
if (hasExpiredVoucher(a)) {
|
||||
a.setBackupVoucher(null);
|
||||
}
|
||||
}).thenCompose(updated -> getBackupAuthCredentials(updated, credentialType, redemptionStart, redemptionEnd));
|
||||
}
|
||||
|
||||
final Instant startOfDay = clock.instant().truncatedTo(ChronoUnit.DAYS);
|
||||
if (redemptionStart.isAfter(redemptionEnd) ||
|
||||
redemptionStart.isBefore(startOfDay) ||
|
||||
redemptionEnd.isAfter(startOfDay.plus(MAX_REDEMPTION_DURATION)) ||
|
||||
!redemptionStart.equals(redemptionStart.truncatedTo(ChronoUnit.DAYS)) ||
|
||||
!redemptionEnd.equals(redemptionEnd.truncatedTo(ChronoUnit.DAYS))) {
|
||||
|
||||
throw Status.INVALID_ARGUMENT.withDescription("invalid redemption window").asRuntimeException();
|
||||
}).thenCompose(updated -> getBackupAuthCredentials(updated, credentialType, redemptionRange));
|
||||
}
|
||||
|
||||
// fetch the blinded backup-id the account should have previously committed to
|
||||
@@ -216,8 +206,7 @@ public class BackupAuthManager {
|
||||
|
||||
// create a credential for every day in the requested period
|
||||
final BackupAuthCredentialRequest credentialReq = new BackupAuthCredentialRequest(committedBytes);
|
||||
return CompletableFuture.completedFuture(Stream
|
||||
.iterate(redemptionStart, redemptionTime -> !redemptionTime.isAfter(redemptionEnd), curr -> curr.plus(Duration.ofDays(1)))
|
||||
return CompletableFuture.completedFuture(StreamSupport.stream(redemptionRange.spliterator(), false)
|
||||
.map(redemptionTime -> {
|
||||
// Check if the account has a voucher that's good for a certain receiptLevel at redemption time, otherwise
|
||||
// use the default receipt level
|
||||
|
||||
@@ -45,6 +45,7 @@ import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
@@ -65,6 +66,7 @@ import org.signal.libsignal.zkgroup.backups.BackupCredentialType;
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
|
||||
import org.whispersystems.textsecuregcm.auth.RedemptionRange;
|
||||
import org.whispersystems.textsecuregcm.backup.BackupAuthManager;
|
||||
import org.whispersystems.textsecuregcm.backup.BackupManager;
|
||||
import org.whispersystems.textsecuregcm.backup.CopyParameters;
|
||||
@@ -309,6 +311,13 @@ public class ArchiveController {
|
||||
final Map<BackupCredentialType, List<BackupAuthCredentialsResponse.BackupAuthCredential>> credentialsByType =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
final RedemptionRange redemptionRange;
|
||||
try {
|
||||
redemptionRange = RedemptionRange.inclusive(Clock.systemUTC(), Instant.ofEpochSecond(startSeconds), Instant.ofEpochSecond(endSeconds));
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new BadRequestException(e.getMessage());
|
||||
}
|
||||
|
||||
return accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier())
|
||||
.thenCompose(maybeAccount -> {
|
||||
final Account account = maybeAccount
|
||||
@@ -318,7 +327,7 @@ public class ArchiveController {
|
||||
.map(credentialType -> this.backupAuthManager.getBackupAuthCredentials(
|
||||
account,
|
||||
credentialType,
|
||||
Instant.ofEpochSecond(startSeconds), Instant.ofEpochSecond(endSeconds))
|
||||
redemptionRange)
|
||||
.thenAccept(credentials -> {
|
||||
backupMetrics.updateGetCredentialCounter(
|
||||
UserAgentTagUtil.getPlatformTag(userAgent),
|
||||
|
||||
@@ -24,7 +24,6 @@ import java.security.InvalidKeyException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
@@ -36,6 +35,7 @@ import org.signal.libsignal.zkgroup.auth.ServerZkAuthOperations;
|
||||
import org.signal.libsignal.zkgroup.calllinks.CallLinkAuthCredentialResponse;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.auth.CertificateGenerator;
|
||||
import org.whispersystems.textsecuregcm.auth.RedemptionRange;
|
||||
import org.whispersystems.textsecuregcm.entities.DeliveryCertificate;
|
||||
import org.whispersystems.textsecuregcm.entities.GroupCredentials;
|
||||
import org.whispersystems.textsecuregcm.identity.IdentityType;
|
||||
@@ -97,17 +97,13 @@ public class CertificateController {
|
||||
@QueryParam("redemptionStartSeconds") long startSeconds,
|
||||
@QueryParam("redemptionEndSeconds") long endSeconds) {
|
||||
|
||||
final Instant startOfDay = clock.instant().truncatedTo(ChronoUnit.DAYS);
|
||||
final Instant redemptionStart = Instant.ofEpochSecond(startSeconds);
|
||||
final Instant redemptionEnd = Instant.ofEpochSecond(endSeconds);
|
||||
|
||||
if (redemptionStart.isAfter(redemptionEnd) ||
|
||||
redemptionStart.isBefore(startOfDay) ||
|
||||
redemptionEnd.isAfter(startOfDay.plus(MAX_REDEMPTION_DURATION)) ||
|
||||
!redemptionStart.equals(redemptionStart.truncatedTo(ChronoUnit.DAYS)) ||
|
||||
!redemptionEnd.equals(redemptionEnd.truncatedTo(ChronoUnit.DAYS))) {
|
||||
|
||||
throw new BadRequestException();
|
||||
final RedemptionRange redemptionRange;
|
||||
try {
|
||||
final Instant redemptionStart = Instant.ofEpochSecond(startSeconds);
|
||||
final Instant redemptionEnd = Instant.ofEpochSecond(endSeconds);
|
||||
redemptionRange = RedemptionRange.inclusive(clock, redemptionStart, redemptionEnd);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new BadRequestException(e.getCause());
|
||||
}
|
||||
|
||||
final Account account = accountsManager.getByAccountIdentifier(auth.accountIdentifier())
|
||||
@@ -116,12 +112,10 @@ public class CertificateController {
|
||||
final List<GroupCredentials.GroupCredential> credentials = new ArrayList<>();
|
||||
final List<GroupCredentials.CallLinkAuthCredential> callLinkAuthCredentials = new ArrayList<>();
|
||||
|
||||
Instant redemption = redemptionStart;
|
||||
|
||||
final ServiceId.Aci aci = new ServiceId.Aci(account.getIdentifier(IdentityType.ACI));
|
||||
final ServiceId.Pni pni = new ServiceId.Pni(account.getIdentifier(IdentityType.PNI));
|
||||
|
||||
while (!redemption.isAfter(redemptionEnd)) {
|
||||
for (Instant redemption : redemptionRange) {
|
||||
AuthCredentialWithPniResponse authCredentialWithPni = serverZkAuthOperations.issueAuthCredentialWithPniZkc(aci, pni, redemption);
|
||||
credentials.add(new GroupCredentials.GroupCredential(
|
||||
authCredentialWithPni.serialize(),
|
||||
@@ -130,8 +124,6 @@ public class CertificateController {
|
||||
callLinkAuthCredentials.add(new GroupCredentials.CallLinkAuthCredential(
|
||||
CallLinkAuthCredentialResponse.issueCredential(aci, redemption, genericServerSecretParams).serialize(),
|
||||
redemption.getEpochSecond()));
|
||||
|
||||
redemption = redemption.plus(Duration.ofDays(1));
|
||||
}
|
||||
|
||||
return new GroupCredentials(credentials, callLinkAuthCredentials, pni.getRawUUID());
|
||||
|
||||
@@ -6,12 +6,11 @@ package org.whispersystems.textsecuregcm.grpc;
|
||||
|
||||
import com.google.protobuf.ByteString;
|
||||
import io.grpc.Status;
|
||||
import io.micrometer.core.instrument.Tag;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import io.micrometer.core.instrument.Tag;
|
||||
import io.micrometer.core.instrument.Tags;
|
||||
import org.signal.chat.backup.GetBackupAuthCredentialsRequest;
|
||||
import org.signal.chat.backup.GetBackupAuthCredentialsResponse;
|
||||
import org.signal.chat.backup.ReactorBackupsGrpc;
|
||||
@@ -24,10 +23,10 @@ import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequest;
|
||||
import org.signal.libsignal.zkgroup.backups.BackupCredentialType;
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation;
|
||||
import org.whispersystems.textsecuregcm.auth.RedemptionRange;
|
||||
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil;
|
||||
import org.whispersystems.textsecuregcm.backup.BackupAuthManager;
|
||||
import org.whispersystems.textsecuregcm.controllers.ArchiveController;
|
||||
import org.whispersystems.textsecuregcm.metrics.BackupMetrics;
|
||||
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
@@ -35,8 +34,6 @@ 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;
|
||||
|
||||
public class BackupsGrpcService extends ReactorBackupsGrpc.BackupsImplBase {
|
||||
|
||||
private final AccountsManager accountManager;
|
||||
@@ -85,14 +82,20 @@ public class BackupsGrpcService extends ReactorBackupsGrpc.BackupsImplBase {
|
||||
@Override
|
||||
public Mono<GetBackupAuthCredentialsResponse> getBackupAuthCredentials(GetBackupAuthCredentialsRequest request) {
|
||||
final Tag platformTag = UserAgentTagUtil.getPlatformTag(RequestAttributesUtil.getUserAgent().orElse(null));
|
||||
final RedemptionRange redemptionRange;
|
||||
try {
|
||||
redemptionRange = RedemptionRange.inclusive(Clock.systemUTC(),
|
||||
Instant.ofEpochSecond(request.getRedemptionStart()),
|
||||
Instant.ofEpochSecond(request.getRedemptionStop()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw Status.INVALID_ARGUMENT.withDescription(e.getMessage()).asRuntimeException();
|
||||
}
|
||||
return authenticatedAccount().flatMap(account -> {
|
||||
|
||||
final Mono<List<BackupAuthManager.Credential>> messageCredentials = Mono.fromCompletionStage(() ->
|
||||
backupAuthManager.getBackupAuthCredentials(
|
||||
account,
|
||||
BackupCredentialType.MESSAGES,
|
||||
Instant.ofEpochSecond(request.getRedemptionStart()),
|
||||
Instant.ofEpochSecond(request.getRedemptionStop())))
|
||||
redemptionRange))
|
||||
.doOnSuccess(credentials ->
|
||||
backupMetrics.updateGetCredentialCounter(platformTag, BackupCredentialType.MESSAGES, credentials.size()));
|
||||
|
||||
@@ -100,8 +103,7 @@ public class BackupsGrpcService extends ReactorBackupsGrpc.BackupsImplBase {
|
||||
backupAuthManager.getBackupAuthCredentials(
|
||||
account,
|
||||
BackupCredentialType.MEDIA,
|
||||
Instant.ofEpochSecond(request.getRedemptionStart()),
|
||||
Instant.ofEpochSecond(request.getRedemptionStop())))
|
||||
redemptionRange))
|
||||
.doOnSuccess(credentials ->
|
||||
backupMetrics.updateGetCredentialCounter(platformTag, BackupCredentialType.MEDIA, credentials.size()));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user