Align numberless account support with project style

This commit is contained in:
Mariusz Woloszyn 2026-09-17 17:29:59 +00:00
parent c0b8f390be
commit 29140bbf64
13 changed files with 170 additions and 80 deletions

View File

@ -1,8 +1,8 @@
# 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.
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
@ -27,6 +27,10 @@ Receive events use the ACI in their `account` field when there is no phone numbe
REST wrappers must understand those nullable numbers and ACI selectors before they
can be assumed compatible.
Numberless accounts are supported through the CLI and JSON-RPC interfaces. The
D-Bus interface still assumes phone-number-based account identifiers and does not
support numberless accounts.
## Validation
Run `./gradlew test installDist` with JDK 25. Regression tests cover the linking

View File

@ -25,6 +25,7 @@ import java.io.IOException;
import java.util.Objects;
import java.util.Set;
import java.util.function.Consumer;
import java.util.stream.Collectors;
public class SignalAccountFiles {
@ -66,9 +67,10 @@ public class SignalAccountFiles {
}
public Set<String> getAllLocalAccountIdentifiers() throws IOException {
return accountsStore.getAllAccounts().stream()
return accountsStore.getAllAccounts()
.stream()
.map(a -> a.number() != null ? a.number() : a.uuid())
.collect(java.util.stream.Collectors.toSet());
.collect(Collectors.toSet());
}
public MultiAccountManager initMultiAccountManager() throws IOException {

View File

@ -29,6 +29,7 @@ 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;
import org.slf4j.LoggerFactory;
@ -143,8 +144,8 @@ public class AccountHelper {
final var number = whoAmI.getNumber();
final var aci = ACI.parseOrThrow(whoAmI.getAci());
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())) {
if (Objects.equals(number, account.getNumber()) && aci.equals(account.getAci()) && Objects.equals(pni,
account.getPni())) {
return;
}
@ -570,14 +571,16 @@ public class AccountHelper {
public void updateAccountAttributes() throws IOException {
if (account.getNumber() != null) {
handleResponseException(dependencies.getAccountApi().setAccountAttributes(account.getAccountAttributes(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));
final RequestResult<Unit, ? extends SetAccountAttributesError> result = runSuspendBlocking(cont -> api.setAccountAttributes(
account.getAccountAttributesV2(),
cont));
if (result instanceof RequestResult.NonSuccess<?> failure) {
if (failure.getError() instanceof AccountApiV2.SetAccountAttributesError.Unauthorized) {
if (failure.getError() instanceof SetAccountAttributesError.Unauthorized) {
throw new AuthorizationFailedException(401, "Authorization failed!");
}
throw new IOException("Account attribute update rate limited; try again later");

View File

@ -886,8 +886,8 @@ public class SendHelper {
var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
try {
final boolean includePniSignature = account.getPni() != null
&& account.getRecipientStore().needsPniSignature(recipientId);
final boolean includePniSignature = account.getPni() != null && account.getRecipientStore()
.needsPniSignature(recipientId);
try {
return s.send(messageSender,
address,

View File

@ -35,6 +35,8 @@ import org.signal.libsignal.protocol.IdentityKeyPair;
import org.signal.libsignal.protocol.ecc.ECPrivateKey;
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.signal.network.api.RegistrationApiV2;
import org.signal.network.api.RegistrationApiV2.LinkDeviceResponse;
import org.signal.network.api.RegistrationApiV2.RegisterAsLinkedDeviceError;
import org.signal.network.rest.SignalRestClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -162,7 +164,8 @@ public class ProvisioningManagerImpl implements ProvisioningManager, Closeable {
final var accountExists = accountPath != null && SignalAccount.accountFileExists(pathConfig.dataPath(),
accountPath);
if (accountExists && !canRelinkExistingAccount(accountPath)) {
throw new UserAlreadyExistsException(identifier, SignalAccount.getFileName(pathConfig.dataPath(), accountPath));
throw new UserAlreadyExistsException(identifier,
SignalAccount.getFileName(pathConfig.dataPath(), accountPath));
}
if (accountPath == null) {
accountPath = accountsStore.addAccount(number, aci);
@ -179,7 +182,7 @@ public class ProvisioningManagerImpl implements ProvisioningManager, Closeable {
pniIdentity = pni == null
? null
: new IdentityKeyPair(new IdentityKey(msg.pniIdentityKeyPublic.toByteArray()),
new ECPrivateKey(msg.pniIdentityKeyPrivate.toByteArray()));
new ECPrivateKey(msg.pniIdentityKeyPrivate.toByteArray()));
profileKey = msg.profileKey == null
? KeyUtils.createProfileKey()
: new ProfileKey(msg.profileKey.toByteArray());
@ -232,9 +235,14 @@ public class ProvisioningManagerImpl implements ProvisioningManager, Closeable {
: generatePreKeysForType(account.getAccountData(ServiceIdType.PNI));
logger.debug("Finishing new device registration");
final var registrationApi = new RegistrationApiV2(new SignalRestClient(
serviceEnvironmentConfig.signalServiceConfiguration(), userAgent), false);
final var deviceId = registerLinkedDevice(registrationApi, account, msg.provisioningCode, aciPreKeys, pniPreKeys);
final var restClient = new SignalRestClient(serviceEnvironmentConfig.signalServiceConfiguration(),
userAgent);
final var registrationApi = new RegistrationApiV2(restClient, false);
final var deviceId = registerLinkedDevice(registrationApi,
account,
msg.provisioningCode,
aciPreKeys,
pniPreKeys);
account.finishLinking(deviceId, aciPreKeys, pniPreKeys);
linkingFinished = true;
@ -291,8 +299,10 @@ public class ProvisioningManagerImpl implements ProvisioningManager, Closeable {
if (message.number != null) {
return PNI.parseOrThrow(message.pni, message.pniBinary);
}
if (message.pni != null || message.pniBinary != null
|| message.pniIdentityKeyPublic != null || message.pniIdentityKeyPrivate != null) {
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) {
@ -314,32 +324,32 @@ public class ProvisioningManagerImpl implements ProvisioningManager, Closeable {
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));
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<RegistrationApiV2.LinkDeviceResponse, ? extends RegistrationApiV2.RegisterAsLinkedDeviceError> result
final RequestResult<LinkDeviceResponse, ? extends RegisterAsLinkedDeviceError> result
) throws IOException {
if (result instanceof RequestResult.NonSuccess<?> failure) {
throw switch (failure.getError()) {
case RegistrationApiV2.RegisterAsLinkedDeviceError.IncorrectVerification ignored ->
case RegisterAsLinkedDeviceError.IncorrectVerification ignored ->
new AuthorizationFailedException(403, "Device verification failed");
case RegistrationApiV2.RegisterAsLinkedDeviceError.MissingCapability ignored ->
case RegisterAsLinkedDeviceError.MissingCapability ignored ->
new IOException("Linked device is missing a required account capability");
case RegistrationApiV2.RegisterAsLinkedDeviceError.MaxLinkedDevices ignored ->
case RegisterAsLinkedDeviceError.MaxLinkedDevices ignored ->
new IOException("Account has reached its linked device limit");
case RegistrationApiV2.RegisterAsLinkedDeviceError.InvalidRequest ignored ->
case RegisterAsLinkedDeviceError.InvalidRequest ignored ->
new IOException("Signal rejected the device linking request");
case RegistrationApiV2.RegisterAsLinkedDeviceError.RateLimited ignored ->
case RegisterAsLinkedDeviceError.RateLimited ignored ->
new IOException("Device linking rate limited; try again later");
default -> new IOException("Unexpected device linking response");
};
@ -348,8 +358,11 @@ public class ProvisioningManagerImpl implements ProvisioningManager, Closeable {
}
static RegistrationApiV2.PreKeyCollection toRegistrationPreKeys(final PreKeyCollection preKeys) {
return preKeys == null ? null : new RegistrationApiV2.PreKeyCollection(preKeys.getIdentityKey(),
preKeys.getSignedPreKey(), preKeys.getLastResortKyberPreKey());
return preKeys == null
? null
: new RegistrationApiV2.PreKeyCollection(preKeys.getIdentityKey(),
preKeys.getSignedPreKey(),
preKeys.getLastResortKyberPreKey());
}
@Override

View File

@ -44,7 +44,8 @@ public class PhoneNumberFormatter {
if (localNumber == null) {
if (!number.startsWith("+")) {
throw new InvalidNumberException("Use an international number including the country code for a numberless account.");
throw new InvalidNumberException(
"Use an international number including the country code for a numberless account.");
}
try {
final var util = PhoneNumberUtil.getInstance();

View File

@ -24,18 +24,34 @@ class NumberlessGroupTest {
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);
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();
.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();
.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()));
assertEquals(9,
context.getGroupV2Helper()
.findRevisionWeWereAdded(group.newBuilder().members(List.of(otherMember)).build()));
}
}
}

View File

@ -14,6 +14,7 @@ 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;
@ -24,6 +25,7 @@ import java.nio.file.Path;
import java.security.SecureRandom;
import java.util.concurrent.atomic.AtomicReference;
import okhttp3.Credentials;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Protocol;
@ -50,8 +52,8 @@ class NumberlessProvisioningTest {
@Test
void numberlessProvisioningRequiresGroupCredentialSalt() {
assertThrows(IOException.class, () -> ProvisioningManagerImpl.parsePni(new ProvisionMessage.Builder().build()));
assertThrows(IOException.class, () -> ProvisioningManagerImpl.parsePni(new ProvisionMessage.Builder()
.authCredentialSalt(ByteString.EMPTY).build()));
final var emptySalt = new ProvisionMessage.Builder().authCredentialSalt(ByteString.EMPTY).build();
assertThrows(IOException.class, () -> ProvisioningManagerImpl.parsePni(emptySalt));
assertNull(ProvisioningManagerImpl.toRegistrationPreKeys(null));
}
@ -60,10 +62,12 @@ class NumberlessProvisioningTest {
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()));
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
@ -85,29 +89,57 @@ class NumberlessProvisioningTest {
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();
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 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);
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);
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());
assertEquals(Credentials.basic(ACI_ID.toString(), "test-password"), request.get().header("Authorization"));
assertTrue(body.get()
.path("accountAttributes")
.path("capabilities")
.path("optionalPhoneNumber")
.asBoolean());
assertTrue(body.get().hasNonNull("aciSignedPreKey"));
assertTrue(body.get().hasNonNull("aciPqLastResortPreKey"));
assertEquals(pni != null, body.get().has("pniSignedPreKey"));
@ -122,10 +154,12 @@ class NumberlessProvisioningTest {
@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"))));
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

@ -27,10 +27,21 @@ class NumberlessAccountTest {
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);
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);
}

View File

@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.signal.core.models.ServiceId.ACI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Set;
@ -29,7 +30,8 @@ class AccountsStoreTest {
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());
assertTrue(new AccountsStore(directory.toFile(), ServiceEnvironment.LIVE, ignored -> null).getAllAccounts()
.isEmpty());
}
@Test
@ -37,7 +39,7 @@ class AccountsStoreTest {
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));
Files.createFile(directory.resolve(oldPath));
final var newPath = store.addAccount(null, null);
store.updateAccount(newPath, null, aci);

View File

@ -215,7 +215,8 @@ public class App {
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 or ACI) 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

@ -30,7 +30,9 @@ public class ListAccountsCommand implements JsonRpcMultiLocalCommand {
final var managers = c.getManagers();
switch (outputWriter) {
case JsonWriter jsonWriter -> {
final var jsonAccounts = managers.stream().map(m -> new JsonAccount(m.getSelfNumber(), m.getSelfACI())).toList();
final var jsonAccounts = managers.stream()
.map(m -> new JsonAccount(m.getSelfNumber(), m.getSelfACI()))
.toList();
jsonWriter.write(jsonAccounts);
}
case PlainTextWriter plainTextWriter -> {

View File

@ -52,7 +52,8 @@ class NumberlessAccountOutputTest {
}
private static Manager manager(final String number) {
return (Manager) Proxy.newProxyInstance(Manager.class.getClassLoader(), new Class<?>[]{Manager.class},
return (Manager) Proxy.newProxyInstance(Manager.class.getClassLoader(),
new Class<?>[]{Manager.class},
(proxy, method, args) -> {
if (method.isDefault()) {
return InvocationHandler.invokeDefault(proxy, method, args);