Skip to content

Commit 90c27f6

Browse files
committed
Add post-registration change number waiting period
1 parent f045e3e commit 90c27f6

6 files changed

Lines changed: 141 additions & 11 deletions

File tree

service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import org.whispersystems.textsecuregcm.configuration.CallQualitySurveyConfiguration;
2323
import org.whispersystems.textsecuregcm.configuration.Cdn3StorageManagerConfiguration;
2424
import org.whispersystems.textsecuregcm.configuration.CdnConfiguration;
25+
import org.whispersystems.textsecuregcm.configuration.ChangeNumberConfiguration;
2526
import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;
2627
import org.whispersystems.textsecuregcm.configuration.ClientReleaseConfiguration;
2728
import org.whispersystems.textsecuregcm.configuration.DefaultAwsCredentialsFactory;
@@ -355,6 +356,11 @@ public class WhisperServerConfiguration extends Configuration {
355356
@JsonProperty
356357
private CallQualitySurveyConfiguration callQualitySurvey;
357358

359+
@Valid
360+
@NotNull
361+
@JsonProperty
362+
private ChangeNumberConfiguration changeNumber = new ChangeNumberConfiguration(Duration.ofHours(1));
363+
358364
public TlsKeyStoreConfiguration getTlsKeyStoreConfiguration() {
359365
return tlsKeyStore;
360366
}
@@ -591,4 +597,7 @@ public HlrLookupConfiguration getHlrLookupConfiguration() {
591597
return hlrLookup;
592598
}
593599

600+
public ChangeNumberConfiguration getChangeNumber() {
601+
return changeNumber;
602+
}
594603
}

service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1096,7 +1096,8 @@ public void run(WhisperServerConfiguration config, Environment environment) thro
10961096
phoneNumberIdentifiers, registrationServiceClient, registrationRecoveryPasswordsManager, registrationRecoveryChecker);
10971097

10981098
final ChangeNumberManager changeNumberManager = new ChangeNumberManager(messageSender, accountsManager,
1099-
phoneVerificationTokenManager, registrationLockVerificationManager, rateLimiters, Clock.systemUTC());
1099+
phoneVerificationTokenManager, registrationLockVerificationManager, rateLimiters,
1100+
config.getChangeNumber().postRegistrationWaitingPeriod(), Clock.systemUTC());
11001101

11011102
final List<Object> commonControllers = Lists.newArrayList(
11021103
new AccountController(accountsManager, rateLimiters, registrationRecoveryPasswordsManager,
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/*
2+
* Copyright 2026 Signal Messenger, LLC
3+
* SPDX-License-Identifier: AGPL-3.0-only
4+
*/
5+
6+
package org.whispersystems.textsecuregcm.configuration;
7+
8+
import java.time.Duration;
9+
10+
public record ChangeNumberConfiguration(Duration postRegistrationWaitingPeriod) {
11+
}

service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountControllerV2.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ public AccountControllerV2(final AccountsManager accountsManager,
7676
@ApiResponse(responseCode = "423", content = @Content(schema = @Schema(implementation = RegistrationLockFailure.class)))
7777
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
7878
name = "Retry-After",
79-
description = "If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed"))
79+
description = "If present, a positive integer indicating the number of seconds before a subsequent attempt could succeed"))
8080
public AccountIdentityResponse changeNumber(@Auth final AuthenticatedDevice authenticatedDevice,
8181
@NotNull @Valid final ChangeNumberRequest request,
8282
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgentString,

service/src/main/java/org/whispersystems/textsecuregcm/storage/ChangeNumberManager.java

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,22 @@
44
*/
55
package org.whispersystems.textsecuregcm.storage;
66

7+
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
8+
9+
import com.google.common.net.HttpHeaders;
10+
import io.micrometer.core.instrument.Metrics;
11+
import io.micrometer.core.instrument.Tag;
12+
import io.micrometer.core.instrument.Tags;
13+
import jakarta.ws.rs.container.ContainerRequestContext;
714
import java.time.Clock;
15+
import java.time.Duration;
16+
import java.time.Instant;
817
import java.util.List;
918
import java.util.Map;
1019
import java.util.Optional;
1120
import java.util.UUID;
1221
import java.util.stream.Collectors;
13-
import com.google.common.net.HttpHeaders;
14-
import io.micrometer.core.instrument.Metrics;
15-
import io.micrometer.core.instrument.Tag;
16-
import io.micrometer.core.instrument.Tags;
17-
import jakarta.ws.rs.container.ContainerRequestContext;
22+
import javax.annotation.Nullable;
1823
import org.signal.libsignal.protocol.IdentityKey;
1924
import org.slf4j.Logger;
2025
import org.slf4j.LoggerFactory;
@@ -36,16 +41,14 @@
3641
import org.whispersystems.textsecuregcm.push.MessageSender;
3742
import org.whispersystems.textsecuregcm.push.MessageTooLargeException;
3843
import org.whispersystems.textsecuregcm.util.Pair;
39-
import javax.annotation.Nullable;
40-
41-
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
4244

4345
public class ChangeNumberManager {
4446

4547
private final MessageSender messageSender;
4648
private final AccountsManager accountsManager;
4749
private final PhoneVerificationTokenManager phoneVerificationTokenManager;
4850
private final RegistrationLockVerificationManager registrationLockVerificationManager;
51+
private final Duration postRegistrationWaitingPeriod;
4952
private final RateLimiters rateLimiters;
5053
private final Clock clock;
5154

@@ -54,20 +57,24 @@ public class ChangeNumberManager {
5457
// Note that this counter name references another class deliberately in the interest of metric continuity
5558
private static final String CHANGE_NUMBER_COUNTER_NAME = name(AccountControllerV2.class, "changeNumber");
5659
private static final String VERIFICATION_TYPE_TAG_NAME = "verification";
60+
private static final String POST_REGISTRATION_WAITING_PERIOD_NOT_MET_COUNTER_NAME = name(ChangeNumberManager.class,
61+
"postRegistrationWaitingPeriodNotMet");
5762

5863
public ChangeNumberManager(
5964
final MessageSender messageSender,
6065
final AccountsManager accountsManager,
6166
final PhoneVerificationTokenManager phoneVerificationTokenManager,
6267
final RegistrationLockVerificationManager registrationLockVerificationManager,
6368
final RateLimiters rateLimiters,
69+
final Duration postRegistrationWaitingPeriod,
6470
final Clock clock) {
6571

6672
this.messageSender = messageSender;
6773
this.accountsManager = accountsManager;
6874
this.phoneVerificationTokenManager = phoneVerificationTokenManager;
6975
this.registrationLockVerificationManager = registrationLockVerificationManager;
7076
this.rateLimiters = rateLimiters;
77+
this.postRegistrationWaitingPeriod = postRegistrationWaitingPeriod;
7178
this.clock = clock;
7279
}
7380

@@ -93,6 +100,14 @@ public Account changeNumber(final UUID accountIdentifier,
93100

94101
// Only verify and check reglock if there's a data change to be made...
95102
if (!account.getNumber().equals(number)) {
103+
104+
final Instant registration = Instant.ofEpochMilli(account.getPrimaryDevice().getCreated());
105+
final Duration waitingPeriodRemaining = Duration.between(clock.instant().minus(postRegistrationWaitingPeriod), registration);
106+
if (waitingPeriodRemaining.isPositive()) {
107+
Metrics.counter(POST_REGISTRATION_WAITING_PERIOD_NOT_MET_COUNTER_NAME).increment();
108+
throw new RateLimitExceededException(waitingPeriodRemaining);
109+
}
110+
96111
rateLimiters.getRegistrationLimiter().validate(number);
97112

98113
final PhoneVerificationRequest.VerificationType verificationType =

service/src/test/java/org/whispersystems/textsecuregcm/storage/ChangeNumberManagerTest.java

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
*/
55
package org.whispersystems.textsecuregcm.storage;
66

7+
import static org.junit.jupiter.api.Assertions.assertEquals;
78
import static org.junit.jupiter.api.Assertions.assertThrows;
89
import static org.mockito.ArgumentMatchers.any;
910
import static org.mockito.ArgumentMatchers.anyByte;
@@ -22,6 +23,8 @@
2223
import jakarta.ws.rs.container.ContainerRequestContext;
2324
import java.time.Duration;
2425
import java.time.Instant;
26+
import java.time.temporal.ChronoUnit;
27+
import java.util.Collection;
2528
import java.util.Collections;
2629
import java.util.HashMap;
2730
import java.util.List;
@@ -30,6 +33,10 @@
3033
import java.util.UUID;
3134
import org.junit.jupiter.api.BeforeEach;
3235
import org.junit.jupiter.api.Test;
36+
import org.junit.jupiter.api.function.Executable;
37+
import org.junit.jupiter.params.ParameterizedTest;
38+
import org.junit.jupiter.params.provider.Arguments;
39+
import org.junit.jupiter.params.provider.MethodSource;
3340
import org.mockito.stubbing.Answer;
3441
import org.signal.libsignal.protocol.IdentityKey;
3542
import org.signal.libsignal.protocol.ecc.ECKeyPair;
@@ -67,6 +74,14 @@ public class ChangeNumberManagerTest {
6774

6875
private static final TestClock CLOCK = TestClock.pinned(Instant.now());
6976

77+
private static final Duration POST_REGISTRATION_WAITING_PERIOD = Duration.ofHours(2);
78+
private static final Device DEFAULT_PRIMARY_DEVICE;
79+
static {
80+
DEFAULT_PRIMARY_DEVICE = new Device();
81+
DEFAULT_PRIMARY_DEVICE.setId((byte) 1);
82+
DEFAULT_PRIMARY_DEVICE.setCreated(CLOCK.instant().minus(POST_REGISTRATION_WAITING_PERIOD).minusSeconds(1).toEpochMilli());
83+
}
84+
7085
@BeforeEach
7186
void setUp() throws Exception {
7287
messageSender = mock(MessageSender.class);
@@ -87,7 +102,8 @@ void setUp() throws Exception {
87102
when(rateLimiters.getRegistrationLimiter()).thenReturn(rateLimiter);
88103

89104
changeNumberManager = new ChangeNumberManager(messageSender, accountsManager,
90-
phoneVerificationTokenManager, registrationLockVerificationManager, rateLimiters, CLOCK);
105+
phoneVerificationTokenManager, registrationLockVerificationManager, rateLimiters,
106+
POST_REGISTRATION_WAITING_PERIOD, CLOCK);
91107

92108
updatedPhoneNumberIdentifiersByAccount = new HashMap<>();
93109

@@ -100,6 +116,7 @@ void setUp() throws Exception {
100116

101117
final UUID uuid = account.getIdentifier(IdentityType.ACI);
102118
final List<Device> devices = account.getDevices();
119+
final Device primaryDevice = account.getPrimaryDevice();
103120

104121
final UUID updatedPni = UUID.randomUUID();
105122
updatedPhoneNumberIdentifiersByAccount.put(account, updatedPni);
@@ -113,6 +130,7 @@ void setUp() throws Exception {
113130
when(updatedAccount.getNumber()).thenReturn(number);
114131
when(updatedAccount.getDevices()).thenReturn(devices);
115132
when(updatedAccount.getDevice(anyByte())).thenReturn(Optional.empty());
133+
when(updatedAccount.getPrimaryDevice()).thenReturn(primaryDevice);
116134

117135
account.getDevices().forEach(device ->
118136
when(updatedAccount.getDevice(device.getId())).thenReturn(Optional.of(device)));
@@ -145,6 +163,7 @@ void changeNumberSingleDevice() throws Exception {
145163
when(account.getIdentifier(IdentityType.ACI)).thenReturn(accountIdentifier);
146164
when(account.isIdentifiedBy(any())).thenReturn(false);
147165
when(account.isIdentifiedBy(new AciServiceIdentifier(accountIdentifier))).thenReturn(true);
166+
when(account.getPrimaryDevice()).thenReturn(DEFAULT_PRIMARY_DEVICE);
148167

149168
when(accountsManager.getAccountsForChangeNumber(eq(accountIdentifier), any()))
150169
.thenReturn(new Pair<>(account, Optional.empty()));
@@ -187,6 +206,7 @@ void changeNumberLinkedDevices() throws Exception {
187206
when(account.getDevice(primaryDeviceId)).thenReturn(Optional.of(primaryDevice));
188207
when(account.getDevice(linkedDeviceId)).thenReturn(Optional.of(linkedDevice));
189208
when(account.getDevices()).thenReturn(List.of(primaryDevice, linkedDevice));
209+
when(account.getPrimaryDevice()).thenReturn(DEFAULT_PRIMARY_DEVICE);
190210

191211
final ECKeyPair pniIdentityKeyPair = ECKeyPair.generate();
192212
final IdentityKey pniIdentityKey = new IdentityKey(pniIdentityKeyPair.getPublicKey());
@@ -269,6 +289,7 @@ void changeNumberSameNumber() throws Exception {
269289
when(account.getIdentifier(IdentityType.ACI)).thenReturn(accountIdentifier);
270290
when(account.isIdentifiedBy(any())).thenReturn(false);
271291
when(account.isIdentifiedBy(new AciServiceIdentifier(accountIdentifier))).thenReturn(true);
292+
when(account.getPrimaryDevice()).thenReturn(DEFAULT_PRIMARY_DEVICE);
272293

273294
when(accountsManager.getAccountsForChangeNumber(eq(accountIdentifier), any()))
274295
.thenReturn(new Pair<>(account, Optional.empty()));
@@ -308,6 +329,7 @@ void changeNumberRateLimited()
308329
when(account.getIdentifier(IdentityType.ACI)).thenReturn(accountIdentifier);
309330
when(account.isIdentifiedBy(any())).thenReturn(false);
310331
when(account.isIdentifiedBy(new AciServiceIdentifier(accountIdentifier))).thenReturn(true);
332+
when(account.getPrimaryDevice()).thenReturn(DEFAULT_PRIMARY_DEVICE);
311333

312334
when(accountsManager.getAccountsForChangeNumber(eq(accountIdentifier), any()))
313335
.thenReturn(new Pair<>(account, Optional.empty()));
@@ -356,6 +378,7 @@ void changeNumberRegistrationLockFailed()
356378
when(account.getIdentifier(IdentityType.ACI)).thenReturn(accountIdentifier);
357379
when(account.isIdentifiedBy(any())).thenReturn(false);
358380
when(account.isIdentifiedBy(new AciServiceIdentifier(accountIdentifier))).thenReturn(true);
381+
when(account.getPrimaryDevice()).thenReturn(DEFAULT_PRIMARY_DEVICE);
359382

360383
final Account existingAccount = mock(Account.class);
361384
when(existingAccount.getNumber()).thenReturn(targetNumber);
@@ -381,4 +404,75 @@ void changeNumberRegistrationLockFailed()
381404
verify(accountsManager, never()).changeNumber(any(), any(), any(), any(), any(), any());
382405
verify(messageSender, never()).sendMessages(any(), any(), any(), any(), any(), any());
383406
}
407+
408+
@ParameterizedTest
409+
@MethodSource
410+
void testRecentRegistration(final boolean expectRateLimited, final boolean sameNumber, final Instant registrationInstant) throws Throwable {
411+
412+
final String originalNumber = PhoneNumberUtil.getInstance().format(
413+
PhoneNumberUtil.getInstance().getExampleNumber("DE"), PhoneNumberUtil.PhoneNumberFormat.E164);
414+
415+
final String targetNumber = sameNumber
416+
? originalNumber
417+
: PhoneNumberUtil.getInstance().format(
418+
PhoneNumberUtil.getInstance().getExampleNumber("US"), PhoneNumberUtil.PhoneNumberFormat.E164);
419+
420+
final ECKeyPair pniIdentityKeyPair = ECKeyPair.generate();
421+
final IdentityKey pniIdentityKey = new IdentityKey(ECKeyPair.generate().getPublicKey());
422+
423+
final Map<Byte, ECSignedPreKey> ecSignedPreKeys =
424+
Map.of(Device.PRIMARY_ID, KeysHelper.signedECPreKey(1, pniIdentityKeyPair));
425+
426+
final Map<Byte, KEMSignedPreKey> kemLastResortPreKeys =
427+
Map.of(Device.PRIMARY_ID, KeysHelper.signedKEMPreKey(2, pniIdentityKeyPair));
428+
429+
final UUID accountIdentifier = UUID.randomUUID();
430+
431+
final Account account = mock(Account.class);
432+
when(account.getNumber()).thenReturn(originalNumber);
433+
when(account.getIdentifier(IdentityType.ACI)).thenReturn(accountIdentifier);
434+
when(account.isIdentifiedBy(any())).thenReturn(false);
435+
when(account.isIdentifiedBy(new AciServiceIdentifier(accountIdentifier))).thenReturn(true);
436+
437+
final Device primaryDevice = mock(Device.class);
438+
when(account.getPrimaryDevice()).thenReturn(primaryDevice);
439+
when(primaryDevice.getCreated()).thenReturn(registrationInstant.toEpochMilli());
440+
441+
when(accountsManager.getAccountsForChangeNumber(eq(accountIdentifier), any()))
442+
.thenReturn(new Pair<>(account, Optional.empty()));
443+
444+
final Executable changeNumberOperation = () -> changeNumberManager.changeNumber(accountIdentifier,
445+
null,
446+
null,
447+
null,
448+
targetNumber,
449+
pniIdentityKey,
450+
ecSignedPreKeys,
451+
kemLastResortPreKeys,
452+
Collections.emptyList(),
453+
Collections.emptyMap(),
454+
mock(ContainerRequestContext.class));
455+
if (expectRateLimited) {
456+
final RateLimitExceededException e = assertThrows(RateLimitExceededException.class, changeNumberOperation);
457+
458+
assertEquals(Duration.between(CLOCK.instant().minus(POST_REGISTRATION_WAITING_PERIOD), registrationInstant), e.getRetryDuration().orElseThrow());
459+
} else {
460+
changeNumberOperation.execute();
461+
verify(accountsManager).changeNumber(accountIdentifier, targetNumber, pniIdentityKey, ecSignedPreKeys, kemLastResortPreKeys, Collections.emptyMap());
462+
}
463+
}
464+
465+
static Collection<Arguments> testRecentRegistration() {
466+
// truncate to millis because that is the resolution for device.created
467+
final Instant tooRecent = CLOCK.instant().minus(POST_REGISTRATION_WAITING_PERIOD).plusSeconds(1)
468+
.truncatedTo(ChronoUnit.MILLIS);
469+
final Instant outsideWaitingPeriod = CLOCK.instant().minus(POST_REGISTRATION_WAITING_PERIOD).minusSeconds(1)
470+
.truncatedTo(ChronoUnit.MILLIS);
471+
return List.of(
472+
// expect exception, same number, registration instant
473+
Arguments.argumentSet("waiting period elapsed", false, false, outsideWaitingPeriod),
474+
Arguments.argumentSet("waiting period not elapsed", true, false, tooRecent),
475+
Arguments.argumentSet("waiting period not elapsed; same number", false, true, tooRecent)
476+
);
477+
}
384478
}

0 commit comments

Comments
 (0)