Skip to content

Commit

Permalink
Allow use of the token returned with spam challenges as auth for the …
Browse files Browse the repository at this point in the history
…challenge verification request
  • Loading branch information
jkt-signal committed Jul 6, 2023
1 parent ef1a8fc commit 098b177
Show file tree
Hide file tree
Showing 9 changed files with 363 additions and 8 deletions.
2 changes: 2 additions & 0 deletions service/config/sample-secrets-bundle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
4 changes: 4 additions & 0 deletions service/config/sample.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
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;
Expand Down Expand Up @@ -186,6 +187,11 @@ public class WhisperServerConfiguration extends Configuration {
@JsonProperty
private UnidentifiedDeliveryConfiguration unidentifiedDelivery;

@Valid
@NotNull
@JsonProperty
private ChallengeConfiguration challenge;

@Valid
@NotNull
@JsonProperty
Expand Down Expand Up @@ -295,6 +301,10 @@ public DynamoDbTables getDynamoDbTables() {
return dynamoDbTables;
}

public ChallengeConfiguration getChallengeConfiguration() {
return challenge;
}

public RecaptchaConfiguration getRecaptchaConfiguration() {
return recaptcha;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@
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;
Expand Down Expand Up @@ -561,7 +562,9 @@ public void run(WhisperServerConfiguration config, Environment environment) thro
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);

Expand Down Expand Up @@ -709,7 +712,7 @@ public void run(WhisperServerConfiguration config, Environment environment) thro
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(),
Expand Down
Original file line number Diff line number Diff line change
@@ -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) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
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;
Expand All @@ -38,18 +39,27 @@
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;
}

Expand All @@ -63,18 +73,20 @@ public ChallengeController(final RateLimitChallengeManager rateLimitChallengeMan
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 {
Expand All @@ -85,13 +97,20 @@ public Response handleChallengeResponse(@Auth final AuthenticatedAccount auth,
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@

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;

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
Expand Down
Original file line number Diff line number Diff line change
@@ -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());
}

}
Loading

0 comments on commit 098b177

Please sign in to comment.