session-ios/SessionMessagingKit/Sending & Receiving/MessageSender.swift
Morgan Pretty f9c2655df4 Finalised the OpenGroupAPI and more tests
Fixed an issue where messages where signed incorrectly when blinding wasn't enabled on a SOGS
Fixed an issue where a single invalid message would result in all messages in that request being dropped
Updated the final legacy endpoint (ban and delete all messages)
Moved the OpenGroupManager poller values into the 'Cache' (so they are thread safe)
Started adding unit tests for the OpenGroupManager
Removed some redundant parameters from the 'Request' type
2022-03-15 15:19:23 +11:00

563 lines
27 KiB
Swift

import PromiseKit
import SessionSnodeKit
import SessionUtilitiesKit
import Sodium
@objc(SNMessageSender)
public final class MessageSender : NSObject {
// MARK: Error
public enum Error : LocalizedError {
case invalidMessage
case protoConversionFailed
case noUserX25519KeyPair
case noUserED25519KeyPair
case signingFailed
case encryptionFailed
case noUsername
// Closed groups
case noThread
case noKeyPair
case invalidClosedGroupUpdate
internal var isRetryable: Bool {
switch self {
case .invalidMessage, .protoConversionFailed, .invalidClosedGroupUpdate, .signingFailed, .encryptionFailed: return false
default: return true
}
}
public var errorDescription: String? {
switch self {
case .invalidMessage: return "Invalid message."
case .protoConversionFailed: return "Couldn't convert message to proto."
case .noUserX25519KeyPair: return "Couldn't find user X25519 key pair."
case .noUserED25519KeyPair: return "Couldn't find user ED25519 key pair."
case .signingFailed: return "Couldn't sign message."
case .encryptionFailed: return "Couldn't encrypt message."
case .noUsername: return "Missing username."
// Closed groups
case .noThread: return "Couldn't find a thread associated with the given group public key."
case .noKeyPair: return "Couldn't find a private key associated with the given group public key."
case .invalidClosedGroupUpdate: return "Invalid group update."
}
}
}
// MARK: Initialization
private override init() { }
// MARK: Preparation
public static func prep(_ signalAttachments: [SignalAttachment], for message: VisibleMessage, using transaction: YapDatabaseReadWriteTransaction) {
guard let tsMessage = TSOutgoingMessage.find(withTimestamp: message.sentTimestamp!) else {
#if DEBUG
preconditionFailure()
#else
return
#endif
}
var attachments: [TSAttachmentStream] = []
signalAttachments.forEach {
let attachment = TSAttachmentStream(contentType: $0.mimeType, byteCount: UInt32($0.dataLength), sourceFilename: $0.sourceFilename,
caption: $0.captionText, albumMessageId: tsMessage.uniqueId!)
attachment.attachmentType = $0.isVoiceMessage ? .voiceMessage : .default
attachments.append(attachment)
attachment.write($0.dataSource)
attachment.save(with: transaction)
}
prep(attachments, for: message, using: transaction)
}
@objc(prep:forMessage:usingTransaction:)
public static func prep(_ attachmentStreams: [TSAttachmentStream], for message: VisibleMessage, using transaction: YapDatabaseReadWriteTransaction) {
guard let tsMessage = TSOutgoingMessage.find(withTimestamp: message.sentTimestamp!) else {
#if DEBUG
preconditionFailure()
#else
return
#endif
}
var attachments = attachmentStreams
// The line below locally generates a thumbnail for the quoted attachment. It just needs to happen at some point during the
// message sending process.
tsMessage.quotedMessage?.createThumbnailAttachmentsIfNecessary(with: transaction)
var linkPreviewAttachmentID: String?
if let id = tsMessage.linkPreview?.imageAttachmentId,
let attachment = TSAttachment.fetch(uniqueId: id, transaction: transaction) as? TSAttachmentStream {
linkPreviewAttachmentID = id
attachments.append(attachment)
}
// Anything added to message.attachmentIDs will be uploaded by an UploadAttachmentJob. Any attachment IDs added to tsMessage will
// make it render as an attachment (not what we want in the case of a link preview or quoted attachment).
message.attachmentIDs = attachments.map { $0.uniqueId! }
tsMessage.attachmentIds.removeAllObjects()
tsMessage.attachmentIds.addObjects(from: message.attachmentIDs)
if let id = linkPreviewAttachmentID { tsMessage.attachmentIds.remove(id) }
tsMessage.save(with: transaction)
}
// MARK: Convenience
public static func send(_ message: Message, to destination: Message.Destination, using transaction: Any) -> Promise<Void> {
switch destination {
case .contact, .closedGroup:
return sendToSnodeDestination(destination, message: message, using: transaction)
case .legacyOpenGroup, .openGroup:
return sendToOpenGroupDestination(destination, message: message, using: transaction)
case .openGroupInbox:
return sendToOpenGroupInboxDestination(destination, message: message, using: transaction)
}
}
// MARK: One-on-One Chats & Closed Groups
internal static func sendToSnodeDestination(_ destination: Message.Destination, message: Message, using transaction: Any, isSyncMessage: Bool = false) -> Promise<Void> {
let (promise, seal) = Promise<Void>.pending()
let storage = SNMessagingKitConfiguration.shared.storage
let transaction = transaction as! YapDatabaseReadWriteTransaction
let userPublicKey = storage.getUserPublicKey()
var isMainAppAndActive = false
if let sharedUserDefaults = UserDefaults(suiteName: "group.com.loki-project.loki-messenger") {
isMainAppAndActive = sharedUserDefaults.bool(forKey: "isMainAppActive")
}
// Set the timestamp, sender and recipient
if message.sentTimestamp == nil { // Visible messages will already have their sent timestamp set
message.sentTimestamp = NSDate.millisecondTimestamp()
}
message.sender = userPublicKey
switch destination {
case .contact(let publicKey): message.recipient = publicKey
case .closedGroup(let groupPublicKey): message.recipient = groupPublicKey
case .legacyOpenGroup, .openGroup, .openGroupInbox: preconditionFailure()
}
let isSelfSend = (message.recipient == userPublicKey)
// Set the failure handler (need it here already for precondition failure handling)
func handleFailure(with error: Swift.Error, using transaction: YapDatabaseReadWriteTransaction) {
MessageSender.handleFailedMessageSend(message, with: error, using: transaction)
seal.reject(error)
}
// Validate the message
guard message.isValid else { handleFailure(with: Error.invalidMessage, using: transaction); return promise }
// Stop here if this is a self-send, unless it's:
// a configuration message
// a sync message
// a closed group control message of type `new`
// an unsend request
let isNewClosedGroupControlMessage = given(message as? ClosedGroupControlMessage) { if case .new = $0.kind { return true } else { return false } } ?? false
guard !isSelfSend || message is ConfigurationMessage || isSyncMessage || isNewClosedGroupControlMessage || message is UnsendRequest else {
storage.write(with: { transaction in
MessageSender.handleSuccessfulMessageSend(message, to: destination, using: transaction)
seal.fulfill(())
}, completion: { })
return promise
}
// Attach the user's profile if needed
if let message = message as? VisibleMessage {
guard let name = storage.getUser()?.name else { handleFailure(with: Error.noUsername, using: transaction); return promise }
if let profileKey = storage.getUser()?.profileEncryptionKey?.keyData, let profilePictureURL = storage.getUser()?.profilePictureURL {
message.profile = VisibleMessage.Profile(displayName: name, profileKey: profileKey, profilePictureURL: profilePictureURL)
} else {
message.profile = VisibleMessage.Profile(displayName: name)
}
}
// Convert it to protobuf
guard let proto = message.toProto(using: transaction) else { handleFailure(with: Error.protoConversionFailed, using: transaction); return promise }
// Serialize the protobuf
let plaintext: Data
do {
plaintext = (try proto.serializedData() as NSData).paddedMessageBody()
} catch {
SNLog("Couldn't serialize proto due to error: \(error).")
handleFailure(with: error, using: transaction)
return promise
}
// Encrypt the serialized protobuf
let ciphertext: Data
do {
switch destination {
case .contact(let publicKey):
ciphertext = try encryptWithSessionProtocol(plaintext, for: publicKey)
case .closedGroup(let groupPublicKey):
guard let encryptionKeyPair = Storage.shared.getLatestClosedGroupEncryptionKeyPair(for: groupPublicKey) else {
throw Error.noKeyPair
}
ciphertext = try encryptWithSessionProtocol(plaintext, for: encryptionKeyPair.hexEncodedPublicKey)
case .legacyOpenGroup, .openGroup, .openGroupInbox: preconditionFailure()
}
}
catch {
SNLog("Couldn't encrypt message for destination: \(destination) due to error: \(error).")
handleFailure(with: error, using: transaction)
return promise
}
// Wrap the result
let kind: SNProtoEnvelope.SNProtoEnvelopeType
let senderPublicKey: String
switch destination {
case .contact:
kind = .sessionMessage
senderPublicKey = ""
case .closedGroup(let groupPublicKey):
kind = .closedGroupMessage
senderPublicKey = groupPublicKey
case .legacyOpenGroup, .openGroup, .openGroupInbox: preconditionFailure()
}
let wrappedMessage: Data
do {
wrappedMessage = try MessageWrapper.wrap(type: kind, timestamp: message.sentTimestamp!,
senderPublicKey: senderPublicKey, base64EncodedContent: ciphertext.base64EncodedString())
} catch {
SNLog("Couldn't wrap message due to error: \(error).")
handleFailure(with: error, using: transaction)
return promise
}
// Send the result
let base64EncodedData = wrappedMessage.base64EncodedString()
let timestamp = UInt64(Int64(message.sentTimestamp!) + SnodeAPI.clockOffset)
let snodeMessage = SnodeMessage(recipient: message.recipient!, data: base64EncodedData, ttl: message.ttl, timestamp: timestamp)
SnodeAPI.sendMessage(snodeMessage).done(on: DispatchQueue.global(qos: .userInitiated)) { promises in
var isSuccess = false
let promiseCount = promises.count
var errorCount = 0
promises.forEach {
let _ = $0.done(on: DispatchQueue.global(qos: .userInitiated)) { rawResponse in
guard !isSuccess else { return } // Succeed as soon as the first promise succeeds
isSuccess = true
storage.write(with: { transaction in
let json = rawResponse as? JSON
let hash = json?["hash"] as? String
message.serverHash = hash
MessageSender.handleSuccessfulMessageSend(message, to: destination, isSyncMessage: isSyncMessage, using: transaction)
var shouldNotify = ((message is VisibleMessage || message is UnsendRequest) && !isSyncMessage)
/*
if let closedGroupControlMessage = message as? ClosedGroupControlMessage, case .new = closedGroupControlMessage.kind {
shouldNotify = true
}
*/
if shouldNotify {
let notifyPNServerJob = NotifyPNServerJob(message: snodeMessage)
if isMainAppAndActive {
JobQueue.shared.add(notifyPNServerJob, using: transaction)
seal.fulfill(())
} else {
notifyPNServerJob.execute().done(on: DispatchQueue.global(qos: .userInitiated)) {
seal.fulfill(())
}.catch(on: DispatchQueue.global(qos: .userInitiated)) { _ in
seal.fulfill(()) // Always fulfill because the notify PN server job isn't critical.
}
}
} else {
seal.fulfill(())
}
}, completion: { })
}
$0.catch(on: DispatchQueue.global(qos: .userInitiated)) { error in
errorCount += 1
guard errorCount == promiseCount else { return } // Only error out if all promises failed
storage.write(with: { transaction in
handleFailure(with: error, using: transaction as! YapDatabaseReadWriteTransaction)
}, completion: { })
}
}
}.catch(on: DispatchQueue.global(qos: .userInitiated)) { error in
SNLog("Couldn't send message due to error: \(error).")
storage.write(with: { transaction in
handleFailure(with: error, using: transaction as! YapDatabaseReadWriteTransaction)
}, completion: { })
}
// Return
return promise
}
// MARK: - Open Groups
internal static func sendToOpenGroupDestination(_ destination: Message.Destination, message: Message, using transaction: Any, dependencies: Dependencies = Dependencies()) -> Promise<Void> {
let (promise, seal) = Promise<Void>.pending()
let transaction = transaction as! YapDatabaseReadWriteTransaction
// Set the timestamp, sender and recipient
if message.sentTimestamp == nil { // Visible messages will already have their sent timestamp set
message.sentTimestamp = UInt64(dependencies.date.timeIntervalSince1970 * 1000) // Should be in ms
}
guard let threadId: String = message.threadID, let openGroup = dependencies.storage.getOpenGroup(for: threadId) else {
preconditionFailure()
}
guard let userEdKeyPair: Box.KeyPair = dependencies.storage.getUserED25519KeyPair() else { preconditionFailure() }
let server: OpenGroupAPI.Server? = dependencies.storage.getOpenGroupServer(name: openGroup.server)
if server?.capabilities.capabilities.contains(.blind) == true {
guard let serverPublicKey = dependencies.storage.getOpenGroupPublicKey(for: openGroup.server) else {
preconditionFailure()
}
guard let blindedKeyPair: Box.KeyPair = dependencies.sodium.blindedKeyPair(serverPublicKey: serverPublicKey, edKeyPair: userEdKeyPair, genericHash: dependencies.genericHash) else {
preconditionFailure()
}
message.sender = SessionId(.blinded, publicKey: blindedKeyPair.publicKey).hexString
}
else {
message.sender = SessionId(.unblinded, publicKey: userEdKeyPair.publicKey).hexString
}
switch destination {
case .contact, .closedGroup, .openGroupInbox: preconditionFailure()
case .legacyOpenGroup(let channel, let server): message.recipient = "\(server).\(channel)"
case .openGroup(let room, let server, let whisperTo, let whisperMods, _):
message.recipient = [
server,
room,
whisperTo,
(whisperTo == nil && whisperMods ? "mods" : nil)
]
.compactMap { $0 }
.joined(separator: ".")
}
// Set the failure handler (need it here already for precondition failure handling)
func handleFailure(with error: Swift.Error, using transaction: YapDatabaseReadWriteTransaction) {
MessageSender.handleFailedMessageSend(message, with: error, using: transaction)
seal.reject(error)
}
// Validate the message
guard let message = message as? VisibleMessage else {
#if DEBUG
preconditionFailure()
#else
handleFailure(with: Error.invalidMessage, using: transaction)
return promise
#endif
}
guard message.isValid else {
handleFailure(with: Error.invalidMessage, using: transaction)
return promise
}
// Attach the user's profile
guard let name = dependencies.storage.getUser()?.name else {
handleFailure(with: Error.noUsername, using: transaction)
return promise
}
if let profileKey = dependencies.storage.getUser()?.profileEncryptionKey?.keyData, let profilePictureURL = dependencies.storage.getUser()?.profilePictureURL {
message.profile = VisibleMessage.Profile(displayName: name, profileKey: profileKey, profilePictureURL: profilePictureURL)
}
else {
message.profile = VisibleMessage.Profile(displayName: name)
}
// Convert it to protobuf
guard let proto = message.toProto(using: transaction) else {
handleFailure(with: Error.protoConversionFailed, using: transaction)
return promise
}
// Serialize the protobuf
let plaintext: Data
do {
plaintext = (try proto.serializedData() as NSData).paddedMessageBody()
}
catch {
SNLog("Couldn't serialize proto due to error: \(error).")
handleFailure(with: error, using: transaction)
return promise
}
// Send the result
guard case .openGroup(let room, let server, let whisperTo, let whisperMods, let fileIds) = destination else {
preconditionFailure()
}
OpenGroupAPI
.send(
plaintext,
to: room,
on: server,
whisperTo: whisperTo,
whisperMods: whisperMods,
fileIds: fileIds,
using: dependencies
)
.done(on: DispatchQueue.global(qos: .userInitiated)) { responseInfo, data in
message.openGroupServerMessageID = UInt64(data.id)
dependencies.storage.write { transaction in
// The `posted` value is in seconds but we sent it in ms so need that for de-duping
MessageSender.handleSuccessfulMessageSend(message, to: destination, serverTimestamp: UInt64(floor(data.posted * 1000)), using: transaction)
seal.fulfill(())
}
}
.catch(on: DispatchQueue.global(qos: .userInitiated)) { error in
dependencies.storage.write { transaction in
handleFailure(with: error, using: transaction as! YapDatabaseReadWriteTransaction)
}
}
return promise
}
internal static func sendToOpenGroupInboxDestination(_ destination: Message.Destination, message: Message, using transaction: Any, dependencies: Dependencies = Dependencies()) -> Promise<Void> {
let (promise, seal) = Promise<Void>.pending()
let storage = SNMessagingKitConfiguration.shared.storage
let transaction = transaction as! YapDatabaseReadWriteTransaction
let userPublicKey = storage.getUserPublicKey()
guard case .openGroupInbox(let server, let openGroupPublicKey, let recipientBlindedPublicKey) = destination else {
preconditionFailure()
}
// Set the timestamp, sender and recipient
if message.sentTimestamp == nil { // Visible messages will already have their sent timestamp set
message.sentTimestamp = NSDate.millisecondTimestamp()
}
message.sender = userPublicKey
message.recipient = recipientBlindedPublicKey
// Set the failure handler (need it here already for precondition failure handling)
func handleFailure(with error: Swift.Error, using transaction: YapDatabaseReadWriteTransaction) {
MessageSender.handleFailedMessageSend(message, with: error, using: transaction)
seal.reject(error)
}
// Attach the user's profile if needed
if let message = message as? VisibleMessage {
guard let name = storage.getUser()?.name else {
handleFailure(with: Error.noUsername, using: transaction)
return promise
}
if let profileKey = storage.getUser()?.profileEncryptionKey?.keyData, let profilePictureURL = storage.getUser()?.profilePictureURL {
message.profile = VisibleMessage.Profile(displayName: name, profileKey: profileKey, profilePictureURL: profilePictureURL)
}
else {
message.profile = VisibleMessage.Profile(displayName: name)
}
}
// Convert it to protobuf
guard let proto = message.toProto(using: transaction) else {
handleFailure(with: Error.protoConversionFailed, using: transaction)
return promise
}
// Serialize the protobuf
let plaintext: Data
do {
plaintext = (try proto.serializedData() as NSData).paddedMessageBody()
}
catch {
SNLog("Couldn't serialize proto due to error: \(error).")
handleFailure(with: error, using: transaction)
return promise
}
// Encrypt the serialized protobuf
let ciphertext: Data
do {
ciphertext = try encryptWithSessionBlindingProtocol(plaintext, for: recipientBlindedPublicKey, openGroupPublicKey: openGroupPublicKey, using: dependencies)
}
catch {
SNLog("Couldn't encrypt message for destination: \(destination) due to error: \(error).")
handleFailure(with: error, using: transaction)
return promise
}
// Send the result
OpenGroupAPI
.send(
ciphertext,
toInboxFor: recipientBlindedPublicKey,
on: server,
using: dependencies
)
.done(on: DispatchQueue.global(qos: .userInitiated)) { responseInfo, data in
dependencies.storage.write { transaction in
MessageSender.handleSuccessfulMessageSend(message, to: destination, using: transaction)
seal.fulfill(())
}
}
.catch(on: DispatchQueue.global(qos: .userInitiated)) { error in
dependencies.storage.write { transaction in
handleFailure(with: error, using: transaction as! YapDatabaseReadWriteTransaction)
}
}
return promise
}
// MARK: Success & Failure Handling
public static func handleSuccessfulMessageSend(_ message: Message, to destination: Message.Destination, serverTimestamp: UInt64? = nil, isSyncMessage: Bool = false, using transaction: Any) {
let transaction = transaction as! YapDatabaseReadWriteTransaction
// Get the visible message if possible
if let tsMessage = TSOutgoingMessage.find(withTimestamp: message.sentTimestamp!) {
// When the sync message is successfully sent, the hash value of this TSOutgoingMessage
// will be replaced by the hash value of the sync message. Since the hash value of the
// real message has no use when we delete a message. It is OK to let it be.
tsMessage.serverHash = message.serverHash
// Track the open group server message ID and update server timestamp
if let openGroupServerMessageID = message.openGroupServerMessageID, let timestamp = serverTimestamp {
// Use server timestamp for open group messages
// Otherwise the quote messages may not be able
// to be found by the timestamp on other devices
tsMessage.updateOpenGroupServerID(openGroupServerMessageID, serverTimeStamp: timestamp)
}
// Mark the message as sent
var recipients = [ message.recipient! ]
if case .closedGroup(_) = destination, let threadID = message.threadID, // threadID should always be set at this point
let thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction), thread.isClosedGroup {
recipients = thread.groupModel.groupMemberIds
}
recipients.forEach { recipient in
tsMessage.update(withSentRecipient: recipient, wasSentByUD: true, transaction: transaction)
}
tsMessage.save(with: transaction)
NotificationCenter.default.post(name: .messageSentStatusDidChange, object: nil, userInfo: nil)
// Start the disappearing messages timer if needed
OWSDisappearingMessagesJob.shared().startAnyExpiration(for: tsMessage, expirationStartedAt: NSDate.millisecondTimestamp(), transaction: transaction)
}
// Prevent the same ExpirationTimerUpdate to be handled twice
if let message = message as? ExpirationTimerUpdate {
Storage.shared.addReceivedMessageTimestamp(message.sentTimestamp!, using: transaction)
}
// Sync the message if:
// it's a visible message or an expiration timer update
// the destination was a contact
// we didn't sync it already
let userPublicKey = getUserHexEncodedPublicKey()
if case .contact(let publicKey) = destination, !isSyncMessage {
if let message = message as? VisibleMessage { message.syncTarget = publicKey }
if let message = message as? ExpirationTimerUpdate { message.syncTarget = publicKey }
// FIXME: Make this a job
sendToSnodeDestination(.contact(publicKey: userPublicKey), message: message, using: transaction, isSyncMessage: true).retainUntilComplete()
}
}
public static func handleFailedMessageSend(_ message: Message, with error: Swift.Error, using transaction: Any) {
guard let tsMessage = TSOutgoingMessage.find(withTimestamp: message.sentTimestamp!) else { return }
// Remove the message timestamps if it fails
Storage.shared.removeReceivedMessageTimestamps([message.sentTimestamp!], using: transaction)
let transaction = transaction as! YapDatabaseReadWriteTransaction
tsMessage.update(sendingError: error, transaction: transaction)
MessageInvalidator.invalidate(tsMessage, with: transaction)
}
}