You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			556 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			TypeScript
		
	
			
		
		
	
	
			556 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			TypeScript
		
	
| import { SignalService } from '../protobuf';
 | |
| import { removeFromCache } from './cache';
 | |
| import { EnvelopePlus } from './types';
 | |
| import { PubKey } from '../session/types';
 | |
| import { fromHexToArray, toHex } from '../session/utils/String';
 | |
| import { ConversationController } from '../session/conversations';
 | |
| import * as ClosedGroupV2 from '../session/groupv2';
 | |
| import { BlockedNumberController, UserUtil } from '../util';
 | |
| import {
 | |
|   generateClosedGroupV2PublicKey,
 | |
|   generateCurve25519KeyPairWithoutPrefix,
 | |
| } from '../session/crypto';
 | |
| import { getMessageQueue } from '../session';
 | |
| import { decryptWithSessionProtocol } from './contentMessage';
 | |
| import * as Data from '../../js/modules/data';
 | |
| import { getPrimary } from '../util/user';
 | |
| import {
 | |
|   ClosedGroupV2NewMessage,
 | |
|   ClosedGroupV2NewMessageParams,
 | |
| } from '../session/messages/outgoing/content/data/groupv2/ClosedGroupV2NewMessage';
 | |
| 
 | |
| import { KeyPair } from '../../libtextsecure/libsignal-protocol';
 | |
| 
 | |
| export type HexKeyPair = {
 | |
|   publicHex: string;
 | |
|   privateHex: string;
 | |
| };
 | |
| 
 | |
