From f46c885fdf3c9f3a396df032b96bae5f67489d51 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Fri, 15 May 2020 12:53:49 +1000 Subject: [PATCH] merge fix-closed-group to clearnet --- _locales/en/messages.json | 4 + js/background.js | 42 +- js/models/conversations.js | 44 ++- js/models/messages.js | 431 ++++++++++++++------- js/notifications.js | 18 +- js/views/app_view.js | 2 +- js/views/conversation_view.js | 2 +- libloki/api.js | 286 ++++++++------ libloki/test/index.html | 2 + libloki/test/messages.js | 78 ++++ libtextsecure/message_receiver.js | 359 ++++++----------- libtextsecure/outgoing_message.js | 226 ++++++++++- libtextsecure/sendmessage.js | 102 +++-- package.json | 1 + preload.js | 2 +- ts/components/session/RegistrationTabs.tsx | 6 +- 16 files changed, 1027 insertions(+), 578 deletions(-) create mode 100644 libloki/test/messages.js diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 7aa0796c7..c37ea1854 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1014,6 +1014,10 @@ "deviceIsSecondaryNoPairing": { "message": "This device is a secondary device and so cannot be linked." }, + "pairingOngoing": { + "message": + "A pairing request is already ongoing. Restart the app if it takes too long." + }, "allowPairing": { "message": "Allow Linking" }, diff --git a/js/background.js b/js/background.js index d5b0d682f..d5f3854a7 100644 --- a/js/background.js +++ b/js/background.js @@ -1760,6 +1760,13 @@ const id = details.number; + libloki.api.debug.logContactSync( + 'Got sync contact message with', + id, + ' details:', + details + ); + if (id === textsecure.storage.user.getNumber()) { // special case for syncing details about ourselves if (details.profileKey) { @@ -1807,9 +1814,8 @@ await conversation.setSecondaryStatus(true, ourPrimaryKey); } - if (conversation.isFriendRequestStatusNone()) { - // Will be replaced with automatic friend request - libloki.api.sendBackgroundMessage(conversation.id); + if (conversation.isFriendRequestStatusNoneOrExpired()) { + libloki.api.sendAutoFriendRequestMessage(conversation.id); } else { // Accept any pending friend requests if there are any conversation.onAcceptFriendRequest({ blockSync: true }); @@ -1894,6 +1900,13 @@ const details = ev.groupDetails; const { id } = details; + libloki.api.debug.logGroupSync( + 'Got sync group message with group id', + id, + ' details:', + details + ); + const conversation = await ConversationController.getOrCreateAndWait( id, 'group' @@ -1944,6 +1957,10 @@ await window.Signal.Data.updateConversation(id, conversation.attributes, { Conversation: Whisper.Conversation, }); + + // send a session request for all the members we do not have a session with + window.libloki.api.sendSessionRequestsToMembers(updates.members); + const { expireTimer } = details; const isValidExpireTimer = typeof expireTimer === 'number'; if (!isValidExpireTimer) { @@ -2009,24 +2026,7 @@ const descriptorId = await textsecure.MessageReceiver.arrayBufferToString( messageDescriptor.id ); - let message; - const { source } = data; - - // Note: This only works currently because we have a 1 device limit - // When we change that, the check below needs to change too - const ourNumber = textsecure.storage.user.getNumber(); - const primaryDevice = window.storage.get('primaryDevicePubKey'); - const isOurDevice = - source && (source === ourNumber || source === primaryDevice); - const isPublicChatMessage = - messageDescriptor.type === 'group' && - descriptorId.match(/^publicChat:/); - if (isPublicChatMessage && isOurDevice) { - // Public chat messages from ourselves should be outgoing - message = await createSentMessage(data); - } else { - message = await createMessage(data); - } + const message = await createMessage(data); const isDuplicate = await isMessageDuplicate(message); if (isDuplicate) { diff --git a/js/models/conversations.js b/js/models/conversations.js index 64f333019..bf2aee6ef 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -783,6 +783,13 @@ isFriendRequestStatusNone() { return this.get('friendRequestStatus') === FriendRequestStatusEnum.none; }, + isFriendRequestStatusNoneOrExpired() { + const status = this.get('friendRequestStatus'); + return ( + status === FriendRequestStatusEnum.none || + status === FriendRequestStatusEnum.requestExpired + ); + }, isPendingFriendRequest() { const status = this.get('friendRequestStatus'); return ( @@ -1036,7 +1043,10 @@ direction: 'incoming', status: ['pending', 'expired'], }); - window.libloki.api.sendBackgroundMessage(this.id); + window.libloki.api.sendBackgroundMessage( + this.id, + window.textsecure.OutgoingMessage.DebugMessageType + .INCOMING_FR_ACCEPTED); } }, // Our outgoing friend request has been accepted @@ -1053,7 +1063,11 @@ response: 'accepted', status: ['pending', 'expired'], }); - window.libloki.api.sendBackgroundMessage(this.id); + window.libloki.api.sendBackgroundMessage( + this.id, + window.textsecure.OutgoingMessage.DebugMessageType + .OUTGOING_FR_ACCEPTED + ); return true; } return false; @@ -2148,7 +2162,10 @@ await this.setSessionResetStatus(SessionResetEnum.request_received); // send empty message, this will trigger the new session to propagate // to the reset initiator. - window.libloki.api.sendBackgroundMessage(this.id); + window.libloki.api.sendBackgroundMessage( + this.id, + window.textsecure.OutgoingMessage.DebugMessageType.SESSION_RESET_RECV + ); }, isSessionResetReceived() { @@ -2184,7 +2201,10 @@ async onNewSessionAdopted() { if (this.get('sessionResetStatus') === SessionResetEnum.initiated) { // send empty message to confirm that we have adopted the new session - window.libloki.api.sendBackgroundMessage(this.id); + window.libloki.api.sendBackgroundMessage( + this.id, + window.textsecure.OutgoingMessage.DebugMessageType.SESSION_RESET + ); } await this.createAndStoreEndSessionMessage({ type: 'incoming', @@ -3026,11 +3046,11 @@ const messageId = message.id; const isExpiringMessage = Message.hasExpiration(messageJSON); - window.log.info('Add notification', { - conversationId: this.idForLogging(), - isExpiringMessage, - messageSentAt, - }); + // window.log.info('Add notification', { + // conversationId: this.idForLogging(), + // isExpiringMessage, + // messageSentAt, + // }); Whisper.Notifications.add({ conversationId, iconUrl, @@ -3077,9 +3097,9 @@ : 'friendRequestNotificationMessage'; const iconUrl = await conversation.getNotificationIcon(); - window.log.info('Add notification for friend request updated', { - conversationId: conversation.idForLogging(), - }); + // window.log.info('Add notification for friend request updated', { + // conversationId: conversation.idForLogging(), + // }); Whisper.Notifications.add({ conversationId: conversation.id, iconUrl, diff --git a/js/models/messages.js b/js/models/messages.js index 6025b3071..a0b334153 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -12,6 +12,7 @@ Whisper, clipboard, libloki, + lokiFileServerAPI, */ /* eslint-disable more/no-then */ @@ -160,9 +161,9 @@ } }, isEndSession() { - const flag = textsecure.protobuf.DataMessage.Flags.END_SESSION; + const endSessionFlag = textsecure.protobuf.DataMessage.Flags.END_SESSION; // eslint-disable-next-line no-bitwise - return !!(this.get('flags') & flag); + return !!(this.get('flags') & endSessionFlag); }, getEndSessionTranslationKey() { const sessionType = this.get('endSessionType'); @@ -174,10 +175,10 @@ return 'sessionEnded'; }, isExpirationTimerUpdate() { - const flag = + const expirationTimerFlag = textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE; // eslint-disable-next-line no-bitwise - return !!(this.get('flags') & flag); + return !!(this.get('flags') & expirationTimerFlag); }, isGroupUpdate() { return !!this.get('group_update'); @@ -281,18 +282,23 @@ isKeyChange() { return this.get('type') === 'keychange'; }, + isFriendRequest() { + // FIXME exclude session request to be seen as a session request return this.get('type') === 'friend-request'; }, isGroupInvitation() { return !!this.get('groupInvitation'); }, isSessionRestoration() { - const flag = textsecure.protobuf.DataMessage.Flags.SESSION_RESTORE; - // eslint-disable-next-line no-bitwise - const sessionRestoreFlag = !!(this.get('flags') & flag); - - return !!this.get('sessionRestoration') || sessionRestoreFlag; + const sessionRestoreFlag = + textsecure.protobuf.DataMessage.Flags.SESSION_RESTORE; + /* eslint-disable no-bitwise */ + return ( + !!this.get('sessionRestoration') || + !!(this.get('flags') & sessionRestoreFlag) + ); + /* eslint-enable no-bitwise */ }, getNotificationText() { const description = this.getDescription(); @@ -1435,7 +1441,7 @@ }); this.trigger('sent', this); - if (this.get('type') !== 'friend-request') { + if (!this.isFriendRequest()) { const c = this.getConversation(); // Don't bother sending sync messages to public chats if (c && !c.isPublic()) { @@ -1900,33 +1906,63 @@ return message; }, - 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'); - const type = message.get('type'); - let conversationId = message.get('conversationId'); - const authorisation = await libloki.storage.getGrantAuthorisationForSecondaryPubKey( - source + async handleSecondaryDeviceFriendRequest(pubKey) { + // fetch the device mapping from the server + const deviceMapping = await lokiFileServerAPI.getUserDeviceMapping( + pubKey ); - const primarySource = - (authorisation && authorisation.primaryDevicePubKey) || source; - const isGroupMessage = !!initialMessage.group; - if (isGroupMessage) { - conversationId = initialMessage.group.id; - } else if (source !== ourNumber && authorisation) { - // Ignore auth from our devices - conversationId = authorisation.primaryDevicePubKey; + 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 || !c.isFriendWithAnyDevice()) { + return false; + } + await libloki.storage.savePairingAuthorisation(authorisation); - const GROUP_TYPES = textsecure.protobuf.GroupContext.Type; + 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) @@ -1935,105 +1971,211 @@ 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(); - return null; + // 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) { - if ( - initialMessage.group.type === GROUP_TYPES.REQUEST_INFO && - !newGroup - ) { - conversation.sendGroupInfo([source]); - return null; - } else if ( - initialMessage.group.members && - initialMessage.group.type === GROUP_TYPES.UPDATE - ) { - if (newGroup) { - conversation.updateGroupAdmins(initialMessage.group.admins); + if ( + initialMessage.group.members && + initialMessage.group.type === GROUP_TYPES.UPDATE + ) { + if (newGroup) { + conversation.updateGroupAdmins(initialMessage.group.admins); - conversation.setFriendRequestStatus( - window.friends.friendRequestStatusEnum.friends - ); - } else { - 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' - ); - } + 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; + const membersMissing = + _.difference( + conversation.get('members'), + initialMessage.group.members + ).length > 0; - if (membersMissing) { - window.log.warn('Non-admin attempts to remove group members'); - } + if (membersMissing) { + window.log.warn('Non-admin attempts to remove group members'); + } - const messageAllowed = !nameChanged && !membersMissing; + const messageAllowed = !nameChanged && !membersMissing; - if (!messageAllowed) { - confirm(); - return null; - } + // Returning true drops the message + if (!messageAllowed) { + confirm(); + return true; } } - // For every member, see if we need to establish a session: - initialMessage.group.members.forEach(memberPubKey => { - const haveSession = _.some( - textsecure.storage.protocol.sessions, - s => s.number === memberPubKey - ); + } + // 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; + }, - const ourPubKey = textsecure.storage.user.getNumber(); - if (!haveSession && memberPubKey !== ourPubKey) { - ConversationController.getOrCreateAndWait( - memberPubKey, - 'private' - ).then(() => { - textsecure.messaging.sendMessageToNumber( - memberPubKey, - '(If you see this message, you must be using an out-of-date client)', - [], - undefined, - [], - Date.now(), - undefined, - undefined, - { messageType: 'friend-request', sessionRequest: true } - ); - }); - } - }); - } else if (newGroup) { - // We have an unknown group, we should request info from the sender - textsecure.messaging.requestGroupInfo(conversationId, [ - primarySource, - ]); + async handleSessionRequest(source, primarySource, confirm) { + // Check if the contact is a member in one of our private groups: + const isKnownClosedGroupMember = window + .getConversations() + .models.filter(c => c.get('members')) + .reduce((acc, x) => window.Lodash.concat(acc, x.get('members')), []) + .includes(primarySource); + + libloki.api.debug.logSessionRequest( + `Received SESSION_REQUEST from source: ${source}, primarySource: ${primarySource}, is one of our private groups: ${isKnownClosedGroupMember}` + ); + + if (isKnownClosedGroupMember) { + window.log.info( + `Auto accepting a 'group' session request for a known group member: ${primarySource}` + ); + window.libloki.api.sendBackgroundMessage( + source, + window.textsecure.OutgoingMessage.DebugMessageType + .SESSION_REQUEST_ACCEPT + ); + + 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; + }, - const isSessionRequest = - initialMessage.flags === - textsecure.protobuf.DataMessage.Flags.SESSION_REQUEST; + 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 @@ -2043,34 +2185,51 @@ // 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, primarySource, confirm); - if (message.isFriendRequest() && isSessionRequest) { - // Check if the contact is a member in one of our private groups: - const groupMember = window - .getConversations() - .models.filter(c => c.get('members')) - .reduce((acc, x) => window.Lodash.concat(acc, x.get('members')), []) - .includes(primarySource); - - if (groupMember) { - window.log.info( - `Auto accepting a 'group' friend request for a known group member: ${primarySource}` - ); - - window.libloki.api.sendBackgroundMessage(message.get('source')); - - confirm(); - } - - // Wether or not we accepted the FR, we exit early so background friend requests + // 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; + } + } + const conversation = conversationPrimary; return conversation.queueJob(async () => { window.log.info( `Starting handleDataMessage for message ${message.idForLogging()} in conversation ${conversation.idForLogging()}` ); + const GROUP_TYPES = textsecure.protobuf.GroupContext.Type; + const type = message.get('type'); const withQuoteReference = await this.copyFromQuotedMessage( initialMessage @@ -2263,7 +2422,7 @@ : 'done'; this.set({ endSessionType }); } - if (type === 'incoming' || type === 'friend-request') { + if (type === 'incoming' || message.isFriendRequest()) { const readSync = Whisper.ReadSyncs.forMessage(message); if (readSync) { if ( @@ -2352,7 +2511,9 @@ let autoAccept = false; // Make sure friend request logic doesn't trigger on messages aimed at groups if (!isGroupMessage) { - if (message.get('type') === 'friend-request') { + // 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. @@ -2377,6 +2538,10 @@ 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) { diff --git a/js/notifications.js b/js/notifications.js index da6935725..2daf2c4b8 100644 --- a/js/notifications.js +++ b/js/notifications.js @@ -60,7 +60,7 @@ const isAudioNotificationEnabled = storage.get('audio-notification') || false; const isAudioNotificationSupported = Settings.isAudioNotificationSupported(); - const isNotificationGroupingSupported = Settings.isNotificationGroupingSupported(); + // const isNotificationGroupingSupported = Settings.isNotificationGroupingSupported(); const numNotifications = this.length; const userSetting = this.getUserSetting(); @@ -73,12 +73,12 @@ userSetting, }); - window.log.info( - 'Update notifications:', - Object.assign({}, status, { - isNotificationGroupingSupported, - }) - ); + // window.log.info( + // 'Update notifications:', + // Object.assign({}, status, { + // isNotificationGroupingSupported, + // }) + // ); if (status.type !== 'ok') { if (status.shouldClearNotifications) { @@ -180,11 +180,11 @@ return storage.get('notification-setting') || SettingNames.MESSAGE; }, onRemove() { - window.log.info('Remove notification'); + // window.log.info('Remove notification'); this.update(); }, clear() { - window.log.info('Remove all notifications'); + // window.log.info('Remove all notifications'); this.reset([]); this.update(); }, diff --git a/js/views/app_view.js b/js/views/app_view.js index 1940fa7ff..47c8f8516 100644 --- a/js/views/app_view.js +++ b/js/views/app_view.js @@ -137,7 +137,7 @@ // this.initialLoadComplete. An example of this: on a phone-pairing setup. _.defaults(options, { initialLoadComplete: this.initialLoadComplete }); - window.log.info('open inbox'); + // window.log.info('open inbox'); this.closeInstaller(); if (!this.inboxView) { diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index 858cfccee..f6560102f 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -1086,7 +1086,7 @@ }, 1); }, fetchMessages() { - window.log.info('fetchMessages'); + // window.log.info('fetchMessages'); this.$('.bar-container').show(); if (this.inProgressFetch) { window.log.warn('Multiple fetchMessage calls!'); diff --git a/libloki/api.js b/libloki/api.js index 6456e2688..4b242d1fa 100644 --- a/libloki/api.js +++ b/libloki/api.js @@ -1,47 +1,118 @@ -/* global window, textsecure, dcodeIO, StringView, ConversationController */ +/* global window, textsecure, dcodeIO, StringView, ConversationController, _ */ +/* eslint-disable no-bitwise */ // eslint-disable-next-line func-names (function() { window.libloki = window.libloki || {}; - async function sendBackgroundMessage(pubKey) { - return sendOnlineBroadcastMessage(pubKey); + const DebugFlagsEnum = { + GROUP_SYNC_MESSAGES: 1, + CONTACT_SYNC_MESSAGES: 2, + AUTO_FRIEND_REQUEST_MESSAGES: 4, + SESSION_REQUEST_MESSAGES: 8, + SESSION_MESSAGE_SENDING: 16, + SESSION_BACKGROUND_MESSAGE: 32, + GROUP_REQUEST_INFO: 64, + NORMAL_FRIEND_REQUEST_MESSAGES: 128, + // If you add any new flag, be sure it is bitwise safe! (unique and 2 multiples) + ALL: 65535, + }; + + const debugFlags = DebugFlagsEnum.ALL; + + const debugLogFn = (...args) => { + if (true) { + // process.env.NODE_ENV.includes('test-integration') || + window.console.warn(...args); + } + }; + + function logSessionMessageSending(...args) { + if (debugFlags & DebugFlagsEnum.SESSION_MESSAGE_SENDING) { + debugLogFn(...args); + } + } + + function logGroupSync(...args) { + if (debugFlags & DebugFlagsEnum.GROUP_SYNC_MESSAGES) { + debugLogFn(...args); + } + } + + function logGroupRequestInfo(...args) { + if (debugFlags & DebugFlagsEnum.GROUP_REQUEST_INFO) { + debugLogFn(...args); + } + } + + function logContactSync(...args) { + if (debugFlags & DebugFlagsEnum.GROUP_CONTACT_MESSAGES) { + debugLogFn(...args); + } } - async function sendOnlineBroadcastMessage(pubKey, isPing = false) { + function logAutoFriendRequest(...args) { + if (debugFlags & DebugFlagsEnum.AUTO_FRIEND_REQUEST_MESSAGES) { + debugLogFn(...args); + } + } + + function logNormalFriendRequest(...args) { + if (debugFlags & DebugFlagsEnum.NORMAL_FRIEND_REQUEST_MESSAGES) { + debugLogFn(...args); + } + } + + function logSessionRequest(...args) { + if (debugFlags & DebugFlagsEnum.SESSION_REQUEST_MESSAGES) { + debugLogFn(...args); + } + } + + function logBackgroundMessage(...args) { + if (debugFlags & DebugFlagsEnum.SESSION_BACKGROUND_MESSAGE) { + debugLogFn(...args); + } + } + + // Returns the primary device pubkey for this secondary device pubkey + // or the same pubkey if there is no other device + async function getPrimaryDevicePubkey(pubKey) { const authorisation = await window.libloki.storage.getGrantAuthorisationForSecondaryPubKey( pubKey ); - if (authorisation && authorisation.primaryDevicePubKey !== pubKey) { - sendOnlineBroadcastMessage(authorisation.primaryDevicePubKey); + return authorisation ? authorisation.primaryDevicePubKey : pubKey; + } + + async function sendBackgroundMessage(pubKey, debugMessageType) { + const primaryPubKey = await getPrimaryDevicePubkey(pubKey); + if (primaryPubKey !== pubKey) { + // if we got the secondary device pubkey first, + // call ourself again with the primary device pubkey + await sendBackgroundMessage(primaryPubKey, debugMessageType); return; } - const p2pAddress = null; - const p2pPort = null; - // We result loki address message for sending "background" messages - const type = textsecure.protobuf.LokiAddressMessage.Type.HOST_UNREACHABLE; - const lokiAddressMessage = new textsecure.protobuf.LokiAddressMessage({ - p2pAddress, - p2pPort, - type, - }); - const content = new textsecure.protobuf.Content({ - lokiAddressMessage, - }); + const backgroundMessage = textsecure.OutgoingMessage.buildBackgroundMessage( + pubKey, + debugMessageType + ); + await backgroundMessage.sendToNumber(pubKey); + } + + async function sendAutoFriendRequestMessage(pubKey) { + const primaryPubKey = await getPrimaryDevicePubkey(pubKey); + if (primaryPubKey !== pubKey) { + // if we got the secondary device pubkey first, + // call ourself again with the primary device pubkey + await sendAutoFriendRequestMessage(primaryPubKey); + return; + } - const options = { messageType: 'onlineBroadcast', isPing }; - // Send a empty message with information about how to contact us directly - const outgoingMessage = new textsecure.OutgoingMessage( - null, // server - Date.now(), // timestamp, - [pubKey], // numbers - content, // message - true, // silent - () => null, // callback - options + const autoFrMessage = textsecure.OutgoingMessage.buildAutoFriendRequestMessage( + pubKey ); - await outgoingMessage.sendToNumber(pubKey); + await autoFrMessage.sendToNumber(pubKey); } function createPairingAuthorisationProtoMessage({ @@ -74,24 +145,10 @@ } function sendUnpairingMessageToSecondary(pubKey) { - const flags = textsecure.protobuf.DataMessage.Flags.UNPAIRING_REQUEST; - const dataMessage = new textsecure.protobuf.DataMessage({ - flags, - }); - const content = new textsecure.protobuf.Content({ - dataMessage, - }); - const options = { messageType: 'device-unpairing' }; - const outgoingMessage = new textsecure.OutgoingMessage( - null, // server - Date.now(), // timestamp, - [pubKey], // numbers - content, // message - true, // silent - () => null, // callback - options + const unpairingMessage = textsecure.OutgoingMessage.buildUnpairingMessage( + pubKey ); - return outgoingMessage.sendToNumber(pubKey); + return unpairingMessage.sendToNumber(pubKey); } // Serialise as ... // This is an implementation of the reciprocal of contacts_parser.js @@ -107,12 +164,7 @@ result.reset(); return result; } - async function createContactSyncProtoMessage(conversations) { - // Extract required contacts information out of conversations - const sessionContacts = conversations.filter( - c => c.isPrivate() && !c.isSecondaryDevice() && c.isFriend() - ); - + async function createContactSyncProtoMessage(sessionContacts) { if (sessionContacts.length === 0) { return null; } @@ -159,31 +211,22 @@ }); return syncMessage; } - function createGroupSyncProtoMessage(conversations) { - // We only want to sync across closed groups that we haven't left - const sessionGroups = conversations.filter( - c => c.isClosedGroup() && !c.get('left') && c.isFriend() - ); + function createGroupSyncProtoMessage(sessionGroup) { + // We are getting a single open group here - if (sessionGroups.length === 0) { - return null; - } + const rawGroup = { + id: window.Signal.Crypto.bytesFromString(sessionGroup.id), + name: sessionGroup.get('name'), + members: sessionGroup.get('members') || [], + blocked: sessionGroup.isBlocked(), + expireTimer: sessionGroup.get('expireTimer'), + admins: sessionGroup.get('groupAdmins') || [], + }; - const rawGroups = sessionGroups.map(conversation => ({ - id: window.Signal.Crypto.bytesFromString(conversation.id), - name: conversation.get('name'), - members: conversation.get('members') || [], - blocked: conversation.isBlocked(), - expireTimer: conversation.get('expireTimer'), - admins: conversation.get('groupAdmins') || [], - })); - - // Convert raw groups to an array of buffers - const groupDetails = rawGroups - .map(x => new textsecure.protobuf.GroupDetails(x)) - .map(x => x.encode()); + // Convert raw group to a buffer + const groupDetail = new textsecure.protobuf.GroupDetails(rawGroup).encode(); // Serialise array of byteBuffers into 1 byteBuffer - const byteBuffer = serialiseByteBuffers(groupDetails); + const byteBuffer = serialiseByteBuffers([groupDetail]); const data = new Uint8Array(byteBuffer.toArrayBuffer()); const groups = new textsecure.protobuf.SyncMessage.Groups({ data, @@ -225,61 +268,74 @@ ourNumber, 'private' ); - const content = new textsecure.protobuf.Content({ - pairingAuthorisation, - }); - const isGrant = authorisation.primaryDevicePubKey === ourNumber; - if (isGrant) { - // Send profile name to secondary device - const lokiProfile = ourConversation.getLokiProfile(); - // profile.avatar is the path to the local image - // replace with the avatar URL - const avatarPointer = ourConversation.get('avatarPointer'); - lokiProfile.avatar = avatarPointer; - const profile = new textsecure.protobuf.DataMessage.LokiProfile( - lokiProfile - ); - const profileKey = window.storage.get('profileKey'); - const dataMessage = new textsecure.protobuf.DataMessage({ - profile, - profileKey, - }); - content.dataMessage = dataMessage; - } // Send - const options = { messageType: 'pairing-request' }; const p = new Promise((resolve, reject) => { - const timestamp = Date.now(); - - const outgoingMessage = new textsecure.OutgoingMessage( - null, // server - timestamp, - [recipientPubKey], // numbers - content, // message - true, // silent - result => { - // callback - if (result.errors.length > 0) { - reject(result.errors[0]); - } else { - resolve(); - } - }, - options + const callback = result => { + // callback + if (result.errors.length > 0) { + reject(result.errors[0]); + } else { + resolve(); + } + }; + const pairingRequestMessage = textsecure.OutgoingMessage.buildPairingRequestMessage( + recipientPubKey, + ourNumber, + ourConversation, + authorisation, + pairingAuthorisation, + callback ); - outgoingMessage.sendToNumber(recipientPubKey); + + pairingRequestMessage.sendToNumber(recipientPubKey); }); return p; } + function sendSessionRequestsToMembers(members = []) { + // For every member, see if we need to establish a session: + members.forEach(memberPubKey => { + const haveSession = _.some( + textsecure.storage.protocol.sessions, + s => s.number === memberPubKey + ); + + const ourPubKey = textsecure.storage.user.getNumber(); + if (!haveSession && memberPubKey !== ourPubKey) { + // eslint-disable-next-line more/no-then + ConversationController.getOrCreateAndWait(memberPubKey, 'private').then( + () => { + const sessionRequestMessage = textsecure.OutgoingMessage.buildSessionRequestMessage( + memberPubKey + ); + sessionRequestMessage.sendToNumber(memberPubKey); + } + ); + } + }); + } + + const debug = { + logContactSync, + logGroupSync, + logAutoFriendRequest, + logSessionRequest, + logSessionMessageSending, + logBackgroundMessage, + logGroupRequestInfo, + logNormalFriendRequest, + }; + window.libloki.api = { sendBackgroundMessage, - sendOnlineBroadcastMessage, + sendAutoFriendRequestMessage, + sendSessionRequestsToMembers, sendPairingAuthorisation, createPairingAuthorisationProtoMessage, sendUnpairingMessageToSecondary, createContactSyncProtoMessage, createGroupSyncProtoMessage, createOpenGroupsSyncProtoMessage, + debug, }; })(); diff --git a/libloki/test/index.html b/libloki/test/index.html index f9eb9c2e4..dc6d74e2e 100644 --- a/libloki/test/index.html +++ b/libloki/test/index.html @@ -23,6 +23,7 @@ + @@ -33,6 +34,7 @@ + diff --git a/libloki/test/messages.js b/libloki/test/messages.js new file mode 100644 index 000000000..6cb8dda0c --- /dev/null +++ b/libloki/test/messages.js @@ -0,0 +1,78 @@ +/* global assert */ + +describe('Loki Messages', () => { + describe('#backgroundMessage', () => { + it('structure is valid', () => { + const pubkey = + '05050505050505050505050505050505050505050505050505050505050505050'; + const backgroundMessage = window.textsecure.OutgoingMessage.buildBackgroundMessage( + pubkey + ); + + const validBackgroundObject = { + server: null, + numbers: [pubkey], + // For now, a background message contains only a loki address message as + // it must not be an empty message for android + }; + + const validBgMessage = { + dataMessage: null, + syncMessage: null, + callMessage: null, + nullMessage: null, + receiptMessage: null, + typingMessage: null, + preKeyBundleMessage: null, + pairingAuthorisation: null, + }; + + const lokiAddressMessage = { + p2pAddress: null, + p2pPort: null, + type: 1, + }; + + assert.isNumber(backgroundMessage.timestamp); + assert.isFunction(backgroundMessage.callback); + assert.deepInclude(backgroundMessage, validBackgroundObject); + assert.deepInclude(backgroundMessage.message, validBgMessage); + assert.deepInclude( + backgroundMessage.message.lokiAddressMessage, + lokiAddressMessage + ); + }); + }); + + describe('#autoFriendRequestMessage', () => { + it('structure is valid', () => { + const pubkey = + '05050505050505050505050505050505050505050505050505050505050505050'; + const autoFrMessage = window.textsecure.OutgoingMessage.buildAutoFriendRequestMessage( + pubkey + ); + + const validAutoFrObject = { + server: null, + numbers: [pubkey], + }; + + const validAutoFrMessage = { + syncMessage: null, + callMessage: null, + nullMessage: null, + receiptMessage: null, + typingMessage: null, + preKeyBundleMessage: null, + lokiAddressMessage: null, + pairingAuthorisation: null, + }; + + assert.isNumber(autoFrMessage.timestamp); + assert.isFunction(autoFrMessage.callback); + assert.deepInclude(autoFrMessage.message, validAutoFrMessage); + assert.isObject(autoFrMessage.message.dataMessage); + assert.deepInclude(autoFrMessage, validAutoFrObject); + }); + }); + }); \ No newline at end of file diff --git a/libtextsecure/message_receiver.js b/libtextsecure/message_receiver.js index 01147ea74..460a5faf8 100644 --- a/libtextsecure/message_receiver.js +++ b/libtextsecure/message_receiver.js @@ -121,8 +121,24 @@ MessageReceiver.prototype.extend({ ); } - const ev = new Event('message'); - ev.confirm = function confirmTerm() {}; + const ourNumber = textsecure.storage.user.getNumber(); + const primaryDevice = window.storage.get('primaryDevicePubKey'); + const isOurDevice = + message.source && + (message.source === ourNumber || message.source === primaryDevice); + const isPublicChatMessage = + message.message.group && + message.message.group.id && + !!message.message.group.id.match(/^publicChat:/); + let ev; + + if (isPublicChatMessage && isOurDevice) { + // Public chat messages from ourselves should be outgoing + ev = new Event('sent'); + } else { + ev = new Event('message'); + } + ev.confirm = function confirmTerm() { }; ev.data = message; this.dispatchAndWait(ev); }, @@ -922,25 +938,7 @@ MessageReceiver.prototype.extend({ } return p.then(() => this.processDecrypted(envelope, msg).then(message => { - const groupId = message.group && message.group.id; - const isBlocked = this.isGroupBlocked(groupId); const primaryDevicePubKey = window.storage.get('primaryDevicePubKey'); - 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` - ); - return this.removeFromCache(envelope); - } // handle profileKey and avatar updates if (envelope.source === primaryDevicePubKey) { @@ -1054,40 +1052,6 @@ MessageReceiver.prototype.extend({ await this.removeFromCache(envelope); } }, - async sendFriendRequestsToSyncContacts(contacts) { - const attachmentPointer = await this.handleAttachment(contacts); - const contactBuffer = new ContactBuffer(attachmentPointer.data); - let contactDetails = contactBuffer.next(); - // Extract just the pubkeys - const friendPubKeys = []; - while (contactDetails !== undefined) { - friendPubKeys.push(contactDetails.number); - contactDetails = contactBuffer.next(); - } - return Promise.all( - friendPubKeys.map(async pubKey => { - const c = await window.ConversationController.getOrCreateAndWait( - pubKey, - 'private' - ); - if (!c) { - return null; - } - const attachments = []; - const quote = null; - const linkPreview = null; - // Send an empty message, the underlying logic will know - // it should send a friend request - return c.sendMessage('', attachments, quote, linkPreview); - }) - ); - }, - async handleAuthorisationForContact(envelope) { - window.log.error( - 'Unexpected pairing request/authorisation received, ignoring.' - ); - return this.removeFromCache(envelope); - }, async handlePairingAuthorisationMessage(envelope, content) { const { pairingAuthorisation } = content; const { secondaryDevicePubKey, grantSignature } = pairingAuthorisation; @@ -1104,45 +1068,6 @@ MessageReceiver.prototype.extend({ return this.handlePairingRequest(envelope, pairingAuthorisation); }, - async handleSecondaryDeviceFriendRequest(pubKey, deviceMapping) { - 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 || !c.isFriendWithAnyDevice()) { - return false; - } - await libloki.storage.savePairingAuthorisation(authorisation); - - return true; - }, - async updateProfile(conversation, profile, profileKey) { // Retain old values unless changed: const newProfile = conversation.get('profile') || {}; @@ -1195,6 +1120,70 @@ MessageReceiver.prototype.extend({ 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 { @@ -1301,152 +1290,50 @@ MessageReceiver.prototype.extend({ 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 groupId = message.group && message.group.id; - const isBlocked = this.isGroupBlocked(groupId); - const ourPubKey = textsecure.storage.user.getNumber(); - const isMe = envelope.source === ourPubKey; - const conversation = window.ConversationController.get(envelope.source); - const isLeavingGroup = Boolean( - message.group && - message.group.type === textsecure.protobuf.GroupContext.Type.QUIT - ); - const friendRequest = - envelope.type === textsecure.protobuf.Envelope.Type.FRIEND_REQUEST; - const { UNPAIRING_REQUEST } = textsecure.protobuf.DataMessage.Flags; - // eslint-disable-next-line no-bitwise - const isUnpairingRequest = Boolean(message.flags & UNPAIRING_REQUEST); - - if (!friendRequest && isUnpairingRequest) { - // TODO: move high-level pairing logic to libloki.multidevice.xx - - const unpairingRequestIsLegit = async () => { - const isSecondary = textsecure.storage.get('isSecondaryDevice'); - if (!isSecondary) { - return false; - } - const primaryPubKey = window.storage.get('primaryDevicePubKey'); - // TODO: allow unpairing from any paired device? - if (envelope.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; - }; - - const legit = await unpairingRequestIsLegit(); + const { UNPAIRING_REQUEST } = textsecure.protobuf.DataMessage.Flags; - this.removeFromCache(envelope); + const friendRequest = + envelope.type === textsecure.protobuf.Envelope.Type.FRIEND_REQUEST; + // eslint-disable-next-line no-bitwise + const isUnpairingRequest = Boolean(message.flags & UNPAIRING_REQUEST); - if (legit) { - // 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 - ); + if (isUnpairingRequest) { + return this.handleUnpairRequest(envelope, ourPubKey); } - window.restart(); - } - } - - // Check if we need to update any profile names - if (!isMe && conversation) { - if (message.profile) { - await this.updateProfile( - conversation, - message.profile, - message.profileKey - ); - } - } - // If we got a friend request message 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 - const isGroupMessage = !!groupId; - if (friendRequest || (!isGroupMessage && !conversation.isFriend())) { - if (isMe) { - window.log.info('refusing to add a friend request to ourselves'); - throw new Error('Cannot add a friend request for ourselves!'); - } else { - const senderPubKey = envelope.source; - // fetch the device mapping from the server - const deviceMapping = await lokiFileServerAPI.getUserDeviceMapping( - senderPubKey - ); - // auto-accept friend request if the device is paired to one of our friend - const autoAccepted = await this.handleSecondaryDeviceFriendRequest( - senderPubKey, - deviceMapping - ); - if (autoAccepted) { - // sending a message back = accepting friend request - // Directly setting friend request status to skip the pending state - await conversation.setFriendRequestStatus( - window.friends.friendRequestStatusEnum.friends + // Check if we need to update any profile names + if (!isMe && conversation && message.profile) { + await this.updateProfile( + conversation, + message.profile, + message.profileKey ); - window.libloki.api.sendBackgroundMessage(envelope.source); - return this.removeFromCache(envelope); } - } - } - - if (groupId && isBlocked && !(isMe && isLeavingGroup)) { - window.log.warn( - `Message ${this.getEnvelopeId( - envelope - )} ignored; destined for blocked group` - ); - return this.removeFromCache(envelope); - } if (!friendRequest && this.isMessageEmpty(message)) { window.log.warn( `Message ${this.getEnvelopeId(envelope)} ignored; it was empty` ); return this.removeFromCache(envelope); } - const ev = new Event('message'); - ev.confirm = this.removeFromCache.bind(this, envelope); - ev.data = { - friendRequest, - source: envelope.source, - sourceDevice: envelope.sourceDevice, - timestamp: envelope.timestamp.toNumber(), - receivedAt: envelope.receivedAt, - unidentifiedDeliveryReceived: envelope.unidentifiedDeliveryReceived, - message, - }; - return this.dispatchAndWait(ev); + // Build a 'message' event i.e. a received message event + const ev = new Event('message'); + ev.confirm = this.removeFromCache.bind(this, envelope); + ev.data = { + friendRequest, + source: senderPubKey, + sourceDevice: envelope.sourceDevice, + timestamp: envelope.timestamp.toNumber(), + receivedAt: envelope.receivedAt, + unidentifiedDeliveryReceived: envelope.unidentifiedDeliveryReceived, + message, + }; + return this.dispatchAndWait(ev); }, isMessageEmpty({ body, @@ -1485,13 +1372,13 @@ MessageReceiver.prototype.extend({ async handleContentMessage(envelope) { const plaintext = await this.decrypt(envelope, envelope.content); - if (!plaintext) { - window.log.warn('handleContentMessage: plaintext was falsey'); - return null; + if (!plaintext) { + window.log.warn('handleContentMessage: plaintext was falsey'); + return null; } else if (plaintext instanceof ArrayBuffer && plaintext.byteLength === 0) { - return null; - } - return this.innerHandleContentMessage(envelope, plaintext); + return null; + } + return this.innerHandleContentMessage(envelope, plaintext); }, async innerHandleContentMessage(envelope, plaintext) { const content = textsecure.protobuf.Content.decode(plaintext); @@ -1750,6 +1637,10 @@ MessageReceiver.prototype.extend({ }); }, handleOpenGroups(envelope, openGroups) { + const groupsArray = openGroups.map(openGroup => openGroup.url); + libloki.api.debug.logGroupSync( + `Received GROUP_SYNC with open groups: [${groupsArray}]` + ); openGroups.forEach(({ url, channelId }) => { window.attemptConnection(url, channelId); }); @@ -1796,9 +1687,6 @@ MessageReceiver.prototype.extend({ isBlocked(number) { return textsecure.storage.get('blocked', []).indexOf(number) >= 0; }, - isGroupBlocked(groupId) { - return textsecure.storage.get('blocked-groups', []).indexOf(groupId) >= 0; - }, cleanAttachment(attachment) { return { ..._.omit(attachment, 'thumbnail'), @@ -1839,9 +1727,6 @@ MessageReceiver.prototype.extend({ ...attachment, data: dcodeIO.ByteBuffer.wrap(attachment.data).toArrayBuffer(), // ByteBuffer to ArrayBuffer }); - - const cleaned = this.cleanAttachment(attachment); - return this.downloadAttachment(cleaned); }, async handleEndSession(number) { window.log.info('got end session'); diff --git a/libtextsecure/outgoing_message.js b/libtextsecure/outgoing_message.js index 61d552a76..3b568bd1e 100644 --- a/libtextsecure/outgoing_message.js +++ b/libtextsecure/outgoing_message.js @@ -107,6 +107,28 @@ function getStaleDeviceIdsForNumber(number) { }); } +const DebugMessageType = { + AUTO_FR_REQUEST: 'auto-friend-request', + AUTO_FR_ACCEPT: 'auto-friend-accept', + + SESSION_REQUEST: 'session-request', + SESSION_REQUEST_ACCEPT: 'session-request-accepted', + + SESSION_RESET: 'session-reset', + SESSION_RESET_RECV: 'session-reset-received', + + OUTGOING_FR_ACCEPTED: 'outgoing-friend-request-accepted', + INCOMING_FR_ACCEPTED: 'incoming-friend-request-accept', + + REQUEST_SYNC_SEND: 'request-sync-send', + CONTACT_SYNC_SEND: 'contact-sync-send', + CLOSED_GROUP_SYNC_SEND: 'closed-group-sync-send', + OPEN_GROUP_SYNC_SEND: 'open-group-sync-send', + + DEVICE_UNPAIRING_SEND: 'device-unpairing-send', + PAIRING_REQUEST_SEND: 'pairing-request', +}; + function OutgoingMessage( server, timestamp, @@ -141,10 +163,10 @@ function OutgoingMessage( senderCertificate, online, messageType, - isPing, isPublic, isMediumGroup, publicSendData, + debugMessageType, } = options || {}; this.numberInfo = numberInfo; @@ -159,7 +181,7 @@ function OutgoingMessage( this.senderCertificate = senderCertificate; this.online = online; this.messageType = messageType || 'outgoing'; - this.isPing = isPing || false; + this.debugMessageType = debugMessageType; } OutgoingMessage.prototype = { @@ -206,8 +228,8 @@ OutgoingMessage.prototype = { ) .then(devicesPubKeys => { if (devicesPubKeys.length === 0) { - // eslint-disable-next-line no-param-reassign - devicesPubKeys = [primaryPubKey]; + // No need to start the sending of message without a recipient + return Promise.resolve(); } return this.doSendMessage(primaryPubKey, devicesPubKeys); }) @@ -301,7 +323,6 @@ OutgoingMessage.prototype = { // TODO: Make NUM_CONCURRENT_CONNECTIONS a global constant const options = { numConnections: NUM_SEND_CONNECTIONS, - isPing: this.isPing, }; options.isPublic = this.isPublic; if (this.isPublic) { @@ -392,6 +413,7 @@ OutgoingMessage.prototype = { } let messageBuffer; + let logDetails; if (isMultiDeviceRequest) { const tempMessage = new textsecure.protobuf.Content(); const tempDataMessage = new textsecure.protobuf.DataMessage(); @@ -402,9 +424,35 @@ OutgoingMessage.prototype = { tempMessage.preKeyBundleMessage = this.message.preKeyBundleMessage; tempMessage.dataMessage = tempDataMessage; messageBuffer = tempMessage.toArrayBuffer(); + logDetails = { + tempMessage, + }; } else { messageBuffer = this.message.toArrayBuffer(); + logDetails = { + message: this.message, + }; + } + const messageTypeStr = this.debugMessageType; + + const ourPubKey = textsecure.storage.user.getNumber(); + const ourPrimaryPubkey = window.storage.get('primaryDevicePubKey'); + const secondaryPubKeys = + (await window.libloki.storage.getSecondaryDevicesFor(ourPubKey)) || []; + let aliasedPubkey = devicePubKey; + if (devicePubKey === ourPubKey) { + aliasedPubkey = 'OUR_PUBKEY'; // should not happen + } else if (devicePubKey === ourPrimaryPubkey) { + aliasedPubkey = 'OUR_PRIMARY_PUBKEY'; + } else if (secondaryPubKeys.includes(devicePubKey)) { + aliasedPubkey = 'OUR SECONDARY PUBKEY'; } + libloki.api.debug.logSessionMessageSending( + `Sending ${messageTypeStr}:${ + this.messageType + } message to ${aliasedPubkey} details:`, + logDetails + ); const plaintext = _getPlaintext(messageBuffer); @@ -480,7 +528,7 @@ OutgoingMessage.prototype = { const content = window.Signal.Crypto.arrayBufferToBase64(ciphertext); return { - type, // FallBackSessionCipher sets this to FRIEND_REQUEST + type, ttl, ourKey, sourceDevice, @@ -502,7 +550,6 @@ OutgoingMessage.prototype = { this.successfulNumbers[this.successfulNumbers.length] = number; this.numberCompleted(); }, - async sendMediumGroupMessage(groupId) { const ttl = getTTLForType(this.messageType); @@ -531,7 +578,7 @@ OutgoingMessage.prototype = { ciphertext, source, keyIdx, - }); + }); // Encrypt for the group's identity key to hide source and key idx: const { @@ -575,7 +622,7 @@ OutgoingMessage.prototype = { this.successfulNumbers[this.successfulNumbers.length] = groupId; this.numberCompleted(); }, - // Send a message to a private group or a session chat (one to one) + // Send a message to a private group member or a session chat (one to one) async sendSessionMessage(outgoingObjects) { // TODO: handle multiple devices/messages per transmit const promises = outgoingObjects.map(async outgoingObject => { @@ -588,11 +635,10 @@ OutgoingMessage.prototype = { isFriendRequest, isSessionRequest, } = outgoingObject; + try { - const socketMessage = wrapInWebsocketMessage( - outgoingObject, - this.timestamp - ); + const socketMessage = wrapInWebsocketMessage(outgoingObject, + this.timestamp); await this.transmitMessage( destination, socketMessage, @@ -689,5 +735,159 @@ OutgoingMessage.prototype = { }, }; +OutgoingMessage.buildAutoFriendRequestMessage = function buildAutoFriendRequestMessage( + pubKey +) { + const dataMessage = new textsecure.protobuf.DataMessage({}); + + const content = new textsecure.protobuf.Content({ + dataMessage, + }); + + const options = { + messageType: 'onlineBroadcast', + debugMessageType: DebugMessageType.AUTO_FR_REQUEST, + }; + // Send a empty message with information about how to contact us directly + return new OutgoingMessage( + null, // server + Date.now(), // timestamp, + [pubKey], // numbers + content, // message + true, // silent + () => null, // callback + options + ); +}; + +OutgoingMessage.buildSessionRequestMessage = function buildSessionRequestMessage( + pubKey +) { + const body = + '(If you see this message, you must be using an out-of-date client)'; + const flags = textsecure.protobuf.DataMessage.Flags.SESSION_REQUEST; + + const dataMessage = new textsecure.protobuf.DataMessage({ body, flags }); + + const content = new textsecure.protobuf.Content({ + dataMessage, + }); + + const options = { + messageType: 'friend-request', + debugMessageType: DebugMessageType.SESSION_REQUEST, + }; + // Send a empty message with information about how to contact us directly + return new OutgoingMessage( + null, // server + Date.now(), // timestamp, + [pubKey], // numbers + content, // message + true, // silent + () => null, // callback + options + ); +}; + +OutgoingMessage.buildBackgroundMessage = function buildBackgroundMessage( + pubKey, + debugMessageType +) { + const p2pAddress = null; + const p2pPort = null; + // We result loki address message for sending "background" messages + const type = textsecure.protobuf.LokiAddressMessage.Type.HOST_UNREACHABLE; + + // This is needed even if LokiAddressMessage shouldn't be used. + // looks like the message is not sent or dropped on reception + // if the content is completely empty + const lokiAddressMessage = new textsecure.protobuf.LokiAddressMessage({ + p2pAddress, + p2pPort, + type, + }); + const content = new textsecure.protobuf.Content({ lokiAddressMessage }); + + const options = { messageType: 'onlineBroadcast', debugMessageType }; + // Send a empty message with information about how to contact us directly + return new OutgoingMessage( + null, // server + Date.now(), // timestamp, + [pubKey], // numbers + content, // message + true, // silent + () => null, // callback + options + ); +}; + +OutgoingMessage.buildUnpairingMessage = function buildUnpairingMessage(pubKey) { + const flags = textsecure.protobuf.DataMessage.Flags.UNPAIRING_REQUEST; + const dataMessage = new textsecure.protobuf.DataMessage({ + flags, + }); + const content = new textsecure.protobuf.Content({ + dataMessage, + }); + const debugMessageType = DebugMessageType.DEVICE_UNPAIRING_SEND; + const options = { messageType: 'device-unpairing', debugMessageType }; + const outgoingMessage = new textsecure.OutgoingMessage( + null, // server + Date.now(), // timestamp, + [pubKey], // numbers + content, // message + true, // silent + () => null, // callback + options + ); + return outgoingMessage; +}; + +OutgoingMessage.buildPairingRequestMessage = function buildPairingRequestMessage( + pubKey, + ourNumber, + ourConversation, + authorisation, + pairingAuthorisation, + callback +) { + const content = new textsecure.protobuf.Content({ + pairingAuthorisation, + }); + const isGrant = authorisation.primaryDevicePubKey === ourNumber; + if (isGrant) { + // Send profile name to secondary device + const lokiProfile = ourConversation.getLokiProfile(); + // profile.avatar is the path to the local image + // replace with the avatar URL + const avatarPointer = ourConversation.get('avatarPointer'); + lokiProfile.avatar = avatarPointer; + const profile = new textsecure.protobuf.DataMessage.LokiProfile( + lokiProfile + ); + const profileKey = window.storage.get('profileKey'); + const dataMessage = new textsecure.protobuf.DataMessage({ + profile, + profileKey, + }); + content.dataMessage = dataMessage; + } + + const debugMessageType = DebugMessageType.PAIRING_REQUEST_SEND; + const options = { messageType: 'pairing-request', debugMessageType }; + const outgoingMessage = new textsecure.OutgoingMessage( + null, // server + Date.now(), // timestamp, + [pubKey], // numbers + content, // message + true, // silent + callback, // callback + options + ); + return outgoingMessage; +}; + +OutgoingMessage.DebugMessageType = DebugMessageType; + window.textsecure = window.textsecure || {}; window.textsecure.OutgoingMessage = OutgoingMessage; diff --git a/libtextsecure/sendmessage.js b/libtextsecure/sendmessage.js index da725272c..1008ee227 100644 --- a/libtextsecure/sendmessage.js +++ b/libtextsecure/sendmessage.js @@ -399,15 +399,6 @@ MessageSender.prototype = { ); } - const outgoing = new OutgoingMessage( - this.server, - timestamp, - numbers, - message, - silent, - callback, - options - ); const ourNumber = textsecure.storage.user.getNumber(); @@ -449,25 +440,32 @@ MessageSender.prototype = { haveSession || keysFound || options.isPublic || - options.isMediumGroup || + options.isMediumGroup || options.messageType === 'friend-request' ) { + const outgoing = new OutgoingMessage( + this.server, + timestamp, + numbers, + message, + silent, + callback, + options + ); this.queueJobForNumber(number, () => outgoing.sendToNumber(number)); } else { window.log.error(`No session for number: ${number}`); + const isGroupMessage = !!( + message && + message.dataMessage && + message.dataMessage.group + ); // If it was a message to a group then we need to send a session request - if (outgoing.isGroup) { - this.sendMessageToNumber( - number, - '(If you see this message, you must be using an out-of-date client)', - [], - undefined, - [], - Date.now(), - undefined, - undefined, - { messageType: 'friend-request', sessionRequest: true } + if (isGroupMessage) { + const sessionRequestMessage = textsecure.OutgoingMessage.buildSessionRequestMessage( + number ); + sessionRequestMessage.sendToNumber(number); } } }); @@ -645,12 +643,15 @@ MessageSender.prototype = { contentMessage.syncMessage = syncMessage; const silent = true; + const debugMessageType = + window.textsecure.OutgoingMessage.DebugMessageType.REQUEST_SYNC_SEND; + return this.sendIndividualProto( myNumber, contentMessage, Date.now(), silent, - options + { ...options, debugMessageType } ); } @@ -664,10 +665,16 @@ MessageSender.prototype = { if (!primaryDeviceKey) { return Promise.resolve(); } - + // Extract required contacts information out of conversations + const sessionContacts = conversations.filter( + c => c.isPrivate() && !c.isSecondaryDevice() && c.isFriend() + ); + if (sessionContacts.length === 0) { + return Promise.resolve(); + } // We need to sync across 3 contacts at a time // This is to avoid hitting storage server limit - const chunked = _.chunk(conversations, 3); + const chunked = _.chunk(sessionContacts, 3); const syncMessages = await Promise.all( chunked.map(c => libloki.api.createContactSyncProtoMessage(c)) ); @@ -678,12 +685,16 @@ MessageSender.prototype = { contentMessage.syncMessage = syncMessage; const silent = true; + + const debugMessageType = + window.textsecure.OutgoingMessage.DebugMessageType.CONTACT_SYNC_SEND; + return this.sendIndividualProto( primaryDeviceKey, contentMessage, Date.now(), silent, - {} // options + { debugMessageType } // options ); }); @@ -695,25 +706,38 @@ MessageSender.prototype = { // primaryDevicePubKey is set to our own number if we are the master device const primaryDeviceKey = window.storage.get('primaryDevicePubKey'); if (!primaryDeviceKey) { + window.console.debug('sendGroupSyncMessage: no primary device pubkey'); + return Promise.resolve(); + } + // We only want to sync across closed groups that we haven't left + const sessionGroups = conversations.filter( + c => c.isClosedGroup() && !c.get('left') && c.isFriend() + ); + if (sessionGroups.length === 0) { + window.console.info('No closed group to sync.'); return Promise.resolve(); } // We need to sync across 1 group at a time // This is because we could hit the storage server limit with one group - const syncPromises = conversations - .map(c => libloki.api.createGroupSyncProtoMessage([c])) + const syncPromises = sessionGroups + .map(c => libloki.api.createGroupSyncProtoMessage(c)) .filter(message => message != null) .map(syncMessage => { const contentMessage = new textsecure.protobuf.Content(); contentMessage.syncMessage = syncMessage; const silent = true; + const debugMessageType = + window.textsecure.OutgoingMessage.DebugMessageType + .CLOSED_GROUP_SYNC_SEND; + return this.sendIndividualProto( primaryDeviceKey, contentMessage, Date.now(), silent, - {} // options + { debugMessageType } // options ); }); @@ -743,12 +767,15 @@ MessageSender.prototype = { contentMessage.syncMessage = openGroupsSyncMessage; const silent = true; + const debugMessageType = + window.textsecure.OutgoingMessage.DebugMessageType.OPEN_GROUP_SYNC_SEND; + return this.sendIndividualProto( primaryDeviceKey, contentMessage, Date.now(), silent, - {} // options + { debugMessageType } // options ); }, @@ -1196,9 +1223,9 @@ MessageSender.prototype = { const attachment = await this.makeAttachmentPointer(avatar); - proto.group.avatar = attachment; - // TODO: re-enable this once we have attachments - proto.group.avatar = null; + proto.group.avatar = attachment; + // TODO: re-enable this once we have attachments + proto.group.avatar = null; await this.sendGroupProto(recipients, proto, Date.now(), options); return proto.group.id; @@ -1242,6 +1269,9 @@ MessageSender.prototype = { proto.group = new textsecure.protobuf.GroupContext(); proto.group.id = stringToArrayBuffer(groupId); proto.group.type = textsecure.protobuf.GroupContext.Type.REQUEST_INFO; + libloki.api.debug.logGroupRequestInfo( + `Sending GROUP_TYPES.REQUEST_INFO to: ${groupNumbers}, about groupId ${groupId}.` + ); return this.sendGroupProto(groupNumbers, proto, Date.now(), options); }, @@ -1260,8 +1290,12 @@ MessageSender.prototype = { profileKey, options ) { - const me = textsecure.storage.user.getNumber(); - const numbers = groupNumbers.filter(number => number !== me); + // We always assume that only primary device is a member in the group + const primaryDeviceKey = + window.storage.get('primaryDevicePubKey') || + textsecure.storage.user.getNumber(); + const numbers = groupNumbers.filter(number => number !== primaryDeviceKey); + const attrs = { recipients: numbers, timestamp, diff --git a/package.json b/package.json index 7cb20061c..4603dedd2 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "start-multi2": "cross-env NODE_APP_INSTANCE=2 electron .", "start-prod": "cross-env NODE_ENV=production NODE_APP_INSTANCE=devprod electron .", "start-prod-multi": "cross-env NODE_ENV=production NODE_APP_INSTANCE=devprod1 electron .", + "start-prod-multi-2": "cross-env NODE_ENV=production NODE_APP_INSTANCE=devprod2 electron .", "start-swarm-test": "cross-env NODE_ENV=swarm-testing NODE_APP_INSTANCE=1 electron .", "start-swarm-test-2": "cross-env NODE_ENV=swarm-testing NODE_APP_INSTANCE=2 electron .", "start-swarm-test-3": "cross-env NODE_ENV=swarm-testing NODE_APP_INSTANCE=3 electron .", diff --git a/preload.js b/preload.js index caab83f98..2b57298d6 100644 --- a/preload.js +++ b/preload.js @@ -151,7 +151,7 @@ window.open = () => null; window.eval = global.eval = () => null; window.drawAttention = () => { - window.log.info('draw attention'); + // window.log.info('draw attention'); ipc.send('draw-attention'); }; window.showWindow = () => { diff --git a/ts/components/session/RegistrationTabs.tsx b/ts/components/session/RegistrationTabs.tsx index fdc4d655c..baeb43136 100644 --- a/ts/components/session/RegistrationTabs.tsx +++ b/ts/components/session/RegistrationTabs.tsx @@ -829,7 +829,11 @@ export class RegistrationTabs extends React.Component<{}, State> { // tslint:disable-next-line: no-backbone-get-set-outside-model if (window.textsecure.storage.get('secondaryDeviceStatus') === 'ongoing') { window.log.warn('registering secondary device already ongoing'); - + window.pushToast({ + title: window.i18n('pairingOngoing'), + type: 'error', + id: 'pairingOngoing', + }); return; } this.setState({