0
0
mirror of https://github.com/signalapp/Signal-Server.git synced 2024-09-20 03:52:16 +02:00

Add svr3 share-set store/retrieve

This commit is contained in:
ravi-signal 2024-05-17 10:45:18 -05:00 committed by GitHub
parent 1182d159aa
commit ce1c5be940
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 493 additions and 92 deletions

View File

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

View File

@ -22,6 +22,7 @@ import org.apache.commons.lang3.StringUtils;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.entities.PhoneVerificationRequest;
import org.whispersystems.textsecuregcm.entities.RegistrationLockFailure;
import org.whispersystems.textsecuregcm.entities.Svr3Credentials;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
@ -55,6 +56,7 @@ public class RegistrationLockVerificationManager {
private final AccountsManager accounts;
private final ClientPresenceManager clientPresenceManager;
private final ExternalServiceCredentialsGenerator svr2CredentialGenerator;
private final ExternalServiceCredentialsGenerator svr3CredentialGenerator;
private final RateLimiters rateLimiters;
private final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager;
private final PushNotificationManager pushNotificationManager;
@ -62,12 +64,14 @@ public class RegistrationLockVerificationManager {
public RegistrationLockVerificationManager(
final AccountsManager accounts, final ClientPresenceManager clientPresenceManager,
final ExternalServiceCredentialsGenerator svr2CredentialGenerator,
final ExternalServiceCredentialsGenerator svr3CredentialGenerator,
final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager,
final PushNotificationManager pushNotificationManager,
final RateLimiters rateLimiters) {
this.accounts = accounts;
this.clientPresenceManager = clientPresenceManager;
this.svr2CredentialGenerator = svr2CredentialGenerator;
this.svr3CredentialGenerator = svr3CredentialGenerator;
this.registrationRecoveryPasswordsManager = registrationRecoveryPasswordsManager;
this.pushNotificationManager = pushNotificationManager;
this.rateLimiters = rateLimiters;
@ -138,8 +142,6 @@ 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 existingSvr2Credentials = svr2CredentialGenerator.generateForUuid(account.getUuid());
final Account updatedAccount;
if (!alreadyLocked) {
updatedAccount = accounts.update(account, Account::lockAuthTokenHash);
@ -168,11 +170,28 @@ public class RegistrationLockVerificationManager {
}
throw new WebApplicationException(Response.status(FAILURE_HTTP_STATUS)
.entity(new RegistrationLockFailure(existingRegistrationLock.getTimeRemaining().toMillis(),
existingRegistrationLock.needsFailureCredentials() ? existingSvr2Credentials : null))
.entity(new RegistrationLockFailure(
existingRegistrationLock.getTimeRemaining().toMillis(),
svr2FailureCredentials(existingRegistrationLock, updatedAccount),
svr3FailureCredentials(existingRegistrationLock, updatedAccount)))
.build());
}
rateLimiters.getPinLimiter().clear(phoneNumber);
}
private @Nullable ExternalServiceCredentials svr2FailureCredentials(final StoredRegistrationLock existingRegistrationLock, final Account account) {
if (!existingRegistrationLock.needsFailureCredentials()) {
return null;
}
return svr2CredentialGenerator.generateForUuid(account.getUuid());
}
private @Nullable Svr3Credentials svr3FailureCredentials(final StoredRegistrationLock existingRegistrationLock, final Account account) {
if (!existingRegistrationLock.needsFailureCredentials()) {
return null;
}
final ExternalServiceCredentials creds = svr3CredentialGenerator.generateForUuid(account.getUuid());
return new Svr3Credentials(creds.username(), creds.password(), account.getSvr3ShareSet());
}
}

View File

@ -29,7 +29,7 @@ import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsSelector;
import org.whispersystems.textsecuregcm.configuration.SecureValueRecovery2Configuration;
import org.whispersystems.textsecuregcm.entities.AuthCheckRequest;
import org.whispersystems.textsecuregcm.entities.AuthCheckResponse;
import org.whispersystems.textsecuregcm.entities.AuthCheckResponseV2;
import org.whispersystems.textsecuregcm.limits.RateLimitedByIp;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.storage.Account;
@ -100,9 +100,9 @@ public class SecureValueRecovery2Controller {
@ApiResponse(responseCode = "200", description = "`JSON` with the check results.", useReturnTypeSchema = true)
@ApiResponse(responseCode = "422", description = "Provided list of SVR2 credentials could not be parsed")
@ApiResponse(responseCode = "400", description = "`POST` request body is not a valid `JSON`")
public AuthCheckResponse authCheck(@NotNull @Valid final AuthCheckRequest request) {
public AuthCheckResponseV2 authCheck(@NotNull @Valid final AuthCheckRequest request) {
final List<ExternalServiceCredentialsSelector.CredentialInfo> credentials = ExternalServiceCredentialsSelector.check(
request.passwords(),
request.tokens(),
backupServiceCredentialGenerator,
MAX_AGE_SECONDS);
@ -113,16 +113,16 @@ public class SecureValueRecovery2Controller {
.map(backupServiceCredentialGenerator::generateForUuid)
.map(ExternalServiceCredentials::username);
return new AuthCheckResponse(credentials.stream().collect(Collectors.toMap(
return new AuthCheckResponseV2(credentials.stream().collect(Collectors.toMap(
ExternalServiceCredentialsSelector.CredentialInfo::token,
info -> {
if (!info.valid()) {
return AuthCheckResponse.Result.INVALID;
return AuthCheckResponseV2.Result.INVALID;
}
final String username = info.credentials().username();
// does this credential match the account id for the e164 provided in the request?
boolean match = matchingUsername.filter(username::equals).isPresent();
return match ? AuthCheckResponse.Result.MATCH : AuthCheckResponse.Result.NO_MATCH;
return match ? AuthCheckResponseV2.Result.MATCH : AuthCheckResponseV2.Result.NO_MATCH;
}
)));
}

View File

@ -10,19 +10,6 @@ import io.dropwizard.auth.Auth;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsSelector;
import org.whispersystems.textsecuregcm.configuration.SecureValueRecovery3Configuration;
import org.whispersystems.textsecuregcm.entities.AuthCheckRequest;
import org.whispersystems.textsecuregcm.entities.AuthCheckResponse;
import org.whispersystems.textsecuregcm.limits.RateLimitedByIp;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.websocket.auth.ReadOnly;
import java.time.Clock;
import java.util.List;
import java.util.Optional;
@ -33,9 +20,26 @@ import javax.validation.constraints.NotNull;
import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsSelector;
import org.whispersystems.textsecuregcm.configuration.SecureValueRecovery3Configuration;
import org.whispersystems.textsecuregcm.entities.AuthCheckRequest;
import org.whispersystems.textsecuregcm.entities.AuthCheckResponseV3;
import org.whispersystems.textsecuregcm.entities.SetShareSetRequest;
import org.whispersystems.textsecuregcm.entities.Svr3Credentials;
import org.whispersystems.textsecuregcm.limits.RateLimitedByIp;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.util.Optionals;
import org.whispersystems.websocket.auth.Mutable;
import org.whispersystems.websocket.auth.ReadOnly;
@Path("/v3/backup")
@Tag(name = "Secure Value Recovery")
@ -48,7 +52,8 @@ public class SecureValueRecovery3Controller {
}
@VisibleForTesting
public static ExternalServiceCredentialsGenerator credentialsGenerator(final SecureValueRecovery3Configuration cfg, final Clock clock) {
public static ExternalServiceCredentialsGenerator credentialsGenerator(final SecureValueRecovery3Configuration cfg,
final Clock clock) {
return ExternalServiceCredentialsGenerator
.builder(cfg.userAuthenticationTokenSharedSecret())
.withUserDerivationKey(cfg.userIdTokenSharedSecret().value())
@ -62,7 +67,7 @@ public class SecureValueRecovery3Controller {
private final AccountsManager accountsManager;
public SecureValueRecovery3Controller(final ExternalServiceCredentialsGenerator backupServiceCredentialGenerator,
final AccountsManager accountsManager) {
final AccountsManager accountsManager) {
this.backupServiceCredentialGenerator = backupServiceCredentialGenerator;
this.accountsManager = accountsManager;
}
@ -73,16 +78,36 @@ public class SecureValueRecovery3Controller {
@Operation(
summary = "Generate credentials for SVR3",
description = """
Generate SVR3 service credentials. Generated credentials have an expiration time of 30 days
Generate SVR3 service credentials. Generated credentials have an expiration time of 30 days
(however, the TTL is fully controlled by the server side and may change even for already generated credentials).
"""
)
@ApiResponse(responseCode = "200", description = "`JSON` with generated credentials.", useReturnTypeSchema = true)
If a share-set has been previously set via /v3/backups/share-set, it will be included in the response
""")
@ApiResponse(responseCode = "200", description = "`JSON` with generated credentials and share-set", useReturnTypeSchema = true)
@ApiResponse(responseCode = "401", description = "Account authentication check failed.")
public ExternalServiceCredentials getAuth(@ReadOnly @Auth final AuthenticatedAccount auth) {
return backupServiceCredentialGenerator.generateFor(auth.getAccount().getUuid().toString());
public Svr3Credentials getAuth(@ReadOnly @Auth final AuthenticatedAccount auth) {
final ExternalServiceCredentials creds = backupServiceCredentialGenerator.generateFor(
auth.getAccount().getUuid().toString());
return new Svr3Credentials(creds.username(), creds.password(), auth.getAccount().getSvr3ShareSet());
}
@PUT
@Path("/share-set")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(
summary = "Set a share-set for the account",
description = """
Add a share-set to the account that can later be retrieved at v3/backups/auth or during registration. After
storing a value with SVR3, clients must store the returned share-set so the value can be restored later.
""")
@ApiResponse(responseCode = "204", description = "Successfully set share-set")
@ApiResponse(responseCode = "401", description = "Account authentication check failed.")
public void setShareSet(
@Mutable @Auth final AuthenticatedAccount auth,
@NotNull @Valid final SetShareSetRequest request) {
accountsManager.update(auth.getAccount(), account -> account.setSvr3ShareSet(request.shareSet()));
}
@POST
@Path("/auth/check")
@ -92,38 +117,44 @@ public class SecureValueRecovery3Controller {
@Operation(
summary = "Check SVR3 credentials",
description = """
Over time, clients may wind up with multiple sets of SVR3 authentication credentials in cloud storage.
Over time, clients may wind up with multiple sets of SVR3 authentication credentials in cloud storage.
To determine which set is most current and should be used to communicate with SVR3 to retrieve a master key
(from which a registration recovery password can be derived), clients should call this endpoint
with a list of stored credentials. The response will identify which (if any) set of credentials are appropriate for communicating with SVR3.
"""
)
(from which a registration recovery password can be derived), clients should call this endpoint
with a list of stored credentials. The response will identify which (if any) set of credentials are
appropriate for communicating with SVR3.
""")
@ApiResponse(responseCode = "200", description = "`JSON` with the check results.", useReturnTypeSchema = true)
@ApiResponse(responseCode = "422", description = "Provided list of SVR3 credentials could not be parsed")
@ApiResponse(responseCode = "400", description = "`POST` request body is not a valid `JSON`")
public AuthCheckResponse authCheck(@NotNull @Valid final AuthCheckRequest request) {
public AuthCheckResponseV3 authCheck(@NotNull @Valid final AuthCheckRequest request) {
final List<ExternalServiceCredentialsSelector.CredentialInfo> credentials = ExternalServiceCredentialsSelector.check(
request.passwords(),
request.tokens(),
backupServiceCredentialGenerator,
MAX_AGE_SECONDS);
final Optional<Account> account = accountsManager.getByE164(request.number());
// the username associated with the provided number
final Optional<String> matchingUsername = accountsManager
.getByE164(request.number())
final Optional<String> matchingUsername = account
.map(Account::getUuid)
.map(backupServiceCredentialGenerator::generateForUuid)
.map(ExternalServiceCredentials::username);
return new AuthCheckResponse(credentials.stream().collect(Collectors.toMap(
return new AuthCheckResponseV3(credentials.stream().collect(Collectors.toMap(
ExternalServiceCredentialsSelector.CredentialInfo::token,
info -> {
if (!info.valid()) {
return AuthCheckResponse.Result.INVALID;
// This isn't a valid credential (could be for a different SVR service, expired, etc)
return AuthCheckResponseV3.Result.invalid();
}
final String username = info.credentials().username();
// does this credential match the account id for the e164 provided in the request?
boolean match = matchingUsername.filter(username::equals).isPresent();
return match ? AuthCheckResponse.Result.MATCH : AuthCheckResponse.Result.NO_MATCH;
final String credUsername = info.credentials().username();
return Optionals
// If the account exists, and the account's username matches this credential's username, return a match
.zipWith(account, matchingUsername.filter(credUsername::equals), (a, ignored) ->
AuthCheckResponseV3.Result.match(a.getSvr3ShareSet()))
// Otherwise, return no-match
.orElseGet(AuthCheckResponseV3.Result::noMatch);
}
)));
}

View File

@ -5,6 +5,8 @@
package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
import javax.validation.constraints.NotEmpty;
@ -14,6 +16,10 @@ import org.whispersystems.textsecuregcm.util.E164;
public record AuthCheckRequest(@Schema(description = "The e164-formatted phone number.")
@NotNull @E164 String number,
@Schema(description = "A list of SVR auth values, previously retrieved from `/v1/backup/auth`; may contain at most 10.")
@NotEmpty @Size(max = 10) List<String> passwords) {
@Schema(description = """
A list of SVR tokens, previously retrieved from `backup/auth`. Tokens should be the
of the form "username:password". May contain at most 10 tokens.""")
@JsonProperty("tokens")
@JsonAlias("passwords") // deprecated
@NotEmpty @Size(max = 10) List<String> tokens) {
}

View File

@ -10,8 +10,8 @@ import io.swagger.v3.oas.annotations.media.Schema;
import java.util.Map;
import javax.validation.constraints.NotNull;
public record AuthCheckResponse(@Schema(description = "A dictionary with the auth check results: `KBS Credentials -> 'match'/'no-match'/'invalid'`")
@NotNull Map<String, Result> matches) {
public record AuthCheckResponseV2(@Schema(description = "A dictionary with the auth check results: `SVR Credentials -> 'match'/'no-match'/'invalid'`")
@NotNull Map<String, Result> matches) {
public enum Result {
MATCH("match"),

View File

@ -0,0 +1,58 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonValue;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.Map;
import javax.annotation.Nullable;
import javax.validation.constraints.NotNull;
public record AuthCheckResponseV3(
@Schema(description = """
A dictionary with the auth check results, keyed by the token corresponding token provided in the request.
""")
@NotNull Map<String, Result> matches) {
public record Result(
@Schema(description = "The status of the credential, either match, no-match, or invalid")
CredentialStatus status,
@Schema(description = """
If the credential was a match, the stored shareSet that can be used to restore a value from SVR. Encoded as
""")
@Nullable byte[] shareSet) {
public static Result invalid() {
return new Result(CredentialStatus.INVALID, null);
}
public static Result noMatch() {
return new Result(CredentialStatus.NO_MATCH, null);
}
public static Result match(@Nullable final byte[] shareSet) {
return new Result(CredentialStatus.MATCH, shareSet);
}
}
public enum CredentialStatus {
MATCH("match"),
NO_MATCH("no-match"),
INVALID("invalid");
private final String clientCode;
CredentialStatus(final String clientCode) {
this.clientCode = clientCode;
}
@JsonValue
public String clientCode() {
return clientCode;
}
}
}

View File

@ -8,11 +8,15 @@ package org.whispersystems.textsecuregcm.entities;
import io.swagger.v3.oas.annotations.media.Schema;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
@Schema(description = "A token provided to the client via a push payload")
@Schema(description = """
Information about the current Registration lock and SVR credentials. With a correct PIN, the credentials can
be used to recover the secret used to derive the registration lock password.
""")
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 SVR2")
ExternalServiceCredentials svr2Credentials) {
ExternalServiceCredentials svr2Credentials,
@Schema(description = "Credentials that can be used with SVR3")
Svr3Credentials svr3Credentials) {
}

View File

@ -0,0 +1,22 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import io.swagger.v3.oas.annotations.media.Schema;
import org.whispersystems.textsecuregcm.util.ByteArrayAdapter;
import org.whispersystems.textsecuregcm.util.ExactlySize;
import javax.validation.constraints.NotEmpty;
public record SetShareSetRequest(
@Schema(description = """
A share-set generated by a client after storing a value in SVR3, serialized in un-padded standard base64
""", implementation = String.class)
@JsonDeserialize(using = ByteArrayAdapter.Deserializing.class)
@NotEmpty
@ExactlySize(SHARE_SET_SIZE)
byte[] shareSet) {
public static final int SHARE_SET_SIZE = 169;
}

View File

@ -0,0 +1,28 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import io.swagger.v3.oas.annotations.media.Schema;
import javax.annotation.Nullable;
import org.whispersystems.textsecuregcm.util.ByteArrayAdapter;
@Schema(description = """
A time limited external service credential that can be used to authenticate and restore from SVR3.
""")
public record Svr3Credentials(
@Schema(description = "The credential username")
String username,
@Schema(description = "The credential password")
String password,
@Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED, description = """
If present, a shareSet previously stored for this account via /v3/backups/shareSet. Required to restore a value
from SVR3. Encoded in standard un-padded base64.
""", implementation = String.class)
@JsonSerialize(using = ByteArrayAdapter.Serializing.class)
@Nullable byte[] shareSet) {}

View File

@ -101,6 +101,19 @@ public class Account {
@JsonProperty("inCds")
private boolean discoverableByPhoneNumber = true;
/**
* A share-set the account holder has stored.
*
* A share-set is generated when a client stores a value in SVR3, and should be stored here with the account. When
* they later want to recover the value, they need their share-set and their secret pin. The share-set is not a secret
* and, without the correct pin, is useless information.
*
* SVR3 share-sets are currently 167 bytes.
*/
@JsonProperty("svr3ss")
@Nullable
private byte[] svr3ShareSet;
@JsonProperty("bcr")
@Nullable
private byte[] backupCredentialRequest;
@ -505,6 +518,14 @@ public class Account {
this.version = version;
}
public @Nullable byte[] getSvr3ShareSet() {
return svr3ShareSet;
}
public void setSvr3ShareSet(final byte[] svr3ShareSet) {
this.svr3ShareSet = svr3ShareSet;
}
public byte[] getBackupCredentialRequest() {
return backupCredentialRequest;
}

View File

@ -309,6 +309,10 @@ public class Accounts extends AbstractDynamoDbStore {
// won't be rate-limited for setting their backup-id.
accountToCreate.setBackupCredentialRequest(existingAccount.getBackupCredentialRequest());
// Carry over the old SVR3 share-set. This is required for an account to restore information from SVR. The share-
// set is not a secret, if the new account claimer does not have the SVR3 pin, it is useless.
accountToCreate.setSvr3ShareSet(existingAccount.getSvr3ShareSet());
final List<TransactWriteItem> writeItems = new ArrayList<>();
// If we're reclaiming an account that already has a username, we'd like to give the re-registering client

View File

@ -0,0 +1,21 @@
package org.whispersystems.textsecuregcm.util;
import java.util.Optional;
import java.util.function.BiFunction;
public class Optionals {
private Optionals() {}
/**
* Apply a function to two optional arguments, returning empty if either argument is empty
*
* @param optionalT Optional of type T
* @param optionalU Optional of type U
* @param fun Function of T and U that returns R
* @return The function applied to the values of optionalT and optionalU, or empty
*/
public static <T, U, R> Optional<R> zipWith(Optional<T> optionalT, Optional<U> optionalU, BiFunction<T, U, R> fun) {
return optionalT.flatMap(t -> optionalU.map(u -> fun.apply(t, u)));
}
}

View File

@ -49,12 +49,15 @@ class RegistrationLockVerificationManagerTest {
private final ClientPresenceManager clientPresenceManager = mock(ClientPresenceManager.class);
private final ExternalServiceCredentialsGenerator svr2CredentialsGenerator = mock(
ExternalServiceCredentialsGenerator.class);
private final ExternalServiceCredentialsGenerator svr3CredentialsGenerator = 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, svr2CredentialsGenerator, registrationRecoveryPasswordsManager, pushNotificationManager, rateLimiters);
accountsManager, clientPresenceManager, svr2CredentialsGenerator, svr3CredentialsGenerator,
registrationRecoveryPasswordsManager, pushNotificationManager, rateLimiters);
private final RateLimiter pinLimiter = mock(RateLimiter.class);

View File

@ -15,10 +15,14 @@ import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;
import org.junit.jupiter.api.extension.ExtendWith;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.configuration.SecureValueRecovery2Configuration;
import org.whispersystems.textsecuregcm.entities.AuthCheckResponseV2;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
import org.whispersystems.textsecuregcm.util.MutableClock;
import org.whispersystems.textsecuregcm.util.SystemMapper;
import javax.ws.rs.core.Response;
import java.util.Map;
import java.util.stream.Collectors;
@ExtendWith(DropwizardExtensionsSupport.class)
public class SecureValueRecovery2ControllerTest extends SecureValueRecoveryControllerBaseTest {
@ -51,4 +55,16 @@ public class SecureValueRecovery2ControllerTest extends SecureValueRecoveryContr
protected SecureValueRecovery2ControllerTest() {
super("/v2", ACCOUNTS_MANAGER, CLOCK, RESOURCES, CREDENTIAL_GENERATOR);
}
@Override
Map<String, CheckStatus> parseCheckResponse(final Response response) {
final AuthCheckResponseV2 authCheckResponseV2 = response.readEntity(AuthCheckResponseV2.class);
return authCheckResponseV2.matches().entrySet().stream().collect(Collectors.toMap(
Map.Entry::getKey, e -> switch (e.getValue()) {
case MATCH -> CheckStatus.MATCH;
case INVALID -> CheckStatus.INVALID;
case NO_MATCH -> CheckStatus.NO_MATCH;
}
));
}
}

View File

@ -6,20 +6,50 @@
package org.whispersystems.textsecuregcm.controllers;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.whispersystems.textsecuregcm.util.MockUtils.randomSecretBytes;
import io.dropwizard.auth.AuthValueFactoryProvider;
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
import io.dropwizard.testing.junit5.ResourceExtension;
import java.util.Base64;
import java.util.Collections;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.annotation.Nullable;
import javax.ws.rs.client.Entity;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.configuration.SecureValueRecovery2Configuration;
import org.whispersystems.textsecuregcm.configuration.SecureValueRecovery3Configuration;
import org.whispersystems.textsecuregcm.entities.AuthCheckRequest;
import org.whispersystems.textsecuregcm.entities.AuthCheckResponseV3;
import org.whispersystems.textsecuregcm.entities.SetShareSetRequest;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
import org.whispersystems.textsecuregcm.util.MutableClock;
import org.whispersystems.textsecuregcm.util.SystemMapper;
import static org.mockito.Mockito.mock;
import static org.whispersystems.textsecuregcm.util.MockUtils.randomSecretBytes;
import org.whispersystems.textsecuregcm.util.TestRandomUtil;
@ExtendWith(DropwizardExtensionsSupport.class)
public class SecureValueRecovery3ControllerTest extends SecureValueRecoveryControllerBaseTest {
@ -44,6 +74,7 @@ public class SecureValueRecovery3ControllerTest extends SecureValueRecoveryContr
private static final ResourceExtension RESOURCES = ResourceExtension.builder()
.addProvider(AuthHelper.getAuthFilter())
.addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedAccount.class))
.setMapper(SystemMapper.jsonMapper())
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
.addResource(CONTROLLER)
@ -52,4 +83,100 @@ public class SecureValueRecovery3ControllerTest extends SecureValueRecoveryContr
protected SecureValueRecovery3ControllerTest() {
super("/v3", ACCOUNTS_MANAGER, CLOCK, RESOURCES, CREDENTIAL_GENERATOR);
}
@Override
Map<String, CheckStatus> parseCheckResponse(final Response response) {
final AuthCheckResponseV3 authCheckResponse = response.readEntity(AuthCheckResponseV3.class);
assertFalse(authCheckResponse.matches()
.values().stream()
.anyMatch(r -> r.status() == AuthCheckResponseV3.CredentialStatus.MATCH && r.shareSet() == null),
"SVR3 matches must contain a non-empty share-set");
return authCheckResponse.matches().entrySet().stream().collect(Collectors.toMap(
Map.Entry::getKey, e -> switch (e.getValue().status()) {
case MATCH -> CheckStatus.MATCH;
case INVALID -> CheckStatus.INVALID;
case NO_MATCH -> CheckStatus.NO_MATCH;
}
));
}
public static Stream<Arguments> checkShareSet() {
byte[] shareSet = TestRandomUtil.nextBytes(100);
return Stream.of(
Arguments.of(shareSet, AuthCheckResponseV3.Result.match(shareSet)),
Arguments.of(null, AuthCheckResponseV3.Result.match(null)));
}
@ParameterizedTest
@MethodSource
public void checkShareSet(@Nullable byte[] shareSet, AuthCheckResponseV3.Result expectedResult) {
final String e164 = "+18005550101";
final UUID uuid = UUID.randomUUID();
final String token = token(uuid, day(10));
CLOCK.setTimeMillis(day(11));
final Account a = mock(Account.class);
when(a.getUuid()).thenReturn(uuid);
when(a.getSvr3ShareSet()).thenReturn(shareSet);
when(ACCOUNTS_MANAGER.getByE164(e164)).thenReturn(Optional.of(a));
final AuthCheckRequest in = new AuthCheckRequest(e164, Collections.singletonList(token));
final Response response = RESOURCES.getJerseyTest()
.target("/v3/backup/auth/check")
.request()
.post(Entity.entity(in, MediaType.APPLICATION_JSON));
try (response) {
assertEquals(200, response.getStatus());
AuthCheckResponseV3 checkResponse = response.readEntity(AuthCheckResponseV3.class);
assertEquals(checkResponse.matches().size(), 1);
assertEquals(checkResponse.matches().get(token).status(), expectedResult.status());
assertArrayEquals(checkResponse.matches().get(token).shareSet(), expectedResult.shareSet());
}
}
@Test
public void setShareSet() {
final Account a = mock(Account.class);
when(ACCOUNTS_MANAGER.update(any(), any())).thenAnswer(invocation -> {
final Consumer<Account> updater = invocation.getArgument(1);
updater.accept(a);
return null;
});
byte[] shareSet = TestRandomUtil.nextBytes(SetShareSetRequest.SHARE_SET_SIZE);
final Response response = RESOURCES.getJerseyTest()
.target("/v3/backup/share-set")
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.put(Entity.entity(new SetShareSetRequest(shareSet), MediaType.APPLICATION_JSON));
assertEquals(204, response.getStatus());
verify(a, times(1)).setSvr3ShareSet(eq(shareSet));
}
static Stream<Arguments> requestParsing() {
return Stream.of(
Arguments.of("", 422),
Arguments.of(null, 422),
Arguments.of("abc**", 400), // bad base64
Arguments.of(Base64.getEncoder().encodeToString(TestRandomUtil.nextBytes(SetShareSetRequest.SHARE_SET_SIZE - 1)), 422),
Arguments.of(Base64.getEncoder().encodeToString(TestRandomUtil.nextBytes(SetShareSetRequest.SHARE_SET_SIZE)), 204));
}
@ParameterizedTest
@MethodSource
public void requestParsing(String shareSet, int responseCode) {
final Response response = RESOURCES.getJerseyTest()
.target("/v3/backup/share-set")
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.put(Entity.entity("""
{"shareSet": "%s"}
""".formatted(shareSet), MediaType.APPLICATION_JSON));
assertEquals(responseCode, response.getStatus());
}
}

View File

@ -23,10 +23,10 @@ import org.mockito.Mockito;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.entities.AuthCheckRequest;
import org.whispersystems.textsecuregcm.entities.AuthCheckResponse;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.util.MutableClock;
import org.whispersystems.textsecuregcm.util.TestRandomUtil;
abstract class SecureValueRecoveryControllerBaseTest {
@ -64,20 +64,27 @@ abstract class SecureValueRecoveryControllerBaseTest {
this.clock = mutableClock;
}
enum CheckStatus {
MATCH,
NO_MATCH,
INVALID
}
abstract Map<String, CheckStatus> parseCheckResponse(Response response);
@Test
public void testOneMatch() throws Exception {
validate(Map.of(
token(USER_1, day(1)), AuthCheckResponse.Result.MATCH,
token(USER_2, day(1)), AuthCheckResponse.Result.NO_MATCH,
token(USER_3, day(1)), AuthCheckResponse.Result.NO_MATCH
token(USER_1, day(1)), CheckStatus.MATCH,
token(USER_2, day(1)), CheckStatus.NO_MATCH,
token(USER_3, day(1)), CheckStatus.NO_MATCH
), day(2));
}
@Test
public void testNoMatch() throws Exception {
validate(Map.of(
token(USER_2, day(1)), AuthCheckResponse.Result.NO_MATCH,
token(USER_3, day(1)), AuthCheckResponse.Result.NO_MATCH
token(USER_2, day(1)), CheckStatus.NO_MATCH,
token(USER_3, day(1)), CheckStatus.NO_MATCH
), day(2));
}
@ -89,35 +96,35 @@ abstract class SecureValueRecoveryControllerBaseTest {
final String fakeToken = token(new ExternalServiceCredentials(user2Cred.username(), user3Cred.password()));
validate(Map.of(
token(user1Cred), AuthCheckResponse.Result.MATCH,
token(user2Cred), AuthCheckResponse.Result.NO_MATCH,
fakeToken, AuthCheckResponse.Result.INVALID
token(user1Cred), CheckStatus.MATCH,
token(user2Cred), CheckStatus.NO_MATCH,
fakeToken, CheckStatus.INVALID
), day(2));
}
@Test
public void testSomeExpired() throws Exception {
validate(Map.of(
token(USER_1, day(100)), AuthCheckResponse.Result.MATCH,
token(USER_2, day(100)), AuthCheckResponse.Result.NO_MATCH,
token(USER_3, day(10)), AuthCheckResponse.Result.INVALID,
token(USER_3, day(20)), AuthCheckResponse.Result.INVALID
token(USER_1, day(100)), CheckStatus.MATCH,
token(USER_2, day(100)), CheckStatus.NO_MATCH,
token(USER_3, day(10)), CheckStatus.INVALID,
token(USER_3, day(20)), CheckStatus.INVALID
), day(110));
}
@Test
public void testSomeHaveNewerVersions() throws Exception {
validate(Map.of(
token(USER_1, day(10)), AuthCheckResponse.Result.INVALID,
token(USER_1, day(20)), AuthCheckResponse.Result.MATCH,
token(USER_2, day(10)), AuthCheckResponse.Result.NO_MATCH,
token(USER_3, day(20)), AuthCheckResponse.Result.NO_MATCH,
token(USER_3, day(10)), AuthCheckResponse.Result.INVALID
token(USER_1, day(10)), CheckStatus.INVALID,
token(USER_1, day(20)), CheckStatus.MATCH,
token(USER_2, day(10)), CheckStatus.NO_MATCH,
token(USER_3, day(20)), CheckStatus.NO_MATCH,
token(USER_3, day(10)), CheckStatus.INVALID
), day(25));
}
private void validate(
final Map<String, AuthCheckResponse.Result> expected,
final Map<String, CheckStatus> expected,
final long nowMillis) throws Exception {
clock.setTimeMillis(nowMillis);
final AuthCheckRequest request = new AuthCheckRequest(E164_VALID, List.copyOf(expected.keySet()));
@ -125,20 +132,20 @@ abstract class SecureValueRecoveryControllerBaseTest {
.request()
.post(Entity.entity(request, MediaType.APPLICATION_JSON));
try (response) {
final AuthCheckResponse res = response.readEntity(AuthCheckResponse.class);
assertEquals(200, response.getStatus());
assertEquals(expected, res.matches());
final Map<String, CheckStatus> res = parseCheckResponse(response);
assertEquals(expected, res);
}
}
@Test
public void testHttpResponseCodeSuccess() throws Exception {
final Map<String, AuthCheckResponse.Result> expected = Map.of(
token(USER_1, day(10)), AuthCheckResponse.Result.INVALID,
token(USER_1, day(20)), AuthCheckResponse.Result.MATCH,
token(USER_2, day(10)), AuthCheckResponse.Result.NO_MATCH,
token(USER_3, day(20)), AuthCheckResponse.Result.NO_MATCH,
token(USER_3, day(10)), AuthCheckResponse.Result.INVALID
final Map<String, CheckStatus> expected = Map.of(
token(USER_1, day(10)), CheckStatus.INVALID,
token(USER_1, day(20)), CheckStatus.MATCH,
token(USER_2, day(10)), CheckStatus.NO_MATCH,
token(USER_3, day(20)), CheckStatus.NO_MATCH,
token(USER_3, day(10)), CheckStatus.INVALID
);
clock.setTimeMillis(day(25));
@ -151,9 +158,8 @@ abstract class SecureValueRecoveryControllerBaseTest {
.post(Entity.entity(in, MediaType.APPLICATION_JSON));
try (response) {
final AuthCheckResponse res = response.readEntity(AuthCheckResponse.class);
assertEquals(200, response.getStatus());
assertEquals(expected, res.matches());
assertEquals(expected, parseCheckResponse(response));
}
}
@ -252,6 +258,35 @@ abstract class SecureValueRecoveryControllerBaseTest {
}
}
@Test
public void testAcceptsPasswordsOrTokens() {
final Response passwordsResponse = resourceExtension.getJerseyTest()
.target(pathPrefix + "/backup/auth/check")
.request()
.post(Entity.entity("""
{
"number": "+18005550123",
"passwords": ["aaa:bbb"]
}
""", MediaType.APPLICATION_JSON));
try (passwordsResponse) {
assertEquals(200, passwordsResponse.getStatus());
}
final Response tokensResponse = resourceExtension.getJerseyTest()
.target(pathPrefix + "/backup/auth/check")
.request()
.post(Entity.entity("""
{
"number": "+18005550123",
"tokens": ["aaa:bbb"]
}
""", MediaType.APPLICATION_JSON));
try (tokensResponse) {
assertEquals(200, tokensResponse.getStatus());
}
}
@Test
public void testHttpResponseCodeWhenNotAJson() throws Exception {
final Response response = resourceExtension.getJerseyTest()
@ -264,11 +299,11 @@ abstract class SecureValueRecoveryControllerBaseTest {
}
}
private String token(final UUID uuid, final long timeMillis) {
String token(final UUID uuid, final long timeMillis) {
return token(credentials(uuid, timeMillis));
}
private static String token(final ExternalServiceCredentials credentials) {
static String token(final ExternalServiceCredentials credentials) {
return credentials.username() + ":" + credentials.password();
}
@ -277,13 +312,14 @@ abstract class SecureValueRecoveryControllerBaseTest {
return credentialsGenerator.generateForUuid(uuid);
}
private static long day(final int n) {
static long day(final int n) {
return TimeUnit.DAYS.toMillis(n);
}
private static Account account(final UUID uuid) {
final Account a = new Account();
a.setUuid(uuid);
a.setSvr3ShareSet(TestRandomUtil.nextBytes(100));
return a;
}
}

View File

@ -417,12 +417,15 @@ class AccountsTest {
}
@Test
void testReclaimAccountPreservesBcr() {
void testReclaimAccountPreservesFields() {
final String e164 = "+14151112222";
final UUID existingUuid = UUID.randomUUID();
final Account existingAccount =
generateAccount(e164, existingUuid, UUID.randomUUID(), List.of(generateDevice(DEVICE_ID_1)));
// the backup credential request and share-set are always preserved across account reclaims
existingAccount.setBackupCredentialRequest(TestRandomUtil.nextBytes(32));
existingAccount.setSvr3ShareSet(TestRandomUtil.nextBytes(100));
createAccount(existingAccount);
final Account secondAccount =
generateAccount(e164, UUID.randomUUID(), UUID.randomUUID(), List.of(generateDevice(DEVICE_ID_1)));
@ -431,6 +434,7 @@ class AccountsTest {
final Account reclaimed = accounts.getByAccountIdentifier(existingUuid).get();
assertThat(reclaimed.getBackupCredentialRequest()).isEqualTo(existingAccount.getBackupCredentialRequest());
assertThat(reclaimed.getSvr3ShareSet()).isEqualTo(existingAccount.getSvr3ShareSet());
}
@Test