Implement /v2/backup/auth/check

This commit is contained in:
ravi-signal 2023-05-04 09:23:33 -07:00 committed by GitHub
parent 0e0c0c5dfe
commit 08333d5989
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 684 additions and 294 deletions

View File

@ -744,7 +744,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
config.getRemoteConfigConfiguration().getGlobalConfig()),
new SecureBackupController(backupCredentialsGenerator, accountsManager),
new SecureStorageController(storageCredentialsGenerator),
new SecureValueRecovery2Controller(svr2CredentialsGenerator, config.getSvr2Configuration()),
new SecureValueRecovery2Controller(svr2CredentialsGenerator, accountsManager, config.getSvr2Configuration()),
new StickerController(rateLimiters, config.getCdnConfiguration().getAccessKey(),
config.getCdnConfiguration().getAccessSecret(), config.getCdnConfiguration().getRegion(),
config.getCdnConfiguration().getBucket()),

View File

@ -0,0 +1,84 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.auth;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
public class ExternalServiceCredentialsSelector {
private ExternalServiceCredentialsSelector() {}
public record CredentialInfo(String token, boolean valid, ExternalServiceCredentials credentials, long timestamp) {
/**
* @return a copy of this record with valid=false
*/
private CredentialInfo invalidate() {
return new CredentialInfo(token, false, credentials, timestamp);
}
}
/**
* Validate a list of username:password credentials.
* A credential is valid if it passes validation by the provided credentialsGenerator AND it is the most recent
* credential in the provided list for a username.
*
* @param tokens A list of credentials, potentially with different usernames
* @param credentialsGenerator To validate these credentials
* @param maxAgeSeconds The maximum allowable age of the credential
* @return A {@link CredentialInfo} for each provided token
*/
public static List<CredentialInfo> check(
final List<String> tokens,
final ExternalServiceCredentialsGenerator credentialsGenerator,
final long maxAgeSeconds) {
// the credential for the username with the latest timestamp (so far)
final Map<String, CredentialInfo> bestForUsername = new HashMap<>();
final List<CredentialInfo> results = new ArrayList<>();
for (String token : tokens) {
// each token is supposed to be in a "${username}:${password}" form,
// (note that password part may also contain ':' characters)
final String[] parts = token.split(":", 2);
if (parts.length != 2) {
results.add(new CredentialInfo(token, false, null, 0L));
continue;
}
final ExternalServiceCredentials credentials = new ExternalServiceCredentials(parts[0], parts[1]);
final Optional<Long> maybeTimestamp = credentialsGenerator.validateAndGetTimestamp(credentials, maxAgeSeconds);
if (maybeTimestamp.isEmpty()) {
results.add(new CredentialInfo(token, false, null, 0L));
continue;
}
// now that we validated signature and token age, we will also find the latest of the tokens
// for each username
final long timestamp = maybeTimestamp.get();
final CredentialInfo best = bestForUsername.get(credentials.username());
if (best == null) {
bestForUsername.put(credentials.username(), new CredentialInfo(token, true, credentials, timestamp));
continue;
}
if (best.timestamp() < timestamp) {
// we found a better credential for the username
bestForUsername.put(credentials.username(), new CredentialInfo(token, true, credentials, timestamp));
// mark the previous best as an invalid credential, since we have a better credential now
results.add(best.invalidate());
} else {
// the credential we already had was more recent, this one can be marked invalid
results.add(new CredentialInfo(token, false, null, 0L));
}
}
// all invalid tokens should be in results, just add the valid ones
results.addAll(bestForUsername.values());
return results;
}
}

View File

