diff --git a/js/background.js b/js/background.js index 859909b61..5ce84a8fc 100644 --- a/js/background.js +++ b/js/background.js @@ -787,6 +787,7 @@ 'group' ); + convo.updateGroupAdmins([primaryDeviceKey]); convo.updateGroup(ev.groupDetails); // Group conversations are automatically 'friends' @@ -795,8 +796,6 @@ window.friends.friendRequestStatusEnum.friends ); - convo.updateGroupAdmins([primaryDeviceKey]); - appView.openConversation(groupId, {}); }; @@ -1372,6 +1371,8 @@ await window.lokiFileServerAPI.updateOurDeviceMapping(); // TODO: we should ensure the message was sent and retry automatically if not await libloki.api.sendUnpairingMessageToSecondary(pubKey); + // Remove all traces of the device + ConversationController.deleteContact(pubKey); Whisper.events.trigger('refreshLinkedDeviceList'); }); } diff --git a/js/models/conversations.js b/js/models/conversations.js index 7471c5798..1889996a7 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -935,7 +935,7 @@ if (newStatus === FriendRequestStatusEnum.friends) { if (!blockSync) { // Sync contact - this.wrapSend(textsecure.messaging.sendContactSyncMessage(this)); + this.wrapSend(textsecure.messaging.sendContactSyncMessage([this])); } // Only enable sending profileKey after becoming friends this.set({ profileSharing: true }); @@ -2232,6 +2232,7 @@ this.get('name'), this.get('avatar'), this.get('members'), + this.get('groupAdmins'), groupUpdate.recipients, options ) @@ -2239,6 +2240,21 @@ ); }, + sendGroupInfo(recipients) { + if (this.isClosedGroup()) { + const options = this.getSendOptions(); + textsecure.messaging.updateGroup( + this.id, + this.get('name'), + this.get('avatar'), + this.get('members'), + this.get('groupAdmins'), + recipients, + options + ); + } + }, + async leaveGroup() { const now = Date.now(); if (this.get('type') === 'group') { @@ -2323,6 +2339,7 @@ const ourNumber = textsecure.storage.user.getNumber(); return !stillUnread.some( m => + m.propsForMessage && m.propsForMessage.text && m.propsForMessage.text.indexOf(`@${ourNumber}`) !== -1 ); diff --git a/js/models/messages.js b/js/models/messages.js index c79ca9c11..7b024886b 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -1929,78 +1929,90 @@ } } - if ( - initialMessage.group && - initialMessage.group.members && - initialMessage.group.type === GROUP_TYPES.UPDATE - ) { - if (newGroup) { - conversation.updateGroupAdmins(initialMessage.group.admins); - - conversation.setFriendRequestStatus( - window.friends.friendRequestStatusEnum.friends - ); - } - - 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 (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 (nameChanged) { - window.log.warn( - 'Non-admin attempts to change the name of the group' + 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' + ); + } - 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; + if (!messageAllowed) { + confirm(); + return null; + } + } } - } - // 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 - ); + // 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 + ); - const ourPubKey = textsecure.storage.user.getNumber(); - if (!haveSession && memberPubKey !== ourPubKey) { - ConversationController.getOrCreateAndWait( - memberPubKey, - 'private' - ).then(() => { - textsecure.messaging.sendMessageToNumber( + const ourPubKey = textsecure.storage.user.getNumber(); + if (!haveSession && memberPubKey !== ourPubKey) { + ConversationController.getOrCreateAndWait( 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 } - ); - }); - } - }); + '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, + ]); + } } const isSessionRequest = diff --git a/js/modules/loki_app_dot_net_api.js b/js/modules/loki_app_dot_net_api.js index c9664abef..8158a5bc2 100644 --- a/js/modules/loki_app_dot_net_api.js +++ b/js/modules/loki_app_dot_net_api.js @@ -229,7 +229,7 @@ class LokiAppDotNetServerAPI { window.storage.get('primaryDevicePubKey') || textsecure.storage.user.getNumber(); const profileConvo = ConversationController.get(ourNumber); - const profile = profileConvo.getLokiProfile(); + const profile = profileConvo && profileConvo.getLokiProfile(); const profileName = profile && profile.displayName; // if doesn't match, write it to the network if (tokenRes.response.data.user.name !== profileName) { diff --git a/libloki/api.js b/libloki/api.js index 9b658a07f..ef1baccf4 100644 --- a/libloki/api.js +++ b/libloki/api.js @@ -1,4 +1,4 @@ -/* global window, textsecure, Whisper, dcodeIO, StringView, ConversationController */ +/* global window, textsecure, dcodeIO, StringView, ConversationController */ // eslint-disable-next-line func-names (function() { @@ -109,8 +109,16 @@ } async function createContactSyncProtoMessage(conversations) { // Extract required contacts information out of conversations + const sessionContacts = conversations.filter( + c => c.isPrivate() && !c.isSecondaryDevice() + ); + + if (sessionContacts.length === 0) { + return null; + } + const rawContacts = await Promise.all( - conversations.map(async conversation => { + sessionContacts.map(async conversation => { const profile = conversation.getLokiProfile(); const number = conversation.getNumber(); const name = profile @@ -151,6 +159,40 @@ }); 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() + ); + + if (sessionGroups.length === 0) { + return null; + } + + 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()); + // Serialise array of byteBuffers into 1 byteBuffer + const byteBuffer = serialiseByteBuffers(groupDetails); + const data = new Uint8Array(byteBuffer.toArrayBuffer()); + const groups = new textsecure.protobuf.SyncMessage.Groups({ + data, + }); + const syncMessage = new textsecure.protobuf.SyncMessage({ + groups, + }); + return syncMessage; + } async function sendPairingAuthorisation(authorisation, recipientPubKey) { const pairingAuthorisation = createPairingAuthorisationProtoMessage( authorisation @@ -179,13 +221,6 @@ profile, profileKey, }); - // Attach contact list - const conversations = await window.Signal.Data.getConversationsWithFriendStatus( - window.friends.friendRequestStatusEnum.friends, - { ConversationCollection: Whisper.ConversationCollection } - ); - const syncMessage = await createContactSyncProtoMessage(conversations); - content.syncMessage = syncMessage; content.dataMessage = dataMessage; } // Send @@ -221,5 +256,6 @@ createPairingAuthorisationProtoMessage, sendUnpairingMessageToSecondary, createContactSyncProtoMessage, + createGroupSyncProtoMessage, }; })(); diff --git a/libloki/storage.js b/libloki/storage.js index 3a2d084e2..0e4cd3dda 100644 --- a/libloki/storage.js +++ b/libloki/storage.js @@ -131,7 +131,8 @@ if (deviceMapping.isPrimary === '0') { const { primaryDevicePubKey } = authorisations.find( - authorisation => authorisation.secondaryDevicePubKey === pubKey + authorisation => + authorisation && authorisation.secondaryDevicePubKey === pubKey ) || {}; if (primaryDevicePubKey) { // do NOT call getprimaryDeviceMapping recursively diff --git a/libtextsecure/account_manager.js b/libtextsecure/account_manager.js index 1dfca9417..088422630 100644 --- a/libtextsecure/account_manager.js +++ b/libtextsecure/account_manager.js @@ -634,6 +634,10 @@ blockSync: true, } ); + // Send sync messages + const conversations = window.getConversations().models; + textsecure.messaging.sendContactSyncMessage(conversations); + textsecure.messaging.sendGroupSyncMessage(conversations); }, validatePubKeyHex(pubKey) { const c = new Whisper.Conversation({ diff --git a/libtextsecure/message_receiver.js b/libtextsecure/message_receiver.js index 757cfbcfa..81adb351f 100644 --- a/libtextsecure/message_receiver.js +++ b/libtextsecure/message_receiver.js @@ -1469,18 +1469,24 @@ MessageReceiver.prototype.extend({ this.removeFromCache(envelope); }, async handleSyncMessage(envelope, syncMessage) { + // We should only accept sync messages from our devices const ourNumber = textsecure.storage.user.getNumber(); - // NOTE: Maybe we should be caching this list? - const ourDevices = await libloki.storage.getAllDevicePubKeysForPrimaryPubKey( + const ourPrimaryNumber = window.storage.get('primaryDevicePubKey'); + const ourOtherDevices = await libloki.storage.getAllDevicePubKeysForPrimaryPubKey( window.storage.get('primaryDevicePubKey') ); - const validSyncSender = - ourDevices && ourDevices.some(devicePubKey => devicePubKey === ourNumber); + const ourDevices = new Set([ + ourNumber, + ourPrimaryNumber, + ...ourOtherDevices, + ]); + const validSyncSender = ourDevices.has(envelope.source); if (!validSyncSender) { throw new Error( "Received sync message from a device we aren't paired with" ); } + if (syncMessage.sent) { const sentMessage = syncMessage.sent; const to = sentMessage.message.group @@ -1574,11 +1580,10 @@ MessageReceiver.prototype.extend({ }, handleGroups(envelope, groups) { window.log.info('group sync'); - const { blob } = groups; // Note: we do not return here because we don't want to block the next message on // this attachment download and a lot of processing of that attachment. - this.handleAttachment(blob).then(attachmentPointer => { + this.handleAttachment(groups).then(attachmentPointer => { const groupBuffer = new GroupBuffer(attachmentPointer.data); let groupDetails = groupBuffer.next(); const promises = []; @@ -1786,6 +1791,10 @@ MessageReceiver.prototype.extend({ 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'); diff --git a/libtextsecure/sendmessage.js b/libtextsecure/sendmessage.js index 57ebb6a6a..3d39d472d 100644 --- a/libtextsecure/sendmessage.js +++ b/libtextsecure/sendmessage.js @@ -664,40 +664,67 @@ MessageSender.prototype = { return Promise.resolve(); }, - async sendContactSyncMessage(contactConversation) { - if (!contactConversation.isPrivate()) { + async sendContactSyncMessage(conversations) { + // If we havn't got a primaryDeviceKey then we are in the middle of pairing + // primaryDevicePubKey is set to our own number if we are the master device + const primaryDeviceKey = window.storage.get('primaryDevicePubKey'); + if (!primaryDeviceKey) { 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 syncMessages = await Promise.all( + chunked.map(c => libloki.api.createContactSyncProtoMessage(c)) + ); + const syncPromises = syncMessages + .filter(message => message != null) + .map(syncMessage => { + const contentMessage = new textsecure.protobuf.Content(); + contentMessage.syncMessage = syncMessage; + + const silent = true; + return this.sendIndividualProto( + primaryDeviceKey, + contentMessage, + Date.now(), + silent, + {} // options + ); + }); + + return Promise.all(syncPromises); + }, + + sendGroupSyncMessage(conversations) { + // If we havn't got a primaryDeviceKey then we are in the middle of pairing + // primaryDevicePubKey is set to our own number if we are the master device const primaryDeviceKey = window.storage.get('primaryDevicePubKey'); - const allOurDevices = (await libloki.storage.getAllDevicePubKeysForPrimaryPubKey( - primaryDeviceKey - )) - // Don't send to ourselves - .filter(pubKey => pubKey !== textsecure.storage.user.getNumber()); - if ( - allOurDevices.includes(contactConversation.id) || - !primaryDeviceKey || - allOurDevices.length === 0 - ) { - // If we havn't got a primaryDeviceKey then we are in the middle of pairing + if (!primaryDeviceKey) { return Promise.resolve(); } - const syncMessage = await libloki.api.createContactSyncProtoMessage([ - contactConversation, - ]); - const contentMessage = new textsecure.protobuf.Content(); - contentMessage.syncMessage = syncMessage; + // 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])) + .filter(message => message != null) + .map(syncMessage => { + const contentMessage = new textsecure.protobuf.Content(); + contentMessage.syncMessage = syncMessage; + + const silent = true; + return this.sendIndividualProto( + primaryDeviceKey, + contentMessage, + Date.now(), + silent, + {} // options + ); + }); - const silent = true; - return this.sendIndividualProto( - primaryDeviceKey, - contentMessage, - Date.now(), - silent, - {} // options - ); + return Promise.all(syncPromises); }, sendRequestContactSyncMessage(options) { @@ -1107,7 +1134,7 @@ MessageSender.prototype = { return this.sendMessage(attrs, options); }, - updateGroup(groupId, name, avatar, members, recipients, options) { + updateGroup(groupId, name, avatar, members, admins, recipients, options) { const proto = new textsecure.protobuf.DataMessage(); proto.group = new textsecure.protobuf.GroupContext(); @@ -1164,6 +1191,14 @@ MessageSender.prototype = { }); }, + requestGroupInfo(groupId, groupNumbers, options) { + const proto = new textsecure.protobuf.DataMessage(); + proto.group = new textsecure.protobuf.GroupContext(); + proto.group.id = stringToArrayBuffer(groupId); + proto.group.type = textsecure.protobuf.GroupContext.Type.REQUEST_INFO; + return this.sendGroupProto(groupNumbers, proto, Date.now(), options); + }, + leaveGroup(groupId, groupNumbers, options) { const proto = new textsecure.protobuf.DataMessage(); proto.group = new textsecure.protobuf.GroupContext(); @@ -1251,6 +1286,7 @@ textsecure.MessageSender = function MessageSenderWrapper(username, password) { sender ); this.sendContactSyncMessage = sender.sendContactSyncMessage.bind(sender); + this.sendGroupSyncMessage = sender.sendGroupSyncMessage.bind(sender); this.sendRequestConfigurationSyncMessage = sender.sendRequestConfigurationSyncMessage.bind( sender ); @@ -1263,6 +1299,7 @@ textsecure.MessageSender = function MessageSenderWrapper(username, password) { this.addNumberToGroup = sender.addNumberToGroup.bind(sender); this.setGroupName = sender.setGroupName.bind(sender); this.setGroupAvatar = sender.setGroupAvatar.bind(sender); + this.requestGroupInfo = sender.requestGroupInfo.bind(sender); this.leaveGroup = sender.leaveGroup.bind(sender); this.sendSyncMessage = sender.sendSyncMessage.bind(sender); this.getProfile = sender.getProfile.bind(sender); diff --git a/protos/SignalService.proto b/protos/SignalService.proto index 41f6ed5ab..f383448fe 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -282,6 +282,7 @@ message SyncMessage { message Groups { optional AttachmentPointer blob = 1; + optional bytes data = 101; } message Blocked { @@ -390,4 +391,5 @@ message GroupDetails { optional uint32 expireTimer = 6; optional string color = 7; optional bool blocked = 8; + repeated string admins = 9; }