/v1/backup/auth/check endpoint added

This commit is contained in:
Sergey Skrobotov
2023-01-30 15:34:28 -08:00
parent 896e65545e
commit dc8f62a4ad
24 changed files with 1334 additions and 197 deletions

View File

@@ -739,7 +739,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
new RemoteConfigController(remoteConfigsManager, adminEventLogger,
config.getRemoteConfigConfiguration().getAuthorizedTokens(),
config.getRemoteConfigConfiguration().getGlobalConfig()),
new SecureBackupController(backupCredentialsGenerator),
new SecureBackupController(backupCredentialsGenerator, accountsManager),
new SecureStorageController(storageCredentialsGenerator),
new SecureValueRecovery2Controller(svr2CredentialsGenerator),
new StickerController(rateLimiters, config.getCdnConfiguration().getAccessKey(),

View File

@@ -8,16 +8,20 @@ package org.whispersystems.textsecuregcm.auth;
import static java.util.Objects.requireNonNull;
import static org.whispersystems.textsecuregcm.util.HmacUtils.hmac256ToHexString;
import static org.whispersystems.textsecuregcm.util.HmacUtils.hmac256TruncatedToHexString;
import static org.whispersystems.textsecuregcm.util.HmacUtils.hmacHexStringsEqual;
import java.time.Clock;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Validate;
public class ExternalServiceCredentialsGenerator {
private static final int TRUNCATE_LENGTH = 10;
private static final String DELIMITER = ":";
private final byte[] key;
private final byte[] userDerivationKey;
@@ -46,28 +50,117 @@ public class ExternalServiceCredentialsGenerator {
this.clock = requireNonNull(clock);
}
/**
* A convenience method for the case of identity in the form of {@link UUID}.
* @param uuid identity to generate credentials for
* @return an instance of {@link ExternalServiceCredentials}
*/
public ExternalServiceCredentials generateForUuid(final UUID uuid) {
return generateFor(uuid.toString());
}
/**
* Generates `ExternalServiceCredentials` for the given identity following this generator's configuration.
* @param identity identity string to generate credentials for
* @return an instance of {@link ExternalServiceCredentials}
*/
public ExternalServiceCredentials generateFor(final String identity) {
final String username = userDerivationKey.length > 0
final String username = shouldDeriveUsername()
? hmac256TruncatedToHexString(userDerivationKey, identity, TRUNCATE_LENGTH)
: identity;
final long currentTimeSeconds = TimeUnit.MILLISECONDS.toSeconds(clock.millis());
final long currentTimeSeconds = currentTimeSeconds();
final String dataToSign = username + ":" + currentTimeSeconds;
final String dataToSign = username + DELIMITER + currentTimeSeconds;
final String signature = truncateSignature
? hmac256TruncatedToHexString(key, dataToSign, TRUNCATE_LENGTH)
: hmac256ToHexString(key, dataToSign);
final String token = (prependUsername ? dataToSign : currentTimeSeconds) + ":" + signature;
final String token = (prependUsername ? dataToSign : currentTimeSeconds) + DELIMITER + signature;
return new ExternalServiceCredentials(username, token);
}
/**
* In certain cases, identity (as it was passed to `generateFor` method)
* is a part of the signature (`password`, in terms of `ExternalServiceCredentials`) string itself.
* For such cases, this method returns the value of the identity string.
* @param password `password` part of `ExternalServiceCredentials`
* @return non-empty optional with an identity string value, or empty if value can't be extracted.
*/
public Optional<String> identityFromSignature(final String password) {
// for some generators, identity in the clear is just not a part of the password
if (!prependUsername || shouldDeriveUsername() || StringUtils.isBlank(password)) {
return Optional.empty();
}
// checking for the case of unexpected format
return StringUtils.countMatches(password, DELIMITER) == 2
? Optional.of(password.substring(0, password.indexOf(DELIMITER)))
: Optional.empty();
}
/**
* Given an instance of {@link ExternalServiceCredentials} object, checks that the password
* matches the username taking into accound this generator's configuration.
* @param credentials an instance of {@link ExternalServiceCredentials}
* @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 String[] parts = requireNonNull(credentials).password().split(DELIMITER);
final String timestampSeconds;
final String actualSignature;
// making sure password format matches our expectations based on the generator configuration
if (parts.length == 3 && prependUsername) {
final String username = parts[0];
// username has to match the one from `credentials`
if (!credentials.username().equals(username)) {
return Optional.empty();
}
timestampSeconds = parts[1];
actualSignature = parts[2];
} else if (parts.length == 2 && !prependUsername) {
timestampSeconds = parts[0];
actualSignature = parts[1];
} else {
// unexpected password format
return Optional.empty();
}
final String signedData = credentials.username() + DELIMITER + timestampSeconds;
final String expectedSignature = truncateSignature
? hmac256TruncatedToHexString(key, signedData, TRUNCATE_LENGTH)
: hmac256ToHexString(key, signedData);
// if the signature is valid it's safe to parse the `timestampSeconds` string into Long
return hmacHexStringsEqual(expectedSignature, actualSignature)
? Optional.of(Long.valueOf(timestampSeconds))
: 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);
}
private boolean shouldDeriveUsername() {
return userDerivationKey.length > 0;
}
private long currentTimeSeconds() {
return clock.instant().getEpochSecond();
}
public static class Builder {
private final byte[] key;

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2013-2020 Signal Messenger, LLC
* Copyright 2013 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.configuration;
@@ -74,6 +74,9 @@ public class RateLimitsConfiguration {
@JsonProperty
private RateLimitConfiguration stories = new RateLimitConfiguration(10_000, 10_000 / (24.0 * 60.0));
@JsonProperty
private RateLimitConfiguration backupAuthCheck = new RateLimitConfiguration(100, 100 / (24.0 * 60.0));
public RateLimitConfiguration getAutoBlock() {
return autoBlock;
}
@@ -158,7 +161,13 @@ public class RateLimitsConfiguration {
return checkAccountExistence;
}
public RateLimitConfiguration getStories() { return stories; }
public RateLimitConfiguration getStories() {
return stories;
}
public RateLimitConfiguration getBackupAuthCheck() {
return backupAuthCheck;
}
public static class RateLimitConfiguration {
@JsonProperty

View File

@@ -87,6 +87,7 @@ import org.whispersystems.textsecuregcm.entities.ReserveUsernameResponse;
import org.whispersystems.textsecuregcm.entities.StaleDevices;
import org.whispersystems.textsecuregcm.entities.UsernameRequest;
import org.whispersystems.textsecuregcm.entities.UsernameResponse;
import org.whispersystems.textsecuregcm.limits.RateLimitedByIp;
import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
@@ -772,9 +773,9 @@ public class AccountController {
@GET
@Path("/username/{username}")
@Produces(MediaType.APPLICATION_JSON)
@RateLimitedByIp(RateLimiters.Handle.USERNAME_LOOKUP)
public AccountIdentifierResponse lookupUsername(
@HeaderParam(HeaderUtils.X_SIGNAL_AGENT) final String userAgent,
@HeaderParam(HttpHeaders.X_FORWARDED_FOR) final String forwardedFor,
@PathParam("username") final String username,
@Context final HttpServletRequest request) throws RateLimitExceededException {
@@ -783,8 +784,6 @@ public class AccountController {
throw new BadRequestException();
}
rateLimitByClientIp(rateLimiters.getUsernameLookupLimiter(), forwardedFor);
checkUsername(username, userAgent);
return accounts
@@ -796,8 +795,8 @@ public class AccountController {
@HEAD
@Path("/account/{uuid}")
@RateLimitedByIp(RateLimiters.Handle.CHECK_ACCOUNT_EXISTENCE)
public Response accountExists(
@HeaderParam(HttpHeaders.X_FORWARDED_FOR) final String forwardedFor,
@PathParam("uuid") final UUID uuid,
@Context HttpServletRequest request) throws RateLimitExceededException {
@@ -805,7 +804,6 @@ public class AccountController {
if (StringUtils.isNotBlank(request.getHeader("Authorization"))) {
throw new BadRequestException();
}
rateLimitByClientIp(rateLimiters.getCheckAccountExistenceLimiter(), forwardedFor);
final Status status = accounts.getByAccountIdentifier(uuid)
.or(() -> accounts.getByPhoneNumberIdentifier(uuid))

View File

@@ -5,40 +5,151 @@
package org.whispersystems.textsecuregcm.controllers;
import static java.util.Objects.requireNonNull;
import com.codahale.metrics.annotation.Timed;
import io.dropwizard.auth.Auth;
import java.time.Clock;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.function.Predicate;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;
import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import org.apache.commons.codec.DecoderException;
import org.apache.commons.lang3.tuple.Pair;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.configuration.SecureBackupServiceConfiguration;
import org.whispersystems.textsecuregcm.entities.AuthCheckRequest;
import org.whispersystems.textsecuregcm.entities.AuthCheckResponse;
import org.whispersystems.textsecuregcm.limits.RateLimitedByIp;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.util.UUIDUtil;
@Path("/v1/backup")
public class SecureBackupController {
private final ExternalServiceCredentialsGenerator backupServiceCredentialsGenerator;
private static final long MAX_AGE_SECONDS = TimeUnit.DAYS.toSeconds(30);
public static ExternalServiceCredentialsGenerator credentialsGenerator(final SecureBackupServiceConfiguration cfg)
throws DecoderException {
return ExternalServiceCredentialsGenerator
.builder(cfg.getUserAuthenticationTokenSharedSecret())
.prependUsername(true)
.build();
private final ExternalServiceCredentialsGenerator credentialsGenerator;
private final AccountsManager accountsManager;
public static ExternalServiceCredentialsGenerator credentialsGenerator(final SecureBackupServiceConfiguration cfg) {
return credentialsGenerator(cfg, Clock.systemUTC());
}
public SecureBackupController(ExternalServiceCredentialsGenerator backupServiceCredentialsGenerator) {
this.backupServiceCredentialsGenerator = backupServiceCredentialsGenerator;
public static ExternalServiceCredentialsGenerator credentialsGenerator(
final SecureBackupServiceConfiguration cfg,
final Clock clock) {
try {
return ExternalServiceCredentialsGenerator
.builder(cfg.getUserAuthenticationTokenSharedSecret())
.prependUsername(true)
.withClock(clock)
.build();
} catch (final DecoderException e) {
throw new IllegalStateException(e);
}
}
public SecureBackupController(
final ExternalServiceCredentialsGenerator credentialsGenerator,
final AccountsManager accountsManager) {
this.credentialsGenerator = requireNonNull(credentialsGenerator);
this.accountsManager = requireNonNull(accountsManager);
}
@Timed
@GET
@Path("/auth")
@Produces(MediaType.APPLICATION_JSON)
public ExternalServiceCredentials getAuth(@Auth AuthenticatedAccount auth) {
return backupServiceCredentialsGenerator.generateForUuid(auth.getAccount().getUuid());
public ExternalServiceCredentials getAuth(final @Auth AuthenticatedAccount auth) {
return credentialsGenerator.generateForUuid(auth.getAccount().getUuid());
}
@Timed
@POST
@Path("/auth/check")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@RateLimitedByIp(RateLimiters.Handle.BACKUP_AUTH_CHECK)
public AuthCheckResponse authCheck(@NotNull @Valid final AuthCheckRequest request) {
final Map<String, AuthCheckResponse.Result> results = new HashMap<>();
final Map<String, Pair<UUID, Long>> tokenToUuid = new HashMap<>();
final Map<UUID, Long> uuidToLatestTimestamp = new HashMap<>();
// first pass -- filter out all tokens that contain invalid credentials
// (this could be either legit but expired or illegitimate for any reason)
request.passwords().forEach(token -> {
// each token is supposed to be in a "${username}:${password}" form,
// (note that password part may also contain ':' characters)
final String[] parts = token.split(":", 2);
if (parts.length != 2) {
results.put(token, AuthCheckResponse.Result.INVALID);
return;
}
final ExternalServiceCredentials credentials = new ExternalServiceCredentials(parts[0], parts[1]);
final Optional<Long> maybeTimestamp = credentialsGenerator.validateAndGetTimestamp(credentials, MAX_AGE_SECONDS);
final Optional<UUID> maybeUuid = UUIDUtil.fromStringSafe(credentials.username());
if (maybeTimestamp.isEmpty() || maybeUuid.isEmpty()) {
results.put(token, AuthCheckResponse.Result.INVALID);
return;
}
// now that we validated signature and token age, we will also find the latest of the tokens
// for each username
final Long timestamp = maybeTimestamp.get();
final UUID uuid = maybeUuid.get();
tokenToUuid.put(token, Pair.of(uuid, timestamp));
final Long latestTimestamp = uuidToLatestTimestamp.getOrDefault(uuid, 0L);
if (timestamp > latestTimestamp) {
uuidToLatestTimestamp.put(uuid, timestamp);
}
});
// as a result of the first pass we now have some tokens that are marked invalid,
// and for others we now know if for any username the list contains multiple tokens
// we also know all distinct usernames from the list
// if it so happens that all tokens are invalid -- respond right away
if (tokenToUuid.isEmpty()) {
return new AuthCheckResponse(results);
}
final Predicate<UUID> uuidMatches = accountsManager
.getByE164(request.number())
.map(account -> (Predicate<UUID>) candidateUuid -> account.getUuid().equals(candidateUuid))
.orElse(candidateUuid -> false);
// second pass will let us discard tokens that have newer versions and will also let us pick the winner (if any)
request.passwords().forEach(token -> {
if (results.containsKey(token)) {
// result already calculated
return;
}
final Pair<UUID, Long> uuidAndTime = requireNonNull(tokenToUuid.get(token));
final Long latestTimestamp = requireNonNull(uuidToLatestTimestamp.get(uuidAndTime.getLeft()));
// check if a newer version available
if (uuidAndTime.getRight() < latestTimestamp) {
results.put(token, AuthCheckResponse.Result.INVALID);
return;
}
results.put(token, uuidMatches.test(uuidAndTime.getLeft())
? AuthCheckResponse.Result.MATCH
: AuthCheckResponse.Result.NO_MATCH);
});
return new AuthCheckResponse(results);
}
}

View File

@@ -0,0 +1,16 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.entities;
import java.util.List;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import org.whispersystems.textsecuregcm.util.E164;
public record AuthCheckRequest(@NotNull @E164 String number,
@NotEmpty @Size(max = 10) List<String> passwords) {
}

View File

@@ -0,0 +1,30 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonValue;
import java.util.Map;
import javax.validation.constraints.NotNull;
public record AuthCheckResponse(@NotNull Map<String, Result> matches) {
public enum Result {
MATCH("match"),
NO_MATCH("no-match"),
INVALID("invalid");
private final String clientCode;
Result(final String clientCode) {
this.clientCode = clientCode;
}
@JsonValue
public String clientCode() {
return clientCode;
}
}
}

View File

@@ -0,0 +1,91 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.limits;
import static java.util.Objects.requireNonNull;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.net.HttpHeaders;
import java.io.IOException;
import java.time.Duration;
import java.util.Optional;
import javax.ws.rs.ClientErrorException;
import javax.ws.rs.container.ContainerRequestContext;
import javax.ws.rs.container.ContainerRequestFilter;
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.ExceptionMapper;
import org.glassfish.jersey.server.ExtendedUriInfo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;
import org.whispersystems.textsecuregcm.util.HeaderUtils;
public class RateLimitByIpFilter implements ContainerRequestFilter {
private static final Logger logger = LoggerFactory.getLogger(RateLimitByIpFilter.class);
@VisibleForTesting
static final RateLimitExceededException INVALID_HEADER_EXCEPTION = new RateLimitExceededException(Duration.ofHours(1));
private static final ExceptionMapper<RateLimitExceededException> EXCEPTION_MAPPER = new RateLimitExceededExceptionMapper();
private final RateLimiters rateLimiters;
public RateLimitByIpFilter(final RateLimiters rateLimiters) {
this.rateLimiters = requireNonNull(rateLimiters);
}
@Override
public void filter(final ContainerRequestContext requestContext) throws IOException {
// requestContext.getUriInfo() should always be an instance of `ExtendedUriInfo`
// in the Jersey client
if (!(requestContext.getUriInfo() instanceof final ExtendedUriInfo uriInfo)) {
return;
}
final RateLimitedByIp annotation = uriInfo.getMatchedResourceMethod()
.getInvocable()
.getHandlingMethod()
.getAnnotation(RateLimitedByIp.class);
if (annotation == null) {
return;
}
final RateLimiters.Handle handle = annotation.value();
try {
final String xffHeader = requestContext.getHeaders().getFirst(HttpHeaders.X_FORWARDED_FOR);
final Optional<String> maybeMostRecentProxy = Optional.ofNullable(xffHeader)
.flatMap(HeaderUtils::getMostRecentProxy);
// checking if we failed to extract the most recent IP from the X-Forwarded-For header
// for any reason
if (maybeMostRecentProxy.isEmpty()) {
// checking if annotation is configured to fail when the most recent IP is not resolved
if (annotation.failOnUnresolvedIp()) {
logger.error("Missing/bad X-Forwarded-For: {}", xffHeader);
throw INVALID_HEADER_EXCEPTION;
}
// otherwise, allow request
return;
}
final Optional<RateLimiter> maybeRateLimiter = rateLimiters.byHandle(handle);
if (maybeRateLimiter.isEmpty()) {
logger.warn("RateLimiter not found for {}. Make sure it's initialized in RateLimiters class", handle);
return;
}
maybeRateLimiter.get().validate(maybeMostRecentProxy.get());
} catch (RateLimitExceededException e) {
final Response response = EXCEPTION_MAPPER.toResponse(e);
throw new ClientErrorException(response);
}
}
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.limits;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimitedByIp {
RateLimiters.Handle value();
boolean failOnUnresolvedIp() default true;
}

View File

@@ -1,15 +1,41 @@
/*
* Copyright 2013-2020 Signal Messenger, LLC
* Copyright 2013 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.limits;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.apache.commons.lang3.tuple.Pair;
import org.whispersystems.textsecuregcm.configuration.RateLimitsConfiguration;
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster;
public class RateLimiters {
public enum Handle {
USERNAME_LOOKUP("usernameLookup"),
CHECK_ACCOUNT_EXISTENCE("checkAccountExistence"),
BACKUP_AUTH_CHECK;
private final String id;
Handle(final String id) {
this.id = id;
}
Handle() {
this.id = name();
}
public String id() {
return id;
}
}
private final RateLimiter smsDestinationLimiter;
private final RateLimiter voiceDestinationLimiter;
private final RateLimiter voiceDestinationDailyLimiter;
@@ -17,114 +43,51 @@ public class RateLimiters {
private final RateLimiter smsVoicePrefixLimiter;
private final RateLimiter verifyLimiter;
private final RateLimiter pinLimiter;
private final RateLimiter attachmentLimiter;
private final RateLimiter preKeysLimiter;
private final RateLimiter messagesLimiter;
private final RateLimiter allocateDeviceLimiter;
private final RateLimiter verifyDeviceLimiter;
private final RateLimiter turnLimiter;
private final RateLimiter profileLimiter;
private final RateLimiter stickerPackLimiter;
private final RateLimiter artPackLimiter;
private final RateLimiter usernameLookupLimiter;
private final RateLimiter usernameSetLimiter;
private final RateLimiter usernameReserveLimiter;
private final RateLimiter checkAccountExistenceLimiter;
private final RateLimiter storiesLimiter;
public RateLimiters(RateLimitsConfiguration config, FaultTolerantRedisCluster cacheCluster) {
this.smsDestinationLimiter = new RateLimiter(cacheCluster, "smsDestination",
config.getSmsDestination().getBucketSize(),
config.getSmsDestination().getLeakRatePerMinute());
private final Map<String, RateLimiter> rateLimiterByHandle;
this.voiceDestinationLimiter = new RateLimiter(cacheCluster, "voxDestination",
config.getVoiceDestination().getBucketSize(),
config.getVoiceDestination().getLeakRatePerMinute());
public RateLimiters(final RateLimitsConfiguration config, final FaultTolerantRedisCluster cacheCluster) {
this.smsDestinationLimiter = fromConfig("smsDestination", config.getSmsDestination(), cacheCluster);
this.voiceDestinationLimiter = fromConfig("voxDestination", config.getVoiceDestination(), cacheCluster);
this.voiceDestinationDailyLimiter = fromConfig("voxDestinationDaily", config.getVoiceDestinationDaily(), cacheCluster);
this.smsVoiceIpLimiter = fromConfig("smsVoiceIp", config.getSmsVoiceIp(), cacheCluster);
this.smsVoicePrefixLimiter = fromConfig("smsVoicePrefix", config.getSmsVoicePrefix(), cacheCluster);
this.verifyLimiter = fromConfig("verify", config.getVerifyNumber(), cacheCluster);
this.pinLimiter = fromConfig("pin", config.getVerifyPin(), cacheCluster);
this.attachmentLimiter = fromConfig("attachmentCreate", config.getAttachments(), cacheCluster);
this.preKeysLimiter = fromConfig("prekeys", config.getPreKeys(), cacheCluster);
this.messagesLimiter = fromConfig("messages", config.getMessages(), cacheCluster);
this.allocateDeviceLimiter = fromConfig("allocateDevice", config.getAllocateDevice(), cacheCluster);
this.verifyDeviceLimiter = fromConfig("verifyDevice", config.getVerifyDevice(), cacheCluster);
this.turnLimiter = fromConfig("turnAllocate", config.getTurnAllocations(), cacheCluster);
this.profileLimiter = fromConfig("profile", config.getProfile(), cacheCluster);
this.stickerPackLimiter = fromConfig("stickerPack", config.getStickerPack(), cacheCluster);
this.artPackLimiter = fromConfig("artPack", config.getArtPack(), cacheCluster);
this.usernameSetLimiter = fromConfig("usernameSet", config.getUsernameSet(), cacheCluster);
this.usernameReserveLimiter = fromConfig("usernameReserve", config.getUsernameReserve(), cacheCluster);
this.storiesLimiter = fromConfig("stories", config.getStories(), cacheCluster);
this.voiceDestinationDailyLimiter = new RateLimiter(cacheCluster, "voxDestinationDaily",
config.getVoiceDestinationDaily().getBucketSize(),
config.getVoiceDestinationDaily().getLeakRatePerMinute());
this.rateLimiterByHandle = Stream.of(
fromConfig(Handle.BACKUP_AUTH_CHECK.id(), config.getBackupAuthCheck(), cacheCluster),
fromConfig(Handle.CHECK_ACCOUNT_EXISTENCE.id(), config.getCheckAccountExistence(), cacheCluster),
fromConfig(Handle.USERNAME_LOOKUP.id(), config.getUsernameLookup(), cacheCluster)
).map(rl -> Pair.of(rl.name, rl)).collect(Collectors.toMap(Pair::getKey, Pair::getValue));
}
this.smsVoiceIpLimiter = new RateLimiter(cacheCluster, "smsVoiceIp",
config.getSmsVoiceIp().getBucketSize(),
config.getSmsVoiceIp().getLeakRatePerMinute());
this.smsVoicePrefixLimiter = new RateLimiter(cacheCluster, "smsVoicePrefix",
config.getSmsVoicePrefix().getBucketSize(),
config.getSmsVoicePrefix().getLeakRatePerMinute());
this.verifyLimiter = new LockingRateLimiter(cacheCluster, "verify",
config.getVerifyNumber().getBucketSize(),
config.getVerifyNumber().getLeakRatePerMinute());
this.pinLimiter = new LockingRateLimiter(cacheCluster, "pin",
config.getVerifyPin().getBucketSize(),
config.getVerifyPin().getLeakRatePerMinute());
this.attachmentLimiter = new RateLimiter(cacheCluster, "attachmentCreate",
config.getAttachments().getBucketSize(),
config.getAttachments().getLeakRatePerMinute());
this.preKeysLimiter = new RateLimiter(cacheCluster, "prekeys",
config.getPreKeys().getBucketSize(),
config.getPreKeys().getLeakRatePerMinute());
this.messagesLimiter = new RateLimiter(cacheCluster, "messages",
config.getMessages().getBucketSize(),
config.getMessages().getLeakRatePerMinute());
this.allocateDeviceLimiter = new RateLimiter(cacheCluster, "allocateDevice",
config.getAllocateDevice().getBucketSize(),
config.getAllocateDevice().getLeakRatePerMinute());
this.verifyDeviceLimiter = new RateLimiter(cacheCluster, "verifyDevice",
config.getVerifyDevice().getBucketSize(),
config.getVerifyDevice().getLeakRatePerMinute());
this.turnLimiter = new RateLimiter(cacheCluster, "turnAllocate",
config.getTurnAllocations().getBucketSize(),
config.getTurnAllocations().getLeakRatePerMinute());
this.profileLimiter = new RateLimiter(cacheCluster, "profile",
config.getProfile().getBucketSize(),
config.getProfile().getLeakRatePerMinute());
this.stickerPackLimiter = new RateLimiter(cacheCluster, "stickerPack",
config.getStickerPack().getBucketSize(),
config.getStickerPack().getLeakRatePerMinute());
this.artPackLimiter = new RateLimiter(cacheCluster, "artPack",
config.getArtPack().getBucketSize(),
config.getArtPack().getLeakRatePerMinute());
this.usernameLookupLimiter = new RateLimiter(cacheCluster, "usernameLookup",
config.getUsernameLookup().getBucketSize(),
config.getUsernameLookup().getLeakRatePerMinute());
this.usernameSetLimiter = new RateLimiter(cacheCluster, "usernameSet",
config.getUsernameSet().getBucketSize(),
config.getUsernameSet().getLeakRatePerMinute());
this.usernameReserveLimiter = new RateLimiter(cacheCluster, "usernameReserve",
config.getUsernameReserve().getBucketSize(),
config.getUsernameReserve().getLeakRatePerMinute());
this.checkAccountExistenceLimiter = new RateLimiter(cacheCluster, "checkAccountExistence",
config.getCheckAccountExistence().getBucketSize(),
config.getCheckAccountExistence().getLeakRatePerMinute());
this.storiesLimiter = new RateLimiter(cacheCluster, "stories",
config.getStories().getBucketSize(),
config.getStories().getLeakRatePerMinute());
public Optional<RateLimiter> byHandle(final Handle handle) {
return Optional.ofNullable(rateLimiterByHandle.get(handle.id()));
}
public RateLimiter getAllocateDeviceLimiter() {
@@ -192,7 +155,7 @@ public class RateLimiters {
}
public RateLimiter getUsernameLookupLimiter() {
return usernameLookupLimiter;
return byHandle(Handle.USERNAME_LOOKUP).orElseThrow();
}
public RateLimiter getUsernameSetLimiter() {
@@ -204,8 +167,17 @@ public class RateLimiters {
}
public RateLimiter getCheckAccountExistenceLimiter() {
return checkAccountExistenceLimiter;
return byHandle(Handle.CHECK_ACCOUNT_EXISTENCE).orElseThrow();
}
public RateLimiter getStoriesLimiter() { return storiesLimiter; }
public RateLimiter getStoriesLimiter() {
return storiesLimiter;
}
private static RateLimiter fromConfig(
final String name,
final RateLimitsConfiguration.RateLimitConfiguration cfg,
final FaultTolerantRedisCluster cacheCluster) {
return new RateLimiter(cacheCluster, name, cfg.getBucketSize(), cfg.getLeakRatePerMinute());
}
}

View File

@@ -0,0 +1,56 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.util;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import java.util.Objects;
import javax.validation.Constraint;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import javax.validation.Payload;
/**
* Constraint annotation that requires annotated entity
* to hold (or return) a string value that is a valid E164-normalized phone number.
*/
@Target({ FIELD, PARAMETER, METHOD })
@Retention(RUNTIME)
@Constraint(validatedBy = E164.Validator.class)
@Documented
public @interface E164 {
String message() default "{org.whispersystems.textsecuregcm.util.E164.message}";
Class<?>[] groups() default { };
Class<? extends Payload>[] payload() default { };
class Validator implements ConstraintValidator<E164, String> {
@Override
public boolean isValid(final String value, final ConstraintValidatorContext context) {
if (Objects.isNull(value)) {
return true;
}
if (!value.startsWith("+")) {
return false;
}
try {
Util.requireNormalizedNumber(value);
} catch (final ImpossiblePhoneNumberException | NonNormalizedPhoneNumberException e) {
return false;
}
return true;
}
}
}

View File

@@ -6,11 +6,12 @@
package org.whispersystems.textsecuregcm.util;
import java.nio.charset.StandardCharsets;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public final class HmacUtils {
@@ -63,4 +64,14 @@ public final class HmacUtils {
public static String hmac256TruncatedToHexString(final byte[] key, final String input, final int length) {
return hmac256TruncatedToHexString(key, input.getBytes(StandardCharsets.UTF_8), length);
}
public static boolean hmacHexStringsEqual(final String expectedAsHexString, final String actualAsHexString) {
try {
final byte[] aBytes = HEX.parseHex(expectedAsHexString);
final byte[] bBytes = HEX.parseHex(actualAsHexString);
return MessageDigest.isEqual(aBytes, bBytes);
} catch (final IllegalArgumentException e) {
return false;
}
}
}

View File

@@ -7,35 +7,48 @@ package org.whispersystems.textsecuregcm.util;
import java.nio.BufferUnderflowException;
import java.nio.ByteBuffer;
import java.util.Optional;
import java.util.UUID;
public class UUIDUtil {
public final class UUIDUtil {
public static byte[] toBytes(final UUID uuid) {
return toByteBuffer(uuid).array();
}
private UUIDUtil() {
// utility class
}
public static ByteBuffer toByteBuffer(final UUID uuid) {
final ByteBuffer byteBuffer = ByteBuffer.wrap(new byte[16]);
byteBuffer.putLong(uuid.getMostSignificantBits());
byteBuffer.putLong(uuid.getLeastSignificantBits());
return byteBuffer.flip();
}
public static byte[] toBytes(final UUID uuid) {
return toByteBuffer(uuid).array();
}
public static UUID fromBytes(final byte[] bytes) {
return fromByteBuffer(ByteBuffer.wrap(bytes));
}
public static ByteBuffer toByteBuffer(final UUID uuid) {
final ByteBuffer byteBuffer = ByteBuffer.wrap(new byte[16]);
byteBuffer.putLong(uuid.getMostSignificantBits());
byteBuffer.putLong(uuid.getLeastSignificantBits());
return byteBuffer.flip();
}
public static UUID fromByteBuffer(final ByteBuffer byteBuffer) {
try {
final long mostSigBits = byteBuffer.getLong();
final long leastSigBits = byteBuffer.getLong();
if (byteBuffer.hasRemaining()) {
throw new IllegalArgumentException("unexpected byte array length; was greater than 16");
}
return new UUID(mostSigBits, leastSigBits);
} catch (BufferUnderflowException e) {
throw new IllegalArgumentException("unexpected byte array length; was less than 16");
public static UUID fromBytes(final byte[] bytes) {
return fromByteBuffer(ByteBuffer.wrap(bytes));
}
public static UUID fromByteBuffer(final ByteBuffer byteBuffer) {
try {
final long mostSigBits = byteBuffer.getLong();
final long leastSigBits = byteBuffer.getLong();
if (byteBuffer.hasRemaining()) {
throw new IllegalArgumentException("unexpected byte array length; was greater than 16");
}
return new UUID(mostSigBits, leastSigBits);
} catch (BufferUnderflowException e) {
throw new IllegalArgumentException("unexpected byte array length; was less than 16");
}
}
public static Optional<UUID> fromStringSafe(final String uuidString) {
try {
return Optional.of(UUID.fromString(uuidString));
} catch (final IllegalArgumentException e) {
return Optional.empty();
}
}
}