From 3ac7a2f356cf13991a42a1c66862fc020a0bc11c Mon Sep 17 00:00:00 2001 From: Katherine Date: Tue, 18 Aug 2026 19:04:30 -0400 Subject: [PATCH] Add an integration test for registering a numberless account using a static credential --- .../signal/integration/IntegrationTools.java | 15 +++++- .../org/signal/integration/Operations.java | 53 +++++++++++++++++++ .../org/signal/integration/TestDevice.java | 7 ++- .../java/org/signal/integration/TestUser.java | 25 +++++++-- .../org/signal/integration/config/Config.java | 5 +- .../integration/config/DynamoDbTables.java | 3 +- .../org/signal/integration/AccountTest.java | 18 +++++++ .../storage/RedeemedReceiptsManager.java | 13 +++++ .../storage/RedeemedReceiptsManagerTest.java | 20 +++++++ 9 files changed, 149 insertions(+), 10 deletions(-) diff --git a/integration-tests/src/main/java/org/signal/integration/IntegrationTools.java b/integration-tests/src/main/java/org/signal/integration/IntegrationTools.java index af238e9ca..7b1321ffc 100644 --- a/integration-tests/src/main/java/org/signal/integration/IntegrationTools.java +++ b/integration-tests/src/main/java/org/signal/integration/IntegrationTools.java @@ -13,12 +13,14 @@ import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import org.signal.integration.config.Config; +import org.signal.libsignal.zkgroup.receipts.ReceiptSerial; import org.whispersystems.textsecuregcm.metrics.NoopAwsSdkMetricPublisher; import org.whispersystems.textsecuregcm.registration.VerificationSession; import org.whispersystems.textsecuregcm.storage.ChangeNumberWaitingPeriods; import org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers; import org.whispersystems.textsecuregcm.storage.PhoneNumberRecoveryPasswords; import org.whispersystems.textsecuregcm.storage.PhoneNumberRecoveryPasswordsManager; +import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager; import org.whispersystems.textsecuregcm.storage.VerificationSessionManager; import org.whispersystems.textsecuregcm.storage.VerificationSessions; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; @@ -36,6 +38,8 @@ public class IntegrationTools { private final ChangeNumberWaitingPeriods changeNumberWaitingPeriods; + private final RedeemedReceiptsManager redeemedReceiptsManager; + public static IntegrationTools create(final Config config) { final AwsCredentialsProvider credentialsProvider = DefaultCredentialsProvider.builder().build(); @@ -55,7 +59,8 @@ public class IntegrationTools { new PhoneNumberRecoveryPasswordsManager(phoneNumberRecoveryPasswords), new VerificationSessionManager(verificationSessions), new PhoneNumberIdentifiers(dynamoDbAsyncClient, config.dynamoDbTables().phoneNumberIdentifiers()), - new ChangeNumberWaitingPeriods(config.dynamoDbTables().changeNumberWaitingPeriods(), dynamoDbClient) + new ChangeNumberWaitingPeriods(config.dynamoDbTables().changeNumberWaitingPeriods(), dynamoDbClient), + new RedeemedReceiptsManager(Clock.systemUTC(), config.dynamoDbTables().redeemedReceipts(), dynamoDbClient) ); } @@ -63,11 +68,13 @@ public class IntegrationTools { final PhoneNumberRecoveryPasswordsManager phoneNumberRecoveryPasswordsManager, final VerificationSessionManager verificationSessionManager, final PhoneNumberIdentifiers phoneNumberIdentifiers, - final ChangeNumberWaitingPeriods changeNumberWaitingPeriods) { + final ChangeNumberWaitingPeriods changeNumberWaitingPeriods, + final RedeemedReceiptsManager redeemedReceiptsManager) { this.phoneNumberRecoveryPasswordsManager = phoneNumberRecoveryPasswordsManager; this.verificationSessionManager = verificationSessionManager; this.phoneNumberIdentifiers = phoneNumberIdentifiers; this.changeNumberWaitingPeriods = changeNumberWaitingPeriods; + this.redeemedReceiptsManager = redeemedReceiptsManager; } public void populateRecoveryPassword(final String phoneNumber, final byte[] password) { @@ -87,4 +94,8 @@ public class IntegrationTools { public void clearChangeNumberWaitingPeriod(TestUser user) { changeNumberWaitingPeriods.delete(user.aciUuid()); } + + public void deleteRedeemedReceipt(final ReceiptSerial receiptSerial) { + redeemedReceiptsManager.deleteReceipt(receiptSerial); + } } diff --git a/integration-tests/src/main/java/org/signal/integration/Operations.java b/integration-tests/src/main/java/org/signal/integration/Operations.java index 99feb6327..bc77a63f5 100644 --- a/integration-tests/src/main/java/org/signal/integration/Operations.java +++ b/integration-tests/src/main/java/org/signal/integration/Operations.java @@ -49,6 +49,13 @@ import org.signal.libsignal.protocol.ecc.ECPublicKey; import org.signal.libsignal.protocol.kem.KEMKeyPair; import org.signal.libsignal.protocol.kem.KEMKeyType; import org.signal.libsignal.protocol.kem.KEMPublicKey; +import org.signal.libsignal.zkgroup.InvalidInputException; +import org.signal.libsignal.zkgroup.ServerPublicParams; +import org.signal.libsignal.zkgroup.VerificationFailedException; +import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations; +import org.signal.libsignal.zkgroup.receipts.ReceiptCredential; +import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation; +import org.signal.libsignal.zkgroup.receipts.ReceiptSerial; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.whispersystems.textsecuregcm.entities.AccountAttributes; @@ -83,6 +90,48 @@ public final class Operations { // utility class } + public record Receipt(ReceiptSerial serial, ReceiptCredential credential) {} + + public static Receipt getPrescribedReceipt() throws InvalidInputException { + return new Receipt( + new ReceiptSerial(Base64.getUrlDecoder().decode(CONFIG.prescribedReceiptSerial())), + new ReceiptCredential(Base64.getUrlDecoder().decode(CONFIG.prescribedReceiptCredential())) + ); + } + + public static TestUser registerNumberlessUser(final ReceiptCredential receiptCredential) + throws InvalidInputException, VerificationFailedException { + final String accountPassword = Base64.getEncoder().encodeToString(randomBytes(32)); + final byte[] recoveryPassword = randomBytes(32); + final TestUser user = TestUser.createNumberless(accountPassword, recoveryPassword); + + final ClientZkReceiptOperations clientOps = new ClientZkReceiptOperations(new ServerPublicParams(Base64.getDecoder().decode(CONFIG.serverZkPublicParams()))); + final ReceiptCredentialPresentation receiptCredentialPresentation = clientOps.createReceiptCredentialPresentation(receiptCredential); + + final ECKeyPair aciIdentityKeyPair = ECKeyPair.generate(); + final RegistrationRequest registrationRequest = new RegistrationRequest(null, + null, + receiptCredentialPresentation.serialize(), + user.accountAttributes(), + true, + new IdentityKey(aciIdentityKeyPair.getPublicKey()), + null, + new DeviceActivationRequest(generateSignedECPreKey(1, aciIdentityKeyPair), + Optional.empty(), + generateSignedKEMPreKey(3, aciIdentityKeyPair), + Optional.empty(), + Optional.empty(), + Optional.empty())); + + final AccountIdentityResponse registrationResponse = apiPost("/v1/registration", registrationRequest) + // username must be present but can be set to anything for a numberless registration request + .authorized("test", accountPassword) + .executeExpectSuccess(AccountIdentityResponse.class); + + user.setAciUuid(registrationResponse.uuid()); + return user; + } + public static TestUser newRegisteredUser(final String number) { final byte[] registrationPassword = populateRandomRecoveryPassword(number); final String accountPassword = Base64.getEncoder().encodeToString(randomBytes(32)); @@ -146,6 +195,10 @@ public final class Operations { INTEGRATION_TOOLS.clearChangeNumberWaitingPeriod(user); } + public static void deleteReceipt(final ReceiptSerial receiptSerial) { + INTEGRATION_TOOLS.deleteRedeemedReceipt(receiptSerial); + } + public static T sendEmptyRequestAuthenticated( final String endpoint, final String method, diff --git a/integration-tests/src/main/java/org/signal/integration/TestDevice.java b/integration-tests/src/main/java/org/signal/integration/TestDevice.java index 7edd81ce3..f396ff09b 100644 --- a/integration-tests/src/main/java/org/signal/integration/TestDevice.java +++ b/integration-tests/src/main/java/org/signal/integration/TestDevice.java @@ -5,6 +5,7 @@ package org.signal.integration; +import jakarta.annotation.Nullable; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import org.apache.commons.lang3.tuple.Pair; @@ -22,10 +23,12 @@ public class TestDevice { public static TestDevice create( final byte deviceId, final IdentityKeyPair aciIdentityKeyPair, - final IdentityKeyPair pniIdentityKeyPair) { + @Nullable final IdentityKeyPair pniIdentityKeyPair) { final TestDevice device = new TestDevice(deviceId); device.addSignedPreKey(aciIdentityKeyPair); - device.addSignedPreKey(pniIdentityKeyPair); + if (pniIdentityKeyPair != null) { + device.addSignedPreKey(pniIdentityKeyPair); + } return device; } diff --git a/integration-tests/src/main/java/org/signal/integration/TestUser.java b/integration-tests/src/main/java/org/signal/integration/TestUser.java index 52021bdea..f240bb36b 100644 --- a/integration-tests/src/main/java/org/signal/integration/TestUser.java +++ b/integration-tests/src/main/java/org/signal/integration/TestUser.java @@ -33,7 +33,8 @@ public class TestUser { private final int registrationId; - private final int pniRegistrationId; + @Nullable + private final Integer pniRegistrationId; private final IdentityKeyPair aciIdentityKey; @@ -56,6 +57,22 @@ public class TestUser { @Nullable private UUID pniUuid; + public static TestUser createNumberless(final String accountPassword, final byte[] accountRecoveryPassword) { + final IdentityKeyPair aciIdentityKey = IdentityKeyPair.generate(); + final int registrationId = KeyHelper.generateRegistrationId(false); + final byte[] unidentifiedAccessKey = new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH]; + new SecureRandom().nextBytes(unidentifiedAccessKey); + + return new TestUser( + registrationId, + null, + aciIdentityKey, + null, + null, + unidentifiedAccessKey, + accountPassword, + accountRecoveryPassword); + } public static TestUser create(final String phoneNumber, final String accountPassword, final byte[] registrationPassword) { // ACI identity key pair @@ -82,10 +99,10 @@ public class TestUser { public TestUser( final int registrationId, - final int pniRegistrationId, + @Nullable final Integer pniRegistrationId, final IdentityKeyPair aciIdentityKey, - final String phoneNumber, - final IdentityKeyPair pniIdentityKey, + @Nullable final String phoneNumber, + @Nullable final IdentityKeyPair pniIdentityKey, final byte[] unidentifiedAccessKey, final String accountPassword, final byte[] registrationPassword) { diff --git a/integration-tests/src/main/java/org/signal/integration/config/Config.java b/integration-tests/src/main/java/org/signal/integration/config/Config.java index f9aa3cbe6..395a4f8d9 100644 --- a/integration-tests/src/main/java/org/signal/integration/config/Config.java +++ b/integration-tests/src/main/java/org/signal/integration/config/Config.java @@ -15,5 +15,8 @@ public record Config(@NotBlank String domain, @NotNull @Valid DynamoDbClientFactory dynamoDbClient, @NotNull @Valid DynamoDbTables dynamoDbTables, @NotBlank String prescribedRegistrationNumber, - @NotBlank String prescribedRegistrationCode) { + @NotBlank String prescribedRegistrationCode, + @NotBlank String prescribedReceiptSerial, + @NotBlank String prescribedReceiptCredential, + @NotBlank String serverZkPublicParams) { } diff --git a/integration-tests/src/main/java/org/signal/integration/config/DynamoDbTables.java b/integration-tests/src/main/java/org/signal/integration/config/DynamoDbTables.java index e9bf87689..c1bf6e95c 100644 --- a/integration-tests/src/main/java/org/signal/integration/config/DynamoDbTables.java +++ b/integration-tests/src/main/java/org/signal/integration/config/DynamoDbTables.java @@ -10,5 +10,6 @@ import jakarta.validation.constraints.NotBlank; public record DynamoDbTables(@NotBlank String registrationRecovery, @NotBlank String verificationSessions, @NotBlank String phoneNumberIdentifiers, - @NotBlank String changeNumberWaitingPeriods) { + @NotBlank String changeNumberWaitingPeriods, + @NotBlank String redeemedReceipts) { } diff --git a/integration-tests/src/test/java/org/signal/integration/AccountTest.java b/integration-tests/src/test/java/org/signal/integration/AccountTest.java index 4df3601fa..1778f7785 100644 --- a/integration-tests/src/test/java/org/signal/integration/AccountTest.java +++ b/integration-tests/src/test/java/org/signal/integration/AccountTest.java @@ -21,6 +21,8 @@ import org.signal.libsignal.protocol.IdentityKey; import org.signal.libsignal.protocol.ecc.ECKeyPair; import org.signal.libsignal.usernames.BaseUsernameException; import org.signal.libsignal.usernames.Username; +import org.signal.libsignal.zkgroup.InvalidInputException; +import org.signal.libsignal.zkgroup.VerificationFailedException; import org.whispersystems.textsecuregcm.entities.AccountIdentifierResponse; import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse; import org.whispersystems.textsecuregcm.entities.ChangeNumberRequest; @@ -33,6 +35,22 @@ import org.whispersystems.textsecuregcm.storage.Device; public class AccountTest { + @Test + public void testCreateNumberlessAccount() + throws VerificationFailedException, InvalidInputException { + final Operations.Receipt receipt = Operations.getPrescribedReceipt(); + final TestUser user = Operations.registerNumberlessUser(receipt.credential()); + try { + final Pair execute = Operations.apiGet("/v1/accounts/whoami") + .authorized(user) + .execute(AccountIdentityResponse.class); + assertEquals(HttpStatus.SC_OK, execute.getLeft()); + } finally { + Operations.deleteReceipt(receipt.serial()); + Operations.deleteUser(user); + } + } + @Test public void testCreateAccount() { final TestUser user = Operations.newRegisteredUser("+19995550101"); diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/RedeemedReceiptsManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/RedeemedReceiptsManager.java index 07b039bc0..779bbd85f 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/RedeemedReceiptsManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/RedeemedReceiptsManager.java @@ -18,6 +18,7 @@ import org.whispersystems.textsecuregcm.util.AttributeValues; import org.whispersystems.textsecuregcm.util.UUIDUtil; import software.amazon.awssdk.services.dynamodb.DynamoDbClient; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest; import software.amazon.awssdk.services.dynamodb.model.Put; import software.amazon.awssdk.services.dynamodb.model.ReturnValue; import software.amazon.awssdk.services.dynamodb.model.ReturnValuesOnConditionCheckFailure; @@ -138,6 +139,18 @@ public class RedeemedReceiptsManager { .build(); } + /// Deletes a receipt with the given receipt serial from the redeemed receipts table. + /// This method is only intended for use by integration tests. + /// + /// @param receiptSerial the receipt serial + @VisibleForTesting + public void deleteReceipt(final ReceiptSerial receiptSerial) { + client.deleteItem(DeleteItemRequest.builder() + .tableName(table) + .key(Map.of(KEY_SERIAL, AttributeValues.b(receiptSerial.serialize()))) + .build()); + } + private static AttributeValue rowTtl(final Instant receiptExpiration) { return AttributeValues.n(receiptExpiration.plus(TTL_PADDING).getEpochSecond()); } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/RedeemedReceiptsManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/RedeemedReceiptsManagerTest.java index d7f044e38..7dc2c2951 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/RedeemedReceiptsManagerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/RedeemedReceiptsManagerTest.java @@ -8,6 +8,7 @@ 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 static org.junit.jupiter.api.Assertions.assertFalse; import java.time.Clock; import java.time.Instant; @@ -138,4 +139,23 @@ class RedeemedReceiptsManagerTest { assertThat(Long.parseLong(response.item().get(RedeemedReceiptsManager.ATTR_TTL).n())).isEqualTo( receiptExpiration.plus(RedeemedReceiptsManager.TTL_PADDING).getEpochSecond()); } + + @Test + void testDelete() { + final Instant receiptExpiration = Instant.ofEpochSecond(42); + final long receiptLevel = 3; + final UUID uuid1 = UUID.randomUUID(); + boolean put; + + put = redeemedReceiptsManager.put(receiptSerial, receiptExpiration, receiptLevel, uuid1); + assertThat(put).isTrue(); + + redeemedReceiptsManager.deleteReceipt(receiptSerial); + + 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()); + assertFalse(response.hasItem()); + } }