Update API endpoints and integration for usernames.

This commit is contained in:
Alex Hart
2023-02-09 13:59:36 -04:00
committed by Greyson Parrelli
parent 803154c544
commit 2c48d40375
22 changed files with 172 additions and 142 deletions

View File

@@ -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<String> 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 {

View File

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

View File

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

View File

@@ -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:
* <ul>
@@ -905,13 +906,13 @@ public class PushServiceSocket {
* <li>400 - Bad Request. The request included authentication.</li>
* </ul>
*
* @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<String> 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);
}
}
/**

View File

@@ -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<String> usernameHashes;
ReserveUsernameRequest(String nickname) {
this.nickname = nickname;
ReserveUsernameRequest(List<String> usernameHashes) {
this.usernameHashes = Collections.unmodifiableList(usernameHashes);
}
String getNickname() {
return nickname;
List<String> getUsernameHashes() {
return usernameHashes;
}
}

View File

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

View File

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

View File

@@ -185,6 +185,7 @@ message AccountRecord {
OptionalBool storyViewReceiptsEnabled = 30;
bool hasReadOnboardingStory = 31;
bool hasSeenGroupStoryEducationSheet = 32;
string username = 33;
}
message StoryDistributionListRecord {