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-06 18:25:19 -04:00 committed by GitHub
parent ef1a8fc50f
commit 098b177bd3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 363 additions and 8 deletions

View File

@ -61,6 +61,8 @@ cdn.accessSecret: test # AWS Access Secret
unidentifiedDelivery.certificate: ABCD1234
unidentifiedDelivery.privateKey: ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789AAAAAAA
challengeToken.blindingSecret: c3VwZXIgc2VjcmV0IGtleQ==
hCaptcha.apiKey: unset
storageService.userAuthenticationTokenSharedSecret: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=

View File

@ -230,6 +230,10 @@ unidentifiedDelivery:
privateKey: secret://unidentifiedDelivery.privateKey
expiresDays: 7
challenge:
blindingSecret: secret://challengeToken.blindingSecret
tokenTtl: PT10M
recaptcha:
projectPath: projects/example
credentialConfigurationJson: "{ }" # service account configuration for backend authentication

View File

@ -23,6 +23,7 @@ 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;
@ -186,6 +187,11 @@ public class WhisperServerConfiguration extends Configuration {
@JsonProperty
private UnidentifiedDeliveryConfiguration unidentifiedDelivery;
@Valid
@NotNull
@JsonProperty
private ChallengeConfiguration challenge;
@Valid
@NotNull
@JsonProperty
@ -295,6 +301,10 @@ public class WhisperServerConfiguration extends Configuration {
return dynamoDbTables;
}
public ChallengeConfiguration getChallengeConfiguration() {
return challenge;
}
public RecaptchaConfiguration getRecaptchaConfiguration() {
return recaptcha;
}

View File

@ -185,6 +185,7 @@ 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;
@ -561,7 +562,9 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
PushChallengeManager pushChallengeManager = new PushChallengeManager(pushNotificationManager,
pushChallengeDynamoDb);
RateLimitChallengeManager rateLimitChallengeManager = new RateLimitChallengeManager(pushChallengeManager,
captchaChecker, rateLimiters);
captchaChecker,
rateLimiters);
final ChallengeTokenBlinder challengeTokenBlinder = new ChallengeTokenBlinder(config.getChallengeConfiguration(), Clock.systemUTC());
ChangeNumberManager changeNumberManager = new ChangeNumberManager(messageSender, accountsManager);
@ -709,7 +712,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(rateLimitChallengeManager),
new ChallengeController(accounts, challengeTokenBlinder, rateLimitChallengeManager),
new DeviceController(pendingDevicesManager, accountsManager, messagesManager, keys, rateLimiters, config.getMaxDevices()),
new DirectoryV2Controller(directoryV2CredentialsGenerator),
new DonationController(clock, zkReceiptOperations, redeemedReceiptsManager, accountsManager, config.getBadges(),

View File

@ -0,0 +1,12 @@
/*
* 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,6 +29,7 @@ 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;
@ -38,18 +39,27 @@ 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 RateLimitChallengeManager rateLimitChallengeManager) {
public ChallengeController(final Accounts accounts,
final ChallengeTokenBlinder tokenBlinder,
final RateLimitChallengeManager rateLimitChallengeManager) {
this.accounts = accounts;
this.tokenBlinder = tokenBlinder;
this.rateLimitChallengeManager = rateLimitChallengeManager;
}
@ -63,18 +73,20 @@ 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 AuthenticatedAccount auth,
public Response handleChallengeResponse(@Auth final Optional<AuthenticatedAccount> maybeAuth,
@Valid final AnswerChallengeRequest answerRequest,
@HeaderParam(HttpHeaders.X_FORWARDED_FOR) final String forwardedFor,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) throws RateLimitExceededException, IOException {
@ -85,13 +97,20 @@ public class ChallengeController {
if (answerRequest instanceof final AnswerPushChallengeRequest pushChallengeRequest) {
tags = tags.and(CHALLENGE_TYPE_TAG, "push");
rateLimitChallengeManager.answerPushChallenge(auth.getAccount(), pushChallengeRequest.getChallenge());
rateLimitChallengeManager.answerPushChallenge(
maybeAuth.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED)).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(
auth.getAccount(),
account,
recaptchaChallengeRequest.getCaptcha(),
mostRecentProxy,
userAgent);

View File

@ -2,6 +2,7 @@ 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;
@ -9,6 +10,7 @@ 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

@ -0,0 +1,109 @@
/*
* 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());
}
}

View File

@ -21,8 +21,13 @@ import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider;
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
import io.dropwizard.testing.junit5.ResourceExtension;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import javax.ws.rs.client.Entity;
import javax.ws.rs.core.Response;
import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;
@ -31,18 +36,30 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount;
import org.whispersystems.textsecuregcm.configuration.ChallengeConfiguration;
import org.whispersystems.textsecuregcm.configuration.secrets.SecretBytes;
import org.whispersystems.textsecuregcm.limits.RateLimitChallengeManager;
import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;
import org.whispersystems.textsecuregcm.push.NotPushRegisteredException;
import org.whispersystems.textsecuregcm.storage.Accounts;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
import org.whispersystems.textsecuregcm.util.ChallengeTokenBlinder;
import org.whispersystems.textsecuregcm.util.SystemMapper;
import org.whispersystems.textsecuregcm.util.TestClock;
@ExtendWith(DropwizardExtensionsSupport.class)
class ChallengeControllerTest {
private static final RateLimitChallengeManager rateLimitChallengeManager = mock(RateLimitChallengeManager.class);
private static final ChallengeController challengeController = new ChallengeController(rateLimitChallengeManager);
private static final Accounts accounts = mock(Accounts.class);
private static final TestClock clock = TestClock.now();
private static final ChallengeTokenBlinder tokenBlinder = new ChallengeTokenBlinder(
new ChallengeConfiguration(new SecretBytes("super secret key".getBytes()), Duration.ofMinutes(10)),
clock);
private static final ChallengeController challengeController = new ChallengeController(accounts, tokenBlinder, rateLimitChallengeManager);
private static final ResourceExtension EXTENSION = ResourceExtension.builder()
.addProvider(AuthHelper.getAuthFilter())
@ -56,7 +73,9 @@ class ChallengeControllerTest {
@AfterEach
void teardown() {
reset(accounts);
reset(rateLimitChallengeManager);
clock.unpin();
}
@Test
@ -188,6 +207,181 @@ class ChallengeControllerTest {
verifyNoInteractions(rateLimitChallengeManager);
}
@Test
void testHandleRecaptchaWithTokenAuth() throws Exception {
final String recaptchaChallengeJson = """
{
"type": "recaptcha",
"token": "%s",
"captcha": "The value of the solved captcha token"
}
""".formatted(tokenBlinder.generateBlindedAccountToken(AuthHelper.VALID_UUID));
when(accounts.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(AuthHelper.VALID_ACCOUNT));
when(rateLimitChallengeManager.answerRecaptchaChallenge(any(), any(), any(), any()))
.thenReturn(true);
final Response response = EXTENSION.target("/v1/challenge")
.request()
.header(HttpHeaders.X_FORWARDED_FOR, "10.0.0.1")
.put(Entity.json(recaptchaChallengeJson));
assertEquals(200, response.getStatus());
verify(rateLimitChallengeManager).answerRecaptchaChallenge(eq(AuthHelper.VALID_ACCOUNT), eq("The value of the solved captcha token"), eq("10.0.0.1"), anyString());
}
@Test
void testHandleRecaptchaWithExpiredToken() throws Exception {
final String recaptchaChallengeJson = """
{
"type": "recaptcha",
"token": "%s",
"captcha": "The value of the solved captcha token"
}
""".formatted(tokenBlinder.generateBlindedAccountToken(AuthHelper.VALID_UUID));
clock.pin(clock.instant().plus(Duration.ofMinutes(20)));
when(accounts.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(AuthHelper.VALID_ACCOUNT));
when(rateLimitChallengeManager.answerRecaptchaChallenge(any(), any(), any(), any()))
.thenReturn(true);
final Response response = EXTENSION.target("/v1/challenge")
.request()
.header(HttpHeaders.X_FORWARDED_FOR, "10.0.0.1")
.put(Entity.json(recaptchaChallengeJson));
assertEquals(401, response.getStatus());
verifyNoInteractions(rateLimitChallengeManager);
}
@Test
void testHandleRecaptchaWithPostdatedToken() throws Exception {
clock.pin(clock.instant());
final String recaptchaChallengeJson = """
{
"type": "recaptcha",
"token": "%s",
"captcha": "The value of the solved captcha token"
}
""".formatted(tokenBlinder.generateBlindedAccountToken(AuthHelper.VALID_UUID));
clock.pin(clock.instant().minus(Duration.ofMinutes(1)));
when(accounts.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(AuthHelper.VALID_ACCOUNT));
when(rateLimitChallengeManager.answerRecaptchaChallenge(any(), any(), any(), any()))
.thenReturn(true);
final Response response = EXTENSION.target("/v1/challenge")
.request()
.header(HttpHeaders.X_FORWARDED_FOR, "10.0.0.1")
.put(Entity.json(recaptchaChallengeJson));
assertEquals(401, response.getStatus());
verifyNoInteractions(rateLimitChallengeManager);
}
@Test
void testHandleRecaptchaNoAuthNonBase64Token() throws Exception {
final String recaptchaChallengeJson = """
{
"type": "recaptcha",
"token": "This is not a valid auth token",
"captcha": "The value of the solved captcha token"
}
""";
when(rateLimitChallengeManager.answerRecaptchaChallenge(any(), any(), any(), any()))
.thenReturn(true);
final Response response = EXTENSION.target("/v1/challenge")
.request()
.header(HttpHeaders.X_FORWARDED_FOR, "10.0.0.1")
.put(Entity.json(recaptchaChallengeJson));
assertEquals(401, response.getStatus());
verifyNoInteractions(rateLimitChallengeManager);
}
@Test
void testHandleRecaptchaNoAuthValidBase64Token() throws Exception {
final String recaptchaChallengeJson = """
{
"type": "recaptcha",
"token": "Y2x1Y2sgY2x1Y2ssIGknbSBhIHBhcnJvdAo=",
"captcha": "The value of the solved captcha token"
}
""";
when(rateLimitChallengeManager.answerRecaptchaChallenge(any(), any(), any(), any()))
.thenReturn(true);
final Response response = EXTENSION.target("/v1/challenge")
.request()
.header(HttpHeaders.X_FORWARDED_FOR, "10.0.0.1")
.put(Entity.json(recaptchaChallengeJson));
assertEquals(401, response.getStatus());
verifyNoInteractions(rateLimitChallengeManager);
}
@Test
void testHandleRecaptchaNoAuthTokenEncryptedWithWrongKey() throws Exception {
final String badToken =
new ChallengeTokenBlinder(
new ChallengeConfiguration(new SecretBytes("oh no, wrong key".getBytes()), Duration.ofMinutes(10)),
clock)
.generateBlindedAccountToken(AuthHelper.VALID_UUID);
final String recaptchaChallengeJson = """
{
"type": "recaptcha",
"token": "%s",
"captcha": "The value of the solved captcha token"
}
""".formatted(badToken);
when(rateLimitChallengeManager.answerRecaptchaChallenge(any(), any(), any(), any()))
.thenReturn(true);
final Response response = EXTENSION.target("/v1/challenge")
.request()
.header(HttpHeaders.X_FORWARDED_FOR, "10.0.0.1")
.put(Entity.json(recaptchaChallengeJson));
assertEquals(401, response.getStatus());
verifyNoInteractions(rateLimitChallengeManager);
}
@Test
void testHandleRecaptchaWithTokenForBadAccount() throws Exception {
final UUID badUUID = UUID.randomUUID();
final String recaptchaChallengeJson = """
{
"type": "recaptcha",
"token": "%s",
"captcha": "The value of the solved captcha token"
}
""".formatted(tokenBlinder.generateBlindedAccountToken(badUUID));
when(accounts.getByAccountIdentifier(badUUID)).thenReturn(Optional.empty());
when(rateLimitChallengeManager.answerRecaptchaChallenge(any(), any(), any(), any()))
.thenReturn(true);
final Response response = EXTENSION.target("/v1/challenge")
.request()
.header(HttpHeaders.X_FORWARDED_FOR, "10.0.0.1")
.put(Entity.json(recaptchaChallengeJson));
assertEquals(401, response.getStatus());
verifyNoInteractions(rateLimitChallengeManager);
}
@Test
void testHandleUnrecognizedAnswer() {
final String unrecognizedJson = """