Skip to content

Commit

Permalink
Add svr3 share-set store/retrieve
Browse files Browse the repository at this point in the history
  • Loading branch information
ravi-signal committed May 17, 2024
1 parent 1182d15 commit ce1c5be
Show file tree
Hide file tree
Showing 18 changed files with 493 additions and 92 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -609,7 +609,8 @@ public void run(WhisperServerConfiguration config, Environment environment) thro
config.getDynamoDbTables().getSubscriptions().getTableName(), dynamoDbAsyncClient);

final RegistrationLockVerificationManager registrationLockVerificationManager = new RegistrationLockVerificationManager(
accountsManager, clientPresenceManager, svr2CredentialsGenerator, registrationRecoveryPasswordsManager, pushNotificationManager, rateLimiters);
accountsManager, clientPresenceManager, svr2CredentialsGenerator, svr3CredentialsGenerator,
registrationRecoveryPasswordsManager, pushNotificationManager, rateLimiters);
final PhoneVerificationTokenManager phoneVerificationTokenManager = new PhoneVerificationTokenManager(
registrationServiceClient, registrationRecoveryPasswordsManager);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.entities.PhoneVerificationRequest;
import org.whispersystems.textsecuregcm.entities.RegistrationLockFailure;
import org.whispersystems.textsecuregcm.entities.Svr3Credentials;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
Expand Down Expand Up @@ -55,19 +56,22 @@ public enum Flow {
private final AccountsManager accounts;
private final ClientPresenceManager clientPresenceManager;
private final ExternalServiceCredentialsGenerator svr2CredentialGenerator;
private final ExternalServiceCredentialsGenerator svr3CredentialGenerator;
private final RateLimiters rateLimiters;
private final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager;
private final PushNotificationManager pushNotificationManager;

public RegistrationLockVerificationManager(
final AccountsManager accounts, final ClientPresenceManager clientPresenceManager,
final ExternalServiceCredentialsGenerator svr2CredentialGenerator,
final ExternalServiceCredentialsGenerator svr3CredentialGenerator,
final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager,
final PushNotificationManager pushNotificationManager,
final RateLimiters rateLimiters) {
this.accounts = accounts;
this.clientPresenceManager = clientPresenceManager;
this.svr2CredentialGenerator = svr2CredentialGenerator;
this.svr3CredentialGenerator = svr3CredentialGenerator;
this.registrationRecoveryPasswordsManager = registrationRecoveryPasswordsManager;
this.pushNotificationManager = pushNotificationManager;
this.rateLimiters = rateLimiters;
Expand Down Expand Up @@ -138,8 +142,6 @@ public void verifyRegistrationLock(final Account account, @Nullable final String
// Freezing the existing account credentials will definitively start the reglock timeout.
// Until the timeout, the current reglock can still be supplied,
// along with phone number verification, to restore access.
final ExternalServiceCredentials existingSvr2Credentials = svr2CredentialGenerator.generateForUuid(account.getUuid());

final Account updatedAccount;
if (!alreadyLocked) {
updatedAccount = accounts.update(account, Account::lockAuthTokenHash);
Expand Down Expand Up @@ -168,11 +170,28 @@ public void verifyRegistrationLock(final Account account, @Nullable final String
}

throw new WebApplicationException(Response.status(FAILURE_HTTP_STATUS)
.entity(new RegistrationLockFailure(existingRegistrationLock.getTimeRemaining().toMillis(),
existingRegistrationLock.needsFailureCredentials() ? existingSvr2Credentials : null))
.entity(new RegistrationLockFailure(
existingRegistrationLock.getTimeRemaining().toMillis(),
svr2FailureCredentials(existingRegistrationLock, updatedAccount),
svr3FailureCredentials(existingRegistrationLock, updatedAccount)))
.build());
}

rateLimiters.getPinLimiter().clear(phoneNumber);
}

private @Nullable ExternalServiceCredentials svr2FailureCredentials(final StoredRegistrationLock existingRegistrationLock, final Account account) {
if (!existingRegistrationLock.needsFailureCredentials()) {
return null;
}
return svr2CredentialGenerator.generateForUuid(account.getUuid());
}

private @Nullable Svr3Credentials svr3FailureCredentials(final StoredRegistrationLock existingRegistrationLock, final Account account) {
if (!existingRegistrationLock.needsFailureCredentials()) {
return null;
}
final ExternalServiceCredentials creds = svr3CredentialGenerator.generateForUuid(account.getUuid());
return new Svr3Credentials(creds.username(), creds.password(), account.getSvr3ShareSet());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsSelector;
import org.whispersystems.textsecuregcm.configuration.SecureValueRecovery2Configuration;
import org.whispersystems.textsecuregcm.entities.AuthCheckRequest;
import org.whispersystems.textsecuregcm.entities.AuthCheckResponse;
import org.whispersystems.textsecuregcm.entities.AuthCheckResponseV2;
import org.whispersystems.textsecuregcm.limits.RateLimitedByIp;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.storage.Account;
Expand Down Expand Up @@ -100,9 +100,9 @@ with a list of stored credentials. The response will identify which (if any) set
@ApiResponse(responseCode = "200", description = "`JSON` with the check results.", useReturnTypeSchema = true)
@ApiResponse(responseCode = "422", description = "Provided list of SVR2 credentials could not be parsed")
@ApiResponse(responseCode = "400", description = "`POST` request body is not a valid `JSON`")
public AuthCheckResponse authCheck(@NotNull @Valid final AuthCheckRequest request) {
public AuthCheckResponseV2 authCheck(@NotNull @Valid final AuthCheckRequest request) {
final List<ExternalServiceCredentialsSelector.CredentialInfo> credentials = ExternalServiceCredentialsSelector.check(
request.passwords(),
request.tokens(),
backupServiceCredentialGenerator,
MAX_AGE_SECONDS);

Expand All @@ -113,16 +113,16 @@ public AuthCheckResponse authCheck(@NotNull @Valid final AuthCheckRequest reques
.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;
}
)));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,6 @@
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;
Expand All @@ -33,9 +20,26 @@
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")
Expand All @@ -48,7 +52,8 @@ public static ExternalServiceCredentialsGenerator credentialsGenerator(final Sec
}

@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())
Expand All @@ -62,7 +67,7 @@ public static ExternalServiceCredentialsGenerator credentialsGenerator(final Sec
private final AccountsManager accountsManager;

public SecureValueRecovery3Controller(final ExternalServiceCredentialsGenerator backupServiceCredentialGenerator,
final AccountsManager accountsManager) {
final AccountsManager accountsManager) {
this.backupServiceCredentialGenerator = backupServiceCredentialGenerator;
this.accountsManager = accountsManager;
}
Expand All @@ -73,16 +78,36 @@ public SecureValueRecovery3Controller(final ExternalServiceCredentialsGenerator
@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")
Expand All @@ -92,38 +117,44 @@ public ExternalServiceCredentials getAuth(@ReadOnly @Auth final AuthenticatedAcc
@Operation(
summary = "Check SVR3 credentials",
description = """
Over time, clients may wind up with multiple sets of SVR3 authentication credentials in cloud storage.
Over time, clients may wind up with multiple sets of SVR3 authentication credentials in cloud storage.
To determine which set is most current and should be used to communicate with SVR3 to retrieve a master key
(from which a registration recovery password can be derived), clients should call this endpoint
with a list of stored credentials. The response will identify which (if any) set of credentials are appropriate for communicating with SVR3.
"""
)
(from which a registration recovery password can be derived), clients should call this endpoint
with a list of stored credentials. The response will identify which (if any) set of credentials are
appropriate for communicating with SVR3.
""")
@ApiResponse(responseCode = "200", description = "`JSON` with the check results.", useReturnTypeSchema = true)
@ApiResponse(responseCode = "422", description = "Provided list of SVR3 credentials could not be parsed")
@ApiResponse(responseCode = "400", description = "`POST` request body is not a valid `JSON`")
public AuthCheckResponse authCheck(@NotNull @Valid final AuthCheckRequest request) {
public AuthCheckResponseV3 authCheck(@NotNull @Valid final AuthCheckRequest request) {
final List<ExternalServiceCredentialsSelector.CredentialInfo> credentials = ExternalServiceCredentialsSelector.check(
request.passwords(),
request.tokens(),
backupServiceCredentialGenerator,
MAX_AGE_SECONDS);

final Optional<Account> account = accountsManager.getByE164(request.number());

// the username associated with the provided number
final Optional<String> matchingUsername = accountsManager
.getByE164(request.number())
final Optional<String> matchingUsername = account
.map(Account::getUuid)
.map(backupServiceCredentialGenerator::generateForUuid)
.map(ExternalServiceCredentials::username);

return new AuthCheckResponse(credentials.stream().collect(Collectors.toMap(
return new AuthCheckResponseV3(credentials.stream().collect(Collectors.toMap(
ExternalServiceCredentialsSelector.CredentialInfo::token,
info -> {
if (!info.valid()) {
return AuthCheckResponse.Result.INVALID;
// This isn't a valid credential (could be for a different SVR service, expired, etc)
return AuthCheckResponseV3.Result.invalid();
}
final String username = info.credentials().username();
// does this credential match the account id for the e164 provided in the request?
boolean match = matchingUsername.filter(username::equals).isPresent();
return match ? AuthCheckResponse.Result.MATCH : AuthCheckResponse.Result.NO_MATCH;
final String credUsername = info.credentials().username();

return Optionals
// If the account exists, and the account's username matches this credential's username, return a match
.zipWith(account, matchingUsername.filter(credUsername::equals), (a, ignored) ->
AuthCheckResponseV3.Result.match(a.getSvr3ShareSet()))
// Otherwise, return no-match
.orElseGet(AuthCheckResponseV3.Result::noMatch);
}
)));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -14,6 +16,10 @@

public record AuthCheckRequest(@Schema(description = "The e164-formatted phone number.")
@NotNull @E164 String number,
@Schema(description = "A list of SVR auth values, previously retrieved from `/v1/backup/auth`; may contain at most 10.")
@NotEmpty @Size(max = 10) List<String> passwords) {
@Schema(description = """
A list of SVR tokens, previously retrieved from `backup/auth`. Tokens should be the
of the form "username:password". May contain at most 10 tokens.""")
@JsonProperty("tokens")
@JsonAlias("passwords") // deprecated
@NotEmpty @Size(max = 10) List<String> tokens) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
import java.util.Map;
import javax.validation.constraints.NotNull;

public record AuthCheckResponse(@Schema(description = "A dictionary with the auth check results: `KBS Credentials -> 'match'/'no-match'/'invalid'`")
@NotNull Map<String, Result> matches) {
public record AuthCheckResponseV2(@Schema(description = "A dictionary with the auth check results: `SVR Credentials -> 'match'/'no-match'/'invalid'`")
@NotNull Map<String, Result> matches) {

public enum Result {
MATCH("match"),
Expand Down
Loading

0 comments on commit ce1c5be

Please sign in to comment.