diff --git a/README.md b/README.md index 96655b69..32f4a29c 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 [numberless accounts](doc/numberless-accounts.md) for details. * 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/doc/numberless-accounts.md b/doc/numberless-accounts.md new file mode 100644 index 00000000..e4b7e752 --- /dev/null +++ b/doc/numberless-accounts.md @@ -0,0 +1,46 @@ +# Linking an account without a phone number + +This branch adds secondary-device linking for accounts registered without a phone +number, such as those created in Signal Android 8.28. It does not implement creating +or recovering a primary numberless account using an Account Key. + +Run `signal-cli link -n signal-cli`, then scan the QR code from the mobile app's +Linked devices screen. The linking URI advertises `capabilities=nopni`. If linking +completes, the CLI prints the account's ACI instead of a phone number. + +Use that ACI with `-a` for subsequent commands: + +```sh +signal-cli -a YOUR_ACCOUNT_ACI receive +signal-cli -a YOUR_ACCOUNT_ACI listGroups +signal-cli -a YOUR_ACCOUNT_ACI send --note-to-self -m 'Numberless account test' +``` + +When only one local account exists, `-a` can be omitted. Numbered accounts continue +to accept their phone number or ACI. A numberless account cannot infer a country +code for a recipient's phone number; use a complete international number beginning +with `+`, an ACI, or a Signal username. + +In JSON-RPC, use the ACI in the `account` parameter. `listAccounts` and `finishLink` +return an `aci` field alongside `number`; `number` is null for a numberless account. +Receive events use the ACI in their `account` field when there is no phone number. +REST wrappers must understand those nullable numbers and ACI selectors before they +can be assumed compatible. + +## Validation + +Run `./gradlew test installDist` with JDK 25. Regression tests cover the linking +request (including ACI authentication and omission of PNI keys), numbered-account +linking, missing group credential salts, persisted account reload, account discovery, +group membership lookup, recipient-number formatting, and JSON account identifiers. + +Live acceptance requires a real mobile account and is separate from these tests: + +1. Link a newly created secondary device using the numberless mobile account. +2. Restart signal-cli and verify that `listAccounts` still reports the ACI. +3. Send and receive a Note to Self message and a DM with a second account. +4. Receive a group message, list the group, and send a reply to that test group. +5. Run the JSON-RPC daemon, restart it, and repeat receiving and sending by ACI. + +Only one signal-cli process may open an account's state at a time. Stop the daemon +before running one-off commands against the same configuration directory. 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/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..231aacc7 100644 --- a/lib/src/main/java/org/asamk/signal/manager/SignalAccountFiles.java +++ b/lib/src/main/java/org/asamk/signal/manager/SignalAccountFiles.java @@ -65,15 +65,25 @@ 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(java.util.stream.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..2dfc4cac 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 @@ -18,6 +18,7 @@ import org.signal.core.models.ServiceId.ACI; import org.signal.core.models.ServiceId.PNI; import org.signal.core.util.Base64; import org.signal.core.util.crypto.DeviceNameCipher; +import org.signal.libsignal.net.RequestResult; import org.signal.libsignal.protocol.IdentityKeyPair; import org.signal.libsignal.protocol.InvalidKeyException; import org.signal.libsignal.protocol.NoSessionException; @@ -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; import org.signal.network.exceptions.NonSuccessfulResponseCodeException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -57,11 +59,13 @@ import java.util.Objects; import java.util.UUID; import java.util.concurrent.TimeUnit; +import kotlin.Unit; import okio.ByteString; import static org.asamk.signal.manager.config.ServiceConfig.PREKEY_MAXIMUM_ID; import static org.asamk.signal.manager.util.Utils.handleResponseException; import static org.asamk.signal.manager.util.Utils.handleResponseExceptionSuspend; +import static org.asamk.signal.manager.util.Utils.runSuspendBlocking; import static org.whispersystems.signalservice.internal.util.Util.isEmpty; public class AccountHelper { @@ -96,10 +100,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 +142,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 +155,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 +569,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; + } + final var api = new AccountApiV2(dependencies.getAuthenticatedSignalWebSocket()); + final RequestResult result = + runSuspendBlocking(cont -> api.setAccountAttributes(account.getAccountAttributesV2(), cont)); + if (result instanceof RequestResult.NonSuccess failure) { + if (failure.getError() instanceof AccountApiV2.SetAccountAttributesError.Unauthorized) { + throw new AuthorizationFailedException(401, "Authorization failed!"); + } + throw new IOException("Account attribute update rate limited; try again later"); + } + handleResponseException(result); } 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..9564830c 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..0d6a60a8 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 @@ -121,7 +121,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..a748611c 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 @@ -20,7 +20,6 @@ import org.asamk.signal.manager.Manager; import org.asamk.signal.manager.ProvisioningManager; import org.asamk.signal.manager.Settings; 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; @@ -30,17 +29,18 @@ import org.signal.core.models.ServiceId.ACI; import org.signal.core.models.ServiceId.PNI; import org.signal.core.models.backup.MediaRootBackupKey; import org.signal.core.util.crypto.DeviceNameCipher; +import org.signal.libsignal.net.RequestResult; 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.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; @@ -68,6 +68,7 @@ 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.runSuspendBlocking; public class ProvisioningManagerImpl implements ProvisioningManager, Closeable { @@ -114,7 +115,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 +150,19 @@ 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,7 +176,9 @@ 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()), + pniIdentity = pni == null + ? null + : new IdentityKeyPair(new IdentityKey(msg.pniIdentityKeyPublic.toByteArray()), new ECPrivateKey(msg.pniIdentityKeyPrivate.toByteArray())); profileKey = msg.profileKey == null ? KeyUtils.createProfileKey() @@ -222,28 +227,14 @@ 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 registrationApi = new RegistrationApiV2(new SignalRestClient( + serviceEnvironmentConfig.signalServiceConfiguration(), userAgent), false); + final var deviceId = registerLinkedDevice(registrationApi, account, msg.provisioningCode, aciPreKeys, pniPreKeys); account.finishLinking(deviceId, aciPreKeys, pniPreKeys); linkingFinished = true; @@ -277,7 +268,7 @@ public class ProvisioningManagerImpl implements ProvisioningManager, Closeable { newManagerListener.accept(m); m = null; } - return number; + return identifier; } finally { if (m != null) { m.close(); @@ -296,6 +287,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()); + final RequestResult result = + runSuspendBlocking(cont -> registrationApi.registerAsSecondaryDevice(account.getAci(), + account.getPassword(), + provisioningCode, + deviceAttributes, + toRegistrationPreKeys(aciPreKeys), + toRegistrationPreKeys(pniPreKeys), + null, + cont)); + return getLinkedDeviceId(result); + } + + static int getLinkedDeviceId( + final RequestResult result + ) throws IOException { + if (result instanceof RequestResult.NonSuccess failure) { + throw switch (failure.getError()) { + case RegistrationApiV2.RegisterAsLinkedDeviceError.IncorrectVerification ignored -> + new AuthorizationFailedException(403, "Device verification failed"); + case RegistrationApiV2.RegisterAsLinkedDeviceError.MissingCapability ignored -> + new IOException("Linked device is missing a required account capability"); + case RegistrationApiV2.RegisterAsLinkedDeviceError.MaxLinkedDevices ignored -> + new IOException("Account has reached its linked device limit"); + case RegistrationApiV2.RegisterAsLinkedDeviceError.InvalidRequest ignored -> + new IOException("Signal rejected the device linking request"); + case RegistrationApiV2.RegisterAsLinkedDeviceError.RateLimited ignored -> + new IOException("Device linking rate limited; try again later"); + default -> new IOException("Unexpected device linking response"); + }; + } + return handleResponseException(result).getDeviceId(); + } + + 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/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/AccountsStore.java b/lib/src/main/java/org/asamk/signal/manager/storage/accounts/AccountsStore.java index 36a6c75e..e9f27384 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) .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())) @@ -101,7 +104,7 @@ public class AccountsStore { if (number != null && number.equals(a.number())) { return new AccountsStorage.Account(a.path(), a.environment(), null, a.uuid()); } - if (aci != null && aci.toString().equals(a.toString())) { + if (aci != null && aci.toString().equals(a.uuid())) { return new AccountsStorage.Account(a.path(), a.environment(), a.number(), null); } 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..d96bb457 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,18 @@ 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..8bf8a0cd --- /dev/null +++ b/lib/src/test/java/org/asamk/signal/manager/helper/NumberlessGroupTest.java @@ -0,0 +1,41 @@ +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..c3ad6d71 --- /dev/null +++ b/lib/src/test/java/org/asamk/signal/manager/internal/NumberlessProvisioningTest.java @@ -0,0 +1,131 @@ +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.signal.core.models.ServiceId.ACI; +import org.signal.core.models.ServiceId.PNI; +import org.signal.libsignal.net.RequestResult; +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.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.assertFalse; +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())); + assertThrows(IOException.class, () -> ProvisioningManagerImpl.parsePni(new ProvisionMessage.Builder() + .authCredentialSalt(ByteString.EMPTY).build())); + 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); + } + + @Test + void numberedLinkRequestStillIncludesPniKeys() throws Exception { + checkLinkRequest("+12025550123", PNI_ID); + } + + private void checkLinkRequest(final String number, final PNI pni) 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(200).message("OK") + .body(ResponseBody.create("{\"deviceId\":2}", 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)); + final var deviceId = ProvisioningManagerImpl.registerLinkedDevice(api, account, "test-code", aciKeys, pniKeys); + + assertEquals(2, deviceId); + assertEquals("PUT", request.get().method()); + assertEquals("/v1/devices/link", request.get().url().encodedPath()); + assertEquals(okhttp3.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(); + } + } + + @Test + @SuppressWarnings("unchecked") + void linkingErrorsAreReportedWithoutCrashingOrIncludingServerBody() { + assertThrows(AuthorizationFailedException.class, () -> ProvisioningManagerImpl.getLinkedDeviceId( + new RequestResult.NonSuccess<>(RegistrationApiV2.RegisterAsLinkedDeviceError.IncorrectVerification.INSTANCE))); + final var error = assertThrows(IOException.class, () -> ProvisioningManagerImpl.getLinkedDeviceId( + new RequestResult.NonSuccess<>(new RegistrationApiV2.RegisterAsLinkedDeviceError.InvalidRequest("secret payload")))); + assertFalse(error.getMessage().contains("secret payload")); + } +} 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..5e8f54be --- /dev/null +++ b/lib/src/test/java/org/asamk/signal/manager/storage/NumberlessAccountTest.java @@ -0,0 +1,53 @@ +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..32d032d7 --- /dev/null +++ b/lib/src/test/java/org/asamk/signal/manager/storage/accounts/AccountsStoreTest.java @@ -0,0 +1,47 @@ +package org.asamk.signal.manager.storage.accounts; + +import org.asamk.signal.manager.api.ServiceEnvironment; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.signal.core.models.ServiceId.ACI; + +import java.nio.file.Path; +import java.util.Set; + +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 { + + @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); + java.nio.file.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()); + } +} 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-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 26fac479..fed04403 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. diff --git a/src/main/java/org/asamk/signal/App.java b/src/main/java/org/asamk/signal/App.java index fa18e597..be8aab1b 100644 --- a/src/main/java/org/asamk/signal/App.java +++ b/src/main/java/org/asamk/signal/App.java @@ -208,14 +208,14 @@ 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/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/test/java/org/asamk/signal/json/NumberlessAccountOutputTest.java b/src/test/java/org/asamk/signal/json/NumberlessAccountOutputTest.java new file mode 100644 index 00000000..15a2bd09 --- /dev/null +++ b/src/test/java/org/asamk/signal/json/NumberlessAccountOutputTest.java @@ -0,0 +1,70 @@ +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()); + }; + }); + } +}