Stored hashed username

This commit is contained in:
Katherine Yen
2023-02-01 12:08:25 -08:00
committed by GitHub
parent 448365c7a0
commit d93d50d038
41 changed files with 799 additions and 1474 deletions

View File

@@ -48,7 +48,6 @@ import org.whispersystems.textsecuregcm.configuration.StripeConfiguration;
import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration;
import org.whispersystems.textsecuregcm.configuration.TestDeviceConfiguration;
import org.whispersystems.textsecuregcm.configuration.UnidentifiedDeliveryConfiguration;
import org.whispersystems.textsecuregcm.configuration.UsernameConfiguration;
import org.whispersystems.textsecuregcm.configuration.ZkConfig;
import org.whispersystems.websocket.configuration.WebSocketConfiguration;
@@ -255,11 +254,6 @@ public class WhisperServerConfiguration extends Configuration {
@JsonProperty
private ReportMessageConfiguration reportMessage = new ReportMessageConfiguration();
@Valid
@NotNull
@JsonProperty
private UsernameConfiguration username = new UsernameConfiguration();
@Valid
@JsonProperty
private SpamFilterConfiguration spamFilterConfiguration;
@@ -447,10 +441,6 @@ public class WhisperServerConfiguration extends Configuration {
return spamFilterConfiguration;
}
public UsernameConfiguration getUsername() {
return username;
}
public RegistrationServiceConfiguration getRegistrationServiceConfiguration() {
return registrationService;
}

View File

@@ -123,6 +123,7 @@ import org.whispersystems.textsecuregcm.mappers.DeviceLimitExceededExceptionMapp
import org.whispersystems.textsecuregcm.mappers.IOExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.ImpossiblePhoneNumberExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.InvalidWebsocketAddressExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.JsonMappingExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.NonNormalizedPhoneNumberExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.ServerRejectedExceptionMapper;
@@ -192,7 +193,6 @@ import org.whispersystems.textsecuregcm.storage.NonNormalizedAccountCrawlerListe
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.PubSubManager;
import org.whispersystems.textsecuregcm.storage.PushChallengeDynamoDb;
import org.whispersystems.textsecuregcm.storage.PushFeedbackProcessor;
@@ -209,7 +209,6 @@ import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
import org.whispersystems.textsecuregcm.util.Constants;
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
import org.whispersystems.textsecuregcm.util.HostnameUtil;
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
import org.whispersystems.textsecuregcm.util.logging.LoggingUnhandledExceptionMapper;
import org.whispersystems.textsecuregcm.util.logging.UncaughtExceptionHandler;
import org.whispersystems.textsecuregcm.websocket.AuthenticatedConnectListener;
@@ -351,8 +350,6 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
config.getDynamoDbTables().getAccounts().getScanPageSize());
PhoneNumberIdentifiers phoneNumberIdentifiers = new PhoneNumberIdentifiers(dynamoDbClient,
config.getDynamoDbTables().getPhoneNumberIdentifiers().getTableName());
ProhibitedUsernames prohibitedUsernames = new ProhibitedUsernames(dynamoDbClient,
config.getDynamoDbTables().getReservedUsernames().getTableName());
Profiles profiles = new Profiles(dynamoDbClient, dynamoDbAsyncClient,
config.getDynamoDbTables().getProfiles().getTableName());
Keys keys = new Keys(dynamoDbClient, config.getDynamoDbTables().getKeys().getTableName());
@@ -483,12 +480,11 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
config.getReportMessageConfiguration().getCounterTtl());
MessagesManager messagesManager = new MessagesManager(messagesDynamoDb, messagesCache, reportMessageManager,
messageDeletionAsyncExecutor);
UsernameGenerator usernameGenerator = new UsernameGenerator(config.getUsername());
DeletedAccountsManager deletedAccountsManager = new DeletedAccountsManager(deletedAccounts,
deletedAccountsLockDynamoDbClient, config.getDynamoDbTables().getDeletedAccountsLock().getTableName());
AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster,
deletedAccountsManager, directoryQueue, keys, messagesManager, prohibitedUsernames, profilesManager,
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, usernameGenerator,
deletedAccountsManager, directoryQueue, keys, messagesManager, profilesManager,
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager,
experimentEnrollmentManager, clock);
RemoteConfigsManager remoteConfigsManager = new RemoteConfigsManager(remoteConfigs);
DispatchManager dispatchManager = new DispatchManager(pubSubClientFactory, Optional.empty());
@@ -820,7 +816,8 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
new DeviceLimitExceededExceptionMapper(),
new ServerRejectedExceptionMapper(),
new ImpossiblePhoneNumberExceptionMapper(),
new NonNormalizedPhoneNumberExceptionMapper()
new NonNormalizedPhoneNumberExceptionMapper(),
new JsonMappingExceptionMapper()
).forEach(exceptionMapper -> {
environment.jersey().register(exceptionMapper);
webSocketEnvironment.jersey().register(exceptionMapper);

View File

@@ -1,44 +0,0 @@
/*
* Copyright 2013-2020 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.configuration;
import com.fasterxml.jackson.annotation.JsonProperty;
import javax.validation.constraints.Min;
import java.time.Duration;
public class UsernameConfiguration {
@JsonProperty
@Min(1)
private int discriminatorInitialWidth = 2;
@JsonProperty
@Min(1)
private int discriminatorMaxWidth = 9;
@JsonProperty
@Min(1)
private int attemptsPerWidth = 10;
@JsonProperty
private Duration reservationTtl = Duration.ofMinutes(5);
public int getDiscriminatorInitialWidth() {
return discriminatorInitialWidth;
}
public int getDiscriminatorMaxWidth() {
return discriminatorMaxWidth;
}
public int getAttemptsPerWidth() {
return attemptsPerWidth;
}
public Duration getReservationTtl() {
return reservationTtl;
}
}

View File

@@ -26,6 +26,7 @@ import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
@@ -75,18 +76,17 @@ import org.whispersystems.textsecuregcm.entities.AccountIdentifierResponse;
import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;
import org.whispersystems.textsecuregcm.entities.ApnRegistrationId;
import org.whispersystems.textsecuregcm.entities.ChangePhoneNumberRequest;
import org.whispersystems.textsecuregcm.entities.ConfirmUsernameRequest;
import org.whispersystems.textsecuregcm.entities.ConfirmUsernameHashRequest;
import org.whispersystems.textsecuregcm.entities.DeviceName;
import org.whispersystems.textsecuregcm.entities.GcmRegistrationId;
import org.whispersystems.textsecuregcm.entities.MismatchedDevices;
import org.whispersystems.textsecuregcm.entities.RegistrationLock;
import org.whispersystems.textsecuregcm.entities.RegistrationLockFailure;
import org.whispersystems.textsecuregcm.entities.ReserveUsernameRequest;
import org.whispersystems.textsecuregcm.entities.ReserveUsernameResponse;
import org.whispersystems.textsecuregcm.entities.ReserveUsernameHashRequest;
import org.whispersystems.textsecuregcm.entities.ReserveUsernameHashResponse;
import org.whispersystems.textsecuregcm.entities.StaleDevices;
import org.whispersystems.textsecuregcm.entities.UsernameRequest;
import org.whispersystems.textsecuregcm.entities.UsernameResponse;
import org.whispersystems.textsecuregcm.limits.RateLimitedByIp;
import org.whispersystems.textsecuregcm.entities.UsernameHashResponse;
import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
@@ -103,7 +103,7 @@ import org.whispersystems.textsecuregcm.storage.ChangeNumberManager;
import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
import org.whispersystems.textsecuregcm.storage.UsernameNotAvailableException;
import org.whispersystems.textsecuregcm.storage.UsernameHashNotAvailableException;
import org.whispersystems.textsecuregcm.storage.UsernameReservationNotFoundException;
import org.whispersystems.textsecuregcm.util.Constants;
import org.whispersystems.textsecuregcm.util.HeaderUtils;
@@ -111,13 +111,13 @@ import org.whispersystems.textsecuregcm.util.Hex;
import org.whispersystems.textsecuregcm.util.ImpossiblePhoneNumberException;
import org.whispersystems.textsecuregcm.util.NonNormalizedPhoneNumberException;
import org.whispersystems.textsecuregcm.util.Optionals;
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
import org.whispersystems.textsecuregcm.util.Util;
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
@Path("/v1/accounts")
public class AccountController {
public static final int MAXIMUM_USERNAME_HASHES_LIST_LENGTH = 20;
public static final int USERNAME_HASH_LENGTH = 32;
private final Logger logger = LoggerFactory.getLogger(AccountController.class);
private final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME);
private final Meter countryFilteredHostMeter = metricRegistry.meter(name(AccountController.class, "country_limited_host" ));
@@ -136,11 +136,7 @@ public class AccountController {
.publishPercentiles(0.75, 0.95, 0.99, 0.999)
.distributionStatisticExpiry(Duration.ofHours(2))
.register(Metrics.globalRegistry);
private static final String NONSTANDARD_USERNAME_COUNTER_NAME = name(AccountController.class, "nonStandardUsername");
private static final String LOCKED_ACCOUNT_COUNTER_NAME = name(AccountController.class, "lockedAccount");
private static final String CHALLENGE_PRESENT_TAG_NAME = "present";
private static final String CHALLENGE_MATCH_TAG_NAME = "matches";
private static final String COUNTRY_CODE_TAG_NAME = "countryCode";
@@ -447,7 +443,7 @@ public class AccountController {
return new AccountIdentityResponse(account.getUuid(),
account.getNumber(),
account.getPhoneNumberIdentifier(),
account.getUsername().orElse(null),
account.getUsernameHash().orElse(null),
existingAccount.map(Account::isStorageSupported).orElse(false));
}
@@ -508,7 +504,7 @@ public class AccountController {
updatedAccount.getUuid(),
updatedAccount.getNumber(),
updatedAccount.getPhoneNumberIdentifier(),
updatedAccount.getUsername().orElse(null),
updatedAccount.getUsernameHash().orElse(null),
updatedAccount.isStorageSupported());
} catch (MismatchedDevicesException e) {
throw new WebApplicationException(Response.status(409)
@@ -687,96 +683,78 @@ public class AccountController {
return new AccountIdentityResponse(auth.getAccount().getUuid(),
auth.getAccount().getNumber(),
auth.getAccount().getPhoneNumberIdentifier(),
auth.getAccount().getUsername().orElse(null),
auth.getAccount().getUsernameHash().orElse(null),
auth.getAccount().isStorageSupported());
}
@Timed
@DELETE
@Path("/username")
@Path("/username_hash")
@Produces(MediaType.APPLICATION_JSON)
public void deleteUsername(@Auth AuthenticatedAccount auth) {
accounts.clearUsername(auth.getAccount());
public void deleteUsernameHash(@Auth AuthenticatedAccount auth) {
accounts.clearUsernameHash(auth.getAccount());
}
@Timed
@PUT
@Path("/username/reserved")
@Path("/username_hash/reserve")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public ReserveUsernameResponse reserveUsername(@Auth AuthenticatedAccount auth,
public ReserveUsernameHashResponse reserveUsernameHash(@Auth AuthenticatedAccount auth,
@HeaderParam(HeaderUtils.X_SIGNAL_AGENT) String userAgent,
@NotNull @Valid ReserveUsernameRequest usernameRequest) throws RateLimitExceededException {
@NotNull @Valid ReserveUsernameHashRequest usernameRequest) throws RateLimitExceededException {
rateLimiters.getUsernameReserveLimiter().validate(auth.getAccount().getUuid());
for (byte[] hash : usernameRequest.usernameHashes()) {
if (hash.length != USERNAME_HASH_LENGTH) {
throw new WebApplicationException(Response.status(422).build());
}
}
try {
final AccountsManager.UsernameReservation reservation = accounts.reserveUsername(
final AccountsManager.UsernameReservation reservation = accounts.reserveUsernameHash(
auth.getAccount(),
usernameRequest.nickname()
usernameRequest.usernameHashes()
);
return new ReserveUsernameResponse(reservation.reservedUsername(), reservation.reservationToken());
} catch (final UsernameNotAvailableException e) {
return new ReserveUsernameHashResponse(reservation.reservedUsernameHash());
} catch (final UsernameHashNotAvailableException e) {
throw new WebApplicationException(Status.CONFLICT);
}
}
@Timed
@PUT
@Path("/username/confirm")
@Path("/username_hash/confirm")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public UsernameResponse confirmUsername(@Auth AuthenticatedAccount auth,
public UsernameHashResponse confirmUsernameHash(@Auth AuthenticatedAccount auth,
@HeaderParam(HeaderUtils.X_SIGNAL_AGENT) String userAgent,
@NotNull @Valid ConfirmUsernameRequest confirmRequest) throws RateLimitExceededException {
@NotNull @Valid ConfirmUsernameHashRequest confirmRequest) throws RateLimitExceededException {
rateLimiters.getUsernameSetLimiter().validate(auth.getAccount().getUuid());
try {
final Account account = accounts.confirmReservedUsername(auth.getAccount(), confirmRequest.usernameToConfirm(), confirmRequest.reservationToken());
final Account account = accounts.confirmReservedUsernameHash(auth.getAccount(), confirmRequest.usernameHash());
return account
.getUsername()
.map(UsernameResponse::new)
.getUsernameHash()
.map(UsernameHashResponse::new)
.orElseThrow(() -> new IllegalStateException("Could not get username after setting"));
} catch (final UsernameReservationNotFoundException e) {
throw new WebApplicationException(Status.CONFLICT);
} catch (final UsernameNotAvailableException e) {
} catch (final UsernameHashNotAvailableException e) {
throw new WebApplicationException(Status.GONE);
}
}
@Timed
@PUT
@Path("/username")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public UsernameResponse setUsername(
@Auth AuthenticatedAccount auth,
@HeaderParam(HeaderUtils.X_SIGNAL_AGENT) String userAgent,
@NotNull @Valid UsernameRequest usernameRequest) throws RateLimitExceededException {
rateLimiters.getUsernameSetLimiter().validate(auth.getAccount().getUuid());
checkUsername(usernameRequest.existingUsername(), userAgent);
try {
final Account account = accounts.setUsername(auth.getAccount(), usernameRequest.nickname(),
usernameRequest.existingUsername());
return account
.getUsername()
.map(UsernameResponse::new)
.orElseThrow(() -> new IllegalStateException("Could not get username after setting"));
} catch (final UsernameNotAvailableException e) {
throw new WebApplicationException(Status.CONFLICT);
}
}
@Timed
@GET
@Path("/username/{username}")
@Path("/username_hash/{usernameHash}")
@Produces(MediaType.APPLICATION_JSON)
@RateLimitedByIp(RateLimiters.Handle.USERNAME_LOOKUP)
public AccountIdentifierResponse lookupUsername(
public AccountIdentifierResponse lookupUsernameHash(
@HeaderParam(HeaderUtils.X_SIGNAL_AGENT) final String userAgent,
@PathParam("username") final String username,
@HeaderParam(HttpHeaders.X_FORWARDED_FOR) final String forwardedFor,
@PathParam("usernameHash") final String usernameHash,
@Context final HttpServletRequest request) throws RateLimitExceededException {
// Disallow clients from making authenticated requests to this endpoint
@@ -784,10 +762,21 @@ public class AccountController {
throw new BadRequestException();
}
checkUsername(username, userAgent);
rateLimitByClientIp(rateLimiters.getUsernameLookupLimiter(), forwardedFor);
final byte[] hash;
try {
hash = Base64.getUrlDecoder().decode(usernameHash);
} catch (IllegalArgumentException | AssertionError e) {
throw new WebApplicationException(Response.status(422).build());
}
if (hash.length != USERNAME_HASH_LENGTH) {
throw new WebApplicationException(Response.status(422).build());
}
return accounts
.getByUsername(username)
.getByUsernameHash(hash)
.map(Account::getUuid)
.map(AccountIdentifierResponse::new)
.orElseThrow(() -> new WebApplicationException(Status.NOT_FOUND));
@@ -944,15 +933,6 @@ public class AccountController {
accounts.delete(auth.getAccount(), AccountsManager.DeletionReason.USER_REQUEST);
}
private void checkUsername(final String username, final String userAgent) {
if (StringUtils.isNotBlank(username) && !UsernameGenerator.isStandardFormat(username)) {
// Technically, a username may not be in the nickname#discriminator format
// if created through some out-of-band mechanism, but it is atypical.
Metrics.counter(NONSTANDARD_USERNAME_COUNTER_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent)))
.increment();
}
}
private String generatePushChallenge() {
SecureRandom random = new SecureRandom();
byte[] challenge = new byte[16];

View File

@@ -11,6 +11,6 @@ import javax.annotation.Nullable;
public record AccountIdentityResponse(UUID uuid,
String number,
UUID pni,
@Nullable String username,
@Nullable byte[] usernameHash,
boolean storageCapable) {
}

View File

@@ -0,0 +1,19 @@
/*
* Copyright 2022 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import org.whispersystems.textsecuregcm.controllers.AccountController;
import org.whispersystems.textsecuregcm.util.ByteArrayBase64UrlAdapter;
import org.whispersystems.textsecuregcm.util.ExactlySize;
public record ConfirmUsernameHashRequest(
@JsonSerialize(using = ByteArrayBase64UrlAdapter.Serializing.class)
@JsonDeserialize(using = ByteArrayBase64UrlAdapter.Deserializing.class)
@ExactlySize(AccountController.USERNAME_HASH_LENGTH)
byte[] usernameHash
) {}

View File

@@ -1,12 +0,0 @@
/*
* Copyright 2022 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.entities;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.util.UUID;
public record ConfirmUsernameRequest(@NotBlank String usernameToConfirm, @NotNull UUID reservationToken) {}

View File

@@ -0,0 +1,23 @@
/*
* Copyright 2022 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import org.whispersystems.textsecuregcm.controllers.AccountController;
import org.whispersystems.textsecuregcm.util.ByteArrayBase64UrlAdapter;
import javax.validation.Valid;
import javax.validation.constraints.Size;
import java.util.List;
public record ReserveUsernameHashRequest(
@Valid
@Size(min=1, max=AccountController.MAXIMUM_USERNAME_HASHES_LIST_LENGTH)
@JsonSerialize(contentUsing = ByteArrayBase64UrlAdapter.Serializing.class)
@JsonDeserialize(contentUsing = ByteArrayBase64UrlAdapter.Deserializing.class)
List<byte[]> usernameHashes
) {}

View File

@@ -0,0 +1,20 @@
/*
* Copyright 2022 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import org.whispersystems.textsecuregcm.controllers.AccountController;
import org.whispersystems.textsecuregcm.util.ByteArrayBase64UrlAdapter;
import org.whispersystems.textsecuregcm.util.ExactlySize;
import java.util.UUID;
public record ReserveUsernameHashResponse(
@JsonSerialize(using = ByteArrayBase64UrlAdapter.Serializing.class)
@JsonDeserialize(using = ByteArrayBase64UrlAdapter.Deserializing.class)
@ExactlySize(AccountController.USERNAME_HASH_LENGTH)
byte[] usernameHash
) {}

View File

@@ -1,12 +0,0 @@
/*
* Copyright 2022 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.entities;
import org.whispersystems.textsecuregcm.util.Nickname;
import javax.validation.Valid;
public record ReserveUsernameRequest(@Valid @Nickname String nickname) {}

View File

@@ -1,10 +0,0 @@
/*
* Copyright 2022 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.entities;
import java.util.UUID;
public record ReserveUsernameResponse(String username, UUID reservationToken) {}

View File

@@ -0,0 +1,21 @@
/*
* Copyright 2022 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import org.whispersystems.textsecuregcm.controllers.AccountController;
import org.whispersystems.textsecuregcm.util.ByteArrayBase64UrlAdapter;
import org.whispersystems.textsecuregcm.util.ExactlySize;
import javax.validation.Valid;
public record UsernameHashResponse(
@Valid
@JsonSerialize(using = ByteArrayBase64UrlAdapter.Serializing.class)
@JsonDeserialize(using = ByteArrayBase64UrlAdapter.Deserializing.class)
@ExactlySize(AccountController.USERNAME_HASH_LENGTH)
byte[] usernameHash
) {}

View File

@@ -1,12 +0,0 @@
/*
* Copyright 2022 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.entities;
import org.whispersystems.textsecuregcm.util.Nickname;
import javax.annotation.Nullable;
import javax.validation.Valid;
public record UsernameRequest(@Valid @Nickname String nickname, @Nullable String existingUsername) {}

View File

@@ -1,8 +0,0 @@
/*
* Copyright 2022 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.entities;
public record UsernameResponse(String username) {}

View File

@@ -0,0 +1,12 @@
package org.whispersystems.textsecuregcm.mappers;
import com.fasterxml.jackson.databind.JsonMappingException;
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.ExceptionMapper;
public class JsonMappingExceptionMapper implements ExceptionMapper<JsonMappingException> {
@Override
public Response toResponse(final JsonMappingException exception) {
return Response.status(422).build();
}
}

View File

@@ -16,12 +16,15 @@ import java.util.Optional;
import java.util.UUID;
import java.util.function.Predicate;
import javax.annotation.Nullable;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.auth.SaltedTokenHash;
import org.whispersystems.textsecuregcm.auth.StoredRegistrationLock;
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
import org.whispersystems.textsecuregcm.storage.Device.DeviceCapabilities;
import org.whispersystems.textsecuregcm.util.ByteArrayBase64UrlAdapter;
import org.whispersystems.textsecuregcm.util.Util;
public class Account {
@@ -39,10 +42,14 @@ public class Account {
private String number;
@JsonProperty
@JsonSerialize(using = ByteArrayBase64UrlAdapter.Serializing.class)
@JsonDeserialize(using = ByteArrayBase64UrlAdapter.Deserializing.class)
@Nullable
private String username;
private byte[] usernameHash;
@JsonProperty
@JsonSerialize(using = ByteArrayBase64UrlAdapter.Serializing.class)
@JsonDeserialize(using = ByteArrayBase64UrlAdapter.Deserializing.class)
@Nullable
private byte[] reservedUsernameHash;
@@ -126,16 +133,16 @@ public class Account {
this.phoneNumberIdentifier = phoneNumberIdentifier;
}
public Optional<String> getUsername() {
public Optional<byte[]> getUsernameHash() {
requireNotStale();
return Optional.ofNullable(username);
return Optional.ofNullable(usernameHash);
}
public void setUsername(final String username) {
public void setUsernameHash(final byte[] usernameHash) {
requireNotStale();
this.username = username;
this.usernameHash = usernameHash;
}
public Optional<byte[]> getReservedUsernameHash() {

View File

@@ -7,11 +7,16 @@ package org.whispersystems.textsecuregcm.storage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.util.Optionals;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Arrays;
class AccountChangeValidator {
private final boolean allowNumberChange;
private final boolean allowUsernameChange;
private final boolean allowUsernameHashChange;
static final AccountChangeValidator GENERAL_CHANGE_VALIDATOR = new AccountChangeValidator(false, false);
static final AccountChangeValidator NUMBER_CHANGE_VALIDATOR = new AccountChangeValidator(true, false);
@@ -20,10 +25,10 @@ class AccountChangeValidator {
private static final Logger logger = LoggerFactory.getLogger(AccountChangeValidator.class);
AccountChangeValidator(final boolean allowNumberChange,
final boolean allowUsernameChange) {
final boolean allowUsernameHashChange) {
this.allowNumberChange = allowNumberChange;
this.allowUsernameChange = allowUsernameChange;
this.allowUsernameHashChange = allowUsernameHashChange;
}
public void validateChange(final Account originalAccount, final Account updatedAccount) {
@@ -44,13 +49,21 @@ class AccountChangeValidator {
}
}
if (!allowUsernameChange) {
assert updatedAccount.getUsername().equals(originalAccount.getUsername());
if (!allowUsernameHashChange) {
// We can potentially replace this with the actual hash of some invalid username (e.g. 1nickname.123)
final byte[] dummyHash = new byte[32];
new SecureRandom().nextBytes(dummyHash);
if (!updatedAccount.getUsername().equals(originalAccount.getUsername())) {
logger.error("Username changed via \"normal\" update; usernames must be changed via setUsername method",
final byte[] updatedAccountUsernameHash = updatedAccount.getUsernameHash().orElse(dummyHash);
final byte[] originalAccountUsernameHash = originalAccount.getUsernameHash().orElse(dummyHash);
boolean usernameUnchanged = MessageDigest.isEqual(updatedAccountUsernameHash, originalAccountUsernameHash);
if (!usernameUnchanged) {
logger.error("Username hash changed via \"normal\" update; username hashes must be changed via reserveUsernameHash and confirmUsernameHash methods",
new RuntimeException());
}
assert usernameUnchanged;
}
}
}

View File

@@ -39,7 +39,6 @@ import org.whispersystems.textsecuregcm.util.AttributeValues;
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
import org.whispersystems.textsecuregcm.util.SystemMapper;
import org.whispersystems.textsecuregcm.util.UUIDUtil;
import org.whispersystems.textsecuregcm.util.UsernameNormalizer;
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
@@ -64,16 +63,14 @@ public class Accounts extends AbstractDynamoDbStore {
private static final Logger log = LoggerFactory.getLogger(Accounts.class);
private static final byte RESERVED_USERNAME_HASH_VERSION = 1;
private static final Timer CREATE_TIMER = Metrics.timer(name(Accounts.class, "create"));
private static final Timer CHANGE_NUMBER_TIMER = Metrics.timer(name(Accounts.class, "changeNumber"));
private static final Timer SET_USERNAME_TIMER = Metrics.timer(name(Accounts.class, "setUsername"));
private static final Timer RESERVE_USERNAME_TIMER = Metrics.timer(name(Accounts.class, "reserveUsername"));
private static final Timer CLEAR_USERNAME_TIMER = Metrics.timer(name(Accounts.class, "clearUsername"));
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 GET_BY_NUMBER_TIMER = Metrics.timer(name(Accounts.class, "getByNumber"));
private static final Timer GET_BY_USERNAME_TIMER = Metrics.timer(name(Accounts.class, "getByUsername"));
private static final Timer GET_BY_USERNAME_HASH_TIMER = Metrics.timer(name(Accounts.class, "getByUsernameHash"));
private static final Timer GET_BY_PNI_TIMER = Metrics.timer(name(Accounts.class, "getByPni"));
private static final Timer GET_BY_UUID_TIMER = Metrics.timer(name(Accounts.class, "getByUuid"));
private static final Timer GET_ALL_FROM_START_TIMER = Metrics.timer(name(Accounts.class, "getAllFrom"));
@@ -96,8 +93,10 @@ public class Accounts extends AbstractDynamoDbStore {
static final String ATTR_VERSION = "V";
// canonically discoverable
static final String ATTR_CANONICALLY_DISCOVERABLE = "C";
// username; string
static final String ATTR_USERNAME = "N";
// username hash; byte[] or null
static final String ATTR_USERNAME_HASH = "N";
// confirmed; bool
static final String ATTR_CONFIRMED = "F";
// unidentified access key; byte[] or null
static final String ATTR_UAK = "UAK";
// time to live; number
@@ -296,24 +295,23 @@ public class Accounts extends AbstractDynamoDbStore {
}
/**
* Reserve a username under a token
*
* @return a reservation token that must be provided when {@link #confirmUsername(Account, String, UUID)} is called
* Reserve a username hash under the account UUID
*/
public UUID reserveUsername(
public void reserveUsernameHash(
final Account account,
final String reservedUsername,
final byte[] reservedUsernameHash,
final Duration ttl) {
final long startNanos = System.nanoTime();
// if there is an existing old reservation it will be cleaned up via ttl
final Optional<byte[]> maybeOriginalReservation = account.getReservedUsernameHash();
account.setReservedUsernameHash(reservedUsernameHash(account.getUuid(), reservedUsername));
account.setReservedUsernameHash(reservedUsernameHash);
boolean succeeded = false;
final long expirationTime = clock.instant().plus(ttl).getEpochSecond();
final UUID reservationToken = UUID.randomUUID();
// Use account UUID as a "reservation token" - by providing this, the client proves ownership of the hash
UUID uuid = account.getUuid();
try {
final List<TransactWriteItem> writeItems = new ArrayList<>();
@@ -321,11 +319,12 @@ public class Accounts extends AbstractDynamoDbStore {
.put(Put.builder()
.tableName(usernamesConstraintTableName)
.item(Map.of(
KEY_ACCOUNT_UUID, AttributeValues.fromUUID(reservationToken),
ATTR_USERNAME, AttributeValues.fromString(UsernameNormalizer.normalize(reservedUsername)),
ATTR_TTL, AttributeValues.fromLong(expirationTime)))
.conditionExpression("attribute_not_exists(#username) OR (#ttl < :now)")
.expressionAttributeNames(Map.of("#username", ATTR_USERNAME, "#ttl", ATTR_TTL))
KEY_ACCOUNT_UUID, AttributeValues.fromUUID(uuid),
ATTR_USERNAME_HASH, AttributeValues.fromByteArray(reservedUsernameHash),
ATTR_TTL, AttributeValues.fromLong(expirationTime),
ATTR_CONFIRMED, 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())))
.returnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.ALL_OLD)
.build())
@@ -335,7 +334,7 @@ public class Accounts extends AbstractDynamoDbStore {
TransactWriteItem.builder()
.update(Update.builder()
.tableName(accountsTableName)
.key(Map.of(KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid())))
.key(Map.of(KEY_ACCOUNT_UUID, AttributeValues.fromUUID(uuid)))
.updateExpression("SET #data = :data ADD #version :version_increment")
.conditionExpression("#version = :version")
.expressionAttributeNames(Map.of("#data", ATTR_ACCOUNT_DATA, "#version", ATTR_VERSION))
@@ -367,42 +366,23 @@ public class Accounts extends AbstractDynamoDbStore {
}
RESERVE_USERNAME_TIMER.record(System.nanoTime() - startNanos, TimeUnit.NANOSECONDS);
}
return reservationToken;
}
/**
* Confirm (set) a previously reserved username
* Confirm (set) a previously reserved username hash
*
* @param account to update
* @param username believed to be available
* @param reservationToken a token returned by the call to {@link #reserveUsername(Account, String, Duration)},
* only required if setting a reserved username
* @throws ContestedOptimisticLockException if the account has been updated or the username taken by someone else
* @param usernameHash believed to be available
* @throws ContestedOptimisticLockException if the account has been updated or the username has taken by someone else
*/
public void confirmUsername(final Account account, final String username, final UUID reservationToken)
throws ContestedOptimisticLockException {
setUsername(account, username, Optional.of(reservationToken));
}
/**
* Set the account username
*
* @param account to update
* @param username believed to be available
* @throws ContestedOptimisticLockException if the account has been updated or the username taken by someone else
*/
public void setUsername(final Account account, final String username) throws ContestedOptimisticLockException {
setUsername(account, username, Optional.empty());
}
private void setUsername(final Account account, final String username, final Optional<UUID> reservationToken)
public void confirmUsernameHash(final Account account, final byte[] usernameHash)
throws ContestedOptimisticLockException {
final long startNanos = System.nanoTime();
final Optional<String> maybeOriginalUsername = account.getUsername();
final Optional<byte[]> maybeOriginalReservation = account.getReservedUsernameHash();
final Optional<byte[]> maybeOriginalUsernameHash = account.getUsernameHash();
final Optional<byte[]> maybeOriginalReservationHash = account.getReservedUsernameHash();
account.setUsername(username);
account.setUsernameHash(usernameHash);
account.setReservedUsernameHash(null);
boolean succeeded = false;
@@ -410,20 +390,21 @@ public class Accounts extends AbstractDynamoDbStore {
try {
final List<TransactWriteItem> writeItems = new ArrayList<>();
// add the username to the constraint table, wiping out the ttl if we had already reserved the name
// Persist the normalized username in the usernamesConstraint table and the original username in the accounts table
// add the username hash to the constraint table, wiping out the ttl if we had already reserved the hash
writeItems.add(TransactWriteItem.builder()
.put(Put.builder()
.tableName(usernamesConstraintTableName)
.item(Map.of(
KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid()),
ATTR_USERNAME, AttributeValues.fromString(UsernameNormalizer.normalize(username))))
ATTR_USERNAME_HASH, AttributeValues.fromByteArray(usernameHash),
ATTR_CONFIRMED, AttributeValues.fromBool(true)))
// it's not in the constraint table OR it's expired OR it was reserved by us
.conditionExpression("attribute_not_exists(#username) OR #ttl < :now OR #aci = :reservation ")
.expressionAttributeNames(Map.of("#username", ATTR_USERNAME, "#ttl", ATTR_TTL, "#aci", KEY_ACCOUNT_UUID))
.conditionExpression("attribute_not_exists(#username_hash) OR #ttl < :now OR (#aci = :aci AND #confirmed = :confirmed)")
.expressionAttributeNames(Map.of("#username_hash", ATTR_USERNAME_HASH, "#ttl", ATTR_TTL, "#aci", KEY_ACCOUNT_UUID, "#confirmed", ATTR_CONFIRMED))
.expressionAttributeValues(Map.of(
":now", AttributeValues.fromLong(clock.instant().getEpochSecond()),
":reservation", AttributeValues.fromUUID(reservationToken.orElseGet(UUID::randomUUID))))
":aci", AttributeValues.fromUUID(account.getUuid()),
":confirmed", AttributeValues.fromBool(false)))
.returnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.ALL_OLD)
.build())
.build());
@@ -433,21 +414,21 @@ public class Accounts extends AbstractDynamoDbStore {
.update(Update.builder()
.tableName(accountsTableName)
.key(Map.of(KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid())))
.updateExpression("SET #data = :data, #username = :username ADD #version :version_increment")
.updateExpression("SET #data = :data, #username_hash = :username_hash ADD #version :version_increment")
.conditionExpression("#version = :version")
.expressionAttributeNames(Map.of("#data", ATTR_ACCOUNT_DATA,
"#username", ATTR_USERNAME,
"#username_hash", ATTR_USERNAME_HASH,
"#version", ATTR_VERSION))
.expressionAttributeValues(Map.of(
":data", AttributeValues.fromByteArray(SystemMapper.getMapper().writeValueAsBytes(account)),
":username", AttributeValues.fromString(username),
":username_hash", AttributeValues.fromByteArray(usernameHash),
":version", AttributeValues.fromInt(account.getVersion()),
":version_increment", AttributeValues.fromInt(1)))
.build())
.build());
maybeOriginalUsername.ifPresent(originalUsername -> writeItems.add(
buildDelete(usernamesConstraintTableName, ATTR_USERNAME, UsernameNormalizer.normalize(originalUsername))));
maybeOriginalUsernameHash.ifPresent(originalUsernameHash -> writeItems.add(
buildDelete(usernamesConstraintTableName, ATTR_USERNAME_HASH, originalUsernameHash)));
final TransactWriteItemsRequest request = TransactWriteItemsRequest.builder()
.transactItems(writeItems)
@@ -466,17 +447,17 @@ public class Accounts extends AbstractDynamoDbStore {
throw e;
} finally {
if (!succeeded) {
account.setUsername(maybeOriginalUsername.orElse(null));
account.setReservedUsernameHash(maybeOriginalReservation.orElse(null));
account.setUsernameHash(maybeOriginalUsernameHash.orElse(null));
account.setReservedUsernameHash(maybeOriginalReservationHash.orElse(null));
}
SET_USERNAME_TIMER.record(System.nanoTime() - startNanos, TimeUnit.NANOSECONDS);
}
}
public void clearUsername(final Account account) {
account.getUsername().ifPresent(username -> {
CLEAR_USERNAME_TIMER.record(() -> {
account.setUsername(null);
public void clearUsernameHash(final Account account) {
account.getUsernameHash().ifPresent(usernameHash -> {
CLEAR_USERNAME_HASH_TIMER.record(() -> {
account.setUsernameHash(null);
boolean succeeded = false;
@@ -488,10 +469,10 @@ public class Accounts extends AbstractDynamoDbStore {
.update(Update.builder()
.tableName(accountsTableName)
.key(Map.of(KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid())))
.updateExpression("SET #data = :data REMOVE #username ADD #version :version_increment")
.updateExpression("SET #data = :data REMOVE #username_hash ADD #version :version_increment")
.conditionExpression("#version = :version")
.expressionAttributeNames(Map.of("#data", ATTR_ACCOUNT_DATA,
"#username", ATTR_USERNAME,
"#username_hash", ATTR_USERNAME_HASH,
"#version", ATTR_VERSION))
.expressionAttributeValues(Map.of(
":data", AttributeValues.fromByteArray(SystemMapper.getMapper().writeValueAsBytes(account)),
@@ -500,7 +481,7 @@ public class Accounts extends AbstractDynamoDbStore {
.build())
.build());
writeItems.add(buildDelete(usernamesConstraintTableName, ATTR_USERNAME, UsernameNormalizer.normalize(username)));
writeItems.add(buildDelete(usernamesConstraintTableName, ATTR_USERNAME_HASH, usernameHash));
final TransactWriteItemsRequest request = TransactWriteItemsRequest.builder()
.transactItems(writeItems)
@@ -520,7 +501,7 @@ public class Accounts extends AbstractDynamoDbStore {
throw e;
} finally {
if (!succeeded) {
account.setUsername(username);
account.setUsernameHash(usernameHash);
}
}
});
@@ -601,27 +582,27 @@ public class Accounts extends AbstractDynamoDbStore {
}
}
public boolean usernameAvailable(final String username) {
return usernameAvailable(Optional.empty(), username);
public boolean usernameHashAvailable(final byte[] username) {
return usernameHashAvailable(Optional.empty(), username);
}
public boolean usernameAvailable(final Optional<UUID> reservationToken, final String username) {
final Optional<Map<String, AttributeValue>> usernameItem = itemByKey(
usernamesConstraintTableName, ATTR_USERNAME, AttributeValues.fromString(UsernameNormalizer.normalize(username)));
public boolean usernameHashAvailable(final Optional<UUID> accountUuid, final byte[] usernameHash) {
final Optional<Map<String, AttributeValue>> usernameHashItem = itemByKey(
usernamesConstraintTableName, ATTR_USERNAME_HASH, AttributeValues.fromByteArray(usernameHash));
if (usernameItem.isEmpty()) {
// username is free
if (usernameHashItem.isEmpty()) {
// username hash is free
return true;
}
final Map<String, AttributeValue> item = usernameItem.get();
final Map<String, AttributeValue> item = usernameHashItem.get();
if (AttributeValues.getLong(item, ATTR_TTL, Long.MAX_VALUE) < clock.instant().getEpochSecond()) {
// username was reserved, but has expired
// username hash was reserved, but has expired
return true;
}
// username is reserved by us
return reservationToken
// username hash is reserved by us
return !AttributeValues.getBool(item, ATTR_CONFIRMED, true) && accountUuid
.map(AttributeValues.getUUID(item, KEY_ACCOUNT_UUID, new UUID(0, 0))::equals)
.orElse(false);
}
@@ -639,13 +620,13 @@ public class Accounts extends AbstractDynamoDbStore {
}
@Nonnull
public Optional<Account> getByUsername(final String username) {
public Optional<Account> getByUsernameHash(final byte[] usernameHash) {
return getByIndirectLookup(
GET_BY_USERNAME_TIMER,
GET_BY_USERNAME_HASH_TIMER,
usernamesConstraintTableName,
ATTR_USERNAME,
AttributeValues.fromString(UsernameNormalizer.normalize(username)),
item -> !item.containsKey(ATTR_TTL) // ignore items with a ttl (reservations)
ATTR_USERNAME_HASH,
AttributeValues.fromByteArray(usernameHash),
item -> AttributeValues.getBool(item, ATTR_CONFIRMED, false) // ignore items that are reservations (not confirmed)
);
}
@@ -665,8 +646,8 @@ public class Accounts extends AbstractDynamoDbStore {
buildDelete(phoneNumberIdentifierConstraintTableName, ATTR_PNI_UUID, account.getPhoneNumberIdentifier())
));
account.getUsername().ifPresent(username -> transactWriteItems.add(
buildDelete(usernamesConstraintTableName, ATTR_USERNAME, UsernameNormalizer.normalize(username))));
account.getUsernameHash().ifPresent(usernameHash -> transactWriteItems.add(
buildDelete(usernamesConstraintTableName, ATTR_USERNAME_HASH, usernameHash)));
final TransactWriteItemsRequest request = TransactWriteItemsRequest.builder()
.transactItems(transactWriteItems).build();
@@ -807,6 +788,11 @@ public class Accounts extends AbstractDynamoDbStore {
return buildDelete(tableName, keyName, AttributeValues.fromString(keyValue));
}
@Nonnull
private static TransactWriteItem buildDelete(final String tableName, final String keyName, final byte[] keyValue) {
return buildDelete(tableName, keyName, AttributeValues.fromByteArray(keyValue));
}
@Nonnull
private static TransactWriteItem buildDelete(final String tableName, final String keyName, final UUID keyValue) {
return buildDelete(tableName, keyName, AttributeValues.fromUUID(keyValue));
@@ -843,22 +829,6 @@ public class Accounts extends AbstractDynamoDbStore {
.collect(Collectors.joining(", "));
}
@Nonnull
public static byte[] reservedUsernameHash(final UUID accountId, final String reservedUsername) {
final MessageDigest sha256;
try {
sha256 = MessageDigest.getInstance("SHA-256");
} catch (final NoSuchAlgorithmException e) {
throw new AssertionError(e);
}
final ByteBuffer byteBuffer = ByteBuffer.allocate(32 + 1);
sha256.update(UsernameNormalizer.normalize(reservedUsername).getBytes(StandardCharsets.UTF_8));
sha256.update(UUIDUtil.toBytes(accountId));
byteBuffer.put(RESERVED_USERNAME_HASH_VERSION);
byteBuffer.put(sha256.digest());
return byteBuffer.array();
}
@VisibleForTesting
@Nonnull
static Account fromItem(final Map<String, AttributeValue> item) {
@@ -883,7 +853,7 @@ public class Accounts extends AbstractDynamoDbStore {
account.setNumber(item.get(ATTR_ACCOUNT_E164).s(), phoneNumberIdentifierFromAttribute);
account.setUuid(accountIdentifier);
account.setUsername(AttributeValues.getString(item, ATTR_USERNAME, null));
account.setUsernameHash(AttributeValues.getByteArray(item, ATTR_USERNAME_HASH, null));
account.setVersion(Integer.parseInt(item.get(ATTR_VERSION).n()));
account.setCanonicallyDiscoverable(Optional.ofNullable(item.get(ATTR_CANONICALLY_DISCOVERABLE))
.map(AttributeValue::bool)

View File

@@ -22,6 +22,7 @@ import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@@ -50,8 +51,6 @@ import org.whispersystems.textsecuregcm.sqs.DirectoryQueue;
import org.whispersystems.textsecuregcm.util.Constants;
import org.whispersystems.textsecuregcm.util.DestinationDeviceValidator;
import org.whispersystems.textsecuregcm.util.SystemMapper;
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
import org.whispersystems.textsecuregcm.util.UsernameNormalizer;
import org.whispersystems.textsecuregcm.util.Util;
public class AccountsManager {
@@ -60,13 +59,13 @@ public class AccountsManager {
private static final Timer createTimer = metricRegistry.timer(name(AccountsManager.class, "create"));
private static final Timer updateTimer = metricRegistry.timer(name(AccountsManager.class, "update"));
private static final Timer getByNumberTimer = metricRegistry.timer(name(AccountsManager.class, "getByNumber"));
private static final Timer getByUsernameTimer = metricRegistry.timer(name(AccountsManager.class, "getByUsername"));
private static final Timer getByUsernameHashTimer = metricRegistry.timer(name(AccountsManager.class, "getByUsernameHash"));
private static final Timer getByUuidTimer = metricRegistry.timer(name(AccountsManager.class, "getByUuid"));
private static final Timer deleteTimer = metricRegistry.timer(name(AccountsManager.class, "delete"));
private static final Timer redisSetTimer = metricRegistry.timer(name(AccountsManager.class, "redisSet"));
private static final Timer redisNumberGetTimer = metricRegistry.timer(name(AccountsManager.class, "redisNumberGet"));
private static final Timer redisUsernameGetTimer = metricRegistry.timer(name(AccountsManager.class, "redisUsernameGet"));
private static final Timer redisUsernameHashGetTimer = metricRegistry.timer(name(AccountsManager.class, "redisUsernameHashGet"));
private static final Timer redisPniGetTimer = metricRegistry.timer(name(AccountsManager.class, "redisPniGet"));
private static final Timer redisUuidGetTimer = metricRegistry.timer(name(AccountsManager.class, "redisUuidGet"));
private static final Timer redisDeleteTimer = metricRegistry.timer(name(AccountsManager.class, "redisDelete"));
@@ -88,7 +87,6 @@ public class AccountsManager {
private final DirectoryQueue directoryQueue;
private final Keys keys;
private final MessagesManager messagesManager;
private final ProhibitedUsernames prohibitedUsernames;
private final ProfilesManager profilesManager;
private final StoredVerificationCodeManager pendingAccounts;
private final SecureStorageClient secureStorageClient;
@@ -96,7 +94,6 @@ public class AccountsManager {
private final ClientPresenceManager clientPresenceManager;
private final ExperimentEnrollmentManager experimentEnrollmentManager;
private final Clock clock;
private final UsernameGenerator usernameGenerator;
private static final ObjectMapper mapper = SystemMapper.getMapper();
@@ -106,9 +103,11 @@ public class AccountsManager {
// the owner.
private static final long CACHE_TTL_SECONDS = Duration.ofDays(2).toSeconds();
private static final Duration USERNAME_HASH_RESERVATION_TTL_MINUTES = Duration.ofMinutes(5);
@FunctionalInterface
private interface AccountPersister {
void persistAccount(Account account) throws UsernameNotAvailableException;
void persistAccount(Account account) throws UsernameHashNotAvailableException;
}
public enum DeletionReason {
@@ -130,13 +129,11 @@ public class AccountsManager {
final DirectoryQueue directoryQueue,
final Keys keys,
final MessagesManager messagesManager,
final ProhibitedUsernames prohibitedUsernames,
final ProfilesManager profilesManager,
final StoredVerificationCodeManager pendingAccounts,
final SecureStorageClient secureStorageClient,
final SecureBackupClient secureBackupClient,
final ClientPresenceManager clientPresenceManager,
final UsernameGenerator usernameGenerator,
final ExperimentEnrollmentManager experimentEnrollmentManager,
final Clock clock) {
this.accounts = accounts;
@@ -151,8 +148,6 @@ public class AccountsManager {
this.secureStorageClient = secureStorageClient;
this.secureBackupClient = secureBackupClient;
this.clientPresenceManager = clientPresenceManager;
this.prohibitedUsernames = prohibitedUsernames;
this.usernameGenerator = usernameGenerator;
this.experimentEnrollmentManager = experimentEnrollmentManager;
this.clock = Objects.requireNonNull(clock);
}
@@ -324,42 +319,43 @@ public class AccountsManager {
return updatedAccount.get();
}
public record UsernameReservation(Account account, String reservedUsername, UUID reservationToken){}
public record UsernameReservation(Account account, byte[] reservedUsernameHash){}
/**
* Generate a username from a nickname, and reserve it so no other accounts may take it.
* Reserve a username hash so that no other accounts may take it.
*
* The reserved username can later be set with {@link #confirmReservedUsername(Account, String, UUID)}. The reservation
* will eventually expire, after which point confirmReservedUsername may fail if another account has taken the
* username.
* The reserved hash can later be set with {@link #confirmReservedUsernameHash(Account, byte[])}. The reservation
* will eventually expire, after which point confirmReservedUsernameHash may fail if another account has taken the
* username hash.
*
* @param account the account to update
* @param requestedNickname the nickname to reserve a username for
* @return the reserved username and an updated Account object
* @throws UsernameNotAvailableException no username is available for the requested nickname
* @param requestedUsernameHashes the list of username hashes to attempt to reserve
* @return the reserved username hash and an updated Account object
* @throws UsernameHashNotAvailableException no username hash is available
*/
public UsernameReservation reserveUsername(final Account account, final String requestedNickname) throws UsernameNotAvailableException {
public UsernameReservation reserveUsernameHash(final Account account, final List<byte[]> requestedUsernameHashes) throws UsernameHashNotAvailableException {
if (!experimentEnrollmentManager.isEnrolled(account.getUuid(), USERNAME_EXPERIMENT_NAME)) {
throw new UsernameNotAvailableException();
throw new UsernameHashNotAvailableException();
}
if (prohibitedUsernames.isProhibited(requestedNickname, account.getUuid())) {
throw new UsernameNotAvailableException();
}
redisDelete(account);
class Reserver implements AccountPersister {
UUID reservationToken;
String reservedUsername;
byte[] reservedUsernameHash;
@Override
public void persistAccount(final Account account) throws UsernameNotAvailableException {
// In the future, this may also check for any forbidden discriminators
reservedUsername = usernameGenerator.generateAvailableUsername(requestedNickname, accounts::usernameAvailable);
reservationToken = accounts.reserveUsername(
account,
reservedUsername,
usernameGenerator.getReservationTtl());
public void persistAccount(final Account account) throws UsernameHashNotAvailableException {
for (byte[] usernameHash : requestedUsernameHashes) {
if (accounts.usernameHashAvailable(usernameHash)) {
reservedUsernameHash = usernameHash;
accounts.reserveUsernameHash(
account,
usernameHash,
USERNAME_HASH_RESERVATION_TTL_MINUTES);
return;
}
}
throw new UsernameHashNotAvailableException();
}
}
final Reserver reserver = new Reserver();
@@ -369,31 +365,28 @@ public class AccountsManager {
reserver,
() -> accounts.getByAccountIdentifier(account.getUuid()).orElseThrow(),
AccountChangeValidator.USERNAME_CHANGE_VALIDATOR);
return new UsernameReservation(updatedAccount, reserver.reservedUsername, reserver.reservationToken);
return new UsernameReservation(updatedAccount, reserver.reservedUsernameHash);
}
/**
* Set a username previously reserved with {@link #reserveUsername(Account, String)}
* Set a username hash previously reserved with {@link #reserveUsernameHash(Account, List<String>)}
*
* @param account the account to update
* @param reservedUsername the previously reserved username
* @param reservationToken the UUID returned from the reservation
* @return the updated account with the username field set
* @throws UsernameNotAvailableException if the reserved username has been taken (because the reservation expired)
* @throws UsernameReservationNotFoundException if `reservedUsername` was not reserved in the account
* @param reservedUsernameHash the previously reserved username hash
* @return the updated account with the username hash field set
* @throws UsernameHashNotAvailableException if the reserved username hash has been taken (because the reservation expired)
* @throws UsernameReservationNotFoundException if `reservedUsernameHash` was not reserved in the account
*/
public Account confirmReservedUsername(final Account account, final String reservedUsername, final UUID reservationToken) throws UsernameNotAvailableException, UsernameReservationNotFoundException {
public Account confirmReservedUsernameHash(final Account account, final byte[] reservedUsernameHash) throws UsernameHashNotAvailableException, UsernameReservationNotFoundException {
if (!experimentEnrollmentManager.isEnrolled(account.getUuid(), USERNAME_EXPERIMENT_NAME)) {
throw new UsernameNotAvailableException();
throw new UsernameHashNotAvailableException();
}
if (account.getUsername().map(reservedUsername::equals).orElse(false)) {
if (account.getUsernameHash().map(currentUsernameHash -> Arrays.equals(currentUsernameHash, reservedUsernameHash)).orElse(false)) {
// the client likely already succeeded and is retrying
return account;
}
final byte[] newHash = Accounts.reservedUsernameHash(account.getUuid(), UsernameNormalizer.normalize(reservedUsername));
if (!account.getReservedUsernameHash().map(oldHash -> Arrays.equals(oldHash, newHash)).orElse(false)) {
if (!account.getReservedUsernameHash().map(oldHash -> Arrays.equals(oldHash, reservedUsernameHash)).orElse(false)) {
// no such reservation existed, either there was no previous call to reserveUsername
// or the reservation changed
throw new UsernameReservationNotFoundException();
@@ -405,63 +398,23 @@ public class AccountsManager {
account,
a -> true,
a -> {
// though we know this username was reserved, the reservation could have lapsed
if (!accounts.usernameAvailable(Optional.of(reservationToken), reservedUsername)) {
throw new UsernameNotAvailableException();
// though we know this username hash was reserved, the reservation could have lapsed
if (!accounts.usernameHashAvailable(Optional.of(account.getUuid()), reservedUsernameHash)) {
throw new UsernameHashNotAvailableException();
}
accounts.confirmUsername(a, reservedUsername, reservationToken);
accounts.confirmUsernameHash(a, reservedUsernameHash);
},
() -> accounts.getByAccountIdentifier(account.getUuid()).orElseThrow(),
AccountChangeValidator.USERNAME_CHANGE_VALIDATOR);
}
/**
* Sets a username generated from `requestedNickname` with no prior reservation
*
* @param account the account to update
* @param requestedNickname the nickname to generate a username from
* @param expectedOldUsername the expected existing username of the account (for replay detection)
* @return the updated account with the username field set
* @throws UsernameNotAvailableException if no free username could be set for `requestedNickname`
*/
public Account setUsername(final Account account, final String requestedNickname, final @Nullable String expectedOldUsername) throws UsernameNotAvailableException {
if (!experimentEnrollmentManager.isEnrolled(account.getUuid(), USERNAME_EXPERIMENT_NAME)) {
throw new UsernameNotAvailableException();
}
if (prohibitedUsernames.isProhibited(requestedNickname, account.getUuid())) {
throw new UsernameNotAvailableException();
}
final Optional<String> currentUsername = account.getUsername();
final Optional<String> currentNickname = currentUsername.map(UsernameGenerator::extractNickname);
if (currentNickname.map(requestedNickname::equals).orElse(false) && !Objects.equals(expectedOldUsername, currentUsername.orElse(null))) {
// The requested nickname matches what the server already has, and the
// client provided the wrong existing username. Treat this as a replayed
// request, assuming that the client has previously succeeded
return account;
}
redisDelete(account);
return failableUpdateWithRetries(
account,
a -> true,
// In the future, this may also check for any forbidden discriminators
a -> accounts.setUsername(
a,
usernameGenerator.generateAvailableUsername(requestedNickname, accounts::usernameAvailable)),
() -> accounts.getByAccountIdentifier(account.getUuid()).orElseThrow(),
AccountChangeValidator.USERNAME_CHANGE_VALIDATOR);
}
public Account clearUsername(final Account account) {
public Account clearUsernameHash(final Account account) {
redisDelete(account);
return updateWithRetries(
account,
a -> true,
accounts::clearUsername,
accounts::clearUsernameHash,
() -> accounts.getByAccountIdentifier(account.getUuid()).orElseThrow(),
AccountChangeValidator.USERNAME_CHANGE_VALIDATOR);
}
@@ -547,7 +500,7 @@ public class AccountsManager {
final AccountChangeValidator changeValidator) {
try {
return failableUpdateWithRetries(account, updater, persister::accept, retriever, changeValidator);
} catch (UsernameNotAvailableException e) {
} catch (UsernameHashNotAvailableException e) {
// not possible
throw new IllegalStateException(e);
}
@@ -557,7 +510,7 @@ public class AccountsManager {
final Function<Account, Boolean> updater,
final AccountPersister persister,
final Supplier<Account> retriever,
final AccountChangeValidator changeValidator) throws UsernameNotAvailableException {
final AccountChangeValidator changeValidator) throws UsernameHashNotAvailableException {
Account originalAccount = cloneAccount(account);
@@ -640,11 +593,11 @@ public class AccountsManager {
}
}
public Optional<Account> getByUsername(final String username) {
try (final Timer.Context ignored = getByUsernameTimer.time()) {
Optional<Account> account = redisGetByUsername(username);
public Optional<Account> getByUsernameHash(final byte[] usernameHash) {
try (final Timer.Context ignored = getByUsernameHashTimer.time()) {
Optional<Account> account = redisGetByUsernameHash(usernameHash);
if (account.isEmpty()) {
account = accounts.getByUsername(username);
account = accounts.getByUsernameHash(usernameHash);
account.ifPresent(this::redisSet);
}
@@ -721,8 +674,8 @@ public class AccountsManager {
clientPresenceManager.disconnectPresence(account.getUuid(), device.getId())));
}
private String getUsernameAccountMapKey(String username) {
return "UAccountMap::" + UsernameNormalizer.normalize(username);
private String getUsernameHashAccountMapKey(byte[] usernameHash) {
return "UAccountMap::" + Base64.getUrlEncoder().withoutPadding().encodeToString(usernameHash);
}
private String getAccountMapKey(String key) {
@@ -744,8 +697,8 @@ public class AccountsManager {
commands.setex(getAccountMapKey(account.getNumber()), CACHE_TTL_SECONDS, account.getUuid().toString());
commands.setex(getAccountEntityKey(account.getUuid()), CACHE_TTL_SECONDS, accountJson);
account.getUsername().ifPresent(username ->
commands.setex(getUsernameAccountMapKey(username), CACHE_TTL_SECONDS, account.getUuid().toString()));
account.getUsernameHash().ifPresent(usernameHash ->
commands.setex(getUsernameHashAccountMapKey(usernameHash), CACHE_TTL_SECONDS, account.getUuid().toString()));
});
} catch (JsonProcessingException e) {
throw new IllegalStateException(e);
@@ -760,8 +713,8 @@ public class AccountsManager {
return redisGetBySecondaryKey(getAccountMapKey(e164), redisNumberGetTimer);
}
private Optional<Account> redisGetByUsername(String username) {
return redisGetBySecondaryKey(getUsernameAccountMapKey(username), redisUsernameGetTimer);
private Optional<Account> redisGetByUsernameHash(byte[] usernameHash) {
return redisGetBySecondaryKey(getUsernameHashAccountMapKey(usernameHash), redisUsernameHashGetTimer);
}
private Optional<Account> redisGetBySecondaryKey(String secondaryKey, Timer timer) {
@@ -812,7 +765,7 @@ public class AccountsManager {
getAccountMapKey(account.getPhoneNumberIdentifier().toString()),
getAccountEntityKey(account.getUuid()));
account.getUsername().ifPresent(username -> connection.sync().del(getUsernameAccountMapKey(username)));
account.getUsernameHash().ifPresent(usernameHash -> connection.sync().del(getUsernameHashAccountMapKey(usernameHash)));
});
}
}

View File

@@ -5,5 +5,5 @@
package org.whispersystems.textsecuregcm.storage;
public class UsernameNotAvailableException extends Exception {
public class UsernameHashNotAvailableException extends Exception {
}

View File

@@ -0,0 +1,27 @@
package org.whispersystems.textsecuregcm.util;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import java.io.IOException;
import java.util.Base64;
public class ByteArrayBase64UrlAdapter {
public static class Serializing extends JsonSerializer<byte[]> {
@Override
public void serialize(byte[] bytes, JsonGenerator jsonGenerator, SerializerProvider serializerProvider)
throws IOException {
jsonGenerator.writeString(Base64.getUrlEncoder().withoutPadding().encodeToString(bytes));
}
}
public static class Deserializing extends JsonDeserializer<byte[]> {
@Override
public byte[] deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
return Base64.getUrlDecoder().decode(jsonParser.getValueAsString());
}
}
}

View File

@@ -1,27 +0,0 @@
/*
* Copyright 2013-2021 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.util;
import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
@Target({ FIELD, PARAMETER })
@Retention(RUNTIME)
@Constraint(validatedBy = NicknameValidator.class)
public @interface Nickname {
String message() default "{org.whispersystems.textsecuregcm.util.Nickname.message}";
Class<?>[] groups() default { };
Class<? extends Payload>[] payload() default { };
}

View File

@@ -1,17 +0,0 @@
/*
* Copyright 2013-2021 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.util;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
public class NicknameValidator implements ConstraintValidator<Nickname, String> {
@Override
public boolean isValid(final String nickname, final ConstraintValidatorContext context) {
return UsernameGenerator.isValidNickname(nickname);
}
}

View File

@@ -1,152 +0,0 @@
/*
* Copyright 2022 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.util;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.math.IntMath;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.DistributionSummary;
import io.micrometer.core.instrument.Metrics;
import org.apache.commons.lang3.StringUtils;
import org.whispersystems.textsecuregcm.configuration.UsernameConfiguration;
import org.whispersystems.textsecuregcm.storage.UsernameNotAvailableException;
import java.time.Duration;
import java.util.concurrent.ThreadLocalRandom;
import java.util.function.Predicate;
import java.util.regex.Pattern;
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
public class UsernameGenerator {
/**
* Nicknames
* <list>
* <li> do not start with a number </li>
* <li> are alphanumeric or underscores only </li>
* <li> have minimum length 3 </li>
* <li> have maximum length 32 </li>
* </list>
*
* Usernames typically consist of a nickname and an integer discriminator
*/
public static final Pattern NICKNAME_PATTERN = Pattern.compile("^[_a-zA-Z][_a-zA-Z0-9]{2,31}$");
public static final String SEPARATOR = ".";
private static final Counter USERNAME_NOT_AVAILABLE_COUNTER = Metrics.counter(name(UsernameGenerator.class, "usernameNotAvailable"));
private static final DistributionSummary DISCRIMINATOR_ATTEMPT_COUNTER = Metrics.summary(name(UsernameGenerator.class, "discriminatorAttempts"));
private final int initialWidth;
private final int discriminatorMaxWidth;
private final int attemptsPerWidth;
private final Duration reservationTtl;
public UsernameGenerator(UsernameConfiguration configuration) {
this(configuration.getDiscriminatorInitialWidth(),
configuration.getDiscriminatorMaxWidth(),
configuration.getAttemptsPerWidth(),
configuration.getReservationTtl());
}
@VisibleForTesting
public UsernameGenerator(int initialWidth, int discriminatorMaxWidth, int attemptsPerWidth, final Duration reservationTtl) {
this.initialWidth = initialWidth;
this.discriminatorMaxWidth = discriminatorMaxWidth;
this.attemptsPerWidth = attemptsPerWidth;
this.reservationTtl = reservationTtl;
}
/**
* Generate a username with a random discriminator
*
* @param nickname The string nickname
* @param usernameAvailableFun A {@link Predicate} that returns true if the provided username is available
* @return The nickname appended with a random discriminator
* @throws UsernameNotAvailableException if we failed to find a nickname+discriminator pair that was available
*/
public String generateAvailableUsername(final String nickname, final Predicate<String> usernameAvailableFun) throws UsernameNotAvailableException {
int rangeMin = 1;
int rangeMax = IntMath.pow(10, initialWidth);
int totalMax = IntMath.pow(10, discriminatorMaxWidth);
int attempts = 0;
while (rangeMax <= totalMax) {
// check discriminators of the current width up to attemptsPerWidth times
for (int i = 0; i < attemptsPerWidth; i++) {
int discriminator = ThreadLocalRandom.current().nextInt(rangeMin, rangeMax);
String username = fromParts(nickname, discriminator);
attempts++;
if (usernameAvailableFun.test(username)) {
DISCRIMINATOR_ATTEMPT_COUNTER.record(attempts);
return username;
}
}
// update the search range to look for numbers of one more digit
// than the previous iteration
rangeMin = rangeMax;
rangeMax *= 10;
}
USERNAME_NOT_AVAILABLE_COUNTER.increment();
throw new UsernameNotAvailableException();
}
/**
* Strips the discriminator from a username, if it is present
*
* @param username the string username
* @return the nickname prefix of the username
*/
public static String extractNickname(final String username) {
int sep = username.indexOf(SEPARATOR);
return sep == -1 ? username : username.substring(0, sep);
}
/**
* Generate a username from a nickname and discriminator
*/
public String fromParts(final String nickname, final int discriminator) throws IllegalArgumentException {
if (!isValidNickname(nickname)) {
throw new IllegalArgumentException("Invalid nickname " + nickname);
}
// zero pad discriminators less than the discriminator initial width
return String.format("%s%s%0" + initialWidth + "d", nickname, SEPARATOR, discriminator);
}
public Duration getReservationTtl() {
return reservationTtl;
}
public static boolean isValidNickname(final String nickname) {
return StringUtils.isNotBlank(nickname) && NICKNAME_PATTERN.matcher(nickname).matches();
}
/**
* Checks if the username consists of a valid nickname followed by an integer discriminator
*
* @param username string username to check
* @return true if the username is in standard form
*/
public static boolean isStandardFormat(final String username) {
if (username == null) {
return false;
}
int sep = username.indexOf(SEPARATOR);
if (sep == -1) {
return false;
}
final String nickname = username.substring(0, sep);
if (!isValidNickname(nickname)) {
return false;
}
try {
int discriminator = Integer.parseInt(username.substring(sep + 1));
return discriminator > 0;
} catch (NumberFormatException e) {
return false;
}
}
}

View File

@@ -1,10 +0,0 @@
package org.whispersystems.textsecuregcm.util;
import java.util.Locale;
public final class UsernameNormalizer {
private UsernameNormalizer() {}
public static String normalize(final String username) {
return username.toLowerCase(Locale.ROOT);
}
}

View File

@@ -17,6 +17,8 @@ import io.dropwizard.cli.EnvironmentCommand;
import io.dropwizard.setup.Environment;
import io.lettuce.core.resource.ClientResources;
import java.time.Clock;
import java.util.Base64;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@@ -50,10 +52,10 @@ import org.whispersystems.textsecuregcm.storage.ProhibitedUsernames;
import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb;
import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
import org.whispersystems.textsecuregcm.storage.UsernameNotAvailableException;
import org.whispersystems.textsecuregcm.storage.UsernameHashNotAvailableException;
import org.whispersystems.textsecuregcm.storage.UsernameReservationNotFoundException;
import org.whispersystems.textsecuregcm.storage.VerificationCodeStore;
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
@@ -65,18 +67,18 @@ public class AssignUsernameCommand extends EnvironmentCommand<WhisperServerConfi
public void run(WhisperServerConfiguration configuration, Environment environment) {
}
}, "assign-username", "assign a username to an account");
}, "assign-username-hash", "assign a username hash to an account");
}
@Override
public void configure(Subparser subparser) {
super.configure(subparser);
subparser.addArgument("-n", "--nickname")
.dest("nickname")
subparser.addArgument("-u", "--usernameHash")
.dest("usernameHash")
.type(String.class)
.required(true)
.help("The nickname (without discriminator) to assign");
.help("The username hash to assign");
subparser.addArgument("-a", "--aci")
.dest("aci")
@@ -192,22 +194,25 @@ public class AssignUsernameCommand extends EnvironmentCommand<WhisperServerConfi
DeletedAccountsManager deletedAccountsManager = new DeletedAccountsManager(deletedAccounts,
deletedAccountsLockDynamoDbClient,
configuration.getDynamoDbTables().getDeletedAccountsLock().getTableName());
UsernameGenerator usernameGenerator = new UsernameGenerator(configuration.getUsername());
StoredVerificationCodeManager pendingAccountsManager = new StoredVerificationCodeManager(pendingAccounts);
AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster,
deletedAccountsManager, directoryQueue, keys, messagesManager, prohibitedUsernames, profilesManager,
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, usernameGenerator,
deletedAccountsManager, directoryQueue, keys, messagesManager, profilesManager,
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager,
experimentEnrollmentManager, Clock.systemUTC());
final String nickname = namespace.getString("nickname");
final String usernameHash = namespace.getString("usernameHash");
final UUID accountIdentifier = UUID.fromString(namespace.getString("aci"));
accountsManager.getByAccountIdentifier(accountIdentifier).ifPresentOrElse(account -> {
try {
final Account result = accountsManager.setUsername(account, nickname, null);
System.out.println("New username: " + result.getUsername());
} catch (final UsernameNotAvailableException e) {
throw new IllegalArgumentException("Username already taken");
final AccountsManager.UsernameReservation reservation = accountsManager.reserveUsernameHash(account,
List.of(Base64.getUrlDecoder().decode(usernameHash)));
final Account result = accountsManager.confirmReservedUsernameHash(account, Base64.getUrlDecoder().decode(usernameHash));
System.out.println("New username hash: " + usernameHash);
} catch (final UsernameHashNotAvailableException e) {
throw new IllegalArgumentException("Username hash already taken");
} catch (final UsernameReservationNotFoundException e) {
throw new IllegalArgumentException("Username hash reservation not found");
}
},
() -> {

View File

@@ -55,7 +55,6 @@ import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
import org.whispersystems.textsecuregcm.storage.VerificationCodeStore;
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
@@ -195,10 +194,9 @@ public class DeleteUserCommand extends EnvironmentCommand<WhisperServerConfigura
deletedAccountsLockDynamoDbClient,
configuration.getDynamoDbTables().getDeletedAccountsLock().getTableName());
StoredVerificationCodeManager pendingAccountsManager = new StoredVerificationCodeManager(pendingAccounts);
UsernameGenerator usernameGenerator = new UsernameGenerator(configuration.getUsername());
AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster,
deletedAccountsManager, directoryQueue, keys, messagesManager, prohibitedUsernames, profilesManager,
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, usernameGenerator,
deletedAccountsManager, directoryQueue, keys, messagesManager, profilesManager,
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager,
experimentEnrollmentManager, clock);
for (String user : users) {

View File

@@ -53,7 +53,6 @@ import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
import org.whispersystems.textsecuregcm.storage.VerificationCodeStore;
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
@@ -196,10 +195,9 @@ public class SetUserDiscoverabilityCommand extends EnvironmentCommand<WhisperSer
deletedAccountsLockDynamoDbClient,
configuration.getDynamoDbTables().getDeletedAccountsLock().getTableName());
StoredVerificationCodeManager pendingAccountsManager = new StoredVerificationCodeManager(pendingAccounts);
UsernameGenerator usernameGenerator = new UsernameGenerator(configuration.getUsername());
AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster,
deletedAccountsManager, directoryQueue, keys, messagesManager, prohibitedUsernames, profilesManager,
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, usernameGenerator,
deletedAccountsManager, directoryQueue, keys, messagesManager, profilesManager,
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager,
experimentEnrollmentManager, clock);
Optional<Account> maybeAccount;