Compare commits

..

12 Commits

Author SHA1 Message Date
AsamK
29dcac23cf Improve attachment download failure handling
Fixes #2116
2026-09-22 21:04:34 +02:00
AsamK
dc2a74e824 Increase max length for JSON-RPC reader
Should allow attachments with MAX_ATTACHMENT_SIZE

Fixes #2127
2026-09-22 20:56:28 +02:00
AsamK
dac64f773d Add support for recovering account with recovery key 2026-09-20 21:54:03 +02:00
AsamK
926cd256e6 Reformat files 2026-09-20 21:14:45 +02:00
AsamK
3d7109eb01 Remove numberless flag in accounts file
This is an edge case and keeping the ACI only account discoverable is
actually the better way to handle it.
2026-09-20 21:14:45 +02:00
Mariusz Woloszyn
d0724fb5b5
Support linking phone-numberless Signal accounts (#2123)
* Support linking phone-numberless Signal accounts

* Align numberless account support with project style

* Support numberless accounts over D-Bus

* Keep superseded accounts out of numberless account discovery

Record numberless accounts explicitly instead of treating every retained ACI
as a discoverable account. Resolve older index entries against saved account
state, preserving ACI lookup and retrying unavailable state on later reads.

The optional index field remains compatible with version 2 readers. Update
native-image reflection metadata and cover replacement, legacy migration,
locked and missing state, and mismatched ACIs in regression tests.

* 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.
2026-09-20 17:51:57 +02:00
nimble-turtle
60649041c8
man: link prints a QR code itself, don't point at qrencode first (#2126) 2026-09-20 09:35:55 +02:00
AsamK
e1d7bc5587 Return actual error of NonSuccess request result 2026-09-18 22:37:36 +02:00
Dmytro Lomako
c146224493
Add --attachment-dimensions and --attachment-blurhash to send (#2120)
* Accept caller-supplied attachment dimensions

Forward positional WIDTHxHEIGHT metadata supplied by the caller. Validate dimensions before sending and retain automatic image detection for omitted entries.

* Accept caller-supplied attachment BlurHashes

Forward positional BlurHashes supplied by the caller alongside attachment dimensions. Reject BlurHashes whose length doesn't match their size digit.

* Skip image probing when dimensions are supplied

Caller-supplied dimensions replace the probed values, so don't buffer and decode the image to measure it.
2026-09-17 16:58:40 +02:00
AsamK
a255a7fecf Update graalvm metadata
Fixes #2118
Fixes #2113
Fixes #2108
Fixes #2101
Fixes #2086
Fixes #2044
2026-09-14 14:19:27 +02:00
AsamK
752f63a5e6 Update graalvm metadata 2026-09-14 13:16:56 +02:00
AsamK
52a0f6415e Prepare next release 2026-09-10 19:58:04 +02:00
69 changed files with 2383 additions and 256 deletions

View File

@ -1,5 +1,11 @@
# Changelog
## [Unreleased]
### Added
- `send --attachment-dimensions` and `--attachment-blurhash` to set the placeholder shown before an attachment is downloaded
## [0.14.8] - 2026-09-10
### Added

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 the [wiki](https://github.com/AsamK/signal-cli/wiki) for further documentation.
* Link to an existing account
If you have an existing Signal account associated
with a number, you can link signal-cli to it with:
If you have an existing Signal account, with or without a phone number,
you can link signal-cli to it with:
signal-cli link

View File

@ -10,7 +10,7 @@ plugins {
allprojects {
group = "org.asamk"
version = "0.14.8"
version = "0.14.9-SNAPSHOT"
}
java {

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

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

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

@ -6,6 +6,7 @@ import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException;
import org.asamk.signal.manager.api.PinLockMissingException;
import org.asamk.signal.manager.api.PinLockedException;
import org.asamk.signal.manager.api.RateLimitException;
import org.asamk.signal.manager.api.TotpRequiredException;
import org.asamk.signal.manager.api.VerificationMethodNotAvailableException;
import java.io.Closeable;
@ -17,13 +18,19 @@ public interface RegistrationManager extends Closeable {
boolean voiceVerification,
String captcha,
final boolean forceRegister
) throws IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, RateLimitException, VerificationMethodNotAvailableException;
) throws IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, RateLimitException, TotpRequiredException, VerificationMethodNotAvailableException;
void verifyAccount(
String verificationCode,
String pin
) throws IOException, PinLockedException, IncorrectPinException, PinLockMissingException;
void registerWithRecoveryKey(
String recoveryKey,
boolean forceRegister,
Integer totp
) throws IOException, RateLimitException, TotpRequiredException;
void deleteLocalAccountData() throws IOException;
boolean isRegistered();

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 {
@ -65,15 +66,26 @@ 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(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();
@ -199,6 +211,11 @@ public class SignalAccountFiles {
String number,
Consumer<Manager> newManagerListener
) throws IOException {
final var aci = ACI.parseOrNull(number);
if (aci != null) {
return initRegistrationManager(aci, newManagerListener);
}
final var accountPath = accountsStore.getPathByNumber(number);
if (accountPath == null || !SignalAccount.accountFileExists(pathConfig.dataPath(), accountPath)) {
final var newAccountPath = accountPath == null ? accountsStore.addAccount(number, null) : accountPath;
@ -238,4 +255,43 @@ public class SignalAccountFiles {
newManagerListener,
new AccountFileUpdaterImpl(accountsStore, accountPath));
}
private RegistrationManager initRegistrationManager(
final ACI aci,
final Consumer<Manager> newManagerListener
) throws IOException {
final var accountPath = accountsStore.getPathByAci(aci);
if (accountPath == null || !SignalAccount.accountFileExists(pathConfig.dataPath(), accountPath)) {
final var newAccountPath = accountPath == null ? accountsStore.addAccount(null, aci) : accountPath;
final var account = SignalAccount.create(pathConfig.dataPath(),
newAccountPath,
null,
aci,
serviceEnvironment,
KeyUtils.generateIdentityKeyPair(),
KeyUtils.generateIdentityKeyPair(),
KeyUtils.createProfileKey(),
settings);
account.initDatabase();
return new RegistrationManagerImpl(account,
pathConfig,
serviceEnvironmentConfig,
userAgent,
newManagerListener,
new AccountFileUpdaterImpl(accountsStore, newAccountPath));
}
final var account = SignalAccount.load(pathConfig.dataPath(), accountPath, true, settings);
if (!aci.equals(account.getAci())) {
account.close();
throw new IOException("ACI in account file doesn't match expected ACI: " + account.getAci());
}
account.initDatabase();
return new RegistrationManagerImpl(account,
pathConfig,
serviceEnvironmentConfig,
userAgent,
newManagerListener,
new AccountFileUpdaterImpl(accountsStore, accountPath));
}
}

View File

@ -0,0 +1,19 @@
package org.asamk.signal.manager.api;
import org.signal.libsignal.net.BadRequestError;
import java.io.IOException;
public class BadRequestException extends IOException {
private final BadRequestError error;
public BadRequestException(final BadRequestError error) {
super(error.toString());
this.error = error;
}
public BadRequestError getError() {
return error;
}
}

View File

@ -6,6 +6,8 @@ import java.util.Optional;
public record Message(
String messageText,
List<String> attachments,
List<AttachmentDimensions> attachmentDimensions,
List<String> attachmentBlurHashes,
boolean viewOnce,
boolean voiceNote,
List<Mention> mentions,
@ -17,6 +19,8 @@ public record Message(
boolean urgent
) {
public record AttachmentDimensions(int width, int height) {}
public record Mention(RecipientIdentifier.Single recipient, int start, int length) {}
public record Quote(

View File

@ -0,0 +1,10 @@
package org.asamk.signal.manager.api;
import java.io.IOException;
public class TotpRequiredException extends IOException {
public TotpRequiredException() {
super("A TOTP token is required or the supplied token is incorrect");
}
}

View File

@ -28,11 +28,14 @@ public class ServiceConfig {
public static final int MAXIMUM_ONE_OFF_REQUEST_SIZE = 3;
public static final long UNREGISTERED_LIFESPAN = TimeUnit.DAYS.toMillis(30);
public static AccountAttributes.Capabilities getCapabilities(boolean isPrimaryDevice) {
public static AccountAttributes.Capabilities getCapabilities(
final boolean isPrimaryDevice,
final boolean hasPhoneNumber
) {
final var attachmentBackfill = !isPrimaryDevice;
final var spqr = true;
final var usernameSyncChangeMessage = !isPrimaryDevice;
final var optionalPhoneNumber = !isPrimaryDevice;
final var optionalPhoneNumber = !isPrimaryDevice || !hasPhoneNumber;
return new AccountAttributes.Capabilities(true,
true,
attachmentBackfill,

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;
@ -27,6 +28,7 @@ import org.signal.libsignal.protocol.state.SignedPreKeyRecord;
import org.signal.libsignal.protocol.util.KeyHelper;
import org.signal.libsignal.usernames.BaseUsernameException;
import org.signal.libsignal.usernames.Username;
import org.signal.network.api.AccountApiV2.SetAccountAttributesError;
import org.signal.network.exceptions.NonSuccessfulResponseCodeException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -96,10 +98,10 @@ public class AccountHelper {
} else {
context.getPreKeyHelper().refreshPreKeysIfNecessary();
}
if (account.getPni() == null) {
if (account.getPni() == null && account.getNumber() != null) {
checkWhoAmiI();
}
if (!account.isPrimaryDevice() && account.getPniIdentityKeyPair() == null) {
if (!account.isPrimaryDevice() && account.getPni() != null && account.getPniIdentityKeyPair() == null) {
throw new IOException("Missing PNI identity key, relinking required");
}
if (account.getPreviousStorageVersion() < 10
@ -138,8 +140,9 @@ public class AccountHelper {
final var whoAmI = dependencies.getAccountManager().getWhoAmI();
final var number = whoAmI.getNumber();
final var aci = ACI.parseOrThrow(whoAmI.getAci());
final var pni = PNI.parseOrThrow(whoAmI.getPni());
if (number.equals(account.getNumber()) && aci.equals(account.getAci()) && pni.equals(account.getPni())) {
final var pni = whoAmI.getPni() == null ? null : PNI.parseOrThrow(whoAmI.getPni());
if (Objects.equals(number, account.getNumber()) && aci.equals(account.getAci()) && Objects.equals(pni,
account.getPni())) {
return;
}
@ -150,7 +153,7 @@ public class AccountHelper {
account.setNumber(number);
account.setAci(aci);
account.setPni(pni);
if (account.isPrimaryDevice() && account.getPniIdentityKeyPair() == null) {
if (pni != null && account.isPrimaryDevice() && account.getPniIdentityKeyPair() == null) {
account.setPniIdentityKeyPair(KeyUtils.generateIdentityKeyPair());
}
account.getRecipientTrustedResolver().resolveSelfRecipientTrusted(account.getSelfRecipientAddress());
@ -564,7 +567,20 @@ public class AccountHelper {
}
public void updateAccountAttributes() throws IOException {
handleResponseException(dependencies.getAccountApi().setAccountAttributes(account.getAccountAttributes(null)));
if (account.getNumber() != null) {
handleResponseException(dependencies.getAccountApi()
.setAccountAttributes(account.getAccountAttributes(null)));
return;
}
try {
handleResponseExceptionSuspend(cont -> dependencies.getAccountApiV2()
.setAccountAttributes(account.getAccountAttributesV2(), cont));
} catch (BadRequestException e) {
if (e.getError() instanceof SetAccountAttributesError.Unauthorized) {
throw new AuthorizationFailedException(401, "Authorization failed!");
}
throw new IOException("Account attribute update rate limited; try again later");
}
}
public void addDevice(DeviceLinkUrl deviceLinkInfo) throws IOException, org.asamk.signal.manager.api.DeviceLimitExceededException {

View File

@ -1,6 +1,7 @@
package org.asamk.signal.manager.helper;
import org.asamk.signal.manager.api.AttachmentInvalidException;
import org.asamk.signal.manager.api.Message.AttachmentDimensions;
import org.asamk.signal.manager.config.ServiceConfig;
import org.asamk.signal.manager.internal.SignalDependencies;
import org.asamk.signal.manager.storage.AttachmentStore;
@ -52,9 +53,11 @@ public class AttachmentHelper {
public List<SignalServiceAttachment> uploadAttachments(
final List<String> attachments,
final List<AttachmentDimensions> dimensions,
final List<String> blurHashes,
boolean voiceNote
) throws AttachmentInvalidException, IOException {
final var attachmentStreams = createAttachmentStreams(attachments, voiceNote);
final var attachmentStreams = createAttachmentStreams(attachments, dimensions, blurHashes, voiceNote);
try {
// Upload attachments here, so we only upload once even for multiple recipients
@ -71,26 +74,31 @@ public class AttachmentHelper {
}
public List<SignalServiceAttachment> uploadAttachments(final List<String> attachments) throws AttachmentInvalidException, IOException {
return uploadAttachments(attachments, false);
return uploadAttachments(attachments, List.of(), List.of(), false);
}
private List<SignalServiceAttachmentStream> createAttachmentStreams(
List<String> attachments,
List<AttachmentDimensions> dimensions,
List<String> blurHashes,
boolean voiceNote
) throws AttachmentInvalidException, IOException {
if (attachments == null) {
return null;
}
final var signalServiceAttachments = new ArrayList<SignalServiceAttachmentStream>(attachments.size());
for (var attachment : attachments) {
final var attachmentStream = getAttachmentStream(attachment, voiceNote);
signalServiceAttachments.add(attachmentStream);
for (var i = 0; i < attachments.size(); i++) {
final var size = i < dimensions.size() ? dimensions.get(i) : null;
final var blurHash = i < blurHashes.size() && !blurHashes.get(i).isEmpty() ? blurHashes.get(i) : null;
signalServiceAttachments.add(getAttachmentStream(attachments.get(i), size, blurHash, voiceNote));
}
return signalServiceAttachments;
}
private SignalServiceAttachmentStream getAttachmentStream(
final String attachment,
final AttachmentDimensions dimensions,
final String blurHash,
final boolean voiceNote
) throws AttachmentInvalidException {
try {
@ -116,6 +124,8 @@ public class AttachmentHelper {
return AttachmentUtils.createAttachmentStream(streamDetails,
streamDetailsAndFileName.second(),
voiceNote,
dimensions,
blurHash,
uploadSpec);
} catch (IOException e) {
throw new AttachmentInvalidException(attachment, e);
@ -130,7 +140,7 @@ public class AttachmentHelper {
}
public SignalServiceAttachmentPointer uploadAttachment(String attachment) throws IOException, AttachmentInvalidException {
final var attachmentStream = getAttachmentStream(attachment, false);
final var attachmentStream = getAttachmentStream(attachment, null, null, false);
return uploadAttachment(attachmentStream);
}
@ -158,7 +168,7 @@ public class AttachmentHelper {
try {
attachmentStore.storeAttachment(pointer, outputStream -> this.retrieveAttachment(pointer, outputStream));
} catch (IOException e) {
logger.warn("Failed to download attachment ({}), ignoring: {}", pointer.getRemoteId(), e.getMessage());
logger.warn("Failed to download attachment ({}), ignoring", pointer.getRemoteId(), e);
}
}

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

@ -970,7 +970,10 @@ public class ManagerImpl implements Manager {
}
if (!message.attachments().isEmpty()) {
final var uploadedAttachments = context.getAttachmentHelper()
.uploadAttachments(message.attachments(), message.voiceNote());
.uploadAttachments(message.attachments(),
message.attachmentDimensions(),
message.attachmentBlurHashes(),
message.voiceNote());
if (!additionalAttachments.isEmpty()) {
additionalAttachments.addAll(uploadedAttachments);
messageBuilder.withAttachments(additionalAttachments);

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) {
@ -121,7 +114,7 @@ public class MultiAccountManagerImpl implements MultiAccountManager {
} else {
// Phone number — check already loaded managers
var existing = managers.stream()
.filter(m -> m.getSelfNumber().equals(identifier))
.filter(m -> identifier.equals(m.getSelfNumber()))
.findFirst()
.orElse(null);
if (existing != null) {

View File

@ -19,8 +19,8 @@ package org.asamk.signal.manager.internal;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.ProvisioningManager;
import org.asamk.signal.manager.Settings;
import org.asamk.signal.manager.api.BadRequestException;
import org.asamk.signal.manager.api.UserAlreadyExistsException;
import org.asamk.signal.manager.config.ServiceConfig;
import org.asamk.signal.manager.config.ServiceEnvironmentConfig;
import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.storage.accounts.AccountsStore;
@ -34,13 +34,15 @@ import org.signal.libsignal.protocol.IdentityKey;
import org.signal.libsignal.protocol.IdentityKeyPair;
import org.signal.libsignal.protocol.ecc.ECPrivateKey;
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.signal.network.api.RegistrationApiV2;
import org.signal.network.api.RegistrationApiV2.LinkDeviceResponse;
import org.signal.network.api.RegistrationApiV2.RegisterAsLinkedDeviceError;
import org.signal.network.rest.SignalRestClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
import org.whispersystems.signalservice.api.account.DeviceAttributes;
import org.whispersystems.signalservice.api.account.PreKeyCollection;
import org.whispersystems.signalservice.api.provisioning.ProvisioningSocket;
import org.whispersystems.signalservice.api.push.ServiceIdType;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException;
import org.whispersystems.signalservice.internal.crypto.SecondaryProvisioningCipher;
import org.whispersystems.signalservice.internal.push.ProvisionMessage;
@ -67,7 +69,7 @@ import kotlinx.coroutines.BuildersKt;
import kotlinx.coroutines.CoroutineScope;
import static org.asamk.signal.manager.util.KeyUtils.generatePreKeysForType;
import static org.asamk.signal.manager.util.Utils.handleResponseException;
import static org.asamk.signal.manager.util.Utils.handleResponseExceptionSuspend;
public class ProvisioningManagerImpl implements ProvisioningManager, Closeable {
@ -114,7 +116,8 @@ public class ProvisioningManagerImpl implements ProvisioningManager, Closeable {
public URI getDeviceLinkUri() throws TimeoutException, IOException {
try {
var url = urlFuture.get(30, TimeUnit.SECONDS);
return new URI(url);
// Mode.Link(false) does not advertise any capabilities itself.
return new URI(url + "&capabilities=nopni");
} catch (java.util.concurrent.TimeoutException e) {
throw new TimeoutException("Timed out waiting for provisioning URL");
} catch (InterruptedException e) {
@ -148,18 +151,20 @@ public class ProvisioningManagerImpl implements ProvisioningManager, Closeable {
var number = msg.number;
var aci = ACI.parseOrThrow(msg.aci, msg.aciBinary);
var pni = PNI.parseOrThrow(msg.pni, msg.pniBinary);
var pni = parsePni(msg);
var identifier = number != null ? number : aci.toString();
logger.info("Received link information from {}, linking in progress ...", number);
logger.info("Received link information from {}, linking in progress ...", identifier);
var accountPath = accountsStore.getPathByAci(aci);
if (accountPath == null) {
if (accountPath == null && number != null) {
accountPath = accountsStore.getPathByNumber(number);
}
final var accountExists = accountPath != null && SignalAccount.accountFileExists(pathConfig.dataPath(),
accountPath);
if (accountExists && !canRelinkExistingAccount(accountPath)) {
throw new UserAlreadyExistsException(number, SignalAccount.getFileName(pathConfig.dataPath(), accountPath));
throw new UserAlreadyExistsException(identifier,
SignalAccount.getFileName(pathConfig.dataPath(), accountPath));
}
if (accountPath == null) {
accountPath = accountsStore.addAccount(number, aci);
@ -173,8 +178,10 @@ public class ProvisioningManagerImpl implements ProvisioningManager, Closeable {
try {
aciIdentity = new IdentityKeyPair(new IdentityKey(msg.aciIdentityKeyPublic.toByteArray()),
new ECPrivateKey(msg.aciIdentityKeyPrivate.toByteArray()));
pniIdentity = new IdentityKeyPair(new IdentityKey(msg.pniIdentityKeyPublic.toByteArray()),
new ECPrivateKey(msg.pniIdentityKeyPrivate.toByteArray()));
pniIdentity = pni == null
? null
: new IdentityKeyPair(new IdentityKey(msg.pniIdentityKeyPublic.toByteArray()),
new ECPrivateKey(msg.pniIdentityKeyPrivate.toByteArray()));
profileKey = msg.profileKey == null
? KeyUtils.createProfileKey()
: new ProfileKey(msg.profileKey.toByteArray());
@ -222,28 +229,19 @@ public class ProvisioningManagerImpl implements ProvisioningManager, Closeable {
}
final var aciPreKeys = generatePreKeysForType(account.getAccountData(ServiceIdType.ACI));
final var pniPreKeys = generatePreKeysForType(account.getAccountData(ServiceIdType.PNI));
final var pniPreKeys = pni == null
? null
: generatePreKeysForType(account.getAccountData(ServiceIdType.PNI));
logger.debug("Finishing new device registration");
final var attrs = account.getAccountAttributes(null);
final var deviceAttributes = new DeviceAttributes(attrs.getFetchesMessages(),
attrs.getRegistrationId(),
attrs.getPniRegistrationId(),
attrs.getName(),
attrs.getCapabilities());
final var unauthAccountManager = SignalServiceAccountManager.createWithStaticCredentials(
serviceEnvironmentConfig.signalServiceConfiguration(),
null,
null,
number,
SignalServiceAddress.DEFAULT_DEVICE_ID,
password,
userAgent,
ServiceConfig.AUTOMATIC_NETWORK_RETRY,
ServiceConfig.GROUP_MAX_SIZE);
final var registerResponse = handleResponseException(unauthAccountManager.getRegistrationApi()
.registerAsSecondaryDevice(msg.provisioningCode, deviceAttributes, aciPreKeys, pniPreKeys, null));
final var deviceId = Integer.parseInt(registerResponse.getDeviceId());
final var restClient = new SignalRestClient(serviceEnvironmentConfig.signalServiceConfiguration(),
userAgent);
final var registrationApi = new RegistrationApiV2(restClient, false);
final var deviceId = registerLinkedDevice(registrationApi,
account,
msg.provisioningCode,
aciPreKeys,
pniPreKeys);
account.finishLinking(deviceId, aciPreKeys, pniPreKeys);
linkingFinished = true;
@ -277,7 +275,7 @@ public class ProvisioningManagerImpl implements ProvisioningManager, Closeable {
newManagerListener.accept(m);
m = null;
}
return number;
return identifier;
} finally {
if (m != null) {
m.close();
@ -296,6 +294,71 @@ public class ProvisioningManagerImpl implements ProvisioningManager, Closeable {
}
}
static PNI parsePni(final ProvisionMessage message) throws IOException {
if (message.number != null) {
return PNI.parseOrThrow(message.pni, message.pniBinary);
}
if (message.pni != null
|| message.pniBinary != null
|| message.pniIdentityKeyPublic != null
|| message.pniIdentityKeyPrivate != null) {
throw new IOException("Provisioning message has PNI material without a phone number");
}
if (message.authCredentialSalt == null || message.authCredentialSalt.size() == 0) {
throw new IOException("Numberless provisioning message is missing the group auth credential salt");
}
return null;
}
static int registerLinkedDevice(
final RegistrationApiV2 registrationApi,
final SignalAccount account,
final String provisioningCode,
final PreKeyCollection aciPreKeys,
final PreKeyCollection pniPreKeys
) throws IOException {
final var attrs = account.getAccountAttributesV2();
final var deviceAttributes = new RegistrationApiV2.DeviceAttributes(attrs.getFetchesMessages(),
attrs.getRegistrationId(),
attrs.getPniRegistrationId(),
attrs.getName(),
attrs.getCapabilities());
try {
final LinkDeviceResponse result = handleResponseExceptionSuspend(cont -> registrationApi.registerAsSecondaryDevice(
account.getAci(),
account.getPassword(),
provisioningCode,
deviceAttributes,
toRegistrationPreKeys(aciPreKeys),
toRegistrationPreKeys(pniPreKeys),
null,
cont));
return result.getDeviceId();
} catch (BadRequestException e) {
throw switch (e.getError()) {
case RegisterAsLinkedDeviceError.IncorrectVerification ignored ->
new AuthorizationFailedException(403, "Device verification failed");
case RegisterAsLinkedDeviceError.MissingCapability ignored ->
new IOException("Linked device is missing a required account capability");
case RegisterAsLinkedDeviceError.MaxLinkedDevices ignored ->
new IOException("Account has reached its linked device limit");
case RegisterAsLinkedDeviceError.InvalidRequest ignored ->
new IOException("Signal rejected the device linking request");
case RegisterAsLinkedDeviceError.RateLimited ignored ->
new IOException("Device linking rate limited; try again later");
default -> new IOException("Unexpected device linking response");
};
}
}
static RegistrationApiV2.PreKeyCollection toRegistrationPreKeys(final PreKeyCollection preKeys) {
return preKeys == null
? null
: new RegistrationApiV2.PreKeyCollection(preKeys.getIdentityKey(),
preKeys.getSignedPreKey(),
preKeys.getLastResortKyberPreKey());
}
@Override
public void close() throws IOException {
socketHandle.close();

View File

@ -18,12 +18,14 @@ package org.asamk.signal.manager.internal;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.RegistrationManager;
import org.asamk.signal.manager.api.BadRequestException;
import org.asamk.signal.manager.api.CaptchaRequiredException;
import org.asamk.signal.manager.api.IncorrectPinException;
import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException;
import org.asamk.signal.manager.api.PinLockMissingException;
import org.asamk.signal.manager.api.PinLockedException;
import org.asamk.signal.manager.api.RateLimitException;
import org.asamk.signal.manager.api.TotpRequiredException;
import org.asamk.signal.manager.api.UpdateProfile;
import org.asamk.signal.manager.api.VerificationMethodNotAvailableException;
import org.asamk.signal.manager.config.ServiceConfig;
@ -33,10 +35,14 @@ import org.asamk.signal.manager.helper.PinHelper;
import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.util.KeyUtils;
import org.asamk.signal.manager.util.NumberVerificationUtils;
import org.signal.core.models.AccountEntropyPool;
import org.signal.core.models.MasterKey;
import org.signal.core.models.ServiceId.ACI;
import org.signal.core.models.ServiceId.PNI;
import org.signal.libsignal.usernames.BaseUsernameException;
import org.signal.network.api.RegistrationApiV2;
import org.signal.network.api.RegistrationApiV2.RegisterAccountError;
import org.signal.network.rest.SignalRestClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
@ -50,15 +56,25 @@ import org.whispersystems.signalservice.api.svr.SecureValueRecovery;
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse;
import java.io.IOException;
import java.util.Base64;
import java.util.function.Consumer;
import static org.asamk.signal.manager.internal.ProvisioningManagerImpl.toRegistrationPreKeys;
import static org.asamk.signal.manager.util.KeyUtils.generatePreKeysForType;
import static org.asamk.signal.manager.util.Utils.handleResponseException;
import static org.asamk.signal.manager.util.Utils.handleResponseExceptionSuspend;
public class RegistrationManagerImpl implements RegistrationManager {
private static final Logger logger = LoggerFactory.getLogger(RegistrationManagerImpl.class);
private static final class RecoveryRequestFailedException extends IOException {
private RecoveryRequestFailedException(final String message, final Throwable cause) {
super(message, cause);
}
}
private SignalAccount account;
private final PathConfig pathConfig;
private final ServiceEnvironmentConfig serviceEnvironmentConfig;
@ -107,7 +123,7 @@ public class RegistrationManagerImpl implements RegistrationManager {
boolean voiceVerification,
String captcha,
final boolean forceRegister
) throws IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, RateLimitException, VerificationMethodNotAvailableException {
) throws IOException, CaptchaRequiredException, NonNormalizedPhoneNumberException, RateLimitException, TotpRequiredException, VerificationMethodNotAvailableException {
if (account.isRegistered()
&& account.getServiceEnvironment() != null
&& account.getServiceEnvironment() != serviceEnvironmentConfig.type()) {
@ -129,6 +145,13 @@ public class RegistrationManagerImpl implements RegistrationManager {
if (recoveryPassword != null && account.isPrimaryDevice() && attemptReregisterAccount(recoveryPassword)) {
return;
}
if (account.getAci() != null && account.getAccountEntropyPool() != null && attemptRecoverAccount(null)) {
return;
}
if (account.getNumber() == null) {
throw new IOException("Failed to recover account using its ACI and Account Entropy Pool");
}
final var registrationApi = unauthenticatedAccountManager.getRegistrationApi();
logger.trace("Creating verification session");
@ -184,6 +207,108 @@ public class RegistrationManagerImpl implements RegistrationManager {
finishAccountRegistration(response, pin, masterKey, aciPreKeys, pniPreKeys);
}
@Override
public void registerWithRecoveryKey(
final String recoveryKey,
final boolean forceRegister,
final Integer totp
) throws IOException, RateLimitException, TotpRequiredException {
if (account.getAci() == null) {
throw new IOException("Recovery-key registration requires an ACI account identifier");
}
if (account.isRegistered() && !forceRegister) {
throw new IOException("Account is already registered; use --reregister to register it again");
}
final var accountEntropyPool = AccountEntropyPool.Companion.parseOrNull(recoveryKey);
if (accountEntropyPool == null || !AccountEntropyPool.Companion.isFullyValid(accountEntropyPool.getValue())) {
throw new IOException("Invalid recovery key");
}
recoverAccount(totp, false, accountEntropyPool);
}
private boolean attemptRecoverAccount(
final Integer totp
) throws IOException, RateLimitException, TotpRequiredException {
final var accountEntropyPool = account.getAccountEntropyPool();
try {
recoverAccount(totp, false, accountEntropyPool);
logger.info("Reregistered existing account using its ACI and Account Entropy Pool.");
return true;
} catch (TotpRequiredException | RateLimitException e) {
throw e;
} catch (RecoveryRequestFailedException e) {
logger.debug("Failed to reregister account using its ACI and Account Entropy Pool", e);
return false;
}
}
private void recoverAccount(
final Integer totp,
final boolean includeRegistrationLock,
final AccountEntropyPool accountEntropyPool
) throws IOException, RateLimitException, TotpRequiredException {
if (account.getPniIdentityKeyPair() == null) {
account.setPniIdentityKeyPair(KeyUtils.generateIdentityKeyPair());
}
final var aciPreKeys = generatePreKeysForType(account.getAccountData(ServiceIdType.ACI));
final var pniPreKeys = generatePreKeysForType(account.getAccountData(ServiceIdType.PNI));
final var masterKey = accountEntropyPool.deriveMasterKey();
final var recoveryPassword = masterKey.deriveRegistrationRecoveryPassword();
final var registrationLock = includeRegistrationLock ? masterKey.deriveRegistrationLock() : null;
final var restClient = new SignalRestClient(serviceEnvironmentConfig.signalServiceConfiguration(), userAgent);
final var registrationApi = new RegistrationApiV2(restClient, true);
final RegistrationApiV2.RegisterAccountResponse response;
try {
response = handleResponseExceptionSuspend(cont -> registrationApi.registerAccount(null,
account.getPassword(),
null,
recoveryPassword,
null,
account.getAccountAttributesV2ForRecovery(registrationLock, recoveryPassword),
toRegistrationPreKeys(aciPreKeys),
toRegistrationPreKeys(pniPreKeys),
null,
true,
account.getAci(),
totp,
cont));
} catch (BadRequestException e) {
switch (e.getError()) {
case RegisterAccountError.RegistrationLock ignored -> {
if (includeRegistrationLock) {
throw new RecoveryRequestFailedException("Registration lock recovery failed", e);
}
recoverAccount(totp, true, accountEntropyPool);
return;
}
case RegisterAccountError.TotpMissingOrIncorrect ignored -> throw new TotpRequiredException();
case RegisterAccountError.RegistrationRecoveryPasswordIncorrect ignored ->
throw new RecoveryRequestFailedException("Account key or recovery key is incorrect", e);
case RegisterAccountError.RateLimited ignored -> throw new RateLimitException(null);
case RegisterAccountError.PostQuantumRatchetRequired ignored ->
throw new IOException("signal-cli is too old to register this account", e);
default -> throw new IOException("Signal rejected recovery-key registration", e);
}
}
final var aci = ACI.parseOrThrow(response.getAci());
final var pni = response.getPni() == null ? null : PNI.parseOrThrow(response.getPni());
final var authCredentialSalt = response.getAuthCredentialSalt() == null
? null
: Base64.getDecoder().decode(response.getAuthCredentialSalt());
account.finishRecoveryRegistration(aci,
pni,
response.getE164(),
accountEntropyPool,
authCredentialSalt,
aciPreKeys,
pni == null ? null : pniPreKeys);
accountFileUpdater.updateAccountIdentifiers(response.getE164(), aci);
finishManagerRegistration(response.getStorageCapable());
}
@Override
public void deleteLocalAccountData() throws IOException {
account.deleteAccountData();
@ -291,13 +416,17 @@ public class RegistrationManagerImpl implements RegistrationManager {
account.finishRegistration(aci, pni, masterKey, pin, aciPreKeys, pniPreKeys);
accountFileUpdater.updateAccountIdentifiers(account.getNumber(), aci);
finishManagerRegistration(response.isStorageCapable());
}
private void finishManagerRegistration(final boolean storageCapable) throws IOException {
ManagerImpl m = null;
try {
m = new ManagerImpl(account, pathConfig, accountFileUpdater, serviceEnvironmentConfig, userAgent);
account = null;
m.refreshPreKeys();
if (response.isStorageCapable()) {
if (storageCapable) {
m.syncRemoteStorage();
}
// Set an initial empty profile so user can be added to groups

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

@ -8,9 +8,11 @@ import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemo
import org.whispersystems.signalservice.api.util.StreamDetails;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.Optional;
public class AttachmentStore {
@ -51,8 +53,23 @@ public class AttachmentStore {
private void storeAttachment(final File attachmentFile, final AttachmentStorer storer) throws IOException {
createAttachmentsDir();
try (OutputStream output = new FileOutputStream(attachmentFile)) {
storer.store(output);
final var temporaryFile = Files.createTempFile(attachmentsPath.toPath(),
attachmentFile.getName() + ".",
".tmp");
try {
try (OutputStream output = Files.newOutputStream(temporaryFile)) {
storer.store(output);
}
try {
Files.move(temporaryFile,
attachmentFile.toPath(),
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException e) {
Files.move(temporaryFile, attachmentFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
}
} finally {
Files.deleteIfExists(temporaryFile);
}
}

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;
@ -233,6 +234,28 @@ public class SignalAccount implements Closeable {
IdentityKeyPair pniIdentityKey,
ProfileKey profileKey,
final Settings settings
) throws IOException {
return create(dataPath,
accountPath,
number,
null,
serviceEnvironment,
aciIdentityKey,
pniIdentityKey,
profileKey,
settings);
}
public static SignalAccount create(
File dataPath,
String accountPath,
String number,
ACI aci,
ServiceEnvironment serviceEnvironment,
IdentityKeyPair aciIdentityKey,
IdentityKeyPair pniIdentityKey,
ProfileKey profileKey,
final Settings settings
) throws IOException {
IOUtils.createPrivateDirectories(dataPath);
var fileName = getFileName(dataPath, accountPath);
@ -251,6 +274,7 @@ public class SignalAccount implements Closeable {
signalAccount.deviceId = SignalServiceAddress.DEFAULT_DEVICE_ID;
signalAccount.dataPath = dataPath;
signalAccount.aciAccountData.setServiceId(aci);
signalAccount.aciAccountData.setIdentityKeyPair(aciIdentityKey);
signalAccount.pniAccountData.setIdentityKeyPair(pniIdentityKey);
signalAccount.aciAccountData.setLocalRegistrationId(KeyHelper.generateRegistrationId(false));
@ -350,7 +374,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();
}
@ -392,6 +418,53 @@ public class SignalAccount implements Closeable {
clearSessionId();
}
public void finishRecoveryRegistration(
final ACI aci,
final PNI pni,
final String number,
final AccountEntropyPool accountEntropyPool,
final byte[] authCredentialSalt,
final PreKeyCollection aciPreKeys,
final PreKeyCollection pniPreKeys
) {
this.pinMasterKey = null;
this.accountEntropyPool = accountEntropyPool;
this.authCredentialSalt = authCredentialSalt;
this.number = number;
getKeyValueStore().storeEntry(storageManifestVersion, -1L);
this.setStorageManifest(null);
this.storageKey = null;
this.encryptedDeviceName = null;
this.deviceId = SignalServiceAddress.DEFAULT_DEVICE_ID;
this.isMultiDevice = false;
this.registered = true;
this.aciAccountData.setServiceId(aci);
this.pniAccountData.setServiceId(pni);
if (pni == null) {
this.pniAccountData.setIdentityKeyPair(null);
}
init();
this.registrationLockPin = null;
setLastReceiveTimestamp(0L);
setLastAppliedPniChangeServerTimestamp(0L);
save();
setPreKeys(ServiceIdType.ACI, aciPreKeys);
if (pni != null && pniPreKeys != null) {
setPreKeys(ServiceIdType.PNI, pniPreKeys);
}
aciAccountData.getSessionStore().archiveAllSessions();
pniAccountData.getSessionStore().archiveAllSessions();
getSenderKeyStore().deleteAll();
getRecipientTrustedResolver().resolveSelfRecipientTrusted(getSelfRecipientAddress());
trustSelfIdentity(ServiceIdType.ACI);
if (pni != null) {
trustSelfIdentity(ServiceIdType.PNI);
}
getKeyValueStore().storeEntry(lastRecipientsRefresh, null);
clearSessionId();
}
public void initDatabase() {
getAccountDatabase();
}
@ -401,7 +474,7 @@ public class SignalAccount implements Closeable {
}
private void migrateLegacyConfigs() {
if (isPrimaryDevice() && getPniIdentityKeyPair() == null) {
if (isPrimaryDevice() && (number != null || getPni() != null) && getPniIdentityKeyPair() == null) {
logger.trace("Migrating legacy parts of account file");
setPniIdentityKeyPair(KeyUtils.generateIdentityKeyPair());
}
@ -1423,7 +1496,56 @@ public class SignalAccount implements Closeable {
}
public AccountAttributes.Capabilities getAccountCapabilities() {
return getCapabilities(isPrimaryDevice());
return getCapabilities(isPrimaryDevice(), number != null);
}
public RegistrationApiV2.AccountAttributes getAccountAttributesV2() {
return getAccountAttributesV2(false, getRegistrationLock());
}
public RegistrationApiV2.AccountAttributes getAccountAttributesV2(
final boolean includePniRegistrationId,
final String registrationLock
) {
return getAccountAttributesV2(includePniRegistrationId,
registrationLock,
getRecoveryPassword(),
number == null ? null : isDiscoverableByPhoneNumber());
}
public RegistrationApiV2.AccountAttributes getAccountAttributesV2ForRecovery(
final String registrationLock,
final String recoveryPassword
) {
return getAccountAttributesV2(true, registrationLock, recoveryPassword, null);
}
private RegistrationApiV2.AccountAttributes getAccountAttributesV2(
final boolean includePniRegistrationId,
final String registrationLock,
final String recoveryPassword,
final Boolean discoverableByPhoneNumber
) {
final var attributes = getAccountAttributes(null);
final var capabilities = attributes.getCapabilities();
return new RegistrationApiV2.AccountAttributes(attributes.getSignalingKey(),
attributes.getRegistrationId(),
attributes.getVoice(),
attributes.getVideo(),
attributes.getFetchesMessages(),
registrationLock,
attributes.getUnidentifiedAccessKey(),
attributes.getUnrestrictedUnidentifiedAccess(),
discoverableByPhoneNumber,
new RegistrationApiV2.AccountAttributes.Capabilities(capabilities.getStorage(),
capabilities.getVersionedExpirationTimer(),
capabilities.getAttachmentBackfill(),
capabilities.getSpqr(),
capabilities.getUsernameChangeSyncMessage(),
capabilities.getOptionalPhoneNumber()),
attributes.getName(),
includePniRegistrationId || getPni() != null ? attributes.getPniRegistrationId() : null,
recoveryPassword);
}
public ServiceId getAccountId(ServiceIdType serviceIdType) {
@ -1636,6 +1758,10 @@ public class SignalAccount implements Closeable {
return accountEntropyPool;
}
public AccountEntropyPool getAccountEntropyPool() {
return accountEntropyPool;
}
public void setAccountEntropyPool(final AccountEntropyPool accountEntropyPool) {
this.accountEntropyPool = accountEntropyPool;
if (accountEntropyPool != null) {

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);
}
@ -188,7 +191,7 @@ public class AccountsStore {
private List<AccountsStorage.Account> readAccounts() throws IOException {
final var pair = openFileChannel(getAccountsFile());
try (final var fileChannel = pair.first(); final var lock = pair.second()) {
final var storage = readAccountsLocked(fileChannel);
var storage = readAccountsLocked(fileChannel);
var accountsVersion = storage.version() == null ? 1 : storage.version();
if (accountsVersion > CURRENT_STORAGE_VERSION) {
@ -197,7 +200,7 @@ public class AccountsStore {
throw new IOException("Accounts file was created by a no longer supported older version: "
+ accountsVersion);
} else if (accountsVersion < CURRENT_STORAGE_VERSION) {
return upgradeAccountsFile(fileChannel, storage, accountsVersion).accounts();
storage = upgradeAccountsFile(fileChannel, storage, accountsVersion);
}
return storage.accounts();
}

View File

@ -1,5 +1,6 @@
package org.asamk.signal.manager.util;
import org.asamk.signal.manager.api.Message.AttachmentDimensions;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream;
@ -27,16 +28,21 @@ public class AttachmentUtils {
StreamDetails streamDetails,
Optional<String> name,
boolean voiceNote,
AttachmentDimensions dimensions,
String blurHash,
ResumableUploadSpec resumableUploadSpec
) throws ResumeLocationInvalidException, IOException {
final var uploadTimestamp = System.currentTimeMillis();
final var probedStream = probeImageDimensions(streamDetails);
final var probedStream = dimensions != null ? new ProbedStream(streamDetails.getStream(),
dimensions.width(),
dimensions.height()) : probeImageDimensions(streamDetails);
return SignalServiceAttachmentStream.newStreamBuilder()
.withStream(probedStream.inputStream())
.withContentType(streamDetails.getContentType())
.withLength(streamDetails.getLength())
.withFileName(name.orElse(null))
.withVoiceNote(voiceNote)
.withBlurHash(blurHash)
.withWidth(probedStream.width())
.withHeight(probedStream.height())
.withUploadTimestamp(uploadTimestamp)
@ -50,7 +56,7 @@ public class AttachmentUtils {
Optional<String> name,
ResumableUploadSpec resumableUploadSpec
) throws ResumeLocationInvalidException, IOException {
return createAttachmentStream(streamDetails, name, false, resumableUploadSpec);
return createAttachmentStream(streamDetails, name, false, null, null, resumableUploadSpec);
}
/**

View File

@ -42,6 +42,19 @@ public class PhoneNumberFormatter {
throw new InvalidNumberException("No valid characters found.");
}
if (localNumber == null) {
if (!number.startsWith("+")) {
throw new InvalidNumberException(
"Use an international number including the country code for a numberless account.");
}
try {
final var util = PhoneNumberUtil.getInstance();
return util.format(util.parse(number, null), PhoneNumberFormat.E164);
} catch (NumberParseException e) {
throw new InvalidNumberException("Invalid international phone number.");
}
}
try {
PhoneNumberUtil util = PhoneNumberUtil.getInstance();
PhoneNumber localNumberObject = util.parse(localNumber, null);

View File

@ -1,5 +1,6 @@
package org.asamk.signal.manager.util;
import org.asamk.signal.manager.api.BadRequestException;
import org.asamk.signal.manager.api.Pair;
import org.signal.core.models.ServiceId;
import org.signal.libsignal.net.BadRequestError;
@ -194,8 +195,8 @@ public class Utils {
}
} else if (result instanceof RequestResult.RetryableNetworkError e) {
throw e.getNetworkError();
} else if (result instanceof RequestResult.NonSuccess) {
throw new AssertionError();
} else if (result instanceof RequestResult.NonSuccess<?> e) {
throw new BadRequestException(e.getError());
}
throw new IllegalStateException("Unexpected value: " + result);
}

View File

@ -0,0 +1,57 @@
package org.asamk.signal.manager.helper;
import org.asamk.signal.manager.Settings;
import org.asamk.signal.manager.api.ServiceEnvironment;
import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.util.KeyUtils;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.signal.core.models.ServiceId.ACI;
import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup;
import org.signal.storageservice.storage.protos.groups.local.DecryptedMember;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
class NumberlessGroupTest {
@TempDir
Path directory;
@Test
void findsOurGroupMembershipByAciWhenPniIsAbsent() throws Exception {
final var aci = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111");
final var otherAci = ACI.parseOrThrow("22222222-2222-4222-8222-222222222222");
try (final var account = SignalAccount.createLinkedAccount(directory.toFile(),
"account",
ServiceEnvironment.STAGING,
Settings.DEFAULT); final var context = new Context(account, null, null, null, null, null)) {
account.setProvisioningData(null,
aci,
null,
"test-password",
new byte[]{1},
KeyUtils.generateIdentityKeyPair(),
null,
KeyUtils.createProfileKey(),
null,
new byte[32],
null);
final var otherMember = new DecryptedMember.Builder().aciBytes(otherAci.toByteString())
.joinedAtRevision(1)
.build();
final var selfMember = new DecryptedMember.Builder().aciBytes(aci.toByteString())
.joinedAtRevision(4)
.build();
final var group = new DecryptedGroup.Builder().revision(9)
.members(List.of(otherMember, selfMember))
.build();
assertEquals(4, context.getGroupV2Helper().findRevisionWeWereAdded(group));
assertEquals(9,
context.getGroupV2Helper()
.findRevisionWeWereAdded(group.newBuilder().members(List.of(otherMember)).build()));
}
}
}

View File

@ -39,8 +39,8 @@ class StorageHelperTest {
Set.of(pendingId)));
assertFalse(StorageHelper.containsOnlyIdentityConflictsPendingRepair(write(List.of()), Set.of(pendingId)));
assertFalse(StorageHelper.containsOnlyIdentityConflictsPendingRepair(new WriteOperationResult(null,
List.of(pendingRecord),
List.of(new byte[]{3})), Set.of(pendingId)));
List.of(pendingRecord),
List.of(new byte[]{3})), Set.of(pendingId)));
}
private static SignalStorageRecord record(final StorageId id) {

View File

@ -0,0 +1,182 @@
package org.asamk.signal.manager.internal;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.asamk.signal.manager.Settings;
import org.asamk.signal.manager.api.ServiceEnvironment;
import org.asamk.signal.manager.config.ServiceConfig;
import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.util.KeyUtils;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.signal.core.models.ServiceId.ACI;
import org.signal.core.models.ServiceId.PNI;
import org.signal.network.api.RegistrationApiV2;
import org.signal.network.rest.SignalRestClient;
import org.whispersystems.signalservice.api.push.ServiceIdType;
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException;
import org.whispersystems.signalservice.internal.push.ProvisionMessage;
import java.io.IOException;
import java.nio.file.Path;
import java.security.SecureRandom;
import java.util.concurrent.atomic.AtomicReference;
import okhttp3.Credentials;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Protocol;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.ResponseBody;
import okio.Buffer;
import okio.ByteString;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class NumberlessProvisioningTest {
private static final ACI ACI_ID = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111");
private static final PNI PNI_ID = PNI.parseOrThrow("22222222-2222-4222-8222-222222222222");
@TempDir
Path directory;
@Test
void numberlessProvisioningRequiresGroupCredentialSalt() {
assertThrows(IOException.class, () -> ProvisioningManagerImpl.parsePni(new ProvisionMessage.Builder().build()));
final var emptySalt = new ProvisionMessage.Builder().authCredentialSalt(ByteString.EMPTY).build();
assertThrows(IOException.class, () -> ProvisioningManagerImpl.parsePni(emptySalt));
assertNull(ProvisioningManagerImpl.toRegistrationPreKeys(null));
}
@Test
void acceptsNumberlessProvisioningButRejectsInconsistentPhoneIdentity() throws Exception {
final var message = new ProvisionMessage.Builder().authCredentialSalt(ByteString.of(new byte[32]));
assertNull(ProvisioningManagerImpl.parsePni(message.build()));
assertThrows(IOException.class, () -> ProvisioningManagerImpl.parsePni(message.pni(PNI_ID.toString()).build()));
assertThrows(IllegalArgumentException.class,
() -> ProvisioningManagerImpl.parsePni(new ProvisionMessage.Builder().number("+12025550123").build()));
assertEquals(PNI_ID,
ProvisioningManagerImpl.parsePni(new ProvisionMessage.Builder().number("+12025550123")
.pni(PNI_ID.toString())
.build()));
}
@Test
void numberlessLinkRequestOmitsPniAndAuthenticatesWithAci() throws Exception {
checkLinkRequest(null, null, 200, null);
}
@Test
void numberedLinkRequestStillIncludesPniKeys() throws Exception {
checkLinkRequest("+12025550123", PNI_ID, 200, null);
}
@ParameterizedTest
@CsvSource({
"422, Signal rejected the device linking request",
"403, [403] Device verification failed",
"409, Linked device is missing a required account capability",
"411, Account has reached its linked device limit",
"429, Device linking rate limited; try again later"
})
void linkingErrorsAreReportedWithoutCrashingOrIncludingServerBody(
final int statusCode,
final String errorMessage
) throws Exception {
checkLinkRequest(null, null, statusCode, errorMessage);
}
private void checkLinkRequest(
final String number,
final PNI pni,
final int statusCode,
final String errorMessage
) throws Exception {
final var request = new AtomicReference<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(statusCode)
.message("Test response")
.body(ResponseBody.create(statusCode == 200 ? "{\"deviceId\":2}" : "secret payload",
MediaType.get("application/json")))
.build();
}).build();
final var config = ServiceConfig.getServiceEnvironmentConfig(ServiceEnvironment.STAGING, "signal-cli-test");
final var restClient = new SignalRestClient(config.signalServiceConfiguration(),
"signal-cli-test",
null,
false,
1000L,
new SecureRandom(),
client);
final var api = new RegistrationApiV2(restClient, false);
try (final var account = SignalAccount.createLinkedAccount(directory.toFile(),
"account",
ServiceEnvironment.STAGING,
Settings.DEFAULT)) {
account.setProvisioningData(number,
ACI_ID,
pni,
"test-password",
new byte[]{1},
KeyUtils.generateIdentityKeyPair(),
pni == null ? null : KeyUtils.generateIdentityKeyPair(),
KeyUtils.createProfileKey(),
null,
new byte[32],
null);
final var aciKeys = KeyUtils.generatePreKeysForType(account.getAccountData(ServiceIdType.ACI));
final var pniKeys = pni == null
? null
: KeyUtils.generatePreKeysForType(account.getAccountData(ServiceIdType.PNI));
if (statusCode == 200) {
assertEquals(2,
ProvisioningManagerImpl.registerLinkedDevice(api, account, "test-code", aciKeys, pniKeys));
} else {
final var error = assertThrows(IOException.class,
() -> ProvisioningManagerImpl.registerLinkedDevice(api,
account,
"test-code",
aciKeys,
pniKeys));
assertEquals(errorMessage, error.getMessage());
if (statusCode == 403) {
assertInstanceOf(AuthorizationFailedException.class, error);
}
}
assertEquals("PUT", request.get().method());
assertEquals("/v1/devices/link", request.get().url().encodedPath());
assertEquals(Credentials.basic(ACI_ID.toString(), "test-password"), request.get().header("Authorization"));
assertTrue(body.get()
.path("accountAttributes")
.path("capabilities")
.path("optionalPhoneNumber")
.asBoolean());
assertTrue(body.get().hasNonNull("aciSignedPreKey"));
assertTrue(body.get().hasNonNull("aciPqLastResortPreKey"));
assertEquals(pni != null, body.get().has("pniSignedPreKey"));
assertEquals(pni != null, body.get().has("pniPqLastResortPreKey"));
assertEquals(pni != null, body.get().path("accountAttributes").has("pniRegistrationId"));
} finally {
client.dispatcher().executorService().shutdown();
client.connectionPool().evictAll();
}
}
}

View File

@ -0,0 +1,115 @@
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.AccountEntropyPool;
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());
}
}
@Test
void recoveredNumberlessPrimaryPreservesRecoveryMaterialAndDropsPniIdentity() throws Exception {
final var aci = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111");
final var entropyPool = AccountEntropyPool.Companion.generate();
final var salt = new byte[32];
salt[0] = 42;
try (final var account = SignalAccount.create(directory.toFile(),
"account",
null,
aci,
ServiceEnvironment.STAGING,
KeyUtils.generateIdentityKeyPair(),
KeyUtils.generateIdentityKeyPair(),
KeyUtils.createProfileKey(),
Settings.DEFAULT)) {
final var aciPreKeys = KeyUtils.generatePreKeysForType(account.getAccountData(ServiceIdType.ACI));
final var pniPreKeys = KeyUtils.generatePreKeysForType(account.getAccountData(ServiceIdType.PNI));
account.finishRecoveryRegistration(aci, null, null, entropyPool, salt, aciPreKeys, pniPreKeys);
}
try (final var account = SignalAccount.load(directory.toFile(), "account", true, Settings.DEFAULT)) {
assertTrue(account.isRegistered());
assertTrue(account.isPrimaryDevice());
assertEquals(aci, account.getAci());
assertNull(account.getNumber());
assertNull(account.getPni());
assertNull(account.getPniIdentityKeyPair());
assertEquals(entropyPool.getValue(), account.getAccountEntropyPool().getValue());
assertArrayEquals(salt, account.getAuthCredentialSalt());
assertTrue(account.getAccountAttributesV2().getCapabilities().getOptionalPhoneNumber());
}
}
@Test
void recoveryAttributesOmitPhoneNumberDiscoverabilityForNumberedAccount() throws Exception {
final var aci = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111");
try (final var account = SignalAccount.create(directory.toFile(),
"account",
"+12025550123",
aci,
ServiceEnvironment.STAGING,
KeyUtils.generateIdentityKeyPair(),
KeyUtils.generateIdentityKeyPair(),
KeyUtils.createProfileKey(),
Settings.DEFAULT)) {
assertNull(account.getAccountAttributesV2ForRecovery(null, "recovery-password")
.getDiscoverableByPhoneNumber());
}
}
}

View File

@ -0,0 +1,81 @@
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.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.signal.core.models.ServiceId.ACI;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Set;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class AccountsStoreTest {
private static final ACI OLD_ACI = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111");
private static final ACI NEW_ACI = ACI.parseOrThrow("22222222-2222-4222-8222-222222222222");
@TempDir
Path directory;
@Test
void discoversNumberlessAccountsByAciAndKeepsEnvironmentsSeparate() throws Exception {
final var store = new AccountsStore(directory.toFile(), ServiceEnvironment.STAGING, path -> null);
final var aci = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111");
final var path = store.addAccount(null, aci);
store.addAccount("+12025550123", null);
final var reopened = new AccountsStore(directory.toFile(), ServiceEnvironment.STAGING, ignored -> null);
assertEquals(2, reopened.getAllAccounts().size());
assertEquals(path, reopened.getPathByAci(aci));
assertNull(reopened.getPathByNumber(null));
assertEquals(Set.of("+12025550123"), reopened.getAllNumbers());
assertTrue(new AccountsStore(directory.toFile(), ServiceEnvironment.LIVE, ignored -> null).getAllAccounts()
.isEmpty());
}
@Test
void updatingAnAciDoesNotLeaveDuplicateNumberlessAccounts() throws Exception {
final var store = new AccountsStore(directory.toFile(), ServiceEnvironment.STAGING, path -> null);
final var aci = ACI.parseOrThrow("11111111-1111-4111-8111-111111111111");
final var oldPath = store.addAccount(null, aci);
Files.createFile(directory.resolve(oldPath));
final var newPath = store.addAccount(null, null);
store.updateAccount(newPath, null, aci);
assertEquals(newPath, store.getPathByAci(aci));
assertEquals(1, store.getAllAccounts().size());
}
@ParameterizedTest
@ValueSource(booleans = {false, true})
void replacingANumberKeepsTheOldAccountAvailableByAci(final boolean update) throws Exception {
final var store = new AccountsStore(directory.toFile(), ServiceEnvironment.STAGING, path -> null);
final var oldPath = store.addAccount("+12025550123", OLD_ACI);
Files.createFile(directory.resolve(oldPath));
final String newPath;
if (update) {
newPath = store.addAccount("+12025550124", NEW_ACI);
store.updateAccount(newPath, "+12025550123", NEW_ACI);
} else {
newPath = store.addAccount("+12025550123", NEW_ACI);
}
final var reopened = new AccountsStore(directory.toFile(), ServiceEnvironment.STAGING, path -> null);
assertEquals(Set.of(oldPath, newPath), getAccountPaths(reopened));
assertEquals(newPath, reopened.getPathByNumber("+12025550123"));
assertEquals(oldPath, reopened.getPathByAci(OLD_ACI));
}
private Set<String> getAccountPaths(final AccountsStore store) throws IOException {
return store.getAllAccounts().stream().map(AccountsStorage.Account::path).collect(Collectors.toSet());
}
}

View File

@ -1,5 +1,6 @@
package org.asamk.signal.manager.util;
import org.asamk.signal.manager.api.Message.AttachmentDimensions;
import org.junit.jupiter.api.Test;
import org.whispersystems.signalservice.api.util.StreamDetails;
@ -43,6 +44,42 @@ class AttachmentUtilsTest {
assertArrayEquals(bytes, attachment.getInputStream().readAllBytes());
}
@Test
public void createAttachmentStream_setsSuppliedDimensionsAndBlurHash() throws Exception {
final var bytes = "opaque video bytes".getBytes();
final var blurHash = "LEHV6nWB2yk8pyo0adR*.7kCMdnj";
final var details = new StreamDetails(new ByteArrayInputStream(bytes), "video/mp4", bytes.length);
final var attachment = AttachmentUtils.createAttachmentStream(details,
Optional.of("clip.mp4"),
false,
new AttachmentDimensions(1080, 1920),
blurHash,
null);
assertEquals(1080, attachment.getWidth());
assertEquals(1920, attachment.getHeight());
assertEquals(Optional.of(blurHash), attachment.getBlurHash());
assertArrayEquals(bytes, attachment.getInputStream().readAllBytes());
}
@Test
public void createAttachmentStream_skipsProbingWhenDimensionsSupplied() throws Exception {
final var imageBytes = pngBytes(37, 21);
final var stream = new ByteArrayInputStream(imageBytes);
final var details = new StreamDetails(stream, "image/png", imageBytes.length);
final var attachment = AttachmentUtils.createAttachmentStream(details,
Optional.of("meme.png"),
false,
new AttachmentDimensions(100, 200),
null,
null);
assertEquals(imageBytes.length, stream.available());
assertEquals(100, attachment.getWidth());
assertEquals(200, attachment.getHeight());
assertArrayEquals(imageBytes, attachment.getInputStream().readAllBytes());
}
private static byte[] pngBytes(final int width, final int height) throws Exception {
final var image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
final var out = new ByteArrayOutputStream();

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

@ -15,9 +15,13 @@ signal-cli-dbus - A commandline and dbus interface for the Signal messenger
*signal-cli* [--verbose] [--data-dir DATA_DIR] [-a ACCOUNT] [-o {plain-text,json}] daemon [--dbus] [--dbus-system]
*dbus-send* [--system | --session] [--print-reply] --type=method_call --dest="org.asamk.Signal" /org/asamk/Signal[/_<phonenumber>] org.asamk.Signal.<method> [string:<string argument>] [array:<type>:<array argument>]
*dbus-send* [--system | --session] [--print-reply] --type=method_call --dest="org.asamk.Signal" /org/asamk/Signal[/<account-id>] org.asamk.Signal.<method> [string:<string argument>] [array:<type>:<array argument>]
Note: when daemon was started without explicit `-a ACCOUNT`, the `dbus-send` command requires adding the phone number in `/org/asamk/Signal/_<phonenumber>`.
Note: when daemon was started without explicit `-a ACCOUNT`, use the account's
object path returned by `org.asamk.SignalControl.listAccounts` or
`org.asamk.SignalControl.getAccount`. Numbered accounts use
`/org/asamk/Signal/_<phonenumber>`. Accounts without a phone number use
`/org/asamk/Signal/<ACI>`, with hyphens replaced by underscores.
== Description
@ -49,6 +53,11 @@ All Exceptions begin with "org.asamk.Signal.Error." which is omitted here for be
Phone numbers always have the format +<countrycode><regional number>
For an account without a phone number, use its ACI (Account ID) with the
`signal-cli --dbus -a ACCOUNT` client. ACI selectors also work for numbered accounts.
An account without a phone number cannot infer the country code of a recipient;
use a complete international phone number, an ACI, or a Signal username.
== Methods
=== SignalControl interface
@ -73,11 +82,29 @@ For example:
dbus-send --session --dest=org.asamk.Signal --type=method_call --print-reply /org/asamk/Signal org.asamk.Signal.link string:"My secondary client" | tr '\n' '\0' | sed 's/.*string //g' | sed 's/\"//g' | qrencode -s10 -tANSI256
----
listAccounts() -> accountList<as>::
listAccounts() -> accountList<ao>::
* accountList : Array of all attached accounts in DBus object path form
Exceptions: None
getAccount(identifier<s>) -> account<o>::
* identifier : Phone number or ACI of an attached account
* account : DBus object path of the account
Exceptions: org.asamk.SignalControl.Error.Failure if the account is unknown
startLink() -> deviceLinkUri<s>::
* deviceLinkUri : Provisioning URI to scan with the primary Signal device
finishLink(deviceLinkUri<s>, newDeviceName<s>) -> identifier<s>::
* deviceLinkUri : URI returned by startLink
* newDeviceName : Name for the linked device
* identifier : Phone number of the linked account, or its ACI when it has no phone number
Both linking methods support accounts with or without a phone number.
Exceptions: org.asamk.SignalControl.Error.Failure
register(number<s>, voiceVerification<b>) -> <>::
* number : Phone number
* voiceVerification : true = use voice verification; false = use SMS verification
@ -115,9 +142,10 @@ Exceptions: None
The following methods listen to the account's object path, which is constructed as follows:
- for single-account mode: "/org/asamk/Signal/"
- for multi-account mode: "/org/asamk/Signal/" + DBusNumber
* DBusNumber: account's phone number, with underscore (_) replacing plus (+)
- for single-account mode: "/org/asamk/Signal"
- for multi-account mode: "/org/asamk/Signal/" + DBusIdentifier
* DBusIdentifier: account's phone number with underscore (_) replacing plus (+),
or its ACI with underscores replacing hyphens when there is no phone number
getContactName(number<s>) -> name<s>::
* number : Phone number
@ -135,7 +163,12 @@ May result in e.g. two entries if a contact and profile name is set.
Exceptions: None
getSelfNumber() -> number<s>::
* number : Your phone number
* number : Your phone number, or an empty string for an account without a phone number
Exceptions: None
getSelfACI() -> aci<s>::
* aci : Your account's ACI (Account ID), available with or without a phone number
Exceptions: None
@ -167,6 +200,14 @@ This is a concatenated list of all defined contacts as well of profiles known (e
Exceptions: None
listRecipientIdentifiers() -> identifiers<as>::
* identifiers : String array of known recipient identifiers, including recipients without a phone number
Uses phone numbers where known and includes ACIs for recipients without a phone
number. Unlike `listNumbers`, this method also includes numberless recipients.
Exceptions: None
removePin() -> <>::
Removes registration PIN protection.

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

@ -19,7 +19,8 @@ signal-cli - A commandline interface for the Signal messenger
signal-cli is a commandline interface for libsignal-service-java.
It supports registering, verifying, sending and receiving messages.
For registering you need a phone number where you can receive SMS or incoming calls.
Accounts can be registered using a phone number that can receive SMS or incoming calls.
Existing accounts can also be recovered using the Account Key and Recovery Key shown by Signal Android.
signal-cli was primarily developed to be used on servers to notify admins of important events.
For this use-case, it has a dbus and a JSON-RPC interface, that can be used to send messages from other programs.
@ -65,6 +66,8 @@ 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 (the Account Key shown by Signal Android) can also select an account and is required for an account without a phone number.
The Account Key can be given as 32 hexadecimal characters or as a UUID with dashes.
This flag must not be given for the `link` command.
It is optional for the `daemon` command.
@ -102,12 +105,13 @@ Disable message send log (for resending messages that recipient couldn't decrypt
=== register
Register a phone number with SMS or voice verification.
Use the verify command to complete the verification.
Register a phone number with SMS or voice verification, or recover an existing account using its Account Key and Recovery Key.
Use the verify command to complete SMS or voice verification.
If the account is just deactivated, the register command will just reactivate account, without requiring an SMS verification.
By default the unregister command just deactivates the account, in which case it can be reactivated without sms verification if the local data is still available.
If the account was deleted (with --delete-account) it cannot be reactivated.
If normal recovery of an existing local account fails and its Account Key and Recovery Key are available locally, recovery using those credentials is attempted before SMS verification.
*-v*, *--voice*::
The verification should be done over voice, not SMS.
@ -120,11 +124,23 @@ For the staging environment, use: https://signalcaptchas.org/staging/registratio
After solving the captcha, right-click on the "Open Signal" link and copy the link.
*--reregister*::
Register even if account is already registered.
Register even if the local account is already registered.
This option is required when using `--recovery-key` with an account that is still marked as registered locally.
*--recovery-key* RECOVERY-KEY::
Recover an existing account using the 64-character Recovery Key shown by Signal Android.
Specify the corresponding ACI (Account Key) with `-a`.
This option cannot be combined with `--voice` or `--captcha`.
*--totp* TOKEN::
The six-digit TOTP token requested during Account Key and Recovery Key based account recovery.
If automatic account recovery reports that a TOTP token is required, rerun `register` with both `--recovery-key` and `--totp`.
This option requires `--recovery-key`.
This option cannot be combined with `--voice` or `--captcha`.
=== verify
Verify the number using the code received via SMS or voice.
Verify a number using the code received via SMS or voice.
VERIFICATIONCODE::
The verification code.
@ -247,7 +263,12 @@ Remove the registration lock pin.
Link to an existing device, instead of registering a new number.
This shows a "sgnl://linkdevice?uuid=..." URI.
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, create a QR code with the URI (e.g. with qrencode) and scan that in the Signal app.
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 Key) with `-a`; recipient phone numbers must include the international country code.
Existing primary accounts without a phone number can be recovered using the `register --recovery-key` command.
*-n* NAME, *--name* NAME::
Optionally specify a name to describe this new device.
@ -344,6 +365,15 @@ Data URI encoded attachments must follow the RFC 2397.
Additionally a file name can be added:
e.g.: `data:<MIME-TYPE>;filename=<FILENAME>;base64,<BASE64 ENCODED DATA>`
*--attachment-dimensions* [WIDTHxHEIGHT [WIDTHxHEIGHT ...]]::
Specify the displayed dimensions (after rotation) of the attachment at the same position in `--attachment`.
Use an empty string to skip one; image dimensions are otherwise detected automatically.
*--attachment-blurhash* [BLURHASH [BLURHASH ...]]::
Specify a BlurHash (https://blurha.sh) for the attachment at the same position in `--attachment`, which clients show while it's downloading.
Use an empty string to skip one.
e.g.: `--attachment a.jpg clip.mp4 --attachment-dimensions '' 1080x1920 --attachment-blurhash '' 'LEHV6nWB2yk8pyo0adR*.7kCMdnj'`
*--view-once*::
Send the message as a view once message.
A conformant client will only allow the receiver to view the message once.

View File

@ -20,6 +20,9 @@ import java.util.Map;
*/
public interface Signal extends DBusInterface {
/**
* Returns the account's phone number, or an empty string for a numberless account.
*/
String getSelfNumber();
String getSelfACI();
@ -186,6 +189,8 @@ public interface Signal extends DBusInterface {
List<String> listNumbers();
List<String> listRecipientIdentifiers();
List<String> getContactNumber(final String name) throws Error.Failure;
@Deprecated

View File

@ -37,7 +37,10 @@ public interface SignalControl extends DBusInterface {
List<DBusPath> listAccounts();
DBusPath getAccount(String number);
/**
* Resolves a phone number or ACI to an exported account's object path.
*/
DBusPath getAccount(String identifier) throws Error.Failure;
interface Error {

View File

@ -0,0 +1,32 @@
package org.asamk.signal;
import java.util.Locale;
import java.util.UUID;
public final class AccountIdentifier {
private AccountIdentifier() {
}
public static String normalize(final String identifier) {
if (identifier == null) {
return null;
}
final var compact = identifier.replace("-", "").replaceAll("\\s", "").toLowerCase(Locale.ROOT);
if (compact.length() != 32 || !compact.matches("[0-9a-f]{32}")) {
return identifier;
}
final var uuid = "%s-%s-%s-%s-%s".formatted(compact.substring(0, 8),
compact.substring(8, 12),
compact.substring(12, 16),
compact.substring(16, 20),
compact.substring(20));
try {
return UUID.fromString(uuid).toString();
} catch (IllegalArgumentException e) {
return identifier;
}
}
}

View File

@ -142,7 +142,7 @@ public class App {
final var outputWriter = getOutputWriter(command);
final var commandHandler = new CommandHandler(ns, outputWriter);
var account = ns.getString("account");
var account = AccountIdentifier.normalize(ns.getString("account"));
final var useDbus = Boolean.TRUE.equals(ns.getBoolean("global-dbus"));
final var useDbusSystem = Boolean.TRUE.equals(ns.getBoolean("global-dbus-system"));
@ -187,8 +187,8 @@ public class App {
}
if (command instanceof RegistrationCommand registrationCommand) {
if (!Manager.isValidNumber(account, null)) {
throw new UserErrorException("Invalid account (phone number), make sure you include the country code.");
if (!Manager.isValidNumber(account, null) && !Manager.isValidAci(account)) {
throw new UserErrorException("Invalid account (E164 phone number or Account Key).");
}
handleRegistrationCommand(registrationCommand, account, signalAccountFiles, commandHandler);
return;
@ -208,14 +208,15 @@ 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();
}
@ -223,9 +224,9 @@ public class App {
private OutputWriter getOutputWriter(final Command command) throws UserErrorException {
final var outputTypeInput = ns.<OutputType>get("output");
final var outputType = outputTypeInput == null ? command.getSupportedOutputTypes()
.stream()
.findFirst()
.orElse(null) : outputTypeInput;
.stream()
.findFirst()
.orElse(null) : outputTypeInput;
final var writer = new BufferedWriter(new OutputStreamWriter(System.out, IOUtils.getConsoleCharset()));
final var outputWriter = outputType == null
? null

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());
@ -614,8 +614,8 @@ public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler {
writer.println("Size: {}{}",
attachment.size().isPresent() ? attachment.size().get() + " bytes" : "<unavailable>",
attachment.preview().isPresent() ? " (Preview is available: "
+ attachment.preview().get().length
+ " bytes)" : "");
+ attachment.preview().get().length
+ " bytes)" : "");
}
if (attachment.thumbnail().isPresent()) {
writer.println("Thumbnail:");

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

@ -109,7 +109,7 @@ public class ListContactsCommand implements JsonRpcLocalCommand {
r.getProfile().getPhoneNumberSharingMode() == null
? ""
: String.valueOf(r.getProfile().getPhoneNumberSharingMode()
== PhoneNumberSharingMode.EVERYBODY),
== PhoneNumberSharingMode.EVERYBODY),
r.getDiscoverable() == null ? "" : String.valueOf(r.getDiscoverable()));
}
}
@ -121,17 +121,17 @@ public class ListContactsCommand implements JsonRpcLocalCommand {
final var jsonInternal = !internal
? null
: new JsonContact.JsonInternal(r.getProfile()
.getCapabilities()
.stream()
.map(Enum::name)
.toList(),
.getCapabilities()
.stream()
.map(Enum::name)
.toList(),
r.getProfile().getUnidentifiedAccessMode() == Profile.UnidentifiedAccessMode.UNKNOWN
? null
? null
: r.getProfile().getUnidentifiedAccessMode().name(),
r.getProfile().getPhoneNumberSharingMode() == null
? null
? null
: r.getProfile().getPhoneNumberSharingMode()
== PhoneNumberSharingMode.EVERYBODY,
== PhoneNumberSharingMode.EVERYBODY,
r.getDiscoverable());
return new JsonContact(address.number().orElse(null),
address.uuid().map(UUID::toString).orElse(null),
@ -159,9 +159,9 @@ public class ListContactsCommand implements JsonRpcLocalCommand {
r.getProfile().getAboutEmoji(),
r.getProfile().getAvatarUrlPath() != null,
r.getProfile().getMobileCoinAddress() == null
? null
? null
: Base64.getEncoder()
.encodeToString(r.getProfile().getMobileCoinAddress())),
.encodeToString(r.getProfile().getMobileCoinAddress())),
jsonInternal);
}).toList();
writer.write(jsonContacts);

View File

@ -15,6 +15,7 @@ import org.asamk.signal.manager.RegistrationManager;
import org.asamk.signal.manager.api.CaptchaRequiredException;
import org.asamk.signal.manager.api.NonNormalizedPhoneNumberException;
import org.asamk.signal.manager.api.RateLimitException;
import org.asamk.signal.manager.api.TotpRequiredException;
import org.asamk.signal.manager.api.VerificationMethodNotAvailableException;
import org.asamk.signal.output.JsonWriter;
import org.asamk.signal.util.CommandUtil;
@ -41,6 +42,8 @@ public class RegisterCommand implements RegistrationCommand, JsonRpcRegistration
subparser.addArgument("--reregister")
.action(Arguments.storeTrue())
.help("Register even if account is already registered");
subparser.addArgument("--recovery-key").help("Recover an account using its 64-character Signal Recovery Key.");
subparser.addArgument("--totp").help("A six-digit TOTP token required for account recovery.");
}
@Override
@ -48,8 +51,10 @@ public class RegisterCommand implements RegistrationCommand, JsonRpcRegistration
final boolean voiceVerification = Boolean.TRUE.equals(ns.getBoolean("voice"));
final var captcha = ns.getString("captcha");
final var reregister = Boolean.TRUE.equals(ns.getBoolean("reregister"));
final var recoveryKey = ns.getString("recovery-key");
final var totp = ns.getString("totp");
register(m, voiceVerification, captcha, reregister);
register(m, voiceVerification, captcha, reregister, recoveryKey, totp);
}
@Override
@ -68,15 +73,53 @@ public class RegisterCommand implements RegistrationCommand, JsonRpcRegistration
final RegistrationManager m,
final JsonWriter jsonWriter
) throws CommandException {
register(m, Boolean.TRUE.equals(request.voice()), request.captcha(), Boolean.TRUE.equals(request.reregister()));
register(m,
Boolean.TRUE.equals(request.voice()),
request.captcha(),
Boolean.TRUE.equals(request.reregister()),
request.recoveryKey(),
request.totp());
}
private void register(
final RegistrationManager m,
final boolean voiceVerification,
final String captcha,
final boolean reregister
final boolean reregister,
final String recoveryKey,
final String totpValue
) throws CommandException {
if (recoveryKey == null && totpValue != null) {
throw new UserErrorException("--totp requires --recovery-key");
}
final Integer totp;
if (totpValue == null) {
totp = null;
} else if (!totpValue.matches("[0-9]{6}")) {
throw new UserErrorException("TOTP token must contain exactly six digits");
} else {
totp = Integer.parseInt(totpValue);
}
if (recoveryKey != null) {
if (voiceVerification || captcha != null) {
throw new UserErrorException("--recovery-key cannot be combined with --voice or --captcha");
}
try {
m.registerWithRecoveryKey(recoveryKey, reregister, totp);
} catch (RateLimitException e) {
final var message = CommandUtil.getRateLimitMessage(e);
throw new RateLimitErrorException(message, e);
} catch (TotpRequiredException e) {
throw new UserErrorException("A TOTP token is required; rerun register with --totp TOKEN");
} catch (IOException e) {
throw new IOErrorException("Failed to register: %s (%s)".formatted(e.getMessage(),
e.getClass().getSimpleName()), e);
}
return;
}
try {
m.register(voiceVerification, captcha, reregister);
} catch (RateLimitException e) {
@ -87,6 +130,9 @@ public class RegisterCommand implements RegistrationCommand, JsonRpcRegistration
throw new UserErrorException(message);
} catch (NonNormalizedPhoneNumberException e) {
throw new UserErrorException("Failed to register: " + e.getMessage(), e);
} catch (TotpRequiredException e) {
throw new UserErrorException(
"A TOTP token is required; rerun register with --recovery-key RECOVERY-KEY --totp TOKEN");
} catch (IOException e) {
throw new IOErrorException("Failed to register: %s (%s)".formatted(e.getMessage(),
e.getClass().getSimpleName()), e);
@ -99,5 +145,11 @@ public class RegisterCommand implements RegistrationCommand, JsonRpcRegistration
}
}
public record RegistrationParams(Boolean voice, String captcha, Boolean reregister) {}
public record RegistrationParams(
Boolean voice,
String captcha,
Boolean reregister,
String recoveryKey,
String totp
) {}
}

View File

@ -36,6 +36,7 @@ import static org.asamk.signal.util.SendMessageResultUtils.outputResult;
public class SendCommand implements JsonRpcLocalCommand {
private static final Logger logger = LoggerFactory.getLogger(SendCommand.class);
private static final String BLURHASH_DIGITS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~";
@Override
public String getName() {
@ -112,6 +113,13 @@ public class SendCommand implements JsonRpcLocalCommand {
subparser.addArgument("--voice-note")
.action(Arguments.storeTrue())
.help("Mark audio attachments as voice notes. Voice notes are displayed inline in Signal clients.");
subparser.addArgument("--attachment-dimensions")
.nargs("*")
.help("Specify displayed WIDTHxHEIGHT for each attachment in order. Use an empty string to skip one.");
subparser.addArgument("--attachment-blurhash")
.nargs("*")
.help("Specify a BlurHash (https://blurha.sh) for each attachment in order, which clients show "
+ "while it's downloading. Use an empty string to skip one.");
}
@Override
@ -175,6 +183,13 @@ public class SendCommand implements JsonRpcLocalCommand {
final var viewOnce = Boolean.TRUE.equals(ns.getBoolean("view-once"));
final var voiceNote = Boolean.TRUE.equals(ns.getBoolean("voice-note"));
final var dimensionStrings = ns.<String>getList("attachment-dimensions");
final var attachmentDimensions = dimensionStrings == null
? List.<Message.AttachmentDimensions>of()
: parseAttachmentDimensions(dimensionStrings);
final var blurHashes = ns.<String>getList("attachment-blurhash");
final var attachmentBlurHashes = blurHashes == null ? List.<String>of() : parseAttachmentBlurHashes(blurHashes);
final var selfNumber = m.getSelfNumber();
final var mentionStrings = ns.<String>getList("mention");
@ -249,6 +264,8 @@ public class SendCommand implements JsonRpcLocalCommand {
try {
final var message = new Message(messageText,
attachments,
attachmentDimensions,
attachmentBlurHashes,
viewOnce,
voiceNote,
mentions,
@ -280,6 +297,46 @@ public class SendCommand implements JsonRpcLocalCommand {
}
}
private List<Message.AttachmentDimensions> parseAttachmentDimensions(
final List<String> dimensionStrings
) throws UserErrorException {
final var dimensionPattern = Pattern.compile("([1-9]\\d*)x([1-9]\\d*)");
final var dimensions = new ArrayList<Message.AttachmentDimensions>();
for (final var dimension : dimensionStrings) {
if (dimension.isEmpty()) {
dimensions.add(null);
continue;
}
final var matcher = dimensionPattern.matcher(dimension);
if (!matcher.matches()) {
throw new UserErrorException("Invalid attachment dimensions syntax ("
+ dimension
+ ") expected 'WIDTHxHEIGHT'");
}
dimensions.add(new Message.AttachmentDimensions(Integer.parseInt(matcher.group(1)),
Integer.parseInt(matcher.group(2))));
}
return dimensions;
}
private List<String> parseAttachmentBlurHashes(final List<String> blurHashes) throws UserErrorException {
for (final var blurHash : blurHashes) {
if (!blurHash.isEmpty() && !isValidBlurHash(blurHash)) {
throw new UserErrorException("Invalid attachment BlurHash (" + blurHash + ")");
}
}
return blurHashes;
}
// Same check the BlurHash decoders make: the first digit fixes the length.
private static boolean isValidBlurHash(final String blurHash) {
if (blurHash.length() < 6) {
return false;
}
final var sizeFlag = BLURHASH_DIGITS.indexOf(blurHash.charAt(0));
return blurHash.length() == 4 + 2 * (sizeFlag % 9 + 1) * (sizeFlag / 9 + 1);
}
private List<Message.Mention> parseMentions(
final String selfNumber,
final List<String> mentionStrings

View File

@ -55,7 +55,7 @@ public class DbusCommandHandler {
try {
if (command instanceof ProvisioningCommand c) {
if (account != null) {
throw new UserErrorException("You cannot specify a account (phone number) when linking");
throw new UserErrorException("You cannot specify an account when linking");
}
handleProvisioningCommand(c, dBusConn, busname, commandHandler);
@ -74,7 +74,9 @@ public class DbusCommandHandler {
throw new UserErrorException("Command only works in multi-account mode");
}
var accountObjectPath = account == null ? tryGetSingleAccountObjectPath(dBusConn, busname) : null;
var accountObjectPath = account == null
? tryGetSingleAccountObjectPath(dBusConn, busname)
: getAccountObjectPath(account, dBusConn, busname);
if (accountObjectPath == null) {
accountObjectPath = DbusConfig.getObjectPath(account);
}
@ -86,6 +88,24 @@ public class DbusCommandHandler {
}
}
private static String getAccountObjectPath(
final String account,
final DBusConnection dBusConn,
final String busname
) throws DBusException, CommandException {
final var control = dBusConn.getRemoteObject(busname, DbusConfig.getObjectPath(), SignalControl.class);
try {
return control.getAccount(account).getPath();
} catch (UnknownMethod e) {
// A single-account daemon exports Signal directly at the base path.
final var signal = dBusConn.getRemoteObject(busname, DbusConfig.getObjectPath(), Signal.class);
if (!account.equals(signal.getSelfNumber()) && !account.equals(signal.getSelfACI())) {
throw new UserErrorException("Unknown account: " + account);
}
return DbusConfig.getObjectPath();
}
}
private static String tryGetSingleAccountObjectPath(
final DBusConnection dBusConn,
final String busname
@ -97,7 +117,7 @@ public class DbusCommandHandler {
throw new UserErrorException("No local users found, you first need to register or link an account");
} else if (accounts.size() > 1) {
throw new UserErrorException(
"Multiple users found, you need to specify an account (phone number) with -a");
"Multiple users found, you need to specify an account (phone number or ACI) with -a");
}
return accounts.getFirst().getPath();

View File

@ -67,7 +67,7 @@ public class DbusHandler implements AutoCloseable {
}
});
c.addOnManagerRemovedHandler(m -> {
final var path = DbusConfig.getObjectPath(m.getSelfNumber());
final var path = DbusConfig.getObjectPath(m.getSelfIdentifier());
try {
final var object = connection.getExportedObject(null, path);
if (object instanceof DbusSignalImpl dbusSignal) {
@ -135,11 +135,11 @@ public class DbusHandler implements AutoCloseable {
final var signal = new DbusSignalImpl(m, conn, objectPath, noReceiveOnStart);
closeables.add(signal);
return Thread.ofPlatform().name("dbus-init-" + m.getSelfNumber()).start(signal::initObjects);
return Thread.ofPlatform().name("dbus-init-" + m.getSelfIdentifier()).start(signal::initObjects);
}
private Thread exportManager(final DBusConnection conn, final Manager m) {
final var objectPath = DbusConfig.getObjectPath(m.getSelfNumber());
final var objectPath = DbusConfig.getObjectPath(m.getSelfIdentifier());
return exportDbusObject(conn, objectPath, m);
}

View File

@ -77,6 +77,7 @@ import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Function;
@ -110,7 +111,8 @@ public class DbusManagerImpl implements Manager {
@Override
public String getSelfNumber() {
return signal.getSelfNumber();
final var number = signal.getSelfNumber();
return number.isEmpty() ? null : number;
}
@Override
@ -438,7 +440,7 @@ public class DbusManagerImpl implements Manager {
numbers.forEach(n -> signal.sendTyping(n, action == TypingAction.STOP));
return 0L;
}, () -> {
signal.sendTyping(signal.getSelfNumber(), action == TypingAction.STOP);
signal.sendTyping(getSelfIdentifier(), action == TypingAction.STOP);
return 0L;
}, groupId -> {
signal.sendGroupTyping(groupId, action == TypingAction.STOP);
@ -486,7 +488,7 @@ public class DbusManagerImpl implements Manager {
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
return handleMessage(recipients,
numbers -> signal.sendRemoteDeleteMessage(targetSentTimestamp, numbers),
() -> signal.sendRemoteDeleteMessage(targetSentTimestamp, signal.getSelfNumber()),
() -> signal.sendRemoteDeleteMessage(targetSentTimestamp, getSelfIdentifier()),
groupId -> signal.sendGroupRemoteDeleteMessage(targetSentTimestamp, groupId));
}
@ -510,7 +512,7 @@ public class DbusManagerImpl implements Manager {
remove,
targetAuthor.getIdentifier(),
targetSentTimestamp,
signal.getSelfNumber()),
getSelfIdentifier()),
groupId -> signal.sendGroupMessageReaction(emoji,
remove,
targetAuthor.getIdentifier(),
@ -809,43 +811,47 @@ public class DbusManagerImpl implements Manager {
final Collection<RecipientIdentifier.Single> addresses,
final Optional<String> name
) {
final var numbers = addresses.stream()
.filter(s -> s instanceof RecipientIdentifier.Number)
.map(s -> ((RecipientIdentifier.Number) s).number())
final var identifiers = addresses.stream()
.map(RecipientIdentifier.Single::getIdentifier)
.collect(Collectors.toSet());
return signal.listNumbers().stream().filter(n -> addresses.isEmpty() || numbers.contains(n)).map(n -> {
final var contactBlocked = signal.isContactBlocked(n);
if (blocked.isPresent() && blocked.get() != contactBlocked) {
return null;
}
final var contactName = signal.getContactName(n);
if (onlyContacts && contactName.isEmpty()) {
return null;
}
if (name.isPresent() && !name.get().equals(contactName)) {
return null;
}
return Recipient.newBuilder()
.withAddress(new RecipientAddress(n))
.withContact(new Contact(contactName,
null,
null,
null,
null,
null,
null,
0,
1,
0,
false,
contactBlocked,
0,
false,
false,
false,
null))
.build();
}).filter(Objects::nonNull).toList();
return signal.listRecipientIdentifiers()
.stream()
.filter(n -> addresses.isEmpty() || identifiers.contains(n))
.map(n -> {
final var contactBlocked = signal.isContactBlocked(n);
if (blocked.isPresent() && blocked.get() != contactBlocked) {
return null;
}
final var contactName = signal.getContactName(n);
if (onlyContacts && contactName.isEmpty()) {
return null;
}
if (name.isPresent() && !name.get().equals(contactName)) {
return null;
}
return Recipient.newBuilder()
.withAddress(getRecipientAddress(n))
.withContact(new Contact(contactName,
null,
null,
null,
null,
null,
null,
0,
1,
0,
false,
contactBlocked,
0,
false,
false,
false,
null))
.build();
})
.filter(Objects::nonNull)
.toList();
}
@Override
@ -870,16 +876,16 @@ public class DbusManagerImpl implements Manager {
(String) group.get("Description").getValue(),
GroupInviteLinkUrl.fromUri((String) group.get("GroupInviteLink").getValue()),
((List<String>) group.get("Members").getValue()).stream()
.map(m -> new GroupMember(new RecipientAddress(m), admins.contains(m), null, null))
.map(m -> new GroupMember(getRecipientAddress(m), admins.contains(m), null, null))
.collect(Collectors.toSet()),
((List<String>) group.get("PendingMembers").getValue()).stream()
.map(RecipientAddress::new)
.map(DbusManagerImpl::getRecipientAddress)
.collect(Collectors.toSet()),
((List<String>) group.get("RequestingMembers").getValue()).stream()
.map(RecipientAddress::new)
.map(DbusManagerImpl::getRecipientAddress)
.collect(Collectors.toSet()),
((List<String>) group.get("Banned").getValue()).stream()
.map(RecipientAddress::new)
.map(DbusManagerImpl::getRecipientAddress)
.collect(Collectors.toSet()),
(boolean) group.get("IsBlocked").getValue(),
(int) group.get("MessageExpirationTimer").getValue(),
@ -910,7 +916,10 @@ public class DbusManagerImpl implements Manager {
final var group = getRemoteObject(identityPath, Signal.Identity.class).GetAll("org.asamk.Signal.Identity");
final var aci = (String) group.get("Uuid").getValue();
final var number = (String) group.get("Number").getValue();
return new Identity(new RecipientAddress(aci, null, number, null),
return new Identity(new RecipientAddress(aci.isEmpty() ? null : aci,
null,
number.isEmpty() ? null : number,
null),
(byte[]) group.get("Fingerprint").getValue(),
(String) group.get("SafetyNumber").getValue(),
(byte[]) group.get("ScannableSafetyNumber").getValue(),
@ -1073,6 +1082,14 @@ public class DbusManagerImpl implements Manager {
return string == null ? "" : string;
}
private static RecipientAddress getRecipientAddress(final String identifier) {
try {
return new RecipientAddress(UUID.fromString(identifier));
} catch (IllegalArgumentException e) {
return new RecipientAddress(identifier);
}
}
private <T extends DBusInterface> T getRemoteObject(final DBusPath path, final Class<T> type) {
try {
return connection.getRemoteObject(busname, path.getPath(), type);
@ -1085,7 +1102,7 @@ public class DbusManagerImpl implements Manager {
try {
this.dbusMsgHandler = messageReceived -> {
final var extras = messageReceived.getExtras();
final var envelope = new MessageEnvelope(Optional.of(new RecipientAddress(messageReceived.getSender())),
final var envelope = new MessageEnvelope(Optional.of(getRecipientAddress(messageReceived.getSender())),
0,
messageReceived.getTimestamp(),
0,
@ -1132,7 +1149,7 @@ public class DbusManagerImpl implements Manager {
connection.addSigHandler(Signal.MessageReceivedV2.class, signal, this.dbusMsgHandler);
this.dbusEditMsgHandler = messageReceived -> {
final var extras = messageReceived.getExtras();
final var envelope = new MessageEnvelope(Optional.of(new RecipientAddress(messageReceived.getSender())),
final var envelope = new MessageEnvelope(Optional.of(getRecipientAddress(messageReceived.getSender())),
0,
messageReceived.getTimestamp(),
0,
@ -1186,7 +1203,7 @@ public class DbusManagerImpl implements Manager {
case "delivery" -> MessageEnvelope.Receipt.Type.DELIVERY;
default -> MessageEnvelope.Receipt.Type.UNKNOWN;
};
final var envelope = new MessageEnvelope(Optional.of(new RecipientAddress(receiptReceived.getSender())),
final var envelope = new MessageEnvelope(Optional.of(getRecipientAddress(receiptReceived.getSender())),
0,
receiptReceived.getTimestamp(),
0,
@ -1207,7 +1224,7 @@ public class DbusManagerImpl implements Manager {
this.dbusSyncHandler = syncReceived -> {
final var extras = syncReceived.getExtras();
final var envelope = new MessageEnvelope(Optional.of(new RecipientAddress(syncReceived.getSource())),
final var envelope = new MessageEnvelope(Optional.of(getRecipientAddress(syncReceived.getSource())),
0,
syncReceived.getTimestamp(),
0,
@ -1221,7 +1238,7 @@ public class DbusManagerImpl implements Manager {
syncReceived.getTimestamp(),
syncReceived.getDestination().isEmpty()
? Optional.empty()
: Optional.of(new RecipientAddress(syncReceived.getDestination())),
: Optional.of(getRecipientAddress(syncReceived.getDestination())),
Set.of(),
Optional.of(new MessageEnvelope.Data(syncReceived.getTimestamp(),
syncReceived.getGroupId().length > 0
@ -1326,7 +1343,7 @@ public class DbusManagerImpl implements Manager {
final List<Map<String, Variant<?>>> mentions = getValue(extras, "mentions");
return mentions.stream()
.map(a -> new MessageEnvelope.Data.Mention(new RecipientAddress(this.<String>getValue(a, "recipient")),
.map(a -> new MessageEnvelope.Data.Mention(getRecipientAddress(this.<String>getValue(a, "recipient")),
getValue(a, "start"),
getValue(a, "length")))
.toList();

View File

@ -43,14 +43,6 @@ public class DbusMultiAccountManagerImpl implements MultiAccountManager {
this.busname = busname;
}
@Override
public List<String> getAccountNumbers() {
return signalControl.listAccounts()
.stream()
.map(a -> getRemoteObject(a, Signal.class).getSelfNumber())
.toList();
}
@Override
public List<Manager> getManagers() {
return signalControl.listAccounts()
@ -74,8 +66,8 @@ public class DbusMultiAccountManagerImpl implements MultiAccountManager {
}
@Override
public Manager getManager(final String phoneNumber) {
return new DbusManagerImpl(getRemoteObject(signalControl.getAccount(phoneNumber), Signal.class),
public Manager getManager(final String identifier) {
return new DbusManagerImpl(getRemoteObject(signalControl.getAccount(identifier), Signal.class),
connection,
busname);
}

View File

@ -5,6 +5,7 @@ import org.asamk.signal.manager.RegistrationManager;
import org.asamk.signal.manager.api.CaptchaRequiredException;
import org.asamk.signal.manager.api.IncorrectPinException;
import org.asamk.signal.manager.api.PinLockedException;
import org.asamk.signal.manager.api.TotpRequiredException;
import org.freedesktop.dbus.connections.impl.DBusConnection;
import java.io.IOException;
@ -53,6 +54,15 @@ public class DbusRegistrationManagerImpl implements RegistrationManager {
}
}
@Override
public void registerWithRecoveryKey(
final String recoveryKey,
final boolean forceRegister,
final Integer totp
) throws IOException, TotpRequiredException {
throw new IOException("Recovery-key registration is not supported over D-Bus");
}
@Override
public void deleteLocalAccountData() throws IOException {
throw new UnsupportedOperationException();

View File

@ -159,11 +159,19 @@ public class DbusSignalControlImpl implements org.asamk.SignalControl {
@Override
public List<DBusPath> listAccounts() {
return c.getAccountNumbers().stream().map(u -> new DBusPath(DbusConfig.getObjectPath(u))).toList();
return c.getManagers()
.stream()
.map(m -> new DBusPath(DbusConfig.getObjectPath(m.getSelfIdentifier())))
.toList();
}
@Override
public DBusPath getAccount(final String number) {
return new DBusPath(DbusConfig.getObjectPath(number));
public DBusPath getAccount(final String identifier) {
final var manager = c.getManagers()
.stream()
.filter(m -> identifier.equals(m.getSelfNumber()) || identifier.equals(m.getSelfACI()))
.findFirst()
.orElseThrow(() -> new Error.Failure("Unknown account: " + identifier));
return new DBusPath(DbusConfig.getObjectPath(manager.getSelfIdentifier()));
}
}

View File

@ -138,7 +138,7 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
@Override
public String getSelfNumber() {
return m.getSelfNumber();
return emptyIfNull(m.getSelfNumber());
}
@Override
@ -242,6 +242,8 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
try {
final var message = new Message(messageText,
attachments,
List.of(),
List.of(),
false,
false,
List.of(),
@ -409,6 +411,8 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
try {
final var message = new Message(messageText,
attachments,
List.of(),
List.of(),
false,
false,
List.of(),
@ -456,6 +460,8 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
try {
final var message = new Message(messageText,
attachments,
List.of(),
List.of(),
false,
false,
List.of(),
@ -709,7 +715,7 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
retryAfterMilliseconds == null
? ""
: ", retry at " + DateUtils.formatTimestamp(System.currentTimeMillis()
+ retryAfterMilliseconds)
+ retryAfterMilliseconds)
));
}
@ -797,6 +803,15 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
.toList();
}
@Override
public List<String> listRecipientIdentifiers() {
return m.getRecipients(false, Optional.empty(), Set.of(), Optional.empty())
.stream()
.map(r -> r.getAddress().getLegacyIdentifier())
.distinct()
.toList();
}
@Override
public List<String> getContactNumber(final String name) {
return m.getRecipients(false, Optional.empty(), Set.of(), Optional.of(name))

View File

@ -43,7 +43,7 @@ record JsonDataMessage(
static JsonDataMessage from(MessageEnvelope.Data dataMessage, Manager m) {
final var timestamp = dataMessage.timestamp();
final var groupInfo = dataMessage.groupContext().isPresent() ? JsonGroupInfo.from(dataMessage.groupContext()
.get(), m) : null;
.get(), m) : null;
final var storyContext = dataMessage.storyContext().isPresent()
? JsonStoryContext.from(dataMessage.storyContext().get())
: null;
@ -59,32 +59,32 @@ record JsonDataMessage(
final var quote = dataMessage.quote().isPresent() ? JsonQuote.from(dataMessage.quote().get()) : null;
final var payment = dataMessage.payment().isPresent() ? JsonPayment.from(dataMessage.payment().get()) : null;
final var mentions = !dataMessage.mentions().isEmpty() ? dataMessage.mentions()
.stream()
.map(JsonMention::from)
.toList() : null;
.stream()
.map(JsonMention::from)
.toList() : null;
final var previews = !dataMessage.previews().isEmpty() ? dataMessage.previews()
.stream()
.map(JsonPreview::from)
.toList() : null;
.stream()
.map(JsonPreview::from)
.toList() : null;
final var remoteDelete = dataMessage.remoteDeleteId().isPresent()
? new JsonRemoteDelete(dataMessage.remoteDeleteId().get())
: null;
final var attachments = !dataMessage.attachments().isEmpty() ? dataMessage.attachments()
.stream()
.map(JsonAttachment::from)
.toList() : null;
.stream()
.map(JsonAttachment::from)
.toList() : null;
final var sticker = dataMessage.sticker().isPresent() ? JsonSticker.from(dataMessage.sticker().get()) : null;
final var contacts = !dataMessage.sharedContacts().isEmpty() ? dataMessage.sharedContacts()
.stream()
.map(JsonSharedContact::from)
.toList() : null;
.stream()
.map(JsonSharedContact::from)
.toList() : null;
final var pollCreate = dataMessage.pollCreate().map(JsonPollCreate::from).orElse(null);
final var pollVote = dataMessage.pollVote().map(JsonPollVote::from).orElse(null);
final var pollTerminate = dataMessage.pollTerminate().map(JsonPollTerminate::from).orElse(null);
final var textStyles = !dataMessage.textStyles().isEmpty() ? dataMessage.textStyles()
.stream()
.map(JsonTextStyle::from)
.toList() : null;
.stream()
.map(JsonTextStyle::from)
.toList() : null;
final var pinMessage = dataMessage.pinMessage().map(JsonPinMessage::from).orElse(null);
final var unpinMessage = dataMessage.unpinMessage().map(JsonUnpinMessage::from).orElse(null);
final var adminDelete = dataMessage.adminDelete().map(JsonAdminDelete::from).orElse(null);

View File

@ -34,14 +34,14 @@ public record JsonQuote(
: null;
final var attachments = !quote.attachments().isEmpty() ? quote.attachments()
.stream()
.map(JsonQuotedAttachment::from)
.toList() : List.<JsonQuotedAttachment>of();
.stream()
.map(JsonQuotedAttachment::from)
.toList() : List.<JsonQuotedAttachment>of();
final var textStyles = !quote.textStyles().isEmpty() ? quote.textStyles()
.stream()
.map(JsonTextStyle::from)
.toList() : null;
.stream()
.map(JsonTextStyle::from)
.toList() : null;
return new JsonQuote(id, author, authorNumber, authorUuid, text, mentions, attachments, textStyles);
}

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

@ -27,13 +27,13 @@ public record JsonSendMessageResult(
result.isSuccess()
? Type.SUCCESS
: result.isRateLimitFailure()
? Type.RATE_LIMIT_FAILURE
? Type.RATE_LIMIT_FAILURE
: result.isNetworkFailure()
? Type.NETWORK_FAILURE
? Type.NETWORK_FAILURE
: result.isUnregisteredFailure()
? Type.UNREGISTERED_FAILURE
? Type.UNREGISTERED_FAILURE
: result.isInvalidPreKeyFailure()
? Type.INVALID_PRE_KEY_FAILURE
? Type.INVALID_PRE_KEY_FAILURE
: Type.IDENTITY_FAILURE,
result.proofRequiredFailure() != null ? result.proofRequiredFailure().getToken() : null,
rateLimitRetryAfterMilliseconds == null ? null : Math.ceilDiv(rateLimitRetryAfterMilliseconds, 1000L));

View File

@ -31,9 +31,9 @@ public record JsonSharedContact(
: null;
final var address = !contact.address().isEmpty() ? contact.address()
.stream()
.map(JsonContactAddress::from)
.toList() : null;
.stream()
.map(JsonContactAddress::from)
.toList() : null;
final var organization = contact.organization().orElse(null);

View File

@ -50,9 +50,9 @@ record JsonSyncMessage(
}
final var readMessages = !syncMessage.read().isEmpty() ? syncMessage.read()
.stream()
.map(JsonSyncReadMessage::from)
.toList() : null;
.stream()
.map(JsonSyncReadMessage::from)
.toList() : null;
final JsonSyncMessageType type;
if (syncMessage.contacts().isPresent()) {

View File

@ -1,6 +1,7 @@
package org.asamk.signal.jsonrpc;
import com.fasterxml.jackson.core.JsonParseException;
import com.fasterxml.jackson.core.StreamReadConstraints;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@ -8,6 +9,7 @@ import com.fasterxml.jackson.databind.node.ContainerNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.node.ValueNode;
import org.asamk.signal.manager.config.ServiceConfig;
import org.asamk.signal.util.Util;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -24,6 +26,8 @@ import java.util.stream.StreamSupport;
public class JsonRpcReader {
private static final Logger logger = LoggerFactory.getLogger(JsonRpcReader.class);
private static final int MAX_JSON_STRING_LENGTH =
((ServiceConfig.MAX_ATTACHMENT_SIZE + 2) / 3) * 4 + 1024;
private final JsonRpcSender jsonRpcSender;
private final ObjectMapper objectMapper;
@ -34,14 +38,22 @@ public class JsonRpcReader {
this.jsonRpcSender = jsonRpcSender;
this.input = null;
this.lineSupplier = lineSupplier;
this.objectMapper = Util.createJsonObjectMapper();
this.objectMapper = createObjectMapper();
}
public JsonRpcReader(final JsonRpcSender jsonRpcSender, final InputStream input) {
this.jsonRpcSender = jsonRpcSender;
this.input = input;
this.lineSupplier = null;
this.objectMapper = Util.createJsonObjectMapper();
this.objectMapper = createObjectMapper();
}
private static ObjectMapper createObjectMapper() {
final var objectMapper = Util.createJsonObjectMapper();
objectMapper.getFactory().setStreamReadConstraints(StreamReadConstraints.builder()
.maxStringLength(MAX_JSON_STRING_LENGTH)
.build());
return objectMapper;
}
public void readMessages(final RequestHandler requestHandler, final Consumer<JsonRpcResponse> responseHandler) {

View File

@ -8,6 +8,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ContainerNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.asamk.signal.AccountIdentifier;
import org.asamk.signal.commands.Command;
import org.asamk.signal.commands.JsonRpcMultiCommand;
import org.asamk.signal.commands.JsonRpcRegistrationCommand;
@ -126,7 +127,7 @@ public class SignalJsonRpcCommandHandler {
private Manager getManagerFromParams(final ContainerNode<?> params) throws JsonRpcException {
if (params != null && params.hasNonNull("account")) {
final var manager = c.getManager(params.get("account").asText());
final var manager = c.getManager(AccountIdentifier.normalize(params.get("account").asText()));
((ObjectNode) params).remove("account");
if (manager == null) {
throw new JsonRpcException(new JsonRpcResponse.Error(JsonRpcResponse.Error.INVALID_PARAMS,
@ -140,7 +141,7 @@ public class SignalJsonRpcCommandHandler {
private Pair<String, RegistrationManager> getRegistrationManagerFromParams(final ContainerNode<?> params) {
if (params != null && params.has("account")) {
final var account = params.get("account").asText();
final var account = AccountIdentifier.normalize(params.get("account").asText());
((ObjectNode) params).remove("account");
try {
return new Pair<>(account, c.getNewRegistrationManager(account));

View File

@ -3239,6 +3239,9 @@
"allDeclaredMethods": true,
"allDeclaredConstructors": true
},
{
"type": "org.asamk.signal.json.JsonGroupCallUpdate"
},
{
"type": "org.asamk.signal.json.JsonGroupInfo",
"allDeclaredFields": true,
@ -6190,6 +6193,22 @@
}
]
},
{
"type": "org.signal.libsignal.internal.LinkedDeviceInternal_ReturnConverter",
"jniAccessible": true,
"methods": [
{
"name": "fromNative",
"parameterTypes": [
"java.lang.Object",
"java.lang.Object",
"java.lang.Object",
"java.lang.Object",
"java.lang.Object"
]
}
]
},
{
"type": "org.signal.libsignal.internal.NativeHandleGuard$SimpleOwner",
"jniAccessible": true,
@ -6234,6 +6253,18 @@
}
]
},
{
"type": "org.signal.libsignal.net.ChallengeOption",
"jniAccessible": true,
"fields": [
{
"name": "CAPTCHA"
},
{
"name": "PUSH_CHALLENGE"
}
]
},
{
"type": "org.signal.libsignal.net.ChatConnection$ListenerBridge",
"jniAccessible": true,
@ -6357,6 +6388,46 @@
}
]
},
{
"type": "org.signal.libsignal.net.DeviceIdNotFoundException",
"jniAccessible": true,
"methods": [
{
"name": "<init>",
"parameterTypes": [
"java.lang.String"
]
}
]
},
{
"type": "org.signal.libsignal.net.MismatchedDeviceException",
"jniAccessible": true,
"methods": [
{
"name": "<init>",
"parameterTypes": [
"java.lang.String",
"org.signal.libsignal.net.MismatchedDeviceException$Entry[]"
]
}
]
},
{
"type": "org.signal.libsignal.net.MismatchedDeviceException$Entry",
"jniAccessible": true,
"methods": [
{
"name": "<init>",
"parameterTypes": [
"byte[]",
"int[]",
"int[]",
"int[]"
]
}
]
},
{
"type": "org.signal.libsignal.net.NetworkException",
"jniAccessible": true,
@ -6369,6 +6440,33 @@
}
]
},
{
"type": "org.signal.libsignal.net.RateLimitChallengeException",
"jniAccessible": true,
"methods": [
{
"name": "<init>",
"parameterTypes": [
"java.lang.String",
"java.lang.String",
"org.signal.libsignal.net.ChallengeOption[]",
"long"
]
}
]
},
{
"type": "org.signal.libsignal.net.RequestUnauthorizedException",
"jniAccessible": true,
"methods": [
{
"name": "<init>",
"parameterTypes": [
"java.lang.String"
]
}
]
},
{
"type": "org.signal.libsignal.net.RetryLaterException",
"jniAccessible": true,
@ -6381,6 +6479,42 @@
}
]
},
{
"type": "org.signal.libsignal.net.ServerSideErrorException",
"jniAccessible": true,
"methods": [
{
"name": "<init>",
"parameterTypes": [
"java.lang.String"
]
}
]
},
{
"type": "org.signal.libsignal.net.ServiceIdNotFoundException",
"jniAccessible": true,
"methods": [
{
"name": "<init>",
"parameterTypes": [
"java.lang.String"
]
}
]
},
{
"type": "org.signal.libsignal.net.TimeoutException",
"jniAccessible": true,
"methods": [
{
"name": "<init>",
"parameterTypes": [
"java.lang.String"
]
}
]
},
{
"type": "org.signal.libsignal.net.TransportFailureException",
"jniAccessible": true,
@ -6393,6 +6527,18 @@
}
]
},
{
"type": "org.signal.libsignal.net.UnexpectedResponseException",
"jniAccessible": true,
"methods": [
{
"name": "<init>",
"parameterTypes": [
"java.lang.String"
]
}
]
},
{
"type": "org.signal.libsignal.net.UploadForm",
"jniAccessible": true,
@ -6408,6 +6554,18 @@
}
]
},
{
"type": "org.signal.libsignal.net.UploadTooLargeException",
"jniAccessible": true,
"methods": [
{
"name": "<init>",
"parameterTypes": [
"java.lang.String"
]
}
]
},
{
"type": "org.signal.libsignal.net.internal.BridgeChatListener",
"jniAccessible": true
@ -6492,6 +6650,13 @@
"parameterTypes": [
"java.lang.String"
]
},
{
"name": "<init>",
"parameterTypes": [
"org.signal.libsignal.protocol.SignalProtocolAddress",
"java.lang.String"
]
}
]
},

View File

@ -0,0 +1,23 @@
package org.asamk.signal;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class AccountIdentifierTest {
@Test
void normalizesSignalAndroidAccountKeys() {
assertEquals("a6b28482-2e32-83d0-7f23-91360a4c2b91",
AccountIdentifier.normalize("A6B284822E3283D07F2391360A4C2B91"));
assertEquals("a6b28482-2e32-83d0-7f23-91360a4c2b91",
AccountIdentifier.normalize("A6B28482-2E32-83D0-7F23-91360A4C2B91"));
}
@Test
void leavesNonAccountIdentifiersUnchanged() {
assertEquals("+12025550123", AccountIdentifier.normalize("+12025550123"));
assertEquals("not-an-account", AccountIdentifier.normalize("not-an-account"));
assertEquals("NOT-AN-ACCOUNT", AccountIdentifier.normalize("NOT-AN-ACCOUNT"));
}
}

View File

@ -0,0 +1,150 @@
package org.asamk.signal.commands;
import net.sourceforge.argparse4j.ArgumentParsers;
import net.sourceforge.argparse4j.DefaultSettings;
import net.sourceforge.argparse4j.inf.Namespace;
import org.asamk.signal.commands.exceptions.UserErrorException;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.Message;
import org.asamk.signal.manager.api.SendMessageResults;
import org.asamk.signal.output.JsonWriter;
import org.asamk.signal.testutil.ManagerMock;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Proxy;
import java.util.Arrays;
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.assertThrows;
class SendCommandParsingTest {
private static final List<String> ATTACHMENTS = List.of("photo.jpg", "portrait.mp4", "other.mp4");
private static final String BLUR_HASH = "LEHV6nWB2yk8pyo0adR*.7kCMdnj";
@Test
void cliPreservesEmptyAndMissingDimensionPositions() throws Exception {
final var parser = ArgumentParsers.newFor("signal-cli", DefaultSettings.VERSION_0_9_0_DEFAULT_SETTINGS)
.includeArgumentNamesAsKeysInResult(true)
.build();
new SendCommand().attachToSubparser(parser.addSubparsers().addParser("send"));
final var message = send(parser.parseArgs(new String[]{
"send",
"--note-to-self",
"-a",
"photo.jpg",
"portrait.mp4",
"other.mp4",
"--attachment-dimensions",
"",
"1080x1920"
}));
assertEquals(ATTACHMENTS, message.attachments());
assertEquals(Arrays.asList(null, new Message.AttachmentDimensions(1080, 1920)), message.attachmentDimensions());
}
@Test
void jsonRpcPassesDimensionsIntoTheSentMessage() throws Exception {
final var message = send(namespace(List.of("", "1920x1080")));
assertEquals(ATTACHMENTS, message.attachments());
assertEquals(Arrays.asList(null, new Message.AttachmentDimensions(1920, 1080)), message.attachmentDimensions());
}
@Test
void cliPreservesBlurHashPositions() throws Exception {
final var parser = ArgumentParsers.newFor("signal-cli", DefaultSettings.VERSION_0_9_0_DEFAULT_SETTINGS)
.includeArgumentNamesAsKeysInResult(true)
.build();
new SendCommand().attachToSubparser(parser.addSubparsers().addParser("send"));
final var message = send(parser.parseArgs(new String[]{
"send",
"--note-to-self",
"-a",
"photo.jpg",
"portrait.mp4",
"other.mp4",
"--attachment-blurhash",
"",
BLUR_HASH
}));
assertEquals(List.of("", BLUR_HASH), message.attachmentBlurHashes());
}
@Test
void jsonRpcPassesBlurHashesWithDimensions() throws Exception {
final var message = send(new JsonRpcNamespace(Map.of("noteToSelf",
true,
"attachments",
ATTACHMENTS,
"attachmentDimensions",
List.of("", "1920x1080"),
"attachmentBlurhash",
List.of("", BLUR_HASH))));
assertEquals(Arrays.asList(null, new Message.AttachmentDimensions(1920, 1080)), message.attachmentDimensions());
assertEquals(List.of("", BLUR_HASH), message.attachmentBlurHashes());
}
@Test
void omittedDimensionsKeepExistingBehavior() throws Exception {
final var message = send(new JsonRpcNamespace(Map.of("noteToSelf", true, "attachments", ATTACHMENTS)));
assertEquals(List.of(), message.attachmentDimensions());
assertEquals(List.of(), message.attachmentBlurHashes());
}
@Test
void rejectsMalformedDimensionsBeforeSending() {
for (final var value : List.of("0x1080", "1920x0", "-1x2", "1.5x2", "1920")) {
assertThrows(UserErrorException.class, () -> send(namespace(List.of(value))), value);
}
}
@Test
void rejectsMalformedBlurHashesBeforeSending() {
// Wrong length for its size digit, an unknown size digit, too short.
for (final var value : List.of(BLUR_HASH + "0", "!" + BLUR_HASH.substring(1), "00000")) {
assertThrows(UserErrorException.class, () -> send(blurHashNamespace(List.of(value))), value);
}
}
private JsonRpcNamespace blurHashNamespace(List<String> blurHashes) {
return new JsonRpcNamespace(Map.of("noteToSelf",
true,
"attachments",
ATTACHMENTS,
"attachmentBlurhash",
blurHashes));
}
private JsonRpcNamespace namespace(List<String> dimensions) {
return new JsonRpcNamespace(Map.of("noteToSelf",
true,
"attachments",
ATTACHMENTS,
"attachmentDimensions",
dimensions));
}
private Message send(Namespace namespace) throws Exception {
final var captured = new AtomicReference<Message>();
final var delegate = ManagerMock.create("+15551234567");
final var manager = (Manager) Proxy.newProxyInstance(Manager.class.getClassLoader(),
new Class<?>[]{Manager.class},
(proxy, method, args) -> {
if (method.getName().equals("sendMessage")) {
captured.set((Message) args[0]);
return new SendMessageResults(1, Map.of());
}
return method.invoke(delegate, args);
});
new SendCommand().handleCommand(namespace, manager, (JsonWriter) ignored -> {
});
return captured.get();
}
}

View File

@ -0,0 +1,415 @@
package org.asamk.signal.dbus;
import org.asamk.Signal;
import org.asamk.SignalControl;
import org.asamk.signal.DbusConfig;
import org.asamk.signal.commands.CommandHandler;
import org.asamk.signal.commands.ListGroupsCommand;
import org.asamk.signal.commands.LocalCommand;
import org.asamk.signal.commands.exceptions.UserErrorException;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.Group;
import org.asamk.signal.manager.api.GroupId;
import org.asamk.signal.manager.api.GroupMember;
import org.asamk.signal.manager.api.GroupPermission;
import org.asamk.signal.manager.api.Identity;
import org.asamk.signal.manager.api.MessageEnvelope;
import org.asamk.signal.manager.api.Recipient;
import org.asamk.signal.manager.api.RecipientAddress;
import org.asamk.signal.manager.api.RecipientIdentifier;
import org.asamk.signal.manager.api.SendMessageResults;
import org.asamk.signal.manager.api.TrustLevel;
import org.asamk.signal.manager.api.TypingAction;
import org.asamk.signal.manager.internal.MultiAccountManagerImpl;
import org.freedesktop.dbus.DBusPath;
import org.freedesktop.dbus.connections.impl.DBusConnection;
import org.freedesktop.dbus.connections.impl.DBusConnectionBuilder;
import org.freedesktop.dbus.exceptions.DBusExecutionException;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullSource;
import org.junit.jupiter.params.provider.ValueSource;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
class NumberlessDbusTest {
private static final String NUMBER = "+12025550123";
private static final String ACI = "11111111-1111-4111-8111-111111111111";
private static final String OTHER_ACI = "22222222-2222-4222-8222-222222222222";
private static final String THIRD_ACI = "33333333-3333-4333-8333-333333333333";
private static final String NUMBERLESS_PATH = "/org/asamk/Signal/11111111_1111_4111_8111_111111111111";
private static final String NUMBERED_PATH = "/org/asamk/Signal/_12025550123";
@Test
void resolvesNumberlessAndNumberedAccountsWithoutChangingNumberedPaths() {
final var numberless = new TestAccount(null, ACI);
final var numbered = new TestAccount(NUMBER, OTHER_ACI);
final var accounts = new MultiAccountManagerImpl(List.of(numberless.manager, numbered.manager), null);
final var control = new DbusSignalControlImpl(accounts, DbusConfig.getObjectPath());
assertEquals(Set.of(new DBusPath(NUMBERLESS_PATH), new DBusPath(NUMBERED_PATH)),
Set.copyOf(control.listAccounts()));
assertEquals(NUMBERLESS_PATH, control.getAccount(ACI).getPath());
assertEquals(NUMBERED_PATH, control.getAccount(NUMBER).getPath());
assertEquals(NUMBERED_PATH, control.getAccount(OTHER_ACI).getPath());
assertThrows(SignalControl.Error.Failure.class, () -> control.getAccount(THIRD_ACI));
}
@ParameterizedTest
@NullSource
@ValueSource(strings = NUMBER)
void mapsOptionalPhoneNumbersAndAddressesSelfByAnAvailableIdentifier(final String number) throws Exception {
final var account = new TestAccount(number, ACI);
final var signal = new DbusSignalImpl(account.manager, null, DbusConfig.getObjectPath(), true);
final var client = new DbusManagerImpl(signal, null, "org.asamk.Signal.Test");
assertEquals(number == null ? "" : number, signal.getSelfNumber());
assertEquals(number, client.getSelfNumber());
assertEquals(number == null ? ACI : number, client.getSelfIdentifier());
checkSelfOperations(client,
account,
number == null
? new RecipientIdentifier.Uuid(UUID.fromString(ACI))
: new RecipientIdentifier.Number(number));
}
// Run with: dbus-run-session -- ./gradlew --no-daemon :test --tests '*NumberlessDbusTest*' --rerun-tasks
@Nested
@Timeout(20)
@EnabledIfEnvironmentVariable(named = "DBUS_SESSION_BUS_ADDRESS", matches = ".+")
class SessionBus {
@Test
void exportsMixedAccountsRoutesAciSelectorsAndRemovesOnlyTheClosedAccount() throws Exception {
final var numberless = new TestAccount(null, ACI);
final var numbered = new TestAccount(NUMBER, OTHER_ACI);
final var secondNumberless = new TestAccount(null, THIRD_ACI);
final var accounts = new MultiAccountManagerImpl(List.of(numberless.manager,
numbered.manager,
secondNumberless.manager), null);
final var busname = newBusname();
try (final var server = new DbusHandler(false,
busname,
accounts,
true); final var connection = DBusConnectionBuilder.forSessionBus().withShared(false).build()) {
server.init();
final var control = connection.getRemoteObject(busname,
DbusConfig.getObjectPath(),
SignalControl.class);
assertEquals(Set.of(NUMBERLESS_PATH, NUMBERED_PATH, DbusConfig.getObjectPath(THIRD_ACI)),
control.listAccounts().stream().map(DBusPath::getPath).collect(Collectors.toSet()));
assertEquals(NUMBERED_PATH, control.getAccount(OTHER_ACI).getPath());
assertEquals(NUMBERED_PATH, control.getAccount(NUMBER).getPath());
assertEquals(NUMBERLESS_PATH, control.getAccount(ACI).getPath());
assertEquals(ACI, selectAccount(ACI, connection, busname));
assertEquals(OTHER_ACI, selectAccount(OTHER_ACI, connection, busname));
assertEquals(OTHER_ACI, selectAccount(NUMBER, connection, busname));
assertThrows(UserErrorException.class, () -> selectAccount(null, connection, busname));
final var client = new DbusMultiAccountManagerImpl(control, connection, busname);
assertEquals(3, client.getManagers().size());
assertEquals(2, client.getManagers().stream().filter(m -> m.getSelfNumber() == null).count());
assertNull(client.getManager(ACI).getSelfNumber());
assertEquals(NUMBER, client.getManager(OTHER_ACI).getSelfNumber());
final var removed = connection.getRemoteObject(busname, NUMBERLESS_PATH, Signal.class);
numberless.manager.close();
assertEquals(2, control.listAccounts().size());
assertThrows(SignalControl.Error.Failure.class, () -> control.getAccount(ACI));
assertThrows(DBusExecutionException.class, removed::getSelfACI);
assertEquals(THIRD_ACI, client.getManager(THIRD_ACI).getSelfACI());
assertEquals(NUMBER, client.getManager(NUMBER).getSelfNumber());
}
}
@ParameterizedTest
@NullSource
@ValueSource(strings = NUMBER)
void selectsSingleAccountDaemonsImplicitlyAndByAci(final String number) throws Exception {
final var account = new TestAccount(number, ACI);
final var busname = newBusname();
try (final var server = new DbusHandler(false,
busname,
account.manager,
true); final var connection = DBusConnectionBuilder.forSessionBus().withShared(false).build()) {
server.init();
assertEquals(ACI, selectAccount(null, connection, busname));
assertEquals(ACI, selectAccount(ACI, connection, busname));
if (number != null) {
assertEquals(ACI, selectAccount(number, connection, busname));
}
assertThrows(UserErrorException.class, () -> selectAccount(OTHER_ACI, connection, busname));
}
}
@Test
void preservesAciAddressesInContactsGroupMembersAndIdentities() throws Exception {
final var account = new TestAccount(null, ACI);
final var contact = new RecipientAddress(UUID.fromString(OTHER_ACI));
final var numberedContact = new RecipientAddress(NUMBER);
account.recipients = List.of(Recipient.newBuilder().withAddress(contact).build(),
Recipient.newBuilder().withAddress(numberedContact).build());
account.identities = List.of(new Identity(contact,
new byte[]{1},
"test",
new byte[]{2},
TrustLevel.TRUSTED_UNVERIFIED,
123));
final var groupId = GroupId.unknownVersion(new byte[32]);
account.groups = List.of(new Group(groupId,
"Test group",
"",
null,
Set.of(new GroupMember(contact, true, null, null),
new GroupMember(numberedContact, false, null, null)),
Set.of(contact),
Set.of(contact),
Set.of(contact),
false,
0,
GroupPermission.EVERY_MEMBER,
GroupPermission.EVERY_MEMBER,
GroupPermission.EVERY_MEMBER,
true,
true,
false));
final var busname = newBusname();
try (final var server = new DbusHandler(false,
busname,
account.manager,
true); final var connection = DBusConnectionBuilder.forSessionBus().withShared(false).build()) {
server.init();
final var signal = connection.getRemoteObject(busname, DbusConfig.getObjectPath(), Signal.class);
assertEquals(List.of(NUMBER), signal.listNumbers());
assertEquals(Set.of(OTHER_ACI, NUMBER), Set.copyOf(signal.listRecipientIdentifiers()));
try (final var client = new DbusManagerImpl(signal, connection, busname)) {
final var contacts = client.getRecipients(false,
Optional.empty(),
Set.of(new RecipientIdentifier.Uuid(UUID.fromString(OTHER_ACI))),
Optional.empty());
assertEquals(1, contacts.size());
assertEquals(contact, contacts.getFirst().getAddress());
assertEquals(contact, client.getIdentities().getFirst().recipient());
final var group = client.getGroups().getFirst();
assertEquals(Set.of(contact, numberedContact),
group.members().stream().map(GroupMember::recipientAddress).collect(Collectors.toSet()));
assertEquals(Set.of(contact), group.pendingMembers());
assertEquals(Set.of(contact), group.requestingMembers());
assertEquals(Set.of(contact), group.bannedMembers());
}
}
}
@Test
void sendsAndReceivesNumberlessDirectGroupAndSelfMessagesOverDbus() throws Exception {
final var account = new TestAccount(null, ACI);
final var accounts = new MultiAccountManagerImpl(List.of(account.manager), null);
final var busname = newBusname();
try (final var server = new DbusHandler(false,
busname,
accounts,
true); final var connection = DBusConnectionBuilder.forSessionBus().withShared(false).build()) {
server.init();
assertEquals(ACI, selectAccount(null, connection, busname));
final var signal = connection.getRemoteObject(busname, NUMBERLESS_PATH, Signal.class);
assertEquals("", signal.getSelfNumber());
final var groupId = GroupId.unknownVersion(new byte[32]);
assertEquals(42, signal.sendMessage("Direct", List.of(), OTHER_ACI));
assertEquals(42, signal.sendGroupMessage("Group", List.of(), groupId.serialize()));
assertEquals(42, signal.sendNoteToSelfMessage("Self", List.of()));
assertEquals(Set.of(new RecipientIdentifier.Uuid(UUID.fromString(OTHER_ACI))),
account.calls.get(0).arguments().get(1));
assertEquals(Set.of(new RecipientIdentifier.Group(groupId)), account.calls.get(1).arguments().get(1));
assertEquals(Set.of(RecipientIdentifier.NoteToSelf.INSTANCE), account.calls.get(2).arguments().get(1));
assertThrows(Signal.Error.InvalidNumber.class,
() -> signal.sendMessage("Invalid local number", List.of(), "2025550124"));
account.calls.clear();
try (final var client = new DbusManagerImpl(signal, connection, busname)) {
checkSelfOperations(client, account, new RecipientIdentifier.Uuid(UUID.fromString(ACI)));
final var received = new LinkedBlockingQueue<MessageEnvelope>();
client.addReceiveHandler((envelope, error) -> received.add(envelope));
for (final var group : List.of(Optional.<GroupId>empty(), Optional.of(groupId))) {
account.emit(messageEnvelope(group));
final var envelope = received.poll(5, TimeUnit.SECONDS);
assertNotNull(envelope);
assertEquals(OTHER_ACI, envelope.sourceAddress().orElseThrow().aci().orElseThrow());
assertEquals(Optional.empty(), envelope.sourceAddress().orElseThrow().number());
assertEquals("Incoming", envelope.data().orElseThrow().body().orElseThrow());
assertEquals(Optional.of(ACI),
envelope.data().orElseThrow().mentions().getFirst().recipient().aci());
assertEquals(group,
envelope.data()
.orElseThrow()
.groupContext()
.map(MessageEnvelope.Data.GroupContext::groupId));
}
}
assertEquals(0, account.receivers.size());
}
}
}
private static void checkSelfOperations(
final Manager client,
final TestAccount account,
final RecipientIdentifier.Single self
) throws Exception {
final Set<RecipientIdentifier> recipients = Set.of(RecipientIdentifier.NoteToSelf.INSTANCE);
client.sendTypingMessage(TypingAction.START, recipients);
client.sendRemoteDeleteMessage(123, recipients);
client.sendMessageReaction("\uD83D\uDC4D", false, self, 123, recipients, false, false);
assertEquals(List.of("sendTypingMessage", "sendRemoteDeleteMessage", "sendMessageReaction"),
account.calls.stream().map(Call::name).toList());
assertEquals(Set.of(self), account.calls.get(0).arguments().get(1));
assertEquals(Set.of(self), account.calls.get(1).arguments().get(1));
assertEquals(Set.of(self), account.calls.get(2).arguments().get(4));
}
private static String selectAccount(
final String account,
final DBusConnection connection,
final String busname
) throws Exception {
final var selected = new AtomicReference<String>();
final var handler = new CommandHandler(null, null) {
@Override
public void handleLocalCommand(final LocalCommand command, final Manager manager) {
selected.set(manager.getSelfACI());
}
};
DbusCommandHandler.handleCommand(new ListGroupsCommand(), account, connection, busname, handler);
return selected.get();
}
private static String newBusname() {
return "org.asamk.Signal.Test" + UUID.randomUUID().toString().replace("-", "");
}
private static MessageEnvelope messageEnvelope(final Optional<GroupId> groupId) {
final var data = new MessageEnvelope.Data(123,
groupId.map(id -> new MessageEnvelope.Data.GroupContext(id, false, 0)),
Optional.empty(),
Optional.empty(),
Optional.of("Incoming"),
0,
false,
false,
false,
false,
false,
Optional.empty(),
Optional.empty(),
Optional.empty(),
List.of(),
Optional.empty(),
Optional.empty(),
List.of(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
List.of(new MessageEnvelope.Data.Mention(new RecipientAddress(UUID.fromString(ACI)), 0, 1)),
List.of(),
List.of(),
Optional.empty(),
Optional.empty(),
Optional.empty());
return new MessageEnvelope(Optional.of(new RecipientAddress(UUID.fromString(OTHER_ACI))),
1,
123,
0,
0,
false,
Optional.empty(),
Optional.empty(),
Optional.of(data),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty());
}
private record Call(String name, List<Object> arguments) {}
private static final class TestAccount {
private final Manager manager;
private final List<Call> calls = new CopyOnWriteArrayList<>();
private final List<Runnable> closedListeners = new CopyOnWriteArrayList<>();
private final List<Manager.ReceiveMessageHandler> receivers = new CopyOnWriteArrayList<>();
private List<Recipient> recipients = List.of();
private List<Group> groups = List.of();
private List<Identity> identities = List.of();
private TestAccount(final String number, final String aci) {
manager = (Manager) Proxy.newProxyInstance(Manager.class.getClassLoader(),
new Class<?>[]{Manager.class},
(proxy, method, args) -> {
if (method.isDefault()) {
return InvocationHandler.invokeDefault(proxy, method, args);
}
return switch (method.getName()) {
case "getSelfNumber" -> number;
case "getSelfACI" -> aci;
case "getLinkedDevices" -> List.of();
case "getGroups" -> groups;
case "getGroup" -> groups.getFirst();
case "getIdentities" -> identities;
case "getRecipients" -> recipients;
case "isContactBlocked" -> false;
case "getContactOrProfileName" -> "Test contact";
case "addAddressChangedListener" -> null;
case "addClosedListener" -> {
closedListeners.add((Runnable) args[0]);
yield null;
}
case "close" -> {
closedListeners.forEach(Runnable::run);
closedListeners.clear();
yield null;
}
case "addReceiveHandler" -> {
receivers.add((Manager.ReceiveMessageHandler) args[0]);
yield null;
}
case "removeReceiveHandler" -> {
receivers.remove(args[0]);
yield null;
}
case "sendMessage", "sendTypingMessage", "sendRemoteDeleteMessage",
"sendMessageReaction" -> {
calls.add(new Call(method.getName(), List.of(args)));
yield new SendMessageResults(42, Map.of());
}
case "hashCode" -> System.identityHashCode(proxy);
case "equals" -> proxy == args[0];
default -> throw new UnsupportedOperationException(method.getName());
};
});
}
private void emit(final MessageEnvelope envelope) {
receivers.forEach(receiver -> receiver.handleMessage(envelope, null));
}
}
}

View File

@ -0,0 +1,71 @@
package org.asamk.signal.json;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.asamk.signal.commands.ListAccountsCommand;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.internal.MultiAccountManagerImpl;
import org.junit.jupiter.api.Test;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
class NumberlessAccountOutputTest {
private static final String ACI = "11111111-1111-4111-8111-111111111111";
@Test
void receiveEventsIdentifyNumberlessAccountByAci() {
final var output = new AtomicReference<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());
};
});
}
}

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;