Add receipt redemption API to chat server

This commit is contained in:
Ehren Kret 2021-10-01 12:44:47 -05:00 committed by GitHub
parent ba58a95a0f
commit 3032415141
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 708 additions and 143 deletions

View File

@ -235,6 +235,12 @@
<artifactId>commons-logging</artifactId> <artifactId>commons-logging</artifactId>
<version>1.2</version> <version>1.2</version>
</dependency> </dependency>
<dependency>
<groupId>org.ow2.asm</groupId>
<artifactId>asm</artifactId>
<version>9.2</version>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
</dependencyManagement> </dependencyManagement>
@ -248,7 +254,7 @@
<dependency> <dependency>
<groupId>com.github.tomakehurst</groupId> <groupId>com.github.tomakehurst</groupId>
<artifactId>wiremock-jre8</artifactId> <artifactId>wiremock-jre8</artifactId>
<version>2.28.1</version> <version>2.31.0</version>
<scope>test</scope> <scope>test</scope>
<exclusions> <exclusions>
<exclusion> <exclusion>

View File

@ -98,6 +98,11 @@ deletedAccountsLockDynamoDb: # DynamoDb table configuration
region: region:
tableName: tableName:
redeemedReceiptsDynamoDb: # DynamoDB table configuration
region:
tableName:
expirationTime: # ISO8601 Duration
migrationDeletedAccountsDynamoDb: # DynamoDB table configuration migrationDeletedAccountsDynamoDb: # DynamoDB table configuration
region: region:
tableName: tableName:
@ -266,3 +271,5 @@ badges:
category: other category: other
badgeIdsEnabledForAll: badgeIdsEnabledForAll:
- TEST - TEST
receiptLevels:
'1': TEST

View File

@ -55,7 +55,7 @@
<dependency> <dependency>
<groupId>org.signal</groupId> <groupId>org.signal</groupId>
<artifactId>zkgroup-java</artifactId> <artifactId>zkgroup-java</artifactId>
<version>0.8.1</version> <version>0.8.2</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.whispersystems</groupId> <groupId>org.whispersystems</groupId>

View File

@ -38,6 +38,7 @@ import org.whispersystems.textsecuregcm.configuration.PushConfiguration;
import org.whispersystems.textsecuregcm.configuration.RateLimitsConfiguration; import org.whispersystems.textsecuregcm.configuration.RateLimitsConfiguration;
import org.whispersystems.textsecuregcm.configuration.RecaptchaConfiguration; import org.whispersystems.textsecuregcm.configuration.RecaptchaConfiguration;
import org.whispersystems.textsecuregcm.configuration.RecaptchaV2Configuration; import org.whispersystems.textsecuregcm.configuration.RecaptchaV2Configuration;
import org.whispersystems.textsecuregcm.configuration.RedeemedReceiptsDynamoDbConfiguration;
import org.whispersystems.textsecuregcm.configuration.RedisClusterConfiguration; import org.whispersystems.textsecuregcm.configuration.RedisClusterConfiguration;
import org.whispersystems.textsecuregcm.configuration.RedisConfiguration; import org.whispersystems.textsecuregcm.configuration.RedisConfiguration;
import org.whispersystems.textsecuregcm.configuration.RemoteConfigConfiguration; import org.whispersystems.textsecuregcm.configuration.RemoteConfigConfiguration;
@ -154,6 +155,11 @@ public class WhisperServerConfiguration extends Configuration {
@JsonProperty @JsonProperty
private DynamoDbConfiguration deletedAccountsLockDynamoDb; private DynamoDbConfiguration deletedAccountsLockDynamoDb;
@Valid
@NotNull
@JsonProperty
private RedeemedReceiptsDynamoDbConfiguration redeemedReceiptsDynamoDb;
@Valid @Valid
@NotNull @NotNull
@JsonProperty @JsonProperty
@ -392,6 +398,10 @@ public class WhisperServerConfiguration extends Configuration {
return deletedAccountsLockDynamoDb; return deletedAccountsLockDynamoDb;
} }
public RedeemedReceiptsDynamoDbConfiguration getRedeemedReceiptsDynamoDbConfiguration() {
return redeemedReceiptsDynamoDb;
}
public DatabaseConfiguration getAbuseDatabaseConfiguration() { public DatabaseConfiguration getAbuseDatabaseConfiguration() {
return abuseDatabase; return abuseDatabase;
} }

View File

