Address numberless linking review cleanup

Reuse AccountApiV2 through SignalDependencies and use the shared suspend response helper while preserving linking error handling. Remove the unused account-number accessor and unsupported D-Bus version fallback. Keep numberless linking documentation in the man page and point the README to the wiki.

Validate linking errors through HTTP response fixtures. All 196 tests pass on JDK 25 with an isolated D-Bus session; build and JSON schema generation also pass. Account-index classification is unchanged pending review discussion.
This commit is contained in:
Mariusz Woloszyn 2026-09-20 15:22:05 +00:00
parent 721312189a
commit 431a6bf286
13 changed files with 81 additions and 185 deletions

View File

@ -65,7 +65,7 @@ For a numbered account, ACCOUNT is your phone number in international format and
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.
See the [wiki](https://github.com/AsamK/signal-cli/wiki) for further documentation.
* Link to an existing account

View File

@ -1,73 +0,0 @@
# Linking an account without a phone number
signal-cli can link as a secondary device to an account registered without a phone
number, such as those created in Signal Android 8.28. Creating or recovering a
primary numberless account using an Account Key is not supported.
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.
Numberless accounts are also supported through D-Bus. Start
`signal-cli daemon --dbus`, then use the ACI with the D-Bus client:
```sh
signal-cli --dbus -a YOUR_ACCOUNT_ACI listGroups
signal-cli --dbus -a YOUR_ACCOUNT_ACI send --note-to-self -m 'Numberless account test'
```
In multi-account mode, D-Bus exports numberless accounts under
`/org/asamk/Signal/ACI`, with the ACI's hyphens replaced by underscores. Numbered
accounts retain their phone-number-based paths. Use `SignalControl.listAccounts`
or `SignalControl.getAccount` to discover paths; `getAccount` accepts either a
phone number or an ACI. `Signal.getSelfNumber` returns an empty string when the
account has no phone number, and `Signal.getSelfACI` returns the ACI.
`Signal.listRecipientIdentifiers` includes contacts without a phone number;
`Signal.listNumbers` continues to return only phone numbers.
## 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
(including superseded accounts and legacy indexes), group membership lookup,
recipient-number formatting, and JSON account identifiers.
D-Bus transport tests require a session bus. To run them on an isolated bus, use
`dbus-run-session -- ./gradlew --no-daemon :test --tests '*NumberlessDbusTest*' --rerun-tasks`.
These tests exercise the D-Bus server and client with a local test backend, including
mixed accounts, direct and group messages, self-directed operations, and account
removal. They do not contact Signal. Without a session bus, only the D-Bus unit
tests run and the transport tests are skipped.
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.
6. Repeat with `daemon --dbus` and the `--dbus -a YOUR_ACCOUNT_ACI` client, checking
direct messages, group messages, and Note to Self.
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

@ -8,8 +8,6 @@ import java.util.function.Consumer;
public interface MultiAccountManager extends AutoCloseable {
List<String> getAccountNumbers();
List<Manager> getManagers();
void addOnManagerAddedHandler(Consumer<Manager> handler);

View File

@ -1,5 +1,6 @@
package org.asamk.signal.manager.helper;
import org.asamk.signal.manager.api.BadRequestException;
import org.asamk.signal.manager.api.CaptchaRequiredException;
import org.asamk.signal.manager.api.DeviceLinkUrl;
import org.asamk.signal.manager.api.IncorrectPinException;
@ -18,7 +19,6 @@ 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;
@ -28,7 +28,6 @@ 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.api.AccountApiV2.SetAccountAttributesError;
import org.signal.network.exceptions.NonSuccessfulResponseCodeException;
import org.slf4j.Logger;
@ -60,13 +59,11 @@ 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 {
@ -575,17 +572,15 @@ public class AccountHelper {
.setAccountAttributes(account.getAccountAttributes(null)));
return;
}
final var api = new AccountApiV2(dependencies.getAuthenticatedSignalWebSocket());
final RequestResult<Unit, ? extends SetAccountAttributesError> result = runSuspendBlocking(cont -> api.setAccountAttributes(
account.getAccountAttributesV2(),
cont));
if (result instanceof RequestResult.NonSuccess<?> failure) {
if (failure.getError() instanceof SetAccountAttributesError.Unauthorized) {
try {
handleResponseExceptionSuspend(cont -> dependencies.getAccountApiV2()
.setAccountAttributes(account.getAccountAttributesV2(), cont));
} catch (BadRequestException e) {
if (e.getError() instanceof SetAccountAttributesError.Unauthorized) {
throw new AuthorizationFailedException(401, "Authorization failed!");
}
throw new IOException("Account attribute update rate limited; try again later");
}
handleResponseException(result);
}
public void addDevice(DeviceLinkUrl deviceLinkInfo) throws IOException, org.asamk.signal.manager.api.DeviceLimitExceededException {

View File

@ -39,13 +39,6 @@ public class MultiAccountManagerImpl implements MultiAccountManager {
managers.forEach(m -> m.addClosedListener(() -> this.removeManager(m)));
}
@Override
public List<String> getAccountNumbers() {
synchronized (managers) {
return managers.stream().map(Manager::getSelfNumber).toList();
}
}
@Override
public List<Manager> getManagers() {
synchronized (managers) {

View File

@ -19,6 +19,7 @@ package org.asamk.signal.manager.internal;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.ProvisioningManager;
import org.asamk.signal.manager.Settings;
import org.asamk.signal.manager.api.BadRequestException;
import org.asamk.signal.manager.api.UserAlreadyExistsException;
import org.asamk.signal.manager.config.ServiceEnvironmentConfig;
import org.asamk.signal.manager.storage.SignalAccount;
@ -29,7 +30,6 @@ 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;
@ -69,8 +69,7 @@ import kotlinx.coroutines.BuildersKt;
import kotlinx.coroutines.CoroutineScope;
import static org.asamk.signal.manager.util.KeyUtils.generatePreKeysForType;
import static org.asamk.signal.manager.util.Utils.handleResponseException;
import static org.asamk.signal.manager.util.Utils.runSuspendBlocking;
import static org.asamk.signal.manager.util.Utils.handleResponseExceptionSuspend;
public class ProvisioningManagerImpl implements ProvisioningManager, Closeable {
@ -324,23 +323,19 @@ public class ProvisioningManagerImpl implements ProvisioningManager, Closeable {
attrs.getPniRegistrationId(),
attrs.getName(),
attrs.getCapabilities());
final RequestResult<LinkDeviceResponse, ? extends 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<LinkDeviceResponse, ? extends RegisterAsLinkedDeviceError> result
) throws IOException {
if (result instanceof RequestResult.NonSuccess<?> failure) {
throw switch (failure.getError()) {
try {
final LinkDeviceResponse result = handleResponseExceptionSuspend(cont -> registrationApi.registerAsSecondaryDevice(
account.getAci(),
account.getPassword(),
provisioningCode,
deviceAttributes,
toRegistrationPreKeys(aciPreKeys),
toRegistrationPreKeys(pniPreKeys),
null,
cont));
return result.getDeviceId();
} catch (BadRequestException e) {
throw switch (e.getError()) {
case RegisterAsLinkedDeviceError.IncorrectVerification ignored ->
new AuthorizationFailedException(403, "Device verification failed");
case RegisterAsLinkedDeviceError.MissingCapability ignored ->
@ -354,7 +349,6 @@ public class ProvisioningManagerImpl implements ProvisioningManager, Closeable {
default -> new IOException("Unexpected device linking response");
};
}
return handleResponseException(result).getDeviceId();
}
static RegistrationApiV2.PreKeyCollection toRegistrationPreKeys(final PreKeyCollection preKeys) {

View File

@ -8,6 +8,7 @@ import org.signal.libsignal.metadata.certificate.CertificateValidator;
import org.signal.libsignal.net.Network;
import org.signal.libsignal.protocol.SignalProtocolAddress;
import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations;
import org.signal.network.api.AccountApiV2;
import org.signal.network.api.AttachmentApi;
import org.signal.network.api.CallingApi;
import org.signal.network.api.CdsApi;
@ -73,6 +74,7 @@ public class SignalDependencies {
private SignalServiceAccountManager accountManager;
private AccountApi accountApi;
private AccountApiV2 accountApiV2;
private RateLimitChallengeApi rateLimitChallengeApi;
private CdsApi cdsApi;
private UsernameApi usernameApi;
@ -221,6 +223,11 @@ public class SignalDependencies {
return getOrCreate(() -> accountApi, () -> accountApi = new AccountApi(getAuthenticatedSignalWebSocket()));
}
public AccountApiV2 getAccountApiV2() {
return getOrCreate(() -> accountApiV2,
() -> accountApiV2 = new AccountApiV2(getAuthenticatedSignalWebSocket()));
}
public RateLimitChallengeApi getRateLimitChallengeApi() {
return getOrCreate(() -> rateLimitChallengeApi,
() -> rateLimitChallengeApi = new RateLimitChallengeApi(getAuthenticatedSignalWebSocket()));

View File

@ -10,11 +10,11 @@ import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.util.KeyUtils;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.signal.core.models.ServiceId.ACI;
import org.signal.core.models.ServiceId.PNI;
import org.signal.libsignal.net.RequestResult;
import org.signal.network.api.RegistrationApiV2;
import org.signal.network.api.RegistrationApiV2.RegisterAsLinkedDeviceError;
import org.signal.network.rest.SignalRestClient;
import org.whispersystems.signalservice.api.push.ServiceIdType;
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException;
@ -36,7 +36,7 @@ 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.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@ -72,15 +72,35 @@ class NumberlessProvisioningTest {
@Test
void numberlessLinkRequestOmitsPniAndAuthenticatesWithAci() throws Exception {
checkLinkRequest(null, null);
checkLinkRequest(null, null, 200, null);
}
@Test
void numberedLinkRequestStillIncludesPniKeys() throws Exception {
checkLinkRequest("+12025550123", PNI_ID);
checkLinkRequest("+12025550123", PNI_ID, 200, null);
}
private void checkLinkRequest(final String number, final PNI pni) throws Exception {
@ParameterizedTest
@CsvSource({
"422, Signal rejected the device linking request",
"403, [403] Device verification failed",
"409, Linked device is missing a required account capability",
"411, Account has reached its linked device limit",
"429, Device linking rate limited; try again later"
})
void linkingErrorsAreReportedWithoutCrashingOrIncludingServerBody(
final int statusCode,
final String errorMessage
) throws Exception {
checkLinkRequest(null, null, statusCode, errorMessage);
}
private void checkLinkRequest(
final String number,
final PNI pni,
final int statusCode,
final String errorMessage
) throws Exception {
final var request = new AtomicReference<Request>();
final var body = new AtomicReference<JsonNode>();
final var mapper = new ObjectMapper();
@ -91,9 +111,10 @@ class NumberlessProvisioningTest {
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")))
.code(statusCode)
.message("Test response")
.body(ResponseBody.create(statusCode == 200 ? "{\"deviceId\":2}" : "secret payload",
MediaType.get("application/json")))
.build();
}).build();
final var config = ServiceConfig.getServiceEnvironmentConfig(ServiceEnvironment.STAGING, "signal-cli-test");
@ -125,13 +146,21 @@ class NumberlessProvisioningTest {
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);
if (statusCode == 200) {
assertEquals(2,
ProvisioningManagerImpl.registerLinkedDevice(api, account, "test-code", aciKeys, pniKeys));
} else {
final var error = assertThrows(IOException.class,
() -> ProvisioningManagerImpl.registerLinkedDevice(api,
account,
"test-code",
aciKeys,
pniKeys));
assertEquals(errorMessage, error.getMessage());
if (statusCode == 403) {
assertInstanceOf(AuthorizationFailedException.class, error);
}
}
assertEquals("PUT", request.get().method());
assertEquals("/v1/devices/link", request.get().url().encodedPath());
assertEquals(Credentials.basic(ACI_ID.toString(), "test-password"), request.get().header("Authorization"));
@ -150,16 +179,4 @@ class NumberlessProvisioningTest {
client.connectionPool().evictAll();
}
}
@Test
@SuppressWarnings("unchecked")
void linkingErrorsAreReportedWithoutCrashingOrIncludingServerBody() {
final var incorrectVerification = RegisterAsLinkedDeviceError.IncorrectVerification.INSTANCE;
assertThrows(AuthorizationFailedException.class,
() -> ProvisioningManagerImpl.getLinkedDeviceId(new RequestResult.NonSuccess<>(incorrectVerification)));
final var invalidRequest = new RegisterAsLinkedDeviceError.InvalidRequest("secret payload");
final var error = assertThrows(IOException.class,
() -> ProvisioningManagerImpl.getLinkedDeviceId(new RequestResult.NonSuccess<>(invalidRequest)));
assertFalse(error.getMessage().contains("secret payload"));
}
}

View File

@ -251,6 +251,10 @@ If you want to connect to another signal-cli instance, you can just use this URI
If you want to link to an Android/iOS device, scan the QR code that signal-cli prints in the terminal with the Signal app.
If signal-cli is not running in a terminal, only the URI is printed and you can create a QR code from it yourself (e.g. with qrencode).
Accounts without a phone number can also be linked.
After linking, use the account's ACI (Account ID) with `-a`; recipient phone numbers must include the international country code.
Creating or recovering a primary account without a phone number using an Account Key is not supported.
*-n* NAME, *--name* NAME::
Optionally specify a name to describe this new device.
By default "cli" will be used.

View File

@ -56,7 +56,6 @@ import org.asamk.signal.manager.api.UsernameLinkUrl;
import org.asamk.signal.manager.api.UsernameStatus;
import org.freedesktop.dbus.DBusPath;
import org.freedesktop.dbus.connections.impl.DBusConnection;
import org.freedesktop.dbus.errors.UnknownMethod;
import org.freedesktop.dbus.exceptions.DBusException;
import org.freedesktop.dbus.exceptions.DBusExecutionException;
import org.freedesktop.dbus.interfaces.DBusInterface;
@ -815,7 +814,8 @@ public class DbusManagerImpl implements Manager {
final var identifiers = addresses.stream()
.map(RecipientIdentifier.Single::getIdentifier)
.collect(Collectors.toSet());
return listRecipientIdentifiers().stream()
return signal.listRecipientIdentifiers()
.stream()
.filter(n -> addresses.isEmpty() || identifiers.contains(n))
.map(n -> {
final var contactBlocked = signal.isContactBlocked(n);
@ -1090,15 +1090,6 @@ public class DbusManagerImpl implements Manager {
}
}
private List<String> listRecipientIdentifiers() {
try {
return signal.listRecipientIdentifiers();
} catch (UnknownMethod e) {
// Older daemons only expose recipients with a known phone number.
return signal.listNumbers();
}
}
private <T extends DBusInterface> T getRemoteObject(final DBusPath path, final Class<T> type) {
try {
return connection.getRemoteObject(busname, path.getPath(), type);

View File

@ -43,11 +43,6 @@ public class DbusMultiAccountManagerImpl implements MultiAccountManager {
this.busname = busname;
}
@Override
public List<String> getAccountNumbers() {
return getManagers().stream().map(Manager::getSelfNumber).toList();
}
@Override
public List<Manager> getManagers() {
return signalControl.listAccounts()

View File

@ -24,7 +24,6 @@ import org.asamk.signal.manager.internal.MultiAccountManagerImpl;
import org.freedesktop.dbus.DBusPath;
import org.freedesktop.dbus.connections.impl.DBusConnection;
import org.freedesktop.dbus.connections.impl.DBusConnectionBuilder;
import org.freedesktop.dbus.errors.UnknownMethod;
import org.freedesktop.dbus.exceptions.DBusExecutionException;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
@ -94,25 +93,6 @@ class NumberlessDbusTest {
: new RecipientIdentifier.Number(number));
}
@Test
void readsContactsFromOlderDaemonsWithoutTheNewRecipientListingMethod() {
final var account = new TestAccount(NUMBER, ACI);
account.recipients = List.of(Recipient.newBuilder().withAddress(new RecipientAddress(NUMBER)).build());
final var signal = new DbusSignalImpl(account.manager, null, DbusConfig.getObjectPath(), true) {
@Override
public List<String> listRecipientIdentifiers() {
throw new UnknownMethod("Not implemented by this daemon");
}
};
final var client = new DbusManagerImpl(signal, null, "org.asamk.Signal.Test");
assertEquals(NUMBER,
client.getRecipients(false, Optional.empty(), Set.of(), Optional.empty())
.getFirst()
.getAddress()
.number()
.orElseThrow());
}
// Run with: dbus-run-session -- ./gradlew --no-daemon :test --tests '*NumberlessDbusTest*' --rerun-tasks
@Nested
@Timeout(20)
@ -148,7 +128,7 @@ class NumberlessDbusTest {
final var client = new DbusMultiAccountManagerImpl(control, connection, busname);
assertEquals(3, client.getManagers().size());
assertEquals(2, client.getAccountNumbers().stream().filter(n -> n == null).count());
assertEquals(2, client.getManagers().stream().filter(m -> m.getSelfNumber() == null).count());
assertNull(client.getManager(ACI).getSelfNumber());
assertEquals(NUMBER, client.getManager(OTHER_ACI).getSelfNumber());

View File

@ -72,11 +72,6 @@ class SubscribeCallEventsTest {
this.managers = new ArrayList<>(managers);
}
@Override
public List<String> getAccountNumbers() {
return managers.stream().map(Manager::getSelfNumber).toList();
}
@Override
public List<Manager> getManagers() {
return managers;