diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9f046f4b..d781a4ca 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,20 @@
# Changelog
-## [Unreleased]
+## [0.14.4] - 2026-05-23
+
+### Added
+
+- Support for a global configuration file to set system-wide defaults
+
+### Fixed
+
+- Group admins can now see profile information for users requesting to join groups.
+- Storage sync with unregistered contacts fixed
+- Incoming messages are validated more accurately, fixing receiving messages from new contacts
+
+### Improved
+
+- Some security and stability improvements, including HTTP HOST header validation and safer temporary file handling.
## [0.14.3] - 2026-04-22
diff --git a/build.gradle.kts b/build.gradle.kts
index 5ed47048..31b0d72c 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -10,7 +10,7 @@ plugins {
allprojects {
group = "org.asamk"
- version = "0.14.4-SNAPSHOT"
+ version = "0.14.4"
}
java {
@@ -102,7 +102,7 @@ dependencies {
implementation(libs.micronaut.json.schema.annotations)
if (gradle.startParameter.taskNames.any { it.contains("jsonSchemas") }) {
implementation(libs.micronaut.json.schema.generator)
- }
+ }
implementation(project(":libsignal-cli"))
testImplementation(libs.junit.jupiter)
diff --git a/data/org.asamk.SignalCli.metainfo.xml b/data/org.asamk.SignalCli.metainfo.xml
index 3bf04d8c..231492be 100644
--- a/data/org.asamk.SignalCli.metainfo.xml
+++ b/data/org.asamk.SignalCli.metainfo.xml
@@ -45,6 +45,9 @@
intense
+
+ https://github.com/AsamK/signal-cli/releases/tag/v0.14.4
+
https://github.com/AsamK/signal-cli/releases/tag/v0.14.3
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index e36c07bb..ef39a50d 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,9 +1,9 @@
[versions]
slf4j = "2.0.18"
-junit = "6.0.3"
-micronaut-json-schema = "2.0.0"
+junit = "6.1.0"
+micronaut-json-schema = "2.0.1"
micronaut-core = "5.0.0"
-signal-service = "2.15.3_unofficial_146"
+signal-service = "2.15.3_unofficial_147"
[libraries]
bouncycastle = "org.bouncycastle:bcprov-jdk18on:1.84"
diff --git a/lib/src/main/java/org/asamk/signal/manager/api/MessageEnvelope.java b/lib/src/main/java/org/asamk/signal/manager/api/MessageEnvelope.java
index 975e1639..34b2274d 100644
--- a/lib/src/main/java/org/asamk/signal/manager/api/MessageEnvelope.java
+++ b/lib/src/main/java/org/asamk/signal/manager/api/MessageEnvelope.java
@@ -4,7 +4,6 @@ import org.asamk.signal.manager.groups.GroupUtils;
import org.asamk.signal.manager.helper.RecipientAddressResolver;
import org.asamk.signal.manager.storage.recipients.RecipientResolver;
import org.asamk.signal.manager.util.MimeUtils;
-import org.signal.core.models.ServiceId;
import org.signal.libsignal.metadata.ProtocolException;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer;
diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/AccountHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/AccountHelper.java
index 2396b36e..a4f037f1 100644
--- a/lib/src/main/java/org/asamk/signal/manager/helper/AccountHelper.java
+++ b/lib/src/main/java/org/asamk/signal/manager/helper/AccountHelper.java
@@ -25,6 +25,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.exceptions.NonSuccessfulResponseCodeException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.account.ChangePhoneNumberRequest;
@@ -37,7 +38,6 @@ import org.whispersystems.signalservice.api.push.UsernameLinkComponents;
import org.whispersystems.signalservice.api.push.exceptions.AlreadyVerifiedException;
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException;
import org.whispersystems.signalservice.api.push.exceptions.DeprecatedVersionException;
-import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
import org.whispersystems.signalservice.api.util.DeviceNameUtil;
import org.whispersystems.signalservice.internal.push.DeviceLimitExceededException;
import org.whispersystems.signalservice.internal.push.KyberPreKeyEntity;
diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/AttachmentHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/AttachmentHelper.java
index afafeb24..1fefcb24 100644
--- a/lib/src/main/java/org/asamk/signal/manager/helper/AttachmentHelper.java
+++ b/lib/src/main/java/org/asamk/signal/manager/helper/AttachmentHelper.java
@@ -34,8 +34,10 @@ public class AttachmentHelper {
private final SignalDependencies dependencies;
private final AttachmentStore attachmentStore;
+ private final Context context;
public AttachmentHelper(final Context context) {
+ this.context = context;
this.dependencies = context.getDependencies();
this.attachmentStore = context.getAttachmentStore();
}
@@ -92,6 +94,21 @@ public class AttachmentHelper {
final boolean voiceNote
) throws AttachmentInvalidException {
try {
+ // Reject local files that point into the signal-cli data directory
+ if (attachment != null && !attachment.startsWith("data:")) {
+ try {
+ final var file = new File(attachment);
+ final var canonical = file.getCanonicalFile();
+ final var dataPath = context.getAccount().getDataPath().getCanonicalFile();
+ if (canonical.toPath().startsWith(dataPath.toPath())) {
+ throw new AttachmentInvalidException(attachment,
+ new IOException("Attaching files from the signal-cli data directory is not allowed"));
+ }
+ } catch (IOException e) {
+ throw new AttachmentInvalidException(attachment, e);
+ }
+ }
+
final var streamDetailsAndFileName = Utils.createStreamDetails(attachment);
final var streamDetails = streamDetailsAndFileName.first();
final var uploadSpec = getResumableUploadSpec(streamDetails);
@@ -109,7 +126,7 @@ public class AttachmentHelper {
final var streamLength = streamDetails.getLength();
final var ciphertextLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(
streamLength));
- return dependencies.getMessageSender().getResumableUploadSpec(ciphertextLength);
+ return dependencies.getCdnService().getResumableUploadSpecBlocking(ciphertextLength);
}
public SignalServiceAttachmentPointer uploadAttachment(String attachment) throws IOException, AttachmentInvalidException {
diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/GroupV2Helper.java b/lib/src/main/java/org/asamk/signal/manager/helper/GroupV2Helper.java
index e2bc2a82..d4df661e 100644
--- a/lib/src/main/java/org/asamk/signal/manager/helper/GroupV2Helper.java
+++ b/lib/src/main/java/org/asamk/signal/manager/helper/GroupV2Helper.java
@@ -21,6 +21,7 @@ import org.signal.libsignal.zkgroup.groups.GroupMasterKey;
import org.signal.libsignal.zkgroup.groups.GroupSecretParams;
import org.signal.libsignal.zkgroup.groups.UuidCiphertext;
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
+import org.signal.network.exceptions.NonSuccessfulResponseCodeException;
import org.signal.storageservice.storage.protos.groups.AccessControl;
import org.signal.storageservice.storage.protos.groups.GroupChange;
import org.signal.storageservice.storage.protos.groups.GroupChangeResponse;
@@ -43,7 +44,6 @@ import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
import org.whispersystems.signalservice.api.groupsv2.InvalidGroupStateException;
import org.whispersystems.signalservice.api.groupsv2.NotAbleToApplyGroupV2ChangeException;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
-import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
import org.whispersystems.signalservice.internal.push.exceptions.NotInGroupException;
import java.io.IOException;
diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/PreKeyHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/PreKeyHelper.java
index bf7ad580..18a7b165 100644
--- a/lib/src/main/java/org/asamk/signal/manager/helper/PreKeyHelper.java
+++ b/lib/src/main/java/org/asamk/signal/manager/helper/PreKeyHelper.java
@@ -9,6 +9,7 @@ import org.signal.libsignal.protocol.InvalidKeyIdException;
import org.signal.libsignal.protocol.state.KyberPreKeyRecord;
import org.signal.libsignal.protocol.state.PreKeyRecord;
import org.signal.libsignal.protocol.state.SignedPreKeyRecord;
+import org.signal.network.exceptions.NonSuccessfulResponseCodeException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.NetworkResultUtil;
@@ -16,7 +17,6 @@ import org.whispersystems.signalservice.api.account.PreKeyUpload;
import org.whispersystems.signalservice.api.keys.OneTimePreKeyCounts;
import org.whispersystems.signalservice.api.push.ServiceIdType;
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException;
-import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
import java.io.IOException;
import java.util.List;
@@ -84,7 +84,8 @@ public class PreKeyHelper {
) throws IOException {
OneTimePreKeyCounts preKeyCounts;
try {
- preKeyCounts = handleResponseException(dependencies.getKeysApi().getAvailablePreKeyCounts(serviceIdType));
+ preKeyCounts = handleResponseException(dependencies.getKeysApi()
+ .getAvailablePreKeyCountsSync(serviceIdType));
} catch (AuthorizationFailedException e) {
logger.debug("Failed to get pre key count, ignoring: " + e.getClass().getSimpleName());
preKeyCounts = new OneTimePreKeyCounts(0, 0);
@@ -145,7 +146,7 @@ public class PreKeyHelper {
kyberPreKeyRecords);
var needsReset = false;
try {
- NetworkResultUtil.toPreKeysLegacy(dependencies.getKeysApi().setPreKeys(preKeyUpload));
+ NetworkResultUtil.toPreKeysLegacy(dependencies.getKeysApi().setPreKeysSync(preKeyUpload));
try {
if (preKeyRecords != null) {
account.addPreKeys(serviceIdType, preKeyRecords);
diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/ProfileHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/ProfileHelper.java
index 7906fc86..64e3a764 100644
--- a/lib/src/main/java/org/asamk/signal/manager/helper/ProfileHelper.java
+++ b/lib/src/main/java/org/asamk/signal/manager/helper/ProfileHelper.java
@@ -21,6 +21,7 @@ import org.signal.libsignal.protocol.IdentityKey;
import org.signal.libsignal.protocol.InvalidKeyException;
import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential;
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
+import org.signal.network.exceptions.PushNetworkException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.NetworkResultUtil;
@@ -30,7 +31,6 @@ import org.whispersystems.signalservice.api.profiles.ProfileAndCredential;
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
-import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
import org.whispersystems.signalservice.api.services.ProfileService;
import org.whispersystems.signalservice.api.util.ExpiringProfileCredentialUtil;
diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/RecipientHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/RecipientHelper.java
index e84a3059..c865f38f 100644
--- a/lib/src/main/java/org/asamk/signal/manager/helper/RecipientHelper.java
+++ b/lib/src/main/java/org/asamk/signal/manager/helper/RecipientHelper.java
@@ -11,13 +11,13 @@ import org.signal.core.models.ServiceId.ACI;
import org.signal.core.models.ServiceId.PNI;
import org.signal.libsignal.usernames.BaseUsernameException;
import org.signal.libsignal.usernames.Username;
+import org.signal.network.exceptions.NonSuccessfulResponseCodeException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.cds.CdsiV2Service;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.CdsiInvalidArgumentException;
import org.whispersystems.signalservice.api.push.exceptions.CdsiInvalidTokenException;
-import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
import java.io.IOException;
import java.util.Collection;
diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/StorageHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/StorageHelper.java
index 6f60f734..d01bf44b 100644
--- a/lib/src/main/java/org/asamk/signal/manager/helper/StorageHelper.java
+++ b/lib/src/main/java/org/asamk/signal/manager/helper/StorageHelper.java
@@ -2,6 +2,7 @@ package org.asamk.signal.manager.helper;
import org.asamk.signal.manager.api.GroupIdV1;
import org.asamk.signal.manager.api.GroupIdV2;
+import org.asamk.signal.manager.api.Pair;
import org.asamk.signal.manager.api.Profile;
import org.asamk.signal.manager.internal.SignalDependencies;
import org.asamk.signal.manager.storage.SignalAccount;
@@ -38,6 +39,7 @@ import java.util.ArrayList;
import java.util.Base64;
import java.util.Collection;
import java.util.Collections;
+import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@@ -211,20 +213,23 @@ public class StorageHelper {
remoteOnlyRecords.size());
}
- // This logic is wrong, records should only be deleted if they're deleted remotely, not if the remote record is updated
-// if (!idDifference.localOnlyIds().isEmpty()) {
-// final var updated = account.getRecipientStore()
-// .removeStorageIdsFromLocalOnlyUnregisteredRecipients(connection,
-// idDifference.localOnlyIds());
-//
-// if (updated > 0) {
-// logger.warn(
-// "Found {} records that were deleted remotely but only marked unregistered locally. Removed those from local store.",
-// updated);
-// }
-// }
-//
- final var unknownInserts = processKnownRecords(connection, remoteOnlyRecords);
+ final var listListPair = processKnownRecords(connection, remoteOnlyRecords);
+ final var unknownInserts = listListPair.first();
+ final var updatedStorageIds = listListPair.second();
+ final var oldUnregisteredLocalOnlyIds = new HashSet<>(idDifference.localOnlyIds());
+ updatedStorageIds.forEach(oldUnregisteredLocalOnlyIds::remove);
+ if (!idDifference.localOnlyIds().isEmpty()) {
+ final var updated = account.getRecipientStore()
+ .removeStorageIdsFromLocalOnlyUnregisteredRecipients(connection,
+ oldUnregisteredLocalOnlyIds);
+
+ if (updated > 0) {
+ logger.warn(
+ "Found {} records that were deleted remotely but only marked unregistered locally. Removed those from local store.",
+ updated);
+ }
+ }
+
final var unknownDeletes = idDifference.localOnlyIds()
.stream()
.filter(id -> !KNOWN_TYPES.contains(id.getType()))
@@ -480,13 +485,13 @@ public class StorageHelper {
private Map generateGroupV1StorageIds(List groupIds) {
return groupIds.stream()
.collect(Collectors.toMap(recipientId -> recipientId,
- recipientId -> StorageId.forGroupV1(KeyUtils.createRawStorageId())));
+ _ -> StorageId.forGroupV1(KeyUtils.createRawStorageId())));
}
private Map generateGroupV2StorageIds(List groupIds) {
return groupIds.stream()
.collect(Collectors.toMap(recipientId -> recipientId,
- recipientId -> StorageId.forGroupV2(KeyUtils.createRawStorageId())));
+ _ -> StorageId.forGroupV2(KeyUtils.createRawStorageId())));
}
private void storeManifestLocally(
@@ -630,16 +635,17 @@ public class StorageHelper {
return new IdDifferenceResult(remoteOnlyKeys, localOnlyKeys, hasTypeMismatch);
}
- private List processKnownRecords(
+ private Pair, List> processKnownRecords(
final Connection connection,
List records
) throws SQLException {
final var unknownRecords = new ArrayList();
+ final var processedRecords = new ArrayList();
final var accountRecordProcessor = new AccountRecordProcessor(account, connection, context.getJobExecutor());
- final var contactRecordProcessor = new ContactRecordProcessor(account, connection, context.getJobExecutor());
final var groupV1RecordProcessor = new GroupV1RecordProcessor(account, connection);
final var groupV2RecordProcessor = new GroupV2RecordProcessor(account, connection);
+ final var contactRecordProcessor = new ContactRecordProcessor(account, connection, context.getJobExecutor());
for (final var record : records) {
if (record.getProto().account != null) {
@@ -662,8 +668,12 @@ public class StorageHelper {
unknownRecords.add(record.getId());
}
}
+ processedRecords.addAll(accountRecordProcessor.getUpdatedStorageIds());
+ processedRecords.addAll(groupV1RecordProcessor.getUpdatedStorageIds());
+ processedRecords.addAll(groupV2RecordProcessor.getUpdatedStorageIds());
+ processedRecords.addAll(contactRecordProcessor.getUpdatedStorageIds());
- return unknownRecords;
+ return new Pair<>(unknownRecords, processedRecords);
}
/**
diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/SyncHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/SyncHelper.java
index 53ab3fa4..ba2ccad1 100644
--- a/lib/src/main/java/org/asamk/signal/manager/helper/SyncHelper.java
+++ b/lib/src/main/java/org/asamk/signal/manager/helper/SyncHelper.java
@@ -160,7 +160,7 @@ public class SyncHelper {
try {
try (OutputStream fos = new FileOutputStream(contactsFile)) {
- var out = new DeviceContactsOutputStream(fos, true, true);
+ var out = new DeviceContactsOutputStream(fos);
for (var contactPair : account.getContactStore().getContacts()) {
final var recipientId = contactPair.first();
final var contact = contactPair.second();
diff --git a/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java b/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java
index 394fa894..18c261b5 100644
--- a/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java
+++ b/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java
@@ -101,6 +101,7 @@ import org.signal.core.util.Base64;
import org.signal.core.util.Hex;
import org.signal.libsignal.protocol.InvalidMessageException;
import org.signal.libsignal.usernames.BaseUsernameException;
+import org.signal.network.exceptions.NonSuccessfulResponseCodeException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
@@ -117,7 +118,6 @@ import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMess
import org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo;
import org.whispersystems.signalservice.api.push.ServiceIdType;
import org.whispersystems.signalservice.api.push.exceptions.CdsiResourceExhaustedException;
-import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
import org.whispersystems.signalservice.api.util.DeviceNameUtil;
import org.whispersystems.signalservice.api.util.StreamDetails;
import org.whispersystems.signalservice.internal.util.Util;
@@ -190,6 +190,7 @@ public class ManagerImpl implements Manager {
userAgent,
account.getCredentialsProvider(),
account.getSignalServiceDataStore(),
+ account.getDeviceId(),
executor,
sessionLock);
final var avatarStore = new AvatarStore(pathConfig.avatarsPath());
diff --git a/lib/src/main/java/org/asamk/signal/manager/internal/RegistrationManagerImpl.java b/lib/src/main/java/org/asamk/signal/manager/internal/RegistrationManagerImpl.java
index e90bb154..1fd86c26 100644
--- a/lib/src/main/java/org/asamk/signal/manager/internal/RegistrationManagerImpl.java
+++ b/lib/src/main/java/org/asamk/signal/manager/internal/RegistrationManagerImpl.java
@@ -231,6 +231,7 @@ public class RegistrationManagerImpl implements RegistrationManager {
userAgent,
account.getCredentialsProvider(),
account.getSignalServiceDataStore(),
+ 0,
null,
new ReentrantSignalSessionLock());
handleResponseException(dependencies.getAccountApi()
diff --git a/lib/src/main/java/org/asamk/signal/manager/internal/SignalDependencies.java b/lib/src/main/java/org/asamk/signal/manager/internal/SignalDependencies.java
index 78d658f4..55795714 100644
--- a/lib/src/main/java/org/asamk/signal/manager/internal/SignalDependencies.java
+++ b/lib/src/main/java/org/asamk/signal/manager/internal/SignalDependencies.java
@@ -5,13 +5,17 @@ import org.asamk.signal.manager.config.ServiceEnvironmentConfig;
import org.asamk.signal.manager.util.Utils;
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.AttachmentApi;
import org.signal.network.api.CallingApi;
import org.signal.network.api.CdsApi;
import org.signal.network.api.CertificateApi;
import org.signal.network.api.LinkDeviceApi;
import org.signal.network.api.RateLimitChallengeApi;
import org.signal.network.api.UsernameApi;
+import org.signal.network.rest.SignalRestClient;
+import org.signal.network.service.CdnService;
import org.signal.network.service.StorageServiceService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -21,12 +25,12 @@ import org.whispersystems.signalservice.api.SignalServiceMessageReceiver;
import org.whispersystems.signalservice.api.SignalServiceMessageSender;
import org.whispersystems.signalservice.api.SignalSessionLock;
import org.whispersystems.signalservice.api.account.AccountApi;
-import org.whispersystems.signalservice.api.attachment.AttachmentApi;
import org.whispersystems.signalservice.api.crypto.SignalServiceCipher;
import org.whispersystems.signalservice.api.groupsv2.ClientZkOperations;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
import org.whispersystems.signalservice.api.keys.KeysApi;
+import org.whispersystems.signalservice.api.keys.PreKeyRepository;
import org.whispersystems.signalservice.api.message.MessageApi;
import org.whispersystems.signalservice.api.profiles.ProfileApi;
import org.whispersystems.signalservice.api.push.ServiceIdType;
@@ -61,6 +65,7 @@ public class SignalDependencies {
private final String userAgent;
private final CredentialsProvider credentialsProvider;
private final SignalServiceDataStore dataStore;
+ private final int deviceId;
private final ExecutorService executor;
private final SignalSessionLock sessionLock;
@@ -82,6 +87,11 @@ public class SignalDependencies {
private KeysApi keysApi;
private GroupsV2Operations groupsV2Operations;
private ClientZkOperations clientZkOperations;
+ private ProfileService profileService;
+ private ProfileApi profileApi;
+ private CdnService cdnService;
+ private PreKeyRepository preKeyRepository;
+ private SignalRestClient signalRestClient;
private PushServiceSocket pushServiceSocket;
private Network libSignalNetwork;
@@ -91,14 +101,13 @@ public class SignalDependencies {
private SignalServiceMessageSender messageSender;
private List secureValueRecovery;
- private ProfileService profileService;
- private ProfileApi profileApi;
SignalDependencies(
final ServiceEnvironmentConfig serviceEnvironmentConfig,
final String userAgent,
final CredentialsProvider credentialsProvider,
final SignalServiceDataStore dataStore,
+ final int deviceId,
final ExecutorService executor,
final SignalSessionLock sessionLock
) {
@@ -106,6 +115,7 @@ public class SignalDependencies {
this.userAgent = userAgent;
this.credentialsProvider = credentialsProvider;
this.dataStore = dataStore;
+ this.deviceId = deviceId;
this.executor = executor;
this.sessionLock = sessionLock;
}
@@ -326,12 +336,33 @@ public class SignalDependencies {
() -> messageReceiver = new SignalServiceMessageReceiver(getPushServiceSocket()));
}
+ private SignalRestClient getSignalRestClient() {
+ return getOrCreate(() -> signalRestClient,
+ () -> signalRestClient = new SignalRestClient(serviceEnvironmentConfig.signalServiceConfiguration(),
+ userAgent,
+ credentialsProvider,
+ ServiceConfig.AUTOMATIC_NETWORK_RETRY));
+ }
+
+ public CdnService getCdnService() {
+ return getOrCreate(() -> cdnService,
+ () -> cdnService = new CdnService(getSignalRestClient(), getAttachmentApi()));
+ }
+
+ public PreKeyRepository getPreKeyRepository() {
+ final SignalProtocolAddress localProtocolAddress = credentialsProvider.getAci().toProtocolAddress(deviceId);
+ return getOrCreate(() -> preKeyRepository,
+ () -> preKeyRepository = new PreKeyRepository(getKeysApi(),
+ dataStore.aci(),
+ localProtocolAddress,
+ Runnable::run));
+ }
+
public SignalServiceMessageSender getMessageSender() {
return getOrCreate(() -> messageSender,
() -> messageSender = new SignalServiceMessageSender(getPushServiceSocket(),
dataStore,
sessionLock,
- getAttachmentApi(),
getMessageApi(),
getKeysApi(),
Optional.empty(),
@@ -339,8 +370,7 @@ public class SignalDependencies {
ServiceConfig.MAX_ENVELOPE_SIZE,
ServiceConfig.MAX_INCREMENTAL_MACS_PER_ENVELOPE,
() -> true,
- true,
- true));
+ getPreKeyRepository()));
}
public List getSecureValueRecovery() {
@@ -368,7 +398,9 @@ public class SignalDependencies {
public SignalServiceCipher getCipher(ServiceIdType serviceIdType) {
final var certificateValidator = new CertificateValidator(serviceEnvironmentConfig.unidentifiedSenderTrustRoots());
- final var serviceId = serviceIdType == ServiceIdType.ACI ? credentialsProvider.getAci() : credentialsProvider.getPni();
+ final var serviceId = serviceIdType == ServiceIdType.ACI
+ ? credentialsProvider.getAci()
+ : credentialsProvider.getPni();
final var address = new SignalServiceAddress(serviceId, credentialsProvider.getE164());
final var deviceId = credentialsProvider.getDeviceId();
return new SignalServiceCipher(address,
diff --git a/lib/src/main/java/org/asamk/signal/manager/internal/SignalWebSocketHealthMonitor.java b/lib/src/main/java/org/asamk/signal/manager/internal/SignalWebSocketHealthMonitor.java
index 39a447ac..656b6646 100644
--- a/lib/src/main/java/org/asamk/signal/manager/internal/SignalWebSocketHealthMonitor.java
+++ b/lib/src/main/java/org/asamk/signal/manager/internal/SignalWebSocketHealthMonitor.java
@@ -1,8 +1,9 @@
package org.asamk.signal.manager.internal;
+import org.jetbrains.annotations.NotNull;
+import org.signal.network.util.Preconditions;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import org.whispersystems.signalservice.api.util.Preconditions;
import org.whispersystems.signalservice.api.util.SleepTimer;
import org.whispersystems.signalservice.api.websocket.HealthMonitor;
import org.whispersystems.signalservice.api.websocket.SignalWebSocket;
@@ -94,6 +95,14 @@ final class SignalWebSocketHealthMonitor implements HealthMonitor {
return needsKeepAlive && webSocket != null && webSocket.shouldSendKeepAlives();
}
+ @Override
+ public void onReceivedAlerts(@NotNull final String[] strings, final boolean b) {
+ if (strings.length == 0) {
+ return;
+ }
+ logger.info("Received alerts: {}", String.join(", ", strings));
+ }
+
/**
* Sends periodic heartbeats/keep-alives over the WebSocket to prevent connection timeouts. If
* the WebSocket fails to get a return heartbeat after [KEEP_ALIVE_TIMEOUT] seconds, it is forced to be recreated.
diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/AttachmentStore.java b/lib/src/main/java/org/asamk/signal/manager/storage/AttachmentStore.java
index d25f1a72..00a284dd 100644
--- a/lib/src/main/java/org/asamk/signal/manager/storage/AttachmentStore.java
+++ b/lib/src/main/java/org/asamk/signal/manager/storage/AttachmentStore.java
@@ -44,7 +44,8 @@ public class AttachmentStore {
}
public StreamDetails retrieveAttachment(final String id) throws IOException {
- final var attachmentFile = new File(attachmentsPath, id);
+ final var safeId = sanitizeId(id);
+ final var attachmentFile = new File(attachmentsPath, safeId);
return Utils.createStreamDetailsFromFile(attachmentFile);
}
@@ -61,7 +62,8 @@ public class AttachmentStore {
Optional contentType
) {
final var extension = getAttachmentExtension(filename, contentType);
- return new File(attachmentsPath, attachmentId.toString() + extension + ".preview");
+ final var safe = sanitizeId(attachmentId.toString());
+ return new File(attachmentsPath, safe + extension + ".preview");
}
private File getAttachmentFile(
@@ -70,7 +72,15 @@ public class AttachmentStore {
Optional contentType
) {
final var extension = getAttachmentExtension(filename, contentType);
- return new File(attachmentsPath, attachmentId.toString() + extension);
+ final var safe = sanitizeId(attachmentId.toString());
+ return new File(attachmentsPath, safe + extension);
+ }
+
+ private static String sanitizeId(final String id) {
+ if (id == null) {
+ return "";
+ }
+ return id.replaceAll("[^A-Za-z0-9_.-]", "_");
}
private static String getAttachmentExtension(final Optional filename, final Optional contentType) {
diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java b/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java
index 35f600b6..c7a5c6d8 100644
--- a/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java
+++ b/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java
@@ -192,6 +192,10 @@ public class SignalAccount implements Closeable {
this.lock = lock;
}
+ public File getDataPath() {
+ return dataPath;
+ }
+
public static SignalAccount load(
File dataPath,
String accountPath,
diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientStore.java b/lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientStore.java
index 8836a1d3..c80b3454 100644
--- a/lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientStore.java
+++ b/lib/src/main/java/org/asamk/signal/manager/storage/recipients/RecipientStore.java
@@ -878,7 +878,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
public int removeStorageIdsFromLocalOnlyUnregisteredRecipients(
final Connection connection,
- final List storageIds
+ final Collection storageIds
) throws SQLException {
final var sql = (
"""
diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/sessions/SessionStore.java b/lib/src/main/java/org/asamk/signal/manager/storage/sessions/SessionStore.java
index 85b28bd7..fdc59e0c 100644
--- a/lib/src/main/java/org/asamk/signal/manager/storage/sessions/SessionStore.java
+++ b/lib/src/main/java/org/asamk/signal/manager/storage/sessions/SessionStore.java
@@ -403,7 +403,7 @@ public class SessionStore implements SignalServiceSessionStore {
}
private static boolean isActive(SessionRecord record) {
- return record != null && record.hasSenderChain();
+ return record != null && record.hasSenderChain(0.0);
}
record Key(String address, int deviceId) {}
diff --git a/lib/src/main/java/org/asamk/signal/manager/syncStorage/DefaultStorageRecordProcessor.java b/lib/src/main/java/org/asamk/signal/manager/syncStorage/DefaultStorageRecordProcessor.java
index 87acb991..83fd5b5f 100644
--- a/lib/src/main/java/org/asamk/signal/manager/syncStorage/DefaultStorageRecordProcessor.java
+++ b/lib/src/main/java/org/asamk/signal/manager/syncStorage/DefaultStorageRecordProcessor.java
@@ -6,7 +6,9 @@ import org.whispersystems.signalservice.api.storage.SignalRecord;
import org.whispersystems.signalservice.api.storage.StorageId;
import java.sql.SQLException;
+import java.util.Collections;
import java.util.Comparator;
+import java.util.HashSet;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
@@ -24,6 +26,7 @@ abstract class DefaultStorageRecordProcessor> implemen
private static final Logger logger = LoggerFactory.getLogger(DefaultStorageRecordProcessor.class);
private final Set matchedRecords = new TreeSet<>(this);
+ private final Set updatedStorageIds = new HashSet<>();
/**
* One type of invalid remote data this handles is two records mapping to the same local data. We
@@ -50,6 +53,7 @@ abstract class DefaultStorageRecordProcessor> implemen
if (local.isEmpty()) {
debug(remote.getId(), remote, "[Local Insert] No matching local record. Inserting.");
+ updatedStorageIds.add(remote.getId());
insertLocal(remote);
return;
}
@@ -64,6 +68,7 @@ abstract class DefaultStorageRecordProcessor> implemen
matchedRecords.add(local.get());
final var merged = merge(remote, local.get());
+ updatedStorageIds.add(merged.getId());
if (!merged.equals(remote)) {
debug(remote.getId(), remote, "[Remote Update] " + merged.describeDiff(remote));
}
@@ -75,6 +80,10 @@ abstract class DefaultStorageRecordProcessor> implemen
}
}
+ public Set getUpdatedStorageIds() {
+ return Collections.unmodifiableSet(updatedStorageIds);
+ }
+
private void debug(StorageId i, E record, String message) {
logger.debug("[{}][{}] {}", i, record.getClass().getSimpleName(), message);
}
diff --git a/lib/src/main/java/org/asamk/signal/manager/util/IOUtils.java b/lib/src/main/java/org/asamk/signal/manager/util/IOUtils.java
index 65d3b1d8..0061e0d9 100644
--- a/lib/src/main/java/org/asamk/signal/manager/util/IOUtils.java
+++ b/lib/src/main/java/org/asamk/signal/manager/util/IOUtils.java
@@ -21,9 +21,19 @@ import static java.nio.file.attribute.PosixFilePermission.OWNER_WRITE;
public class IOUtils {
public static File createTempFile() throws IOException {
- final var tempFile = File.createTempFile("signal-cli_tmp_", ".tmp");
- tempFile.deleteOnExit();
- return tempFile;
+ final var prefix = "signal-cli_tmp_";
+ final var suffix = ".tmp";
+ try {
+ Set perms = EnumSet.of(OWNER_READ, OWNER_WRITE);
+ var path = Files.createTempFile(prefix, suffix, PosixFilePermissions.asFileAttribute(perms));
+ var tempFile = path.toFile();
+ tempFile.deleteOnExit();
+ return tempFile;
+ } catch (UnsupportedOperationException e) {
+ final var tempFile = File.createTempFile(prefix, suffix);
+ tempFile.deleteOnExit();
+ return tempFile;
+ }
}
public static byte[] readFully(InputStream in) throws IOException {
diff --git a/lib/src/main/java/org/asamk/signal/manager/util/NumberVerificationUtils.java b/lib/src/main/java/org/asamk/signal/manager/util/NumberVerificationUtils.java
index 1908c8f5..8733deb5 100644
--- a/lib/src/main/java/org/asamk/signal/manager/util/NumberVerificationUtils.java
+++ b/lib/src/main/java/org/asamk/signal/manager/util/NumberVerificationUtils.java
@@ -10,11 +10,11 @@ import org.asamk.signal.manager.api.RateLimitException;
import org.asamk.signal.manager.api.VerificationMethodNotAvailableException;
import org.asamk.signal.manager.helper.PinHelper;
import org.signal.core.models.MasterKey;
+import org.signal.network.exceptions.NonSuccessfulResponseCodeException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.push.exceptions.ChallengeRequiredException;
import org.whispersystems.signalservice.api.push.exceptions.NoSuchSessionException;
-import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
import org.whispersystems.signalservice.api.push.exceptions.TokenNotAcceptedException;
import org.whispersystems.signalservice.api.registration.RegistrationApi;
import org.whispersystems.signalservice.internal.push.LockedException;
diff --git a/lib/src/main/java/org/asamk/signal/manager/util/Utils.java b/lib/src/main/java/org/asamk/signal/manager/util/Utils.java
index aee133ab..1871798b 100644
--- a/lib/src/main/java/org/asamk/signal/manager/util/Utils.java
+++ b/lib/src/main/java/org/asamk/signal/manager/util/Utils.java
@@ -7,9 +7,9 @@ import org.signal.libsignal.net.RequestResult;
import org.signal.libsignal.protocol.IdentityKey;
import org.signal.libsignal.protocol.fingerprint.Fingerprint;
import org.signal.libsignal.protocol.fingerprint.NumericFingerprintGenerator;
+import org.signal.network.NetworkResult;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import org.whispersystems.signalservice.api.NetworkResult;
import org.whispersystems.signalservice.api.NetworkResultUtil;
import org.whispersystems.signalservice.api.util.StreamDetails;
diff --git a/libsignal-version b/libsignal-version
index da011ce4..6bc8ee7b 100644
--- a/libsignal-version
+++ b/libsignal-version
@@ -1 +1 @@
-0.92.1
+0.94.1
diff --git a/man/signal-cli-dbus.5.adoc b/man/signal-cli-dbus.5.adoc
index 732efe43..9246e9ed 100644
--- a/man/signal-cli-dbus.5.adoc
+++ b/man/signal-cli-dbus.5.adoc
@@ -13,7 +13,7 @@ signal-cli-dbus - A commandline and dbus interface for the Signal messenger
== Synopsis
-*signal-cli* [--verbose] [--config CONFIG] [-a ACCOUNT] [-o {plain-text,json}] daemon [--dbus] [--dbus-system]
+*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[/_] org.asamk.Signal. [string:] [array::]
diff --git a/man/signal-cli-jsonrpc.5.adoc b/man/signal-cli-jsonrpc.5.adoc
index 1c035af8..b9abb8ce 100644
--- a/man/signal-cli-jsonrpc.5.adoc
+++ b/man/signal-cli-jsonrpc.5.adoc
@@ -13,9 +13,9 @@ signal-cli-jsonrpc - A commandline and dbus interface for the Signal messenger
== Synopsis
-*signal-cli* [--verbose] [--config CONFIG] [-a ACCOUNT] daemon [--socket[=SOCKET_PATH]] [--tcp[=HOST:PORT]] [--http[=HOST:PORT]]
+*signal-cli* [--verbose] [--data-dir DATA_DIR] [-a ACCOUNT] daemon [--socket[=SOCKET_PATH]] [--tcp[=HOST:PORT]] [--http[=HOST:PORT]]
-*signal-cli* [--verbose] [--config CONFIG] [-a ACCOUNT] jsonRpc
+*signal-cli* [--verbose] [--data-dir DATA_DIR] [-a ACCOUNT] jsonRpc
== Description
diff --git a/man/signal-cli.1.adoc b/man/signal-cli.1.adoc
index e349a2ad..f6ed732d 100644
--- a/man/signal-cli.1.adoc
+++ b/man/signal-cli.1.adoc
@@ -13,7 +13,7 @@ signal-cli - A commandline interface for the Signal messenger
== Synopsis
-*signal-cli* [--config CONFIG] [-h | -v | -a ACCOUNT | --dbus | --dbus-system] command [command-options]
+*signal-cli* [--data-dir DATA_DIR] [-h | -v | -a ACCOUNT | --dbus | --dbus-system] command [command-options]
== Description
@@ -57,8 +57,8 @@ If `--verbose` is also given, the detailed logs will only be written to the log
Scrub possibly sensitive information from the log, like phone numbers and UUIDs.
Doesn't work reliably on dbus logs with very verbose logging (`-vvv`)
-*--config* CONFIG::
-Set the path, where to store the config.
+*-d* DATA_DIR, *--data-dir* DATA_DIR, *-c* CONFIG, *--config* CONFIG::
+Set the path where to store account data and local configuration.
Make sure you have full read/write access to the given directory.
(Default: `$XDG_DATA_HOME/signal-cli` (`$HOME/.local/share/signal-cli`))
@@ -1172,10 +1172,25 @@ signal-cli -a ACCOUNT trust -a RECIPIENT
== Files
-The password and cryptographic keys are created when registering and stored in the current users home directory, the directory can be changed with *--config*:
+The password and cryptographic keys are created when registering and stored in the current users home directory, the directory can be changed with *--data-dir* (legacy *--config*):
`$XDG_DATA_HOME/signal-cli/` (`$HOME/.local/share/signal-cli/`)
+=== Configuration file
+
+signal-cli supports a JSON-based global configuration file that provides defaults for CLI options.
+Keys use camelCase and generally match the long CLI parameter names (for example `dataDir`, `verbose`, `logFile`, `serviceEnvironment`, `trustNewIdentities`, `output`, `disableSendLog`, `account`).
+
+Configuration files are read and merged in this order; later files override earlier ones:
+
+- `/etc/signal-cli/config.json` (system-wide defaults)
+- the path in the `SIGNAL_CLI_CONFIG` environment variable (if set)
+- `$XDG_CONFIG_HOME/signal-cli/config.json` (per-user; defaults to `$HOME/.config/signal-cli/config.json`)
+
+When multiple configuration files are present their settings are merged; values from later files override earlier values.
+Command-line options always take precedence over configuration file values.
+Overall precedence (highest → lowest): command-line options → per-user config → system config → built-in defaults.
+
== Authors
Maintained by AsamK , who is assisted by other open source contributors.
diff --git a/src/main/java/org/asamk/signal/App.java b/src/main/java/org/asamk/signal/App.java
index bcf3c605..d29cc2f8 100644
--- a/src/main/java/org/asamk/signal/App.java
+++ b/src/main/java/org/asamk/signal/App.java
@@ -46,7 +46,9 @@ public class App {
private final Namespace ns;
- static ArgumentParser buildArgumentParser() {
+ static ArgumentParser buildArgumentParser(GlobalConfig config) {
+ final var cfg = config == null ? GlobalConfig.DEFAULT : config;
+
var parser = ArgumentParsers.newFor("signal-cli", VERSION_0_9_0_DEFAULT_SETTINGS)
.includeArgumentNamesAsKeysInResult(true)
.build()
@@ -57,47 +59,60 @@ public class App {
parser.addArgument("--version").help("Show package version.").action(Arguments.version());
parser.addArgument("-v", "--verbose")
.help("Raise log level and include lib signal logs. Specify multiple times for even more logs.")
- .action(Arguments.count());
+ .action(Arguments.count())
+ .setDefault(cfg.verbose());
parser.addArgument("--log-file")
.type(File.class)
- .help("Write log output to the given file. If --verbose is also given, the detailed logs will only be written to the log file.");
+ .help("Write log output to the given file. If --verbose is also given, the detailed logs will only be written to the log file.")
+ .setDefault(cfg.logFile() == null ? null : new File(cfg.logFile()));
parser.addArgument("--scrub-log")
.action(Arguments.storeTrue())
- .help("Scrub possibly sensitive information from the log, like phone numbers and UUIDs.");
- parser.addArgument("-c", "--config")
- .help("Set the path, where to store the config (Default: $XDG_DATA_HOME/signal-cli , $HOME/.local/share/signal-cli).");
+ .help("Scrub possibly sensitive information from the log, like phone numbers and UUIDs.")
+ .setDefault(cfg.scrubLog());
+ parser.addArgument("-d", "--data-dir", "-c", "--config")
+ .help("Set the path where to store data (Default: $XDG_DATA_HOME/signal-cli , $HOME/.local/share/signal-cli).")
+ .setDefault(cfg.dataDir());
parser.addArgument("-a", "--account", "-u", "--username")
.help("Specify your phone number, that will be your identifier.");
var mut = parser.addMutuallyExclusiveGroup();
- mut.addArgument("--dbus").dest("global-dbus").help("Make request via user dbus.").action(Arguments.storeTrue());
+ mut.addArgument("--dbus")
+ .dest("global-dbus")
+ .help("Make request via user dbus.")
+ .action(Arguments.storeTrue())
+ .setDefault(cfg.dbus());
mut.addArgument("--dbus-system")
.dest("global-dbus-system")
.help("Make request via system dbus.")
- .action(Arguments.storeTrue());
+ .action(Arguments.storeTrue())
+ .setDefault(cfg.dbusSystem());
parser.addArgument("--bus-name")
.dest("global-bus-name")
- .setDefault(DbusConfig.getBusname())
+ .setDefault(cfg.busName() != null ? cfg.busName() : DbusConfig.getBusname())
.help("Specify the D-Bus bus name to connect to.");
parser.addArgument("-o", "--output")
.help("Choose to output in plain text or JSON")
- .type(Arguments.enumStringType(OutputType.class));
+ .type(Arguments.enumStringType(OutputType.class))
+ .setDefault(cfg.output() == null ? null : cfg.output());
parser.addArgument("--service-environment")
.help("Choose the server environment to use.")
.type(Arguments.enumStringType(ServiceEnvironmentCli.class))
- .setDefault(ServiceEnvironmentCli.LIVE);
+ .setDefault(cfg.serviceEnvironment() != null ? cfg.serviceEnvironment() : ServiceEnvironmentCli.LIVE);
parser.addArgument("--trust-new-identities")
.help("Choose when to trust new identities.")
.type(Arguments.enumStringType(TrustNewIdentityCli.class))
- .setDefault(TrustNewIdentityCli.ON_FIRST_USE);
+ .setDefault(cfg.trustNewIdentities() != null
+ ? cfg.trustNewIdentities()
+ : TrustNewIdentityCli.ON_FIRST_USE);
parser.addArgument("--disable-send-log")
.help("Disable message send log (for resending messages that recipient couldn't decrypt)")
- .action(Arguments.storeTrue());
+ .action(Arguments.storeTrue())
+ .setDefault(cfg.disableSendLog());
parser.epilog(
"The global arguments are shown with 'signal-cli -h' and need to come before the subcommand, while the subcommand-specific arguments (shown with 'signal-cli SUBCOMMAND -h') need to be given after the subcommand.");
@@ -219,12 +234,12 @@ public class App {
}
private SignalAccountFiles loadSignalAccountFiles() throws IOErrorException {
- final File configPath;
- final var config = ns.getString("config");
- if (config != null) {
- configPath = new File(config);
+ final File dataPath;
+ final var dataDir = ns.getString("data-dir");
+ if (dataDir != null) {
+ dataPath = new File(dataDir);
} else {
- configPath = getDefaultConfigPath();
+ dataPath = getDefaultDataPath();
}
final var serviceEnvironmentCli = ns.get("service-environment");
@@ -240,7 +255,7 @@ public class App {
final var disableSendLog = Boolean.TRUE.equals(ns.getBoolean("disable-send-log"));
try {
- return new SignalAccountFiles(configPath,
+ return new SignalAccountFiles(dataPath,
serviceEnvironment,
BaseConfig.USER_AGENT,
new Settings(trustNewIdentity, disableSendLog));
@@ -339,7 +354,7 @@ public class App {
/**
* @return the default data directory to be used by signal-cli.
*/
- private static File getDefaultConfigPath() {
+ private static File getDefaultDataPath() {
return new File(IOUtils.getDataHomeDir(), "signal-cli");
}
}
diff --git a/src/main/java/org/asamk/signal/BaseConfig.java b/src/main/java/org/asamk/signal/BaseConfig.java
index 7a35165b..da2e5422 100644
--- a/src/main/java/org/asamk/signal/BaseConfig.java
+++ b/src/main/java/org/asamk/signal/BaseConfig.java
@@ -8,7 +8,7 @@ public class BaseConfig {
public static final String PROJECT_VERSION = BaseConfig.class.getPackage().getImplementationVersion();
static final String USER_AGENT_SIGNAL_ANDROID = Optional.ofNullable(System.getenv("SIGNAL_CLI_USER_AGENT"))
- .orElse("Signal-Android/8.8.0");
+ .orElse("Signal-Android/8.12.1");
static final String USER_AGENT_SIGNAL_CLI = PROJECT_NAME == null
? "signal-cli"
: PROJECT_NAME + "/" + PROJECT_VERSION;
diff --git a/src/main/java/org/asamk/signal/ConfigLoader.java b/src/main/java/org/asamk/signal/ConfigLoader.java
new file mode 100644
index 00000000..861de2e0
--- /dev/null
+++ b/src/main/java/org/asamk/signal/ConfigLoader.java
@@ -0,0 +1,81 @@
+package org.asamk.signal;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+
+import org.asamk.signal.commands.exceptions.UserErrorException;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+/**
+ * Loads and merges configuration files. Merge order (later files override earlier):
+ * - /etc/signal-cli/config.json
+ * - file pointed to by SIGNAL_CLI_CONFIG (if set)
+ * - $XDG_CONFIG_HOME/signal-cli/config.json or $HOME/.config/signal-cli/config.json
+ */
+public final class ConfigLoader {
+
+ private ConfigLoader() {
+ }
+
+ public static GlobalConfig load() throws UserErrorException {
+ final ObjectMapper mapper = new ObjectMapper();
+ final ObjectNode merged = mapper.createObjectNode();
+
+ // System config
+ addIfExists(merged, mapper, Paths.get("/etc/signal-cli/config.json"));
+
+ // User config via env (if set) else XDG or ~/.config
+ final String env = System.getenv("SIGNAL_CLI_CONFIG");
+ if (env != null && !env.isEmpty()) {
+ addIfExists(merged, mapper, Paths.get(env));
+ } else {
+ final String xdg = System.getenv("XDG_CONFIG_HOME");
+ if (xdg != null && !xdg.isEmpty()) {
+ addIfExists(merged, mapper, Paths.get(xdg, "signal-cli", "config.json"));
+ } else {
+ addIfExists(merged,
+ mapper,
+ Paths.get(System.getProperty("user.home"), ".config", "signal-cli", "config.json"));
+ }
+ }
+
+ try {
+ if (merged.isEmpty()) {
+ return GlobalConfig.DEFAULT;
+ }
+ return mapper.treeToValue(merged, GlobalConfig.class);
+ } catch (Exception e) {
+ throw new UserErrorException("Failed to parse configuration file(s): " + e.getMessage(), e);
+ }
+ }
+
+ private static void addIfExists(ObjectNode merged, ObjectMapper mapper, Path p) throws UserErrorException {
+ if (p == null) return;
+ try {
+ if (Files.exists(p)) {
+ final JsonNode node = mapper.readTree(p.toFile());
+ merge(merged, node);
+ }
+ } catch (IOException e) {
+ throw new UserErrorException("Failed to load config from " + p + ": " + e.getMessage(), e);
+ }
+ }
+
+ private static void merge(ObjectNode target, JsonNode source) {
+ source.properties().forEach(entry -> {
+ final String name = entry.getKey();
+ final JsonNode value = entry.getValue();
+ final JsonNode existing = target.get(name);
+ if (existing != null && existing.isObject() && value.isObject()) {
+ merge((ObjectNode) existing, value);
+ } else {
+ target.set(name, value);
+ }
+ });
+ }
+}
diff --git a/src/main/java/org/asamk/signal/GlobalConfig.java b/src/main/java/org/asamk/signal/GlobalConfig.java
new file mode 100644
index 00000000..1205df7c
--- /dev/null
+++ b/src/main/java/org/asamk/signal/GlobalConfig.java
@@ -0,0 +1,36 @@
+package org.asamk.signal;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+public record GlobalConfig(
+ @JsonProperty("verbose") Integer verbose,
+ @JsonProperty("logFile") String logFile,
+ @JsonProperty("scrubLog") Boolean scrubLog,
+ @JsonProperty("dataDir") String dataDir,
+ @JsonProperty("busName") String busName,
+ @JsonProperty("dbus") Boolean dbus,
+ @JsonProperty("dbusSystem") Boolean dbusSystem,
+ @JsonProperty("output") OutputType output,
+ @JsonProperty("serviceEnvironment") ServiceEnvironmentCli serviceEnvironment,
+ @JsonProperty("trustNewIdentities") TrustNewIdentityCli trustNewIdentities,
+ @JsonProperty("disableSendLog") Boolean disableSendLog,
+ @JsonProperty("account") String account
+) {
+
+ public static final GlobalConfig DEFAULT = new GlobalConfig(null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ ServiceEnvironmentCli.LIVE,
+ TrustNewIdentityCli.ON_FIRST_USE,
+ null,
+ null);
+
+ public static GlobalConfig empty() {
+ return new GlobalConfig(null, null, null, null, null, null, null, null, null, null, null, null);
+ }
+}
diff --git a/src/main/java/org/asamk/signal/Main.java b/src/main/java/org/asamk/signal/Main.java
index 568f58c6..800b0ee6 100644
--- a/src/main/java/org/asamk/signal/Main.java
+++ b/src/main/java/org/asamk/signal/Main.java
@@ -40,27 +40,32 @@ import java.security.Security;
public class Main {
- public static void main(String[] args) {
+ static void main(String[] args) {
// enable unlimited strength crypto via Policy, supported on relevant JREs
Security.setProperty("crypto.policy", "unlimited");
installSecurityProviderWorkaround();
+ // Load global config early so we can use its values as parser defaults
+ final GlobalConfig globalConfig;
+ try {
+ globalConfig = ConfigLoader.load();
+ } catch (UserErrorException e) {
+ System.exit(handleCommandException(e, null));
+ return;
+ }
+
// Configuring the logger needs to happen before any logger is initialized
- final var loggingConfig = parseLoggingConfig(args);
+ final var loggingConfig = parseLoggingConfig(args, globalConfig);
configureLogging(loggingConfig);
- final var parser = App.buildArgumentParser();
+ final var parser = App.buildArgumentParser(globalConfig);
final var ns = parser.parseArgsOrFail(args);
int status = 0;
try {
new App(ns).init();
} catch (CommandException e) {
- System.err.println(e.getMessage());
- if (loggingConfig.verboseLevel > 0 && e.getCause() != null) {
- e.getCause().printStackTrace(System.err);
- }
- status = getStatusForError(e);
+ status = handleCommandException(e, loggingConfig);
} catch (Throwable e) {
e.printStackTrace(System.err);
status = 2;
@@ -69,16 +74,27 @@ public class Main {
System.exit(status);
}
+ private static int handleCommandException(final CommandException e, final LoggingConfig loggingConfig) {
+ System.err.println(e.getMessage());
+ if (loggingConfig != null && loggingConfig.verboseLevel > 0 && e.getCause() != null) {
+ e.getCause().printStackTrace(System.err);
+ }
+ return getStatusForError(e);
+ }
+
private static void installSecurityProviderWorkaround() {
// Register our own security provider
Security.insertProviderAt(new SecurityProvider(), 1);
Security.addProvider(new BouncyCastleProvider());
}
- private static LoggingConfig parseLoggingConfig(final String[] args) {
- final var nsLog = parseArgs(args);
+ private static LoggingConfig parseLoggingConfig(final String[] args, final GlobalConfig config) {
+ final var nsLog = parseArgs(args, config);
if (nsLog == null) {
- return new LoggingConfig(0, null, false);
+ final var verbose = config != null && config.verbose() != null ? config.verbose() : 0;
+ final var logFile = config != null && config.logFile() != null ? new File(config.logFile()) : null;
+ final var scrubLog = config != null && Boolean.TRUE.equals(config.scrubLog());
+ return new LoggingConfig(verbose, logFile, scrubLog);
}
final var verboseLevel = nsLog.getInt("verbose");
@@ -90,14 +106,20 @@ public class Main {
/**
* This method only parses commandline args relevant for logging configuration.
*/
- private static Namespace parseArgs(String[] args) {
+ private static Namespace parseArgs(String[] args, final GlobalConfig config) {
var parser = ArgumentParsers.newFor("signal-cli", DefaultSettings.VERSION_0_9_0_DEFAULT_SETTINGS)
.includeArgumentNamesAsKeysInResult(true)
.build()
.defaultHelp(false);
- parser.addArgument("-v", "--verbose").action(Arguments.count());
- parser.addArgument("--log-file").type(File.class);
- parser.addArgument("--scrub-log").action(Arguments.storeTrue());
+ parser.addArgument("-v", "--verbose")
+ .action(Arguments.count())
+ .setDefault(config == null ? null : config.verbose());
+ parser.addArgument("--log-file")
+ .type(File.class)
+ .setDefault(config == null || config.logFile() == null ? null : new File(config.logFile()));
+ parser.addArgument("--scrub-log")
+ .action(Arguments.storeTrue())
+ .setDefault(config == null ? null : config.scrubLog());
try {
return parser.parseKnownArgs(args, null);
@@ -124,12 +146,12 @@ public class Main {
private static int getStatusForError(final CommandException e) {
return switch (e) {
- case UserErrorException userErrorException -> 1;
- case UnexpectedErrorException unexpectedErrorException -> 2;
- case IOErrorException ioErrorException -> 3;
- case UntrustedKeyErrorException untrustedKeyErrorException -> 4;
- case RateLimitErrorException rateLimitErrorException -> 5;
- case CaptchaRejectedErrorException captchaRejectedErrorException -> 6;
+ case UserErrorException _ -> 1;
+ case UnexpectedErrorException _ -> 2;
+ case IOErrorException _ -> 3;
+ case UntrustedKeyErrorException _ -> 4;
+ case RateLimitErrorException _ -> 5;
+ case CaptchaRejectedErrorException _ -> 6;
case null -> 2;
};
}
diff --git a/src/main/java/org/asamk/signal/OutputType.java b/src/main/java/org/asamk/signal/OutputType.java
index 383d635f..2da6b5db 100644
--- a/src/main/java/org/asamk/signal/OutputType.java
+++ b/src/main/java/org/asamk/signal/OutputType.java
@@ -1,5 +1,7 @@
package org.asamk.signal;
+import com.fasterxml.jackson.annotation.JsonCreator;
+
public enum OutputType {
PLAIN_TEXT {
@Override
@@ -12,5 +14,16 @@ public enum OutputType {
public String toString() {
return "json";
}
- },
+ };
+
+ @JsonCreator
+ public static OutputType fromString(String value) {
+ if (value == null) return null;
+ final var norm = value.trim().toLowerCase().replaceAll("[^a-z0-9]", "");
+ return switch (norm) {
+ case "plaintext" -> PLAIN_TEXT;
+ case "json" -> JSON;
+ default -> throw new IllegalArgumentException("Invalid output type: " + value);
+ };
+ }
}
diff --git a/src/main/java/org/asamk/signal/ServiceEnvironmentCli.java b/src/main/java/org/asamk/signal/ServiceEnvironmentCli.java
index 9010f3f8..f61a6833 100644
--- a/src/main/java/org/asamk/signal/ServiceEnvironmentCli.java
+++ b/src/main/java/org/asamk/signal/ServiceEnvironmentCli.java
@@ -1,5 +1,7 @@
package org.asamk.signal;
+import com.fasterxml.jackson.annotation.JsonCreator;
+
public enum ServiceEnvironmentCli {
LIVE {
@Override
@@ -12,5 +14,16 @@ public enum ServiceEnvironmentCli {
public String toString() {
return "staging";
}
- },
+ };
+
+ @JsonCreator
+ public static ServiceEnvironmentCli fromString(String value) {
+ if (value == null) return null;
+ final var norm = value.trim().toLowerCase();
+ return switch (norm) {
+ case "live" -> LIVE;
+ case "staging" -> STAGING;
+ default -> throw new IllegalArgumentException("Invalid service-environment: " + value);
+ };
+ }
}
diff --git a/src/main/java/org/asamk/signal/TrustNewIdentityCli.java b/src/main/java/org/asamk/signal/TrustNewIdentityCli.java
index 5cc36bbd..665ab90b 100644
--- a/src/main/java/org/asamk/signal/TrustNewIdentityCli.java
+++ b/src/main/java/org/asamk/signal/TrustNewIdentityCli.java
@@ -1,5 +1,7 @@
package org.asamk.signal;
+import com.fasterxml.jackson.annotation.JsonCreator;
+
public enum TrustNewIdentityCli {
ALWAYS {
@Override
@@ -18,5 +20,17 @@ public enum TrustNewIdentityCli {
public String toString() {
return "never";
}
- },
+ };
+
+ @JsonCreator
+ public static TrustNewIdentityCli fromString(String value) {
+ if (value == null) return null;
+ final var norm = value.trim().toLowerCase().replaceAll("[^a-z0-9]", "");
+ return switch (norm) {
+ case "always" -> ALWAYS;
+ case "onfirstuse" -> ON_FIRST_USE;
+ case "never" -> NEVER;
+ default -> throw new IllegalArgumentException("Invalid trust-new-identities: " + value);
+ };
+ }
}
diff --git a/src/main/java/org/asamk/signal/http/HttpServerHandler.java b/src/main/java/org/asamk/signal/http/HttpServerHandler.java
index 325b0eec..551c5b04 100644
--- a/src/main/java/org/asamk/signal/http/HttpServerHandler.java
+++ b/src/main/java/org/asamk/signal/http/HttpServerHandler.java
@@ -19,8 +19,11 @@ import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.net.InetSocketAddress;
+import java.util.HashSet;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
+import java.util.Set;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -37,12 +40,14 @@ public class HttpServerHandler implements AutoCloseable {
private final Manager m;
private HttpServer server;
private final AtomicBoolean shutdown = new AtomicBoolean(false);
+ private final Set allowedHosts;
public HttpServerHandler(final InetSocketAddress address, final Manager m) {
this.address = address;
commandHandler = new SignalJsonRpcCommandHandler(m, Commands::getCommand);
this.c = null;
this.m = m;
+ this.allowedHosts = buildAllowedHosts(address);
}
public HttpServerHandler(final InetSocketAddress address, final MultiAccountManager c) {
@@ -50,6 +55,7 @@ public class HttpServerHandler implements AutoCloseable {
commandHandler = new SignalJsonRpcCommandHandler(c, Commands::getCommand);
this.c = c;
this.m = null;
+ this.allowedHosts = buildAllowedHosts(address);
}
public void init() throws IOException {
@@ -67,6 +73,7 @@ public class HttpServerHandler implements AutoCloseable {
server.start();
logger.info("Started HTTP server on {}", address);
+ logger.warn("HTTP server has no authentication; Host header is pinned to {}", allowedHosts);
}
@Override
@@ -99,6 +106,12 @@ public class HttpServerHandler implements AutoCloseable {
}
private void handleRpcEndpoint(HttpExchange httpExchange) throws IOException {
+ if (!isHostAllowed(httpExchange)) {
+ logger.warn("Rejected RPC request with invalid Host header: {} from {}",
+ httpExchange.getRequestHeaders().getFirst("Host"), httpExchange.getRemoteAddress());
+ sendResponse(421, null, httpExchange);
+ return;
+ }
if (!"/api/v1/rpc".equals(httpExchange.getRequestURI().getPath())) {
sendResponse(404, null, httpExchange);
return;
@@ -146,6 +159,12 @@ public class HttpServerHandler implements AutoCloseable {
}
private void handleEventsEndpoint(HttpExchange httpExchange) throws IOException {
+ if (!isHostAllowed(httpExchange)) {
+ logger.warn("Rejected Events request with invalid Host header: {} from {}",
+ httpExchange.getRequestHeaders().getFirst("Host"), httpExchange.getRemoteAddress());
+ sendResponse(421, null, httpExchange);
+ return;
+ }
if (!"/api/v1/events".equals(httpExchange.getRequestURI().getPath())) {
sendResponse(404, null, httpExchange);
return;
@@ -273,4 +292,59 @@ public class HttpServerHandler implements AutoCloseable {
void call();
}
+
+ private Set buildAllowedHosts(final InetSocketAddress address) {
+ final var s = new HashSet();
+ final var host = address == null ? null : address.getHostString();
+ if (host != null && !host.isEmpty()) {
+ s.add(host.toLowerCase(Locale.ROOT));
+ }
+ s.add("localhost");
+ s.add("127.0.0.1");
+ s.add("::1");
+ return s;
+ }
+
+ private boolean isHostAllowed(final HttpExchange httpExchange) {
+ final var hostHeader = httpExchange.getRequestHeaders().getFirst("Host");
+ if (hostHeader == null || hostHeader.isEmpty()) {
+ return false;
+ }
+
+ String hostPart = hostHeader;
+ String portPart = null;
+ if (hostHeader.startsWith("[")) {
+ final var idx = hostHeader.indexOf(']');
+ if (idx == -1) return false;
+ hostPart = hostHeader.substring(1, idx);
+ if (hostHeader.length() > idx + 1 && hostHeader.charAt(idx + 1) == ':') {
+ portPart = hostHeader.substring(idx + 2);
+ }
+ } else {
+ final var colon = hostHeader.lastIndexOf(':');
+ if (colon != -1) {
+ final var possiblePort = hostHeader.substring(colon + 1);
+ if (possiblePort.chars().allMatch(Character::isDigit)) {
+ hostPart = hostHeader.substring(0, colon);
+ portPart = possiblePort;
+ }
+ }
+ }
+
+ hostPart = hostPart.toLowerCase(Locale.ROOT);
+ if (!allowedHosts.contains(hostPart)) {
+ return false;
+ }
+
+ if (portPart != null) {
+ try {
+ final var port = Integer.parseInt(portPart);
+ if (port != address.getPort()) return false;
+ } catch (NumberFormatException e) {
+ return false;
+ }
+ }
+
+ return true;
+ }
}
diff --git a/src/main/resources/META-INF/native-image/org.asamk/signal-cli/reachability-metadata.json b/src/main/resources/META-INF/native-image/org.asamk/signal-cli/reachability-metadata.json
index 4513328b..cf17ea05 100644
--- a/src/main/resources/META-INF/native-image/org.asamk/signal-cli/reachability-metadata.json
+++ b/src/main/resources/META-INF/native-image/org.asamk/signal-cli/reachability-metadata.json
@@ -1317,6 +1317,14 @@
}
]
},
+ {
+ "type": "java.util.concurrent.CopyOnWriteArrayList",
+ "fields": [
+ {
+ "name": "lock"
+ }
+ ]
+ },
{
"type": "java.util.concurrent.ForkJoinTask",
"fields": [
@@ -1979,6 +1987,28 @@
{
"type": "org.asamk.SignalControl.Error.InvalidNumber"
},
+ {
+ "type": "org.asamk.signal.GlobalConfig",
+ "methods": [
+ {
+ "name": "",
+ "parameterTypes": [
+ "java.lang.Integer",
+ "java.lang.String",
+ "java.lang.Boolean",
+ "java.lang.String",
+ "java.lang.String",
+ "java.lang.Boolean",
+ "java.lang.Boolean",
+ "org.asamk.signal.OutputType",
+ "org.asamk.signal.ServiceEnvironmentCli",
+ "org.asamk.signal.TrustNewIdentityCli",
+ "java.lang.Boolean",
+ "java.lang.String"
+ ]
+ }
+ ]
+ },
{
"type": "org.asamk.signal.Main",
"jniAccessible": true,
@@ -1995,6 +2025,31 @@
}
]
},
+ {
+ "type": "org.asamk.signal.OutputType"
+ },
+ {
+ "type": "org.asamk.signal.ServiceEnvironmentCli",
+ "methods": [
+ {
+ "name": "fromString",
+ "parameterTypes": [
+ "java.lang.String"
+ ]
+ }
+ ]
+ },
+ {
+ "type": "org.asamk.signal.TrustNewIdentityCli",
+ "methods": [
+ {
+ "name": "fromString",
+ "parameterTypes": [
+ "java.lang.String"
+ ]
+ }
+ ]
+ },
{
"type": "org.asamk.signal.commands.AcceptCallCommand$JsonCallInfo",
"allDeclaredFields": true,
@@ -6488,6 +6543,33 @@
"type": "org.signal.libsignal.zkgroup.profiles.ProfileKey",
"allDeclaredFields": true
},
+ {
+ "type": "org.signal.network.api.SenderCertificate",
+ "methods": [
+ {
+ "name": "",
+ "parameterTypes": []
+ },
+ {
+ "name": "setCertificate",
+ "parameterTypes": [
+ "byte[]"
+ ]
+ }
+ ]
+ },
+ {
+ "type": "org.signal.network.api.SenderCertificate$ByteArrayDeserializer",
+ "methods": [
+ {
+ "name": "",
+ "parameterTypes": []
+ }
+ ]
+ },
+ {
+ "type": "org.signal.network.api.SenderCertificate$ByteArraySerializer"
+ },
{
"type": "org.signal.storageservice.storage.protos.groups.AccessControl",
"fields": [
diff --git a/src/test/java/org/asamk/signal/http/SseInitialFlushTest.java b/src/test/java/org/asamk/signal/http/SseInitialFlushTest.java
index 4796ae5d..d62c5220 100644
--- a/src/test/java/org/asamk/signal/http/SseInitialFlushTest.java
+++ b/src/test/java/org/asamk/signal/http/SseInitialFlushTest.java
@@ -39,7 +39,7 @@ import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
-import java.net.URL;
+import java.net.URI;
import java.time.Duration;
import java.util.Collection;
import java.util.List;
@@ -97,7 +97,7 @@ class SseInitialFlushTest {
@Test
void sseEndpointReturnsHeadersWithinTwoSeconds() {
assertDoesNotThrow(() -> {
- var url = new URL("http://127.0.0.1:" + port + "/api/v1/events");
+ var url = new URI("http", null, "127.0.0.1", port, "/api/v1/events", null, null).toURL();
var conn = (HttpURLConnection) url.openConnection();
conn.setRequestProperty("Accept", "text/event-stream");
conn.setReadTimeout(2_000); // 2 s — fails before fix (15 s flush), passes after