@ -56,6 +56,8 @@ import org.jdbi.v3.core.Jdbi;
import org.signal.zkgroup.ServerSecretParams; import org.signal.zkgroup.ServerSecretParams;
import org.signal.zkgroup.auth.ServerZkAuthOperations; import org.signal.zkgroup.auth.ServerZkAuthOperations;
import org.signal.zkgroup.profiles.ServerZkProfileOperations; import org.signal.zkgroup.profiles.ServerZkProfileOperations;
import org.signal.zkgroup.receipts.ReceiptCredentialPresentation;
import org.signal.zkgroup.receipts.ServerZkReceiptOperations;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.dispatch.DispatchManager; import org.whispersystems.dispatch.DispatchManager;
@ -175,6 +177,7 @@ import org.whispersystems.textsecuregcm.storage.ProfilesManager;
import org.whispersystems.textsecuregcm.storage.PubSubManager; import org.whispersystems.textsecuregcm.storage.PubSubManager;
import org.whispersystems.textsecuregcm.storage.PushChallengeDynamoDb; import org.whispersystems.textsecuregcm.storage.PushChallengeDynamoDb;
import org.whispersystems.textsecuregcm.storage.PushFeedbackProcessor; import org.whispersystems.textsecuregcm.storage.PushFeedbackProcessor;
import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager;
import org.whispersystems.textsecuregcm.storage.RemoteConfigs; import org.whispersystems.textsecuregcm.storage.RemoteConfigs;
import org.whispersystems.textsecuregcm.storage.RemoteConfigsManager; import org.whispersystems.textsecuregcm.storage.RemoteConfigsManager;
import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb; import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb;
@ -208,6 +211,7 @@ import org.whispersystems.websocket.setup.WebSocketEnvironment;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient; import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.S3Client;
@ -286,8 +290,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
environment.getObjectMapper().setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE); environment.getObjectMapper().setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE);
environment.getObjectMapper().setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY); environment.getObjectMapper().setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY);
ProfileBadgeConverter profileBadgeConverter = new ConfiguredProfileBadgeConverter( ProfileBadgeConverter profileBadgeConverter = new ConfiguredProfileBadgeConverter(clock, config.getBadges());
Clock.systemUTC(), config.getBadges());
JdbiFactory jdbiFactory = new JdbiFactory(DefaultNameStrategy.CHECK_EMPTY); JdbiFactory jdbiFactory = new JdbiFactory(DefaultNameStrategy.CHECK_EMPTY);
Jdbi accountJdbi = jdbiFactory.build(environment, config.getAccountsDatabaseConfiguration(), "accountdb"); Jdbi accountJdbi = jdbiFactory.build(environment, config.getAccountsDatabaseConfiguration(), "accountdb");
@ -333,6 +336,10 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
.withCredentials(InstanceProfileCredentialsProvider.getInstance()) .withCredentials(InstanceProfileCredentialsProvider.getInstance())
.build(); .build();
DynamoDbAsyncClient redeemedReceiptsDynamoDbClient = DynamoDbFromConfig.asyncClient(
config.getRedeemedReceiptsDynamoDbConfiguration(),
software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider.create());
DeletedAccounts deletedAccounts = new DeletedAccounts(deletedAccountsDynamoDbClient, DeletedAccounts deletedAccounts = new DeletedAccounts(deletedAccountsDynamoDbClient,
config.getDeletedAccountsDynamoDbConfiguration().getTableName(), config.getDeletedAccountsDynamoDbConfiguration().getTableName(),
config.getDeletedAccountsDynamoDbConfiguration().getNeedsReconciliationIndexName()); config.getDeletedAccountsDynamoDbConfiguration().getNeedsReconciliationIndexName());
@ -437,6 +444,11 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
ProvisioningManager provisioningManager = new ProvisioningManager(pubSubManager); ProvisioningManager provisioningManager = new ProvisioningManager(pubSubManager);
TorExitNodeManager torExitNodeManager = new TorExitNodeManager(recurringJobExecutor, config.getTorExitNodeListConfiguration()); TorExitNodeManager torExitNodeManager = new TorExitNodeManager(recurringJobExecutor, config.getTorExitNodeListConfiguration());
AsnManager asnManager = new AsnManager(recurringJobExecutor, config.getAsnTableConfiguration()); AsnManager asnManager = new AsnManager(recurringJobExecutor, config.getAsnTableConfiguration());
RedeemedReceiptsManager redeemedReceiptsManager = new RedeemedReceiptsManager(
clock,
config.getRedeemedReceiptsDynamoDbConfiguration().getTableName(),
redeemedReceiptsDynamoDbClient,
config.getRedeemedReceiptsDynamoDbConfiguration().getExpirationTime());
AccountAuthenticator accountAuthenticator = new AccountAuthenticator(accountsManager); AccountAuthenticator accountAuthenticator = new AccountAuthenticator(accountsManager);
DisabledPermittedAccountAuthenticator disabledPermittedAccountAuthenticator = new DisabledPermittedAccountAuthenticator(accountsManager); DisabledPermittedAccountAuthenticator disabledPermittedAccountAuthenticator = new DisabledPermittedAccountAuthenticator(accountsManager);
@ -535,6 +547,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
ServerSecretParams zkSecretParams = new ServerSecretParams(config.getZkConfig().getServerSecret()); ServerSecretParams zkSecretParams = new ServerSecretParams(config.getZkConfig().getServerSecret());
ServerZkProfileOperations zkProfileOperations = new ServerZkProfileOperations(zkSecretParams); ServerZkProfileOperations zkProfileOperations = new ServerZkProfileOperations(zkSecretParams);
ServerZkAuthOperations zkAuthOperations = new ServerZkAuthOperations(zkSecretParams); ServerZkAuthOperations zkAuthOperations = new ServerZkAuthOperations(zkSecretParams);
ServerZkReceiptOperations zkReceiptOperations = new ServerZkReceiptOperations(zkSecretParams);
AuthFilter<BasicCredentials, AuthenticatedAccount> accountAuthFilter = new BasicCredentialAuthFilter.Builder<AuthenticatedAccount>().setAuthenticator( AuthFilter<BasicCredentials, AuthenticatedAccount> accountAuthFilter = new BasicCredentialAuthFilter.Builder<AuthenticatedAccount>().setAuthenticator(
accountAuthenticator).buildAuthFilter(); accountAuthenticator).buildAuthFilter();
@ -587,7 +600,8 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
new ChallengeController(rateLimitChallengeManager), new ChallengeController(rateLimitChallengeManager),
new DeviceController(pendingDevicesManager, accountsManager, messagesManager, keysDynamoDb, rateLimiters, config.getMaxDevices()), new DeviceController(pendingDevicesManager, accountsManager, messagesManager, keysDynamoDb, rateLimiters, config.getMaxDevices()),
new DirectoryController(directoryCredentialsGenerator), new DirectoryController(directoryCredentialsGenerator),
new DonationController(donationExecutor, config.getDonationConfiguration()), new DonationController(clock, zkReceiptOperations, redeemedReceiptsManager, accountsManager, config.getBadges(),
ReceiptCredentialPresentation::new, donationExecutor, config.getDonationConfiguration()),
new MessageController(rateLimiters, messageSender, receiptSender, accountsManager, messagesManager, unsealedSenderRateLimiter, apnFallbackManager, dynamicConfigurationManager, rateLimitChallengeManager, reportMessageManager, metricsCluster, declinedMessageReceiptExecutor, multiRecipientMessageExecutor), new MessageController(rateLimiters, messageSender, receiptSender, accountsManager, messagesManager, unsealedSenderRateLimiter, apnFallbackManager, dynamicConfigurationManager, rateLimitChallengeManager, reportMessageManager, metricsCluster, declinedMessageReceiptExecutor, multiRecipientMessageExecutor),
new PaymentsController(currencyManager, paymentsCredentialsGenerator), new PaymentsController(currencyManager, paymentsCredentialsGenerator),
new ProfileController(clock, rateLimiters, accountsManager, profilesManager, usernamesManager, dynamicConfigurationManager, profileBadgeConverter, config.getBadges(), cdnS3Client, profileCdnPolicyGenerator, profileCdnPolicySigner, config.getCdnConfiguration().getBucket(), zkProfileOperations), new ProfileController(clock, rateLimiters, accountsManager, profilesManager, usernamesManager, dynamicConfigurationManager, profileBadgeConverter, config.getBadges(), cdnS3Client, profileCdnPolicyGenerator, profileCdnPolicySigner, config.getCdnConfiguration().getBucket(), zkProfileOperations),

View File

@ -6,24 +6,32 @@
package org.whispersystems.textsecuregcm.configuration; package org.whispersystems.textsecuregcm.configuration;
import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonSetter; import com.fasterxml.jackson.annotation.JsonSetter;
import com.fasterxml.jackson.annotation.Nulls; import com.fasterxml.jackson.annotation.Nulls;
import io.dropwizard.validation.ValidationMethod;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import javax.validation.Valid; import javax.validation.Valid;
import javax.validation.constraints.NotNull; import javax.validation.constraints.NotNull;
public class BadgesConfiguration { public class BadgesConfiguration {
private final List<BadgeConfiguration> badges; private final List<BadgeConfiguration> badges;
private final List<String> badgeIdsEnabledForAll; private final List<String> badgeIdsEnabledForAll;
private final Map<Long, String> receiptLevels;
@JsonCreator @JsonCreator
public BadgesConfiguration( public BadgesConfiguration(
@JsonProperty("badges") @JsonSetter(nulls = Nulls.AS_EMPTY) final List<BadgeConfiguration> badges, @JsonProperty("badges") @JsonSetter(nulls = Nulls.AS_EMPTY) final List<BadgeConfiguration> badges,
@JsonProperty("badgeIdsEnabledForAll") @JsonSetter(nulls = Nulls.AS_EMPTY) final List<String> badgeIdsEnabledForAll) { @JsonProperty("badgeIdsEnabledForAll") @JsonSetter(nulls = Nulls.AS_EMPTY) final List<String> badgeIdsEnabledForAll,
@JsonProperty("receiptLevels") @JsonSetter(nulls = Nulls.AS_EMPTY) final Map<Long, String> receiptLevels) {
this.badges = Objects.requireNonNull(badges); this.badges = Objects.requireNonNull(badges);
this.badgeIdsEnabledForAll = Objects.requireNonNull(badgeIdsEnabledForAll); this.badgeIdsEnabledForAll = Objects.requireNonNull(badgeIdsEnabledForAll);
this.receiptLevels = Objects.requireNonNull(receiptLevels);
} }
@Valid @Valid
@ -37,4 +45,17 @@ public class BadgesConfiguration {
public List<String> getBadgeIdsEnabledForAll() { public List<String> getBadgeIdsEnabledForAll() {
return badgeIdsEnabledForAll; return badgeIdsEnabledForAll;
} }
@Valid
@NotNull
public Map<Long, String> getReceiptLevels() {
return receiptLevels;
}
@JsonIgnore
@ValidationMethod(message = "contains receipt level mappings that are not configured badges")
public boolean isAllReceiptLevelsConfigured() {
final Set<String> badgeNames = badges.stream().map(BadgeConfiguration::getId).collect(Collectors.toSet());
return badgeNames.containsAll(receiptLevels.values());
}
} }

View File

@ -0,0 +1,21 @@
/*
* Copyright 2021 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.configuration;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.Duration;
import javax.validation.constraints.NotNull;
public class RedeemedReceiptsDynamoDbConfiguration extends DynamoDbConfiguration {
private Duration expirationTime;
@NotNull
@JsonProperty
public Duration getExpirationTime() {
return expirationTime;
}
}

View File

@ -18,13 +18,22 @@ import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandlers; import java.net.http.HttpResponse.BodyHandlers;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration; import java.time.Duration;
import java.time.Instant;
import java.util.Base64; import java.util.Base64;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executor; import java.util.concurrent.Executor;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ForkJoinPool.ManagedBlocker;
import java.util.function.Function;
import javax.annotation.Nonnull;
import javax.validation.Valid; import javax.validation.Valid;
import javax.ws.rs.Consumes; import javax.ws.rs.Consumes;
import javax.ws.rs.POST; import javax.ws.rs.POST;
@ -32,28 +41,64 @@ import javax.ws.rs.Path;
import javax.ws.rs.Produces; import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType; import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status;
import org.signal.zkgroup.InvalidInputException;
import org.signal.zkgroup.VerificationFailedException;
import org.signal.zkgroup.receipts.ReceiptCredentialPresentation;
import org.signal.zkgroup.receipts.ReceiptSerial;
import org.signal.zkgroup.receipts.ServerZkReceiptOperations;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
import org.whispersystems.textsecuregcm.configuration.DonationConfiguration; import org.whispersystems.textsecuregcm.configuration.DonationConfiguration;
import org.whispersystems.textsecuregcm.entities.ApplePayAuthorizationRequest; import org.whispersystems.textsecuregcm.entities.ApplePayAuthorizationRequest;
import org.whispersystems.textsecuregcm.entities.ApplePayAuthorizationResponse; import org.whispersystems.textsecuregcm.entities.ApplePayAuthorizationResponse;
import org.whispersystems.textsecuregcm.entities.RedeemReceiptRequest;
import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient; import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;
import org.whispersystems.textsecuregcm.http.FormDataBodyPublisher; import org.whispersystems.textsecuregcm.http.FormDataBodyPublisher;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountBadge;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager;
import org.whispersystems.textsecuregcm.util.SystemMapper; import org.whispersystems.textsecuregcm.util.SystemMapper;
@Path("/v1/donation") @Path("/v1/donation")
public class DonationController { public class DonationController {
public interface ReceiptCredentialPresentationFactory {
ReceiptCredentialPresentation build(byte[] bytes) throws InvalidInputException;
}
private final Logger logger = LoggerFactory.getLogger(DonationController.class); private final Logger logger = LoggerFactory.getLogger(DonationController.class);
private final Clock clock;
private final ServerZkReceiptOperations serverZkReceiptOperations;
private final RedeemedReceiptsManager redeemedReceiptsManager;
private final AccountsManager accountsManager;
private final BadgesConfiguration badgesConfiguration;
private final ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory;
private final URI uri; private final URI uri;
private final String apiKey; private final String apiKey;
private final String description; private final String description;
private final Set<String> supportedCurrencies; private final Set<String> supportedCurrencies;
private final FaultTolerantHttpClient httpClient; private final FaultTolerantHttpClient httpClient;
public DonationController(final Executor executor, final DonationConfiguration configuration) { public DonationController(
@Nonnull final Clock clock,
@Nonnull final ServerZkReceiptOperations serverZkReceiptOperations,
@Nonnull final RedeemedReceiptsManager redeemedReceiptsManager,
@Nonnull final AccountsManager accountsManager,
@Nonnull final BadgesConfiguration badgesConfiguration,
@Nonnull final ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory,
@Nonnull final Executor httpClientExecutor,
@Nonnull final DonationConfiguration configuration) {
this.clock = Objects.requireNonNull(clock);
this.serverZkReceiptOperations = Objects.requireNonNull(serverZkReceiptOperations);
this.redeemedReceiptsManager = Objects.requireNonNull(redeemedReceiptsManager);
this.accountsManager = Objects.requireNonNull(accountsManager);
this.badgesConfiguration = Objects.requireNonNull(badgesConfiguration);
this.receiptCredentialPresentationFactory = Objects.requireNonNull(receiptCredentialPresentationFactory);
this.uri = URI.create(configuration.getUri()); this.uri = URI.create(configuration.getUri());
this.apiKey = configuration.getApiKey(); this.apiKey = configuration.getApiKey();
this.description = configuration.getDescription(); this.description = configuration.getDescription();
@ -64,12 +109,82 @@ public class DonationController {
.withVersion(Version.HTTP_2) .withVersion(Version.HTTP_2)
.withConnectTimeout(Duration.ofSeconds(10)) .withConnectTimeout(Duration.ofSeconds(10))
.withRedirect(Redirect.NEVER) .withRedirect(Redirect.NEVER)
.withExecutor(executor) .withExecutor(Objects.requireNonNull(httpClientExecutor))
.withName("donation") .withName("donation")
.withSecurityProtocol(FaultTolerantHttpClient.SECURITY_PROTOCOL_TLS_1_3) .withSecurityProtocol(FaultTolerantHttpClient.SECURITY_PROTOCOL_TLS_1_3)
.build(); .build();
} }
@Timed
@POST
@Path("/redeem-receipt")
@Consumes(MediaType.APPLICATION_JSON)
@Produces({MediaType.APPLICATION_JSON, MediaType.TEXT_PLAIN})
public CompletionStage<Response> redeemReceipt(
@Auth final AuthenticatedAccount auth,
@Valid final RedeemReceiptRequest request) {
return CompletableFuture.supplyAsync(() -> {
ReceiptCredentialPresentation receiptCredentialPresentation;
try {
receiptCredentialPresentation = receiptCredentialPresentationFactory.build(
request.getReceiptCredentialPresentation());
} catch (InvalidInputException e) {
return CompletableFuture.completedFuture(Response.status(Status.BAD_REQUEST).entity("invalid receipt credential presentation").type(MediaType.TEXT_PLAIN_TYPE).build());
}
try {
serverZkReceiptOperations.verifyReceiptCredentialPresentation(receiptCredentialPresentation);
} catch (VerificationFailedException e) {
return CompletableFuture.completedFuture(Response.status(Status.BAD_REQUEST).entity("receipt credential presentation verification failed").type(MediaType.TEXT_PLAIN_TYPE).build());
}
final ReceiptSerial receiptSerial = receiptCredentialPresentation.getReceiptSerial();
final Instant receiptExpiration = Instant.ofEpochSecond(receiptCredentialPresentation.getReceiptExpirationTime());
final long receiptLevel = receiptCredentialPresentation.getReceiptLevel();
final String badgeId = badgesConfiguration.getReceiptLevels().get(receiptLevel);
if (badgeId == null) {
return CompletableFuture.completedFuture(Response.serverError().entity("server does not recognize the requested receipt level").type(MediaType.TEXT_PLAIN_TYPE).build());
}
final CompletionStage<Boolean> putStage = redeemedReceiptsManager.put(
receiptSerial, receiptExpiration.getEpochSecond(), receiptLevel, auth.getAccount().getUuid());
return putStage.thenApplyAsync(receiptMatched -> {
if (!receiptMatched) {
return Response.status(Status.BAD_REQUEST).entity("receipt serial is already redeemed").type(MediaType.TEXT_PLAIN_TYPE).build();
}
try {
ForkJoinPool.managedBlock(new ManagedBlocker() {
boolean done = false;
@Override
public boolean block() {
final Optional<Account> optionalAccount = accountsManager.get(auth.getAccount().getUuid());
optionalAccount.ifPresent(account -> {
accountsManager.update(account, a -> {
a.addBadge(clock, new AccountBadge(badgeId, receiptExpiration, request.isVisible()));
if (request.isPrimary()) {
a.makeBadgePrimaryIfExists(clock, badgeId);
}
});
});
done = true;
return true;
}
@Override
public boolean isReleasable() {
return done;
}
});
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return Response.serverError().build();
}
return Response.ok().build();
});
}).thenCompose(Function.identity());
}
@Timed @Timed
@POST @POST
@Path("/authorize-apple-pay") @Path("/authorize-apple-pay")

View File

@ -5,48 +5,20 @@
package org.whispersystems.textsecuregcm.entities; package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.google.common.annotations.VisibleForTesting;
import java.io.IOException;
import java.util.Base64;
public class DeliveryCertificate { public class DeliveryCertificate {
@JsonProperty private final byte[] certificate;
@JsonSerialize(using = ByteArraySerializer.class)
@JsonDeserialize(using = ByteArrayDeserializer.class)
private byte[] certificate;
public DeliveryCertificate(byte[] certificate) { @JsonCreator
public DeliveryCertificate(
@JsonProperty("certificate") byte[] certificate) {
this.certificate = certificate; this.certificate = certificate;
} }
public DeliveryCertificate() {}
@VisibleForTesting
public byte[] getCertificate() { public byte[] getCertificate() {
return certificate; return certificate;
} }
public static class ByteArraySerializer extends JsonSerializer<byte[]> {
@Override
public void serialize(byte[] bytes, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
jsonGenerator.writeString(Base64.getEncoder().encodeToString(bytes));
}
}
public static class ByteArrayDeserializer extends JsonDeserializer<byte[]> {
@Override
public byte[] deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
return Base64.getDecoder().decode(jsonParser.getValueAsString());
}
}
} }

View File

@ -1,32 +1,21 @@
/* /*
* Copyright 2013-2020 Signal Messenger, LLC * Copyright 2021 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
package org.whispersystems.textsecuregcm.entities; package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import java.io.IOException;
import java.util.Base64;
import java.util.List; import java.util.List;
public class GroupCredentials { public class GroupCredentials {
@JsonProperty private final List<GroupCredential> credentials;
private List<GroupCredential> credentials;
public GroupCredentials() {} @JsonCreator
public GroupCredentials(
public GroupCredentials(List<GroupCredential> credentials) { @JsonProperty("credentials") List<GroupCredential> credentials) {
this.credentials = credentials; this.credentials = credentials;
} }
@ -36,18 +25,14 @@ public class GroupCredentials {
public static class GroupCredential { public static class GroupCredential {
@JsonProperty private final byte[] credential;
@JsonSerialize(using = ByteArraySerializer.class) private final int redemptionTime;
@JsonDeserialize(using = ByteArrayDeserializer.class)
private byte[] credential;
@JsonProperty @JsonCreator
private int redemptionTime; public GroupCredential(
@JsonProperty("credential") byte[] credential,
public GroupCredential() {} @JsonProperty("redemptionTime") int redemptionTime) {
this.credential = credential;
public GroupCredential(byte[] credential, int redemptionTime) {
this.credential = credential;
this.redemptionTime = redemptionTime; this.redemptionTime = redemptionTime;
} }
@ -59,19 +44,4 @@ public class GroupCredentials {
return redemptionTime; return redemptionTime;
} }
} }
public static class ByteArraySerializer extends JsonSerializer<byte[]> {
@Override
public void serialize(byte[] bytes, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
jsonGenerator.writeString(Base64.getEncoder().encodeToString(bytes));
}
}
public static class ByteArrayDeserializer extends JsonDeserializer<byte[]> {
@Override
public byte[] deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
return Base64.getDecoder().decode(jsonParser.getValueAsString());
}
}
} }

View File

@ -0,0 +1,43 @@
/*
* Copyright 2021 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import javax.validation.constraints.NotEmpty;
import org.signal.zkgroup.receipts.ReceiptCredentialPresentation;
import org.whispersystems.textsecuregcm.util.ExactlySize;
public class RedeemReceiptRequest {
private final byte[] receiptCredentialPresentation;
private final boolean visible;
private final boolean primary;
@JsonCreator
public RedeemReceiptRequest(
@JsonProperty("receiptCredentialPresentation") byte[] receiptCredentialPresentation,
@JsonProperty("visible") boolean visible,
@JsonProperty("primary") boolean primary) {
this.receiptCredentialPresentation = receiptCredentialPresentation;
this.visible = visible;
this.primary = primary;
}
@NotEmpty
@ExactlySize({ReceiptCredentialPresentation.SIZE})
public byte[] getReceiptCredentialPresentation() {
return receiptCredentialPresentation;
}
public boolean isVisible() {
return visible;
}
public boolean isPrimary() {
return primary;
}
}

View File

@ -353,6 +353,30 @@ public class Account {
purgeStaleBadges(clock); purgeStaleBadges(clock);
} }
public void makeBadgePrimaryIfExists(Clock clock, String badgeId) {
requireNotStale();
// early exit if it's already the first item in the list
if (!badges.isEmpty() && Objects.equals(badges.get(0).getId(), badgeId)) {
purgeStaleBadges(clock);
return;
}
int indexOfBadge = -1;
for (int i = 1; i < badges.size(); i++) {
if (Objects.equals(badgeId, badges.get(i).getId())) {
indexOfBadge = i;
break;
}
}
if (indexOfBadge != -1) {
badges.add(0, badges.remove(indexOfBadge));
}
purgeStaleBadges(clock);
}
public void removeBadge(Clock clock, String id) { public void removeBadge(Clock clock, String id) {
requireNotStale(); requireNotStale();

View File

@ -0,0 +1,99 @@
/*
* Copyright 2021 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.storage;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import javax.annotation.Nonnull;
import org.signal.zkgroup.receipts.ReceiptSerial;
import org.whispersystems.textsecuregcm.util.AttributeValues;
import org.whispersystems.textsecuregcm.util.UUIDUtil;
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
import software.amazon.awssdk.services.dynamodb.model.ReturnValue;
import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest;
public class RedeemedReceiptsManager {
public static final String KEY_SERIAL = "S";
public static final String KEY_TTL = "E";
public static final String KEY_RECEIPT_EXPIRATION = "G";
public static final String KEY_RECEIPT_LEVEL = "L";
public static final String KEY_ACCOUNT_UUID = "U";
public static final String KEY_REDEMPTION_TIME = "R";
private final Clock clock;
private final String table;
private final DynamoDbAsyncClient client;
private final Duration expirationTime;
public RedeemedReceiptsManager(
@Nonnull final Clock clock,
@Nonnull final String table,
@Nonnull final DynamoDbAsyncClient client,
@Nonnull final Duration expirationTime) {
this.clock = Objects.requireNonNull(clock);
this.table = Objects.requireNonNull(table);
this.client = Objects.requireNonNull(client);
this.expirationTime = Objects.requireNonNull(expirationTime);
}
/**
* Returns true either if it's able to insert a new redeemed receipt entry with the {@code receiptExpiration}, {@code
* receiptLevel}, and {@code accountUuid} provided or if an existing entry already exists with the same values thereby
* allowing idempotent request processing.
*/
public CompletableFuture<Boolean> put(
@Nonnull final ReceiptSerial receiptSerial,
final long receiptExpiration,
final long receiptLevel,
@Nonnull final UUID accountUuid) {
// fail early if given bad inputs
Objects.requireNonNull(receiptSerial);
Objects.requireNonNull(accountUuid);
final Instant now = clock.instant();
final Instant rowExpiration = now.plus(expirationTime);
final AttributeValue serialAttributeValue = AttributeValues.b(receiptSerial.serialize());
final UpdateItemRequest updateItemRequest = UpdateItemRequest.builder()
.tableName(table)
.key(Map.of(KEY_SERIAL, serialAttributeValue))
.returnValues(ReturnValue.ALL_NEW)
.updateExpression("SET #ttl = if_not_exists(#ttl, :ttl), "
+ "#receipt_expiration = if_not_exists(#receipt_expiration, :receipt_expiration), "
+ "#receipt_level = if_not_exists(#receipt_level, :receipt_level), "
+ "#account_uuid = if_not_exists(#account_uuid, :account_uuid), "
+ "#redemption_time = if_not_exists(#redemption_time, :redemption_time)")
.expressionAttributeNames(Map.of(
"#ttl", KEY_TTL,
"#receipt_expiration", KEY_RECEIPT_EXPIRATION,
"#receipt_level", KEY_RECEIPT_LEVEL,
"#account_uuid", KEY_ACCOUNT_UUID,
"#redemption_time", KEY_REDEMPTION_TIME))
.expressionAttributeValues(Map.of(
":ttl", AttributeValues.n(rowExpiration.getEpochSecond()),
":receipt_expiration", AttributeValues.n(receiptExpiration),
":receipt_level", AttributeValues.n(receiptLevel),
":account_uuid", AttributeValues.b(accountUuid),
":redemption_time", AttributeValues.n(now.getEpochSecond())))
.build();
return client.updateItem(updateItemRequest).thenApply(updateItemResponse -> {
final Map<String, AttributeValue> attributes = updateItemResponse.attributes();
final long ddbReceiptExpiration = Long.parseLong(attributes.get(KEY_RECEIPT_EXPIRATION).n());
final long ddbReceiptLevel = Long.parseLong(attributes.get(KEY_RECEIPT_LEVEL).n());
final UUID ddbAccountUuid = UUIDUtil.fromByteBuffer(attributes.get(KEY_ACCOUNT_UUID).b().asByteBuffer());
return ddbReceiptExpiration == receiptExpiration && ddbReceiptLevel == receiptLevel &&
Objects.equals(ddbAccountUuid, accountUuid);
});
}
}

