From f7aacefc40e0e4dbee380443491b853f09ea8147 Mon Sep 17 00:00:00 2001 From: Jon Chambers <63609320+jon-signal@users.noreply.github.com> Date: Mon, 7 Oct 2024 16:26:11 -0400 Subject: [PATCH] Manage device linking tokens transactionally --- service/config/sample.yml | 1 + .../textsecuregcm/WhisperServerService.java | 10 +- .../AccountsTableConfiguration.java | 10 +- .../controllers/DeviceController.java | 186 ++++------------- .../textsecuregcm/storage/Accounts.java | 42 +++- .../storage/AccountsManager.java | 187 ++++++++++++++++-- .../LinkDeviceTokenAlreadyUsedException.java | 4 + .../workers/CommandDependencies.java | 7 +- .../controllers/DeviceControllerTest.java | 185 +++++++---------- ...ccountCreationDeletionIntegrationTest.java | 5 +- ...ntsManagerChangeNumberIntegrationTest.java | 5 +- ...ConcurrentModificationIntegrationTest.java | 5 +- .../storage/AccountsManagerTest.java | 87 +++++++- ...ccountsManagerUsernameIntegrationTest.java | 6 +- .../textsecuregcm/storage/AccountsTest.java | 19 +- .../AddRemoveDeviceIntegrationTest.java | 77 +++++++- .../storage/DynamoDbExtensionSchema.java | 10 + service/src/test/resources/config/test.yml | 1 + 18 files changed, 539 insertions(+), 308 deletions(-) create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/storage/LinkDeviceTokenAlreadyUsedException.java diff --git a/service/config/sample.yml b/service/config/sample.yml index 8547ab90f..abe71c8b3 100644 --- a/service/config/sample.yml +++ b/service/config/sample.yml @@ -102,6 +102,7 @@ dynamoDbTables: phoneNumberTableName: Example_Accounts_PhoneNumbers phoneNumberIdentifierTableName: Example_Accounts_PhoneNumberIdentifiers usernamesTableName: Example_Accounts_Usernames + usedLinkDeviceTokensTableName: Example_Accounts_UsedLinkDeviceTokens backups: tableName: Example_Backups clientReleases: diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index a230bcefe..dd06be112 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -403,7 +403,8 @@ public class WhisperServerService extends Application maxDeviceConfiguration; - private final Clock clock; + public DeviceController(final AccountsManager accounts, + final ClientPublicKeysManager clientPublicKeysManager, + final RateLimiters rateLimiters, + final Map 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 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 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; - } } 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 c9ffae3ce..fb0d0e9cd 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/Accounts.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/Accounts.java @@ -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 getByE164(final String number) { return getByIndirectLookup( diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountsManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountsManager.java index 2c82c09bd..ebae563f9 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountsManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountsManager.java @@ -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 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 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> addDevice(final Account account, final DeviceSpec deviceSpec) { + public CompletableFuture> 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> addDevice(final UUID accountIdentifier, final DeviceSpec deviceSpec, final int retries) { + private CompletableFuture> 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 additionalWriteItems = keysManager.buildWriteItemsForNewDevice( + final List 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 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 removeDevice(final Account account, final byte deviceId) { if (deviceId == Device.PRIMARY_ID) { throw new IllegalArgumentException("Cannot remove primary device"); diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/LinkDeviceTokenAlreadyUsedException.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/LinkDeviceTokenAlreadyUsedException.java new file mode 100644 index 000000000..5c04fbde2 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/LinkDeviceTokenAlreadyUsedException.java @@ -0,0 +1,4 @@ +package org.whispersystems.textsecuregcm.storage; + +public class LinkDeviceTokenAlreadyUsedException extends Exception { +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/workers/CommandDependencies.java b/service/src/main/java/org/whispersystems/textsecuregcm/workers/CommandDependencies.java index f17420bc1..9e4a66228 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/workers/CommandDependencies.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/workers/CommandDependencies.java @@ -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 = diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/DeviceControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/DeviceControllerTest.java index 59d490bf9..3e5ed353e 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/DeviceControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/DeviceControllerTest.java @@ -25,12 +25,10 @@ import io.dropwizard.testing.junit5.ResourceExtension; import io.lettuce.core.cluster.api.async.RedisAdvancedClusterAsyncCommands; import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands; import java.nio.charset.StandardCharsets; -import java.time.Instant; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.stream.IntStream; import java.util.stream.Stream; @@ -73,11 +71,11 @@ 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.tests.util.AccountsHelper; import org.whispersystems.textsecuregcm.tests.util.AuthHelper; import org.whispersystems.textsecuregcm.tests.util.KeysHelper; import org.whispersystems.textsecuregcm.tests.util.MockRedisFuture; -import org.whispersystems.textsecuregcm.tests.util.RedisClusterHelper; import org.whispersystems.textsecuregcm.util.Pair; import org.whispersystems.textsecuregcm.util.TestClock; import org.whispersystems.textsecuregcm.util.TestRandomUtil; @@ -102,16 +100,10 @@ class DeviceControllerTest { private static final byte NEXT_DEVICE_ID = 42; private static DeviceController deviceController = new DeviceController( - generateLinkDeviceSecret(), accountsManager, clientPublicKeysManager, rateLimiters, - RedisClusterHelper.builder() - .stringCommands(commands) - .stringAsyncCommands(asyncCommands) - .build(), - deviceConfiguration, - testClock); + deviceConfiguration); @RegisterExtension public static final AuthHelper.AuthFilterExtension AUTH_FILTER_EXTENSION = new AuthHelper.AuthFilterExtension(); @@ -126,10 +118,6 @@ class DeviceControllerTest { .addResource(deviceController) .build(); - private static byte[] generateLinkDeviceSecret() { - return TestRandomUtil.nextBytes(32); - } - @BeforeEach void setup() { when(rateLimiters.getAllocateDeviceLimiter()).thenReturn(rateLimiter); @@ -183,12 +171,6 @@ class DeviceControllerTest { when(existingDevice.getId()).thenReturn(Device.PRIMARY_ID); when(AuthHelper.VALID_ACCOUNT.getDevices()).thenReturn(List.of(existingDevice)); - VerificationCode deviceCode = resources.getJerseyTest() - .target("/v1/devices/provisioning/code") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(VerificationCode.class); - final ECSignedPreKey aciSignedPreKey; final ECSignedPreKey pniSignedPreKey; final KEMSignedPreKey aciPqLastResortPreKey; @@ -205,7 +187,9 @@ class DeviceControllerTest { when(account.getIdentityKey(IdentityType.ACI)).thenReturn(new IdentityKey(aciIdentityKeyPair.getPublicKey())); when(account.getIdentityKey(IdentityType.PNI)).thenReturn(new IdentityKey(pniIdentityKeyPair.getPublicKey())); - when(accountsManager.addDevice(any(), any())).thenAnswer(invocation -> { + when(accountsManager.checkDeviceLinkingToken(anyString())).thenReturn(Optional.of(AuthHelper.VALID_UUID)); + + when(accountsManager.addDevice(any(), any(), any())).thenAnswer(invocation -> { final Account a = invocation.getArgument(0); final DeviceSpec deviceSpec = invocation.getArgument(1); @@ -217,7 +201,7 @@ class DeviceControllerTest { final AccountAttributes accountAttributes = new AccountAttributes(fetchesMessages, 1234, 5678, null, null, true, new DeviceCapabilities(true, true, true, false, false)); - final LinkDeviceRequest request = new LinkDeviceRequest(deviceCode.verificationCode(), + final LinkDeviceRequest request = new LinkDeviceRequest("link-device-token", accountAttributes, new DeviceActivationRequest(aciSignedPreKey, pniSignedPreKey, aciPqLastResortPreKey, pniPqLastResortPreKey, apnRegistrationId, gcmRegistrationId)); @@ -230,7 +214,7 @@ class DeviceControllerTest { assertThat(response.getDeviceId()).isEqualTo(NEXT_DEVICE_ID); final ArgumentCaptor deviceSpecCaptor = ArgumentCaptor.forClass(DeviceSpec.class); - verify(accountsManager).addDevice(eq(account), deviceSpecCaptor.capture()); + verify(accountsManager).addDevice(eq(account), deviceSpecCaptor.capture(), any()); final Device device = deviceSpecCaptor.getValue().toDevice(NEXT_DEVICE_ID, testClock); @@ -241,8 +225,6 @@ class DeviceControllerTest { expectedGcmToken.ifPresentOrElse(expectedToken -> assertEquals(expectedToken, device.getGcmId()), () -> assertNull(device.getGcmId())); - - verify(asyncCommands).set(anyString(), anyString(), any()); } private static Stream linkDeviceAtomic() { @@ -261,7 +243,7 @@ class DeviceControllerTest { @MethodSource void deviceDowngradeDeleteSync(final boolean accountSupportsDeleteSync, final boolean deviceSupportsDeleteSync, final int expectedStatus) { when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(account)); - when(accountsManager.addDevice(any(), any())) + when(accountsManager.addDevice(any(), any(), any())) .thenReturn(CompletableFuture.completedFuture(new Pair<>(mock(Account.class), mock(Device.class)))); final Device primaryDevice = mock(Device.class); @@ -287,7 +269,9 @@ class DeviceControllerTest { when(asyncCommands.set(any(), any(), any())).thenReturn(MockRedisFuture.completedFuture(null)); - final LinkDeviceRequest request = new LinkDeviceRequest(deviceController.generateVerificationToken(AuthHelper.VALID_UUID), + when(accountsManager.checkDeviceLinkingToken(anyString())).thenReturn(Optional.of(AuthHelper.VALID_UUID)); + + final LinkDeviceRequest request = new LinkDeviceRequest("link-device-token", new AccountAttributes(false, 1234, 5678, null, null, true, new DeviceCapabilities(true, true, true, deviceSupportsDeleteSync, false)), new DeviceActivationRequest(aciSignedPreKey, pniSignedPreKey, aciPqLastResortPreKey, pniPqLastResortPreKey, Optional.empty(), Optional.of(new GcmRegistrationId("gcm-id")))); @@ -314,7 +298,7 @@ class DeviceControllerTest { void deviceDowngradeVersionedExpirationTimer(final boolean accountSupportsVersionedExpirationTimer, final boolean deviceSupportsVersionedExpirationTimer, final int expectedStatus) { when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(account)); - when(accountsManager.addDevice(any(), any())) + when(accountsManager.addDevice(any(), any(), any())) .thenReturn(CompletableFuture.completedFuture(new Pair<>(mock(Account.class), mock(Device.class)))); final Device primaryDevice = mock(Device.class); @@ -340,7 +324,9 @@ class DeviceControllerTest { when(asyncCommands.set(any(), any(), any())).thenReturn(MockRedisFuture.completedFuture(null)); - final LinkDeviceRequest request = new LinkDeviceRequest(deviceController.generateVerificationToken(AuthHelper.VALID_UUID), + when(accountsManager.checkDeviceLinkingToken(anyString())).thenReturn(Optional.of(AuthHelper.VALID_UUID)); + + final LinkDeviceRequest request = new LinkDeviceRequest("link-device-token", new AccountAttributes(false, 1234, 5678, null, null, true, new DeviceCapabilities(true, true, true, deviceSupportsVersionedExpirationTimer, false)), new DeviceActivationRequest(aciSignedPreKey, pniSignedPreKey, aciPqLastResortPreKey, pniPqLastResortPreKey, Optional.empty(), Optional.of(new GcmRegistrationId("gcm-id")))); @@ -386,7 +372,7 @@ class DeviceControllerTest { when(account.getIdentityKey(IdentityType.ACI)).thenReturn(new IdentityKey(aciIdentityKeyPair.getPublicKey())); when(account.getIdentityKey(IdentityType.PNI)).thenReturn(new IdentityKey(pniIdentityKeyPair.getPublicKey())); - final LinkDeviceRequest request = new LinkDeviceRequest(deviceController.generateVerificationToken(AuthHelper.VALID_UUID), + final LinkDeviceRequest request = new LinkDeviceRequest("link-device-token", new AccountAttributes(false, 1234, 5678, null, null, true, null), new DeviceActivationRequest(aciSignedPreKey, pniSignedPreKey, aciPqLastResortPreKey, pniPqLastResortPreKey, Optional.empty(), Optional.of(new GcmRegistrationId("gcm-id")))); @@ -400,6 +386,52 @@ class DeviceControllerTest { } } + @Test + void linkDeviceAtomicReusedToken() { + final Device existingDevice = mock(Device.class); + when(existingDevice.getId()).thenReturn(Device.PRIMARY_ID); + when(AuthHelper.VALID_ACCOUNT.getDevices()).thenReturn(List.of(existingDevice)); + + final ECSignedPreKey aciSignedPreKey; + final ECSignedPreKey pniSignedPreKey; + final KEMSignedPreKey aciPqLastResortPreKey; + final KEMSignedPreKey pniPqLastResortPreKey; + + final ECKeyPair aciIdentityKeyPair = Curve.generateKeyPair(); + final ECKeyPair pniIdentityKeyPair = Curve.generateKeyPair(); + + aciSignedPreKey = KeysHelper.signedECPreKey(1, aciIdentityKeyPair); + pniSignedPreKey = KeysHelper.signedECPreKey(2, pniIdentityKeyPair); + aciPqLastResortPreKey = KeysHelper.signedKEMPreKey(3, aciIdentityKeyPair); + pniPqLastResortPreKey = KeysHelper.signedKEMPreKey(4, pniIdentityKeyPair); + + when(account.getIdentityKey(IdentityType.ACI)).thenReturn(new IdentityKey(aciIdentityKeyPair.getPublicKey())); + when(account.getIdentityKey(IdentityType.PNI)).thenReturn(new IdentityKey(pniIdentityKeyPair.getPublicKey())); + + when(accountsManager.checkDeviceLinkingToken(anyString())).thenReturn(Optional.of(AuthHelper.VALID_UUID)); + + when(accountsManager.addDevice(any(), any(), any())) + .thenReturn(CompletableFuture.failedFuture(new LinkDeviceTokenAlreadyUsedException())); + + when(asyncCommands.set(any(), any(), any())).thenReturn(MockRedisFuture.completedFuture(null)); + + final AccountAttributes accountAttributes = new AccountAttributes(true, 1234, 5678, null, + null, true, new DeviceCapabilities(true, true, true, false, false)); + + final LinkDeviceRequest request = new LinkDeviceRequest("link-device-token", + accountAttributes, + new DeviceActivationRequest(aciSignedPreKey, pniSignedPreKey, aciPqLastResortPreKey, pniPqLastResortPreKey, Optional.empty(), Optional.empty())); + + try (final Response response = resources.getJerseyTest() + .target("/v1/devices/link") + .request() + .header("Authorization", AuthHelper.getProvisioningAuthHeader(AuthHelper.VALID_NUMBER, "password1")) + .put(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE))) { + + assertEquals(403, response.getStatus()); + } + } + @Test void linkDeviceAtomicWithVerificationTokenUsed() { @@ -427,7 +459,7 @@ class DeviceControllerTest { when(commands.get(anyString())).thenReturn(""); - final LinkDeviceRequest request = new LinkDeviceRequest(deviceController.generateVerificationToken(AuthHelper.VALID_UUID), + final LinkDeviceRequest request = new LinkDeviceRequest("link-device-token", new AccountAttributes(false, 1234, 5678, null, null, true, null), new DeviceActivationRequest(aciSignedPreKey, pniSignedPreKey, aciPqLastResortPreKey, pniPqLastResortPreKey, Optional.empty(), Optional.of(new GcmRegistrationId("gcm-id")))); @@ -577,16 +609,12 @@ class DeviceControllerTest { when(existingDevice.getId()).thenReturn(Device.PRIMARY_ID); when(AuthHelper.VALID_ACCOUNT.getDevices()).thenReturn(List.of(existingDevice)); - VerificationCode deviceCode = resources.getJerseyTest() - .target("/v1/devices/provisioning/code") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(VerificationCode.class); - when(account.getIdentityKey(IdentityType.ACI)).thenReturn(new IdentityKey(aciIdentityKeyPair.getPublicKey())); when(account.getIdentityKey(IdentityType.PNI)).thenReturn(new IdentityKey(pniIdentityKeyPair.getPublicKey())); - final LinkDeviceRequest request = new LinkDeviceRequest(deviceCode.verificationCode(), + when(accountsManager.checkDeviceLinkingToken(anyString())).thenReturn(Optional.of(AuthHelper.VALID_UUID)); + + final LinkDeviceRequest request = new LinkDeviceRequest("link-device-token", new AccountAttributes(true, 1234, 5678, null, null, true, null), new DeviceActivationRequest(aciSignedPreKey, pniSignedPreKey, aciPqLastResortPreKey, pniPqLastResortPreKey, Optional.empty(), Optional.empty())); @@ -614,17 +642,12 @@ class DeviceControllerTest { final Device existingDevice = mock(Device.class); when(existingDevice.getId()).thenReturn(Device.PRIMARY_ID); when(AuthHelper.VALID_ACCOUNT.getDevices()).thenReturn(List.of(existingDevice)); - - VerificationCode deviceCode = resources.getJerseyTest() - .target("/v1/devices/provisioning/code") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(VerificationCode.class); - when(account.getIdentityKey(IdentityType.ACI)).thenReturn(aciIdentityKey); when(account.getIdentityKey(IdentityType.PNI)).thenReturn(pniIdentityKey); - final LinkDeviceRequest request = new LinkDeviceRequest(deviceCode.verificationCode(), + when(accountsManager.checkDeviceLinkingToken(anyString())).thenReturn(Optional.of(AuthHelper.VALID_UUID)); + + final LinkDeviceRequest request = new LinkDeviceRequest("link-device-token", new AccountAttributes(true, 1234, 5678, null, null, true, null), new DeviceActivationRequest(aciSignedPreKey, pniSignedPreKey, aciPqLastResortPreKey, pniPqLastResortPreKey, Optional.empty(), Optional.empty())); @@ -683,7 +706,7 @@ class DeviceControllerTest { when(account.getIdentityKey(IdentityType.ACI)).thenReturn(new IdentityKey(aciIdentityKeyPair.getPublicKey())); when(account.getIdentityKey(IdentityType.PNI)).thenReturn(new IdentityKey(pniIdentityKeyPair.getPublicKey())); - final LinkDeviceRequest request = new LinkDeviceRequest(deviceController.generateVerificationToken(AuthHelper.VALID_UUID), + final LinkDeviceRequest request = new LinkDeviceRequest("link-device-token", new AccountAttributes(false, 1234, 5678, TestRandomUtil.nextBytes(512), null, true, null), new DeviceActivationRequest(aciSignedPreKey, pniSignedPreKey, aciPqLastResortPreKey, pniPqLastResortPreKey, Optional.empty(), Optional.of(new GcmRegistrationId("gcm-id")))); @@ -704,12 +727,6 @@ class DeviceControllerTest { when(existingDevice.getId()).thenReturn(Device.PRIMARY_ID); when(AuthHelper.VALID_ACCOUNT.getDevices()).thenReturn(List.of(existingDevice)); - VerificationCode deviceCode = resources.getJerseyTest() - .target("/v1/devices/provisioning/code") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(VerificationCode.class); - final ECKeyPair aciIdentityKeyPair = Curve.generateKeyPair(); final ECKeyPair pniIdentityKeyPair = Curve.generateKeyPair(); @@ -721,16 +738,18 @@ class DeviceControllerTest { when(account.getIdentityKey(IdentityType.ACI)).thenReturn(new IdentityKey(aciIdentityKeyPair.getPublicKey())); when(account.getIdentityKey(IdentityType.PNI)).thenReturn(new IdentityKey(pniIdentityKeyPair.getPublicKey())); - when(accountsManager.addDevice(any(), any())).thenAnswer(invocation -> { + when(accountsManager.addDevice(any(), any(), any())).thenAnswer(invocation -> { final Account a = invocation.getArgument(0); final DeviceSpec deviceSpec = invocation.getArgument(1); return CompletableFuture.completedFuture(new Pair<>(a, deviceSpec.toDevice(NEXT_DEVICE_ID, testClock))); }); + when(accountsManager.checkDeviceLinkingToken(anyString())).thenReturn(Optional.of(AuthHelper.VALID_UUID)); + when(asyncCommands.set(any(), any(), any())).thenReturn(MockRedisFuture.completedFuture(null)); - final LinkDeviceRequest request = new LinkDeviceRequest(deviceCode.verificationCode(), + final LinkDeviceRequest request = new LinkDeviceRequest("link-device-token", new AccountAttributes(false, registrationId, pniRegistrationId, null, null, true, new DeviceCapabilities(true, true, true, false, false)), new DeviceActivationRequest(aciSignedPreKey, pniSignedPreKey, aciPqLastResortPreKey, pniPqLastResortPreKey, Optional.of(new ApnRegistrationId("apn")), Optional.empty())); @@ -785,7 +804,7 @@ class DeviceControllerTest { .get(); assertEquals(411, response.getStatus()); - verify(accountsManager, never()).addDevice(any(), any()); + verify(accountsManager, never()).addDevice(any(), any(), any()); } @Test @@ -898,60 +917,6 @@ class DeviceControllerTest { } } - @Test - void checkVerificationToken() { - final UUID uuid = UUID.randomUUID(); - - assertEquals(Optional.of(uuid), - deviceController.checkVerificationToken(deviceController.generateVerificationToken(uuid))); - } - - @ParameterizedTest - @MethodSource - void checkVerificationTokenBadToken(final String token, final Instant currentTime) { - testClock.pin(currentTime); - - assertEquals(Optional.empty(), - deviceController.checkVerificationToken(token)); - } - - private static Stream checkVerificationTokenBadToken() { - final Instant tokenTimestamp = testClock.instant(); - - return Stream.of( - // Expired token - Arguments.of(deviceController.generateVerificationToken(UUID.randomUUID()), - tokenTimestamp.plus(DeviceController.TOKEN_EXPIRATION_DURATION).plusSeconds(1)), - - // Bad UUID - Arguments.of("not-a-valid-uuid.1691096565171:0CKWF7q3E9fi4sB2or4q1A0Up2z_73EQlMAy7Dpel9c=", tokenTimestamp), - - // No UUID - Arguments.of(".1691096565171:0CKWF7q3E9fi4sB2or4q1A0Up2z_73EQlMAy7Dpel9c=", tokenTimestamp), - - // Bad timestamp - Arguments.of("e552603a-1492-4de6-872d-bac19a2825b4.not-a-valid-timestamp:0CKWF7q3E9fi4sB2or4q1A0Up2z_73EQlMAy7Dpel9c=", tokenTimestamp), - - // No timestamp - Arguments.of("e552603a-1492-4de6-872d-bac19a2825b4:0CKWF7q3E9fi4sB2or4q1A0Up2z_73EQlMAy7Dpel9c=", tokenTimestamp), - - // Blank timestamp - Arguments.of("e552603a-1492-4de6-872d-bac19a2825b4.:0CKWF7q3E9fi4sB2or4q1A0Up2z_73EQlMAy7Dpel9c=", tokenTimestamp), - - // No signature - Arguments.of("e552603a-1492-4de6-872d-bac19a2825b4.1691096565171", tokenTimestamp), - - // Blank signature - Arguments.of("e552603a-1492-4de6-872d-bac19a2825b4.1691096565171:", tokenTimestamp), - - // Incorrect signature - Arguments.of("e552603a-1492-4de6-872d-bac19a2825b4.1691096565171:0CKWF7q3E9fi4sB2or4q1A0Up2z_73EQlMAy7Dpel9c=", tokenTimestamp), - - // Invalid signature - Arguments.of("e552603a-1492-4de6-872d-bac19a2825b4.1691096565171:This is not valid base64", tokenTimestamp) - ); - } - @Test void setPublicKey() { final SetPublicKeyRequest request = new SetPublicKeyRequest(Curve.generateKeyPair().getPublicKey()); diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountCreationDeletionIntegrationTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountCreationDeletionIntegrationTest.java index f4998ae0b..37ea07312 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountCreationDeletionIntegrationTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountCreationDeletionIntegrationTest.java @@ -105,7 +105,8 @@ public class AccountCreationDeletionIntegrationTest { DynamoDbExtensionSchema.Tables.NUMBERS.tableName(), DynamoDbExtensionSchema.Tables.PNI_ASSIGNMENTS.tableName(), DynamoDbExtensionSchema.Tables.USERNAMES.tableName(), - DynamoDbExtensionSchema.Tables.DELETED_ACCOUNTS.tableName()); + DynamoDbExtensionSchema.Tables.DELETED_ACCOUNTS.tableName(), + DynamoDbExtensionSchema.Tables.USED_LINK_DEVICE_TOKENS.tableName()); accountLockExecutor = Executors.newSingleThreadExecutor(); clientPresenceExecutor = Executors.newSingleThreadExecutor(); @@ -141,6 +142,7 @@ public class AccountCreationDeletionIntegrationTest { accounts, phoneNumberIdentifiers, CACHE_CLUSTER_EXTENSION.getRedisCluster(), + CACHE_CLUSTER_EXTENSION.getRedisCluster(), accountLockManager, keysManager, messagesManager, @@ -153,6 +155,7 @@ public class AccountCreationDeletionIntegrationTest { accountLockExecutor, clientPresenceExecutor, CLOCK, + "link-device-secret".getBytes(StandardCharsets.UTF_8), dynamicConfigurationManager); } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerChangeNumberIntegrationTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerChangeNumberIntegrationTest.java index 8447d00e5..cf03a4b81 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerChangeNumberIntegrationTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerChangeNumberIntegrationTest.java @@ -98,7 +98,8 @@ class AccountsManagerChangeNumberIntegrationTest { Tables.NUMBERS.tableName(), Tables.PNI_ASSIGNMENTS.tableName(), Tables.USERNAMES.tableName(), - Tables.DELETED_ACCOUNTS.tableName()); + Tables.DELETED_ACCOUNTS.tableName(), + Tables.USED_LINK_DEVICE_TOKENS.tableName()); accountLockExecutor = Executors.newSingleThreadExecutor(); clientPresenceExecutor = Executors.newSingleThreadExecutor(); @@ -136,6 +137,7 @@ class AccountsManagerChangeNumberIntegrationTest { accounts, phoneNumberIdentifiers, CACHE_CLUSTER_EXTENSION.getRedisCluster(), + CACHE_CLUSTER_EXTENSION.getRedisCluster(), accountLockManager, keysManager, messagesManager, @@ -148,6 +150,7 @@ class AccountsManagerChangeNumberIntegrationTest { accountLockExecutor, clientPresenceExecutor, mock(Clock.class), + "link-device-secret".getBytes(StandardCharsets.UTF_8), dynamicConfigurationManager); } } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerConcurrentModificationIntegrationTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerConcurrentModificationIntegrationTest.java index f9419a4f2..2c99831c1 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerConcurrentModificationIntegrationTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerConcurrentModificationIntegrationTest.java @@ -93,7 +93,8 @@ class AccountsManagerConcurrentModificationIntegrationTest { Tables.NUMBERS.tableName(), Tables.PNI_ASSIGNMENTS.tableName(), Tables.USERNAMES.tableName(), - Tables.DELETED_ACCOUNTS.tableName()); + Tables.DELETED_ACCOUNTS.tableName(), + Tables.USED_LINK_DEVICE_TOKENS.tableName()); { //noinspection unchecked @@ -123,6 +124,7 @@ class AccountsManagerConcurrentModificationIntegrationTest { accounts, phoneNumberIdentifiers, RedisClusterHelper.builder().stringCommands(commands).build(), + RedisClusterHelper.builder().stringCommands(commands).build(), accountLockManager, mock(KeysManager.class), mock(MessagesManager.class), @@ -135,6 +137,7 @@ class AccountsManagerConcurrentModificationIntegrationTest { mock(Executor.class), mock(Executor.class), mock(Clock.class), + "link-device-secret".getBytes(StandardCharsets.UTF_8), dynamicConfigurationManager ); } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerTest.java index ccb3982be..1d8e93b4d 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerTest.java @@ -37,7 +37,9 @@ import io.lettuce.core.cluster.api.async.RedisAdvancedClusterAsyncCommands; import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; import java.time.Duration; +import java.time.Instant; import java.util.ArrayList; import java.util.Base64; import java.util.Collections; @@ -75,6 +77,7 @@ import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier; import org.whispersystems.textsecuregcm.identity.IdentityType; import org.whispersystems.textsecuregcm.identity.PniServiceIdentifier; import org.whispersystems.textsecuregcm.push.ClientPresenceManager; +import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster; import org.whispersystems.textsecuregcm.securestorage.SecureStorageClient; import org.whispersystems.textsecuregcm.securevaluerecovery.SecureValueRecovery2Client; import org.whispersystems.textsecuregcm.securevaluerecovery.SecureValueRecoveryException; @@ -89,6 +92,7 @@ import org.whispersystems.textsecuregcm.util.CompletableFutureTestUtil; import org.whispersystems.textsecuregcm.util.Pair; import org.whispersystems.textsecuregcm.util.TestClock; import org.whispersystems.textsecuregcm.util.TestRandomUtil; +import javax.crypto.spec.SecretKeySpec; @Timeout(value = 10, threadMode = Timeout.ThreadMode.SEPARATE_THREAD) class AccountsManagerTest { @@ -102,6 +106,10 @@ class AccountsManagerTest { private static final byte[] ENCRYPTED_USERNAME_1 = Base64.getUrlDecoder().decode(BASE_64_URL_ENCRYPTED_USERNAME_1); private static final byte[] ENCRYPTED_USERNAME_2 = Base64.getUrlDecoder().decode(BASE_64_URL_ENCRYPTED_USERNAME_2); + private static final byte[] LINK_DEVICE_SECRET = "link-device-secret".getBytes(StandardCharsets.UTF_8); + + private static TestClock CLOCK; + private Accounts accounts; private KeysManager keysManager; private MessagesManager messagesManager; @@ -113,7 +121,6 @@ class AccountsManagerTest { private RedisAdvancedClusterCommands commands; private RedisAdvancedClusterAsyncCommands asyncCommands; - private TestClock clock; private AccountsManager accountsManager; private SecureValueRecovery2Client svr2Client; private DynamicConfiguration dynamicConfiguration; @@ -161,6 +168,7 @@ class AccountsManagerTest { asyncCommands = mock(RedisAdvancedClusterAsyncCommands.class); when(asyncCommands.del(any(String[].class))).thenReturn(MockRedisFuture.completedFuture(0L)); when(asyncCommands.get(any())).thenReturn(MockRedisFuture.completedFuture(null)); + when(asyncCommands.set(any(), any(), any())).thenReturn(MockRedisFuture.completedFuture("OK")); when(asyncCommands.setex(any(), anyLong(), any())).thenReturn(MockRedisFuture.completedFuture("OK")); when(accounts.updateAsync(any())).thenReturn(CompletableFuture.completedFuture(null)); @@ -220,16 +228,18 @@ class AccountsManagerTest { when(messagesManager.clear(any())).thenReturn(CompletableFuture.completedFuture(null)); when(profilesManager.deleteAll(any())).thenReturn(CompletableFuture.completedFuture(null)); - clock = TestClock.now(); + CLOCK = TestClock.now(); + final FaultTolerantRedisCluster redisCluster = RedisClusterHelper.builder() + .stringCommands(commands) + .stringAsyncCommands(asyncCommands) + .build(); accountsManager = new AccountsManager( accounts, phoneNumberIdentifiers, - RedisClusterHelper.builder() - .stringCommands(commands) - .stringAsyncCommands(asyncCommands) - .build(), + redisCluster, + redisCluster, accountLockManager, keysManager, messagesManager, @@ -241,7 +251,8 @@ class AccountsManagerTest { clientPublicKeysManager, mock(Executor.class), clientPresenceExecutor, - clock, + CLOCK, + LINK_DEVICE_SECRET, dynamicConfigurationManager); } @@ -920,7 +931,7 @@ class AccountsManagerTest { PhoneNumberUtil.getInstance().format(PhoneNumberUtil.getInstance().getExampleNumber("US"), PhoneNumberUtil.PhoneNumberFormat.E164); - final Account account = AccountsHelper.generateTestAccount(phoneNumber, List.of(generateTestDevice(clock.millis()))); + final Account account = AccountsHelper.generateTestAccount(phoneNumber, List.of(generateTestDevice(CLOCK.millis()))); final UUID aci = account.getIdentifier(IdentityType.ACI); final UUID pni = account.getIdentifier(IdentityType.PNI); @@ -945,7 +956,7 @@ class AccountsManagerTest { when(accounts.getByAccountIdentifierAsync(aci)).thenReturn(CompletableFuture.completedFuture(Optional.of(account))); when(accounts.updateTransactionallyAsync(any(), any())).thenReturn(CompletableFuture.completedFuture(null)); - clock.pin(clock.instant().plusSeconds(60)); + CLOCK.pin(CLOCK.instant().plusSeconds(60)); final Pair updatedAccountAndDevice = accountsManager.addDevice(account, new DeviceSpec( deviceNameCiphertext, @@ -960,7 +971,8 @@ class AccountsManagerTest { aciSignedPreKey, pniSignedPreKey, aciPqLastResortPreKey, - pniPqLastResortPreKey)) + pniPqLastResortPreKey), + accountsManager.generateDeviceLinkingToken(aci)) .join(); verify(keysManager).deleteSingleUsePreKeys(aci, nextDeviceId); @@ -1589,4 +1601,59 @@ class AccountsManagerTest { KeysHelper.signedKEMPreKey(4, pniKeyPair)), null); } + + @Test + void checkDeviceLinkingToken() { + final UUID aci = UUID.randomUUID(); + + assertEquals(Optional.of(aci), + accountsManager.checkDeviceLinkingToken(accountsManager.generateDeviceLinkingToken(aci))); + } + + @ParameterizedTest + @MethodSource + void checkVerificationTokenBadToken(final String token, final Instant currentTime) { + CLOCK.pin(currentTime); + + assertEquals(Optional.empty(), accountsManager.checkDeviceLinkingToken(token)); + } + + private static Stream checkVerificationTokenBadToken() throws InvalidKeyException { + final Instant tokenTimestamp = Instant.now(); + + return Stream.of( + // Expired token + Arguments.of(AccountsManager.generateDeviceLinkingToken(UUID.randomUUID(), + new SecretKeySpec(LINK_DEVICE_SECRET, AccountsManager.LINK_DEVICE_VERIFICATION_TOKEN_ALGORITHM), + CLOCK), + tokenTimestamp.plus(AccountsManager.LINK_DEVICE_TOKEN_EXPIRATION_DURATION).plusSeconds(1)), + + // Bad UUID + Arguments.of("not-a-valid-uuid.1691096565171:0CKWF7q3E9fi4sB2or4q1A0Up2z_73EQlMAy7Dpel9c=", tokenTimestamp), + + // No UUID + Arguments.of(".1691096565171:0CKWF7q3E9fi4sB2or4q1A0Up2z_73EQlMAy7Dpel9c=", tokenTimestamp), + + // Bad timestamp + Arguments.of("e552603a-1492-4de6-872d-bac19a2825b4.not-a-valid-timestamp:0CKWF7q3E9fi4sB2or4q1A0Up2z_73EQlMAy7Dpel9c=", tokenTimestamp), + + // No timestamp + Arguments.of("e552603a-1492-4de6-872d-bac19a2825b4:0CKWF7q3E9fi4sB2or4q1A0Up2z_73EQlMAy7Dpel9c=", tokenTimestamp), + + // Blank timestamp + Arguments.of("e552603a-1492-4de6-872d-bac19a2825b4.:0CKWF7q3E9fi4sB2or4q1A0Up2z_73EQlMAy7Dpel9c=", tokenTimestamp), + + // No signature + Arguments.of("e552603a-1492-4de6-872d-bac19a2825b4.1691096565171", tokenTimestamp), + + // Blank signature + Arguments.of("e552603a-1492-4de6-872d-bac19a2825b4.1691096565171:", tokenTimestamp), + + // Incorrect signature + Arguments.of("e552603a-1492-4de6-872d-bac19a2825b4.1691096565171:0CKWF7q3E9fi4sB2or4q1A0Up2z_73EQlMAy7Dpel9c=", tokenTimestamp), + + // Invalid signature + Arguments.of("e552603a-1492-4de6-872d-bac19a2825b4.1691096565171:This is not valid base64", tokenTimestamp) + ); + } } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerUsernameIntegrationTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerUsernameIntegrationTest.java index a3f341c84..d2de40cd2 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerUsernameIntegrationTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerUsernameIntegrationTest.java @@ -14,6 +14,7 @@ import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import java.nio.charset.StandardCharsets; import java.time.Clock; import java.time.Duration; import java.time.Instant; @@ -105,7 +106,8 @@ class AccountsManagerUsernameIntegrationTest { Tables.NUMBERS.tableName(), Tables.PNI_ASSIGNMENTS.tableName(), Tables.USERNAMES.tableName(), - Tables.DELETED_ACCOUNTS.tableName())); + Tables.DELETED_ACCOUNTS.tableName(), + Tables.USED_LINK_DEVICE_TOKENS.tableName())); final AccountLockManager accountLockManager = mock(AccountLockManager.class); @@ -135,6 +137,7 @@ class AccountsManagerUsernameIntegrationTest { accounts, phoneNumberIdentifiers, CACHE_CLUSTER_EXTENSION.getRedisCluster(), + CACHE_CLUSTER_EXTENSION.getRedisCluster(), accountLockManager, keysManager, messageManager, @@ -147,6 +150,7 @@ class AccountsManagerUsernameIntegrationTest { Executors.newSingleThreadExecutor(), Executors.newSingleThreadExecutor(), mock(Clock.class), + "link-device-secret".getBytes(StandardCharsets.UTF_8), dynamicConfigurationManager); } 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 b8c74beb5..18e8ccb9d 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsTest.java @@ -106,6 +106,7 @@ class AccountsTest { Tables.PNI_ASSIGNMENTS, Tables.USERNAMES, Tables.DELETED_ACCOUNTS, + Tables.USED_LINK_DEVICE_TOKENS, // This is an unrelated table used to test "tag-along" transactional updates Tables.CLIENT_RELEASES); @@ -132,7 +133,8 @@ class AccountsTest { Tables.NUMBERS.tableName(), Tables.PNI_ASSIGNMENTS.tableName(), Tables.USERNAMES.tableName(), - Tables.DELETED_ACCOUNTS.tableName()); + Tables.DELETED_ACCOUNTS.tableName(), + Tables.USED_LINK_DEVICE_TOKENS.tableName()); } @Test @@ -560,7 +562,8 @@ class AccountsTest { Tables.NUMBERS.tableName(), Tables.PNI_ASSIGNMENTS.tableName(), Tables.USERNAMES.tableName(), - Tables.DELETED_ACCOUNTS.tableName()); + Tables.DELETED_ACCOUNTS.tableName(), + Tables.USED_LINK_DEVICE_TOKENS.tableName()); Exception e = TransactionConflictException.builder().build(); e = wrapException ? new CompletionException(e) : e; @@ -648,7 +651,8 @@ class AccountsTest { Tables.NUMBERS.tableName(), Tables.PNI_ASSIGNMENTS.tableName(), Tables.USERNAMES.tableName(), - Tables.DELETED_ACCOUNTS.tableName()); + Tables.DELETED_ACCOUNTS.tableName(), + Tables.USED_LINK_DEVICE_TOKENS.tableName()); when(dynamoDbAsyncClient.transactWriteItems(any(TransactWriteItemsRequest.class))) .thenReturn(CompletableFuture.failedFuture(TransactionCanceledException.builder() @@ -1039,7 +1043,8 @@ class AccountsTest { Tables.NUMBERS.tableName(), Tables.PNI_ASSIGNMENTS.tableName(), Tables.USERNAMES.tableName(), - Tables.DELETED_ACCOUNTS.tableName()); + Tables.DELETED_ACCOUNTS.tableName(), + Tables.USED_LINK_DEVICE_TOKENS.tableName()); final Account account = generateAccount("+14155551111", UUID.randomUUID(), UUID.randomUUID()); createAccount(account); @@ -1081,7 +1086,8 @@ class AccountsTest { Tables.NUMBERS.tableName(), Tables.PNI_ASSIGNMENTS.tableName(), Tables.USERNAMES.tableName(), - Tables.DELETED_ACCOUNTS.tableName()); + Tables.DELETED_ACCOUNTS.tableName(), + Tables.USED_LINK_DEVICE_TOKENS.tableName()); final Account account = generateAccount("+14155551111", UUID.randomUUID(), UUID.randomUUID()); createAccount(account); @@ -1181,7 +1187,8 @@ class AccountsTest { Tables.NUMBERS.tableName(), Tables.PNI_ASSIGNMENTS.tableName(), Tables.USERNAMES.tableName(), - Tables.DELETED_ACCOUNTS.tableName()); + Tables.DELETED_ACCOUNTS.tableName(), + Tables.USED_LINK_DEVICE_TOKENS.tableName()); final Account account = generateAccount("+14155551111", UUID.randomUUID(), UUID.randomUUID()); createAccount(account); diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AddRemoveDeviceIntegrationTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/AddRemoveDeviceIntegrationTest.java index e11b477e7..b65258e76 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AddRemoveDeviceIntegrationTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/AddRemoveDeviceIntegrationTest.java @@ -2,6 +2,7 @@ package org.whispersystems.textsecuregcm.storage; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -45,6 +46,7 @@ public class AddRemoveDeviceIntegrationTest { DynamoDbExtensionSchema.Tables.CLIENT_PUBLIC_KEYS, DynamoDbExtensionSchema.Tables.DELETED_ACCOUNTS, DynamoDbExtensionSchema.Tables.DELETED_ACCOUNTS_LOCK, + DynamoDbExtensionSchema.Tables.USED_LINK_DEVICE_TOKENS, DynamoDbExtensionSchema.Tables.NUMBERS, DynamoDbExtensionSchema.Tables.PNI, DynamoDbExtensionSchema.Tables.PNI_ASSIGNMENTS, @@ -93,7 +95,8 @@ public class AddRemoveDeviceIntegrationTest { DynamoDbExtensionSchema.Tables.NUMBERS.tableName(), DynamoDbExtensionSchema.Tables.PNI_ASSIGNMENTS.tableName(), DynamoDbExtensionSchema.Tables.USERNAMES.tableName(), - DynamoDbExtensionSchema.Tables.DELETED_ACCOUNTS.tableName()); + DynamoDbExtensionSchema.Tables.DELETED_ACCOUNTS.tableName(), + DynamoDbExtensionSchema.Tables.USED_LINK_DEVICE_TOKENS.tableName()); accountLockExecutor = Executors.newSingleThreadExecutor(); clientPresenceExecutor = Executors.newSingleThreadExecutor(); @@ -129,6 +132,7 @@ public class AddRemoveDeviceIntegrationTest { accounts, phoneNumberIdentifiers, CACHE_CLUSTER_EXTENSION.getRedisCluster(), + CACHE_CLUSTER_EXTENSION.getRedisCluster(), accountLockManager, keysManager, messagesManager, @@ -141,6 +145,7 @@ public class AddRemoveDeviceIntegrationTest { accountLockExecutor, clientPresenceExecutor, CLOCK, + "link-device-secret".getBytes(StandardCharsets.UTF_8), dynamicConfigurationManager); } @@ -182,7 +187,8 @@ public class AddRemoveDeviceIntegrationTest { KeysHelper.signedECPreKey(1, aciKeyPair), KeysHelper.signedECPreKey(2, pniKeyPair), KeysHelper.signedKEMPreKey(3, aciKeyPair), - KeysHelper.signedKEMPreKey(4, pniKeyPair))) + KeysHelper.signedKEMPreKey(4, pniKeyPair)), + accountsManager.generateDeviceLinkingToken(account.getIdentifier(IdentityType.ACI))) .join(); assertEquals(2, updatedAccountAndDevice.first().getDevices().size()); @@ -199,6 +205,67 @@ public class AddRemoveDeviceIntegrationTest { assertTrue(keysManager.getLastResort(updatedAccountAndDevice.first().getPhoneNumberIdentifier(), addedDeviceId).join().isPresent()); } + @Test + void addDeviceReusedToken() throws InterruptedException { + final String number = PhoneNumberUtil.getInstance().format( + PhoneNumberUtil.getInstance().getExampleNumber("US"), + PhoneNumberUtil.PhoneNumberFormat.E164); + + final ECKeyPair aciKeyPair = Curve.generateKeyPair(); + final ECKeyPair pniKeyPair = Curve.generateKeyPair(); + + final Account account = AccountsHelper.createAccount(accountsManager, number); + assertEquals(1, accountsManager.getByAccountIdentifier(account.getUuid()).orElseThrow().getDevices().size()); + + final String linkDeviceToken = accountsManager.generateDeviceLinkingToken(account.getIdentifier(IdentityType.ACI)); + + final Pair updatedAccountAndDevice = + accountsManager.addDevice(account, new DeviceSpec( + "device-name".getBytes(StandardCharsets.UTF_8), + "password", + "OWT", + new Device.DeviceCapabilities(true, true, true, false, false), + 1, + 2, + true, + Optional.empty(), + Optional.empty(), + KeysHelper.signedECPreKey(1, aciKeyPair), + KeysHelper.signedECPreKey(2, pniKeyPair), + KeysHelper.signedKEMPreKey(3, aciKeyPair), + KeysHelper.signedKEMPreKey(4, pniKeyPair)), + linkDeviceToken) + .join(); + + assertEquals(2, + accountsManager.getByAccountIdentifier(updatedAccountAndDevice.first().getUuid()).orElseThrow().getDevices() + .size()); + + final CompletionException completionException = assertThrows(CompletionException.class, + () -> accountsManager.addDevice(account, new DeviceSpec( + "device-name".getBytes(StandardCharsets.UTF_8), + "password", + "OWT", + new Device.DeviceCapabilities(true, true, true, false, false), + 1, + 2, + true, + Optional.empty(), + Optional.empty(), + KeysHelper.signedECPreKey(1, aciKeyPair), + KeysHelper.signedECPreKey(2, pniKeyPair), + KeysHelper.signedKEMPreKey(3, aciKeyPair), + KeysHelper.signedKEMPreKey(4, pniKeyPair)), + linkDeviceToken) + .join()); + + assertInstanceOf(LinkDeviceTokenAlreadyUsedException.class, completionException.getCause()); + + assertEquals(2, + accountsManager.getByAccountIdentifier(updatedAccountAndDevice.first().getUuid()).orElseThrow().getDevices() + .size()); + } + @Test void removeDevice() throws InterruptedException { final String number = PhoneNumberUtil.getInstance().format( @@ -225,7 +292,8 @@ public class AddRemoveDeviceIntegrationTest { KeysHelper.signedECPreKey(1, aciKeyPair), KeysHelper.signedECPreKey(2, pniKeyPair), KeysHelper.signedKEMPreKey(3, aciKeyPair), - KeysHelper.signedKEMPreKey(4, pniKeyPair))) + KeysHelper.signedKEMPreKey(4, pniKeyPair)), + accountsManager.generateDeviceLinkingToken(account.getIdentifier(IdentityType.ACI))) .join(); final byte addedDeviceId = updatedAccountAndDevice.second().getId(); @@ -278,7 +346,8 @@ public class AddRemoveDeviceIntegrationTest { KeysHelper.signedECPreKey(1, aciKeyPair), KeysHelper.signedECPreKey(2, pniKeyPair), KeysHelper.signedKEMPreKey(3, aciKeyPair), - KeysHelper.signedKEMPreKey(4, pniKeyPair))) + KeysHelper.signedKEMPreKey(4, pniKeyPair)), + accountsManager.generateDeviceLinkingToken(account.getIdentifier(IdentityType.ACI))) .join(); final byte addedDeviceId = updatedAccountAndDevice.second().getId(); diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/DynamoDbExtensionSchema.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/DynamoDbExtensionSchema.java index 3aa5d3e73..6fc7c44fd 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/DynamoDbExtensionSchema.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/DynamoDbExtensionSchema.java @@ -372,6 +372,16 @@ public final class DynamoDbExtensionSchema { List.of(), List.of()), + USED_LINK_DEVICE_TOKENS("used_link_device_tokens_test", + Accounts.KEY_LINK_DEVICE_TOKEN_HASH, + null, + List.of(AttributeDefinition.builder() + .attributeName(Accounts.KEY_LINK_DEVICE_TOKEN_HASH) + .attributeType(ScalarAttributeType.B) + .build()), + List.of(), + List.of()), + USERNAMES("usernames_test", Accounts.ATTR_USERNAME_HASH, null, diff --git a/service/src/test/resources/config/test.yml b/service/src/test/resources/config/test.yml index 2edb863f6..5c157529d 100644 --- a/service/src/test/resources/config/test.yml +++ b/service/src/test/resources/config/test.yml @@ -97,6 +97,7 @@ dynamoDbTables: phoneNumberTableName: numbers_test phoneNumberIdentifierTableName: pni_assignment_test usernamesTableName: usernames_test + usedLinkDeviceTokensTableName: used_link_device_tokens_test backups: tableName: backups_test clientReleases: