From 9f60ed534a6198b231af521932b959685e0b903b Mon Sep 17 00:00:00 2001 From: AsamK Date: Mon, 3 Apr 2023 19:00:05 +0200 Subject: [PATCH] Implement support for usernames --- graalvm-config-dir/jni-config.json | 16 ++++ graalvm-config-dir/reflect-config.json | 33 ++++++++ .../org/asamk/signal/manager/Manager.java | 13 +++ .../org/asamk/signal/manager/ManagerImpl.java | 27 +++++- .../manager/api/InvalidUsernameException.java | 12 +++ .../signal/manager/api/RecipientAddress.java | 24 ++++-- .../manager/api/RecipientIdentifier.java | 19 +++++ .../signal/manager/helper/AccountHelper.java | 84 +++++++++++++++++++ .../manager/helper/RecipientHelper.java | 40 +++++++-- .../signal/manager/helper/StorageHelper.java | 7 +- .../manager/storage/AccountDatabase.java | 10 ++- .../signal/manager/storage/SignalAccount.java | 16 +++- .../storage/recipients/RecipientAddress.java | 43 +++++++--- .../storage/recipients/RecipientStore.java | 78 ++++++++++++++--- .../recipients/RecipientTrustedResolver.java | 8 ++ .../signal/commands/ListContactsCommand.java | 6 +- .../signal/commands/UpdateAccountCommand.java | 39 +++++++++ .../asamk/signal/dbus/DbusManagerImpl.java | 11 +++ 18 files changed, 440 insertions(+), 46 deletions(-) create mode 100644 lib/src/main/java/org/asamk/signal/manager/api/InvalidUsernameException.java diff --git a/graalvm-config-dir/jni-config.json b/graalvm-config-dir/jni-config.json index 09c4a05a..9f1cab28 100644 --- a/graalvm-config-dir/jni-config.json +++ b/graalvm-config-dir/jni-config.json @@ -195,6 +195,22 @@ { "name":"org.signal.libsignal.protocol.state.SignedPreKeyStore" }, +{ + "name":"org.signal.libsignal.usernames.BadNicknameCharacterException", + "methods":[{"name":"","parameterTypes":["java.lang.String"] }] +}, +{ + "name":"org.signal.libsignal.usernames.CannotBeEmptyException", + "methods":[{"name":"","parameterTypes":["java.lang.String"] }] +}, +{ + "name":"org.signal.libsignal.usernames.NicknameTooLongException", + "methods":[{"name":"","parameterTypes":["java.lang.String"] }] +}, +{ + "name":"org.signal.libsignal.usernames.NicknameTooShortException", + "methods":[{"name":"","parameterTypes":["java.lang.String"] }] +}, { "name":"org.signal.libsignal.zkgroup.InvalidInputException", "methods":[{"name":"","parameterTypes":["java.lang.String"] }] diff --git a/graalvm-config-dir/reflect-config.json b/graalvm-config-dir/reflect-config.json index d34b2243..22b58b56 100644 --- a/graalvm-config-dir/reflect-config.json +++ b/graalvm-config-dir/reflect-config.json @@ -672,6 +672,13 @@ "queryAllDeclaredConstructors":true, "methods":[{"name":"deviceLinkUri","parameterTypes":[] }] }, +{ + "name":"org.asamk.signal.commands.UpdateAccountCommand$JsonAccountResponse", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "methods":[{"name":"username","parameterTypes":[] }] +}, { "name":"org.asamk.signal.commands.VerifyCommand$VerifyParams", "allDeclaredFields":true, @@ -2535,6 +2542,12 @@ "allDeclaredMethods":true, "allDeclaredConstructors":true }, +{ + "name":"org.whispersystems.signalservice.internal.push.ConfirmUsernameRequest", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"org.whispersystems.signalservice.internal.push.DeviceCode", "allDeclaredFields":true, @@ -2553,6 +2566,13 @@ "allDeclaredMethods":true, "allDeclaredConstructors":true }, +{ + "name":"org.whispersystems.signalservice.internal.push.GetAciByUsernameResponse", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"org.whispersystems.signalservice.internal.push.GroupMismatchedDevices", "allDeclaredFields":true, @@ -2699,6 +2719,19 @@ {"name":"getSkipDeviceTransfer","parameterTypes":[] } ] }, +{ + "name":"org.whispersystems.signalservice.internal.push.ReserveUsernameRequest", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"org.whispersystems.signalservice.internal.push.ReserveUsernameResponse", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"org.whispersystems.signalservice.internal.push.SendGroupMessageResponse", "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 d4f8b76f..bcb8f348 100644 --- a/lib/src/main/java/org/asamk/signal/manager/Manager.java +++ b/lib/src/main/java/org/asamk/signal/manager/Manager.java @@ -9,6 +9,7 @@ import org.asamk.signal.manager.api.Identity; import org.asamk.signal.manager.api.InactiveGroupLinkException; import org.asamk.signal.manager.api.InvalidDeviceLinkException; import org.asamk.signal.manager.api.InvalidStickerException; +import org.asamk.signal.manager.api.InvalidUsernameException; import org.asamk.signal.manager.api.Message; import org.asamk.signal.manager.api.MessageEnvelope; import org.asamk.signal.manager.api.NotPrimaryDeviceException; @@ -77,6 +78,18 @@ public interface Manager extends Closeable { */ void updateProfile(UpdateProfile updateProfile) throws IOException; + /** + * Set a username for the account. + * If the username is null, it will be deleted. + */ + String setUsername(String username) throws IOException, InvalidUsernameException; + + /** + * Set a username for the account. + * If the username is null, it will be deleted. + */ + void deleteUsername() throws IOException; + void unregister() throws IOException; void deleteAccount() throws IOException; diff --git a/lib/src/main/java/org/asamk/signal/manager/ManagerImpl.java b/lib/src/main/java/org/asamk/signal/manager/ManagerImpl.java index 844c3323..451adb59 100644 --- a/lib/src/main/java/org/asamk/signal/manager/ManagerImpl.java +++ b/lib/src/main/java/org/asamk/signal/manager/ManagerImpl.java @@ -25,6 +25,7 @@ import org.asamk.signal.manager.api.Identity; import org.asamk.signal.manager.api.InactiveGroupLinkException; import org.asamk.signal.manager.api.InvalidDeviceLinkException; import org.asamk.signal.manager.api.InvalidStickerException; +import org.asamk.signal.manager.api.InvalidUsernameException; import org.asamk.signal.manager.api.Message; import org.asamk.signal.manager.api.MessageEnvelope; import org.asamk.signal.manager.api.NotPrimaryDeviceException; @@ -65,6 +66,7 @@ import org.asamk.signal.manager.util.AttachmentUtils; import org.asamk.signal.manager.util.KeyUtils; import org.asamk.signal.manager.util.MimeUtils; import org.asamk.signal.manager.util.StickerUtils; +import org.signal.libsignal.usernames.BaseUsernameException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.whispersystems.signalservice.api.SignalSessionLock; @@ -290,6 +292,20 @@ class ManagerImpl implements Manager { context.getSyncHelper().sendSyncFetchProfileMessage(); } + @Override + public String setUsername(final String username) throws IOException, InvalidUsernameException { + try { + return context.getAccountHelper().reserveUsername(username); + } catch (BaseUsernameException e) { + throw new InvalidUsernameException(e.getMessage() + " (" + e.getClass().getSimpleName() + ")", e); + } + } + + @Override + public void deleteUsername() throws IOException { + context.getAccountHelper().deleteUsername(); + } + @Override public void unregister() throws IOException { context.getAccountHelper().unregister(); @@ -737,13 +753,18 @@ class ManagerImpl implements Manager { @Override public void deleteRecipient(final RecipientIdentifier.Single recipient) { - account.removeRecipient(account.getRecipientResolver().resolveRecipient(recipient.getIdentifier())); + final var recipientIdOptional = context.getRecipientHelper().resolveRecipientOptional(recipient); + if (recipientIdOptional.isPresent()) { + account.removeRecipient(recipientIdOptional.get()); + } } @Override public void deleteContact(final RecipientIdentifier.Single recipient) { - account.getContactStore() - .deleteContact(account.getRecipientResolver().resolveRecipient(recipient.getIdentifier())); + final var recipientIdOptional = context.getRecipientHelper().resolveRecipientOptional(recipient); + if (recipientIdOptional.isPresent()) { + account.getContactStore().deleteContact(recipientIdOptional.get()); + } } @Override diff --git a/lib/src/main/java/org/asamk/signal/manager/api/InvalidUsernameException.java b/lib/src/main/java/org/asamk/signal/manager/api/InvalidUsernameException.java new file mode 100644 index 00000000..2d3b58a7 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/api/InvalidUsernameException.java @@ -0,0 +1,12 @@ +package org.asamk.signal.manager.api; + +public class InvalidUsernameException extends Exception { + + public InvalidUsernameException(final String message) { + super(message); + } + + public InvalidUsernameException(final String message, final Throwable cause) { + super(message, cause); + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/api/RecipientAddress.java b/lib/src/main/java/org/asamk/signal/manager/api/RecipientAddress.java index 920eafc5..ee3bded9 100644 --- a/lib/src/main/java/org/asamk/signal/manager/api/RecipientAddress.java +++ b/lib/src/main/java/org/asamk/signal/manager/api/RecipientAddress.java @@ -6,7 +6,7 @@ import org.whispersystems.signalservice.api.push.SignalServiceAddress; import java.util.Optional; import java.util.UUID; -public record RecipientAddress(Optional uuid, Optional number) { +public record RecipientAddress(Optional uuid, Optional number, Optional username) { public static final UUID UNKNOWN_UUID = ServiceId.UNKNOWN.uuid(); @@ -18,21 +18,25 @@ public record RecipientAddress(Optional uuid, Optional number) { */ public RecipientAddress { uuid = uuid.isPresent() && uuid.get().equals(UNKNOWN_UUID) ? Optional.empty() : uuid; - if (uuid.isEmpty() && number.isEmpty()) { + if (uuid.isEmpty() && number.isEmpty() && username.isEmpty()) { throw new AssertionError("Must have either a UUID or E164 number!"); } } public RecipientAddress(UUID uuid, String e164) { - this(Optional.ofNullable(uuid), Optional.ofNullable(e164)); + this(Optional.ofNullable(uuid), Optional.ofNullable(e164), Optional.empty()); + } + + public RecipientAddress(UUID uuid, String e164, String username) { + this(Optional.ofNullable(uuid), Optional.ofNullable(e164), Optional.ofNullable(username)); } public RecipientAddress(SignalServiceAddress address) { - this(Optional.of(address.getServiceId().uuid()), address.getNumber()); + this(Optional.of(address.getServiceId().uuid()), address.getNumber(), Optional.empty()); } public RecipientAddress(UUID uuid) { - this(Optional.of(uuid), Optional.empty()); + this(Optional.of(uuid), Optional.empty(), Optional.empty()); } public ServiceId getServiceId() { @@ -44,6 +48,8 @@ public record RecipientAddress(Optional uuid, Optional number) { return uuid.get().toString(); } else if (number.isPresent()) { return number.get(); + } else if (username.isPresent()) { + return username.get(); } else { throw new AssertionError("Given the checks in the constructor, this should not be possible."); } @@ -54,14 +60,16 @@ public record RecipientAddress(Optional uuid, Optional number) { return number.get(); } else if (uuid.isPresent()) { return uuid.get().toString(); + } else if (username.isPresent()) { + return username.get(); } else { throw new AssertionError("Given the checks in the constructor, this should not be possible."); } } public boolean matches(RecipientAddress other) { - return (uuid.isPresent() && other.uuid.isPresent() && uuid.get().equals(other.uuid.get())) || ( - number.isPresent() && other.number.isPresent() && number.get().equals(other.number.get()) - ); + return (uuid.isPresent() && other.uuid.isPresent() && uuid.get().equals(other.uuid.get())) + || (number.isPresent() && other.number.isPresent() && number.get().equals(other.number.get())) + || (username.isPresent() && other.username.isPresent() && username.get().equals(other.username.get())); } } diff --git a/lib/src/main/java/org/asamk/signal/manager/api/RecipientIdentifier.java b/lib/src/main/java/org/asamk/signal/manager/api/RecipientIdentifier.java index 70a60d47..d8952ec0 100644 --- a/lib/src/main/java/org/asamk/signal/manager/api/RecipientIdentifier.java +++ b/lib/src/main/java/org/asamk/signal/manager/api/RecipientIdentifier.java @@ -30,6 +30,10 @@ public sealed interface RecipientIdentifier { return new Uuid(UUID.fromString(identifier)); } + if (identifier.startsWith("u:")) { + return new Username(identifier.substring(2)); + } + final var normalizedNumber = PhoneNumberFormatter.formatNumber(identifier, localNumber); if (!normalizedNumber.equals(identifier)) { final Logger logger = LoggerFactory.getLogger(RecipientIdentifier.class); @@ -46,6 +50,8 @@ public sealed interface RecipientIdentifier { return new Number(address.number().get()); } else if (address.uuid().isPresent()) { return new Uuid(address.uuid().get()); + } else if (address.username().isPresent()) { + return new Username(address.username().get()); } throw new AssertionError("RecipientAddress without identifier"); } @@ -79,6 +85,19 @@ public sealed interface RecipientIdentifier { } } + record Username(String username) implements Single { + + @Override + public String getIdentifier() { + return "u:" + username; + } + + @Override + public RecipientAddress toPartialRecipientAddress() { + return new RecipientAddress(null, null, username); + } + } + record Group(GroupId groupId) implements RecipientIdentifier { @Override 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 2b6c812b..3bff549d 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 @@ -15,6 +15,8 @@ import org.asamk.signal.manager.util.Utils; import org.signal.libsignal.protocol.IdentityKeyPair; import org.signal.libsignal.protocol.InvalidKeyException; import org.signal.libsignal.protocol.state.SignedPreKeyRecord; +import org.signal.libsignal.usernames.BaseUsernameException; +import org.signal.libsignal.usernames.Username; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.whispersystems.signalservice.api.account.ChangePhoneNumberRequest; @@ -27,13 +29,18 @@ import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedE import org.whispersystems.signalservice.api.push.exceptions.DeprecatedVersionException; import org.whispersystems.signalservice.api.util.DeviceNameUtil; import org.whispersystems.signalservice.internal.push.OutgoingPushMessage; +import org.whispersystems.util.Base64UrlSafe; import java.io.IOException; +import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.concurrent.TimeUnit; +import static org.whispersystems.signalservice.internal.util.Util.isEmpty; + public class AccountHelper { private final static Logger logger = LoggerFactory.getLogger(AccountHelper.class); @@ -173,6 +180,83 @@ public class AccountHelper { updateSelfIdentifiers(newNumber, account.getAci(), PNI.parseOrThrow(result.first().getPni())); } + public static final int USERNAME_MIN_LENGTH = 3; + public static final int USERNAME_MAX_LENGTH = 32; + + public String 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; + } + } + + final var candidates = Username.generateCandidates(nickname, USERNAME_MIN_LENGTH, USERNAME_MAX_LENGTH); + final var candidateHashes = new ArrayList(); + for (final var candidate : candidates) { + candidateHashes.add(Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(candidate))); + } + + final var response = dependencies.getAccountManager().reserveUsername(candidateHashes); + final var hashIndex = candidateHashes.indexOf(response.getUsernameHash()); + if (hashIndex == -1) { + logger.warn("[reserveUsername] The response hash could not be found in our set of candidateHashes."); + throw new IOException("Unexpected username response"); + } + + logger.debug("[reserveUsername] Successfully reserved username."); + final var username = candidates.get(hashIndex); + + dependencies.getAccountManager().confirmUsername(username, response); + account.setUsername(username); + account.getRecipientStore().resolveSelfRecipientTrusted(account.getSelfRecipientAddress()); + logger.debug("[confirmUsername] Successfully confirmed username."); + + return username; + } + + public void refreshCurrentUsername() throws IOException, BaseUsernameException { + final var localUsername = account.getUsername(); + if (localUsername == null) { + return; + } + + final var whoAmIResponse = dependencies.getAccountManager().getWhoAmI(); + final var serverUsernameHash = whoAmIResponse.getUsernameHash(); + final var hasServerUsername = !isEmpty(serverUsernameHash); + final var localUsernameHash = Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(localUsername)); + + if (!hasServerUsername) { + logger.debug("No remote username is set."); + } + + if (!Objects.equals(localUsernameHash, serverUsernameHash)) { + logger.debug("Local username hash does not match server username hash."); + } + + if (!hasServerUsername || !Objects.equals(localUsernameHash, serverUsernameHash)) { + logger.debug("Attempting to resynchronize username."); + tryReserveConfirmUsername(localUsername, localUsernameHash); + } 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)); + logger.debug("[reserveUsername] Successfully reserved existing username."); + dependencies.getAccountManager().confirmUsername(username, response); + logger.debug("[confirmUsername] Successfully confirmed existing username."); + } + + public void deleteUsername() throws IOException { + dependencies.getAccountManager().deleteUsername(); + account.setUsername(null); + logger.debug("[deleteUsername] Successfully deleted the username."); + } + public void setDeviceName(String deviceName) { final var privateKey = account.getAciIdentityKeyPair().getPrivateKey(); final var encryptedDeviceName = DeviceNameUtil.encryptDeviceName(deviceName, privateKey); 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 dc442f0e..9243ceba 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 @@ -6,6 +6,8 @@ import org.asamk.signal.manager.api.UnregisteredRecipientException; import org.asamk.signal.manager.config.ServiceEnvironmentConfig; import org.asamk.signal.manager.storage.SignalAccount; import org.asamk.signal.manager.storage.recipients.RecipientId; +import org.signal.libsignal.usernames.BaseUsernameException; +import org.signal.libsignal.usernames.Username; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.whispersystems.signalservice.api.push.ACI; @@ -13,6 +15,7 @@ import org.whispersystems.signalservice.api.push.PNI; import org.whispersystems.signalservice.api.push.ServiceId; import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.services.CdsiV2Service; +import org.whispersystems.util.Base64UrlSafe; import java.io.IOException; import java.util.Collection; @@ -47,7 +50,7 @@ public class RecipientHelper { final var number = address.number().get(); final ServiceId serviceId; try { - serviceId = getRegisteredUser(number); + serviceId = getRegisteredUserByNumber(number); } catch (UnregisteredRecipientException e) { logger.warn("Failed to get uuid for e164 number: {}", number); // Return SignalServiceAddress with unknown UUID @@ -78,15 +81,33 @@ public class RecipientHelper { public RecipientId resolveRecipient(final RecipientIdentifier.Single recipient) throws UnregisteredRecipientException { if (recipient instanceof RecipientIdentifier.Uuid uuidRecipient) { return account.getRecipientResolver().resolveRecipient(ServiceId.from(uuidRecipient.uuid())); - } else { - final var number = ((RecipientIdentifier.Number) recipient).number(); - return account.getRecipientStore().resolveRecipient(number, () -> { + } else if (recipient instanceof RecipientIdentifier.Number numberRecipient) { + final var number = numberRecipient.number(); + return account.getRecipientStore().resolveRecipientByNumber(number, () -> { try { - return getRegisteredUser(number); + return getRegisteredUserByNumber(number); } catch (Exception e) { return null; } }); + } else if (recipient instanceof RecipientIdentifier.Username usernameRecipient) { + final var username = usernameRecipient.username(); + return account.getRecipientStore().resolveRecipientByUsername(username, () -> { + try { + return getRegisteredUserByUsername(username); + } catch (Exception e) { + return null; + } + }); + } + throw new AssertionError("Unexpected RecipientIdentifier: " + recipient); + } + + public Optional resolveRecipientOptional(final RecipientIdentifier.Single recipient) { + try { + return Optional.of(resolveRecipient(recipient)); + } catch (UnregisteredRecipientException e) { + return Optional.empty(); } } @@ -96,7 +117,7 @@ public class RecipientHelper { return recipientId; } final var number = address.getNumber().get(); - final var serviceId = getRegisteredUser(number); + final var serviceId = getRegisteredUserByNumber(number); return account.getRecipientTrustedResolver() .resolveRecipientTrusted(new SignalServiceAddress(serviceId, number)); } @@ -111,7 +132,7 @@ public class RecipientHelper { return registeredUsers; } - private ServiceId getRegisteredUser(final String number) throws IOException, UnregisteredRecipientException { + private ServiceId getRegisteredUserByNumber(final String number) throws IOException, UnregisteredRecipientException { final Map aciMap; try { aciMap = getRegisteredUsers(Set.of(number)); @@ -153,8 +174,9 @@ public class RecipientHelper { return registeredUsers; } - private ACI getRegisteredUserByUsername(String username) throws IOException { - return dependencies.getAccountManager().getAciByUsernameHash(username); + private ACI getRegisteredUserByUsername(String username) throws IOException, BaseUsernameException { + return dependencies.getAccountManager() + .getAciByUsernameHash(Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username))); } public record RegisteredUser(Optional aci, Optional pni) { diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/StorageHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/StorageHelper.java index 257cea83..68eab7f0 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/StorageHelper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/StorageHelper.java @@ -102,7 +102,11 @@ public class StorageHelper { final var contactRecord = record.getContact().get(); final var address = new RecipientAddress(contactRecord.getServiceId(), contactRecord.getNumber().orElse(null)); - final var recipientId = account.getRecipientResolver().resolveRecipient(address); + var recipientId = account.getRecipientResolver().resolveRecipient(address); + if (contactRecord.getUsername().isPresent()) { + recipientId = account.getRecipientTrustedResolver() + .resolveRecipientTrusted(contactRecord.getServiceId(), contactRecord.getUsername().get()); + } final var contact = account.getContactStore().getContact(recipientId); final var blocked = contact != null && contact.isBlocked(); @@ -257,6 +261,7 @@ public class StorageHelper { }); } account.getConfigurationStore().setPhoneNumberUnlisted(accountRecord.isPhoneNumberUnlisted()); + account.setUsername(accountRecord.getUsername()); if (accountRecord.getProfileKey().isPresent()) { ProfileKey profileKey; diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/AccountDatabase.java b/lib/src/main/java/org/asamk/signal/manager/storage/AccountDatabase.java index c13be697..c08036be 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/AccountDatabase.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/AccountDatabase.java @@ -22,7 +22,7 @@ import java.sql.SQLException; public class AccountDatabase extends Database { private final static Logger logger = LoggerFactory.getLogger(AccountDatabase.class); - private static final long DATABASE_VERSION = 11; + private static final long DATABASE_VERSION = 12; private AccountDatabase(final HikariDataSource dataSource) { super(logger, DATABASE_VERSION, dataSource); @@ -296,5 +296,13 @@ public class AccountDatabase extends Database { """); } } + if (oldVersion < 12) { + logger.debug("Updating database: Adding username field"); + try (final var statement = connection.createStatement()) { + statement.executeUpdate(""" + ALTER TABLE recipient ADD COLUMN username TEXT; + """); + } + } } } 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 25847c6c..b104eb84 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 @@ -120,6 +120,7 @@ public class SignalAccount implements Closeable { private String accountPath; private ServiceEnvironment serviceEnvironment; private String number; + private String username; private ACI aci; private PNI pni; private String sessionId; @@ -542,6 +543,9 @@ public class SignalAccount implements Closeable { serviceEnvironment = ServiceEnvironment.valueOf(rootNode.get("serviceEnvironment").asText()); } registered = Utils.getNotNullNode(rootNode, "registered").asBoolean(); + if (rootNode.hasNonNull("usernameIdentifier")) { + username = rootNode.get("usernameIdentifier").asText(); + } if (rootNode.hasNonNull("uuid")) { try { aci = ACI.parseOrThrow(rootNode.get("uuid").asText()); @@ -935,6 +939,7 @@ public class SignalAccount implements Closeable { rootNode.put("version", CURRENT_STORAGE_VERSION) .put("username", number) .put("serviceEnvironment", serviceEnvironment == null ? null : serviceEnvironment.name()) + .put("usernameIdentifier", username) .put("uuid", aci == null ? null : aci.toString()) .put("pni", pni == null ? null : pni.toString()) .put("sessionId", sessionId) @@ -1297,6 +1302,15 @@ public class SignalAccount implements Closeable { save(); } + public String getUsername() { + return username; + } + + public void setUsername(final String username) { + this.username = username; + save(); + } + public ServiceEnvironment getServiceEnvironment() { return serviceEnvironment; } @@ -1368,7 +1382,7 @@ public class SignalAccount implements Closeable { } public RecipientAddress getSelfRecipientAddress() { - return new RecipientAddress(aci, pni, number); + return new RecipientAddress(aci, pni, number, username); } public RecipientId getSelfRecipientId() { diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientAddress.java b/lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientAddress.java index 6f796b4a..d94a93a3 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientAddress.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientAddress.java @@ -6,7 +6,9 @@ import org.whispersystems.signalservice.api.push.SignalServiceAddress; import java.util.Optional; -public record RecipientAddress(Optional serviceId, Optional pni, Optional number) { +public record RecipientAddress( + Optional serviceId, Optional pni, Optional number, Optional username +) { /** * Construct a RecipientAddress. @@ -38,23 +40,30 @@ public record RecipientAddress(Optional serviceId, Optional pni, } public RecipientAddress(Optional serviceId, Optional number) { - this(serviceId, Optional.empty(), number); + this(serviceId, Optional.empty(), number, Optional.empty()); } public RecipientAddress(ServiceId serviceId, String e164) { - this(Optional.ofNullable(serviceId), Optional.empty(), Optional.ofNullable(e164)); + this(Optional.ofNullable(serviceId), Optional.empty(), Optional.ofNullable(e164), Optional.empty()); } public RecipientAddress(ServiceId serviceId, PNI pni, String e164) { - this(Optional.ofNullable(serviceId), Optional.ofNullable(pni), Optional.ofNullable(e164)); + this(Optional.ofNullable(serviceId), Optional.ofNullable(pni), Optional.ofNullable(e164), Optional.empty()); + } + + public RecipientAddress(ServiceId serviceId, PNI pni, String e164, String username) { + this(Optional.ofNullable(serviceId), + Optional.ofNullable(pni), + Optional.ofNullable(e164), + Optional.ofNullable(username)); } public RecipientAddress(SignalServiceAddress address) { - this(Optional.of(address.getServiceId()), Optional.empty(), address.getNumber()); + this(Optional.of(address.getServiceId()), Optional.empty(), address.getNumber(), Optional.empty()); } public RecipientAddress(org.asamk.signal.manager.api.RecipientAddress address) { - this(address.uuid().map(ServiceId::from), Optional.empty(), address.number()); + this(address.uuid().map(ServiceId::from), Optional.empty(), address.number(), address.username()); } public RecipientAddress(ServiceId serviceId) { @@ -66,7 +75,8 @@ public record RecipientAddress(Optional serviceId, Optional pni, this.serviceId.isEmpty() || this.isServiceIdPNI() || this.serviceId.equals(address.pni) ) && !address.isServiceIdPNI() ? address.serviceId : this.serviceId, address.pni.or(this::pni), - address.number.or(this::number)); + address.number.or(this::number), + address.username.or(this::username)); } public RecipientAddress removeIdentifiersFrom(RecipientAddress address) { @@ -74,7 +84,8 @@ public record RecipientAddress(Optional serviceId, Optional pni, ? Optional.empty() : this.serviceId, address.pni.equals(this.pni) || address.serviceId.equals(this.pni) ? Optional.empty() : this.pni, - address.number.equals(this.number) ? Optional.empty() : this.number); + address.number.equals(this.number) ? Optional.empty() : this.number, + address.username.equals(this.username) ? Optional.empty() : this.username); } public ServiceId getServiceId() { @@ -118,13 +129,17 @@ public record RecipientAddress(Optional serviceId, Optional pni, } public boolean hasSingleIdentifier() { - return serviceId().isEmpty() || number.isEmpty(); + final var identifiersCount = serviceId().map(s -> 1).orElse(0) + + number().map(s -> 1).orElse(0) + + username().map(s -> 1).orElse(0); + return identifiersCount == 1; } public boolean hasIdentifiersOf(RecipientAddress address) { return (address.serviceId.isEmpty() || address.serviceId.equals(serviceId) || address.serviceId.equals(pni)) && (address.pni.isEmpty() || address.pni.equals(pni)) - && (address.number.isEmpty() || address.number.equals(number)); + && (address.number.isEmpty() || address.number.equals(number)) + && (address.username.isEmpty() || address.username.equals(username)); } public boolean hasAdditionalIdentifiersThan(RecipientAddress address) { @@ -142,6 +157,10 @@ public record RecipientAddress(Optional serviceId, Optional pni, number.isPresent() && ( address.number.isEmpty() || !address.number.equals(number) ) + ) || ( + username.isPresent() && ( + address.username.isEmpty() || !address.username.equals(username) + ) ); } @@ -158,6 +177,8 @@ public record RecipientAddress(Optional serviceId, Optional pni, } public org.asamk.signal.manager.api.RecipientAddress toApiRecipientAddress() { - return new org.asamk.signal.manager.api.RecipientAddress(serviceId().map(ServiceId::uuid), number()); + return new org.asamk.signal.manager.api.RecipientAddress(serviceId().map(ServiceId::uuid), + number(), + username()); } } diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientStore.java b/lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientStore.java index 81470e83..d3b949f8 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientStore.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientStore.java @@ -52,6 +52,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re CREATE TABLE recipient ( _id INTEGER PRIMARY KEY AUTOINCREMENT, number TEXT UNIQUE, + username TEXT UNIQUE, uuid BLOB UNIQUE, pni BLOB UNIQUE, profile_key BLOB, @@ -93,7 +94,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re public RecipientAddress resolveRecipientAddress(RecipientId recipientId) { final var sql = ( """ - SELECT r.number, r.uuid, r.pni + SELECT r.number, r.uuid, r.pni, r.username FROM %s r WHERE r._id = ? """ @@ -193,7 +194,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re return new RecipientId(recipientId, this); } - public RecipientId resolveRecipient( + public RecipientId resolveRecipientByNumber( final String number, Supplier serviceIdSupplier ) throws UnregisteredRecipientException { final Optional byNumber; @@ -214,6 +215,28 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re return byNumber.get().id(); } + public RecipientId resolveRecipientByUsername( + final String username, Supplier serviceIdSupplier + ) throws UnregisteredRecipientException { + final Optional byUsername; + try (final var connection = database.getConnection()) { + byUsername = findByUsername(connection, username); + } catch (SQLException e) { + throw new RuntimeException("Failed read from recipient store", e); + } + if (byUsername.isEmpty() || byUsername.get().address().serviceId().isEmpty()) { + final var serviceId = serviceIdSupplier.get(); + if (serviceId == null) { + throw new UnregisteredRecipientException(new org.asamk.signal.manager.api.RecipientAddress(null, + null, + username)); + } + + return resolveRecipient(serviceId); + } + return byUsername.get().id(); + } + public RecipientId resolveRecipient(RecipientAddress address) { synchronized (recipientsLock) { final RecipientId recipientId; @@ -247,7 +270,21 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re final Optional aci, final Optional pni, final Optional number ) { final var serviceId = aci.map(a -> (ServiceId) a).or(() -> pni); - return resolveRecipientTrusted(new RecipientAddress(serviceId, pni, number), false); + return resolveRecipientTrusted(new RecipientAddress(serviceId, pni, number, Optional.empty()), false); + } + + @Override + public RecipientId resolveRecipientTrusted(final ServiceId serviceId, final String username) { + return resolveRecipientTrusted(new RecipientAddress(serviceId, null, null, username), false); + } + + public RecipientId resolveRecipientTrusted( + final ACI aci, final String username + ) { + return resolveRecipientTrusted(new RecipientAddress(Optional.of(aci), + Optional.empty(), + Optional.empty(), + Optional.of(username)), false); } @Override @@ -309,7 +346,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re final var sql = ( """ SELECT r._id, - r.number, r.uuid, r.pni, + r.number, r.uuid, r.pni, r.username, r.profile_key, r.profile_key_credential, r.given_name, r.family_name, r.expiration_time, r.profile_sharing, r.color, r.blocked, r.archived, r.profile_last_update_timestamp, r.profile_given_name, r.profile_family_name, r.profile_about, r.profile_about_emoji, r.profile_avatar_url_path, r.profile_mobile_coin_address, r.profile_unidentified_access_mode, r.profile_capabilities @@ -739,7 +776,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re final var sql = ( """ UPDATE %s - SET number = ?, uuid = ?, pni = ? + SET number = ?, uuid = ?, pni = ?, username = ? WHERE _id = ? """ ).formatted(TABLE_RECIPIENT); @@ -747,7 +784,8 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re statement.setString(1, address.number().orElse(null)); statement.setBytes(2, address.serviceId().map(ServiceId::uuid).map(UuidUtil::toByteArray).orElse(null)); statement.setBytes(3, address.pni().map(PNI::uuid).map(UuidUtil::toByteArray).orElse(null)); - statement.setLong(4, recipientId.id()); + statement.setString(4, address.username().orElse(null)); + statement.setLong(5, recipientId.id()); statement.executeUpdate(); } } @@ -800,7 +838,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re final Connection connection, final String number ) throws SQLException { final var sql = """ - SELECT r._id, r.number, r.uuid, r.pni + SELECT r._id, r.number, r.uuid, r.pni, r.username FROM %s r WHERE r.number = ? LIMIT 1 @@ -811,11 +849,26 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re } } + private Optional findByUsername( + final Connection connection, final String username + ) throws SQLException { + final var sql = """ + SELECT r._id, r.number, r.uuid, r.pni, r.username + FROM %s r + WHERE r.username = ? + LIMIT 1 + """.formatted(TABLE_RECIPIENT); + try (final var statement = connection.prepareStatement(sql)) { + statement.setString(1, username); + return Utils.executeQueryForOptional(statement, this::getRecipientWithAddressFromResultSet); + } + } + private Optional findByServiceId( final Connection connection, final ServiceId serviceId ) throws SQLException { final var sql = """ - SELECT r._id, r.number, r.uuid, r.pni + SELECT r._id, r.number, r.uuid, r.pni, r.username FROM %s r WHERE r.uuid = ? OR r.pni = ? LIMIT 1 @@ -830,16 +883,18 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re final Connection connection, final RecipientAddress address ) throws SQLException { final var sql = """ - SELECT r._id, r.number, r.uuid, r.pni + SELECT r._id, r.number, r.uuid, r.pni, r.username FROM %s r WHERE r.uuid = ?1 OR r.pni = ?1 OR r.uuid = ?2 OR r.pni = ?2 OR - r.number = ?3 + r.number = ?3 OR + r.username = ?4 """.formatted(TABLE_RECIPIENT); try (final var statement = connection.prepareStatement(sql)) { statement.setBytes(1, address.serviceId().map(ServiceId::uuid).map(UuidUtil::toByteArray).orElse(null)); statement.setBytes(2, address.pni().map(ServiceId::uuid).map(UuidUtil::toByteArray).orElse(null)); statement.setString(3, address.number().orElse(null)); + statement.setString(4, address.username().orElse(null)); return Utils.executeQueryForStream(statement, this::getRecipientWithAddressFromResultSet) .collect(Collectors.toSet()); } @@ -908,7 +963,8 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re final var serviceId = Optional.ofNullable(resultSet.getBytes("uuid")).map(ServiceId::parseOrNull); final var pni = Optional.ofNullable(resultSet.getBytes("pni")).map(PNI::parseOrNull); final var number = Optional.ofNullable(resultSet.getString("number")); - return new RecipientAddress(serviceId, pni, number); + final var username = Optional.ofNullable(resultSet.getString("username")); + return new RecipientAddress(serviceId, pni, number, username); } private RecipientId getRecipientIdFromResultSet(ResultSet resultSet) throws SQLException { diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientTrustedResolver.java b/lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientTrustedResolver.java index 38949c64..9fc46d67 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientTrustedResolver.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientTrustedResolver.java @@ -2,6 +2,7 @@ package org.asamk.signal.manager.storage.recipients; import org.whispersystems.signalservice.api.push.ACI; import org.whispersystems.signalservice.api.push.PNI; +import org.whispersystems.signalservice.api.push.ServiceId; import org.whispersystems.signalservice.api.push.SignalServiceAddress; import java.util.Optional; @@ -15,6 +16,8 @@ public interface RecipientTrustedResolver { RecipientId resolveRecipientTrusted(Optional aci, Optional pni, Optional number); + RecipientId resolveRecipientTrusted(ServiceId serviceId, String username); + class RecipientTrustedResolverWrapper implements RecipientTrustedResolver { private final Supplier recipientTrustedResolverSupplier; @@ -39,5 +42,10 @@ public interface RecipientTrustedResolver { ) { return recipientTrustedResolverSupplier.get().resolveRecipientTrusted(aci, pni, number); } + + @Override + public RecipientId resolveRecipientTrusted(final ServiceId serviceId, final String username) { + return recipientTrustedResolverSupplier.get().resolveRecipientTrusted(serviceId, username); + } } } diff --git a/src/main/java/org/asamk/signal/commands/ListContactsCommand.java b/src/main/java/org/asamk/signal/commands/ListContactsCommand.java index abcfdca3..e3b978b1 100644 --- a/src/main/java/org/asamk/signal/commands/ListContactsCommand.java +++ b/src/main/java/org/asamk/signal/commands/ListContactsCommand.java @@ -55,10 +55,12 @@ public class ListContactsCommand implements JsonRpcLocalCommand { for (var r : recipients) { final var contact = r.getContact() == null ? Contact.newBuilder().build() : r.getContact(); final var profile = r.getProfile() == null ? Profile.newBuilder().build() : r.getProfile(); - writer.println("Number: {} Name: {} Profile name: {} Color: {} Blocked: {} Message expiration: {}", + writer.println( + "Number: {} Name: {} Profile name: {} Username: {} Color: {} Blocked: {} Message expiration: {}", r.getAddress().getLegacyIdentifier(), contact.getName(), profile.getDisplayName(), + r.getAddress().username().orElse(""), contact.getColor(), contact.isBlocked(), contact.getMessageExpirationTime() == 0 @@ -72,6 +74,7 @@ public class ListContactsCommand implements JsonRpcLocalCommand { final var contact = r.getContact() == null ? Contact.newBuilder().build() : r.getContact(); return new JsonContact(address.number().orElse(null), address.uuid().map(UUID::toString).orElse(null), + address.username().orElse(null), contact.getName(), contact.getColor(), contact.isBlocked(), @@ -96,6 +99,7 @@ public class ListContactsCommand implements JsonRpcLocalCommand { private record JsonContact( String number, String uuid, + String username, String name, String color, boolean isBlocked, diff --git a/src/main/java/org/asamk/signal/commands/UpdateAccountCommand.java b/src/main/java/org/asamk/signal/commands/UpdateAccountCommand.java index 7fe70213..2bb2d110 100644 --- a/src/main/java/org/asamk/signal/commands/UpdateAccountCommand.java +++ b/src/main/java/org/asamk/signal/commands/UpdateAccountCommand.java @@ -1,12 +1,17 @@ package org.asamk.signal.commands; +import net.sourceforge.argparse4j.impl.Arguments; import net.sourceforge.argparse4j.inf.Namespace; import net.sourceforge.argparse4j.inf.Subparser; import org.asamk.signal.commands.exceptions.CommandException; import org.asamk.signal.commands.exceptions.IOErrorException; +import org.asamk.signal.commands.exceptions.UserErrorException; import org.asamk.signal.manager.Manager; +import org.asamk.signal.manager.api.InvalidUsernameException; +import org.asamk.signal.output.JsonWriter; import org.asamk.signal.output.OutputWriter; +import org.asamk.signal.output.PlainTextWriter; import java.io.IOException; @@ -21,6 +26,11 @@ public class UpdateAccountCommand implements JsonRpcLocalCommand { public void attachToSubparser(final Subparser subparser) { subparser.help("Update the account attributes on the signal server."); subparser.addArgument("-n", "--device-name").help("Specify a name to describe this device."); + var mut = subparser.addMutuallyExclusiveGroup(); + mut.addArgument("-u", "--username").help("Specify a username that can then be used to contact this account."); + mut.addArgument("--delete-username") + .action(Arguments.storeTrue()) + .help("Delete the username associated with this account."); } @Override @@ -33,5 +43,34 @@ public class UpdateAccountCommand implements JsonRpcLocalCommand { } catch (IOException e) { throw new IOErrorException("UpdateAccount error: " + e.getMessage(), e); } + + var username = ns.getString("username"); + if (username != null) { + try { + final var newUsername = m.setUsername(username); + if (outputWriter instanceof PlainTextWriter w) { + w.println("Your new username: {}", newUsername); + } else if (outputWriter instanceof JsonWriter w) { + w.write(new JsonAccountResponse(newUsername)); + } + } catch (IOException e) { + throw new IOErrorException("Failed to set username: " + e.getMessage(), e); + } catch (InvalidUsernameException e) { + throw new UserErrorException("Invalid username: " + e.getMessage(), e); + } + } + + var deleteUsername = Boolean.TRUE.equals(ns.getBoolean("delete-username")); + if (deleteUsername) { + try { + m.deleteUsername(); + } catch (IOException e) { + throw new IOErrorException("Failed to delete username: " + e.getMessage(), e); + } + } } + + private record JsonAccountResponse( + String username + ) {} } diff --git a/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java b/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java index 3cdce248..cefb7a82 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java @@ -10,6 +10,7 @@ import org.asamk.signal.manager.api.Group; import org.asamk.signal.manager.api.Identity; import org.asamk.signal.manager.api.InactiveGroupLinkException; import org.asamk.signal.manager.api.InvalidDeviceLinkException; +import org.asamk.signal.manager.api.InvalidUsernameException; import org.asamk.signal.manager.api.Message; import org.asamk.signal.manager.api.MessageEnvelope; import org.asamk.signal.manager.api.NotPrimaryDeviceException; @@ -151,6 +152,16 @@ public class DbusManagerImpl implements Manager { updateProfile.isDeleteAvatar()); } + @Override + public String setUsername(final String username) throws IOException, InvalidUsernameException { + throw new UnsupportedOperationException(); + } + + @Override + public void deleteUsername() throws IOException { + throw new UnsupportedOperationException(); + } + @Override public void unregister() throws IOException { signal.unregister(); -- 2.50.1