Implement username links

This commit is contained in:
AsamK 2023-11-16 20:18:15 +01:00
parent 77f284661b
commit 37c65ca6b4
10 changed files with 214 additions and 24 deletions

View file

@ -41,6 +41,7 @@ import org.asamk.signal.manager.api.UnregisteredRecipientException;
import org.asamk.signal.manager.api.UpdateGroup;
import org.asamk.signal.manager.api.UpdateProfile;
import org.asamk.signal.manager.api.UserStatus;
import org.asamk.signal.manager.api.UsernameLinkUrl;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.util.PhoneNumberFormatter;
@ -100,11 +101,15 @@ public interface Manager extends Closeable {
*/
void updateProfile(UpdateProfile updateProfile) throws IOException;
String getUsername();
UsernameLinkUrl getUsernameLink();
/**
* Set a username for the account.
* If the username is null, it will be deleted.
*/
String setUsername(String username) throws IOException, InvalidUsernameException;
void setUsername(String username) throws IOException, InvalidUsernameException;
/**
* Set a username for the account.

View file

@ -0,0 +1,82 @@
package org.asamk.signal.manager.api;
import org.signal.core.util.Base64;
import org.whispersystems.signalservice.api.push.UsernameLinkComponents;
import org.whispersystems.signalservice.api.util.UuidUtil;
import java.io.IOException;
import java.util.Arrays;
import java.util.regex.Pattern;
public final class UsernameLinkUrl {
private static final Pattern URL_REGEX = Pattern.compile("(https://)?signal.me/?#eu/([a-zA-Z0-9+\\-_/]+)");
private static final String BASE_URL = "https://signal.me/#eu/";
private final String url;
private final UsernameLinkComponents usernameLinkComponents;
public static UsernameLinkUrl fromUri(String url) throws InvalidUsernameLinkException {
final var matcher = URL_REGEX.matcher(url);
if (!matcher.matches()) {
throw new InvalidUsernameLinkException("Invalid username link");
}
final var path = matcher.group(2);
final byte[] allBytes;
try {
allBytes = Base64.decode(path);
} catch (IOException e) {
throw new InvalidUsernameLinkException("Invalid base64 encoding");
}
if (allBytes.length != 48) {
throw new InvalidUsernameLinkException("Invalid username link");
}
final var entropy = Arrays.copyOfRange(allBytes, 0, 32);
final var serverId = Arrays.copyOfRange(allBytes, 32, allBytes.length);
final var serverIdUuid = UuidUtil.parseOrNull(serverId);
if (serverIdUuid == null) {
throw new InvalidUsernameLinkException("Invalid serverId");
}
return new UsernameLinkUrl(new UsernameLinkComponents(entropy, serverIdUuid));
}
public UsernameLinkUrl(UsernameLinkComponents usernameLinkComponents) {
this.usernameLinkComponents = usernameLinkComponents;
this.url = createUrl(usernameLinkComponents);
}
private static String createUrl(UsernameLinkComponents usernameLinkComponents) {
final var entropy = usernameLinkComponents.getEntropy();
final var serverId = UuidUtil.toByteArray(usernameLinkComponents.getServerId());
final var combined = new byte[entropy.length + serverId.length];
System.arraycopy(entropy, 0, combined, 0, entropy.length);
System.arraycopy(serverId, 0, combined, entropy.length, serverId.length);
final var base64 = Base64.encodeUrlSafeWithoutPadding(combined);
return BASE_URL + base64;
}
public String getUrl() {
return url;
}
public UsernameLinkComponents getComponents() {
return usernameLinkComponents;
}
public static final class InvalidUsernameLinkException extends Exception {
public InvalidUsernameLinkException(String message) {
super(message);
}
public InvalidUsernameLinkException(Throwable cause) {
super(cause);
}
}
}

View file

@ -33,6 +33,9 @@ import org.whispersystems.signalservice.api.push.SignedPreKeyEntity;
import org.whispersystems.signalservice.api.push.exceptions.AlreadyVerifiedException;
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException;
import org.whispersystems.signalservice.api.push.exceptions.DeprecatedVersionException;
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.api.util.DeviceNameUtil;
import org.whispersystems.signalservice.internal.push.KyberPreKeyEntity;
import org.whispersystems.signalservice.internal.push.OutgoingPushMessage;
@ -98,6 +101,13 @@ public class AccountHelper {
&& account.getRegistrationLockPin() != null) {
migrateRegistrationPin();
}
if (account.getUsername() != null && account.getUsernameLink() == null) {
try {
tryToSetUsernameLink(new Username(account.getUsername()));
} catch (BaseUsernameException e) {
logger.debug("Invalid local username");
}
}
} catch (DeprecatedVersionException e) {
logger.debug("Signal-Server returned deprecated version exception", e);
throw e;
@ -305,13 +315,13 @@ public class AccountHelper {
public static final int USERNAME_MIN_LENGTH = 3;
public static final int USERNAME_MAX_LENGTH = 32;
public String reserveUsername(String nickname) throws IOException, BaseUsernameException {
public void reserveUsername(String nickname) throws IOException, BaseUsernameException {
final var currentUsername = account.getUsername();
if (currentUsername != null) {
final var currentNickname = currentUsername.substring(0, currentUsername.indexOf('.'));
if (currentNickname.equals(nickname)) {
refreshCurrentUsername();
return currentUsername;
return;
}
}
@ -329,14 +339,13 @@ public class AccountHelper {
}
logger.debug("[reserveUsername] Successfully reserved username.");
final var username = candidates.get(hashIndex).getUsername();
final var username = candidates.get(hashIndex);
dependencies.getAccountManager().confirmUsername(username, response);
account.setUsername(username);
dependencies.getAccountManager().confirmUsername(username.getUsername(), response);
account.setUsername(username.getUsername());
account.getRecipientStore().resolveSelfRecipientTrusted(account.getSelfRecipientAddress());
logger.debug("[confirmUsername] Successfully confirmed username.");
return username;
tryToSetUsernameLink(username);
}
public void refreshCurrentUsername() throws IOException, BaseUsernameException {
@ -348,7 +357,8 @@ public class AccountHelper {
final var whoAmIResponse = dependencies.getAccountManager().getWhoAmI();
final var serverUsernameHash = whoAmIResponse.getUsernameHash();
final var hasServerUsername = !isEmpty(serverUsernameHash);
final var localUsernameHash = Base64.encodeUrlSafeWithoutPadding(new Username(localUsername).getHash());
final var username = new Username(localUsername);
final var localUsernameHash = Base64.encodeUrlSafeWithoutPadding(username.getHash());
if (!hasServerUsername) {
logger.debug("No remote username is set.");
@ -360,17 +370,40 @@ public class AccountHelper {
if (!hasServerUsername || !Objects.equals(localUsernameHash, serverUsernameHash)) {
logger.debug("Attempting to resynchronize username.");
tryReserveConfirmUsername(localUsername, localUsernameHash);
try {
tryReserveConfirmUsername(username);
} catch (UsernameMalformedException | UsernameTakenException | UsernameIsNotReservedException e) {
logger.debug("[confirmUsername] Failed to reserve confirm username: {} ({})",
e.getMessage(),
e.getClass().getSimpleName());
account.setUsername(null);
account.setUsernameLink(null);
throw e;
}
} else {
logger.debug("Username already set, not refreshing.");
}
}
private void tryReserveConfirmUsername(final String username, String localUsernameHash) throws IOException {
final var response = dependencies.getAccountManager().reserveUsername(List.of(localUsernameHash));
private void tryReserveConfirmUsername(final Username username) throws IOException {
final var response = dependencies.getAccountManager()
.reserveUsername(List.of(Base64.encodeUrlSafeWithoutPadding(username.getHash())));
logger.debug("[reserveUsername] Successfully reserved existing username.");
dependencies.getAccountManager().confirmUsername(username, response);
dependencies.getAccountManager().confirmUsername(username.getUsername(), response);
logger.debug("[confirmUsername] Successfully confirmed existing username.");
tryToSetUsernameLink(username);
}
private void tryToSetUsernameLink(Username username) {
for (var i = 1; i < 4; i++) {
try {
final var linkComponents = dependencies.getAccountManager().createUsernameLink(username);
account.setUsernameLink(linkComponents);
break;
} catch (IOException e) {
logger.debug("[tryToSetUsernameLink] Failed with IOException on attempt {}/3", i, e);
}
}
}
public void deleteUsername() throws IOException {

View file

@ -2,6 +2,7 @@ package org.asamk.signal.manager.helper;
import org.asamk.signal.manager.api.RecipientIdentifier;
import org.asamk.signal.manager.api.UnregisteredRecipientException;
import org.asamk.signal.manager.api.UsernameLinkUrl;
import org.asamk.signal.manager.config.ServiceEnvironmentConfig;
import org.asamk.signal.manager.internal.SignalDependencies;
import org.asamk.signal.manager.storage.SignalAccount;
@ -93,10 +94,23 @@ public class RecipientHelper {
}
});
} else if (recipient instanceof RecipientIdentifier.Username usernameRecipient) {
final var username = usernameRecipient.username();
return account.getRecipientStore().resolveRecipientByUsername(username, () -> {
var username = usernameRecipient.username();
try {
UsernameLinkUrl usernameLinkUrl = UsernameLinkUrl.fromUri(username);
final var components = usernameLinkUrl.getComponents();
final var encryptedUsername = dependencies.getAccountManager()
.getEncryptedUsernameFromLinkServerId(components.getServerId());
final var link = new Username.UsernameLink(components.getEntropy(), encryptedUsername);
username = Username.fromLink(link).getUsername();
} catch (UsernameLinkUrl.InvalidUsernameLinkException e) {
} catch (IOException | BaseUsernameException e) {
throw new RuntimeException(e);
}
final String finalUsername = username;
return account.getRecipientStore().resolveRecipientByUsername(finalUsername, () -> {
try {
return getRegisteredUserByUsername(username);
return getRegisteredUserByUsername(finalUsername);
} catch (Exception e) {
return null;
}

View file

@ -61,6 +61,7 @@ import org.asamk.signal.manager.api.UnregisteredRecipientException;
import org.asamk.signal.manager.api.UpdateGroup;
import org.asamk.signal.manager.api.UpdateProfile;
import org.asamk.signal.manager.api.UserStatus;
import org.asamk.signal.manager.api.UsernameLinkUrl;
import org.asamk.signal.manager.config.ServiceEnvironmentConfig;
import org.asamk.signal.manager.helper.AccountFileUpdater;
import org.asamk.signal.manager.helper.Context;
@ -332,9 +333,19 @@ public class ManagerImpl implements Manager {
}
@Override
public String setUsername(final String username) throws IOException, InvalidUsernameException {
public String getUsername() {
return account.getUsername();
}
@Override
public UsernameLinkUrl getUsernameLink() {
return new UsernameLinkUrl(account.getUsernameLink());
}
@Override
public void setUsername(final String username) throws IOException, InvalidUsernameException {
try {
return context.getAccountHelper().reserveUsername(username);
context.getAccountHelper().reserveUsername(username);
} catch (BaseUsernameException e) {
throw new InvalidUsernameException(e.getMessage() + " (" + e.getClass().getSimpleName() + ")", e);
}

View file

@ -76,6 +76,7 @@ import org.whispersystems.signalservice.api.push.ServiceId.ACI;
import org.whispersystems.signalservice.api.push.ServiceId.PNI;
import org.whispersystems.signalservice.api.push.ServiceIdType;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.UsernameLinkComponents;
import org.whispersystems.signalservice.api.storage.SignalStorageManifest;
import org.whispersystems.signalservice.api.storage.StorageKey;
import org.whispersystems.signalservice.api.util.CredentialsProvider;
@ -129,6 +130,7 @@ public class SignalAccount implements Closeable {
private ServiceEnvironment serviceEnvironment;
private String number;
private String username;
private UsernameLinkComponents usernameLink;
private String encryptedDeviceName;
private int deviceId = 0;
private String password;
@ -1278,6 +1280,14 @@ public class SignalAccount implements Closeable {
save();
}
public UsernameLinkComponents getUsernameLink() {
return usernameLink;
}
public void setUsernameLink(final UsernameLinkComponents usernameLink) {
this.usernameLink = usernameLink;
}
public ServiceEnvironment getServiceEnvironment() {
return serviceEnvironment;
}