pull/1461/head
Audric Ackermann 4 years ago
parent 6e11c6db2e
commit 0e25ab2874
No known key found for this signature in database
GPG Key ID: 999F434D76324AD4

@ -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 .",

@ -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 {

@ -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<string>
@ -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 => {

@ -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;
}

@ -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,

@ -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<string> = [];
let admins: Array<string> = [];
// 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<string>,
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<string>,
messageId: string,
stillMembers: Array<string>
) {
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()

@ -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<string>;
}
export class ClosedGroupAddedMembersMessage extends ClosedGroupMessage {
private readonly addedMembers: Array<string>;
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;
}
}

@ -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;
}
}

@ -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;
}
}

@ -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<string>;
}
export class ClosedGroupRemovedMembersMessage extends ClosedGroupMessage {
private readonly removedMembers: Array<string>;
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;
}
}

@ -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<string>;
expireTimer: number;
}
export class ClosedGroupUpdateMessage extends ClosedGroupMessage {
private readonly name: string;
private readonly members: Array<string>;
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;
}
}

@ -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';

@ -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';

@ -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);
// });
// });
});
});

@ -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,
});

@ -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;
}

@ -2,3 +2,4 @@ export * from './timeout';
export * from './stubbing';
export * from './pubkey';
export * from './message';
export * from './envelope';

Loading…
Cancel
Save