Allow re-registered accounts to reclaim their usernames

This commit is contained in:
ravi-signal 2023-11-13 10:41:23 -06:00 committed by GitHub
parent acd1140ef6
commit a4a4204762
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 329 additions and 54 deletions

View File

@ -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);
}

View File

@ -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;
}
}