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>
<version>1.2</version>
</dependency>
<dependency>
<groupId>org.ow2.asm</groupId>
<artifactId>asm</artifactId>
<version>9.2</version>
<scope>test</scope>
</dependency>
</dependencies>
</dependencyManagement>
@ -248,7 +254,7 @@
<dependency>
<groupId>com.github.tomakehurst</groupId>
<artifactId>wiremock-jre8</artifactId>
<version>2.28.1</version>
<version>2.31.0</version>
<scope>test</scope>
<exclusions>
<exclusion>

View File

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

View File

@ -55,7 +55,7 @@
<dependency>
<groupId>org.signal</groupId>
<artifactId>zkgroup-java</artifactId>
<version>0.8.1</version>
<version>0.8.2</version>
</dependency>
<dependency>
<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.RecaptchaConfiguration;
import org.whispersystems.textsecuregcm.configuration.RecaptchaV2Configuration;
import org.whispersystems.textsecuregcm.configuration.RedeemedReceiptsDynamoDbConfiguration;
import org.whispersystems.textsecuregcm.configuration.RedisClusterConfiguration;
import org.whispersystems.textsecuregcm.configuration.RedisConfiguration;
import org.whispersystems.textsecuregcm.configuration.RemoteConfigConfiguration;
@ -154,6 +155,11 @@ public class WhisperServerConfiguration extends Configuration {
@JsonProperty
private DynamoDbConfiguration deletedAccountsLockDynamoDb;
@Valid
@NotNull
@JsonProperty
private RedeemedReceiptsDynamoDbConfiguration redeemedReceiptsDynamoDb;
@Valid
@NotNull
@JsonProperty
@ -392,6 +398,10 @@ public class WhisperServerConfiguration extends Configuration {
return deletedAccountsLockDynamoDb;
}
public RedeemedReceiptsDynamoDbConfiguration getRedeemedReceiptsDynamoDbConfiguration() {
return redeemedReceiptsDynamoDb;
}
public DatabaseConfiguration getAbuseDatabaseConfiguration() {
return abuseDatabase;
}

View File

@ -56,6 +56,8 @@ import org.jdbi.v3.core.Jdbi;
import org.signal.zkgroup.ServerSecretParams;
import org.signal.zkgroup.auth.ServerZkAuthOperations;
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.LoggerFactory;
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.PushChallengeDynamoDb;
import org.whispersystems.textsecuregcm.storage.PushFeedbackProcessor;
import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager;
import org.whispersystems.textsecuregcm.storage.RemoteConfigs;
import org.whispersystems.textsecuregcm.storage.RemoteConfigsManager;
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.StaticCredentialsProvider;
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.s3.S3Client;
@ -286,8 +290,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
environment.getObjectMapper().setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE);
environment.getObjectMapper().setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY);
ProfileBadgeConverter profileBadgeConverter = new ConfiguredProfileBadgeConverter(
Clock.systemUTC(), config.getBadges());
ProfileBadgeConverter profileBadgeConverter = new ConfiguredProfileBadgeConverter(clock, config.getBadges());
JdbiFactory jdbiFactory = new JdbiFactory(DefaultNameStrategy.CHECK_EMPTY);
Jdbi accountJdbi = jdbiFactory.build(environment, config.getAccountsDatabaseConfiguration(), "accountdb");
@ -333,6 +336,10 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
.withCredentials(InstanceProfileCredentialsProvider.getInstance())
.build();
DynamoDbAsyncClient redeemedReceiptsDynamoDbClient = DynamoDbFromConfig.asyncClient(
config.getRedeemedReceiptsDynamoDbConfiguration(),
software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider.create());
DeletedAccounts deletedAccounts = new DeletedAccounts(deletedAccountsDynamoDbClient,
config.getDeletedAccountsDynamoDbConfiguration().getTableName(),
config.getDeletedAccountsDynamoDbConfiguration().getNeedsReconciliationIndexName());
@ -437,6 +444,11 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
ProvisioningManager provisioningManager = new ProvisioningManager(pubSubManager);
TorExitNodeManager torExitNodeManager = new TorExitNodeManager(recurringJobExecutor, config.getTorExitNodeListConfiguration());
AsnManager asnManager = new AsnManager(recurringJobExecutor, config.getAsnTableConfiguration());
RedeemedReceiptsManager redeemedReceiptsManager = new RedeemedReceiptsManager(
clock,
config.getRedeemedReceiptsDynamoDbConfiguration().getTableName(),
redeemedReceiptsDynamoDbClient,
config.getRedeemedReceiptsDynamoDbConfiguration().getExpirationTime());
AccountAuthenticator accountAuthenticator = new AccountAuthenticator(accountsManager);
DisabledPermittedAccountAuthenticator disabledPermittedAccountAuthenticator = new DisabledPermittedAccountAuthenticator(accountsManager);
@ -535,6 +547,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
ServerSecretParams zkSecretParams = new ServerSecretParams(config.getZkConfig().getServerSecret());
ServerZkProfileOperations zkProfileOperations = new ServerZkProfileOperations(zkSecretParams);
ServerZkAuthOperations zkAuthOperations = new ServerZkAuthOperations(zkSecretParams);
ServerZkReceiptOperations zkReceiptOperations = new ServerZkReceiptOperations(zkSecretParams);
AuthFilter<BasicCredentials, AuthenticatedAccount> accountAuthFilter = new BasicCredentialAuthFilter.Builder<AuthenticatedAccount>().setAuthenticator(
accountAuthenticator).buildAuthFilter();
@ -587,7 +600,8 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
new ChallengeController(rateLimitChallengeManager),
new DeviceController(pendingDevicesManager, accountsManager, messagesManager, keysDynamoDb, rateLimiters, config.getMaxDevices()),
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 PaymentsController(currencyManager, paymentsCredentialsGenerator),
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;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonSetter;
import com.fasterxml.jackson.annotation.Nulls;
import io.dropwizard.validation.ValidationMethod;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;
public class BadgesConfiguration {
private final List<BadgeConfiguration> badges;
private final List<String> badgeIdsEnabledForAll;
private final Map<Long, String> receiptLevels;
@JsonCreator
public BadgesConfiguration(
@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.badgeIdsEnabledForAll = Objects.requireNonNull(badgeIdsEnabledForAll);
this.receiptLevels = Objects.requireNonNull(receiptLevels);
}
@Valid
@ -37,4 +45,17 @@ public class BadgesConfiguration {
public List<String> getBadgeIdsEnabledForAll() {
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.BodyHandlers;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
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.ws.rs.Consumes;
import javax.ws.rs.POST;
@ -32,28 +41,64 @@ import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
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.LoggerFactory;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
import org.whispersystems.textsecuregcm.configuration.DonationConfiguration;
import org.whispersystems.textsecuregcm.entities.ApplePayAuthorizationRequest;
import org.whispersystems.textsecuregcm.entities.ApplePayAuthorizationResponse;
import org.whispersystems.textsecuregcm.entities.RedeemReceiptRequest;
import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;
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;
@Path("/v1/donation")
public class DonationController {
public interface ReceiptCredentialPresentationFactory {
ReceiptCredentialPresentation build(byte[] bytes) throws InvalidInputException;
}
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 String apiKey;
private final String description;
private final Set<String> supportedCurrencies;
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.apiKey = configuration.getApiKey();
this.description = configuration.getDescription();
@ -64,12 +109,82 @@ public class DonationController {
.withVersion(Version.HTTP_2)
.withConnectTimeout(Duration.ofSeconds(10))
.withRedirect(Redirect.NEVER)
.withExecutor(executor)
.withExecutor(Objects.requireNonNull(httpClientExecutor))
.withName("donation")
.withSecurityProtocol(FaultTolerantHttpClient.SECURITY_PROTOCOL_TLS_1_3)
.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
@POST
@Path("/authorize-apple-pay")

View File

@ -5,48 +5,20 @@
package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonCreator;
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 {
@JsonProperty
@JsonSerialize(using = ByteArraySerializer.class)
@JsonDeserialize(using = ByteArrayDeserializer.class)
private byte[] certificate;
private final byte[] certificate;
public DeliveryCertificate(byte[] certificate) {
@JsonCreator
public DeliveryCertificate(
@JsonProperty("certificate") byte[] certificate) {
this.certificate = certificate;
}
public DeliveryCertificate() {}
@VisibleForTesting
public byte[] getCertificate() {
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
*/
package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonCreator;
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;
public class GroupCredentials {
@JsonProperty
private List<GroupCredential> credentials;
private final List<GroupCredential> credentials;
public GroupCredentials() {}
public GroupCredentials(List<GroupCredential> credentials) {
@JsonCreator
public GroupCredentials(
@JsonProperty("credentials") List<GroupCredential> credentials) {
this.credentials = credentials;
}
@ -36,18 +25,14 @@ public class GroupCredentials {
public static class GroupCredential {
@JsonProperty
@JsonSerialize(using = ByteArraySerializer.class)
@JsonDeserialize(using = ByteArrayDeserializer.class)
private byte[] credential;
private final byte[] credential;
private final int redemptionTime;
@JsonProperty
private int redemptionTime;
public GroupCredential() {}
public GroupCredential(byte[] credential, int redemptionTime) {
this.credential = credential;
@JsonCreator
public GroupCredential(
@JsonProperty("credential") byte[] credential,
@JsonProperty("redemptionTime") int redemptionTime) {
this.credential = credential;
this.redemptionTime = redemptionTime;
}
@ -59,19 +44,4 @@ public class GroupCredentials {
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);
}
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) {
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. */
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) {
return AttributeValue.builder().s(value).build();
}

View File

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

View File

@ -5,19 +5,24 @@
package org.whispersystems.textsecuregcm.util;
import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import static java.lang.annotation.ElementType.*;
import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import javax.validation.Constraint;
import javax.validation.Payload;
@Target({ FIELD, METHOD, PARAMETER, ANNOTATION_TYPE })
@Retention(RUNTIME)
@Constraint(validatedBy = ExactlySizeValidator.class)
@Constraint(validatedBy = {
ExactlySizeValidatorForString.class,
ExactlySizeValidatorForArraysOfByte.class,
})
@Documented
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
*/
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.ConstraintValidatorContext;
public abstract class ExactlySizeValidator<T> implements ConstraintValidator<ExactlySize, T> {
public class ExactlySizeValidator implements ConstraintValidator<ExactlySize, String> {
private int[] permittedSizes;
private Set<Integer> permittedSizes;
@Override
public void initialize(ExactlySize exactlySize) {
this.permittedSizes = exactlySize.value();
public void initialize(ExactlySize annotation) {
permittedSizes = Arrays.stream(annotation.value()).boxed().collect(Collectors.toSet());
}
@Override
public boolean isValid(String object, ConstraintValidatorContext constraintContext) {
int objectLength;
if (object == null) objectLength = 0;
else objectLength = object.length();
for (int permittedSize : permittedSizes) {
if (permittedSize == objectLength) return true;
}
return false;
public boolean isValid(T value, ConstraintValidatorContext context) {
return permittedSizes.contains(size(value));
}
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.ListResourceBundle;
import java.util.Locale;
import java.util.Map;
import java.util.ResourceBundle;
import java.util.ResourceBundle.Control;
import java.util.stream.Stream;
@ -80,7 +81,7 @@ public class ConfiguredProfileBadgeConverterTest {
return objects;
}
};
return new BadgesConfiguration(badges, List.of());
return new BadgesConfiguration(badges, List.of(), Map.of());
}
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.core.WireMockConfiguration.wireMockConfig;
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 io.dropwizard.auth.PolymorphicAuthValueFactoryProvider;
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.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import javax.ws.rs.client.Entity;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;
import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.jupiter.api.AfterEach;
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.ReceiptCredentialPresentation;
import org.signal.zkgroup.receipts.ReceiptSerial;
import org.signal.zkgroup.receipts.ServerZkReceiptOperations;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
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.DonationConfiguration;
import org.whispersystems.textsecuregcm.configuration.RetryConfiguration;
import org.whispersystems.textsecuregcm.controllers.DonationController;
import org.whispersystems.textsecuregcm.entities.ApplePayAuthorizationRequest;
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.util.SystemMapper;
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
public final WireMockRule wireMockRule = new WireMockRule(wireMockConfig().dynamicPort().dynamicHttpsPort());
@RegisterExtension
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;
@Before
public void before() throws Throwable {
static DonationConfiguration getDonationConfiguration() {
DonationConfiguration configuration = new DonationConfiguration();
configuration.setApiKey("test-api-key");
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.setRetry(new RetryConfiguration());
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()
.addProvider(AuthHelper.getAuthFilter())
.addProvider(new PolymorphicAuthValueFactoryProvider.Binder<>(
ImmutableSet.of(AuthenticatedAccount.class, DisabledPermittedAuthenticatedAccount.class)))
.setMapper(SystemMapper.getMapper())
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
.addResource(new DonationController(executor, configuration))
.addResource(new DonationController(clock, zkReceiptOperations, redeemedReceiptsManager, accountsManager,
getBadgesConfiguration(), receiptCredentialPresentationFactory, httpClientExecutor,
getDonationConfiguration()))
.build();
resources.before();
}
@After
public void after() throws Throwable {
@AfterEach
void afterEach() throws Throwable {
resources.after();
}
@Test
public void testGetApplePayAuthorizationReturns200() {
wireMockRule.stubFor(post(urlEqualTo("/foo/bar"))
void testGetApplePayAuthorizationReturns200() {
wm.stubFor(post(urlEqualTo("/foo/bar"))
.withBasicAuth("test-api-key", "")
.willReturn(aResponse()
.withHeader("Content-Type", MediaType.APPLICATION_JSON)
@ -96,7 +177,7 @@ public class DonationControllerTest {
}
@Test
public void testGetApplePayAuthorizationWithoutAuthHeaderReturns401() {
void testGetApplePayAuthorizationWithoutAuthHeaderReturns401() {
ApplePayAuthorizationRequest request = new ApplePayAuthorizationRequest();
request.setCurrency("usd");
request.setAmount(1000);
@ -109,7 +190,7 @@ public class DonationControllerTest {
}
@Test
public void testGetApplePayAuthorizationWithUnsupportedCurrencyReturns422() {
void testGetApplePayAuthorizationWithUnsupportedCurrencyReturns422() {
ApplePayAuthorizationRequest request = new ApplePayAuthorizationRequest();
request.setCurrency("zzz");
request.setAmount(1000);
@ -121,4 +202,48 @@ public class DonationControllerTest {
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.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
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("TEST2", "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,
postPolicyGenerator,
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