diff --git a/preload.js b/preload.js index 3108dc279..2061fc19d 100644 --- a/preload.js +++ b/preload.js @@ -56,13 +56,12 @@ window.getDefaultFileServer = () => config.defaultFileServer; window.initialisedAPI = false; window.lokiFeatureFlags = { - multiDeviceUnpairing: true, - privateGroupChats: true, useOnionRequests: true, useOnionRequestsV2: true, useFileOnionRequests: true, useFileOnionRequestsV2: true, // more compact encoding of files in response onionRequestHops: 3, + useExplicitGroupUpdatesSending: false, }; if ( @@ -487,11 +486,10 @@ if ( } if (config.environment.includes('test-integration')) { window.lokiFeatureFlags = { - multiDeviceUnpairing: true, - privateGroupChats: true, useOnionRequests: false, useFileOnionRequests: false, useOnionRequestsV2: false, + useExplicitGroupUpdatesSending: false, }; /* eslint-disable global-require, import/no-extraneous-dependencies */ window.sinon = require('sinon'); diff --git a/ts/session/group/index.ts b/ts/session/group/index.ts index c1fac3014..e498fb8ed 100644 --- a/ts/session/group/index.ts +++ b/ts/session/group/index.ts @@ -21,6 +21,7 @@ import { ClosedGroupNameChangeMessage, ClosedGroupNewMessage, ClosedGroupRemovedMembersMessage, + ClosedGroupUpdateMessage, } from '../messages/outgoing/content/data/group'; import { ConversationModel } from '../../models/conversation'; import { MessageModel } from '../../models/message'; @@ -76,6 +77,8 @@ export async function syncMediumGroups(groups: Array) { // await Promise.all(groups.map(syncMediumGroup)); } +// tslint:disable: max-func-body-length +// tslint:disable: cyclomatic-complexity export async function initiateGroupUpdate( groupId: string, groupName: string, @@ -118,6 +121,111 @@ export async function initiateGroupUpdate( expireTimer: convo.get('expireTimer'), }; + if (!window.lokiFeatureFlags.useExplicitGroupUpdatesSending) { + // we still don't send any explicit group updates for now - only the receiving side is enabled + const dbMessageAdded = await addUpdateMessage(convo, diff, 'outgoing'); + window.getMessageController().register(dbMessageAdded.id, dbMessageAdded); + // Check preconditions + const hexEncryptionKeyPair = await Data.getLatestClosedGroupEncryptionKeyPair( + groupId + ); + if (!hexEncryptionKeyPair) { + throw new Error("Couldn't get key pair for closed group"); + } + + const encryptionKeyPair = ECKeyPair.fromHexKeyPair(hexEncryptionKeyPair); + const removedMembers = diff.leavingMembers || []; + const newMembers = diff.joiningMembers || []; // joining members + const wasAnyUserRemoved = removedMembers.length > 0; + const ourPrimary = await UserUtils.getOurNumber(); + const isUserLeaving = removedMembers.includes(ourPrimary.key); + const isCurrentUserAdmin = convo + .get('groupAdmins') + ?.includes(ourPrimary.key); + const expireTimerToShare = groupDetails.expireTimer || 0; + + const admins = convo.get('groupAdmins') || []; + 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({ + timestamp: Date.now(), + groupId, + name: groupName, + members, + identifier: dbMessageAdded.id || uuid(), + expireTimer: expireTimerToShare, + }); + + if (isUserLeaving) { + 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(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) { + // 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: dbMessageAdded.id || uuid(), + expireTimer: expireTimerToShare, + }); + + const promises = newMembers.map(async m => { + await ConversationController.getInstance().getOrCreateAndWait( + m, + 'private' + ); + const memberPubKey = PubKey.cast(m); + await getMessageQueue().sendToPubKey( + memberPubKey, + newClosedGroupUpdate + ); + }); + await Promise.all(promises); + } + } + + return; + } + if (diff.newName?.length) { const nameOnlyDiff: GroupDiff = { newName: diff.newName }; const dbMessageName = await addUpdateMessage( @@ -345,12 +453,26 @@ export async function leaveClosedGroup(groupId: string) { window.getMessageController().register(dbMessage.id, dbMessage); const existingExpireTimer = convo.get('expireTimer') || 0; // Send the update to the group - const ourLeavingMessage = new ClosedGroupMemberLeftMessage({ - timestamp: Date.now(), - groupId, - identifier: dbMessage.id, - expireTimer: existingExpireTimer, - }); + let ourLeavingMessage; + + if (window.lokiFeatureFlags.useExplicitGroupUpdatesSending) { + ourLeavingMessage = new ClosedGroupMemberLeftMessage({ + timestamp: Date.now(), + groupId, + identifier: dbMessage.id, + expireTimer: existingExpireTimer, + }); + } else { + const ourPubkey = await UserUtils.getOurNumber(); + ourLeavingMessage = new ClosedGroupUpdateMessage({ + timestamp: Date.now(), + groupId, + identifier: dbMessage.id, + expireTimer: existingExpireTimer, + name: convo.get('name'), + members: convo.get('members').filter(m => m !== ourPubkey.key), + }); + } window.log.info( `We are leaving the group ${groupId}. Sending our leaving message.` diff --git a/ts/session/messages/outgoing/content/data/group/ClosedGroupUpdateMessage.ts b/ts/session/messages/outgoing/content/data/group/ClosedGroupUpdateMessage.ts new file mode 100644 index 000000000..f5a0b2c73 --- /dev/null +++ b/ts/session/messages/outgoing/content/data/group/ClosedGroupUpdateMessage.ts @@ -0,0 +1,51 @@ +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 e9f861ff7..c16a052c1 100644 --- a/ts/session/messages/outgoing/content/data/group/index.ts +++ b/ts/session/messages/outgoing/content/data/group/index.ts @@ -4,3 +4,4 @@ export * from './ClosedGroupNewMessage'; export * from './ClosedGroupAddedMembersMessage'; export * from './ClosedGroupNameChangeMessage'; export * from './ClosedGroupRemovedMembersMessage'; +export * from './ClosedGroupUpdateMessage'; diff --git a/ts/window.d.ts b/ts/window.d.ts index 3e2d06096..a3029a7ac 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -12,7 +12,7 @@ import { LibTextsecure } from '../libtextsecure'; import { ConversationType } from '../js/modules/data'; import { RecoveryPhraseUtil } from '../libloki/modules/mnemonic'; import { ConfirmationDialogParams } from '../background'; -import {} from 'styled-components/cssprop'; +import { } from 'styled-components/cssprop'; import { ConversationControllerType } from '../js/ConversationController'; import { any } from 'underscore'; @@ -60,12 +60,11 @@ declare global { libsignal: LibsignalProtocol; log: any; lokiFeatureFlags: { - multiDeviceUnpairing: boolean; - privateGroupChats: boolean; useOnionRequests: boolean; useOnionRequestsV2: boolean; useFileOnionRequests: boolean; useFileOnionRequestsV2: boolean; + useExplicitGroupUpdatesSending: boolean; onionRequestHops: number; }; lokiFileServerAPI: LokiFileServerInstance;