diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
index 6a5c9cbb8c..12d1a43375 100644
--- a/.idea/codeStyles/Project.xml
+++ b/.idea/codeStyles/Project.xml
@@ -41,13 +41,6 @@
-
-
-
-
-
diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java
index 9dfdac71b7..aef468bba0 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java
@@ -141,9 +141,6 @@ public class RefreshOwnProfileJob extends BaseJob {
profileAndCredential.getExpiringProfileKeyCredential()
.ifPresent(expiringProfileKeyCredential -> setExpiringProfileKeyCredential(self, ProfileKeyUtil.getSelfProfileKey(), expiringProfileKeyCredential));
- String username = ApplicationDependencies.getSignalServiceAccountManager().getWhoAmI().getUsername();
- SignalDatabase.recipients().setUsername(Recipient.self().getId(), username);
-
StoryOnboardingDownloadJob.Companion.enqueueIfNeeded();
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileFragment.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileFragment.java
index 5c0ec56519..bed2ec3f24 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileFragment.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileFragment.java
@@ -24,6 +24,8 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.google.android.material.snackbar.Snackbar;
import org.signal.core.util.logging.Log;
+import org.signal.libsignal.usernames.BaseUsernameException;
+import org.signal.libsignal.usernames.Username;
import org.thoughtcrime.securesms.AvatarPreviewActivity;
import org.thoughtcrime.securesms.LoggingFragment;
import org.thoughtcrime.securesms.R;
@@ -44,6 +46,7 @@ import org.thoughtcrime.securesms.util.NameUtil;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import org.thoughtcrime.securesms.util.navigation.SafeNavigation;
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
+import org.whispersystems.util.Base64UrlSafe;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
@@ -242,13 +245,14 @@ public class ManageProfileFragment extends LoggingFragment {
private void presentUsername(@Nullable String username) {
if (username == null || username.isEmpty()) {
binding.manageProfileUsername.setText(R.string.ManageProfileFragment_username);
+ binding.manageProfileUsernameSubtitle.setText(R.string.ManageProfileFragment_your_username);
binding.manageProfileUsernameShare.setVisibility(View.GONE);
} else {
binding.manageProfileUsername.setText(username);
try {
- binding.manageProfileUsernameSubtitle.setText(getString(R.string.signal_me_username_url_no_scheme, URLEncoder.encode(username, StandardCharsets.UTF_8.toString())));
- } catch (UnsupportedEncodingException e) {
+ binding.manageProfileUsernameSubtitle.setText(getString(R.string.signal_me_username_url_no_scheme, Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username))));
+ } catch (BaseUsernameException e) {
Log.w(TAG, "Could not format username link", e);
binding.manageProfileUsernameSubtitle.setText(R.string.ManageProfileFragment_your_username);
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditRepository.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditRepository.java
index c567e6622f..ef5aaeb18b 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditRepository.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditRepository.java
@@ -5,17 +5,22 @@ import androidx.annotation.WorkerThread;
import org.signal.core.util.Result;
import org.signal.core.util.logging.Log;
+import org.signal.libsignal.usernames.BaseUsernameException;
+import org.signal.libsignal.usernames.Username;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
-import org.thoughtcrime.securesms.jobs.MultiDeviceProfileContentUpdateJob;
import org.thoughtcrime.securesms.recipients.Recipient;
+import org.thoughtcrime.securesms.util.UsernameUtil;
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
import org.whispersystems.signalservice.api.push.exceptions.UsernameIsNotReservedException;
import org.whispersystems.signalservice.api.push.exceptions.UsernameMalformedException;
import org.whispersystems.signalservice.api.push.exceptions.UsernameTakenException;
import org.whispersystems.signalservice.internal.push.ReserveUsernameResponse;
+import org.whispersystems.util.Base64UrlSafe;
import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers;
@@ -30,12 +35,12 @@ class UsernameEditRepository {
this.accountManager = ApplicationDependencies.getSignalServiceAccountManager();
}
- @NonNull Single> reserveUsername(@NonNull String nickname) {
+ @NonNull Single> reserveUsername(@NonNull String nickname) {
return Single.fromCallable(() -> reserveUsernameInternal(nickname)).subscribeOn(Schedulers.io());
}
- @NonNull Single confirmUsername(@NonNull ReserveUsernameResponse reserveUsernameResponse) {
- return Single.fromCallable(() -> confirmUsernameInternal(reserveUsernameResponse)).subscribeOn(Schedulers.io());
+ @NonNull Single confirmUsername(@NonNull UsernameState.Reserved reserved) {
+ return Single.fromCallable(() -> confirmUsernameInternal(reserved)).subscribeOn(Schedulers.io());
}
@NonNull Single deleteUsername() {
@@ -43,11 +48,28 @@ class UsernameEditRepository {
}
@WorkerThread
- private @NonNull Result reserveUsernameInternal(@NonNull String nickname) {
+ private @NonNull Result reserveUsernameInternal(@NonNull String nickname) {
try {
- ReserveUsernameResponse username = accountManager.reserveUsername(nickname);
+ List candidates = Username.generateCandidates(nickname, UsernameUtil.MIN_LENGTH, UsernameUtil.MAX_LENGTH);
+ List hashes = new ArrayList<>();
+
+ for (String candidate : candidates) {
+ byte[] hash = Username.hash(candidate);
+ hashes.add(Base64UrlSafe.encodeBytesWithoutPadding(hash));
+ }
+
+ ReserveUsernameResponse response = accountManager.reserveUsername(hashes);
+ int hashIndex = hashes.indexOf(response.getUsernameHash());
+ if (hashIndex == -1) {
+ Log.w(TAG, "[reserveUsername] The response hash could not be found in our set of hashes.");
+ return Result.failure(UsernameSetResult.CANDIDATE_GENERATION_ERROR);
+ }
+
Log.i(TAG, "[reserveUsername] Successfully reserved username.");
- return Result.success(username);
+ return Result.success(new UsernameState.Reserved(candidates.get(hashIndex), response));
+ } catch (BaseUsernameException e) {
+ Log.w(TAG, "[reserveUsername] An error occurred while generating candidates.");
+ return Result.failure(UsernameSetResult.CANDIDATE_GENERATION_ERROR);
} catch (UsernameTakenException e) {
Log.w(TAG, "[reserveUsername] Username taken.");
return Result.failure(UsernameSetResult.USERNAME_UNAVAILABLE);
@@ -61,11 +83,10 @@ class UsernameEditRepository {
}
@WorkerThread
- private @NonNull UsernameSetResult confirmUsernameInternal(@NonNull ReserveUsernameResponse reserveUsernameResponse) {
+ private @NonNull UsernameSetResult confirmUsernameInternal(@NonNull UsernameState.Reserved reserved) {
try {
- accountManager.confirmUsername(reserveUsernameResponse);
- SignalDatabase.recipients().setUsername(Recipient.self().getId(), reserveUsernameResponse.getUsername());
- ApplicationDependencies.getJobManager().add(new MultiDeviceProfileContentUpdateJob());
+ accountManager.confirmUsername(reserved.getUsername(), reserved.getReserveUsernameResponse());
+ SignalDatabase.recipients().setUsername(Recipient.self().getId(), reserved.getUsername());
Log.i(TAG, "[confirmUsername] Successfully reserved username.");
return UsernameSetResult.SUCCESS;
} catch (UsernameTakenException e) {
@@ -94,7 +115,7 @@ class UsernameEditRepository {
}
enum UsernameSetResult {
- SUCCESS, USERNAME_UNAVAILABLE, USERNAME_INVALID, NETWORK_ERROR
+ SUCCESS, USERNAME_UNAVAILABLE, USERNAME_INVALID, NETWORK_ERROR, CANDIDATE_GENERATION_ERROR
}
enum UsernameDeleteResult {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditViewModel.java
index 68a50a55b7..c453756c3f 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditViewModel.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditViewModel.java
@@ -110,7 +110,7 @@ class UsernameEditViewModel extends ViewModel {
uiState.update(state -> new State(ButtonState.SUBMIT_LOADING, UsernameStatus.NONE, state.usernameState));
- Disposable confirmUsernameDisposable = repo.confirmUsername(((UsernameState.Reserved) usernameState).getReserveUsernameResponse())
+ Disposable confirmUsernameDisposable = repo.confirmUsername((UsernameState.Reserved) usernameState)
.subscribe(result -> {
String nickname = usernameState.getNickname();
@@ -181,8 +181,8 @@ class UsernameEditViewModel extends ViewModel {
uiState.update(state -> new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.NONE, UsernameState.Loading.INSTANCE));
Disposable reserveDisposable = repo.reserveUsername(nickname).subscribe(result -> {
result.either(
- reserveUsernameJsonResponse -> {
- uiState.update(state -> new State(ButtonState.SUBMIT, UsernameStatus.NONE, new UsernameState.Reserved(reserveUsernameJsonResponse)));
+ reserved -> {
+ uiState.update(state -> new State(ButtonState.SUBMIT, UsernameStatus.NONE, reserved));
return null;
},
failure -> {
@@ -199,6 +199,10 @@ class UsernameEditViewModel extends ViewModel {
uiState.update(state -> new State(ButtonState.SUBMIT, UsernameStatus.NONE, UsernameState.NoUsername.INSTANCE));
events.onNext(Event.NETWORK_FAILURE);
break;
+ case CANDIDATE_GENERATION_ERROR:
+ // TODO -- Retry
+ uiState.update(state -> new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.TAKEN, UsernameState.NoUsername.INSTANCE));
+ break;
}
return null;
diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameShareBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameShareBottomSheet.kt
index 91c87723b5..1b88b37162 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameShareBottomSheet.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameShareBottomSheet.kt
@@ -9,6 +9,7 @@ import androidx.core.content.ContextCompat
import androidx.fragment.app.setFragmentResult
import androidx.navigation.fragment.findNavController
import org.signal.core.util.DimensionUnit
+import org.signal.libsignal.usernames.Username
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
@@ -19,8 +20,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.FragmentResultContract
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.Util
-import java.net.URLEncoder
-import java.nio.charset.StandardCharsets
+import org.whispersystems.util.Base64UrlSafe
/**
* Allows the user to either share their username directly or to copy it to their clipboard.
@@ -71,7 +71,7 @@ class UsernameShareBottomSheet : DSLSettingsBottomSheetFragment() {
customPref(
CopyButton.Model(
- text = getString(R.string.signal_me_username_url, URLEncoder.encode(username, StandardCharsets.UTF_8.toString())),
+ text = getString(R.string.signal_me_username_url, Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username))),
onClick = {
copyToClipboard(it)
}
@@ -82,7 +82,7 @@ class UsernameShareBottomSheet : DSLSettingsBottomSheetFragment() {
customPref(
ShareButton.Model(
- text = getString(R.string.signal_me_username_url, URLEncoder.encode(username, StandardCharsets.UTF_8.toString())),
+ text = getString(R.string.signal_me_username_url, Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username))),
onClick = {
openShareSheet(it.text)
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameState.kt b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameState.kt
index 1a0c3e3b46..a4b2ebcee7 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameState.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameState.kt
@@ -17,10 +17,9 @@ sealed class UsernameState {
object NoUsername : UsernameState()
data class Reserved(
+ override val username: String,
val reserveUsernameResponse: ReserveUsernameResponse
- ) : UsernameState() {
- override val username: String? = reserveUsernameResponse.username
- }
+ ) : UsernameState()
data class Set(
override val username: String
diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/AccountRecordProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/storage/AccountRecordProcessor.java
index 5113e1f6d7..37016b9fbd 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/storage/AccountRecordProcessor.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/storage/AccountRecordProcessor.java
@@ -125,8 +125,9 @@ public class AccountRecordProcessor extends DefaultStorageRecordProcessor serviceId = UsernameUtil.fetchAciForUsername(username);
+ Optional serviceId = UsernameUtil.fetchAciForUsernameHash(username);
if (serviceId.isPresent()) {
recipient = Recipient.externalUsername(serviceId.get(), username);
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtil.java
index 71f38ca50e..c5ea443867 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtil.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtil.java
@@ -7,13 +7,15 @@ import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import org.signal.core.util.logging.Log;
+import org.signal.libsignal.usernames.BaseUsernameException;
+import org.signal.libsignal.usernames.Username;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
-import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
import org.whispersystems.signalservice.api.push.ACI;
import org.whispersystems.signalservice.api.push.ServiceId;
+import org.whispersystems.util.Base64UrlSafe;
import java.io.IOException;
import java.util.Locale;
@@ -24,10 +26,10 @@ public class UsernameUtil {
private static final String TAG = Log.tag(UsernameUtil.class);
- public static final int MIN_LENGTH = 4;
- public static final int MAX_LENGTH = 26;
+ public static final int MIN_LENGTH = 3;
+ public static final int MAX_LENGTH = 32;
- private static final Pattern FULL_PATTERN = Pattern.compile("^[a-z_][a-z0-9_]{3,25}$", Pattern.CASE_INSENSITIVE);
+ private static final Pattern FULL_PATTERN = Pattern.compile(String.format(Locale.US, "^[a-zA-Z_][a-zA-Z0-9_]{%d,%d}$", MIN_LENGTH - 1, MAX_LENGTH - 1), Pattern.CASE_INSENSITIVE);
private static final Pattern DIGIT_START_PATTERN = Pattern.compile("^[0-9].*$");
public static boolean isValidUsernameForSearch(@Nullable String value) {
@@ -66,9 +68,19 @@ public class UsernameUtil {
}
}
+ Log.d(TAG, "No local user with this username. Searching remotely.");
try {
- Log.d(TAG, "No local user with this username. Searching remotely.");
- ACI aci = ApplicationDependencies.getSignalServiceAccountManager().getAciByUsername(username);
+ return fetchAciForUsernameHash(Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username)));
+ } catch (BaseUsernameException e) {
+ return Optional.empty();
+ }
+ }
+
+ @WorkerThread
+ public static @NonNull Optional fetchAciForUsernameHash(@NonNull String base64UrlSafeEncodedUsernameHash) {
+ try {
+ ACI aci = ApplicationDependencies.getSignalServiceAccountManager()
+ .getAciByUsernameHash(base64UrlSafeEncodedUsernameHash);
return Optional.ofNullable(aci);
} catch (IOException e) {
return Optional.empty();
diff --git a/app/src/main/res/layout/copy_button.xml b/app/src/main/res/layout/copy_button.xml
index 529ee49feb..41ed1db634 100644
--- a/app/src/main/res/layout/copy_button.xml
+++ b/app/src/main/res/layout/copy_button.xml
@@ -7,6 +7,8 @@
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dsl_settings_gutter"
android:layout_marginEnd="@dimen/dsl_settings_gutter"
+ android:ellipsize="end"
+ android:lines="1"
android:minHeight="56dp"
android:paddingHorizontal="16dp"
android:textAlignment="viewStart"
diff --git a/app/src/main/res/layout/manage_profile_fragment.xml b/app/src/main/res/layout/manage_profile_fragment.xml
index 98773e6d81..6531fb9e6e 100644
--- a/app/src/main/res/layout/manage_profile_fragment.xml
+++ b/app/src/main/res/layout/manage_profile_fragment.xml
@@ -186,6 +186,8 @@
style="@style/Signal.Text.Preview"
android:layout_width="0dp"
android:layout_height="wrap_content"
+ android:ellipsize="end"
+ android:lines="1"
android:text="@string/ManageProfileFragment_your_username"
android:textColor="@color/signal_text_secondary"
app:layout_constraintBottom_toBottomOf="parent"
diff --git a/app/src/test/java/org/thoughtcrime/securesms/util/UsernameUtilTest.java b/app/src/test/java/org/thoughtcrime/securesms/util/UsernameUtilTest.java
index 8925473f6d..00f0cce462 100644
--- a/app/src/test/java/org/thoughtcrime/securesms/util/UsernameUtilTest.java
+++ b/app/src/test/java/org/thoughtcrime/securesms/util/UsernameUtilTest.java
@@ -11,12 +11,12 @@ public class UsernameUtilTest {
public void checkUsername_tooShort() {
assertEquals(UsernameUtil.InvalidReason.TOO_SHORT, UsernameUtil.checkUsername(null).get());
assertEquals(UsernameUtil.InvalidReason.TOO_SHORT, UsernameUtil.checkUsername("").get());
- assertEquals(UsernameUtil.InvalidReason.TOO_SHORT, UsernameUtil.checkUsername("abc").get());
+ assertEquals(UsernameUtil.InvalidReason.TOO_SHORT, UsernameUtil.checkUsername("ab").get());
}
@Test
public void checkUsername_tooLong() {
- assertEquals(UsernameUtil.InvalidReason.TOO_LONG, UsernameUtil.checkUsername("abcdefghijklmnopqrstuvwxyz1").get());
+ assertEquals(UsernameUtil.InvalidReason.TOO_LONG, UsernameUtil.checkUsername("abcdefghijklmnopqrstuvwxyz1234567").get());
}
@Test
diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java
index ab87d047ea..5a058dab41 100644
--- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java
+++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java
@@ -20,7 +20,6 @@ import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential;
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.whispersystems.signalservice.api.account.AccountAttributes;
import org.whispersystems.signalservice.api.account.ChangePhoneNumberRequest;
-import org.whispersystems.signalservice.api.crypto.InvalidCiphertextException;
import org.whispersystems.signalservice.api.crypto.ProfileCipher;
import org.whispersystems.signalservice.api.crypto.ProfileCipherOutputStream;
import org.whispersystems.signalservice.api.groupsv2.ClientZkOperations;
@@ -55,19 +54,11 @@ import org.whispersystems.signalservice.api.util.CredentialsProvider;
import org.whispersystems.signalservice.api.util.Preconditions;
import org.whispersystems.signalservice.internal.ServiceResponse;
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration;
-import org.whispersystems.signalservice.internal.contacts.crypto.ContactDiscoveryCipher;
-import org.whispersystems.signalservice.internal.contacts.crypto.Quote;
-import org.whispersystems.signalservice.internal.contacts.crypto.RemoteAttestation;
-import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedQuoteException;
-import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedResponseException;
-import org.whispersystems.signalservice.internal.contacts.entities.DiscoveryRequest;
-import org.whispersystems.signalservice.internal.contacts.entities.DiscoveryResponse;
import org.whispersystems.signalservice.internal.crypto.PrimaryProvisioningCipher;
import org.whispersystems.signalservice.internal.push.AuthCredentials;
import org.whispersystems.signalservice.internal.push.CdsiAuthResponse;
import org.whispersystems.signalservice.internal.push.ProfileAvatarData;
import org.whispersystems.signalservice.internal.push.PushServiceSocket;
-import org.whispersystems.signalservice.internal.push.RemoteAttestationUtil;
import org.whispersystems.signalservice.internal.push.RemoteConfigResponse;
import org.whispersystems.signalservice.internal.push.RequestVerificationCodeResponse;
import org.whispersystems.signalservice.internal.push.ReserveUsernameResponse;
@@ -85,13 +76,10 @@ import org.whispersystems.signalservice.internal.util.StaticCredentialsProvider;
import org.whispersystems.signalservice.internal.util.Util;
import org.whispersystems.util.Base64;
-import java.io.ByteArrayInputStream;
-import java.io.DataInputStream;
import java.io.IOException;
import java.security.KeyStore;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
-import java.security.SignatureException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
@@ -102,7 +90,6 @@ import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
-import java.util.UUID;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
@@ -823,20 +810,16 @@ public class SignalServiceAccountManager {
}
}
- public ACI getAciByUsername(String username) throws IOException {
- return this.pushServiceSocket.getAciByUsername(username);
+ public ACI getAciByUsernameHash(String usernameHash) throws IOException {
+ return this.pushServiceSocket.getAciByUsernameHash(usernameHash);
}
- public void setUsername(String nickname, String existingUsername) throws IOException {
- this.pushServiceSocket.setUsername(nickname, existingUsername);
+ public ReserveUsernameResponse reserveUsername(List usernameHashes) throws IOException {
+ return this.pushServiceSocket.reserveUsername(usernameHashes);
}
- public ReserveUsernameResponse reserveUsername(String nickname) throws IOException {
- return this.pushServiceSocket.reserveUsername(nickname);
- }
-
- public void confirmUsername(ReserveUsernameResponse reserveUsernameResponse) throws IOException {
- this.pushServiceSocket.confirmUsername(reserveUsernameResponse);
+ public void confirmUsername(String username, ReserveUsernameResponse reserveUsernameResponse) throws IOException {
+ this.pushServiceSocket.confirmUsername(username, reserveUsernameResponse);
}
public void deleteUsername() throws IOException {
diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalAccountRecord.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalAccountRecord.java
index dffa9f0f19..c4cb2f7c03 100644
--- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalAccountRecord.java
+++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalAccountRecord.java
@@ -19,6 +19,8 @@ import java.util.List;
import java.util.Objects;
import java.util.Optional;
+import javax.annotation.Nullable;
+
public final class SignalAccountRecord implements SignalRecord {
private static final String TAG = SignalAccountRecord.class.getSimpleName();
@@ -195,6 +197,10 @@ public final class SignalAccountRecord implements SignalRecord {
diff.add("HasSeenGroupStoryEducationSheet");
}
+ if (!Objects.equals(getUsername(), that.getUsername())) {
+ diff.add("Username");
+ }
+
return diff.toString();
} else {
return "Different class. " + getClass().getSimpleName() + " | " + other.getClass().getSimpleName();
@@ -325,6 +331,10 @@ public final class SignalAccountRecord implements SignalRecord {
return proto.getHasSeenGroupStoryEducationSheet();
}
+ public @Nullable String getUsername() {
+ return proto.getUsername();
+ }
+
public AccountRecord toProto() {
return proto;
}
@@ -697,6 +707,16 @@ public final class SignalAccountRecord implements SignalRecord {
return this;
}
+ public Builder setUsername(@Nullable String username) {
+ if (username == null || username.isEmpty()) {
+ builder.clearUsername();
+ } else {
+ builder.setUsername(username);
+ }
+
+ return this;
+ }
+
private static AccountRecord.Builder parseUnknowns(byte[] serializedUnknowns) {
try {
return AccountRecord.parseFrom(serializedUnknowns).toBuilder();
diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ConfirmUsernameRequest.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ConfirmUsernameRequest.java
index 9e2c2612b2..cd617ace4b 100644
--- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ConfirmUsernameRequest.java
+++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ConfirmUsernameRequest.java
@@ -4,13 +4,13 @@ import com.fasterxml.jackson.annotation.JsonProperty;
class ConfirmUsernameRequest {
@JsonProperty
- private String usernameToConfirm;
+ private String usernameHash;
@JsonProperty
- private String reservationToken;
+ private String zkProof;
- ConfirmUsernameRequest(String usernameToConfirm, String reservationToken) {
- this.usernameToConfirm = usernameToConfirm;
- this.reservationToken = reservationToken;
+ ConfirmUsernameRequest(String usernameHash, String zkProof) {
+ this.usernameHash = usernameHash;
+ this.zkProof = zkProof;
}
}
diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java
index a97eb1fdfd..be7175b174 100644
--- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java
+++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java
@@ -19,6 +19,8 @@ import org.signal.libsignal.protocol.state.PreKeyBundle;
import org.signal.libsignal.protocol.state.PreKeyRecord;
import org.signal.libsignal.protocol.state.SignedPreKeyRecord;
import org.signal.libsignal.protocol.util.Pair;
+import org.signal.libsignal.usernames.BaseUsernameException;
+import org.signal.libsignal.usernames.Username;
import org.signal.libsignal.zkgroup.VerificationFailedException;
import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations;
import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential;
@@ -97,8 +99,6 @@ import org.whispersystems.signalservice.internal.configuration.SignalCdnUrl;
import org.whispersystems.signalservice.internal.configuration.SignalProxy;
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration;
import org.whispersystems.signalservice.internal.configuration.SignalUrl;
-import org.whispersystems.signalservice.internal.contacts.entities.DiscoveryRequest;
-import org.whispersystems.signalservice.internal.contacts.entities.DiscoveryResponse;
import org.whispersystems.signalservice.internal.contacts.entities.KeyBackupRequest;
import org.whispersystems.signalservice.internal.contacts.entities.KeyBackupResponse;
import org.whispersystems.signalservice.internal.contacts.entities.TokenResponse;
@@ -161,7 +161,6 @@ import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import javax.annotation.Nonnull;
-import javax.annotation.Nullable;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
@@ -202,10 +201,10 @@ public class PushServiceSocket {
private static final String REGISTRATION_LOCK_PATH = "/v1/accounts/registration_lock";
private static final String REQUEST_PUSH_CHALLENGE = "/v1/accounts/fcm/preauth/%s/%s";
private static final String WHO_AM_I = "/v1/accounts/whoami";
- private static final String GET_USERNAME_PATH = "/v1/accounts/username/%s";
- private static final String MODIFY_USERNAME_PATH = "/v1/accounts/username";
- private static final String RESERVE_USERNAME_PATH = "/v1/accounts/username/reserved";
- private static final String CONFIRM_USERNAME_PATH = "/v1/accounts/username/confirm";
+ private static final String GET_USERNAME_PATH = "/v1/accounts/username_hash/%s";
+ private static final String MODIFY_USERNAME_PATH = "/v1/accounts/username_hash";
+ private static final String RESERVE_USERNAME_PATH = "/v1/accounts/username_hash/reserve";
+ private static final String CONFIRM_USERNAME_PATH = "/v1/accounts/username_hash/confirm";
private static final String DELETE_ACCOUNT_PATH = "/v1/accounts/me";
private static final String CHANGE_NUMBER_PATH = "/v1/accounts/number";
private static final String IDENTIFIER_REGISTERED_PATH = "/v1/accounts/account/%s";
@@ -896,7 +895,9 @@ public class PushServiceSocket {
}
/**
- * Gets the ACI for the given username, if it exists. This is an unauthenticated request.
+ * GET /v1/accounts/username_hash/{usernameHash}
+ *
+ * Gets the ACI for the given username hash, if it exists. This is an unauthenticated request.
*
* This network request can have the following error responses:
*
@@ -905,13 +906,13 @@ public class PushServiceSocket {
*
400 - Bad Request. The request included authentication.
*
*
- * @param username The username to look up.
+ * @param usernameHash The usernameHash to look up.
* @return The ACI for the given username if it exists.
* @throws IOException if a network exception occurs.
*/
- public @NonNull ACI getAciByUsername(String username) throws IOException {
+ public @NonNull ACI getAciByUsernameHash(String usernameHash) throws IOException {
String response = makeServiceRequestWithoutAuthentication(
- String.format(GET_USERNAME_PATH, URLEncoder.encode(username, StandardCharsets.UTF_8.toString())),
+ String.format(GET_USERNAME_PATH, URLEncoder.encode(usernameHash, StandardCharsets.UTF_8.toString())),
"GET",
null,
NO_HEADERS,
@@ -927,38 +928,16 @@ public class PushServiceSocket {
}
/**
- * Set the username for the account without seeing the discriminator first.
- *
- * @param nickname The user-supplied nickname, which must meet the requirements for usernames.
- * @param existingUsername (Optional) If the account has a current username, indicates what the client thinks the current username is. Allows the server to
- * deduplicate repeated requests.
- * @return The username as set by the server, which includes both the nickname and discriminator.
- * @throws IOException Thrown when the username is invalid or taken, or when another network error occurs.
- */
- public @NonNull String setUsername(@NonNull String nickname, @Nullable String existingUsername) throws IOException {
- SetUsernameRequest setUsernameRequest = new SetUsernameRequest(nickname, existingUsername);
-
- String responseString = makeServiceRequest(MODIFY_USERNAME_PATH, "PUT", JsonUtil.toJson(setUsernameRequest), NO_HEADERS, (responseCode, body) -> {
- switch (responseCode) {
- case 422: throw new UsernameMalformedException();
- case 409: throw new UsernameTakenException();
- }
- }, Optional.empty());
-
- SetUsernameResponse response = JsonUtil.fromJsonResponse(responseString, SetUsernameResponse.class);
- return response.getUsername();
- }
-
- /**
+ * PUT /v1/accounts/username_hash/reserve
* Reserve a username for the account. This replaces an existing reservation if one exists. The username is guaranteed to be available for 5 minutes and can
* be confirmed with confirmUsername.
*
- * @param nickname The user-supplied nickname, which must meet the requirements for usernames.
+ * @param usernameHashes A list of hashed usernames encoded as web-safe base64 strings without padding. The list will have a max length of 20, and each hash will be 32 bytes.
* @return The reserved username. It is available for confirmation for 5 minutes.
* @throws IOException Thrown when the username is invalid or taken, or when another network error occurs.
*/
- public @NonNull ReserveUsernameResponse reserveUsername(@NonNull String nickname) throws IOException {
- ReserveUsernameRequest reserveUsernameRequest = new ReserveUsernameRequest(nickname);
+ public @NonNull ReserveUsernameResponse reserveUsername(@NonNull List usernameHashes) throws IOException {
+ ReserveUsernameRequest reserveUsernameRequest = new ReserveUsernameRequest(usernameHashes);
String responseString = makeServiceRequest(RESERVE_USERNAME_PATH, "PUT", JsonUtil.toJson(reserveUsernameRequest), NO_HEADERS, (responseCode, body) -> {
switch (responseCode) {
@@ -971,20 +950,33 @@ public class PushServiceSocket {
}
/**
+ * PUT /v1/accounts/username_hash/confirm
* Set a previously reserved username for the account.
*
+ * @param username The username the user wishes to confirm. For example, myusername.27
* @param reserveUsernameResponse The response object from the reservation
- * @throws IOException Thrown when the username is invalid or taken, or when another network error occurs.
+ * @throws IOException Thrown when the username is invalid or taken, or when another network error occurs.
*/
- public void confirmUsername(ReserveUsernameResponse reserveUsernameResponse) throws IOException {
- ConfirmUsernameRequest confirmUsernameRequest = new ConfirmUsernameRequest(reserveUsernameResponse.getUsername(), reserveUsernameResponse.getReservationToken());
+ public void confirmUsername(String username, ReserveUsernameResponse reserveUsernameResponse) throws IOException {
+ try {
+ byte[] randomness = new byte[32];
+ random.nextBytes(randomness);
- makeServiceRequest(CONFIRM_USERNAME_PATH, "PUT", JsonUtil.toJson(confirmUsernameRequest), NO_HEADERS, (responseCode, body) -> {
- switch (responseCode) {
- case 409: throw new UsernameIsNotReservedException();
- case 410: throw new UsernameTakenException();
- }
- }, Optional.empty());
+ byte[] proof = Username.generateProof(username, randomness);
+ ConfirmUsernameRequest confirmUsernameRequest = new ConfirmUsernameRequest(reserveUsernameResponse.getUsernameHash(),
+ Base64UrlSafe.encodeBytesWithoutPadding(proof));
+
+ makeServiceRequest(CONFIRM_USERNAME_PATH, "PUT", JsonUtil.toJson(confirmUsernameRequest), NO_HEADERS, (responseCode, body) -> {
+ switch (responseCode) {
+ case 409:
+ throw new UsernameIsNotReservedException();
+ case 410:
+ throw new UsernameTakenException();
+ }
+ }, Optional.empty());
+ } catch (BaseUsernameException e) {
+ throw new IOException(e);
+ }
}
/**
diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ReserveUsernameRequest.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ReserveUsernameRequest.java
index 8435c8cedd..eec41934ab 100644
--- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ReserveUsernameRequest.java
+++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ReserveUsernameRequest.java
@@ -2,15 +2,18 @@ package org.whispersystems.signalservice.internal.push;
import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.Collections;
+import java.util.List;
+
class ReserveUsernameRequest {
@JsonProperty
- private String nickname;
+ private List usernameHashes;
- ReserveUsernameRequest(String nickname) {
- this.nickname = nickname;
+ ReserveUsernameRequest(List usernameHashes) {
+ this.usernameHashes = Collections.unmodifiableList(usernameHashes);
}
- String getNickname() {
- return nickname;
+ List getUsernameHashes() {
+ return usernameHashes;
}
}
diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ReserveUsernameResponse.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ReserveUsernameResponse.java
index d3d20b12a9..011c0b7268 100644
--- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ReserveUsernameResponse.java
+++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ReserveUsernameResponse.java
@@ -4,26 +4,18 @@ import com.fasterxml.jackson.annotation.JsonProperty;
public class ReserveUsernameResponse {
@JsonProperty
- private String username;
-
- @JsonProperty
- private String reservationToken;
+ private String usernameHash;
ReserveUsernameResponse() {}
/**
* Visible for testing.
*/
- public ReserveUsernameResponse(String username, String reservationToken) {
- this.username = username;
- this.reservationToken = reservationToken;
+ public ReserveUsernameResponse(String usernameHash) {
+ this.usernameHash = usernameHash;
}
- public String getUsername() {
- return username;
- }
-
- String getReservationToken() {
- return reservationToken;
+ public String getUsernameHash() {
+ return usernameHash;
}
}
diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/WhoAmIResponse.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/WhoAmIResponse.java
index af9180b885..2920ccd728 100644
--- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/WhoAmIResponse.java
+++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/WhoAmIResponse.java
@@ -13,7 +13,7 @@ public class WhoAmIResponse {
public String number;
@JsonProperty
- public String username;
+ public String usernameHash;
public String getAci() {
return uuid;
@@ -27,7 +27,7 @@ public class WhoAmIResponse {
return number;
}
- public String getUsername() {
- return username;
+ public String getUsernameHash() {
+ return usernameHash;
}
}
diff --git a/libsignal/service/src/main/proto/StorageService.proto b/libsignal/service/src/main/proto/StorageService.proto
index 80f02efba1..4cf7b47d7e 100644
--- a/libsignal/service/src/main/proto/StorageService.proto
+++ b/libsignal/service/src/main/proto/StorageService.proto
@@ -185,6 +185,7 @@ message AccountRecord {
OptionalBool storyViewReceiptsEnabled = 30;
bool hasReadOnboardingStory = 31;
bool hasSeenGroupStoryEducationSheet = 32;
+ string username = 33;
}
message StoryDistributionListRecord {