Message receiving refactor: handleDataMessage onwards

This commit is contained in:
Maxim Shishmarev 2020-06-12 18:08:44 +10:00
parent 38f64cf172
commit 8ca7b8cfb4
27 changed files with 2491 additions and 1875 deletions

1
.gitignore vendored
View File

@ -31,6 +31,7 @@ test/test.js
# React / TypeScript
ts/**/*.js
ts/protobuf/*.d.ts
ts/**/*.js.map
# Swapfiles
**/*.swp

View File

@ -1564,7 +1564,10 @@
mySignalingKey,
options
);
messageReceiver.addEventListener('message', onMessageReceived);
messageReceiver.addEventListener(
'message',
window.NewReceiver.handleMessageEvent
);
messageReceiver.addEventListener('contact', onContactReceived);
window.textsecure.messaging = new textsecure.MessageSender(
USERNAME,
@ -1581,11 +1584,17 @@
mySignalingKey,
options
);
messageReceiver.addEventListener('message', onMessageReceived);
messageReceiver.addEventListener(
'message',
window.NewReceiver.handleMessageEvent
);
messageReceiver.addEventListener('delivery', onDeliveryReceipt);
messageReceiver.addEventListener('contact', onContactReceived);
messageReceiver.addEventListener('group', onGroupReceived);
messageReceiver.addEventListener('sent', onSentMessage);
messageReceiver.addEventListener(
'sent',
window.NewReceiver.handleMessageEvent
);
messageReceiver.addEventListener('readSync', onReadSync);
messageReceiver.addEventListener('read', onReadReceipt);
messageReceiver.addEventListener('verified', onVerified);
@ -1597,7 +1606,7 @@
messageReceiver.addEventListener('typing', onTyping);
Whisper.events.on('endSession', source => {
messageReceiver.handleEndSession(source);
window.NewReceiver.handleEndSession(source);
});
window.Signal.AttachmentDownloads.start({
@ -2003,182 +2012,8 @@
ev.confirm();
}
// Descriptors
const getGroupDescriptor = group => ({
type: Message.GROUP,
id: group.id,
});
// Matches event data from `libtextsecure` `MessageReceiver::handleSentMessage`:
const getDescriptorForSent = ({ message, destination }) =>
message.group
? getGroupDescriptor(message.group)
: { type: Message.PRIVATE, id: destination };
// Matches event data from `libtextsecure` `MessageReceiver::handleDataMessage`:
const getDescriptorForReceived = ({ message, source }) =>
message.group
? getGroupDescriptor(message.group)
: { type: Message.PRIVATE, id: source };
function createMessageHandler({
createMessage,
getMessageDescriptor,
handleProfileUpdate,
}) {
return async event => {
const { data, confirm } = event;
if (!data) {
window.log.warn('Invalid data passed to createMessageHandler.', event);
return confirm();
}
const messageDescriptor = getMessageDescriptor(data);
// Funnel messages to primary device conversation if multi-device
const authorisation = await libloki.storage.getGrantAuthorisationForSecondaryPubKey(
messageDescriptor.id
);
if (authorisation) {
messageDescriptor.id = authorisation.primaryDevicePubKey;
}
const { PROFILE_KEY_UPDATE } = textsecure.protobuf.DataMessage.Flags;
// eslint-disable-next-line no-bitwise
const isProfileUpdate = Boolean(data.message.flags & PROFILE_KEY_UPDATE);
if (isProfileUpdate) {
return handleProfileUpdate({ data, confirm, messageDescriptor });
}
const descriptorId = await textsecure.MessageReceiver.arrayBufferToString(
messageDescriptor.id
);
const message = await createMessage(data);
const isDuplicate = await isMessageDuplicate(message);
if (isDuplicate) {
// RSS expects duplicates, so squelch log
if (!descriptorId.match(/^rss:/)) {
window.log.warn('Received duplicate message', message.idForLogging());
}
return confirm();
}
await ConversationController.getOrCreateAndWait(
messageDescriptor.id,
messageDescriptor.type
);
return message.handleDataMessage(data.message, confirm, {
initialLoadComplete,
});
};
}
// Received:
async function handleMessageReceivedProfileUpdate({
data,
confirm,
messageDescriptor,
}) {
const profileKey = data.message.profileKey.toString('base64');
const sender = await ConversationController.getOrCreateAndWait(
messageDescriptor.id,
'private'
);
// Will do the save for us
await sender.setProfileKey(profileKey);
return confirm();
}
const onMessageReceived = createMessageHandler({
handleProfileUpdate: handleMessageReceivedProfileUpdate,
getMessageDescriptor: getDescriptorForReceived,
createMessage: initIncomingMessage,
});
// Sent:
async function handleMessageSentProfileUpdate({
data,
confirm,
messageDescriptor,
}) {
// First set profileSharing = true for the conversation we sent to
const { id, type } = messageDescriptor;
const conversation = await ConversationController.getOrCreateAndWait(
id,
type
);
conversation.set({ profileSharing: true });
await window.Signal.Data.updateConversation(id, conversation.attributes, {
Conversation: Whisper.Conversation,
});
// Then we update our own profileKey if it's different from what we have
const ourNumber = textsecure.storage.user.getNumber();
const profileKey = data.message.profileKey.toString('base64');
const me = await ConversationController.getOrCreate(ourNumber, 'private');
// Will do the save for us if needed
await me.setProfileKey(profileKey);
return confirm();
}
function createSentMessage(data) {
const now = Date.now();
let sentTo = [];
if (data.unidentifiedStatus && data.unidentifiedStatus.length) {
sentTo = data.unidentifiedStatus.map(item => item.destination);
const unidentified = _.filter(data.unidentifiedStatus, item =>
Boolean(item.unidentified)
);
// eslint-disable-next-line no-param-reassign
data.unidentifiedDeliveries = unidentified.map(item => item.destination);
}
return new Whisper.Message({
source: textsecure.storage.user.getNumber(),
sourceDevice: data.sourceDevice,
sent_at: data.timestamp,
sent_to: sentTo,
received_at: data.isPublic ? data.receivedAt : now,
conversationId: data.destination,
type: 'outgoing',
sent: true,
unidentifiedDeliveries: data.unidentifiedDeliveries || [],
expirationStartTimestamp: Math.min(
data.expirationStartTimestamp || data.timestamp || Date.now(),
Date.now()
),
});
}
const onSentMessage = createMessageHandler({
handleProfileUpdate: handleMessageSentProfileUpdate,
getMessageDescriptor: getDescriptorForSent,
createMessage: createSentMessage,
});
async function isMessageDuplicate(message) {
try {
const { attributes } = message;
const result = await window.Signal.Data.getMessageBySender(attributes, {
Message: Whisper.Message,
});
return Boolean(result);
} catch (error) {
window.log.error('isMessageDuplicate error:', Errors.toLogFormat(error));
return false;
}
}
async function initIncomingMessage(data, options = {}) {
const { isError } = options;
async function initIncomingMessage(data) {
// Now this function is only called for errors, so no delivery receipts
let messageData = {
source: data.source,
@ -2205,35 +2040,6 @@
const message = new Whisper.Message(messageData);
// Send a delivery receipt
// If we don't return early here, we can get into infinite error loops. So, no delivery receipts for sealed sender errors.
// Note(LOKI): don't send receipt for FR as we don't have a session yet
const isGroup = data && data.message && data.message.group;
const shouldSendReceipt =
!isError &&
data.unidentifiedDeliveryReceived &&
!data.friendRequest &&
!isGroup;
// Send the receipt async and hope that it succeeds
if (shouldSendReceipt) {
const { wrap, sendOptions } = ConversationController.prepareForSend(
data.source
);
wrap(
textsecure.messaging.sendDeliveryReceipt(
data.source,
data.timestamp,
sendOptions
)
).catch(error => {
window.log.error(
`Failed to send delivery receipt to ${data.source} for message ${data.timestamp}:`,
error && error.stack ? error.stack : error
);
});
}
return message;
}
@ -2365,7 +2171,9 @@
return;
}
const envelope = ev.proto;
const message = await initIncomingMessage(envelope, { isError: true });
// TODO: see if we could reuse the one in receiver.ts
const message = await initIncomingMessage(envelope);
await message.saveErrors(error || new Error('Error was null'));
const id = message.get('conversationId');

View File

@ -12,7 +12,6 @@
Whisper,
clipboard,
libloki,
lokiFileServerAPI,
*/
/* eslint-disable more/no-then */
@ -23,7 +22,7 @@
window.Whisper = window.Whisper || {};
const { Message: TypedMessage, Contact, PhoneNumber, Errors } = Signal.Types;
const { Message: TypedMessage, Contact, PhoneNumber } = Signal.Types;
const {
deleteExternalMessageFiles,
@ -31,7 +30,6 @@
loadAttachmentData,
loadQuoteData,
loadPreviewData,
upgradeMessageSchema,
} = window.Signal.Migrations;
const { bytesFromString } = window.Signal.Crypto;
@ -1683,7 +1681,7 @@
});
errors = errors.concat(this.get('errors') || []);
if (this.isEndSession) {
if (this.isEndSession()) {
this.set({ endSessionType: 'failed' });
}
@ -1703,965 +1701,6 @@
);
return !!error;
},
async queueAttachmentDownloads() {
const messageId = this.id;
let count = 0;
let bodyPending;
const [longMessageAttachments, normalAttachments] = _.partition(
this.get('attachments') || [],
attachment =>
attachment.contentType === Whisper.Message.LONG_MESSAGE_CONTENT_TYPE
);
if (longMessageAttachments.length > 1) {
window.log.error(
`Received more than one long message attachment in message ${this.idForLogging()}`
);
}
if (longMessageAttachments.length > 0) {
count += 1;
bodyPending = true;
await window.Signal.AttachmentDownloads.addJob(
longMessageAttachments[0],
{
messageId,
type: 'long-message',
index: 0,
}
);
}
const attachments = await Promise.all(
normalAttachments.map((attachment, index) => {
count += 1;
return window.Signal.AttachmentDownloads.addJob(attachment, {
messageId,
type: 'attachment',
index,
});
})
);
const preview = await Promise.all(
(this.get('preview') || []).map(async (item, index) => {
if (!item.image) {
return item;
}
count += 1;
return {
...item,
image: await window.Signal.AttachmentDownloads.addJob(item.image, {
messageId,
type: 'preview',
index,
}),
};
})
);
const contact = await Promise.all(
(this.get('contact') || []).map(async (item, index) => {
if (!item.avatar || !item.avatar.avatar) {
return item;
}
count += 1;
return {
...item,
avatar: {
...item.avatar,
avatar: await window.Signal.AttachmentDownloads.addJob(
item.avatar.avatar,
{
messageId,
type: 'contact',
index,
}
),
},
};
})
);
let quote = this.get('quote');
if (quote && quote.attachments && quote.attachments.length) {
quote = {
...quote,
attachments: await Promise.all(
(quote.attachments || []).map(async (item, index) => {
// If we already have a path, then we copied this image from the quoted
// message and we don't need to download the attachment.
if (!item.thumbnail || item.thumbnail.path) {
return item;
}
count += 1;
return {
...item,
thumbnail: await window.Signal.AttachmentDownloads.addJob(
item.thumbnail,
{
messageId,
type: 'quote',
index,
}
),
};
})
),
};
}
let group = this.get('group');
if (group && group.avatar) {
group = {
...group,
avatar: await window.Signal.AttachmentDownloads.addJob(group.avatar, {
messageId,
type: 'group-avatar',
index: 0,
}),
};
}
if (count > 0) {
this.set({ bodyPending, attachments, preview, contact, quote, group });
await window.Signal.Data.saveMessage(this.attributes, {
Message: Whisper.Message,
});
return true;
}
return false;
},
async copyFromQuotedMessage(message, attemptCount = 1) {
const { quote } = message;
if (!quote) {
return message;
}
const { attachments, id, author } = quote;
const firstAttachment = attachments[0];
const collection = await window.Signal.Data.getMessagesBySentAt(id, {
MessageCollection: Whisper.MessageCollection,
});
const found = collection.find(item => {
const messageAuthor = item.getContact();
return messageAuthor && author === messageAuthor.id;
});
if (!found) {
// Exponential backoff, giving up after 5 attempts:
if (attemptCount < 5) {
setTimeout(() => {
window.log.info(
`Looking for the message id : ${id}, attempt: ${attemptCount + 1}`
);
this.copyFromQuotedMessage(message, attemptCount + 1);
}, attemptCount * attemptCount * 500);
}
quote.referencedMessageNotFound = true;
return message;
}
window.log.info(`Found quoted message id: ${id}`);
quote.referencedMessageNotFound = false;
const queryMessage = MessageController.register(found.id, found);
quote.text = queryMessage.get('body');
if (attemptCount > 1) {
// Normally the caller would save the message, but in case we are
// called by a timer, we need to update the message manually
this.set({ quote });
await window.Signal.Data.saveMessage(this.attributes, {
Message: Whisper.Message,
});
return null;
}
if (firstAttachment) {
firstAttachment.thumbnail = null;
}
if (
!firstAttachment ||
(!window.Signal.Util.GoogleChrome.isImageTypeSupported(
firstAttachment.contentType
) &&
!window.Signal.Util.GoogleChrome.isVideoTypeSupported(
firstAttachment.contentType
))
) {
return message;
}
try {
if (
queryMessage.get('schemaVersion') <
TypedMessage.VERSION_NEEDED_FOR_DISPLAY
) {
const upgradedMessage = await upgradeMessageSchema(
queryMessage.attributes
);
queryMessage.set(upgradedMessage);
await window.Signal.Data.saveMessage(upgradedMessage, {
Message: Whisper.Message,
});
}
} catch (error) {
window.log.error(
'Problem upgrading message quoted message from database',
Errors.toLogFormat(error)
);
return message;
}
const queryAttachments = queryMessage.get('attachments') || [];
if (queryAttachments.length > 0) {
const queryFirst = queryAttachments[0];
const { thumbnail } = queryFirst;
if (thumbnail && thumbnail.path) {
firstAttachment.thumbnail = {
...thumbnail,
copied: true,
};
}
}
const queryPreview = queryMessage.get('preview') || [];
if (queryPreview.length > 0) {
const queryFirst = queryPreview[0];
const { image } = queryFirst;
if (image && image.path) {
firstAttachment.thumbnail = {
...image,
copied: true,
};
}
}
return message;
},
async handleSecondaryDeviceFriendRequest(pubKey) {
// fetch the device mapping from the server
const deviceMapping = await lokiFileServerAPI.getUserDeviceMapping(
pubKey
);
if (!deviceMapping) {
return false;
}
// Only handle secondary pubkeys
if (deviceMapping.isPrimary === '1' || !deviceMapping.authorisations) {
return false;
}
const { authorisations } = deviceMapping;
// Secondary devices should only have 1 authorisation from a primary device
if (authorisations.length !== 1) {
return false;
}
const authorisation = authorisations[0];
if (!authorisation) {
return false;
}
if (!authorisation.grantSignature) {
return false;
}
const isValid = await libloki.crypto.validateAuthorisation(authorisation);
if (!isValid) {
return false;
}
const correctSender = pubKey === authorisation.secondaryDevicePubKey;
if (!correctSender) {
return false;
}
const { primaryDevicePubKey } = authorisation;
// ensure the primary device is a friend
const c = window.ConversationController.get(primaryDevicePubKey);
if (!c || !(await c.isFriendWithAnyDevice())) {
return false;
}
await libloki.storage.savePairingAuthorisation(authorisation);
return true;
},
/**
* Returns true if the message is already completely handled and confirmed
* and the processing of this message must stop.
*/
handleGroupMessage(source, initialMessage, primarySource, confirm) {
const conversationId = initialMessage.group.id;
const conversation = ConversationController.get(conversationId);
const GROUP_TYPES = textsecure.protobuf.GroupContext.Type;
if (this.shouldIgnoreBlockedGroup(initialMessage, source)) {
window.log.warn(`Message ignored; destined for blocked group`);
confirm();
return true;
}
// NOTE: we use friends status to tell if this is
// the creation of the group (initial update)
const newGroup = !conversation.isFriend();
const knownMembers = conversation.get('members');
if (!newGroup && knownMembers) {
const fromMember = knownMembers.includes(primarySource);
// if the group exists and we have its members,
// we must drop a message from anyone else than the existing members.
if (!fromMember) {
window.log.warn(
`Ignoring group message from non-member: ${primarySource}`
);
confirm();
// returning true drops the message
return true;
}
}
if (initialMessage.group.type === GROUP_TYPES.REQUEST_INFO && !newGroup) {
libloki.api.debug.logGroupRequestInfo(
`Received GROUP_TYPES.REQUEST_INFO from source: ${source}, primarySource: ${primarySource}, sending back group info.`
);
conversation.sendGroupInfo([source]);
confirm();
return true;
}
if (
initialMessage.group.members &&
initialMessage.group.type === GROUP_TYPES.UPDATE
) {
if (newGroup) {
conversation.updateGroupAdmins(initialMessage.group.admins);
conversation.setFriendRequestStatus(
window.friends.friendRequestStatusEnum.friends
);
} else {
// be sure to drop a message from a non admin if it tries to change group members
// or change the group name
const fromAdmin = conversation
.get('groupAdmins')
.includes(primarySource);
if (!fromAdmin) {
// Make sure the message is not removing members / renaming the group
const nameChanged =
conversation.get('name') !== initialMessage.group.name;
if (nameChanged) {
window.log.warn(
'Non-admin attempts to change the name of the group'
);
}
const membersMissing =
_.difference(
conversation.get('members'),
initialMessage.group.members
).length > 0;
if (membersMissing) {
window.log.warn('Non-admin attempts to remove group members');
}
const messageAllowed = !nameChanged && !membersMissing;
// Returning true drops the message
if (!messageAllowed) {
confirm();
return true;
}
}
}
// send a session request for all the members we do not have a session with
window.libloki.api.sendSessionRequestsToMembers(
initialMessage.group.members
);
} else if (newGroup) {
// We have an unknown group, we should request info from the sender
textsecure.messaging.requestGroupInfo(conversationId, [primarySource]);
}
return false;
},
async handleSessionRequest(source, confirm) {
window.console.log(`Received SESSION_REQUEST from source: ${source}`);
window.libloki.api.sendSessionEstablishedMessage(source);
confirm();
},
isGroupBlocked(groupId) {
return textsecure.storage.get('blocked-groups', []).indexOf(groupId) >= 0;
},
shouldIgnoreBlockedGroup(message, senderPubKey) {
const groupId = message.group && message.group.id;
const isBlocked = this.isGroupBlocked(groupId);
const isLeavingGroup = Boolean(
message.group &&
message.group.type === textsecure.protobuf.GroupContext.Type.QUIT
);
const primaryDevicePubKey = window.storage.get('primaryDevicePubKey');
const isMe =
senderPubKey === textsecure.storage.user.getNumber() ||
senderPubKey === primaryDevicePubKey;
return groupId && isBlocked && !(isMe && isLeavingGroup);
},
async handleAutoFriendRequestMessage(
source,
ourPubKey,
conversation,
confirm
) {
const isMe = source === ourPubKey;
// If we got a friend request message (session request excluded) or
// if we're not friends with the current user that sent this private message
// Check to see if we need to auto accept their friend request
if (isMe) {
window.log.info('refusing to add a friend request to ourselves');
throw new Error('Cannot add a friend request for ourselves!');
} else {
// auto-accept friend request if the device is paired to one of our friend's primary device
const shouldAutoAcceptFR = await this.handleSecondaryDeviceFriendRequest(
source
);
if (shouldAutoAcceptFR) {
libloki.api.debug.logAutoFriendRequest(
`Received AUTO_FRIEND_REQUEST from source: ${source}`
);
// Directly setting friend request status to skip the pending state
await conversation.setFriendRequestStatus(
window.friends.friendRequestStatusEnum.friends
);
// sending a message back = accepting friend request
window.libloki.api.sendBackgroundMessage(
source,
window.textsecure.OutgoingMessage.DebugMessageType.AUTO_FR_ACCEPT
);
confirm();
// return true to notify the message is fully processed
return true;
}
}
return false;
},
async handleDataMessage(initialMessage, confirm) {
// This function is called from the background script in a few scenarios:
// 1. on an incoming message
// 2. on a sent message sync'd from another device
// 3. in rare cases, an incoming message can be retried, though it will
// still go through one of the previous two codepaths
const ourNumber = textsecure.storage.user.getNumber();
const message = this;
const source = message.get('source');
let conversationId = message.get('conversationId');
const authorisation = await libloki.storage.getGrantAuthorisationForSecondaryPubKey(
source
);
const primarySource =
(authorisation && authorisation.primaryDevicePubKey) || source;
const isGroupMessage = !!initialMessage.group;
if (isGroupMessage) {
/* handle one part of the group logic here:
handle requesting info of a new group,
dropping an admin only update from a non admin, ...
*/
conversationId = initialMessage.group.id;
const shouldReturn = this.handleGroupMessage(
source,
initialMessage,
primarySource,
confirm
);
// handleGroupMessage() can process fully a message in some cases
// so we need to return early if that's the case
if (shouldReturn) {
return null;
}
} else if (source !== ourNumber && authorisation) {
// Ignore auth from our devices
conversationId = authorisation.primaryDevicePubKey;
}
// the conversation with the primary device of that source (can be the same as conversationOrigin)
const conversationPrimary = ConversationController.get(conversationId);
// the conversation with this real device
const conversationOrigin = ConversationController.get(source);
if (
// eslint-disable-next-line no-bitwise
initialMessage.flags &
textsecure.protobuf.DataMessage.Flags.SESSION_RESTORE
) {
// Show that the session reset is "in progress" even though we had a valid session
this.set({ endSessionType: 'ongoing' });
}
/**
* A session request message is a friend-request message with the flag
* SESSION_REQUEST set to true.
*/
const sessionRequestFlag =
textsecure.protobuf.DataMessage.Flags.SESSION_REQUEST;
/* eslint-disable no-bitwise */
if (
message.isFriendRequest() &&
!!(initialMessage.flags & sessionRequestFlag)
) {
await this.handleSessionRequest(source, confirm);
// Wether or not we accepted the FR, we exit early so session requests
// cannot be used for establishing regular private conversations
return null;
}
/* eslint-enable no-bitwise */
// Session request have been dealt with before, so a friend request here is
// not a session request message. Also, handleAutoFriendRequestMessage() only handles the autoAccept logic of an auto friend request.
if (
message.isFriendRequest() ||
(!isGroupMessage && !conversationOrigin.isFriend())
) {
const shouldReturn = await this.handleAutoFriendRequestMessage(
source,
ourNumber,
conversationOrigin,
confirm
);
// handleAutoFriendRequestMessage can process fully a message in some cases
// so we need to return early if that's the case
if (shouldReturn) {
return null;
}
}
return conversationPrimary.queueJob(async () => {
window.log.info(
`Starting handleDataMessage for message ${message.idForLogging()} in conversation ${conversationPrimary.idForLogging()}`
);
const GROUP_TYPES = textsecure.protobuf.GroupContext.Type;
const type = message.get('type');
const withQuoteReference = await this.copyFromQuotedMessage(
initialMessage
);
const dataMessage = await upgradeMessageSchema(withQuoteReference);
try {
const now = new Date().getTime();
let attributes = {
...conversationPrimary.attributes,
};
if (dataMessage.group) {
let groupUpdate = null;
attributes = {
...attributes,
type: 'group',
groupId: dataMessage.group.id,
};
if (dataMessage.group.type === GROUP_TYPES.UPDATE) {
attributes = {
...attributes,
name: dataMessage.group.name,
members: dataMessage.group.members,
};
groupUpdate =
conversationPrimary.changedAttributes(
_.pick(dataMessage.group, 'name', 'avatar')
) || {};
const addedMembers = _.difference(
attributes.members,
conversationPrimary.get('members')
);
if (addedMembers.length > 0) {
groupUpdate.joined = addedMembers;
}
if (conversationPrimary.get('left')) {
// TODO: Maybe we shouldn't assume this message adds us:
// we could maybe still get this message by mistake
window.log.warn('re-added to a left group');
attributes.left = false;
}
if (attributes.isKickedFromGroup) {
// Assume somebody re-invited us since we received this update
attributes.isKickedFromGroup = false;
}
// Check if anyone got kicked:
const removedMembers = _.difference(
conversationPrimary.get('members'),
attributes.members
);
if (removedMembers.length > 0) {
if (
removedMembers.includes(textsecure.storage.user.getNumber())
) {
groupUpdate.kicked = 'You';
attributes.isKickedFromGroup = true;
} else {
groupUpdate.kicked = removedMembers;
}
}
} else if (dataMessage.group.type === GROUP_TYPES.QUIT) {
if (source === textsecure.storage.user.getNumber()) {
attributes.left = true;
groupUpdate = { left: 'You' };
} else {
groupUpdate = { left: source };
}
attributes.members = _.without(
conversationPrimary.get('members'),
source
);
}
if (groupUpdate !== null) {
message.set({ group_update: groupUpdate });
}
}
if (initialMessage.groupInvitation) {
message.set({ groupInvitation: initialMessage.groupInvitation });
}
const urls = window.Signal.LinkPreviews.findLinks(dataMessage.body);
const incomingPreview = dataMessage.preview || [];
const preview = incomingPreview.filter(
item =>
(item.image || item.title) &&
urls.includes(item.url) &&
window.Signal.LinkPreviews.isLinkInWhitelist(item.url)
);
if (preview.length < incomingPreview.length) {
window.log.info(
`${message.idForLogging()}: Eliminated ${preview.length -
incomingPreview.length} previews with invalid urls'`
);
}
message.set({
attachments: dataMessage.attachments,
body: dataMessage.body,
contact: dataMessage.contact,
conversationId: conversationPrimary.id,
decrypted_at: now,
errors: [],
flags: dataMessage.flags,
hasAttachments: dataMessage.hasAttachments,
hasFileAttachments: dataMessage.hasFileAttachments,
hasVisualMediaAttachments: dataMessage.hasVisualMediaAttachments,
quote: dataMessage.quote,
preview,
schemaVersion: dataMessage.schemaVersion,
});
if (type === 'outgoing') {
const receipts = Whisper.DeliveryReceipts.forMessage(
conversationPrimary,
message
);
receipts.forEach(receipt =>
message.set({
delivered: (message.get('delivered') || 0) + 1,
delivered_to: _.union(message.get('delivered_to') || [], [
receipt.get('source'),
]),
})
);
}
attributes.active_at = now;
conversationPrimary.set(attributes);
// Re-enable typing if re-joined the group
conversationPrimary.updateTextInputState();
if (message.isExpirationTimerUpdate()) {
message.set({
expirationTimerUpdate: {
source,
expireTimer: dataMessage.expireTimer,
},
});
conversationPrimary.set({ expireTimer: dataMessage.expireTimer });
} else if (dataMessage.expireTimer) {
message.set({ expireTimer: dataMessage.expireTimer });
}
// NOTE: Remove once the above uses
// `Conversation::updateExpirationTimer`:
const { expireTimer } = dataMessage;
const shouldLogExpireTimerChange =
message.isExpirationTimerUpdate() || expireTimer;
if (shouldLogExpireTimerChange) {
window.log.info("Update conversation 'expireTimer'", {
id: conversationPrimary.idForLogging(),
expireTimer,
source: 'handleDataMessage',
});
}
if (!message.isEndSession()) {
if (dataMessage.expireTimer) {
if (
dataMessage.expireTimer !==
conversationPrimary.get('expireTimer')
) {
conversationPrimary.updateExpirationTimer(
dataMessage.expireTimer,
source,
message.get('received_at'),
{
fromGroupUpdate: message.isGroupUpdate(),
}
);
}
} else if (
conversationPrimary.get('expireTimer') &&
// We only turn off timers if it's not a group update
!message.isGroupUpdate()
) {
conversationPrimary.updateExpirationTimer(
null,
source,
message.get('received_at')
);
}
} else {
const endSessionType = conversationPrimary.isSessionResetReceived()
? 'ongoing'
: 'done';
this.set({ endSessionType });
}
if (type === 'incoming' || message.isFriendRequest()) {
const readSync = Whisper.ReadSyncs.forMessage(message);
if (readSync) {
if (
message.get('expireTimer') &&
!message.get('expirationStartTimestamp')
) {
message.set(
'expirationStartTimestamp',
Math.min(readSync.get('read_at'), Date.now())
);
}
}
if (readSync || message.isExpirationTimerUpdate()) {
message.unset('unread');
// This is primarily to allow the conversation to mark all older
// messages as read, as is done when we receive a read sync for
// a message we already know about.
const c = message.getConversation();
if (c) {
c.onReadMessage(message);
}
} else {
if (
message.attributes.body &&
message.attributes.body.indexOf(`@${ourNumber}`) !== -1
) {
conversationPrimary.set({ mentionedUs: true });
}
conversationPrimary.set({
unreadCount: conversationPrimary.get('unreadCount') + 1,
isArchived: false,
});
}
}
if (type === 'outgoing') {
const reads = Whisper.ReadReceipts.forMessage(
conversationPrimary,
message
);
if (reads.length) {
const readBy = reads.map(receipt => receipt.get('reader'));
message.set({
read_by: _.union(message.get('read_by'), readBy),
});
}
// A sync'd message to ourself is automatically considered read and delivered
if (conversationPrimary.isMe()) {
message.set({
read_by: conversationPrimary.getRecipients(),
delivered_to: conversationPrimary.getRecipients(),
});
}
message.set({ recipients: conversationPrimary.getRecipients() });
}
const conversationTimestamp = conversationPrimary.get('timestamp');
if (
!conversationTimestamp ||
message.get('sent_at') > conversationTimestamp
) {
conversationPrimary.lastMessage = message.getNotificationText();
conversationPrimary.set({
timestamp: message.get('sent_at'),
});
}
if (dataMessage.profileKey) {
const profileKey = dataMessage.profileKey.toString('base64');
if (source === textsecure.storage.user.getNumber()) {
conversationPrimary.set({ profileSharing: true });
} else if (conversationPrimary.isPrivate()) {
conversationPrimary.setProfileKey(profileKey);
} else {
conversationOrigin.setProfileKey(profileKey);
}
}
let autoAccept = false;
// Make sure friend request logic doesn't trigger on messages aimed at groups
if (!isGroupMessage) {
// We already handled (and returned) session request and auto Friend Request before,
// so that can only be a normal Friend Request
if (message.isFriendRequest()) {
/*
Here is the before and after state diagram for the operation before.
None -> RequestReceived
PendingSend -> RequestReceived
RequestReceived -> RequestReceived
Sent -> Friends
Expired -> Friends
Friends -> Friends
The cases where we auto accept are the following:
- We sent the user a friend request,
and that user sent us a friend request.
- We are friends with the user,
and that user just sent us a friend request.
*/
const isFriend = conversationOrigin.isFriend();
const hasSentFriendRequest = conversationOrigin.hasSentFriendRequest();
autoAccept = isFriend || hasSentFriendRequest;
if (autoAccept) {
message.set({ friendStatus: 'accepted' });
}
libloki.api.debug.logNormalFriendRequest(
`Received a NORMAL_FRIEND_REQUEST from source: ${source}, primarySource: ${primarySource}, isAlreadyFriend: ${isFriend}, didWeAlreadySentFR: ${hasSentFriendRequest}`
);
if (isFriend) {
window.Whisper.events.trigger('endSession', source);
} else if (hasSentFriendRequest) {
await conversationOrigin.onFriendRequestAccepted();
} else {
await conversationOrigin.onFriendRequestReceived();
}
} else if (message.get('type') !== 'outgoing') {
// Ignore 'outgoing' messages because they are sync messages
await conversationOrigin.onFriendRequestAccepted();
}
}
// We need to map the original message source to the primary device
if (source !== ourNumber) {
message.set({ source: primarySource });
}
const id = await window.Signal.Data.saveMessage(message.attributes, {
Message: Whisper.Message,
});
message.set({ id });
MessageController.register(message.id, message);
// Note that this can save the message again, if jobs were queued. We need to
// call it after we have an id for this message, because the jobs refer back
// to their source message.
await message.queueAttachmentDownloads();
await window.Signal.Data.updateConversation(
conversationId,
conversationPrimary.attributes,
{ Conversation: Whisper.Conversation }
);
conversationPrimary.trigger('newmessage', message);
try {
// We go to the database here because, between the message save above and
// the previous line's trigger() call, we might have marked all messages
// unread in the database. This message might already be read!
const fetched = await window.Signal.Data.getMessageById(
message.get('id'),
{
Message: Whisper.Message,
}
);
const previousUnread = message.get('unread');
// Important to update message with latest read state from database
message.merge(fetched);
if (previousUnread !== message.get('unread')) {
window.log.warn(
'Caught race condition on new message read state! ' +
'Manually starting timers.'
);
// We call markRead() even though the message is already
// marked read because we need to start expiration
// timers, etc.
message.markRead();
}
} catch (error) {
window.log.warn(
'handleDataMessage: Message',
message.idForLogging(),
'was deleted'
);
}
if (message.get('unread')) {
// Need to do this here because the conversation has already changed states
if (autoAccept) {
await conversationPrimary.notifyFriendRequest(source, 'accepted');
} else {
await conversationPrimary.notify(message);
}
}
confirm();
} catch (error) {
const errorForLog = error && error.stack ? error.stack : error;
window.log.error(
'handleDataMessage',
message.idForLogging(),
'error:',
errorForLog
);
throw error;
}
});
},
async markRead(readAt) {
this.unset('unread');

View File

@ -1,4 +1,4 @@
/* global Whisper, Signal, setTimeout, clearTimeout, MessageController */
/* global Whisper, Signal, setTimeout, clearTimeout, MessageController, NewReceiver */
const { isFunction, isNumber, omit } = require('lodash');
const getGuid = require('uuid/v4');
@ -168,7 +168,7 @@ async function _runJob(job) {
}
try {
downloaded = await messageReceiver.downloadAttachment(attachment);
downloaded = await NewReceiver.downloadAttachment(attachment);
} catch (error) {
// Attachments on the server expire after 30 days, then start returning 404
if (error && error.code === 404) {

View File

@ -421,20 +421,19 @@
this.model.id
);
const allMembers = await Promise.all(
allPubKeys.map(async pubKey => {
const conv = ConversationController.get(pubKey);
let profileName = 'Anonymous';
if (conv) {
profileName = await conv.getProfileName();
}
return {
id: pubKey,
authorPhoneNumber: pubKey,
authorProfileName: profileName,
};
})
);
const allMembers = allPubKeys.map(pubKey => {
const conv = ConversationController.get(pubKey);
let profileName = 'Anonymous';
if (conv) {
profileName = conv.getProfileName();
}
return {
id: pubKey,
authorPhoneNumber: pubKey,
authorProfileName: profileName,
};
});
window.lokiPublicChatAPI.setListOfMembers(allMembers);
};
@ -1295,6 +1294,7 @@
}
},
// THIS DOES NOT DOWNLOAD ANYTHING!
downloadAttachment({ attachment, message, isDangerous }) {
if (isDangerous) {
const toast = new Whisper.DangerousFileTypeToast();

View File

@ -15,10 +15,8 @@
/* global lokiMessageAPI: false */
/* global feeds: false */
/* global Whisper: false */
/* global lokiFileServerAPI: false */
/* global WebAPI: false */
/* global ConversationController: false */
/* global Signal: false */
/* global log: false */
/* global libsession: false */
@ -27,6 +25,17 @@
let openGroupBound = false;
// TODO: remove this when no longer needed here
function getEnvelopeId(envelope) {
if (envelope.source) {
return `${envelope.source}.${
envelope.sourceDevice
} ${envelope.timestamp.toNumber()} (${envelope.id})`;
}
return envelope.id;
}
function MessageReceiver(username, password, signalingKey, options = {}) {
this.count = 0;
@ -115,7 +124,7 @@ MessageReceiver.prototype.extend({
message.source,
'private'
);
await this.updateProfile(
await window.NewReceiver.updateProfile(
conversation,
message.message.profile,
message.message.profileKey
@ -194,7 +203,6 @@ MessageReceiver.prototype.extend({
},
handleRequest(request, options) {
const { onSuccess, onFailure } = options;
this.incoming = this.incoming || [];
const lastPromise = _.last(this.incoming);
@ -254,7 +262,7 @@ MessageReceiver.prototype.extend({
// To ensure that we queue in the same order we receive messages
await lastPromise;
this.queueEnvelope(envelope, onSuccess, onFailure);
this.queueEnvelope(envelope);
},
error => {
request.respond(500, 'Failed to cache message');
@ -431,15 +439,6 @@ MessageReceiver.prototype.extend({
}
}
},
getEnvelopeId(envelope) {
if (envelope.source) {
return `${envelope.source}.${
envelope.sourceDevice
} ${envelope.timestamp.toNumber()} (${envelope.id})`;
}
return envelope.id;
},
async getAllFromCache() {
window.log.info('getAllFromCache');
const count = await textsecure.storage.unprocessed.getCount();
@ -533,7 +532,7 @@ MessageReceiver.prototype.extend({
return textsecure.storage.unprocessed.remove(id);
},
queueDecryptedEnvelope(envelope, plaintext) {
const id = this.getEnvelopeId(envelope);
const id = getEnvelopeId(envelope);
window.log.info('queueing decrypted envelope', id);
const task = this.handleDecryptedEnvelope.bind(this, envelope, plaintext);
@ -550,8 +549,8 @@ MessageReceiver.prototype.extend({
);
});
},
queueEnvelope(envelope, onSuccess = null, onFailure = null) {
const id = this.getEnvelopeId(envelope);
queueEnvelope(envelope) {
const id = getEnvelopeId(envelope);
window.log.info('queueing envelope', id);
const task = this.handleEnvelope.bind(this, envelope);
@ -560,11 +559,6 @@ MessageReceiver.prototype.extend({
`queueEnvelope ${id}`
);
const promise = this.addToQueue(taskWithTimeout);
promise.then(() => {
if (onSuccess) {
onSuccess();
}
});
return promise.catch(error => {
window.log.error(
@ -573,9 +567,6 @@ MessageReceiver.prototype.extend({
':',
error && error.stack ? error.stack : error
);
if (onFailure) {
onFailure();
}
});
},
// Same as handleEnvelope, just without the decryption step. Necessary for handling
@ -608,9 +599,7 @@ MessageReceiver.prototype.extend({
if (envelope.content) {
return this.handleContentMessage(envelope);
}
if (envelope.legacyMessage) {
return this.handleLegacyMessage(envelope);
}
this.removeFromCache(envelope);
throw new Error('Received message with no content and no legacyMessage');
},
@ -736,7 +725,7 @@ MessageReceiver.prototype.extend({
switch (envelope.type) {
case textsecure.protobuf.Envelope.Type.CIPHERTEXT:
window.log.info('message from', this.getEnvelopeId(envelope));
window.log.info('message from', getEnvelopeId(envelope));
promise = lokiSessionCipher
.decryptWhisperMessage(ciphertext)
.then(this.unpad);
@ -757,7 +746,7 @@ MessageReceiver.prototype.extend({
break;
}
case textsecure.protobuf.Envelope.Type.PREKEY_BUNDLE:
window.log.info('prekey message from', this.getEnvelopeId(envelope));
window.log.info('prekey message from', getEnvelopeId(envelope));
promise = this.decryptPreKeyWhisperMessage(
ciphertext,
lokiSessionCipher,
@ -927,37 +916,19 @@ MessageReceiver.prototype.extend({
// eslint-disable-next-line no-bitwise
if (msg.flags & textsecure.protobuf.DataMessage.Flags.END_SESSION) {
await this.handleEndSession(destination);
await window.NewReceiver.handleEndSession(destination);
}
// if (msg.mediumGroupUpdate) {
// await this.handleMediumGroupUpdate(envelope, msg.mediumGroupUpdate);
// return;
// }
if (msg.mediumGroupUpdate) {
await window.NewReceiver.handleMediumGroupUpdate(
envelope,
msg.mediumGroupUpdate
);
}
const message = await this.processDecrypted(envelope, msg);
const message = await window.NewReceiver.processDecrypted(envelope, msg);
const primaryDevicePubKey = window.storage.get('primaryDevicePubKey');
// const groupId = message.group && message.group.id;
// const isBlocked = this.isGroupBlocked(groupId);
//
// const isMe =
// envelope.source === textsecure.storage.user.getNumber() ||
// envelope.source === primaryDevicePubKey;
// const isLeavingGroup = Boolean(
// message.group &&
// message.group.type === textsecure.protobuf.GroupContext.Type.QUIT
// );
// if (groupId && isBlocked && !(isMe && isLeavingGroup)) {
// window.log.warn(
// `Message ${this.getEnvelopeId(
// envelope
// )} ignored; destined for blocked group`
// );
// this.removeFromCache(envelope);
// return;
// }
// handle profileKey and avatar updates
if (envelope.source === primaryDevicePubKey) {
@ -966,7 +937,11 @@ MessageReceiver.prototype.extend({
primaryDevicePubKey
);
if (profile) {
this.updateProfile(primaryConversation, profile, profileKey);
window.NewReceiver.updateProfile(
primaryConversation,
profile,
profileKey
);
}
}
@ -1052,7 +1027,7 @@ MessageReceiver.prototype.extend({
const ourNumber = window.storage.get('primaryDevicePubKey');
const me = window.ConversationController.get(ourNumber);
if (me) {
this.updateProfile(me, profile, profileKey);
window.NewReceiver.updateProfile(me, profile, profileKey);
}
}
// Update contact list
@ -1084,432 +1059,6 @@ MessageReceiver.prototype.extend({
}
return this.handlePairingRequest(envelope, pairingAuthorisation);
},
async updateProfile(conversation, profile, profileKey) {
// Retain old values unless changed:
const newProfile = conversation.get('profile') || {};
newProfile.displayName = profile.displayName;
// TODO: may need to allow users to reset their avatars to null
if (profile.avatar) {
const prevPointer = conversation.get('avatarPointer');
const needsUpdate =
!prevPointer || !_.isEqual(prevPointer, profile.avatar);
if (needsUpdate) {
conversation.set('avatarPointer', profile.avatar);
conversation.set('profileKey', profileKey);
const downloaded = await this.downloadAttachment({
url: profile.avatar,
isRaw: true,
});
// null => use jazzicon
let path = null;
if (profileKey) {
// Convert profileKey to ArrayBuffer, if needed
const encoding = typeof profileKey === 'string' ? 'base64' : null;
try {
const profileKeyArrayBuffer = dcodeIO.ByteBuffer.wrap(
profileKey,
encoding
).toArrayBuffer();
const decryptedData = await textsecure.crypto.decryptProfile(
downloaded.data,
profileKeyArrayBuffer
);
const upgraded = await Signal.Migrations.processNewAttachment({
...downloaded,
data: decryptedData,
});
({ path } = upgraded);
} catch (e) {
window.log.error(`Could not decrypt profile image: ${e}`);
}
}
newProfile.avatar = path;
}
} else {
newProfile.avatar = null;
}
await conversation.setLokiProfile(newProfile);
},
async unpairingRequestIsLegit(source, ourPubKey) {
const isSecondary = textsecure.storage.get('isSecondaryDevice');
if (!isSecondary) {
return false;
}
const primaryPubKey = window.storage.get('primaryDevicePubKey');
// TODO: allow unpairing from any paired device?
if (source !== primaryPubKey) {
return false;
}
const primaryMapping = await lokiFileServerAPI.getUserDeviceMapping(
primaryPubKey
);
// If we don't have a mapping on the primary then we have been unlinked
if (!primaryMapping) {
return true;
}
// We expect the primary device to have updated its mapping
// before sending the unpairing request
const found = primaryMapping.authorisations.find(
authorisation => authorisation.secondaryDevicePubKey === ourPubKey
);
// our pubkey should NOT be in the primary device mapping
return !found;
},
async clearAppAndRestart() {
// remove our device mapping annotations from file server
await lokiFileServerAPI.clearOurDeviceMappingAnnotations();
// Delete the account and restart
try {
await window.Signal.Logs.deleteAll();
await window.Signal.Data.removeAll();
await window.Signal.Data.close();
await window.Signal.Data.removeDB();
await window.Signal.Data.removeOtherData();
// TODO generate an empty db with a flag
// to display a message about the unpairing
// after the app restarts
} catch (error) {
window.log.error(
'Something went wrong deleting all data:',
error && error.stack ? error.stack : error
);
}
window.restart();
},
async handleUnpairRequest(envelope, ourPubKey) {
// TODO: move high-level pairing logic to libloki.multidevice.xx
const legit = await this.unpairingRequestIsLegit(
envelope.source,
ourPubKey
);
this.removeFromCache(envelope);
if (legit) {
await this.clearAppAndRestart();
}
},
async handleMediumGroupUpdate(envelope, groupUpdate) {
const { type, groupId } = groupUpdate;
const ourIdentity = await textsecure.storage.user.getNumber();
const senderIdentity = envelope.source;
if (
type === textsecure.protobuf.MediumGroupUpdate.Type.SENDER_KEY_REQUEST
) {
log.debug('[sender key] sender key request from:', senderIdentity);
const proto = new textsecure.protobuf.DataMessage();
// We reuse the same message type for sender keys
const update = new textsecure.protobuf.MediumGroupUpdate();
const { chainKey, keyIdx } = await window.SenderKeyAPI.getSenderKeys(
groupId,
ourIdentity
);
update.type = textsecure.protobuf.MediumGroupUpdate.Type.SENDER_KEY;
update.groupId = groupId;
update.senderKey = new textsecure.protobuf.SenderKey({
chainKey: StringView.arrayBufferToHex(chainKey),
keyIdx,
});
proto.mediumGroupUpdate = update;
textsecure.messaging.updateMediumGroup([senderIdentity], proto);
this.removeFromCache(envelope);
} else if (type === textsecure.protobuf.MediumGroupUpdate.Type.SENDER_KEY) {
const { senderKey } = groupUpdate;
log.debug('[sender key] got a new sender key from:', senderIdentity);
await window.SenderKeyAPI.saveSenderKeys(
groupId,
senderIdentity,
senderKey.chainKey,
senderKey.keyIdx
);
this.removeFromCache(envelope);
} else if (type === textsecure.protobuf.MediumGroupUpdate.Type.NEW_GROUP) {
const maybeConvo = await window.ConversationController.get(groupId);
const groupExists = !!maybeConvo;
const {
members: membersBinary,
groupSecretKey,
groupName,
senderKey,
admins,
} = groupUpdate;
const members = membersBinary.map(pk =>
StringView.arrayBufferToHex(pk.toArrayBuffer())
);
const convo = groupExists
? maybeConvo
: await window.ConversationController.getOrCreateAndWait(
groupId,
'group'
);
{
// Add group update message
const now = Date.now();
const message = convo.messageCollection.add({
conversationId: convo.id,
type: 'incoming',
sent_at: now,
received_at: now,
group_update: {
name: groupName,
members,
},
});
const messageId = await window.Signal.Data.saveMessage(
message.attributes,
{
Message: Whisper.Message,
}
);
message.set({ id: messageId });
}
if (groupExists) {
// ***** Updating the group *****
log.info('Received a group update for medium group:', groupId);
// Check that the sender is admin (make sure it words with multidevice)
const isAdmin = convo.get('groupAdmins').includes(senderIdentity);
if (!isAdmin) {
log.warn('Rejected attempt to update a group by non-admin');
this.removeFromCache(envelope);
return;
}
convo.set('name', groupName);
convo.set('members', members);
// TODO: check that we are still in the group (when we enable deleting members)
convo.saveChangesToDB();
// Update other fields. Add a corresponding "update" message to the conversation
} else {
// ***** Creating a new group *****
log.info('Received a new medium group:', groupId);
// TODO: Check that we are even a part of this group?
convo.set('is_medium_group', true);
convo.set('active_at', Date.now());
convo.set('name', groupName);
convo.set('groupAdmins', admins);
convo.setFriendRequestStatus(
window.friends.friendRequestStatusEnum.friends
);
const secretKeyHex = StringView.arrayBufferToHex(
groupSecretKey.toArrayBuffer()
);
await window.Signal.Data.createOrUpdateIdentityKey({
id: groupId,
secretKey: secretKeyHex,
});
// Save sender's key
await window.SenderKeyAPI.saveSenderKeys(
groupId,
envelope.source,
senderKey.chainKey,
senderKey.keyIdx
);
const ownSenderKey = await window.SenderKeyAPI.createSenderKeyForGroup(
groupId,
ourIdentity
);
{
// Send own key to every member
const otherMembers = _.without(members, ourIdentity);
const proto = new textsecure.protobuf.DataMessage();
// We reuse the same message type for sender keys
const update = new textsecure.protobuf.MediumGroupUpdate();
update.type = textsecure.protobuf.MediumGroupUpdate.Type.SENDER_KEY;
update.groupId = groupId;
update.senderKey = new textsecure.protobuf.SenderKey({
chainKey: ownSenderKey,
keyIdx: 0,
});
proto.mediumGroupUpdate = update;
textsecure.messaging.updateMediumGroup(otherMembers, proto);
}
// Subscribe to this group
this.pollForAdditionalId(groupId);
}
this.removeFromCache(envelope);
}
},
async handleDataMessage(envelope, msg) {
window.log.info('data message from', this.getEnvelopeId(envelope));
if (msg.mediumGroupUpdate) {
this.handleMediumGroupUpdate(envelope, msg.mediumGroupUpdate);
// TODO: investigate the meaning of this return value
return true;
}
// eslint-disable-next-line no-bitwise
if (msg.flags & textsecure.protobuf.DataMessage.Flags.END_SESSION) {
await this.handleEndSession(envelope.source);
}
const message = await this.processDecrypted(envelope, msg);
const ourPubKey = textsecure.storage.user.getNumber();
const senderPubKey = envelope.source;
const isMe = senderPubKey === ourPubKey;
const conversation = window.ConversationController.get(senderPubKey);
const { UNPAIRING_REQUEST } = textsecure.protobuf.DataMessage.Flags;
// eslint-disable-next-line no-bitwise
const isUnpairingRequest = Boolean(message.flags & UNPAIRING_REQUEST);
if (isUnpairingRequest) {
return this.handleUnpairRequest(envelope, ourPubKey);
}
// Check if we need to update any profile names
if (!isMe && conversation && message.profile) {
await this.updateProfile(
conversation,
message.profile,
message.profileKey
);
}
if (this.isMessageEmpty(message)) {
window.log.warn(
`Message ${this.getEnvelopeId(envelope)} ignored; it was empty`
);
return this.removeFromCache(envelope);
}
// Loki - Temp hack until new protocol
// A friend request is a non-group text message which we haven't processed yet
const isGroupMessage = Boolean(message.group || message.mediumGroupUpdate);
const friendRequestStatusNoneOrExpired = conversation
? conversation.isFriendRequestStatusNoneOrExpired()
: true;
const isFriendRequest =
!isGroupMessage &&
!_.isEmpty(message.body) &&
friendRequestStatusNoneOrExpired;
const source = envelope.senderIdentity || senderPubKey;
const isOwnDevice = async pubkey => {
const primaryDevice = window.storage.get('primaryDevicePubKey');
const secondaryDevices = await window.libloki.storage.getPairedDevicesFor(
primaryDevice
);
const allDevices = [primaryDevice, ...secondaryDevices];
return allDevices.includes(pubkey);
};
const ownDevice = await isOwnDevice(source);
let ev;
if (conversation.isMediumGroup() && ownDevice) {
// Data messages for medium groups don't arrive as sync messages. Instead,
// linked devices poll for group messages independently, thus they need
// to recognise some of those messages at their own.
ev = new Event('sent');
} else {
ev = new Event('message');
}
if (envelope.senderIdentity) {
message.group = {
id: envelope.source,
};
}
ev.confirm = this.removeFromCache.bind(this, envelope);
ev.data = {
friendRequest: isFriendRequest,
source,
sourceDevice: envelope.sourceDevice,
timestamp: envelope.timestamp.toNumber(),
receivedAt: envelope.receivedAt,
unidentifiedDeliveryReceived: envelope.unidentifiedDeliveryReceived,
message,
};
return this.dispatchAndWait(ev);
},
isMessageEmpty({
body,
attachments,
group,
flags,
quote,
contact,
preview,
groupInvitation,
mediumGroupUpdate,
}) {
return (
!flags &&
_.isEmpty(body) &&
_.isEmpty(attachments) &&
_.isEmpty(group) &&
_.isEmpty(quote) &&
_.isEmpty(contact) &&
_.isEmpty(preview) &&
_.isEmpty(groupInvitation) &&
_.isEmpty(mediumGroupUpdate)
);
},
handleLegacyMessage(envelope) {
return this.decrypt(envelope, envelope.legacyMessage).then(plaintext => {
if (!plaintext) {
window.log.warn('handleLegacyMessage: plaintext was falsey');
return null;
}
return this.innerHandleLegacyMessage(envelope, plaintext);
});
},
innerHandleLegacyMessage(envelope, plaintext) {
const message = textsecure.protobuf.DataMessage.decode(plaintext);
return this.handleDataMessage(envelope, message);
},
async handleContentMessage(envelope) {
const plaintext = await this.decrypt(envelope, envelope.content);
@ -1624,7 +1173,8 @@ MessageReceiver.prototype.extend({
return this.handleSyncMessage(envelope, content.syncMessage);
}
if (content.dataMessage) {
return this.handleDataMessage(envelope, content.dataMessage);
window.NewReceiver.handleDataMessage(envelope, content.dataMessage);
return undefined;
}
if (content.nullMessage) {
return this.handleNullMessage(envelope, content.nullMessage);
@ -1642,7 +1192,7 @@ MessageReceiver.prototype.extend({
return null;
},
handleCallMessage(envelope) {
window.log.info('call message from', this.getEnvelopeId(envelope));
window.log.info('call message from', getEnvelopeId(envelope));
this.removeFromCache(envelope);
},
handleReceiptMessage(envelope, receiptMessage) {
@ -1714,7 +1264,7 @@ MessageReceiver.prototype.extend({
return this.dispatchEvent(ev);
},
handleNullMessage(envelope) {
window.log.info('null message from', this.getEnvelopeId(envelope));
window.log.info('null message from', getEnvelopeId(envelope));
this.removeFromCache(envelope);
},
async handleSyncMessage(envelope, syncMessage) {
@ -1747,7 +1297,7 @@ MessageReceiver.prototype.extend({
to,
sentMessage.timestamp.toNumber(),
'from',
this.getEnvelopeId(envelope)
getEnvelopeId(envelope)
);
return this.handleSentMessage(envelope, sentMessage, sentMessage.message);
} else if (syncMessage.contacts) {
@ -1762,7 +1312,7 @@ MessageReceiver.prototype.extend({
window.log.info('Got SyncMessage Request');
return this.removeFromCache(envelope);
} else if (syncMessage.read && syncMessage.read.length) {
window.log.info('read messages from', this.getEnvelopeId(envelope));
window.log.info('read messages from', getEnvelopeId(envelope));
return this.handleRead(envelope, syncMessage.read);
} else if (syncMessage.verified) {
return this.handleVerified(envelope, syncMessage.verified);
@ -1908,39 +1458,6 @@ MessageReceiver.prototype.extend({
isBlocked(number) {
return textsecure.storage.get('blocked', []).indexOf(number) >= 0;
},
cleanAttachment(attachment) {
return {
..._.omit(attachment, 'thumbnail'),
id: attachment.id.toString(),
key: attachment.key ? attachment.key.toString('base64') : null,
digest: attachment.digest ? attachment.digest.toString('base64') : null,
};
},
async downloadAttachment(attachment) {
// The attachment id is actually just the absolute url of the attachment
let data = await this.server.getAttachment(attachment.url);
if (!attachment.isRaw) {
const { key, digest, size } = attachment;
data = await textsecure.crypto.decryptAttachment(
data,
window.Signal.Crypto.base64ToArrayBuffer(key),
window.Signal.Crypto.base64ToArrayBuffer(digest)
);
if (!size || size !== data.byteLength) {
throw new Error(
`downloadAttachment: Size ${size} did not match downloaded attachment size ${data.byteLength}`
);
}
}
return {
..._.omit(attachment, 'digest', 'key'),
data,
};
},
handleAttachment(attachment) {
// window.log.info('Not handling attachments.');
return Promise.resolve({
@ -1948,160 +1465,6 @@ MessageReceiver.prototype.extend({
data: dcodeIO.ByteBuffer.wrap(attachment.data).toArrayBuffer(), // ByteBuffer to ArrayBuffer
});
},
async handleEndSession(number) {
window.log.info('got end session');
let conversation;
try {
conversation = window.ConversationController.get(number);
if (conversation) {
await conversation.onSessionResetReceived();
} else {
throw new Error();
}
} catch (e) {
window.log.error('Error getting conversation: ', number);
}
},
processDecrypted(envelope, decrypted) {
/* eslint-disable no-bitwise, no-param-reassign */
const FLAGS = textsecure.protobuf.DataMessage.Flags;
// Now that its decrypted, validate the message and clean it up for consumer
// processing
// Note that messages may (generally) only perform one action and we ignore remaining
// fields after the first action.
if (decrypted.flags == null) {
decrypted.flags = 0;
}
if (decrypted.expireTimer == null) {
decrypted.expireTimer = 0;
}
if (decrypted.flags & FLAGS.END_SESSION) {
decrypted.body = null;
decrypted.attachments = [];
decrypted.group = null;
return Promise.resolve(decrypted);
} else if (decrypted.flags & FLAGS.EXPIRATION_TIMER_UPDATE) {
decrypted.body = null;
decrypted.attachments = [];
} else if (decrypted.flags & FLAGS.PROFILE_KEY_UPDATE) {
decrypted.body = null;
decrypted.attachments = [];
} else if (decrypted.flags & FLAGS.SESSION_REQUEST) {
// do nothing
} else if (decrypted.flags & FLAGS.SESSION_RESTORE) {
// do nothing
} else if (decrypted.flags & FLAGS.UNPAIRING_REQUEST) {
// do nothing
} else if (decrypted.flags !== 0) {
throw new Error('Unknown flags in message');
}
const promises = [];
if (decrypted.group !== null) {
decrypted.group.id = decrypted.group.id.toBinary();
switch (decrypted.group.type) {
case textsecure.protobuf.GroupContext.Type.UPDATE:
decrypted.body = null;
decrypted.attachments = [];
break;
case textsecure.protobuf.GroupContext.Type.QUIT:
decrypted.body = null;
decrypted.attachments = [];
break;
case textsecure.protobuf.GroupContext.Type.DELIVER:
decrypted.group.name = null;
decrypted.group.members = [];
decrypted.group.avatar = null;
break;
case textsecure.protobuf.GroupContext.Type.REQUEST_INFO:
decrypted.body = null;
decrypted.attachments = [];
break;
default:
this.removeFromCache(envelope);
throw new Error('Unknown group message type');
}
}
const attachmentCount = decrypted.attachments.length;
const ATTACHMENT_MAX = 32;
if (attachmentCount > ATTACHMENT_MAX) {
throw new Error(
`Too many attachments: ${attachmentCount} included in one message, max is ${ATTACHMENT_MAX}`
);
}
// Here we go from binary to string/base64 in all AttachmentPointer digest/key fields
if (
decrypted.group &&
decrypted.group.type === textsecure.protobuf.GroupContext.Type.UPDATE
) {
if (decrypted.group.avatar !== null) {
decrypted.group.avatar = this.cleanAttachment(decrypted.group.avatar);
}
}
decrypted.attachments = (decrypted.attachments || []).map(
this.cleanAttachment.bind(this)
);
decrypted.preview = (decrypted.preview || []).map(item => {
const { image } = item;
if (!image) {
return item;
}
return {
...item,
image: this.cleanAttachment(image),
};
});
decrypted.contact = (decrypted.contact || []).map(item => {
const { avatar } = item;
if (!avatar || !avatar.avatar) {
return item;
}
return {
...item,
avatar: {
...item.avatar,
avatar: this.cleanAttachment(item.avatar.avatar),
},
};
});
if (decrypted.quote && decrypted.quote.id) {
decrypted.quote.id = decrypted.quote.id.toNumber();
}
if (decrypted.quote) {
decrypted.quote.attachments = (decrypted.quote.attachments || []).map(
item => {
const { thumbnail } = item;
if (!thumbnail) {
return item;
}
return {
...item,
thumbnail: this.cleanAttachment(item.thumbnail),
};
}
);
}
return Promise.all(promises).then(() => decrypted);
/* eslint-enable no-bitwise, no-param-reassign */
},
});
window.textsecure = window.textsecure || {};
@ -2125,18 +1488,11 @@ textsecure.MessageReceiver = function MessageReceiverWrapper(
messageReceiver
);
this.getStatus = messageReceiver.getStatus.bind(messageReceiver);
this.handleEndSession = messageReceiver.handleEndSession.bind(
messageReceiver
);
this.close = messageReceiver.close.bind(messageReceiver);
this.savePreKeyBundleMessage = messageReceiver.savePreKeyBundleMessage.bind(
messageReceiver
);
this.downloadAttachment = messageReceiver.downloadAttachment.bind(
messageReceiver
);
this.pollForAdditionalId = messageReceiver.pollForAdditionalId.bind(
messageReceiver
);

View File

@ -26,7 +26,7 @@ const getTTLForType = type => {
case 'pairing-request':
return 2 * 60 * 1000; // 2 minutes for pairing requests
default:
return (window.getMessageTTL() || 24) * 60 * 60 * 1000; // 1 day default for any other message
return 24 * 60 * 60 * 1000; // 1 day default for any other message
}
};

View File

@ -17,7 +17,7 @@
"postinstall": "electron-builder install-app-deps && rimraf node_modules/dtrace-provider",
"start": "cross-env NODE_APP_INSTANCE=$MULTI electron .",
"start-prod": "cross-env NODE_ENV=production NODE_APP_INSTANCE=devprod$MULTI electron .",
"start-swarm-test": "cross-env NODE_ENV=production NODE_APP_INSTANCE=$MULTI electron .",
"start-swarm-test": "cross-env NODE_ENV=swarm-testing NODE_APP_INSTANCE=$MULTI electron .",
"grunt": "grunt",
"icon-gen": "electron-icon-maker --input=images/icon_1024.png --output=./build",
"generate": "yarn icon-gen && yarn grunt",
@ -115,6 +115,7 @@
"uuid": "3.3.2"
},
"devDependencies": {
"@types/backbone": "^1.4.2",
"@types/chai": "4.1.2",
"@types/chai-as-promised": "^7.1.2",
"@types/classnames": "2.2.3",

View File

@ -435,6 +435,8 @@ window.addEventListener('contextmenu', e => {
}
});
window.NewReceiver = require('./ts/receiver/receiver');
window.shortenPubkey = pubkey => `(...${pubkey.substring(pubkey.length - 6)})`;
window.pubkeyPattern = /@[a-fA-F0-9]{64,66}\b/g;

8
ts/global.d.ts vendored
View File

@ -25,6 +25,9 @@ interface Window {
Signal: any;
Whisper: any;
ConversationController: any;
MessageController: any;
StringView: any;
// Following function needs to be written in background.js
// getMemberList: any;
@ -59,6 +62,11 @@ interface Window {
lokiFeatureFlags: any;
resetDatabase: any;
restart: () => void;
lokiFileServerAPI: any;
WebAPI: any;
SenderKeyAPI: any;
}
interface Promise<T> {

241
ts/receiver/attachments.ts Normal file
View File

@ -0,0 +1,241 @@
import { MessageModel } from './message';
// TODO: Might convert it to a class later
let webAPI: any;
export async function downloadAttachment(attachment: any) {
const _ = window.Lodash;
if (!webAPI) {
webAPI = window.WebAPI.connect();
}
// The attachment id is actually just the absolute url of the attachment
let data = await webAPI.getAttachment(attachment.url);
if (!attachment.isRaw) {
const { key, digest, size } = attachment;
data = await window.textsecure.crypto.decryptAttachment(
data,
window.Signal.Crypto.base64ToArrayBuffer(key),
window.Signal.Crypto.base64ToArrayBuffer(digest)
);
if (!size || size !== data.byteLength) {
throw new Error(
`downloadAttachment: Size ${size} did not match downloaded attachment size ${data.byteLength}`
);
}
}
return {
..._.omit(attachment, 'digest', 'key'),
data,
};
}
async function processLongAttachments(
message: MessageModel,
attachments: Array<any>
): Promise<boolean> {
if (attachments.length === 0) {
return false;
}
if (attachments.length > 1) {
window.log.error(
`Received more than one long message attachment in message ${message.idForLogging()}`
);
}
const attachment = attachments[0];
message.set({ bodyPending: true });
await window.Signal.AttachmentDownloads.addJob(attachment, {
messageId: message.id,
type: 'long-message',
index: 0,
});
return true;
}
async function processNormalAttachments(
message: MessageModel,
normalAttachments: Array<any>
): Promise<number> {
const attachments = await Promise.all(
normalAttachments.map((attachment: any, index: any) => {
return window.Signal.AttachmentDownloads.addJob(attachment, {
messageId: message.id,
type: 'attachment',
index,
});
})
);
message.set({ attachments });
return attachments.length;
}
async function processPreviews(message: MessageModel): Promise<number> {
let addedCount = 0;
const preview = await Promise.all(
(message.get('preview') || []).map(async (item: any, index: any) => {
if (!item.image) {
return item;
}
addedCount += 1;
const image = await window.Signal.AttachmentDownloads.addJob(item.image, {
messageId: message.id,
type: 'preview',
index,
});
return { ...item, image };
})
);
message.set({ preview });
return addedCount;
}
async function processAvatars(message: MessageModel): Promise<number> {
let addedCount = 0;
const contacts = message.get('contact') || [];
const contact = await Promise.all(
contacts.map(async (item: any, index: any) => {
if (!item.avatar || !item.avatar.avatar) {
return item;
}
addedCount += 1;
const avatarJob = await window.Signal.AttachmentDownloads.addJob(
item.avatar.avatar,
{
messaeId: message.id,
type: 'contact',
index,
}
);
return {
...item,
avatar: {
...item.avatar,
avatar: avatarJob,
},
};
})
);
message.set({ contact });
return addedCount;
}
async function processQuoteAttachments(message: MessageModel): Promise<number> {
let addedCount = 0;
const quote = message.get('quote');
if (!quote || !quote.attachments || !quote.attachments.length) {
return 0;
}
quote.attachments = await Promise.all(
quote.attachments.map(async (item: any, index: any) => {
// If we already have a path, then we copied this image from the quoted
// message and we don't need to download the attachment.
if (!item.thumbnail || item.thumbnail.path) {
return item;
}
addedCount += 1;
const thumbnail = await window.Signal.AttachmentDownloads.addJob(
item.thumbnail,
{
messageId: message.id,
type: 'quote',
index,
}
);
return { ...item, thumbnail };
})
);
message.set({ quote });
return addedCount;
}
async function processGroupAvatar(message: MessageModel): Promise<boolean> {
let group = message.get('group');
if (!group || !group.avatar) {
return false;
}
group = {
...group,
avatar: await window.Signal.AttachmentDownloads.addJob(group.avatar, {
messageId: message.id,
type: 'group-avatar',
index: 0,
}),
};
message.set({ group });
return true;
}
export async function queueAttachmentDownloads(
message: MessageModel
): Promise<boolean> {
const _ = window.Lodash;
const { Whisper } = window;
let count = 0;
const [longMessageAttachments, normalAttachments] = _.partition(
message.get('attachments') || [],
(attachment: any) =>
attachment.contentType === Whisper.Message.LONG_MESSAGE_CONTENT_TYPE
);
if (await processLongAttachments(message, longMessageAttachments)) {
count += 1;
}
count += await processNormalAttachments(message, normalAttachments);
count += await processPreviews(message);
count += await processAvatars(message);
count += await processQuoteAttachments(message);
if (await processGroupAvatar(message)) {
count += 1;
}
if (count > 0) {
await window.Signal.Data.saveMessage(message.attributes, {
Message: Whisper.Message,
});
return true;
}
return false;
}

7
ts/receiver/cache.ts Normal file
View File

@ -0,0 +1,7 @@
import { EnvelopePlus } from './types';
export function removeFromCache(envelope: EnvelopePlus) {
const { id } = envelope;
return window.textsecure.storage.unprocessed.remove(id);
}

39
ts/receiver/conversation.d.ts vendored Normal file
View File

@ -0,0 +1,39 @@
interface ConversationAttributes {
members: Array<string>;
left: boolean;
expireTimer: number;
profileSharing: boolean;
mentionedUs: boolean;
unreadCount: number;
isArchived: boolean;
active_at: number;
timestamp: number; // timestamp of what?
}
export interface ConversationModel
extends Backbone.Model<ConversationAttributes> {
setFriendRequestStatus: (status: any) => Promise<void>;
idForLogging: () => string;
saveChangesToDB: () => Promise<void>;
notifyFriendRequest: (source: string, type: string) => Promise<void>;
notify: (message: MessageModel) => void;
isSessionResetReceived: () => boolean;
updateExpirationTimer: (
expireTimer: number | null,
source: string,
receivedAt: number,
options: object
) => void;
isPrivate: () => boolean;
setProfileKey: (key: string) => void;
isMe: () => boolean;
getRecipients: () => Array<string>;
onReadMessage: (message: MessageModel) => void;
updateTextInputState: () => void;
isFriend: () => boolean;
hasSentFriendRequest: () => boolean;
onFriendRequestAccepted: () => Promise<void>;
onFriendRequestReceived: () => Promise<void>;
lastMessage: string;
}

View File

@ -0,0 +1,41 @@
import { MessageModel } from './message';
interface ConversationAttributes {
members: Array<string>;
left: boolean;
expireTimer: number;
profileSharing: boolean;
mentionedUs: boolean;
unreadCount: number;
isArchived: boolean;
active_at: number;
timestamp: number; // timestamp of what?
}
export interface ConversationModel
extends Backbone.Model<ConversationAttributes> {
setFriendRequestStatus: (status: any) => Promise<void>;
idForLogging: () => string;
saveChangesToDB: () => Promise<void>;
notifyFriendRequest: (source: string, type: string) => Promise<void>;
notify: (message: MessageModel) => void;
isSessionResetReceived: () => boolean;
updateExpirationTimer: (
expireTimer: number | null,
source: string,
receivedAt: number,
options: object
) => void;
isPrivate: () => boolean;
setProfileKey: (key: string) => void;
isMe: () => boolean;
getRecipients: () => Array<string>;
onReadMessage: (message: MessageModel) => void;
updateTextInputState: () => void;
isFriend: () => boolean;
hasSentFriendRequest: () => boolean;
onFriendRequestAccepted: () => Promise<void>;
onFriendRequestReceived: () => Promise<void>;
lastMessage: string;
}

116
ts/receiver/groups.ts Normal file
View File

@ -0,0 +1,116 @@
import { SignalService } from '../protobuf';
const _ = window.Lodash;
function isGroupBlocked(groupId: string) {
return (
window.textsecure.storage.get('blocked-groups', []).indexOf(groupId) >= 0
);
}
function shouldIgnoreBlockedGroup(group: any, senderPubKey: string) {
const groupId = group.id;
const isBlocked = isGroupBlocked(groupId);
const isLeavingGroup = Boolean(
group.type === SignalService.GroupContext.Type.QUIT
);
const primaryDevicePubKey = window.storage.get('primaryDevicePubKey');
const isMe =
senderPubKey === window.textsecure.storage.user.getNumber() ||
senderPubKey === primaryDevicePubKey;
return isBlocked && !(isMe && isLeavingGroup);
}
/**
* Returns true if the message is already completely handled and confirmed
* and the processing of this message must stop.
*/
export async function preprocessGroupMessage(
source: string,
group: any,
primarySource: string
) {
const conversationId = group.id;
const conversation = await window.ConversationController.getOrCreateAndWait(
conversationId,
'group'
);
const GROUP_TYPES = SignalService.GroupContext.Type;
if (shouldIgnoreBlockedGroup(group, source)) {
window.log.warn('Message ignored; destined for blocked group');
return true;
}
// NOTE: we use friends status to tell if this is
// the creation of the group (initial update)
const newGroup = !conversation.isFriend();
const knownMembers = conversation.get('members');
if (!newGroup && knownMembers) {
const fromMember = knownMembers.includes(primarySource);
// if the group exists and we have its members,
// we must drop a message from anyone else than the existing members.
if (!fromMember) {
window.log.warn(
`Ignoring group message from non-member: ${primarySource}`
);
// returning true drops the message
return true;
}
}
if (group.type === GROUP_TYPES.REQUEST_INFO && !newGroup) {
window.libloki.api.debug.logGroupRequestInfo(
`Received GROUP_TYPES.REQUEST_INFO from source: ${source}, primarySource: ${primarySource}, sending back group info.`
);
conversation.sendGroupInfo([source]);
return true;
}
if (group.members && group.type === GROUP_TYPES.UPDATE) {
if (newGroup) {
conversation.updateGroupAdmins(group.admins);
conversation.setFriendRequestStatus(
window.friends.friendRequestStatusEnum.friends
);
} else {
// be sure to drop a message from a non admin if it tries to change group members
// or change the group name
const fromAdmin = conversation.get('groupAdmins').includes(primarySource);
if (!fromAdmin) {
// Make sure the message is not removing members / renaming the group
const nameChanged = conversation.get('name') !== group.name;
if (nameChanged) {
window.log.warn('Non-admin attempts to change the name of the group');
}
const membersMissing =
_.difference(conversation.get('members'), group.members).length > 0;
if (membersMissing) {
window.log.warn('Non-admin attempts to remove group members');
}
const messageAllowed = !nameChanged && !membersMissing;
// Returning true drops the message
if (!messageAllowed) {
return true;
}
}
}
// send a session request for all the members we do not have a session with
window.libloki.api.sendSessionRequestsToMembers(group.members);
} else if (newGroup) {
// We have an unknown group, we should request info from the sender
window.textsecure.messaging.requestGroupInfo(conversationId, [
primarySource,
]);
}
return false;
}

211
ts/receiver/mediumGroups.ts Normal file
View File

@ -0,0 +1,211 @@
import { SignalService } from '../protobuf';
import { removeFromCache } from './cache';
import { EnvelopePlus } from './types';
async function handleSenderKeyRequest(
envelope: EnvelopePlus,
groupUpdate: any
) {
const { SenderKeyAPI, StringView, textsecure, log } = window;
const senderIdentity = envelope.source;
const ourIdentity = await textsecure.storage.user.getNumber();
const { groupId } = groupUpdate;
log.debug('[sender key] sender key request from:', senderIdentity);
const proto = new SignalService.DataMessage();
// We reuse the same message type for sender keys
const update = new SignalService.MediumGroupUpdate();
const { chainKey, keyIdx } = await SenderKeyAPI.getSenderKeys(
groupId,
ourIdentity
);
update.type = SignalService.MediumGroupUpdate.Type.SENDER_KEY;
update.groupId = groupId;
update.senderKey = new SignalService.SenderKey({
chainKey: StringView.arrayBufferToHex(chainKey),
keyIdx,
});
proto.mediumGroupUpdate = update;
textsecure.messaging.updateMediumGroup([senderIdentity], proto);
removeFromCache(envelope);
}
async function handleSenderKey(envelope: EnvelopePlus, groupUpdate: any) {
const { SenderKeyAPI, log } = window;
const { groupId, senderKey } = groupUpdate;
const senderIdentity = envelope.source;
log.debug('[sender key] got a new sender key from:', senderIdentity);
await SenderKeyAPI.saveSenderKeys(
groupId,
senderIdentity,
senderKey.chainKey,
senderKey.keyIdx
);
removeFromCache(envelope);
}
async function handleNewGroup(envelope: EnvelopePlus, groupUpdate: any) {
const {
SenderKeyAPI,
StringView,
Whisper,
log,
textsecure,
Lodash: _,
} = window;
const senderIdentity = envelope.source;
const ourIdentity = await textsecure.storage.user.getNumber();
const {
groupId,
members: membersBinary,
groupSecretKey,
groupName,
senderKey,
admins,
} = groupUpdate;
const maybeConvo = await window.ConversationController.get(groupId);
const groupExists = !!maybeConvo;
const members = membersBinary.map((pk: any) =>
StringView.arrayBufferToHex(pk.toArrayBuffer())
);
const convo = groupExists
? maybeConvo
: await window.ConversationController.getOrCreateAndWait(groupId, 'group');
{
// Add group update message
const now = Date.now();
const message = convo.messageCollection.add({
conversationId: convo.id,
type: 'incoming',
sent_at: now,
received_at: now,
group_update: {
name: groupName,
members,
},
});
const messageId = await window.Signal.Data.saveMessage(message.attributes, {
Message: Whisper.Message,
});
message.set({ id: messageId });
}
if (groupExists) {
// ***** Updating the group *****
log.info('Received a group update for medium group:', groupId);
// Check that the sender is admin (make sure it words with multidevice)
const isAdmin = convo.get('groupAdmins').includes(senderIdentity);
if (!isAdmin) {
log.warn('Rejected attempt to update a group by non-admin');
removeFromCache(envelope);
return;
}
convo.set('name', groupName);
convo.set('members', members);
// TODO: check that we are still in the group (when we enable deleting members)
convo.saveChangesToDB();
// Update other fields. Add a corresponding "update" message to the conversation
} else {
// ***** Creating a new group *****
log.info('Received a new medium group:', groupId);
// TODO: Check that we are even a part of this group?
convo.set('is_medium_group', true);
convo.set('active_at', Date.now());
convo.set('name', groupName);
convo.set('groupAdmins', admins);
convo.setFriendRequestStatus(
window.friends.friendRequestStatusEnum.friends
);
const secretKeyHex = StringView.arrayBufferToHex(
groupSecretKey.toArrayBuffer()
);
await window.Signal.Data.createOrUpdateIdentityKey({
id: groupId,
secretKey: secretKeyHex,
});
// Save sender's key
await SenderKeyAPI.saveSenderKeys(
groupId,
envelope.source,
senderKey.chainKey,
senderKey.keyIdx
);
const ownSenderKey = await SenderKeyAPI.createSenderKeyForGroup(
groupId,
ourIdentity
);
{
// Send own key to every member
const otherMembers = _.without(members, ourIdentity);
const proto = new SignalService.DataMessage();
// We reuse the same message type for sender keys
const update = new SignalService.MediumGroupUpdate();
update.type = SignalService.MediumGroupUpdate.Type.SENDER_KEY;
update.groupId = groupId;
update.senderKey = new SignalService.SenderKey({
chainKey: ownSenderKey,
keyIdx: 0,
});
proto.mediumGroupUpdate = update;
textsecure.messaging.updateMediumGroup(otherMembers, proto);
}
// TODO: !!!! This will need to be re-enabled after message polling refactor !!!!!
// Subscribe to this group
// this.pollForAdditionalId(groupId);
}
removeFromCache(envelope);
}
export async function handleMediumGroupUpdate(
envelope: EnvelopePlus,
groupUpdate: any
) {
const { type } = groupUpdate;
const { Type } = SignalService.MediumGroupUpdate;
if (type === Type.SENDER_KEY_REQUEST) {
await handleSenderKeyRequest(envelope, groupUpdate);
} else if (type === Type.SENDER_KEY) {
await handleSenderKey(envelope, groupUpdate);
} else if (type === Type.NEW_GROUP) {
await handleNewGroup(envelope, groupUpdate);
}
}

56
ts/receiver/message.d.ts vendored Normal file
View File

@ -0,0 +1,56 @@
enum MessageModelType {
INCOMING = 'incoming',
OUTGOING = 'outgoing',
FRIEND_REQUEST = 'friend-request',
}
export enum EndSessionType {
DONE = 'done',
ONGOING = 'ongoing',
}
interface MessageAttributes {
id: number;
source: string;
endSessionType: EndSessionType;
quote: any;
expireTimer: number;
received_at: number;
sent_at: number;
preview: any;
body: string;
expirationStartTimestamp: any;
read_by: Array<string>;
delivered_to: Array<string>;
decrypted_at: number;
recipients: Array<string>;
delivered: number;
friendStatus: any;
type: MessageModelType;
group_update: any;
groupInvitation: any;
attachments: any;
contact: any;
conversationId: any;
errors: any;
flags: number;
hasAttachments: boolean;
hasFileAttachments: boolean;
hasVisualMediaAttachments: boolean;
schemaVersion: number;
expirationTimerUpdate: any;
unread: boolean;
group: any;
bodyPending: boolean;
}
export interface MessageModel extends Backbone.Model<MessageAttributes> {
idForLogging: () => string;
isGroupUpdate: () => boolean;
isExpirationTimerUpdate: () => boolean;
isFriendRequest: () => boolean;
getNotificationText: () => string;
isEndSession: () => boolean;
markRead: () => void;
merge: (other: MessageModel) => void;
}

56
ts/receiver/message.ts Normal file
View File

@ -0,0 +1,56 @@
enum MessageModelType {
INCOMING = 'incoming',
OUTGOING = 'outgoing',
FRIEND_REQUEST = 'friend-request',
}
export enum EndSessionType {
DONE = 'done',
ONGOING = 'ongoing',
}
interface MessageAttributes {
id: number;
source: string;
endSessionType: EndSessionType;
quote: any;
expireTimer: number;
received_at: number;
sent_at: number;
preview: any;
body: string;
expirationStartTimestamp: any;
read_by: Array<string>;
delivered_to: Array<string>;
decrypted_at: number;
recipients: Array<string>;
delivered: number;
friendStatus: any;
type: MessageModelType;
group_update: any;
groupInvitation: any;
attachments: any;
contact: any;
conversationId: any;
errors: any;
flags: number;
hasAttachments: boolean;
hasFileAttachments: boolean;
hasVisualMediaAttachments: boolean;
schemaVersion: number;
expirationTimerUpdate: any;
unread: boolean;
group: any;
bodyPending: boolean;
}
export interface MessageModel extends Backbone.Model<MessageAttributes> {
idForLogging: () => string;
isGroupUpdate: () => boolean;
isExpirationTimerUpdate: () => boolean;
isFriendRequest: () => boolean;
getNotificationText: () => string;
isEndSession: () => boolean;
markRead: () => void;
merge: (other: MessageModel) => void;
}

View File

@ -0,0 +1,70 @@
import { removeFromCache } from './cache';
import { EnvelopePlus } from './types';
async function unpairingRequestIsLegit(source: string, ourPubKey: string) {
const { textsecure, storage, lokiFileServerAPI } = window;
const isSecondary = textsecure.storage.get('isSecondaryDevice');
if (!isSecondary) {
return false;
}
const primaryPubKey = storage.get('primaryDevicePubKey');
// TODO: allow unpairing from any paired device?
if (source !== primaryPubKey) {
return false;
}
const primaryMapping = await lokiFileServerAPI.getUserDeviceMapping(
primaryPubKey
);
// If we don't have a mapping on the primary then we have been unlinked
if (!primaryMapping) {
return true;
}
// We expect the primary device to have updated its mapping
// before sending the unpairing request
const found = primaryMapping.authorisations.find(
(authorisation: any) => authorisation.secondaryDevicePubKey === ourPubKey
);
// our pubkey should NOT be in the primary device mapping
return !found;
}
async function clearAppAndRestart() {
// remove our device mapping annotations from file server
await window.lokiFileServerAPI.clearOurDeviceMappingAnnotations();
// Delete the account and restart
try {
await window.Signal.Logs.deleteAll();
await window.Signal.Data.removeAll();
await window.Signal.Data.close();
await window.Signal.Data.removeDB();
await window.Signal.Data.removeOtherData();
// TODO generate an empty db with a flag
// to display a message about the unpairing
// after the app restarts
} catch (error) {
window.log.error(
'Something went wrong deleting all data:',
error && error.stack ? error.stack : error
);
}
window.restart();
}
export async function handleUnpairRequest(
envelope: EnvelopePlus,
ourPubKey: string
) {
// TODO: move high-level pairing logic to libloki.multidevice.xx
const legit = await unpairingRequestIsLegit(envelope.source, ourPubKey);
removeFromCache(envelope);
if (legit) {
await clearAppAndRestart();
}
}

0
ts/receiver/polling.rs Normal file
View File

685
ts/receiver/queuedJob.ts Normal file
View File

@ -0,0 +1,685 @@
import { queueAttachmentDownloads } from './attachments';
import { Quote } from './types';
import { ConversationModel } from './conversation';
import { EndSessionType, MessageModel } from './message';
async function handleGroups(
conversation: ConversationModel,
group: any,
source: any
): Promise<any> {
const _ = window.Lodash;
const textsecure = window.textsecure;
const GROUP_TYPES = textsecure.protobuf.GroupContext.Type;
// TODO: this should be primary device id!
const ourNumber = textsecure.storage.user.getNumber();
let groupUpdate = null;
// conversation attributes
const attributes: any = {
type: 'group',
groupId: group.id,
};
const oldMembers = conversation.get('members');
if (group.type === GROUP_TYPES.UPDATE) {
attributes.name = group.name;
attributes.members = group.members;
groupUpdate =
conversation.changedAttributes(_.pick(group, 'name', 'avatar')) || {};
const addedMembers = _.difference(attributes.members, oldMembers);
if (addedMembers.length > 0) {
groupUpdate.joined = addedMembers;
}
if (conversation.get('left')) {
// TODO: Maybe we shouldn't assume this message adds us:
// we could maybe still get this message by mistake
window.log.warn('re-added to a left group');
attributes.left = false;
}
if (attributes.isKickedFromGroup) {
// Assume somebody re-invited us since we received this update
attributes.isKickedFromGroup = false;
}
// Check if anyone got kicked:
const removedMembers = _.difference(oldMembers, attributes.members);
if (removedMembers.includes(ourNumber)) {
groupUpdate.kicked = 'You';
attributes.isKickedFromGroup = true;
} else {
groupUpdate.kicked = removedMembers;
}
} else if (group.type === GROUP_TYPES.QUIT) {
if (source === ourNumber) {
attributes.left = true;
groupUpdate = { left: 'You' };
} else {
groupUpdate = { left: source };
}
attributes.members = _.without(oldMembers, source);
}
conversation.set(attributes);
return groupUpdate;
}
function handleSessionReset(
conversation: ConversationModel,
message: MessageModel
) {
const endSessionType = conversation.isSessionResetReceived()
? EndSessionType.ONGOING
: EndSessionType.DONE;
message.set({ endSessionType });
}
function contentTypeSupported(type: any): boolean {
const Chrome = window.Signal.Util.GoogleChrome;
return Chrome.isImageTypeSupported(type) || Chrome.isVideoTypeSupported(type);
}
async function copyFromQuotedMessage(
msg: MessageModel,
quote: Quote,
attemptCount: number = 1
): Promise<void> {
const _ = window.Lodash;
const { Whisper, MessageController } = window;
const { upgradeMessageSchema } = window.Signal.Migrations;
const { Message: TypedMessage, Errors } = window.Signal.Types;
if (!quote) {
return;
}
const { attachments, id, author } = quote;
const firstAttachment = attachments[0];
const collection = await window.Signal.Data.getMessagesBySentAt(id, {
MessageCollection: Whisper.MessageCollection,
});
const found = collection.find((item: any) => {
const messageAuthor = item.getContact();
return messageAuthor && author === messageAuthor.id;
});
if (!found) {
// Exponential backoff, giving up after 5 attempts:
if (attemptCount < 5) {
setTimeout(() => {
window.log.info(
`Looking for the message id : ${id}, attempt: ${attemptCount + 1}`
);
copyFromQuotedMessage(msg, quote, attemptCount + 1).ignore();
}, attemptCount * attemptCount * 500);
}
quote.referencedMessageNotFound = true;
return;
}
window.log.info(`Found quoted message id: ${id}`);
quote.referencedMessageNotFound = false;
const queryMessage = MessageController.register(found.id, found);
quote.text = queryMessage.get('body');
if (attemptCount > 1) {
// Normally the caller would save the message, but in case we are
// called by a timer, we need to update the message manually
msg.set({ quote });
await window.Signal.Data.saveMessage(msg.attributes, {
Message: Whisper.Message,
});
return;
}
if (!firstAttachment || !contentTypeSupported(firstAttachment)) {
return;
}
firstAttachment.thumbnail = null;
try {
if (
queryMessage.get('schemaVersion') <
TypedMessage.VERSION_NEEDED_FOR_DISPLAY
) {
const upgradedMessage = await upgradeMessageSchema(
queryMessage.attributes
);
queryMessage.set(upgradedMessage);
await window.Signal.Data.saveMessage(upgradedMessage, {
Message: Whisper.Message,
});
}
} catch (error) {
window.log.error(
'Problem upgrading message quoted message from database',
Errors.toLogFormat(error)
);
return;
}
const queryAttachments = queryMessage.get('attachments') || [];
if (queryAttachments.length > 0) {
const queryFirst = queryAttachments[0];
const { thumbnail } = queryFirst;
if (thumbnail && thumbnail.path) {
firstAttachment.thumbnail = {
...thumbnail,
copied: true,
};
}
}
const queryPreview = queryMessage.get('preview') || [];
if (queryPreview.length > 0) {
const queryFirst = queryPreview[0];
const { image } = queryFirst;
if (image && image.path) {
firstAttachment.thumbnail = {
...image,
copied: true,
};
}
}
}
// Handle expiration timer as part of a regular message
function handleExpireTimer(
source: string,
message: MessageModel,
expireTimer: number,
conversation: ConversationModel
) {
const oldValue = conversation.get('expireTimer');
if (expireTimer) {
message.set({ expireTimer });
if (expireTimer !== oldValue) {
conversation.updateExpirationTimer(
expireTimer,
source,
message.get('received_at'),
{
fromGroupUpdate: message.isGroupUpdate(), // WHAT DOES GROUP UPDATE HAVE TO DO WITH THIS???
}
);
}
} else if (oldValue && !message.isGroupUpdate()) {
// We only turn off timers if it's not a group update
conversation.updateExpirationTimer(
null,
source,
message.get('received_at'),
{}
);
}
}
function handleLinkPreviews(
messageBody: string,
messagePreview: any,
message: MessageModel
) {
const urls = window.Signal.LinkPreviews.findLinks(messageBody);
const incomingPreview = messagePreview || [];
const preview = incomingPreview.filter(
(item: any) =>
(item.image || item.title) &&
urls.includes(item.url) &&
window.Signal.LinkPreviews.isLinkInWhitelist(item.url)
);
if (preview.length < incomingPreview.length) {
window.log.info(
`${message.idForLogging()}: Eliminated ${preview.length -
incomingPreview.length} previews with invalid urls'`
);
}
message.set({ preview });
}
function processProfileKey(
source: string,
conversation: ConversationModel,
sendingDeviceConversation: ConversationModel,
profileKeyBuffer: any
) {
const ourNumber = window.textsecure.storage.user.getNumber();
const profileKey = profileKeyBuffer.toString('base64');
if (source === ourNumber) {
conversation.set({ profileSharing: true });
} else if (conversation.isPrivate()) {
conversation.setProfileKey(profileKey);
} else {
sendingDeviceConversation.setProfileKey(profileKey);
}
}
function handleMentions(
message: MessageModel,
conversation: ConversationModel,
ourPrimaryNumber: string
) {
const body = message.get('body');
if (body && body.indexOf(`@${ourPrimaryNumber}`) !== -1) {
conversation.set({ mentionedUs: true });
}
}
function updateReadStatus(
message: MessageModel,
conversation: ConversationModel
) {
const readSync = window.Whisper.ReadSyncs.forMessage(message);
if (readSync) {
const shouldExpire = message.get('expireTimer');
const alreadyStarted = message.get('expirationStartTimestamp');
if (shouldExpire && !alreadyStarted) {
// Start message expiration timer
const start = Math.min(readSync.get('read_at'), Date.now());
message.set('expirationStartTimestamp', start);
}
}
if (readSync || message.isExpirationTimerUpdate()) {
message.unset('unread');
// This is primarily to allow the conversation to mark all older
// messages as read, as is done when we receive a read sync for
// a message we already know about.
conversation.onReadMessage(message);
} else {
conversation.set({
unreadCount: conversation.get('unreadCount') + 1,
isArchived: false,
});
}
}
function handleSyncedReceipts(
message: MessageModel,
conversation: ConversationModel
) {
const _ = window.Lodash;
const readReceipts = window.Whisper.ReadReceipts.forMessage(
conversation,
message
);
if (readReceipts.length) {
const readBy = readReceipts.map((receipt: any) => receipt.get('reader'));
message.set({
read_by: _.union(message.get('read_by'), readBy),
});
}
const deliveryReceipts = window.Whisper.DeliveryReceipts.forMessage(
conversation,
message
);
if (deliveryReceipts.length) {
handleSyncDeliveryReceipts(message, deliveryReceipts);
}
// A sync'd message to ourself is automatically considered read and delivered
const recipients = conversation.getRecipients();
if (conversation.isMe()) {
message.set({
read_by: recipients,
delivered_to: recipients,
});
}
message.set({ recipients });
}
function handleSyncDeliveryReceipts(message: MessageModel, receipts: any) {
const _ = window.Lodash;
const sources = receipts.map((receipt: any) => receipt.get('source'));
const deliveredTo = _.union(message.get('delivered_to') || [], sources);
const deliveredCount = deliveredTo.length;
message.set({
delivered: deliveredCount,
delivered_to: deliveredTo,
});
}
async function handleFriendRequest(
message: MessageModel,
source: string,
primarySource: string,
sendingDeviceConversation: ConversationModel
) {
if (message.isFriendRequest()) {
/*
Here is the before and after state diagram for the operation before.
None -> RequestReceived
PendingSend -> RequestReceived
RequestReceived -> RequestReceived
Sent -> Friends
Expired -> Friends
Friends -> Friends
The cases where we auto accept are the following:
- We sent the user a friend request,
and that user sent us a friend request.
- We are friends with the user,
and that user just sent us a friend request.
*/
const isFriend = sendingDeviceConversation.isFriend();
const hasSentFriendRequest = sendingDeviceConversation.hasSentFriendRequest();
const autoAccept = isFriend || hasSentFriendRequest;
if (autoAccept) {
message.set({ friendStatus: 'accepted' });
}
window.libloki.api.debug.logNormalFriendRequest(
`Received a NORMAL_FRIEND_REQUEST from source: ${source}, primarySource: ${primarySource}, isAlreadyFriend: ${isFriend}, didWeAlreadySentFR: ${hasSentFriendRequest}`
);
if (isFriend) {
// Why end session here?
window.Whisper.events.trigger('endSession', source);
} else if (hasSentFriendRequest) {
await sendingDeviceConversation.onFriendRequestAccepted();
} else {
await sendingDeviceConversation.onFriendRequestReceived();
}
} else if (message.get('type') === 'incoming') {
// Responses to a FR will be handled here:
await sendingDeviceConversation.onFriendRequestAccepted();
}
}
async function handleRegularMessage(
conversation: ConversationModel,
message: MessageModel,
initialMessage: any,
source: string,
isGroupMessage: boolean,
ourNumber: any,
primarySource: any
) {
const _ = window.Lodash;
const {
textsecure,
Whisper,
ConversationController,
MessageController,
libloki,
} = window;
const { upgradeMessageSchema } = window.Signal.Migrations;
const type = message.get('type');
await copyFromQuotedMessage(message, initialMessage.quote);
// `upgradeMessageSchema` only seems to add `schemaVersion: 10` to the message
const dataMessage = await upgradeMessageSchema(initialMessage);
const now = new Date().getTime();
if (dataMessage.group) {
// This is not necessarily a group update message, it could also be a regular group message
const groupUpdate = await handleGroups(
conversation,
dataMessage.group,
source
);
if (groupUpdate !== null) {
message.set({ group_update: groupUpdate });
}
}
if (dataMessage.groupInvitation) {
message.set({ groupInvitation: dataMessage.groupInvitation });
}
handleLinkPreviews(dataMessage.body, dataMessage.preview, message);
message.set({
flags: dataMessage.flags,
hasAttachments: dataMessage.hasAttachments,
hasFileAttachments: dataMessage.hasFileAttachments,
hasVisualMediaAttachments: dataMessage.hasVisualMediaAttachments,
quote: dataMessage.quote,
schemaVersion: dataMessage.schemaVersion,
attachments: dataMessage.attachments,
body: dataMessage.body,
contact: dataMessage.contact,
conversationId: conversation.id,
decrypted_at: now,
errors: [],
});
conversation.set({ active_at: now });
// Re-enable typing if re-joined the group
conversation.updateTextInputState();
// Handle expireTimer found directly as part of a regular message
handleExpireTimer(source, message, dataMessage.expireTimer, conversation);
handleMentions(message, conversation, ourNumber);
if (type === 'incoming' || type === 'friend-request') {
updateReadStatus(message, conversation);
}
if (type === 'outgoing') {
handleSyncedReceipts(message, conversation);
}
const conversationTimestamp = conversation.get('timestamp');
if (
!conversationTimestamp ||
message.get('sent_at') > conversationTimestamp
) {
conversation.lastMessage = message.getNotificationText();
conversation.set({
timestamp: message.get('sent_at'),
});
}
const sendingDeviceConversation = await ConversationController.getOrCreateAndWait(
source,
'private'
);
if (dataMessage.profileKey) {
processProfileKey(
source,
conversation,
sendingDeviceConversation,
dataMessage.profileKey
);
}
// Make sure friend request logic doesn't trigger on messages aimed at groups
if (!isGroupMessage) {
// We already handled (and returned) session request and auto Friend Request before,
// so that can only be a normal Friend Request
await handleFriendRequest(
message,
source,
primarySource,
sendingDeviceConversation
);
}
if (source !== ourNumber) {
message.set({ source: primarySource });
}
}
function handleExpirationTimerUpdate(
conversation: ConversationModel,
message: MessageModel,
source: string,
expireTimer: number
) {
// TODO: if the message is an expiration timer update, it
// shouldn't be responsible for anything else!!!
message.set({
expirationTimerUpdate: {
source,
expireTimer,
},
});
conversation.set({ expireTimer });
window.log.info("Update conversation 'expireTimer'", {
id: conversation.idForLogging(),
expireTimer,
source: 'handleDataMessage',
});
conversation.updateExpirationTimer(
expireTimer,
source,
message.get('received_at'),
{
fromGroupUpdate: message.isGroupUpdate(), // WHAT DOES GROUP UPDATE HAVE TO DO WITH THIS???
}
);
}
export async function handleMessageJob(
message: MessageModel,
conversation: ConversationModel,
initialMessage: any,
ourNumber: string,
confirm: any,
source: string,
isGroupMessage: any,
primarySource: any
) {
window.log.info(
`Starting handleDataMessage for message ${message.idForLogging()} in conversation ${conversation.idForLogging()}`
);
try {
message.set({ flags: initialMessage.flags });
if (message.isEndSession()) {
handleSessionReset(conversation, message);
} else if (message.isExpirationTimerUpdate()) {
const { expireTimer } = initialMessage;
handleExpirationTimerUpdate(conversation, message, source, expireTimer);
} else {
await handleRegularMessage(
conversation,
message,
initialMessage,
source,
isGroupMessage,
ourNumber,
primarySource
);
}
const { Whisper, MessageController, ConversationController } = window;
const id = await window.Signal.Data.saveMessage(message.attributes, {
Message: Whisper.Message,
});
message.set({ id });
MessageController.register(message.id, message);
// Note that this can save the message again, if jobs were queued. We need to
// call it after we have an id for this message, because the jobs refer back
// to their source message.
await queueAttachmentDownloads(message);
await conversation.saveChangesToDB();
conversation.trigger('newmessage', message);
try {
// We go to the database here because, between the message save above and
// the previous line's trigger() call, we might have marked all messages
// unread in the database. This message might already be read!
const fetched = await window.Signal.Data.getMessageById(
message.get('id'),
{
Message: Whisper.Message,
}
);
const previousUnread = message.get('unread');
// Important to update message with latest read state from database
message.merge(fetched);
if (previousUnread !== message.get('unread')) {
window.log.warn(
'Caught race condition on new message read state! ' +
'Manually starting timers.'
);
// We call markRead() even though the message is already
// marked read because we need to start expiration
// timers, etc.
message.markRead();
}
} catch (error) {
window.log.warn(
'handleDataMessage: Message',
message.idForLogging(),
'was deleted'
);
}
const sendingDeviceConversation = await ConversationController.getOrCreateAndWait(
source,
'private'
);
const isFriend = sendingDeviceConversation.isFriend();
const hasSentFriendRequest = sendingDeviceConversation.hasSentFriendRequest();
const autoAccept =
message.isFriendRequest() && (isFriend || hasSentFriendRequest);
if (message.get('unread')) {
// Need to do this here because the conversation has already changed states
if (autoAccept) {
await conversation.notifyFriendRequest(source, 'accepted');
} else {
conversation.notify(message);
}
}
confirm();
} catch (error) {
const errorForLog = error && error.stack ? error.stack : error;
window.log.error(
'handleDataMessage',
message.idForLogging(),
'error:',
errorForLog
);
throw error;
}
}

824
ts/receiver/receiver.ts Normal file
View File

@ -0,0 +1,824 @@
// TODO: fix libloki and textsecure not being available here yet
import { preprocessGroupMessage } from './groups';
import { handleMessageJob } from './queuedJob';
import { handleEndSession } from './sessionHandling';
import { handleUnpairRequest } from './multidevice';
import { EnvelopePlus } from './types';
import { ConversationModel } from './conversation';
import { EndSessionType, MessageModel } from './message';
import { downloadAttachment } from './attachments';
import { handleMediumGroupUpdate } from './mediumGroups';
import { SignalService } from './../protobuf';
import { removeFromCache } from './cache';
import { toNumber } from 'lodash';
import { DataMessage } from '../session/messages/outgoing';
export { handleEndSession, handleMediumGroupUpdate };
const _ = window.Lodash;
interface MessageCreationData {
timestamp: number;
isPublic: boolean;
receivedAt: number;
sourceDevice: number; // always 1 isn't it?
unidentifiedDeliveryReceived: any; // ???
friendRequest: boolean;
isRss: boolean;
source: boolean;
serverId: string;
// Needed for synced outgoing messages
unidentifiedStatus: any; // ???
expirationStartTimestamp: any; // ???
destination: string;
}
function initIncomingMessage(data: MessageCreationData): MessageModel {
const {
timestamp,
isPublic,
receivedAt,
sourceDevice,
unidentifiedDeliveryReceived,
friendRequest,
isRss,
source,
serverId,
} = data;
const type = friendRequest ? 'friend-request' : 'incoming';
const messageData: any = {
source,
sourceDevice,
serverId, // + (not present below in `createSentMessage`)
sent_at: timestamp,
received_at: receivedAt || Date.now(),
conversationId: source,
unidentifiedDeliveryReceived, // +
type,
direction: 'incoming', // +
unread: 1, // +
isPublic, // +
isRss, // +
};
if (friendRequest) {
messageData.friendStatus = 'pending';
}
return new window.Whisper.Message(messageData);
}
function createSentMessage(data: MessageCreationData): MessageModel {
const now = Date.now();
let sentTo = [];
const {
timestamp,
isPublic,
receivedAt,
sourceDevice,
unidentifiedStatus,
expirationStartTimestamp,
destination,
} = data;
let unidentifiedDeliveries;
if (unidentifiedStatus && unidentifiedStatus.length) {
sentTo = unidentifiedStatus.map((item: any) => item.destination);
const unidentified = _.filter(unidentifiedStatus, (item: any) =>
Boolean(item.unidentified)
);
// eslint-disable-next-line no-param-reassign
unidentifiedDeliveries = unidentified.map((item: any) => item.destination);
}
const sentSpecificFields = {
sent_to: sentTo,
sent: true,
unidentifiedDeliveries: unidentifiedDeliveries || [],
expirationStartTimestamp: Math.min(
expirationStartTimestamp || data.timestamp || now,
now
),
};
const messageData: any = {
source: window.textsecure.storage.user.getNumber(),
sourceDevice,
sent_at: timestamp,
received_at: isPublic ? receivedAt : now,
conversationId: destination, // conversation ID will might change later (if it is a group)
type: 'outgoing',
...sentSpecificFields,
};
return new window.Whisper.Message(messageData);
}
function createMessage(
data: MessageCreationData,
isIncoming: boolean
): MessageModel {
if (isIncoming) {
return initIncomingMessage(data);
} else {
return createSentMessage(data);
}
}
async function handleProfileUpdate(
profileKeyBuffer: any,
convoId: string,
convoType: ConversationType,
isIncoming: boolean
) {
const profileKey = profileKeyBuffer.toString('base64');
if (!isIncoming) {
const receiver = await window.ConversationController.getOrCreateAndWait(
convoId,
convoType
);
// First set profileSharing = true for the conversation we sent to
receiver.set({ profileSharing: true });
await receiver.saveChangesToDB();
// Then we update our own profileKey if it's different from what we have
const ourNumber = window.textsecure.storage.user.getNumber();
const me = await window.ConversationController.getOrCreate(
ourNumber,
'private'
);
// Will do the save for us if needed
await me.setProfileKey(profileKey);
} else {
const sender = await window.ConversationController.getOrCreateAndWait(
convoId,
'private'
);
// Will do the save for us
await sender.setProfileKey(profileKey);
}
}
enum ConversationType {
GROUP = 'group',
PRIVATE = 'private',
}
function sendDeliveryReceipt(source: string, timestamp: any) {
const { wrap, sendOptions } = window.ConversationController.prepareForSend(
source
);
wrap(
window.textsecure.messaging.sendDeliveryReceipt(
source,
timestamp,
sendOptions
)
).catch((error: any) => {
window.log.error(
`Failed to send delivery receipt to ${source} for message ${timestamp}:`,
error && error.stack ? error.stack : error
);
});
}
interface MessageId {
source: any;
sourceDevice: any;
timestamp: any;
}
async function isMessageDuplicate({
source,
sourceDevice,
timestamp,
}: MessageId) {
const { Errors } = window.Signal.Types;
try {
const result = await window.Signal.Data.getMessageBySender(
{ source, sourceDevice, sent_at: timestamp },
{
Message: window.Whisper.Message,
}
);
return Boolean(result);
} catch (error) {
window.log.error('isMessageDuplicate error:', Errors.toLogFormat(error));
return false;
}
}
async function handleSessionRequest(source: string) {
await window.libloki.api.sendSessionEstablishedMessage(source);
}
async function handleSecondaryDeviceFriendRequest(pubKey: string) {
// fetch the device mapping from the server
const deviceMapping = await window.lokiFileServerAPI.getUserDeviceMapping(
pubKey
);
if (!deviceMapping) {
return false;
}
// Only handle secondary pubkeys
if (deviceMapping.isPrimary === '1' || !deviceMapping.authorisations) {
return false;
}
const { authorisations } = deviceMapping;
// Secondary devices should only have 1 authorisation from a primary device
if (authorisations.length !== 1) {
return false;
}
const authorisation = authorisations[0];
if (!authorisation) {
return false;
}
if (!authorisation.grantSignature) {
return false;
}
const isValid = await window.libloki.crypto.validateAuthorisation(
authorisation
);
if (!isValid) {
return false;
}
const correctSender = pubKey === authorisation.secondaryDevicePubKey;
if (!correctSender) {
return false;
}
const { primaryDevicePubKey } = authorisation;
// ensure the primary device is a friend
const c = window.ConversationController.get(primaryDevicePubKey);
if (!c || !(await c.isFriendWithAnyDevice())) {
return false;
}
await window.libloki.storage.savePairingAuthorisation(authorisation);
return true;
}
async function handleAutoFriendRequestMessage(
source: string,
ourPubKey: string,
conversation: ConversationModel
) {
const isMe = source === ourPubKey;
// If we got a friend request message (session request excluded) or
// if we're not friends with the current user that sent this private message
// Check to see if we need to auto accept their friend request
if (isMe) {
window.log.info('refusing to add a friend request to ourselves');
throw new Error('Cannot add a friend request for ourselves!');
}
// auto-accept friend request if the device is paired to one of our friend's primary device
const shouldAutoAcceptFR = await handleSecondaryDeviceFriendRequest(source);
if (shouldAutoAcceptFR) {
window.libloki.api.debug.logAutoFriendRequest(
`Received AUTO_FRIEND_REQUEST from source: ${source}`
);
// Directly setting friend request status to skip the pending state
await conversation.setFriendRequestStatus(
window.friends.friendRequestStatusEnum.friends
);
// sending a message back = accepting friend request
window.libloki.api.sendBackgroundMessage(
source,
window.textsecure.OutgoingMessage.DebugMessageType.AUTO_FR_ACCEPT
);
// return true to notify the message is fully processed
return true;
}
return false;
}
function getEnvelopeId(envelope: EnvelopePlus) {
if (envelope.source) {
return `${envelope.source}.${envelope.sourceDevice} ${toNumber(
envelope.timestamp
)} (${envelope.id})`;
}
return envelope.id;
}
function isMessageEmpty(message: SignalService.DataMessage) {
const {
flags,
body,
attachments,
group,
quote,
contact,
preview,
groupInvitation,
mediumGroupUpdate,
} = message;
return (
!flags &&
_.isEmpty(body) &&
_.isEmpty(attachments) &&
_.isEmpty(group) &&
_.isEmpty(quote) &&
_.isEmpty(contact) &&
_.isEmpty(preview) &&
_.isEmpty(groupInvitation) &&
_.isEmpty(mediumGroupUpdate)
);
}
function cleanAttachment(attachment: any) {
return {
..._.omit(attachment, 'thumbnail'),
id: attachment.id.toString(),
key: attachment.key ? attachment.key.toString('base64') : null,
digest: attachment.digest ? attachment.digest.toString('base64') : null,
};
}
function cleanAttachments(decrypted: any) {
const { quote, group } = decrypted;
// Here we go from binary to string/base64 in all AttachmentPointer digest/key fields
if (group && group.type === SignalService.GroupContext.Type.UPDATE) {
if (group.avatar !== null) {
group.avatar = cleanAttachment(group.avatar);
}
}
decrypted.attachments = (decrypted.attachments || []).map(cleanAttachment);
decrypted.preview = (decrypted.preview || []).map((item: any) => {
const { image } = item;
if (!image) {
return item;
}
return {
...item,
image: cleanAttachment(image),
};
});
decrypted.contact = (decrypted.contact || []).map((item: any) => {
const { avatar } = item;
if (!avatar || !avatar.avatar) {
return item;
}
return {
...item,
avatar: {
...item.avatar,
avatar: cleanAttachment(item.avatar.avatar),
},
};
});
if (quote) {
if (quote.id) {
quote.id = toNumber(quote.id);
}
quote.attachments = (quote.attachments || []).map((item: any) => {
const { thumbnail } = item;
if (!thumbnail) {
return item;
}
return {
...item,
thumbnail: cleanAttachment(item.thumbnail),
};
});
}
}
export function processDecrypted(envelope: EnvelopePlus, decrypted: any) {
/* tslint:disable:no-bitwise */
const FLAGS = SignalService.DataMessage.Flags;
// Now that its decrypted, validate the message and clean it up for consumer
// processing
// Note that messages may (generally) only perform one action and we ignore remaining
// fields after the first action.
if (decrypted.flags == null) {
decrypted.flags = 0;
}
if (decrypted.expireTimer == null) {
decrypted.expireTimer = 0;
}
if (decrypted.flags & FLAGS.END_SESSION) {
decrypted.body = '';
decrypted.attachments = [];
decrypted.group = null;
return Promise.resolve(decrypted);
} else if (decrypted.flags & FLAGS.EXPIRATION_TIMER_UPDATE) {
decrypted.body = '';
decrypted.attachments = [];
} else if (decrypted.flags & FLAGS.PROFILE_KEY_UPDATE) {
decrypted.body = '';
decrypted.attachments = [];
} else if (decrypted.flags & FLAGS.SESSION_REQUEST) {
// do nothing
} else if (decrypted.flags & FLAGS.SESSION_RESTORE) {
// do nothing
} else if (decrypted.flags & FLAGS.UNPAIRING_REQUEST) {
// do nothing
} else if (decrypted.flags !== 0) {
throw new Error('Unknown flags in message');
}
if (decrypted.group) {
decrypted.group.id = decrypted.group.id?.toBinary();
switch (decrypted.group.type) {
case SignalService.GroupContext.Type.UPDATE:
decrypted.body = '';
decrypted.attachments = [];
break;
case SignalService.GroupContext.Type.QUIT:
decrypted.body = '';
decrypted.attachments = [];
break;
case SignalService.GroupContext.Type.DELIVER:
decrypted.group.name = null;
decrypted.group.members = [];
decrypted.group.avatar = null;
break;
case SignalService.GroupContext.Type.REQUEST_INFO:
decrypted.body = '';
decrypted.attachments = [];
break;
default:
removeFromCache(envelope);
throw new Error('Unknown group message type');
}
}
const attachmentCount = decrypted.attachments.length;
const ATTACHMENT_MAX = 32;
if (attachmentCount > ATTACHMENT_MAX) {
removeFromCache(envelope);
throw new Error(
`Too many attachments: ${attachmentCount} included in one message, max is ${ATTACHMENT_MAX}`
);
}
cleanAttachments(decrypted);
return decrypted;
/* tslint:disable:no-bitwise */
}
export async function updateProfile(
conversation: any,
profile: SignalService.DataMessage.ILokiProfile,
profileKey: any
) {
const { dcodeIO, textsecure, Signal } = window;
// Retain old values unless changed:
const newProfile = conversation.get('profile') || {};
newProfile.displayName = profile.displayName;
// TODO: may need to allow users to reset their avatars to null
if (profile.avatar) {
const prevPointer = conversation.get('avatarPointer');
const needsUpdate = !prevPointer || !_.isEqual(prevPointer, profile.avatar);
if (needsUpdate) {
conversation.set('avatarPointer', profile.avatar);
conversation.set('profileKey', profileKey);
const downloaded = await downloadAttachment({
url: profile.avatar,
isRaw: true,
});
// null => use jazzicon
let path = null;
if (profileKey) {
// Convert profileKey to ArrayBuffer, if needed
const encoding = typeof profileKey === 'string' ? 'base64' : null;
try {
const profileKeyArrayBuffer = dcodeIO.ByteBuffer.wrap(
profileKey,
encoding
).toArrayBuffer();
const decryptedData = await textsecure.crypto.decryptProfile(
downloaded.data,
profileKeyArrayBuffer
);
const upgraded = await Signal.Migrations.processNewAttachment({
...downloaded,
data: decryptedData,
});
({ path } = upgraded);
} catch (e) {
window.log.error(`Could not decrypt profile image: ${e}`);
}
}
newProfile.avatar = path;
}
} else {
newProfile.avatar = null;
}
await conversation.setLokiProfile(newProfile);
}
export async function handleDataMessage(
envelope: EnvelopePlus,
dataMessage: SignalService.DataMessage
): Promise<void> {
window.log.info('data message from', getEnvelopeId(envelope));
if (dataMessage.mediumGroupUpdate) {
handleMediumGroupUpdate(envelope, dataMessage.mediumGroupUpdate).ignore();
// TODO: investigate the meaning of this return value
return;
}
// eslint-disable-next-line no-bitwise
if (dataMessage.flags & SignalService.DataMessage.Flags.END_SESSION) {
await handleEndSession(envelope.source);
}
const message = await processDecrypted(envelope, dataMessage);
const ourPubKey = window.textsecure.storage.user.getNumber();
const senderPubKey = envelope.source;
const isMe = senderPubKey === ourPubKey;
const conversation = window.ConversationController.get(senderPubKey);
const { UNPAIRING_REQUEST } = SignalService.DataMessage.Flags;
// eslint-disable-next-line no-bitwise
const isUnpairingRequest = Boolean(message.flags & UNPAIRING_REQUEST);
if (isUnpairingRequest) {
return handleUnpairRequest(envelope, ourPubKey);
}
// Check if we need to update any profile names
if (!isMe && conversation && message.profile) {
await updateProfile(conversation, message.profile, message.profileKey);
}
if (isMessageEmpty(message)) {
window.log.warn(`Message ${getEnvelopeId(envelope)} ignored; it was empty`);
return removeFromCache(envelope);
}
// Loki - Temp hack until new protocol
// A friend request is a non-group text message which we haven't processed yet
const isGroupMessage = Boolean(message.group || message.mediumGroupUpdate);
const friendRequestStatusNoneOrExpired = conversation
? conversation.isFriendRequestStatusNoneOrExpired()
: true;
const isFriendRequest =
!isGroupMessage &&
!_.isEmpty(message.body) &&
friendRequestStatusNoneOrExpired;
const source = envelope.senderIdentity || senderPubKey;
const isOwnDevice = async (pubkey: string) => {
const primaryDevice = window.storage.get('primaryDevicePubKey');
const secondaryDevices = await window.libloki.storage.getPairedDevicesFor(
primaryDevice
);
const allDevices = [primaryDevice, ...secondaryDevices];
return allDevices.includes(pubkey);
};
const ownDevice = await isOwnDevice(source);
const ownMessage = conversation.isMediumGroup() && ownDevice;
const ev: any = {};
if (ownMessage) {
// Data messages for medium groups don't arrive as sync messages. Instead,
// linked devices poll for group messages independently, thus they need
// to recognise some of those messages at their own.
ev.type = 'sent';
} else {
ev.type = 'message';
}
if (envelope.senderIdentity) {
message.group = {
id: envelope.source,
};
}
ev.confirm = () => removeFromCache(envelope);
ev.data = {
friendRequest: isFriendRequest,
source,
sourceDevice: envelope.sourceDevice,
timestamp: toNumber(envelope.timestamp),
receivedAt: envelope.receivedAt,
unidentifiedDeliveryReceived: envelope.unidentifiedDeliveryReceived,
message,
};
await handleMessageEvent(ev);
}
// tslint:disable:cyclomatic-complexity max-func-body-length */
export async function handleMessageEvent(event: any): Promise<void> {
const { data, confirm } = event;
const isIncoming = event.type === 'message';
if (!data || !data.message) {
window.log.warn('Invalid data passed to handleMessageEvent.', event);
confirm();
return;
}
const { message, destination } = data;
let { source } = data;
const isGroupMessage = message.group;
const type = isGroupMessage
? ConversationType.GROUP
: ConversationType.PRIVATE;
// MAXIM: So id is actually conversationId
const id = isIncoming ? source : destination;
const {
PROFILE_KEY_UPDATE,
SESSION_REQUEST,
SESSION_RESTORE,
} = SignalService.DataMessage.Flags;
// eslint-disable-next-line no-bitwise
const isProfileUpdate = Boolean(message.flags & PROFILE_KEY_UPDATE);
if (isProfileUpdate) {
await handleProfileUpdate(message.profileKey, id, type, isIncoming);
confirm();
return;
}
const msg = createMessage(data, isIncoming);
// if the message is `sent` (from secondary device) we have to set the sender manually... (at least for now)
source = source || msg.get('source');
const isDuplicate = await isMessageDuplicate(data);
if (isDuplicate) {
// RSS expects duplicates, so squelch log
if (!source.match(/^rss:/)) {
window.log.warn('Received duplicate message', msg.idForLogging());
}
confirm();
return;
}
// Note(LOKI): don't send receipt for FR as we don't have a session yet
const shouldSendReceipt =
isIncoming &&
data.unidentifiedDeliveryReceived &&
!data.friendRequest &&
!isGroupMessage;
if (shouldSendReceipt) {
sendDeliveryReceipt(source, data.timestamp);
}
await window.ConversationController.getOrCreateAndWait(id, type);
// =========== Process flags =============
/**
* A session request message is a friend-request message with the flag
* SESSION_REQUEST set to true.
*/
// eslint-disable-next-line no-bitwise
const sessionRequest =
data.friendRequest && !!(message.flags & SESSION_REQUEST);
// NOTE: I don't need to create/use msg if it is a session request!
if (sessionRequest) {
await handleSessionRequest(source);
confirm();
return;
}
// eslint-disable-next-line no-bitwise
if (message.flags & SESSION_RESTORE) {
// Show that the session reset is "in progress" even though we had a valid session
msg.set({ endSessionType: EndSessionType.ONGOING });
}
const ourNumber = window.textsecure.storage.user.getNumber();
// AFR are only relevant for incoming messages
if (isIncoming) {
// the conversation with this real device
const conversationOrigin = window.ConversationController.get(source);
// Session request have been dealt with before, so a friend request here is
// not a session request message. Also, handleAutoFriendRequestMessage() only
// handles the autoAccept logic of an auto friend request.
if (
data.friendRequest ||
(!isGroupMessage && !conversationOrigin.isFriend())
) {
const accepted = await handleAutoFriendRequestMessage(
source,
ourNumber,
conversationOrigin
);
if (accepted) {
confirm();
return;
}
}
}
// =========================================
// Conversation Id is:
// - primarySource if it is an incoming DM message,
// - destination if it is an outgoing message,
// - group.id if it is a group message
let conversationId = id;
const authorisation = await window.libloki.storage.getGrantAuthorisationForSecondaryPubKey(
source
);
const primarySource =
(authorisation && authorisation.primaryDevicePubKey) || source;
if (isGroupMessage) {
/* handle one part of the group logic here:
handle requesting info of a new group,
dropping an admin only update from a non admin, ...
*/
conversationId = message.group.id;
const shouldReturn = await preprocessGroupMessage(
source,
message.group,
primarySource
);
// handleGroupMessage() can process fully a message in some cases
// so we need to return early if that's the case
if (shouldReturn) {
confirm();
return;
}
}
if (source !== ourNumber && authorisation) {
// Ignore auth from our devices
conversationId = authorisation.primaryDevicePubKey;
}
// the conversation with the primary device of that source (can be the same as conversationOrigin)
const conversation = window.ConversationController.get(conversationId);
conversation.queueJob(() => {
handleMessageJob(
msg,
conversation,
message,
ourNumber,
confirm,
source,
isGroupMessage,
primarySource
).ignore();
});
}
// tslint:enable:cyclomatic-complexity max-func-body-length */

View File

@ -0,0 +1,16 @@
export async function handleEndSession(number: string): Promise<void> {
window.log.info('got end session');
const { ConversationController } = window;
try {
const conversation = ConversationController.get(number);
if (conversation) {
await conversation.onSessionResetReceived();
} else {
throw new Error();
}
} catch (e) {
window.log.error('Error getting conversation: ', number);
}
}

16
ts/receiver/types.ts Normal file
View File

@ -0,0 +1,16 @@
import { SignalService } from '../protobuf';
export interface Quote {
id: any;
author: any;
attachments: Array<any>;
text: string;
referencedMessageNotFound: boolean;
}
export interface EnvelopePlus extends SignalService.Envelope {
senderIdentity: string; // Sender's pubkey after it's been decrypted (for medium groups)
receivedAt: number; // We only seem to set this for public messages?
unidentifiedDeliveryReceived: boolean;
id: string;
}

View File

@ -12,7 +12,7 @@
// "checkJs": true, // Report errors in .js files.
"jsx": "react", // Specify JSX code generation: 'preserve', 'react-native', or 'react'.
// "declaration": true, // Generates corresponding '.d.ts' file.
// "sourceMap": true, // Generates corresponding '.map' file.
"sourceMap": true, // Generates corresponding '.map' file.
// "outFile": "./", // Concatenate and emit output to single file.
// "outDir": "./", // Redirect output structure to the directory.
"rootDir": "./ts", // Specify the root directory of input files. Use to control the output directory structure with --outDir.

View File

@ -146,6 +146,9 @@
// We use || and && shortcutting because we're javascript programmers
"strict-boolean-expressions": false,
"no-suspicious-comment": false,
"no-backbone-get-set-outside-model": false,
"underscore-consistent-invocation": false,
"newline-before-return": false,
"react-no-dangerous-html": [
true,
{

View File

@ -163,6 +163,14 @@
dependencies:
defer-to-connect "^1.0.1"
"@types/backbone@^1.4.2":
version "1.4.2"
resolved "https://registry.yarnpkg.com/@types/backbone/-/backbone-1.4.2.tgz#2af5ca6536d4cd510842eea6eeea11a42fa704b9"
integrity sha512-+yfi5cLeIPU3JuCrFP4Bodpv8oLrE5sbiqQIMPvHIKaVCz0JCBt9GEQKZsz2haibrTV4Axks6ovoHc2yUbpWzg==
dependencies:
"@types/jquery" "*"
"@types/underscore" "*"
"@types/chai-as-promised@^7.1.2":
version "7.1.2"
resolved "https://registry.yarnpkg.com/@types/chai-as-promised/-/chai-as-promised-7.1.2.tgz#2f564420e81eaf8650169e5a3a6b93e096e5068b"
@ -261,6 +269,13 @@
"@types/minimatch" "*"
"@types/node" "*"
"@types/jquery@*":
version "3.3.38"
resolved "https://registry.yarnpkg.com/@types/jquery/-/jquery-3.3.38.tgz#6385f1e1b30bd2bff55ae8ee75ea42a999cc3608"
integrity sha512-nkDvmx7x/6kDM5guu/YpXkGZ/Xj/IwGiLDdKM99YA5Vag7pjGyTJ8BNUh/6hxEn/sEu5DKtyRgnONJ7EmOoKrA==
dependencies:
"@types/sizzle" "*"
"@types/jquery@3.3.29":
version "3.3.29"
resolved "https://registry.yarnpkg.com/@types/jquery/-/jquery-3.3.29.tgz#680a2219ce3c9250483722fccf5570d1e2d08abd"
@ -445,6 +460,11 @@
resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-1.0.4.tgz#922d092c84a776a59acb0bd6785fd82b59b9bad5"
integrity sha512-6jtHrHpmiXOXoJ31Cg9R+iEVwuEKPf0XHwFUI93eEPXx492/J2JHyafkleKE2EYzZprayk9FSjTyK1GDqcwDng==
"@types/underscore@*":
version "1.10.0"
resolved "https://registry.yarnpkg.com/@types/underscore/-/underscore-1.10.0.tgz#5cb0dff2a5f616fc8e0c61b482bf01fa20a03cec"
integrity sha512-ZAbqul7QAKpM2h1PFGa5ETN27ulmqtj0QviYHasw9LffvXZvVHuraOx/FOsIPPDNGZN0Qo1nASxxSfMYOtSoCw==
"@types/uuid@3.4.4":
version "3.4.4"
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-3.4.4.tgz#7af69360fa65ef0decb41fd150bf4ca5c0cefdf5"