Allow re-registered accounts to reclaim their usernames
This commit is contained in:
parent
acd1140ef6
commit
a4a4204762
|
@ -78,6 +78,8 @@ public class Accounts extends AbstractDynamoDbStore {
|
|||
|
||||
private static final Logger log = LoggerFactory.getLogger(Accounts.class);
|
||||
|
||||
private static final Duration USERNAME_RECLAIM_TTL = Duration.ofDays(3);
|
||||
|
||||
static final List<String> ACCOUNT_FIELDS_TO_EXCLUDE_FROM_SERIALIZATION = List.of("uuid", "usernameLinkHandle");
|
||||
|
||||
private static final ObjectWriter ACCOUNT_DDB_JSON_WRITER = SystemMapper.jsonMapper()
|
||||
|
@ -89,6 +91,7 @@ public class Accounts extends AbstractDynamoDbStore {
|
|||
private static final Timer RESERVE_USERNAME_TIMER = Metrics.timer(name(Accounts.class, "reserveUsername"));
|
||||
private static final Timer CLEAR_USERNAME_HASH_TIMER = Metrics.timer(name(Accounts.class, "clearUsernameHash"));
|
||||
private static final Timer UPDATE_TIMER = Metrics.timer(name(Accounts.class, "update"));
|
||||
private static final Timer RECLAIM_TIMER = Metrics.timer(name(Accounts.class, "reclaim"));
|
||||
private static final Timer GET_BY_NUMBER_TIMER = Metrics.timer(name(Accounts.class, "getByNumber"));
|
||||
private static final Timer GET_BY_USERNAME_HASH_TIMER = Metrics.timer(name(Accounts.class, "getByUsernameHash"));
|
||||
private static final Timer GET_BY_USERNAME_LINK_HANDLE_TIMER = Metrics.timer(name(Accounts.class, "getByUsernameLinkHandle"));
|
||||
|
@ -118,6 +121,8 @@ public class Accounts extends AbstractDynamoDbStore {
|
|||
static final String ATTR_USERNAME_HASH = "N";
|
||||
// confirmed; bool
|
||||
static final String ATTR_CONFIRMED = "F";
|
||||
// reclaimable; bool. Indicates that on confirmation the username link should be preserved
|
||||
static final String ATTR_RECLAIMABLE = "R";
|
||||
// unidentified access key; byte[] or null
|
||||
static final String ATTR_UAK = "UAK";
|
||||
// time to live; number
|
||||
|
@ -222,16 +227,9 @@ public class Accounts extends AbstractDynamoDbStore {
|
|||
|
||||
final ByteBuffer actualAccountUuid = reason.item().get(KEY_ACCOUNT_UUID).b().asByteBuffer();
|
||||
account.setUuid(UUIDUtil.fromByteBuffer(actualAccountUuid));
|
||||
|
||||
final Account existingAccount = getByAccountIdentifier(account.getUuid()).orElseThrow();
|
||||
|
||||
// It's up to the client to delete this username hash if they can't retrieve and decrypt the plaintext username from storage service
|
||||
existingAccount.getUsernameHash().ifPresent(account::setUsernameHash);
|
||||
account.setNumber(existingAccount.getNumber(), existingAccount.getPhoneNumberIdentifier());
|
||||
account.setVersion(existingAccount.getVersion());
|
||||
|
||||
update(account);
|
||||
|
||||
joinAndUnwrapUpdateFuture(reclaimAccount(existingAccount, account));
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -246,11 +244,91 @@ public class Accounts extends AbstractDynamoDbStore {
|
|||
} catch (final JsonProcessingException e) {
|
||||
throw new IllegalArgumentException(e);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies over any account attributes that should be preserved when a new account reclaims an account identifier.
|
||||
*
|
||||
* @param existingAccount the existing account in the accounts table
|
||||
* @param accountToCreate a new account, with the same number and identifier as existingAccount
|
||||
*/
|
||||
private CompletionStage<Void> reclaimAccount(final Account existingAccount, final Account accountToCreate) {
|
||||
if (!existingAccount.getUuid().equals(accountToCreate.getUuid()) ||
|
||||
!existingAccount.getNumber().equals(accountToCreate.getNumber())) {
|
||||
throw new IllegalArgumentException("reclaimed accounts must match");
|
||||
}
|
||||
return AsyncTimerUtil.record(RECLAIM_TIMER, () -> {
|
||||
|
||||
accountToCreate.setVersion(existingAccount.getVersion());
|
||||
|
||||
final List<TransactWriteItem> writeItems = new ArrayList<>();
|
||||
|
||||
// If we're reclaiming an account that already has a username, we'd like to give the re-registering client
|
||||
// an opportunity to reclaim their original username and link. We do this by:
|
||||
// 1. marking the usernameHash as reserved for the aci
|
||||
// 2. saving the username link id, but not the encrypted username. The link will be broken until the client
|
||||
// reclaims their username
|
||||
//
|
||||
// If we partially reclaim the account but fail (for example, we update the account but the client goes away
|
||||
// before creation is finished), we might be reclaiming the account we already reclaimed. In that case, we
|
||||
// should copy over the reserved username and link verbatim
|
||||
if (existingAccount.getReservedUsernameHash().isPresent() &&
|
||||
existingAccount.getUsernameLinkHandle() != null &&
|
||||
existingAccount.getUsernameHash().isEmpty() &&
|
||||
existingAccount.getEncryptedUsername().isEmpty()) {
|
||||
// reclaiming a partially reclaimed account
|
||||
accountToCreate.setReservedUsernameHash(existingAccount.getReservedUsernameHash().get());
|
||||
accountToCreate.setUsernameLinkHandle(existingAccount.getUsernameLinkHandle());
|
||||
} else if (existingAccount.getUsernameHash().isPresent()) {
|
||||
// reclaiming an account with a username
|
||||
final byte[] usernameHash = existingAccount.getUsernameHash().get();
|
||||
final long expirationTime = clock.instant().plus(USERNAME_RECLAIM_TTL).getEpochSecond();
|
||||
accountToCreate.setReservedUsernameHash(usernameHash);
|
||||
accountToCreate.setUsernameLinkHandle(existingAccount.getUsernameLinkHandle());
|
||||
|
||||
writeItems.add(TransactWriteItem.builder()
|
||||
.put(Put.builder()
|
||||
.tableName(usernamesConstraintTableName)
|
||||
.item(Map.of(
|
||||
KEY_ACCOUNT_UUID, AttributeValues.fromUUID(accountToCreate.getUuid()),
|
||||
ATTR_USERNAME_HASH, AttributeValues.fromByteArray(usernameHash),
|
||||
ATTR_TTL, AttributeValues.fromLong(expirationTime),
|
||||
ATTR_CONFIRMED, AttributeValues.fromBool(false),
|
||||
ATTR_RECLAIMABLE, AttributeValues.fromBool(true)))
|
||||
.conditionExpression("attribute_not_exists(#username_hash) OR (#ttl < :now) OR #uuid = :uuid")
|
||||
.expressionAttributeNames(Map.of(
|
||||
"#username_hash", ATTR_USERNAME_HASH,
|
||||
"#ttl", ATTR_TTL,
|
||||
"#uuid", KEY_ACCOUNT_UUID))
|
||||
.expressionAttributeValues(Map.of(
|
||||
":now", AttributeValues.fromLong(clock.instant().getEpochSecond()),
|
||||
":uuid", AttributeValues.fromUUID(accountToCreate.getUuid())))
|
||||
.returnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.ALL_OLD)
|
||||
.build())
|
||||
.build());
|
||||
}
|
||||
writeItems.add(UpdateAccountSpec.forAccount(accountsTableName, accountToCreate).transactItem());
|
||||
|
||||
return asyncClient.transactWriteItems(TransactWriteItemsRequest.builder().transactItems(writeItems).build())
|
||||
.thenApply(response -> {
|
||||
accountToCreate.setVersion(accountToCreate.getVersion() + 1);
|
||||
return (Void) null;
|
||||
})
|
||||
.exceptionally(throwable -> {
|
||||
final Throwable unwrapped = ExceptionUtils.unwrap(throwable);
|
||||
if (unwrapped instanceof TransactionCanceledException te) {
|
||||
if (te.cancellationReasons().stream().anyMatch(Accounts::conditionalCheckFailed)) {
|
||||
throw new ContestedOptimisticLockException();
|
||||
}
|
||||
}
|
||||
// rethrow
|
||||
throw CompletableFutureUtils.errorAsCompletionException(throwable);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the phone number for the given account. The given account's number should be its current, pre-change
|
||||
* number. If this method succeeds, the account's number will be changed to the new number and its phone number
|
||||
|
@ -369,7 +447,8 @@ public class Accounts extends AbstractDynamoDbStore {
|
|||
KEY_ACCOUNT_UUID, AttributeValues.fromUUID(uuid),
|
||||
ATTR_USERNAME_HASH, AttributeValues.fromByteArray(reservedUsernameHash),
|
||||
ATTR_TTL, AttributeValues.fromLong(expirationTime),
|
||||
ATTR_CONFIRMED, AttributeValues.fromBool(false)))
|
||||
ATTR_CONFIRMED, AttributeValues.fromBool(false),
|
||||
ATTR_RECLAIMABLE, AttributeValues.fromBool(false)))
|
||||
.conditionExpression("attribute_not_exists(#username_hash) OR (#ttl < :now)")
|
||||
.expressionAttributeNames(Map.of("#username_hash", ATTR_USERNAME_HASH, "#ttl", ATTR_TTL))
|
||||
.expressionAttributeValues(Map.of(":now", AttributeValues.fromLong(clock.instant().getEpochSecond())))
|
||||
|
@ -426,32 +505,36 @@ public class Accounts extends AbstractDynamoDbStore {
|
|||
*/
|
||||
public CompletableFuture<Void> confirmUsernameHash(final Account account, final byte[] usernameHash, @Nullable final byte[] encryptedUsername) {
|
||||
final Timer.Sample sample = Timer.start();
|
||||
final UUID newLinkHandle = UUID.randomUUID();
|
||||
|
||||
final TransactWriteItemsRequest request;
|
||||
return pickLinkHandle(account, usernameHash)
|
||||
.thenCompose(linkHandle -> {
|
||||
final TransactWriteItemsRequest request;
|
||||
try {
|
||||
final Account updatedAccount = AccountUtil.cloneAccountAsNotStale(account);
|
||||
updatedAccount.setUsernameHash(usernameHash);
|
||||
updatedAccount.setReservedUsernameHash(null);
|
||||
updatedAccount.setUsernameLinkDetails(encryptedUsername == null ? null : linkHandle, encryptedUsername);
|
||||
|
||||
try {
|
||||
final Account updatedAccount = AccountUtil.cloneAccountAsNotStale(account);
|
||||
updatedAccount.setUsernameHash(usernameHash);
|
||||
updatedAccount.setReservedUsernameHash(null);
|
||||
updatedAccount.setUsernameLinkDetails(encryptedUsername == null ? null : newLinkHandle, encryptedUsername);
|
||||
request = buildConfirmUsernameHashRequest(updatedAccount, account.getUsernameHash());
|
||||
} catch (final JsonProcessingException e) {
|
||||
throw new IllegalArgumentException(e);
|
||||
}
|
||||
|
||||
request = buildConfirmUsernameHashRequest(updatedAccount, account.getUsernameHash());
|
||||
} catch (final JsonProcessingException e) {
|
||||
throw new IllegalArgumentException(e);
|
||||
}
|
||||
|
||||
return asyncClient.transactWriteItems(request)
|
||||
.thenRun(() -> {
|
||||
return asyncClient.transactWriteItems(request).thenApply(ignored -> linkHandle);
|
||||
})
|
||||
.thenApply(linkHandle -> {
|
||||
account.setUsernameHash(usernameHash);
|
||||
account.setReservedUsernameHash(null);
|
||||
account.setUsernameLinkDetails(encryptedUsername == null ? null : newLinkHandle, encryptedUsername);
|
||||
account.setUsernameLinkDetails(encryptedUsername == null ? null : linkHandle, encryptedUsername);
|
||||
|
||||
account.setVersion(account.getVersion() + 1);
|
||||
return (Void) null;
|
||||
})
|
||||
.exceptionally(throwable -> {
|
||||
if (ExceptionUtils.unwrap(throwable) instanceof TransactionCanceledException transactionCanceledException) {
|
||||
if (transactionCanceledException.cancellationReasons().stream().map(CancellationReason::code).anyMatch(CONDITIONAL_CHECK_FAILED::equals)) {
|
||||
if (ExceptionUtils.unwrap(
|
||||
throwable) instanceof TransactionCanceledException transactionCanceledException) {
|
||||
if (transactionCanceledException.cancellationReasons().stream().map(CancellationReason::code)
|
||||
.anyMatch(CONDITIONAL_CHECK_FAILED::equals)) {
|
||||
throw new ContestedOptimisticLockException();
|
||||
}
|
||||
}
|
||||
|
@ -461,7 +544,31 @@ public class Accounts extends AbstractDynamoDbStore {
|
|||
.whenComplete((ignored, throwable) -> sample.stop(SET_USERNAME_TIMER));
|
||||
}
|
||||
|
||||
private TransactWriteItemsRequest buildConfirmUsernameHashRequest(final Account updatedAccount, final Optional<byte[]> maybeOriginalUsernameHash)
|
||||
private CompletableFuture<UUID> pickLinkHandle(final Account account, final byte[] usernameHash) {
|
||||
if (account.getUsernameLinkHandle() == null) {
|
||||
// There's no old link handle, so we can just use a randomly generated link handle
|
||||
return CompletableFuture.completedFuture(UUID.randomUUID());
|
||||
}
|
||||
|
||||
// Otherwise, there's an existing link handle. If this is the result of an account being re-registered, we should
|
||||
// preserve the link handle.
|
||||
return asyncClient.getItem(GetItemRequest.builder()
|
||||
.tableName(usernamesConstraintTableName)
|
||||
.key(Map.of(ATTR_USERNAME_HASH, AttributeValues.b(usernameHash)))
|
||||
.projectionExpression(ATTR_RECLAIMABLE).build())
|
||||
.thenApply(response -> {
|
||||
if (response.hasItem() && AttributeValues.getBool(response.item(), ATTR_RECLAIMABLE, false)) {
|
||||
// this username reservation indicates it's a username waiting to be "reclaimed"
|
||||
return account.getUsernameLinkHandle();
|
||||
}
|
||||
// There was no existing username reservation, or this was a standard "new" username. Either way, we should
|
||||
// generate a new link handle.
|
||||
return UUID.randomUUID();
|
||||
});
|
||||
}
|
||||
|
||||
private TransactWriteItemsRequest buildConfirmUsernameHashRequest(final Account updatedAccount,
|
||||
final Optional<byte[]> maybeOriginalUsernameHash)
|
||||
throws JsonProcessingException {
|
||||
|
||||
final List<TransactWriteItem> writeItems = new ArrayList<>();
|
||||
|
@ -593,10 +700,42 @@ public class Accounts extends AbstractDynamoDbStore {
|
|||
.build();
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public CompletionStage<Void> updateAsync(final Account account) {
|
||||
return AsyncTimerUtil.record(UPDATE_TIMER, () -> {
|
||||
final UpdateItemRequest updateItemRequest;
|
||||
|
||||
/**
|
||||
* A ddb update that can be used as part of a transaction or single-item update statement.
|
||||
*/
|
||||
record UpdateAccountSpec(
|
||||
String tableName,
|
||||
Map<String, AttributeValue> key,
|
||||
Map<String, String> attrNames,
|
||||
Map<String, AttributeValue> attrValues,
|
||||
String updateExpression,
|
||||
String conditionExpression) {
|
||||
UpdateItemRequest updateItemRequest() {
|
||||
return UpdateItemRequest.builder()
|
||||
.tableName(tableName)
|
||||
.key(key)
|
||||
.updateExpression(updateExpression)
|
||||
.conditionExpression(conditionExpression)
|
||||
.expressionAttributeNames(attrNames)
|
||||
.expressionAttributeValues(attrValues)
|
||||
.build();
|
||||
}
|
||||
|
||||
TransactWriteItem transactItem() {
|
||||
return TransactWriteItem.builder().update(Update.builder()
|
||||
.tableName(tableName)
|
||||
.key(key)
|
||||
.updateExpression(updateExpression)
|
||||
.conditionExpression(conditionExpression)
|
||||
.expressionAttributeNames(attrNames)
|
||||
.expressionAttributeValues(attrValues)
|
||||
.build()).build();
|
||||
}
|
||||
|
||||
static UpdateAccountSpec forAccount(
|
||||
final String accountTableName,
|
||||
final Account account) {
|
||||
try {
|
||||
// username, e164, and pni cannot be modified through this method
|
||||
final Map<String, String> attrNames = new HashMap<>(Map.of(
|
||||
|
@ -618,28 +757,53 @@ public class Accounts extends AbstractDynamoDbStore {
|
|||
attrValues.put(":uak", AttributeValues.fromByteArray(account.getUnidentifiedAccessKey().get()));
|
||||
updateExpressionBuilder.append(", #uak = :uak");
|
||||
}
|
||||
|
||||
// If the account has a username/handle pair, we should add it to the top level attributes.
|
||||
// When we remove an encryptedUsername but preserve the link (re-registration), it's possible that the account
|
||||
// has a usernameLinkHandle but not an encrypted username. In this case there should already be a top-level
|
||||
// usernameLink attribute.
|
||||
if (account.getEncryptedUsername().isPresent() && account.getUsernameLinkHandle() != null) {
|
||||
attrNames.put("#ul", ATTR_USERNAME_LINK_UUID);
|
||||
attrValues.put(":ul", AttributeValues.fromUUID(account.getUsernameLinkHandle()));
|
||||
updateExpressionBuilder.append(", #ul = :ul");
|
||||
}
|
||||
updateExpressionBuilder.append(" ADD #version :version_increment");
|
||||
if (account.getEncryptedUsername().isEmpty() || account.getUsernameLinkHandle() == null) {
|
||||
attrNames.put("#ul", ATTR_USERNAME_LINK_UUID);
|
||||
updateExpressionBuilder.append(" REMOVE #ul");
|
||||
}
|
||||
|
||||
updateItemRequest = UpdateItemRequest.builder()
|
||||
.tableName(accountsTableName)
|
||||
.key(Map.of(KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid())))
|
||||
.updateExpression(updateExpressionBuilder.toString())
|
||||
.conditionExpression("attribute_exists(#number) AND #version = :version")
|
||||
.expressionAttributeNames(attrNames)
|
||||
.expressionAttributeValues(attrValues)
|
||||
.build();
|
||||
// Some operations may remove the usernameLink or the usernameHash (re-registration, clear username link, and
|
||||
// clear username hash). Since these also have top-level ddb attributes, we need to make sure to remove those
|
||||
// as well.
|
||||
final List<String> removes = new ArrayList<>();
|
||||
if (account.getUsernameLinkHandle() == null) {
|
||||
attrNames.put("#ul", ATTR_USERNAME_LINK_UUID);
|
||||
removes.add("#ul");
|
||||
}
|
||||
if (account.getUsernameHash().isEmpty()) {
|
||||
attrNames.put("#username_hash", ATTR_USERNAME_HASH);
|
||||
removes.add("#username_hash");
|
||||
}
|
||||
if (!removes.isEmpty()) {
|
||||
updateExpressionBuilder.append(" REMOVE %s".formatted(String.join(",", removes)));
|
||||
}
|
||||
updateExpressionBuilder.append(" ADD #version :version_increment");
|
||||
|
||||
return new UpdateAccountSpec(
|
||||
accountTableName,
|
||||
Map.of(KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid())),
|
||||
attrNames,
|
||||
attrValues,
|
||||
updateExpressionBuilder.toString(),
|
||||
"attribute_exists(#number) AND #version = :version");
|
||||
} catch (final JsonProcessingException e) {
|
||||
throw new IllegalArgumentException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public CompletionStage<Void> updateAsync(final Account account) {
|
||||
return AsyncTimerUtil.record(UPDATE_TIMER, () -> {
|
||||
final UpdateItemRequest updateItemRequest = UpdateAccountSpec
|
||||
.forAccount(accountsTableName, account)
|
||||
.updateItemRequest();
|
||||
|
||||
return asyncClient.updateItem(updateItemRequest)
|
||||
.thenApply(response -> {
|
||||
|
@ -662,9 +826,9 @@ public class Accounts extends AbstractDynamoDbStore {
|
|||
});
|
||||
}
|
||||
|
||||
public void update(final Account account) throws ContestedOptimisticLockException {
|
||||
private static void joinAndUnwrapUpdateFuture(CompletionStage<Void> future) {
|
||||
try {
|
||||
updateAsync(account).toCompletableFuture().join();
|
||||
future.toCompletableFuture().join();
|
||||
} catch (final CompletionException e) {
|
||||
// unwrap CompletionExceptions, throw as long is it's unchecked
|
||||
Throwables.throwIfUnchecked(ExceptionUtils.unwrap(e));
|
||||
|
@ -676,6 +840,10 @@ public class Accounts extends AbstractDynamoDbStore {
|
|||
}
|
||||
}
|
||||
|
||||
public void update(final Account account) throws ContestedOptimisticLockException {
|
||||
joinAndUnwrapUpdateFuture(updateAsync(account));
|
||||
}
|
||||
|
||||
public CompletableFuture<Boolean> usernameHashAvailable(final byte[] username) {
|
||||
return usernameHashAvailable(Optional.empty(), username);
|
||||
}
|
||||
|
|
|
@ -34,6 +34,7 @@ import java.util.Random;
|
|||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.stream.Collectors;
|
||||
|
@ -45,6 +46,7 @@ import org.junit.jupiter.api.Timeout;
|
|||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.EnumSource;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;
|
||||
|
@ -323,6 +325,75 @@ class AccountsTest {
|
|||
verifyStoredState("+14151112222", uuid, null, null, retrieved.get(), account);
|
||||
}
|
||||
|
||||
// State before the account is re-registered
|
||||
enum UsernameStatus {
|
||||
NONE,
|
||||
RESERVED,
|
||||
RESERVED_WITH_SAVED_LINK,
|
||||
CONFIRMED
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(UsernameStatus.class)
|
||||
void reclaimAccountWithNoUsername(UsernameStatus usernameStatus) {
|
||||
Device device = generateDevice(DEVICE_ID_1);
|
||||
UUID firstUuid = UUID.randomUUID();
|
||||
UUID firstPni = UUID.randomUUID();
|
||||
Account account = generateAccount("+14151112222", firstUuid, firstPni, List.of(device));
|
||||
accounts.create(account);
|
||||
|
||||
final byte[] usernameHash = randomBytes(32);
|
||||
final byte[] encryptedUsername = randomBytes(32);
|
||||
switch (usernameStatus) {
|
||||
case NONE:
|
||||
break;
|
||||
case RESERVED:
|
||||
accounts.reserveUsernameHash(account, randomBytes(32), Duration.ofMinutes(1)).join();
|
||||
break;
|
||||
case RESERVED_WITH_SAVED_LINK:
|
||||
// give the account a username
|
||||
accounts.reserveUsernameHash(account, usernameHash, Duration.ofMinutes(1)).join();
|
||||
accounts.confirmUsernameHash(account, usernameHash, encryptedUsername).join();
|
||||
|
||||
// simulate a failed re-reg: we give the account a reclaimable username, but we'll try
|
||||
// re-registering again later in the test case
|
||||
account = generateAccount("+14151112222", UUID.randomUUID(), UUID.randomUUID(), List.of(generateDevice(DEVICE_ID_1)));
|
||||
accounts.create(account);
|
||||
break;
|
||||
case CONFIRMED:
|
||||
accounts.reserveUsernameHash(account, usernameHash, Duration.ofMinutes(1)).join();
|
||||
accounts.confirmUsernameHash(account, usernameHash, encryptedUsername).join();
|
||||
break;
|
||||
}
|
||||
|
||||
Optional<UUID> preservedLink = Optional.ofNullable(account.getUsernameLinkHandle());
|
||||
|
||||
// re-register the account
|
||||
account = generateAccount("+14151112222", UUID.randomUUID(), UUID.randomUUID(), List.of(generateDevice(DEVICE_ID_1)));
|
||||
accounts.create(account);
|
||||
|
||||
// If we had a username link, or we had previously saved a username link from another re-registration, make sure
|
||||
// we preserve it
|
||||
accounts.confirmUsernameHash(account, usernameHash, encryptedUsername).join();
|
||||
|
||||
boolean shouldReuseLink = switch (usernameStatus) {
|
||||
case RESERVED_WITH_SAVED_LINK, CONFIRMED -> true;
|
||||
case NONE, RESERVED -> false;
|
||||
};
|
||||
|
||||
// If we had a reclaimable username, make sure we preserved the link.
|
||||
assertThat(account.getUsernameLinkHandle().equals(preservedLink.orElse(null)))
|
||||
.isEqualTo(shouldReuseLink);
|
||||
|
||||
|
||||
// in all cases, we should now have usernameHash, usernameLink, and encryptedUsername set
|
||||
assertThat(account.getUsernameHash()).isNotEmpty();
|
||||
assertThat(account.getEncryptedUsername()).isNotEmpty();
|
||||
assertThat(account.getUsernameLinkHandle()).isNotNull();
|
||||
assertThat(account.getReservedUsernameHash()).isEmpty();
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
void testOverwrite() {
|
||||
Device device = generateDevice(DEVICE_ID_1);
|
||||
|
@ -332,14 +403,12 @@ class AccountsTest {
|
|||
|
||||
accounts.create(account);
|
||||
|
||||
final SecureRandom byteGenerator = new SecureRandom();
|
||||
final byte[] usernameHash = new byte[32];
|
||||
byteGenerator.nextBytes(usernameHash);
|
||||
final byte[] encryptedUsername = new byte[16];
|
||||
byteGenerator.nextBytes(encryptedUsername);
|
||||
final byte[] usernameHash = randomBytes(32);
|
||||
final byte[] encryptedUsername = randomBytes(16);
|
||||
|
||||
// Set up the existing account to have a username hash
|
||||
accounts.confirmUsernameHash(account, usernameHash, encryptedUsername).join();
|
||||
final UUID usernameLinkHandle = account.getUsernameLinkHandle();
|
||||
|
||||
verifyStoredState("+14151112222", account.getUuid(), account.getPhoneNumberIdentifier(), usernameHash, account, true);
|
||||
|
||||
|
@ -355,14 +424,35 @@ class AccountsTest {
|
|||
|
||||
final boolean freshUser = accounts.create(account);
|
||||
assertThat(freshUser).isFalse();
|
||||
verifyStoredState("+14151112222", firstUuid, firstPni, usernameHash, account, true);
|
||||
// usernameHash should be unset
|
||||
verifyStoredState("+14151112222", firstUuid, firstPni, null, account, true);
|
||||
|
||||
// username should become 'reclaimable'
|
||||
Map<String, AttributeValue> item = readAccount(firstUuid);
|
||||
Account result = Accounts.fromItem(item);
|
||||
assertThat(AttributeValues.getUUID(item, Accounts.ATTR_USERNAME_LINK_UUID, null))
|
||||
.isEqualTo(usernameLinkHandle)
|
||||
.isEqualTo(result.getUsernameLinkHandle());
|
||||
assertThat(result.getUsernameHash()).isEmpty();
|
||||
assertThat(result.getEncryptedUsername()).isEmpty();
|
||||
assertArrayEquals(result.getReservedUsernameHash().get(), usernameHash);
|
||||
|
||||
// should keep the same usernameLink, now encryptedUsername should be set
|
||||
accounts.confirmUsernameHash(result, usernameHash, encryptedUsername).join();
|
||||
item = readAccount(firstUuid);
|
||||
result = Accounts.fromItem(item);
|
||||
assertThat(AttributeValues.getUUID(item, Accounts.ATTR_USERNAME_LINK_UUID, null))
|
||||
.isEqualTo(usernameLinkHandle)
|
||||
.isEqualTo(result.getUsernameLinkHandle());
|
||||
assertArrayEquals(result.getEncryptedUsername().get(), encryptedUsername);
|
||||
assertArrayEquals(result.getUsernameHash().get(), usernameHash);
|
||||
assertThat(result.getReservedUsernameHash()).isEmpty();
|
||||
|
||||
assertPhoneNumberConstraintExists("+14151112222", firstUuid);
|
||||
assertPhoneNumberIdentifierConstraintExists(firstPni, firstUuid);
|
||||
|
||||
device = generateDevice(DEVICE_ID_1);
|
||||
Account invalidAccount = generateAccount("+14151113333", firstUuid, UUID.randomUUID(), List.of(device));
|
||||
|
||||
assertThatThrownBy(() -> accounts.create(invalidAccount));
|
||||
}
|
||||
|
||||
|
@ -1078,6 +1168,17 @@ class AccountsTest {
|
|||
assertThat(pniConstraintResponse.hasItem()).isFalse();
|
||||
}
|
||||
|
||||
private Map<String, AttributeValue> readAccount(final UUID uuid) {
|
||||
final DynamoDbClient db = DYNAMO_DB_EXTENSION.getDynamoDbClient();
|
||||
|
||||
final GetItemResponse get = db.getItem(GetItemRequest.builder()
|
||||
.tableName(Tables.ACCOUNTS.tableName())
|
||||
.key(Map.of(Accounts.KEY_ACCOUNT_UUID, AttributeValues.fromUUID(uuid)))
|
||||
.consistentRead(true)
|
||||
.build());
|
||||
return get.item();
|
||||
}
|
||||
|
||||
private void verifyStoredState(String number, UUID uuid, UUID pni, byte[] usernameHash, Account expecting, boolean canonicallyDiscoverable) {
|
||||
final DynamoDbClient db = DYNAMO_DB_EXTENSION.getDynamoDbClient();
|
||||
|
||||
|
@ -1131,4 +1232,10 @@ class AccountsTest {
|
|||
assertThat(resultDevice.getCreated()).isEqualTo(expectingDevice.getCreated());
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] randomBytes(int count) {
|
||||
byte[] bytes = new byte[count];
|
||||
ThreadLocalRandom.current().nextBytes(bytes);
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue