/*
Copyright (C) 2015-2021 AsamK and contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package org.asamk.signal.manager;
import org.asamk.signal.manager.actions.HandleAction;
import org.asamk.signal.manager.api.Device;
import org.asamk.signal.manager.api.Group;
import org.asamk.signal.manager.api.Identity;
import org.asamk.signal.manager.api.Message;
import org.asamk.signal.manager.api.RecipientIdentifier;
import org.asamk.signal.manager.api.SendGroupMessageResults;
import org.asamk.signal.manager.api.SendMessageResults;
import org.asamk.signal.manager.api.TypingAction;
import org.asamk.signal.manager.config.ServiceConfig;
import org.asamk.signal.manager.config.ServiceEnvironmentConfig;
import org.asamk.signal.manager.groups.GroupId;
import org.asamk.signal.manager.groups.GroupInviteLinkUrl;
import org.asamk.signal.manager.groups.GroupLinkState;
import org.asamk.signal.manager.groups.GroupNotFoundException;
import org.asamk.signal.manager.groups.GroupPermission;
import org.asamk.signal.manager.groups.GroupSendingNotAllowedException;
import org.asamk.signal.manager.groups.LastGroupAdminException;
import org.asamk.signal.manager.groups.NotAGroupMemberException;
import org.asamk.signal.manager.helper.AttachmentHelper;
import org.asamk.signal.manager.helper.ContactHelper;
import org.asamk.signal.manager.helper.GroupHelper;
import org.asamk.signal.manager.helper.GroupV2Helper;
import org.asamk.signal.manager.helper.IncomingMessageHandler;
import org.asamk.signal.manager.helper.PinHelper;
import org.asamk.signal.manager.helper.PreKeyHelper;
import org.asamk.signal.manager.helper.ProfileHelper;
import org.asamk.signal.manager.helper.SendHelper;
import org.asamk.signal.manager.helper.StorageHelper;
import org.asamk.signal.manager.helper.SyncHelper;
import org.asamk.signal.manager.helper.UnidentifiedAccessHelper;
import org.asamk.signal.manager.jobs.Context;
import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.storage.groups.GroupInfo;
import org.asamk.signal.manager.storage.identities.IdentityInfo;
import org.asamk.signal.manager.storage.messageCache.CachedMessage;
import org.asamk.signal.manager.storage.recipients.Contact;
import org.asamk.signal.manager.storage.recipients.Profile;
import org.asamk.signal.manager.storage.recipients.RecipientAddress;
import org.asamk.signal.manager.storage.recipients.RecipientId;
import org.asamk.signal.manager.storage.stickers.Sticker;
import org.asamk.signal.manager.storage.stickers.StickerPackId;
import org.asamk.signal.manager.util.KeyUtils;
import org.asamk.signal.manager.util.StickerUtils;
import org.asamk.signal.manager.util.Utils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.libsignal.IdentityKey;
import org.whispersystems.libsignal.InvalidKeyException;
import org.whispersystems.libsignal.ecc.ECPublicKey;
import org.whispersystems.libsignal.fingerprint.Fingerprint;
import org.whispersystems.libsignal.fingerprint.FingerprintParsingException;
import org.whispersystems.libsignal.fingerprint.FingerprintVersionMismatchException;
import org.whispersystems.libsignal.util.Pair;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.SignalSessionLock;
import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException;
import org.whispersystems.signalservice.api.messages.SendMessageResult;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId;
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope;
import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException;
import org.whispersystems.signalservice.api.util.DeviceNameUtil;
import org.whispersystems.signalservice.api.util.InvalidNumberException;
import org.whispersystems.signalservice.api.util.PhoneNumberFormatter;
import org.whispersystems.signalservice.api.websocket.WebSocketUnavailableException;
import org.whispersystems.signalservice.internal.contacts.crypto.Quote;
import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedQuoteException;
import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedResponseException;
import org.whispersystems.signalservice.internal.util.DynamicCredentialsProvider;
import org.whispersystems.signalservice.internal.util.Hex;
import org.whispersystems.signalservice.internal.util.Util;
import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.SignatureException;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Function;
import java.util.stream.Collectors;
import static org.asamk.signal.manager.config.ServiceConfig.capabilities;
public class ManagerImpl implements Manager {
private final static Logger logger = LoggerFactory.getLogger(ManagerImpl.class);
private final ServiceEnvironmentConfig serviceEnvironmentConfig;
private final SignalDependencies dependencies;
private SignalAccount account;
private final ExecutorService executor = Executors.newCachedThreadPool();
private final ProfileHelper profileHelper;
private final PinHelper pinHelper;
private final StorageHelper storageHelper;
private final SendHelper sendHelper;
private final SyncHelper syncHelper;
private final AttachmentHelper attachmentHelper;
private final GroupHelper groupHelper;
private final ContactHelper contactHelper;
private final IncomingMessageHandler incomingMessageHandler;
private final PreKeyHelper preKeyHelper;
private final Context context;
private boolean hasCaughtUpWithOldMessages = false;
ManagerImpl(
SignalAccount account,
PathConfig pathConfig,
ServiceEnvironmentConfig serviceEnvironmentConfig,
String userAgent
) {
this.account = account;
this.serviceEnvironmentConfig = serviceEnvironmentConfig;
final var credentialsProvider = new DynamicCredentialsProvider(account.getUuid(),
account.getUsername(),
account.getPassword(),
account.getDeviceId());
final var sessionLock = new SignalSessionLock() {
private final ReentrantLock LEGACY_LOCK = new ReentrantLock();
@Override
public Lock acquire() {
LEGACY_LOCK.lock();
return LEGACY_LOCK::unlock;
}
};
this.dependencies = new SignalDependencies(serviceEnvironmentConfig,
userAgent,
credentialsProvider,
account.getSignalProtocolStore(),
executor,
sessionLock);
final var avatarStore = new AvatarStore(pathConfig.getAvatarsPath());
final var attachmentStore = new AttachmentStore(pathConfig.getAttachmentsPath());
final var stickerPackStore = new StickerPackStore(pathConfig.getStickerPacksPath());
this.attachmentHelper = new AttachmentHelper(dependencies, attachmentStore);
this.pinHelper = new PinHelper(dependencies.getKeyBackupService());
final var unidentifiedAccessHelper = new UnidentifiedAccessHelper(account::getProfileKey,
account.getProfileStore()::getProfileKey,
this::getRecipientProfile,
this::getSenderCertificate);
this.profileHelper = new ProfileHelper(account,
dependencies,
avatarStore,
account.getProfileStore()::getProfileKey,
unidentifiedAccessHelper::getAccessFor,
this::resolveSignalServiceAddress);
final GroupV2Helper groupV2Helper = new GroupV2Helper(profileHelper::getRecipientProfileKeyCredential,
this::getRecipientProfile,
account::getSelfRecipientId,
dependencies.getGroupsV2Operations(),
dependencies.getGroupsV2Api(),
this::resolveSignalServiceAddress);
this.sendHelper = new SendHelper(account,
dependencies,
unidentifiedAccessHelper,
this::resolveSignalServiceAddress,
account.getRecipientStore(),
this::handleIdentityFailure,
this::getGroupInfo,
this::refreshRegisteredUser);
this.groupHelper = new GroupHelper(account,
dependencies,
attachmentHelper,
sendHelper,
groupV2Helper,
avatarStore,
this::resolveSignalServiceAddress,
account.getRecipientStore());
this.storageHelper = new StorageHelper(account, dependencies, groupHelper);
this.contactHelper = new ContactHelper(account);
this.syncHelper = new SyncHelper(account,
attachmentHelper,
sendHelper,
groupHelper,
avatarStore,
this::resolveSignalServiceAddress);
preKeyHelper = new PreKeyHelper(account, dependencies);
this.context = new Context(account,
dependencies,
stickerPackStore,
sendHelper,
groupHelper,
syncHelper,
profileHelper,
storageHelper,
preKeyHelper);
var jobExecutor = new JobExecutor(context);
this.incomingMessageHandler = new IncomingMessageHandler(account,
dependencies,
account.getRecipientStore(),
this::resolveSignalServiceAddress,
groupHelper,
contactHelper,
attachmentHelper,
syncHelper,
this::getRecipientProfile,
jobExecutor);
}
@Override
public String getSelfNumber() {
return account.getUsername();
}
@Override
public void checkAccountState() throws IOException {
if (account.getLastReceiveTimestamp() == 0) {
logger.info("The Signal protocol expects that incoming messages are regularly received.");
} else {
var diffInMilliseconds = System.currentTimeMillis() - account.getLastReceiveTimestamp();
long days = TimeUnit.DAYS.convert(diffInMilliseconds, TimeUnit.MILLISECONDS);
if (days > 7) {
logger.warn(
"Messages have been last received {} days ago. The Signal protocol expects that incoming messages are regularly received.",
days);
}
}
preKeyHelper.refreshPreKeysIfNecessary();
if (account.getUuid() == null) {
account.setUuid(dependencies.getAccountManager().getOwnUuid());
}
updateAccountAttributes(null);
}
/**
* This is used for checking a set of phone numbers for registration on Signal
*
* @param numbers The set of phone number in question
* @return A map of numbers to canonicalized number and uuid. If a number is not registered the uuid is null.
* @throws IOException if its unable to get the contacts to check if they're registered
*/
@Override
public Map> areUsersRegistered(Set numbers) throws IOException {
Map canonicalizedNumbers = numbers.stream().collect(Collectors.toMap(n -> n, n -> {
try {
return PhoneNumberFormatter.formatNumber(n, account.getUsername());
} catch (InvalidNumberException e) {
return "";
}
}));
// Note "registeredUsers" has no optionals. It only gives us info on users who are registered
var registeredUsers = getRegisteredUsers(canonicalizedNumbers.values()
.stream()
.filter(s -> !s.isEmpty())
.collect(Collectors.toSet()));
return numbers.stream().collect(Collectors.toMap(n -> n, n -> {
final var number = canonicalizedNumbers.get(n);
final var uuid = registeredUsers.get(number);
return new Pair<>(number.isEmpty() ? null : number, uuid);
}));
}
@Override
public void updateAccountAttributes(String deviceName) throws IOException {
final String encryptedDeviceName;
if (deviceName == null) {
encryptedDeviceName = account.getEncryptedDeviceName();
} else {
final var privateKey = account.getIdentityKeyPair().getPrivateKey();
encryptedDeviceName = DeviceNameUtil.encryptDeviceName(deviceName, privateKey);
account.setEncryptedDeviceName(encryptedDeviceName);
}
dependencies.getAccountManager()
.setAccountAttributes(encryptedDeviceName,
null,
account.getLocalRegistrationId(),
true,
null,
account.getPinMasterKey() == null ? null : account.getPinMasterKey().deriveRegistrationLock(),
account.getSelfUnidentifiedAccessKey(),
account.isUnrestrictedUnidentifiedAccess(),
capabilities,
account.isDiscoverableByPhoneNumber());
}
@Override
public void updateConfiguration(
final Boolean readReceipts,
final Boolean unidentifiedDeliveryIndicators,
final Boolean typingIndicators,
final Boolean linkPreviews
) throws IOException, NotMasterDeviceException {
if (!account.isMasterDevice()) {
throw new NotMasterDeviceException();
}
if (readReceipts != null) {
account.getConfigurationStore().setReadReceipts(readReceipts);
}
if (unidentifiedDeliveryIndicators != null) {
account.getConfigurationStore().setUnidentifiedDeliveryIndicators(unidentifiedDeliveryIndicators);
}
if (typingIndicators != null) {
account.getConfigurationStore().setTypingIndicators(typingIndicators);
}
if (linkPreviews != null) {
account.getConfigurationStore().setLinkPreviews(linkPreviews);
}
syncHelper.sendConfigurationMessage();
}
/**
* @param givenName if null, the previous givenName will be kept
* @param familyName if null, the previous familyName will be kept
* @param about if null, the previous about text will be kept
* @param aboutEmoji if null, the previous about emoji will be kept
* @param avatar if avatar is null the image from the local avatar store is used (if present),
*/
@Override
public void setProfile(
String givenName, final String familyName, String about, String aboutEmoji, Optional avatar
) throws IOException {
profileHelper.setProfile(givenName, familyName, about, aboutEmoji, avatar);
syncHelper.sendSyncFetchProfileMessage();
}
@Override
public void unregister() throws IOException {
// When setting an empty GCM id, the Signal-Server also sets the fetchesMessages property to false.
// If this is the master device, other users can't send messages to this number anymore.
// If this is a linked device, other users can still send messages, but this device doesn't receive them anymore.
dependencies.getAccountManager().setGcmId(Optional.absent());
account.setRegistered(false);
}
@Override
public void deleteAccount() throws IOException {
try {
pinHelper.removeRegistrationLockPin();
} catch (UnauthenticatedResponseException e) {
logger.warn("Failed to remove registration lock pin");
}
account.setRegistrationLockPin(null, null);
dependencies.getAccountManager().deleteAccount();
account.setRegistered(false);
}
@Override
public void submitRateLimitRecaptchaChallenge(String challenge, String captcha) throws IOException {
dependencies.getAccountManager().submitRateLimitRecaptchaChallenge(challenge, captcha);
}
@Override
public List getLinkedDevices() throws IOException {
var devices = dependencies.getAccountManager().getDevices();
account.setMultiDevice(devices.size() > 1);
var identityKey = account.getIdentityKeyPair().getPrivateKey();
return devices.stream().map(d -> {
String deviceName = d.getName();
if (deviceName != null) {
try {
deviceName = DeviceNameUtil.decryptDeviceName(deviceName, identityKey);
} catch (IOException e) {
logger.debug("Failed to decrypt device name, maybe plain text?", e);
}
}
return new Device(d.getId(),
deviceName,
d.getCreated(),
d.getLastSeen(),
d.getId() == account.getDeviceId());
}).collect(Collectors.toList());
}
@Override
public void removeLinkedDevices(int deviceId) throws IOException {
dependencies.getAccountManager().removeDevice(deviceId);
var devices = dependencies.getAccountManager().getDevices();
account.setMultiDevice(devices.size() > 1);
}
@Override
public void addDeviceLink(URI linkUri) throws IOException, InvalidKeyException {
var info = DeviceLinkInfo.parseDeviceLinkUri(linkUri);
addDevice(info.deviceIdentifier, info.deviceKey);
}
private void addDevice(String deviceIdentifier, ECPublicKey deviceKey) throws IOException, InvalidKeyException {
var identityKeyPair = account.getIdentityKeyPair();
var verificationCode = dependencies.getAccountManager().getNewDeviceVerificationCode();
dependencies.getAccountManager()
.addDevice(deviceIdentifier,
deviceKey,
identityKeyPair,
Optional.of(account.getProfileKey().serialize()),
verificationCode);
account.setMultiDevice(true);
}
@Override
public void setRegistrationLockPin(Optional pin) throws IOException, UnauthenticatedResponseException {
if (!account.isMasterDevice()) {
throw new RuntimeException("Only master device can set a PIN");
}
if (pin.isPresent()) {
final var masterKey = account.getPinMasterKey() != null
? account.getPinMasterKey()
: KeyUtils.createMasterKey();
pinHelper.setRegistrationLockPin(pin.get(), masterKey);
account.setRegistrationLockPin(pin.get(), masterKey);
} else {
// Remove KBS Pin
pinHelper.removeRegistrationLockPin();
account.setRegistrationLockPin(null, null);
}
}
void refreshPreKeys() throws IOException {
preKeyHelper.refreshPreKeys();
}
@Override
public Profile getRecipientProfile(RecipientIdentifier.Single recipient) throws UnregisteredUserException {
return profileHelper.getRecipientProfile(resolveRecipient(recipient));
}
private Profile getRecipientProfile(RecipientId recipientId) {
return profileHelper.getRecipientProfile(recipientId);
}
@Override
public List getGroups() {
return account.getGroupStore().getGroups().stream().map(this::toGroup).collect(Collectors.toList());
}
private Group toGroup(final GroupInfo groupInfo) {
if (groupInfo == null) {
return null;
}
return new Group(groupInfo.getGroupId(),
groupInfo.getTitle(),
groupInfo.getDescription(),
groupInfo.getGroupInviteLink(),
groupInfo.getMembers()
.stream()
.map(account.getRecipientStore()::resolveRecipientAddress)
.collect(Collectors.toSet()),
groupInfo.getPendingMembers()
.stream()
.map(account.getRecipientStore()::resolveRecipientAddress)
.collect(Collectors.toSet()),
groupInfo.getRequestingMembers()
.stream()
.map(account.getRecipientStore()::resolveRecipientAddress)
.collect(Collectors.toSet()),
groupInfo.getAdminMembers()
.stream()
.map(account.getRecipientStore()::resolveRecipientAddress)
.collect(Collectors.toSet()),
groupInfo.isBlocked(),
groupInfo.getMessageExpirationTime(),
groupInfo.isAnnouncementGroup(),
groupInfo.isMember(account.getSelfRecipientId()));
}
@Override
public SendGroupMessageResults quitGroup(
GroupId groupId, Set groupAdmins
) throws GroupNotFoundException, IOException, NotAGroupMemberException, LastGroupAdminException {
final var newAdmins = resolveRecipients(groupAdmins);
return groupHelper.quitGroup(groupId, newAdmins);
}
@Override
public void deleteGroup(GroupId groupId) throws IOException {
groupHelper.deleteGroup(groupId);
}
@Override
public Pair createGroup(
String name, Set members, File avatarFile
) throws IOException, AttachmentInvalidException {
return groupHelper.createGroup(name, members == null ? null : resolveRecipients(members), avatarFile);
}
@Override
public SendGroupMessageResults updateGroup(
GroupId groupId,
String name,
String description,
Set members,
Set removeMembers,
Set admins,
Set removeAdmins,
boolean resetGroupLink,
GroupLinkState groupLinkState,
GroupPermission addMemberPermission,
GroupPermission editDetailsPermission,
File avatarFile,
Integer expirationTimer,
Boolean isAnnouncementGroup
) throws IOException, GroupNotFoundException, AttachmentInvalidException, NotAGroupMemberException, GroupSendingNotAllowedException {
return groupHelper.updateGroup(groupId,
name,
description,
members == null ? null : resolveRecipients(members),
removeMembers == null ? null : resolveRecipients(removeMembers),
admins == null ? null : resolveRecipients(admins),
removeAdmins == null ? null : resolveRecipients(removeAdmins),
resetGroupLink,
groupLinkState,
addMemberPermission,
editDetailsPermission,
avatarFile,
expirationTimer,
isAnnouncementGroup);
}
@Override
public Pair joinGroup(
GroupInviteLinkUrl inviteLinkUrl
) throws IOException, GroupLinkNotActiveException {
return groupHelper.joinGroup(inviteLinkUrl);
}
private SendMessageResults sendMessage(
SignalServiceDataMessage.Builder messageBuilder, Set recipients
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
var results = new HashMap>();
long timestamp = System.currentTimeMillis();
messageBuilder.withTimestamp(timestamp);
for (final var recipient : recipients) {
if (recipient instanceof RecipientIdentifier.Single) {
final var recipientId = resolveRecipient((RecipientIdentifier.Single) recipient);
final var result = sendHelper.sendMessage(messageBuilder, recipientId);
results.put(recipient, List.of(result));
} else if (recipient instanceof RecipientIdentifier.NoteToSelf) {
final var result = sendHelper.sendSelfMessage(messageBuilder);
results.put(recipient, List.of(result));
} else if (recipient instanceof RecipientIdentifier.Group) {
final var groupId = ((RecipientIdentifier.Group) recipient).groupId;
final var result = sendHelper.sendAsGroupMessage(messageBuilder, groupId);
results.put(recipient, result);
}
}
return new SendMessageResults(timestamp, results);
}
private void sendTypingMessage(
SignalServiceTypingMessage.Action action, Set recipients
) throws IOException, UntrustedIdentityException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
final var timestamp = System.currentTimeMillis();
for (var recipient : recipients) {
if (recipient instanceof RecipientIdentifier.Single) {
final var message = new SignalServiceTypingMessage(action, timestamp, Optional.absent());
final var recipientId = resolveRecipient((RecipientIdentifier.Single) recipient);
sendHelper.sendTypingMessage(message, recipientId);
} else if (recipient instanceof RecipientIdentifier.Group) {
final var groupId = ((RecipientIdentifier.Group) recipient).groupId;
final var message = new SignalServiceTypingMessage(action, timestamp, Optional.of(groupId.serialize()));
sendHelper.sendGroupTypingMessage(message, groupId);
}
}
}
@Override
public void sendTypingMessage(
TypingAction action, Set recipients
) throws IOException, UntrustedIdentityException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
sendTypingMessage(action.toSignalService(), recipients);
}
@Override
public void sendReadReceipt(
RecipientIdentifier.Single sender, List messageIds
) throws IOException, UntrustedIdentityException {
var receiptMessage = new SignalServiceReceiptMessage(SignalServiceReceiptMessage.Type.READ,
messageIds,
System.currentTimeMillis());
sendHelper.sendReceiptMessage(receiptMessage, resolveRecipient(sender));
}
@Override
public void sendViewedReceipt(
RecipientIdentifier.Single sender, List messageIds
) throws IOException, UntrustedIdentityException {
var receiptMessage = new SignalServiceReceiptMessage(SignalServiceReceiptMessage.Type.VIEWED,
messageIds,
System.currentTimeMillis());
sendHelper.sendReceiptMessage(receiptMessage, resolveRecipient(sender));
}
@Override
public SendMessageResults sendMessage(
Message message, Set recipients
) throws IOException, AttachmentInvalidException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
final var messageBuilder = SignalServiceDataMessage.newBuilder();
applyMessage(messageBuilder, message);
return sendMessage(messageBuilder, recipients);
}
private void applyMessage(
final SignalServiceDataMessage.Builder messageBuilder, final Message message
) throws AttachmentInvalidException, IOException {
messageBuilder.withBody(message.getMessageText());
final var attachments = message.getAttachments();
if (attachments != null) {
messageBuilder.withAttachments(attachmentHelper.uploadAttachments(attachments));
}
}
@Override
public SendMessageResults sendRemoteDeleteMessage(
long targetSentTimestamp, Set recipients
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
var delete = new SignalServiceDataMessage.RemoteDelete(targetSentTimestamp);
final var messageBuilder = SignalServiceDataMessage.newBuilder().withRemoteDelete(delete);
return sendMessage(messageBuilder, recipients);
}
@Override
public SendMessageResults sendMessageReaction(
String emoji,
boolean remove,
RecipientIdentifier.Single targetAuthor,
long targetSentTimestamp,
Set recipients
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
var targetAuthorRecipientId = resolveRecipient(targetAuthor);
var reaction = new SignalServiceDataMessage.Reaction(emoji,
remove,
resolveSignalServiceAddress(targetAuthorRecipientId),
targetSentTimestamp);
final var messageBuilder = SignalServiceDataMessage.newBuilder().withReaction(reaction);
return sendMessage(messageBuilder, recipients);
}
@Override
public SendMessageResults sendEndSessionMessage(Set recipients) throws IOException {
var messageBuilder = SignalServiceDataMessage.newBuilder().asEndSessionMessage();
try {
return sendMessage(messageBuilder,
recipients.stream().map(RecipientIdentifier.class::cast).collect(Collectors.toSet()));
} catch (GroupNotFoundException | NotAGroupMemberException | GroupSendingNotAllowedException e) {
throw new AssertionError(e);
} finally {
for (var recipient : recipients) {
final var recipientId = resolveRecipient(recipient);
account.getSessionStore().deleteAllSessions(recipientId);
}
}
}
@Override
public void setContactName(
RecipientIdentifier.Single recipient, String name
) throws NotMasterDeviceException, UnregisteredUserException {
if (!account.isMasterDevice()) {
throw new NotMasterDeviceException();
}
contactHelper.setContactName(resolveRecipient(recipient), name);
}
@Override
public void setContactBlocked(
RecipientIdentifier.Single recipient, boolean blocked
) throws NotMasterDeviceException, IOException {
if (!account.isMasterDevice()) {
throw new NotMasterDeviceException();
}
contactHelper.setContactBlocked(resolveRecipient(recipient), blocked);
// TODO cycle our profile key
syncHelper.sendBlockedList();
}
@Override
public void setGroupBlocked(
final GroupId groupId, final boolean blocked
) throws GroupNotFoundException, IOException {
groupHelper.setGroupBlocked(groupId, blocked);
// TODO cycle our profile key
syncHelper.sendBlockedList();
}
/**
* Change the expiration timer for a contact
*/
@Override
public void setExpirationTimer(
RecipientIdentifier.Single recipient, int messageExpirationTimer
) throws IOException {
var recipientId = resolveRecipient(recipient);
contactHelper.setExpirationTimer(recipientId, messageExpirationTimer);
final var messageBuilder = SignalServiceDataMessage.newBuilder().asExpirationUpdate();
try {
sendMessage(messageBuilder, Set.of(recipient));
} catch (NotAGroupMemberException | GroupNotFoundException | GroupSendingNotAllowedException e) {
throw new AssertionError(e);
}
}
/**
* Upload the sticker pack from path.
*
* @param path Path can be a path to a manifest.json file or to a zip file that contains a manifest.json file
* @return if successful, returns the URL to install the sticker pack in the signal app
*/
@Override
public URI uploadStickerPack(File path) throws IOException, StickerPackInvalidException {
var manifest = StickerUtils.getSignalServiceStickerManifestUpload(path);
var messageSender = dependencies.getMessageSender();
var packKey = KeyUtils.createStickerUploadKey();
var packIdString = messageSender.uploadStickerManifest(manifest, packKey);
var packId = StickerPackId.deserialize(Hex.fromStringCondensed(packIdString));
var sticker = new Sticker(packId, packKey);
account.getStickerStore().updateSticker(sticker);
try {
return new URI("https",
"signal.art",
"/addstickers/",
"pack_id="
+ URLEncoder.encode(Hex.toStringCondensed(packId.serialize()), StandardCharsets.UTF_8)
+ "&pack_key="
+ URLEncoder.encode(Hex.toStringCondensed(packKey), StandardCharsets.UTF_8));
} catch (URISyntaxException e) {
throw new AssertionError(e);
}
}
@Override
public void requestAllSyncData() throws IOException {
syncHelper.requestAllSyncData();
retrieveRemoteStorage();
}
void retrieveRemoteStorage() throws IOException {
if (account.getStorageKey() != null) {
storageHelper.readDataFromStorage();
}
}
private byte[] getSenderCertificate() {
byte[] certificate;
try {
if (account.isPhoneNumberShared()) {
certificate = dependencies.getAccountManager().getSenderCertificate();
} else {
certificate = dependencies.getAccountManager().getSenderCertificateForPhoneNumberPrivacy();
}
} catch (IOException e) {
logger.warn("Failed to get sender certificate, ignoring: {}", e.getMessage());
return null;
}
// TODO cache for a day
return certificate;
}
private RecipientId refreshRegisteredUser(RecipientId recipientId) throws IOException {
final var address = resolveSignalServiceAddress(recipientId);
if (!address.getNumber().isPresent()) {
return recipientId;
}
final var number = address.getNumber().get();
final var uuid = getRegisteredUser(number);
return resolveRecipientTrusted(new SignalServiceAddress(uuid, number));
}
private UUID getRegisteredUser(final String number) throws IOException {
final Map uuidMap;
try {
uuidMap = getRegisteredUsers(Set.of(number));
} catch (NumberFormatException e) {
throw new UnregisteredUserException(number, e);
}
final var uuid = uuidMap.get(number);
if (uuid == null) {
throw new UnregisteredUserException(number, null);
}
return uuid;
}
private Map getRegisteredUsers(final Set numbers) throws IOException {
final Map registeredUsers;
try {
registeredUsers = dependencies.getAccountManager()
.getRegisteredUsers(ServiceConfig.getIasKeyStore(),
numbers,
serviceEnvironmentConfig.getCdsMrenclave());
} catch (Quote.InvalidQuoteFormatException | UnauthenticatedQuoteException | SignatureException | UnauthenticatedResponseException | InvalidKeyException e) {
throw new IOException(e);
}
// Store numbers as recipients so we have the number/uuid association
registeredUsers.forEach((number, uuid) -> resolveRecipientTrusted(new SignalServiceAddress(uuid, number)));
return registeredUsers;
}
private void retryFailedReceivedMessages(ReceiveMessageHandler handler, boolean ignoreAttachments) {
Set queuedActions = new HashSet<>();
for (var cachedMessage : account.getMessageCache().getCachedMessages()) {
var actions = retryFailedReceivedMessage(handler, ignoreAttachments, cachedMessage);
if (actions != null) {
queuedActions.addAll(actions);
}
}
handleQueuedActions(queuedActions);
}
private List retryFailedReceivedMessage(
final ReceiveMessageHandler handler, final boolean ignoreAttachments, final CachedMessage cachedMessage
) {
var envelope = cachedMessage.loadEnvelope();
if (envelope == null) {
cachedMessage.delete();
return null;
}
final var result = incomingMessageHandler.handleRetryEnvelope(envelope, ignoreAttachments, handler);
final var actions = result.first();
final var exception = result.second();
if (exception instanceof UntrustedIdentityException) {
if (System.currentTimeMillis() - envelope.getServerDeliveredTimestamp() > 1000L * 60 * 60 * 24 * 30) {
// Envelope is more than a month old, cleaning up.
cachedMessage.delete();
return null;
}
if (!envelope.hasSourceUuid()) {
final var identifier = ((UntrustedIdentityException) exception).getSender();
final var recipientId = account.getRecipientStore().resolveRecipient(identifier);
try {
account.getMessageCache().replaceSender(cachedMessage, recipientId);
} catch (IOException ioException) {
logger.warn("Failed to move cached message to recipient folder: {}", ioException.getMessage());
}
}
return null;
}
// If successful and for all other errors that are not recoverable, delete the cached message
cachedMessage.delete();
return actions;
}
@Override
public void receiveMessages(
long timeout,
TimeUnit unit,
boolean returnOnTimeout,
boolean ignoreAttachments,
ReceiveMessageHandler handler
) throws IOException {
retryFailedReceivedMessages(handler, ignoreAttachments);
Set queuedActions = new HashSet<>();
final var signalWebSocket = dependencies.getSignalWebSocket();
signalWebSocket.connect();
hasCaughtUpWithOldMessages = false;
while (!Thread.interrupted()) {
SignalServiceEnvelope envelope;
final CachedMessage[] cachedMessage = {null};
account.setLastReceiveTimestamp(System.currentTimeMillis());
logger.debug("Checking for new message from server");
try {
var result = signalWebSocket.readOrEmpty(unit.toMillis(timeout), envelope1 -> {
final var recipientId = envelope1.hasSourceUuid()
? resolveRecipient(envelope1.getSourceAddress())
: null;
// store message on disk, before acknowledging receipt to the server
cachedMessage[0] = account.getMessageCache().cacheMessage(envelope1, recipientId);
});
if (result.isPresent()) {
envelope = result.get();
logger.debug("New message received from server");
} else {
logger.debug("Received indicator that server queue is empty");
handleQueuedActions(queuedActions);
queuedActions.clear();
hasCaughtUpWithOldMessages = true;
synchronized (this) {
this.notifyAll();
}
// Continue to wait another timeout for new messages
continue;
}
} catch (AssertionError e) {
if (e.getCause() instanceof InterruptedException) {
Thread.currentThread().interrupt();
break;
} else {
throw e;
}
} catch (WebSocketUnavailableException e) {
logger.debug("Pipe unexpectedly unavailable, connecting");
signalWebSocket.connect();
continue;
} catch (TimeoutException e) {
if (returnOnTimeout) return;
continue;
}
final var result = incomingMessageHandler.handleEnvelope(envelope, ignoreAttachments, handler);
queuedActions.addAll(result.first());
final var exception = result.second();
if (hasCaughtUpWithOldMessages) {
handleQueuedActions(queuedActions);
}
if (cachedMessage[0] != null) {
if (exception instanceof UntrustedIdentityException) {
final var address = ((UntrustedIdentityException) exception).getSender();
final var recipientId = resolveRecipient(address);
if (!envelope.hasSourceUuid()) {
try {
cachedMessage[0] = account.getMessageCache().replaceSender(cachedMessage[0], recipientId);
} catch (IOException ioException) {
logger.warn("Failed to move cached message to recipient folder: {}",
ioException.getMessage());
}
}
} else {
cachedMessage[0].delete();
}
}
}
handleQueuedActions(queuedActions);
}
@Override
public boolean hasCaughtUpWithOldMessages() {
return hasCaughtUpWithOldMessages;
}
private void handleQueuedActions(final Collection queuedActions) {
var interrupted = false;
for (var action : queuedActions) {
try {
action.execute(context);
} catch (Throwable e) {
if ((e instanceof AssertionError || e instanceof RuntimeException)
&& e.getCause() instanceof InterruptedException) {
interrupted = true;
continue;
}
logger.warn("Message action failed.", e);
}
}
if (interrupted) {
Thread.currentThread().interrupt();
}
}
@Override
public boolean isContactBlocked(final RecipientIdentifier.Single recipient) {
final RecipientId recipientId;
try {
recipientId = resolveRecipient(recipient);
} catch (UnregisteredUserException e) {
return false;
}
return contactHelper.isContactBlocked(recipientId);
}
@Override
public File getAttachmentFile(SignalServiceAttachmentRemoteId attachmentId) {
return attachmentHelper.getAttachmentFile(attachmentId);
}
@Override
public void sendContacts() throws IOException {
syncHelper.sendContacts();
}
@Override
public List> getContacts() {
return account.getContactStore()
.getContacts()
.stream()
.map(p -> new Pair<>(account.getRecipientStore().resolveRecipientAddress(p.first()), p.second()))
.collect(Collectors.toList());
}
@Override
public String getContactOrProfileName(RecipientIdentifier.Single recipient) {
final RecipientId recipientId;
try {
recipientId = resolveRecipient(recipient);
} catch (UnregisteredUserException e) {
return null;
}
final var contact = account.getContactStore().getContact(recipientId);
if (contact != null && !Util.isEmpty(contact.getName())) {
return contact.getName();
}
final var profile = getRecipientProfile(recipientId);
if (profile != null) {
return profile.getDisplayName();
}
return null;
}
@Override
public Group getGroup(GroupId groupId) {
return toGroup(groupHelper.getGroup(groupId));
}
public GroupInfo getGroupInfo(GroupId groupId) {
return groupHelper.getGroup(groupId);
}
@Override
public List getIdentities() {
return account.getIdentityKeyStore()
.getIdentities()
.stream()
.map(this::toIdentity)
.collect(Collectors.toList());
}
private Identity toIdentity(final IdentityInfo identityInfo) {
if (identityInfo == null) {
return null;
}
final var address = account.getRecipientStore().resolveRecipientAddress(identityInfo.getRecipientId());
return new Identity(address,
identityInfo.getIdentityKey(),
computeSafetyNumber(address.toSignalServiceAddress(), identityInfo.getIdentityKey()),
computeSafetyNumberForScanning(address.toSignalServiceAddress(), identityInfo.getIdentityKey()),
identityInfo.getTrustLevel(),
identityInfo.getDateAdded());
}
@Override
public List getIdentities(RecipientIdentifier.Single recipient) {
IdentityInfo identity;
try {
identity = account.getIdentityKeyStore().getIdentity(resolveRecipient(recipient));
} catch (UnregisteredUserException e) {
identity = null;
}
return identity == null ? List.of() : List.of(toIdentity(identity));
}
/**
* Trust this the identity with this fingerprint
*
* @param recipient username of the identity
* @param fingerprint Fingerprint
*/
@Override
public boolean trustIdentityVerified(RecipientIdentifier.Single recipient, byte[] fingerprint) {
RecipientId recipientId;
try {
recipientId = resolveRecipient(recipient);
} catch (UnregisteredUserException e) {
return false;
}
return trustIdentity(recipientId,
identityKey -> Arrays.equals(identityKey.serialize(), fingerprint),
TrustLevel.TRUSTED_VERIFIED);
}
/**
* Trust this the identity with this safety number
*
* @param recipient username of the identity
* @param safetyNumber Safety number
*/
@Override
public boolean trustIdentityVerifiedSafetyNumber(RecipientIdentifier.Single recipient, String safetyNumber) {
RecipientId recipientId;
try {
recipientId = resolveRecipient(recipient);
} catch (UnregisteredUserException e) {
return false;
}
var address = resolveSignalServiceAddress(recipientId);
return trustIdentity(recipientId,
identityKey -> safetyNumber.equals(computeSafetyNumber(address, identityKey)),
TrustLevel.TRUSTED_VERIFIED);
}
/**
* Trust this the identity with this scannable safety number
*
* @param recipient username of the identity
* @param safetyNumber Scannable safety number
*/
@Override
public boolean trustIdentityVerifiedSafetyNumber(RecipientIdentifier.Single recipient, byte[] safetyNumber) {
RecipientId recipientId;
try {
recipientId = resolveRecipient(recipient);
} catch (UnregisteredUserException e) {
return false;
}
var address = resolveSignalServiceAddress(recipientId);
return trustIdentity(recipientId, identityKey -> {
final var fingerprint = computeSafetyNumberFingerprint(address, identityKey);
try {
return fingerprint != null && fingerprint.getScannableFingerprint().compareTo(safetyNumber);
} catch (FingerprintVersionMismatchException | FingerprintParsingException e) {
return false;
}
}, TrustLevel.TRUSTED_VERIFIED);
}
/**
* Trust all keys of this identity without verification
*
* @param recipient username of the identity
*/
@Override
public boolean trustIdentityAllKeys(RecipientIdentifier.Single recipient) {
RecipientId recipientId;
try {
recipientId = resolveRecipient(recipient);
} catch (UnregisteredUserException e) {
return false;
}
return trustIdentity(recipientId, identityKey -> true, TrustLevel.TRUSTED_UNVERIFIED);
}
private boolean trustIdentity(
RecipientId recipientId, Function verifier, TrustLevel trustLevel
) {
var identity = account.getIdentityKeyStore().getIdentity(recipientId);
if (identity == null) {
return false;
}
if (!verifier.apply(identity.getIdentityKey())) {
return false;
}
account.getIdentityKeyStore().setIdentityTrustLevel(recipientId, identity.getIdentityKey(), trustLevel);
try {
var address = resolveSignalServiceAddress(recipientId);
syncHelper.sendVerifiedMessage(address, identity.getIdentityKey(), trustLevel);
} catch (IOException e) {
logger.warn("Failed to send verification sync message: {}", e.getMessage());
}
return true;
}
private void handleIdentityFailure(
final RecipientId recipientId, final SendMessageResult.IdentityFailure identityFailure
) {
final var identityKey = identityFailure.getIdentityKey();
if (identityKey != null) {
final var newIdentity = account.getIdentityKeyStore().saveIdentity(recipientId, identityKey, new Date());
if (newIdentity) {
account.getSessionStore().archiveSessions(recipientId);
}
} else {
// Retrieve profile to get the current identity key from the server
profileHelper.refreshRecipientProfile(recipientId);
}
}
@Override
public String computeSafetyNumber(SignalServiceAddress theirAddress, IdentityKey theirIdentityKey) {
final Fingerprint fingerprint = computeSafetyNumberFingerprint(theirAddress, theirIdentityKey);
return fingerprint == null ? null : fingerprint.getDisplayableFingerprint().getDisplayText();
}
private byte[] computeSafetyNumberForScanning(SignalServiceAddress theirAddress, IdentityKey theirIdentityKey) {
final Fingerprint fingerprint = computeSafetyNumberFingerprint(theirAddress, theirIdentityKey);
return fingerprint == null ? null : fingerprint.getScannableFingerprint().getSerialized();
}
private Fingerprint computeSafetyNumberFingerprint(
final SignalServiceAddress theirAddress, final IdentityKey theirIdentityKey
) {
return Utils.computeSafetyNumber(capabilities.isUuid(),
account.getSelfAddress(),
account.getIdentityKeyPair().getPublicKey(),
theirAddress,
theirIdentityKey);
}
@Override
public SignalServiceAddress resolveSignalServiceAddress(SignalServiceAddress address) {
return resolveSignalServiceAddress(resolveRecipient(address));
}
private SignalServiceAddress resolveSignalServiceAddress(RecipientId recipientId) {
final var address = account.getRecipientStore().resolveRecipientAddress(recipientId);
if (address.getUuid().isPresent()) {
return address.toSignalServiceAddress();
}
// Address in recipient store doesn't have a uuid, this shouldn't happen
// Try to retrieve the uuid from the server
final var number = address.getNumber().get();
final UUID uuid;
try {
uuid = getRegisteredUser(number);
} catch (IOException e) {
logger.warn("Failed to get uuid for e164 number: {}", number, e);
// Return SignalServiceAddress with unknown UUID
return address.toSignalServiceAddress();
}
return resolveSignalServiceAddress(account.getRecipientStore().resolveRecipient(uuid));
}
private Set resolveRecipients(Collection recipients) throws UnregisteredUserException {
final var recipientIds = new HashSet(recipients.size());
for (var number : recipients) {
final var recipientId = resolveRecipient(number);
recipientIds.add(recipientId);
}
return recipientIds;
}
private RecipientId resolveRecipient(final RecipientIdentifier.Single recipient) throws UnregisteredUserException {
if (recipient instanceof RecipientIdentifier.Uuid) {
return account.getRecipientStore().resolveRecipient(((RecipientIdentifier.Uuid) recipient).uuid);
} else {
final var number = ((RecipientIdentifier.Number) recipient).number;
return account.getRecipientStore().resolveRecipient(number, () -> {
try {
return getRegisteredUser(number);
} catch (IOException e) {
return null;
}
});
}
}
private RecipientId resolveRecipient(SignalServiceAddress address) {
return account.getRecipientStore().resolveRecipient(address);
}
private RecipientId resolveRecipientTrusted(SignalServiceAddress address) {
return account.getRecipientStore().resolveRecipientTrusted(address);
}
@Override
public void close() throws IOException {
close(true);
}
private void close(boolean closeAccount) throws IOException {
executor.shutdown();
dependencies.getSignalWebSocket().disconnect();
if (closeAccount && account != null) {
account.close();
}
account = null;
}
}