@ -13,12 +13,11 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.time.Clock;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;
import javax.ws.rs.Consumes;
@ -27,9 +26,9 @@ import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import org.apache.commons.lang3.tuple.Pair;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsSelector;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.configuration.SecureBackupServiceConfiguration;
import org.whispersystems.textsecuregcm.entities.AuthCheckRequest;
@ -97,7 +96,7 @@ public class SecureBackupController {
summary = "Check SVR credentials",
description = """
Over time, clients may wind up with multiple sets of KBS authentication credentials in cloud storage.
To determine which set is most current and should be used to communicate with SVR to retrieve a master password
To determine which set is most current and should be used to communicate with SVR 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 SVR.
"""
@ -106,70 +105,27 @@ public class SecureBackupController {
@ApiResponse(responseCode = "422", description = "Provided list of KBS 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) {
final Map<String, AuthCheckResponse.Result> results = new HashMap<>();
final Map<String, Pair<UUID, Long>> tokenToUuid = new HashMap<>();
final Map<UUID, Long> uuidToLatestTimestamp = new HashMap<>();
// first pass -- filter out all tokens that contain invalid credentials
// (this could be either legit but expired or illegitimate for any reason)
request.passwords().forEach(token -> {
// each token is supposed to be in a "${username}:${password}" form,
// (note that password part may also contain ':' characters)
final String[] parts = token.split(":", 2);
if (parts.length != 2) {
results.put(token, AuthCheckResponse.Result.INVALID);
return;
}
final ExternalServiceCredentials credentials = new ExternalServiceCredentials(parts[0], parts[1]);
final Optional<Long> maybeTimestamp = credentialsGenerator.validateAndGetTimestamp(credentials, MAX_AGE_SECONDS);
final Optional<UUID> maybeUuid = UUIDUtil.fromStringSafe(credentials.username());
if (maybeTimestamp.isEmpty() || maybeUuid.isEmpty()) {
results.put(token, AuthCheckResponse.Result.INVALID);
return;
}
// now that we validated signature and token age, we will also find the latest of the tokens
// for each username
final Long timestamp = maybeTimestamp.get();
final UUID uuid = maybeUuid.get();
tokenToUuid.put(token, Pair.of(uuid, timestamp));
final Long latestTimestamp = uuidToLatestTimestamp.getOrDefault(uuid, 0L);
if (timestamp > latestTimestamp) {
uuidToLatestTimestamp.put(uuid, timestamp);
}
});
// as a result of the first pass we now have some tokens that are marked invalid,
// and for others we now know if for any username the list contains multiple tokens
// we also know all distinct usernames from the list
// if it so happens that all tokens are invalid -- respond right away
if (tokenToUuid.isEmpty()) {
return new AuthCheckResponse(results);
}
final List<ExternalServiceCredentialsSelector.CredentialInfo> credentials = ExternalServiceCredentialsSelector.check(
request.passwords(),
credentialsGenerator,
MAX_AGE_SECONDS);
final Predicate<UUID> uuidMatches = accountsManager
.getByE164(request.number())
.map(account -> (Predicate<UUID>) candidateUuid -> account.getUuid().equals(candidateUuid))
.map(account -> (Predicate<UUID>) account.getUuid()::equals)
.orElse(candidateUuid -> false);
// second pass will let us discard tokens that have newer versions and will also let us pick the winner (if any)
request.passwords().forEach(token -> {
if (results.containsKey(token)) {
// result already calculated
return;
}
final Pair<UUID, Long> uuidAndTime = requireNonNull(tokenToUuid.get(token));
final Long latestTimestamp = requireNonNull(uuidToLatestTimestamp.get(uuidAndTime.getLeft()));
// check if a newer version available
if (uuidAndTime.getRight() < latestTimestamp) {
results.put(token, AuthCheckResponse.Result.INVALID);
return;
}
results.put(token, uuidMatches.test(uuidAndTime.getLeft())
? AuthCheckResponse.Result.MATCH
: AuthCheckResponse.Result.NO_MATCH);
});
return new AuthCheckResponse(results);
return new AuthCheckResponse(credentials.stream().collect(Collectors.toMap(
ExternalServiceCredentialsSelector.CredentialInfo::token,
info -> {
if (!info.valid()) {
return AuthCheckResponse.Result.INVALID;
}
final String username = info.credentials().username();
// does this credential match the account id for the e164 provided in the request?
boolean match = UUIDUtil.fromStringSafe(username).filter(uuidMatches).isPresent();
return match ? AuthCheckResponse.Result.MATCH : AuthCheckResponse.Result.NO_MATCH;
}
)));
}
}

View File

@ -10,35 +10,66 @@ 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 javax.validation.Valid;
import javax.validation.constraints.NotNull;
import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.NotFoundException;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import org.jetbrains.annotations.TestOnly;
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.SecureValueRecovery2Configuration;
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.textsecuregcm.util.UUIDUtil;
import java.time.Clock;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.function.Predicate;
import java.util.stream.Collectors;
@Path("/v2/backup")
@Tag(name = "Secure Value Recovery")
public class SecureValueRecovery2Controller {
private static final long MAX_AGE_SECONDS = TimeUnit.DAYS.toSeconds(30);
public static ExternalServiceCredentialsGenerator credentialsGenerator(final SecureValueRecovery2Configuration cfg) {
return credentialsGenerator(cfg, Clock.systemUTC());
}
@TestOnly
public static ExternalServiceCredentialsGenerator credentialsGenerator(final SecureValueRecovery2Configuration cfg, final Clock clock) {
return ExternalServiceCredentialsGenerator
.builder(cfg.userAuthenticationTokenSharedSecret())
.withUserDerivationKey(cfg.userIdTokenSharedSecret())
.prependUsername(false)
.withDerivedUsernameTruncateLength(16)
.withClock(clock)
.build();
}
private final ExternalServiceCredentialsGenerator backupServiceCredentialGenerator;
private final AccountsManager accountsManager;
private final boolean enabled;
public SecureValueRecovery2Controller(final ExternalServiceCredentialsGenerator backupServiceCredentialGenerator,
final AccountsManager accountsManager,
final SecureValueRecovery2Configuration cfg) {
this.backupServiceCredentialGenerator = backupServiceCredentialGenerator;
this.accountsManager = accountsManager;
this.enabled = cfg.enabled();
}
@ -61,4 +92,50 @@ public class SecureValueRecovery2Controller {
}
return backupServiceCredentialGenerator.generateFor(auth.getAccount().getUuid().toString());
}
@Timed
@POST
@Path("/auth/check")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@RateLimitedByIp(RateLimiters.For.BACKUP_AUTH_CHECK)
@Operation(
summary = "Check SVR2 credentials",
description = """
Over time, clients may wind up with multiple sets of SVR2 authentication credentials in cloud storage.
To determine which set is most current and should be used to communicate with SVR2 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 SVR2.
"""
)
@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) {
final List<ExternalServiceCredentialsSelector.CredentialInfo> credentials = ExternalServiceCredentialsSelector.check(
request.passwords(),
backupServiceCredentialGenerator,
MAX_AGE_SECONDS);
// the username associated with the provided number
final Optional<String> matchingUsername = accountsManager
.getByE164(request.number())
.map(Account::getUuid)
.map(backupServiceCredentialGenerator::generateForUuid)
.map(ExternalServiceCredentials::username);
return new AuthCheckResponse(credentials.stream().collect(Collectors.toMap(
ExternalServiceCredentialsSelector.CredentialInfo::token,
info -> {
if (!info.valid()) {
return AuthCheckResponse.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;
}
)));
}
}

