Revert "Allow use of the token returned with spam challenges as auth for the challenge verification request"

This commit is contained in:
Jonathan Klabunde Tomer
2023-07-12 11:45:02 -07:00
committed by GitHub
parent 9aaac0eefd
commit 5847300290
9 changed files with 8 additions and 363 deletions

View File

@@ -23,7 +23,6 @@ import org.whispersystems.textsecuregcm.configuration.AwsAttachmentsConfiguratio
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
import org.whispersystems.textsecuregcm.configuration.BraintreeConfiguration;
import org.whispersystems.textsecuregcm.configuration.CdnConfiguration;
import org.whispersystems.textsecuregcm.configuration.ChallengeConfiguration;
import org.whispersystems.textsecuregcm.configuration.DatadogConfiguration;
import org.whispersystems.textsecuregcm.configuration.DirectoryV2Configuration;
import org.whispersystems.textsecuregcm.configuration.DynamoDbClientConfiguration;
@@ -187,11 +186,6 @@ public class WhisperServerConfiguration extends Configuration {
@JsonProperty
private UnidentifiedDeliveryConfiguration unidentifiedDelivery;
@Valid
@NotNull
@JsonProperty
private ChallengeConfiguration challenge;
@Valid
@NotNull
@JsonProperty
@@ -301,10 +295,6 @@ public class WhisperServerConfiguration extends Configuration {
return dynamoDbTables;
}
public ChallengeConfiguration getChallengeConfiguration() {
return challenge;
}
public RecaptchaConfiguration getRecaptchaConfiguration() {
return recaptcha;
}

View File

@@ -185,7 +185,6 @@ import org.whispersystems.textsecuregcm.storage.VerificationSessionManager;
import org.whispersystems.textsecuregcm.storage.VerificationSessions;
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
import org.whispersystems.textsecuregcm.util.ChallengeTokenBlinder;
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
import org.whispersystems.textsecuregcm.util.SystemMapper;
import org.whispersystems.textsecuregcm.util.UsernameHashZkProofVerifier;
@@ -562,9 +561,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
PushChallengeManager pushChallengeManager = new PushChallengeManager(pushNotificationManager,
pushChallengeDynamoDb);
RateLimitChallengeManager rateLimitChallengeManager = new RateLimitChallengeManager(pushChallengeManager,
captchaChecker,
rateLimiters);
final ChallengeTokenBlinder challengeTokenBlinder = new ChallengeTokenBlinder(config.getChallengeConfiguration(), Clock.systemUTC());
captchaChecker, rateLimiters);
ChangeNumberManager changeNumberManager = new ChangeNumberManager(messageSender, accountsManager);
@@ -712,7 +709,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
new AttachmentControllerV3(rateLimiters, config.getGcpAttachmentsConfiguration().domain(), config.getGcpAttachmentsConfiguration().email(), config.getGcpAttachmentsConfiguration().maxSizeInBytes(), config.getGcpAttachmentsConfiguration().pathPrefix(), config.getGcpAttachmentsConfiguration().rsaSigningKey().value()),
new CallLinkController(rateLimiters, genericZkSecretParams),
new CertificateController(new CertificateGenerator(config.getDeliveryCertificate().certificate().value(), config.getDeliveryCertificate().ecPrivateKey(), config.getDeliveryCertificate().expiresDays()), zkAuthOperations, genericZkSecretParams, clock),
new ChallengeController(accounts, challengeTokenBlinder, rateLimitChallengeManager),
new ChallengeController(rateLimitChallengeManager),
new DeviceController(pendingDevicesManager, accountsManager, messagesManager, keys, rateLimiters, config.getMaxDevices()),
new DirectoryV2Controller(directoryV2CredentialsGenerator),
new DonationController(clock, zkReceiptOperations, redeemedReceiptsManager, accountsManager, config.getBadges(),

View File

@@ -1,12 +0,0 @@
/*
* Copyright 2021-2022 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.configuration;
import java.time.Duration;
import org.whispersystems.textsecuregcm.configuration.secrets.SecretBytes;
public record ChallengeConfiguration(SecretBytes blindingSecret, Duration tokenTtl) {
}

View File

@@ -29,7 +29,6 @@ import javax.ws.rs.POST;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
@@ -39,27 +38,18 @@ import org.whispersystems.textsecuregcm.entities.AnswerRecaptchaChallengeRequest
import org.whispersystems.textsecuregcm.limits.RateLimitChallengeManager;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.push.NotPushRegisteredException;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.Accounts;
import org.whispersystems.textsecuregcm.util.ChallengeTokenBlinder;
import org.whispersystems.textsecuregcm.util.HeaderUtils;
@Path("/v1/challenge")
@Tag(name = "Challenge")
public class ChallengeController {
private final Accounts accounts;
private final ChallengeTokenBlinder tokenBlinder;
private final RateLimitChallengeManager rateLimitChallengeManager;
private static final String CHALLENGE_RESPONSE_COUNTER_NAME = name(ChallengeController.class, "challengeResponse");
private static final String CHALLENGE_TYPE_TAG = "type";
public ChallengeController(final Accounts accounts,
final ChallengeTokenBlinder tokenBlinder,
final RateLimitChallengeManager rateLimitChallengeManager) {
this.accounts = accounts;
this.tokenBlinder = tokenBlinder;
public ChallengeController(final RateLimitChallengeManager rateLimitChallengeManager) {
this.rateLimitChallengeManager = rateLimitChallengeManager;
}
@@ -73,20 +63,18 @@ public class ChallengeController {
Some server endpoints (the "send message" endpoint, for example) may return a 428 response indicating the client must complete a challenge before continuing.
Clients may use this endpoint to provide proof of a completed challenge. If successful, the client may then
continue their original operation.
This endpoint permits unauthenticated calls if the `token` that was provided by the server with the original 428 response is supplied in the request body.
""",
requestBody = @RequestBody(content = {@Content(schema = @Schema(oneOf = {AnswerPushChallengeRequest.class,
AnswerRecaptchaChallengeRequest.class}))})
)
@ApiResponse(responseCode = "200", description = "Indicates the challenge proof was accepted")
@ApiResponse(responseCode = "401", description = "Indicates authentication or token from original challenge are required")
@ApiResponse(responseCode = "413", description = "Too many attempts", headers = @Header(
name = "Retry-After",
description = "If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed"))
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
name = "Retry-After",
description = "If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed"))
public Response handleChallengeResponse(@Auth final Optional<AuthenticatedAccount> maybeAuth,
public Response handleChallengeResponse(@Auth final AuthenticatedAccount auth,
@Valid final AnswerChallengeRequest answerRequest,
@HeaderParam(HttpHeaders.X_FORWARDED_FOR) final String forwardedFor,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) throws RateLimitExceededException, IOException {
@@ -97,20 +85,13 @@ public class ChallengeController {
if (answerRequest instanceof final AnswerPushChallengeRequest pushChallengeRequest) {
tags = tags.and(CHALLENGE_TYPE_TAG, "push");
rateLimitChallengeManager.answerPushChallenge(
maybeAuth.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED)).getAccount(),
pushChallengeRequest.getChallenge());
rateLimitChallengeManager.answerPushChallenge(auth.getAccount(), pushChallengeRequest.getChallenge());
} else if (answerRequest instanceof AnswerRecaptchaChallengeRequest recaptchaChallengeRequest) {
tags = tags.and(CHALLENGE_TYPE_TAG, "recaptcha");
final Account account = maybeAuth
.map(AuthenticatedAccount::getAccount)
.or(() -> tokenBlinder.unblindAccountToken(recaptchaChallengeRequest.getToken()).flatMap(accounts::getByAccountIdentifier))
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
final String mostRecentProxy = HeaderUtils.getMostRecentProxy(forwardedFor).orElseThrow(() -> new BadRequestException());
boolean success = rateLimitChallengeManager.answerRecaptchaChallenge(
account,
auth.getAccount(),
recaptchaChallengeRequest.getCaptcha(),
mostRecentProxy,
userAgent);

View File

@@ -2,7 +2,6 @@ package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
import javax.validation.constraints.NotNull;
@@ -10,7 +9,6 @@ public class RateLimitChallenge {
@JsonProperty
@NotNull
@Schema(description="An opaque token to be included along with the challenge result in the verification request")
private final String token;
@JsonProperty

View File

@@ -1,109 +0,0 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.util;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.security.GeneralSecurityException;
import java.security.ProviderException;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.Optional;
import java.util.UUID;
import javax.crypto.AEADBadTagException;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import org.whispersystems.textsecuregcm.configuration.ChallengeConfiguration;
public class ChallengeTokenBlinder {
private record Token(
UUID uuid,
Instant timestamp) {
}
private static final ObjectMapper mapper = SystemMapper.jsonMapper();
private final Clock clock;
private final Duration tokenTtl;
private final SecureRandom secureRandom = new SecureRandom();
private final SecretKey blindingKey;
public ChallengeTokenBlinder(final ChallengeConfiguration config, final Clock clock) {
this.blindingKey = new SecretKeySpec(config.blindingSecret().value(), "AES");
this.tokenTtl = config.tokenTtl();
this.clock = clock;
}
public String generateBlindedAccountToken(UUID aci) {
final Token token = new Token(aci, clock.instant());
final byte[] serializedToken;
try {
serializedToken = mapper.writeValueAsBytes(token);
} catch (IOException e) { // should really, really never happen
throw new IllegalArgumentException();
}
final byte[] iv = new byte[12];
secureRandom.nextBytes(iv);
final GCMParameterSpec parameterSpec = new GCMParameterSpec(128, iv);
try {
final Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, blindingKey, parameterSpec);
final byte[] ciphertext = cipher.doFinal(serializedToken);
final ByteBuffer byteBuffer = ByteBuffer.allocate(iv.length + ciphertext.length);
byteBuffer.put(iv);
byteBuffer.put(ciphertext);
return Base64.getUrlEncoder().withoutPadding().encodeToString(byteBuffer.array());
} catch (GeneralSecurityException e) {
throw new IllegalArgumentException(e);
}
}
public Optional<UUID> unblindAccountToken(String token) {
final byte[] ciphertext;
try {
ciphertext = Base64.getUrlDecoder().decode(token);
} catch (IllegalArgumentException e) {
return Optional.empty();
}
final Token parsedToken;
try {
final Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
final GCMParameterSpec parameterSpec = new GCMParameterSpec(128, ciphertext, 0, 12);
cipher.init(Cipher.DECRYPT_MODE, blindingKey, parameterSpec);
parsedToken = mapper.readValue(cipher.doFinal(ciphertext, 12, ciphertext.length - 12), Token.class);
} catch (ProviderException | AEADBadTagException | JsonProcessingException e) {
// the token doesn't successfully decrypt with this key, it's bogus (or from an older server version or before a key rotation)
return Optional.empty();
} catch (IOException | GeneralSecurityException e) { // should never happen
throw new IllegalArgumentException();
}
Instant now = clock.instant();
Instant intervalStart = now.minus(tokenTtl);
Instant tokenTime = parsedToken.timestamp();
if (tokenTime.isAfter(now) || tokenTime.isBefore(intervalStart)) {
// expired or fraudulently-future token
return Optional.empty();
}
return Optional.of(parsedToken.uuid());
}
}