"name":"org.signal.libsignal.usernames.CannotBeEmptyException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
+{
+ "name":"org.signal.libsignal.usernames.MissingSeparatorException",
+ "methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
+},
{
"name":"org.signal.libsignal.usernames.NicknameTooLongException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
"allDeclaredFields":true,
"queryAllDeclaredMethods":true,
"queryAllDeclaredConstructors":true,
- "methods":[{"name":"username","parameterTypes":[] }]
+ "methods":[{"name":"username","parameterTypes":[] }, {"name":"usernameLink","parameterTypes":[] }]
},
{
"name":"org.asamk.signal.commands.VerifyCommand$VerifyParams",
"name":"org.whispersystems.signalservice.internal.push.SenderCertificate$ByteArrayDesieralizer",
"methods":[{"name":"<init>","parameterTypes":[] }]
},
+{
+ "name":"org.whispersystems.signalservice.internal.push.SetUsernameLinkRequestBody",
+ "allDeclaredFields":true,
+ "queryAllDeclaredMethods":true,
+ "queryAllDeclaredConstructors":true,
+ "methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }, {"name":"getUsernameLinkEncryptedValue","parameterTypes":[] }]
+},
+{
+ "name":"org.whispersystems.signalservice.internal.push.SetUsernameLinkResponseBody",
+ "allDeclaredFields":true,
+ "queryAllDeclaredMethods":true,
+ "queryAllDeclaredConstructors":true,
+ "methods":[{"name":"<init>","parameterTypes":["java.util.UUID"] }, {"name":"<init>","parameterTypes":["java.util.UUID","int","kotlin.jvm.internal.DefaultConstructorMarker"] }]
+},
{
"name":"org.whispersystems.signalservice.internal.push.StaleDevices",
"allDeclaredFields":true,
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;
*/
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.
--- /dev/null
+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);
+ }
+ }
+}
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;
&& 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;
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;
}
}
}
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 {
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.");
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 {
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;
}
});
} 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;
}
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;
}
@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);
}
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;
private ServiceEnvironment serviceEnvironment;
private String number;
private String username;
+ private UsernameLinkComponents usernameLink;
private String encryptedDeviceName;
private int deviceId = 0;
private String password;
save();
}
+ public UsernameLinkComponents getUsernameLink() {
+ return usernameLink;
+ }
+
+ public void setUsernameLink(final UsernameLinkComponents usernameLink) {
+ this.usernameLink = usernameLink;
+ }
+
public ServiceEnvironment getServiceEnvironment() {
return serviceEnvironment;
}
var username = ns.getString("username");
if (username != null) {
try {
- final var newUsername = m.setUsername(username);
+ m.setUsername(username);
+ final var newUsername = m.getUsername();
+ final var newUsernameLink = m.getUsernameLink();
switch (outputWriter) {
- case PlainTextWriter w -> w.println("Your new username: {}", newUsername);
- case JsonWriter w -> w.write(new JsonAccountResponse(newUsername));
+ case PlainTextWriter w -> w.println("Your new username: {} ({})",
+ newUsername,
+ newUsernameLink == null ? "-" : newUsernameLink.getUrl());
+ case JsonWriter w -> w.write(new JsonAccountResponse(newUsername,
+ newUsernameLink == null ? null : newUsernameLink.getUrl()));
}
} catch (IOException e) {
throw new IOErrorException("Failed to set username: " + e.getMessage(), e);
}
private record JsonAccountResponse(
- @JsonInclude(JsonInclude.Include.NON_NULL) String username
+ @JsonInclude(JsonInclude.Include.NON_NULL) String username,
+ @JsonInclude(JsonInclude.Include.NON_NULL) String usernameLink
) {}
}
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.freedesktop.dbus.DBusMap;
import org.freedesktop.dbus.DBusPath;
import org.freedesktop.dbus.connections.impl.DBusConnection;
}
@Override
- public String setUsername(final String username) throws IOException, InvalidUsernameException {
+ public String getUsername() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public UsernameLinkUrl getUsernameLink() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void setUsername(final String username) throws IOException, InvalidUsernameException {
throw new UnsupportedOperationException();
}