diff --git a/service/config/sample.yml b/service/config/sample.yml index 7fb84930c..920200c99 100644 --- a/service/config/sample.yml +++ b/service/config/sample.yml @@ -80,6 +80,9 @@ dynamoDbTables: tableName: Example_ReservedUsernames subscriptions: tableName: Example_Subscriptions + registrationRecovery: + tableName: Example_RegistrationRecovery + expiration: P300D # Duration of time until rows expire cacheCluster: # Redis server configuration for cache cluster configurationUri: redis://redis.example.com:6379/ diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index f6810d455..53dd82f3a 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -197,6 +197,8 @@ import org.whispersystems.textsecuregcm.storage.PubSubManager; import org.whispersystems.textsecuregcm.storage.PushChallengeDynamoDb; import org.whispersystems.textsecuregcm.storage.PushFeedbackProcessor; import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager; +import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswords; +import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager; import org.whispersystems.textsecuregcm.storage.RemoteConfigs; import org.whispersystems.textsecuregcm.storage.RemoteConfigsManager; import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb; @@ -368,6 +370,12 @@ public class WhisperServerService extends Application { + final Account updatedAccount = accounts.update(account, a -> { a.getDevice(deviceId).ifPresent(d -> { d.setFetchesMessages(attributes.getFetchesMessages()); d.setName(attributes.getName()); @@ -667,6 +672,10 @@ public class AccountController { a.setUnrestrictedUnidentifiedAccess(attributes.isUnrestrictedUnidentifiedAccess()); a.setDiscoverableByPhoneNumber(attributes.isDiscoverableByPhoneNumber()); }); + + // if registration recovery password was sent to us, store it (or refresh its expiration) + attributes.recoveryPassword().ifPresent(registrationRecoveryPassword -> + registrationRecoveryPasswordsManager.storeForCurrentNumber(updatedAccount.getNumber(), registrationRecoveryPassword)); } @GET diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountAttributes.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountAttributes.java index 199dc549f..6f256c1a9 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountAttributes.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountAttributes.java @@ -1,16 +1,19 @@ /* - * Copyright 2013-2020 Signal Messenger, LLC + * Copyright 2013 Signal Messenger, LLC * SPDX-License-Identifier: AGPL-3.0-only */ package org.whispersystems.textsecuregcm.entities; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.google.common.annotations.VisibleForTesting; +import java.util.Optional; +import java.util.OptionalInt; import javax.annotation.Nullable; import javax.validation.constraints.Size; import org.whispersystems.textsecuregcm.storage.Device.DeviceCapabilities; +import org.whispersystems.textsecuregcm.util.ByteArrayAdapter; import org.whispersystems.textsecuregcm.util.ExactlySize; -import java.util.OptionalInt; public class AccountAttributes { @@ -20,7 +23,6 @@ public class AccountAttributes { @JsonProperty private int registrationId; - @Nullable @JsonProperty("pniRegistrationId") private Integer phoneNumberIdentityRegistrationId; @@ -44,11 +46,22 @@ public class AccountAttributes { @JsonProperty private boolean discoverableByPhoneNumber = true; - public AccountAttributes() {} + @JsonProperty + @Nullable + @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class) + private byte[] recoveryPassword = null; + + public AccountAttributes() { + } @VisibleForTesting - public AccountAttributes(boolean fetchesMessages, int registrationId, String name, String registrationLock, - boolean discoverableByPhoneNumber, final DeviceCapabilities capabilities) { + public AccountAttributes( + final boolean fetchesMessages, + final int registrationId, + final String name, + final String registrationLock, + final boolean discoverableByPhoneNumber, + final DeviceCapabilities capabilities) { this.fetchesMessages = fetchesMessages; this.registrationId = registrationId; this.name = name; @@ -93,8 +106,19 @@ public class AccountAttributes { return discoverableByPhoneNumber; } + public Optional recoveryPassword() { + return Optional.ofNullable(recoveryPassword); + } + @VisibleForTesting - public void setUnidentifiedAccessKey(final byte[] unidentifiedAccessKey) { + public AccountAttributes withUnidentifiedAccessKey(final byte[] unidentifiedAccessKey) { this.unidentifiedAccessKey = unidentifiedAccessKey; + return this; + } + + @VisibleForTesting + public AccountAttributes withRecoveryPassword(final byte[] recoveryPassword) { + this.recoveryPassword = recoveryPassword; + return this; } } 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 8f2d59b19..ea1c3def3 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountsManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountsManager.java @@ -6,6 +6,7 @@ package org.whispersystems.textsecuregcm.storage; import static com.codahale.metrics.MetricRegistry.name; +import static java.util.Objects.requireNonNull; import com.codahale.metrics.MetricRegistry; import com.codahale.metrics.SharedMetricRegistries; @@ -26,7 +27,6 @@ import java.util.Base64; import java.util.Collections; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.Optional; import java.util.UUID; import java.util.concurrent.CompletableFuture; @@ -93,6 +93,7 @@ public class AccountsManager { private final SecureBackupClient secureBackupClient; private final ClientPresenceManager clientPresenceManager; private final ExperimentEnrollmentManager experimentEnrollmentManager; + private final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager; private final Clock clock; private static final ObjectMapper mapper = SystemMapper.getMapper(); @@ -135,6 +136,7 @@ public class AccountsManager { final SecureBackupClient secureBackupClient, final ClientPresenceManager clientPresenceManager, final ExperimentEnrollmentManager experimentEnrollmentManager, + final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager, final Clock clock) { this.accounts = accounts; this.phoneNumberIdentifiers = phoneNumberIdentifiers; @@ -149,7 +151,8 @@ public class AccountsManager { this.secureBackupClient = secureBackupClient; this.clientPresenceManager = clientPresenceManager; this.experimentEnrollmentManager = experimentEnrollmentManager; - this.clock = Objects.requireNonNull(clock); + this.registrationRecoveryPasswordsManager = requireNonNull(registrationRecoveryPasswordsManager); + this.clock = requireNonNull(clock); } public Account create(final String number, @@ -230,6 +233,9 @@ public class AccountsManager { // The newly-created account has explicitly opted out of discoverability directoryQueue.deleteAccount(account); } + + accountAttributes.recoveryPassword().ifPresent(registrationRecoveryPassword -> + registrationRecoveryPasswordsManager.storeForCurrentNumber(account.getNumber(), registrationRecoveryPassword)); }); return account; @@ -451,12 +457,7 @@ public class AccountsManager { public Account updateDeviceAuthentication(final Account account, final Device device, final SaltedTokenHash credentials) { Preconditions.checkArgument(credentials.getVersion() == SaltedTokenHash.CURRENT_VERSION); - return updateDevice(account, device.getId(), new Consumer() { - @Override - public void accept(final Device device) { - device.setAuthTokenHash(credentials); - } - }); + return updateDevice(account, device.getId(), device1 -> device1.setAuthTokenHash(credentials)); } /** @@ -662,6 +663,7 @@ public class AccountsManager { keys.delete(account.getPhoneNumberIdentifier()); messagesManager.clear(account.getUuid()); messagesManager.clear(account.getPhoneNumberIdentifier()); + registrationRecoveryPasswordsManager.removeForNumber(account.getNumber()); deleteStorageServiceDataFuture.join(); deleteBackupServiceDataFuture.join(); diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/RegistrationRecoveryPasswords.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/RegistrationRecoveryPasswords.java new file mode 100644 index 000000000..f25dbc744 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/RegistrationRecoveryPasswords.java @@ -0,0 +1,101 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.storage; + +import static java.util.Objects.requireNonNull; + +import java.time.Clock; +import java.time.Duration; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import org.whispersystems.textsecuregcm.auth.SaltedTokenHash; +import org.whispersystems.textsecuregcm.util.AttributeValues; +import org.whispersystems.textsecuregcm.util.Util; +import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest; +import software.amazon.awssdk.services.dynamodb.model.GetItemRequest; +import software.amazon.awssdk.services.dynamodb.model.PutItemRequest; + +public class RegistrationRecoveryPasswords extends AbstractDynamoDbStore { + + static final String KEY_E164 = "P"; + static final String ATTR_EXP = "E"; + static final String ATTR_SALT = "S"; + static final String ATTR_HASH = "H"; + + private final String tableName; + + private final Duration expiration; + + private final DynamoDbAsyncClient asyncClient; + + private final Clock clock; + + public RegistrationRecoveryPasswords( + final String tableName, + final Duration expiration, + final DynamoDbClient dynamoDbClient, + final DynamoDbAsyncClient asyncClient) { + this(tableName, expiration, dynamoDbClient, asyncClient, Clock.systemUTC()); + } + + RegistrationRecoveryPasswords( + final String tableName, + final Duration expiration, + final DynamoDbClient dynamoDbClient, + final DynamoDbAsyncClient asyncClient, + final Clock clock) { + super(dynamoDbClient); + this.tableName = requireNonNull(tableName); + this.expiration = requireNonNull(expiration); + this.asyncClient = requireNonNull(asyncClient); + this.clock = requireNonNull(clock); + } + + public CompletableFuture> lookup(final String number) { + return asyncClient.getItem(GetItemRequest.builder() + .tableName(tableName) + .key(Map.of( + KEY_E164, AttributeValues.fromString(number))) + .build()) + .thenApply(getItemResponse -> { + final Map item = getItemResponse.item(); + if (item == null || !item.containsKey(ATTR_SALT) || !item.containsKey(ATTR_HASH)) { + return Optional.empty(); + } + final String salt = item.get(ATTR_SALT).s(); + final String hash = item.get(ATTR_HASH).s(); + return Optional.of(new SaltedTokenHash(hash, salt)); + }); + } + + public CompletableFuture addOrReplace(final String number, final SaltedTokenHash data) { + return asyncClient.putItem(PutItemRequest.builder() + .tableName(tableName) + .item(Map.of( + KEY_E164, AttributeValues.fromString(number), + ATTR_EXP, AttributeValues.fromLong(expirationSeconds()), + ATTR_SALT, AttributeValues.fromString(data.salt()), + ATTR_HASH, AttributeValues.fromString(data.hash()))) + .build()) + .thenRun(Util.NOOP); + } + + public CompletableFuture removeEntry(final String number) { + return asyncClient.deleteItem(DeleteItemRequest.builder() + .tableName(tableName) + .key(Map.of(KEY_E164, AttributeValues.fromString(number))) + .build()) + .thenRun(Util.NOOP); + } + + private long expirationSeconds() { + return clock.instant().plus(expiration).getEpochSecond(); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/RegistrationRecoveryPasswordsManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/RegistrationRecoveryPasswordsManager.java new file mode 100644 index 000000000..ccc657f46 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/RegistrationRecoveryPasswordsManager.java @@ -0,0 +1,65 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.storage; + +import static java.util.Objects.requireNonNull; + +import java.lang.invoke.MethodHandles; +import java.util.HexFormat; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.whispersystems.textsecuregcm.auth.SaltedTokenHash; + +public class RegistrationRecoveryPasswordsManager { + + private static final Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + + private final RegistrationRecoveryPasswords registrationRecoveryPasswords; + + + public RegistrationRecoveryPasswordsManager(final RegistrationRecoveryPasswords registrationRecoveryPasswords) { + this.registrationRecoveryPasswords = requireNonNull(registrationRecoveryPasswords); + } + + public CompletableFuture verify(final String number, final byte[] password) { + return registrationRecoveryPasswords.lookup(number) + .thenApply(maybeHash -> maybeHash.filter(hash -> hash.verify(bytesToString(password)))) + .whenComplete((result, error) -> { + if (error != null) { + logger.warn("Failed to lookup Registration Recovery Password", error); + } + }) + .thenApply(Optional::isPresent); + } + + public CompletableFuture storeForCurrentNumber(final String number, final byte[] password) { + final String token = bytesToString(password); + final SaltedTokenHash tokenHash = SaltedTokenHash.generateFor(token); + return registrationRecoveryPasswords.addOrReplace(number, tokenHash) + .whenComplete((result, error) -> { + if (error != null) { + logger.warn("Failed to store Registration Recovery Password", error); + } + }); + } + + public CompletableFuture removeForNumber(final String number) { + // remove is a "fire-and-forget" operation, + // there is no action to be taken on its completion + return registrationRecoveryPasswords.removeEntry(number) + .whenComplete((ignored, error) -> { + if (error != null) { + logger.warn("Failed to remove Registration Recovery Password", error); + } + }); + } + + private static String bytesToString(final byte[] bytes) { + return HexFormat.of().formatHex(bytes); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/Util.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/Util.java index 582ad4556..abe09b481 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/Util.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/util/Util.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2020 Signal Messenger, LLC + * Copyright 2013 Signal Messenger, LLC * SPDX-License-Identifier: AGPL-3.0-only */ package org.whispersystems.textsecuregcm.util; @@ -10,7 +10,6 @@ import com.google.i18n.phonenumbers.PhoneNumberUtil.PhoneNumberFormat; import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber; import java.time.Clock; import java.time.Duration; -import java.time.temporal.ChronoField; import java.util.Arrays; import java.util.Collection; import java.util.List; @@ -29,6 +28,8 @@ public class Util { private static final PhoneNumberUtil PHONE_NUMBER_UTIL = PhoneNumberUtil.getInstance(); + public static final Runnable NOOP = () -> {}; + /** * Checks that the given number is a valid, E164-normalized phone number. * diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/workers/AssignUsernameCommand.java b/service/src/main/java/org/whispersystems/textsecuregcm/workers/AssignUsernameCommand.java index 191f95bcc..3dabc6d5a 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/workers/AssignUsernameCommand.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/workers/AssignUsernameCommand.java @@ -49,6 +49,8 @@ import org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers; import org.whispersystems.textsecuregcm.storage.Profiles; import org.whispersystems.textsecuregcm.storage.ProfilesManager; import org.whispersystems.textsecuregcm.storage.ProhibitedUsernames; +import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswords; +import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager; import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb; import org.whispersystems.textsecuregcm.storage.ReportMessageManager; import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager; @@ -144,6 +146,14 @@ public class AssignUsernameCommand extends EnvironmentCommand account = accountsManager.getByE164(user); diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/workers/SetUserDiscoverabilityCommand.java b/service/src/main/java/org/whispersystems/textsecuregcm/workers/SetUserDiscoverabilityCommand.java index a295170ba..0cebb31c8 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/workers/SetUserDiscoverabilityCommand.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/workers/SetUserDiscoverabilityCommand.java @@ -48,6 +48,8 @@ import org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers; import org.whispersystems.textsecuregcm.storage.Profiles; import org.whispersystems.textsecuregcm.storage.ProfilesManager; import org.whispersystems.textsecuregcm.storage.ProhibitedUsernames; +import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswords; +import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager; import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb; import org.whispersystems.textsecuregcm.storage.ReportMessageManager; import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager; @@ -146,6 +148,14 @@ public class SetUserDiscoverabilityCommand extends EnvironmentCommand maybeAccount; diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerTest.java index 54e125b19..83f439628 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerTest.java @@ -50,6 +50,7 @@ import javax.annotation.Nullable; import javax.ws.rs.client.Entity; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; +import org.apache.commons.lang3.RandomUtils; import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -87,8 +88,8 @@ import org.whispersystems.textsecuregcm.entities.RegistrationLockFailure; import org.whispersystems.textsecuregcm.entities.ReserveUsernameHashRequest; import org.whispersystems.textsecuregcm.entities.ReserveUsernameHashResponse; import org.whispersystems.textsecuregcm.entities.SignedPreKey; -import org.whispersystems.textsecuregcm.limits.RateLimitByIpFilter; import org.whispersystems.textsecuregcm.entities.UsernameHashResponse; +import org.whispersystems.textsecuregcm.limits.RateLimitByIpFilter; import org.whispersystems.textsecuregcm.limits.RateLimiter; import org.whispersystems.textsecuregcm.limits.RateLimiters; import org.whispersystems.textsecuregcm.mappers.ImpossiblePhoneNumberExceptionMapper; @@ -107,6 +108,7 @@ import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.ChangeNumberManager; import org.whispersystems.textsecuregcm.storage.Device; import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; +import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager; import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager; import org.whispersystems.textsecuregcm.storage.UsernameHashNotAvailableException; import org.whispersystems.textsecuregcm.storage.UsernameReservationNotFoundException; @@ -173,6 +175,8 @@ class AccountControllerTest { private static CaptchaChecker captchaChecker = mock(CaptchaChecker.class); private static PushNotificationManager pushNotificationManager = mock(PushNotificationManager.class); private static ChangeNumberManager changeNumberManager = mock(ChangeNumberManager.class); + private static RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager = mock( + RegistrationRecoveryPasswordsManager.class); private static ClientPresenceManager clientPresenceManager = mock(ClientPresenceManager.class); private static TestClock testClock = TestClock.now(); @@ -210,6 +214,7 @@ class AccountControllerTest { captchaChecker, pushNotificationManager, changeNumberManager, + registrationRecoveryPasswordsManager, STORAGE_CREDENTIAL_GENERATOR, clientPresenceManager, testClock)) @@ -1818,6 +1823,21 @@ class AccountControllerTest { assertThat(response.getStatus()).isEqualTo(204); } + @Test + void testAccountsAttributesUpdateRecoveryPassword() { + final byte[] recoveryPassword = RandomUtils.nextBytes(32); + final Response response = + resources.getJerseyTest() + .target("/v1/accounts/attributes/") + .request() + .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.UNDISCOVERABLE_UUID, AuthHelper.UNDISCOVERABLE_PASSWORD)) + .put(Entity.json(new AccountAttributes(false, 2222, null, null, true, null) + .withRecoveryPassword(recoveryPassword))); + + assertThat(response.getStatus()).isEqualTo(204); + verify(registrationRecoveryPasswordsManager).storeForCurrentNumber(eq(AuthHelper.UNDISCOVERABLE_NUMBER), eq(recoveryPassword)); + } + @Test void testSetAccountAttributesDisableDiscovery() { Response response = @@ -1832,15 +1852,13 @@ class AccountControllerTest { @Test void testSetAccountAttributesBadUnidentifiedKeyLength() { - final AccountAttributes attributes = new AccountAttributes(false, 2222, null, null, false, null); - attributes.setUnidentifiedAccessKey(new byte[7]); - Response response = resources.getJerseyTest() .target("/v1/accounts/attributes/") .request() .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.json(attributes)); + .put(Entity.json(new AccountAttributes(false, 2222, null, null, false, null) + .withUnidentifiedAccessKey(new byte[7]))); assertThat(response.getStatus()).isEqualTo(422); } 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 7a959036e..169f69d45 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerChangeNumberIntegrationTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerChangeNumberIntegrationTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2021 Signal Messenger, LLC + * Copyright 2013 Signal Messenger, LLC * SPDX-License-Identifier: AGPL-3.0-only */ @@ -197,6 +197,7 @@ class AccountsManagerChangeNumberIntegrationTest { secureBackupClient, clientPresenceManager, mock(ExperimentEnrollmentManager.class), + mock(RegistrationRecoveryPasswordsManager.class), mock(Clock.class)); } } 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 fdf42fd2a..e1ff343b9 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerConcurrentModificationIntegrationTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerConcurrentModificationIntegrationTest.java @@ -164,6 +164,7 @@ class AccountsManagerConcurrentModificationIntegrationTest { mock(SecureBackupClient.class), mock(ClientPresenceManager.class), mock(ExperimentEnrollmentManager.class), + mock(RegistrationRecoveryPasswordsManager.class), mock(Clock.class) ); } 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 a5de6dca3..263121ca2 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2022 Signal Messenger, LLC + * Copyright 2013 Signal Messenger, LLC * SPDX-License-Identifier: AGPL-3.0-only */ @@ -154,6 +154,7 @@ class AccountsManagerTest { backupClient, mock(ClientPresenceManager.class), enrollmentManager, + mock(RegistrationRecoveryPasswordsManager.class), mock(Clock.class)); } 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 2a9818a13..ab41b791b 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerUsernameIntegrationTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerUsernameIntegrationTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2021 Signal Messenger, LLC + * Copyright 2013 Signal Messenger, LLC * SPDX-License-Identifier: AGPL-3.0-only */ @@ -185,6 +185,7 @@ class AccountsManagerUsernameIntegrationTest { mock(SecureBackupClient.class), mock(ClientPresenceManager.class), experimentEnrollmentManager, + mock(RegistrationRecoveryPasswordsManager.class), mock(Clock.class)); } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/RegistrationRecoveryTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/RegistrationRecoveryTest.java new file mode 100644 index 000000000..888716dbc --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/RegistrationRecoveryTest.java @@ -0,0 +1,164 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +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.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.whispersystems.textsecuregcm.auth.SaltedTokenHash; +import org.whispersystems.textsecuregcm.util.AttributeValues; +import org.whispersystems.textsecuregcm.util.MockUtils; +import org.whispersystems.textsecuregcm.util.MutableClock; +import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.GetItemRequest; +import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType; + +public class RegistrationRecoveryTest { + + private static final String TABLE_NAME = "registration_recovery_passwords"; + + private static final MutableClock CLOCK = MockUtils.mutableClock(0); + + private static final Duration EXPIRATION = Duration.ofSeconds(1000); + + private static final String NUMBER = "+18005555555"; + + private static final SaltedTokenHash ORIGINAL_HASH = SaltedTokenHash.generateFor("pass1"); + + private static final SaltedTokenHash ANOTHER_HASH = SaltedTokenHash.generateFor("pass2"); + + @RegisterExtension + private static final DynamoDbExtension DB_EXTENSION = DynamoDbExtension.builder() + .tableName(TABLE_NAME) + .hashKey(RegistrationRecoveryPasswords.KEY_E164) + .attributeDefinition(AttributeDefinition.builder() + .attributeName(RegistrationRecoveryPasswords.KEY_E164) + .attributeType(ScalarAttributeType.S) + .build()) + .build(); + + private RegistrationRecoveryPasswords store; + + private RegistrationRecoveryPasswordsManager manager; + + @BeforeEach + public void before() throws Exception { + CLOCK.setTimeMillis(Clock.systemUTC().millis()); + store = new RegistrationRecoveryPasswords( + DB_EXTENSION.getTableName(), + EXPIRATION, + DB_EXTENSION.getDynamoDbClient(), + DB_EXTENSION.getDynamoDbAsyncClient(), + CLOCK + ); + manager = new RegistrationRecoveryPasswordsManager(store); + } + + @Test + public void testLookupAfterWrite() throws Exception { + store.addOrReplace(NUMBER, ORIGINAL_HASH).get(); + final long initialExp = fetchTimestamp(NUMBER); + final long expectedExpiration = CLOCK.instant().getEpochSecond() + EXPIRATION.getSeconds(); + assertEquals(expectedExpiration, initialExp); + + final Optional saltedTokenHash = store.lookup(NUMBER).get(); + assertTrue(saltedTokenHash.isPresent()); + assertEquals(ORIGINAL_HASH.salt(), saltedTokenHash.get().salt()); + assertEquals(ORIGINAL_HASH.hash(), saltedTokenHash.get().hash()); + } + + @Test + public void testLookupAfterRefresh() throws Exception { + store.addOrReplace(NUMBER, ORIGINAL_HASH).get(); + + CLOCK.increment(50, TimeUnit.SECONDS); + store.addOrReplace(NUMBER, ORIGINAL_HASH).get(); + final long updatedExp = fetchTimestamp(NUMBER); + final long expectedExp = CLOCK.instant().getEpochSecond() + EXPIRATION.getSeconds(); + assertEquals(expectedExp, updatedExp); + + final Optional saltedTokenHash = store.lookup(NUMBER).get(); + assertTrue(saltedTokenHash.isPresent()); + assertEquals(ORIGINAL_HASH.salt(), saltedTokenHash.get().salt()); + assertEquals(ORIGINAL_HASH.hash(), saltedTokenHash.get().hash()); + } + + @Test + public void testReplace() throws Exception { + store.addOrReplace(NUMBER, ORIGINAL_HASH).get(); + store.addOrReplace(NUMBER, ANOTHER_HASH).get(); + + final Optional saltedTokenHash = store.lookup(NUMBER).get(); + assertTrue(saltedTokenHash.isPresent()); + assertEquals(ANOTHER_HASH.salt(), saltedTokenHash.get().salt()); + assertEquals(ANOTHER_HASH.hash(), saltedTokenHash.get().hash()); + } + + @Test + public void testRemove() throws Exception { + store.addOrReplace(NUMBER, ORIGINAL_HASH).get(); + assertTrue(store.lookup(NUMBER).get().isPresent()); + + store.removeEntry(NUMBER).get(); + assertTrue(store.lookup(NUMBER).get().isEmpty()); + } + + @Test + public void testManagerFlow() throws Exception { + final byte[] password = "password".getBytes(StandardCharsets.UTF_8); + final byte[] updatedPassword = "udpate".getBytes(StandardCharsets.UTF_8); + final byte[] wrongPassword = "qwerty123".getBytes(StandardCharsets.UTF_8); + + // initial store + manager.storeForCurrentNumber(NUMBER, password).get(); + assertTrue(manager.verify(NUMBER, password).get()); + assertFalse(manager.verify(NUMBER, wrongPassword).get()); + + // update + manager.storeForCurrentNumber(NUMBER, password).get(); + assertTrue(manager.verify(NUMBER, password).get()); + assertFalse(manager.verify(NUMBER, wrongPassword).get()); + + // replace + manager.storeForCurrentNumber(NUMBER, updatedPassword).get(); + assertTrue(manager.verify(NUMBER, updatedPassword).get()); + assertFalse(manager.verify(NUMBER, password).get()); + assertFalse(manager.verify(NUMBER, wrongPassword).get()); + + manager.removeForNumber(NUMBER).get(); + assertFalse(manager.verify(NUMBER, updatedPassword).get()); + assertFalse(manager.verify(NUMBER, password).get()); + assertFalse(manager.verify(NUMBER, wrongPassword).get()); + } + + private static long fetchTimestamp(final String number) throws ExecutionException, InterruptedException { + return DB_EXTENSION.getDynamoDbAsyncClient().getItem(GetItemRequest.builder() + .tableName(DB_EXTENSION.getTableName()) + .key(Map.of(RegistrationRecoveryPasswords.KEY_E164, AttributeValues.fromString(number))) + .build()) + .thenApply(getItemResponse -> { + final Map item = getItemResponse.item(); + if (item == null || !item.containsKey(RegistrationRecoveryPasswords.ATTR_EXP)) { + throw new RuntimeException("Data not found"); + } + final String exp = item.get(RegistrationRecoveryPasswords.ATTR_EXP).n(); + return Long.parseLong(exp); + }) + .get(); + } +}