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;