From ce1c5be9407aa25ee3431689c77739be9b58e7ab Mon Sep 17 00:00:00 2001 From: ravi-signal <99042880+ravi-signal@users.noreply.github.com> Date: Fri, 17 May 2024 10:45:18 -0500 Subject: [PATCH] Add svr3 share-set store/retrieve --- .../textsecuregcm/WhisperServerService.java | 3 +- .../RegistrationLockVerificationManager.java | 27 +++- .../SecureValueRecovery2Controller.java | 12 +- .../SecureValueRecovery3Controller.java | 103 ++++++++----- .../entities/AuthCheckRequest.java | 10 +- ...Response.java => AuthCheckResponseV2.java} | 4 +- .../entities/AuthCheckResponseV3.java | 58 ++++++++ .../entities/RegistrationLockFailure.java | 10 +- .../entities/SetShareSetRequest.java | 22 +++ .../entities/Svr3Credentials.java | 28 ++++ .../textsecuregcm/storage/Account.java | 21 +++ .../textsecuregcm/storage/Accounts.java | 4 + .../textsecuregcm/util/Optionals.java | 21 +++ ...gistrationLockVerificationManagerTest.java | 5 +- .../SecureValueRecovery2ControllerTest.java | 16 +++ .../SecureValueRecovery3ControllerTest.java | 135 +++++++++++++++++- ...SecureValueRecoveryControllerBaseTest.java | 100 ++++++++----- .../textsecuregcm/storage/AccountsTest.java | 6 +- 18 files changed, 493 insertions(+), 92 deletions(-) rename service/src/main/java/org/whispersystems/textsecuregcm/entities/{AuthCheckResponse.java => AuthCheckResponseV2.java} (73%) create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/entities/AuthCheckResponseV3.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/entities/SetShareSetRequest.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/entities/Svr3Credentials.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/util/Optionals.java diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index b940ab680..ac82ddbce 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -609,7 +609,8 @@ public class WhisperServerService extends Application 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; } ))); } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecovery3Controller.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecovery3Controller.java index 7cf8cf28f..e7df22d18 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecovery3Controller.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecovery3Controller.java @@ -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 credentials = ExternalServiceCredentialsSelector.check( - request.passwords(), + request.tokens(), backupServiceCredentialGenerator, MAX_AGE_SECONDS); + final Optional account = accountsManager.getByE164(request.number()); + // the username associated with the provided number - final Optional matchingUsername = accountsManager - .getByE164(request.number()) + final Optional 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); } ))); } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AuthCheckRequest.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/AuthCheckRequest.java index 1b493e681..5f5852afc 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AuthCheckRequest.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/AuthCheckRequest.java @@ -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 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 tokens) { } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AuthCheckResponse.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/AuthCheckResponseV2.java similarity index 73% rename from service/src/main/java/org/whispersystems/textsecuregcm/entities/AuthCheckResponse.java rename to service/src/main/java/org/whispersystems/textsecuregcm/entities/AuthCheckResponseV2.java index e0f94a75a..de4137d7b 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AuthCheckResponse.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/AuthCheckResponseV2.java @@ -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 matches) { +public record AuthCheckResponseV2(@Schema(description = "A dictionary with the auth check results: `SVR Credentials -> 'match'/'no-match'/'invalid'`") + @NotNull Map matches) { public enum Result { MATCH("match"), diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AuthCheckResponseV3.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/AuthCheckResponseV3.java new file mode 100644 index 000000000..13396c370 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/AuthCheckResponseV3.java @@ -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 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; + } + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/RegistrationLockFailure.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/RegistrationLockFailure.java index 3a4d7aa77..5bdfa2980 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/RegistrationLockFailure.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/RegistrationLockFailure.java @@ -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) { } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/SetShareSetRequest.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/SetShareSetRequest.java new file mode 100644 index 000000000..8bcc4a2fb --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/SetShareSetRequest.java @@ -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; +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/Svr3Credentials.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/Svr3Credentials.java new file mode 100644 index 000000000..888cec857 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/Svr3Credentials.java @@ -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) {} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/Account.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/Account.java index bacbeae8b..77660d489 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/Account.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/Account.java @@ -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; } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/Accounts.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/Accounts.java index fd8886a9e..56036c77b 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/Accounts.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/Accounts.java @@ -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 writeItems = new ArrayList<>(); // If we're reclaiming an account that already has a username, we'd like to give the re-registering client diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/Optionals.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/Optionals.java new file mode 100644 index 000000000..4e3e49038 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/util/Optionals.java @@ -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 Optional zipWith(Optional optionalT, Optional optionalU, BiFunction fun) { + return optionalT.flatMap(t -> optionalU.map(u -> fun.apply(t, u))); + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/auth/RegistrationLockVerificationManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/auth/RegistrationLockVerificationManagerTest.java index ed67c07e1..a6d0c0949 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/auth/RegistrationLockVerificationManagerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/auth/RegistrationLockVerificationManagerTest.java @@ -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); diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecovery2ControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecovery2ControllerTest.java index a7357d29c..0bd8ecbf9 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecovery2ControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecovery2ControllerTest.java @@ -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 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; + } + )); + } } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecovery3ControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecovery3ControllerTest.java index 0204f6e51..05e8377ef 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecovery3ControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecovery3ControllerTest.java @@ -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 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 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 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 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()); + } } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecoveryControllerBaseTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecoveryControllerBaseTest.java index 36ee25170..e80f30c7e 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecoveryControllerBaseTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecoveryControllerBaseTest.java @@ -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 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 expected, + final Map 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 res = parseCheckResponse(response); + assertEquals(expected, res); } } @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 + final Map 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; } } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsTest.java index d60c167c8..e10487f27 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsTest.java @@ -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