Multidevice support for medium groups

This commit is contained in:
Maxim Shishmarev 2020-05-18 15:12:22 +10:00
parent 3561ac49c0
commit 2a0130ff04
11 changed files with 340 additions and 177 deletions

View File

@ -404,29 +404,29 @@ module.exports = {
)
).should.eventually.be.true;
await Promise.all(others.map(async app => {
// next check that other members have been invited and have the group in their conversations
await app.client.waitForExist(
ConversationPage.rowOpenGroupConversationName(
this.VALID_CLOSED_GROUP_NAME1
),
6000
await Promise.all(
others.map(async otherApp => {
// next check that other members have been invited and have the group in their conversations
await otherApp.client.waitForExist(
ConversationPage.rowOpenGroupConversationName(
this.VALID_CLOSED_GROUP_NAME1
),
6000
);
// open the closed group conversation on otherApp
await otherApp.client
.element(ConversationPage.conversationButtonSection)
.click();
await this.timeout(500);
await otherApp.client
.element(
ConversationPage.rowOpenGroupConversationName(
this.VALID_CLOSED_GROUP_NAME1
)
)
.click();
})
);
// open the closed group conversation on app2
await app.client
.element(ConversationPage.conversationButtonSection)
.click();
await this.timeout(500);
await app.client
.element(
ConversationPage.rowOpenGroupConversationName(
this.VALID_CLOSED_GROUP_NAME1
)
)
.click();
}));
},
async linkApp2ToApp(app1, app2) {

View File

@ -64,7 +64,9 @@ module.exports = {
'Enter a group name'
),
createClosedGroupMemberItem: commonPage.divWithClass('session-member-item'),
createClosedGroupSealedSenderToggle: commonPage.divWithClass('session-toggle'),
createClosedGroupSealedSenderToggle: commonPage.divWithClass(
'session-toggle'
),
createClosedGroupMemberItemSelected: commonPage.divWithClass(
'session-member-item selected'
),

View File

@ -746,17 +746,9 @@
identityKeys.privKey
);
// Constructing a "create group" message
const proto = new textsecure.protobuf.DataMessage();
const primary = window.storage.get('primaryDevicePubKey');
const groupUpdate = new textsecure.protobuf.MediumGroupUpdate();
groupUpdate.groupId = groupId;
groupUpdate.groupSecretKey = groupSecretKeyHex;
groupUpdate.senderKey = senderKey;
groupUpdate.members = [ourIdentity, ...members];
groupUpdate.groupName = groupName;
proto.mediumGroupUpdate = groupUpdate;
const allMembers = [primary, ...members];
await window.Signal.Data.createOrUpdateIdentityKey({
id: groupId,
@ -768,11 +760,14 @@
ev.groupDetails = {
id: groupId,
name: groupName,
members: groupUpdate.members,
recipients: groupUpdate.members,
members: allMembers,
recipients: allMembers,
active: true,
expireTimer: 0,
avatar: '',
secretKey: identityKeys.privKey,
senderKey,
is_medium_group: true,
};
ev.confirm = () => {};
@ -786,13 +781,14 @@
convo.updateGroup(ev.groupDetails);
convo.setFriendRequestStatus(
window.friends.friendRequestStatusEnum.friends
);
appView.openConversation(groupId, {});
// Subscribe to this group id
messageReceiver.pollForAdditionalId(groupId);
// TODO: include ourselves so that our lined devices work as well!
await textsecure.messaging.updateMediumGroup(members, proto);
};
window.doCreateGroup = async (groupName, members) => {
@ -1911,6 +1907,7 @@
members: details.members,
color: details.color,
type: 'group',
is_medium_group: details.is_medium_group || false,
};
if (details.active) {

View File

@ -14,7 +14,8 @@
clipboard,
BlockedNumberController,
lokiPublicChatAPI,
JobQueue
JobQueue,
StringView
*/
/* eslint-disable more/no-then */
@ -2289,12 +2290,41 @@
group_update: groupUpdate,
});
const id = await window.Signal.Data.saveMessage(message.attributes, {
Message: Whisper.Message,
});
message.set({ id });
const messageId = await window.Signal.Data.saveMessage(
message.attributes,
{
Message: Whisper.Message,
}
);
message.set({ id: messageId });
const options = this.getSendOptions();
if (groupUpdate.is_medium_group) {
// Constructing a "create group" message
const proto = new textsecure.protobuf.DataMessage();
const mgUpdate = new textsecure.protobuf.MediumGroupUpdate();
const { id, name, secretKey, senderKey, members } = groupUpdate;
mgUpdate.type = textsecure.protobuf.MediumGroupUpdate.Type.NEW_GROUP;
mgUpdate.groupId = id;
mgUpdate.groupSecretKey = secretKey;
mgUpdate.senderKey = new textsecure.protobuf.SenderKey({
chainKey: senderKey,
keyIdx: 0,
});
mgUpdate.members = members.map(pkHex =>
StringView.hexToArrayBuffer(pkHex)
);
mgUpdate.groupName = name;
proto.mediumGroupUpdate = mgUpdate;
await textsecure.messaging.updateMediumGroup(members, proto);
return;
}
message.send(
this.wrapSend(
textsecure.messaging.updateGroup(

View File

@ -1477,7 +1477,8 @@
if (!this.isFriendRequest()) {
const c = this.getConversation();
// Don't bother sending sync messages to public chats
if (c && !c.isPublic()) {
// or groups with sender keys
if (c && !c.isPublic() && !c.get('is_medium_group')) {
this.sendSyncMessage();
}
}

View File

@ -5,7 +5,8 @@
dcodeIO,
libloki,
log,
crypto
crypto,
textsecure
*/
/* eslint-disable more/no-then */
@ -39,9 +40,7 @@ async function saveSenderKeysInner(
}
// Save somebody else's key
async function saveSenderKeys(groupId, senderIdentity, chainKey) {
// New key, so index 0
const keyIdx = 0;
async function saveSenderKeys(groupId, senderIdentity, chainKey, keyIdx) {
const messageKeys = {};
await saveSenderKeysInner(
groupId,
@ -133,7 +132,7 @@ async function advanceRatchet(groupId, senderIdentity, idx) {
log.error(
`Could not find ratchet for groupId ${groupId} sender: ${senderIdentity}`
);
return null;
throw new textsecure.SenderKeyMissing(senderIdentity);
}
// Normally keyIdx will be 1 behind, in which case we stepRatchet one time only
@ -179,6 +178,7 @@ async function advanceRatchet(groupId, senderIdentity, idx) {
break;
} else if (nextKeyIdx > idx) {
log.error('Developer error: nextKeyIdx > idx');
return null;
} else {
// Store keys for skipped nextKeyIdx, we might need them to decrypt
// messages that arrive out-of-order
@ -289,9 +289,16 @@ async function encryptWithSenderKeyInner(plaintext, groupId, ourIdentity) {
return { ciphertext, keyIdx };
}
async function getSenderKeys(groupId, senderIdentity) {
const { chainKey, keyIdx } = await loadChainKey(groupId, senderIdentity);
return { chainKey, keyIdx };
}
module.exports = {
createSenderKeyForGroup,
encryptWithSenderKey,
decryptWithSenderKey,
saveSenderKeys,
getSenderKeys,
};

View File

@ -220,6 +220,7 @@
});
return syncMessage;
}
function createGroupSyncProtoMessage(sessionGroup) {
// We are getting a single open group here

View File

@ -263,6 +263,19 @@
}
}
function SenderKeyMissing(senderIdentity) {
this.name = 'SenderKeyMissing';
this.senderIdentity = senderIdentity;
Error.call(this, this.name);
// Maintains proper stack trace, where our error was thrown (only available on V8)
// via https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error
if (Error.captureStackTrace) {
Error.captureStackTrace(this);
}
}
window.textsecure.UnregisteredUserError = UnregisteredUserError;
window.textsecure.SendMessageNetworkError = SendMessageNetworkError;
window.textsecure.IncomingIdentityKeyError = IncomingIdentityKeyError;
@ -282,4 +295,5 @@
window.textsecure.TimestampError = TimestampError;
window.textsecure.PublicChatError = PublicChatError;
window.textsecure.PublicTokenError = PublicTokenError;
window.textsecure.SenderKeyMissing = SenderKeyMissing;
})();

View File

@ -849,6 +849,22 @@ MessageReceiver.prototype.extend({
return promise
.then(plaintext => this.postDecrypt(envelope, plaintext))
.catch(error => {
if (error && error instanceof textsecure.SenderKeyMissing) {
const groupId = envelope.source;
const { senderIdentity } = error;
log.info(
'Requesting missing key for identity: ',
senderIdentity,
'groupId: ',
groupId
);
textsecure.messaging.requestSenderKeys(senderIdentity, groupId);
return;
}
let errorToThrow = error;
const noSession =
@ -876,7 +892,7 @@ MessageReceiver.prototype.extend({
ev.confirm = this.removeFromCache.bind(this, envelope);
const returnError = () => Promise.reject(errorToThrow);
return this.dispatchAndWait(ev).then(returnError, returnError);
this.dispatchAndWait(ev).then(returnError, returnError);
});
},
async decryptPreKeyWhisperMessage(ciphertext, sessionCipher, address) {
@ -900,7 +916,7 @@ MessageReceiver.prototype.extend({
},
// handle a SYNC message for a message
// sent by another device
handleSentMessage(envelope, sentContainer, msg) {
async handleSentMessage(envelope, sentContainer, msg) {
const {
destination,
timestamp,
@ -908,41 +924,63 @@ MessageReceiver.prototype.extend({
unidentifiedStatus,
} = sentContainer;
let p = Promise.resolve();
// eslint-disable-next-line no-bitwise
if (msg.flags & textsecure.protobuf.DataMessage.Flags.END_SESSION) {
p = this.handleEndSession(destination);
await this.handleEndSession(destination);
}
return p.then(() =>
this.processDecrypted(envelope, msg).then(message => {
const primaryDevicePubKey = window.storage.get('primaryDevicePubKey');
// handle profileKey and avatar updates
if (envelope.source === primaryDevicePubKey) {
const { profileKey, profile } = message;
const primaryConversation = ConversationController.get(
primaryDevicePubKey
);
if (profile) {
this.updateProfile(primaryConversation, profile, profileKey);
}
}
if (msg.mediumGroupUpdate) {
await this.handleMediumGroupUpdate(envelope, msg.mediumGroupUpdate);
return;
}
const ev = new Event('sent');
ev.confirm = this.removeFromCache.bind(this, envelope);
ev.data = {
destination,
timestamp: timestamp.toNumber(),
device: envelope.sourceDevice,
unidentifiedStatus,
message,
};
if (expirationStartTimestamp) {
ev.data.expirationStartTimestamp = expirationStartTimestamp.toNumber();
}
return this.dispatchAndWait(ev);
})
const message = await this.processDecrypted(envelope, msg);
const groupId = message.group && message.group.id;
const isBlocked = this.isGroupBlocked(groupId);
const primaryDevicePubKey = window.storage.get('primaryDevicePubKey');
const isMe =
envelope.source === textsecure.storage.user.getNumber() ||
envelope.source === primaryDevicePubKey;
const isLeavingGroup = Boolean(
message.group &&
message.group.type === textsecure.protobuf.GroupContext.Type.QUIT
);
if (groupId && isBlocked && !(isMe && isLeavingGroup)) {
window.log.warn(
`Message ${this.getEnvelopeId(
envelope
)} ignored; destined for blocked group`
);
this.removeFromCache(envelope);
return;
}
// handle profileKey and avatar updates
if (envelope.source === primaryDevicePubKey) {
const { profileKey, profile } = message;
const primaryConversation = ConversationController.get(
primaryDevicePubKey
);
if (profile) {
this.updateProfile(primaryConversation, profile, profileKey);
}
}
const ev = new Event('sent');
ev.confirm = this.removeFromCache.bind(this, envelope);
ev.data = {
destination,
timestamp: timestamp.toNumber(),
device: envelope.sourceDevice,
unidentifiedStatus,
message,
};
if (expirationStartTimestamp) {
ev.data.expirationStartTimestamp = expirationStartTimestamp.toNumber();
}
this.dispatchAndWait(ev);
},
async handleLokiAddressMessage(envelope) {
window.log.warn('Ignoring a Loki address message');
@ -1163,96 +1201,121 @@ MessageReceiver.prototype.extend({
},
async handleMediumGroupUpdate(envelope, groupUpdate) {
const {
groupId,
groupSecretKey,
senderKey,
members,
groupName,
} = groupUpdate;
const { type, groupId } = groupUpdate;
const convoExists = window.ConversationController.get(groupId, 'group');
if (convoExists) {
// If the group already exists, check that `members` is empty,
// and if so, it is sender key message
// TODO: introduce TYPE into this message instead?
if (!members || !members.length) {
log.info('[sender key] got a new sender key from:', envelope.source);
// We probably don't need to await here
await window.SenderKeyAPI.saveSenderKeys(
groupId,
envelope.source,
senderKey
);
this.removeFromCache(envelope);
return;
}
log.error(`Conversation for groupId ${groupId} already exists`);
}
const convo = await window.ConversationController.getOrCreateAndWait(
groupId,
'group'
);
convo.set('is_medium_group', true);
convo.set('active_at', Date.now());
convo.set('name', groupName);
await window.Signal.Data.createOrUpdateIdentityKey({
id: groupId,
secretKey: groupSecretKey,
});
// Save sender's key
await window.SenderKeyAPI.saveSenderKeys(
groupId,
envelope.source,
senderKey
);
// TODO: Check that we are even a part of this group?
const ourIdentity = await textsecure.storage.user.getNumber();
const senderIdentity = envelope.source;
const ownSenderKey = await window.SenderKeyAPI.createSenderKeyForGroup(
groupId,
ourIdentity
);
{
// TODO: Send own key to every member
const otherMembers = _.without(members, ourIdentity);
if (
type === textsecure.protobuf.MediumGroupUpdate.Type.SENDER_KEY_REQUEST
) {
log.debug('[sender key] sender key request from:', senderIdentity);
const proto = new textsecure.protobuf.DataMessage();
// We reuse the same message type for sender keys
const update = new textsecure.protobuf.MediumGroupUpdate();
const { chainKey, keyIdx } = await window.SenderKeyAPI.getSenderKeys(
groupId,
ourIdentity
);
update.type = textsecure.protobuf.MediumGroupUpdate.Type.SENDER_KEY;
update.groupId = groupId;
update.senderKey = ownSenderKey;
update.senderKey = new textsecure.protobuf.SenderKey({
chainKey: StringView.arrayBufferToHex(chainKey),
keyIdx,
});
proto.mediumGroupUpdate = update;
// TODO: send to our linked devices too?
textsecure.messaging.updateMediumGroup([senderIdentity], proto);
// Don't need to await here
this.removeFromCache(envelope);
} else if (type === textsecure.protobuf.MediumGroupUpdate.Type.SENDER_KEY) {
const { senderKey } = groupUpdate;
// TODO: Some of the members might not have a session with us, so
// we should send a session request
log.debug('[sender key] got a new sender key from:', senderIdentity);
textsecure.messaging.updateMediumGroup(otherMembers, proto);
await window.SenderKeyAPI.saveSenderKeys(
groupId,
senderIdentity,
senderKey.chainKey,
senderKey.keyIdx
);
this.removeFromCache(envelope);
} else if (type === textsecure.protobuf.MediumGroupUpdate.Type.NEW_GROUP) {
const {
members: membersBinary,
groupSecretKey,
groupName,
senderKey,
} = groupUpdate;
const members = membersBinary.map(pk =>
StringView.arrayBufferToHex(pk.toArrayBuffer())
);
const convo = await window.ConversationController.getOrCreateAndWait(
groupId,
'group'
);
convo.set('is_medium_group', true);
convo.set('active_at', Date.now());
convo.set('name', groupName);
convo.setFriendRequestStatus(
window.friends.friendRequestStatusEnum.friends
);
await window.Signal.Data.createOrUpdateIdentityKey({
id: groupId,
secretKey: StringView.arrayBufferToHex(groupSecretKey.toArrayBuffer()),
});
// Save sender's key
await window.SenderKeyAPI.saveSenderKeys(
groupId,
envelope.source,
senderKey.chainKey,
senderKey.keyIdx
);
// TODO: Check that we are even a part of this group?
const ownSenderKey = await window.SenderKeyAPI.createSenderKeyForGroup(
groupId,
ourIdentity
);
{
// Send own key to every member
const otherMembers = _.without(members, ourIdentity);
const proto = new textsecure.protobuf.DataMessage();
// We reuse the same message type for sender keys
const update = new textsecure.protobuf.MediumGroupUpdate();
update.type = textsecure.protobuf.MediumGroupUpdate.Type.SENDER_KEY;
update.groupId = groupId;
update.senderKey = new textsecure.protobuf.SenderKey({
chainKey: ownSenderKey,
keyIdx: 0,
});
proto.mediumGroupUpdate = update;
textsecure.messaging.updateMediumGroup(otherMembers, proto);
}
// Subscribe to this group
this.pollForAdditionalId(groupId);
// All further messages (maybe rather than 'control' messages) should come to this group's swarm
this.removeFromCache(envelope);
}
// Subscribe to this group
this.pollForAdditionalId(groupId);
// All further messages (maybe rather than 'control' messages) should come to this group's swarm
this.removeFromCache(envelope);
},
async handleDataMessage(envelope, msg) {
window.log.info('data message from', this.getEnvelopeId(envelope));
@ -1308,14 +1371,33 @@ MessageReceiver.prototype.extend({
!_.isEmpty(message.body) &&
friendRequestStatusNoneOrExpired;
// Build a 'message' event i.e. a received message event
const ev = new Event('message');
const source = envelope.senderIdentity || senderPubKey;
const isOwnDevice = async pubkey => {
const primaryDevice = window.storage.get('primaryDevicePubKey');
const secondaryDevices = await window.libloki.storage.getPairedDevicesFor(
primaryDevice
);
const allDevices = [primaryDevice, ...secondaryDevices];
return allDevices.includes(pubkey);
};
const ownDevice = await isOwnDevice(source);
let ev;
if (conversation.get('is_medium_group') && ownDevice) {
// Data messages for medium groups don't arrive as sync messages. Instead,
// linked devices poll for group messages independently, thus they need
// to recognise some of those messages at their own.
ev = new Event('sent');
} else {
ev = new Event('message');
}
if (envelope.senderIdentity) {
message.group = {
id: envelope.source
id: envelope.source,
};
}
@ -1340,6 +1422,7 @@ MessageReceiver.prototype.extend({
contact,
preview,
groupInvitation,
mediumGroupUpdate,
}) {
return (
!flags &&
@ -1349,7 +1432,8 @@ MessageReceiver.prototype.extend({
_.isEmpty(quote) &&
_.isEmpty(contact) &&
_.isEmpty(preview) &&
_.isEmpty(groupInvitation)
_.isEmpty(groupInvitation) &&
_.isEmpty(mediumGroupUpdate)
);
},
handleLegacyMessage(envelope) {

View File

@ -430,7 +430,7 @@ MessageSender.prototype = {
let keysFound = false;
// If we don't have a session but we already have prekeys to
// start communication then we should use them
if (!haveSession && !options.isPublic) {
if (!haveSession && !options.isPublic && !options.isMediumGroup) {
keysFound = await hasKeys(number);
}
@ -710,7 +710,7 @@ MessageSender.prototype = {
}
// We only want to sync across closed groups that we haven't left
const sessionGroups = conversations.filter(
c => c.isClosedGroup() && !c.get('left') && c.isFriend()
c => c.isClosedGroup() && !c.get('left') && c.isFriend() && !c.get('is_medium_group')
);
if (sessionGroups.length === 0) {
window.console.info('No closed group to sync.');
@ -975,7 +975,12 @@ MessageSender.prototype = {
});
},
sendGroupProto(providedNumbers, proto, timestamp = Date.now(), options = {}) {
async sendGroupProto(
providedNumbers,
proto,
timestamp = Date.now(),
options = {}
) {
// We always assume that only primary device is a member in the group
const primaryDeviceKey =
window.storage.get('primaryDevicePubKey') ||
@ -1014,12 +1019,13 @@ MessageSender.prototype = {
);
});
return sendPromise.then(result => {
// Sync the group message to our other devices
const encoded = textsecure.protobuf.DataMessage.encode(proto);
this.sendSyncMessage(encoded, timestamp, null, null, [], [], options);
return result;
});
const result = await sendPromise;
// Sync the group message to our other devices
const encoded = textsecure.protobuf.DataMessage.encode(proto);
this.sendSyncMessage(encoded, timestamp, null, null, [], [], options);
return result;
},
async getMessageProto(
@ -1282,6 +1288,16 @@ MessageSender.prototype = {
return this.sendGroupProto(groupNumbers, proto, Date.now(), options);
},
requestSenderKeys(sender, groupId) {
const proto = new textsecure.protobuf.DataMessage();
const update = new textsecure.protobuf.MediumGroupUpdate();
update.type = textsecure.protobuf.MediumGroupUpdate.Type.SENDER_KEY_REQUEST;
update.groupId = groupId;
proto.mediumGroupUpdate = update;
textsecure.messaging.updateMediumGroup([sender], proto);
},
leaveGroup(groupId, groupNumbers, options) {
const proto = new textsecure.protobuf.DataMessage();
proto.group = new textsecure.protobuf.GroupContext();
@ -1391,6 +1407,7 @@ textsecure.MessageSender = function MessageSenderWrapper(username, password) {
this.setGroupName = sender.setGroupName.bind(sender);
this.setGroupAvatar = sender.setGroupAvatar.bind(sender);
this.requestGroupInfo = sender.requestGroupInfo.bind(sender);
this.requestSenderKeys = sender.requestSenderKeys.bind(sender);
this.leaveGroup = sender.leaveGroup.bind(sender);
this.sendSyncMessage = sender.sendSyncMessage.bind(sender);
this.getProfile = sender.getProfile.bind(sender);

View File

@ -51,17 +51,26 @@ message MediumGroupContent {
optional bytes ephemeralKey = 2;
}
message MediumGroupUpdate {
optional string groupName = 1;
optional string groupId = 2; // should this be bytes?
optional string groupSecretKey = 3;
optional string senderKey = 4;
repeated string members = 5;
message SenderKey {
optional string chainKey = 1;
optional uint32 keyIdx = 2;
}
message SenderKeyUpdate {
optional string groupId = 1;
optional string senderKey = 2;
message MediumGroupUpdate {
enum Type {
NEW_GROUP = 0; // groupId, groupName, groupSecretKey, members, senderKey
GROUP_INFO = 1; // groupId, groupName, members, senderKey
SENDER_KEY_REQUEST = 2; // groupId
SENDER_KEY = 3; // groupId, SenderKey
}
optional string groupName = 1;
optional string groupId = 2; // should this be bytes?
optional bytes groupSecretKey = 3;
optional SenderKey senderKey = 4;
repeated bytes members = 5;
optional Type type = 6;
}
message LokiAddressMessage {
@ -424,4 +433,5 @@ message GroupDetails {
optional string color = 7;
optional bool blocked = 8;
repeated string admins = 9;
optional bool is_medium_group = 10;
}