From 08333d5989ea78314e4d7502c44b7977f4d82999 Mon Sep 17 00:00:00 2001 From: ravi-signal <99042880+ravi-signal@users.noreply.github.com> Date: Thu, 4 May 2023 09:23:33 -0700 Subject: [PATCH] Implement /v2/backup/auth/check --- .../textsecuregcm/WhisperServerService.java | 2 +- .../ExternalServiceCredentialsSelector.java | 84 +++++ .../controllers/SecureBackupController.java | 86 ++---- .../SecureValueRecovery2Controller.java | 77 +++++ .../SecureBackupControllerTest.java | 235 +------------- .../SecureValueRecovery2ControllerTest.java | 55 ++++ ...SecureValueRecoveryControllerBaseTest.java | 289 ++++++++++++++++++ ...xternalServiceCredentialsSelectorTest.java | 145 +++++++++ .../textsecuregcm/util/MutableClock.java | 5 + 9 files changed, 684 insertions(+), 294 deletions(-) create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/auth/ExternalServiceCredentialsSelector.java create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecovery2ControllerTest.java create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecoveryControllerBaseTest.java create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/tests/auth/ExternalServiceCredentialsSelectorTest.java diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 07ed13cc3..6cfa3a6c4 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -744,7 +744,7 @@ public class WhisperServerService extends Application check( + final List tokens, + final ExternalServiceCredentialsGenerator credentialsGenerator, + final long maxAgeSeconds) { + + // the credential for the username with the latest timestamp (so far) + final Map bestForUsername = new HashMap<>(); + final List 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 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; + } + +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SecureBackupController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SecureBackupController.java index 93543c142..e04ee41e5 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SecureBackupController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SecureBackupController.java @@ -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 results = new HashMap<>(); - final Map> tokenToUuid = new HashMap<>(); - final Map 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 maybeTimestamp = credentialsGenerator.validateAndGetTimestamp(credentials, MAX_AGE_SECONDS); - final Optional 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 credentials = ExternalServiceCredentialsSelector.check( + request.passwords(), + credentialsGenerator, + MAX_AGE_SECONDS); final Predicate uuidMatches = accountsManager .getByE164(request.number()) - .map(account -> (Predicate) candidateUuid -> account.getUuid().equals(candidateUuid)) + .map(account -> (Predicate) 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 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; + } + ))); } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecovery2Controller.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecovery2Controller.java index 08f45e634..f5c8b0764 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecovery2Controller.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecovery2Controller.java @@ -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 credentials = ExternalServiceCredentialsSelector.check( + request.passwords(), + backupServiceCredentialGenerator, + MAX_AGE_SECONDS); + + // the username associated with the provided number + final Optional 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; + } + ))); + } } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SecureBackupControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SecureBackupControllerTest.java index beb7a64e3..76e607eb2 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SecureBackupControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SecureBackupControllerTest.java @@ -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 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 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); } } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecovery2ControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecovery2ControllerTest.java new file mode 100644 index 000000000..d8f469646 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecovery2ControllerTest.java @@ -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); + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecoveryControllerBaseTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecoveryControllerBaseTest.java new file mode 100644 index 000000000..0e8e9475b --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecoveryControllerBaseTest.java @@ -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 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 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; + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/auth/ExternalServiceCredentialsSelectorTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/auth/ExternalServiceCredentialsSelectorTest.java new file mode 100644 index 000000000..368a52d56 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/auth/ExternalServiceCredentialsSelectorTest.java @@ -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 tokens = Stream.of(cred1New, cred1Old, cred2New, cred2Old) + .map(ExternalServiceCredentialsSelectorTest::token) + .toList(); + + final List 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 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 invalidCredentials() { + return Stream.of( + "blah:blah", + token(atTime(GEN1, -TimeUnit.MINUTES.toSeconds(2), UUID1)), // too old + "nocolon", + "nothingaftercolon:", + ":nothingbeforecolon", + token(GEN2.generateForUuid(UUID1)) + ); + } + +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/util/MutableClock.java b/service/src/test/java/org/whispersystems/textsecuregcm/util/MutableClock.java index 5fae8f9ff..839e2b94a 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/util/MutableClock.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/util/MutableClock.java @@ -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;