From 29fbf3444265794c8e2e9dd6270d0f158d2f1cbf Mon Sep 17 00:00:00 2001 From: nielsandriesse Date: Mon, 6 Jul 2020 14:22:09 +1000 Subject: [PATCH] Update for multi device Still to do is including the sender public key in sender key messages so that we can correctly handle slave devices, and also to get rid of the ordering requirement --- .../Closed Groups/ClosedGroupsProtocol.swift | 114 ++++++++++-------- 1 file changed, 63 insertions(+), 51 deletions(-) diff --git a/SignalServiceKit/src/Loki/Protocol/Closed Groups/ClosedGroupsProtocol.swift b/SignalServiceKit/src/Loki/Protocol/Closed Groups/ClosedGroupsProtocol.swift index ea1aa033a..da710d3d3 100644 --- a/SignalServiceKit/src/Loki/Protocol/Closed Groups/ClosedGroupsProtocol.swift +++ b/SignalServiceKit/src/Loki/Protocol/Closed Groups/ClosedGroupsProtocol.swift @@ -14,21 +14,29 @@ import PromiseKit public final class ClosedGroupsProtocol : NSObject { public static let isSharedSenderKeysEnabled = true - /// - Note: It's recommended to batch fetch the device links for the given set of members before invoking this, to avoid - /// the message sending pipeline making a request for each member. + /// - Note: It's recommended to batch fetch the device links for the given set of members before invoking this, to avoid the message sending pipeline + /// making a request for each member. public static func createClosedGroup(name: String, members membersAsSet: Set, transaction: YapDatabaseReadWriteTransaction) -> TSGroupThread { + // Prepare var membersAsSet = membersAsSet let messageSenderJobQueue = SSKEnvironment.shared.messageSenderJobQueue let userPublicKey = getUserHexEncodedPublicKey() // Generate a key pair for the group let groupKeyPair = Curve25519.generateKeyPair() - let groupPublicKey = groupKeyPair.hexEncodedPublicKey - // Ensure the current user's master device is included in the member list + let groupPublicKey = groupKeyPair.hexEncodedPublicKey // Includes the "05" prefix + // Ensure the current user's master device is the one that's included in the member list membersAsSet.remove(userPublicKey) membersAsSet.insert(UserDefaults.standard[.masterHexEncodedPublicKey] ?? userPublicKey) - // Create ratchets for all users involved - let members = [String](membersAsSet) // On the receiving side it's assumed that the member list and chain key list are ordered the same - let ratchets = members.map { + // Create ratchets for all members (and their linked devices). The sorting that happens is needed because the receiving end assumes that the member + // list and sender key list are ordered the same. + var membersAndLinkedDevicesAsSet: Set = [] + for member in membersAsSet { + let deviceLinks = OWSPrimaryStorage.shared().getDeviceLinks(for: member, in: transaction) + membersAndLinkedDevicesAsSet.formUnion(deviceLinks.flatMap { [ $0.master.hexEncodedPublicKey, $0.slave.hexEncodedPublicKey ] }) + } + let members = [String](membersAsSet).sorted() + let membersAndLinkedDevices = [String](membersAndLinkedDevicesAsSet).sorted() + let ratchets = membersAndLinkedDevices.map { SharedSenderKeysImplementation.shared.generateRatchet(for: groupPublicKey, senderPublicKey: $0, using: transaction) } // Create the group @@ -39,15 +47,15 @@ public final class ClosedGroupsProtocol : NSObject { thread.usesSharedSenderKeys = true thread.save(with: transaction) SSKEnvironment.shared.profileManager.addThread(toProfileWhitelist: thread) - // Establish sessions if needed (shouldn't be necessary under normal circumstances as - // the user can only pick from existing contacts) - establishSessionsIfNeeded(with: members, using: transaction) - // Send a closed group update message to all members involved using established channels + // Establish sessions if needed + establishSessionsIfNeeded(with: members, using: transaction) // Not `membersAndLinkedDevices` as this internally takes care of multi device already + // Send a closed group update message to all members (and their linked devices) using established channels let senderKeys = ratchets.map { ClosedGroupSenderKey(chainKey: Data(hex: $0.chainKey), keyIndex: $0.keyIndex) } - for member in members { + for member in members { // Not `membersAndLinkedDevices` as this internally takes care of multi device already let thread = TSContactThread.getOrCreateThread(withContactId: member, transaction: transaction) thread.save(with: transaction) - let closedGroupUpdateMessageKind = ClosedGroupUpdateMessage.Kind.new(groupPublicKey: Data(hex: groupPublicKey), name: name, groupPrivateKey: groupKeyPair.privateKey, senderKeys: senderKeys, members: members, admins: admins) + let closedGroupUpdateMessageKind = ClosedGroupUpdateMessage.Kind.new(groupPublicKey: Data(hex: groupPublicKey), name: name, + groupPrivateKey: groupKeyPair.privateKey, senderKeys: senderKeys, members: members, admins: admins) let closedGroupUpdateMessage = ClosedGroupUpdateMessage(thread: thread, kind: closedGroupUpdateMessageKind) messageSenderJobQueue.add(message: closedGroupUpdateMessage, transaction: transaction) } @@ -76,28 +84,35 @@ public final class ClosedGroupsProtocol : NSObject { // Add the members to the member list var members = group.groupMemberIds members.append(contentsOf: newMembersAsSet) - // Establish sessions if needed (shouldn't be necessary under normal circumstances as - // the user can only pick from existing contacts) - establishSessionsIfNeeded(with: members, using: transaction) - // Generate ratchets for the new members - let newMembers = [String](newMembersAsSet) // On the receiving side it's assumed that the member list and chain key list are ordered the same - let ratchets = newMembers.map { + // Generate ratchets for the new members (and their linked devices). The sorting that happens is needed because the receiving end assumes that the member + // list and sender key list are ordered the same. + var newMembersAndLinkedDevicesAsSet: Set = [] + for member in newMembersAsSet { + let deviceLinks = OWSPrimaryStorage.shared().getDeviceLinks(for: member, in: transaction) + newMembersAndLinkedDevicesAsSet.formUnion(deviceLinks.flatMap { [ $0.master.hexEncodedPublicKey, $0.slave.hexEncodedPublicKey ] }) + } + members = members.sorted() + let newMembersAndLinkedDevices = [String](newMembersAndLinkedDevicesAsSet).sorted() + let ratchets = newMembersAndLinkedDevices.map { SharedSenderKeysImplementation.shared.generateRatchet(for: groupPublicKey, senderPublicKey: $0, using: transaction) } - // Send a closed group update message to the existing members with the new members' ratchets (this message is - // aimed at the group) + // Send a closed group update message to the existing members with the new members' ratchets (this message is aimed at the group) let senderKeys = ratchets.map { ClosedGroupSenderKey(chainKey: Data(hex: $0.chainKey), keyIndex: $0.keyIndex) } - let closedGroupUpdateMessageKind = ClosedGroupUpdateMessage.Kind.info(groupPublicKey: Data(hex: groupPublicKey), name: name, senderKeys: senderKeys, members: members, admins: admins) + let closedGroupUpdateMessageKind = ClosedGroupUpdateMessage.Kind.info(groupPublicKey: Data(hex: groupPublicKey), name: name, senderKeys: senderKeys, + members: members, admins: admins) let closedGroupUpdateMessage = ClosedGroupUpdateMessage(thread: thread, kind: closedGroupUpdateMessageKind) messageSenderJobQueue.add(message: closedGroupUpdateMessage, transaction: transaction) - // Send closed group update messages to the new members using established channels + // Establish sessions if needed + establishSessionsIfNeeded(with: [String](newMembersAsSet), using: transaction) // Not `newMembersAndLinkedDevices` as this internally takes care of multi device already + // Send closed group update messages to the new members (and their linked devices) using established channels let allSenderKeys = Storage.getAllClosedGroupRatchets(for: groupPublicKey).map { // This includes the newly generated ratchets ClosedGroupSenderKey(chainKey: Data(hex: $0.chainKey), keyIndex: $0.keyIndex) } - for member in newMembers { + for member in newMembersAsSet { // Not `newMembersAndLinkedDevices` as this internally takes care of multi device already let thread = TSContactThread.getOrCreateThread(contactId: member) thread.save(with: transaction) - let closedGroupUpdateMessageKind = ClosedGroupUpdateMessage.Kind.new(groupPublicKey: Data(hex: groupPublicKey), name: name, groupPrivateKey: Data(hex: groupPrivateKey), senderKeys: allSenderKeys, members: members, admins: admins) + let closedGroupUpdateMessageKind = ClosedGroupUpdateMessage.Kind.new(groupPublicKey: Data(hex: groupPublicKey), name: name, + groupPrivateKey: Data(hex: groupPrivateKey), senderKeys: allSenderKeys, members: members, admins: admins) let closedGroupUpdateMessage = ClosedGroupUpdateMessage(thread: thread, kind: closedGroupUpdateMessageKind) messageSenderJobQueue.add(message: closedGroupUpdateMessage, transaction: transaction) } @@ -115,12 +130,12 @@ public final class ClosedGroupsProtocol : NSObject { } public static func removeMembers(_ membersToRemove: Set, from groupPublicKey: String, using transaction: YapDatabaseReadWriteTransaction) { + // Prepare let userPublicKey = getUserHexEncodedPublicKey() let isUserLeaving = membersToRemove.contains(userPublicKey) guard !isUserLeaving || membersToRemove.count == 1 else { return print("[Loki] Can't remove self and others simultaneously.") } - // Prepare let messageSenderJobQueue = SSKEnvironment.shared.messageSenderJobQueue let groupID = LKGroupUtilities.getEncodedClosedGroupID(groupPublicKey) guard let thread = TSGroupThread.fetch(uniqueId: groupID, transaction: transaction) else { @@ -137,26 +152,26 @@ public final class ClosedGroupsProtocol : NSObject { } indexes.forEach { members.remove(at: $0) } // Send the update to the group (don't include new ratchets as everyone should generate new ratchets individually) - let closedGroupUpdateMessageKind = ClosedGroupUpdateMessage.Kind.info(groupPublicKey: Data(hex: groupPublicKey), name: name, senderKeys: [], members: members, admins: admins) + let closedGroupUpdateMessageKind = ClosedGroupUpdateMessage.Kind.info(groupPublicKey: Data(hex: groupPublicKey), name: name, senderKeys: [], + members: members, admins: admins) let closedGroupUpdateMessage = ClosedGroupUpdateMessage(thread: thread, kind: closedGroupUpdateMessageKind) messageSenderJobQueue.add(message: closedGroupUpdateMessage, transaction: transaction) // Delete all ratchets (it's important that this happens after sending out the update) Storage.removeAllClosedGroupRatchets(for: groupPublicKey, using: transaction) - // Remove the group from the user's set of public keys to poll for if the user is leaving. Otherwise generate - // a new ratchet and send it out to all members (minus the removed ones) using established channels. + // Remove the group from the user's set of public keys to poll for if the user is leaving. Otherwise generate a new ratchet and send it out to all + // members (minus the removed ones) and their linked devices using established channels. if isUserLeaving { Storage.removeClosedGroupPrivateKey(for: groupPublicKey, using: transaction) } else { - // Establish sessions if needed (shouldn't be necessary under normal circumstances as - // sessions would've already been established previously) - establishSessionsIfNeeded(with: members, using: transaction) - // Send out the user's new ratchet to all members (minus the removed ones) using established channels - let newRatchet = SharedSenderKeysImplementation.shared.generateRatchet(for: groupPublicKey, senderPublicKey: userPublicKey, using: transaction) - let newSenderKey = ClosedGroupSenderKey(chainKey: Data(hex: newRatchet.chainKey), keyIndex: newRatchet.keyIndex) - for member in members { + // Establish sessions if needed + establishSessionsIfNeeded(with: members, using: transaction) // This internally takes care of multi device + // Send out the user's new ratchet to all members (minus the removed ones) and their linked devices using established channels + let userRatchet = SharedSenderKeysImplementation.shared.generateRatchet(for: groupPublicKey, senderPublicKey: userPublicKey, using: transaction) + let userSenderKey = ClosedGroupSenderKey(chainKey: Data(hex: userRatchet.chainKey), keyIndex: userRatchet.keyIndex) + for member in members { // This internally takes care of multi device let thread = TSContactThread.getOrCreateThread(withContactId: member, transaction: transaction) thread.save(with: transaction) - let closedGroupUpdateMessageKind = ClosedGroupUpdateMessage.Kind.senderKey(groupPublicKey: Data(hex: groupPublicKey), senderKey: newSenderKey) + let closedGroupUpdateMessageKind = ClosedGroupUpdateMessage.Kind.senderKey(groupPublicKey: Data(hex: groupPublicKey), senderKey: userSenderKey) let closedGroupUpdateMessage = ClosedGroupUpdateMessage(thread: thread, kind: closedGroupUpdateMessageKind) messageSenderJobQueue.add(message: closedGroupUpdateMessage, transaction: transaction) } @@ -210,12 +225,13 @@ public final class ClosedGroupsProtocol : NSObject { let infoMessage = TSInfoMessage(timestamp: NSDate.ows_millisecondTimeStamp(), in: thread, messageType: .typeGroupUpdate) infoMessage.save(with: transaction) // Establish sessions if needed - establishSessionsIfNeeded(with: members, using: transaction) + establishSessionsIfNeeded(with: members, using: transaction) // This internally takes care of multi device } - /// Invoked upon receiving a group update. A group update is sent out when a group's name is changed, when new users - /// are added, when users leave or are kicked, or if the group admins are changed. - private static func handleInfoMessage(_ closedGroupUpdate: SSKProtoDataMessageClosedGroupUpdate, from senderPublicKey: String, using transaction: YapDatabaseReadWriteTransaction) { + /// Invoked upon receiving a group update. A group update is sent out when a group's name is changed, when new users are added, when users leave or are + /// kicked, or if the group admins are changed. + private static func handleInfoMessage(_ closedGroupUpdate: SSKProtoDataMessageClosedGroupUpdate, from senderPublicKey: String, + using transaction: YapDatabaseReadWriteTransaction) { // Unwrap the message let groupPublicKey = closedGroupUpdate.groupPublicKey.toHexString() let name = closedGroupUpdate.name @@ -237,19 +253,15 @@ public final class ClosedGroupsProtocol : NSObject { return print("[Loki] Ignoring closed group update from non-admin.") } // Establish sessions if needed (it's important that this happens before the code below) - establishSessionsIfNeeded(with: members, using: transaction) - // Parse out any new members and store their ratchets (it's important that - // this happens before the code below) + establishSessionsIfNeeded(with: members, using: transaction) // This internally takes care of multi device + // Parse out any new members and store their ratchets (it's important that this happens before the code below) let oldMembers = group.groupMemberIds let newMembers = members.filter { !oldMembers.contains($0) } - if newMembers.count == senderKeys.count { // If someone left or was kicked the message won't have any sender keys - zip(newMembers, senderKeys).forEach { (member, senderKey) in - let ratchet = ClosedGroupRatchet(chainKey: senderKey.chainKey.toHexString(), keyIndex: UInt(senderKey.keyIndex), messageKeys: []) - Storage.setClosedGroupRatchet(for: groupPublicKey, senderPublicKey: member, ratchet: ratchet, using: transaction) - } + zip(newMembers, senderKeys).forEach { (member, senderKey) in + let ratchet = ClosedGroupRatchet(chainKey: senderKey.chainKey.toHexString(), keyIndex: UInt(senderKey.keyIndex), messageKeys: []) + Storage.setClosedGroupRatchet(for: groupPublicKey, senderPublicKey: member, ratchet: ratchet, using: transaction) } - // Delete all ratchets and send out the user's new ratchet using established - // channels if any member of the group left or was removed + // Delete all ratchets and send out the user's new ratchet using established channels if any member of the group left or was removed if Set(members).intersection(oldMembers) != Set(oldMembers) { Storage.removeAllClosedGroupRatchets(for: groupPublicKey, using: transaction) let userPublicKey = getUserHexEncodedPublicKey()