From b7e986f43cc2b51128e0427a38ec0bcb717cfc7d Mon Sep 17 00:00:00 2001 From: Jon Chambers Date: Mon, 12 May 2025 15:52:40 -0400 Subject: [PATCH] Add an integration test for changing phone numbers --- .../org/signal/integration/Operations.java | 24 +++++++----- .../org/signal/integration/AccountTest.java | 39 ++++++++++++++++++- .../controllers/AccountControllerV2.java | 6 +-- .../entities/AccountIdentityResponse.java | 16 ++++---- 4 files changed, 63 insertions(+), 22 deletions(-) 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 4711fb782..87527ba32 100644 --- a/integration-tests/src/main/java/org/signal/integration/Operations.java +++ b/integration-tests/src/main/java/org/signal/integration/Operations.java @@ -72,14 +72,12 @@ public final class Operations { } public static TestUser newRegisteredUser(final String number) { - final byte[] registrationPassword = randomBytes(32); + final byte[] registrationPassword = populateRandomRecoveryPassword(number); final String accountPassword = Base64.getEncoder().encodeToString(randomBytes(32)); final TestUser user = TestUser.create(number, accountPassword, registrationPassword); final AccountAttributes accountAttributes = user.accountAttributes(); - INTEGRATION_TOOLS.populateRecoveryPassword(number, registrationPassword).join(); - final ECKeyPair aciIdentityKeyPair = Curve.generateKeyPair(); final ECKeyPair pniIdentityKeyPair = Curve.generateKeyPair(); @@ -108,6 +106,7 @@ public final class Operations { } public record PrescribedVerificationNumber(String number, String verificationCode) {} + public static PrescribedVerificationNumber prescribedVerificationNumber() { return new PrescribedVerificationNumber( CONFIG.prescribedRegistrationNumber(), @@ -123,6 +122,13 @@ public final class Operations { .orElseThrow(() -> new RuntimeException("push challenge not found for the verification session")); } + public static byte[] populateRandomRecoveryPassword(final String number) { + final byte[] recoveryPassword = randomBytes(32); + INTEGRATION_TOOLS.populateRecoveryPassword(number, recoveryPassword).join(); + + return recoveryPassword; + } + public static T sendEmptyRequestAuthenticated( final String endpoint, final String method, @@ -329,15 +335,15 @@ public final class Operations { } } - private static ECSignedPreKey generateSignedECPreKey(long id, final ECKeyPair identityKeyPair) { + public static ECSignedPreKey generateSignedECPreKey(final long id, final ECKeyPair identityKeyPair) { final ECPublicKey pubKey = Curve.generateKeyPair().getPublicKey(); - final byte[] sig = identityKeyPair.getPrivateKey().calculateSignature(pubKey.serialize()); - return new ECSignedPreKey(id, pubKey, sig); + final byte[] signature = identityKeyPair.getPrivateKey().calculateSignature(pubKey.serialize()); + return new ECSignedPreKey(id, pubKey, signature); } - private static KEMSignedPreKey generateSignedKEMPreKey(long id, final ECKeyPair identityKeyPair) { + public static KEMSignedPreKey generateSignedKEMPreKey(final long id, final ECKeyPair identityKeyPair) { final KEMPublicKey pubKey = KEMKeyPair.generate(KEMKeyType.KYBER_1024).getPublicKey(); - final byte[] sig = identityKeyPair.getPrivateKey().calculateSignature(pubKey.serialize()); - return new KEMSignedPreKey(id, pubKey, sig); + final byte[] signature = identityKeyPair.getPrivateKey().calculateSignature(pubKey.serialize()); + return new KEMSignedPreKey(id, pubKey, signature); } } 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 f028938c3..dd3b795fa 100644 --- a/integration-tests/src/test/java/org/signal/integration/AccountTest.java +++ b/integration-tests/src/test/java/org/signal/integration/AccountTest.java @@ -6,27 +6,35 @@ package org.signal.integration; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import java.util.Arrays; import java.util.Base64; +import java.util.Collections; import java.util.List; +import java.util.Map; import org.apache.commons.lang3.tuple.Pair; import org.apache.http.HttpStatus; import org.junit.jupiter.api.Test; +import org.signal.libsignal.protocol.IdentityKey; +import org.signal.libsignal.protocol.ecc.Curve; +import org.signal.libsignal.protocol.ecc.ECKeyPair; import org.signal.libsignal.usernames.BaseUsernameException; import org.signal.libsignal.usernames.Username; import org.whispersystems.textsecuregcm.entities.AccountIdentifierResponse; import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse; +import org.whispersystems.textsecuregcm.entities.ChangeNumberRequest; import org.whispersystems.textsecuregcm.entities.ConfirmUsernameHashRequest; import org.whispersystems.textsecuregcm.entities.ReserveUsernameHashRequest; import org.whispersystems.textsecuregcm.entities.ReserveUsernameHashResponse; import org.whispersystems.textsecuregcm.entities.UsernameHashResponse; import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier; +import org.whispersystems.textsecuregcm.storage.Device; public class AccountTest { @Test - public void testCreateAccount() throws Exception { + public void testCreateAccount() { final TestUser user = Operations.newRegisteredUser("+19995550101"); try { final Pair execute = Operations.apiGet("/v1/accounts/whoami") @@ -39,7 +47,7 @@ public class AccountTest { } @Test - public void testCreateAccountAtomic() throws Exception { + public void testCreateAccountAtomic() { final TestUser user = Operations.newRegisteredUser("+19995550201"); try { final Pair execute = Operations.apiGet("/v1/accounts/whoami") @@ -51,6 +59,33 @@ public class AccountTest { } } + @Test + public void changePhoneNumber() { + final TestUser user = Operations.newRegisteredUser("+19995550301"); + final String targetNumber = "+19995550302"; + + final ECKeyPair pniIdentityKeyPair = Curve.generateKeyPair(); + + final ChangeNumberRequest changeNumberRequest = new ChangeNumberRequest(null, + Operations.populateRandomRecoveryPassword(targetNumber), + targetNumber, + null, + new IdentityKey(pniIdentityKeyPair.getPublicKey()), + Collections.emptyList(), + Map.of(Device.PRIMARY_ID, Operations.generateSignedECPreKey(1, pniIdentityKeyPair)), + Map.of(Device.PRIMARY_ID, Operations.generateSignedKEMPreKey(2, pniIdentityKeyPair)), + Map.of(Device.PRIMARY_ID, 17)); + + final AccountIdentityResponse accountIdentityResponse = + Operations.apiPut("/v2/accounts/number", changeNumberRequest) + .authorized(user) + .executeExpectSuccess(AccountIdentityResponse.class); + + assertEquals(user.aciUuid(), accountIdentityResponse.uuid()); + assertNotEquals(user.pniUuid(), accountIdentityResponse.pni()); + assertEquals(targetNumber, accountIdentityResponse.number()); + } + @Test public void testUsernameOperations() throws Exception { final TestUser user = Operations.newRegisteredUser("+19995550102"); diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountControllerV2.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountControllerV2.java index d4c375484..95bc58536 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountControllerV2.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountControllerV2.java @@ -102,9 +102,9 @@ public class AccountControllerV2 { name = "Retry-After", description = "If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed")) public AccountIdentityResponse changeNumber(@Mutable @Auth final AuthenticatedDevice authenticatedDevice, - @NotNull @Valid final ChangeNumberRequest request, @HeaderParam(HttpHeaders.USER_AGENT) final String userAgentString, - @Context final ContainerRequestContext requestContext) - throws RateLimitExceededException, InterruptedException { + @NotNull @Valid final ChangeNumberRequest request, + @HeaderParam(HttpHeaders.USER_AGENT) final String userAgentString, + @Context final ContainerRequestContext requestContext) throws RateLimitExceededException, InterruptedException { if (!authenticatedDevice.getAuthenticatedDevice().isPrimary()) { throw new ForbiddenException(); diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountIdentityResponse.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountIdentityResponse.java index ec8be7cd6..7bb72cb71 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountIdentityResponse.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountIdentityResponse.java @@ -7,30 +7,30 @@ package org.whispersystems.textsecuregcm.entities; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonSerialize; -import org.whispersystems.textsecuregcm.util.ByteArrayBase64UrlAdapter; import io.swagger.v3.oas.annotations.media.Schema; import java.util.UUID; import javax.annotation.Nullable; +import org.whispersystems.textsecuregcm.util.ByteArrayBase64UrlAdapter; public record AccountIdentityResponse( - @Schema(description="the account identifier for this account") - UUID uuid, + @Schema(description = "the account identifier for this account") + UUID uuid, - @Schema(description="the phone number associated with this account") + @Schema(description = "the phone number associated with this account") String number, - @Schema(description="the account identifier for this account's phone-number identity") + @Schema(description = "the account identifier for this account's phone-number identity") UUID pni, - @Schema(description="a hash of this account's username, if set") + @Schema(description = "a hash of this account's username, if set") @JsonSerialize(using = ByteArrayBase64UrlAdapter.Serializing.class) @JsonDeserialize(using = ByteArrayBase64UrlAdapter.Deserializing.class) @Nullable byte[] usernameHash, - @Schema(description="this account's username link handle, if set") + @Schema(description = "this account's username link handle, if set") @Nullable UUID usernameLinkHandle, - @Schema(description="whether any of this account's devices support storage") + @Schema(description = "whether any of this account's devices support storage") boolean storageCapable, @Schema(description = "entitlements for this account and their current expirations")