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

@ -173,6 +173,10 @@
"name":"org.signal.libsignal.usernames.CannotBeEmptyException", "name":"org.signal.libsignal.usernames.CannotBeEmptyException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }] "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", "name":"org.signal.libsignal.usernames.NicknameTooLongException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }] "methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]

View file

@ -768,7 +768,7 @@
"allDeclaredFields":true, "allDeclaredFields":true,
"queryAllDeclaredMethods":true, "queryAllDeclaredMethods":true,
"queryAllDeclaredConstructors":true, "queryAllDeclaredConstructors":true,
"methods":[{"name":"username","parameterTypes":[] }] "methods":[{"name":"username","parameterTypes":[] }, {"name":"usernameLink","parameterTypes":[] }]
}, },
{ {
"name":"org.asamk.signal.commands.VerifyCommand$VerifyParams", "name":"org.asamk.signal.commands.VerifyCommand$VerifyParams",
@ -2594,6 +2594,20 @@
"name":"org.whispersystems.signalservice.internal.push.SenderCertificate$ByteArrayDesieralizer", "name":"org.whispersystems.signalservice.internal.push.SenderCertificate$ByteArrayDesieralizer",
"methods":[{"name":"<init>","parameterTypes":[] }] "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", "name":"org.whispersystems.signalservice.internal.push.StaleDevices",
"allDeclaredFields":true, "allDeclaredFields":true,

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.UpdateGroup;
import org.asamk.signal.manager.api.UpdateProfile; import org.asamk.signal.manager.api.UpdateProfile;
import org.asamk.signal.manager.api.UserStatus; import org.asamk.signal.manager.api.UserStatus;
import org.asamk.signal.manager.api.UsernameLinkUrl;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.util.PhoneNumberFormatter; import org.whispersystems.signalservice.api.util.PhoneNumberFormatter;
@ -100,11 +101,15 @@ public interface Manager extends Closeable {
*/ */
void updateProfile(UpdateProfile updateProfile) throws IOException; void updateProfile(UpdateProfile updateProfile) throws IOException;
String getUsername();
UsernameLinkUrl getUsernameLink();
/** /**
* Set a username for the account. * Set a username for the account.
* If the username is null, it will be deleted. * 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. * 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.AlreadyVerifiedException;
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException; import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException;
import org.whispersystems.signalservice.api.push.exceptions.DeprecatedVersionException; 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.api.util.DeviceNameUtil;
import org.whispersystems.signalservice.internal.push.KyberPreKeyEntity; import org.whispersystems.signalservice.internal.push.KyberPreKeyEntity;
import org.whispersystems.signalservice.internal.push.OutgoingPushMessage; import org.whispersystems.signalservice.internal.push.OutgoingPushMessage;
@ -98,6 +101,13 @@ public class AccountHelper {
&& account.getRegistrationLockPin() != null) { && account.getRegistrationLockPin() != null) {
migrateRegistrationPin(); 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) { } catch (DeprecatedVersionException e) {
logger.debug("Signal-Server returned deprecated version exception", e); logger.debug("Signal-Server returned deprecated version exception", e);
throw e; throw e;
@ -305,13 +315,13 @@ public class AccountHelper {
public static final int USERNAME_MIN_LENGTH = 3; public static final int USERNAME_MIN_LENGTH = 3;
public static final int USERNAME_MAX_LENGTH = 32; 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(); final var currentUsername = account.getUsername();
if (currentUsername != null) { if (currentUsername != null) {
final var currentNickname = currentUsername.substring(0, currentUsername.indexOf('.')); final var currentNickname = currentUsername.substring(0, currentUsername.indexOf('.'));
if (currentNickname.equals(nickname)) { if (currentNickname.equals(nickname)) {
refreshCurrentUsername(); refreshCurrentUsername();
return currentUsername; return;
} }
} }
@ -329,14 +339,13 @@ public class AccountHelper {
} }
logger.debug("[reserveUsername] Successfully reserved username."); logger.debug("[reserveUsername] Successfully reserved username.");
final var username = candidates.get(hashIndex).getUsername(); final var username = candidates.get(hashIndex);
dependencies.getAccountManager().confirmUsername(username, response); dependencies.getAccountManager().confirmUsername(username.getUsername(), response);
account.setUsername(username); account.setUsername(username.getUsername());
account.getRecipientStore().resolveSelfRecipientTrusted(account.getSelfRecipientAddress()); account.getRecipientStore().resolveSelfRecipientTrusted(account.getSelfRecipientAddress());
logger.debug("[confirmUsername] Successfully confirmed username."); logger.debug("[confirmUsername] Successfully confirmed username.");
tryToSetUsernameLink(username);
return username;
} }
public void refreshCurrentUsername() throws IOException, BaseUsernameException { public void refreshCurrentUsername() throws IOException, BaseUsernameException {
@ -348,7 +357,8 @@ public class AccountHelper {
final var whoAmIResponse = dependencies.getAccountManager().getWhoAmI(); final var whoAmIResponse = dependencies.getAccountManager().getWhoAmI();
final var serverUsernameHash = whoAmIResponse.getUsernameHash(); final var serverUsernameHash = whoAmIResponse.getUsernameHash();
final var hasServerUsername = !isEmpty(serverUsernameHash); 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) { if (!hasServerUsername) {
logger.debug("No remote username is set."); logger.debug("No remote username is set.");
@ -360,17 +370,40 @@ public class AccountHelper {
if (!hasServerUsername || !Objects.equals(localUsernameHash, serverUsernameHash)) { if (!hasServerUsername || !Objects.equals(localUsernameHash, serverUsernameHash)) {
logger.debug("Attempting to resynchronize username."); 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 { } else {
logger.debug("Username already set, not refreshing."); logger.debug("Username already set, not refreshing.");
} }
} }
private void tryReserveConfirmUsername(final String username, String localUsernameHash) throws IOException { private void tryReserveConfirmUsername(final Username username) throws IOException {
final var response = dependencies.getAccountManager().reserveUsername(List.of(localUsernameHash)); final var response = dependencies.getAccountManager()
.reserveUsername(List.of(Base64.encodeUrlSafeWithoutPadding(username.getHash())));
logger.debug("[reserveUsername] Successfully reserved existing username."); 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."); 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 { 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.RecipientIdentifier;
import org.asamk.signal.manager.api.UnregisteredRecipientException; 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.config.ServiceEnvironmentConfig;
import org.asamk.signal.manager.internal.SignalDependencies; import org.asamk.signal.manager.internal.SignalDependencies;
import org.asamk.signal.manager.storage.SignalAccount; import org.asamk.signal.manager.storage.SignalAccount;
@ -93,10 +94,23 @@ public class RecipientHelper {
} }
}); });
} else if (recipient instanceof RecipientIdentifier.Username usernameRecipient) { } else if (recipient instanceof RecipientIdentifier.Username usernameRecipient) {
final var username = usernameRecipient.username(); var username = usernameRecipient.username();
return account.getRecipientStore().resolveRecipientByUsername(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 { try {
return getRegisteredUserByUsername(username); return getRegisteredUserByUsername(finalUsername);
} catch (Exception e) { } catch (Exception e) {
return null; 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.UpdateGroup;
import org.asamk.signal.manager.api.UpdateProfile; import org.asamk.signal.manager.api.UpdateProfile;
import org.asamk.signal.manager.api.UserStatus; 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.config.ServiceEnvironmentConfig;
import org.asamk.signal.manager.helper.AccountFileUpdater; import org.asamk.signal.manager.helper.AccountFileUpdater;
import org.asamk.signal.manager.helper.Context; import org.asamk.signal.manager.helper.Context;
@ -332,9 +333,19 @@ public class ManagerImpl implements Manager {
} }
@Override @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 { try {
return context.getAccountHelper().reserveUsername(username); context.getAccountHelper().reserveUsername(username);
} catch (BaseUsernameException e) { } catch (BaseUsernameException e) {
throw new InvalidUsernameException(e.getMessage() + " (" + e.getClass().getSimpleName() + ")", 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.ServiceId.PNI;
import org.whispersystems.signalservice.api.push.ServiceIdType; import org.whispersystems.signalservice.api.push.ServiceIdType;
import org.whispersystems.signalservice.api.push.SignalServiceAddress; 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.SignalStorageManifest;
import org.whispersystems.signalservice.api.storage.StorageKey; import org.whispersystems.signalservice.api.storage.StorageKey;
import org.whispersystems.signalservice.api.util.CredentialsProvider; import org.whispersystems.signalservice.api.util.CredentialsProvider;
@ -129,6 +130,7 @@ public class SignalAccount implements Closeable {
private ServiceEnvironment serviceEnvironment; private ServiceEnvironment serviceEnvironment;
private String number; private String number;
private String username; private String username;
private UsernameLinkComponents usernameLink;
private String encryptedDeviceName; private String encryptedDeviceName;
private int deviceId = 0; private int deviceId = 0;
private String password; private String password;
@ -1278,6 +1280,14 @@ public class SignalAccount implements Closeable {
save(); save();
} }
public UsernameLinkComponents getUsernameLink() {
return usernameLink;
}
public void setUsernameLink(final UsernameLinkComponents usernameLink) {
this.usernameLink = usernameLink;
}
public ServiceEnvironment getServiceEnvironment() { public ServiceEnvironment getServiceEnvironment() {
return serviceEnvironment; return serviceEnvironment;
} }

View file

@ -49,10 +49,15 @@ public class UpdateAccountCommand implements JsonRpcLocalCommand {
var username = ns.getString("username"); var username = ns.getString("username");
if (username != null) { if (username != null) {
try { try {
final var newUsername = m.setUsername(username); m.setUsername(username);
final var newUsername = m.getUsername();
final var newUsernameLink = m.getUsernameLink();
switch (outputWriter) { switch (outputWriter) {
case PlainTextWriter w -> w.println("Your new username: {}", newUsername); case PlainTextWriter w -> w.println("Your new username: {} ({})",
case JsonWriter w -> w.write(new JsonAccountResponse(newUsername)); newUsername,
newUsernameLink == null ? "-" : newUsernameLink.getUrl());
case JsonWriter w -> w.write(new JsonAccountResponse(newUsername,
newUsernameLink == null ? null : newUsernameLink.getUrl()));
} }
} catch (IOException e) { } catch (IOException e) {
throw new IOErrorException("Failed to set username: " + e.getMessage(), e); throw new IOErrorException("Failed to set username: " + e.getMessage(), e);
@ -72,6 +77,7 @@ public class UpdateAccountCommand implements JsonRpcLocalCommand {
} }
private record JsonAccountResponse( private record JsonAccountResponse(
@JsonInclude(JsonInclude.Include.NON_NULL) String username @JsonInclude(JsonInclude.Include.NON_NULL) String username,
@JsonInclude(JsonInclude.Include.NON_NULL) String usernameLink
) {} ) {}
} }

View file

@ -46,6 +46,7 @@ import org.asamk.signal.manager.api.UnregisteredRecipientException;
import org.asamk.signal.manager.api.UpdateGroup; import org.asamk.signal.manager.api.UpdateGroup;
import org.asamk.signal.manager.api.UpdateProfile; import org.asamk.signal.manager.api.UpdateProfile;
import org.asamk.signal.manager.api.UserStatus; import org.asamk.signal.manager.api.UserStatus;
import org.asamk.signal.manager.api.UsernameLinkUrl;
import org.freedesktop.dbus.DBusMap; import org.freedesktop.dbus.DBusMap;
import org.freedesktop.dbus.DBusPath; import org.freedesktop.dbus.DBusPath;
import org.freedesktop.dbus.connections.impl.DBusConnection; import org.freedesktop.dbus.connections.impl.DBusConnection;
@ -164,7 +165,17 @@ public class DbusManagerImpl implements Manager {
} }
@Override @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(); throw new UnsupportedOperationException();
} }