diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/ExternalServiceCredentialsGenerator.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/ExternalServiceCredentialsGenerator.java index 3317ca4c6..5205bd430 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/auth/ExternalServiceCredentialsGenerator.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/auth/ExternalServiceCredentialsGenerator.java @@ -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 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() { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/ExternalServiceCredentialsSelector.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/ExternalServiceCredentialsSelector.java index 9c42e8b2c..59efb0e5f 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/auth/ExternalServiceCredentialsSelector.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/auth/ExternalServiceCredentialsSelector.java @@ -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 maybeTimestamp = credentialsGenerator.validateAndGetTimestamp(credentials, maxAgeSeconds); + final Optional 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)); } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecovery2Controller.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecovery2Controller.java index 6700fd98d..2227c5bfa 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecovery2Controller.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecovery2Controller.java @@ -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 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 -> { diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/auth/ExternalServiceCredentialsGeneratorTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/auth/ExternalServiceCredentialsGeneratorTest.java index 3e951f641..3d8f80fcb 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/auth/ExternalServiceCredentialsGeneratorTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/auth/ExternalServiceCredentialsGeneratorTest.java @@ -148,8 +148,10 @@ class ExternalServiceCredentialsGeneratorTest { final long elapsedSeconds = 10000; clock.incrementSeconds(elapsedSeconds); - assertEquals(standardGenerator.validateAndGetTimestamp(standardCredentials, elapsedSeconds + 1).orElseThrow(), TIME_SECONDS); - assertTrue(standardGenerator.validateAndGetTimestamp(standardCredentials, elapsedSeconds - 1).isEmpty()); + final Long timestamp = standardGenerator.validateAndGetTimestamp(standardCredentials).orElseThrow(); + + assertFalse(standardGenerator.isCredentialExpired(timestamp, elapsedSeconds + 1)); + assertTrue(standardGenerator.isCredentialExpired(timestamp, elapsedSeconds - 1)); } @Test