diff --git a/pom.xml b/pom.xml index 5a0ba7640..26dc9db32 100644 --- a/pom.xml +++ b/pom.xml @@ -235,6 +235,12 @@ commons-logging 1.2 + + org.ow2.asm + asm + 9.2 + test + @@ -248,7 +254,7 @@ com.github.tomakehurst wiremock-jre8 - 2.28.1 + 2.31.0 test diff --git a/service/config/sample.yml b/service/config/sample.yml index c41fbc7ce..5fe26335b 100644 --- a/service/config/sample.yml +++ b/service/config/sample.yml @@ -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 diff --git a/service/pom.xml b/service/pom.xml index ea53fb4c9..6ffa1ca23 100644 --- a/service/pom.xml +++ b/service/pom.xml @@ -55,7 +55,7 @@ org.signal zkgroup-java - 0.8.1 + 0.8.2 org.whispersystems diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java index 79c87e89f..9ae7a5dec 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java @@ -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; } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 4c25c5bd0..5d62486f5 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -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 accountAuthFilter = new BasicCredentialAuthFilter.Builder().setAuthenticator( accountAuthenticator).buildAuthFilter(); @@ -587,7 +600,8 @@ public class WhisperServerService extends Application badges; private final List badgeIdsEnabledForAll; + private final Map receiptLevels; @JsonCreator public BadgesConfiguration( @JsonProperty("badges") @JsonSetter(nulls = Nulls.AS_EMPTY) final List badges, - @JsonProperty("badgeIdsEnabledForAll") @JsonSetter(nulls = Nulls.AS_EMPTY) final List badgeIdsEnabledForAll) { + @JsonProperty("badgeIdsEnabledForAll") @JsonSetter(nulls = Nulls.AS_EMPTY) final List badgeIdsEnabledForAll, + @JsonProperty("receiptLevels") @JsonSetter(nulls = Nulls.AS_EMPTY) final Map 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 getBadgeIdsEnabledForAll() { return badgeIdsEnabledForAll; } + + @Valid + @NotNull + public Map getReceiptLevels() { + return receiptLevels; + } + + @JsonIgnore + @ValidationMethod(message = "contains receipt level mappings that are not configured badges") + public boolean isAllReceiptLevelsConfigured() { + final Set badgeNames = badges.stream().map(BadgeConfiguration::getId).collect(Collectors.toSet()); + return badgeNames.containsAll(receiptLevels.values()); + } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RedeemedReceiptsDynamoDbConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RedeemedReceiptsDynamoDbConfiguration.java new file mode 100644 index 000000000..972297ff7 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RedeemedReceiptsDynamoDbConfiguration.java @@ -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; + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DonationController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DonationController.java index 874c99413..e8f5334f3 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DonationController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DonationController.java @@ -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 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 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 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 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") diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/DeliveryCertificate.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/DeliveryCertificate.java index 3696dabe1..49dd46503 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/DeliveryCertificate.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/DeliveryCertificate.java @@ -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 { - @Override - public void serialize(byte[] bytes, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException { - jsonGenerator.writeString(Base64.getEncoder().encodeToString(bytes)); - } - } - - public static class ByteArrayDeserializer extends JsonDeserializer { - @Override - public byte[] deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException { - return Base64.getDecoder().decode(jsonParser.getValueAsString()); - } - } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/GroupCredentials.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/GroupCredentials.java index b000976bc..0bb71bece 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/GroupCredentials.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/GroupCredentials.java @@ -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 credentials; + private final List credentials; - public GroupCredentials() {} - - public GroupCredentials(List credentials) { + @JsonCreator + public GroupCredentials( + @JsonProperty("credentials") List 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 { - @Override - public void serialize(byte[] bytes, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException { - jsonGenerator.writeString(Base64.getEncoder().encodeToString(bytes)); - } - } - - public static class ByteArrayDeserializer extends JsonDeserializer { - @Override - public byte[] deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException { - return Base64.getDecoder().decode(jsonParser.getValueAsString()); - } - } - } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/RedeemReceiptRequest.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/RedeemReceiptRequest.java new file mode 100644 index 000000000..ab72a31ab --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/RedeemReceiptRequest.java @@ -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; + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/Account.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/Account.java index bc82af8a2..64dba40a6 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/Account.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/Account.java @@ -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(); diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/RedeemedReceiptsManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/RedeemedReceiptsManager.java new file mode 100644 index 000000000..e58ff0337 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/RedeemedReceiptsManager.java @@ -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 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 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); + }); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/AttributeValues.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/AttributeValues.java index 20464bd7c..de77e859a 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/AttributeValues.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/util/AttributeValues.java @@ -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(); } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/DynamoDbFromConfig.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/DynamoDbFromConfig.java index 738252b5c..1e30d05dc 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/DynamoDbFromConfig.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/util/DynamoDbFromConfig.java @@ -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(); } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySize.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySize.java index 21f93325d..6c0145839 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySize.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySize.java @@ -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 { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySizeValidator.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySizeValidator.java index 8a828c07e..a66cf939f 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySizeValidator.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySizeValidator.java @@ -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 implements ConstraintValidator { -public class ExactlySizeValidator implements ConstraintValidator { - - private int[] permittedSizes; + private Set 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); } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySizeValidatorForArraysOfByte.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySizeValidatorForArraysOfByte.java new file mode 100644 index 000000000..358eaf65a --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySizeValidatorForArraysOfByte.java @@ -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 { + + @Override + protected int size(final byte[] value) { + return value == null ? 0 : value.length; + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySizeValidatorForString.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySizeValidatorForString.java new file mode 100644 index 000000000..9d77641cd --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySizeValidatorForString.java @@ -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 { + + @Override + protected int size(final String value) { + return value == null ? 0 : value.length(); + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/badges/ConfiguredProfileBadgeConverterTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/badges/ConfiguredProfileBadgeConverterTest.java index 201da05a6..f3df7c867 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/badges/ConfiguredProfileBadgeConverterTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/badges/ConfiguredProfileBadgeConverterTest.java @@ -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) { diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/DonationControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/DonationControllerTest.java index 0f0fd3ed9..bf77103ce 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/DonationControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/DonationControllerTest.java @@ -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"); + } } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/ProfileControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/ProfileControllerTest.java index 7f5cf084c..7f98df85d 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/ProfileControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/ProfileControllerTest.java @@ -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, diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/RedeemedReceiptsManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/RedeemedReceiptsManagerTest.java new file mode 100644 index 000000000..cb66307d7 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/RedeemedReceiptsManagerTest.java @@ -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 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(); + } +} diff --git a/service/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/service/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 000000000..1f0955d45 --- /dev/null +++ b/service/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline