Unify TTL handling of redeemed receipts

This commit is contained in:
Ameya Lokare
2026-08-12 14:49:38 -07:00
parent ae04fae1b9
commit a8a4a70a68
21 changed files with 154 additions and 82 deletions
-1
View File
@@ -138,7 +138,6 @@ dynamoDbTables:
tableName: Example_PushNotificationExperimentSamples
redeemedReceipts:
tableName: Example_RedeemedReceipts
expiration: P30D # Duration of time until rows expire
registrationRecovery:
tableName: Example_RegistrationRecovery
expiration: P300D # Duration of time until rows expire
@@ -545,8 +545,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
RedeemedReceiptsManager redeemedReceiptsManager = new RedeemedReceiptsManager(clock,
config.getDynamoDbTables().getRedeemedReceipts().getTableName(),
dynamoDbClient,
config.getDynamoDbTables().getRedeemedReceipts().getExpiration());
dynamoDbClient);
Accounts accounts = new Accounts(
clock,
@@ -266,7 +266,7 @@ public class BackupAuthManager {
}
boolean receiptAllowed = redeemedReceiptsManager
.put(receiptSerial, receiptExpiration.getEpochSecond(), receiptLevel, account.getAccountIdentifier());
.put(receiptSerial, receiptExpiration, receiptLevel, account.getAccountIdentifier());
if (!receiptAllowed) {
throw new BackupBadReceiptException("receipt serial is already redeemed");
}
@@ -69,7 +69,7 @@ public class DynamoDbTables {
private final Table profilesV2;
private final Table pushChallenge;
private final Table pushNotificationExperimentSamples;
private final TableWithExpiration redeemedReceipts;
private final Table redeemedReceipts;
private final TableWithExpiration registrationRecovery;
private final Table remoteConfig;
private final Table reportMessage;
@@ -100,7 +100,7 @@ public class DynamoDbTables {
@JsonProperty("profilesV2") final Table profilesV2,
@JsonProperty("pushChallenge") final Table pushChallenge,
@JsonProperty("pushNotificationExperimentSamples") final Table pushNotificationExperimentSamples,
@JsonProperty("redeemedReceipts") final TableWithExpiration redeemedReceipts,
@JsonProperty("redeemedReceipts") final Table redeemedReceipts,
@JsonProperty("registrationRecovery") final TableWithExpiration registrationRecovery,
@JsonProperty("remoteConfig") final Table remoteConfig,
@JsonProperty("reportMessage") final Table reportMessage,
@@ -273,7 +273,7 @@ public class DynamoDbTables {
@NotNull
@Valid
public TableWithExpiration getRedeemedReceipts() {
public Table getRedeemedReceipts() {
return redeemedReceipts;
}
@@ -130,7 +130,7 @@ public class DonationController {
}
final boolean receiptMatched = redeemedReceiptsManager.put(
receiptSerial, receiptExpiration.getEpochSecond(), receiptLevel, auth.accountIdentifier());
receiptSerial, receiptExpiration, receiptLevel, auth.accountIdentifier());
if (!receiptMatched) {
return Response.status(Status.BAD_REQUEST)
.entity("receipt serial is already redeemed")
@@ -84,7 +84,7 @@ public class DonationsGrpcService extends SimpleDonationsGrpc.DonationsImplBase
throw GrpcExceptions.unavailable("server does not recognize the requested receipt level");
}
final boolean receiptMatched = redeemedReceiptsManager.put(
receiptSerial, receiptExpiration.getEpochSecond(), receiptLevel, authenticatedDevice.accountIdentifier());
receiptSerial, receiptExpiration, receiptLevel, authenticatedDevice.accountIdentifier());
if (!receiptMatched) {
return RedeemReceiptResponse.newBuilder()
.setAlreadyRedeemed(FailedPrecondition.newBuilder()
@@ -334,7 +334,7 @@ public class Accounts {
if (conditionalCheckFailed(receiptSerialConstraintCancellationReason)) {
final UUID existingAccountUuid = UUIDUtil.fromByteBuffer(receiptSerialConstraintCancellationReason.item()
.get(RedeemedReceiptsManager.KEY_ACCOUNT_UUID).b().asByteBuffer());
.get(RedeemedReceiptsManager.ATTR_ACCOUNT_UUID).b().asByteBuffer());
final Account existingAccount = getByAccountIdentifier(existingAccountUuid)
// The account was already deleted, and we don't allow re-registering with the same receipt
.orElseThrow(ReceiptAlreadyRedeemedException::new);
@@ -12,6 +12,7 @@ import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import javax.annotation.Nonnull;
import com.google.common.annotations.VisibleForTesting;
import org.signal.libsignal.zkgroup.receipts.ReceiptSerial;
import org.whispersystems.textsecuregcm.util.AttributeValues;
import org.whispersystems.textsecuregcm.util.UUIDUtil;
@@ -27,26 +28,26 @@ import software.amazon.awssdk.services.dynamodb.model.UpdateItemResponse;
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";
public static final String ATTR_TTL = "E";
public static final String ATTR_RECEIPT_EXPIRATION = "G";
public static final String ATTR_RECEIPT_LEVEL = "L";
public static final String ATTR_ACCOUNT_UUID = "U";
public static final String ATTR_REDEMPTION_TIME = "R";
private final Clock clock;
private final String table;
private final DynamoDbClient client;
private final Duration expirationTime;
@VisibleForTesting
static final Duration TTL_PADDING = Duration.ofDays(30);
public RedeemedReceiptsManager(
@Nonnull final Clock clock,
@Nonnull final String table,
@Nonnull final DynamoDbClient client,
@Nonnull final Duration expirationTime) {
@Nonnull final DynamoDbClient client) {
this.clock = Objects.requireNonNull(clock);
this.table = Objects.requireNonNull(table);
this.client = Objects.requireNonNull(client);
this.expirationTime = Objects.requireNonNull(expirationTime);
}
/**
@@ -56,7 +57,7 @@ public class RedeemedReceiptsManager {
*/
public boolean put(
@Nonnull final ReceiptSerial receiptSerial,
final long receiptExpiration,
final Instant receiptExpiration,
final long receiptLevel,
@Nonnull final UUID accountUuid) {
@@ -65,7 +66,6 @@ public class RedeemedReceiptsManager {
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()
@@ -78,14 +78,14 @@ public class RedeemedReceiptsManager {
+ "#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))
"#ttl", ATTR_TTL,
"#receipt_expiration", ATTR_RECEIPT_EXPIRATION,
"#receipt_level", ATTR_RECEIPT_LEVEL,
"#account_uuid", ATTR_ACCOUNT_UUID,
"#redemption_time", ATTR_REDEMPTION_TIME))
.expressionAttributeValues(Map.of(
":ttl", AttributeValues.n(rowExpiration.getEpochSecond()),
":receipt_expiration", AttributeValues.n(receiptExpiration),
":ttl", rowTtl(receiptExpiration),
":receipt_expiration", AttributeValues.n(receiptExpiration.getEpochSecond()),
":receipt_level", AttributeValues.n(receiptLevel),
":account_uuid", AttributeValues.b(accountUuid),
":redemption_time", AttributeValues.n(now.getEpochSecond())))
@@ -93,10 +93,10 @@ public class RedeemedReceiptsManager {
final UpdateItemResponse updateItemResponse = client.updateItem(updateItemRequest);
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 &&
final long ddbReceiptExpiration = Long.parseLong(attributes.get(ATTR_RECEIPT_EXPIRATION).n());
final long ddbReceiptLevel = Long.parseLong(attributes.get(ATTR_RECEIPT_LEVEL).n());
final UUID ddbAccountUuid = UUIDUtil.fromByteBuffer(attributes.get(ATTR_ACCOUNT_UUID).b().asByteBuffer());
return ddbReceiptExpiration == receiptExpiration.getEpochSecond() && ddbReceiptLevel == receiptLevel &&
Objects.equals(ddbAccountUuid, accountUuid);
}
@@ -119,18 +119,16 @@ public class RedeemedReceiptsManager {
.tableName(table)
.item(Map.of(
KEY_SERIAL, AttributeValues.b(receiptSerial.serialize()),
// NOTE: this differs from the expiration for other receipt types (see RedeemedReceiptsManager#put) which
// sets the expiration to now + a static expiration time. The TTL handling needs to be unified in the future.
KEY_TTL, AttributeValues.n(receiptExpiration.getEpochSecond()),
KEY_RECEIPT_EXPIRATION, AttributeValues.n(receiptExpiration.getEpochSecond()),
KEY_RECEIPT_LEVEL, AttributeValues.n(receiptLevel),
KEY_ACCOUNT_UUID, AttributeValues.b(accountUuid),
KEY_REDEMPTION_TIME, AttributeValues.n(clock.instant().getEpochSecond())))
ATTR_TTL, rowTtl(receiptExpiration),
ATTR_RECEIPT_EXPIRATION, AttributeValues.n(receiptExpiration.getEpochSecond()),
ATTR_RECEIPT_LEVEL, AttributeValues.n(receiptLevel),
ATTR_ACCOUNT_UUID, AttributeValues.b(accountUuid),
ATTR_REDEMPTION_TIME, AttributeValues.n(clock.instant().getEpochSecond())))
.conditionExpression("attribute_not_exists(#serial) OR (#account_uuid = :account_uuid AND #receipt_level = :receipt_level)")
.expressionAttributeNames(Map.of(
"#serial", KEY_SERIAL,
"#account_uuid", KEY_ACCOUNT_UUID,
"#receipt_level", KEY_RECEIPT_LEVEL))
"#account_uuid", ATTR_ACCOUNT_UUID,
"#receipt_level", ATTR_RECEIPT_LEVEL))
.expressionAttributeValues(Map.of(
":account_uuid", AttributeValues.b(accountUuid),
":receipt_level", AttributeValues.n(receiptLevel)
@@ -139,4 +137,8 @@ public class RedeemedReceiptsManager {
.build())
.build();
}
private static AttributeValue rowTtl(final Instant receiptExpiration) {
return AttributeValues.n(receiptExpiration.plus(TTL_PADDING).getEpochSecond());
}
}
@@ -263,8 +263,7 @@ public record CommandDependencies(
RedeemedReceiptsManager redeemedReceiptsManager = new RedeemedReceiptsManager(clock,
configuration.getDynamoDbTables().getRedeemedReceipts().getTableName(),
dynamoDbClient,
configuration.getDynamoDbTables().getRedeemedReceipts().getExpiration());
dynamoDbClient);
Accounts accounts = new Accounts(
clock,
@@ -340,7 +340,7 @@ public class BackupAuthManagerTest {
.build();
clock.pin(Instant.EPOCH.plus(Duration.ofDays(1)));
when(accountsManager.update(any(Account.class), any())).thenReturn(account);
when(redeemedReceiptsManager.put(any(), eq(expirationTime.getEpochSecond()), eq(201L), eq(aci)))
when(redeemedReceiptsManager.put(any(), eq(expirationTime), eq(201L), eq(aci)))
.thenReturn(true);
authManager.redeemReceipt(account, receiptPresentation(201, expirationTime));
verify(accountsManager, times(1)).update(any(Account.class), any());
@@ -353,7 +353,7 @@ public class BackupAuthManagerTest {
final Account account = new MockAccountBuilder().mediaCredential(Optional.empty()).build();
clock.pin(Instant.EPOCH.plus(Duration.ofDays(1)));
when(redeemedReceiptsManager.put(any(), eq(expirationTime.getEpochSecond()), eq(201L), eq(aci)))
when(redeemedReceiptsManager.put(any(), eq(expirationTime), eq(201L), eq(aci)))
.thenReturn(true);
assertThatExceptionOfType(BackupMissingIdCommitmentException.class)
.isThrownBy(() -> authManager.redeemReceipt(account, receiptPresentation(201, expirationTime)));
@@ -374,7 +374,7 @@ public class BackupAuthManagerTest {
clock.pin(Instant.EPOCH.plus(Duration.ofDays(1)));
when(accountsManager.update(any(Account.class), any())).thenReturn(account);
when(redeemedReceiptsManager.put(any(), eq(newExpirationTime.getEpochSecond()), eq(201L), eq(aci)))
when(redeemedReceiptsManager.put(any(), eq(newExpirationTime), eq(201L), eq(aci)))
.thenReturn(true);
authManager.redeemReceipt(account, receiptPresentation(201, newExpirationTime));
@@ -429,7 +429,7 @@ public class BackupAuthManagerTest {
clock.pin(Instant.EPOCH.plus(Duration.ofDays(1)));
when(accountsManager.update(any(Account.class), any())).thenReturn(account);
when(redeemedReceiptsManager.put(any(), eq(expirationTime.getEpochSecond()), eq(201L), eq(aci)))
when(redeemedReceiptsManager.put(any(), eq(expirationTime), eq(201L), eq(aci)))
.thenReturn(false);
assertThatExceptionOfType(BackupBadReceiptException.class)
@@ -138,7 +138,7 @@ class DonationControllerTest {
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(true);
when(redeemedReceiptsManager.put(same(receiptSerial), eq(Instant.ofEpochSecond(receiptExpiration)), eq(receiptLevel), eq(AuthHelper.VALID_UUID))).thenReturn(true);
when(accountsManager.getByAccountIdentifier(eq(AuthHelper.VALID_UUID)))
.thenReturn(Optional.of(AuthHelper.VALID_ACCOUNT));
@@ -163,7 +163,7 @@ class DonationControllerTest {
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(false);
when(redeemedReceiptsManager.put(same(receiptSerial), eq(Instant.ofEpochSecond(receiptExpiration)), eq(receiptLevel), eq(AuthHelper.VALID_UUID))).thenReturn(false);
when(accountsManager.getByAccountIdentifier(eq(AuthHelper.VALID_UUID)))
.thenReturn(Optional.of(AuthHelper.VALID_ACCOUNT));
@@ -84,7 +84,7 @@ class DonationsGrpcServiceTest extends SimpleBaseGrpcTest<DonationsGrpcService,
private final TestClock clock = TestClock.pinned(Instant.ofEpochSecond(100));
private static final long EXPIRATION_TIME_EPOCH_SECONDS = 200;
private static final Instant EXPIRATION_TIME = Instant.ofEpochSecond(200);
@Override
protected DonationsGrpcService createServiceBeforeEachTest() {
@@ -98,7 +98,7 @@ class DonationsGrpcServiceTest extends SimpleBaseGrpcTest<DonationsGrpcService,
}
when(receiptCredentialPresentation.getReceiptLevel()).thenReturn(ReceiptLevel.ONE_TIME_DONATION.getValue());
when(badgesConfiguration.getReceiptLevels()).thenReturn(Map.of(ReceiptLevel.ONE_TIME_DONATION, "testBadge"));
when(receiptCredentialPresentation.getReceiptExpirationTime()).thenReturn(EXPIRATION_TIME_EPOCH_SECONDS);
when(receiptCredentialPresentation.getReceiptExpirationTime()).thenReturn(EXPIRATION_TIME.getEpochSecond());
when(receiptCredentialPresentation.getReceiptSerial()).thenReturn(receiptSerial);
donationPermitsManager = new DonationPermitsManager(mock(DonationPermits.class), donationsPermitSecretParams,
@@ -123,7 +123,7 @@ class DonationsGrpcServiceTest extends SimpleBaseGrpcTest<DonationsGrpcService,
void redeemReceipt(
@CartesianTest.Values(booleans = {true, false}) final boolean isVisible,
@CartesianTest.Values(booleans = {true, false}) final boolean isPrimary) {
when(redeemedReceiptsManager.put(receiptSerial, EXPIRATION_TIME_EPOCH_SECONDS, 1, AUTHENTICATED_ACI)).thenReturn(
when(redeemedReceiptsManager.put(receiptSerial, EXPIRATION_TIME, 1, AUTHENTICATED_ACI)).thenReturn(
true);
final RedeemReceiptResponse response = authenticatedServiceStub().redeemReceipt(RedeemReceiptRequest.newBuilder()
@@ -135,7 +135,7 @@ class DonationsGrpcServiceTest extends SimpleBaseGrpcTest<DonationsGrpcService,
assertEquals(RedeemReceiptResponse.ResponseCase.SUCCESS, response.getResponseCase());
verify(account).addBadge(clock,
new AccountBadge("testBadge", Instant.ofEpochSecond(EXPIRATION_TIME_EPOCH_SECONDS), isVisible));
new AccountBadge("testBadge", EXPIRATION_TIME, isVisible));
if (isPrimary) {
verify(account).makeBadgePrimaryIfExists(clock, "testBadge");
} else {
@@ -145,7 +145,7 @@ class DonationsGrpcServiceTest extends SimpleBaseGrpcTest<DonationsGrpcService,
@Test
void alreadyRedeemed() {
when(redeemedReceiptsManager.put(receiptSerial, EXPIRATION_TIME_EPOCH_SECONDS, 1, AUTHENTICATED_ACI)).thenReturn(
when(redeemedReceiptsManager.put(receiptSerial, EXPIRATION_TIME, 1, AUTHENTICATED_ACI)).thenReturn(
false);
final RedeemReceiptResponse response = authenticatedServiceStub().redeemReceipt(RedeemReceiptRequest.newBuilder()
@@ -115,7 +115,7 @@ public class AccountCreationDeletionIntegrationTest {
DYNAMO_DB_EXTENSION.getDynamoDbClient(),
DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(),
new RedeemedReceiptsManager(CLOCK, DynamoDbExtensionSchema.Tables.REDEEMED_RECEIPTS.tableName(),
DYNAMO_DB_EXTENSION.getDynamoDbClient(), Duration.ofDays(30)),
DYNAMO_DB_EXTENSION.getDynamoDbClient()),
DynamoDbExtensionSchema.Tables.ACCOUNTS.tableName(),
DynamoDbExtensionSchema.Tables.NUMBERS.tableName(),
DynamoDbExtensionSchema.Tables.PNI_ASSIGNMENTS.tableName(),
@@ -98,7 +98,7 @@ class AccountsManagerChangeNumberIntegrationTest {
DYNAMO_DB_EXTENSION.getDynamoDbClient(),
DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(),
new RedeemedReceiptsManager(Clock.systemUTC(), Tables.REDEEMED_RECEIPTS.tableName(),
DYNAMO_DB_EXTENSION.getDynamoDbClient(), Duration.ofDays(30)),
DYNAMO_DB_EXTENSION.getDynamoDbClient()),
Tables.ACCOUNTS.tableName(),
Tables.NUMBERS.tableName(),
Tables.PNI_ASSIGNMENTS.tableName(),
@@ -88,7 +88,7 @@ class AccountsManagerConcurrentModificationIntegrationTest {
DYNAMO_DB_EXTENSION.getDynamoDbClient(),
DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(),
new RedeemedReceiptsManager(Clock.systemUTC(), Tables.REDEEMED_RECEIPTS.tableName(),
DYNAMO_DB_EXTENSION.getDynamoDbClient(), Duration.ofDays(30)),
DYNAMO_DB_EXTENSION.getDynamoDbClient()),
Tables.ACCOUNTS.tableName(),
Tables.NUMBERS.tableName(),
Tables.PNI_ASSIGNMENTS.tableName(),
@@ -85,7 +85,7 @@ public class AccountsManagerMigrateRecoveryPasswordIntegrationTest {
DYNAMO_DB_EXTENSION.getDynamoDbClient(),
DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(),
new RedeemedReceiptsManager(Clock.systemUTC(), DynamoDbExtensionSchema.Tables.REDEEMED_RECEIPTS.tableName(),
DYNAMO_DB_EXTENSION.getDynamoDbClient(), Duration.ofDays(30)),
DYNAMO_DB_EXTENSION.getDynamoDbClient()),
DynamoDbExtensionSchema.Tables.ACCOUNTS.tableName(),
DynamoDbExtensionSchema.Tables.NUMBERS.tableName(),
DynamoDbExtensionSchema.Tables.PNI_ASSIGNMENTS.tableName(),
@@ -106,7 +106,7 @@ class AccountsManagerUsernameIntegrationTest {
DYNAMO_DB_EXTENSION.getDynamoDbClient(),
DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(),
new RedeemedReceiptsManager(Clock.systemUTC(), Tables.REDEEMED_RECEIPTS.tableName(),
DYNAMO_DB_EXTENSION.getDynamoDbClient(), Duration.ofDays(30)),
DYNAMO_DB_EXTENSION.getDynamoDbClient()),
Tables.ACCOUNTS.tableName(),
Tables.NUMBERS.tableName(),
Tables.PNI_ASSIGNMENTS.tableName(),
@@ -147,7 +147,7 @@ class AccountsTest {
DYNAMO_DB_EXTENSION.getDynamoDbClient(),
DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(),
new RedeemedReceiptsManager(clock, Tables.REDEEMED_RECEIPTS.tableName(),
DYNAMO_DB_EXTENSION.getDynamoDbClient(), Duration.ofDays(30)),
DYNAMO_DB_EXTENSION.getDynamoDbClient()),
Tables.ACCOUNTS.tableName(),
Tables.NUMBERS.tableName(),
Tables.PNI_ASSIGNMENTS.tableName(),
@@ -880,7 +880,7 @@ class AccountsTest {
dynamoDbClient,
mock(DynamoDbAsyncClient.class),
new RedeemedReceiptsManager(clock, Tables.REDEEMED_RECEIPTS.tableName(),
dynamoDbClient, Duration.ofDays(30)),
dynamoDbClient),
Tables.ACCOUNTS.tableName(),
Tables.NUMBERS.tableName(),
Tables.PNI_ASSIGNMENTS.tableName(),
@@ -986,7 +986,7 @@ class AccountsTest {
dynamoDbClient,
mock(DynamoDbAsyncClient.class),
new RedeemedReceiptsManager(clock, Tables.REDEEMED_RECEIPTS.tableName(),
dynamoDbClient, Duration.ofDays(30)),
dynamoDbClient),
Tables.ACCOUNTS.tableName(),
Tables.NUMBERS.tableName(),
Tables.PNI_ASSIGNMENTS.tableName(),
@@ -1444,7 +1444,7 @@ class AccountsTest {
dynamoDbClient,
mock(DynamoDbAsyncClient.class),
new RedeemedReceiptsManager(clock, Tables.REDEEMED_RECEIPTS.tableName(),
dynamoDbClient, Duration.ofDays(30)),
dynamoDbClient),
Tables.ACCOUNTS.tableName(),
Tables.NUMBERS.tableName(),
Tables.PNI_ASSIGNMENTS.tableName(),
@@ -1492,7 +1492,7 @@ class AccountsTest {
dynamoDbClient,
mock(DynamoDbAsyncClient.class),
new RedeemedReceiptsManager(clock, Tables.REDEEMED_RECEIPTS.tableName(),
dynamoDbClient, Duration.ofDays(30)),
dynamoDbClient),
Tables.ACCOUNTS.tableName(),
Tables.NUMBERS.tableName(),
Tables.PNI_ASSIGNMENTS.tableName(),
@@ -1598,7 +1598,7 @@ class AccountsTest {
dynamoDbClient,
mock(DynamoDbAsyncClient.class),
new RedeemedReceiptsManager(clock, Tables.REDEEMED_RECEIPTS.tableName(),
dynamoDbClient, Duration.ofDays(30)),
dynamoDbClient),
Tables.ACCOUNTS.tableName(),
Tables.NUMBERS.tableName(),
Tables.PNI_ASSIGNMENTS.tableName(),
@@ -2378,9 +2378,9 @@ class AccountsTest {
.build()
);
assertThat(receiptConstraintResponse.hasItem()).isTrue();
assertThat(AttributeValues.getUUID(receiptConstraintResponse.item(), RedeemedReceiptsManager.KEY_ACCOUNT_UUID, null)).isEqualTo(accountId);
assertThat(AttributeValues.getLong(receiptConstraintResponse.item(), RedeemedReceiptsManager.KEY_RECEIPT_LEVEL, -1)).isEqualTo(receiptCredentialPresentation.getReceiptLevel());
assertThat(AttributeValues.getLong(receiptConstraintResponse.item(), RedeemedReceiptsManager.KEY_RECEIPT_EXPIRATION, -1)).isEqualTo(receiptCredentialPresentation.getReceiptExpirationTime());
assertThat(AttributeValues.getUUID(receiptConstraintResponse.item(), RedeemedReceiptsManager.ATTR_ACCOUNT_UUID, null)).isEqualTo(accountId);
assertThat(AttributeValues.getLong(receiptConstraintResponse.item(), RedeemedReceiptsManager.ATTR_RECEIPT_LEVEL, -1)).isEqualTo(receiptCredentialPresentation.getReceiptLevel());
assertThat(AttributeValues.getLong(receiptConstraintResponse.item(), RedeemedReceiptsManager.ATTR_RECEIPT_EXPIRATION, -1)).isEqualTo(receiptCredentialPresentation.getReceiptExpirationTime());
}
private Map<String, AttributeValue> readAccount(final UUID uuid) {
@@ -102,7 +102,7 @@ public class AddRemoveDeviceIntegrationTest {
DYNAMO_DB_EXTENSION.getDynamoDbClient(),
DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(),
new RedeemedReceiptsManager(clock, DynamoDbExtensionSchema.Tables.REDEEMED_RECEIPTS.tableName(),
DYNAMO_DB_EXTENSION.getDynamoDbClient(), Duration.ofDays(30)),
DYNAMO_DB_EXTENSION.getDynamoDbClient()),
DynamoDbExtensionSchema.Tables.ACCOUNTS.tableName(),
DynamoDbExtensionSchema.Tables.NUMBERS.tableName(),
DynamoDbExtensionSchema.Tables.PNI_ASSIGNMENTS.tableName(),
@@ -6,20 +6,29 @@
package org.whispersystems.textsecuregcm.storage;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.ExecutionException;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.receipts.ReceiptSerial;
import org.whispersystems.textsecuregcm.storage.DynamoDbExtensionSchema.Tables;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
import org.whispersystems.textsecuregcm.util.AttributeValues;
import org.whispersystems.textsecuregcm.util.TestClock;
import org.whispersystems.textsecuregcm.util.TestRandomUtil;
import software.amazon.awssdk.services.dynamodb.model.CancellationReason;
import software.amazon.awssdk.services.dynamodb.model.GetItemRequest;
import software.amazon.awssdk.services.dynamodb.model.GetItemResponse;
import software.amazon.awssdk.services.dynamodb.model.TransactWriteItem;
import software.amazon.awssdk.services.dynamodb.model.TransactWriteItemsRequest;
import software.amazon.awssdk.services.dynamodb.model.TransactionCanceledException;
class RedeemedReceiptsManagerTest {
@@ -38,30 +47,95 @@ class RedeemedReceiptsManagerTest {
redeemedReceiptsManager = new RedeemedReceiptsManager(
clock,
Tables.REDEEMED_RECEIPTS.tableName(),
DYNAMO_DB_EXTENSION.getDynamoDbClient(),
Duration.ofDays(90));
DYNAMO_DB_EXTENSION.getDynamoDbClient());
}
@Test
void testPut() throws ExecutionException, InterruptedException {
final long receiptExpiration = 42;
void testPut() {
final Instant receiptExpiration = Instant.ofEpochSecond(42);
final long receiptLevel = 3;
final UUID uuid1 = UUID.randomUUID();
boolean put;
// initial insert should return true
put = redeemedReceiptsManager.put(receiptSerial, receiptExpiration, receiptLevel, AuthHelper.VALID_UUID);
put = redeemedReceiptsManager.put(receiptSerial, receiptExpiration, receiptLevel, uuid1);
assertThat(put).isTrue();
// subsequent attempted inserts with modified parameters should return false
put = redeemedReceiptsManager.put(receiptSerial, receiptExpiration + 1, receiptLevel, AuthHelper.VALID_UUID);
put = redeemedReceiptsManager.put(receiptSerial, receiptExpiration.plusSeconds(1), receiptLevel, uuid1);
assertThat(put).isFalse();
put = redeemedReceiptsManager.put(receiptSerial, receiptExpiration, receiptLevel + 1, AuthHelper.VALID_UUID);
put = redeemedReceiptsManager.put(receiptSerial, receiptExpiration, receiptLevel + 1, uuid1);
assertThat(put).isFalse();
put = redeemedReceiptsManager.put(receiptSerial, receiptExpiration, receiptLevel, AuthHelper.VALID_UUID_TWO);
final UUID uuid2 = UUID.randomUUID();
put = redeemedReceiptsManager.put(receiptSerial, receiptExpiration, receiptLevel, uuid2);
assertThat(put).isFalse();
// repeated insert attempt of the original parameters should return true
put = redeemedReceiptsManager.put(receiptSerial, receiptExpiration, receiptLevel, AuthHelper.VALID_UUID);
put = redeemedReceiptsManager.put(receiptSerial, receiptExpiration, receiptLevel, uuid1);
assertThat(put).isTrue();
// verify that the TTL is receipt expiration + padding
final GetItemResponse response = DYNAMO_DB_EXTENSION.getDynamoDbClient().getItem(GetItemRequest.builder()
.tableName(Tables.REDEEMED_RECEIPTS.tableName())
.key(Map.of(RedeemedReceiptsManager.KEY_SERIAL, AttributeValues.b(receiptSerial.serialize())))
.build());
assertThat(Long.parseLong(response.item().get(RedeemedReceiptsManager.ATTR_TTL).n())).isEqualTo(
receiptExpiration.plus(RedeemedReceiptsManager.TTL_PADDING).getEpochSecond());
}
@Test
void testBuildTransactWriteItemForReceipt() {
final Instant receiptExpiration = Instant.ofEpochSecond(42);
final long receiptLevel1 = 3;
final UUID uuid1 = UUID.randomUUID();
final TransactWriteItem writeItem = redeemedReceiptsManager.buildTransactWriteItemForReceipt(receiptSerial,
receiptExpiration, receiptLevel1, uuid1);
assertThatCode(() -> DYNAMO_DB_EXTENSION.getDynamoDbClient().transactWriteItems(TransactWriteItemsRequest.builder()
.transactItems(writeItem)
.build())).doesNotThrowAnyException();
// A subsequent write with the same parameters should be idempotent
assertThatCode(() -> DYNAMO_DB_EXTENSION.getDynamoDbClient().transactWriteItems(TransactWriteItemsRequest.builder()
.transactItems(writeItem)
.build())).doesNotThrowAnyException();
// An attempt to insert the same receipt with a different UUID should fail
final UUID uuid2 = UUID.randomUUID();
final TransactWriteItem writeItem2 = redeemedReceiptsManager.buildTransactWriteItemForReceipt(receiptSerial,
receiptExpiration, receiptLevel1, uuid2);
assertThatThrownBy(
() -> DYNAMO_DB_EXTENSION.getDynamoDbClient().transactWriteItems(TransactWriteItemsRequest.builder()
.transactItems(writeItem2)
.build()))
.isInstanceOfSatisfying(TransactionCanceledException.class, e -> {
final List<CancellationReason> cancellationReasons = e.cancellationReasons();
assertThat(cancellationReasons).hasSize(1);
assertThat(cancellationReasons.getFirst().code()).isEqualTo("ConditionalCheckFailed");
});
// An attempt to insert the same receipt with the original UUID but a different receipt level should fail
final long receiptLevel2 = 4;
final TransactWriteItem writeItem3 = redeemedReceiptsManager.buildTransactWriteItemForReceipt(receiptSerial,
receiptExpiration, receiptLevel2, uuid1);
assertThatThrownBy(
() -> DYNAMO_DB_EXTENSION.getDynamoDbClient().transactWriteItems(TransactWriteItemsRequest.builder()
.transactItems(writeItem3)
.build()))
.isInstanceOfSatisfying(TransactionCanceledException.class, e -> {
final List<CancellationReason> cancellationReasons = e.cancellationReasons();
assertThat(cancellationReasons).hasSize(1);
assertThat(cancellationReasons.getFirst().code()).isEqualTo("ConditionalCheckFailed");
});
// verify that the TTL is receipt expiration + padding
final GetItemResponse response = DYNAMO_DB_EXTENSION.getDynamoDbClient().getItem(GetItemRequest.builder()
.tableName(Tables.REDEEMED_RECEIPTS.tableName())
.key(Map.of(RedeemedReceiptsManager.KEY_SERIAL, AttributeValues.b(receiptSerial.serialize())))
.build());
assertThat(Long.parseLong(response.item().get(RedeemedReceiptsManager.ATTR_TTL).n())).isEqualTo(
receiptExpiration.plus(RedeemedReceiptsManager.TTL_PADDING).getEpochSecond());
}
}
@@ -144,7 +144,6 @@ dynamoDbTables:
tableName: Example_PushNotificationExperimentSamples
redeemedReceipts:
tableName: redeemed_receipts_test
expiration: P30D # Duration of time until rows expire
registrationRecovery:
tableName: registration_recovery_passwords_test
expiration: P300D # Duration of time until rows expire