Add svr2 credentials to RegistrationLockFailure responses

Add an svr2 credential to 423 responses for:
  - PUT v2/accounts/number
  - POST v1/registration

Also add some openapi annotations to those endpoints
This commit is contained in:
Ravi Khadiwala 2023-05-11 19:17:35 -05:00 committed by ravi-signal
parent 7395b5760a
commit 8c42199baf
10 changed files with 93 additions and 23 deletions

View File

@ -525,7 +525,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
config.getDynamoDbTables().getSubscriptions().getTableName(), dynamoDbAsyncClient);
final RegistrationLockVerificationManager registrationLockVerificationManager = new RegistrationLockVerificationManager(
accountsManager, clientPresenceManager, backupCredentialsGenerator, registrationRecoveryPasswordsManager, pushNotificationManager, rateLimiters);
accountsManager, clientPresenceManager, backupCredentialsGenerator, svr2CredentialsGenerator, registrationRecoveryPasswordsManager, pushNotificationManager, rateLimiters);
final PhoneVerificationTokenManager phoneVerificationTokenManager = new PhoneVerificationTokenManager(
registrationServiceClient, registrationRecoveryPasswordsManager);

View File

@ -8,13 +8,16 @@ package org.whispersystems.textsecuregcm.auth;
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
import com.google.common.annotations.VisibleForTesting;
import javax.annotation.Nullable;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.Response;
import io.micrometer.core.instrument.DistributionSummary;
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Tag;
import io.micrometer.core.instrument.Tags;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import javax.annotation.Nullable;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.Response;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.entities.PhoneVerificationRequest;
import org.whispersystems.textsecuregcm.entities.RegistrationLockFailure;
@ -28,9 +31,6 @@ import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;
import org.whispersystems.textsecuregcm.util.Util;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
public class RegistrationLockVerificationManager {
public enum Flow {
@ -54,20 +54,23 @@ public class RegistrationLockVerificationManager {
private final AccountsManager accounts;
private final ClientPresenceManager clientPresenceManager;
private final ExternalServiceCredentialsGenerator backupServiceCredentialGenerator;
private final ExternalServiceCredentialsGenerator svr1CredentialGenerator;
private final ExternalServiceCredentialsGenerator svr2CredentialGenerator;
private final RateLimiters rateLimiters;
private final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager;
private final PushNotificationManager pushNotificationManager;
public RegistrationLockVerificationManager(
final AccountsManager accounts, final ClientPresenceManager clientPresenceManager,
final ExternalServiceCredentialsGenerator backupServiceCredentialGenerator,
final ExternalServiceCredentialsGenerator svr1CredentialGenerator,
final ExternalServiceCredentialsGenerator svr2CredentialGenerator,
final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager,
final PushNotificationManager pushNotificationManager,
final RateLimiters rateLimiters) {
this.accounts = accounts;
this.clientPresenceManager = clientPresenceManager;
this.backupServiceCredentialGenerator = backupServiceCredentialGenerator;
this.svr1CredentialGenerator = svr1CredentialGenerator;
this.svr2CredentialGenerator = svr2CredentialGenerator;
this.registrationRecoveryPasswordsManager = registrationRecoveryPasswordsManager;
this.pushNotificationManager = pushNotificationManager;
this.rateLimiters = rateLimiters;
@ -138,8 +141,8 @@ public class RegistrationLockVerificationManager {
// Freezing the existing account credentials will definitively start the reglock timeout.
// Until the timeout, the current reglock can still be supplied,
// along with phone number verification, to restore access.
final ExternalServiceCredentials existingBackupCredentials =
backupServiceCredentialGenerator.generateForUuid(account.getUuid());
final ExternalServiceCredentials existingSvr1Credentials = svr1CredentialGenerator.generateForUuid(account.getUuid());
final ExternalServiceCredentials existingSvr2Credentials = svr2CredentialGenerator.generateForUuid(account.getUuid());
final Account updatedAccount;
if (!alreadyLocked) {
@ -170,7 +173,8 @@ public class RegistrationLockVerificationManager {
throw new WebApplicationException(Response.status(FAILURE_HTTP_STATUS)
.entity(new RegistrationLockFailure(existingRegistrationLock.getTimeRemaining().toMillis(),
existingRegistrationLock.needsFailureCredentials() ? existingBackupCredentials : null))
existingRegistrationLock.needsFailureCredentials() ? existingSvr1Credentials : null,
existingRegistrationLock.needsFailureCredentials() ? existingSvr2Credentials : null))
.build());
}

View File

@ -14,6 +14,9 @@ import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Tag;
import io.micrometer.core.instrument.Tags;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.headers.Header;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import java.time.Instant;
import java.util.Optional;
@ -39,8 +42,9 @@ import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;
import org.whispersystems.textsecuregcm.entities.ChangeNumberRequest;
import org.whispersystems.textsecuregcm.entities.MismatchedDevices;
import org.whispersystems.textsecuregcm.entities.PhoneNumberDiscoverabilityRequest;
import org.whispersystems.textsecuregcm.entities.PhoneVerificationRequest;
import org.whispersystems.textsecuregcm.entities.PhoneNumberIdentityKeyDistributionRequest;
import org.whispersystems.textsecuregcm.entities.PhoneVerificationRequest;
import org.whispersystems.textsecuregcm.entities.RegistrationLockFailure;
import org.whispersystems.textsecuregcm.entities.StaleDevices;
import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
@ -77,6 +81,16 @@ public class AccountControllerV2 {
@Path("/number")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Change number", description = "Changes a phone number for an existing account.")
@ApiResponse(responseCode = "200", description = "The phone number associated with the authenticated account was changed successfully", useReturnTypeSchema = true)
@ApiResponse(responseCode = "403", description = "Verification failed for the provided Registration Recovery Password")
@ApiResponse(responseCode = "409", description = "Mismatched number of devices or device ids in 'devices to notify' list", content = @Content(schema = @Schema(implementation = MismatchedDevices.class)))
@ApiResponse(responseCode = "410", description = "Mismatched registration ids in 'devices to notify' list", content = @Content(schema = @Schema(implementation = StaleDevices.class)))
@ApiResponse(responseCode = "422", description = "The request did not pass validation")
@ApiResponse(responseCode = "423", content = @Content(schema = @Schema(implementation = RegistrationLockFailure.class)))
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
name = "Retry-After",
description = "If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed"))
public AccountIdentityResponse changeNumber(@Auth final AuthenticatedAccount authenticatedAccount,
@NotNull @Valid final ChangeNumberRequest request, @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent)
throws RateLimitExceededException, InterruptedException {

View File

@ -13,6 +13,11 @@ import io.micrometer.core.instrument.DistributionSummary;
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Tag;
import io.micrometer.core.instrument.Tags;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.headers.Header;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
@ -34,6 +39,7 @@ import org.whispersystems.textsecuregcm.auth.PhoneVerificationTokenManager;
import org.whispersystems.textsecuregcm.auth.RegistrationLockVerificationManager;
import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;
import org.whispersystems.textsecuregcm.entities.PhoneVerificationRequest;
import org.whispersystems.textsecuregcm.entities.RegistrationLockFailure;
import org.whispersystems.textsecuregcm.entities.RegistrationRequest;
import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
@ -78,6 +84,22 @@ public class RegistrationController {
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Registers an account",
description = """
Registers a new account or attempts to re-register an existing account. It is expected that a well-behaved client
could make up to three consecutive calls to this API:
1. gets 423 from existing registration lock \n
2. gets 409 from device available for transfer \n
3. success \n
""")
@ApiResponse(responseCode = "200", description = "The phone number associated with the authenticated account was changed successfully", useReturnTypeSchema = true)
@ApiResponse(responseCode = "403", description = "Verification failed for the provided Registration Recovery Password")
@ApiResponse(responseCode = "409", description = "The caller has not explicitly elected to skip transferring data from another device, but a device transfer is technically possible")
@ApiResponse(responseCode = "422", description = "The request did not pass validation")
@ApiResponse(responseCode = "423", content = @Content(schema = @Schema(implementation = RegistrationLockFailure.class)))
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
name = "Retry-After",
description = "If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed"))
public AccountIdentityResponse register(
@HeaderParam(HttpHeaders.AUTHORIZATION) @NotNull final BasicAuthorizationHeader authorizationHeader,
@HeaderParam(HeaderUtils.X_SIGNAL_AGENT) final String signalAgent,

View File

@ -7,15 +7,18 @@ package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.google.common.annotations.VisibleForTesting;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
public class MismatchedDevices {
@JsonProperty
@Schema(description = "Devices present on the account but absent in the request")
public List<Long> missingDevices;
@JsonProperty
@Schema(description = "Devices absent on the request but present in the account")
public List<Long> extraDevices;
@VisibleForTesting

View File

@ -5,8 +5,16 @@
package org.whispersystems.textsecuregcm.entities;
import io.swagger.v3.oas.annotations.media.Schema;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
public record RegistrationLockFailure(long timeRemaining, ExternalServiceCredentials backupCredentials) {
@Schema(description = "A token provided to the client via a push payload")
public record RegistrationLockFailure(
@Schema(description = "Time remaining in milliseconds before the existing registration lock expires")
long timeRemaining,
@Schema(description = "Credentials that can be used with SVR1")
ExternalServiceCredentials backupCredentials,
@Schema(description = "Credentials that can be used with SVR2")
ExternalServiceCredentials svr2Credentials) {
}

View File

@ -6,12 +6,14 @@
package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
public class StaleDevices {
@JsonProperty
@Schema(description = "Devices that are no longer active")
private List<Long> staleDevices;
public StaleDevices() {}

View File

@ -47,14 +47,16 @@ class RegistrationLockVerificationManagerTest {
private final AccountsManager accountsManager = mock(AccountsManager.class);
private final ClientPresenceManager clientPresenceManager = mock(ClientPresenceManager.class);
private final ExternalServiceCredentialsGenerator backupServiceCredentialsGeneraor = mock(
private final ExternalServiceCredentialsGenerator svr1CredentialsGenerator = mock(
ExternalServiceCredentialsGenerator.class);
private final ExternalServiceCredentialsGenerator svr2CredentialsGenerator = mock(
ExternalServiceCredentialsGenerator.class);
private final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager = mock(
RegistrationRecoveryPasswordsManager.class);
private static PushNotificationManager pushNotificationManager = mock(PushNotificationManager.class);
private final RateLimiters rateLimiters = mock(RateLimiters.class);
private final RegistrationLockVerificationManager registrationLockVerificationManager = new RegistrationLockVerificationManager(
accountsManager, clientPresenceManager, backupServiceCredentialsGeneraor, registrationRecoveryPasswordsManager, pushNotificationManager, rateLimiters);
accountsManager, clientPresenceManager, svr1CredentialsGenerator, svr2CredentialsGenerator, registrationRecoveryPasswordsManager, pushNotificationManager, rateLimiters);
private final RateLimiter pinLimiter = mock(RateLimiter.class);
@ -65,7 +67,9 @@ class RegistrationLockVerificationManagerTest {
void setUp() {
clearInvocations(pushNotificationManager);
when(rateLimiters.getPinLimiter()).thenReturn(pinLimiter);
when(backupServiceCredentialsGeneraor.generateForUuid(any()))
when(svr1CredentialsGenerator.generateForUuid(any()))
.thenReturn(mock(ExternalServiceCredentials.class));
when(svr2CredentialsGenerator.generateForUuid(any()))
.thenReturn(mock(ExternalServiceCredentials.class));
final Device device = mock(Device.class);

View File

@ -72,6 +72,7 @@ import org.signal.libsignal.protocol.ecc.ECKeyPair;
import org.signal.libsignal.usernames.BaseUsernameException;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.auth.RegistrationLockVerificationManager;
import org.whispersystems.textsecuregcm.auth.SaltedTokenHash;
@ -83,6 +84,7 @@ import org.whispersystems.textsecuregcm.captcha.AssessmentResult;
import org.whispersystems.textsecuregcm.captcha.CaptchaChecker;
import org.whispersystems.textsecuregcm.captcha.RegistrationCaptchaManager;
import org.whispersystems.textsecuregcm.configuration.SecureBackupServiceConfiguration;
import org.whispersystems.textsecuregcm.configuration.SecureValueRecovery2Configuration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicCaptchaConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
@ -199,15 +201,25 @@ class AccountControllerTest {
private static DynamicConfigurationManager dynamicConfigurationManager = mock(DynamicConfigurationManager.class);
private byte[] registration_lock_key = new byte[32];
private static final SecureBackupServiceConfiguration BACKUP_CFG = MockUtils.buildMock(
private static final SecureBackupServiceConfiguration SVR1_CFG = MockUtils.buildMock(
SecureBackupServiceConfiguration.class,
cfg -> when(cfg.getUserAuthenticationTokenSharedSecret()).thenReturn(new byte[32]));
private static final ExternalServiceCredentialsGenerator backupCredentialsGenerator = SecureBackupController.credentialsGenerator(
BACKUP_CFG);
private static final SecureValueRecovery2Configuration SVR2_CFG = MockUtils.buildMock(
SecureValueRecovery2Configuration.class,
cfg -> {
when(cfg.userAuthenticationTokenSharedSecret()).thenReturn(new byte[32]);
when(cfg.userIdTokenSharedSecret()).thenReturn(new byte[32]);
});
private static final ExternalServiceCredentialsGenerator svr1CredentialsGenerator = SecureBackupController.credentialsGenerator(
SVR1_CFG);
private static final ExternalServiceCredentialsGenerator svr2CredentialsGenerator = SecureValueRecovery2Controller.credentialsGenerator(
SVR2_CFG);
private static final RegistrationLockVerificationManager registrationLockVerificationManager = new RegistrationLockVerificationManager(
accountsManager, clientPresenceManager, backupCredentialsGenerator, registrationRecoveryPasswordsManager,
accountsManager, clientPresenceManager, svr1CredentialsGenerator, svr2CredentialsGenerator, registrationRecoveryPasswordsManager,
pushNotificationManager, rateLimiters);
private static final RegistrationCaptchaManager registrationCaptchaManager = new RegistrationCaptchaManager(
captchaChecker, rateLimiters, Map.of(TEST_NUMBER, 123456), dynamicConfigurationManager);
@ -1268,6 +1280,8 @@ class AccountControllerTest {
assertThat(failure.backupCredentials().username()).isEqualTo(SENDER_REG_LOCK_UUID.toString());
assertThat(failure.backupCredentials().password()).isNotEmpty();
assertThat(failure.backupCredentials().password().startsWith(SENDER_REG_LOCK_UUID.toString())).isTrue();
assertThat(failure.svr2Credentials()).isNotNull();
assertThat(failure.svr2Credentials()).isEqualTo(svr2CredentialsGenerator.generateFor(SENDER_REG_LOCK_UUID.toString()));
assertThat(failure.timeRemaining()).isGreaterThan(0);
// verify(senderRegLockAccount).lockAuthenticationCredentials();

View File

@ -71,7 +71,6 @@ import org.whispersystems.textsecuregcm.entities.AccountDataReportResponse;
import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;
import org.whispersystems.textsecuregcm.entities.ChangeNumberRequest;
import org.whispersystems.textsecuregcm.entities.PhoneNumberDiscoverabilityRequest;
import org.whispersystems.textsecuregcm.entities.PhoneNumberIdentityKeyDistributionRequest;
import org.whispersystems.textsecuregcm.entities.RegistrationServiceSession;
import org.whispersystems.textsecuregcm.entities.SignedPreKey;
import org.whispersystems.textsecuregcm.limits.RateLimiter;