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()