Forgive some clock skew when requesting ZK credentials

This commit is contained in:
ravi-signal
2025-10-01 13:03:27 -05:00
committed by GitHub
parent 70ac4ad139
commit 9384813752
10 changed files with 273 additions and 117 deletions

View File

@@ -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);
}
}

View File

@@ -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

View File

@@ -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),

View File

@@ -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());

View File

@@ -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()));