Instrument /v2/svr/auth/check credential age

This commit is contained in:
Ravi Khadiwala
2026-01-26 17:00:28 -06:00
committed by ravi-signal
parent 0357b25f92
commit fb1c20582e
4 changed files with 71 additions and 28 deletions

View File

@@ -192,17 +192,9 @@ public class ExternalServiceCredentialsGenerator {
: Optional.empty();
}
/**
* Given an instance of {@link ExternalServiceCredentials} object and the max allowed age for those credentials,
* checks if credentials are valid and not expired.
* @param credentials an instance of {@link ExternalServiceCredentials}
* @param maxAgeSeconds age in seconds
* @return An optional with a timestamp (seconds) of when the credentials were generated,
* or an empty optional if the password doesn't match the username for any reason (including malformed data)
*/
public Optional<Long> validateAndGetTimestamp(final ExternalServiceCredentials credentials, final long maxAgeSeconds) {
return validateAndGetTimestamp(credentials)
.filter(ts -> currentTimeSeconds() - ts <= maxAgeSeconds);
@VisibleForTesting
boolean isCredentialExpired(final long credentialTimestamp, final long maxAgeSeconds) {
return currentTimeSeconds() - credentialTimestamp > maxAgeSeconds;
}
private boolean shouldDeriveUsername() {

View File

@@ -15,12 +15,27 @@ public class ExternalServiceCredentialsSelector {
private ExternalServiceCredentialsSelector() {}
public record CredentialInfo(String token, boolean valid, ExternalServiceCredentials credentials, long timestamp) {
public enum CredentialStatus {
// The token passes verification, is not expired, and is the most recent token available
VALID,
// The token was malformed or the signature was invalid
FAILED_VERIFICATION,
// The credential was correctly signed, but exceeded the maximum credential age
EXPIRED,
// There was a more recent credential available with the same username
REPLACED
}
public record CredentialInfo(String token, ExternalServiceCredentials credentials, long timestamp, CredentialStatus status) {
/**
* @return a copy of this record with valid=false
* @return a copy of this record that indicates it has been replaced with a more up-to-date token
*/
private CredentialInfo invalidate() {
return new CredentialInfo(token, false, credentials, timestamp);
private CredentialInfo replaced() {
return new CredentialInfo(token, credentials, timestamp, CredentialStatus.REPLACED);
}
public boolean valid() {
return status == CredentialStatus.VALID;
}
}
@@ -47,13 +62,18 @@ public class ExternalServiceCredentialsSelector {
// (note that password part may also contain ':' characters)
final String[] parts = token.split(":", 2);
if (parts.length != 2) {
results.add(new CredentialInfo(token, false, null, 0L));
results.add(new CredentialInfo(token, null, 0L, CredentialStatus.FAILED_VERIFICATION));
continue;
}
final ExternalServiceCredentials credentials = new ExternalServiceCredentials(parts[0], parts[1]);
final Optional<Long> maybeTimestamp = credentialsGenerator.validateAndGetTimestamp(credentials, maxAgeSeconds);
final Optional<Long> maybeTimestamp = credentialsGenerator.validateAndGetTimestamp(credentials);
if (maybeTimestamp.isEmpty()) {
results.add(new CredentialInfo(token, false, null, 0L));
results.add(new CredentialInfo(token, credentials, 0L, CredentialStatus.FAILED_VERIFICATION));
continue;
}
final long credentialTs = maybeTimestamp.get();
if (credentialsGenerator.isCredentialExpired(credentialTs, maxAgeSeconds)) {
results.add(new CredentialInfo(token, credentials, credentialTs, CredentialStatus.EXPIRED));
continue;
}
@@ -62,17 +82,17 @@ public class ExternalServiceCredentialsSelector {
final long timestamp = maybeTimestamp.get();
final CredentialInfo best = bestForUsername.get(credentials.username());
if (best == null) {
bestForUsername.put(credentials.username(), new CredentialInfo(token, true, credentials, timestamp));
bestForUsername.put(credentials.username(), new CredentialInfo(token, credentials, timestamp, CredentialStatus.VALID));
continue;
}
if (best.timestamp() < timestamp) {
// we found a better credential for the username
bestForUsername.put(credentials.username(), new CredentialInfo(token, true, credentials, timestamp));
bestForUsername.put(credentials.username(), new CredentialInfo(token, credentials, timestamp, CredentialStatus.VALID));
// mark the previous best as an invalid credential, since we have a better credential now
results.add(best.invalidate());
results.add(best.replaced());
} else {
// the credential we already had was more recent, this one can be marked invalid
results.add(new CredentialInfo(token, false, null, 0L));
results.add(new CredentialInfo(token, null, 0L, CredentialStatus.REPLACED));
}
}

View File

@@ -7,23 +7,29 @@ package org.whispersystems.textsecuregcm.controllers;
import com.google.common.annotations.VisibleForTesting;
import io.dropwizard.auth.Auth;
import io.micrometer.core.instrument.DistributionSummary;
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Tag;
import io.micrometer.core.instrument.Tags;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
@@ -34,13 +40,18 @@ import org.whispersystems.textsecuregcm.entities.AuthCheckRequest;
import org.whispersystems.textsecuregcm.entities.AuthCheckResponseV2;
import org.whispersystems.textsecuregcm.limits.RateLimitedByIp;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
@Path("/v2/{name: backup|svr}")
@Tag(name = "Secure Value Recovery")
@io.swagger.v3.oas.annotations.tags.Tag(name = "Secure Value Recovery")
@Schema(description = "Note: /v2/backup is deprecated. Use /v2/svr instead.")
public class SecureValueRecovery2Controller {
private static final String CREDENTIAL_AGE_DISTRIBUTION_NAME =
MetricsUtil.name(SecureValueRecovery2Controller.class, "credentialAge");
public static final Duration MAX_AGE = Duration.ofDays(30);
@@ -102,7 +113,9 @@ public class SecureValueRecovery2Controller {
@ApiResponse(responseCode = "200", description = "`JSON` with the check results.", useReturnTypeSchema = true)
@ApiResponse(responseCode = "422", description = "Provided list of SVR2 credentials could not be parsed")
@ApiResponse(responseCode = "400", description = "`POST` request body is not a valid `JSON`")
public AuthCheckResponseV2 authCheck(@NotNull @Valid final AuthCheckRequest request) {
public AuthCheckResponseV2 authCheck(
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
@NotNull @Valid final AuthCheckRequest request) {
final List<ExternalServiceCredentialsSelector.CredentialInfo> credentials = ExternalServiceCredentialsSelector.check(
request.tokens(),
backupServiceCredentialGenerator,
@@ -115,6 +128,22 @@ public class SecureValueRecovery2Controller {
.map(backupServiceCredentialGenerator::generateForUuid)
.map(ExternalServiceCredentials::username);
// Instrument how expired or not the best credential is
credentials.stream()
.filter(info -> switch (info.status()) {
case VALID, EXPIRED -> true;
default -> false;
})
// Look only at credentials that match the current account for the e164
.filter(info -> matchingUsername.filter(info.credentials().username()::equals).isPresent())
// Instrument the matching credential with the most recent timestamp
.max(Comparator.comparing(ExternalServiceCredentialsSelector.CredentialInfo::timestamp))
.ifPresent(info -> DistributionSummary.builder(CREDENTIAL_AGE_DISTRIBUTION_NAME)
.baseUnit("days")
.tags(Tags.of(UserAgentTagUtil.getPlatformTag(userAgent), Tag.of("valid", Boolean.toString(info.valid()))))
.register(Metrics.globalRegistry)
.record(Duration.between(Instant.ofEpochMilli(info.timestamp()), Instant.now()).toDays()));
return new AuthCheckResponseV2(credentials.stream().collect(Collectors.toMap(
ExternalServiceCredentialsSelector.CredentialInfo::token,
info -> {