import { SignalService } from '../protobuf'; import { removeFromCache } from './cache'; import { EnvelopePlus } from './types'; import { PubKey } from '../session/types'; import { toHex } from '../session/utils/String'; import { getConversationController } from '../session/conversations'; import * as ClosedGroup from '../session/group'; import { BlockedNumberController } from '../util'; import { generateClosedGroupPublicKey, generateCurve25519KeyPairWithoutPrefix, } from '../session/crypto'; import { getMessageQueue } from '../session'; import { decryptWithSessionProtocol } from './contentMessage'; import { addClosedGroupEncryptionKeyPair, getAllEncryptionKeyPairsForGroup, getLatestClosedGroupEncryptionKeyPair, removeAllClosedGroupEncryptionKeyPairs, } from '../../ts/data/data'; import { ClosedGroupNewMessage, ClosedGroupNewMessageParams, } from '../session/messages/outgoing/controlMessage/group/ClosedGroupNewMessage'; import { ECKeyPair, HexKeyPair } from './keypairs'; import { UserUtils } from '../session/utils'; import { ConversationModel, ConversationTypeEnum } from '../models/conversation'; import _ from 'lodash'; import { forceSyncConfigurationNowIfNeeded } from '../session/utils/syncUtils'; import { getMessageController } from '../session/messages'; import { ClosedGroupEncryptionPairReplyMessage } from '../session/messages/outgoing/controlMessage/group/ClosedGroupEncryptionPairReplyMessage'; import { queueAllCachedFromSource } from './receiver'; import { openConversationWithMessages } from '../state/ducks/conversations'; import { getSwarmPollingInstance } from '../session/snode_api'; import { MessageModel } from '../models/message'; import { updateConfirmModal } from '../state/ducks/modalDialog'; import { perfEnd, perfStart } from '../session/utils/Performance'; export const distributingClosedGroupEncryptionKeyPairs = new Map(); // this is a cache of the keypairs stored in the db. const cacheOfClosedGroupKeyPairs: Map> = new Map(); export async function getAllCachedECKeyPair(groupPubKey: string) { let keyPairsFound = cacheOfClosedGroupKeyPairs.get(groupPubKey); if (!keyPairsFound || keyPairsFound.length === 0) { keyPairsFound = (await getAllEncryptionKeyPairsForGroup(groupPubKey)) || []; cacheOfClosedGroupKeyPairs.set(groupPubKey, keyPairsFound); } return keyPairsFound.slice(); } /** * * @returns true if this keypair was not already saved for this publickey */ export async function addKeyPairToCacheAndDBIfNeeded( groupPubKey: string, keyPair: HexKeyPair ): Promise { const existingKeyPairs = await getAllCachedECKeyPair(groupPubKey); const alreadySaved = existingKeyPairs.some(k => { return k.privateHex === keyPair.privateHex && k.publicHex === keyPair.publicHex; }); if (alreadySaved) { return false; } await addClosedGroupEncryptionKeyPair(groupPubKey, keyPair); if (!cacheOfClosedGroupKeyPairs.has(groupPubKey)) { cacheOfClosedGroupKeyPairs.set(groupPubKey, []); } cacheOfClosedGroupKeyPairs.get(groupPubKey)?.push(keyPair); return true; } export async function innerRemoveAllClosedGroupEncryptionKeyPairs(groupPubKey: string) { cacheOfClosedGroupKeyPairs.set(groupPubKey, []); await removeAllClosedGroupEncryptionKeyPairs(groupPubKey); } export async function handleClosedGroupControlMessage( envelope: EnvelopePlus, groupUpdate: SignalService.DataMessage.ClosedGroupControlMessage ) { const { type } = groupUpdate; const { Type } = SignalService.DataMessage.ClosedGroupControlMessage; window?.log?.info( ` handle closed group update from ${envelope.senderIdentity || envelope.source} about group ${ envelope.source }` ); if (BlockedNumberController.isGroupBlocked(PubKey.cast(envelope.source))) { window?.log?.warn('Message ignored; destined for blocked group'); await removeFromCache(envelope); return; } // We drop New closed group message from our other devices, as they will come as ConfigurationMessage instead if (type === Type.ENCRYPTION_KEY_PAIR) { const isComingFromGroupPubkey = envelope.type === SignalService.Envelope.Type.CLOSED_GROUP_MESSAGE; await handleClosedGroupEncryptionKeyPair(envelope, groupUpdate, isComingFromGroupPubkey); return; } if (type === Type.NEW) { await handleNewClosedGroup(envelope, groupUpdate); return; } if ( type === Type.NAME_CHANGE || type === Type.MEMBERS_REMOVED || type === Type.MEMBERS_ADDED || type === Type.MEMBER_LEFT || type === Type.ENCRYPTION_KEY_PAIR_REQUEST ) { await performIfValid(envelope, groupUpdate); return; } window?.log?.error('Unknown group update type: ', type); await removeFromCache(envelope); } function sanityCheckNewGroup( groupUpdate: SignalService.DataMessage.ClosedGroupControlMessage ): boolean { // for a new group message, we need everything to be set const { name, publicKey, members, admins, encryptionKeyPair } = groupUpdate; if (!name?.length) { window?.log?.warn('groupUpdate: name is empty'); return false; } if (!name?.length) { window?.log?.warn('groupUpdate: name is empty'); return false; } if (!publicKey?.length) { window?.log?.warn('groupUpdate: publicKey is empty'); return false; } const hexGroupPublicKey = toHex(publicKey); if (!PubKey.from(hexGroupPublicKey)) { window?.log?.warn( 'groupUpdate: publicKey is not recognized as a valid pubkey', hexGroupPublicKey ); return false; } if (!members?.length) { window?.log?.warn('groupUpdate: members is empty'); return false; } if (members.some(m => m.length === 0)) { window?.log?.warn('groupUpdate: one of the member pubkey is empty'); return false; } if (!admins?.length) { window?.log?.warn('groupUpdate: admins is empty'); return false; } if (admins.some(a => a.length === 0)) { window?.log?.warn('groupUpdate: one of the admins pubkey is empty'); return false; } if (!encryptionKeyPair?.publicKey?.length) { window?.log?.warn('groupUpdate: keypair publicKey is empty'); return false; } if (!encryptionKeyPair?.privateKey?.length) { window?.log?.warn('groupUpdate: keypair privateKey is empty'); return false; } return true; } export async function handleNewClosedGroup( envelope: EnvelopePlus, groupUpdate: SignalService.DataMessage.ClosedGroupControlMessage ) { if (groupUpdate.type !== SignalService.DataMessage.ClosedGroupControlMessage.Type.NEW) { return; } if (!sanityCheckNewGroup(groupUpdate)) { window?.log?.warn('Sanity check for newGroup failed, dropping the message...'); await removeFromCache(envelope); return; } const ourNumber = UserUtils.getOurPubKeyFromCache(); if (envelope.senderIdentity === ourNumber.key) { window?.log?.warn('Dropping new closed group updatemessage from our other device.'); return removeFromCache(envelope); } const { name, publicKey, members: membersAsData, admins: adminsAsData, encryptionKeyPair, } = groupUpdate; const groupId = toHex(publicKey); const members = membersAsData.map(toHex); const admins = adminsAsData.map(toHex); const envelopeTimestamp = _.toNumber(envelope.timestamp); // a type new is sent and received on one to one so do not use envelope.senderIdentity here const sender = envelope.source; if (!members.includes(ourNumber.key)) { window?.log?.info( 'Got a new group message but apparently we are not a member of it. Dropping it.' ); await removeFromCache(envelope); return; } const maybeConvo = getConversationController().get(groupId); const expireTimer = groupUpdate.expireTimer; if (maybeConvo) { // if we did not left this group, just add the keypair we got if not already there if (!maybeConvo.get('isKickedFromGroup') && !maybeConvo.get('left')) { const ecKeyPairAlreadyExistingConvo = new ECKeyPair( // tslint:disable: no-non-null-assertion encryptionKeyPair!.publicKey, encryptionKeyPair!.privateKey ); const isKeyPairAlreadyHere = await addKeyPairToCacheAndDBIfNeeded( groupId, ecKeyPairAlreadyExistingConvo.toHexKeyPair() ); await maybeConvo.updateExpirationTimer(expireTimer, sender, Date.now()); if (isKeyPairAlreadyHere) { window.log.info('Dropping already saved keypair for group', groupId); await removeFromCache(envelope); return; } window.log.info(`Received the encryptionKeyPair for new group ${groupId}`); await removeFromCache(envelope); window.log.warn( 'Closed group message of type NEW: the conversation already exists, but we saved the new encryption keypair' ); return; } // convo exists and we left or got kicked, enable typing and continue processing // Enable typing: maybeConvo.set('isKickedFromGroup', false); maybeConvo.set('left', false); maybeConvo.set('lastJoinedTimestamp', _.toNumber(envelope.timestamp)); // we just got readded. Consider the zombie list to have been cleared maybeConvo.set('zombies', []); } const convo = maybeConvo || (await getConversationController().getOrCreateAndWait(groupId, ConversationTypeEnum.GROUP)); // ***** Creating a new group ***** window?.log?.info('Received a new ClosedGroup of id:', groupId); await ClosedGroup.addUpdateMessage( convo, { newName: name, joiningMembers: members }, 'incoming', envelopeTimestamp ); // We only set group admins on group creation const groupDetails: ClosedGroup.GroupInfo = { id: groupId, name: name, members: members, admins, activeAt: envelopeTimestamp, weWereJustAdded: true, }; // be sure to call this before sending the message. // the sending pipeline needs to know from GroupUtils when a message is for a medium group await ClosedGroup.updateOrCreateClosedGroup(groupDetails); // ClosedGroup.updateOrCreateClosedGroup will mark the activeAt to Date.now if it's active // But we need to override this value with the sent timestamp of the message creating this group for us. // Having that timestamp set will allow us to pickup incoming group update which were sent between // envelope.timestamp and Date.now(). And we need to listen to those (some might even remove us) convo.set('lastJoinedTimestamp', envelopeTimestamp); await convo.updateExpirationTimer(expireTimer, sender, envelopeTimestamp); convo.updateLastMessage(); await convo.commit(); // sanity checks validate this // tslint:disable: no-non-null-assertion const ecKeyPair = new ECKeyPair(encryptionKeyPair!.publicKey, encryptionKeyPair!.privateKey); window?.log?.info(`Received the encryptionKeyPair for new group ${groupId}`); await addKeyPairToCacheAndDBIfNeeded(groupId, ecKeyPair.toHexKeyPair()); // start polling for this new group getSwarmPollingInstance().addGroupId(PubKey.cast(groupId)); await removeFromCache(envelope); // trigger decrypting of all this group messages we did not decrypt successfully yet. await queueAllCachedFromSource(groupId); } /** * * @param isKicked if true, we mark the reason for leaving as a we got kicked */ export async function markGroupAsLeftOrKicked( groupPublicKey: string, groupConvo: ConversationModel, isKicked: boolean ) { await innerRemoveAllClosedGroupEncryptionKeyPairs(groupPublicKey); if (isKicked) { groupConvo.set('isKickedFromGroup', true); } else { groupConvo.set('left', true); } getSwarmPollingInstance().removePubkey(groupPublicKey); } /** * This function is called when we get a message with the new encryption keypair for a closed group. * 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 handleClosedGroupEncryptionKeyPair( envelope: EnvelopePlus, groupUpdate: SignalService.DataMessage.ClosedGroupControlMessage, isComingFromGroupPubkey: boolean ) { if ( groupUpdate.type !== SignalService.DataMessage.ClosedGroupControlMessage.Type.ENCRYPTION_KEY_PAIR ) { return; } const ourNumber = UserUtils.getOurPubKeyFromCache(); // groupUpdate.publicKey might be set. This is used to give an explicitGroupPublicKey for this update. const groupPublicKey = toHex(groupUpdate.publicKey) || envelope.source; // in the case of an encryption key pair coming as a reply to a request we made // senderIdentity will be unset as the message is not encoded for medium groups const sender = isComingFromGroupPubkey ? envelope.senderIdentity : envelope.source; window?.log?.info(`Got a group update for group ${groupPublicKey}, type: ENCRYPTION_KEY_PAIR`); const ourKeyPair = await UserUtils.getIdentityKeyPair(); if (!ourKeyPair) { window?.log?.warn("Couldn't find user X25519 key pair."); await removeFromCache(envelope); return; } const groupConvo = getConversationController().get(groupPublicKey); if (!groupConvo) { window?.log?.warn( `Ignoring closed group encryption key pair for nonexistent group. ${groupPublicKey}` ); await removeFromCache(envelope); return; } if (!groupConvo.isMediumGroup()) { window?.log?.warn( `Ignoring closed group encryption key pair for nonexistent medium group. ${groupPublicKey}` ); await removeFromCache(envelope); return; } if (!groupConvo.get('groupAdmins')?.includes(sender)) { window?.log?.warn( `Ignoring closed group encryption key pair from non-admin. ${groupPublicKey}` ); await removeFromCache(envelope); return; } // Find our wrapper and decrypt it if possible const ourWrapper = groupUpdate.wrappers.find(w => toHex(w.publicKey) === ourNumber.key); if (!ourWrapper) { window?.log?.warn( `Couldn\'t find our wrapper in the encryption keypairs wrappers for group ${groupPublicKey}` ); await removeFromCache(envelope); return; } let plaintext: Uint8Array; try { perfStart(`encryptionKeyPair-${envelope.id}`); const buffer = await decryptWithSessionProtocol( envelope, ourWrapper.encryptedKeyPair, ECKeyPair.fromKeyPair(ourKeyPair) ); perfEnd(`encryptionKeyPair-${envelope.id}`, 'encryptionKeyPair'); if (!buffer || buffer.byteLength === 0) { throw new Error(); } plaintext = new Uint8Array(buffer); } catch (e) { window?.log?.warn("Couldn't decrypt closed group encryption key pair.", e); await removeFromCache(envelope); return; } // Parse it let proto: SignalService.KeyPair; try { proto = SignalService.KeyPair.decode(plaintext); if (!proto || proto.privateKey.length === 0 || proto.publicKey.length === 0) { throw new Error(); } } catch (e) { window?.log?.warn("Couldn't parse closed group encryption key pair."); await removeFromCache(envelope); return; } let keyPair: ECKeyPair; try { keyPair = new ECKeyPair(proto.publicKey, proto.privateKey); } catch (e) { window?.log?.warn("Couldn't parse closed group encryption key pair."); await removeFromCache(envelope); return; } window?.log?.info(`Received a new encryptionKeyPair for group ${groupPublicKey}`); // Store it if needed const newKeyPairInHex = keyPair.toHexKeyPair(); const isKeyPairAlreadyHere = await addKeyPairToCacheAndDBIfNeeded( groupPublicKey, newKeyPairInHex ); if (isKeyPairAlreadyHere) { window?.log?.info('Dropping already saved keypair for group', groupPublicKey); await removeFromCache(envelope); return; } window?.log?.info('Got a new encryption keypair for group', groupPublicKey); await removeFromCache(envelope); // trigger decrypting of all this group messages we did not decrypt successfully yet. await queueAllCachedFromSource(groupPublicKey); } async function performIfValid( envelope: EnvelopePlus, groupUpdate: SignalService.DataMessage.ClosedGroupControlMessage ) { const { Type } = SignalService.DataMessage.ClosedGroupControlMessage; const groupPublicKey = envelope.source; const sender = envelope.senderIdentity; const convo = getConversationController().get(groupPublicKey); if (!convo) { window?.log?.warn('dropping message for nonexistent group'); return removeFromCache(envelope); } 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; } const envelopeTimestamp = _.toNumber(envelope.timestamp); if (envelopeTimestamp <= 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(sender)) { window?.log?.error( `Error: closed group: ignoring closed group update message from non-member. ${sender} is not a current member.` ); await removeFromCache(envelope); return; } // make sure the conversation with this user exist (even if it's just hidden) await getConversationController().getOrCreateAndWait(sender, ConversationTypeEnum.PRIVATE); 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, convo); } else if (groupUpdate.type === Type.ENCRYPTION_KEY_PAIR_REQUEST) { window?.log?.warn( 'Received ENCRYPTION_KEY_PAIR_REQUEST message but it is not enabled for now.' ); await removeFromCache(envelope); // if you add a case here, remember to add it where performIfValid is called too. } 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; window?.log?.info(`Got a group update for group ${envelope.source}, type: NAME_CHANGED`); if (newName !== convo.get('name')) { const groupDiff: ClosedGroup.GroupDiff = { newName, }; await ClosedGroup.addUpdateMessage( convo, groupDiff, 'incoming', _.toNumber(envelope.timestamp) ); convo.set({ name: newName }); convo.updateLastMessage(); 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)); window?.log?.info(`Got a group update for group ${envelope.source}, type: MEMBERS_ADDED`); // make sure those members are not on our zombie list addedMembers.forEach(added => removeMemberFromZombies(envelope, PubKey.cast(added), convo)); if (membersNotAlreadyPresent.length === 0) { window?.log?.info( 'no new members in this group update compared to what we have already. Skipping update' ); // this is just to make sure that the zombie list got written to the db. // if a member adds a member we have as a zombie, we consider that this member is not a zombie anymore await convo.commit(); await removeFromCache(envelope); return; } // this is to avoid a race condition where a user gets removed and added back while the admin is offline if (await areWeAdmin(convo)) { await sendLatestKeyPairToUsers(convo, convo.id, membersNotAlreadyPresent); } const members = [...oldMembers, ...membersNotAlreadyPresent]; // make sure the conversation with those members (even if it's just hidden) await Promise.all( members.map(async m => getConversationController().getOrCreateAndWait(m, ConversationTypeEnum.PRIVATE) ) ); const groupDiff: ClosedGroup.GroupDiff = { joiningMembers: membersNotAlreadyPresent, }; await ClosedGroup.addUpdateMessage(convo, groupDiff, 'incoming', _.toNumber(envelope.timestamp)); convo.set({ members }); convo.updateLastMessage(); await convo.commit(); await removeFromCache(envelope); } async function areWeAdmin(groupConvo: ConversationModel) { if (!groupConvo) { throw new Error('areWeAdmin needs a convo'); } const groupAdmins = groupConvo.get('groupAdmins'); const ourNumber = UserUtils.getOurPubKeyStrFromCache(); return groupAdmins?.includes(ourNumber) || false; } async function handleClosedGroupMembersRemoved( envelope: EnvelopePlus, groupUpdate: SignalService.DataMessage.ClosedGroupControlMessage, convo: ConversationModel ) { // Check that the admin wasn't removed const currentMembers = convo.get('members'); // removedMembers are all members in the diff const removedMembers = groupUpdate.members.map(toHex); // effectivelyRemovedMembers are the members which where effectively on this group before the update // and is used for the group update message only const effectivelyRemovedMembers = removedMembers.filter(m => currentMembers.includes(m)); const groupPubKey = envelope.source; window?.log?.info(`Got a group update for group ${envelope.source}, type: MEMBERS_REMOVED`); const membersAfterUpdate = _.difference(currentMembers, removedMembers); const groupAdmins = convo.get('groupAdmins'); if (!groupAdmins?.length) { throw new Error('No admins found for closed group member removed update.'); } const firstAdmin = groupAdmins[0]; if (removedMembers.includes(firstAdmin)) { window?.log?.warn('Ignoring invalid closed group update: trying to remove the admin.'); await removeFromCache(envelope); throw new Error('Admins cannot be removed. They can only leave'); } // The MEMBERS_REMOVED message type can only come from an admin. if (!groupAdmins.includes(envelope.senderIdentity)) { window?.log?.warn('Ignoring invalid closed group update. Only admins can remove members.'); await removeFromCache(envelope); throw new Error('Only admins can remove members.'); } // If the current user was removed: // • Stop polling for the group // • Remove the key pairs associated with the group const ourPubKey = UserUtils.getOurPubKeyFromCache(); const wasCurrentUserRemoved = !membersAfterUpdate.includes(ourPubKey.key); if (wasCurrentUserRemoved) { await markGroupAsLeftOrKicked(groupPubKey, convo, true); } // Note: we don't want to send a new encryption keypair when we get a member removed. // this is only happening when the admin gets a MEMBER_LEFT message // Only add update message if we have something to show if (membersAfterUpdate.length !== currentMembers.length) { const groupDiff: ClosedGroup.GroupDiff = { leavingMembers: effectivelyRemovedMembers, }; await ClosedGroup.addUpdateMessage( convo, groupDiff, 'incoming', _.toNumber(envelope.timestamp) ); convo.updateLastMessage(); } // Update the group const zombies = convo.get('zombies').filter(z => membersAfterUpdate.includes(z)); convo.set({ members: membersAfterUpdate }); convo.set({ zombies }); await convo.commit(); await removeFromCache(envelope); } function isUserAZombie(convo: ConversationModel, user: PubKey) { return convo.get('zombies').includes(user.key); } /** * Returns true if the user was not a zombie and so was added to the zombies. * No commit() are called */ function addMemberToZombies( envelope: EnvelopePlus, userToAdd: PubKey, convo: ConversationModel ): boolean { const zombies = convo.get('zombies'); const isAlreadyZombie = isUserAZombie(convo, userToAdd); if (isAlreadyZombie) { return false; } convo.set('zombies', [...zombies, userToAdd.key]); return true; } /** * * Returns true if the user was not a zombie and so was not removed from the zombies. * Note: no commit() are made */ function removeMemberFromZombies( envelope: EnvelopePlus, userToAdd: PubKey, convo: ConversationModel ): boolean { const zombies = convo.get('zombies'); const isAlreadyAZombie = isUserAZombie(convo, userToAdd); if (!isAlreadyAZombie) { return false; } convo.set( 'zombies', zombies.filter(z => z !== userToAdd.key) ); return true; } async function handleClosedGroupAdminMemberLeft( groupPublicKey: string, isCurrentUserAdmin: boolean, convo: ConversationModel, envelope: EnvelopePlus ) { // if the admin was remove and we are the admin, it can only be voluntary await markGroupAsLeftOrKicked(groupPublicKey, convo, !isCurrentUserAdmin); convo.set('members', []); // everybody left ! this is how we disable a group when the admin left const groupDiff: ClosedGroup.GroupDiff = { leavingMembers: convo.get('members'), }; await ClosedGroup.addUpdateMessage(convo, groupDiff, 'incoming', _.toNumber(envelope.timestamp)); convo.updateLastMessage(); await convo.commit(); await removeFromCache(envelope); } async function handleClosedGroupLeftOurself( groupPublicKey: string, convo: ConversationModel, envelope: EnvelopePlus ) { await markGroupAsLeftOrKicked(groupPublicKey, convo, false); const groupDiff: ClosedGroup.GroupDiff = { leavingMembers: [envelope.senderIdentity], }; await ClosedGroup.addUpdateMessage(convo, groupDiff, 'incoming', _.toNumber(envelope.timestamp)); convo.updateLastMessage(); // remove ourself from the list of members convo.set( 'members', convo.get('members').filter(m => !UserUtils.isUsFromCache(m)) ); await convo.commit(); await removeFromCache(envelope); } async function handleClosedGroupMemberLeft(envelope: EnvelopePlus, 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 newMembers = oldMembers.filter(s => s !== sender); window?.log?.info(`Got a group update for group ${envelope.source}, type: MEMBER_LEFT`); // Show log if we sent this message ourself (from another device or not) if (UserUtils.isUsFromCache(sender)) { window?.log?.info('Got self-sent group update member left...'); } const ourPubkey = UserUtils.getOurPubKeyStrFromCache(); // if the admin leaves, the group is disabled for every members const isCurrentUserAdmin = convo.get('groupAdmins')?.includes(ourPubkey) || false; if (didAdminLeave) { await handleClosedGroupAdminMemberLeft(groupPublicKey, isCurrentUserAdmin, convo, envelope); return; } // if we are no longer a member, we LEFT from another device if (!newMembers.includes(ourPubkey)) { // stop polling, remove all stored pubkeys and make sure the UI does not let us write messages await handleClosedGroupLeftOurself(groupPublicKey, convo, envelope); return; } // Another member left, not us, not the admin, just another member. // But this member was in the list of members (as performIfValid checks for that) const groupDiff: ClosedGroup.GroupDiff = { leavingMembers: [sender], }; await ClosedGroup.addUpdateMessage(convo, groupDiff, 'incoming', _.toNumber(envelope.timestamp)); convo.updateLastMessage(); // if a user just left and we are the admin, we remove him right away for everyone by sending a MEMBERS_REMOVED message so no need to add him as a zombie if (oldMembers.includes(sender)) { addMemberToZombies(envelope, PubKey.cast(sender), convo); } convo.set('members', newMembers); await convo.commit(); await removeFromCache(envelope); } async function sendLatestKeyPairToUsers( groupConvo: ConversationModel, groupPubKey: string, targetUsers: Array ) { // use the inMemory keypair if found const inMemoryKeyPair = distributingClosedGroupEncryptionKeyPairs.get(groupPubKey); // Get the latest encryption key pair const latestKeyPair = await getLatestClosedGroupEncryptionKeyPair(groupPubKey); if (!inMemoryKeyPair && !latestKeyPair) { window?.log?.info('We do not have the keypair ourself, so dropping this message.'); return; } const keyPairToUse = inMemoryKeyPair || ECKeyPair.fromHexKeyPair(latestKeyPair as HexKeyPair); const expireTimer = groupConvo.get('expireTimer') || 0; await Promise.all( targetUsers.map(async member => { window?.log?.info(`Sending latest closed group encryption key pair to: ${member}`); await getConversationController().getOrCreateAndWait(member, ConversationTypeEnum.PRIVATE); const wrappers = await ClosedGroup.buildEncryptionKeyPairWrappers([member], keyPairToUse); const keypairsMessage = new ClosedGroupEncryptionPairReplyMessage({ groupId: groupPubKey, timestamp: Date.now(), encryptedKeyPairs: wrappers, }); // the encryption keypair is sent using established channels await getMessageQueue().sendToPubKey(PubKey.cast(member), keypairsMessage); }) ); } export async function createClosedGroup(groupName: string, members: Array) { const setOfMembers = new Set(members); const ourNumber = UserUtils.getOurPubKeyFromCache(); // Create Group Identity // Generate the key pair that'll be used for encryption and decryption // Generate the group's public key const groupPublicKey = await generateClosedGroupPublicKey(); const encryptionKeyPair = await generateCurve25519KeyPairWithoutPrefix(); if (!encryptionKeyPair) { throw new Error('Could not create encryption keypair for new closed group'); } // Ensure the current uses' primary device is included in the member list setOfMembers.add(ourNumber.key); const listOfMembers = [...setOfMembers]; // Create the group const convo = await getConversationController().getOrCreateAndWait( groupPublicKey, ConversationTypeEnum.GROUP ); const admins = [ourNumber.key]; const existingExpireTimer = 0; const groupDetails: ClosedGroup.GroupInfo = { id: groupPublicKey, name: groupName, members: listOfMembers, admins, activeAt: Date.now(), expireTimer: existingExpireTimer, }; // used for UI only, adding of a message to remind who is in the group and the name of the group const groupDiff: ClosedGroup.GroupDiff = { newName: groupName, joiningMembers: listOfMembers, }; const dbMessage = await ClosedGroup.addUpdateMessage(convo, groupDiff, 'outgoing', Date.now()); getMessageController().register(dbMessage.id, dbMessage); // be sure to call this before sending the message. // the sending pipeline needs to know from GroupUtils when a message is for a medium group await ClosedGroup.updateOrCreateClosedGroup(groupDetails); await convo.commit(); convo.updateLastMessage(); // Send a closed group update message to all members individually const allInvitesSent = await sendToGroupMembers( listOfMembers, groupPublicKey, groupName, admins, encryptionKeyPair, dbMessage, existingExpireTimer ); if (allInvitesSent) { const newHexKeypair = encryptionKeyPair.toHexKeyPair(); const isHexKeyPairSaved = await addKeyPairToCacheAndDBIfNeeded(groupPublicKey, newHexKeypair); if (isHexKeyPairSaved) { window?.log?.info('Dropping already saved keypair for group', groupPublicKey); } // Subscribe to this group id getSwarmPollingInstance().addGroupId(new PubKey(groupPublicKey)); } await forceSyncConfigurationNowIfNeeded(); await openConversationWithMessages({ conversationKey: groupPublicKey }); } /** * Sends a group invite message to each member of the group. * @returns Array of promises for group invite messages sent to group members */ async function sendToGroupMembers( listOfMembers: Array, groupPublicKey: string, groupName: string, admins: Array, encryptionKeyPair: ECKeyPair, dbMessage: MessageModel, existingExpireTimer: number, isRetry: boolean = false ): Promise { const promises = createInvitePromises( listOfMembers, groupPublicKey, groupName, admins, encryptionKeyPair, dbMessage, existingExpireTimer ); window?.log?.info(`Creating a new group and an encryptionKeyPair for group ${groupPublicKey}`); // evaluating if all invites sent, if failed give the option to retry failed invites via modal dialog const inviteResults = await Promise.all(promises); const allInvitesSent = _.every(inviteResults, Boolean); if (allInvitesSent) { // if (true) { if (isRetry) { const invitesTitle = inviteResults.length > 1 ? window.i18n('closedGroupInviteSuccessTitlePlural') : window.i18n('closedGroupInviteSuccessTitle'); window.inboxStore?.dispatch( updateConfirmModal({ title: invitesTitle, message: window.i18n('closedGroupInviteSuccessMessage'), hideCancel: true, }) ); } return allInvitesSent; } else { // Confirmation dialog that recursively calls sendToGroupMembers on resolve window.inboxStore?.dispatch( updateConfirmModal({ title: inviteResults.length > 1 ? window.i18n('closedGroupInviteFailTitlePlural') : window.i18n('closedGroupInviteFailTitle'), message: inviteResults.length > 1 ? window.i18n('closedGroupInviteFailMessagePlural') : window.i18n('closedGroupInviteFailMessage'), okText: window.i18n('closedGroupInviteOkText'), onClickOk: async () => { const membersToResend: Array = new Array(); inviteResults.forEach((result, index) => { const member = listOfMembers[index]; // group invite must always contain the admin member. if (result !== true || admins.includes(member)) { membersToResend.push(member); } }); if (membersToResend.length > 0) { const isRetrySend = true; await sendToGroupMembers( membersToResend, groupPublicKey, groupName, admins, encryptionKeyPair, dbMessage, existingExpireTimer, isRetrySend ); } }, }) ); } return allInvitesSent; } function createInvitePromises( listOfMembers: Array, groupPublicKey: string, groupName: string, admins: Array, encryptionKeyPair: ECKeyPair, dbMessage: MessageModel, existingExpireTimer: number ) { return listOfMembers.map(async m => { const messageParams: ClosedGroupNewMessageParams = { groupId: groupPublicKey, name: groupName, members: listOfMembers, admins, keypair: encryptionKeyPair, timestamp: Date.now(), identifier: dbMessage.id, expireTimer: existingExpireTimer, }; const message = new ClosedGroupNewMessage(messageParams); return getMessageQueue().sendToPubKeyNonDurably(PubKey.cast(m), message); }); }