View File

@ -6,6 +6,7 @@
package org.whispersystems.textsecuregcm.controllers;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
import io.dropwizard.testing.junit5.ResourceExtension;
@ -37,17 +38,7 @@ import org.whispersystems.textsecuregcm.util.MutableClock;
import org.whispersystems.textsecuregcm.util.SystemMapper;
@ExtendWith(DropwizardExtensionsSupport.class)
class SecureBackupControllerTest {
private static final UUID USER_1 = UUID.randomUUID();
private static final UUID USER_2 = UUID.randomUUID();
private static final UUID USER_3 = UUID.randomUUID();
private static final String E164_VALID = "+18005550123";
private static final String E164_INVALID = "1(800)555-0123";
class SecureBackupControllerTest extends SecureValueRecoveryControllerBaseTest {
private static final byte[] SECRET = RandomUtils.nextBytes(32);
@ -55,14 +46,14 @@ class SecureBackupControllerTest {
SecureBackupServiceConfiguration.class,
cfg -> Mockito.when(cfg.getUserAuthenticationTokenSharedSecret()).thenReturn(SECRET)
);
private static final MutableClock CLOCK = MockUtils.mutableClock(0);
private static final MutableClock CLOCK = new MutableClock();
private static final ExternalServiceCredentialsGenerator CREDENTIAL_GENERATOR =
SecureBackupController.credentialsGenerator(CFG, CLOCK);
private static final AccountsManager ACCOUNTS_MANAGER = Mockito.mock(AccountsManager.class);
private static final AccountsManager ACCOUNTS_MANAGER = mock(AccountsManager.class);
private static final SecureBackupController CONTROLLER =
new SecureBackupController(CREDENTIAL_GENERATOR, ACCOUNTS_MANAGER);
@ -73,219 +64,7 @@ class SecureBackupControllerTest {
.addResource(CONTROLLER)
.build();
@BeforeAll
public static void before() throws Exception {
Mockito.when(ACCOUNTS_MANAGER.getByE164(E164_VALID)).thenReturn(Optional.of(account(USER_1)));
}
@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
), 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
), day(2));
}
@Test
public void testEmptyInput() throws Exception {
validate(Collections.emptyMap(), day(2));
}
@Test
public void testSomeInvalid() throws Exception {
final String fakeToken = token(USER_3, day(1)).replaceAll(USER_3.toString(), USER_2.toString());
validate(Map.of(
token(USER_1, day(1)), AuthCheckResponse.Result.MATCH,
token(USER_2, day(1)), AuthCheckResponse.Result.NO_MATCH,
fakeToken, AuthCheckResponse.Result.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
), 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
), day(25));
}
private static void validate(
final Map<String, AuthCheckResponse.Result> expected,
final long nowMillis) throws Exception {
CLOCK.setTimeMillis(nowMillis);
final AuthCheckRequest request = new AuthCheckRequest(E164_VALID, List.copyOf(expected.keySet()));
final AuthCheckResponse response = CONTROLLER.authCheck(request);
assertEquals(expected, response.matches());
}
@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
);
CLOCK.setTimeMillis(day(25));
final AuthCheckRequest in = new AuthCheckRequest(E164_VALID, List.copyOf(expected.keySet()));
final Response response = RESOURCES.getJerseyTest()
.target("/v1/backup/auth/check")
.request()
.post(Entity.entity(in, MediaType.APPLICATION_JSON));
try (response) {
final AuthCheckResponse res = response.readEntity(AuthCheckResponse.class);
assertEquals(200, response.getStatus());
assertEquals(expected, res.matches());
}
}
@Test
public void testHttpResponseCodeWhenInvalidNumber() throws Exception {
final AuthCheckRequest in = new AuthCheckRequest(E164_INVALID, Collections.singletonList("1"));
final Response response = RESOURCES.getJerseyTest()
.target("/v1/backup/auth/check")
.request()
.post(Entity.entity(in, MediaType.APPLICATION_JSON));
try (response) {
assertEquals(422, response.getStatus());
}
}
@Test
public void testHttpResponseCodeWhenTooManyTokens() throws Exception {
final AuthCheckRequest inOkay = new AuthCheckRequest(E164_VALID, List.of(
"1", "2", "3", "4", "5", "6", "7", "8", "9", "10"
));
final AuthCheckRequest inTooMany = new AuthCheckRequest(E164_VALID, List.of(
"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"
));
final AuthCheckRequest inNoTokens = new AuthCheckRequest(E164_VALID, Collections.emptyList());
final Response responseOkay = RESOURCES.getJerseyTest()
.target("/v1/backup/auth/check")
.request()
.post(Entity.entity(inOkay, MediaType.APPLICATION_JSON));
final Response responseError1 = RESOURCES.getJerseyTest()
.target("/v1/backup/auth/check")
.request()
.post(Entity.entity(inTooMany, MediaType.APPLICATION_JSON));
final Response responseError2 = RESOURCES.getJerseyTest()
.target("/v1/backup/auth/check")
.request()
.post(Entity.entity(inNoTokens, MediaType.APPLICATION_JSON));
try (responseOkay; responseError1; responseError2) {
assertEquals(200, responseOkay.getStatus());
assertEquals(422, responseError1.getStatus());
assertEquals(422, responseError2.getStatus());
}
}
@Test
public void testHttpResponseCodeWhenPasswordsMissing() throws Exception {
final Response response = RESOURCES.getJerseyTest()
.target("/v1/backup/auth/check")
.request()
.post(Entity.entity("""
{
"number": "123"
}
""", MediaType.APPLICATION_JSON));
try (response) {
assertEquals(422, response.getStatus());
}
}
@Test
public void testHttpResponseCodeWhenNumberMissing() throws Exception {
final Response response = RESOURCES.getJerseyTest()
.target("/v1/backup/auth/check")
.request()
.post(Entity.entity("""
{
"passwords": ["aaa:bbb"]
}
""", MediaType.APPLICATION_JSON));
try (response) {
assertEquals(422, response.getStatus());
}
}
@Test
public void testHttpResponseCodeWhenExtraFields() throws Exception {
final Response response = RESOURCES.getJerseyTest()
.target("/v1/backup/auth/check")
.request()
.post(Entity.entity("""
{
"number": "+18005550123",
"passwords": ["aaa:bbb"],
"unexpected": "value"
}
""", MediaType.APPLICATION_JSON));
try (response) {
assertEquals(200, response.getStatus());
}
}
@Test
public void testHttpResponseCodeWhenNotAJson() throws Exception {
final Response response = RESOURCES.getJerseyTest()
.target("/v1/backup/auth/check")
.request()
.post(Entity.entity("random text", MediaType.APPLICATION_JSON));
try (response) {
assertEquals(400, response.getStatus());
}
}
private static String token(final UUID uuid, final long timeMillis) {
CLOCK.setTimeMillis(timeMillis);
final ExternalServiceCredentials credentials = CREDENTIAL_GENERATOR.generateForUuid(uuid);
return credentials.username() + ":" + credentials.password();
}
private 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);
return a;
protected SecureBackupControllerTest() {
super("/v1", ACCOUNTS_MANAGER, CLOCK, RESOURCES, CREDENTIAL_GENERATOR);
}
}

