diff --git a/js/background.js b/js/background.js index b82564f8c..fa165ffdb 100644 --- a/js/background.js +++ b/js/background.js @@ -638,23 +638,22 @@ window.doUpdateGroup = async (groupId, groupName, members, avatar) => { const ourKey = textsecure.storage.user.getNumber(); - const ev = new Event('message'); - ev.confirm = () => {}; - - ev.data = { - source: ourKey, - timestamp: Date.now(), - message: { - group: { - id: groupId, - type: textsecure.protobuf.GroupContext.Type.UPDATE, - name: groupName, - members, - avatar: null, // TODO - }, + const ev = { + groupDetails: { + id: groupId, + name: groupName, + members, + recipients: members, + active: true, + expireTimer: 0, + avatar: '', + is_medium_group: true, }, + confirm: () => {}, }; + await onGroupReceived(ev); + const convo = await ConversationController.getOrCreateAndWait( groupId, 'group' @@ -719,15 +718,42 @@ const recipients = _.union(convo.get('members'), members); - await onMessageReceived(ev); - convo.updateGroup({ - groupId, - groupName, + const isMediumGroup = convo.get('is_medium_group'); + + const updateObj = { + id: groupId, + name: groupName, avatar: nullAvatar, recipients, members, + is_medium_group: isMediumGroup, options, - }); + }; + + // Send own sender keys and group secret key + if (isMediumGroup) { + const { chainKey, keyIdx } = await window.SenderKeyAPI.getSenderKeys( + groupId, + ourKey + ); + + updateObj.senderKey = { + chainKey: StringView.arrayBufferToHex(chainKey), + keyIdx, + }; + + const groupIdentity = await window.Signal.Data.getIdentityKeyById( + groupId + ); + + const secretKeyHex = StringView.hexToArrayBuffer( + groupIdentity.secretKey + ); + + updateObj.secretKey = secretKeyHex; + } + + convo.updateGroup(updateObj); }; window.createMediumSizeGroup = async (groupName, members) => { @@ -779,6 +805,7 @@ 'group' ); + convo.updateGroupAdmins([primary]); convo.updateGroup(ev.groupDetails); convo.setFriendRequestStatus( diff --git a/js/models/conversations.js b/js/models/conversations.js index b84e84104..1f42eb47d 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -2272,6 +2272,12 @@ } }, + async saveChangesToDB() { + await window.Signal.Data.updateConversation(this.id, this.attributes, { + Conversation: Whisper.Conversation, + }); + }, + async updateGroup(providedGroupUpdate) { let groupUpdate = providedGroupUpdate; @@ -2311,23 +2317,23 @@ mgUpdate.type = textsecure.protobuf.MediumGroupUpdate.Type.NEW_GROUP; mgUpdate.groupId = id; mgUpdate.groupSecretKey = secretKey; - mgUpdate.senderKey = new textsecure.protobuf.SenderKey({ - chainKey: senderKey, - keyIdx: 0, - }); + mgUpdate.senderKey = new textsecure.protobuf.SenderKey(senderKey); mgUpdate.members = members.map(pkHex => StringView.hexToArrayBuffer(pkHex) ); mgUpdate.groupName = name; + mgUpdate.admins = this.get('groupAdmins'); proto.mediumGroupUpdate = mgUpdate; - await textsecure.messaging.updateMediumGroup(members, proto); + message.send( + this.wrapSend(textsecure.messaging.updateMediumGroup(members, proto)) + ); return; } message.send( this.wrapSend( - textsecure.messaging.updateGroup( + textsecure.messaging.sendGroupUpdate( this.id, this.get('name'), this.get('avatar'), @@ -2343,7 +2349,7 @@ sendGroupInfo(recipients) { if (this.isClosedGroup()) { const options = this.getSendOptions(); - textsecure.messaging.updateGroup( + textsecure.messaging.sendGroupUpdate( this.id, this.get('name'), this.get('avatar'), @@ -2357,6 +2363,15 @@ async leaveGroup() { const now = Date.now(); + + if (this.get('is_medium_group')) { + // NOTE: we should probably remove sender keys for groupId, + // and its secret key, but it is low priority + + // TODO: need to reset everyone's sender keys + window.lokiMessageAPI.stopPollingForGroup(this.id); + } + if (this.get('type') === 'group') { const groupNumbers = this.getRecipients(); this.set({ left: true }); diff --git a/js/modules/loki_message_api.js b/js/modules/loki_message_api.js index 4921f2b8f..982de8699 100644 --- a/js/modules/loki_message_api.js +++ b/js/modules/loki_message_api.js @@ -74,6 +74,8 @@ class LokiMessageAPI { this.jobQueue = new window.JobQueue(); this.sendingData = {}; this.ourKey = ourKey; + // stop polling for a group if its id is no longer found here + this.groupIdsToPoll = {}; } async sendMessage(pubKey, data, messageTimeStamp, ttl, options = {}) { @@ -325,7 +327,7 @@ class LokiMessageAPI { ); // eslint-disable-next-line no-constant-condition - while (true) { + while (this.groupIdsToPoll[groupId]) { try { let messages = await _retrieveNextMessages(node, groupId); @@ -384,6 +386,13 @@ class LokiMessageAPI { async pollForGroupId(groupId, onMessages) { log.info(`Starting to poll for group id: ${groupId}`); + if (this.groupIdsToPoll[groupId]) { + log.warn(`Already polling for group id: ${groupId}`); + return; + } + + this.groupIdsToPoll[groupId] = true; + // Get nodes for groupId const nodes = await lokiSnodeAPI.refreshSwarmNodesForPubKey(groupId); @@ -394,6 +403,16 @@ class LokiMessageAPI { ); } + async stopPollingForGroup(groupId) { + if (!this.groupIdsToPoll[groupId]) { + log.warn(`Already not polling for group id: ${groupId}`); + return; + } + + log.warn(`Stop polling for group id: ${groupId}`); + delete this.groupIdsToPoll[groupId]; + } + async _openRetrieveConnection(pSwarmPool, stopPollingPromise, onMessages) { const swarmPool = pSwarmPool; // lint let stopPollingResult = false; diff --git a/js/modules/loki_sender_key_api.js b/js/modules/loki_sender_key_api.js index df4b4f06d..7e895ed17 100644 --- a/js/modules/loki_sender_key_api.js +++ b/js/modules/loki_sender_key_api.js @@ -177,8 +177,10 @@ async function advanceRatchet(groupId, senderIdentity, idx) { curMessageKey = messageKey; break; } else if (nextKeyIdx > idx) { - log.error('Developer error: nextKeyIdx > idx'); - return null; + log.error( + `Could not decrypt for an older ratchet step: (${nextKeyIdx})nextKeyIdx > (${idx})idx` + ); + throw new Error(`Cannot revert ratchet for group ${groupId}!`); } else { // Store keys for skipped nextKeyIdx, we might need them to decrypt // messages that arrive out-of-order diff --git a/libloki/api.js b/libloki/api.js index 62e188450..47a31c018 100644 --- a/libloki/api.js +++ b/libloki/api.js @@ -21,6 +21,7 @@ const debugFlags = DebugFlagsEnum.ALL; const debugLogFn = (...args) => { + // eslint-disable-next-line no-constant-condition if (true) { // process.env.NODE_ENV.includes('test-integration') || window.console.warn(...args); diff --git a/libtextsecure/message_receiver.js b/libtextsecure/message_receiver.js index 569fa59df..f7c283a67 100644 --- a/libtextsecure/message_receiver.js +++ b/libtextsecure/message_receiver.js @@ -1247,72 +1247,131 @@ MessageReceiver.prototype.extend({ 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 = await window.ConversationController.getOrCreateAndWait( - groupId, - 'group' - ); - convo.set('is_medium_group', true); - convo.set('active_at', Date.now()); - convo.set('name', groupName); + const convo = groupExists + ? maybeConvo + : await window.ConversationController.getOrCreateAndWait( + groupId, + 'group' + ); - convo.setFriendRequestStatus( - window.friends.friendRequestStatusEnum.friends - ); + { + // 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, + }, + }); - await window.Signal.Data.createOrUpdateIdentityKey({ - id: groupId, - secretKey: StringView.arrayBufferToHex(groupSecretKey.toArrayBuffer()), - }); + const messageId = await window.Signal.Data.saveMessage( + message.attributes, + { + Message: Whisper.Message, + } + ); + message.set({ id: messageId }); + } - // Save sender's key - await window.SenderKeyAPI.saveSenderKeys( - groupId, - envelope.source, - senderKey.chainKey, - senderKey.keyIdx - ); + if (groupExists) { + // ***** Updating the group ***** + log.info('Received a group update for medium group:', groupId); - // TODO: Check that we are even a part of this group? - const ownSenderKey = await window.SenderKeyAPI.createSenderKeyForGroup( - groupId, - ourIdentity - ); + // Check that the sender is admin (make sure it words with multidevice) + const isAdmin = convo.get('groupAdmins').includes(senderIdentity); - { - // 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, + 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, }); - proto.mediumGroupUpdate = update; + // Save sender's key + await window.SenderKeyAPI.saveSenderKeys( + groupId, + envelope.source, + senderKey.chainKey, + senderKey.keyIdx + ); + + const ownSenderKey = await window.SenderKeyAPI.createSenderKeyForGroup( + groupId, + ourIdentity + ); - textsecure.messaging.updateMediumGroup(otherMembers, proto); - } + { + // Send own key to every member + const otherMembers = _.without(members, ourIdentity); - // Subscribe to this group - this.pollForAdditionalId(groupId); + const proto = new textsecure.protobuf.DataMessage(); - // All further messages (maybe rather than 'control' messages) should come to this group's swarm + // 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); } diff --git a/libtextsecure/outgoing_message.js b/libtextsecure/outgoing_message.js index 59d22dbb2..ae28a8c42 100644 --- a/libtextsecure/outgoing_message.js +++ b/libtextsecure/outgoing_message.js @@ -167,6 +167,7 @@ function OutgoingMessage( isMediumGroup, publicSendData, debugMessageType, + autoSession, } = options || {}; this.numberInfo = numberInfo; @@ -182,6 +183,7 @@ function OutgoingMessage( this.online = online; this.messageType = messageType || 'outgoing'; this.debugMessageType = debugMessageType; + this.autoSession = autoSession || false; } OutgoingMessage.prototype = { diff --git a/libtextsecure/sendmessage.js b/libtextsecure/sendmessage.js index 162b36dee..3a99083df 100644 --- a/libtextsecure/sendmessage.js +++ b/libtextsecure/sendmessage.js @@ -460,7 +460,7 @@ MessageSender.prototype = { message.dataMessage.group ); // If it was a message to a group then we need to send a session request - if (isGroupMessage) { + if (isGroupMessage || options.autoSession) { const sessionRequestMessage = textsecure.OutgoingMessage.buildSessionRequestMessage( number ); @@ -710,7 +710,11 @@ MessageSender.prototype = { } // 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() && !c.get('is_medium_group') + c => + c.isClosedGroup() && + !c.get('left') && + c.isFriend() && + !c.get('is_medium_group') ); if (sessionGroups.length === 0) { window.console.info('No closed group to sync.'); @@ -1207,12 +1211,18 @@ MessageSender.prototype = { }, async updateMediumGroup(members, groupUpdateProto) { + // Automatically request session if not found (updates use pairwise sessions) + const autoSession = true; + await this.sendGroupProto(members, groupUpdateProto, Date.now(), { isPublic: false, + autoSession, }); + + return true; }, - async updateGroup( + async sendGroupUpdate( groupId, name, avatar, @@ -1401,7 +1411,7 @@ textsecure.MessageSender = function MessageSenderWrapper(username, password) { this.resetSession = sender.resetSession.bind(sender); this.sendMessageToGroup = sender.sendMessageToGroup.bind(sender); this.sendTypingMessage = sender.sendTypingMessage.bind(sender); - this.updateGroup = sender.updateGroup.bind(sender); + this.sendGroupUpdate = sender.sendGroupUpdate.bind(sender); this.updateMediumGroup = sender.updateMediumGroup.bind(sender); this.addNumberToGroup = sender.addNumberToGroup.bind(sender); this.setGroupName = sender.setGroupName.bind(sender); diff --git a/protos/SignalService.proto b/protos/SignalService.proto index 3d7b4cf77..ba7375657 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -70,7 +70,8 @@ message MediumGroupUpdate { optional bytes groupSecretKey = 3; optional SenderKey senderKey = 4; repeated bytes members = 5; - optional Type type = 6; + repeated string admins = 6; + optional Type type = 7; } message LokiAddressMessage { diff --git a/stylesheets/_session.scss b/stylesheets/_session.scss index 4a79a7cd1..150ffb7ee 100644 --- a/stylesheets/_session.scss +++ b/stylesheets/_session.scss @@ -190,7 +190,7 @@ textarea { &.brand { min-width: 165px; height: 45px; - line-height: 40px; + align-items: center; padding: 0px $session-margin-lg; font-size: $session-font-md; font-family: $session-font-accent; diff --git a/ts/components/conversation/InviteFriendsDialog.tsx b/ts/components/conversation/InviteFriendsDialog.tsx index a48672230..f96ab3c60 100644 --- a/ts/components/conversation/InviteFriendsDialog.tsx +++ b/ts/components/conversation/InviteFriendsDialog.tsx @@ -111,6 +111,7 @@ export class InviteFriendsDialog extends React.Component { return members.map((member: ContactType, index: number) => ( {