Support linking phone-numberless Signal accounts

This commit is contained in:
Mariusz Woloszyn 2026-09-17 17:10:39 +00:00
parent c146224493
commit c0b8f390be
27 changed files with 631 additions and 69 deletions

View File

@ -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

View File

@ -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.

View File

@ -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
*

View File

@ -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;
}

View File

@ -65,15 +65,25 @@ public class SignalAccountFiles {
return accountsStore.getAllNumbers();
}
public Set<String> 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<Manager, Throwable>(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, Throwable>(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<Manager, Throwable>(null, e);
}
}).filter(Objects::nonNull).toList();

View File

@ -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<Unit, ? extends AccountApiV2.SetAccountAttributesError> 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 {

View File

@ -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());
}

View File

@ -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),

View File

@ -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,

View File

@ -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) {

View File

@ -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<RegistrationApiV2.LinkDeviceResponse, ? extends RegistrationApiV2.RegisterAsLinkedDeviceError> 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<RegistrationApiV2.LinkDeviceResponse, ? extends RegistrationApiV2.RegisterAsLinkedDeviceError> 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();

View File

@ -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();
}

View File

@ -63,11 +63,14 @@ public class AccountsStore {
public synchronized Set<AccountsStorage.Account> 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);
}

View File

@ -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);

View File

@ -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()));
}
}
}

View File

@ -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<Request>();
final var body = new AtomicReference<JsonNode>();
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"));
}
}

View File

@ -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());
}
}
}

View File

@ -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());
}
}

View File

@ -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"));
}
}

View File

@ -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

View File

@ -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.

View File

@ -208,14 +208,14 @@ public class App {
private static String getAccountIfOnlyOne(final SignalAccountFiles signalAccountFiles) throws IOErrorException, UserErrorException {
Set<String> 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();
}

View File

@ -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());

View File

@ -54,9 +54,9 @@ public class FinishLinkCommand implements JsonRpcMultiCommand<FinishLinkCommand.
if (deviceName == null) {
deviceName = "cli";
}
final String number;
final String identifier;
try {
number = provisioningManager.finishDeviceLink(deviceName);
identifier = provisioningManager.finishDeviceLink(deviceName);
} catch (TimeoutException e) {
throw new UserErrorException("Link request timed out, please try again.");
} catch (IOException e) {
@ -73,10 +73,11 @@ public class FinishLinkCommand implements JsonRpcMultiCommand<FinishLinkCommand.
+ "\" before trying again.");
}
jsonWriter.write(new JsonFinishLink(number));
final var manager = m.getManager(identifier);
jsonWriter.write(new JsonFinishLink(manager.getSelfNumber(), manager.getSelfACI()));
}
public record FinishLinkParams(String deviceLinkUri, String deviceName) {}
private record JsonFinishLink(String number) {}
private record JsonFinishLink(String number, String aci) {}
}

View File

@ -27,19 +27,20 @@ public class ListAccountsCommand implements JsonRpcMultiLocalCommand {
final MultiAccountManager c,
final OutputWriter outputWriter
) throws CommandException {
final var accountNumbers = c.getAccountNumbers();
final var managers = c.getManagers();
switch (outputWriter) {
case JsonWriter jsonWriter -> {
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) {}
}

View File

@ -23,7 +23,7 @@ public class JsonReceiveMessageHandler implements Manager.ReceiveMessageHandler
@Override
public void handleMessage(MessageEnvelope envelope, Throwable exception) {
final var object = new HashMap<String, Object>();
object.put("account", m.getSelfNumber());
object.put("account", m.getSelfIdentifier());
if (exception != null) {
object.put("exception", JsonError.from(exception));
}

View File

@ -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<Object>();
new JsonReceiveMessageHandler(manager(null), output::set).handleMessage(null, null);
assertEquals(ACI, ((Map<?, ?>) output.get()).get("account"));
}
@Test
void receiveEventsKeepNumberedAccountIdentifiers() {
final var output = new AtomicReference<Object>();
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<Object>();
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());
};
});
}
}