| export class ECKeyPair {
 | |
|   public readonly publicKeyData: Uint8Array;
 | |
|   public readonly privateKeyData: Uint8Array;
 | |
| 
 | |
|   constructor(publicKeyData: Uint8Array, privateKeyData: Uint8Array) {
 | |
|     this.publicKeyData = publicKeyData;
 | |
|     this.privateKeyData = privateKeyData;
 | |
|   }
 | |
| 
 | |
|   public static fromArrayBuffer(pub: ArrayBuffer, priv: ArrayBuffer) {
 | |
|     return new ECKeyPair(new Uint8Array(pub), new Uint8Array(priv));
 | |
|   }
 | |
| 
 | |
|   public static fromKeyPair(pair: KeyPair) {
 | |
|     return new ECKeyPair(
 | |
|       new Uint8Array(pair.pubKey),
 | |
|       new Uint8Array(pair.privKey)
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   public static fromHexKeyPair(pair: HexKeyPair) {
 | |
|     return new ECKeyPair(
 | |
|       fromHexToArray(pair.publicHex),
 | |
|       fromHexToArray(pair.privateHex)
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   public toString() {
 | |
|     const hexKeypair = this.toHexKeyPair();
 | |
|     return `ECKeyPair: ${hexKeypair.publicHex} ${hexKeypair.privateHex}`;
 | |
|   }
 | |
| 
 | |
|   public toHexKeyPair(): HexKeyPair {
 | |
|     const publicHex = toHex(this.publicKeyData);
 | |
|     const privateHex = toHex(this.privateKeyData);
 | |
|     return {
 | |
|       publicHex,
 | |
|       privateHex,
 | |
|     };
 | |
|   }
 | |
| }
 | |
| 
 | |
| export async function handleClosedGroupV2(
 | |
|   envelope: EnvelopePlus,
 | |
|   groupUpdate: any
 | |
| ) {
 | |
|   const { type } = groupUpdate;
 | |
|   const { Type } = SignalService.ClosedGroupUpdateV2;
 | |
| 
 | |
|   if (BlockedNumberController.isGroupBlocked(PubKey.cast(envelope.source))) {
 | |
|     window.log.warn('Message ignored; destined for blocked group');
 | |
|     await removeFromCache(envelope);
 | |
|     return;
 | |
|   }
 | |
| 
 | |
|   if (type === Type.ENCRYPTION_KEY_PAIR) {
 | |
|     await handleKeyPairClosedGroupV2(envelope, groupUpdate);
 | |
|   } else if (type === Type.NEW) {
 | |
|     await handleNewClosedGroupV2(envelope, groupUpdate);
 | |
|   } else if (type === Type.UPDATE) {
 | |
|     await handleUpdateClosedGroupV2(envelope, groupUpdate);
 | |
|   } else {
 | |
|     window.log.error('Unknown group update type v2: ', type);
 | |
|   }
 | |
| }
 | |
| 
 | |
| function sanityCheckNewGroupV2(
 | |
|   groupUpdate: SignalService.ClosedGroupUpdateV2
 | |
| ): boolean {
 | |
|   // for a new group message, we need everything to be set
 | |
|   const { name, publicKey, members, admins, encryptionKeyPair } = groupUpdate;
 | |
|   const { log } = window;
 | |
| 
 | |
|   if (!name?.length) {
 | |
|     log.warn('groupUpdateV2: name is empty');
 | |
|     return false;
 | |
|   }
 | |
| 
 | |
|   if (!name?.length) {
 | |
|     log.warn('groupUpdateV2: name is empty');
 | |
|     return false;
 | |
|   }
 | |
| 
 | |
|   if (!publicKey?.length) {
 | |
|     log.warn('groupUpdateV2: publicKey is empty');
 | |
|     return false;
 | |
|   }
 | |
| 
 | |
|   const hexGroupPublicKey = toHex(publicKey);
 | |
|   if (!PubKey.from(hexGroupPublicKey)) {
 | |
|     log.warn(
 | |
|       'groupUpdateV2: publicKey is not recognized as a valid pubkey',
 | |
|       hexGroupPublicKey
 | |
|     );
 | |
|     return false;
 | |
|   }
 | |
| 
 | |
|   if (!members?.length) {
 | |
|     log.warn('groupUpdateV2: members is empty');
 | |
|     return false;
 | |
|   }
 | |
| 
 | |
|   if (members.some(m => m.length === 0)) {
 | |
|     log.warn('groupUpdateV2: one of the member pubkey is empty');
 | |
|     return false;
 | |
|   }
 | |
| 
 | |
|   if (!admins?.length) {
 | |
|     log.warn('groupUpdateV2: admins is empty');
 | |
|     return false;
 | |
|   }
 | |
| 
 | |
|   if (admins.some(a => a.length === 0)) {
 | |
|     log.warn('groupUpdateV2: one of the admins pubkey is empty');
 | |
|     return false;
 | |
|   }
 | |
| 
 | |
|   if (!encryptionKeyPair?.publicKey?.length) {
 | |
|     log.warn('groupUpdateV2: keypair publicKey is empty');
 | |
|     return false;
 | |
|   }
 | |
| 
 | |
|   if (!encryptionKeyPair?.privateKey?.length) {
 | |
|     log.warn('groupUpdateV2: keypair privateKey is empty');
 | |
|     return false;
 | |
|   }
 | |
|   return true;
 | |
| }
 | |
| 
 | |
| async function handleNewClosedGroupV2(
 | |
|   envelope: EnvelopePlus,
 | |
|   groupUpdate: SignalService.ClosedGroupUpdateV2
 | |
| ) {
 | |
|   const { log } = window;
 | |
| 
 | |
|   if (groupUpdate.type !== SignalService.ClosedGroupUpdateV2.Type.NEW) {
 | |
|     return;
 | |
|   }
 | |
|   if (!sanityCheckNewGroupV2(groupUpdate)) {
 | |
|     log.warn('Sanity check for newGroupV2 failed, dropping the message...');
 | |
|     await removeFromCache(envelope);
 | |
|     return;
 | |
|   }
 | |
| 
 | |
|   const {
 | |
|     name,
 | |
|     publicKey,
 | |
|     members: membersAsData,
 | |
|     admins: adminsAsData,
 | |
|     encryptionKeyPair,
 | |
|   } = groupUpdate;
 | |
| 
 | |
|   const groupId = toHex(publicKey);
 | |
|   const members = membersAsData.map(toHex);
 | |
|   const admins = adminsAsData.map(toHex);
 | |
|   // FIXME maybe we should handle an expiretimer here too? And on ClosedGroupV2 updates?
 | |
| 
 | |
|   const maybeConvo = ConversationController.getInstance().get(groupId);
 | |
| 
 | |
|   const groupExists = !!maybeConvo;
 | |
| 
 | |
|   if (groupExists) {
 | |
|     if (maybeConvo && maybeConvo.get('isKickedFromGroup')) {
 | |
|       // TODO: indicate that we've been re-invited
 | |
|       // to the group if that is the case
 | |
| 
 | |
|       // Enable typing:
 | |
|       maybeConvo.set('isKickedFromGroup', false);
 | |
|       maybeConvo.set('left', false);
 | |
|       maybeConvo.updateTextInputState();
 | |
|     } else {
 | |
|       log.warn(
 | |
|         'Ignoring a closed group v2 message of type NEW: the conversation already exists'
 | |
|       );
 | |
|       await removeFromCache(envelope);
 | |
|       return;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   const convo =
 | |
|     maybeConvo ||
 | |
|     (await ConversationController.getInstance().getOrCreateAndWait(
 | |
|       groupId,
 | |
|       'group'
 | |
|     ));
 | |
|   // ***** Creating a new group *****
 | |
|   log.info('Received a new ClosedGroupV2 of id:', groupId);
 | |
| 
 | |
|   await ClosedGroupV2.addUpdateMessage(
 | |
|     convo,
 | |
|     { newName: name, joiningMembers: members },
 | |
|     'incoming'
 | |
|   );
 | |
| 
 | |
|   // TODO: Check that we are even a part of this group
 | |
| 
 | |
|   convo.set('name', name);
 | |
|   convo.set('members', members);
 | |
|   // mark a closed group v2 as a medium group.
 | |
|   // this field is used to poll for this groupPubKey on the swarm nodes, among other things
 | |
|   convo.set('is_medium_group', true);
 | |
|   convo.set('active_at', Date.now());
 | |
| 
 | |
|   // We only set group admins on group creation
 | |
|   convo.set('groupAdmins', admins);
 | |
|   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 a the encryptionKeyPair for new group ${groupId}`);
 | |
| 
 | |
|   await Data.addClosedGroupEncryptionKeyPair(groupId, ecKeyPair.toHexKeyPair());
 | |
| 
 | |
|   // start polling for this new group
 | |
|   window.SwarmPolling.addGroupId(PubKey.cast(groupId));
 | |
| 
 | |
|   await removeFromCache(envelope);
 | |
| }
 | |
| 
 | |
| async function handleUpdateClosedGroupV2(
 | |
|   envelope: EnvelopePlus,
 | |
|   groupUpdate: SignalService.ClosedGroupUpdateV2
 | |
| ) {
 | |
|   if (groupUpdate.type !== SignalService.ClosedGroupUpdateV2.Type.UPDATE) {
 | |
|     return;
 | |
|   }
 | |
|   const { name, members: membersBinary } = groupUpdate;
 | |
|   const { log } = window;
 | |
| 
 | |
|   // for a closed group v2 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 v2 update message (INFO) for a non-existing group'
 | |
|     );
 | |
|     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 v2: 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 v2
 | |
|   const members = membersBinary.map(toHex);
 | |
|   const diff = ClosedGroupV2.buildGroupDiff(convo, { name, members });
 | |
| 
 | |
|   // Check whether we are still in the group
 | |
|   const ourPrimary = await UserUtil.getPrimary();
 | |
|   const wasCurrentUserRemoved = !members.includes(ourPrimary.key);
 | |
|   const isCurrentUserAdmin = curAdmins?.includes(ourPrimary.key);
 | |
| 
 | |
|   if (wasCurrentUserRemoved) {
 | |
|     if (isCurrentUserAdmin) {
 | |
|       // cannot remove the admin from a v2 closed group
 | |
|       log.info(
 | |
|         'Dropping message trying to remove the admin (us) from a v2 closed group'
 | |
|       );
 | |
|       await removeFromCache(envelope);
 | |
|       return;
 | |
|     }
 | |
|     await window.Signal.Data.removeAllClosedGroupEncryptionKeyPairs(
 | |
|       groupPublicKey
 | |
|     );
 | |
|     convo.set('isKickedFromGroup', true);
 | |
|     // Disable typing:
 | |
|     convo.updateTextInputState();
 | |
|     window.SwarmPolling.removePubkey(groupPublicKey);
 | |
|   } else {
 | |
|     if (convo.get('isKickedFromGroup')) {
 | |
|       // Enable typing:
 | |
|       convo.set('isKickedFromGroup', false);
 | |
|       convo.set('left', false);
 | |
|       // Subscribe to this group id
 | |
|       window.SwarmPolling.addGroupId(new PubKey(groupPublicKey));
 | |
|       convo.updateTextInputState();
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   // Generate and distribute a new encryption key pair if needed
 | |
|   const wasAnyUserRemoved =
 | |
|     diff.leavingMembers && diff.leavingMembers.length > 0;
 | |
|   if (wasAnyUserRemoved && isCurrentUserAdmin) {
 | |
|     window.log.info(
 | |
|       'Handling group update: A user was removed and we are the admin. Generating and sending a new ECKeyPair'
 | |
|     );
 | |
|     await ClosedGroupV2.generateAndSendNewEncryptionKeyPair(
 | |
|       groupPublicKey,
 | |
|       members
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   // Only add update message if we have something to show
 | |
|   if (
 | |
|     diff.joiningMembers?.length ||
 | |
|     diff.leavingMembers?.length ||
 | |
|     diff.newName
 | |
|   ) {
 | |
|     await ClosedGroupV2.addUpdateMessage(convo, diff, 'incoming');
 | |
|     if (diff.joiningMembers?.length) {
 | |
|       // send a session request for all the members we do not have a session with
 | |
|       await window.libloki.api.sendSessionRequestsToMembers(members);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   convo.set('name', name);
 | |
|   convo.set('members', members);
 | |
| 
 | |
|   await convo.commit();
 | |
| 
 | |
|   await removeFromCache(envelope);
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * This function is called when we get a message with the new encryption keypair for a closed group v2.
 | |
|  * 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 handleKeyPairClosedGroupV2(
 | |
|   envelope: EnvelopePlus,
 | |
|   groupUpdate: SignalService.ClosedGroupUpdateV2
 | |
| ) {
 | |
|   if (
 | |
|     groupUpdate.type !==
 | |
|     SignalService.ClosedGroupUpdateV2.Type.ENCRYPTION_KEY_PAIR
 | |
|   ) {
 | |
|     return;
 | |
|   }
 | |
|   const ourPrimary = await UserUtil.getPrimary();
 | |
|   const groupPublicKey = envelope.source;
 | |
|   const ourKeyPair = await UserUtil.getIdentityKeyPair();
 | |
| 
 | |
|   if (!ourKeyPair) {
 | |
|     window.log.warn("Couldn't find user X25519 key pair.");
 | |
|     await removeFromCache(envelope);
 | |
|     return;
 | |
|   }
 | |
| 
 | |
|   const groupConvo = ConversationController.getInstance().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(envelope.senderIdentity)) {
 | |
|     window.log.warn(
 | |
|       `Ignoring closed group encryption key pair from non-admin. ${groupPublicKey}: ${envelope.senderIdentity}`
 | |
|     );
 | |
|     await removeFromCache(envelope);
 | |
|     return;
 | |
|   }
 | |
| 
 | |
|   // Find our wrapper and decrypt it if possible
 | |
|   const ourWrapper = groupUpdate.wrappers.find(
 | |
|     w => toHex(w.publicKey) === ourPrimary.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 {
 | |
|     const buffer = await decryptWithSessionProtocol(
 | |
|       envelope,
 | |
|       ourWrapper.encryptedKeyPair,
 | |
|       ECKeyPair.fromKeyPair(ourKeyPair)
 | |
|     );
 | |
|     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.ClosedGroupUpdateV2.KeyPair;
 | |
|   try {
 | |
|     proto = SignalService.ClosedGroupUpdateV2.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
 | |
|   await Data.addClosedGroupEncryptionKeyPair(
 | |
|     groupPublicKey,
 | |
|     keyPair.toHexKeyPair()
 | |
|   );
 | |
|   await removeFromCache(envelope);
 | |
| }
 | |
| 
 | |
| export async function createClosedGroupV2(
 | |
|   groupName: string,
 | |
|   members: Array<string>
 | |
| ) {
 | |
|   const setOfMembers = new Set(members);
 | |
| 
 | |
|   const ourPrimary = await getPrimary();
 | |
|   // Create Group Identity
 | |
|   // Generate the key pair that'll be used for encryption and decryption
 | |
|   // Generate the group's public key
 | |
|   const groupPublicKey = await generateClosedGroupV2PublicKey();
 | |
|   const encryptionKeyPair = await generateCurve25519KeyPairWithoutPrefix();
 | |
|   if (!encryptionKeyPair) {
 | |
|     throw new Error(
 | |
|       'Could not create encryption keypair for new closed group v2'
 | |
|     );
 | |
|   }
 | |
|   // Ensure the current uses' primary device is included in the member list
 | |
|   setOfMembers.add(ourPrimary.key);
 | |
|   const listOfMembers = [...setOfMembers];
 | |
| 
 | |
|   // Create the group
 | |
|   const convo = await ConversationController.getInstance().getOrCreateAndWait(
 | |
|     groupPublicKey,
 | |
|     'group'
 | |
|   );
 | |
| 
 | |
|   const admins = [ourPrimary.key];
 | |
| 
 | |
|   const groupDetails = {
 | |
|     id: groupPublicKey,
 | |
|     name: groupName,
 | |
|     members: listOfMembers,
 | |
|     admins,
 | |
|     active: true,
 | |
|     expireTimer: 0,
 | |
|     is_medium_group: true,
 | |
|   };
 | |
| 
 | |
|   // used for UI only, adding of a message to remind who is in the group and the name of the group
 | |
|   const groupDiff: ClosedGroupV2.GroupDiff = {
 | |
|     newName: groupName,
 | |
|     joiningMembers: listOfMembers,
 | |
|   };
 | |
| 
 | |
|   const dbMessage = await ClosedGroupV2.addUpdateMessage(
 | |
|     convo,
 | |
|     groupDiff,
 | |
|     'outgoing'
 | |
|   );
 | |
|   window.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 ClosedGroupV2.updateOrCreateClosedGroupV2(groupDetails);
 | |
|   // Send a closed group update message to all members individually
 | |
|   const promises = listOfMembers.map(async m => {
 | |
|     const messageParams: ClosedGroupV2NewMessageParams = {
 | |
|       groupId: groupPublicKey,
 | |
|       name: groupName,
 | |
|       members: listOfMembers,
 | |
|       admins,
 | |
|       keypair: encryptionKeyPair,
 | |
|       timestamp: Date.now(),
 | |
|       identifier: dbMessage.id,
 | |
|       expireTimer: 0,
 | |
|     };
 | |
|     const message = new ClosedGroupV2NewMessage(messageParams);
 | |
|     window.log.info(
 | |
|       `Creating a new group and an encryptionKeyPair for group ${groupPublicKey}`
 | |
|     );
 | |
|     // tslint:disable-next-line: no-non-null-assertion
 | |
|     await Data.addClosedGroupEncryptionKeyPair(
 | |
|       groupPublicKey,
 | |
|       encryptionKeyPair.toHexKeyPair()
 | |
|     );
 | |
|     return getMessageQueue().sendUsingMultiDevice(PubKey.cast(m), message);
 | |
|   });
 | |
| 
 | |
|   // Subscribe to this group id
 | |
|   window.SwarmPolling.addGroupId(new PubKey(groupPublicKey));
 | |
| 
 | |
|   await Promise.all(promises);
 | |
| 
 | |
|   window.inboxStore.dispatch(
 | |
|     window.actionsCreators.openConversationExternal(groupPublicKey)
 | |
|   );
 | |
| }
 |