From: AsamK Date: Thu, 16 Nov 2023 19:18:15 +0000 (+0100) Subject: Implement username links X-Git-Tag: v0.13.0~81 X-Git-Url: https://git.nmode.ca/signal-cli/commitdiff_plain/37c65ca6b4d86c918740de323be797bd8246ffc9 Implement username links --- diff --git a/graalvm-config-dir/jni-config.json b/graalvm-config-dir/jni-config.json index a7e63038..5b47a7b6 100644 --- a/graalvm-config-dir/jni-config.json +++ b/graalvm-config-dir/jni-config.json @@ -173,6 +173,10 @@ "name":"org.signal.libsignal.usernames.CannotBeEmptyException", "methods":[{"name":"","parameterTypes":["java.lang.String"] }] }, +{ + "name":"org.signal.libsignal.usernames.MissingSeparatorException", + "methods":[{"name":"","parameterTypes":["java.lang.String"] }] +}, { "name":"org.signal.libsignal.usernames.NicknameTooLongException", "methods":[{"name":"","parameterTypes":["java.lang.String"] }] diff --git a/graalvm-config-dir/reflect-config.json b/graalvm-config-dir/reflect-config.json index 9f2935ce..7bfa2e71 100644 --- a/graalvm-config-dir/reflect-config.json +++ b/graalvm-config-dir/reflect-config.json @@ -768,7 +768,7 @@ "allDeclaredFields":true, "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true, - "methods":[{"name":"username","parameterTypes":[] }] + "methods":[{"name":"username","parameterTypes":[] }, {"name":"usernameLink","parameterTypes":[] }] }, { "name":"org.asamk.signal.commands.VerifyCommand$VerifyParams", @@ -2594,6 +2594,20 @@ "name":"org.whispersystems.signalservice.internal.push.SenderCertificate$ByteArrayDesieralizer", "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"org.whispersystems.signalservice.internal.push.SetUsernameLinkRequestBody", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "methods":[{"name":"","parameterTypes":["java.lang.String"] }, {"name":"getUsernameLinkEncryptedValue","parameterTypes":[] }] +}, +{ + "name":"org.whispersystems.signalservice.internal.push.SetUsernameLinkResponseBody", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "methods":[{"name":"","parameterTypes":["java.util.UUID"] }, {"name":"","parameterTypes":["java.util.UUID","int","kotlin.jvm.internal.DefaultConstructorMarker"] }] +}, { "name":"org.whispersystems.signalservice.internal.push.StaleDevices", "allDeclaredFields":true, diff --git a/lib/src/main/java/org/asamk/signal/manager/Manager.java b/lib/src/main/java/org/asamk/signal/manager/Manager.java index 6153000b..ac88e2bc 100644 --- a/lib/src/main/java/org/asamk/signal/manager/Manager.java +++ b/lib/src/main/java/org/asamk/signal/manager/Manager.java @@ -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. diff --git a/lib/src/main/java/org/asamk/signal/manager/api/UsernameLinkUrl.java b/lib/src/main/java/org/asamk/signal/manager/api/UsernameLinkUrl.java new file mode 100644 index 00000000..f8fff6eb --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/api/UsernameLinkUrl.java @@ -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); + } + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/AccountHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/AccountHelper.java index d56b2379..32642f28 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/AccountHelper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/AccountHelper.java @@ -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 { diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/RecipientHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/RecipientHelper.java index 2ab17768..6bddde84 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/RecipientHelper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/RecipientHelper.java @@ -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; } diff --git a/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java b/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java index d4d0b10a..cfa54cde 100644 --- a/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java +++ b/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java @@ -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); } diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java b/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java index cd6720d3..5a6738d1 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java @@ -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; } diff --git a/src/main/java/org/asamk/signal/commands/UpdateAccountCommand.java b/src/main/java/org/asamk/signal/commands/UpdateAccountCommand.java index d430f49f..c729a39a 100644 --- a/src/main/java/org/asamk/signal/commands/UpdateAccountCommand.java +++ b/src/main/java/org/asamk/signal/commands/UpdateAccountCommand.java @@ -49,10 +49,15 @@ public class UpdateAccountCommand implements JsonRpcLocalCommand { 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); @@ -72,6 +77,7 @@ public class UpdateAccountCommand implements JsonRpcLocalCommand { } private record JsonAccountResponse( - @JsonInclude(JsonInclude.Include.NON_NULL) String username + @JsonInclude(JsonInclude.Include.NON_NULL) String username, + @JsonInclude(JsonInclude.Include.NON_NULL) String usernameLink ) {} } diff --git a/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java b/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java index a3f18393..985e2674 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java @@ -46,6 +46,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.freedesktop.dbus.DBusMap; import org.freedesktop.dbus.DBusPath; import org.freedesktop.dbus.connections.impl.DBusConnection; @@ -164,7 +165,17 @@ public class DbusManagerImpl implements Manager { } @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(); }