mirror of
https://github.com/lux-tel/session-ios.git
synced 2026-10-30 20:38:32 +00:00
508 lines
23 KiB
Swift
508 lines
23 KiB
Swift
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
|
//
|
|
// stringlint:disable
|
|
|
|
import Foundation
|
|
import SessionNetworkingKit
|
|
import SessionUtilitiesKit
|
|
|
|
// MARK: - Log.Category
|
|
|
|
public extension Log.Category {
|
|
static let messageSender: Log.Category = .create("MessageSender", defaultLevel: .info)
|
|
}
|
|
|
|
// MARK: - MessageSender
|
|
|
|
public final class MessageSender {
|
|
private typealias SendResponse = (message: Message, serverTimestampMs: Int64?, serverExpirationMs: Int64?)
|
|
public enum Event {
|
|
case willSend(Message, Message.Destination, interactionId: Int64?)
|
|
case success(Message, Message.Destination, interactionId: Int64?, serverTimestampMs: Int64?, serverExpirationMs: Int64?)
|
|
case failure(Message, Message.Destination, interactionId: Int64?, error: MessageSenderError)
|
|
|
|
var message: Message {
|
|
switch self {
|
|
case .willSend(let message, _, _), .success(let message, _, _, _, _),
|
|
.failure(let message, _, _, _):
|
|
return message
|
|
}
|
|
}
|
|
|
|
var destination: Message.Destination {
|
|
switch self {
|
|
case .willSend(_, let destination, _), .success(_, let destination, _, _, _),
|
|
.failure(_, let destination, _, _):
|
|
return destination
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Message Preparation
|
|
|
|
public static func preparedSend(
|
|
message: Message,
|
|
to destination: Message.Destination,
|
|
namespace: Network.SnodeAPI.Namespace?,
|
|
interactionId: Int64?,
|
|
attachments: [(attachment: Attachment, fileId: String)]?,
|
|
authMethod: AuthenticationMethod,
|
|
onEvent: ((Event) -> Void)?,
|
|
using dependencies: Dependencies
|
|
) throws -> Network.PreparedRequest<Message> {
|
|
// Common logic for all destinations
|
|
let messageSendTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs()
|
|
let updatedMessage: Message = message
|
|
|
|
// Set the message 'sentTimestamp' (Visible messages will already have their sent timestamp set)
|
|
updatedMessage.sentTimestampMs = (
|
|
updatedMessage.sentTimestampMs ??
|
|
UInt64(messageSendTimestampMs)
|
|
)
|
|
updatedMessage.sigTimestampMs = updatedMessage.sentTimestampMs
|
|
|
|
do {
|
|
let preparedRequest: Network.PreparedRequest<SendResponse>
|
|
|
|
switch destination {
|
|
case .contact, .syncMessage, .closedGroup:
|
|
preparedRequest = try preparedSendToSnodeDestination(
|
|
message: updatedMessage,
|
|
to: destination,
|
|
namespace: namespace,
|
|
interactionId: interactionId,
|
|
attachments: attachments,
|
|
messageSendTimestampMs: messageSendTimestampMs,
|
|
authMethod: authMethod,
|
|
onEvent: onEvent,
|
|
using: dependencies
|
|
)
|
|
|
|
case .openGroup:
|
|
preparedRequest = try preparedSendToOpenGroupDestination(
|
|
message: updatedMessage,
|
|
to: destination,
|
|
interactionId: interactionId,
|
|
attachments: attachments,
|
|
messageSendTimestampMs: messageSendTimestampMs,
|
|
authMethod: authMethod,
|
|
onEvent: onEvent,
|
|
using: dependencies
|
|
)
|
|
|
|
case .openGroupInbox:
|
|
preparedRequest = try preparedSendToOpenGroupInboxDestination(
|
|
message: message,
|
|
to: destination,
|
|
interactionId: interactionId,
|
|
attachments: attachments,
|
|
messageSendTimestampMs: messageSendTimestampMs,
|
|
authMethod: authMethod,
|
|
onEvent: onEvent,
|
|
using: dependencies
|
|
)
|
|
}
|
|
|
|
return preparedRequest
|
|
.handleEvents(
|
|
receiveOutput: { _, response in
|
|
onEvent?(.success(
|
|
response.message,
|
|
destination,
|
|
interactionId: interactionId,
|
|
serverTimestampMs: response.serverTimestampMs,
|
|
serverExpirationMs: response.serverExpirationMs
|
|
))
|
|
},
|
|
receiveCompletion: { result in
|
|
switch result {
|
|
case .finished: break
|
|
case .failure(let error):
|
|
onEvent?(.failure(
|
|
message,
|
|
destination,
|
|
interactionId: interactionId,
|
|
error: .other(nil, "Couldn't send message", error)
|
|
))
|
|
}
|
|
}
|
|
)
|
|
.map { _, response in response.message }
|
|
}
|
|
catch let error as MessageSenderError {
|
|
onEvent?(.failure(message, destination, interactionId: interactionId, error: error))
|
|
throw error
|
|
}
|
|
}
|
|
|
|
private static func preparedSendToSnodeDestination(
|
|
message: Message,
|
|
to destination: Message.Destination,
|
|
namespace: Network.SnodeAPI.Namespace?,
|
|
interactionId: Int64?,
|
|
attachments: [(attachment: Attachment, fileId: String)]?,
|
|
messageSendTimestampMs: Int64,
|
|
authMethod: AuthenticationMethod,
|
|
onEvent: ((Event) -> Void)?,
|
|
using dependencies: Dependencies
|
|
) throws -> Network.PreparedRequest<SendResponse> {
|
|
guard let namespace: Network.SnodeAPI.Namespace = namespace else {
|
|
throw MessageSenderError.invalidMessage
|
|
}
|
|
|
|
/// Set the sender/recipient info (needed to be valid)
|
|
///
|
|
/// **Note:** The `sentTimestamp` will differ from the `messageSendTimestampMs` as it's the time the user originally
|
|
/// sent the message whereas the `messageSendTimestamp` is the time it will be uploaded to the swarm
|
|
let userSessionId: SessionId = dependencies[cache: .general].sessionId
|
|
let sentTimestampMs: UInt64 = (message.sentTimestampMs ?? UInt64(messageSendTimestampMs))
|
|
message.sender = userSessionId.hexString
|
|
message.sentTimestampMs = sentTimestampMs
|
|
message.sigTimestampMs = sentTimestampMs
|
|
|
|
// Attach the user's profile if needed (no need to do so for 'Note to Self' or sync
|
|
// messages as they will be managed by the user config handling
|
|
switch (destination, message as? MessageWithProfile) {
|
|
case (.syncMessage, _), (_, .none): break
|
|
case (.contact(let publicKey), _) where publicKey == userSessionId.hexString: break
|
|
case (_, .some(var messageWithProfile)):
|
|
messageWithProfile.profile = dependencies
|
|
.mutate(cache: .libSession) { $0.profile(contactId: userSessionId.hexString) }
|
|
.map { profile in
|
|
VisibleMessage.VMProfile(
|
|
displayName: profile.name,
|
|
profileKey: profile.displayPictureEncryptionKey,
|
|
profilePictureUrl: profile.displayPictureUrl,
|
|
updateTimestampSeconds: profile.profileLastUpdated
|
|
)
|
|
}
|
|
}
|
|
|
|
// Convert and prepare the data for sending
|
|
let swarmPublicKey: String = {
|
|
switch destination {
|
|
case .contact(let publicKey): return publicKey
|
|
case .syncMessage: return userSessionId.hexString
|
|
case .closedGroup(let groupPublicKey): return groupPublicKey
|
|
case .openGroup, .openGroupInbox: preconditionFailure()
|
|
}
|
|
}()
|
|
let snodeMessage = SnodeMessage(
|
|
recipient: swarmPublicKey,
|
|
data: try MessageSender.encodeMessageForSending(
|
|
namespace: namespace,
|
|
destination: destination,
|
|
message: message,
|
|
attachments: attachments,
|
|
authMethod: authMethod,
|
|
using: dependencies
|
|
),
|
|
ttl: Message.getSpecifiedTTL(message: message, destination: destination, using: dependencies),
|
|
timestampMs: UInt64(messageSendTimestampMs)
|
|
)
|
|
|
|
// Perform any pre-send actions
|
|
onEvent?(.willSend(message, destination, interactionId: interactionId))
|
|
|
|
return try Network.SnodeAPI
|
|
.preparedSendMessage(
|
|
message: snodeMessage,
|
|
in: namespace,
|
|
authMethod: authMethod,
|
|
using: dependencies
|
|
)
|
|
.map { _, response in
|
|
let expirationTimestampMs: Int64 = (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + SnodeReceivedMessage.defaultExpirationMs)
|
|
let updatedMessage: Message = message
|
|
updatedMessage.serverHash = response.hash
|
|
|
|
return (updatedMessage, nil, expirationTimestampMs)
|
|
}
|
|
}
|
|
|
|
private static func preparedSendToOpenGroupDestination(
|
|
message: Message,
|
|
to destination: Message.Destination,
|
|
interactionId: Int64?,
|
|
attachments: [(attachment: Attachment, fileId: String)]?,
|
|
messageSendTimestampMs: Int64,
|
|
authMethod: AuthenticationMethod,
|
|
onEvent: ((Event) -> Void)?,
|
|
using dependencies: Dependencies
|
|
) throws -> Network.PreparedRequest<SendResponse> {
|
|
// Note: It's possible to send a message and then delete the open group you sent the message to
|
|
// which would go into this case, so rather than handling it as an invalid state we just want to
|
|
// error in a non-retryable way
|
|
guard
|
|
let message: VisibleMessage = message as? VisibleMessage,
|
|
case .community(let server, let publicKey, let hasCapabilities, let supportsBlinding, _) = authMethod.info,
|
|
case .openGroup(let roomToken, let destinationServer, let whisperTo, let whisperMods) = destination,
|
|
server == destinationServer,
|
|
let userEdKeyPair: KeyPair = dependencies[singleton: .crypto].generate(
|
|
.ed25519KeyPair(seed: dependencies[cache: .general].ed25519Seed)
|
|
)
|
|
else { throw MessageSenderError.invalidMessage }
|
|
|
|
// Set the sender/recipient info (needed to be valid)
|
|
let userSessionId: SessionId = dependencies[cache: .general].sessionId
|
|
message.sender = try {
|
|
// If the server doesn't support blinding then go with an unblinded id
|
|
guard !hasCapabilities || supportsBlinding else {
|
|
return SessionId(.unblinded, publicKey: userEdKeyPair.publicKey).hexString
|
|
}
|
|
guard
|
|
let blinded15KeyPair: KeyPair = dependencies[singleton: .crypto].generate(
|
|
.blinded15KeyPair(
|
|
serverPublicKey: publicKey,
|
|
ed25519SecretKey: userEdKeyPair.secretKey
|
|
)
|
|
)
|
|
else { throw MessageSenderError.signingFailed }
|
|
|
|
return SessionId(.blinded15, publicKey: blinded15KeyPair.publicKey).hexString
|
|
}()
|
|
message.profile = dependencies
|
|
.mutate(cache: .libSession) { cache in
|
|
cache.profile(contactId: userSessionId.hexString).map {
|
|
($0, cache.get(.checkForCommunityMessageRequests))
|
|
}
|
|
}
|
|
.map { profile, checkForCommunityMessageRequests in
|
|
VisibleMessage.VMProfile(
|
|
displayName: profile.name,
|
|
profileKey: profile.displayPictureEncryptionKey,
|
|
profilePictureUrl: profile.displayPictureUrl,
|
|
updateTimestampSeconds: profile.profileLastUpdated,
|
|
blocksCommunityMessageRequests: !checkForCommunityMessageRequests
|
|
)
|
|
}
|
|
|
|
guard !(message.profile?.displayName ?? "").isEmpty else { throw MessageSenderError.noUsername }
|
|
|
|
let plaintext: Data = try MessageSender.encodeMessageForSending(
|
|
namespace: .default,
|
|
destination: destination,
|
|
message: message,
|
|
attachments: attachments,
|
|
authMethod: authMethod,
|
|
using: dependencies
|
|
)
|
|
|
|
// Perform any pre-send actions
|
|
onEvent?(.willSend(message, destination, interactionId: interactionId))
|
|
|
|
return try Network.SOGS
|
|
.preparedSend(
|
|
plaintext: plaintext,
|
|
roomToken: roomToken,
|
|
whisperTo: whisperTo,
|
|
whisperMods: whisperMods,
|
|
fileIds: attachments?.map { $0.fileId },
|
|
authMethod: authMethod,
|
|
using: dependencies
|
|
)
|
|
.map { _, response in
|
|
let updatedMessage: Message = message
|
|
updatedMessage.openGroupServerMessageId = UInt64(response.id)
|
|
updatedMessage.sentTimestampMs = response.posted.map { UInt64(floor($0 * 1000)) }
|
|
|
|
return (updatedMessage, response.posted.map { Int64(floor($0 * 1000)) }, nil)
|
|
}
|
|
}
|
|
|
|
private static func preparedSendToOpenGroupInboxDestination(
|
|
message: Message,
|
|
to destination: Message.Destination,
|
|
interactionId: Int64?,
|
|
attachments: [(attachment: Attachment, fileId: String)]?,
|
|
messageSendTimestampMs: Int64,
|
|
authMethod: AuthenticationMethod,
|
|
onEvent: ((Event) -> Void)?,
|
|
using dependencies: Dependencies
|
|
) throws -> Network.PreparedRequest<SendResponse> {
|
|
// The `openGroupInbox` destination does not support attachments
|
|
guard
|
|
(attachments ?? []).isEmpty,
|
|
case .openGroupInbox(_, _, let recipientBlindedPublicKey) = destination
|
|
else { throw MessageSenderError.invalidMessage }
|
|
|
|
let userSessionId: SessionId = dependencies[cache: .general].sessionId
|
|
message.sender = userSessionId.hexString
|
|
|
|
// Attach the user's profile if needed
|
|
switch (message as? MessageWithProfile) {
|
|
case .some(var messageWithProfile):
|
|
messageWithProfile.profile = dependencies
|
|
.mutate(cache: .libSession) { $0.profile(contactId: userSessionId.hexString) }
|
|
.map { profile in
|
|
VisibleMessage.VMProfile(
|
|
displayName: profile.name,
|
|
profileKey: profile.displayPictureEncryptionKey,
|
|
profilePictureUrl: profile.displayPictureUrl,
|
|
updateTimestampSeconds: profile.profileLastUpdated
|
|
)
|
|
}
|
|
|
|
default: break
|
|
}
|
|
let ciphertext: Data = try MessageSender.encodeMessageForSending(
|
|
namespace: .default,
|
|
destination: destination,
|
|
message: message,
|
|
attachments: nil,
|
|
authMethod: authMethod,
|
|
using: dependencies
|
|
)
|
|
|
|
// Perform any pre-send actions
|
|
onEvent?(.willSend(message, destination, interactionId: interactionId))
|
|
|
|
return try Network.SOGS
|
|
.preparedSend(
|
|
ciphertext: ciphertext,
|
|
toInboxFor: recipientBlindedPublicKey,
|
|
authMethod: authMethod,
|
|
using: dependencies
|
|
)
|
|
.map { _, response in
|
|
let updatedMessage: Message = message
|
|
updatedMessage.openGroupServerMessageId = UInt64(response.id)
|
|
updatedMessage.sentTimestampMs = UInt64(floor(response.posted * 1000))
|
|
|
|
return (updatedMessage, Int64(floor(response.posted * 1000)), Int64(floor(response.expires * 1000)))
|
|
}
|
|
}
|
|
|
|
// MARK: - Message Wrapping
|
|
|
|
public static func encodeMessageForSending(
|
|
namespace: Network.SnodeAPI.Namespace,
|
|
destination: Message.Destination,
|
|
message: Message,
|
|
attachments: [(attachment: Attachment, fileId: String)]?,
|
|
authMethod: AuthenticationMethod,
|
|
using dependencies: Dependencies
|
|
) throws -> Data {
|
|
/// Check the message itself is valid
|
|
guard
|
|
message.isValid(isSending: true),
|
|
let sentTimestampMs: UInt64 = message.sentTimestampMs
|
|
else { throw MessageSenderError.invalidMessage }
|
|
|
|
let plaintext: Data = try {
|
|
switch (namespace, destination) {
|
|
case (.revokedRetrievableGroupMessages, _):
|
|
return try BencodeEncoder(using: dependencies).encode(message)
|
|
|
|
case (_, .openGroup), (_, .openGroupInbox):
|
|
guard
|
|
let proto: SNProtoContent = try message.toProto()?
|
|
.addingAttachmentsIfNeeded(message, attachments?.map { $0.attachment })
|
|
else { throw MessageSenderError.protoConversionFailed }
|
|
|
|
return try Result { try proto.serializedData().paddedMessageBody() }
|
|
.mapError { MessageSenderError.other(nil, "Couldn't serialize proto", $0) }
|
|
.successOrThrow()
|
|
|
|
default:
|
|
guard
|
|
let proto: SNProtoContent = try message.toProto()?
|
|
.addingAttachmentsIfNeeded(message, attachments?.map { $0.attachment })
|
|
else { throw MessageSenderError.protoConversionFailed }
|
|
|
|
return try Result { try proto.serializedData() }
|
|
.map { serialisedData -> Data in
|
|
switch destination {
|
|
case .closedGroup(let groupId) where (try? SessionId.Prefix(from: groupId)) == .group:
|
|
return serialisedData
|
|
|
|
default: return serialisedData.paddedMessageBody()
|
|
}
|
|
}
|
|
.mapError { MessageSenderError.other(nil, "Couldn't serialize proto", $0) }
|
|
.successOrThrow()
|
|
}
|
|
}()
|
|
|
|
switch (destination, namespace) {
|
|
/// Updated group messages should be wrapped _before_ encrypting
|
|
case (.closedGroup(let groupId), .groupMessages) where (try? SessionId.Prefix(from: groupId)) == .group:
|
|
let messageData: Data = try Result {
|
|
try MessageWrapper.wrap(
|
|
type: .closedGroupMessage,
|
|
timestampMs: sentTimestampMs,
|
|
content: plaintext,
|
|
wrapInWebSocketMessage: false
|
|
)
|
|
}
|
|
.mapError { MessageSenderError.other(nil, "Couldn't wrap message", $0) }
|
|
.successOrThrow()
|
|
|
|
let ciphertext: Data = try dependencies[singleton: .crypto].tryGenerate(
|
|
.ciphertextForGroupMessage(
|
|
groupSessionId: SessionId(.group, hex: groupId),
|
|
message: Array(messageData)
|
|
)
|
|
)
|
|
return ciphertext
|
|
|
|
/// `revokedRetrievableGroupMessages` should be sent in plaintext (their content has custom encryption)
|
|
case (.closedGroup(let groupId), .revokedRetrievableGroupMessages) where (try? SessionId.Prefix(from: groupId)) == .group:
|
|
return plaintext
|
|
|
|
// Standard one-to-one messages and legacy groups (which used a `05` prefix)
|
|
case (.contact, .default), (.syncMessage, _), (.closedGroup, _):
|
|
let ciphertext: Data = try dependencies[singleton: .crypto].tryGenerate(
|
|
.ciphertextWithSessionProtocol(
|
|
plaintext: plaintext,
|
|
destination: destination
|
|
)
|
|
)
|
|
|
|
return try Result {
|
|
try MessageWrapper.wrap(
|
|
type: try {
|
|
switch destination {
|
|
case .contact, .syncMessage: return .sessionMessage
|
|
case .closedGroup: return .closedGroupMessage
|
|
default: throw MessageSenderError.invalidMessage
|
|
}
|
|
}(),
|
|
timestampMs: sentTimestampMs,
|
|
senderPublicKey: {
|
|
switch destination {
|
|
case .closedGroup: return try authMethod.swarmPublicKey // Needed for Android
|
|
default: return "" // Empty for all other cases
|
|
}
|
|
}(),
|
|
content: ciphertext
|
|
)
|
|
}
|
|
.mapError { MessageSenderError.other(nil, "Couldn't wrap message", $0) }
|
|
.successOrThrow()
|
|
|
|
/// Community messages should be sent in plaintext
|
|
case (.openGroup, _): return plaintext
|
|
|
|
/// Blinded community messages have their own special encryption
|
|
case (.openGroupInbox(_, let serverPublicKey, let recipientBlindedPublicKey), _):
|
|
return try dependencies[singleton: .crypto].generateResult(
|
|
.ciphertextWithSessionBlindingProtocol(
|
|
plaintext: plaintext,
|
|
recipientBlindedId: recipientBlindedPublicKey,
|
|
serverPublicKey: serverPublicKey
|
|
)
|
|
)
|
|
.mapError { MessageSenderError.other(nil, "Couldn't encrypt message for destination: \(destination)", $0) }
|
|
.successOrThrow()
|
|
|
|
/// Config messages should be sent directly rather than via this method
|
|
case (.closedGroup(let groupId), _) where (try? SessionId.Prefix(from: groupId)) == .group:
|
|
throw MessageSenderError.invalidConfigMessageHandling
|
|
|
|
/// Config messages should be sent directly rather than via this method
|
|
case (.contact, _): throw MessageSenderError.invalidConfigMessageHandling
|
|
}
|
|
}
|
|
}
|