From d0724fb5b5c5c9433141ffb852b48a8d4aa11f11 Mon Sep 17 00:00:00 2001 From: Mariusz Woloszyn Date: Sun, 20 Sep 2026 17:51:57 +0200 Subject: [PATCH] Support linking phone-numberless Signal accounts (#2123) * Support linking phone-numberless Signal accounts * Align numberless account support with project style * Support numberless accounts over D-Bus * Keep superseded accounts out of numberless account discovery Record numberless accounts explicitly instead of treating every retained ACI as a discoverable account. Resolve older index entries against saved account state, preserving ACI lookup and retrying unavailable state on later reads. The optional index field remains compatible with version 2 readers. Update native-image reflection metadata and cover replacement, legacy migration, locked and missing state, and mismatched ACIs in regression tests. * Address numberless linking review cleanup Reuse AccountApiV2 through SignalDependencies and use the shared suspend response helper while preserving linking error handling. Remove the unused account-number accessor and unsupported D-Bus version fallback. Keep numberless linking documentation in the man page and point the README to the wiki. Validate linking errors through HTTP response fixtures. All 196 tests pass on JDK 25 with an isolated D-Bus session; build and JSON schema generation also pass. Account-index classification is unchanged pending review discussion. --- README.md | 9 +- .../org/asamk/signal/manager/Manager.java | 8 + .../signal/manager/MultiAccountManager.java | 2 - .../signal/manager/ProvisioningManager.java | 3 + .../signal/manager/SignalAccountFiles.java | 18 +- .../signal/manager/helper/AccountHelper.java | 28 +- .../signal/manager/helper/GroupV2Helper.java | 4 +- .../helper/IncomingMessageHandler.java | 4 +- .../signal/manager/helper/SendHelper.java | 3 +- .../internal/MultiAccountManagerImpl.java | 9 +- .../internal/ProvisioningManagerImpl.java | 129 ++++-- .../manager/internal/SignalDependencies.java | 7 + .../signal/manager/storage/SignalAccount.java | 28 +- .../storage/accounts/AccountsStorage.java | 3 +- .../storage/accounts/AccountsStore.java | 56 ++- .../manager/util/PhoneNumberFormatter.java | 13 + .../manager/helper/NumberlessGroupTest.java | 57 +++ .../internal/NumberlessProvisioningTest.java | 182 ++++++++ .../storage/NumberlessAccountTest.java | 64 +++ .../storage/accounts/AccountsStoreTest.java | 208 +++++++++ .../util/PhoneNumberFormatterTest.java | 25 ++ man/signal-cli-dbus.5.adoc | 55 ++- man/signal-cli-jsonrpc.5.adoc | 8 +- man/signal-cli.1.adoc | 5 + src/main/java/org/asamk/Signal.java | 5 + src/main/java/org/asamk/SignalControl.java | 5 +- src/main/java/org/asamk/signal/App.java | 5 +- .../asamk/signal/ReceiveMessageHandler.java | 8 +- .../signal/commands/FinishLinkCommand.java | 9 +- .../signal/commands/ListAccountsCommand.java | 13 +- .../asamk/signal/dbus/DbusCommandHandler.java | 26 +- .../org/asamk/signal/dbus/DbusHandler.java | 6 +- .../asamk/signal/dbus/DbusManagerImpl.java | 119 ++--- .../dbus/DbusMultiAccountManagerImpl.java | 12 +- .../signal/dbus/DbusSignalControlImpl.java | 14 +- .../org/asamk/signal/dbus/DbusSignalImpl.java | 11 +- .../json/JsonReceiveMessageHandler.java | 2 +- .../signal-cli/reachability-metadata.json | 7 +- .../asamk/signal/dbus/NumberlessDbusTest.java | 415 ++++++++++++++++++ .../json/NumberlessAccountOutputTest.java | 71 +++ .../jsonrpc/SubscribeCallEventsTest.java | 5 - 41 files changed, 1484 insertions(+), 177 deletions(-) create mode 100644 lib/src/test/java/org/asamk/signal/manager/helper/NumberlessGroupTest.java create mode 100644 lib/src/test/java/org/asamk/signal/manager/internal/NumberlessProvisioningTest.java create mode 100644 lib/src/test/java/org/asamk/signal/manager/storage/NumberlessAccountTest.java create mode 100644 lib/src/test/java/org/asamk/signal/manager/storage/accounts/AccountsStoreTest.java create mode 100644 lib/src/test/java/org/asamk/signal/manager/util/PhoneNumberFormatterTest.java create mode 100644 src/test/java/org/asamk/signal/dbus/NumberlessDbusTest.java create mode 100644 src/test/java/org/asamk/signal/json/NumberlessAccountOutputTest.java diff --git a/README.md b/README.md index 96655b69..a98f651c 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ It supports registering, verifying, sending and receiving messages. signal-cli uses a [patched libsignal-service-java](https://github.com/Turasa/libsignal-service-java), extracted from the [Signal-Android source code](https://github.com/signalapp/Signal-Android/tree/main/lib/libsignal-service). For registering you need a phone number where you can receive SMS or incoming calls. +Existing accounts without a phone number can be linked from a compatible Signal mobile app. signal-cli is primarily intended to be used on servers to notify admins of important events. For this use-case, it has a daemon mode with JSON-RPC interface ([man page](https://github.com/AsamK/signal-cli/blob/master/man/signal-cli-jsonrpc.5.adoc)) @@ -60,14 +61,16 @@ For a complete usage overview please read the [man page](https://github.com/AsamK/signal-cli/blob/master/man/signal-cli.1.adoc) and the [wiki](https://github.com/AsamK/signal-cli/wiki). -Important: The ACCOUNT is your phone number in international format and must include the country calling code. Hence it +For a numbered account, ACCOUNT is your phone number in international format and must include the country calling code. Hence it should start with a "+" sign. (See [Wikipedia](https://en.wikipedia.org/wiki/List_of_country_calling_codes) for a list of all country codes.) +For a linked account without a phone number, use its ACI (Account ID) instead. +See the [wiki](https://github.com/AsamK/signal-cli/wiki) for further documentation. * Link to an existing account - If you have an existing Signal account associated - with a number, you can link signal-cli to it with: + If you have an existing Signal account, with or without a phone number, + you can link signal-cli to it with: signal-cli link 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 7d0c73af..82d42eda 100644 --- a/lib/src/main/java/org/asamk/signal/manager/Manager.java +++ b/lib/src/main/java/org/asamk/signal/manager/Manager.java @@ -98,6 +98,14 @@ public interface Manager extends Closeable { String getSelfACI(); + /** + * Returns the phone number for numbered accounts, or the ACI for numberless accounts. + */ + default String getSelfIdentifier() { + final var number = getSelfNumber(); + return number != null ? number : getSelfACI(); + } + /** * This is used for checking a set of phone numbers for registration on Signal * diff --git a/lib/src/main/java/org/asamk/signal/manager/MultiAccountManager.java b/lib/src/main/java/org/asamk/signal/manager/MultiAccountManager.java index 15b60594..5d83819b 100644 --- a/lib/src/main/java/org/asamk/signal/manager/MultiAccountManager.java +++ b/lib/src/main/java/org/asamk/signal/manager/MultiAccountManager.java @@ -8,8 +8,6 @@ import java.util.function.Consumer; public interface MultiAccountManager extends AutoCloseable { - List getAccountNumbers(); - List getManagers(); void addOnManagerAddedHandler(Consumer handler); diff --git a/lib/src/main/java/org/asamk/signal/manager/ProvisioningManager.java b/lib/src/main/java/org/asamk/signal/manager/ProvisioningManager.java index 09a329f5..bcff5ac0 100644 --- a/lib/src/main/java/org/asamk/signal/manager/ProvisioningManager.java +++ b/lib/src/main/java/org/asamk/signal/manager/ProvisioningManager.java @@ -10,5 +10,8 @@ public interface ProvisioningManager { URI getDeviceLinkUri() throws TimeoutException, IOException; + /** + * Completes linking and returns the account's phone number, or ACI if it has no number. + */ String finishDeviceLink(String deviceName) throws IOException, TimeoutException, UserAlreadyExistsException; } diff --git a/lib/src/main/java/org/asamk/signal/manager/SignalAccountFiles.java b/lib/src/main/java/org/asamk/signal/manager/SignalAccountFiles.java index d5856d97..6834bff2 100644 --- a/lib/src/main/java/org/asamk/signal/manager/SignalAccountFiles.java +++ b/lib/src/main/java/org/asamk/signal/manager/SignalAccountFiles.java @@ -25,6 +25,7 @@ import java.io.IOException; import java.util.Objects; import java.util.Set; import java.util.function.Consumer; +import java.util.stream.Collectors; public class SignalAccountFiles { @@ -65,15 +66,26 @@ public class SignalAccountFiles { return accountsStore.getAllNumbers(); } + public Set getAllLocalAccountIdentifiers() throws IOException { + return accountsStore.getAllAccounts() + .stream() + .map(a -> a.number() != null ? a.number() : a.uuid()) + .collect(Collectors.toSet()); + } + public MultiAccountManager initMultiAccountManager() throws IOException { final var managerPairs = accountsStore.getAllAccounts().parallelStream().map(a -> { + final var identifier = a.number() != null ? a.number() : a.uuid(); try { - return new Pair(initManagerByNumber(a.number(), a.path()), null); + final var manager = a.number() != null + ? initManagerByNumber(a.number(), a.path()) + : initManagerByAci(ACI.parseOrThrow(a.uuid()), a.path()); + return new Pair(manager, null); } catch (NotRegisteredException e) { - logger.warn("Ignoring {}: {} ({})", a.number(), e.getMessage(), e.getClass().getSimpleName()); + logger.warn("Ignoring {}: {} ({})", identifier, e.getMessage(), e.getClass().getSimpleName()); return null; } catch (AccountCheckException | IOException e) { - logger.error("Failed to load {}: {} ({})", a.number(), e.getMessage(), e.getClass().getSimpleName()); + logger.error("Failed to load {}: {} ({})", identifier, e.getMessage(), e.getClass().getSimpleName()); return new Pair(null, e); } }).filter(Objects::nonNull).toList(); 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 5f208ede..210a5906 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 @@ -1,5 +1,6 @@ package org.asamk.signal.manager.helper; +import org.asamk.signal.manager.api.BadRequestException; import org.asamk.signal.manager.api.CaptchaRequiredException; import org.asamk.signal.manager.api.DeviceLinkUrl; import org.asamk.signal.manager.api.IncorrectPinException; @@ -27,6 +28,7 @@ import org.signal.libsignal.protocol.state.SignedPreKeyRecord; import org.signal.libsignal.protocol.util.KeyHelper; import org.signal.libsignal.usernames.BaseUsernameException; import org.signal.libsignal.usernames.Username; +import org.signal.network.api.AccountApiV2.SetAccountAttributesError; import org.signal.network.exceptions.NonSuccessfulResponseCodeException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -96,10 +98,10 @@ public class AccountHelper { } else { context.getPreKeyHelper().refreshPreKeysIfNecessary(); } - if (account.getPni() == null) { + if (account.getPni() == null && account.getNumber() != null) { checkWhoAmiI(); } - if (!account.isPrimaryDevice() && account.getPniIdentityKeyPair() == null) { + if (!account.isPrimaryDevice() && account.getPni() != null && account.getPniIdentityKeyPair() == null) { throw new IOException("Missing PNI identity key, relinking required"); } if (account.getPreviousStorageVersion() < 10 @@ -138,8 +140,9 @@ public class AccountHelper { final var whoAmI = dependencies.getAccountManager().getWhoAmI(); final var number = whoAmI.getNumber(); final var aci = ACI.parseOrThrow(whoAmI.getAci()); - final var pni = PNI.parseOrThrow(whoAmI.getPni()); - if (number.equals(account.getNumber()) && aci.equals(account.getAci()) && pni.equals(account.getPni())) { + final var pni = whoAmI.getPni() == null ? null : PNI.parseOrThrow(whoAmI.getPni()); + if (Objects.equals(number, account.getNumber()) && aci.equals(account.getAci()) && Objects.equals(pni, + account.getPni())) { return; } @@ -150,7 +153,7 @@ public class AccountHelper { account.setNumber(number); account.setAci(aci); account.setPni(pni); - if (account.isPrimaryDevice() && account.getPniIdentityKeyPair() == null) { + if (pni != null && account.isPrimaryDevice() && account.getPniIdentityKeyPair() == null) { account.setPniIdentityKeyPair(KeyUtils.generateIdentityKeyPair()); } account.getRecipientTrustedResolver().resolveSelfRecipientTrusted(account.getSelfRecipientAddress()); @@ -564,7 +567,20 @@ public class AccountHelper { } public void updateAccountAttributes() throws IOException { - handleResponseException(dependencies.getAccountApi().setAccountAttributes(account.getAccountAttributes(null))); + if (account.getNumber() != null) { + handleResponseException(dependencies.getAccountApi() + .setAccountAttributes(account.getAccountAttributes(null))); + return; + } + try { + handleResponseExceptionSuspend(cont -> dependencies.getAccountApiV2() + .setAccountAttributes(account.getAccountAttributesV2(), cont)); + } catch (BadRequestException e) { + if (e.getError() instanceof SetAccountAttributesError.Unauthorized) { + throw new AuthorizationFailedException(401, "Authorization failed!"); + } + throw new IOException("Account attribute update rate limited; try again later"); + } } public void addDevice(DeviceLinkUrl deviceLinkInfo) throws IOException, org.asamk.signal.manager.api.DeviceLimitExceededException { diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/GroupV2Helper.java b/lib/src/main/java/org/asamk/signal/manager/helper/GroupV2Helper.java index 6bbe6971..0ed6d4b2 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/GroupV2Helper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/GroupV2Helper.java @@ -136,7 +136,7 @@ class GroupV2Helper { int findRevisionWeWereAdded(DecryptedGroup partialDecryptedGroup) { ByteString aciBytes = getSelfAci().toByteString(); - ByteString pniBytes = getSelfPni().toByteString(); + ByteString pniBytes = getSelfPni() == null ? null : getSelfPni().toByteString(); for (DecryptedMember decryptedMember : partialDecryptedGroup.members) { if (decryptedMember.aciBytes.equals(aciBytes) || decryptedMember.pniBytes.equals(pniBytes)) { return decryptedMember.joinedAtRevision; @@ -264,7 +264,7 @@ class GroupV2Helper { var pendingMembersList = groupInfoV2.getGroup().pendingMembers; final var selfAci = getSelfAci(); var selfPendingMember = DecryptedGroupUtil.findPendingByServiceId(pendingMembersList, selfAci); - if (selfPendingMember.isEmpty()) { + if (selfPendingMember.isEmpty() && getSelfPni() != null) { selfPendingMember = DecryptedGroupUtil.findPendingByServiceId(pendingMembersList, getSelfPni()); } diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/IncomingMessageHandler.java b/lib/src/main/java/org/asamk/signal/manager/helper/IncomingMessageHandler.java index 6aa9c5a0..7af7b28c 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/IncomingMessageHandler.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/IncomingMessageHandler.java @@ -339,7 +339,7 @@ public final class IncomingMessageHandler { throw new AssertionError(e); } - if (account.getPni().equals(destination.serviceId)) { + if (destination.serviceId.equals(account.getPni())) { account.getRecipientStore().markNeedsPniSignature(sender, true); } else if (account.getAci().equals(destination.serviceId)) { account.getRecipientStore().markNeedsPniSignature(sender, false); @@ -1124,7 +1124,7 @@ public final class IncomingMessageHandler { if (destination == null || destination.isUnknown()) { throw new InvalidMessageException("Missing destination"); } - if (!account.getAci().equals(destination) && !account.getPni().equals(destination)) { + if (!destination.equals(account.getAci()) && !destination.equals(account.getPni())) { throw new InvalidMessageException("Message not intended for this account"); } return new DeviceAddress(account.getRecipientResolver().resolveRecipient(destination), diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java index dd057c23..bd1188af 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java @@ -886,7 +886,8 @@ public class SendHelper { var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId); try { - final boolean includePniSignature = account.getRecipientStore().needsPniSignature(recipientId); + final boolean includePniSignature = account.getPni() != null && account.getRecipientStore() + .needsPniSignature(recipientId); try { return s.send(messageSender, address, diff --git a/lib/src/main/java/org/asamk/signal/manager/internal/MultiAccountManagerImpl.java b/lib/src/main/java/org/asamk/signal/manager/internal/MultiAccountManagerImpl.java index 560fe62c..c767afba 100644 --- a/lib/src/main/java/org/asamk/signal/manager/internal/MultiAccountManagerImpl.java +++ b/lib/src/main/java/org/asamk/signal/manager/internal/MultiAccountManagerImpl.java @@ -39,13 +39,6 @@ public class MultiAccountManagerImpl implements MultiAccountManager { managers.forEach(m -> m.addClosedListener(() -> this.removeManager(m))); } - @Override - public List getAccountNumbers() { - synchronized (managers) { - return managers.stream().map(Manager::getSelfNumber).toList(); - } - } - @Override public List getManagers() { synchronized (managers) { @@ -121,7 +114,7 @@ public class MultiAccountManagerImpl implements MultiAccountManager { } else { // Phone number — check already loaded managers var existing = managers.stream() - .filter(m -> m.getSelfNumber().equals(identifier)) + .filter(m -> identifier.equals(m.getSelfNumber())) .findFirst() .orElse(null); if (existing != null) { diff --git a/lib/src/main/java/org/asamk/signal/manager/internal/ProvisioningManagerImpl.java b/lib/src/main/java/org/asamk/signal/manager/internal/ProvisioningManagerImpl.java index 65da23e5..c92aec12 100644 --- a/lib/src/main/java/org/asamk/signal/manager/internal/ProvisioningManagerImpl.java +++ b/lib/src/main/java/org/asamk/signal/manager/internal/ProvisioningManagerImpl.java @@ -19,8 +19,8 @@ package org.asamk.signal.manager.internal; import org.asamk.signal.manager.Manager; import org.asamk.signal.manager.ProvisioningManager; import org.asamk.signal.manager.Settings; +import org.asamk.signal.manager.api.BadRequestException; import org.asamk.signal.manager.api.UserAlreadyExistsException; -import org.asamk.signal.manager.config.ServiceConfig; import org.asamk.signal.manager.config.ServiceEnvironmentConfig; import org.asamk.signal.manager.storage.SignalAccount; import org.asamk.signal.manager.storage.accounts.AccountsStore; @@ -34,13 +34,15 @@ import org.signal.libsignal.protocol.IdentityKey; import org.signal.libsignal.protocol.IdentityKeyPair; import org.signal.libsignal.protocol.ecc.ECPrivateKey; import org.signal.libsignal.zkgroup.profiles.ProfileKey; +import org.signal.network.api.RegistrationApiV2; +import org.signal.network.api.RegistrationApiV2.LinkDeviceResponse; +import org.signal.network.api.RegistrationApiV2.RegisterAsLinkedDeviceError; +import org.signal.network.rest.SignalRestClient; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.whispersystems.signalservice.api.SignalServiceAccountManager; -import org.whispersystems.signalservice.api.account.DeviceAttributes; +import org.whispersystems.signalservice.api.account.PreKeyCollection; import org.whispersystems.signalservice.api.provisioning.ProvisioningSocket; import org.whispersystems.signalservice.api.push.ServiceIdType; -import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException; import org.whispersystems.signalservice.internal.crypto.SecondaryProvisioningCipher; import org.whispersystems.signalservice.internal.push.ProvisionMessage; @@ -67,7 +69,7 @@ import kotlinx.coroutines.BuildersKt; import kotlinx.coroutines.CoroutineScope; import static org.asamk.signal.manager.util.KeyUtils.generatePreKeysForType; -import static org.asamk.signal.manager.util.Utils.handleResponseException; +import static org.asamk.signal.manager.util.Utils.handleResponseExceptionSuspend; public class ProvisioningManagerImpl implements ProvisioningManager, Closeable { @@ -114,7 +116,8 @@ public class ProvisioningManagerImpl implements ProvisioningManager, Closeable { public URI getDeviceLinkUri() throws TimeoutException, IOException { try { var url = urlFuture.get(30, TimeUnit.SECONDS); - return new URI(url); + // Mode.Link(false) does not advertise any capabilities itself. + return new URI(url + "&capabilities=nopni"); } catch (java.util.concurrent.TimeoutException e) { throw new TimeoutException("Timed out waiting for provisioning URL"); } catch (InterruptedException e) { @@ -148,18 +151,20 @@ public class ProvisioningManagerImpl implements ProvisioningManager, Closeable { var number = msg.number; var aci = ACI.parseOrThrow(msg.aci, msg.aciBinary); - var pni = PNI.parseOrThrow(msg.pni, msg.pniBinary); + var pni = parsePni(msg); + var identifier = number != null ? number : aci.toString(); - logger.info("Received link information from {}, linking in progress ...", number); + logger.info("Received link information from {}, linking in progress ...", identifier); var accountPath = accountsStore.getPathByAci(aci); - if (accountPath == null) { + if (accountPath == null && number != null) { accountPath = accountsStore.getPathByNumber(number); } final var accountExists = accountPath != null && SignalAccount.accountFileExists(pathConfig.dataPath(), accountPath); if (accountExists && !canRelinkExistingAccount(accountPath)) { - throw new UserAlreadyExistsException(number, SignalAccount.getFileName(pathConfig.dataPath(), accountPath)); + throw new UserAlreadyExistsException(identifier, + SignalAccount.getFileName(pathConfig.dataPath(), accountPath)); } if (accountPath == null) { accountPath = accountsStore.addAccount(number, aci); @@ -173,8 +178,10 @@ public class ProvisioningManagerImpl implements ProvisioningManager, Closeable { try { aciIdentity = new IdentityKeyPair(new IdentityKey(msg.aciIdentityKeyPublic.toByteArray()), new ECPrivateKey(msg.aciIdentityKeyPrivate.toByteArray())); - pniIdentity = new IdentityKeyPair(new IdentityKey(msg.pniIdentityKeyPublic.toByteArray()), - new ECPrivateKey(msg.pniIdentityKeyPrivate.toByteArray())); + pniIdentity = pni == null + ? null + : new IdentityKeyPair(new IdentityKey(msg.pniIdentityKeyPublic.toByteArray()), + new ECPrivateKey(msg.pniIdentityKeyPrivate.toByteArray())); profileKey = msg.profileKey == null ? KeyUtils.createProfileKey() : new ProfileKey(msg.profileKey.toByteArray()); @@ -222,28 +229,19 @@ public class ProvisioningManagerImpl implements ProvisioningManager, Closeable { } final var aciPreKeys = generatePreKeysForType(account.getAccountData(ServiceIdType.ACI)); - final var pniPreKeys = generatePreKeysForType(account.getAccountData(ServiceIdType.PNI)); + final var pniPreKeys = pni == null + ? null + : generatePreKeysForType(account.getAccountData(ServiceIdType.PNI)); logger.debug("Finishing new device registration"); - final var attrs = account.getAccountAttributes(null); - final var deviceAttributes = new DeviceAttributes(attrs.getFetchesMessages(), - attrs.getRegistrationId(), - attrs.getPniRegistrationId(), - attrs.getName(), - attrs.getCapabilities()); - final var unauthAccountManager = SignalServiceAccountManager.createWithStaticCredentials( - serviceEnvironmentConfig.signalServiceConfiguration(), - null, - null, - number, - SignalServiceAddress.DEFAULT_DEVICE_ID, - password, - userAgent, - ServiceConfig.AUTOMATIC_NETWORK_RETRY, - ServiceConfig.GROUP_MAX_SIZE); - final var registerResponse = handleResponseException(unauthAccountManager.getRegistrationApi() - .registerAsSecondaryDevice(msg.provisioningCode, deviceAttributes, aciPreKeys, pniPreKeys, null)); - final var deviceId = Integer.parseInt(registerResponse.getDeviceId()); + final var restClient = new SignalRestClient(serviceEnvironmentConfig.signalServiceConfiguration(), + userAgent); + final var registrationApi = new RegistrationApiV2(restClient, false); + final var deviceId = registerLinkedDevice(registrationApi, + account, + msg.provisioningCode, + aciPreKeys, + pniPreKeys); account.finishLinking(deviceId, aciPreKeys, pniPreKeys); linkingFinished = true; @@ -277,7 +275,7 @@ public class ProvisioningManagerImpl implements ProvisioningManager, Closeable { newManagerListener.accept(m); m = null; } - return number; + return identifier; } finally { if (m != null) { m.close(); @@ -296,6 +294,71 @@ public class ProvisioningManagerImpl implements ProvisioningManager, Closeable { } } + static PNI parsePni(final ProvisionMessage message) throws IOException { + if (message.number != null) { + return PNI.parseOrThrow(message.pni, message.pniBinary); + } + if (message.pni != null + || message.pniBinary != null + || message.pniIdentityKeyPublic != null + || message.pniIdentityKeyPrivate != null) { + throw new IOException("Provisioning message has PNI material without a phone number"); + } + if (message.authCredentialSalt == null || message.authCredentialSalt.size() == 0) { + throw new IOException("Numberless provisioning message is missing the group auth credential salt"); + } + return null; + } + + static int registerLinkedDevice( + final RegistrationApiV2 registrationApi, + final SignalAccount account, + final String provisioningCode, + final PreKeyCollection aciPreKeys, + final PreKeyCollection pniPreKeys + ) throws IOException { + final var attrs = account.getAccountAttributesV2(); + final var deviceAttributes = new RegistrationApiV2.DeviceAttributes(attrs.getFetchesMessages(), + attrs.getRegistrationId(), + attrs.getPniRegistrationId(), + attrs.getName(), + attrs.getCapabilities()); + try { + final LinkDeviceResponse result = handleResponseExceptionSuspend(cont -> registrationApi.registerAsSecondaryDevice( + account.getAci(), + account.getPassword(), + provisioningCode, + deviceAttributes, + toRegistrationPreKeys(aciPreKeys), + toRegistrationPreKeys(pniPreKeys), + null, + cont)); + return result.getDeviceId(); + } catch (BadRequestException e) { + throw switch (e.getError()) { + case RegisterAsLinkedDeviceError.IncorrectVerification ignored -> + new AuthorizationFailedException(403, "Device verification failed"); + case RegisterAsLinkedDeviceError.MissingCapability ignored -> + new IOException("Linked device is missing a required account capability"); + case RegisterAsLinkedDeviceError.MaxLinkedDevices ignored -> + new IOException("Account has reached its linked device limit"); + case RegisterAsLinkedDeviceError.InvalidRequest ignored -> + new IOException("Signal rejected the device linking request"); + case RegisterAsLinkedDeviceError.RateLimited ignored -> + new IOException("Device linking rate limited; try again later"); + default -> new IOException("Unexpected device linking response"); + }; + } + } + + static RegistrationApiV2.PreKeyCollection toRegistrationPreKeys(final PreKeyCollection preKeys) { + return preKeys == null + ? null + : new RegistrationApiV2.PreKeyCollection(preKeys.getIdentityKey(), + preKeys.getSignedPreKey(), + preKeys.getLastResortKyberPreKey()); + } + @Override public void close() throws IOException { socketHandle.close(); diff --git a/lib/src/main/java/org/asamk/signal/manager/internal/SignalDependencies.java b/lib/src/main/java/org/asamk/signal/manager/internal/SignalDependencies.java index 643e81df..7add6211 100644 --- a/lib/src/main/java/org/asamk/signal/manager/internal/SignalDependencies.java +++ b/lib/src/main/java/org/asamk/signal/manager/internal/SignalDependencies.java @@ -8,6 +8,7 @@ import org.signal.libsignal.metadata.certificate.CertificateValidator; import org.signal.libsignal.net.Network; import org.signal.libsignal.protocol.SignalProtocolAddress; import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations; +import org.signal.network.api.AccountApiV2; import org.signal.network.api.AttachmentApi; import org.signal.network.api.CallingApi; import org.signal.network.api.CdsApi; @@ -73,6 +74,7 @@ public class SignalDependencies { private SignalServiceAccountManager accountManager; private AccountApi accountApi; + private AccountApiV2 accountApiV2; private RateLimitChallengeApi rateLimitChallengeApi; private CdsApi cdsApi; private UsernameApi usernameApi; @@ -221,6 +223,11 @@ public class SignalDependencies { return getOrCreate(() -> accountApi, () -> accountApi = new AccountApi(getAuthenticatedSignalWebSocket())); } + public AccountApiV2 getAccountApiV2() { + return getOrCreate(() -> accountApiV2, + () -> accountApiV2 = new AccountApiV2(getAuthenticatedSignalWebSocket())); + } + public RateLimitChallengeApi getRateLimitChallengeApi() { return getOrCreate(() -> rateLimitChallengeApi, () -> rateLimitChallengeApi = new RateLimitChallengeApi(getAuthenticatedSignalWebSocket())); 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 77511d6e..09a0caa9 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 @@ -71,6 +71,7 @@ import org.signal.libsignal.protocol.state.SignedPreKeyRecord; import org.signal.libsignal.protocol.util.KeyHelper; import org.signal.libsignal.zkgroup.InvalidInputException; import org.signal.libsignal.zkgroup.profiles.ProfileKey; +import org.signal.network.api.RegistrationApiV2; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.whispersystems.signalservice.api.SignalServiceAccountDataStore; @@ -350,7 +351,9 @@ public class SignalAccount implements Closeable { this.registered = true; this.deviceId = deviceId; setPreKeys(ServiceIdType.ACI, aciPreKeys); - setPreKeys(ServiceIdType.PNI, pniPreKeys); + if (pniPreKeys != null) { + setPreKeys(ServiceIdType.PNI, pniPreKeys); + } save(); } @@ -1426,6 +1429,29 @@ public class SignalAccount implements Closeable { return getCapabilities(isPrimaryDevice()); } + public RegistrationApiV2.AccountAttributes getAccountAttributesV2() { + final var attributes = getAccountAttributes(null); + final var capabilities = attributes.getCapabilities(); + return new RegistrationApiV2.AccountAttributes(attributes.getSignalingKey(), + attributes.getRegistrationId(), + attributes.getVoice(), + attributes.getVideo(), + attributes.getFetchesMessages(), + attributes.getRegistrationLock(), + attributes.getUnidentifiedAccessKey(), + attributes.getUnrestrictedUnidentifiedAccess(), + number == null ? null : attributes.getDiscoverableByPhoneNumber(), + new RegistrationApiV2.AccountAttributes.Capabilities(capabilities.getStorage(), + capabilities.getVersionedExpirationTimer(), + capabilities.getAttachmentBackfill(), + capabilities.getSpqr(), + capabilities.getUsernameChangeSyncMessage(), + capabilities.getOptionalPhoneNumber()), + attributes.getName(), + getPni() == null ? null : attributes.getPniRegistrationId(), + attributes.getRecoveryPassword()); + } + public ServiceId getAccountId(ServiceIdType serviceIdType) { return getAccountData(serviceIdType).getServiceId(); } diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/accounts/AccountsStorage.java b/lib/src/main/java/org/asamk/signal/manager/storage/accounts/AccountsStorage.java index 40107986..ca202b67 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/accounts/AccountsStorage.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/accounts/AccountsStorage.java @@ -4,5 +4,6 @@ import java.util.List; public record AccountsStorage(List accounts, Integer version) { - public record Account(String path, String environment, String number, String uuid) {} + // Older indexes omit this flag; entries with only a UUID must then be checked against their account state. + public record Account(String path, String environment, String number, String uuid, Boolean numberless) {} } diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/accounts/AccountsStore.java b/lib/src/main/java/org/asamk/signal/manager/storage/accounts/AccountsStore.java index 36a6c75e..8d3ade20 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/accounts/AccountsStore.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/accounts/AccountsStore.java @@ -63,11 +63,14 @@ public class AccountsStore { public synchronized Set getAllAccounts() throws IOException { return readAccounts().stream() .filter(a -> a.environment() == null || serviceEnvironment.equals(a.environment())) - .filter(a -> a.number() != null) + .filter(a -> a.number() != null || (a.uuid() != null && Boolean.TRUE.equals(a.numberless()))) .collect(Collectors.toSet()); } public synchronized String getPathByNumber(String number) throws IOException { + if (number == null) { + return null; + } return readAccounts().stream() .filter(a -> a.environment() == null || serviceEnvironment.equals(a.environment())) .filter(a -> number.equals(a.number())) @@ -95,14 +98,15 @@ public class AccountsStore { return new AccountsStorage.Account(a.path(), serviceEnvironment, number, - aci == null ? null : aci.toString()); + aci == null ? null : aci.toString(), + number == null && aci != null); } if (number != null && number.equals(a.number())) { - return new AccountsStorage.Account(a.path(), a.environment(), null, a.uuid()); + return new AccountsStorage.Account(a.path(), a.environment(), null, a.uuid(), false); } - if (aci != null && aci.toString().equals(a.toString())) { - return new AccountsStorage.Account(a.path(), a.environment(), a.number(), null); + if (aci != null && aci.toString().equals(a.uuid())) { + return new AccountsStorage.Account(a.path(), a.environment(), a.number(), null, false); } return a; @@ -114,7 +118,8 @@ public class AccountsStore { final var account = new AccountsStorage.Account(accountPath, serviceEnvironment, number, - aci == null ? null : aci.toString()); + aci == null ? null : aci.toString(), + number == null && aci != null); updateAccounts(accounts -> { final var existingAccounts = accounts.stream().map(a -> { if (a.environment() != null && !serviceEnvironment.equals(a.environment())) { @@ -122,10 +127,10 @@ public class AccountsStore { } if (number != null && number.equals(a.number())) { - return new AccountsStorage.Account(a.path(), a.environment(), null, a.uuid()); + return new AccountsStorage.Account(a.path(), a.environment(), null, a.uuid(), false); } if (aci != null && aci.toString().equals(a.uuid())) { - return new AccountsStorage.Account(a.path(), a.environment(), a.number(), null); + return new AccountsStorage.Account(a.path(), a.environment(), a.number(), null, false); } return a; @@ -156,7 +161,7 @@ public class AccountsStore { private void createInitialAccounts() throws IOException { final var legacyAccountPaths = getLegacyAccountPaths(); final var accountsStorage = new AccountsStorage(legacyAccountPaths.stream() - .map(number -> new AccountsStorage.Account(number, null, number, null)) + .map(number -> new AccountsStorage.Account(number, null, number, null, false)) .toList(), CURRENT_STORAGE_VERSION); IOUtils.createPrivateDirectories(dataPath); @@ -188,7 +193,7 @@ public class AccountsStore { private List readAccounts() throws IOException { final var pair = openFileChannel(getAccountsFile()); try (final var fileChannel = pair.first(); final var lock = pair.second()) { - final var storage = readAccountsLocked(fileChannel); + var storage = readAccountsLocked(fileChannel); var accountsVersion = storage.version() == null ? 1 : storage.version(); if (accountsVersion > CURRENT_STORAGE_VERSION) { @@ -197,12 +202,36 @@ public class AccountsStore { throw new IOException("Accounts file was created by a no longer supported older version: " + accountsVersion); } else if (accountsVersion < CURRENT_STORAGE_VERSION) { - return upgradeAccountsFile(fileChannel, storage, accountsVersion).accounts(); + storage = upgradeAccountsFile(fileChannel, storage, accountsVersion); } - return storage.accounts(); + final var accounts = storage.accounts().stream().map(this::resolveNumberlessAccount).toList(); + if (!accounts.equals(storage.accounts())) { + saveAccountsLocked(fileChannel, new AccountsStorage(accounts, storage.version())); + } + return accounts; } } + private AccountsStorage.Account resolveNumberlessAccount(final AccountsStorage.Account entry) { + if (entry.numberless() != null) { + return entry; + } + + var numberless = false; + if (entry.number() == null && entry.uuid() != null) { + // Older entries also lose their number when another account takes it over. + try (final var account = accountLoader.loadAccountOrNull(entry.path())) { + if (account == null) { + // Keep the entry unresolved so a locked or unavailable account can be checked later. + return entry; + } + numberless = account.getNumber() == null && account.getAci() != null && entry.uuid() + .equals(account.getAci().toString()); + } + } + return new AccountsStorage.Account(entry.path(), entry.environment(), entry.number(), entry.uuid(), numberless); + } + private AccountsStorage upgradeAccountsFile( final FileChannel fileChannel, final AccountsStorage storage, @@ -223,7 +252,8 @@ public class AccountsStore { return new AccountsStorage.Account(a.path(), getServiceEnvironmentString(account.getServiceEnvironment()), a.number(), - a.uuid()); + a.uuid(), + a.numberless()); } }).toList(); } diff --git a/lib/src/main/java/org/asamk/signal/manager/util/PhoneNumberFormatter.java b/lib/src/main/java/org/asamk/signal/manager/util/PhoneNumberFormatter.java index cc0c6503..57176c76 100644 --- a/lib/src/main/java/org/asamk/signal/manager/util/PhoneNumberFormatter.java +++ b/lib/src/main/java/org/asamk/signal/manager/util/PhoneNumberFormatter.java @@ -42,6 +42,19 @@ public class PhoneNumberFormatter { throw new InvalidNumberException("No valid characters found."); } + if (localNumber == null) { + if (!number.startsWith("+")) { + throw new InvalidNumberException( + "Use an international number including the country code for a numberless account."); + } + try { + final var util = PhoneNumberUtil.getInstance(); + return util.format(util.parse(number, null), PhoneNumberFormat.E164); + } catch (NumberParseException e) { + throw new InvalidNumberException("Invalid international phone number."); + } + } + try { PhoneNumberUtil util = PhoneNumberUtil.getInstance(); PhoneNumber localNumberObject = util.parse(localNumber, null); diff --git a/lib/src/test/java/org/asamk/signal/manager/helper/NumberlessGroupTest.java b/lib/src/test/java/org/asamk/signal/manager/helper/NumberlessGroupTest.java new file mode 100644 index 00000000..a8733273 --- /dev/null +++ b/lib/src/test/java/org/asamk/signal/manager/helper/NumberlessGroupTest.java @@ -0,0 +1,57 @@ +package org.asamk.signal.manager.helper; + +import org.asamk.signal.manager.Settings; +import org.asamk.signal.manager.api.ServiceEnvironment; +import org.asamk.signal.manager.storage.SignalAccount; +import org.asamk.signal.manager.util.KeyUtils; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.signal.core.models.ServiceId.ACI; +import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup; +import org.signal.storageservice.storage.protos.groups.local.DecryptedMember; + +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class NumberlessGroupTest { + + @TempDir + Path directory; + + @Test + void findsOurGroupMembershipByAciWhenPniIsAbsent() throws Exception { + final var aci = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111"); + final var otherAci = ACI.parseOrThrow("22222222-2222-4222-8222-222222222222"); + try (final var account = SignalAccount.createLinkedAccount(directory.toFile(), + "account", + ServiceEnvironment.STAGING, + Settings.DEFAULT); final var context = new Context(account, null, null, null, null, null)) { + account.setProvisioningData(null, + aci, + null, + "test-password", + new byte[]{1}, + KeyUtils.generateIdentityKeyPair(), + null, + KeyUtils.createProfileKey(), + null, + new byte[32], + null); + final var otherMember = new DecryptedMember.Builder().aciBytes(otherAci.toByteString()) + .joinedAtRevision(1) + .build(); + final var selfMember = new DecryptedMember.Builder().aciBytes(aci.toByteString()) + .joinedAtRevision(4) + .build(); + final var group = new DecryptedGroup.Builder().revision(9) + .members(List.of(otherMember, selfMember)) + .build(); + assertEquals(4, context.getGroupV2Helper().findRevisionWeWereAdded(group)); + assertEquals(9, + context.getGroupV2Helper() + .findRevisionWeWereAdded(group.newBuilder().members(List.of(otherMember)).build())); + } + } +} diff --git a/lib/src/test/java/org/asamk/signal/manager/internal/NumberlessProvisioningTest.java b/lib/src/test/java/org/asamk/signal/manager/internal/NumberlessProvisioningTest.java new file mode 100644 index 00000000..1510a871 --- /dev/null +++ b/lib/src/test/java/org/asamk/signal/manager/internal/NumberlessProvisioningTest.java @@ -0,0 +1,182 @@ +package org.asamk.signal.manager.internal; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.asamk.signal.manager.Settings; +import org.asamk.signal.manager.api.ServiceEnvironment; +import org.asamk.signal.manager.config.ServiceConfig; +import org.asamk.signal.manager.storage.SignalAccount; +import org.asamk.signal.manager.util.KeyUtils; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.signal.core.models.ServiceId.ACI; +import org.signal.core.models.ServiceId.PNI; +import org.signal.network.api.RegistrationApiV2; +import org.signal.network.rest.SignalRestClient; +import org.whispersystems.signalservice.api.push.ServiceIdType; +import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException; +import org.whispersystems.signalservice.internal.push.ProvisionMessage; + +import java.io.IOException; +import java.nio.file.Path; +import java.security.SecureRandom; +import java.util.concurrent.atomic.AtomicReference; + +import okhttp3.Credentials; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Protocol; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; +import okio.Buffer; +import okio.ByteString; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class NumberlessProvisioningTest { + + private static final ACI ACI_ID = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111"); + private static final PNI PNI_ID = PNI.parseOrThrow("22222222-2222-4222-8222-222222222222"); + + @TempDir + Path directory; + + @Test + void numberlessProvisioningRequiresGroupCredentialSalt() { + assertThrows(IOException.class, () -> ProvisioningManagerImpl.parsePni(new ProvisionMessage.Builder().build())); + final var emptySalt = new ProvisionMessage.Builder().authCredentialSalt(ByteString.EMPTY).build(); + assertThrows(IOException.class, () -> ProvisioningManagerImpl.parsePni(emptySalt)); + assertNull(ProvisioningManagerImpl.toRegistrationPreKeys(null)); + } + + @Test + void acceptsNumberlessProvisioningButRejectsInconsistentPhoneIdentity() throws Exception { + final var message = new ProvisionMessage.Builder().authCredentialSalt(ByteString.of(new byte[32])); + assertNull(ProvisioningManagerImpl.parsePni(message.build())); + assertThrows(IOException.class, () -> ProvisioningManagerImpl.parsePni(message.pni(PNI_ID.toString()).build())); + assertThrows(IllegalArgumentException.class, + () -> ProvisioningManagerImpl.parsePni(new ProvisionMessage.Builder().number("+12025550123").build())); + assertEquals(PNI_ID, + ProvisioningManagerImpl.parsePni(new ProvisionMessage.Builder().number("+12025550123") + .pni(PNI_ID.toString()) + .build())); + } + + @Test + void numberlessLinkRequestOmitsPniAndAuthenticatesWithAci() throws Exception { + checkLinkRequest(null, null, 200, null); + } + + @Test + void numberedLinkRequestStillIncludesPniKeys() throws Exception { + checkLinkRequest("+12025550123", PNI_ID, 200, null); + } + + @ParameterizedTest + @CsvSource({ + "422, Signal rejected the device linking request", + "403, [403] Device verification failed", + "409, Linked device is missing a required account capability", + "411, Account has reached its linked device limit", + "429, Device linking rate limited; try again later" + }) + void linkingErrorsAreReportedWithoutCrashingOrIncludingServerBody( + final int statusCode, + final String errorMessage + ) throws Exception { + checkLinkRequest(null, null, statusCode, errorMessage); + } + + private void checkLinkRequest( + final String number, + final PNI pni, + final int statusCode, + final String errorMessage + ) throws Exception { + final var request = new AtomicReference(); + final var body = new AtomicReference(); + final var mapper = new ObjectMapper(); + final var client = new OkHttpClient.Builder().addInterceptor(chain -> { + request.set(chain.request()); + final var buffer = new Buffer(); + chain.request().body().writeTo(buffer); + body.set(mapper.readTree(buffer.readUtf8())); + return new Response.Builder().request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .code(statusCode) + .message("Test response") + .body(ResponseBody.create(statusCode == 200 ? "{\"deviceId\":2}" : "secret payload", + MediaType.get("application/json"))) + .build(); + }).build(); + final var config = ServiceConfig.getServiceEnvironmentConfig(ServiceEnvironment.STAGING, "signal-cli-test"); + final var restClient = new SignalRestClient(config.signalServiceConfiguration(), + "signal-cli-test", + null, + false, + 1000L, + new SecureRandom(), + client); + final var api = new RegistrationApiV2(restClient, false); + + try (final var account = SignalAccount.createLinkedAccount(directory.toFile(), + "account", + ServiceEnvironment.STAGING, + Settings.DEFAULT)) { + account.setProvisioningData(number, + ACI_ID, + pni, + "test-password", + new byte[]{1}, + KeyUtils.generateIdentityKeyPair(), + pni == null ? null : KeyUtils.generateIdentityKeyPair(), + KeyUtils.createProfileKey(), + null, + new byte[32], + null); + final var aciKeys = KeyUtils.generatePreKeysForType(account.getAccountData(ServiceIdType.ACI)); + final var pniKeys = pni == null + ? null + : KeyUtils.generatePreKeysForType(account.getAccountData(ServiceIdType.PNI)); + if (statusCode == 200) { + assertEquals(2, + ProvisioningManagerImpl.registerLinkedDevice(api, account, "test-code", aciKeys, pniKeys)); + } else { + final var error = assertThrows(IOException.class, + () -> ProvisioningManagerImpl.registerLinkedDevice(api, + account, + "test-code", + aciKeys, + pniKeys)); + assertEquals(errorMessage, error.getMessage()); + if (statusCode == 403) { + assertInstanceOf(AuthorizationFailedException.class, error); + } + } + assertEquals("PUT", request.get().method()); + assertEquals("/v1/devices/link", request.get().url().encodedPath()); + assertEquals(Credentials.basic(ACI_ID.toString(), "test-password"), request.get().header("Authorization")); + assertTrue(body.get() + .path("accountAttributes") + .path("capabilities") + .path("optionalPhoneNumber") + .asBoolean()); + assertTrue(body.get().hasNonNull("aciSignedPreKey")); + assertTrue(body.get().hasNonNull("aciPqLastResortPreKey")); + assertEquals(pni != null, body.get().has("pniSignedPreKey")); + assertEquals(pni != null, body.get().has("pniPqLastResortPreKey")); + assertEquals(pni != null, body.get().path("accountAttributes").has("pniRegistrationId")); + } finally { + client.dispatcher().executorService().shutdown(); + client.connectionPool().evictAll(); + } + } +} diff --git a/lib/src/test/java/org/asamk/signal/manager/storage/NumberlessAccountTest.java b/lib/src/test/java/org/asamk/signal/manager/storage/NumberlessAccountTest.java new file mode 100644 index 00000000..f637702b --- /dev/null +++ b/lib/src/test/java/org/asamk/signal/manager/storage/NumberlessAccountTest.java @@ -0,0 +1,64 @@ +package org.asamk.signal.manager.storage; + +import org.asamk.signal.manager.Settings; +import org.asamk.signal.manager.api.ServiceEnvironment; +import org.asamk.signal.manager.util.KeyUtils; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.signal.core.models.ServiceId.ACI; +import org.whispersystems.signalservice.api.push.ServiceIdType; + +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class NumberlessAccountTest { + + @TempDir + Path directory; + + @Test + void numberlessLinkedAccountSurvivesReload() throws Exception { + final var aci = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111"); + final var identity = KeyUtils.generateIdentityKeyPair(); + final var salt = new byte[32]; + salt[0] = 42; + try (final var account = SignalAccount.createLinkedAccount(directory.toFile(), + "account", + ServiceEnvironment.STAGING, + Settings.DEFAULT)) { + account.setProvisioningData(null, + aci, + null, + "test-password", + new byte[]{1}, + identity, + null, + KeyUtils.createProfileKey(), + null, + salt, + null); + account.finishLinking(2, KeyUtils.generatePreKeysForType(account.getAccountData(ServiceIdType.ACI)), null); + } + + try (final var account = SignalAccount.load(directory.toFile(), "account", true, Settings.DEFAULT)) { + assertTrue(account.isRegistered()); + assertFalse(account.isPrimaryDevice()); + assertEquals(2, account.getDeviceId()); + assertEquals(aci, account.getAci()); + assertNull(account.getNumber()); + assertNull(account.getPni()); + assertNull(account.getPniIdentityKeyPair()); + assertNull(account.getSignalServiceDataStore().pniOrNull()); + assertArrayEquals(identity.serialize(), account.getAciIdentityKeyPair().serialize()); + assertArrayEquals(salt, account.getAuthCredentialSalt()); + assertNull(account.getAccountAttributesV2().getPniRegistrationId()); + assertNull(account.getAccountAttributesV2().getDiscoverableByPhoneNumber()); + assertTrue(account.getAccountAttributesV2().getCapabilities().getOptionalPhoneNumber()); + } + } +} diff --git a/lib/src/test/java/org/asamk/signal/manager/storage/accounts/AccountsStoreTest.java b/lib/src/test/java/org/asamk/signal/manager/storage/accounts/AccountsStoreTest.java new file mode 100644 index 00000000..5f30dd64 --- /dev/null +++ b/lib/src/test/java/org/asamk/signal/manager/storage/accounts/AccountsStoreTest.java @@ -0,0 +1,208 @@ +package org.asamk.signal.manager.storage.accounts; + +import org.asamk.signal.manager.Settings; +import org.asamk.signal.manager.api.ServiceEnvironment; +import org.asamk.signal.manager.storage.SignalAccount; +import org.asamk.signal.manager.storage.Utils; +import org.asamk.signal.manager.util.KeyUtils; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.signal.core.models.ServiceId.ACI; + +import java.io.IOException; +import java.nio.channels.OverlappingFileLockException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AccountsStoreTest { + + private static final ACI OLD_ACI = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111"); + private static final ACI NEW_ACI = ACI.parseOrThrow("22222222-2222-4222-8222-222222222222"); + + @TempDir + Path directory; + + @Test + void discoversNumberlessAccountsByAciAndKeepsEnvironmentsSeparate() throws Exception { + final var store = new AccountsStore(directory.toFile(), ServiceEnvironment.STAGING, path -> null); + final var aci = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111"); + final var path = store.addAccount(null, aci); + store.addAccount("+12025550123", null); + + final var reopened = new AccountsStore(directory.toFile(), ServiceEnvironment.STAGING, ignored -> null); + assertEquals(2, reopened.getAllAccounts().size()); + assertEquals(path, reopened.getPathByAci(aci)); + assertNull(reopened.getPathByNumber(null)); + assertEquals(Set.of("+12025550123"), reopened.getAllNumbers()); + assertTrue(new AccountsStore(directory.toFile(), ServiceEnvironment.LIVE, ignored -> null).getAllAccounts() + .isEmpty()); + } + + @Test + void updatingAnAciDoesNotLeaveDuplicateNumberlessAccounts() throws Exception { + final var store = new AccountsStore(directory.toFile(), ServiceEnvironment.STAGING, path -> null); + final var aci = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111"); + final var oldPath = store.addAccount(null, aci); + Files.createFile(directory.resolve(oldPath)); + final var newPath = store.addAccount(null, null); + store.updateAccount(newPath, null, aci); + + assertEquals(newPath, store.getPathByAci(aci)); + assertEquals(1, store.getAllAccounts().size()); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void replacingANumberDoesNotRediscoverTheOldAccountAsNumberless(final boolean update) throws Exception { + final var store = new AccountsStore(directory.toFile(), ServiceEnvironment.STAGING, path -> null); + final var oldPath = store.addAccount("+12025550123", OLD_ACI); + Files.createFile(directory.resolve(oldPath)); + final String newPath; + if (update) { + newPath = store.addAccount("+12025550124", NEW_ACI); + store.updateAccount(newPath, "+12025550123", NEW_ACI); + } else { + newPath = store.addAccount("+12025550123", NEW_ACI); + } + + final var reopened = new AccountsStore(directory.toFile(), ServiceEnvironment.STAGING, path -> { + throw new AssertionError("New entries should not require loading account state for discovery"); + }); + assertEquals(Set.of(newPath), getAccountPaths(reopened)); + assertEquals(newPath, reopened.getPathByNumber("+12025550123")); + assertEquals(oldPath, reopened.getPathByAci(OLD_ACI)); + + // Only an explicit account update should turn the old entry into a numberless account. + reopened.updateAccount(oldPath, null, OLD_ACI); + assertEquals(Set.of(oldPath, newPath), getAccountPaths(reopened)); + } + + @ParameterizedTest + @ValueSource(ints = {1, 2}) + void legacyIndexesDistinguishNumberlessAccountsFromSupersededNumberedAccounts(final int version) throws Exception { + createSavedAccount("old", "+12025550123", OLD_ACI); + createSavedAccount("numberless", null, NEW_ACI); + final var legacyIndex = """ + { + "version": %d, + "accounts": [ + {"path": "old", "environment": %s, "number": null, "uuid": "%s"}, + {"path": "numberless", "environment": %s, "number": null, "uuid": "%s"}, + {"path": "current", "environment": "STAGING", "number": "+12025550123", "uuid": null} + ] + } + """.formatted(version, + version == 1 ? "null" : "\"STAGING\"", + OLD_ACI, + version == 1 ? "null" : "\"STAGING\"", + NEW_ACI); + Files.writeString(directory.resolve("accounts.json"), legacyIndex); + + final var store = new AccountsStore(directory.toFile(), ServiceEnvironment.STAGING, this::loadAccountOrNull); + assertEquals(Set.of("numberless", "current"), getAccountPaths(store)); + assertEquals("old", store.getPathByAci(OLD_ACI)); + assertEquals("numberless", store.getPathByAci(NEW_ACI)); + assertEquals(Set.of("+12025550123"), store.getAllNumbers()); + + final var reopened = new AccountsStore(directory.toFile(), ServiceEnvironment.STAGING, path -> { + throw new AssertionError("Resolved entries should not require loading account state again"); + }); + assertEquals(Set.of("numberless", "current"), getAccountPaths(reopened)); + assertTrue(new AccountsStore(directory.toFile(), + ServiceEnvironment.LIVE, + this::loadAccountOrNull).getAllAccounts().isEmpty()); + } + + @Test + void retriesLockedLegacyAccountsAndPersistsSuccessfulClassification() throws Exception { + createSavedAccount("numberless", null, NEW_ACI); + writeLegacyNumberlessEntry(NEW_ACI); + final var store = new AccountsStore(directory.toFile(), ServiceEnvironment.STAGING, this::loadAccountOrNull); + + try (final var account = SignalAccount.load(directory.toFile(), "numberless", false, Settings.DEFAULT)) { + assertTrue(store.getAllAccounts().isEmpty()); + assertEquals("numberless", store.getPathByAci(NEW_ACI)); + final var storage = Utils.createStorageObjectMapper() + .readValue(directory.resolve("accounts.json").toFile(), AccountsStorage.class); + assertNull(storage.accounts().getFirst().numberless()); + } + + assertEquals(Set.of("numberless"), getAccountPaths(store)); + try (final var account = SignalAccount.load(directory.toFile(), "numberless", false, Settings.DEFAULT)) { + final var reopened = new AccountsStore(directory.toFile(), ServiceEnvironment.STAGING, path -> { + throw new AssertionError("A classified account should remain discoverable while locked"); + }); + assertEquals(Set.of("numberless"), getAccountPaths(reopened)); + } + } + + @Test + void retriesLegacyAccountsAfterMissingStateIsRestored() throws Exception { + writeLegacyNumberlessEntry(NEW_ACI); + final var store = new AccountsStore(directory.toFile(), ServiceEnvironment.STAGING, this::loadAccountOrNull); + assertTrue(store.getAllAccounts().isEmpty()); + assertEquals("numberless", store.getPathByAci(NEW_ACI)); + + createSavedAccount("numberless", null, NEW_ACI); + assertEquals(Set.of("numberless"), getAccountPaths(store)); + } + + @Test + void doesNotDiscoverLegacyEntriesWithAMismatchedAci() throws Exception { + createSavedAccount("numberless", null, NEW_ACI); + writeLegacyNumberlessEntry(OLD_ACI); + final var store = new AccountsStore(directory.toFile(), ServiceEnvironment.STAGING, this::loadAccountOrNull); + assertTrue(store.getAllAccounts().isEmpty()); + assertEquals("numberless", store.getPathByAci(OLD_ACI)); + } + + private Set getAccountPaths(final AccountsStore store) throws IOException { + return store.getAllAccounts().stream().map(AccountsStorage.Account::path).collect(Collectors.toSet()); + } + + private void writeLegacyNumberlessEntry(final ACI aci) throws IOException { + final var legacyIndex = """ + { + "version": 2, + "accounts": [ + {"path": "numberless", "environment": "STAGING", "number": null, "uuid": "%s"} + ] + } + """.formatted(aci); + Files.writeString(directory.resolve("accounts.json"), legacyIndex); + } + + private void createSavedAccount(final String path, final String number, final ACI aci) throws IOException { + // Account creation needs a number until the ACI has been assigned. + try (final var account = SignalAccount.create(directory.toFile(), + path, + "+12025550123", + ServiceEnvironment.STAGING, + KeyUtils.generateIdentityKeyPair(), + KeyUtils.generateIdentityKeyPair(), + KeyUtils.createProfileKey(), + Settings.DEFAULT)) { + account.setAci(aci); + account.setNumber(number); + } + } + + private SignalAccount loadAccountOrNull(final String path) { + if (!SignalAccount.accountFileExists(directory.toFile(), path)) { + return null; + } + try { + return SignalAccount.load(directory.toFile(), path, false, Settings.DEFAULT); + } catch (IOException | OverlappingFileLockException e) { + return null; + } + } +} diff --git a/lib/src/test/java/org/asamk/signal/manager/util/PhoneNumberFormatterTest.java b/lib/src/test/java/org/asamk/signal/manager/util/PhoneNumberFormatterTest.java new file mode 100644 index 00000000..5da9932b --- /dev/null +++ b/lib/src/test/java/org/asamk/signal/manager/util/PhoneNumberFormatterTest.java @@ -0,0 +1,25 @@ +package org.asamk.signal.manager.util; + +import org.asamk.signal.manager.api.InvalidNumberException; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class PhoneNumberFormatterTest { + + @Test + void numberlessAccountsCanAddressInternationalPhoneNumbers() throws Exception { + assertEquals("+12025550123", PhoneNumberFormatter.formatNumber("+1 (202) 555-0123", null)); + } + + @Test + void numberlessAccountsCannotInferACountryCode() { + assertThrows(InvalidNumberException.class, () -> PhoneNumberFormatter.formatNumber("2025550123", null)); + } + + @Test + void numberedAccountsStillInferTheirCountryCode() throws Exception { + assertEquals("+12025550123", PhoneNumberFormatter.formatNumber("2025550123", "+12025550124")); + } +} diff --git a/man/signal-cli-dbus.5.adoc b/man/signal-cli-dbus.5.adoc index 34d102b7..7b4a754e 100644 --- a/man/signal-cli-dbus.5.adoc +++ b/man/signal-cli-dbus.5.adoc @@ -15,9 +15,13 @@ signal-cli-dbus - A commandline and dbus interface for the Signal messenger *signal-cli* [--verbose] [--data-dir DATA_DIR] [-a ACCOUNT] [-o {plain-text,json}] daemon [--dbus] [--dbus-system] -*dbus-send* [--system | --session] [--print-reply] --type=method_call --dest="org.asamk.Signal" /org/asamk/Signal[/_] org.asamk.Signal. [string:] [array::] +*dbus-send* [--system | --session] [--print-reply] --type=method_call --dest="org.asamk.Signal" /org/asamk/Signal[/] org.asamk.Signal. [string:] [array::] -Note: when daemon was started without explicit `-a ACCOUNT`, the `dbus-send` command requires adding the phone number in `/org/asamk/Signal/_`. +Note: when daemon was started without explicit `-a ACCOUNT`, use the account's +object path returned by `org.asamk.SignalControl.listAccounts` or +`org.asamk.SignalControl.getAccount`. Numbered accounts use +`/org/asamk/Signal/_`. Accounts without a phone number use +`/org/asamk/Signal/`, with hyphens replaced by underscores. == Description @@ -49,6 +53,11 @@ All Exceptions begin with "org.asamk.Signal.Error." which is omitted here for be Phone numbers always have the format + +For an account without a phone number, use its ACI (Account ID) with the +`signal-cli --dbus -a ACCOUNT` client. ACI selectors also work for numbered accounts. +An account without a phone number cannot infer the country code of a recipient; +use a complete international phone number, an ACI, or a Signal username. + == Methods === SignalControl interface @@ -73,11 +82,29 @@ For example: dbus-send --session --dest=org.asamk.Signal --type=method_call --print-reply /org/asamk/Signal org.asamk.Signal.link string:"My secondary client" | tr '\n' '\0' | sed 's/.*string //g' | sed 's/\"//g' | qrencode -s10 -tANSI256 ---- -listAccounts() -> accountList:: +listAccounts() -> accountList:: * accountList : Array of all attached accounts in DBus object path form Exceptions: None +getAccount(identifier) -> account:: +* identifier : Phone number or ACI of an attached account +* account : DBus object path of the account + +Exceptions: org.asamk.SignalControl.Error.Failure if the account is unknown + +startLink() -> deviceLinkUri:: +* deviceLinkUri : Provisioning URI to scan with the primary Signal device + +finishLink(deviceLinkUri, newDeviceName) -> identifier:: +* deviceLinkUri : URI returned by startLink +* newDeviceName : Name for the linked device +* identifier : Phone number of the linked account, or its ACI when it has no phone number + +Both linking methods support accounts with or without a phone number. + +Exceptions: org.asamk.SignalControl.Error.Failure + register(number, voiceVerification) -> <>:: * number : Phone number * voiceVerification : true = use voice verification; false = use SMS verification @@ -115,9 +142,10 @@ Exceptions: None The following methods listen to the account's object path, which is constructed as follows: -- for single-account mode: "/org/asamk/Signal/" -- for multi-account mode: "/org/asamk/Signal/" + DBusNumber -* DBusNumber: account's phone number, with underscore (_) replacing plus (+) +- for single-account mode: "/org/asamk/Signal" +- for multi-account mode: "/org/asamk/Signal/" + DBusIdentifier +* DBusIdentifier: account's phone number with underscore (_) replacing plus (+), + or its ACI with underscores replacing hyphens when there is no phone number getContactName(number) -> name:: * number : Phone number @@ -135,7 +163,12 @@ May result in e.g. two entries if a contact and profile name is set. Exceptions: None getSelfNumber() -> number:: -* number : Your phone number +* number : Your phone number, or an empty string for an account without a phone number + +Exceptions: None + +getSelfACI() -> aci:: +* aci : Your account's ACI (Account ID), available with or without a phone number Exceptions: None @@ -167,6 +200,14 @@ This is a concatenated list of all defined contacts as well of profiles known (e Exceptions: None +listRecipientIdentifiers() -> identifiers:: +* identifiers : String array of known recipient identifiers, including recipients without a phone number + +Uses phone numbers where known and includes ACIs for recipients without a phone +number. Unlike `listNumbers`, this method also includes numberless recipients. + +Exceptions: None + removePin() -> <>:: Removes registration PIN protection. diff --git a/man/signal-cli-jsonrpc.5.adoc b/man/signal-cli-jsonrpc.5.adoc index b9abb8ce..b9162925 100644 --- a/man/signal-cli-jsonrpc.5.adoc +++ b/man/signal-cli-jsonrpc.5.adoc @@ -76,6 +76,9 @@ Messages are then sent similar to the automatic mode, but wrapped in a subscript When the daemon/jsonRpc command is started without an account parameter (-a), signal-cli will provide all local accounts and additional commands to register (`register`) and link (`startLink`, `finishLink`) new accounts. In multi-account mode, requests for a single account require an additional `account` param. +For an account without a phone number, this parameter must be its ACI (Account ID). +`listAccounts` returns the ACI in `aci` and a null `number` for these accounts. +Receive events identify them by ACI in the `account` field. REQUEST: `{"jsonrpc":"2.0","method":"listGroups","id":"my special mark","params":{"account":"+33123456789"}}` @@ -132,7 +135,10 @@ Params: REQUEST: `{"jsonrpc":"2.0","method":"finishLink","id":"6","params":{"deviceLinkUri":"sgnl://linkdevice?uuid=X&pub_key=X","deviceName":"new-name"}}` -RESPONSE: `{"jsonrpc":"2.0","result":{"deviceLinkUri":"sgnl://linkdevice?uuid=X&pub_key=X"},"id":"6"}` +RESPONSE: `{"jsonrpc":"2.0","result":{"number":null,"aci":"11111111-1111-4111-8111-111111111111"},"id":"6"}` + +The `number` field contains the phone number for a numbered account. Linking a numberless +account uses the same QR flow; no Account Key is supplied to `finishLink`. == Examples diff --git a/man/signal-cli.1.adoc b/man/signal-cli.1.adoc index 9e4d922e..375bfbb2 100644 --- a/man/signal-cli.1.adoc +++ b/man/signal-cli.1.adoc @@ -65,6 +65,7 @@ Make sure you have full read/write access to the given directory. *-a* ACCOUNT, *--account* ACCOUNT:: Specify your phone number, that will be your identifier. The phone number must include the country calling code, i.e. the number must start with a "+" sign. +An ACI (Account ID) can also select an existing account and is required for an account without a phone number. This flag must not be given for the `link` command. It is optional for the `daemon` command. @@ -250,6 +251,10 @@ If you want to connect to another signal-cli instance, you can just use this URI If you want to link to an Android/iOS device, scan the QR code that signal-cli prints in the terminal with the Signal app. If signal-cli is not running in a terminal, only the URI is printed and you can create a QR code from it yourself (e.g. with qrencode). +Accounts without a phone number can also be linked. +After linking, use the account's ACI (Account ID) with `-a`; recipient phone numbers must include the international country code. +Creating or recovering a primary account without a phone number using an Account Key is not supported. + *-n* NAME, *--name* NAME:: Optionally specify a name to describe this new device. By default "cli" will be used. diff --git a/src/main/java/org/asamk/Signal.java b/src/main/java/org/asamk/Signal.java index 728ff4c6..2f5b712d 100644 --- a/src/main/java/org/asamk/Signal.java +++ b/src/main/java/org/asamk/Signal.java @@ -20,6 +20,9 @@ import java.util.Map; */ public interface Signal extends DBusInterface { + /** + * Returns the account's phone number, or an empty string for a numberless account. + */ String getSelfNumber(); String getSelfACI(); @@ -186,6 +189,8 @@ public interface Signal extends DBusInterface { List listNumbers(); + List listRecipientIdentifiers(); + List getContactNumber(final String name) throws Error.Failure; @Deprecated diff --git a/src/main/java/org/asamk/SignalControl.java b/src/main/java/org/asamk/SignalControl.java index 3eea790f..540039cd 100644 --- a/src/main/java/org/asamk/SignalControl.java +++ b/src/main/java/org/asamk/SignalControl.java @@ -37,7 +37,10 @@ public interface SignalControl extends DBusInterface { List listAccounts(); - DBusPath getAccount(String number); + /** + * Resolves a phone number or ACI to an exported account's object path. + */ + DBusPath getAccount(String identifier) throws Error.Failure; interface Error { diff --git a/src/main/java/org/asamk/signal/App.java b/src/main/java/org/asamk/signal/App.java index fa18e597..aa2441dd 100644 --- a/src/main/java/org/asamk/signal/App.java +++ b/src/main/java/org/asamk/signal/App.java @@ -208,14 +208,15 @@ public class App { private static String getAccountIfOnlyOne(final SignalAccountFiles signalAccountFiles) throws IOErrorException, UserErrorException { Set accounts; try { - accounts = signalAccountFiles.getAllLocalAccountNumbers(); + accounts = signalAccountFiles.getAllLocalAccountIdentifiers(); } catch (IOException e) { throw new IOErrorException("Failed to load local accounts file", e); } if (accounts.isEmpty()) { throw new UserErrorException("No local users found, you first need to register or link an account"); } else if (accounts.size() > 1) { - throw new UserErrorException("Multiple users found, you need to specify an account (phone number) with -a"); + throw new UserErrorException( + "Multiple users found, you need to specify an account (phone number or ACI) with -a"); } return accounts.stream().findFirst().get(); } diff --git a/src/main/java/org/asamk/signal/ReceiveMessageHandler.java b/src/main/java/org/asamk/signal/ReceiveMessageHandler.java index d179aed0..e3127a67 100644 --- a/src/main/java/org/asamk/signal/ReceiveMessageHandler.java +++ b/src/main/java/org/asamk/signal/ReceiveMessageHandler.java @@ -39,7 +39,7 @@ public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler { writer.println("Envelope from: {} (device: {}) to {}", source.map(this::formatContact).orElse("unknown source"), envelope.sourceDevice(), - m.getSelfNumber()); + m.getSelfIdentifier()); writer.println("Timestamp: {}", DateUtils.formatTimestamp(envelope.timestamp())); writer.println("Server timestamps: received: {} delivered: {}", DateUtils.formatTimestamp(envelope.serverReceivedTimestamp()), @@ -55,13 +55,13 @@ public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler { final var recipientName = e.getSender().getLegacyIdentifier(); writer.println( "Use 'signal-cli -a {} listIdentities -n {}', verify the key and run 'signal-cli -a {} trust -v \"FINGER_PRINT\" {}' to mark it as trusted", - m.getSelfNumber(), + m.getSelfIdentifier(), recipientName, - m.getSelfNumber(), + m.getSelfIdentifier(), recipientName); writer.println( "If you don't care about security, use 'signal-cli -a {} trust -a {}' to trust it without verification", - m.getSelfNumber(), + m.getSelfIdentifier(), recipientName); } else { writer.println("Exception: {} ({})", exception.getMessage(), exception.getClass().getSimpleName()); diff --git a/src/main/java/org/asamk/signal/commands/FinishLinkCommand.java b/src/main/java/org/asamk/signal/commands/FinishLinkCommand.java index c9282616..8a8f8454 100644 --- a/src/main/java/org/asamk/signal/commands/FinishLinkCommand.java +++ b/src/main/java/org/asamk/signal/commands/FinishLinkCommand.java @@ -54,9 +54,9 @@ public class FinishLinkCommand implements JsonRpcMultiCommand { - final var jsonAccounts = accountNumbers.stream().map(JsonAccount::new).toList(); + final var jsonAccounts = managers.stream() + .map(m -> new JsonAccount(m.getSelfNumber(), m.getSelfACI())) + .toList(); jsonWriter.write(jsonAccounts); } case PlainTextWriter plainTextWriter -> { - for (final var number : accountNumbers) { - plainTextWriter.println("Number: {}", number); + for (final var manager : managers) { + plainTextWriter.println(manager.getSelfNumber() != null ? "Number: {}" : "ACI: {}", + manager.getSelfIdentifier()); } } } } - private record JsonAccount(String number) {} + private record JsonAccount(String number, String aci) {} } diff --git a/src/main/java/org/asamk/signal/dbus/DbusCommandHandler.java b/src/main/java/org/asamk/signal/dbus/DbusCommandHandler.java index 546a433f..59c1086a 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusCommandHandler.java +++ b/src/main/java/org/asamk/signal/dbus/DbusCommandHandler.java @@ -55,7 +55,7 @@ public class DbusCommandHandler { try { if (command instanceof ProvisioningCommand c) { if (account != null) { - throw new UserErrorException("You cannot specify a account (phone number) when linking"); + throw new UserErrorException("You cannot specify an account when linking"); } handleProvisioningCommand(c, dBusConn, busname, commandHandler); @@ -74,7 +74,9 @@ public class DbusCommandHandler { throw new UserErrorException("Command only works in multi-account mode"); } - var accountObjectPath = account == null ? tryGetSingleAccountObjectPath(dBusConn, busname) : null; + var accountObjectPath = account == null + ? tryGetSingleAccountObjectPath(dBusConn, busname) + : getAccountObjectPath(account, dBusConn, busname); if (accountObjectPath == null) { accountObjectPath = DbusConfig.getObjectPath(account); } @@ -86,6 +88,24 @@ public class DbusCommandHandler { } } + private static String getAccountObjectPath( + final String account, + final DBusConnection dBusConn, + final String busname + ) throws DBusException, CommandException { + final var control = dBusConn.getRemoteObject(busname, DbusConfig.getObjectPath(), SignalControl.class); + try { + return control.getAccount(account).getPath(); + } catch (UnknownMethod e) { + // A single-account daemon exports Signal directly at the base path. + final var signal = dBusConn.getRemoteObject(busname, DbusConfig.getObjectPath(), Signal.class); + if (!account.equals(signal.getSelfNumber()) && !account.equals(signal.getSelfACI())) { + throw new UserErrorException("Unknown account: " + account); + } + return DbusConfig.getObjectPath(); + } + } + private static String tryGetSingleAccountObjectPath( final DBusConnection dBusConn, final String busname @@ -97,7 +117,7 @@ public class DbusCommandHandler { throw new UserErrorException("No local users found, you first need to register or link an account"); } else if (accounts.size() > 1) { throw new UserErrorException( - "Multiple users found, you need to specify an account (phone number) with -a"); + "Multiple users found, you need to specify an account (phone number or ACI) with -a"); } return accounts.getFirst().getPath(); diff --git a/src/main/java/org/asamk/signal/dbus/DbusHandler.java b/src/main/java/org/asamk/signal/dbus/DbusHandler.java index 2ed6d11f..e74e141b 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusHandler.java +++ b/src/main/java/org/asamk/signal/dbus/DbusHandler.java @@ -67,7 +67,7 @@ public class DbusHandler implements AutoCloseable { } }); c.addOnManagerRemovedHandler(m -> { - final var path = DbusConfig.getObjectPath(m.getSelfNumber()); + final var path = DbusConfig.getObjectPath(m.getSelfIdentifier()); try { final var object = connection.getExportedObject(null, path); if (object instanceof DbusSignalImpl dbusSignal) { @@ -135,11 +135,11 @@ public class DbusHandler implements AutoCloseable { final var signal = new DbusSignalImpl(m, conn, objectPath, noReceiveOnStart); closeables.add(signal); - return Thread.ofPlatform().name("dbus-init-" + m.getSelfNumber()).start(signal::initObjects); + return Thread.ofPlatform().name("dbus-init-" + m.getSelfIdentifier()).start(signal::initObjects); } private Thread exportManager(final DBusConnection conn, final Manager m) { - final var objectPath = DbusConfig.getObjectPath(m.getSelfNumber()); + final var objectPath = DbusConfig.getObjectPath(m.getSelfIdentifier()); return exportDbusObject(conn, objectPath, m); } diff --git a/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java b/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java index 74dc12a1..091816cc 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java @@ -77,6 +77,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.UUID; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; import java.util.function.Function; @@ -110,7 +111,8 @@ public class DbusManagerImpl implements Manager { @Override public String getSelfNumber() { - return signal.getSelfNumber(); + final var number = signal.getSelfNumber(); + return number.isEmpty() ? null : number; } @Override @@ -438,7 +440,7 @@ public class DbusManagerImpl implements Manager { numbers.forEach(n -> signal.sendTyping(n, action == TypingAction.STOP)); return 0L; }, () -> { - signal.sendTyping(signal.getSelfNumber(), action == TypingAction.STOP); + signal.sendTyping(getSelfIdentifier(), action == TypingAction.STOP); return 0L; }, groupId -> { signal.sendGroupTyping(groupId, action == TypingAction.STOP); @@ -486,7 +488,7 @@ public class DbusManagerImpl implements Manager { ) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException { return handleMessage(recipients, numbers -> signal.sendRemoteDeleteMessage(targetSentTimestamp, numbers), - () -> signal.sendRemoteDeleteMessage(targetSentTimestamp, signal.getSelfNumber()), + () -> signal.sendRemoteDeleteMessage(targetSentTimestamp, getSelfIdentifier()), groupId -> signal.sendGroupRemoteDeleteMessage(targetSentTimestamp, groupId)); } @@ -510,7 +512,7 @@ public class DbusManagerImpl implements Manager { remove, targetAuthor.getIdentifier(), targetSentTimestamp, - signal.getSelfNumber()), + getSelfIdentifier()), groupId -> signal.sendGroupMessageReaction(emoji, remove, targetAuthor.getIdentifier(), @@ -809,43 +811,47 @@ public class DbusManagerImpl implements Manager { final Collection addresses, final Optional name ) { - final var numbers = addresses.stream() - .filter(s -> s instanceof RecipientIdentifier.Number) - .map(s -> ((RecipientIdentifier.Number) s).number()) + final var identifiers = addresses.stream() + .map(RecipientIdentifier.Single::getIdentifier) .collect(Collectors.toSet()); - return signal.listNumbers().stream().filter(n -> addresses.isEmpty() || numbers.contains(n)).map(n -> { - final var contactBlocked = signal.isContactBlocked(n); - if (blocked.isPresent() && blocked.get() != contactBlocked) { - return null; - } - final var contactName = signal.getContactName(n); - if (onlyContacts && contactName.isEmpty()) { - return null; - } - if (name.isPresent() && !name.get().equals(contactName)) { - return null; - } - return Recipient.newBuilder() - .withAddress(new RecipientAddress(n)) - .withContact(new Contact(contactName, - null, - null, - null, - null, - null, - null, - 0, - 1, - 0, - false, - contactBlocked, - 0, - false, - false, - false, - null)) - .build(); - }).filter(Objects::nonNull).toList(); + return signal.listRecipientIdentifiers() + .stream() + .filter(n -> addresses.isEmpty() || identifiers.contains(n)) + .map(n -> { + final var contactBlocked = signal.isContactBlocked(n); + if (blocked.isPresent() && blocked.get() != contactBlocked) { + return null; + } + final var contactName = signal.getContactName(n); + if (onlyContacts && contactName.isEmpty()) { + return null; + } + if (name.isPresent() && !name.get().equals(contactName)) { + return null; + } + return Recipient.newBuilder() + .withAddress(getRecipientAddress(n)) + .withContact(new Contact(contactName, + null, + null, + null, + null, + null, + null, + 0, + 1, + 0, + false, + contactBlocked, + 0, + false, + false, + false, + null)) + .build(); + }) + .filter(Objects::nonNull) + .toList(); } @Override @@ -870,16 +876,16 @@ public class DbusManagerImpl implements Manager { (String) group.get("Description").getValue(), GroupInviteLinkUrl.fromUri((String) group.get("GroupInviteLink").getValue()), ((List) group.get("Members").getValue()).stream() - .map(m -> new GroupMember(new RecipientAddress(m), admins.contains(m), null, null)) + .map(m -> new GroupMember(getRecipientAddress(m), admins.contains(m), null, null)) .collect(Collectors.toSet()), ((List) group.get("PendingMembers").getValue()).stream() - .map(RecipientAddress::new) + .map(DbusManagerImpl::getRecipientAddress) .collect(Collectors.toSet()), ((List) group.get("RequestingMembers").getValue()).stream() - .map(RecipientAddress::new) + .map(DbusManagerImpl::getRecipientAddress) .collect(Collectors.toSet()), ((List) group.get("Banned").getValue()).stream() - .map(RecipientAddress::new) + .map(DbusManagerImpl::getRecipientAddress) .collect(Collectors.toSet()), (boolean) group.get("IsBlocked").getValue(), (int) group.get("MessageExpirationTimer").getValue(), @@ -910,7 +916,10 @@ public class DbusManagerImpl implements Manager { final var group = getRemoteObject(identityPath, Signal.Identity.class).GetAll("org.asamk.Signal.Identity"); final var aci = (String) group.get("Uuid").getValue(); final var number = (String) group.get("Number").getValue(); - return new Identity(new RecipientAddress(aci, null, number, null), + return new Identity(new RecipientAddress(aci.isEmpty() ? null : aci, + null, + number.isEmpty() ? null : number, + null), (byte[]) group.get("Fingerprint").getValue(), (String) group.get("SafetyNumber").getValue(), (byte[]) group.get("ScannableSafetyNumber").getValue(), @@ -1073,6 +1082,14 @@ public class DbusManagerImpl implements Manager { return string == null ? "" : string; } + private static RecipientAddress getRecipientAddress(final String identifier) { + try { + return new RecipientAddress(UUID.fromString(identifier)); + } catch (IllegalArgumentException e) { + return new RecipientAddress(identifier); + } + } + private T getRemoteObject(final DBusPath path, final Class type) { try { return connection.getRemoteObject(busname, path.getPath(), type); @@ -1085,7 +1102,7 @@ public class DbusManagerImpl implements Manager { try { this.dbusMsgHandler = messageReceived -> { final var extras = messageReceived.getExtras(); - final var envelope = new MessageEnvelope(Optional.of(new RecipientAddress(messageReceived.getSender())), + final var envelope = new MessageEnvelope(Optional.of(getRecipientAddress(messageReceived.getSender())), 0, messageReceived.getTimestamp(), 0, @@ -1132,7 +1149,7 @@ public class DbusManagerImpl implements Manager { connection.addSigHandler(Signal.MessageReceivedV2.class, signal, this.dbusMsgHandler); this.dbusEditMsgHandler = messageReceived -> { final var extras = messageReceived.getExtras(); - final var envelope = new MessageEnvelope(Optional.of(new RecipientAddress(messageReceived.getSender())), + final var envelope = new MessageEnvelope(Optional.of(getRecipientAddress(messageReceived.getSender())), 0, messageReceived.getTimestamp(), 0, @@ -1186,7 +1203,7 @@ public class DbusManagerImpl implements Manager { case "delivery" -> MessageEnvelope.Receipt.Type.DELIVERY; default -> MessageEnvelope.Receipt.Type.UNKNOWN; }; - final var envelope = new MessageEnvelope(Optional.of(new RecipientAddress(receiptReceived.getSender())), + final var envelope = new MessageEnvelope(Optional.of(getRecipientAddress(receiptReceived.getSender())), 0, receiptReceived.getTimestamp(), 0, @@ -1207,7 +1224,7 @@ public class DbusManagerImpl implements Manager { this.dbusSyncHandler = syncReceived -> { final var extras = syncReceived.getExtras(); - final var envelope = new MessageEnvelope(Optional.of(new RecipientAddress(syncReceived.getSource())), + final var envelope = new MessageEnvelope(Optional.of(getRecipientAddress(syncReceived.getSource())), 0, syncReceived.getTimestamp(), 0, @@ -1221,7 +1238,7 @@ public class DbusManagerImpl implements Manager { syncReceived.getTimestamp(), syncReceived.getDestination().isEmpty() ? Optional.empty() - : Optional.of(new RecipientAddress(syncReceived.getDestination())), + : Optional.of(getRecipientAddress(syncReceived.getDestination())), Set.of(), Optional.of(new MessageEnvelope.Data(syncReceived.getTimestamp(), syncReceived.getGroupId().length > 0 @@ -1326,7 +1343,7 @@ public class DbusManagerImpl implements Manager { final List>> mentions = getValue(extras, "mentions"); return mentions.stream() - .map(a -> new MessageEnvelope.Data.Mention(new RecipientAddress(this.getValue(a, "recipient")), + .map(a -> new MessageEnvelope.Data.Mention(getRecipientAddress(this.getValue(a, "recipient")), getValue(a, "start"), getValue(a, "length"))) .toList(); diff --git a/src/main/java/org/asamk/signal/dbus/DbusMultiAccountManagerImpl.java b/src/main/java/org/asamk/signal/dbus/DbusMultiAccountManagerImpl.java index 2b9a0a77..da88343b 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusMultiAccountManagerImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusMultiAccountManagerImpl.java @@ -43,14 +43,6 @@ public class DbusMultiAccountManagerImpl implements MultiAccountManager { this.busname = busname; } - @Override - public List getAccountNumbers() { - return signalControl.listAccounts() - .stream() - .map(a -> getRemoteObject(a, Signal.class).getSelfNumber()) - .toList(); - } - @Override public List getManagers() { return signalControl.listAccounts() @@ -74,8 +66,8 @@ public class DbusMultiAccountManagerImpl implements MultiAccountManager { } @Override - public Manager getManager(final String phoneNumber) { - return new DbusManagerImpl(getRemoteObject(signalControl.getAccount(phoneNumber), Signal.class), + public Manager getManager(final String identifier) { + return new DbusManagerImpl(getRemoteObject(signalControl.getAccount(identifier), Signal.class), connection, busname); } diff --git a/src/main/java/org/asamk/signal/dbus/DbusSignalControlImpl.java b/src/main/java/org/asamk/signal/dbus/DbusSignalControlImpl.java index a20789eb..d139d034 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusSignalControlImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusSignalControlImpl.java @@ -159,11 +159,19 @@ public class DbusSignalControlImpl implements org.asamk.SignalControl { @Override public List listAccounts() { - return c.getAccountNumbers().stream().map(u -> new DBusPath(DbusConfig.getObjectPath(u))).toList(); + return c.getManagers() + .stream() + .map(m -> new DBusPath(DbusConfig.getObjectPath(m.getSelfIdentifier()))) + .toList(); } @Override - public DBusPath getAccount(final String number) { - return new DBusPath(DbusConfig.getObjectPath(number)); + public DBusPath getAccount(final String identifier) { + final var manager = c.getManagers() + .stream() + .filter(m -> identifier.equals(m.getSelfNumber()) || identifier.equals(m.getSelfACI())) + .findFirst() + .orElseThrow(() -> new Error.Failure("Unknown account: " + identifier)); + return new DBusPath(DbusConfig.getObjectPath(manager.getSelfIdentifier())); } } diff --git a/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java b/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java index 30d7e4a0..906fe101 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java @@ -138,7 +138,7 @@ public class DbusSignalImpl implements Signal, AutoCloseable { @Override public String getSelfNumber() { - return m.getSelfNumber(); + return emptyIfNull(m.getSelfNumber()); } @Override @@ -803,6 +803,15 @@ public class DbusSignalImpl implements Signal, AutoCloseable { .toList(); } + @Override + public List listRecipientIdentifiers() { + return m.getRecipients(false, Optional.empty(), Set.of(), Optional.empty()) + .stream() + .map(r -> r.getAddress().getLegacyIdentifier()) + .distinct() + .toList(); + } + @Override public List getContactNumber(final String name) { return m.getRecipients(false, Optional.empty(), Set.of(), Optional.of(name)) diff --git a/src/main/java/org/asamk/signal/json/JsonReceiveMessageHandler.java b/src/main/java/org/asamk/signal/json/JsonReceiveMessageHandler.java index ca0dba2b..cb2fa4e1 100644 --- a/src/main/java/org/asamk/signal/json/JsonReceiveMessageHandler.java +++ b/src/main/java/org/asamk/signal/json/JsonReceiveMessageHandler.java @@ -23,7 +23,7 @@ public class JsonReceiveMessageHandler implements Manager.ReceiveMessageHandler @Override public void handleMessage(MessageEnvelope envelope, Throwable exception) { final var object = new HashMap(); - object.put("account", m.getSelfNumber()); + object.put("account", m.getSelfIdentifier()); if (exception != null) { object.put("exception", JsonError.from(exception)); } diff --git a/src/main/resources/META-INF/native-image/org.asamk/signal-cli/reachability-metadata.json b/src/main/resources/META-INF/native-image/org.asamk/signal-cli/reachability-metadata.json index 60ff4c40..2c4f242d 100644 --- a/src/main/resources/META-INF/native-image/org.asamk/signal-cli/reachability-metadata.json +++ b/src/main/resources/META-INF/native-image/org.asamk/signal-cli/reachability-metadata.json @@ -4012,7 +4012,8 @@ "java.lang.String", "java.lang.String", "java.lang.String", - "java.lang.String" + "java.lang.String", + "java.lang.Boolean" ] }, { @@ -4023,6 +4024,10 @@ "name": "number", "parameterTypes": [] }, + { + "name": "numberless", + "parameterTypes": [] + }, { "name": "path", "parameterTypes": [] diff --git a/src/test/java/org/asamk/signal/dbus/NumberlessDbusTest.java b/src/test/java/org/asamk/signal/dbus/NumberlessDbusTest.java new file mode 100644 index 00000000..cf6520f5 --- /dev/null +++ b/src/test/java/org/asamk/signal/dbus/NumberlessDbusTest.java @@ -0,0 +1,415 @@ +package org.asamk.signal.dbus; + +import org.asamk.Signal; +import org.asamk.SignalControl; +import org.asamk.signal.DbusConfig; +import org.asamk.signal.commands.CommandHandler; +import org.asamk.signal.commands.ListGroupsCommand; +import org.asamk.signal.commands.LocalCommand; +import org.asamk.signal.commands.exceptions.UserErrorException; +import org.asamk.signal.manager.Manager; +import org.asamk.signal.manager.api.Group; +import org.asamk.signal.manager.api.GroupId; +import org.asamk.signal.manager.api.GroupMember; +import org.asamk.signal.manager.api.GroupPermission; +import org.asamk.signal.manager.api.Identity; +import org.asamk.signal.manager.api.MessageEnvelope; +import org.asamk.signal.manager.api.Recipient; +import org.asamk.signal.manager.api.RecipientAddress; +import org.asamk.signal.manager.api.RecipientIdentifier; +import org.asamk.signal.manager.api.SendMessageResults; +import org.asamk.signal.manager.api.TrustLevel; +import org.asamk.signal.manager.api.TypingAction; +import org.asamk.signal.manager.internal.MultiAccountManagerImpl; +import org.freedesktop.dbus.DBusPath; +import org.freedesktop.dbus.connections.impl.DBusConnection; +import org.freedesktop.dbus.connections.impl.DBusConnectionBuilder; +import org.freedesktop.dbus.exceptions.DBusExecutionException; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Proxy; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class NumberlessDbusTest { + + private static final String NUMBER = "+12025550123"; + private static final String ACI = "11111111-1111-4111-8111-111111111111"; + private static final String OTHER_ACI = "22222222-2222-4222-8222-222222222222"; + private static final String THIRD_ACI = "33333333-3333-4333-8333-333333333333"; + private static final String NUMBERLESS_PATH = "/org/asamk/Signal/11111111_1111_4111_8111_111111111111"; + private static final String NUMBERED_PATH = "/org/asamk/Signal/_12025550123"; + + @Test + void resolvesNumberlessAndNumberedAccountsWithoutChangingNumberedPaths() { + final var numberless = new TestAccount(null, ACI); + final var numbered = new TestAccount(NUMBER, OTHER_ACI); + final var accounts = new MultiAccountManagerImpl(List.of(numberless.manager, numbered.manager), null); + final var control = new DbusSignalControlImpl(accounts, DbusConfig.getObjectPath()); + + assertEquals(Set.of(new DBusPath(NUMBERLESS_PATH), new DBusPath(NUMBERED_PATH)), + Set.copyOf(control.listAccounts())); + assertEquals(NUMBERLESS_PATH, control.getAccount(ACI).getPath()); + assertEquals(NUMBERED_PATH, control.getAccount(NUMBER).getPath()); + assertEquals(NUMBERED_PATH, control.getAccount(OTHER_ACI).getPath()); + assertThrows(SignalControl.Error.Failure.class, () -> control.getAccount(THIRD_ACI)); + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = NUMBER) + void mapsOptionalPhoneNumbersAndAddressesSelfByAnAvailableIdentifier(final String number) throws Exception { + final var account = new TestAccount(number, ACI); + final var signal = new DbusSignalImpl(account.manager, null, DbusConfig.getObjectPath(), true); + final var client = new DbusManagerImpl(signal, null, "org.asamk.Signal.Test"); + + assertEquals(number == null ? "" : number, signal.getSelfNumber()); + assertEquals(number, client.getSelfNumber()); + assertEquals(number == null ? ACI : number, client.getSelfIdentifier()); + checkSelfOperations(client, + account, + number == null + ? new RecipientIdentifier.Uuid(UUID.fromString(ACI)) + : new RecipientIdentifier.Number(number)); + } + + // Run with: dbus-run-session -- ./gradlew --no-daemon :test --tests '*NumberlessDbusTest*' --rerun-tasks + @Nested + @Timeout(20) + @EnabledIfEnvironmentVariable(named = "DBUS_SESSION_BUS_ADDRESS", matches = ".+") + class SessionBus { + + @Test + void exportsMixedAccountsRoutesAciSelectorsAndRemovesOnlyTheClosedAccount() throws Exception { + final var numberless = new TestAccount(null, ACI); + final var numbered = new TestAccount(NUMBER, OTHER_ACI); + final var secondNumberless = new TestAccount(null, THIRD_ACI); + final var accounts = new MultiAccountManagerImpl(List.of(numberless.manager, + numbered.manager, + secondNumberless.manager), null); + final var busname = newBusname(); + try (final var server = new DbusHandler(false, + busname, + accounts, + true); final var connection = DBusConnectionBuilder.forSessionBus().withShared(false).build()) { + server.init(); + final var control = connection.getRemoteObject(busname, + DbusConfig.getObjectPath(), + SignalControl.class); + assertEquals(Set.of(NUMBERLESS_PATH, NUMBERED_PATH, DbusConfig.getObjectPath(THIRD_ACI)), + control.listAccounts().stream().map(DBusPath::getPath).collect(Collectors.toSet())); + assertEquals(NUMBERED_PATH, control.getAccount(OTHER_ACI).getPath()); + assertEquals(NUMBERED_PATH, control.getAccount(NUMBER).getPath()); + assertEquals(NUMBERLESS_PATH, control.getAccount(ACI).getPath()); + assertEquals(ACI, selectAccount(ACI, connection, busname)); + assertEquals(OTHER_ACI, selectAccount(OTHER_ACI, connection, busname)); + assertEquals(OTHER_ACI, selectAccount(NUMBER, connection, busname)); + assertThrows(UserErrorException.class, () -> selectAccount(null, connection, busname)); + + final var client = new DbusMultiAccountManagerImpl(control, connection, busname); + assertEquals(3, client.getManagers().size()); + assertEquals(2, client.getManagers().stream().filter(m -> m.getSelfNumber() == null).count()); + assertNull(client.getManager(ACI).getSelfNumber()); + assertEquals(NUMBER, client.getManager(OTHER_ACI).getSelfNumber()); + + final var removed = connection.getRemoteObject(busname, NUMBERLESS_PATH, Signal.class); + numberless.manager.close(); + assertEquals(2, control.listAccounts().size()); + assertThrows(SignalControl.Error.Failure.class, () -> control.getAccount(ACI)); + assertThrows(DBusExecutionException.class, removed::getSelfACI); + assertEquals(THIRD_ACI, client.getManager(THIRD_ACI).getSelfACI()); + assertEquals(NUMBER, client.getManager(NUMBER).getSelfNumber()); + } + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = NUMBER) + void selectsSingleAccountDaemonsImplicitlyAndByAci(final String number) throws Exception { + final var account = new TestAccount(number, ACI); + final var busname = newBusname(); + try (final var server = new DbusHandler(false, + busname, + account.manager, + true); final var connection = DBusConnectionBuilder.forSessionBus().withShared(false).build()) { + server.init(); + assertEquals(ACI, selectAccount(null, connection, busname)); + assertEquals(ACI, selectAccount(ACI, connection, busname)); + if (number != null) { + assertEquals(ACI, selectAccount(number, connection, busname)); + } + assertThrows(UserErrorException.class, () -> selectAccount(OTHER_ACI, connection, busname)); + } + } + + @Test + void preservesAciAddressesInContactsGroupMembersAndIdentities() throws Exception { + final var account = new TestAccount(null, ACI); + final var contact = new RecipientAddress(UUID.fromString(OTHER_ACI)); + final var numberedContact = new RecipientAddress(NUMBER); + account.recipients = List.of(Recipient.newBuilder().withAddress(contact).build(), + Recipient.newBuilder().withAddress(numberedContact).build()); + account.identities = List.of(new Identity(contact, + new byte[]{1}, + "test", + new byte[]{2}, + TrustLevel.TRUSTED_UNVERIFIED, + 123)); + final var groupId = GroupId.unknownVersion(new byte[32]); + account.groups = List.of(new Group(groupId, + "Test group", + "", + null, + Set.of(new GroupMember(contact, true, null, null), + new GroupMember(numberedContact, false, null, null)), + Set.of(contact), + Set.of(contact), + Set.of(contact), + false, + 0, + GroupPermission.EVERY_MEMBER, + GroupPermission.EVERY_MEMBER, + GroupPermission.EVERY_MEMBER, + true, + true, + false)); + final var busname = newBusname(); + try (final var server = new DbusHandler(false, + busname, + account.manager, + true); final var connection = DBusConnectionBuilder.forSessionBus().withShared(false).build()) { + server.init(); + final var signal = connection.getRemoteObject(busname, DbusConfig.getObjectPath(), Signal.class); + assertEquals(List.of(NUMBER), signal.listNumbers()); + assertEquals(Set.of(OTHER_ACI, NUMBER), Set.copyOf(signal.listRecipientIdentifiers())); + try (final var client = new DbusManagerImpl(signal, connection, busname)) { + final var contacts = client.getRecipients(false, + Optional.empty(), + Set.of(new RecipientIdentifier.Uuid(UUID.fromString(OTHER_ACI))), + Optional.empty()); + assertEquals(1, contacts.size()); + assertEquals(contact, contacts.getFirst().getAddress()); + assertEquals(contact, client.getIdentities().getFirst().recipient()); + final var group = client.getGroups().getFirst(); + assertEquals(Set.of(contact, numberedContact), + group.members().stream().map(GroupMember::recipientAddress).collect(Collectors.toSet())); + assertEquals(Set.of(contact), group.pendingMembers()); + assertEquals(Set.of(contact), group.requestingMembers()); + assertEquals(Set.of(contact), group.bannedMembers()); + } + } + } + + @Test + void sendsAndReceivesNumberlessDirectGroupAndSelfMessagesOverDbus() throws Exception { + final var account = new TestAccount(null, ACI); + final var accounts = new MultiAccountManagerImpl(List.of(account.manager), null); + final var busname = newBusname(); + try (final var server = new DbusHandler(false, + busname, + accounts, + true); final var connection = DBusConnectionBuilder.forSessionBus().withShared(false).build()) { + server.init(); + assertEquals(ACI, selectAccount(null, connection, busname)); + final var signal = connection.getRemoteObject(busname, NUMBERLESS_PATH, Signal.class); + assertEquals("", signal.getSelfNumber()); + final var groupId = GroupId.unknownVersion(new byte[32]); + assertEquals(42, signal.sendMessage("Direct", List.of(), OTHER_ACI)); + assertEquals(42, signal.sendGroupMessage("Group", List.of(), groupId.serialize())); + assertEquals(42, signal.sendNoteToSelfMessage("Self", List.of())); + assertEquals(Set.of(new RecipientIdentifier.Uuid(UUID.fromString(OTHER_ACI))), + account.calls.get(0).arguments().get(1)); + assertEquals(Set.of(new RecipientIdentifier.Group(groupId)), account.calls.get(1).arguments().get(1)); + assertEquals(Set.of(RecipientIdentifier.NoteToSelf.INSTANCE), account.calls.get(2).arguments().get(1)); + assertThrows(Signal.Error.InvalidNumber.class, + () -> signal.sendMessage("Invalid local number", List.of(), "2025550124")); + + account.calls.clear(); + try (final var client = new DbusManagerImpl(signal, connection, busname)) { + checkSelfOperations(client, account, new RecipientIdentifier.Uuid(UUID.fromString(ACI))); + final var received = new LinkedBlockingQueue(); + client.addReceiveHandler((envelope, error) -> received.add(envelope)); + for (final var group : List.of(Optional.empty(), Optional.of(groupId))) { + account.emit(messageEnvelope(group)); + final var envelope = received.poll(5, TimeUnit.SECONDS); + assertNotNull(envelope); + assertEquals(OTHER_ACI, envelope.sourceAddress().orElseThrow().aci().orElseThrow()); + assertEquals(Optional.empty(), envelope.sourceAddress().orElseThrow().number()); + assertEquals("Incoming", envelope.data().orElseThrow().body().orElseThrow()); + assertEquals(Optional.of(ACI), + envelope.data().orElseThrow().mentions().getFirst().recipient().aci()); + assertEquals(group, + envelope.data() + .orElseThrow() + .groupContext() + .map(MessageEnvelope.Data.GroupContext::groupId)); + } + } + assertEquals(0, account.receivers.size()); + } + } + } + + private static void checkSelfOperations( + final Manager client, + final TestAccount account, + final RecipientIdentifier.Single self + ) throws Exception { + final Set recipients = Set.of(RecipientIdentifier.NoteToSelf.INSTANCE); + client.sendTypingMessage(TypingAction.START, recipients); + client.sendRemoteDeleteMessage(123, recipients); + client.sendMessageReaction("\uD83D\uDC4D", false, self, 123, recipients, false, false); + assertEquals(List.of("sendTypingMessage", "sendRemoteDeleteMessage", "sendMessageReaction"), + account.calls.stream().map(Call::name).toList()); + assertEquals(Set.of(self), account.calls.get(0).arguments().get(1)); + assertEquals(Set.of(self), account.calls.get(1).arguments().get(1)); + assertEquals(Set.of(self), account.calls.get(2).arguments().get(4)); + } + + private static String selectAccount( + final String account, + final DBusConnection connection, + final String busname + ) throws Exception { + final var selected = new AtomicReference(); + final var handler = new CommandHandler(null, null) { + @Override + public void handleLocalCommand(final LocalCommand command, final Manager manager) { + selected.set(manager.getSelfACI()); + } + }; + DbusCommandHandler.handleCommand(new ListGroupsCommand(), account, connection, busname, handler); + return selected.get(); + } + + private static String newBusname() { + return "org.asamk.Signal.Test" + UUID.randomUUID().toString().replace("-", ""); + } + + private static MessageEnvelope messageEnvelope(final Optional groupId) { + final var data = new MessageEnvelope.Data(123, + groupId.map(id -> new MessageEnvelope.Data.GroupContext(id, false, 0)), + Optional.empty(), + Optional.empty(), + Optional.of("Incoming"), + 0, + false, + false, + false, + false, + false, + Optional.empty(), + Optional.empty(), + Optional.empty(), + List.of(), + Optional.empty(), + Optional.empty(), + List.of(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + List.of(new MessageEnvelope.Data.Mention(new RecipientAddress(UUID.fromString(ACI)), 0, 1)), + List.of(), + List.of(), + Optional.empty(), + Optional.empty(), + Optional.empty()); + return new MessageEnvelope(Optional.of(new RecipientAddress(UUID.fromString(OTHER_ACI))), + 1, + 123, + 0, + 0, + false, + Optional.empty(), + Optional.empty(), + Optional.of(data), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty()); + } + + private record Call(String name, List arguments) {} + + private static final class TestAccount { + + private final Manager manager; + private final List calls = new CopyOnWriteArrayList<>(); + private final List closedListeners = new CopyOnWriteArrayList<>(); + private final List receivers = new CopyOnWriteArrayList<>(); + private List recipients = List.of(); + private List groups = List.of(); + private List identities = List.of(); + + private TestAccount(final String number, final String aci) { + manager = (Manager) Proxy.newProxyInstance(Manager.class.getClassLoader(), + new Class[]{Manager.class}, + (proxy, method, args) -> { + if (method.isDefault()) { + return InvocationHandler.invokeDefault(proxy, method, args); + } + return switch (method.getName()) { + case "getSelfNumber" -> number; + case "getSelfACI" -> aci; + case "getLinkedDevices" -> List.of(); + case "getGroups" -> groups; + case "getGroup" -> groups.getFirst(); + case "getIdentities" -> identities; + case "getRecipients" -> recipients; + case "isContactBlocked" -> false; + case "getContactOrProfileName" -> "Test contact"; + case "addAddressChangedListener" -> null; + case "addClosedListener" -> { + closedListeners.add((Runnable) args[0]); + yield null; + } + case "close" -> { + closedListeners.forEach(Runnable::run); + closedListeners.clear(); + yield null; + } + case "addReceiveHandler" -> { + receivers.add((Manager.ReceiveMessageHandler) args[0]); + yield null; + } + case "removeReceiveHandler" -> { + receivers.remove(args[0]); + yield null; + } + case "sendMessage", "sendTypingMessage", "sendRemoteDeleteMessage", + "sendMessageReaction" -> { + calls.add(new Call(method.getName(), List.of(args))); + yield new SendMessageResults(42, Map.of()); + } + case "hashCode" -> System.identityHashCode(proxy); + case "equals" -> proxy == args[0]; + default -> throw new UnsupportedOperationException(method.getName()); + }; + }); + } + + private void emit(final MessageEnvelope envelope) { + receivers.forEach(receiver -> receiver.handleMessage(envelope, null)); + } + } +} diff --git a/src/test/java/org/asamk/signal/json/NumberlessAccountOutputTest.java b/src/test/java/org/asamk/signal/json/NumberlessAccountOutputTest.java new file mode 100644 index 00000000..015b6ded --- /dev/null +++ b/src/test/java/org/asamk/signal/json/NumberlessAccountOutputTest.java @@ -0,0 +1,71 @@ +package org.asamk.signal.json; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.asamk.signal.commands.ListAccountsCommand; +import org.asamk.signal.manager.Manager; +import org.asamk.signal.manager.internal.MultiAccountManagerImpl; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Proxy; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; + +class NumberlessAccountOutputTest { + + private static final String ACI = "11111111-1111-4111-8111-111111111111"; + + @Test + void receiveEventsIdentifyNumberlessAccountByAci() { + final var output = new AtomicReference(); + new JsonReceiveMessageHandler(manager(null), output::set).handleMessage(null, null); + assertEquals(ACI, ((Map) output.get()).get("account")); + } + + @Test + void receiveEventsKeepNumberedAccountIdentifiers() { + final var output = new AtomicReference(); + new JsonReceiveMessageHandler(manager("+12025550123"), output::set).handleMessage(null, null); + assertEquals("+12025550123", ((Map) output.get()).get("account")); + } + + @Test + void listAccountsIncludesAciWithoutPuttingItInTheNumberField() throws Exception { + final var numberless = manager(null); + final var numbered = manager("+12025550123"); + final var multi = new MultiAccountManagerImpl(List.of(numberless, numbered), null); + final var output = new AtomicReference(); + new ListAccountsCommand().handleCommand(Map.of(), multi, output::set); + final var accounts = new ObjectMapper().valueToTree(output.get()); + assertEquals(2, accounts.size()); + for (final var account : accounts) { + assertEquals(ACI, account.path("aci").asText()); + assertFalse(ACI.equals(account.path("number").asText())); + } + assertSame(numbered, multi.getManager("+12025550123")); + } + + private static Manager manager(final String number) { + return (Manager) Proxy.newProxyInstance(Manager.class.getClassLoader(), + new Class[]{Manager.class}, + (proxy, method, args) -> { + if (method.isDefault()) { + return InvocationHandler.invokeDefault(proxy, method, args); + } + return switch (method.getName()) { + case "getSelfNumber" -> number; + case "getSelfACI" -> ACI; + case "hashCode" -> System.identityHashCode(proxy); + case "equals" -> proxy == args[0]; + case "addClosedListener" -> null; + default -> throw new UnsupportedOperationException(method.getName()); + }; + }); + } +} diff --git a/src/test/java/org/asamk/signal/jsonrpc/SubscribeCallEventsTest.java b/src/test/java/org/asamk/signal/jsonrpc/SubscribeCallEventsTest.java index 80fc63e2..9ec1c87d 100644 --- a/src/test/java/org/asamk/signal/jsonrpc/SubscribeCallEventsTest.java +++ b/src/test/java/org/asamk/signal/jsonrpc/SubscribeCallEventsTest.java @@ -72,11 +72,6 @@ class SubscribeCallEventsTest { this.managers = new ArrayList<>(managers); } - @Override - public List getAccountNumbers() { - return managers.stream().map(Manager::getSelfNumber).toList(); - } - @Override public List getManagers() { return managers;