mirror of
https://github.com/signalapp/Signal-Server
synced 2026-04-21 17:58:05 +01:00
Implement /v2/backup/auth/check
This commit is contained in:
@@ -744,7 +744,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
config.getRemoteConfigConfiguration().getGlobalConfig()),
|
||||
new SecureBackupController(backupCredentialsGenerator, accountsManager),
|
||||
new SecureStorageController(storageCredentialsGenerator),
|
||||
new SecureValueRecovery2Controller(svr2CredentialsGenerator, config.getSvr2Configuration()),
|
||||
new SecureValueRecovery2Controller(svr2CredentialsGenerator, accountsManager, config.getSvr2Configuration()),
|
||||
new StickerController(rateLimiters, config.getCdnConfiguration().getAccessKey(),
|
||||
config.getCdnConfiguration().getAccessSecret(), config.getCdnConfiguration().getRegion(),
|
||||
config.getCdnConfiguration().getBucket()),
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
public class ExternalServiceCredentialsSelector {
|
||||
|
||||
private ExternalServiceCredentialsSelector() {}
|
||||
|
||||
public record CredentialInfo(String token, boolean valid, ExternalServiceCredentials credentials, long timestamp) {
|
||||
/**
|
||||
* @return a copy of this record with valid=false
|
||||
*/
|
||||
private CredentialInfo invalidate() {
|
||||
return new CredentialInfo(token, false, credentials, timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a list of username:password credentials.
|
||||
* A credential is valid if it passes validation by the provided credentialsGenerator AND it is the most recent
|
||||
* credential in the provided list for a username.
|
||||
*
|
||||
* @param tokens A list of credentials, potentially with different usernames
|
||||
* @param credentialsGenerator To validate these credentials
|
||||
* @param maxAgeSeconds The maximum allowable age of the credential
|
||||
* @return A {@link CredentialInfo} for each provided token
|
||||
*/
|
||||
public static List<CredentialInfo> check(
|
||||
final List<String> tokens,
|
||||
final ExternalServiceCredentialsGenerator credentialsGenerator,
|
||||
final long maxAgeSeconds) {
|
||||
|
||||
// the credential for the username with the latest timestamp (so far)
|
||||
final Map<String, CredentialInfo> bestForUsername = new HashMap<>();
|
||||
final List<CredentialInfo> results = new ArrayList<>();
|
||||
for (String token : tokens) {
|
||||
// each token is supposed to be in a "${username}:${password}" form,
|
||||
// (note that password part may also contain ':' characters)
|
||||
final String[] parts = token.split(":", 2);
|
||||
if (parts.length != 2) {
|
||||
results.add(new CredentialInfo(token, false, null, 0L));
|
||||
continue;
|
||||
}
|
||||
final ExternalServiceCredentials credentials = new ExternalServiceCredentials(parts[0], parts[1]);
|
||||
final Optional<Long> maybeTimestamp = credentialsGenerator.validateAndGetTimestamp(credentials, maxAgeSeconds);
|
||||
if (maybeTimestamp.isEmpty()) {
|
||||
results.add(new CredentialInfo(token, false, null, 0L));
|
||||
continue;
|
||||
}
|
||||
|
||||
// now that we validated signature and token age, we will also find the latest of the tokens
|
||||
// for each username
|
||||
final long timestamp = maybeTimestamp.get();
|
||||
final CredentialInfo best = bestForUsername.get(credentials.username());
|
||||
if (best == null) {
|
||||
bestForUsername.put(credentials.username(), new CredentialInfo(token, true, credentials, timestamp));
|
||||
continue;
|
||||
}
|
||||
if (best.timestamp() < timestamp) {
|
||||
// we found a better credential for the username
|
||||
bestForUsername.put(credentials.username(), new CredentialInfo(token, true, credentials, timestamp));
|
||||
// mark the previous best as an invalid credential, since we have a better credential now
|
||||
results.add(best.invalidate());
|
||||
} else {
|
||||
// the credential we already had was more recent, this one can be marked invalid
|
||||
results.add(new CredentialInfo(token, false, null, 0L));
|
||||
}
|
||||
}
|
||||
|
||||
// all invalid tokens should be in results, just add the valid ones
|
||||
results.addAll(bestForUsername.values());
|
||||
return results;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -13,12 +13,11 @@ import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import java.time.Clock;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import javax.ws.rs.Consumes;
|
||||
@@ -27,9 +26,9 @@ import javax.ws.rs.POST;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.Produces;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
import org.apache.commons.lang3.tuple.Pair;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsSelector;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
|
||||
import org.whispersystems.textsecuregcm.configuration.SecureBackupServiceConfiguration;
|
||||
import org.whispersystems.textsecuregcm.entities.AuthCheckRequest;
|
||||
@@ -97,7 +96,7 @@ public class SecureBackupController {
|
||||
summary = "Check SVR credentials",
|
||||
description = """
|
||||
Over time, clients may wind up with multiple sets of KBS authentication credentials in cloud storage.
|
||||
To determine which set is most current and should be used to communicate with SVR to retrieve a master password
|
||||
To determine which set is most current and should be used to communicate with SVR to retrieve a master key
|
||||
(from which a registration recovery password can be derived), clients should call this endpoint
|
||||
with a list of stored credentials. The response will identify which (if any) set of credentials are appropriate for communicating with SVR.
|
||||
"""
|
||||
@@ -106,70 +105,27 @@ public class SecureBackupController {
|
||||
@ApiResponse(responseCode = "422", description = "Provided list of KBS credentials could not be parsed")
|
||||
@ApiResponse(responseCode = "400", description = "`POST` request body is not a valid `JSON`")
|
||||
public AuthCheckResponse authCheck(@NotNull @Valid final AuthCheckRequest request) {
|
||||
final Map<String, AuthCheckResponse.Result> results = new HashMap<>();
|
||||
final Map<String, Pair<UUID, Long>> tokenToUuid = new HashMap<>();
|
||||
final Map<UUID, Long> uuidToLatestTimestamp = new HashMap<>();
|
||||
|
||||
// first pass -- filter out all tokens that contain invalid credentials
|
||||
// (this could be either legit but expired or illegitimate for any reason)
|
||||
request.passwords().forEach(token -> {
|
||||
// each token is supposed to be in a "${username}:${password}" form,
|
||||
// (note that password part may also contain ':' characters)
|
||||
final String[] parts = token.split(":", 2);
|
||||
if (parts.length != 2) {
|
||||
results.put(token, AuthCheckResponse.Result.INVALID);
|
||||
return;
|
||||
}
|
||||
final ExternalServiceCredentials credentials = new ExternalServiceCredentials(parts[0], parts[1]);
|
||||
final Optional<Long> maybeTimestamp = credentialsGenerator.validateAndGetTimestamp(credentials, MAX_AGE_SECONDS);
|
||||
final Optional<UUID> maybeUuid = UUIDUtil.fromStringSafe(credentials.username());
|
||||
if (maybeTimestamp.isEmpty() || maybeUuid.isEmpty()) {
|
||||
results.put(token, AuthCheckResponse.Result.INVALID);
|
||||
return;
|
||||
}
|
||||
// now that we validated signature and token age, we will also find the latest of the tokens
|
||||
// for each username
|
||||
final Long timestamp = maybeTimestamp.get();
|
||||
final UUID uuid = maybeUuid.get();
|
||||
tokenToUuid.put(token, Pair.of(uuid, timestamp));
|
||||
final Long latestTimestamp = uuidToLatestTimestamp.getOrDefault(uuid, 0L);
|
||||
if (timestamp > latestTimestamp) {
|
||||
uuidToLatestTimestamp.put(uuid, timestamp);
|
||||
}
|
||||
});
|
||||
|
||||
// as a result of the first pass we now have some tokens that are marked invalid,
|
||||
// and for others we now know if for any username the list contains multiple tokens
|
||||
// we also know all distinct usernames from the list
|
||||
|
||||
// if it so happens that all tokens are invalid -- respond right away
|
||||
if (tokenToUuid.isEmpty()) {
|
||||
return new AuthCheckResponse(results);
|
||||
}
|
||||
final List<ExternalServiceCredentialsSelector.CredentialInfo> credentials = ExternalServiceCredentialsSelector.check(
|
||||
request.passwords(),
|
||||
credentialsGenerator,
|
||||
MAX_AGE_SECONDS);
|
||||
|
||||
final Predicate<UUID> uuidMatches = accountsManager
|
||||
.getByE164(request.number())
|
||||
.map(account -> (Predicate<UUID>) candidateUuid -> account.getUuid().equals(candidateUuid))
|
||||
.map(account -> (Predicate<UUID>) account.getUuid()::equals)
|
||||
.orElse(candidateUuid -> false);
|
||||
|
||||
// second pass will let us discard tokens that have newer versions and will also let us pick the winner (if any)
|
||||
request.passwords().forEach(token -> {
|
||||
if (results.containsKey(token)) {
|
||||
// result already calculated
|
||||
return;
|
||||
}
|
||||
final Pair<UUID, Long> uuidAndTime = requireNonNull(tokenToUuid.get(token));
|
||||
final Long latestTimestamp = requireNonNull(uuidToLatestTimestamp.get(uuidAndTime.getLeft()));
|
||||
// check if a newer version available
|
||||
if (uuidAndTime.getRight() < latestTimestamp) {
|
||||
results.put(token, AuthCheckResponse.Result.INVALID);
|
||||
return;
|
||||
}
|
||||
results.put(token, uuidMatches.test(uuidAndTime.getLeft())
|
||||
? AuthCheckResponse.Result.MATCH
|
||||
: AuthCheckResponse.Result.NO_MATCH);
|
||||
});
|
||||
|
||||
return new AuthCheckResponse(results);
|
||||
return new AuthCheckResponse(credentials.stream().collect(Collectors.toMap(
|
||||
ExternalServiceCredentialsSelector.CredentialInfo::token,
|
||||
info -> {
|
||||
if (!info.valid()) {
|
||||
return AuthCheckResponse.Result.INVALID;
|
||||
}
|
||||
final String username = info.credentials().username();
|
||||
// does this credential match the account id for the e164 provided in the request?
|
||||
boolean match = UUIDUtil.fromStringSafe(username).filter(uuidMatches).isPresent();
|
||||
return match ? AuthCheckResponse.Result.MATCH : AuthCheckResponse.Result.NO_MATCH;
|
||||
}
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,35 +10,66 @@ import io.dropwizard.auth.Auth;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import javax.ws.rs.Consumes;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.NotFoundException;
|
||||
import javax.ws.rs.POST;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.Produces;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
import org.jetbrains.annotations.TestOnly;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsSelector;
|
||||
import org.whispersystems.textsecuregcm.configuration.SecureValueRecovery2Configuration;
|
||||
import org.whispersystems.textsecuregcm.entities.AuthCheckRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.AuthCheckResponse;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimitedByIp;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.util.UUIDUtil;
|
||||
import java.time.Clock;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Path("/v2/backup")
|
||||
@Tag(name = "Secure Value Recovery")
|
||||
public class SecureValueRecovery2Controller {
|
||||
|
||||
private static final long MAX_AGE_SECONDS = TimeUnit.DAYS.toSeconds(30);
|
||||
|
||||
public static ExternalServiceCredentialsGenerator credentialsGenerator(final SecureValueRecovery2Configuration cfg) {
|
||||
return credentialsGenerator(cfg, Clock.systemUTC());
|
||||
}
|
||||
|
||||
@TestOnly
|
||||
public static ExternalServiceCredentialsGenerator credentialsGenerator(final SecureValueRecovery2Configuration cfg, final Clock clock) {
|
||||
return ExternalServiceCredentialsGenerator
|
||||
.builder(cfg.userAuthenticationTokenSharedSecret())
|
||||
.withUserDerivationKey(cfg.userIdTokenSharedSecret())
|
||||
.prependUsername(false)
|
||||
.withDerivedUsernameTruncateLength(16)
|
||||
.withClock(clock)
|
||||
.build();
|
||||
}
|
||||
|
||||
private final ExternalServiceCredentialsGenerator backupServiceCredentialGenerator;
|
||||
private final AccountsManager accountsManager;
|
||||
private final boolean enabled;
|
||||
|
||||
public SecureValueRecovery2Controller(final ExternalServiceCredentialsGenerator backupServiceCredentialGenerator,
|
||||
final AccountsManager accountsManager,
|
||||
final SecureValueRecovery2Configuration cfg) {
|
||||
this.backupServiceCredentialGenerator = backupServiceCredentialGenerator;
|
||||
this.accountsManager = accountsManager;
|
||||
this.enabled = cfg.enabled();
|
||||
}
|
||||
|
||||
@@ -61,4 +92,50 @@ public class SecureValueRecovery2Controller {
|
||||
}
|
||||
return backupServiceCredentialGenerator.generateFor(auth.getAccount().getUuid().toString());
|
||||
}
|
||||
|
||||
|
||||
@Timed
|
||||
@POST
|
||||
@Path("/auth/check")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@RateLimitedByIp(RateLimiters.For.BACKUP_AUTH_CHECK)
|
||||
@Operation(
|
||||
summary = "Check SVR2 credentials",
|
||||
description = """
|
||||
Over time, clients may wind up with multiple sets of SVR2 authentication credentials in cloud storage.
|
||||
To determine which set is most current and should be used to communicate with SVR2 to retrieve a master key
|
||||
(from which a registration recovery password can be derived), clients should call this endpoint
|
||||
with a list of stored credentials. The response will identify which (if any) set of credentials are appropriate for communicating with SVR2.
|
||||
"""
|
||||
)
|
||||
@ApiResponse(responseCode = "200", description = "`JSON` with the check results.", useReturnTypeSchema = true)
|
||||
@ApiResponse(responseCode = "422", description = "Provided list of SVR2 credentials could not be parsed")
|
||||
@ApiResponse(responseCode = "400", description = "`POST` request body is not a valid `JSON`")
|
||||
public AuthCheckResponse authCheck(@NotNull @Valid final AuthCheckRequest request) {
|
||||
final List<ExternalServiceCredentialsSelector.CredentialInfo> credentials = ExternalServiceCredentialsSelector.check(
|
||||
request.passwords(),
|
||||
backupServiceCredentialGenerator,
|
||||
MAX_AGE_SECONDS);
|
||||
|
||||
// the username associated with the provided number
|
||||
final Optional<String> matchingUsername = accountsManager
|
||||
.getByE164(request.number())
|
||||
.map(Account::getUuid)
|
||||
.map(backupServiceCredentialGenerator::generateForUuid)
|
||||
.map(ExternalServiceCredentials::username);
|
||||
|
||||
return new AuthCheckResponse(credentials.stream().collect(Collectors.toMap(
|
||||
ExternalServiceCredentialsSelector.CredentialInfo::token,
|
||||
info -> {
|
||||
if (!info.valid()) {
|
||||
return AuthCheckResponse.Result.INVALID;
|
||||
}
|
||||
final String username = info.credentials().username();
|
||||
// does this credential match the account id for the e164 provided in the request?
|
||||
boolean match = matchingUsername.filter(username::equals).isPresent();
|
||||
return match ? AuthCheckResponse.Result.MATCH : AuthCheckResponse.Result.NO_MATCH;
|
||||
}
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user