View File

@ -0,0 +1,55 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.controllers;
import static org.mockito.Mockito.mock;
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
import io.dropwizard.testing.junit5.ResourceExtension;
import org.apache.commons.lang3.RandomUtils;
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.storage.AccountsManager;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
import org.whispersystems.textsecuregcm.util.MutableClock;
import org.whispersystems.textsecuregcm.util.SystemMapper;
@ExtendWith(DropwizardExtensionsSupport.class)
public class SecureValueRecovery2ControllerTest extends SecureValueRecoveryControllerBaseTest {
private static final SecureValueRecovery2Configuration CFG = new SecureValueRecovery2Configuration(
true,
"",
RandomUtils.nextBytes(32),
RandomUtils.nextBytes(32),
null,
null,
null
);
private static final MutableClock CLOCK = new MutableClock();
private static final ExternalServiceCredentialsGenerator CREDENTIAL_GENERATOR =
SecureValueRecovery2Controller.credentialsGenerator(CFG, CLOCK);
private static final AccountsManager ACCOUNTS_MANAGER = mock(AccountsManager.class);
private static final SecureValueRecovery2Controller CONTROLLER =
new SecureValueRecovery2Controller(CREDENTIAL_GENERATOR, ACCOUNTS_MANAGER, CFG);
private static final ResourceExtension RESOURCES = ResourceExtension.builder()
.addProvider(AuthHelper.getAuthFilter())
.setMapper(SystemMapper.jsonMapper())
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
.addResource(CONTROLLER)
.build();
protected SecureValueRecovery2ControllerTest() {
super("/v2", ACCOUNTS_MANAGER, CLOCK, RESOURCES, CREDENTIAL_GENERATOR);
}
}