View File

@ -15,6 +15,26 @@ import java.util.UUID;
/** AwsAV provides static helper methods for working with AWS AttributeValues. */ /** AwsAV provides static helper methods for working with AWS AttributeValues. */
public class AttributeValues { public class AttributeValues {
// Clear-type methods
public static AttributeValue b(byte[] value) {
return AttributeValue.builder().b(SdkBytes.fromByteArray(value)).build();
}
public static AttributeValue b(ByteBuffer value) {
return AttributeValue.builder().b(SdkBytes.fromByteBuffer(value)).build();
}
public static AttributeValue b(UUID value) {
return b(UUIDUtil.toByteBuffer(value));
}
public static AttributeValue n(long value) {
return AttributeValue.builder().n(String.valueOf(value)).build();
}
// More opinionated methods
public static AttributeValue fromString(String value) { public static AttributeValue fromString(String value) {
return AttributeValue.builder().s(value).build(); return AttributeValue.builder().s(value).build();
} }

View File

@ -2,22 +2,21 @@ package org.whispersystems.textsecuregcm.util;
import org.whispersystems.textsecuregcm.configuration.DynamoDbConfiguration; import org.whispersystems.textsecuregcm.configuration.DynamoDbConfiguration;
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
import software.amazon.awssdk.core.client.config.ClientAsyncConfiguration;
import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration; import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration;
import software.amazon.awssdk.core.client.config.SdkAdvancedAsyncClientOption;
import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient; import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClientBuilder; import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClientBuilder;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient; import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import java.util.concurrent.Executor;
public class DynamoDbFromConfig { public class DynamoDbFromConfig {
private static ClientOverrideConfiguration clientOverrideConfiguration(DynamoDbConfiguration config) { private static ClientOverrideConfiguration clientOverrideConfiguration(DynamoDbConfiguration config) {
return ClientOverrideConfiguration.builder() return ClientOverrideConfiguration.builder()
.apiCallTimeout(config.getClientExecutionTimeout()) .apiCallTimeout(config.getClientExecutionTimeout())
.apiCallAttemptTimeout(config.getClientRequestTimeout()) .apiCallAttemptTimeout(config.getClientRequestTimeout())
.build(); .build();
} }
public static DynamoDbClient client(DynamoDbConfiguration config, AwsCredentialsProvider credentialsProvider) { public static DynamoDbClient client(DynamoDbConfiguration config, AwsCredentialsProvider credentialsProvider) {
return DynamoDbClient.builder() return DynamoDbClient.builder()
.region(Region.of(config.getRegion())) .region(Region.of(config.getRegion()))
@ -25,17 +24,13 @@ public class DynamoDbFromConfig {
.overrideConfiguration(clientOverrideConfiguration(config)) .overrideConfiguration(clientOverrideConfiguration(config))
.build(); .build();
} }
public static DynamoDbAsyncClient asyncClient(DynamoDbConfiguration config, AwsCredentialsProvider credentialsProvider, Executor executor) {
public static DynamoDbAsyncClient asyncClient(
DynamoDbConfiguration config, AwsCredentialsProvider credentialsProvider) {
DynamoDbAsyncClientBuilder builder = DynamoDbAsyncClient.builder() DynamoDbAsyncClientBuilder builder = DynamoDbAsyncClient.builder()
.region(Region.of(config.getRegion())) .region(Region.of(config.getRegion()))
.credentialsProvider(credentialsProvider) .credentialsProvider(credentialsProvider)
.overrideConfiguration(clientOverrideConfiguration(config)); .overrideConfiguration(clientOverrideConfiguration(config));
if (executor != null) {
builder.asyncConfiguration(ClientAsyncConfiguration.builder()
.advancedOption(SdkAdvancedAsyncClientOption.FUTURE_COMPLETION_EXECUTOR,
executor)
.build());
}
return builder.build(); return builder.build();
} }
} }

View File

@ -5,19 +5,24 @@
package org.whispersystems.textsecuregcm.util; package org.whispersystems.textsecuregcm.util;
import javax.validation.Constraint; import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
import javax.validation.Payload; import static java.lang.annotation.ElementType.FIELD;
import java.lang.annotation.Documented; import static java.lang.annotation.ElementType.METHOD;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import static java.lang.annotation.ElementType.*;
import static java.lang.annotation.ElementType.PARAMETER; import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.RetentionPolicy.RUNTIME; import static java.lang.annotation.RetentionPolicy.RUNTIME;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import javax.validation.Constraint;
import javax.validation.Payload;
@Target({ FIELD, METHOD, PARAMETER, ANNOTATION_TYPE }) @Target({ FIELD, METHOD, PARAMETER, ANNOTATION_TYPE })
@Retention(RUNTIME) @Retention(RUNTIME)
@Constraint(validatedBy = ExactlySizeValidator.class) @Constraint(validatedBy = {
ExactlySizeValidatorForString.class,
ExactlySizeValidatorForArraysOfByte.class,
})
@Documented @Documented
public @interface ExactlySize { public @interface ExactlySize {

View File

@ -1,34 +1,29 @@
/* /*
* Copyright 2013-2020 Signal Messenger, LLC * Copyright 2021 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
package org.whispersystems.textsecuregcm.util; package org.whispersystems.textsecuregcm.util;
import java.util.Arrays;
import java.util.Set;
import java.util.stream.Collectors;
import javax.validation.ConstraintValidator; import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext; import javax.validation.ConstraintValidatorContext;
public abstract class ExactlySizeValidator<T> implements ConstraintValidator<ExactlySize, T> {
public class ExactlySizeValidator implements ConstraintValidator<ExactlySize, String> { private Set<Integer> permittedSizes;
private int[] permittedSizes;
@Override @Override
public void initialize(ExactlySize exactlySize) { public void initialize(ExactlySize annotation) {
this.permittedSizes = exactlySize.value(); permittedSizes = Arrays.stream(annotation.value()).boxed().collect(Collectors.toSet());
} }
@Override @Override
public boolean isValid(String object, ConstraintValidatorContext constraintContext) { public boolean isValid(T value, ConstraintValidatorContext context) {
int objectLength; return permittedSizes.contains(size(value));
if (object == null) objectLength = 0;
else objectLength = object.length();
for (int permittedSize : permittedSizes) {
if (permittedSize == objectLength) return true;
}
return false;
} }
protected abstract int size(T value);
} }

View File

@ -0,0 +1,14 @@
/*
* Copyright 2021 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.util;
public class ExactlySizeValidatorForArraysOfByte extends ExactlySizeValidator<byte[]> {
@Override
protected int size(final byte[] value) {
return value == null ? 0 : value.length;
}
}

View File

@ -0,0 +1,15 @@
/*
* Copyright 2013-2020 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.util;
public class ExactlySizeValidatorForString extends ExactlySizeValidator<String> {
@Override
protected int size(final String value) {
return value == null ? 0 : value.length();
}
}

View File

@ -19,6 +19,7 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.ListResourceBundle; import java.util.ListResourceBundle;
import java.util.Locale; import java.util.Locale;
import java.util.Map;
import java.util.ResourceBundle; import java.util.ResourceBundle;
import java.util.ResourceBundle.Control; import java.util.ResourceBundle.Control;
import java.util.stream.Stream; import java.util.stream.Stream;
@ -80,7 +81,7 @@ public class ConfiguredProfileBadgeConverterTest {
return objects; return objects;
} }
}; };
return new BadgesConfiguration(badges, List.of()); return new BadgesConfiguration(badges, List.of(), Map.of());
} }
private BadgeConfiguration getBadge(BadgesConfiguration badgesConfiguration, int i) { private BadgeConfiguration getBadge(BadgesConfiguration badgesConfiguration, int i) {

View File

@ -10,70 +10,151 @@ import static com.github.tomakehurst.wiremock.client.WireMock.post;
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.github.tomakehurst.wiremock.junit.WireMockRule; import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
import com.google.common.collect.ImmutableSet; import com.google.common.collect.ImmutableSet;
import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider;
import io.dropwizard.testing.junit5.ResourceExtension; import io.dropwizard.testing.junit5.ResourceExtension;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor; import java.util.concurrent.Executor;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import javax.ws.rs.client.Entity; import javax.ws.rs.client.Entity;
import javax.ws.rs.core.MediaType; import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;
import org.junit.After; import org.junit.jupiter.api.AfterEach;
import org.junit.Before; import org.junit.jupiter.api.BeforeEach;
import org.junit.Rule; import org.junit.jupiter.api.Test;
import org.junit.Test; import org.junit.jupiter.api.extension.RegisterExtension;
import org.signal.zkgroup.InvalidInputException;
import org.signal.zkgroup.receipts.ReceiptCredentialPresentation;
import org.signal.zkgroup.receipts.ReceiptSerial;
import org.signal.zkgroup.receipts.ServerZkReceiptOperations;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount; import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount;
import org.whispersystems.textsecuregcm.configuration.BadgeConfiguration;
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration; import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;
import org.whispersystems.textsecuregcm.configuration.DonationConfiguration; import org.whispersystems.textsecuregcm.configuration.DonationConfiguration;
import org.whispersystems.textsecuregcm.configuration.RetryConfiguration; import org.whispersystems.textsecuregcm.configuration.RetryConfiguration;
import org.whispersystems.textsecuregcm.controllers.DonationController; import org.whispersystems.textsecuregcm.controllers.DonationController;
import org.whispersystems.textsecuregcm.entities.ApplePayAuthorizationRequest; import org.whispersystems.textsecuregcm.entities.ApplePayAuthorizationRequest;
import org.whispersystems.textsecuregcm.entities.ApplePayAuthorizationResponse; import org.whispersystems.textsecuregcm.entities.ApplePayAuthorizationResponse;
import org.whispersystems.textsecuregcm.entities.RedeemReceiptRequest;
import org.whispersystems.textsecuregcm.storage.AccountBadge;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager;
import org.whispersystems.textsecuregcm.tests.util.AccountsHelper;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper; import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
import org.whispersystems.textsecuregcm.util.SystemMapper;
public class DonationControllerTest { public class DonationControllerTest {
private static final Executor executor = Executors.newSingleThreadExecutor(); private static final Executor httpClientExecutor = Executors.newSingleThreadExecutor();
private static final long nowEpochSeconds = 1_500_000_000L;
@Rule @RegisterExtension
public final WireMockRule wireMockRule = new WireMockRule(wireMockConfig().dynamicPort().dynamicHttpsPort()); static WireMockExtension wm = WireMockExtension.newInstance()
.options(wireMockConfig().dynamicPort().dynamicHttpsPort())
.build();
static SecureRandom secureRandom;
static {
try {
secureRandom = SecureRandom.getInstanceStrong();
} catch (NoSuchAlgorithmException e) {
throw new AssertionError(e);
}
}
private ResourceExtension resources; static DonationConfiguration getDonationConfiguration() {
@Before
public void before() throws Throwable {
DonationConfiguration configuration = new DonationConfiguration(); DonationConfiguration configuration = new DonationConfiguration();
configuration.setApiKey("test-api-key"); configuration.setApiKey("test-api-key");
configuration.setDescription("some description"); configuration.setDescription("some description");
configuration.setUri("http://localhost:" + wireMockRule.port() + "/foo/bar"); configuration.setUri("http://localhost:" + wm.getRuntimeInfo().getHttpPort() + "/foo/bar");
configuration.setCircuitBreaker(new CircuitBreakerConfiguration()); configuration.setCircuitBreaker(new CircuitBreakerConfiguration());
configuration.setRetry(new RetryConfiguration()); configuration.setRetry(new RetryConfiguration());
configuration.setSupportedCurrencies(Set.of("usd", "gbp")); configuration.setSupportedCurrencies(Set.of("usd", "gbp"));
return configuration;
}
static BadgesConfiguration getBadgesConfiguration() {
return new BadgesConfiguration(
List.of(
new BadgeConfiguration("TEST", "other", "l", "m", "h", "x", "xx", "xxx", "s", "S"),
new BadgeConfiguration("TEST1", "testing", "l", "m", "h", "x", "xx", "xxx", "s", "S"),
new BadgeConfiguration("TEST2", "testing", "l", "m", "h", "x", "xx", "xxx", "s", "S"),
new BadgeConfiguration("TEST3", "testing", "l", "m", "h", "x", "xx", "xxx", "s", "S")),
List.of("TEST"),
Map.of(1L, "TEST1", 2L, "TEST2", 3L, "TEST3"));
}
Clock clock;
ServerZkReceiptOperations zkReceiptOperations;
RedeemedReceiptsManager redeemedReceiptsManager;
AccountsManager accountsManager;
byte[] receiptSerialBytes;
ReceiptSerial receiptSerial;
byte[] presentation;
DonationController.ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory;
ReceiptCredentialPresentation receiptCredentialPresentation;
ResourceExtension resources;
@BeforeEach
void beforeEach() throws Throwable {
clock = mock(Clock.class);
zkReceiptOperations = mock(ServerZkReceiptOperations.class);
redeemedReceiptsManager = mock(RedeemedReceiptsManager.class);
accountsManager = mock(AccountsManager.class);
AccountsHelper.setupMockUpdate(accountsManager);
receiptSerialBytes = new byte[ReceiptSerial.SIZE];
secureRandom.nextBytes(receiptSerialBytes);
receiptSerial = new ReceiptSerial(receiptSerialBytes);
presentation = new byte[ReceiptCredentialPresentation.SIZE];
secureRandom.nextBytes(presentation);
receiptCredentialPresentationFactory = mock(DonationController.ReceiptCredentialPresentationFactory.class);
receiptCredentialPresentation = mock(ReceiptCredentialPresentation.class);
when(clock.millis()).thenReturn(nowEpochSeconds * 1000L);
when(clock.instant()).thenReturn(Instant.ofEpochSecond(nowEpochSeconds));
try {
when(receiptCredentialPresentationFactory.build(presentation)).thenReturn(receiptCredentialPresentation);
} catch (InvalidInputException e) {
throw new AssertionError(e);
}
resources = ResourceExtension.builder() resources = ResourceExtension.builder()
.addProvider(AuthHelper.getAuthFilter()) .addProvider(AuthHelper.getAuthFilter())
.addProvider(new PolymorphicAuthValueFactoryProvider.Binder<>( .addProvider(new PolymorphicAuthValueFactoryProvider.Binder<>(
ImmutableSet.of(AuthenticatedAccount.class, DisabledPermittedAuthenticatedAccount.class))) ImmutableSet.of(AuthenticatedAccount.class, DisabledPermittedAuthenticatedAccount.class)))
.setMapper(SystemMapper.getMapper())
.setTestContainerFactory(new GrizzlyWebTestContainerFactory()) .setTestContainerFactory(new GrizzlyWebTestContainerFactory())
.addResource(new DonationController(executor, configuration)) .addResource(new DonationController(clock, zkReceiptOperations, redeemedReceiptsManager, accountsManager,
getBadgesConfiguration(), receiptCredentialPresentationFactory, httpClientExecutor,
getDonationConfiguration()))
.build(); .build();
resources.before(); resources.before();
} }
@After @AfterEach
public void after() throws Throwable { void afterEach() throws Throwable {
resources.after(); resources.after();
} }
@Test @Test
public void testGetApplePayAuthorizationReturns200() { void testGetApplePayAuthorizationReturns200() {
wireMockRule.stubFor(post(urlEqualTo("/foo/bar")) wm.stubFor(post(urlEqualTo("/foo/bar"))
.withBasicAuth("test-api-key", "") .withBasicAuth("test-api-key", "")
.willReturn(aResponse() .willReturn(aResponse()
.withHeader("Content-Type", MediaType.APPLICATION_JSON) .withHeader("Content-Type", MediaType.APPLICATION_JSON)
@ -96,7 +177,7 @@ public class DonationControllerTest {
} }
@Test @Test
public void testGetApplePayAuthorizationWithoutAuthHeaderReturns401() { void testGetApplePayAuthorizationWithoutAuthHeaderReturns401() {
ApplePayAuthorizationRequest request = new ApplePayAuthorizationRequest(); ApplePayAuthorizationRequest request = new ApplePayAuthorizationRequest();
request.setCurrency("usd"); request.setCurrency("usd");
request.setAmount(1000); request.setAmount(1000);
@ -109,7 +190,7 @@ public class DonationControllerTest {
} }
@Test @Test
public void testGetApplePayAuthorizationWithUnsupportedCurrencyReturns422() { void testGetApplePayAuthorizationWithUnsupportedCurrencyReturns422() {
ApplePayAuthorizationRequest request = new ApplePayAuthorizationRequest(); ApplePayAuthorizationRequest request = new ApplePayAuthorizationRequest();
request.setCurrency("zzz"); request.setCurrency("zzz");
request.setAmount(1000); request.setAmount(1000);
@ -121,4 +202,48 @@ public class DonationControllerTest {
assertThat(response.getStatus()).isEqualTo(422); assertThat(response.getStatus()).isEqualTo(422);
} }
@Test
void testRedeemReceipt() {
when(receiptCredentialPresentation.getReceiptSerial()).thenReturn(receiptSerial);
final long receiptLevel = 1L;
when(receiptCredentialPresentation.getReceiptLevel()).thenReturn(receiptLevel);
final long receiptExpiration = nowEpochSeconds + 86400 * 30;
when(receiptCredentialPresentation.getReceiptExpirationTime()).thenReturn(receiptExpiration);
when(redeemedReceiptsManager.put(same(receiptSerial), eq(receiptExpiration), eq(receiptLevel), eq(AuthHelper.VALID_UUID))).thenReturn(
CompletableFuture.completedFuture(Boolean.TRUE));
when(accountsManager.get(eq(AuthHelper.VALID_UUID))).thenReturn(Optional.of(AuthHelper.VALID_ACCOUNT));
RedeemReceiptRequest request = new RedeemReceiptRequest(presentation, true, true);
Response response = resources.getJerseyTest()
.target("/v1/donation/redeem-receipt")
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.post(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE));
assertThat(response.getStatus()).isEqualTo(200);
verify(AuthHelper.VALID_ACCOUNT).addBadge(same(clock), eq(new AccountBadge("TEST1", Instant.ofEpochSecond(receiptExpiration), true)));
verify(AuthHelper.VALID_ACCOUNT).makeBadgePrimaryIfExists(same(clock), eq("TEST1"));
}
@Test
void testRedeemReceiptAlreadyRedeemedWithDifferentParameters() {
when(receiptCredentialPresentation.getReceiptSerial()).thenReturn(receiptSerial);
final long receiptLevel = 1L;
when(receiptCredentialPresentation.getReceiptLevel()).thenReturn(receiptLevel);
final long receiptExpiration = nowEpochSeconds + 86400 * 30;
when(receiptCredentialPresentation.getReceiptExpirationTime()).thenReturn(receiptExpiration);
when(redeemedReceiptsManager.put(same(receiptSerial), eq(receiptExpiration), eq(receiptLevel), eq(AuthHelper.VALID_UUID))).thenReturn(
CompletableFuture.completedFuture(Boolean.FALSE));
RedeemReceiptRequest request = new RedeemReceiptRequest(presentation, true, true);
Response response = resources.getJerseyTest()
.target("/v1/donation/redeem-receipt")
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.post(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE));
assertThat(response.getStatus()).isEqualTo(400);
assertThat(response.readEntity(String.class)).isEqualTo("receipt serial is already redeemed");
}
} }

View File

@ -26,6 +26,7 @@ import java.time.Clock;
import java.time.Instant; import java.time.Instant;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import javax.ws.rs.client.Entity; import javax.ws.rs.client.Entity;
@ -115,7 +116,7 @@ class ProfileControllerTest {
new BadgeConfiguration("TEST1", "testing", "l", "m", "h", "x", "xx", "xxx", "s", "S"), new BadgeConfiguration("TEST1", "testing", "l", "m", "h", "x", "xx", "xxx", "s", "S"),
new BadgeConfiguration("TEST2", "testing", "l", "m", "h", "x", "xx", "xxx", "s", "S"), new BadgeConfiguration("TEST2", "testing", "l", "m", "h", "x", "xx", "xxx", "s", "S"),
new BadgeConfiguration("TEST3", "testing", "l", "m", "h", "x", "xx", "xxx", "s", "S") new BadgeConfiguration("TEST3", "testing", "l", "m", "h", "x", "xx", "xxx", "s", "S")
), List.of("TEST1")), ), List.of("TEST1"), Map.of(1L, "TEST1", 2L, "TEST2", 3L, "TEST3")),
s3client, s3client,
postPolicyGenerator, postPolicyGenerator,
policySigner, policySigner,

View File

@ -0,0 +1,91 @@
/*
* Copyright 2021 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.tests.storage;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.signal.zkgroup.InvalidInputException;
import org.signal.zkgroup.receipts.ReceiptSerial;
import org.whispersystems.textsecuregcm.storage.DynamoDbExtension;
import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;
import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType;
class RedeemedReceiptsManagerTest {
private static final long NOW_EPOCH_SECONDS = 1_500_000_000L;
private static final String REDEEMED_RECEIPTS_TABLE_NAME = "redeemed_receipts";
private static final SecureRandom SECURE_RANDOM;
static {
try {
SECURE_RANDOM = SecureRandom.getInstanceStrong();
} catch (NoSuchAlgorithmException e) {
throw new AssertionError(e);
}
}
@RegisterExtension
static DynamoDbExtension dynamoDbExtension = DynamoDbExtension.builder()
.tableName(REDEEMED_RECEIPTS_TABLE_NAME)
.hashKey(RedeemedReceiptsManager.KEY_SERIAL)
.attributeDefinition(AttributeDefinition.builder()
.attributeName(RedeemedReceiptsManager.KEY_SERIAL)
.attributeType(ScalarAttributeType.B)
.build())
.build();
Clock clock;
ReceiptSerial receiptSerial;
RedeemedReceiptsManager redeemedReceiptsManager;
@BeforeEach
void beforeEach() throws InvalidInputException {
clock = mock(Clock.class);
when(clock.millis()).thenReturn(NOW_EPOCH_SECONDS * 1000L);
when(clock.instant()).thenReturn(Instant.ofEpochSecond(NOW_EPOCH_SECONDS));
byte[] receiptSerialBytes = new byte[ReceiptSerial.SIZE];
SECURE_RANDOM.nextBytes(receiptSerialBytes);
receiptSerial = new ReceiptSerial(receiptSerialBytes);
redeemedReceiptsManager = new RedeemedReceiptsManager(
clock, REDEEMED_RECEIPTS_TABLE_NAME, dynamoDbExtension.getDynamoDbAsyncClient(), Duration.ofDays(90));
}
@Test
void testPut() throws ExecutionException, InterruptedException {
final long receiptExpiration = 42;
final long receiptLevel = 3;
CompletableFuture<Boolean> put;
// initial insert should return true
put = redeemedReceiptsManager.put(receiptSerial, receiptExpiration, receiptLevel, AuthHelper.VALID_UUID);
assertThat(put.get()).isTrue();
// subsequent attempted inserts with modified parameters should return false
put = redeemedReceiptsManager.put(receiptSerial, receiptExpiration + 1, receiptLevel, AuthHelper.VALID_UUID);
assertThat(put.get()).isFalse();
put = redeemedReceiptsManager.put(receiptSerial, receiptExpiration, receiptLevel + 1, AuthHelper.VALID_UUID);
assertThat(put.get()).isFalse();
put = redeemedReceiptsManager.put(receiptSerial, receiptExpiration, receiptLevel, AuthHelper.VALID_UUID_TWO);
assertThat(put.get()).isFalse();
// repeated insert attempt of the original parameters should return true
put = redeemedReceiptsManager.put(receiptSerial, receiptExpiration, receiptLevel, AuthHelper.VALID_UUID);
assertThat(put.get()).isTrue();
}
}

View File

@ -0,0 +1 @@
mock-maker-inline