From 0e25ab2874327ceaf464f353f59219db2406a6b7 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Fri, 29 Jan 2021 11:29:24 +1100 Subject: [PATCH] WIP --- package.json | 1 + protos/SignalService.proto | 6 +- ts/receiver/closedGroups.ts | 243 +++++++++++--- ts/receiver/dataMessage.ts | 4 +- ts/session/constants.ts | 4 +- ts/session/group/index.ts | 312 +++++++++++------- .../group/ClosedGroupAddedMembersMessage.ts | 47 +++ .../group/ClosedGroupMemberLeftMessage.ts | 31 ++ .../group/ClosedGroupNameChangeMessage.ts | 42 +++ .../group/ClosedGroupRemovedMembersMessage.ts | 46 +++ .../data/group/ClosedGroupUpdateMessage.ts | 51 --- .../outgoing/content/data/group/index.ts | 4 +- .../messages/outgoing/content/data/index.ts | 1 - .../unit/receiving/ClosedGroupUpdates_test.ts | 58 ++++ ts/test/session/unit/utils/Messages_test.ts | 37 ++- ts/test/test-utils/utils/envelope.ts | 41 +++ ts/test/test-utils/utils/index.ts | 1 + 17 files changed, 694 insertions(+), 235 deletions(-) create mode 100644 ts/session/messages/outgoing/content/data/group/ClosedGroupAddedMembersMessage.ts create mode 100644 ts/session/messages/outgoing/content/data/group/ClosedGroupMemberLeftMessage.ts create mode 100644 ts/session/messages/outgoing/content/data/group/ClosedGroupNameChangeMessage.ts create mode 100644 ts/session/messages/outgoing/content/data/group/ClosedGroupRemovedMembersMessage.ts delete mode 100644 ts/session/messages/outgoing/content/data/group/ClosedGroupUpdateMessage.ts create mode 100644 ts/test/session/unit/receiving/ClosedGroupUpdates_test.ts create mode 100644 ts/test/test-utils/utils/envelope.ts diff --git a/package.json b/package.json index ed3e34150..7ff770755 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "test-electron": "yarn grunt test", "test-integration": "ELECTRON_DISABLE_SANDBOX=1 mocha --exit --full-trace --timeout 10000 ts/test/session/integration/integration_itest.js", "test-node": "mocha --recursive --exit --timeout 10000 test/app test/modules \"./ts/test/**/*_test.js\" libloki/test/node ", + "test-audric": "mocha --recursive --exit --timeout 10000 ts/test/session/unit/receiving/", "eslint": "eslint --cache .", "eslint-fix": "eslint --fix .", "eslint-full": "eslint .", diff --git a/protos/SignalService.proto b/protos/SignalService.proto index 582b5c2aa..3ede0c1f1 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -149,7 +149,11 @@ message DataMessage { enum Type { NEW = 1; // publicKey, name, encryptionKeyPair, members, admins UPDATE = 2; // name, members - ENCRYPTION_KEY_PAIR = 3; // wrappers + ENCRYPTION_KEY_PAIR = 3; // wrappers + NAME_CHANGE = 4; // name + MEMBERS_ADDED = 5; // members + MEMBERS_REMOVED = 6; // members + MEMBER_LEFT = 7; } message KeyPair { diff --git a/ts/receiver/closedGroups.ts b/ts/receiver/closedGroups.ts index 43419f1a2..7e8875afb 100644 --- a/ts/receiver/closedGroups.ts +++ b/ts/receiver/closedGroups.ts @@ -21,10 +21,11 @@ import { import { ECKeyPair } from './keypairs'; import { getOurNumber } from '../session/utils/User'; import { UserUtils } from '../session/utils'; +import { ConversationModel } from '../../js/models/conversations'; -export async function handleClosedGroup( +export async function handleClosedGroupControlMessage( envelope: EnvelopePlus, - groupUpdate: any + groupUpdate: SignalService.DataMessage.ClosedGroupControlMessage ) { const { type } = groupUpdate; const { Type } = SignalService.DataMessage.ClosedGroupControlMessage; @@ -36,11 +37,11 @@ export async function handleClosedGroup( } if (type === Type.ENCRYPTION_KEY_PAIR) { - await handleKeyPairClosedGroup(envelope, groupUpdate); + await handleClosedGroupEncryptionKeyPair(envelope, groupUpdate); } else if (type === Type.NEW) { await handleNewClosedGroup(envelope, groupUpdate); - } else if (type === Type.UPDATE) { - await handleUpdateClosedGroup(envelope, groupUpdate); + } else if (type === Type.NAME_CHANGE || type === Type.MEMBERS_REMOVED || type === Type.MEMBERS_ADDED || type === Type.MEMBER_LEFT || type === Type.UPDATE) { + await performIfValid(envelope, groupUpdate); } else { window.log.error('Unknown group update type: ', type); } @@ -218,59 +219,17 @@ async function handleNewClosedGroup( async function handleUpdateClosedGroup( envelope: EnvelopePlus, - groupUpdate: SignalService.DataMessage.ClosedGroupControlMessage + groupUpdate: SignalService.DataMessage.ClosedGroupControlMessage, + convo: ConversationModel ) { - if ( - groupUpdate.type !== - SignalService.DataMessage.ClosedGroupControlMessage.Type.UPDATE - ) { - return; - } const { name, members: membersBinary } = groupUpdate; const { log } = window; // for a closed group update message, the envelope.source is the groupPublicKey const groupPublicKey = envelope.source; - const convo = ConversationController.getInstance().get(groupPublicKey); - - if (!convo) { - log.warn( - 'Ignoring a closed group update message (INFO) for a non-existing group' - ); - await removeFromCache(envelope); - return; - } - - // Check that the message isn't from before the group was created - let lastJoinedTimestamp = convo.get('lastJoinedTimestamp'); - // might happen for existing groups - if (!lastJoinedTimestamp) { - const aYearAgo = Date.now() - 1000 * 60 * 24 * 365; - convo.set({ - lastJoinedTimestamp: aYearAgo, - }); - lastJoinedTimestamp = aYearAgo; - } - - if (envelope.timestamp <= lastJoinedTimestamp) { - window.log.warn( - 'Got a group update with an older timestamp than when we joined this group last time. Dropping it' - ); - await removeFromCache(envelope); - return; - } const curAdmins = convo.get('groupAdmins'); - // Check that the sender is a member of the group (before the update) - const oldMembers = convo.get('members') || []; - if (!oldMembers.includes(envelope.senderIdentity)) { - log.error( - `Error: closed group: ignoring closed group update message from non-member. ${envelope.senderIdentity} is not a current member.` - ); - await removeFromCache(envelope); - return; - } // NOTE: admins cannot change with closed groups const members = membersBinary.map(toHex); @@ -293,8 +252,8 @@ async function handleUpdateClosedGroup( await window.Signal.Data.removeAllClosedGroupEncryptionKeyPairs( groupPublicKey ); - convo.set('isKickedFromGroup', true); // Disable typing: + convo.set('isKickedFromGroup', true); window.SwarmPolling.removePubkey(groupPublicKey); } else { if (convo.get('isKickedFromGroup')) { @@ -341,7 +300,7 @@ async function handleUpdateClosedGroup( * In this message, we have n-times the same keypair encoded with n being the number of current members. * One of that encoded keypair is the one for us. We need to find it, decode it, and save it for use with this group. */ -async function handleKeyPairClosedGroup( +async function handleClosedGroupEncryptionKeyPair( envelope: EnvelopePlus, groupUpdate: SignalService.DataMessage.ClosedGroupControlMessage ) { @@ -451,6 +410,187 @@ async function handleKeyPairClosedGroup( await removeFromCache(envelope); } + +async function performIfValid(envelope: EnvelopePlus, + groupUpdate: SignalService.DataMessage.ClosedGroupControlMessage) { + const { Type } = SignalService.DataMessage.ClosedGroupControlMessage; + + const groupPublicKey = envelope.source; + + const convo = ConversationController.getInstance().get(groupPublicKey); + if (!convo) { + window.log.warn('dropping message for nonexistent group'); + return; + } + + if (!convo) { + window.log.warn( + 'Ignoring a closed group update message (INFO) for a non-existing group' + ); + return removeFromCache(envelope); + } + + // Check that the message isn't from before the group was created + let lastJoinedTimestamp = convo.get('lastJoinedTimestamp'); + // might happen for existing groups + if (!lastJoinedTimestamp) { + const aYearAgo = Date.now() - 1000 * 60 * 24 * 365; + convo.set({ + lastJoinedTimestamp: aYearAgo, + }); + lastJoinedTimestamp = aYearAgo; + } + + if (envelope.timestamp <= lastJoinedTimestamp) { + window.log.warn( + 'Got a group update with an older timestamp than when we joined this group last time. Dropping it.' + ); + return removeFromCache(envelope); + } + + // Check that the sender is a member of the group (before the update) + const oldMembers = convo.get('members') || []; + if (!oldMembers.includes(envelope.senderIdentity)) { + window.log.error( + `Error: closed group: ignoring closed group update message from non-member. ${envelope.senderIdentity} is not a current member.` + ); + await removeFromCache(envelope); + return; + } + + if (groupUpdate.type === Type.UPDATE) { + await handleUpdateClosedGroup(envelope, groupUpdate, convo); + } else if (groupUpdate.type === Type.NAME_CHANGE) { + await handleClosedGroupNameChanged(envelope, groupUpdate, convo); + } else if (groupUpdate.type === Type.MEMBERS_ADDED) { + await handleClosedGroupMembersAdded(envelope, groupUpdate, convo); + } else if (groupUpdate.type === Type.MEMBERS_REMOVED) { + await handleClosedGroupMembersRemoved(envelope, groupUpdate, convo); + } else if (groupUpdate.type === Type.MEMBER_LEFT) { + await handleClosedGroupMemberLeft(envelope, groupUpdate, convo); + } + + + return true; +} + +async function handleClosedGroupNameChanged( + envelope: EnvelopePlus, + groupUpdate: SignalService.DataMessage.ClosedGroupControlMessage, + convo: ConversationModel +) { + // Only add update message if we have something to show + const newName = groupUpdate.name; + if ( + newName !== convo.get(('name')) + ) { + const groupDiff: ClosedGroup.GroupDiff = { + newName, + }; + await ClosedGroup.addUpdateMessage(convo, groupDiff, 'incoming'); + convo.set({ name: newName }); + await convo.commit(); + } + + + await removeFromCache(envelope); +} + +async function handleClosedGroupMembersAdded( + envelope: EnvelopePlus, + groupUpdate: SignalService.DataMessage.ClosedGroupControlMessage, + convo: ConversationModel +) { + const { members: addedMembersBinary } = groupUpdate; + const addedMembers = (addedMembersBinary || []).map(toHex); + const oldMembers = convo.get('members') || []; + const membersNotAlreadyPresent = addedMembers.filter(m => !oldMembers.includes(m)); + console.warn('membersNotAlreadyPresent', membersNotAlreadyPresent); + + if (membersNotAlreadyPresent.length === 0) { + window.log.info('no new members in this group update compared to what we have already. Skipping update'); + await removeFromCache(envelope); + return; + } + + const members = [...oldMembers, ...membersNotAlreadyPresent]; + // Only add update message if we have something to show + + const groupDiff: ClosedGroup.GroupDiff = { + joiningMembers: membersNotAlreadyPresent, + }; + await ClosedGroup.addUpdateMessage(convo, groupDiff, 'incoming'); + + convo.set({ members }); + await convo.commit(); + await removeFromCache(envelope); +} + +async function handleClosedGroupMembersRemoved( + envelope: EnvelopePlus, + groupUpdate: SignalService.DataMessage.ClosedGroupControlMessage, + convo: ConversationModel) { + +} + +async function handleClosedGroupMemberLeft( + envelope: EnvelopePlus, + groupUpdate: SignalService.DataMessage.ClosedGroupControlMessage, + convo: ConversationModel +) { + const sender = envelope.senderIdentity; + const groupPublicKey = envelope.source; + const didAdminLeave = convo.get('groupAdmins')?.includes(sender) || false; + // If the admin leaves the group is disbanded + // otherwise, we remove the sender from the list of current members in this group + const oldMembers = convo.get('members') || []; + const leftMemberWasPresent = oldMembers.includes(sender); + const members = didAdminLeave ? [] : (oldMembers).filter(s => s !== sender); + // Guard against self-sends + const ourPubkey = await UserUtils.getCurrentDevicePubKey(); + if (!ourPubkey) { + throw new Error('Could not get user pubkey'); + } + if (sender === ourPubkey) { + window.log.info('self send group update ignored'); + await removeFromCache(envelope); + return; + } + + // Generate and distribute a new encryption key pair if needed + const isCurrentUserAdmin = convo.get('groupAdmins')?.includes(ourPubkey) || false; + if (isCurrentUserAdmin && !!members.length) { + await ClosedGroup.generateAndSendNewEncryptionKeyPair(groupPublicKey, members); + } + + if (didAdminLeave) { + await window.Signal.Data.removeAllClosedGroupEncryptionKeyPairs( + groupPublicKey + ); + // Disable typing: + convo.set('isKickedFromGroup', true); + window.SwarmPolling.removePubkey(groupPublicKey); + } + // Update the group + + // Only add update message if we have something to show + if ( + leftMemberWasPresent + ) { + const groupDiff: ClosedGroup.GroupDiff = { + leavingMembers: didAdminLeave ? oldMembers : [sender], + }; + await ClosedGroup.addUpdateMessage(convo, groupDiff, 'incoming'); + } + + convo.set('members', members); + + await convo.commit(); + + await removeFromCache(envelope); +} + + export async function createClosedGroup( groupName: string, members: Array @@ -504,6 +644,7 @@ export async function createClosedGroup( // the sending pipeline needs to know from GroupUtils when a message is for a medium group await ClosedGroup.updateOrCreateClosedGroup(groupDetails); convo.set('lastJoinedTimestamp', Date.now()); + await convo.commit(); // Send a closed group update message to all members individually const promises = listOfMembers.map(async m => { diff --git a/ts/receiver/dataMessage.ts b/ts/receiver/dataMessage.ts index 93ac94bc5..ce85c8e96 100644 --- a/ts/receiver/dataMessage.ts +++ b/ts/receiver/dataMessage.ts @@ -12,7 +12,7 @@ import { StringUtils, UserUtils } from '../session/utils'; import { DeliveryReceiptMessage } from '../session/messages/outgoing'; import { getMessageQueue } from '../session'; import { ConversationController } from '../session/conversations'; -import { handleClosedGroup } from './closedGroups'; +import { handleClosedGroupControlMessage } from './closedGroups'; import { isUs } from '../session/utils/User'; export async function updateProfile( @@ -251,7 +251,7 @@ export async function handleDataMessage( window.log.info('data message from', getEnvelopeId(envelope)); if (dataMessage.closedGroupControlMessage) { - await handleClosedGroup(envelope, dataMessage.closedGroupControlMessage); + await handleClosedGroupControlMessage(envelope, dataMessage.closedGroupControlMessage as SignalService.DataMessage.ClosedGroupControlMessage); return; } diff --git a/ts/session/constants.ts b/ts/session/constants.ts index dfd6cb195..1d192b0e9 100644 --- a/ts/session/constants.ts +++ b/ts/session/constants.ts @@ -1,9 +1,7 @@ -import { DAYS, MINUTES, SECONDS } from './utils/Number'; +import { DAYS, SECONDS } from './utils/Number'; // tslint:disable: binary-expression-operand-order export const TTL_DEFAULT = { - PAIRING_REQUEST: 2 * MINUTES, - DEVICE_UNPAIRING: 4 * DAYS, TYPING_MESSAGE: 20 * SECONDS, REGULAR_MESSAGE: 2 * DAYS, ENCRYPTION_PAIR_GROUP: 4 * DAYS, diff --git a/ts/session/group/index.ts b/ts/session/group/index.ts index 4d07b074d..9eae0e79d 100644 --- a/ts/session/group/index.ts +++ b/ts/session/group/index.ts @@ -12,7 +12,6 @@ import { getMessageQueue } from '../instance'; import { ClosedGroupEncryptionPairMessage, ClosedGroupNewMessage, - ClosedGroupUpdateMessage, ExpirationTimerUpdateMessage, } from '../messages/outgoing'; import uuid from 'uuid'; @@ -21,6 +20,12 @@ import { generateCurve25519KeyPairWithoutPrefix } from '../crypto'; import { encryptUsingSessionProtocol } from '../crypto/MessageEncrypter'; import { ECKeyPair } from '../../receiver/keypairs'; import { UserUtils } from '../utils'; +import { ClosedGroupMemberLeftMessage } from '../messages/outgoing/content/data/group/ClosedGroupMemberLeftMessage'; +import { + ClosedGroupAddedMembersMessage, + ClosedGroupNameChangeMessage, + ClosedGroupRemovedMembersMessage, +} from '../messages/outgoing/content/data/group'; export interface GroupInfo { id: string; @@ -106,11 +111,6 @@ export async function initiateGroupUpdate( await updateOrCreateClosedGroup(groupDetails); - if (avatar) { - // would get to download this file on each client in the group - // and reference the local file - } - const updateObj: GroupInfo = { id: groupId, name: groupName, @@ -119,10 +119,51 @@ export async function initiateGroupUpdate( expireTimer: convo.get('expireTimer'), }; - const dbMessage = await addUpdateMessage(convo, diff, 'outgoing'); - window.getMessageController().register(dbMessage.id, dbMessage); + if (diff.newName?.length) { + const nameOnlyDiff: GroupDiff = { newName: diff.newName }; + const dbMessageName = await addUpdateMessage( + convo, + nameOnlyDiff, + 'outgoing' + ); + window.getMessageController().register(dbMessageName.id, dbMessageName); + await sendNewName(convo, diff.newName, dbMessageName.id); + } - await sendGroupUpdateForClosed(convo, diff, updateObj, dbMessage.id); + if (diff.joiningMembers?.length) { + const joiningOnlyDiff: GroupDiff = { joiningMembers: diff.joiningMembers }; + const dbMessageAdded = await addUpdateMessage( + convo, + joiningOnlyDiff, + 'outgoing' + ); + window.getMessageController().register(dbMessageAdded.id, dbMessageAdded); + await sendAddedMembers( + convo, + diff.joiningMembers, + dbMessageAdded.id, + updateObj + ); + } + + if (diff.leavingMembers?.length) { + const leavingOnlyDiff: GroupDiff = { leavingMembers: diff.leavingMembers }; + const dbMessageLeaving = await addUpdateMessage( + convo, + leavingOnlyDiff, + 'outgoing' + ); + window + .getMessageController() + .register(dbMessageLeaving.id, dbMessageLeaving); + const stillMembers = members; + await sendRemovedMembers( + convo, + diff.leavingMembers, + dbMessageLeaving.id, + stillMembers + ); + } } export async function addUpdateMessage( @@ -146,7 +187,7 @@ export async function addUpdateMessage( const now = Date.now(); - const markUnread = type === 'incoming'; + const unread = type === 'incoming'; const message = await convo.addMessage({ conversationId: convo.get('id'), @@ -154,10 +195,10 @@ export async function addUpdateMessage( sent_at: now, received_at: now, group_update: groupUpdate, - unread: markUnread, + unread, }); - if (markUnread) { + if (unread) { // update the unreadCount for this convo const unreadCount = await convo.getUnreadCount(); convo.set({ @@ -262,8 +303,6 @@ export async function updateOrCreateClosedGroup(details: GroupInfo) { } export async function leaveClosedGroup(groupId: string) { - window.SwarmPolling.removePubkey(groupId); - const convo = ConversationController.getInstance().get(groupId); if (!convo) { @@ -275,18 +314,22 @@ export async function leaveClosedGroup(groupId: string) { const now = Date.now(); let members: Array = []; + let admins: Array = []; - // for now, a destroyed group is one with those 2 flags set to true. - // FIXME audric, add a flag to conversation model when a group is destroyed + // if we are the admin, the group must be destroyed for every members if (isCurrentUserAdmin) { window.log.info('Admin left a closed group. We need to destroy it'); convo.set({ left: true }); members = []; + admins = []; } else { + // otherwise, just the exclude ourself from the members and trigger an update with this convo.set({ left: true }); members = convo.get('members').filter(m => m !== ourNumber.key); + admins = convo.get('groupAdmins') || []; } convo.set({ members }); + convo.set({ groupAdmins: admins }); await convo.commit(); const dbMessage = await convo.addMessage({ @@ -298,37 +341,62 @@ export async function leaveClosedGroup(groupId: string) { }); window.getMessageController().register(dbMessage.id, dbMessage); - const groupUpdate: GroupInfo = { - id: convo.get('id'), - name: convo.get('name'), - members, - admins: convo.get('groupAdmins'), - }; + // Send the update to the group + const ourLeavingMessage = new ClosedGroupMemberLeftMessage({ + timestamp: Date.now(), + groupId, + identifier: dbMessage.id, + expireTimer: 0, + }); - await sendGroupUpdateForClosed( - convo, - { leavingMembers: [ourNumber.key] }, - groupUpdate, - dbMessage.id + window.log.info( + `We are leaving the group ${groupId}. Sending our leaving message.` ); + // sent the message to the group and once done, remove everything related to this group + window.SwarmPolling.removePubkey(groupId); + await getMessageQueue().sendToGroup(ourLeavingMessage, async () => { + window.log.info( + `Leaving message sent ${groupId}. Removing everything related to this group.` + ); + await Data.removeAllClosedGroupEncryptionKeyPairs(groupId); + }); } -export async function sendGroupUpdateForClosed( +async function sendNewName( convo: ConversationModel, - diff: MemberChanges, - groupUpdate: GroupInfo, + name: string, messageId: string ) { - const { id: groupId, members, name: groupName, expireTimer } = groupUpdate; - const ourNumber = await UserUtils.getOurNumber(); + if (name.length === 0) { + window.log.warn('No name given for group update. Skipping'); + return; + } - const removedMembers = diff.leavingMembers || []; - const newMembers = diff.joiningMembers || []; // joining members - const wasAnyUserRemoved = removedMembers.length > 0; - const isUserLeaving = removedMembers.includes(ourNumber.key); - const isCurrentUserAdmin = convo.get('groupAdmins')?.includes(ourNumber.key); - const expireTimerToShare = expireTimer || 0; + const groupId = convo.get('id'); + // Send the update to the group + const nameChangeMessage = new ClosedGroupNameChangeMessage({ + timestamp: Date.now(), + groupId, + identifier: messageId, + expireTimer: 0, + name, + }); + await getMessageQueue().sendToGroup(nameChangeMessage); +} + +async function sendAddedMembers( + convo: ConversationModel, + addedMembers: Array, + messageId: string, + groupUpdate: GroupInfo +) { + if (!addedMembers?.length) { + window.log.warn('No addedMembers given for group update. Skipping'); + return; + } + + const { id: groupId, members, name: groupName } = groupUpdate; const admins = groupUpdate.admins || []; // Check preconditions @@ -340,103 +408,106 @@ export async function sendGroupUpdateForClosed( } const encryptionKeyPair = ECKeyPair.fromHexKeyPair(hexEncryptionKeyPair); + const expireTimer = convo.get('expireTimer') || 0; - if (removedMembers.includes(admins[0]) && newMembers.length !== 0) { - throw new Error( - "Can't remove admin from closed group without removing everyone." - ); // Error.invalidClosedGroupUpdate - } - - if (isUserLeaving && newMembers.length !== 0) { - if (removedMembers.length !== 1 || newMembers.length !== 0) { - throw new Error( - "Can't remove self and add or remove others simultaneously." - ); - } - } - - // Send the update to the group - const mainClosedGroupUpdate = new ClosedGroupUpdateMessage({ + // Send the Added Members message to the group (only members already in the group will get it) + const closedGroupControlMessage = new ClosedGroupAddedMembersMessage({ timestamp: Date.now(), groupId, + addedMembers, + identifier: messageId, + expireTimer, + }); + await getMessageQueue().sendToGroup(closedGroupControlMessage); + + // Send closed group update messages to any new members individually + const newClosedGroupUpdate = new ClosedGroupNewMessage({ + timestamp: Date.now(), name: groupName, + groupId, + admins, members, + keypair: encryptionKeyPair, identifier: messageId || uuid(), - expireTimer: expireTimerToShare, + expireTimer, }); + // if an expire timer is set, we have to send it to the joining members + let expirationTimerMessage: ExpirationTimerUpdateMessage | undefined; + if (expireTimer && expireTimer > 0) { + const expireUpdate = { + timestamp: Date.now(), + expireTimer, + groupId: groupId, + }; + + expirationTimerMessage = new ExpirationTimerUpdateMessage(expireUpdate); + } + const promises = addedMembers.map(async m => { + await ConversationController.getInstance().getOrCreateAndWait(m, 'private'); + const memberPubKey = PubKey.cast(m); + await getMessageQueue().sendToPubKey(memberPubKey, newClosedGroupUpdate); + + if (expirationTimerMessage) { + await getMessageQueue().sendToPubKey( + memberPubKey, + expirationTimerMessage + ); + } + }); + await Promise.all(promises); +} + +async function sendRemovedMembers( + convo: ConversationModel, + removedMembers: Array, + messageId: string, + stillMembers: Array +) { + if (!removedMembers?.length) { + window.log.warn('No removedMembers given for group update. Skipping'); + return; + } + const ourNumber = await UserUtils.getOurNumber(); + const admins = convo.get('groupAdmins') || []; + const groupId = convo.get('id'); + + const isCurrentUserAdmin = admins.includes(ourNumber.key); + const isUserLeaving = removedMembers.includes(ourNumber.key); if (isUserLeaving) { - window.log.info( - `We are leaving the group ${groupId}. Sending our leaving message.` + throw new Error( + 'Cannot remove members and leave the group at the same time' ); - // sent the message to the group and once done, remove everything related to this group - window.SwarmPolling.removePubkey(groupId); - await getMessageQueue().sendToGroup(mainClosedGroupUpdate, async () => { - window.log.info( - `Leaving message sent ${groupId}. Removing everything related to this group.` - ); - await Data.removeAllClosedGroupEncryptionKeyPairs(groupId); - }); - } else { - // Send the group update, and only once sent, generate and distribute a new encryption key pair if needed - await getMessageQueue().sendToGroup(mainClosedGroupUpdate, async () => { - if (wasAnyUserRemoved && isCurrentUserAdmin) { + } + if (removedMembers.includes(admins[0]) && stillMembers.length !== 0) { + throw new Error( + "Can't remove admin from closed group without removing everyone." + ); + } + const expireTimer = convo.get('expireTimer') || 0; + + // Send the update to the group and generate + distribute a new encryption key pair if needed + const mainClosedGroupControlMessage = new ClosedGroupRemovedMembersMessage({ + timestamp: Date.now(), + groupId, + removedMembers, + identifier: messageId, + expireTimer, + }); + // Send the group update, and only once sent, generate and distribute a new encryption key pair if needed + await getMessageQueue().sendToGroup( + mainClosedGroupControlMessage, + async () => { + if (isCurrentUserAdmin) { // we send the new encryption key only to members already here before the update - const membersNotNew = members.filter(m => !newMembers.includes(m)); window.log.info( `Sending group update: A user was removed from ${groupId} and we are the admin. Generating and sending a new EncryptionKeyPair` ); - await generateAndSendNewEncryptionKeyPair(groupId, membersNotNew); - } - }); - - if (newMembers.length) { - // Send closed group update messages to any new members individually - const newClosedGroupUpdate = new ClosedGroupNewMessage({ - timestamp: Date.now(), - name: groupName, - groupId, - admins, - members, - keypair: encryptionKeyPair, - identifier: messageId || uuid(), - expireTimer: expireTimerToShare, - }); - - // if an expiretimer in this ClosedGroup already, send it in another message - // if an expire timer is set, we have to send it to the joining members - let expirationTimerMessage: ExpirationTimerUpdateMessage | undefined; - if (expireTimer && expireTimer > 0) { - const expireUpdate = { - timestamp: Date.now(), - expireTimer, - groupId: groupId, - }; - - expirationTimerMessage = new ExpirationTimerUpdateMessage(expireUpdate); + await generateAndSendNewEncryptionKeyPair(groupId, stillMembers); } - const promises = newMembers.map(async m => { - await ConversationController.getInstance().getOrCreateAndWait( - m, - 'private' - ); - const memberPubKey = PubKey.cast(m); - await getMessageQueue().sendToPubKey( - memberPubKey, - newClosedGroupUpdate - ); - - if (expirationTimerMessage) { - await getMessageQueue().sendToPubKey( - memberPubKey, - expirationTimerMessage - ); - } - }); - await Promise.all(promises); } - } + ); } export async function generateAndSendNewEncryptionKeyPair( @@ -504,13 +575,13 @@ export async function generateAndSendNewEncryptionKeyPair( }) ); - const expireTimerToShare = groupConvo.get('expireTimer') || 0; + const expireTimer = groupConvo.get('expireTimer') || 0; const keypairsMessage = new ClosedGroupEncryptionPairMessage({ groupId: toHex(groupId), timestamp: Date.now(), encryptedKeyPairs: wrappers, - expireTimer: expireTimerToShare, + expireTimer, }); const messageSentCallback = async () => { @@ -518,7 +589,6 @@ export async function generateAndSendNewEncryptionKeyPair( `KeyPairMessage for ClosedGroup ${groupPublicKey} is sent. Saving the new encryptionKeyPair.` ); - // tslint:disable-next-line: no-non-null-assertion await Data.addClosedGroupEncryptionKeyPair( toHex(groupId), newKeyPair.toHexKeyPair() diff --git a/ts/session/messages/outgoing/content/data/group/ClosedGroupAddedMembersMessage.ts b/ts/session/messages/outgoing/content/data/group/ClosedGroupAddedMembersMessage.ts new file mode 100644 index 000000000..5d765685d --- /dev/null +++ b/ts/session/messages/outgoing/content/data/group/ClosedGroupAddedMembersMessage.ts @@ -0,0 +1,47 @@ +import { fromHex } from 'bytebuffer'; +import { Constants } from '../../../../..'; +import { SignalService } from '../../../../../../protobuf'; +import { fromHexToArray } from '../../../../../utils/String'; +import { + ClosedGroupMessage, + ClosedGroupMessageParams, +} from './ClosedGroupMessage'; + +interface ClosedGroupAddedMembersMessageParams + extends ClosedGroupMessageParams { + addedMembers: Array; +} + +export class ClosedGroupAddedMembersMessage extends ClosedGroupMessage { + private readonly addedMembers: Array; + + constructor(params: ClosedGroupAddedMembersMessageParams) { + super({ + timestamp: params.timestamp, + identifier: params.identifier, + groupId: params.groupId, + expireTimer: params.expireTimer, + }); + this.addedMembers = params.addedMembers; + if (!this.addedMembers?.length) { + throw new Error('addedMembers cannot be empty'); + } + } + + public dataProto(): SignalService.DataMessage { + const dataMessage = super.dataProto(); + + // tslint:disable: no-non-null-assertion + dataMessage.closedGroupControlMessage!.type = + SignalService.DataMessage.ClosedGroupControlMessage.Type.MEMBERS_ADDED; + dataMessage.closedGroupControlMessage!.members = this.addedMembers.map( + fromHexToArray + ); + + return dataMessage; + } + + public ttl(): number { + return Constants.TTL_DEFAULT.REGULAR_MESSAGE; + } +} diff --git a/ts/session/messages/outgoing/content/data/group/ClosedGroupMemberLeftMessage.ts b/ts/session/messages/outgoing/content/data/group/ClosedGroupMemberLeftMessage.ts new file mode 100644 index 000000000..e8d9e37dc --- /dev/null +++ b/ts/session/messages/outgoing/content/data/group/ClosedGroupMemberLeftMessage.ts @@ -0,0 +1,31 @@ +import { Constants } from '../../../../..'; +import { SignalService } from '../../../../../../protobuf'; +import { + ClosedGroupMessage, + ClosedGroupMessageParams, +} from './ClosedGroupMessage'; + +export class ClosedGroupMemberLeftMessage extends ClosedGroupMessage { + constructor(params: ClosedGroupMessageParams) { + super({ + timestamp: params.timestamp, + identifier: params.identifier, + groupId: params.groupId, + expireTimer: params.expireTimer, + }); + } + + public dataProto(): SignalService.DataMessage { + const dataMessage = super.dataProto(); + + // tslint:disable: no-non-null-assertion + dataMessage.closedGroupControlMessage!.type = + SignalService.DataMessage.ClosedGroupControlMessage.Type.MEMBER_LEFT; + + return dataMessage; + } + + public ttl(): number { + return Constants.TTL_DEFAULT.REGULAR_MESSAGE; + } +} diff --git a/ts/session/messages/outgoing/content/data/group/ClosedGroupNameChangeMessage.ts b/ts/session/messages/outgoing/content/data/group/ClosedGroupNameChangeMessage.ts new file mode 100644 index 000000000..06d45bcdb --- /dev/null +++ b/ts/session/messages/outgoing/content/data/group/ClosedGroupNameChangeMessage.ts @@ -0,0 +1,42 @@ +import { Constants } from '../../../../..'; +import { SignalService } from '../../../../../../protobuf'; +import { + ClosedGroupMessage, + ClosedGroupMessageParams, +} from './ClosedGroupMessage'; + +interface ClosedGroupNameChangeMessageParams extends ClosedGroupMessageParams { + name: string; +} + +export class ClosedGroupNameChangeMessage extends ClosedGroupMessage { + private readonly name: string; + + constructor(params: ClosedGroupNameChangeMessageParams) { + super({ + timestamp: params.timestamp, + identifier: params.identifier, + groupId: params.groupId, + expireTimer: params.expireTimer, + }); + this.name = params.name; + if (this.name.length === 0) { + throw new Error('name cannot be empty'); + } + } + + public dataProto(): SignalService.DataMessage { + const dataMessage = super.dataProto(); + + // tslint:disable: no-non-null-assertion + dataMessage.closedGroupControlMessage!.type = + SignalService.DataMessage.ClosedGroupControlMessage.Type.NAME_CHANGE; + dataMessage.closedGroupControlMessage!.name = this.name; + + return dataMessage; + } + + public ttl(): number { + return Constants.TTL_DEFAULT.REGULAR_MESSAGE; + } +} diff --git a/ts/session/messages/outgoing/content/data/group/ClosedGroupRemovedMembersMessage.ts b/ts/session/messages/outgoing/content/data/group/ClosedGroupRemovedMembersMessage.ts new file mode 100644 index 000000000..184bd9c76 --- /dev/null +++ b/ts/session/messages/outgoing/content/data/group/ClosedGroupRemovedMembersMessage.ts @@ -0,0 +1,46 @@ +import { Constants } from '../../../../..'; +import { SignalService } from '../../../../../../protobuf'; +import { fromHexToArray } from '../../../../../utils/String'; +import { + ClosedGroupMessage, + ClosedGroupMessageParams, +} from './ClosedGroupMessage'; + +interface ClosedGroupRemovedMembersMessageParams + extends ClosedGroupMessageParams { + removedMembers: Array; +} + +export class ClosedGroupRemovedMembersMessage extends ClosedGroupMessage { + private readonly removedMembers: Array; + + constructor(params: ClosedGroupRemovedMembersMessageParams) { + super({ + timestamp: params.timestamp, + identifier: params.identifier, + groupId: params.groupId, + expireTimer: params.expireTimer, + }); + this.removedMembers = params.removedMembers; + if (!this.removedMembers?.length) { + throw new Error('removedMembers cannot be empty'); + } + } + + public dataProto(): SignalService.DataMessage { + const dataMessage = super.dataProto(); + + // tslint:disable: no-non-null-assertion + dataMessage.closedGroupControlMessage!.type = + SignalService.DataMessage.ClosedGroupControlMessage.Type.MEMBERS_REMOVED; + dataMessage.closedGroupControlMessage!.members = this.removedMembers.map( + fromHexToArray + ); + + return dataMessage; + } + + public ttl(): number { + return Constants.TTL_DEFAULT.REGULAR_MESSAGE; + } +} diff --git a/ts/session/messages/outgoing/content/data/group/ClosedGroupUpdateMessage.ts b/ts/session/messages/outgoing/content/data/group/ClosedGroupUpdateMessage.ts deleted file mode 100644 index f5a0b2c73..000000000 --- a/ts/session/messages/outgoing/content/data/group/ClosedGroupUpdateMessage.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { SignalService } from '../../../../../../protobuf'; -import { - ClosedGroupMessage, - ClosedGroupMessageParams, -} from './ClosedGroupMessage'; -import { fromHexToArray } from '../../../../../utils/String'; - -export interface ClosedGroupUpdateMessageParams - extends ClosedGroupMessageParams { - name: string; - members: Array; - expireTimer: number; -} - -export class ClosedGroupUpdateMessage extends ClosedGroupMessage { - private readonly name: string; - private readonly members: Array; - - constructor(params: ClosedGroupUpdateMessageParams) { - super({ - timestamp: params.timestamp, - identifier: params.identifier, - groupId: params.groupId, - expireTimer: params.expireTimer, - }); - this.name = params.name; - this.members = params.members; - - // members can be empty. It means noone is in the group anymore and it happens when an admin leaves the group - if (!params.members) { - throw new Error('Members must be set'); - } - if (!params.name || params.name.length === 0) { - throw new Error('Name must cannot be empty'); - } - } - - public dataProto(): SignalService.DataMessage { - const dataMessage = new SignalService.DataMessage(); - - dataMessage.closedGroupControlMessage = new SignalService.DataMessage.ClosedGroupControlMessage(); - dataMessage.closedGroupControlMessage.type = - SignalService.DataMessage.ClosedGroupControlMessage.Type.UPDATE; - dataMessage.closedGroupControlMessage.name = this.name; - dataMessage.closedGroupControlMessage.members = this.members.map( - fromHexToArray - ); - - return dataMessage; - } -} diff --git a/ts/session/messages/outgoing/content/data/group/index.ts b/ts/session/messages/outgoing/content/data/group/index.ts index a78a92de1..e9f861ff7 100644 --- a/ts/session/messages/outgoing/content/data/group/index.ts +++ b/ts/session/messages/outgoing/content/data/group/index.ts @@ -1,4 +1,6 @@ export * from './ClosedGroupChatMessage'; export * from './ClosedGroupEncryptionPairMessage'; export * from './ClosedGroupNewMessage'; -export * from './ClosedGroupUpdateMessage'; +export * from './ClosedGroupAddedMembersMessage'; +export * from './ClosedGroupNameChangeMessage'; +export * from './ClosedGroupRemovedMembersMessage'; diff --git a/ts/session/messages/outgoing/content/data/index.ts b/ts/session/messages/outgoing/content/data/index.ts index d7f7ed20a..0aba32056 100644 --- a/ts/session/messages/outgoing/content/data/index.ts +++ b/ts/session/messages/outgoing/content/data/index.ts @@ -5,6 +5,5 @@ export * from './group/ClosedGroupMessage'; export * from './group/ClosedGroupChatMessage'; export * from './group/ClosedGroupEncryptionPairMessage'; export * from './group/ClosedGroupNewMessage'; -export * from './group/ClosedGroupUpdateMessage'; export * from './group/ClosedGroupMessage'; export * from './ExpirationTimerUpdateMessage'; diff --git a/ts/test/session/unit/receiving/ClosedGroupUpdates_test.ts b/ts/test/session/unit/receiving/ClosedGroupUpdates_test.ts new file mode 100644 index 000000000..53e950ffa --- /dev/null +++ b/ts/test/session/unit/receiving/ClosedGroupUpdates_test.ts @@ -0,0 +1,58 @@ +import chai from 'chai'; +import * as sinon from 'sinon'; +import _ from 'lodash'; +import { describe } from 'mocha'; + +import { GroupUtils, PromiseUtils, UserUtils } from '../../../../session/utils'; +import { TestUtils } from '../../../../test/test-utils'; +import { generateEnvelopePlusClosedGroup, generateGroupUpdateNameChange } from '../../../test-utils/utils/envelope'; +import { handleClosedGroupControlMessage } from '../../../../receiver/closedGroups'; +import { ConversationController } from '../../../../session/conversations'; + + +// tslint:disable-next-line: no-require-imports no-var-requires no-implicit-dependencies +const chaiAsPromised = require('chai-as-promised'); +chai.use(chaiAsPromised); + +const { expect } = chai; + +// tslint:disable-next-line: max-func-body-length +describe('ClosedGroupUpdates', () => { + // Initialize new stubbed cache + const sandbox = sinon.createSandbox(); + const ourDevice = TestUtils.generateFakePubKey(); + const ourNumber = ourDevice.key; + const groupId = TestUtils.generateFakePubKey().key; + const members = TestUtils.generateFakePubKeys(10); + const sender = members[3].key; + const getConvo = sandbox.stub(ConversationController.getInstance(), 'get'); + + beforeEach(async () => { + // Utils Stubs + sandbox.stub(UserUtils, 'getCurrentDevicePubKey').resolves(ourNumber); + }); + + afterEach(() => { + TestUtils.restoreStubs(); + sandbox.restore(); + }); + + describe('handleClosedGroupControlMessage', () => { + describe('performIfValid', () => { + it('does not perform if convo does not exist', async () => { + const envelope = generateEnvelopePlusClosedGroup(groupId, sender); + const groupUpdate = generateGroupUpdateNameChange(groupId); + getConvo.returns(undefined as any); + await handleClosedGroupControlMessage(envelope, groupUpdate); + }); + }); + + // describe('handleClosedGroupNameChanged', () => { + // it('does not trigger an update of the group if the name is the same', async () => { + // const envelope = generateEnvelopePlusClosedGroup(groupId, sender); + // const groupUpdate = generateGroupUpdateNameChange(groupId); + // await handleClosedGroupControlMessage(envelope, groupUpdate); + // }); + // }); + }); +}); diff --git a/ts/test/session/unit/utils/Messages_test.ts b/ts/test/session/unit/utils/Messages_test.ts index 62977f789..68dfd0003 100644 --- a/ts/test/session/unit/utils/Messages_test.ts +++ b/ts/test/session/unit/utils/Messages_test.ts @@ -7,9 +7,13 @@ import { ClosedGroupChatMessage } from '../../../../session/messages/outgoing/co import { ClosedGroupEncryptionPairMessage, ClosedGroupNewMessage, - ClosedGroupUpdateMessage, } from '../../../../session/messages/outgoing'; import { SignalService } from '../../../../protobuf'; +import { + ClosedGroupAddedMembersMessage, + ClosedGroupNameChangeMessage, + ClosedGroupRemovedMembersMessage, +} from '../../../../session/messages/outgoing/content/data/group'; // tslint:disable-next-line: no-require-imports no-var-requires const chaiAsPromised = require('chai-as-promised'); chai.use(chaiAsPromised); @@ -117,13 +121,38 @@ describe('Message Utils', () => { expect(rawMessage.encryption).to.equal(EncryptionType.Fallback); }); - it('passing ClosedGroupUpdateMessage returns ClosedGroup', async () => { + it('passing ClosedGroupNameChangeMessage returns ClosedGroup', async () => { const device = TestUtils.generateFakePubKey(); - const msg = new ClosedGroupUpdateMessage({ + const msg = new ClosedGroupNameChangeMessage({ timestamp: Date.now(), name: 'df', - members: [TestUtils.generateFakePubKey().key], + groupId: TestUtils.generateFakePubKey().key, + expireTimer: 0, + }); + const rawMessage = await MessageUtils.toRawMessage(device, msg); + expect(rawMessage.encryption).to.equal(EncryptionType.ClosedGroup); + }); + + it('passing ClosedGroupAddedMembersMessage returns ClosedGroup', async () => { + const device = TestUtils.generateFakePubKey(); + + const msg = new ClosedGroupAddedMembersMessage({ + timestamp: Date.now(), + addedMembers: [TestUtils.generateFakePubKey().key], + groupId: TestUtils.generateFakePubKey().key, + expireTimer: 0, + }); + const rawMessage = await MessageUtils.toRawMessage(device, msg); + expect(rawMessage.encryption).to.equal(EncryptionType.ClosedGroup); + }); + + it('passing ClosedGroupRemovedMembersMessage returns ClosedGroup', async () => { + const device = TestUtils.generateFakePubKey(); + + const msg = new ClosedGroupRemovedMembersMessage({ + timestamp: Date.now(), + removedMembers: [TestUtils.generateFakePubKey().key], groupId: TestUtils.generateFakePubKey().key, expireTimer: 0, }); diff --git a/ts/test/test-utils/utils/envelope.ts b/ts/test/test-utils/utils/envelope.ts new file mode 100644 index 000000000..00bc1e961 --- /dev/null +++ b/ts/test/test-utils/utils/envelope.ts @@ -0,0 +1,41 @@ +import { EnvelopePlus } from '../../../receiver/types'; +import { SignalService } from '../../../protobuf'; + +import uuid from 'uuid'; +import { fromHexToArray } from '../../../session/utils/String'; + + +export function generateEnvelopePlusClosedGroup( + groupId: string, + sender: string +): EnvelopePlus { + const envelope: EnvelopePlus = { + senderIdentity: sender, + receivedAt: Date.now(), + timestamp: Date.now() - 2000, + id: uuid(), + type: SignalService.Envelope.Type.CLOSED_GROUP_CIPHERTEXT, + source: groupId, + content: new Uint8Array(), + toJSON: () => ['fake'], + }; + + return envelope; +} + + +export function generateGroupUpdateNameChange( + groupId: string +): SignalService.DataMessage.ClosedGroupControlMessage { + const update: SignalService.DataMessage.ClosedGroupControlMessage = { + type: SignalService.DataMessage.ClosedGroupControlMessage.Type.NAME_CHANGE, + toJSON: () => ['fake'], + publicKey: fromHexToArray(groupId), + name: 'fakeNewName', + members: [], + admins: [], + wrappers: [], + }; + + return update; +} diff --git a/ts/test/test-utils/utils/index.ts b/ts/test/test-utils/utils/index.ts index 7cfc3adc3..aeea183af 100644 --- a/ts/test/test-utils/utils/index.ts +++ b/ts/test/test-utils/utils/index.ts @@ -2,3 +2,4 @@ export * from './timeout'; export * from './stubbing'; export * from './pubkey'; export * from './message'; +export * from './envelope';