View File

@ -0,0 +1,289 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.controllers;
import static org.junit.jupiter.api.Assertions.assertEquals;
import io.dropwizard.testing.junit5.ResourceExtension;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import javax.ws.rs.client.Entity;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
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;
abstract class SecureValueRecoveryControllerBaseTest {
private static final UUID USER_1 = UUID.randomUUID();
private static final UUID USER_2 = UUID.randomUUID();
private static final UUID USER_3 = UUID.randomUUID();
private static final String E164_VALID = "+18005550123";
private static final String E164_INVALID = "1(800)555-0123";
private final String pathPrefix;
private final ResourceExtension resourceExtension;
private final AccountsManager mockAccountsManager;
private final ExternalServiceCredentialsGenerator credentialsGenerator;
private final MutableClock clock;
@BeforeEach
public void before() throws Exception {
Mockito.when(mockAccountsManager.getByE164(E164_VALID)).thenReturn(Optional.of(account(USER_1)));
}
protected SecureValueRecoveryControllerBaseTest(
final String pathPrefix,
final AccountsManager mockAccountsManager,
final MutableClock mutableClock,
final ResourceExtension resourceExtension,
final ExternalServiceCredentialsGenerator credentialsGenerator) {
this.pathPrefix = pathPrefix;
this.resourceExtension = resourceExtension;
this.mockAccountsManager = mockAccountsManager;
this.credentialsGenerator = credentialsGenerator;
this.clock = mutableClock;
}
@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
), 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
), day(2));
}
@Test
public void testSomeInvalid() throws Exception {
final ExternalServiceCredentials user1Cred = credentials(USER_1, day(1));
final ExternalServiceCredentials user2Cred = credentials(USER_2, day(1));
final ExternalServiceCredentials user3Cred = credentials(USER_3, day(1));
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
), 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
), 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
), day(25));
}
private void validate(
final Map<String, AuthCheckResponse.Result> expected,
final long nowMillis) throws Exception {
clock.setTimeMillis(nowMillis);
final AuthCheckRequest request = new AuthCheckRequest(E164_VALID, List.copyOf(expected.keySet()));
final Response response = resourceExtension.getJerseyTest().target(pathPrefix + "/backup/auth/check")
.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());
}
}
@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
);
clock.setTimeMillis(day(25));
final AuthCheckRequest in = new AuthCheckRequest(E164_VALID, List.copyOf(expected.keySet()));
final Response response = resourceExtension.getJerseyTest()
.target(pathPrefix + "/backup/auth/check")
.request()
.post(Entity.entity(in, MediaType.APPLICATION_JSON));
try (response) {
final AuthCheckResponse res = response.readEntity(AuthCheckResponse.class);
assertEquals(200, response.getStatus());
assertEquals(expected, res.matches());
}
}
@Test
public void testHttpResponseCodeWhenInvalidNumber() throws Exception {
final AuthCheckRequest in = new AuthCheckRequest(E164_INVALID, Collections.singletonList("1"));
final Response response = resourceExtension.getJerseyTest()
.target(pathPrefix + "/backup/auth/check")
.request()
.post(Entity.entity(in, MediaType.APPLICATION_JSON));
try (response) {
assertEquals(422, response.getStatus());
}
}
@Test
public void testHttpResponseCodeWhenTooManyTokens() throws Exception {
final AuthCheckRequest inOkay = new AuthCheckRequest(E164_VALID, List.of(
"1", "2", "3", "4", "5", "6", "7", "8", "9", "10"
));
final AuthCheckRequest inTooMany = new AuthCheckRequest(E164_VALID, List.of(
"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"
));
final AuthCheckRequest inNoTokens = new AuthCheckRequest(E164_VALID, Collections.emptyList());
final Response responseOkay = resourceExtension.getJerseyTest()
.target(pathPrefix + "/backup/auth/check")
.request()
.post(Entity.entity(inOkay, MediaType.APPLICATION_JSON));
final Response responseError1 = resourceExtension.getJerseyTest()
.target(pathPrefix + "/backup/auth/check")
.request()
.post(Entity.entity(inTooMany, MediaType.APPLICATION_JSON));
final Response responseError2 = resourceExtension.getJerseyTest()
.target(pathPrefix + "/backup/auth/check")
.request()
.post(Entity.entity(inNoTokens, MediaType.APPLICATION_JSON));
try (responseOkay; responseError1; responseError2) {
assertEquals(200, responseOkay.getStatus());
assertEquals(422, responseError1.getStatus());
assertEquals(422, responseError2.getStatus());
}
}
@Test
public void testHttpResponseCodeWhenPasswordsMissing() throws Exception {
final Response response = resourceExtension.getJerseyTest()
.target(pathPrefix + "/backup/auth/check")
.request()
.post(Entity.entity("""
{
"number": "123"
}
""", MediaType.APPLICATION_JSON));
try (response) {
assertEquals(422, response.getStatus());
}
}
@Test
public void testHttpResponseCodeWhenNumberMissing() throws Exception {
final Response response = resourceExtension.getJerseyTest()
.target(pathPrefix + "/backup/auth/check")
.request()
.post(Entity.entity("""
{
"passwords": ["aaa:bbb"]
}
""", MediaType.APPLICATION_JSON));
try (response) {
assertEquals(422, response.getStatus());
}
}
@Test
public void testHttpResponseCodeWhenExtraFields() throws Exception {
final Response response = resourceExtension.getJerseyTest()
.target(pathPrefix + "/backup/auth/check")
.request()
.post(Entity.entity("""
{
"number": "+18005550123",
"passwords": ["aaa:bbb"],
"unexpected": "value"
}
""", MediaType.APPLICATION_JSON));
try (response) {
assertEquals(200, response.getStatus());
}
}
@Test
public void testHttpResponseCodeWhenNotAJson() throws Exception {
final Response response = resourceExtension.getJerseyTest()
.target(pathPrefix + "/backup/auth/check")
.request()
.post(Entity.entity("random text", MediaType.APPLICATION_JSON));
try (response) {
assertEquals(400, response.getStatus());
}
}
private String token(final UUID uuid, final long timeMillis) {
return token(credentials(uuid, timeMillis));
}
private static String token(ExternalServiceCredentials credentials) {
return credentials.username() + ":" + credentials.password();
}
private ExternalServiceCredentials credentials(final UUID uuid, final long timeMillis) {
clock.setTimeMillis(timeMillis);
return credentialsGenerator.generateForUuid(uuid);
}
private 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);
return a;
}
}

