mirror of
https://github.com/signalapp/Signal-Server
synced 2026-04-20 04:08:04 +01:00
Manage device linking tokens transactionally
This commit is contained in:
@@ -403,7 +403,8 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
config.getDynamoDbTables().getAccounts().getPhoneNumberTableName(),
|
||||
config.getDynamoDbTables().getAccounts().getPhoneNumberIdentifierTableName(),
|
||||
config.getDynamoDbTables().getAccounts().getUsernamesTableName(),
|
||||
config.getDynamoDbTables().getDeletedAccounts().getTableName());
|
||||
config.getDynamoDbTables().getDeletedAccounts().getTableName(),
|
||||
config.getDynamoDbTables().getAccounts().getUsedLinkDeviceTokensTableName());
|
||||
ClientReleases clientReleases = new ClientReleases(dynamoDbAsyncClient,
|
||||
config.getDynamoDbTables().getClientReleases().getTableName());
|
||||
PhoneNumberIdentifiers phoneNumberIdentifiers = new PhoneNumberIdentifiers(dynamoDbClient,
|
||||
@@ -637,11 +638,11 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
ClientPublicKeysManager clientPublicKeysManager =
|
||||
new ClientPublicKeysManager(clientPublicKeys, accountLockManager, accountLockExecutor);
|
||||
AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster,
|
||||
accountLockManager, keysManager, messagesManager, profilesManager,
|
||||
rateLimitersCluster, accountLockManager, keysManager, messagesManager, profilesManager,
|
||||
secureStorageClient, secureValueRecovery2Client,
|
||||
clientPresenceManager,
|
||||
registrationRecoveryPasswordsManager, clientPublicKeysManager, accountLockExecutor, clientPresenceExecutor,
|
||||
clock, dynamicConfigurationManager);
|
||||
clock, config.getLinkDeviceSecretConfiguration().secret().value(), dynamicConfigurationManager);
|
||||
RemoteConfigsManager remoteConfigsManager = new RemoteConfigsManager(remoteConfigs);
|
||||
APNSender apnSender = new APNSender(apnSenderExecutor, config.getApnConfiguration());
|
||||
FcmSender fcmSender = new FcmSender(fcmSenderExecutor, config.getFcmConfiguration().credentials().value());
|
||||
@@ -1107,8 +1108,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
config.getDeliveryCertificate().ecPrivateKey(), config.getDeliveryCertificate().expiresDays()),
|
||||
zkAuthOperations, callingGenericZkSecretParams, clock),
|
||||
new ChallengeController(rateLimitChallengeManager, challengeConstraintChecker),
|
||||
new DeviceController(config.getLinkDeviceSecretConfiguration().secret().value(), accountsManager,
|
||||
clientPublicKeysManager, rateLimiters, rateLimitersCluster, config.getMaxDevices(), clock),
|
||||
new DeviceController(accountsManager, clientPublicKeysManager, rateLimiters, config.getMaxDevices()),
|
||||
new DirectoryV2Controller(directoryV2CredentialsGenerator),
|
||||
new DonationController(clock, zkReceiptOperations, redeemedReceiptsManager, accountsManager, config.getBadges(),
|
||||
ReceiptCredentialPresentation::new),
|
||||
|
||||
@@ -10,19 +10,22 @@ public class AccountsTableConfiguration extends Table {
|
||||
private final String phoneNumberTableName;
|
||||
private final String phoneNumberIdentifierTableName;
|
||||
private final String usernamesTableName;
|
||||
private final String usedLinkDeviceTokensTableName;
|
||||
|
||||
@JsonCreator
|
||||
public AccountsTableConfiguration(
|
||||
@JsonProperty("tableName") final String tableName,
|
||||
@JsonProperty("phoneNumberTableName") final String phoneNumberTableName,
|
||||
@JsonProperty("phoneNumberIdentifierTableName") final String phoneNumberIdentifierTableName,
|
||||
@JsonProperty("usernamesTableName") final String usernamesTableName) {
|
||||
@JsonProperty("usernamesTableName") final String usernamesTableName,
|
||||
@JsonProperty("usedLinkDeviceTokensTableName") final String usedLinkDeviceTokensTableName) {
|
||||
|
||||
super(tableName);
|
||||
|
||||
this.phoneNumberTableName = phoneNumberTableName;
|
||||
this.phoneNumberIdentifierTableName = phoneNumberIdentifierTableName;
|
||||
this.usernamesTableName = usernamesTableName;
|
||||
this.usedLinkDeviceTokensTableName = usedLinkDeviceTokensTableName;
|
||||
}
|
||||
|
||||
@NotBlank
|
||||
@@ -39,4 +42,9 @@ public class AccountsTableConfiguration extends Table {
|
||||
public String getUsernamesTableName() {
|
||||
return usernamesTableName;
|
||||
}
|
||||
|
||||
@NotBlank
|
||||
public String getUsedLinkDeviceTokensTableName() {
|
||||
return usedLinkDeviceTokensTableName;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,32 +4,18 @@
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import com.google.common.net.HttpHeaders;
|
||||
import io.dropwizard.auth.Auth;
|
||||
import io.lettuce.core.SetArgs;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.headers.Header;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.Key;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import javax.annotation.Nullable;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import javax.ws.rs.Consumes;
|
||||
@@ -61,13 +47,13 @@ import org.whispersystems.textsecuregcm.entities.ProvisioningMessage;
|
||||
import org.whispersystems.textsecuregcm.entities.SetPublicKeyRequest;
|
||||
import org.whispersystems.textsecuregcm.identity.IdentityType;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.ClientPublicKeysManager;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
import org.whispersystems.textsecuregcm.storage.Device.DeviceCapabilities;
|
||||
import org.whispersystems.textsecuregcm.storage.DeviceSpec;
|
||||
import org.whispersystems.textsecuregcm.storage.LinkDeviceTokenAlreadyUsedException;
|
||||
import org.whispersystems.textsecuregcm.util.VerificationCode;
|
||||
import org.whispersystems.websocket.auth.Mutable;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
@@ -78,43 +64,20 @@ public class DeviceController {
|
||||
|
||||
static final int MAX_DEVICES = 6;
|
||||
|
||||
private final Key verificationTokenKey;
|
||||
private final AccountsManager accounts;
|
||||
private final ClientPublicKeysManager clientPublicKeysManager;
|
||||
private final RateLimiters rateLimiters;
|
||||
private final FaultTolerantRedisCluster usedTokenCluster;
|
||||
private final Map<String, Integer> maxDeviceConfiguration;
|
||||
|
||||
private final Clock clock;
|
||||
public DeviceController(final AccountsManager accounts,
|
||||
final ClientPublicKeysManager clientPublicKeysManager,
|
||||
final RateLimiters rateLimiters,
|
||||
final Map<String, Integer> maxDeviceConfiguration) {
|
||||
|
||||
private static final String VERIFICATION_TOKEN_ALGORITHM = "HmacSHA256";
|
||||
|
||||
@VisibleForTesting
|
||||
static final Duration TOKEN_EXPIRATION_DURATION = Duration.ofMinutes(10);
|
||||
|
||||
public DeviceController(byte[] linkDeviceSecret,
|
||||
AccountsManager accounts,
|
||||
ClientPublicKeysManager clientPublicKeysManager,
|
||||
RateLimiters rateLimiters,
|
||||
FaultTolerantRedisCluster usedTokenCluster,
|
||||
Map<String, Integer> maxDeviceConfiguration, final Clock clock) {
|
||||
this.verificationTokenKey = new SecretKeySpec(linkDeviceSecret, VERIFICATION_TOKEN_ALGORITHM);
|
||||
this.accounts = accounts;
|
||||
this.clientPublicKeysManager = clientPublicKeysManager;
|
||||
this.rateLimiters = rateLimiters;
|
||||
this.usedTokenCluster = usedTokenCluster;
|
||||
this.maxDeviceConfiguration = maxDeviceConfiguration;
|
||||
this.clock = clock;
|
||||
|
||||
// Fail fast: reject bad keys
|
||||
try {
|
||||
final Mac mac = Mac.getInstance(VERIFICATION_TOKEN_ALGORITHM);
|
||||
mac.init(verificationTokenKey);
|
||||
} catch (final NoSuchAlgorithmException e) {
|
||||
throw new AssertionError("All Java implementations must support HmacSHA256", e);
|
||||
} catch (final InvalidKeyException e) {
|
||||
throw new IllegalArgumentException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@@ -196,7 +159,7 @@ public class DeviceController {
|
||||
throw new WebApplicationException(Response.Status.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
return new VerificationCode(generateVerificationToken(account.getUuid()));
|
||||
return new VerificationCode(accounts.generateDeviceLinkingToken(account.getUuid()));
|
||||
}
|
||||
|
||||
@PUT
|
||||
@@ -222,7 +185,7 @@ public class DeviceController {
|
||||
@Context ContainerRequest containerRequest)
|
||||
throws RateLimitExceededException, DeviceLimitExceededException {
|
||||
|
||||
final Account account = checkVerificationToken(linkDeviceRequest.verificationCode())
|
||||
final Account account = accounts.checkDeviceLinkingToken(linkDeviceRequest.verificationCode())
|
||||
.flatMap(accounts::getByAccountIdentifier)
|
||||
.orElseThrow(ForbiddenException::new);
|
||||
|
||||
@@ -274,27 +237,33 @@ public class DeviceController {
|
||||
signalAgent = "OWD";
|
||||
}
|
||||
|
||||
return accounts.addDevice(account, new DeviceSpec(accountAttributes.getName(),
|
||||
authorizationHeader.getPassword(),
|
||||
signalAgent,
|
||||
capabilities,
|
||||
accountAttributes.getRegistrationId(),
|
||||
accountAttributes.getPhoneNumberIdentityRegistrationId(),
|
||||
accountAttributes.getFetchesMessages(),
|
||||
deviceActivationRequest.apnToken(),
|
||||
deviceActivationRequest.gcmToken(),
|
||||
deviceActivationRequest.aciSignedPreKey(),
|
||||
deviceActivationRequest.pniSignedPreKey(),
|
||||
deviceActivationRequest.aciPqLastResortPreKey(),
|
||||
deviceActivationRequest.pniPqLastResortPreKey()))
|
||||
.thenCompose(a -> usedTokenCluster.withCluster(connection -> connection.async()
|
||||
.set(getUsedTokenKey(linkDeviceRequest.verificationCode()), "", new SetArgs().ex(TOKEN_EXPIRATION_DURATION)))
|
||||
.thenApply(ignored -> a))
|
||||
.thenApply(accountAndDevice -> new DeviceResponse(
|
||||
accountAndDevice.first().getIdentifier(IdentityType.ACI),
|
||||
accountAndDevice.first().getIdentifier(IdentityType.PNI),
|
||||
accountAndDevice.second().getId()))
|
||||
.join();
|
||||
try {
|
||||
return accounts.addDevice(account, new DeviceSpec(accountAttributes.getName(),
|
||||
authorizationHeader.getPassword(),
|
||||
signalAgent,
|
||||
capabilities,
|
||||
accountAttributes.getRegistrationId(),
|
||||
accountAttributes.getPhoneNumberIdentityRegistrationId(),
|
||||
accountAttributes.getFetchesMessages(),
|
||||
deviceActivationRequest.apnToken(),
|
||||
deviceActivationRequest.gcmToken(),
|
||||
deviceActivationRequest.aciSignedPreKey(),
|
||||
deviceActivationRequest.pniSignedPreKey(),
|
||||
deviceActivationRequest.aciPqLastResortPreKey(),
|
||||
deviceActivationRequest.pniPqLastResortPreKey()),
|
||||
linkDeviceRequest.verificationCode())
|
||||
.thenApply(accountAndDevice -> new DeviceResponse(
|
||||
accountAndDevice.first().getIdentifier(IdentityType.ACI),
|
||||
accountAndDevice.first().getIdentifier(IdentityType.PNI),
|
||||
accountAndDevice.second().getId()))
|
||||
.join();
|
||||
} catch (final CompletionException e) {
|
||||
if (e.getCause() instanceof LinkDeviceTokenAlreadyUsedException) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@PUT
|
||||
@@ -336,95 +305,10 @@ public class DeviceController {
|
||||
setPublicKeyRequest.publicKey());
|
||||
}
|
||||
|
||||
private Mac getInitializedMac() {
|
||||
try {
|
||||
final Mac mac = Mac.getInstance(VERIFICATION_TOKEN_ALGORITHM);
|
||||
mac.init(verificationTokenKey);
|
||||
|
||||
return mac;
|
||||
} catch (final NoSuchAlgorithmException | InvalidKeyException e) {
|
||||
// All Java implementations must support HmacSHA256 and we checked the key at construction time, so this can never
|
||||
// happen
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
String generateVerificationToken(final UUID aci) {
|
||||
final String claims = aci + "." + clock.instant().toEpochMilli();
|
||||
final byte[] signature = getInitializedMac().doFinal(claims.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
return claims + ":" + Base64.getUrlEncoder().encodeToString(signature);
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
Optional<UUID> checkVerificationToken(final String verificationToken) {
|
||||
final boolean tokenUsed = usedTokenCluster.withCluster(connection ->
|
||||
connection.sync().get(getUsedTokenKey(verificationToken)) != null);
|
||||
|
||||
if (tokenUsed) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
final String[] claimsAndSignature = verificationToken.split(":", 2);
|
||||
|
||||
if (claimsAndSignature.length != 2) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
final byte[] expectedSignature = getInitializedMac().doFinal(
|
||||
claimsAndSignature[0].getBytes(StandardCharsets.UTF_8));
|
||||
final byte[] providedSignature;
|
||||
|
||||
try {
|
||||
providedSignature = Base64.getUrlDecoder().decode(claimsAndSignature[1]);
|
||||
} catch (final IllegalArgumentException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
if (!MessageDigest.isEqual(expectedSignature, providedSignature)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
final String[] aciAndTimestamp = claimsAndSignature[0].split("\\.", 2);
|
||||
|
||||
if (aciAndTimestamp.length != 2) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
final UUID aci;
|
||||
|
||||
try {
|
||||
aci = UUID.fromString(aciAndTimestamp[0]);
|
||||
} catch (final IllegalArgumentException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
final Instant timestamp;
|
||||
|
||||
try {
|
||||
timestamp = Instant.ofEpochMilli(Long.parseLong(aciAndTimestamp[1]));
|
||||
} catch (final NumberFormatException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
final Instant tokenExpiration = timestamp.plus(TOKEN_EXPIRATION_DURATION);
|
||||
|
||||
if (tokenExpiration.isBefore(clock.instant())) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
return Optional.of(aci);
|
||||
}
|
||||
|
||||
private static boolean isCapabilityDowngrade(Account account, DeviceCapabilities capabilities) {
|
||||
boolean isDowngrade = false;
|
||||
isDowngrade |= account.isDeleteSyncSupported() && !capabilities.deleteSync();
|
||||
isDowngrade |= account.isVersionedExpirationTimerSupported() && !capabilities.versionedExpirationTimer();
|
||||
return isDowngrade;
|
||||
}
|
||||
|
||||
private static String getUsedTokenKey(final String token) {
|
||||
return "usedToken::" + token;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ import com.google.common.base.Throwables;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import io.micrometer.core.instrument.Timer;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
@@ -43,6 +46,7 @@ import org.whispersystems.textsecuregcm.util.UUIDUtil;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.scheduler.Scheduler;
|
||||
import software.amazon.awssdk.core.SdkBytes;
|
||||
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
|
||||
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
|
||||
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
|
||||
@@ -122,6 +126,12 @@ public class Accounts extends AbstractDynamoDbStore {
|
||||
// username hash; byte[] or null
|
||||
static final String ATTR_USERNAME_HASH = "N";
|
||||
|
||||
// bytes, primary key
|
||||
static final String KEY_LINK_DEVICE_TOKEN_HASH = "H";
|
||||
|
||||
// integer, seconds
|
||||
static final String ATTR_LINK_DEVICE_TOKEN_TTL = "E";
|
||||
|
||||
// unidentified access key; byte[] or null
|
||||
static final String ATTR_UAK = "UAK";
|
||||
|
||||
@@ -154,6 +164,7 @@ public class Accounts extends AbstractDynamoDbStore {
|
||||
private final String phoneNumberIdentifierConstraintTableName;
|
||||
private final String usernamesConstraintTableName;
|
||||
private final String deletedAccountsTableName;
|
||||
private final String usedLinkDeviceTokenTableName;
|
||||
private final String accountsTableName;
|
||||
|
||||
@VisibleForTesting
|
||||
@@ -165,7 +176,8 @@ public class Accounts extends AbstractDynamoDbStore {
|
||||
final String phoneNumberConstraintTableName,
|
||||
final String phoneNumberIdentifierConstraintTableName,
|
||||
final String usernamesConstraintTableName,
|
||||
final String deletedAccountsTableName) {
|
||||
final String deletedAccountsTableName,
|
||||
final String usedLinkDeviceTokenTableName) {
|
||||
|
||||
super(client);
|
||||
this.clock = clock;
|
||||
@@ -175,6 +187,7 @@ public class Accounts extends AbstractDynamoDbStore {
|
||||
this.accountsTableName = accountsTableName;
|
||||
this.usernamesConstraintTableName = usernamesConstraintTableName;
|
||||
this.deletedAccountsTableName = deletedAccountsTableName;
|
||||
this.usedLinkDeviceTokenTableName = usedLinkDeviceTokenTableName;
|
||||
}
|
||||
|
||||
public Accounts(
|
||||
@@ -184,11 +197,12 @@ public class Accounts extends AbstractDynamoDbStore {
|
||||
final String phoneNumberConstraintTableName,
|
||||
final String phoneNumberIdentifierConstraintTableName,
|
||||
final String usernamesConstraintTableName,
|
||||
final String deletedAccountsTableName) {
|
||||
final String deletedAccountsTableName,
|
||||
final String usedLinkDeviceTokenTableName) {
|
||||
|
||||
this(Clock.systemUTC(), client, asyncClient, accountsTableName,
|
||||
phoneNumberConstraintTableName, phoneNumberIdentifierConstraintTableName, usernamesConstraintTableName,
|
||||
deletedAccountsTableName);
|
||||
deletedAccountsTableName, usedLinkDeviceTokenTableName);
|
||||
}
|
||||
|
||||
static class UsernameTable {
|
||||
@@ -1065,6 +1079,28 @@ public class Accounts extends AbstractDynamoDbStore {
|
||||
});
|
||||
}
|
||||
|
||||
public TransactWriteItem buildTransactWriteItemForLinkDevice(final String linkDeviceToken, final Duration tokenTtl) {
|
||||
final byte[] linkDeviceTokenHash;
|
||||
|
||||
try {
|
||||
linkDeviceTokenHash = MessageDigest.getInstance("SHA-256").digest(linkDeviceToken.getBytes(StandardCharsets.UTF_8));
|
||||
} catch (final NoSuchAlgorithmException e) {
|
||||
throw new AssertionError("Every implementation of the Java platform is required to support the SHA-256 MessageDigest algorithm", e);
|
||||
}
|
||||
|
||||
return TransactWriteItem.builder()
|
||||
.put(Put.builder()
|
||||
.tableName(usedLinkDeviceTokenTableName)
|
||||
.item(Map.of(
|
||||
KEY_LINK_DEVICE_TOKEN_HASH, AttributeValue.fromB(SdkBytes.fromByteArray(linkDeviceTokenHash)),
|
||||
ATTR_LINK_DEVICE_TOKEN_TTL, AttributeValue.fromN(String.valueOf(clock.instant().plus(tokenTtl).getEpochSecond()))
|
||||
))
|
||||
.conditionExpression("attribute_not_exists(#linkDeviceTokenHash)")
|
||||
.expressionAttributeNames(Map.of("#linkDeviceTokenHash", KEY_LINK_DEVICE_TOKEN_HASH))
|
||||
.build())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public Optional<Account> getByE164(final String number) {
|
||||
return getByIndirectLookup(
|
||||
|
||||
@@ -13,6 +13,7 @@ import com.fasterxml.jackson.databind.ObjectWriter;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import com.google.common.base.Preconditions;
|
||||
import io.lettuce.core.RedisException;
|
||||
import io.lettuce.core.SetArgs;
|
||||
import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import io.micrometer.core.instrument.Tag;
|
||||
@@ -20,11 +21,18 @@ import io.micrometer.core.instrument.Tags;
|
||||
import io.micrometer.core.instrument.Timer;
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.Key;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
@@ -43,6 +51,8 @@ import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Stream;
|
||||
import javax.annotation.Nullable;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import org.apache.commons.lang3.ObjectUtils;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.signal.libsignal.protocol.IdentityKey;
|
||||
@@ -71,6 +81,7 @@ import org.whispersystems.textsecuregcm.util.Util;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.scheduler.Scheduler;
|
||||
import software.amazon.awssdk.services.dynamodb.model.TransactWriteItem;
|
||||
import software.amazon.awssdk.services.dynamodb.model.TransactionCanceledException;
|
||||
|
||||
public class AccountsManager {
|
||||
|
||||
@@ -98,6 +109,7 @@ public class AccountsManager {
|
||||
private final Accounts accounts;
|
||||
private final PhoneNumberIdentifiers phoneNumberIdentifiers;
|
||||
private final FaultTolerantRedisCluster cacheCluster;
|
||||
private final FaultTolerantRedisCluster rateLimitCluster;
|
||||
private final AccountLockManager accountLockManager;
|
||||
private final KeysManager keysManager;
|
||||
private final MessagesManager messagesManager;
|
||||
@@ -112,6 +124,8 @@ public class AccountsManager {
|
||||
private final Clock clock;
|
||||
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
|
||||
|
||||
private final Key verificationTokenKey;
|
||||
|
||||
private static final ObjectWriter ACCOUNT_REDIS_JSON_WRITER = SystemMapper.jsonMapper()
|
||||
.writer(SystemMapper.excludingField(Account.class, List.of("uuid")));
|
||||
|
||||
@@ -125,6 +139,12 @@ public class AccountsManager {
|
||||
|
||||
private static final int MAX_UPDATE_ATTEMPTS = 10;
|
||||
|
||||
@VisibleForTesting
|
||||
static final Duration LINK_DEVICE_TOKEN_EXPIRATION_DURATION = Duration.ofMinutes(10);
|
||||
|
||||
@VisibleForTesting
|
||||
static final String LINK_DEVICE_VERIFICATION_TOKEN_ALGORITHM = "HmacSHA256";
|
||||
|
||||
public enum DeletionReason {
|
||||
ADMIN_DELETED("admin"),
|
||||
EXPIRED ("expired"),
|
||||
@@ -140,6 +160,7 @@ public class AccountsManager {
|
||||
public AccountsManager(final Accounts accounts,
|
||||
final PhoneNumberIdentifiers phoneNumberIdentifiers,
|
||||
final FaultTolerantRedisCluster cacheCluster,
|
||||
final FaultTolerantRedisCluster rateLimitCluster,
|
||||
final AccountLockManager accountLockManager,
|
||||
final KeysManager keysManager,
|
||||
final MessagesManager messagesManager,
|
||||
@@ -152,10 +173,12 @@ public class AccountsManager {
|
||||
final Executor accountLockExecutor,
|
||||
final Executor clientPresenceExecutor,
|
||||
final Clock clock,
|
||||
final byte[] linkDeviceSecret,
|
||||
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager) {
|
||||
this.accounts = accounts;
|
||||
this.phoneNumberIdentifiers = phoneNumberIdentifiers;
|
||||
this.cacheCluster = cacheCluster;
|
||||
this.rateLimitCluster = rateLimitCluster;
|
||||
this.accountLockManager = accountLockManager;
|
||||
this.keysManager = keysManager;
|
||||
this.messagesManager = messagesManager;
|
||||
@@ -169,6 +192,15 @@ public class AccountsManager {
|
||||
this.clientPresenceExecutor = clientPresenceExecutor;
|
||||
this.clock = requireNonNull(clock);
|
||||
this.dynamicConfigurationManager = dynamicConfigurationManager;
|
||||
|
||||
this.verificationTokenKey = new SecretKeySpec(linkDeviceSecret, LINK_DEVICE_VERIFICATION_TOKEN_ALGORITHM);
|
||||
|
||||
// Fail fast: reject bad keys
|
||||
try {
|
||||
getInitializedMac(verificationTokenKey);
|
||||
} catch (final InvalidKeyException e) {
|
||||
throw new IllegalArgumentException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public Account create(final String number,
|
||||
@@ -275,46 +307,179 @@ public class AccountsManager {
|
||||
});
|
||||
}
|
||||
|
||||
public CompletableFuture<Pair<Account, Device>> addDevice(final Account account, final DeviceSpec deviceSpec) {
|
||||
public CompletableFuture<Pair<Account, Device>> addDevice(final Account account, final DeviceSpec deviceSpec, final String linkDeviceToken) {
|
||||
return accountLockManager.withLockAsync(List.of(account.getNumber()),
|
||||
() -> addDevice(account.getIdentifier(IdentityType.ACI), deviceSpec, MAX_UPDATE_ATTEMPTS),
|
||||
() -> addDevice(account.getIdentifier(IdentityType.ACI), deviceSpec, linkDeviceToken, MAX_UPDATE_ATTEMPTS),
|
||||
accountLockExecutor);
|
||||
}
|
||||
|
||||
private CompletableFuture<Pair<Account, Device>> addDevice(final UUID accountIdentifier, final DeviceSpec deviceSpec, final int retries) {
|
||||
private CompletableFuture<Pair<Account, Device>> addDevice(final UUID accountIdentifier, final DeviceSpec deviceSpec, final String linkDeviceToken, final int retries) {
|
||||
return accounts.getByAccountIdentifierAsync(accountIdentifier)
|
||||
.thenApply(maybeAccount -> maybeAccount.orElseThrow(ContestedOptimisticLockException::new))
|
||||
.thenCompose(account -> {
|
||||
final byte nextDeviceId = account.getNextDeviceId();
|
||||
|
||||
return CompletableFuture.allOf(
|
||||
keysManager.deleteSingleUsePreKeys(account.getUuid(), nextDeviceId),
|
||||
keysManager.deleteSingleUsePreKeys(account.getPhoneNumberIdentifier(), nextDeviceId),
|
||||
messagesManager.clear(account.getUuid(), nextDeviceId))
|
||||
.thenApply(ignored -> new Pair<>(account, nextDeviceId));
|
||||
})
|
||||
.thenCompose(accountAndNextDeviceId -> {
|
||||
final Account account = accountAndNextDeviceId.first();
|
||||
final byte nextDeviceId = accountAndNextDeviceId.second();
|
||||
|
||||
account.addDevice(deviceSpec.toDevice(nextDeviceId, clock));
|
||||
|
||||
final List<TransactWriteItem> additionalWriteItems = keysManager.buildWriteItemsForNewDevice(
|
||||
final List<TransactWriteItem> additionalWriteItems = new ArrayList<>(keysManager.buildWriteItemsForNewDevice(
|
||||
account.getIdentifier(IdentityType.ACI),
|
||||
account.getIdentifier(IdentityType.PNI),
|
||||
nextDeviceId,
|
||||
deviceSpec.aciSignedPreKey(),
|
||||
deviceSpec.pniSignedPreKey(),
|
||||
deviceSpec.aciPqLastResortPreKey(),
|
||||
deviceSpec.pniPqLastResortPreKey());
|
||||
deviceSpec.pniPqLastResortPreKey()));
|
||||
|
||||
return CompletableFuture.allOf(
|
||||
keysManager.deleteSingleUsePreKeys(account.getUuid(), nextDeviceId),
|
||||
keysManager.deleteSingleUsePreKeys(account.getPhoneNumberIdentifier(), nextDeviceId),
|
||||
messagesManager.clear(account.getUuid(), nextDeviceId))
|
||||
.thenCompose(ignored -> accounts.updateTransactionallyAsync(account, additionalWriteItems))
|
||||
additionalWriteItems.add(accounts.buildTransactWriteItemForLinkDevice(linkDeviceToken, LINK_DEVICE_TOKEN_EXPIRATION_DURATION));
|
||||
|
||||
return accounts.updateTransactionallyAsync(account, additionalWriteItems)
|
||||
.thenApply(ignored -> new Pair<>(account, account.getDevice(nextDeviceId).orElseThrow()));
|
||||
})
|
||||
.thenCompose(updatedAccountAndDevice -> rateLimitCluster.withCluster(connection ->
|
||||
connection.async().set(getUsedTokenKey(linkDeviceToken), "", new SetArgs().ex(LINK_DEVICE_TOKEN_EXPIRATION_DURATION)))
|
||||
.thenApply(ignored -> updatedAccountAndDevice))
|
||||
.thenCompose(updatedAccountAndDevice -> redisDeleteAsync(updatedAccountAndDevice.first())
|
||||
.thenApply(ignored -> updatedAccountAndDevice))
|
||||
.exceptionallyCompose(throwable -> {
|
||||
if (ExceptionUtils.unwrap(throwable) instanceof ContestedOptimisticLockException && retries > 0) {
|
||||
return addDevice(accountIdentifier, deviceSpec, retries - 1);
|
||||
return addDevice(accountIdentifier, deviceSpec, linkDeviceToken, retries - 1);
|
||||
} else if (ExceptionUtils.unwrap(throwable) instanceof TransactionCanceledException transactionCanceledException) {
|
||||
// We can be confident the transaction was canceled because the linked device token was already used if the
|
||||
// "check token" transaction write item is the only one that failed. That SHOULD be the last one in the
|
||||
// list.
|
||||
final long cancelledTransactions = transactionCanceledException.cancellationReasons().stream()
|
||||
.filter(cancellationReason -> !"None".equals(cancellationReason.code()))
|
||||
.count();
|
||||
|
||||
final boolean tokenReuseConditionFailed =
|
||||
"ConditionalCheckFailed".equals(transactionCanceledException.cancellationReasons().getLast().code());
|
||||
|
||||
if (cancelledTransactions == 1 && tokenReuseConditionFailed) {
|
||||
return CompletableFuture.failedFuture(new LinkDeviceTokenAlreadyUsedException());
|
||||
}
|
||||
}
|
||||
|
||||
return CompletableFuture.failedFuture(throwable);
|
||||
});
|
||||
}
|
||||
|
||||
private Mac getInitializedMac() {
|
||||
try {
|
||||
return getInitializedMac(verificationTokenKey);
|
||||
} catch (final InvalidKeyException e) {
|
||||
// We checked the key at construction time, so this can never happen
|
||||
throw new AssertionError("Previously valid key now invalid", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static Mac getInitializedMac(final Key linkDeviceTokenKey) throws InvalidKeyException {
|
||||
try {
|
||||
final Mac mac = Mac.getInstance(LINK_DEVICE_VERIFICATION_TOKEN_ALGORITHM);
|
||||
mac.init(linkDeviceTokenKey);
|
||||
|
||||
return mac;
|
||||
} catch (final NoSuchAlgorithmException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
public String generateDeviceLinkingToken(final UUID aci) {
|
||||
final String claims = aci + "." + clock.instant().toEpochMilli();
|
||||
final byte[] signature = getInitializedMac().doFinal(claims.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
return claims + ":" + Base64.getUrlEncoder().encodeToString(signature);
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
static String generateDeviceLinkingToken(final UUID aci, final Key linkDeviceTokenKey, final Clock clock)
|
||||
throws InvalidKeyException {
|
||||
|
||||
final String claims = aci + "." + clock.instant().toEpochMilli();
|
||||
final byte[] signature = getInitializedMac(linkDeviceTokenKey).doFinal(claims.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
return claims + ":" + Base64.getUrlEncoder().encodeToString(signature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks that a device-linking token is valid and returns the account identifier from the token if so, or empty if
|
||||
* the token was invalid or has already been used
|
||||
*
|
||||
* @param token the device-linking token to check
|
||||
*
|
||||
* @return the account identifier from a valid token or empty if the token was invalid or already used
|
||||
*/
|
||||
public Optional<UUID> checkDeviceLinkingToken(final String token) {
|
||||
final boolean tokenUsed = rateLimitCluster.withCluster(connection ->
|
||||
connection.sync().get(getUsedTokenKey(token)) != null);
|
||||
|
||||
if (tokenUsed) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
final String[] claimsAndSignature = token.split(":", 2);
|
||||
|
||||
if (claimsAndSignature.length != 2) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
final byte[] expectedSignature = getInitializedMac().doFinal(claimsAndSignature[0].getBytes(StandardCharsets.UTF_8));
|
||||
final byte[] providedSignature;
|
||||
|
||||
try {
|
||||
providedSignature = Base64.getUrlDecoder().decode(claimsAndSignature[1]);
|
||||
} catch (final IllegalArgumentException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
if (!MessageDigest.isEqual(expectedSignature, providedSignature)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
final String[] aciAndTimestamp = claimsAndSignature[0].split("\\.", 2);
|
||||
|
||||
if (aciAndTimestamp.length != 2) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
final UUID aci;
|
||||
|
||||
try {
|
||||
aci = UUID.fromString(aciAndTimestamp[0]);
|
||||
} catch (final IllegalArgumentException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
final Instant timestamp;
|
||||
|
||||
try {
|
||||
timestamp = Instant.ofEpochMilli(Long.parseLong(aciAndTimestamp[1]));
|
||||
} catch (final NumberFormatException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
final Instant tokenExpiration = timestamp.plus(LINK_DEVICE_TOKEN_EXPIRATION_DURATION);
|
||||
|
||||
if (tokenExpiration.isBefore(clock.instant())) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
return Optional.of(aci);
|
||||
}
|
||||
|
||||
private static String getUsedTokenKey(final String token) {
|
||||
return "usedToken::" + token;
|
||||
}
|
||||
|
||||
public CompletableFuture<Account> removeDevice(final Account account, final byte deviceId) {
|
||||
if (deviceId == Device.PRIMARY_ID) {
|
||||
throw new IllegalArgumentException("Cannot remove primary device");
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
package org.whispersystems.textsecuregcm.storage;
|
||||
|
||||
public class LinkDeviceTokenAlreadyUsedException extends Exception {
|
||||
}
|
||||
@@ -180,7 +180,8 @@ record CommandDependencies(
|
||||
configuration.getDynamoDbTables().getAccounts().getPhoneNumberTableName(),
|
||||
configuration.getDynamoDbTables().getAccounts().getPhoneNumberIdentifierTableName(),
|
||||
configuration.getDynamoDbTables().getAccounts().getUsernamesTableName(),
|
||||
configuration.getDynamoDbTables().getDeletedAccounts().getTableName());
|
||||
configuration.getDynamoDbTables().getDeletedAccounts().getTableName(),
|
||||
configuration.getDynamoDbTables().getAccounts().getUsedLinkDeviceTokensTableName());
|
||||
PhoneNumberIdentifiers phoneNumberIdentifiers = new PhoneNumberIdentifiers(dynamoDbClient,
|
||||
configuration.getDynamoDbTables().getPhoneNumberIdentifiers().getTableName());
|
||||
Profiles profiles = new Profiles(dynamoDbClient, dynamoDbAsyncClient,
|
||||
@@ -225,10 +226,10 @@ record CommandDependencies(
|
||||
ClientPublicKeysManager clientPublicKeysManager =
|
||||
new ClientPublicKeysManager(clientPublicKeys, accountLockManager, accountLockExecutor);
|
||||
AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster,
|
||||
accountLockManager, keys, messagesManager, profilesManager,
|
||||
rateLimitersCluster, accountLockManager, keys, messagesManager, profilesManager,
|
||||
secureStorageClient, secureValueRecovery2Client, clientPresenceManager,
|
||||
registrationRecoveryPasswordsManager, clientPublicKeysManager, accountLockExecutor, clientPresenceExecutor,
|
||||
clock, dynamicConfigurationManager);
|
||||
clock, configuration.getLinkDeviceSecretConfiguration().secret().value(), dynamicConfigurationManager);
|
||||
RateLimiters rateLimiters = RateLimiters.createAndValidate(configuration.getLimitsConfiguration(),
|
||||
dynamicConfigurationManager, rateLimitersCluster);
|
||||
final BackupsDb backupsDb =
|
||||
|
||||
Reference in New Issue
Block a user