View File

@ -0,0 +1,145 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.tests.auth;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;
import org.apache.commons.lang3.RandomUtils;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsSelector;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsSelector.CredentialInfo;
import org.whispersystems.textsecuregcm.util.MockUtils;
import org.whispersystems.textsecuregcm.util.MutableClock;
public class ExternalServiceCredentialsSelectorTest {
private static final UUID UUID1 = UUID.randomUUID();
private static final UUID UUID2 = UUID.randomUUID();
private static final MutableClock CLOCK = MockUtils.mutableClock(TimeUnit.DAYS.toSeconds(1));
private static final ExternalServiceCredentialsGenerator GEN1 =
ExternalServiceCredentialsGenerator
.builder(RandomUtils.nextBytes(32))
.prependUsername(true)
.withClock(CLOCK)
.build();
private static final ExternalServiceCredentialsGenerator GEN2 =
ExternalServiceCredentialsGenerator
.builder(RandomUtils.nextBytes(32))
.withUserDerivationKey(RandomUtils.nextBytes(32))
.prependUsername(false)
.withDerivedUsernameTruncateLength(16)
.withClock(CLOCK)
.build();
private static ExternalServiceCredentials atTime(
final ExternalServiceCredentialsGenerator gen,
final long deltaMillis,
final UUID identity) {
final Instant old = CLOCK.instant();
try {
CLOCK.incrementMillis(deltaMillis);
return gen.generateForUuid(identity);
} finally {
CLOCK.setTimeInstant(old);
}
}
private static String token(final ExternalServiceCredentials cred) {
return cred.username() + ":" + cred.password();
}
@Test
void single() {
final ExternalServiceCredentials cred = GEN1.generateForUuid(UUID1);
var result = ExternalServiceCredentialsSelector.check(
List.of(token(cred)), GEN1, TimeUnit.MINUTES.toSeconds(1));
assertThat(result).singleElement()
.matches(CredentialInfo::valid)
.matches(info -> info.credentials().equals(cred));
}
@Test
void multipleUsernames() {
final ExternalServiceCredentials cred1New = GEN1.generateForUuid(UUID1);
final ExternalServiceCredentials cred1Old = atTime(GEN1, -1, UUID1);
final ExternalServiceCredentials cred2New = GEN1.generateForUuid(UUID2);
final ExternalServiceCredentials cred2Old = atTime(GEN1, -1, UUID2);
final List<String> tokens = Stream.of(cred1New, cred1Old, cred2New, cred2Old)
.map(ExternalServiceCredentialsSelectorTest::token)
.toList();
final List<CredentialInfo> result = ExternalServiceCredentialsSelector.check(tokens, GEN1,
TimeUnit.MINUTES.toSeconds(1));
assertThat(result).hasSize(4);
assertThat(result).filteredOn(CredentialInfo::valid)
.hasSize(2)
.map(CredentialInfo::credentials)
.containsExactlyInAnyOrder(cred1New, cred2New);
assertThat(result).filteredOn(info -> !info.valid())
.map(CredentialInfo::token)
.containsExactlyInAnyOrder(token(cred1Old), token(cred2Old));
}
@Test
void multipleGenerators() {
final ExternalServiceCredentials gen1Cred = GEN1.generateForUuid(UUID1);
final ExternalServiceCredentials gen2Cred = GEN2.generateForUuid(UUID1);
final List<CredentialInfo> result = ExternalServiceCredentialsSelector.check(
List.of(token(gen1Cred), token(gen2Cred)),
GEN2,
TimeUnit.MINUTES.toSeconds(1));
assertThat(result)
.hasSize(2)
.filteredOn(CredentialInfo::valid)
.singleElement()
.matches(info -> info.credentials().equals(gen2Cred));
assertThat(result)
.filteredOn(info -> !info.valid())
.singleElement()
.matches(info -> info.token().equals(token(gen1Cred)));
}
@ParameterizedTest
@MethodSource
void invalidCredentials(final String invalidCredential) {
final ExternalServiceCredentials validCredential = GEN1.generateForUuid(UUID1);
var result = ExternalServiceCredentialsSelector.check(
List.of(invalidCredential, token(validCredential)), GEN1, TimeUnit.MINUTES.toSeconds(1));
assertThat(result).hasSize(2);
assertThat(result).filteredOn(CredentialInfo::valid).singleElement()
.matches(info -> info.credentials().equals(validCredential));
assertThat(result).filteredOn(info -> !info.valid()).singleElement()
.matches(info -> info.token().equals(invalidCredential));
}
static Stream<String> invalidCredentials() {
return Stream.of(
"blah:blah",
token(atTime(GEN1, -TimeUnit.MINUTES.toSeconds(2), UUID1)), // too old
"nocolon",
"nothingaftercolon:",
":nothingbeforecolon",
token(GEN2.generateForUuid(UUID1))
);
}
}

View File

@ -28,6 +28,11 @@ public class MutableClock extends Clock {
this(Clock.systemUTC());
}
public MutableClock setTimeInstant(final Instant instant) {
delegate.set(Clock.fixed(instant, ZoneId.of("Etc/UTC")));
return this;
}
public MutableClock setTimeMillis(final long timeMillis) {
delegate.set(fixedTimeMillis(timeMillis));
return this;