diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/CypherProtocol.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/CypherProtocol.xcscheme new file mode 100644 index 0000000..c8448ec --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/CypherProtocol.xcscheme @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/CypherTextKit-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/CypherTextKit-Package.xcscheme index a8ddf26..5483bc7 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/CypherTextKit-Package.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/CypherTextKit-Package.xcscheme @@ -121,7 +121,7 @@ AnyChatMessage? { - if messages.isEmpty { + @MainActor fileprivate func popNext() async throws -> AnyChatMessage? { + while messages.isEmpty { if drained { return nil } - + try await getMore(iterationSize) - return try await popNext() - } else { - return messages.removeFirst() } + + return messages.removeFirst() } - - public func dropNext() { + + @MainActor fileprivate func dropNext() { if !messages.isEmpty { messages.removeFirst() } } - - public func peekNext() async throws -> AnyChatMessage? { - if messages.isEmpty { + + @MainActor fileprivate func peekNext() async throws -> AnyChatMessage? { + while messages.isEmpty { if drained { return nil } - + try await getMore(iterationSize) - return try await peekNext() - } else { - return messages.first } + + return messages.first } - - public func getMore(_ limit: Int) async throws { + + @MainActor fileprivate func getMore(_ limit: Int) async throws { if drained { return } - + let messages = try await messenger.cachedStore.listChatMessages( inConversation: conversationId, senderId: senderId, @@ -75,31 +70,30 @@ fileprivate final class DeviceChatCursor { offsetBy: self.offset, limit: limit ) + self.latestOrder = messages.last?.order ?? self.latestOrder - self.drained = messages.count < limit self.offset += messages.count - try await self.messages.append( - contentsOf: messages.asyncMap { message in - AnyChatMessage( - target: self.target, - messenger: self.messenger, - raw: try await self.messenger.decrypt(message) - ) - }) + + for message in messages { + let raw = try messenger.decrypt(message) + self.messages.append( + AnyChatMessage( + target: target, + messenger: messenger, + raw: raw + ) + ) + } } } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) public final class AnyChatMessageCursor { let messenger: CypherMessenger private let devices: [DeviceChatCursor] let sortMode: SortMode - private final class ResultSet { - var messages = [AnyChatMessage]() - } - private init( conversationId: UUID, messenger: CypherMessenger, @@ -111,89 +105,85 @@ public final class AnyChatMessageCursor { self.sortMode = sortMode } - public func getNext() async throws -> AnyChatMessage? { - struct CursorResult { - let device: DeviceChatCursor - let message: AnyChatMessage - } - - var results = try await devices.asyncCompactMap { device -> (Date, CursorResult)? in + @MainActor public func getNext() async throws -> AnyChatMessage? { + var results = [(Date, DeviceChatCursor)]() + + for device in devices { if let message = try await device.peekNext() { - let result = CursorResult( - device: device, - message: message - ) - - return (message.sentDate ?? Date(), result) - } else { - return nil + let sentDate = message.sentDate ?? Date() + results.append((sentDate, device)) } } - + results.sort { lhs, rhs -> Bool in switch self.sortMode { case .ascending: return lhs.0 < rhs.0 - case .descending: - return lhs.0 > rhs.0 + case .descending: + return lhs.0 > rhs.0 } } - - guard let result = results.first?.1 else { + + guard let deviceCursor = results.first?.1 else { return nil } - - return try await result.device.popNext() + + return try await deviceCursor.popNext() } - private func _getMore(_ max: Int, joinedWith resultSet: ResultSet) async throws { + @MainActor private func _getMore(_ max: Int, joinedWith resultSet: inout [AnyChatMessage]) async throws { if max <= 0 { return } - - guard let message = try await getNext() else { - return + + for _ in 0.. [AnyChatMessage] { - let resultSet = ResultSet() + @MainActor public func getMore(_ max: Int) async throws -> [AnyChatMessage] { + var resultSet = [AnyChatMessage]() if max <= 500 { - resultSet.messages.reserveCapacity(max) + resultSet.reserveCapacity(max) } - try await _getMore(max, joinedWith: resultSet) - return resultSet.messages + try await _getMore(max, joinedWith: &resultSet) + return resultSet } - public static func readingConversation( + @MainActor public static func readingConversation( _ conversation: Conversation, sortMode: SortMode = .descending ) async throws -> AnyChatMessageCursor { assert(sortMode == .descending, "Unsupported ascending") - - var devices = try await conversation.memberDevices().asyncMap { device in - await DeviceChatCursor( - target: conversation.getTarget(), - conversationId: conversation.conversation.encrypted.id, - messenger: conversation.messenger, - senderId: device.props.senderId, - sortMode: sortMode + + var devices = [DeviceChatCursor]() + + for device in try await conversation.historicMemberDevices() { + devices.append( + DeviceChatCursor( + target: await conversation.getTarget(), + conversationId: conversation.conversation.encrypted.id, + messenger: conversation.messenger, + senderId: await device.props.senderId, + sortMode: sortMode + ) ) } - await devices.append( + + devices.append( DeviceChatCursor( - target: conversation.getTarget(), + target: await conversation.getTarget(), conversationId: conversation.conversation.encrypted.id, messenger: conversation.messenger, senderId: conversation.messenger.deviceIdentityId, sortMode: sortMode ) ) - + return AnyChatMessageCursor( conversationId: conversation.conversation.encrypted.id, messenger: conversation.messenger, diff --git a/Sources/CypherMessaging/Contacts/API+Contacts.swift b/Sources/CypherMessaging/Contacts/API+Contacts.swift index 1ce049c..f34fc83 100644 --- a/Sources/CypherMessaging/Contacts/API+Contacts.swift +++ b/Sources/CypherMessaging/Contacts/API+Contacts.swift @@ -3,17 +3,18 @@ import BSON import Foundation import NIO -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) public struct Contact: Identifiable, Hashable { public let messenger: CypherMessenger public let model: DecryptedModel + @CacheActor public let cache = Cache() - public func save() async throws { + @MainActor public func save() async throws { try await messenger.cachedStore.updateContact(model.encrypted) messenger.eventHandler.onUpdateContact(self) } - public var username: Username { + @MainActor public var username: Username { model.username } @@ -27,28 +28,32 @@ public struct Contact: Identifiable, Hashable { id.hash(into: &hasher) } - public func remove() async throws { + @MainActor public func remove() async throws { try await messenger.cachedStore.removeContact(model.encrypted) messenger.eventHandler.onRemoveContact(self) } - - public func refreshDevices() async throws { + + @MainActor public func refreshDevices() async throws { try await messenger._refreshDeviceIdentities(for: username) } } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) extension CypherMessenger { - public func listContacts() async throws -> [Contact] { - try await self.cachedStore.fetchContacts().asyncMap { contact in - Contact( - messenger: self, - model: try await self.decrypt(contact) + @MainActor public func listContacts() async throws -> [Contact] { + var contacts = [Contact]() + for contact in try await self.cachedStore.fetchContacts() { + contacts.append( + Contact( + messenger: self, + model: try self.decrypt(contact) + ) ) } + return contacts } - public func getContact(byUsername username: Username) async throws -> Contact? { + @MainActor public func getContact(byUsername username: Username) async throws -> Contact? { for contact in try await listContacts() { if contact.model.username == username { return contact @@ -58,7 +63,7 @@ extension CypherMessenger { return nil } - public func createContact(byUsername username: Username) async throws -> Contact { + @MainActor public func createContact(byUsername username: Username) async throws -> Contact { if username == self.username { throw CypherSDKError.badInput } @@ -86,10 +91,10 @@ extension CypherMessenger { try await self.cachedStore.createContact(contact) self.eventHandler.onCreateContact( - Contact(messenger: self, model: try await self.decrypt(contact)), + Contact(messenger: self, model: try self.decrypt(contact)), messenger: self ) - return try await Contact(messenger: self, model: self.decrypt(contact)) + return try Contact(messenger: self, model: self.decrypt(contact)) } } } diff --git a/Sources/CypherMessaging/Conversations/API+ChatMessage.swift b/Sources/CypherMessaging/Conversations/API+ChatMessage.swift index e457532..cb9027e 100644 --- a/Sources/CypherMessaging/Conversations/API+ChatMessage.swift +++ b/Sources/CypherMessaging/Conversations/API+ChatMessage.swift @@ -1,48 +1,48 @@ import Foundation -@available(macOS 12, iOS 15, *) -public struct AnyChatMessage { +@available(macOS 10.15, iOS 13, *) +public struct AnyChatMessage: Sendable { public let target: TargetConversation public let messenger: CypherMessenger public let raw: DecryptedModel - public func markAsRead() async throws { + @MainActor public func markAsRead() async throws { if raw.deliveryState == .read || sender == messenger.username { return } - _ = try await messenger._markMessage(byId: raw.encrypted.id, as: .read) + try await messenger._markMessage(byId: raw.encrypted.id, as: .read) } - public var text: String { + @MainActor public var text: String { raw.message.text } - public var metadata: Document { + @MainActor public var metadata: Document { raw.message.metadata } - public var messageType: CypherMessageType { + @MainActor public var messageType: CypherMessageType { raw.message.messageType } - public var messageSubtype: String? { + @MainActor public var messageSubtype: String? { raw.message.messageSubtype } - public var sentDate: Date? { + @MainActor public var sentDate: Date? { raw.message.sentDate } - public var destructionTimer: TimeInterval? { + @MainActor public var destructionTimer: TimeInterval? { raw.message.destructionTimer } - public var sender: Username { + @MainActor public var sender: Username { raw.senderUser } - public func remove() async throws { + @MainActor public func remove() async throws { try await messenger.cachedStore.removeChatMessage(raw.encrypted) messenger.eventHandler.onRemoveChatMessage(self) } diff --git a/Sources/CypherMessaging/Conversations/API+Conversations.swift b/Sources/CypherMessaging/Conversations/API+Conversations.swift index 8d469d4..db9f272 100644 --- a/Sources/CypherMessaging/Conversations/API+Conversations.swift +++ b/Sources/CypherMessaging/Conversations/API+Conversations.swift @@ -1,9 +1,10 @@ import CypherProtocol -import BSON +@preconcurrency import BSON import Foundation +import TaskQueue import NIO -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) extension CypherMessenger { public func getConversation(byId id: UUID) async throws -> TargetConversation.Resolved? { let conversations = try await cachedStore.fetchConversations() @@ -21,78 +22,83 @@ extension CypherMessenger { return nil } - public func getInternalConversation() async throws -> InternalConversation { - let conversations = try await cachedStore.fetchConversations() - for conversation in conversations { - let conversation = try await self.decrypt(conversation) - - if conversation.members == [self.username] { - return InternalConversation(conversation: conversation, messenger: self) + @MainActor public func getInternalConversation() async throws -> InternalConversation { + try await taskQueue.runThrowing { @MainActor in + let conversations = try await self.cachedStore.fetchConversations() + for conversation in conversations { + let conversation = try self.decrypt(conversation) + + if conversation.members == [self.username] { + return InternalConversation(conversation: conversation, messenger: self) + } } + + let conversation = try await self._createConversation( + members: [self.username], + metadata: [:] + ) + + return InternalConversation( + conversation: try self.decrypt(conversation), + messenger: self + ) } - - let conversation = try await self._createConversation( - members: [self.username], - metadata: [:] - ) - - return InternalConversation( - conversation: try await self.decrypt(conversation), - messenger: self - ) } internal func _openGroupChat(byId id: GroupChatId) async throws -> GroupChat { - if let groupChat = try await getGroupChat(byId: id) { - return groupChat - } - - let config = try await self.transport.readPublishedBlob( - byId: id.raw, - as: Signed.self - ) - - guard let config = config else { - throw CypherSDKError.unknownGroup - } - - let groupConfig = try config.blob.readWithoutVerifying() + try await taskQueue.runThrowing { @MainActor in + if let groupChat = try await self.getGroupChat(byId: id) { + return groupChat + } + + let config = try await self.transport.readPublishedBlob( + byId: id.raw, + as: Signed.self + ) - let devices = try await self._fetchDeviceIdentities(for: groupConfig.admin) - for device in devices { - if config.blob.isSigned(by: device.props.identity) { - let config = ReferencedBlob(id: config.id, blob: groupConfig) - let groupMetadata = GroupMetadata( - custom: [:], - config: config - ) - let conversation = try ConversationModel( - props: .init( - members: groupConfig.members, - metadata: BSONEncoder().encode(groupMetadata), - localOrder: 0 - ), - encryptionKey: self.databaseEncryptionKey - ) - - try await self.cachedStore.createConversation(conversation) - let chat = GroupChat( - conversation: try await self.decrypt(conversation), - messenger: self, - metadata: groupMetadata - ) - self.eventHandler.onCreateConversation(chat) - return chat + guard let config = config else { + throw CypherSDKError.unknownGroup } - } - throw CypherSDKError.invalidGroupConfig + let groupConfig = try config.blob.readWithoutVerifying() + + let devices = try await self._fetchDeviceIdentities(for: groupConfig.admin) + for device in devices { + if await config.blob.isSigned(by: device.props.identity) { + let config = ReferencedBlob(id: config.id, blob: groupConfig) + let groupMetadata = GroupMetadata( + custom: [:], + config: config + ) + let conversation = try ConversationModel( + props: .init( + members: groupConfig.members, + kickedMembers: [], + metadata: BSONEncoder().encode(groupMetadata), + localOrder: 0 + ), + encryptionKey: self.databaseEncryptionKey + ) + + try await self.cachedStore.createConversation(conversation) + let chat = GroupChat( + conversation: try self.decrypt(conversation), + messenger: self, + metadata: groupMetadata + ) + self.eventHandler.onCreateConversation(chat) + return chat + } + } + + throw CypherSDKError.invalidGroupConfig + } } - public func getGroupChat(byId id: GroupChatId) async throws -> GroupChat? { + @MainActor public func getGroupChat(byId id: GroupChatId) async throws -> GroupChat? { let conversations = try await cachedStore.fetchConversations() nextConversation: for conversation in conversations { - let conversation = try await self.decrypt(conversation) + let conversation = try self.decrypt(conversation) guard conversation.members.count >= 2, conversation.members.contains(self.username) @@ -123,10 +129,10 @@ extension CypherMessenger { return nil } - public func getPrivateChat(with otherUser: Username) async throws -> PrivateChat? { + @MainActor public func getPrivateChat(with otherUser: Username) async throws -> PrivateChat? { let conversations = try await cachedStore.fetchConversations() nextConversation: for conversation in conversations { - let conversation = try await self.decrypt(conversation) + let conversation = try self.decrypt(conversation) let members = conversation.members if @@ -159,6 +165,8 @@ extension CypherMessenger { moderators: [self.username], metadata: sharedMetadata ) + // TODO: Transparent Group Chats (no uploaded binary blobs) + // TODO: Predefined group members? let referencedBlob = try await self.transport.publishBlob(self.sign(config)) let metadata = GroupMetadata( @@ -176,7 +184,7 @@ extension CypherMessenger { metadata: metadataDocument ) - let chat = GroupChat( + let chat = GroupChat( conversation: try await self.decrypt(conversation), messenger: self, metadata: metadata @@ -192,34 +200,36 @@ extension CypherMessenger { } public func createPrivateChat(with otherUser: Username) async throws -> PrivateChat { - guard otherUser != self.username else { - throw CypherSDKError.badInput - } - - if let conversation = try await self.getPrivateChat(with: otherUser) { - return conversation - } else { - let metadata = try await self.eventHandler.createPrivateChatMetadata( - withUser: otherUser, - messenger: self - ) - - let conversation = try await self._createConversation( - members: [otherUser], - metadata: metadata - ) + try await taskQueue.runThrowing { @MainActor in + guard otherUser != self.username else { + throw CypherSDKError.badInput + } - return PrivateChat( - conversation: try await self.decrypt(conversation), - messenger: self - ) + if let conversation = try await self.getPrivateChat(with: otherUser) { + return conversation + } else { + let metadata = try await self.eventHandler.createPrivateChatMetadata( + withUser: otherUser, + messenger: self + ) + + let conversation = try await self._createConversation( + members: [otherUser], + metadata: metadata + ) + + return PrivateChat( + conversation: try self.decrypt(conversation), + messenger: self + ) + } } } - public func listPrivateChats(increasingOrder: @escaping (PrivateChat, PrivateChat) throws -> Bool) async throws -> [PrivateChat] { + @MainActor public func listPrivateChats(increasingOrder: @escaping @Sendable @MainActor (PrivateChat, PrivateChat) throws -> Bool) async throws -> [PrivateChat] { let conversations = try await cachedStore.fetchConversations() return try await conversations.asyncCompactMap { conversation -> PrivateChat? in - let conversation = try await self.decrypt(conversation) + let conversation = try self.decrypt(conversation) let members = conversation.members guard members.contains(self.username), @@ -230,13 +240,15 @@ extension CypherMessenger { } return PrivateChat(conversation: conversation, messenger: self) - }.sorted(by: increasingOrder) + }.sorted { lhs, rhs in + return try increasingOrder(lhs, rhs) + } } - public func listGroupChats(increasingOrder: @escaping (GroupChat, GroupChat) throws -> Bool) async throws -> [GroupChat] { + @MainActor public func listGroupChats(increasingOrder: @escaping @Sendable @MainActor (GroupChat, GroupChat) throws -> Bool) async throws -> [GroupChat] { let conversations = try await cachedStore.fetchConversations() return try await conversations.asyncCompactMap { conversation -> GroupChat? in - let conversation = try await self.decrypt(conversation) + let conversation = try self.decrypt(conversation) let members = conversation.members guard @@ -257,17 +269,19 @@ extension CypherMessenger { } catch { return nil } - }.sorted(by: increasingOrder) + }.sorted { lhs, rhs in + return try increasingOrder(lhs, rhs) + } } - public func listConversations( + @MainActor public func listConversations( includingInternalConversation: Bool, - increasingOrder: @escaping (TargetConversation.Resolved, TargetConversation.Resolved) throws -> Bool + increasingOrder: @escaping @Sendable @MainActor (TargetConversation.Resolved, TargetConversation.Resolved) throws -> Bool ) async throws -> [TargetConversation.Resolved] { let conversations = try await cachedStore.fetchConversations() return try await conversations.asyncCompactMap { conversation -> TargetConversation.Resolved? in - let conversation = try await self.decrypt(conversation) + let conversation = try self.decrypt(conversation) let resolved = await TargetConversation.Resolved(conversation: conversation, messenger: self) if !includingInternalConversation, case .internalChat = resolved { @@ -275,12 +289,14 @@ extension CypherMessenger { } return resolved - }.sorted(by: increasingOrder) + }.sorted { lhs, rhs in + return try increasingOrder(lhs, rhs) + } } } -@available(macOS 12, iOS 15, *) -public protocol AnyConversation { +@available(macOS 10.15, iOS 13, *) +public protocol AnyConversation: Sendable { var conversation: DecryptedModel { get } var messenger: CypherMessenger { get } var cache: Cache { get } @@ -289,7 +305,7 @@ public protocol AnyConversation { func resolveTarget() async -> TargetConversation.Resolved } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) extension AnyConversation { public func listOpenP2PConnections() async throws -> [P2PClient] { try await self.memberDevices().asyncCompactMap { device in @@ -315,21 +331,26 @@ extension AnyConversation { } // TODO: This _could_ be cached - internal func memberDevices() async throws -> [DecryptedModel] { + internal func memberDevices() async throws -> [_DecryptedModel] { try await messenger._fetchDeviceIdentities(forUsers: conversation.members) } + + internal func historicMemberDevices() async throws -> [_DecryptedModel] { + try await messenger._fetchDeviceIdentities(forUsers: conversation.allHistoricMembers) + } - public func save() async throws { + @MainActor public func save() async throws { try await messenger.cachedStore.updateConversation(conversation.encrypted) messenger.eventHandler.onUpdateConversation(self) } - private func getNextLocalOrder() async throws -> Int { + internal func getNextLocalOrder() async throws -> Int { let order = try await conversation.getNextLocalOrder() try await messenger.cachedStore.updateConversation(conversation.encrypted) return order } + @discardableResult @JobQueueActor public func sendRawMessage( type: CypherMessageType, messageSubtype: String? = nil, @@ -365,7 +386,7 @@ extension AnyConversation { metadata: Document = [:], destructionTimer: TimeInterval? = nil, sentDate: Date = Date() - ) async throws -> DecryptedModel { + ) async throws -> DecryptedModel? { let order = try await getNextLocalOrder() let message = await SingleCypherMessage( messageType: type, @@ -390,12 +411,22 @@ extension AnyConversation { ) } - internal func _saveMessage( + @CypherTextKitActor internal func _saveMessage( senderId: Int, order: Int, props: ChatMessageModel.SecureProps, remoteId: String = UUID().uuidString - ) async throws -> DecryptedModel { + ) async throws -> DecryptedModel? { + if let existingMessage = try? await messenger.cachedStore.fetchChatMessage(byRemoteId: remoteId) { + let existingMessage = try await messenger.decrypt(existingMessage) + + if await existingMessage.senderUser == props.senderUser, await existingMessage.senderDeviceId == props.senderDeviceId { + return nil + } else { + // TODO: Allow duplicate remote IDs, if they originate from different users + } + } + let chatMessage = try ChatMessageModel( conversationId: conversation.id, senderId: senderId, @@ -419,7 +450,7 @@ extension AnyConversation { return message } - internal func _sendMessage( + @MainActor internal func _sendMessage( _ message: SingleCypherMessage, to recipients: Set, pushType: PushType @@ -441,7 +472,7 @@ extension AnyConversation { case .send: () case .saveAndSend: - let chatMessage = try await _saveMessage( + guard let chatMessage = try await _saveMessage( senderId: messenger.deviceIdentityId, order: message.order, props: .init( @@ -449,25 +480,52 @@ extension AnyConversation { senderUser: self.messenger.username, senderDeviceId: self.messenger.deviceId ) - ) + ) else { + // Message was not saved, probably an error + // Let's abort to avoid confusion, while we can + return nil + } + remoteId = chatMessage.encrypted.remoteId localId = chatMessage.id _chatMessage = chatMessage } - - try await messenger._queueTask( - .sendMultiRecipientMessage( - SendMultiRecipientMessageTask( - message: CypherMessage(message: message), - // We _always_ attach a messageID so the protocol doesn't give away - // The precense of magic packets - messageId: remoteId, - recipients: recipients, - localId: localId, - pushType: pushType + + if messenger.transport.supportsMultiRecipientMessages && messenger.isOnline { + // If offline, try to leverage a potential peer-to-peer mesh + try await messenger._queueTask( + .sendMultiRecipientMessage( + SendMultiRecipientMessageTask( + message: CypherMessage(message: message), + // We _always_ attach a messageID so the protocol doesn't give away + // The precense of magic packets + messageId: remoteId, + recipients: recipients, + localId: localId, + pushType: pushType + ) ) ) - ) + } else { + var tasks = [CypherTask]() + + for device in try await memberDevices() { + await tasks.append( + .sendMessage( + SendMessageTask( + message: CypherMessage(message: message), + recipient: device.username, + recipientDeviceId: device.deviceId, + localId: localId, + pushType: pushType, + messageId: remoteId + ) + ) + ) + } + + try await messenger._queueTasks(tasks) + } try await messenger.cachedStore.updateConversation(conversation.encrypted) @@ -482,41 +540,41 @@ extension AnyConversation { } } - public func message(byRemoteId remoteId: String) async throws -> AnyChatMessage { + @MainActor public func message(byRemoteId remoteId: String) async throws -> AnyChatMessage { let message = try await self.messenger.cachedStore.fetchChatMessage(byRemoteId: remoteId) return await AnyChatMessage( target: self.getTarget(), messenger: self.messenger, - raw: try await self.messenger.decrypt(message) + raw: try self.messenger.decrypt(message) ) } - public func message(byLocalId id: UUID) async throws -> AnyChatMessage { + @MainActor public func message(byLocalId id: UUID) async throws -> AnyChatMessage { let message = try await self.messenger.cachedStore.fetchChatMessage(byId: id) return await AnyChatMessage( target: self.getTarget(), messenger: self.messenger, - raw: try await self.messenger.decrypt(message) + raw: try self.messenger.decrypt(message) ) } - public func allMessages(sortedBy sortMode: SortMode) async throws -> [AnyChatMessage] { + @MainActor public func allMessages(sortedBy sortMode: SortMode) async throws -> [AnyChatMessage] { let cursor = try await cursor(sortedBy: sortMode) return try await cursor.getMore(.max) } - - public func cursor(sortedBy sortMode: SortMode) async throws -> AnyChatMessageCursor { + + @MainActor public func cursor(sortedBy sortMode: SortMode) async throws -> AnyChatMessageCursor { try await AnyChatMessageCursor.readingConversation(self) } } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) public struct InternalConversation: AnyConversation { public let conversation: DecryptedModel public let messenger: CypherMessenger - public let cache = Cache() + @CacheActor public let cache = Cache() public func getTarget() async -> TargetConversation { return .currentUser @@ -528,18 +586,74 @@ public struct InternalConversation: AnyConversation { public func sendInternalMessage(_ message: SingleCypherMessage) async throws { // Refresh device identities - // TODO: Rate limit - _ = try await self.messenger._fetchDeviceIdentities(for: messenger.username) + if messenger.isOnline { + try await self.messenger._refreshDeviceIdentities(for: messenger.username) + } + try await messenger._writeMessage(message, to: messenger.username) } + + @JobQueueActor public func sendMagicPacket( + messageSubtype: String, + text: String, + metadata: Document = [:], + toDeviceId recipientDeviceId: DeviceId + ) async throws { + let order = try await getNextLocalOrder() + try await messenger._queueTask( + .sendMessage( + SendMessageTask( + message: CypherMessage( + message: SingleCypherMessage( + messageType: .magic, + messageSubtype: messageSubtype, + text: text, + metadata: metadata, + sentDate: Date(), + preferredPushType: PushType.none, + order: order, + target: .currentUser + ) + ), + recipient: messenger.username, + recipientDeviceId: recipientDeviceId, + localId: nil, + pushType: .none, + messageId: UUID().uuidString + ) + ) + ) + } + + @JobQueueActor public func sendMagicPacket( + messageSubtype: String, + text: String, + metadata: Document = [:] + ) async throws { + let order = try await getNextLocalOrder() + _ = try await _sendMessage( + SingleCypherMessage( + messageType: .magic, + messageSubtype: messageSubtype, + text: text, + metadata: metadata, + sentDate: Date(), + preferredPushType: PushType.none, + order: order, + target: .currentUser + ), + to: conversation.members, + pushType: .none + ) + } } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) public struct GroupChat: AnyConversation { public let conversation: DecryptedModel public let messenger: CypherMessenger internal var metadata: GroupMetadata - public let cache = Cache() + @CacheActor public let cache = Cache() public func getGroupConfig() async -> ReferencedBlob { metadata.config } @@ -554,32 +668,207 @@ public struct GroupChat: AnyConversation { public func resolveTarget() async -> TargetConversation.Resolved { .groupChat(self) } + + @MainActor public func kickMember(_ member: Username) async throws { + if !conversation.members.contains(member) { + throw CypherSDKError.notGroupMember + } + + try conversation.modifyProps { props in + props.members.remove(member) + props.kickedMembers.insert(member) + } + + try await self.save() + + // Send message to user explicitly, so that they know they're kicked + let order = try await getNextLocalOrder() + try await messenger._writeMessage( + SingleCypherMessage( + messageType: .magic, + messageSubtype: "_/group/kick", + text: member.raw, + metadata: [:], + destructionTimer: nil, + sentDate: Date(), + preferredPushType: PushType.none, + order: order, + target: await self.getTarget() + ), + to: member + ) + + // TODO: Notify all members, update uploaded config + } + + @MainActor public func inviteMember(_ member: Username) async throws { + if conversation.members.contains(member) { + return + } + + try conversation.modifyProps { props in + props.members.insert(member) + props.kickedMembers.remove(member) + } + + try await self.save() + try await sendRawMessage( + type: .magic, + messageSubtype: "_/group/invite", + text: member.raw, + preferredPushType: .none + ) + + // TODO: Notify all members, update uploaded config + } } -public struct GroupMetadata: Codable { +public struct GroupMetadata: Codable, Sendable { public private(set) var _type = "group" public var custom: Document public internal(set) var config: ReferencedBlob } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) public struct PrivateChat: AnyConversation { public let conversation: DecryptedModel public let messenger: CypherMessenger - public let cache = Cache() + @CacheActor public let cache = Cache() - public func getTarget() -> TargetConversation { - .otherUser(conversationPartner) + public func getTarget() async -> TargetConversation { + .otherUser(await conversationPartner) } public func resolveTarget() -> TargetConversation.Resolved { .privateChat(self) } - public var conversationPartner: Username { + @MainActor public var conversationPartner: Username { // PrivateChats always have exactly 2 members var members = conversation.members members.remove(messenger.username) return members.first! } + + @JobQueueActor public func sendMagicPacket( + messageSubtype: String, + text: String, + metadata: Document = [:], + toDeviceId recipientDeviceId: DeviceId + ) async throws { + let order = try await getNextLocalOrder() + try await messenger._queueTask( + .sendMessage( + SendMessageTask( + message: CypherMessage( + message: SingleCypherMessage( + messageType: .magic, + messageSubtype: messageSubtype, + text: text, + metadata: metadata, + sentDate: Date(), + preferredPushType: PushType.none, + order: order, + target: .currentUser + ) + ), + recipient: conversationPartner, + recipientDeviceId: recipientDeviceId, + localId: nil, + pushType: .none, + messageId: UUID().uuidString + ) + ) + ) + } +} + +extension AnyConversation { + @discardableResult + @JobQueueActor public func sendMagicPacketWithinCurrentUser( + messageSubtype: String? = nil, + text: String, + metadata: Document = [:], + preferredPushType: PushType + ) async throws -> AnyChatMessage? { + let order = try await getNextLocalOrder() + return try await self._sendMessage( + SingleCypherMessage( + messageType: .magic, + messageSubtype: messageSubtype, + text: text, + metadata: metadata, + destructionTimer: nil, + sentDate: Date(), + preferredPushType: PushType.none, + order: order, + target: getTarget() + ), + to: [messenger.username], + pushType: .none + ) + } + + @discardableResult + @JobQueueActor public func sendMagicPacketMessage( + messageSubtype: String? = nil, + text: String, + metadata: Document = [:], + preferredPushType: PushType + ) async throws -> AnyChatMessage? { + let order = try await getNextLocalOrder() + return try await self._sendMessage( + SingleCypherMessage( + messageType: .magic, + messageSubtype: messageSubtype, + text: text, + metadata: metadata, + destructionTimer: nil, + sentDate: Date(), + preferredPushType: PushType.none, + order: order, + target: getTarget() + ), + to: conversation.members, + pushType: .none + ) + } + + @discardableResult + @JobQueueActor public func createDeviceLocalMessage( + messageType: CypherMessageType, + messageSubtype: String? = nil, + text: String, + metadata: Document = [:] + ) async throws -> AnyChatMessage? { + let order = try await getNextLocalOrder() + let target = await getTarget() + guard let message = try await _saveMessage( + senderId: messenger.deviceIdentityId, + order: order, + props: .init( + sending: SingleCypherMessage( + messageType: messageType, + messageSubtype: messageSubtype, + text: text, + metadata: metadata, + destructionTimer: nil, + sentDate: Date(), + preferredPushType: PushType.none, + order: order, + target: target + ), + senderUser: messenger.username, + senderDeviceId: messenger.deviceId + ) + ) else { + return nil + } + + return AnyChatMessage( + target: target, + messenger: messenger, + raw: message + ) + } } diff --git a/Sources/CypherMessaging/Conversations/SingleCypherMessage.swift b/Sources/CypherMessaging/Conversations/SingleCypherMessage.swift index 4785d80..ecc9ed5 100644 --- a/Sources/CypherMessaging/Conversations/SingleCypherMessage.swift +++ b/Sources/CypherMessaging/Conversations/SingleCypherMessage.swift @@ -2,20 +2,72 @@ import BSON import CypherProtocol import Foundation -public enum PushType: String, Codable { - case none, call, message, contactRequest = "contactrequest", cancelCall = "cancelcall" +public enum PushType: RawRepresentable, Codable, Sendable { + case none + case call + case message + case contactRequest + case cancelCall + case custom(String) + + public init(rawValue: String) { + switch rawValue { + case "none": + self = .none + case "call": + self = .call + case "message": + self = .message + case "contact-request": + self = .contactRequest + case "cancel-call": + self = .cancelCall + default: + self = .custom(rawValue) + } + } + + public var rawValue: String { + switch self { + case .none: + return "none" + case .call: + return "call" + case .message: + return "message" + case .contactRequest: + return "contact-request" + case .cancelCall: + return "cancel-call" + case .custom(let string): + return string + } + } } public enum CypherMessageType: String, Codable { case text, media, magic } -@available(macOS 12, iOS 15, *) -public enum TargetConversation { +@available(macOS 10.15, iOS 13, *) +public enum TargetConversation: Sendable, Equatable { case currentUser case otherUser(Username) case groupChat(GroupChatId) + public static func ==(lhs: TargetConversation, rhs: TargetConversation) -> Bool { + switch (lhs, rhs) { + case (.currentUser, .currentUser): + return true + case (.groupChat(let lhs), .groupChat(let rhs)): + return lhs == rhs + case (.otherUser(let lhs), .otherUser(let rhs)): + return lhs == rhs + default: + return false + } + } + public func resolve( in messenger: CypherMessenger ) async throws -> TargetConversation.Resolved { @@ -37,19 +89,19 @@ public enum TargetConversation { } } - public enum Resolved: AnyConversation, Identifiable { + public enum Resolved: AnyConversation, Identifiable, Sendable { case privateChat(PrivateChat) case groupChat(GroupChat) case internalChat(InternalConversation) init?(conversation: DecryptedModel, messenger: CypherMessenger) async { - let members = conversation.members + let members = await conversation.members let username = messenger.username guard members.contains(username) else { return nil } - let metadata = conversation.metadata + let metadata = await conversation.metadata switch members.count { case ..<0: return nil @@ -100,7 +152,7 @@ public enum TargetConversation { public func getTarget() async -> TargetConversation { switch self { case .privateChat(let chat): - return chat.getTarget() + return await chat.getTarget() case .groupChat(let chat): return await chat.getTarget() case .internalChat(let chat): @@ -129,7 +181,7 @@ public enum TargetConversation { } } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) public struct ConversationTarget: Codable { // Only the fields specified here are encoded private enum CodingKeys: String, CodingKey { @@ -159,8 +211,8 @@ public struct ConversationTarget: Codable { } } -@available(macOS 12, iOS 15, *) -public struct SingleCypherMessage: Codable { +@available(macOS 10.15, iOS 13, *) +public struct SingleCypherMessage: Codable, @unchecked Sendable { // Only the fields specified here are encoded private enum CodingKeys: String, CodingKey { case messageType = "a" diff --git a/Sources/CypherMessaging/EventHandler.swift b/Sources/CypherMessaging/EventHandler.swift index e54324d..dcb0e62 100644 --- a/Sources/CypherMessaging/EventHandler.swift +++ b/Sources/CypherMessaging/EventHandler.swift @@ -6,7 +6,7 @@ public struct DeviceReference { public let deviceId: DeviceId } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) public struct ReceivedMessageContext { public let sender: DeviceReference public let messenger: CypherMessenger @@ -14,7 +14,7 @@ public struct ReceivedMessageContext { public let conversation: TargetConversation.Resolved } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) public struct SentMessageContext { public let recipients: Set public let messenger: CypherMessenger @@ -45,24 +45,26 @@ public struct ProcessMessageAction { } // TODO: Make this into a concrete type, so more events can be supported -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) public protocol CypherMessengerEventHandler { - func onRekey(withUser: Username, deviceId: DeviceId, messenger: CypherMessenger) async throws - func onDeviceRegisteryRequest(_ config: UserDeviceConfig, messenger: CypherMessenger) async throws - func onReceiveMessage(_ message: ReceivedMessageContext) async throws -> ProcessMessageAction - func onSendMessage(_ message: SentMessageContext) async throws -> SendMessageAction - func createPrivateChatMetadata(withUser otherUser: Username, messenger: CypherMessenger) async throws -> Document - func createContactMetadata(for username: Username, messenger: CypherMessenger) async throws -> Document - func onMessageChange(_ message: AnyChatMessage) - func onCreateContact(_ contact: Contact, messenger: CypherMessenger) - func onUpdateContact(_ contact: Contact) - func onCreateConversation(_ conversation: AnyConversation) - func onUpdateConversation(_ conversation: AnyConversation) - func onCreateChatMessage(_ conversation: AnyChatMessage) - func onContactIdentityChange(username: Username, messenger: CypherMessenger) - func onP2PClientOpen(_ client: P2PClient, messenger: CypherMessenger) - func onP2PClientClose(messenger: CypherMessenger) - func onRemoveContact(_ contact: Contact) - func onRemoveChatMessage(_ message: AnyChatMessage) - func onDeviceRegistery(_ deviceId: DeviceId, messenger: CypherMessenger) async throws + @MainActor func onRekey(withUser: Username, deviceId: DeviceId, messenger: CypherMessenger) async throws + @MainActor func onDeviceRegisteryRequest(_ config: UserDeviceConfig, messenger: CypherMessenger) async throws + @MainActor func onReceiveMessage(_ message: ReceivedMessageContext) async throws -> ProcessMessageAction + @MainActor func onSendMessage(_ message: SentMessageContext) async throws -> SendMessageAction + @MainActor func createPrivateChatMetadata(withUser otherUser: Username, messenger: CypherMessenger) async throws -> Document + @MainActor func createContactMetadata(for username: Username, messenger: CypherMessenger) async throws -> Document + @MainActor func onMessageChange(_ message: AnyChatMessage) + @MainActor func onCreateContact(_ contact: Contact, messenger: CypherMessenger) + @MainActor func onUpdateContact(_ contact: Contact) + @MainActor func onCreateConversation(_ conversation: AnyConversation) + @MainActor func onUpdateConversation(_ conversation: AnyConversation) + @MainActor func onCreateChatMessage(_ conversation: AnyChatMessage) + @MainActor func onContactIdentityChange(username: Username, messenger: CypherMessenger) + @MainActor func onP2PClientOpen(_ client: P2PClient, messenger: CypherMessenger) + @MainActor func onP2PClientClose(messenger: CypherMessenger) + @MainActor func onRemoveContact(_ contact: Contact) + @MainActor func onRemoveChatMessage(_ message: AnyChatMessage) + @MainActor func onDeviceRegistery(_ deviceId: DeviceId, messenger: CypherMessenger) async + @MainActor func onOtherUserDeviceRegistery(username: Username, deviceId: DeviceId, messenger: CypherMessenger) + @MainActor func onCustomConfigChange() } diff --git a/Sources/CypherMessaging/Export.swift b/Sources/CypherMessaging/Export.swift index fb26d0f..eb5200f 100644 --- a/Sources/CypherMessaging/Export.swift +++ b/Sources/CypherMessaging/Export.swift @@ -2,4 +2,3 @@ @_exported import CypherProtocol @_exported import Foundation @_exported import BSON -@_exported import _NIOConcurrency diff --git a/Sources/CypherMessaging/Helpers.swift b/Sources/CypherMessaging/Helpers.swift index 613cd3a..3df9193 100644 --- a/Sources/CypherMessaging/Helpers.swift +++ b/Sources/CypherMessaging/Helpers.swift @@ -1,7 +1,6 @@ import NIO -import _NIOConcurrency -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) extension EventLoop { public func executeAsync(_ block: @escaping @Sendable () async throws -> T) -> EventLoopFuture { let promise = self.makePromise(of: T.self) diff --git a/Sources/CypherMessaging/Jobs/CypherTask.swift b/Sources/CypherMessaging/Jobs/CypherTask.swift index 2cf8ad3..5e1e2d9 100644 --- a/Sources/CypherMessaging/Jobs/CypherTask.swift +++ b/Sources/CypherMessaging/Jobs/CypherTask.swift @@ -1,6 +1,6 @@ import CypherProtocol import Foundation -import CryptoKit +import Crypto import BSON import NIO @@ -29,7 +29,7 @@ struct HandshakeMessageTask: Codable { let messageId: String } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) struct CreateChatTask: Codable { private enum CodingKeys: String, CodingKey { case message = "a" @@ -50,7 +50,7 @@ struct CreateChatTask: Codable { let acceptedByOtherUser: Bool } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) struct AddContactTask: Codable { private enum CodingKeys: String, CodingKey { case message = "a" @@ -65,7 +65,7 @@ struct AddContactTask: Codable { let nickname: String } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) struct SendMessageTask: Codable { private enum CodingKeys: String, CodingKey { case message = "a" @@ -90,15 +90,17 @@ struct ReceiveMessageTask: Codable { case messageId = "b" case sender = "c" case deviceId = "d" + case createdAt = "e" } let message: RatchetedCypherMessage let messageId: String let sender: Username let deviceId: DeviceId + let createdAt: Date? } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) struct SendMessageDeliveryStateChangeTask: Codable { private enum CodingKeys: String, CodingKey { case localId = "a" @@ -115,37 +117,41 @@ struct SendMessageDeliveryStateChangeTask: Codable { let newState: ChatMessageModel.DeliveryState } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) struct ReceiveMessageDeliveryStateChangeTask: Codable { private enum CodingKeys: String, CodingKey { case messageId = "a" case sender = "b" case deviceId = "c" case newState = "d" + case receivedAt = "e" } let messageId: String let sender: Username let deviceId: DeviceId? let newState: ChatMessageModel.DeliveryState + let receivedAt: Date } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) struct ReceiveMultiRecipientMessageTask: Codable { private enum CodingKeys: String, CodingKey { case message = "a" case messageId = "b" case sender = "c" case deviceId = "d" + case createdAt = "e" } let message: MultiRecipientCypherMessage let messageId: String let sender: Username let deviceId: DeviceId + let createdAt: Date? } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) struct SendMultiRecipientMessageTask: Codable { private enum CodingKeys: String, CodingKey { case message = "a" @@ -207,7 +213,7 @@ public enum _CypherTaskConfig { public static var sendMessageRetryMode: TaskRetryMode? = nil } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) enum CypherTask: Codable, StoredTask { private enum CodingKeys: String, CodingKey { case key = "a" @@ -228,17 +234,21 @@ enum CypherTask: Codable, StoredTask { return mode } - return .retryAfter(30, maxAttempts: 3) + return .retryAfter(30, maxAttempts: nil) case .processMultiRecipientMessage, .processMessage: return .always case .receiveMessageDeliveryStateChangeTask: - return .retryAfter(60, maxAttempts: 5) + // Message sent by other device, but same (our) user + // We don't know what message this is, yet + return .retryAfter(60, maxAttempts: 30) } } - var requiresConnectivity: Bool { + func requiresConnectivity(on messenger: CypherMessenger) -> Bool { switch self { - case .sendMessage, .sendMultiRecipientMessage, .sendMessageDeliveryStateChangeTask: + case .sendMessage: + return !messenger.canBroadcastInMesh + case .sendMultiRecipientMessage, .sendMessageDeliveryStateChangeTask: return true case .processMessage, .processMultiRecipientMessage, .receiveMessageDeliveryStateChangeTask: return false @@ -342,9 +352,9 @@ enum CypherTask: Codable, StoredTask { func onDelayed(on messenger: CypherMessenger) async throws { switch self { case .sendMessage(let task): - _ = try await messenger._markMessage(byId: task.localId, as: .undelivered) + try await messenger._markMessage(byId: task.localId, as: .undelivered) case .sendMultiRecipientMessage(let task): - _ = try await messenger._markMessage(byId: task.localId, as: .undelivered) + try await messenger._markMessage(byId: task.localId, as: .undelivered) case .processMessage, .processMultiRecipientMessage, .sendMessageDeliveryStateChangeTask, .receiveMessageDeliveryStateChangeTask: () } @@ -362,7 +372,8 @@ enum CypherTask: Codable, StoredTask { multiRecipientContainer: nil, messageId: message.messageId, sender: message.sender, - senderDevice: message.deviceId + senderDevice: message.deviceId, + createdAt: message.createdAt ) case .sendMultiRecipientMessage(let task): debugLog("Sending message to multiple recipients", task.recipients) @@ -372,7 +383,8 @@ enum CypherTask: Codable, StoredTask { task.message, messageId: task.messageId, sender: task.sender, - senderDevice: task.deviceId + senderDevice: task.deviceId, + createdAt: task.createdAt ) case .sendMessageDeliveryStateChangeTask(let task): let result = try await messenger._markMessage(byId: task.localId, as: task.newState) @@ -402,20 +414,22 @@ enum CypherTask: Codable, StoredTask { fatalError("TODO") } case .receiveMessageDeliveryStateChangeTask(let task): - _ = try await messenger._markMessage(byRemoteId: task.messageId, updatedBy: task.sender, as: task.newState) + try await messenger._markMessage(byRemoteId: task.messageId, updatedBy: task.sender, as: task.newState) } } } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) enum TaskHelpers { fileprivate static func writeMultiRecipientMessageTask( task: SendMultiRecipientMessageTask, messenger: CypherMessenger ) async throws { + assert(messenger.transport.supportsMultiRecipientMessages) + guard messenger.authenticated == .authenticated else { debugLog("Not connected with the server") - _ = try await messenger._markMessage(byId: task.localId, as: .undelivered) + try await messenger._markMessage(byId: task.localId, as: .undelivered) throw CypherSDKError.offline } @@ -430,16 +444,16 @@ enum TaskHelpers { let index = devices.count - i - 1 let device = devices[index] - do { - if let p2pTransport = try? await messenger.getEstablishedP2PConnection( - with: device.username, - deviceId: device.deviceId - ) { + if let p2pTransport = try? await messenger.getEstablishedP2PConnection( + with: device.username, + deviceId: device.deviceId + ) { + do { try await p2pTransport.sendMessage(task.message, messageId: task.messageId) devices.remove(at: index) + } catch { + debugLog("Failed to send message over P2P connection", error) } - } catch { - debugLog("Failed to send message over P2P connection", error) } } @@ -457,12 +471,6 @@ enum TaskHelpers { task: SendMessageTask, messenger: CypherMessenger ) async throws { - guard messenger.authenticated == .authenticated else { - debugLog("Not connected with the server") - _ = try await messenger._markMessage(byId: task.localId, as: .undelivered) - throw CypherSDKError.offline - } - // Fetch the identity debugLog("Executing task: Send message") @@ -482,21 +490,44 @@ enum TaskHelpers { } } - try await messenger._writeWithRatchetEngine(ofUser: task.recipient, deviceId: task.recipientDeviceId) { ratchetEngine, rekeyState in + guard let device = try await messenger._fetchKnownDeviceIdentity(for: task.recipient, deviceId: task.recipientDeviceId) else { + // Nothing to do, device is not a member (anymore?) + return + } + + try await device._writeWithRatchetEngine(messenger: messenger) { ratchetEngine, rekeyState in let encodedMessage = try BSONEncoder().encode(task.message).makeData() let ratchetMessage = try ratchetEngine.ratchetEncrypt(encodedMessage) - let encryptedMessage = try await messenger._signRatchetMessage(ratchetMessage, rekey: rekeyState) - try await messenger.transport.sendMessage( - encryptedMessage, - toUser: task.recipient, - otherUserDeviceId: task.recipientDeviceId, - pushType: task.pushType, - messageId: task.messageId - ) + let encryptedMessage = try messenger._signRatchetMessage(ratchetMessage, rekey: rekeyState) + if messenger.isOnline { + try await messenger.transport.sendMessage( + encryptedMessage, + toUser: task.recipient, + otherUserDeviceId: task.recipientDeviceId, + pushType: task.pushType, + messageId: task.messageId + ) + } else if messenger.canBroadcastInMesh, encodedMessage.count <= P2PClient.maximumMeshPacketSize { + try await messenger._writeMessageOverMesh( + encryptedMessage, + messageId: task.messageId, + to: Peer( + username: device.username, + deviceConfig: UserDeviceConfig( + deviceId: device.deviceId, + identity: device.identity, + publicKey: device.publicKey, + isMasterDevice: device.isMasterDevice + ) + ) + ) + + // Throw an error anyways, this packet may not arrive + throw CypherSDKError.offline + } else { + throw CypherSDKError.offline + } } - - // Message may be a magic packet - _ = try? await messenger._markMessage(byId: task.localId, as: .none) } } diff --git a/Sources/CypherMessaging/Jobs/JobQueue.swift b/Sources/CypherMessaging/Jobs/JobQueue.swift index e1987e5..69b768c 100644 --- a/Sources/CypherMessaging/Jobs/JobQueue.swift +++ b/Sources/CypherMessaging/Jobs/JobQueue.swift @@ -1,24 +1,17 @@ import BSON import Crypto import Foundation -import SwiftUI import NIO -@globalActor final actor JobQueueActor { - public static let shared = JobQueueActor() - - private init() {} -} - -@available(macOS 12, iOS 15, *) -final class JobQueue: ObservableObject { +@available(macOS 10.15, iOS 13, *) +final class JobQueue { weak private(set) var messenger: CypherMessenger? private let database: CypherMessengerStore private let databaseEncryptionKey: SymmetricKey - public private(set) var runningJobs = false - public private(set) var hasOutstandingTasks = true - private var pausing: EventLoopPromise? - private var jobs: [DecryptedModel] { + @JobQueueActor public private(set) var runningJobs = false + @JobQueueActor public private(set) var hasOutstandingTasks = true + @JobQueueActor private var pausing: EventLoopPromise? + @JobQueueActor private var jobs: [_DecryptedModel] { didSet { markAsDone() } @@ -26,13 +19,18 @@ final class JobQueue: ObservableObject { private let eventLoop: EventLoop private static var taskDecoders = [TaskKey: TaskDecoder]() - init(messenger: CypherMessenger, database: CypherMessengerStore, databaseEncryptionKey: SymmetricKey) async throws { + init(messenger: CypherMessenger, database: CypherMessengerStore, databaseEncryptionKey: SymmetricKey) { self.messenger = messenger self.eventLoop = messenger.eventLoop self.database = database self.databaseEncryptionKey = databaseEncryptionKey - self.jobs = try await database.readJobs().asyncMap { job -> (Date, DecryptedModel) in - let job = try await messenger.decrypt(job) + self.jobs = [] + } + + @JobQueueActor + func loadJobs() async throws { + self.jobs = try await database.readJobs().map { job -> (Date, _DecryptedModel) in + let job = try messenger!._cachelessDecrypt(job) return (job.scheduledAt, job) }.sorted { lhs, rhs in lhs.0 < rhs.0 @@ -46,13 +44,13 @@ final class JobQueue: ObservableObject { } @JobQueueActor - func cancelJob(_ job: DecryptedModel) async throws { + func cancelJob(_ job: _DecryptedModel) async throws { // TODO: What if the job is cancelled while executing and succeeding? try await dequeueJob(job) } @JobQueueActor - func dequeueJob(_ job: DecryptedModel) async throws { + func dequeueJob(_ job: _DecryptedModel) async throws { try await database.removeJob(job.encrypted) for i in 0..(_ task: T) async throws { + @JobQueueActor + public func queueTask(_ task: T) async throws { guard let messenger = self.messenger else { throw CypherSDKError.appLocked } @@ -72,7 +71,7 @@ final class JobQueue: ObservableObject { encryptionKey: databaseEncryptionKey ) - let queuedJob = try await messenger.decrypt(job) + let queuedJob = try messenger._cachelessDecrypt(job) self.jobs.append(queuedJob) self.hasOutstandingTasks = true try await database.createJob(job) @@ -81,14 +80,60 @@ final class JobQueue: ObservableObject { } } - fileprivate var isDoneNotifications = [EventLoopPromise]() + @JobQueueActor + public func queueTasks(_ tasks: [T]) async throws { + if tasks.isEmpty { + return + } + + guard let messenger = self.messenger else { + throw CypherSDKError.appLocked + } + + let jobs = try tasks.map { task in + try JobModel( + props: .init(task: task), + encryptionKey: databaseEncryptionKey + ) + } + + var queuedJobs = [_DecryptedModel]() + + for job in jobs { + queuedJobs.append(try messenger._cachelessDecrypt(job)) + } + + do { + for job in jobs { + try await database.createJob(job) + } + } catch { + debugLog("Failed to queue all jobs of type \(T.self)") + + for job in jobs { + try? await database.removeJob(job) + } + + throw error + } + + self.jobs.append(contentsOf: queuedJobs) + self.hasOutstandingTasks = true + + if !self.runningJobs { + self.startRunningTasks() + } + } + + @JobQueueActor fileprivate var isDoneNotifications = [EventLoopPromise]() @JobQueueActor - func awaitDoneProcessing() async throws -> SynchronisationResult { -// if runningJobs { -// return .busy -// } else - if hasOutstandingTasks, let messenger = messenger { + func awaitDoneProcessing(untilEmpty: Bool) async throws -> SynchronisationResult { + if hasOutstandingTasks, !jobs.isEmpty, let messenger = messenger { + if !untilEmpty, nextJob() == nil { + return .skipped + } + let promise = messenger.eventLoop.makePromise(of: Void.self) self.isDoneNotifications.append(promise) startRunningTasks() @@ -99,19 +144,23 @@ final class JobQueue: ObservableObject { } } + @JobQueueActor func markAsDone() { - if !hasOutstandingTasks && !isDoneNotifications.isEmpty { - for notification in isDoneNotifications { - notification.succeed(()) - } - - isDoneNotifications = [] + for notification in isDoneNotifications { + notification.succeed(()) } + + isDoneNotifications = [] } @JobQueueActor func startRunningTasks() { debugLog("Starting job queue") + + if let messenger = messenger, !messenger.isOnline, !messenger.canBroadcastInMesh { + debugLog("App is offline, aborting") + return + } if runningJobs { debugLog("Job queue already running") @@ -159,6 +208,9 @@ final class JobQueue: ObservableObject { switch result { case .success, .delayed, .failed(haltExecution: false): return try await next() + case .waitingForDelays: + self.runningJobs = false + self.markAsDone() case .failed(haltExecution: true): for job in self.jobs { let task: StoredTask @@ -232,6 +284,7 @@ final class JobQueue: ObservableObject { resume() } + @JobQueueActor public func pause() async throws { let promise = eventLoop.makePromise(of: Void.self) pausing = promise @@ -244,25 +297,47 @@ final class JobQueue: ObservableObject { public enum TaskResult { case success, delayed, failed(haltExecution: Bool) + case waitingForDelays } - + @JobQueueActor - private func runNextJob() async throws -> TaskResult { + private func nextJob() -> _DecryptedModel? { debugLog("Available jobs", jobs.count) var index = 0 let initialJob = jobs[0] - - if initialJob.props.isBackgroundTask, jobs.count > 1 { + + findOtherJob: if (initialJob.props.isBackgroundTask && jobs.count > 1) || initialJob.delayedUntil != nil { + if let delayedUntil = initialJob.delayedUntil, delayedUntil <= Date() { + break findOtherJob + } + findBetterTask: for newIndex in 1.. Date() { + continue findBetterTask + } + index = newIndex break findBetterTask } } } - + let job = jobs[index] + + if let delayedUntil = job.delayedUntil, delayedUntil > Date() { + return nil + } else { + return job + } + } + + @JobQueueActor + private func runNextJob() async throws -> TaskResult { + guard let job = nextJob() else { + return .waitingForDelays + } debugLog("Running job", job.props) @@ -290,7 +365,7 @@ final class JobQueue: ObservableObject { throw CypherSDKError.appLocked } - if task.requiresConnectivity, messenger.transport.authenticated != .authenticated { + if task.requiresConnectivity(on: messenger), messenger.isOnline, messenger.authenticated != .authenticated { debugLog("Job required connectivity, but app is offline") throw CypherSDKError.offline } @@ -304,8 +379,8 @@ final class JobQueue: ObservableObject { switch task.retryMode.raw { case .retryAfter(let retryDelay, let maxAttempts): - debugLog("Delaying task for an hour") - try await job.delayExecution(retryDelay: retryDelay) + debugLog("Delaying task for \(retryDelay) seconds") + try job.delayExecution(retryDelay: retryDelay) if let maxAttempts = maxAttempts, job.attempts >= maxAttempts { try await self.cancelJob(job) diff --git a/Sources/CypherMessaging/Jobs/StoredTask.swift b/Sources/CypherMessaging/Jobs/StoredTask.swift index 2db2c19..b97bb61 100644 --- a/Sources/CypherMessaging/Jobs/StoredTask.swift +++ b/Sources/CypherMessaging/Jobs/StoredTask.swift @@ -2,19 +2,19 @@ import NIO import BSON import Foundation -@available(macOS 12, iOS 15, *) -public protocol StoredTask: Codable { +@available(macOS 10.15, iOS 13, *) +public protocol StoredTask: Codable, Sendable { var key: TaskKey { get } var isBackgroundTask: Bool { get } var retryMode: TaskRetryMode { get } var priority: TaskPriority { get } - var requiresConnectivity: Bool { get } + func requiresConnectivity(on messenger: CypherMessenger) -> Bool func execute(on messenger: CypherMessenger) async throws func onDelayed(on messenger: CypherMessenger) async throws } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) typealias TaskDecoder = (Document) throws -> StoredTask public struct TaskPriority { diff --git a/Sources/CypherMessaging/Messenger.swift b/Sources/CypherMessaging/Messenger.swift index 47125d0..83151da 100644 --- a/Sources/CypherMessaging/Messenger.swift +++ b/Sources/CypherMessaging/Messenger.swift @@ -1,20 +1,24 @@ -import BSON -import Foundation +@preconcurrency import BSON +@preconcurrency import Foundation import Crypto +import TaskQueue import NIO import CypherProtocol -public enum DeviceRegisteryMode: Int, Codable { +public enum DeviceRegisteryMode: Int, Codable, Sendable { case masterDevice, childDevice, unregistered } -@globalActor final actor CryptoActor { - public static let shared = CryptoActor() +@globalActor public final actor CypherTextKitActor { + public static let shared = CypherTextKitActor() private init() {} } -internal struct _CypherMessengerConfig: Codable { +public typealias CryptoActor = CypherTextKitActor +typealias JobQueueActor = CypherTextKitActor + +internal struct _CypherMessengerConfig: Codable, Sendable { private enum CodingKeys: String, CodingKey { case databaseEncryptionKey = "a" case deviceKeys = "b" @@ -22,6 +26,7 @@ internal struct _CypherMessengerConfig: Codable { case registeryMode = "d" case custom = "e" case deviceIdentityId = "f" + case lastKnownUserConfig = "g" } let databaseEncryptionKey: Data @@ -30,14 +35,15 @@ internal struct _CypherMessengerConfig: Codable { var registeryMode: DeviceRegisteryMode var custom: Document let deviceIdentityId: Int + var lastKnownUserConfig: UserConfig? } -enum RekeyState { +enum RekeyState: Sendable { case rekey, next } /// Provided by CypherMessenger to a factory (function) so that it can create a Transport Client to the app's servers -public struct TransportCreationRequest { +public struct TransportCreationRequest: Sendable { public let username: Username public let deviceId: DeviceId public let userConfig: UserConfig @@ -51,11 +57,16 @@ public struct TransportCreationRequest { } } +public struct ContactCard: Codable, Sendable { + public let username: Username + public let config: UserConfig +} + /// The representation of a P2PSession with another device /// /// Peer-to-peer sessions are used to communicate directly with another device /// They can rely on a custom otransport implementation, leveraging network or platform features -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) internal struct P2PSession { /// The connected device's known username /// Multiple devics may belong to the same username @@ -76,8 +87,21 @@ internal struct P2PSession { /// The transport client used by P2PClient let transport: P2PTransportClient - init( - deviceIdentity: DecryptedModel, + @CryptoActor init( + peer: Peer, + transport: P2PTransportClient, + client: P2PClient + ) { + self.username = peer.username + self.deviceId = peer.deviceId + self.publicKey = peer.publicKey + self.identity = peer.identity + self.transport = transport + self.client = client + } + + @CryptoActor init( + deviceIdentity: _DecryptedModel, transport: P2PTransportClient, client: P2PClient ) { @@ -91,10 +115,10 @@ internal struct P2PSession { } /// This actor stores all mutable shared state for a CypherMessenger instance -fileprivate final actor CypherMessengerActor { - var config: _CypherMessengerConfig - var p2pSessions = [P2PSession]() - var appPassword: String +fileprivate final class CypherMessengerActor { + @CypherTextKitActor var config: _CypherMessengerConfig + @CypherTextKitActor var p2pSessions = [P2PSession]() + @CypherTextKitActor var appPassword: String let cachedStore: _CypherMessengerStoreCache internal init(config: _CypherMessengerConfig, cachedStore: _CypherMessengerStoreCache, appPassword: String) { @@ -104,7 +128,7 @@ fileprivate final actor CypherMessengerActor { self.cachedStore = cachedStore } - func updateConfig(_ run: (inout _CypherMessengerConfig) -> ()) async throws { + @CypherTextKitActor func updateConfig(_ run: @Sendable (inout _CypherMessengerConfig) -> ()) async throws { let salt = try await self.cachedStore.readLocalDeviceSalt() let appEncryptionKey = CypherMessenger.formAppEncryptionKey(appPassword: appPassword, salt: salt) run(&config) @@ -112,17 +136,17 @@ fileprivate final actor CypherMessengerActor { try await self.cachedStore.writeLocalDeviceConfig(encryptedConfig.makeData()) } - func changeAppPassword(to appPassword: String) async throws { + @CypherTextKitActor func changeAppPassword(to appPassword: String) async throws { let salt = try await self.cachedStore.readLocalDeviceSalt() let appEncryptionKey = CypherMessenger.formAppEncryptionKey(appPassword: appPassword, salt: salt) let encryptedConfig = try Encrypted(self.config, encryptionKey: appEncryptionKey) - let data = try BSONEncoder().encode(encryptedConfig).makeData() + let data = encryptedConfig.makeData() try await self.cachedStore.writeLocalDeviceConfig(data) self.appPassword = appPassword } - var isSetupCompleted: Bool { + @CypherTextKitActor var isSetupCompleted: Bool { switch config.registeryMode { case .unregistered: return false @@ -131,11 +155,11 @@ fileprivate final actor CypherMessengerActor { } } - public func sign(_ value: T) throws -> Signed { + @CypherTextKitActor public func sign(_ value: T) throws -> Signed { try Signed(value, signedBy: config.deviceKeys.identity) } - public func writeCustomConfig(_ custom: Document) async throws { + @CypherTextKitActor public func writeCustomConfig(_ custom: Document) async throws { let salt = try await self.cachedStore.readLocalDeviceSalt() let appEncryptionKey = CypherMessenger.formAppEncryptionKey(appPassword: self.appPassword, salt: salt) var newConfig = self.config @@ -145,7 +169,7 @@ fileprivate final actor CypherMessengerActor { self.config = newConfig } - func closeP2PConnection(_ connection: P2PTransportClient) async { + @CypherTextKitActor func closeP2PConnection(_ connection: P2PTransportClient) async { debugLog("Removing P2P session from active pool") guard let index = p2pSessions.firstIndex(where: { $0.transport === connection @@ -157,7 +181,7 @@ fileprivate final actor CypherMessengerActor { return await session.client.disconnect() } - func registerSession(_ session: P2PSession) { + @CypherTextKitActor func registerSession(_ session: P2PSession) { p2pSessions.append(session) } } @@ -166,10 +190,32 @@ fileprivate final actor CypherMessengerActor { /// CypherMessenger is responsible for orchestrating end-to-end encrypted communication of any kind. /// /// CypherMessenger can be created as a singleton, but multiple clients in the same process is supported. -@available(macOS 12, iOS 15, *) -public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportClientDelegate { +@available(macOS 10.15, iOS 13, *) +public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportClientDelegate, P2PTransportFactoryDelegate, @unchecked Sendable { + @CypherTextKitActor public func createLocalDeviceAdvertisement() async throws -> P2PAdvertisement { + let advertisement = P2PAdvertisement.Advertisement( + origin: Peer( + username: username, + deviceConfig: UserDeviceConfig( + deviceId: deviceId, + identity: state.config.deviceKeys.identity.publicKey, + publicKey: state.config.deviceKeys.privateKey.publicKey, + isMasterDevice: state.config.registeryMode == .masterDevice + ) + ) + ) + + return try await P2PAdvertisement(advertisement: sign(advertisement)) + } + internal let eventLoop: EventLoop + + /// A queue of stored jobs, executing signals in sequence. Mainly used for messaging traffic which requires cryptographic sequentiality. private(set) var jobQueue: JobQueue! + + /// A thread which executes higher-level operations in sequence. Does not load/store unfinished tasks, but might inject tasks into `jobQueue` + let taskQueue = TaskQueue() + private var inactiveP2PSessionsTimeout: Int? = 30 internal let deviceIdentityId: Int fileprivate let state: CypherMessengerActor @@ -178,10 +224,24 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC internal let cachedStore: _CypherMessengerStoreCache internal let databaseEncryptionKey: SymmetricKey + /// All rediscovered usernames during this session + /// Will reset next boot + @CryptoActor internal var rediscoveredUsernames = Set() + /// The TransportClient implementation provided to CypherTextKit for this CypherMessenger to communicate through public let transport: CypherServerTransportClient + public var isOnline: Bool { transport.isConnected } public var authenticated: AuthenticationState { transport.authenticated } + public var canBroadcastInMesh: Bool { + for factory in p2pFactories { + if factory.isMeshEnabled { + return true + } + } + + return false + } /// The username that this device is registered to public let username: Username @@ -211,8 +271,9 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC cachedStore: cachedStore, appPassword: appPassword ) - self.jobQueue = try await JobQueue(messenger: self, database: self.cachedStore, databaseEncryptionKey: self.databaseEncryptionKey) + self.jobQueue = JobQueue(messenger: self, database: self.cachedStore, databaseEncryptionKey: self.databaseEncryptionKey) + try await jobQueue.loadJobs() try await self.transport.setDelegate(to: self) if transport.authenticated == .unauthenticated { @@ -223,6 +284,31 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC } await jobQueue.resume() + + for factory in p2pFactories { + factory.delegate = self + } + + // Ensure this device knows if it's registered or not + if config.registeryMode == .unregistered { + Task { @CypherTextKitActor in + let bundle = try await transport.readKeyBundle(forUsername: self.username) + try await self.updateConfig { appConfig in + appConfig.lastKnownUserConfig = bundle + } + for device in try bundle.readAndValidateDevices() { + if device.deviceId == self.deviceId { + try await self.updateConfig { $0.registeryMode = device.isMasterDevice ? .masterDevice : .childDevice } + return + } + } + } + } else if transport.isConnected, config.lastKnownUserConfig == nil { + let bundle = try await transport.readKeyBundle(forUsername: config.username) + try await self.updateConfig { appConfig in + appConfig.lastKnownUserConfig = bundle + } + } } /// Initializes and registers a new messenger. This generates a new private key. @@ -252,13 +338,19 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC Data(bytes: buffer.baseAddress!, count: buffer.count) } - let config = _CypherMessengerConfig( + let deviceKeys = DevicePrivateKeys(deviceId: deviceId) + let userConfig = try UserConfig( + mainDevice: deviceKeys, + otherDevices: [] + ) + var config = _CypherMessengerConfig( databaseEncryptionKey: databaseEncryptionKeyData, - deviceKeys: DevicePrivateKeys(deviceId: deviceId), + deviceKeys: deviceKeys, username: username, registeryMode: .unregistered, custom: [:], - deviceIdentityId: .random(in: 1 ..< .max) + deviceIdentityId: .random(in: 1 ..< .max), + lastKnownUserConfig: userConfig ) let salt = try await database.readLocalDeviceSalt() @@ -267,11 +359,6 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC salt: salt ) - let userConfig = try UserConfig( - mainDevice: config.deviceKeys, - otherDevices: [] - ) - var encryptedConfig = try Encrypted(config, encryptionKey: appEncryptionKey) let transportRequest = TransportCreationRequest( @@ -311,12 +398,18 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC try await transport.requestDeviceRegistery(metadata) return messenger } catch { - var config = config config.registeryMode = .masterDevice - encryptedConfig = try Encrypted(config, encryptionKey: appEncryptionKey) + encryptedConfig = try Encrypted(config, encryptionKey: appEncryptionKey) try await database.writeLocalDeviceConfig(encryptedConfig.makeData()) - try await transport.publishKeyBundle(userConfig) + + guard transport.isConnected || transport.supportsDelayedRegistration else { + throw CypherSDKError.cannotRegisterDeviceConfig + } + + if transport.isConnected { + try await transport.publishKeyBundle(userConfig) + } return try await CypherMessenger( appPassword: appPassword, @@ -419,14 +512,19 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC let box = try AES.GCM.SealedBox(combined: data) let encryptedConfig = Encrypted<_CypherMessengerConfig>(representing: box) let config = try encryptedConfig.decrypt(using: encryptionKey) + let transportRequest = try TransportCreationRequest( username: config.username, deviceId: config.deviceKeys.deviceId, - userConfig: UserConfig(mainDevice: config.deviceKeys, otherDevices: []), + userConfig: config.lastKnownUserConfig ?? UserConfig( + mainDevice: config.deviceKeys, + otherDevices: [] + ), signingIdentity: config.deviceKeys.identity ) let transport = try await createTransport(transportRequest) + return try await CypherMessenger( appPassword: appPassword, eventHandler: eventHandler, @@ -452,14 +550,65 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC let data = try await self.cachedStore.readLocalDeviceConfig() let box = try AES.GCM.SealedBox(combined: data) let config = Encrypted<_CypherMessengerConfig>(representing: box) - _ = try config.decrypt(using: appEncryptionKey) + try config.canDecrypt(using: appEncryptionKey) return true } catch { return false } } - internal func updateConfig(_ run: (inout _CypherMessengerConfig) -> ()) async throws { + @CryptoActor public func importContactCard( + _ card: ContactCard + ) async throws { + let knownDevices = try await _fetchKnownDeviceIdentities(for: card.username) + guard knownDevices.isEmpty else { + throw CypherSDKError.cannotImportExistingContact + } + + try await _processDeviceConfig( + card.config, + forUername: card.username, + knownDevices: [] + ) + } + + @CypherTextKitActor public func createContactCard() async throws -> ContactCard { + if state.config.registeryMode == .masterDevice { + let otherDevices = try await _fetchKnownDeviceIdentities(for: self.username) + + return ContactCard( + username: self.username, + config: try UserConfig( + mainDevice: state.config.deviceKeys, + otherDevices: otherDevices.map { device in + return UserDeviceConfig( + deviceId: device.deviceId, + identity: device.identity, + publicKey: device.publicKey, + isMasterDevice: false + ) + } + ) + ) + } else if let lastKnownUserConfig = state.config.lastKnownUserConfig { + return ContactCard( + username: self.username, + config: lastKnownUserConfig + ) + } else { + let bundle = try await transport.readKeyBundle(forUsername: self.username) + try await self.updateConfig { appConfig in + appConfig.lastKnownUserConfig = bundle + } + + return ContactCard( + username: self.username, + config: bundle + ) + } + } + + internal func updateConfig(_ run: @Sendable (inout _CypherMessengerConfig) -> ()) async throws { try await state.updateConfig(run) } @@ -476,9 +625,14 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC /// The client is responsible for transporting the UserDeviceConfig to another device, for example through a QR code. /// The master device must then call `addDevice` with this request. public func createDeviceRegisteryRequest(isMasterDevice: Bool = false) async throws -> UserDeviceConfig? { + guard await registeryMode == .unregistered else { + // Cannot register, already registered + return nil + } + if try await isRegisteredOnline() { - try await updateConfig { config in - config.registeryMode = .childDevice + try await updateConfig { appConfig in + appConfig.registeryMode = .childDevice } return nil } @@ -491,6 +645,10 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC ) } + @CypherTextKitActor public var isPasswordProtected: Bool { + !state.appPassword.isEmpty + } + public func checkSetupCompleted() async -> Bool { await state.isSetupCompleted } @@ -503,61 +661,64 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC /// Mainly used by test suties, to ensure all outstanding work is finished. /// This is handy when you're simulating communication, but want to delay assertions until this CypherMessenger has processed all outstanding work. - public func processJobQueue() async throws -> SynchronisationResult { - try await jobQueue.awaitDoneProcessing() + public func processJobQueue(untilEmpty: Bool = false) async throws -> SynchronisationResult { + try await jobQueue.awaitDoneProcessing(untilEmpty: untilEmpty) + } + + public func resumeJobQueue() async { + await jobQueue.resume() } // TODO: Make internal /// Internal API that CypherMessenger uses to read information from Transport Clients public func receiveServerEvent(_ event: CypherServerEvent) async throws { switch event.raw { - case let .multiRecipientMessageSent(message, id: messageId, byUser: sender, deviceId: deviceId): - // guard let key = message.keys.first(where: { - // $0.user == self.config.username && $0.deviceId == self.config.deviceKeys.deviceId - // }) else { - // return - // } - + case let .multiRecipientMessageSent(message, id: messageId, byUser: sender, deviceId: deviceId, createdAt: createdAt): return try await self.jobQueue.queueTask( CypherTask.processMultiRecipientMessage( ReceiveMultiRecipientMessageTask( message: message, messageId: messageId, sender: sender, - deviceId: deviceId + deviceId: deviceId, + createdAt: createdAt ) ) ) - case let .messageSent(message, id: messageId, byUser: sender, deviceId: deviceId): + case let .messageSent(message, id: messageId, byUser: sender, deviceId: deviceId, createdAt: createdAt): + // TODO: Server- or even origin-defined creation date return try await self.jobQueue.queueTask( CypherTask.processMessage( ReceiveMessageTask( message: message, messageId: messageId, sender: sender, - deviceId: deviceId + deviceId: deviceId, + createdAt: createdAt ) ) ) - case let .messageDisplayed(by: recipient, deviceId: deviceId, id: messageId): + case let .messageDisplayed(by: recipient, deviceId: deviceId, id: messageId, receivedAt: receivedAt): return try await self.jobQueue.queueTask( CypherTask.receiveMessageDeliveryStateChangeTask( ReceiveMessageDeliveryStateChangeTask( messageId: messageId, sender: recipient, deviceId: deviceId, - newState: .read + newState: .read, + receivedAt: receivedAt ) ) ) - case let .messageReceived(by: recipient, deviceId: deviceId, id: messageId): + case let .messageReceived(by: recipient, deviceId: deviceId, id: messageId, receivedAt: receivedAt): return try await self.jobQueue.queueTask( CypherTask.receiveMessageDeliveryStateChangeTask( ReceiveMessageDeliveryStateChangeTask( messageId: messageId, sender: recipient, deviceId: deviceId, - newState: .received + newState: .received, + receivedAt: receivedAt ) ) ) @@ -577,7 +738,7 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC } /// Adds a new device to this user's devices. This device can from now on receive all messages, and communicate in name of this user. - public func addDevice(_ deviceConfig: UserDeviceConfig) async throws { + @MainActor public func addDevice(_ deviceConfig: UserDeviceConfig) async throws { var config = try await transport.readKeyBundle(forUsername: self.username) guard await config.identity.data == state.config.deviceKeys.identity.publicKey.data else { throw CypherSDKError.corruptUserConfig @@ -585,9 +746,13 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC try config.addDeviceConfig(deviceConfig, signedWith: await state.config.deviceKeys.identity) try await self.transport.publishKeyBundle(config) + let uploadedConfig = config + try await self.updateConfig { appConfig in + appConfig.lastKnownUserConfig = uploadedConfig + } let internalConversation = try await self.getInternalConversation() let metadata = try BSONEncoder().encode(deviceConfig) - _ = try await self._createDeviceIdentity( + try await self._createDeviceIdentity( from: deviceConfig, forUsername: self.username ) @@ -603,11 +768,42 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC try await internalConversation.sendInternalMessage(message) - for contact in try await self.listContacts() { + for contact in try await listContacts() { try await _writeMessage(message, to: contact.username) } + } + + @CypherTextKitActor func _writeMessageOverMesh( + _ message: RatchetedCypherMessage, + messageId: String, + to recipient: Peer + ) async throws { + let origin = Peer( + username: username, + deviceConfig: UserDeviceConfig( + deviceId: deviceId, + identity: state.config.deviceKeys.identity.publicKey, + publicKey: state.config.deviceKeys.privateKey.publicKey, + isMasterDevice: state.config.registeryMode == .masterDevice + ) + ) - try await eventHandler.onDeviceRegistery(deviceConfig.deviceId, messenger: self) + let broadcastMessage = P2PBroadcast.Message( + origin: origin, + target: recipient, + messageId: messageId, + createdAt: Date(), + payload: message + ) + let signedBroadcastMessage = try await sign(broadcastMessage) + let broadcast = P2PBroadcast(hops: 16, value: signedBroadcastMessage) + + for client in listOpenP2PConnections() where client.isMeshEnabled { + Task { + // Ignore errors + try await client.sendMessage(.broadcast(broadcast)) + } + } } func _writeMessage( @@ -630,10 +826,10 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC } } - func _withCreatedMultiRecipientMessage( + @CryptoActor func _withCreatedMultiRecipientMessage( encrypting message: CypherMessage, - forDevices devices: [DecryptedModel], - run: (MultiRecipientCypherMessage) async throws -> T + forDevices devices: [_DecryptedModel], + run: @Sendable (MultiRecipientCypherMessage) async throws -> T ) async throws -> T { let key = SymmetricKey(size: .bits256) let keyData = key.withUnsafeBytes { buffer in @@ -644,13 +840,13 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC let encryptedMessage = try AES.GCM.seal(messageData, using: key).combined! var keyMessages = [MultiRecipientCypherMessage.ContainerKey]() - var rekeyDevices = [DecryptedModel]() + var rekeyDevices = [_DecryptedModel]() for device in devices { let keyMessage = try await device._writeWithRatchetEngine(messenger: self) { ratchetEngine, rekeyState -> MultiRecipientCypherMessage.ContainerKey in let ratchetMessage = try ratchetEngine.ratchetEncrypt(keyData) - return try await MultiRecipientCypherMessage.ContainerKey( + return try MultiRecipientCypherMessage.ContainerKey( user: device.props.username, deviceId: device.props.deviceId, message: self._signRatchetMessage( @@ -670,7 +866,7 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC do { let message = try MultiRecipientCypherMessage( encryptedMessage: encryptedMessage, - signWith: await state.config.deviceKeys.identity, + signWith: state.config.deviceKeys.identity, keys: keyMessages ) @@ -679,38 +875,148 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC for device in rekeyDevices { // Device was instantiated with a rekey, but the message wasn't sent // So to prevent any further confusion & issues, just reset this - try await device.updateDoubleRatchetState(to: nil) + try device.updateDoubleRatchetState(to: nil) } throw error } } - actor ModelCache { + @MainActor final class ModelCache { private var cache = [UUID: Weak]() - func getModel(ofType: M.Type, forId id: UUID) -> DecryptedModel? { + @MainActor func getModel(ofType: M.Type, forId id: UUID) -> DecryptedModel? { cache[id]?.object as? DecryptedModel } - func addModel(_ model: DecryptedModel, forId id: UUID) { + @MainActor func addModel(_ model: DecryptedModel, forId id: UUID) { cache[id] = Weak(object: model) } } - private let cache = ModelCache() + @CryptoActor final class InternalModelCache { + private var cache = [UUID: Weak]() + @CryptoActor func getModel(ofType: M.Type, forId id: UUID) -> _DecryptedModel? { + cache[id]?.object as? _DecryptedModel + } + + @CryptoActor func addModel(_ model: _DecryptedModel, forId id: UUID) { + cache[id] = Weak(object: model) + } + } + + @MainActor private let cache = ModelCache() + @CryptoActor private let _cache = InternalModelCache() + + /// Decrypts a model as provided by the database + /// It is critical to call this method for decryption for stability reasons, as CypherMessenger prevents duplicate representations of a Model from existing at the same time. + @CryptoActor internal func _cachelessDecrypt(_ model: M) throws -> _DecryptedModel { + try _DecryptedModel(model: model, encryptionKey: databaseEncryptionKey) + } + + /// Decrypts a model as provided by the database + /// It is critical to call this method for decryption for stability reasons, as CypherMessenger prevents duplicate representations of a Model from existing at the same time. + @CryptoActor func _decrypt(_ model: M) throws -> _DecryptedModel { + if let decrypted = _cache.getModel(ofType: M.self, forId: model.id) { + return decrypted + } + + let decrypted = try _DecryptedModel(model: model, encryptionKey: databaseEncryptionKey) + _cache.addModel(decrypted, forId: model.id) + return decrypted + } /// Decrypts a model as provided by the database /// It is critical to call this method for decryption for stability reasons, as CypherMessenger prevents duplicate representations of a Model from existing at the same time. - @CryptoActor public func decrypt(_ model: M) async throws -> DecryptedModel { - if let decrypted = await cache.getModel(ofType: M.self, forId: model.id) { + @MainActor public func decrypt(_ model: M) throws -> DecryptedModel { + if let decrypted = cache.getModel(ofType: M.self, forId: model.id) { return decrypted } - let decrypted = try await DecryptedModel(model: model, encryptionKey: databaseEncryptionKey) - await cache.addModel(decrypted, forId: model.id) + let decrypted = try DecryptedModel(model: model, encryptionKey: databaseEncryptionKey) + cache.addModel(decrypted, forId: model.id) return decrypted } + @CryptoActor public func decryptDirectMessageNotification( + _ message: RatchetedCypherMessage, + senderUser username: Username, + senderDeviceId deviceId: DeviceId + ) async throws -> String? { + guard + !message.rekey, + let deviceIdentity = try await self._fetchKnownDeviceIdentity(for: username, deviceId: deviceId), + let ratchetState = deviceIdentity.doubleRatchet + else { + return nil + } + + let message = try message.readAndValidate(usingIdentity: deviceIdentity.identity) + var ratchet = DoubleRatchetHKDF(state: ratchetState, configuration: doubleRatchetConfig) + guard case .success(let data) = try ratchet.ratchetDecrypt(message) else { + return nil + } + + // Don't save the ratchet state + let decryptedMessage = try BSONDecoder().decode(CypherMessage.self, from: Document(data: data)) + + switch decryptedMessage.box { + case .array(let list): + return list.first { $0.messageType == .text }?.text + case .single(let message): + if message.messageType != .text || message.text.isEmpty { + return nil + } + + return message.text + } + } + + @CryptoActor public func decryptMultiRecipientMessageNotification( + _ message: MultiRecipientCypherMessage, + senderUser username: Username, + senderDeviceId deviceId: DeviceId + ) async throws -> String? { + guard + let foundKey = message.keys.first(where: { key in + key.deviceId == self.deviceId && key.user == self.username + }), + let deviceIdentity = try await self._fetchKnownDeviceIdentity(for: username, deviceId: deviceId), + let ratchetState = deviceIdentity.doubleRatchet + else { + return nil + } + + let keyMessage = try foundKey.message.readAndValidate(usingIdentity: deviceIdentity.identity) + var ratchet = DoubleRatchetHKDF(state: ratchetState, configuration: doubleRatchetConfig) + guard case .success(let keyData) = try ratchet.ratchetDecrypt(keyMessage) else { + return nil + } + + guard keyData.count == 32 else { + throw CypherSDKError.invalidMultiRecipientKey + } + + let key = SymmetricKey(data: keyData) + + // Don't save the ratchet state + let decryptedMessage = try message.container.readAndValidate( + type: CypherMessage.self, + usingIdentity: deviceIdentity.props.identity, + decryptingWith: key + ) + + switch decryptedMessage.box { + case .array(let list): + return list.first { $0.messageType == .text }?.text + case .single(let message): + if message.messageType != .text || message.text.isEmpty { + return nil + } + + return message.text + } + } + /// Encrypts a file for storage on the disk. Can be used for any personal information, or attachments received. public func encryptLocalFile(_ data: Data) throws -> AES.GCM.SealedBox { try AES.GCM.seal(data, using: databaseEncryptionKey) @@ -723,19 +1029,19 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC /// Signs a message using this device's Private Key, allowing another client to verify the message's origin. @CryptoActor public func sign(_ value: T) async throws -> Signed { - try await state.sign(value) + try state.sign(value) } - @CryptoActor func _signRatchetMessage(_ message: RatchetMessage, rekey: RekeyState) async throws -> RatchetedCypherMessage { + @CryptoActor func _signRatchetMessage(_ message: RatchetMessage, rekey: RekeyState) throws -> RatchetedCypherMessage { return try RatchetedCypherMessage( message: message, - signWith: await state.config.deviceKeys.identity, + signWith: state.config.deviceKeys.identity, rekey: rekey == .rekey ) } - @CryptoActor fileprivate func _formSharedSecret(with publicKey: PublicKey) async throws -> SharedSecret { - try await state.config.deviceKeys.privateKey.sharedSecretFromKeyAgreement( + @CryptoActor internal func _formSharedSecret(with publicKey: PublicKey) throws -> SharedSecret { + try state.config.deviceKeys.privateKey.sharedSecretFromKeyAgreement( with: publicKey ) } @@ -763,42 +1069,51 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC /// Writes a new custom configuration file to this device public func writeCustomConfig(_ custom: Document) async throws { try await state.writeCustomConfig(custom) + await eventHandler.onCustomConfigChange() } func _writeWithRatchetEngine( ofUser username: Username, deviceId: DeviceId, - run: @escaping (inout DoubleRatchetHKDF, RekeyState) async throws -> T + run: @Sendable (inout DoubleRatchetHKDF, RekeyState) async throws -> T ) async throws -> T { let device = try await self._fetchDeviceIdentity(for: username, deviceId: deviceId) return try await device._writeWithRatchetEngine(messenger: self, run: run) } + @CypherTextKitActor public func p2pTransportDiscovered(_ connection: P2PTransportClient, remotePeer: Peer) async throws { + connection.delegate = self + try await state.registerSession( + P2PSession( + peer: remotePeer, + transport: connection, + client: P2PClient( + client: connection, + messenger: self, + closeInactiveAfter: self.inactiveP2PSessionsTimeout + ) + ) + ) + } + // TODO: Make internal /// An internal implementation that allows CypherMessenger to respond to information received by a P2PConnection - public func p2pConnection( - _ connection: P2PTransportClient, - closedWithOptions: Set - ) async throws { + public func p2pConnectionClosed(_ connection: P2PTransportClient) async throws { debugLog("P2P session disconnecting") - if closedWithOptions.contains(.reconnnectPossible) { - do { - return try await connection.reconnect() - } catch { - debugLog("Reconnecting P2P connection failed") - return await state.closeP2PConnection(connection) - } - } - return await state.closeP2PConnection(connection) } - internal func _processP2PMessage( + @CryptoActor internal func _processP2PMessage( _ message: SingleCypherMessage, remoteMessageId: String, - sender device: DecryptedModel + sender device: _DecryptedModel ) async throws { + guard let sentDate = message.sentDate, abs(sentDate.timeIntervalSinceNow) <= 60 else { + // Ignore older P2P messages + return + } + var subType = message.messageSubtype ?? "" assert(subType.hasPrefix("_/p2p/0/")) @@ -824,9 +1139,16 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC messenger: self, targetConversation: message.target, state: P2PFrameworkState( - username: device.username, - deviceId: device.deviceId, - identity: device.identity + remote: Peer( + username: device.username, + deviceConfig: UserDeviceConfig( + deviceId: device.deviceId, + identity: device.identity, + publicKey: device.publicKey, + isMasterDevice: device.isMasterDevice + ) + ), + isMeshEnabled: factory.isMeshEnabled ) ) @@ -834,7 +1156,7 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC // TODO: What is a P2P session already exists? if let client = client { client.delegate = self - await state.registerSession( + try await state.registerSession( P2PSession( deviceIdentity: device, transport: client, @@ -854,6 +1176,11 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC _ connection: P2PTransportClient, receivedMessage buffer: ByteBuffer ) async throws { + guard buffer.readableBytes <= 16_000_000 else { + // Package too large for P2P Transport + return + } + guard let session = await state.p2pSessions.first(where: { $0.transport === connection }) else { @@ -865,8 +1192,12 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC /// Lists all active P2P connections /// Especially useful for when a client wants to purge unneeded sessions - public func listOpenP2PConnections() async -> [P2PClient] { - await state.p2pSessions.map(\.client) + @CypherTextKitActor public func listOpenP2PConnections() -> [P2PClient] { + state.p2pSessions.map(\.client) + } + + @CypherTextKitActor public func hasP2PConnection(with username: Username) -> Bool { + state.p2pSessions.contains { $0.username == username } } internal func getEstablishedP2PConnection( @@ -878,20 +1209,20 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC })?.client } - internal func getEstablishedP2PConnection( - with device: DecryptedModel + @CryptoActor internal func getEstablishedP2PConnection( + with device: _DecryptedModel ) async throws -> P2PClient? { - await state.p2pSessions.first(where: { user in + state.p2pSessions.first(where: { user in user.username == device.username && user.deviceId == device.deviceId })?.client } - internal func createP2PConnection( - with device: DecryptedModel, + @CryptoActor internal func createP2PConnection( + with device: _DecryptedModel, targetConversation: TargetConversation, preferredTransportIdentifier: String? = nil ) async throws { - if await state.p2pSessions.contains(where: { user in + if state.p2pSessions.contains(where: { user in user.username == device.username && user.deviceId == device.deviceId }) { return @@ -916,9 +1247,16 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC } let state = P2PFrameworkState( - username: device.username, - deviceId: device.deviceId, - identity: device.identity + remote: Peer( + username: device.username, + deviceConfig: UserDeviceConfig( + deviceId: device.deviceId, + identity: device.identity, + publicKey: device.publicKey, + isMasterDevice: device.isMasterDevice + ) + ), + isMeshEnabled: transportFactory.isMeshEnabled ) let client = try await transportFactory.createConnection( @@ -933,7 +1271,7 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC // TODO: What if a P2P session already exists? if let client = client { client.delegate = self - await self.state.registerSession( + try await self.state.registerSession( P2PSession( deviceIdentity: device, transport: client, @@ -946,130 +1284,158 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC ) } } + + @CypherTextKitActor public var registeryMode: DeviceRegisteryMode { + self.state.config.registeryMode + } + + @CypherTextKitActor public func listDevices() async throws -> [UserDevice] { + return try await self._fetchDeviceIdentities(for: self.username).map { device in + UserDevice(device: device) + } + } + + public func renameCurrentDevice(to name: String) async throws { + let chat = try await getInternalConversation() + try await chat.sendMagicPacket( + messageSubtype: "_/devices/rename", + text: "", + metadata: BSONEncoder().encode(MagicPackets.RenameDevice(deviceId: self.deviceId, name: name)) + ) + } } -extension DecryptedModel where M == DeviceIdentityModel { +extension Contact { + @CypherTextKitActor public func listDevices() async throws -> [UserDevice] { + return try await messenger._fetchDeviceIdentities(for: username).map { device in + UserDevice(device: device) + } + } +} + +extension _DecryptedModel where M == DeviceIdentityModel { @CryptoActor func _readWithRatchetEngine( - ofUser username: Username, - deviceId: DeviceId, message: RatchetedCypherMessage, messenger: CypherMessenger - ) async throws -> Data { - try await withLock { - func rekey() async throws { - debugLog("Rekeying - removing ratchet state") - try await self.updateDoubleRatchetState(to: nil) - - try await messenger.eventHandler.onRekey( - withUser: username, - deviceId: deviceId, - messenger: messenger - ) - try await messenger.cachedStore.updateDeviceIdentity(encrypted) - try await messenger._queueTask( - .sendMessage( - SendMessageTask( - message: CypherMessage( - message: SingleCypherMessage( - messageType: .magic, - messageSubtype: "_/ignore", - text: "", - metadata: [:], - order: 0, - target: .otherUser(username) - ) - ), - recipient: username, - recipientDeviceId: deviceId, - localId: UUID(), - pushType: .none, - messageId: UUID().uuidString - ) + ) async throws -> Data? { + @CryptoActor func rekey() async throws { + debugLog("Rekeying - removing ratchet state") + try self.updateDoubleRatchetState(to: nil) + try self.setProp(at: \.lastRekey, to: Date()) + + try await messenger.eventHandler.onRekey( + withUser: username, + deviceId: deviceId, + messenger: messenger + ) + try await messenger.cachedStore.updateDeviceIdentity(encrypted) + try await messenger._queueTask( + .sendMessage( + SendMessageTask( + message: CypherMessage( + message: SingleCypherMessage( + messageType: .magic, + messageSubtype: "_/ignore", + text: "", + metadata: [:], + order: 0, + target: .otherUser(username) + ) + ), + recipient: username, + recipientDeviceId: deviceId, + localId: UUID(), + pushType: .none, + messageId: UUID().uuidString ) ) - } + ) + } + + let data: Data + var ratchet: DoubleRatchetHKDF + if let existingState = self.doubleRatchet, !message.rekey { + ratchet = DoubleRatchetHKDF( + state: existingState, + configuration: doubleRatchetConfig + ) - let data: Data - var ratchet: DoubleRatchetHKDF - if let existingState = self.doubleRatchet, !message.rekey { - ratchet = DoubleRatchetHKDF( - state: existingState, - configuration: doubleRatchetConfig - ) - - do { - let ratchetMessage = try message.readAndValidate(usingIdentity: self.identity) - data = try ratchet.ratchetDecrypt(ratchetMessage) - } catch { - try await rekey() - debugLog("Failed to read message", error) - throw error - } - } else { - guard message.rekey else { - debugLog("Couldn't read message not marked as rekey") - throw CypherSDKError.invalidHandshake - } - - do { - let secret = try await messenger._formSharedSecret(with: self.publicKey) - let symmetricKey = messenger._deriveSymmetricKey( - from: secret, - initiator: messenger.username - ) - let ratchetMessage = try message.readAndValidate(usingIdentity: self.identity) - (ratchet, data) = try DoubleRatchetHKDF.initializeRecipient( - secretKey: symmetricKey, - localPrivateKey: await messenger.state.config.deviceKeys.privateKey, - configuration: doubleRatchetConfig, - initialMessage: ratchetMessage - ) - } catch { - // TODO: Ignore incoming follow-up messages - debugLog("Failed to initialise recipient", error) - try await rekey() - throw error + do { + let ratchetMessage = try message.readAndValidate(usingIdentity: self.identity) + switch try ratchet.ratchetDecrypt(ratchetMessage) { + case .success(let d): + data = d + case .keyExpiry: + return nil } + } catch { + try await rekey() + debugLog("Failed to read message", error) + throw error + } + } else { + guard message.rekey else { + debugLog("Couldn't read message not marked as rekey") + throw CypherSDKError.invalidHandshake } - try await self.updateDoubleRatchetState(to: ratchet.state) - - try await messenger.cachedStore.updateDeviceIdentity(encrypted) - return data + do { + let secret = try messenger._formSharedSecret(with: self.publicKey) + let symmetricKey = messenger._deriveSymmetricKey( + from: secret, + initiator: messenger.username + ) + let ratchetMessage = try message.readAndValidate(usingIdentity: self.identity) + (ratchet, data) = try DoubleRatchetHKDF.initializeRecipient( + secretKey: symmetricKey, + localPrivateKey: messenger.state.config.deviceKeys.privateKey, + configuration: doubleRatchetConfig, + initialMessage: ratchetMessage + ) + } catch { + // TODO: Ignore incoming follow-up messages + debugLog("Failed to initialise recipient", error) + try await rekey() + throw error + } } + + try self.updateDoubleRatchetState(to: ratchet.state) + + try await messenger.cachedStore.updateDeviceIdentity(encrypted) + return data } @CryptoActor func _writeWithRatchetEngine( messenger: CypherMessenger, - run: @escaping (inout DoubleRatchetHKDF, RekeyState) async throws -> T + run: @Sendable @CryptoActor (inout DoubleRatchetHKDF, RekeyState) async throws -> T ) async throws -> T { - try await withLock { - var ratchet: DoubleRatchetHKDF - let rekey: Bool - - if let existingState = self.doubleRatchet { - ratchet = DoubleRatchetHKDF( - state: existingState, - configuration: doubleRatchetConfig - ) - rekey = false - } else { - let secret = try await messenger._formSharedSecret(with: publicKey) - let symmetricKey = messenger._deriveSymmetricKey(from: secret, initiator: self.username) - ratchet = try DoubleRatchetHKDF.initializeSender( - secretKey: symmetricKey, - contactingRemote: publicKey, - configuration: doubleRatchetConfig - ) - rekey = true - } - - let result = try await run(&ratchet, rekey ? .rekey : .next) - try await updateDoubleRatchetState(to: ratchet.state) - - try await messenger.cachedStore.updateDeviceIdentity(encrypted) - return result + var ratchet: DoubleRatchetHKDF + let rekey: Bool + + if let existingState = self.doubleRatchet { + ratchet = DoubleRatchetHKDF( + state: existingState, + configuration: doubleRatchetConfig + ) + rekey = false + } else { + let secret = try messenger._formSharedSecret(with: publicKey) + let symmetricKey = messenger._deriveSymmetricKey(from: secret, initiator: self.username) + try self.setProp(at: \.lastRekey, to: Date()) + ratchet = try DoubleRatchetHKDF.initializeSender( + secretKey: symmetricKey, + contactingRemote: publicKey, + configuration: doubleRatchetConfig + ) + rekey = true } + + let result = try await run(&ratchet, rekey ? .rekey : .next) + try updateDoubleRatchetState(to: ratchet.state) + + try await messenger.cachedStore.updateDeviceIdentity(encrypted) + return result } } @@ -1105,3 +1471,28 @@ fileprivate let doubleRatchetConfig = DoubleRatchetConfiguration( headerAssociatedDataGenerator: .constant("Cypher ChatMessage".data(using: .ascii)!), maxSkippedMessageKeys: 100 ) + +public struct UserDevice: Identifiable { + public var id: DeviceId { config.deviceId } + public let username: Username + public let config: UserDeviceConfig + public let name: String? + + @CypherTextKitActor internal init(device: _DecryptedModel) { + self.username = device.username + self.name = device.deviceName + self.config = UserDeviceConfig( + deviceId: device.deviceId, + identity: device.identity, + publicKey: device.publicKey, + isMasterDevice: device.isMasterDevice + ) + } +} + +enum MagicPackets { + internal struct RenameDevice: Codable { + let deviceId: DeviceId + let name: String + } +} diff --git a/Sources/CypherMessaging/P2PClient.swift b/Sources/CypherMessaging/P2PClient.swift index 931530c..8959354 100644 --- a/Sources/CypherMessaging/P2PClient.swift +++ b/Sources/CypherMessaging/P2PClient.swift @@ -1,9 +1,10 @@ +import Crypto import NIO import BSON import CypherProtocol struct Acknowledgement { - public let id: ObjectId + public let id: String fileprivate let done: EventLoopFuture public func completion() async throws { @@ -12,10 +13,10 @@ struct Acknowledgement { } fileprivate final actor AcknowledgementManager { - var acks = [ObjectId: EventLoopPromise]() + var acks = [String: EventLoopPromise]() func next(on eventLoop: EventLoop, deadline: TimeAmount = .seconds(10)) -> Acknowledgement { - let id = ObjectId() + let id = UUID().uuidString let promise = eventLoop.makePromise(of: Void.self) acks[id] = promise @@ -26,7 +27,7 @@ fileprivate final actor AcknowledgementManager { return Acknowledgement(id: id, done: promise.futureResult) } - func acknowledge(_ id: ObjectId) { + func acknowledge(_ id: String) { acks[id]?.succeed(()) } @@ -39,22 +40,39 @@ fileprivate final actor AcknowledgementManager { } } +fileprivate struct ForwardedBroadcast: Hashable { + let username: Username + let deviceId: DeviceId + let messageId: String +} + /// A peer-to-peer connection with a remote device. Used for low-latency communication with a third-party device. /// P2PClient is also used for static-length packets that are easily identified, such as status changes. /// /// You can interact with P2PClient as if you're sending and receiving cleartext messages, while the client itself applies the end-to-end encryption. -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) public final class P2PClient { + public static let maximumMeshPacketSize = 16_000 private weak var messenger: CypherMessenger? private let client: P2PTransportClient let eventLoop: EventLoop private let ack = AcknowledgementManager() internal private(set) var lastActivity = Date() + public var isMeshEnabled: Bool { client.state.isMeshEnabled } + @CypherTextKitActor private var forwardedBroadcasts = [ForwardedBroadcast]() public private(set) var remoteStatus: P2PStatusMessage? { didSet { _onStatusChange?(remoteStatus) } } + private var handshakeSent = false + private let encryptionKey: SymmetricKey + private var inboundPacketId = 0 + private var outboundPacketId = 0 + private let encryptionNonce = SymmetricKey(size: .bits256).withUnsafeBytes { buffer in + Array(buffer.bindMemory(to: UInt8.self)) + } + private var decryptionKey: SymmetricKey? private var task: RepeatedTask? /// The username of the remote device to which this P2PClient is connected @@ -66,29 +84,37 @@ public final class P2PClient { public var isConnected: Bool { client.connected == .connected } - private var _onStatusChange: ((P2PStatusMessage?) -> ())? - private var _onDisconnect: (() -> ())? + private var _onStatusChange: (@Sendable (P2PStatusMessage?) -> ())? + private var _onDisconnect: (@Sendable () -> ())? /// The provided closure is called when the client disconnects - public func onDisconnect(perform: @escaping () -> ()) { + public func onDisconnect(perform: @escaping @Sendable () -> ()) { _onDisconnect = perform } /// The provided closure is called when the remote device indicates it's status has changed - public func onStatusChange(perform: @escaping (P2PStatusMessage?) -> ()) { + public func onStatusChange(perform: @escaping @Sendable (P2PStatusMessage?) -> ()) { _onStatusChange = perform } - internal init( + @CypherTextKitActor internal init( client: P2PTransportClient, messenger: CypherMessenger, closeInactiveAfter seconds: Int? - ) { + ) async throws { self.messenger = messenger self.client = client self.eventLoop = messenger.eventLoop - messenger.eventHandler.onP2PClientOpen(self, messenger: messenger) + let sharedSecret = try messenger._formSharedSecret(with: client.state.remote.publicKey) + encryptionKey = sharedSecret.hkdfDerivedSymmetricKey( + using: SHA512.self, + salt: encryptionNonce, + sharedInfo: "p2p".data(using: .utf8)!, + outputByteCount: 32 + ) + + await messenger.eventHandler.onP2PClientOpen(self, messenger: messenger) if let seconds = seconds { assert(seconds > 0 && seconds <= 3600, "Invalid inactivity timer") @@ -114,32 +140,65 @@ public final class P2PClient { } /// This function is called whenever a P2PTransportClient receives information from a remote device - internal func receiveBuffer( + @CypherTextKitActor internal func receiveBuffer( _ buffer: ByteBuffer ) async throws { guard let messenger = messenger else { return } + guard buffer.readableBytes <= 100_000 else { + // Ignore packets over 100KB + // While BSON is a fast parser that does no unnecessary copies + // We should still protect memory + return + } + + // TODO: DOS prevention against repeating malicious peers + self.lastActivity = Date() let document = Document(buffer: buffer) - let ratchetMessage = try BSONDecoder().decode(RatchetedCypherMessage.self, from: document) + let packet = try BSONDecoder().decode(P2PMessage.self, from: document) - let device = try await messenger._fetchDeviceIdentity(for: username, deviceId: deviceId) - let data = try await device._readWithRatchetEngine( - ofUser: client.state.username, - deviceId: client.state.deviceId, - message: ratchetMessage, - messenger: messenger - ) - let messageBson = Document(data: data) - let message = try BSONDecoder().decode(P2PMessage.self, from: messageBson) + switch packet { + case .encrypted(let encryptedMessage): + if let decryptionKey = decryptionKey { + let message = try encryptedMessage.decrypt(using: decryptionKey) + + guard message.id == inboundPacketId else { + // Replay attack? + return await disconnect() + } + + inboundPacketId += 1 + return try await receiveDecryptedPayload(message) + } else { + debugLog("Cannot decrypt message without key") + } + case .handshake(let handshake): + let sharedSecret = try messenger._formSharedSecret(with: client.state.remote.publicKey) + decryptionKey = sharedSecret.hkdfDerivedSymmetricKey( + using: SHA512.self, + salt: handshake.nonce, + sharedInfo: "p2p".data(using: .utf8)!, + outputByteCount: 32 + ) + inboundPacketId = 0 + } + } + + @CypherTextKitActor private func receiveDecryptedPayload( + _ message: P2PPayload + ) async throws { + guard let messenger = messenger else { + return + } switch message.box { case .status(let status): self.remoteStatus = status - case .sendMessage(let message): + case .message(let message): let device = try await messenger._fetchDeviceIdentity(for: username, deviceId: deviceId) let messageId = message.id switch message.message.box { @@ -160,13 +219,115 @@ public final class P2PClient { } case .ack: await ack.acknowledge(message.ack) + case .broadcast(var broadcast): + guard + // Ignore broadcasts on non-mesh clients + client.state.isMeshEnabled, + // 2KB packaging overhead over payload + broadcast.value.value.makeByteBuffer().readableBytes <= P2PClient.maximumMeshPacketSize + 2_000, + // Prevent infinite hopping + broadcast.hops <= 64 + else { + // Ignore broadcast + return + } + + broadcast.hops -= 1 + let signedBroadcast = broadcast.value + let unverifiedBroadcast = try signedBroadcast.readWithoutVerifying() + let claimedOrigin = unverifiedBroadcast.origin + let destination = unverifiedBroadcast.target + + // TODO: Prevent Denial-of-Service spammers from spamming us through the mesh + + let forwardedBroadcast = ForwardedBroadcast( + username: claimedOrigin.username, + deviceId: claimedOrigin.deviceId, + messageId: unverifiedBroadcast.messageId + ) + + guard !forwardedBroadcasts.contains(forwardedBroadcast) else { + // Don't re-process the same message + return + } + + forwardedBroadcasts.append(forwardedBroadcast) + + if forwardedBroadcasts.count > 200 { + // Clean up historic broadcasts list in bulk + // To clean up memory, and to allow re-broadcasts in case things changed + forwardedBroadcasts.removeFirst(100) + } + + let broadcastMessage: P2PBroadcast.Message + let knownDevices = try await messenger._fetchKnownDeviceIdentities(for: claimedOrigin.username) + let verified: Bool + + if let knownPeer = knownDevices.first(where: { $0.deviceId == claimedOrigin.deviceId }) { + // Device is known, accept! + broadcastMessage = try signedBroadcast.readAndVerifySignature(signedBy: knownPeer.identity) + verified = true + } else if knownDevices.isEmpty { + // User is not known, so assume the device is plausible although unverified + broadcastMessage = try signedBroadcast.readAndVerifySignature(signedBy: claimedOrigin.identity) + verified = false + } else { + // User is known, but device is not known. Abort, might be malicious + return + } + + if destination.username == messenger.username && destination.deviceId == messenger.deviceId { + // It's for us! + try await messenger._queueTask( + .processMessage( + ReceiveMessageTask( + message: broadcastMessage.payload, + messageId: broadcastMessage.messageId, + sender: claimedOrigin.username, + deviceId: claimedOrigin.deviceId, + createdAt: broadcastMessage.createdAt + ) + ) + ) + + // TODO: Broadcast ack back? How does the client know it's arrived? + } + + let p2pConnections = messenger.listOpenP2PConnections() + + if let p2pConnection = p2pConnections.first(where: { + $0.client.state.username == destination.username && $0.client.state.deviceId == destination.deviceId + }) { + // We know who to send it to! + return try await p2pConnection.sendMessage(.broadcast(broadcast)) + } + + if broadcast.hops <= 0 { + // End of reach, let's stop here + return + } + + // Try to pass it on to other peers, maybe they can help + for p2pConnection in p2pConnections { + if p2pConnection.username == client.state.username && p2pConnection.deviceId == client.state.deviceId { + // Ignore, since we're just cascading it back to the origin of this broadcast + } else { + let broadcast = broadcast + Task { + // Ignore (connection) errors, we've tried our best + try await p2pConnection.sendMessage(.broadcast(broadcast)) + } + } + } + + // TODO: Forward to/broadcast to the internet services? } } func sendMessage(_ message: CypherMessage, messageId: String) async throws { debugLog("Routing message over P2P", message) try await sendMessage( - .sendMessage( + .message( P2PSendMessage( message: message, id: messageId @@ -192,38 +353,48 @@ public final class P2PClient { /// Sends a message (cleartext) to a remote peer /// This function then applies end-to-end encryption before transmitting the information over the internet. - private func sendMessage(_ box: P2PMessage.Box) async throws { - guard let messenger = self.messenger else { - throw CypherSDKError.offline + internal func sendMessage(_ box: P2PPayload.Box) async throws { + if !handshakeSent { + handshakeSent = true + try await sendHandshake() } self.lastActivity = Date() let ack = await ack.next(on: eventLoop) - let message = P2PMessage( + let payload = P2PPayload( box: box, - ack: ack.id + ack: ack.id, + packetId: outboundPacketId ) + outboundPacketId += 1 - try await messenger._writeWithRatchetEngine( - ofUser: client.state.username, - deviceId: client.state.deviceId - ) { ratchetEngine, rekey in - let messageBson = try BSONEncoder().encode(message) - let encryptedMessage = try ratchetEngine.ratchetEncrypt(messageBson.makeData()) - let signedMessage = try await messenger._signRatchetMessage(encryptedMessage, rekey: rekey) - let signedMessageBson = try BSONEncoder().encode(signedMessage) - - try await self.client.sendMessage(signedMessageBson.makeByteBuffer()) - } + let message = try P2PMessage.encrypted( + Encrypted( + payload, + encryptionKey: encryptionKey + ) + ) + + let signedMessageBson = try BSONEncoder().encode(message) + try await self.client.sendMessage(signedMessageBson.makeByteBuffer()) try await ack.completion() } + /// Sends a message (cleartext) to a remote peer + /// This function then applies end-to-end encryption before transmitting the information over the internet. + private func sendHandshake() async throws { + let message = P2PMessage.handshake(P2PHandshake(nonce: encryptionNonce)) + let signedMessageBson = try BSONEncoder().encode(message) + outboundPacketId = 0 + try await self.client.sendMessage(signedMessageBson.makeByteBuffer()) + } + /// Disconnects the transport layer public func disconnect() async { if let messenger = self.messenger { - messenger.eventHandler.onP2PClientClose(messenger: messenger) + await messenger.eventHandler.onP2PClientClose(messenger: messenger) } await client.disconnect() diff --git a/Sources/CypherMessaging/P2PTransport/IPv6+TCP/IPv6TCPP2PTransport.swift b/Sources/CypherMessaging/P2PTransport/IPv6+TCP/IPv6TCPP2PTransport.swift index 95ff3cb..3046cb4 100644 --- a/Sources/CypherMessaging/P2PTransport/IPv6+TCP/IPv6TCPP2PTransport.swift +++ b/Sources/CypherMessaging/P2PTransport/IPv6+TCP/IPv6TCPP2PTransport.swift @@ -6,7 +6,7 @@ public enum IPv6TCPP2PError: Error { case reconnectFailed, timeout, socketCreationFailed } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) private final class BufferHandler: ChannelInboundHandler { typealias InboundIn = ByteBuffer private weak var client: IPv6TCPP2PTransportClient? @@ -24,7 +24,7 @@ private final class BufferHandler: ChannelInboundHandler { if let delegate = client.delegate { context.eventLoop.executeAsync { - _ = try await delegate.p2pConnection(client, receivedMessage: buffer) + try await delegate.p2pConnection(client, receivedMessage: buffer) }.whenFailure { error in context.fireErrorCaught(error) } @@ -32,7 +32,7 @@ private final class BufferHandler: ChannelInboundHandler { } } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) final class IPv6TCPP2PTransportClient: P2PTransportClient { public weak var delegate: P2PTransportClientDelegate? public private(set) var connected = ConnectionState.connected @@ -44,10 +44,6 @@ final class IPv6TCPP2PTransportClient: P2PTransportClient { self.channel = channel } - public func reconnect() async throws { - throw IPv6TCPP2PError.reconnectFailed - } - public func disconnect() async { do { try await channel.close() @@ -101,9 +97,11 @@ public struct StunConfig { } } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) public final class IPv6TCPP2PTransportClientFactory: P2PTransportClientFactory { public let transportLayerIdentifier = "_ipv6-tcp" + public let isMeshEnabled = false + public weak var delegate: P2PTransportFactoryDelegate? let eventLoop = MultiThreadedEventLoopGroup(numberOfThreads: 1).next() let stun: StunConfig? diff --git a/Sources/CypherMessaging/Primitives/Cache.swift b/Sources/CypherMessaging/Primitives/Cache.swift index 964a842..4fe902c 100644 --- a/Sources/CypherMessaging/Primitives/Cache.swift +++ b/Sources/CypherMessaging/Primitives/Cache.swift @@ -5,26 +5,29 @@ // Created by Joannis Orlandos on 19/04/2021. // -import SwiftUI +typealias CacheActor = MainActor public protocol CacheKey { associatedtype Value } -public final class Cache { +@CacheActor public final class Cache: Sendable { internal init() {} private var values = [ObjectIdentifier: Any]() + @CacheActor public func read(_ key: Key.Type) -> Key.Value? { values[ObjectIdentifier(key)] as? Key.Value } + @CacheActor public func setValue(_ value: Key.Value, forKey key: Key.Type) { values[ObjectIdentifier(key)] = value } - public func readOrCreateValue(forKey key: Key.Type, resolve: () -> Key.Value) -> Key.Value { + @CacheActor + public func readOrCreateValue(forKey key: Key.Type, resolve: @Sendable () -> Key.Value) -> Key.Value { if let value = read(key) { return value } else { diff --git a/Sources/CypherMessaging/Primitives/GroupChat.swift b/Sources/CypherMessaging/Primitives/GroupChat.swift index 79db926..f07ea8a 100644 --- a/Sources/CypherMessaging/Primitives/GroupChat.swift +++ b/Sources/CypherMessaging/Primitives/GroupChat.swift @@ -1,9 +1,9 @@ -import BSON +@preconcurrency import BSON import CypherProtocol -import Foundation +@preconcurrency import Foundation /// A string wrapper so that Strings are handled in a case-insensitive manner and to prevent mistakes like provding the wring String in a function -public struct GroupChatId: CustomStringConvertible, Identifiable, Codable, Hashable, Equatable, Comparable, ExpressibleByStringLiteral { +public struct GroupChatId: CustomStringConvertible, Identifiable, Codable, Hashable, Equatable, Comparable, ExpressibleByStringLiteral, Sendable { public let raw: String public static func ==(lhs: GroupChatId, rhs: GroupChatId) -> Bool { @@ -38,7 +38,7 @@ public struct GroupChatId: CustomStringConvertible, Identifiable, Codable, Hasha public var id: String { raw } } -public struct ReferencedBlob: Codable { +public struct ReferencedBlob: Codable, Sendable { public let id: String public var blob: T @@ -48,13 +48,14 @@ public struct ReferencedBlob: Codable { } } -public struct GroupChatConfig: Codable { +public struct GroupChatConfig: Codable, Sendable { private enum CodingKeys: String, CodingKey { case members = "a" case createdAt = "b" case moderators = "c" case metadata = "d" case admin = "e" + case kickedMembers = "f" } public private(set) var members: Set @@ -62,6 +63,7 @@ public struct GroupChatConfig: Codable { public private(set) var moderators: Set public var metadata: Document public let admin: Username + public private(set) var kickedMembers: Set public init( admin: Username, @@ -77,14 +79,18 @@ public struct GroupChatConfig: Codable { self.moderators = moderators self.createdAt = Date() self.metadata = metadata + self.kickedMembers = [] } public mutating func addMember(_ username: Username) { members.insert(username) + kickedMembers.remove(username) } public mutating func removeMember(_ username: Username) { - members.remove(username) + if let member = members.remove(username) { + kickedMembers.insert(member) + } moderators.remove(username) } diff --git a/Sources/CypherMessaging/Primitives/UserKeys.swift b/Sources/CypherMessaging/Primitives/UserKeys.swift index 3d706ba..3449f0c 100644 --- a/Sources/CypherMessaging/Primitives/UserKeys.swift +++ b/Sources/CypherMessaging/Primitives/UserKeys.swift @@ -1,8 +1,9 @@ import Foundation import CypherProtocol +import Crypto /// The user's private keys are only stored on the user's main device -public struct DevicePrivateKeys: Codable { +public struct DevicePrivateKeys: Codable, Sendable { private enum CodingKeys: String, CodingKey { case deviceId = "a" case identity = "b" @@ -21,13 +22,13 @@ public struct DevicePrivateKeys: Codable { } } -public struct UserConfig: Codable { +public struct UserConfig: Codable, @unchecked Sendable { private enum CodingKeys: String, CodingKey { case identity = "a" case devices = "b" } - /// Identity is a public key used to validate messages sidned by `identity` + /// Identity is a public key used to validate messages signed by `identity` /// This is the main device's identity, which when trusted verified all other devices' validity public let identity: PublicSigningKey @@ -57,6 +58,10 @@ public struct UserConfig: Codable { _ config: UserDeviceConfig, signedWith identity: PrivateSigningKey ) throws { + guard self.identity.data == identity.publicKey.data else { + throw CypherSDKError.invalidSignature + } + var devices = try readAndValidateDevices() if devices.contains(where: { $0.deviceId == config.deviceId }) { diff --git a/Sources/CypherMessaging/Protocol/CypherMessage.swift b/Sources/CypherMessaging/Protocol/CypherMessage.swift index b0721fe..6663474 100644 --- a/Sources/CypherMessaging/Protocol/CypherMessage.swift +++ b/Sources/CypherMessaging/Protocol/CypherMessage.swift @@ -1,16 +1,16 @@ -@available(macOS 12, iOS 15, *) -struct CypherMessage: Codable { +@available(macOS 10.15, iOS 13, *) +struct CypherMessage: Codable, Sendable { private enum CodingKeys: String, CodingKey { case type = "a" case box = "b" } - private enum WrappedType: Int, Codable { + private enum WrappedType: Int, Codable, Sendable { case single = 0 case array = 1 } - internal enum Wrapped { + internal enum Wrapped: Sendable { case single(SingleCypherMessage) case array([SingleCypherMessage]) } diff --git a/Sources/CypherMessaging/Protocol/P2PMessage.swift b/Sources/CypherMessaging/Protocol/P2PMessage.swift index 85656b7..a65eb49 100644 --- a/Sources/CypherMessaging/Protocol/P2PMessage.swift +++ b/Sources/CypherMessaging/Protocol/P2PMessage.swift @@ -6,62 +6,96 @@ public struct P2PSendMessage: Codable { let id: String } -public struct P2PMessage: Codable { +public struct P2PBroadcast: Codable { + // TODO: Shorter CodingKeys + public struct Message: Codable { + // TODO: Shorter CodingKeys + let origin: Peer + let target: Peer + let messageId: String + let createdAt: Date + let payload: RatchetedCypherMessage + } + + var hops: Int + let value: Signed +} + +public struct P2PHandshake: Codable { + let nonce: [UInt8] +} + +public enum P2PMessage: Codable { + case encrypted(Encrypted) + case handshake(P2PHandshake) +} + +public struct P2PPayload: Codable { private enum CodingKeys: String, CodingKey { case type = "a" case box = "b" case ack = "c" + case id = "d" } private enum MessageType: Int, Codable { case status = 0 case sendMessage = 1 case ack = 2 + case broadcast = 3 } internal enum Box { case status(P2PStatusMessage) - case sendMessage(P2PSendMessage) + case message(P2PSendMessage) case ack + case broadcast(P2PBroadcast) } let box: Box - let ack: ObjectId + let ack: String + let id: Int - init(box: Box, ack: ObjectId) { + init(box: Box, ack: String, packetId: Int) { self.box = box self.ack = ack + self.id = packetId } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(ack, forKey: .ack) + try container.encode(id, forKey: .id) switch box { case .status(let status): try container.encode(MessageType.status, forKey: .type) try container.encode(status, forKey: .box) - try container.encode(ack, forKey: .ack) - case .sendMessage(let message): + case .message(let message): try container.encode(MessageType.sendMessage, forKey: .type) try container.encode(message, forKey: .box) - try container.encode(ack, forKey: .ack) case .ack: try container.encode(MessageType.ack, forKey: .type) - try container.encode(ack, forKey: .ack) + case .broadcast(let message): + try container.encode(MessageType.broadcast, forKey: .type) + try container.encode(message, forKey: .box) } } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - self.ack = try container.decode(ObjectId.self, forKey: .ack) + self.ack = try container.decode(String.self, forKey: .ack) + self.id = try container.decode(Int.self, forKey: .id) switch try container.decode(MessageType.self, forKey: .type) { case .status: self.box = try .status(container.decode(P2PStatusMessage.self, forKey: .box)) case .sendMessage: - self.box = try .sendMessage(container.decode(P2PSendMessage.self, forKey: .box)) + self.box = try .message(container.decode(P2PSendMessage.self, forKey: .box)) case .ack: self.box = .ack + case .broadcast: + self.box = try .broadcast(container.decode(P2PBroadcast.self, forKey: .box)) } } } diff --git a/Sources/CypherMessaging/Store/CypherMessengerStore.swift b/Sources/CypherMessaging/Store/CypherMessengerStore.swift index ec96eba..8db9cf4 100644 --- a/Sources/CypherMessaging/Store/CypherMessengerStore.swift +++ b/Sources/CypherMessaging/Store/CypherMessengerStore.swift @@ -1,11 +1,11 @@ import Foundation import NIO -public enum SortMode { +public enum SortMode: Sendable { case ascending, descending } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) public protocol CypherMessengerStore { func fetchContacts() async throws -> [ContactModel] func createContact(_ contact: ContactModel) async throws diff --git a/Sources/CypherMessaging/Store/_CypherMessengerStoreCache.swift b/Sources/CypherMessaging/Store/_CypherMessengerStoreCache.swift index 4af2b4b..925784b 100644 --- a/Sources/CypherMessaging/Store/_CypherMessengerStoreCache.swift +++ b/Sources/CypherMessaging/Store/_CypherMessengerStoreCache.swift @@ -12,7 +12,7 @@ struct Weak { private init() {} } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) internal final class _CypherMessengerStoreCache: CypherMessengerStore { internal let base: CypherMessengerStore @@ -41,6 +41,12 @@ internal final class _CypherMessengerStoreCache: CypherMessengerStore { return contacts } else { let contacts = try await self.base.fetchContacts() + + // If two fetches ran in parallel, the first one wins + if let contacts = self.contacts { + return contacts + } + self.contacts = contacts return contacts } @@ -63,6 +69,7 @@ internal final class _CypherMessengerStoreCache: CypherMessengerStore { return try await base.updateContact(contact) } + // TODO: Rename to `byLocalId: UUID` @CypherCacheActor func fetchChatMessage(byId messageId: UUID) async throws -> ChatMessageModel { if let message = self.messages[messageId] { return message @@ -73,6 +80,7 @@ internal final class _CypherMessengerStoreCache: CypherMessengerStore { } } + // TODO: Deprecate, remoteID should be unique per senderID, not globally @CypherCacheActor func fetchChatMessage(byRemoteId remoteId: String) async throws -> ChatMessageModel { let message = try await self.base.fetchChatMessage(byRemoteId: remoteId) if let cachedMessage = self.messages[message.id] { @@ -87,6 +95,12 @@ internal final class _CypherMessengerStoreCache: CypherMessengerStore { return conversations } else { let conversations = try await self.base.fetchConversations() + + // If two `fetchConversations` ran in parallel, the first one wins + if let conversations = self.conversations { + return conversations + } + self.conversations = conversations return conversations } @@ -114,6 +128,12 @@ internal final class _CypherMessengerStoreCache: CypherMessengerStore { return deviceIdentities } else { let deviceIdentities = try await self.base.fetchDeviceIdentities() + + // If two fetches ran in parallel, the first one wins + if let deviceIdentities = self.deviceIdentities { + return deviceIdentities + } + self.deviceIdentities = deviceIdentities return deviceIdentities } diff --git a/Sources/CypherMessaging/TestSupport/MemorySpokeMessengerStore.swift b/Sources/CypherMessaging/TestSupport/MemorySpokeMessengerStore.swift index d4f11dd..85d2620 100644 --- a/Sources/CypherMessaging/TestSupport/MemorySpokeMessengerStore.swift +++ b/Sources/CypherMessaging/TestSupport/MemorySpokeMessengerStore.swift @@ -5,7 +5,7 @@ public enum MemoryCypherMessengerStoreError: Error { case notFound } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) public final actor MemoryCypherMessengerStore: CypherMessengerStore { private let salt = UUID().uuidString private var localConfig: Data? diff --git a/Sources/CypherMessaging/TestSupport/SpoofP2PTransport.swift b/Sources/CypherMessaging/TestSupport/SpoofP2PTransport.swift index 9e2c90e..4bf6cd1 100644 --- a/Sources/CypherMessaging/TestSupport/SpoofP2PTransport.swift +++ b/Sources/CypherMessaging/TestSupport/SpoofP2PTransport.swift @@ -4,7 +4,7 @@ enum SpoofP2PTransportError: Error { case disconnected } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) public final class SpoofP2PTransportClient: P2PTransportClient { public weak var delegate: P2PTransportClientDelegate? public fileprivate(set) var connected: ConnectionState = .connecting @@ -19,16 +19,11 @@ public final class SpoofP2PTransportClient: P2PTransportClient { self.otherClient = otherClient } - public func reconnect() async throws { - if otherClient == nil { - self.connected = .disconnected - throw SpoofP2PTransportError.disconnected - } else { - self.connected = .connected - } - } - public func disconnect() async { + if connected == .disconnected || connected == .disconnecting { + return + } + self.connected = .disconnecting if let otherClient = otherClient { @@ -55,21 +50,75 @@ public final class SpoofP2PTransportClient: P2PTransportClient { } } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) fileprivate final class SpoofTransportFactoryMedium { - var clients = [String: SpoofP2PTransportClient]() + @CypherTextKitActor var clients = [String: SpoofP2PTransportClient]() + @CypherTextKitActor var devices = [String: SpoofP2PTransportFactory]() private init() {} static let `default` = SpoofTransportFactoryMedium() } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) public final class SpoofP2PTransportFactory: P2PTransportClientFactory { - public init() {} + @CypherTextKitActor public static func clearMesh() { + SpoofTransportFactoryMedium.default.devices = [:] + } + + @discardableResult + @CypherTextKitActor public static func connectMesh(from: String, to: String) async throws -> Bool { + guard + let from = SpoofTransportFactoryMedium.default.devices[from], + let to = SpoofTransportFactoryMedium.default.devices[to], + let fromAdvertisement = try await from.delegate?.createLocalDeviceAdvertisement(), + let toAdvertisement = try await to.delegate?.createLocalDeviceAdvertisement() + else { + return false + } + + let fromState = try await from.createLocalTransportState(advertisement: toAdvertisement) + let fromClient = SpoofP2PTransportClient( + state: fromState, + otherClient: nil + ) + + let toState = try await to.createLocalTransportState(advertisement: fromAdvertisement) + let toClient = SpoofP2PTransportClient( + state: toState, + otherClient: fromClient + ) + + fromClient.otherClient = toClient + + try await from.delegate?.p2pTransportDiscovered( + fromClient, + remotePeer: fromState.remote + ) + try await to.delegate?.p2pTransportDiscovered( + toClient, + remotePeer: toState.remote + ) + + fromClient.connected = .connected + toClient.connected = .connected + return true + } public let transportLayerIdentifier = "_spoof" + public let isMeshEnabled: Bool + public weak var delegate: P2PTransportFactoryDelegate? + + public init() { + self.isMeshEnabled = false + } + + @CypherTextKitActor public init(meshId: String) { + self.isMeshEnabled = true + + SpoofTransportFactoryMedium.default.devices[meshId] = self + } - public func createConnection(handle: P2PTransportFactoryHandle) async throws -> P2PTransportClient? { + @CypherTextKitActor public func createConnection(handle: P2PTransportFactoryHandle) async throws -> P2PTransportClient? { let localClient = SpoofP2PTransportClient( state: handle.state, otherClient: nil @@ -86,7 +135,7 @@ public final class SpoofP2PTransportFactory: P2PTransportClientFactory { return localClient } - public func receiveMessage(_ text: String, metadata: Document, handle: P2PTransportFactoryHandle) async throws -> P2PTransportClient? { + @CypherTextKitActor public func receiveMessage(_ text: String, metadata: Document, handle: P2PTransportFactoryHandle) async throws -> P2PTransportClient? { guard let client = SpoofTransportFactoryMedium.default.clients[text] else { return nil } diff --git a/Sources/CypherMessaging/TestSupport/SpoofSpokeEventHandler.swift b/Sources/CypherMessaging/TestSupport/SpoofSpokeEventHandler.swift index c158fce..af442c6 100644 --- a/Sources/CypherMessaging/TestSupport/SpoofSpokeEventHandler.swift +++ b/Sources/CypherMessaging/TestSupport/SpoofSpokeEventHandler.swift @@ -1,6 +1,6 @@ import NIO -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) public struct SpoofCypherEventHandler: CypherMessengerEventHandler { public init() {} @@ -48,4 +48,6 @@ public struct SpoofCypherEventHandler: CypherMessengerEventHandler { public func onRemoveChatMessage(_ message: AnyChatMessage) {} public func onDeviceRegistery(_ deviceId: DeviceId, messenger: CypherMessenger) {} + public func onOtherUserDeviceRegistery(username: Username, deviceId: DeviceId, messenger: CypherMessenger) { } + public func onCustomConfigChange() {} } diff --git a/Sources/CypherMessaging/TestSupport/SpoofTransport.swift b/Sources/CypherMessaging/TestSupport/SpoofTransport.swift index 4bfd036..61abcb6 100644 --- a/Sources/CypherMessaging/TestSupport/SpoofTransport.swift +++ b/Sources/CypherMessaging/TestSupport/SpoofTransport.swift @@ -3,7 +3,7 @@ import Foundation import NIO public enum SpoofTransportClientSettings { - public enum PacketType { + public enum PacketType: Sendable { case readReceipt(remoteId: String, otherUser: Username) case receiveReceipt(remoteId: String, otherUser: Username) case deviceRegistery @@ -14,7 +14,14 @@ public enum SpoofTransportClientSettings { case sendMessage(messageId: String) } - public static var shouldDropPacket: (Username, PacketType) async throws -> () = { _, _ in } + public static var isOffline = false + public static var shouldDropPacket: @Sendable @CryptoActor (Username, PacketType) async throws -> () = { _, _ in } + public static func removeBacklog() -> [DeviceId: [CypherServerEvent]] { + SpoofServer.local.removeBacklog() + } + public static func addBacklog(_ backlog: [DeviceId: [CypherServerEvent]]) { + SpoofServer.local.addBacklog(backlog) + } } fileprivate final class SpoofServer { @@ -46,6 +53,22 @@ fileprivate final class SpoofServer { publishedBlobs = [:] } + func removeBacklog() -> [DeviceId: [CypherServerEvent]] { + defer { backlog = [:] } + return backlog + } + + func addBacklog(_ backlog: [DeviceId: [CypherServerEvent]]) { + for (device, log) in backlog { + if var existing = self.backlog[device] { + existing.append(contentsOf: log) + self.backlog[device] = existing + } else { + self.backlog[device] = log + } + } + } + fileprivate func login(username: Username, deviceId: DeviceId) async throws -> SpoofTransportClient { SpoofTransportClient(username: username, deviceId: deviceId, server: self) } @@ -131,6 +154,8 @@ public final class SpoofTransportClient: ConnectableCypherTransportClient { private let server: SpoofServer public private(set) var authenticated = AuthenticationState.unauthenticated public let supportsMultiRecipientMessages = true + public let supportsDelayedRegistration = true + public var isConnected: Bool { !SpoofTransportClientSettings.isOffline } public weak var delegate: CypherTransportClientDelegate? public func setDelegate(to delegate: CypherTransportClientDelegate) async throws { @@ -153,16 +178,21 @@ public final class SpoofTransportClient: ConnectableCypherTransportClient { } public static func login(_ request: TransportCreationRequest) async throws -> SpoofTransportClient { - try await SpoofServer.local.login(username: request.username, deviceId: request.deviceId) + return try await SpoofServer.local.login(username: request.username, deviceId: request.deviceId) } public func receiveServerEvent(_ event: CypherServerEvent) async throws { - _ = try await delegate?.receiveServerEvent(event) + try await delegate?.receiveServerEvent(event) } public func reconnect() async throws { + if SpoofTransportClientSettings.isOffline { + throw SpoofP2PTransportError.disconnected + } + server.connectUser(self) authenticated = .authenticated + try await server.requestBacklog(username: username, deviceId: deviceId, into: self) } public func disconnect() async throws { @@ -171,6 +201,10 @@ public final class SpoofTransportClient: ConnectableCypherTransportClient { } public func sendMessageReadReceipt(byRemoteId remoteId: String, to otherUser: Username) async throws { + if !isConnected { + throw CypherSDKError.offline + } + try await SpoofTransportClientSettings.shouldDropPacket( self.username, .readReceipt(remoteId: remoteId, otherUser: otherUser) @@ -179,6 +213,10 @@ public final class SpoofTransportClient: ConnectableCypherTransportClient { } public func sendMessageReceivedReceipt(byRemoteId remoteId: String, to otherUser: Username) async throws { + if !isConnected { + throw CypherSDKError.offline + } + try await SpoofTransportClientSettings.shouldDropPacket( self.username, .receiveReceipt(remoteId: remoteId, otherUser: otherUser) @@ -187,6 +225,10 @@ public final class SpoofTransportClient: ConnectableCypherTransportClient { } public func requestDeviceRegistery(_ userDeviceConfig: UserDeviceConfig) async throws { + if !isConnected { + throw CypherSDKError.offline + } + try await SpoofTransportClientSettings.shouldDropPacket( self.username, .deviceRegistery @@ -207,6 +249,10 @@ public final class SpoofTransportClient: ConnectableCypherTransportClient { } public func readKeyBundle(forUsername username: Username) async throws -> UserConfig { + if !isConnected { + throw CypherSDKError.offline + } + try await SpoofTransportClientSettings.shouldDropPacket( self.username, .readKeyBundle(username: username) @@ -220,6 +266,10 @@ public final class SpoofTransportClient: ConnectableCypherTransportClient { } public func publishKeyBundle(_ keys: UserConfig) async throws { + if !isConnected { + throw CypherSDKError.offline + } + try await SpoofTransportClientSettings.shouldDropPacket( self.username, .publishKeyBundle @@ -234,6 +284,10 @@ public final class SpoofTransportClient: ConnectableCypherTransportClient { } public func publishBlob(_ blob: C) async throws -> ReferencedBlob { + if !isConnected { + throw CypherSDKError.offline + } + try await SpoofTransportClientSettings.shouldDropPacket( self.username, .publishBlob @@ -245,6 +299,10 @@ public final class SpoofTransportClient: ConnectableCypherTransportClient { } public func readPublishedBlob(byId id: String, as type: C.Type) async throws -> ReferencedBlob? { + if !isConnected { + throw CypherSDKError.offline + } + try await SpoofTransportClientSettings.shouldDropPacket( self.username, .readBlob(id: id) @@ -264,6 +322,10 @@ public final class SpoofTransportClient: ConnectableCypherTransportClient { pushType: PushType, messageId: String ) async throws { + if !isConnected { + throw CypherSDKError.offline + } + try await SpoofTransportClientSettings.shouldDropPacket( self.username, .sendMessage(messageId: messageId) @@ -273,7 +335,8 @@ public final class SpoofTransportClient: ConnectableCypherTransportClient { message, id: messageId, byUser: self.username, - deviceId: deviceId + deviceId: deviceId, + createdAt: Date() ), to: otherUser, deviceId: otherUserDeviceId @@ -285,6 +348,10 @@ public final class SpoofTransportClient: ConnectableCypherTransportClient { pushType: PushType, messageId: String ) async throws { + if !isConnected { + throw CypherSDKError.offline + } + try await SpoofTransportClientSettings.shouldDropPacket( self.username, .sendMessage(messageId: messageId) diff --git a/Sources/CypherMessaging/Transport/CypherTransportClient.swift b/Sources/CypherMessaging/Transport/CypherTransportClient.swift index bfe3ac6..f080072 100644 --- a/Sources/CypherMessaging/Transport/CypherTransportClient.swift +++ b/Sources/CypherMessaging/Transport/CypherTransportClient.swift @@ -41,10 +41,16 @@ public protocol CypherServerTransportClient: AnyObject { /// `true` when logged in, `false` on incorrect login, `nil` when no server request has been executed yet var authenticated: AuthenticationState { get } + /// `true` if the client is able to communicate with a/the server + var isConnected: Bool { get } + /// When `true`, the CypherMessenger's internals may call the `sendMultiRecipientMessage` method. /// Supporting MultiRecipient Messages allows the app to expend less data uploading files to multiple recipients. var supportsMultiRecipientMessages: Bool { get } + // TODO: Implement support + var supportsDelayedRegistration: Bool { get } + /// (Re-)starts the connection(s). func reconnect() async throws @@ -112,6 +118,10 @@ public protocol CypherServerTransportClient: AnyObject { func sendMultiRecipientMessage(_ message: MultiRecipientCypherMessage, pushType: PushType, messageId: String) async throws } +extension CypherServerTransportClient { + public var supportsDelayedRegistration: Bool { false } +} + public protocol ConnectableCypherTransportClient: CypherServerTransportClient { static func login(_ request: TransportCreationRequest) async throws -> Self } @@ -122,29 +132,29 @@ public protocol CypherTransportClientDelegate: AnyObject { public struct CypherServerEvent { enum _CypherServerEvent { - case multiRecipientMessageSent(MultiRecipientCypherMessage, id: String, byUser: Username, deviceId: DeviceId) - case messageSent(RatchetedCypherMessage, id: String, byUser: Username, deviceId: DeviceId) - case messageDisplayed(by: Username, deviceId: DeviceId, id: String) - case messageReceived(by: Username, deviceId: DeviceId, id: String) + case multiRecipientMessageSent(MultiRecipientCypherMessage, id: String, byUser: Username, deviceId: DeviceId, createdAt: Date?) + case messageSent(RatchetedCypherMessage, id: String, byUser: Username, deviceId: DeviceId, createdAt: Date?) + case messageDisplayed(by: Username, deviceId: DeviceId, id: String, receivedAt: Date) + case messageReceived(by: Username, deviceId: DeviceId, id: String, receivedAt: Date) case requestDeviceRegistery(UserDeviceConfig) } internal let raw: _CypherServerEvent - public static func multiRecipientMessageSent(_ message: MultiRecipientCypherMessage, id: String, byUser user: Username, deviceId: DeviceId) -> CypherServerEvent { - CypherServerEvent(raw: .multiRecipientMessageSent(message, id: id, byUser: user, deviceId: deviceId)) + public static func multiRecipientMessageSent(_ message: MultiRecipientCypherMessage, id: String, byUser user: Username, deviceId: DeviceId, createdAt: Date? = Date()) -> CypherServerEvent { + CypherServerEvent(raw: .multiRecipientMessageSent(message, id: id, byUser: user, deviceId: deviceId, createdAt: createdAt)) } - public static func messageSent(_ message: RatchetedCypherMessage, id: String, byUser user: Username, deviceId: DeviceId) -> CypherServerEvent { - CypherServerEvent(raw: .messageSent(message, id: id, byUser: user, deviceId: deviceId)) + public static func messageSent(_ message: RatchetedCypherMessage, id: String, byUser user: Username, deviceId: DeviceId, createdAt: Date? = Date()) -> CypherServerEvent { + CypherServerEvent(raw: .messageSent(message, id: id, byUser: user, deviceId: deviceId, createdAt: createdAt)) } - public static func messageDisplayed(by user: Username, deviceId: DeviceId, id: String) -> CypherServerEvent { - CypherServerEvent(raw: .messageDisplayed(by: user, deviceId: deviceId, id: id)) + public static func messageDisplayed(by user: Username, deviceId: DeviceId, id: String, receivedAt: Date = Date()) -> CypherServerEvent { + CypherServerEvent(raw: .messageDisplayed(by: user, deviceId: deviceId, id: id, receivedAt: receivedAt)) } - public static func messageReceived(by user: Username, deviceId: DeviceId, id: String) -> CypherServerEvent { - CypherServerEvent(raw: .messageReceived(by: user, deviceId: deviceId, id: id)) + public static func messageReceived(by user: Username, deviceId: DeviceId, id: String, receivedAt: Date = Date()) -> CypherServerEvent { + CypherServerEvent(raw: .messageReceived(by: user, deviceId: deviceId, id: id, receivedAt: receivedAt)) } public static func requestDeviceRegistery(_ config: UserDeviceConfig) -> CypherServerEvent { diff --git a/Sources/CypherMessaging/Transport/P2PTransportClient.swift b/Sources/CypherMessaging/Transport/P2PTransportClient.swift index 3820024..47d594f 100644 --- a/Sources/CypherMessaging/Transport/P2PTransportClient.swift +++ b/Sources/CypherMessaging/Transport/P2PTransportClient.swift @@ -5,12 +5,38 @@ public enum ConnectionState { case connecting, connected, disconnecting, disconnected } +public struct Peer: Codable { + public let username: Username + public let deviceConfig: UserDeviceConfig + public var deviceId: DeviceId { deviceConfig.deviceId } + public var identity: PublicSigningKey { deviceConfig.identity } + public var publicKey: PublicKey { deviceConfig.publicKey } +} + +public struct P2PAdvertisement: Codable { + internal struct Advertisement: Codable { + let origin: Peer + } + + internal let advertisement: Signed +} + +internal actor P2PMeshState { + +} + /// Can only be initialised by the CypherTextKit /// Contains internal and public information about the connection and conncted clients public struct P2PFrameworkState { - internal let username: Username - internal let deviceId: DeviceId - internal let identity: PublicSigningKey + public let remote: Peer + internal var username: Username { remote.username } + internal var deviceId: DeviceId { remote.deviceId } + internal var identity: PublicSigningKey { remote.identity } + internal let isMeshEnabled: Bool + + // TODO: Attempt Offline Verification + internal var verified = true + internal let mesh = P2PMeshState() } /// PeerToPeerTransportClient is used to create a direct connection between two devices. @@ -18,7 +44,7 @@ public struct P2PFrameworkState { /// These transport clients need not concern themselves with end-to-end encryption, as the data they receive is already encrypted. /// /// CypherTextKit may opt to use a direct connection as a _replacement_ for via-server communication, as to improve security, bandwidth AND latency. -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) public protocol P2PTransportClient: AnyObject { /// The delegate receives incoming data from the the remote peer. MUST be `weak` to prevent memory leaks. /// @@ -33,9 +59,6 @@ public protocol P2PTransportClient: AnyObject { /// Obtained on creation through `P2PTransportClientFactory` var state: P2PFrameworkState { get } - /// (Re-)starts the connection(s). - func reconnect() async throws - /// Disconnects any active connections. func disconnect() async @@ -43,21 +66,29 @@ public protocol P2PTransportClient: AnyObject { func sendMessage(_ buffer: ByteBuffer) async throws } -public enum P2PTransportClosureOption { - case reconnnectPossible -} +public enum P2PTransportClosureOption {} -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) public protocol P2PTransportClientDelegate: AnyObject { func p2pConnection(_ connection: P2PTransportClient, receivedMessage buffer: ByteBuffer) async throws - func p2pConnection(_ connection: P2PTransportClient, closedWithOptions: Set) async throws + func p2pConnectionClosed(_ connection: P2PTransportClient) async throws +} + +@available(macOS 10.15, iOS 13, *) +public protocol P2PTransportFactoryDelegate: AnyObject { + func p2pTransportDiscovered( + _ connection: P2PTransportClient, + remotePeer: Peer + ) async throws + + func createLocalDeviceAdvertisement() async throws -> P2PAdvertisement } public struct P2PTransportCreationRequest { public let state: P2PFrameworkState } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) public typealias PeerToPeerConnectionBuilder = (P2PTransportCreationRequest) -> P2PTransportClient /// P2PTransportClientFactory is a _stateful_ factory that can instantiate new connections @@ -67,10 +98,18 @@ public typealias PeerToPeerConnectionBuilder = (P2PTransportCreationRequest) -> /// Example: Apple devices can use Multipeer Connectivity, possibly without making use of server-side communication. /// /// Example: WebRTC based implementations are likely to make use of the handle to send and receive SDPs. The factory can then make use of internal state for storing incomplete connections. -@available(macOS 12, iOS 15, *) -public protocol P2PTransportClientFactory { +@available(macOS 10.15, iOS 13, *) +public protocol P2PTransportClientFactory: AnyObject { var transportLayerIdentifier: String { get } + /// Whether this transport type supports relaying messages for between two peers + var isMeshEnabled: Bool { get } + + /// The delegate receives signals of factory-discovered peers. MUST be `weak` to prevent memory leaks. + /// + /// CypherTextKit is responsible for managing and delegating data received from this channel + var delegate: P2PTransportFactoryDelegate? { get set } + func receiveMessage( _ text: String, metadata: Document, @@ -88,8 +127,27 @@ public protocol P2PTransportClientFactory { ) async throws -> P2PTransportClient? } +extension P2PTransportClientFactory { + public var isMeshEnabled: Bool { false } + + public func createLocalTransportState( + advertisement: P2PAdvertisement + ) async throws -> P2PFrameworkState { + let remote = try advertisement.advertisement.readWithoutVerifying() + guard advertisement.advertisement.isSigned(by: remote.origin.identity) else { + throw CypherSDKError.invalidSignature + } + + return P2PFrameworkState( + remote: remote.origin, + isMeshEnabled: isMeshEnabled, + verified: false + ) + } +} + /// An interface through which can be communicated with the remote device -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) public struct P2PTransportFactoryHandle { internal let transportLayerIdentifier: String internal let messenger: CypherMessenger diff --git a/Sources/CypherMessaging/_Internal/Crypto/EncryptedData.swift b/Sources/CypherMessaging/_Internal/Crypto/EncryptedData.swift index 8f1fd2c..ea0d336 100644 --- a/Sources/CypherMessaging/_Internal/Crypto/EncryptedData.swift +++ b/Sources/CypherMessaging/_Internal/Crypto/EncryptedData.swift @@ -3,7 +3,7 @@ import Foundation import Crypto /// Used when encrypting a specific value -public final class Encrypted: Codable { +public final class Encrypted: Codable, @unchecked Sendable { private var value: AES.GCM.SealedBox private var wrapped: T? @@ -14,16 +14,20 @@ public final class Encrypted: Codable { let data = try BSONEncoder().encode(wrapper).makeData() // Encrypt & store the encoded data - self.value = try AES.GCM.seal(data, using: encryptionKey) + self.value = try! AES.GCM.seal(data, using: encryptionKey) } - public func update(to value: T, using encryptionKey: SymmetricKey) async throws { + public func update(to value: T, using encryptionKey: SymmetricKey) throws { self.wrapped = value let wrapper = PrimitiveWrapper(value: value) let data = try BSONEncoder().encode(wrapper).makeData() self.value = try AES.GCM.seal(data, using: encryptionKey) } + public func canDecrypt(using encryptionKey: SymmetricKey) throws { + _ = try AES.GCM.open(value, using: encryptionKey) + } + // The inverse of the initializer public func decrypt(using encryptionKey: SymmetricKey) throws -> T { if let wrapped = wrapped { @@ -38,7 +42,7 @@ public final class Encrypted: Codable { // Return the value let value = wrapper.value -// wrapped = value + wrapped = value return value } diff --git a/Sources/CypherMessaging/_Internal/Error.swift b/Sources/CypherMessaging/_Internal/Error.swift index fe7cdd3..bfb4c9a 100644 --- a/Sources/CypherMessaging/_Internal/Error.swift +++ b/Sources/CypherMessaging/_Internal/Error.swift @@ -16,4 +16,9 @@ enum CypherSDKError: Error { case invalidTransport case unsupportedTransport case internalError + case notGroupMember, notGroupModerator + case duplicateChatMessage + case invalidSignature + case cannotRegisterDeviceConfig + case cannotImportExistingContact } diff --git a/Sources/CypherMessaging/_Internal/Helpers+CypherMessenger.swift b/Sources/CypherMessaging/_Internal/Helpers+CypherMessenger.swift index aded501..6241469 100644 --- a/Sources/CypherMessaging/_Internal/Helpers+CypherMessenger.swift +++ b/Sources/CypherMessaging/_Internal/Helpers+CypherMessenger.swift @@ -8,18 +8,23 @@ enum UserIdentityState { case consistent, newIdentity, changedIdentity } -@available(macOS 12, iOS 15, *) +// TODO: Respect push notification preferences that other users apply on your chat +// TODO: Server-Side mute and even block another user +// TODO: Encrypted push notifs +@available(macOS 10.15, iOS 13, *) internal extension CypherMessenger { + @CryptoActor + @discardableResult func _markMessage(byRemoteId remoteId: String, updatedBy user: Username, as newState: ChatMessageModel.DeliveryState) async throws -> MarkMessageResult { let message = try await cachedStore.fetchChatMessage(byRemoteId: remoteId) let decryptedMessage = try await self.decrypt(message) - guard decryptedMessage.props.senderUser == self.username else { + guard await decryptedMessage.props.senderUser == self.username else { throw CypherSDKError.badInput } - let oldState = decryptedMessage.deliveryState - let result = try await decryptedMessage.transitionDeliveryState(to: newState) + let oldState = await decryptedMessage.deliveryState + let result = try await decryptedMessage.transitionDeliveryState(to: newState, forUser: user, messenger: self) do { try await self._updateChatMessage(decryptedMessage) @@ -30,6 +35,8 @@ internal extension CypherMessenger { } } + @CryptoActor + @discardableResult func _markMessage(byId id: UUID?, as newState: ChatMessageModel.DeliveryState) async throws -> MarkMessageResult { guard let id = id else { return .error @@ -37,9 +44,9 @@ internal extension CypherMessenger { let message = try await cachedStore.fetchChatMessage(byId: id) let decryptedMessage = try await self.decrypt(message) - let oldState = decryptedMessage.deliveryState + let oldState = await decryptedMessage.deliveryState - let result = try await decryptedMessage.transitionDeliveryState(to: newState) + let result = try await decryptedMessage.transitionDeliveryState(to: newState, forUser: self.username, messenger: self) do { try await self._updateChatMessage(decryptedMessage) @@ -50,9 +57,10 @@ internal extension CypherMessenger { } } + @CryptoActor func _updateChatMessage(_ message: DecryptedModel) async throws { try await self.cachedStore.updateChatMessage(message.encrypted) - self.eventHandler.onMessageChange( + await self.eventHandler.onMessageChange( AnyChatMessage( target: message.props.message.target, messenger: self, @@ -61,6 +69,7 @@ internal extension CypherMessenger { ) } + @CryptoActor func _createConversation( members: Set, metadata: Document @@ -70,6 +79,7 @@ internal extension CypherMessenger { let conversation = try ConversationModel( props: .init( members: members, + kickedMembers: [], metadata: metadata, localOrder: 0 ), @@ -82,14 +92,22 @@ internal extension CypherMessenger { throw CypherSDKError.internalError } - self.eventHandler.onCreateConversation(resolved) + await self.eventHandler.onCreateConversation(resolved) return conversation } + @CryptoActor func _queueTask(_ task: CypherTask) async throws { try await self.jobQueue.queueTask(task) } + @CryptoActor + func _queueTasks(_ task: [CypherTask]) async throws { + try await self.jobQueue.queueTasks(task) + } + + @CryptoActor + @discardableResult func _updateUserIdentity(of username: Username, to config: UserConfig) async throws -> UserIdentityState { if username == self.username { return .consistent @@ -99,11 +117,11 @@ internal extension CypherMessenger { for contact in contacts { let contact = try await self.decrypt(contact) - guard contact.props.username == username else { + guard await contact.props.username == username else { continue } - if contact.config.identity.data == config.identity.data { + if await contact.config.identity.data == config.identity.data { return .consistent } else { try await contact.updateConfig(to: config) @@ -127,64 +145,116 @@ internal extension CypherMessenger { ) try await self.cachedStore.createContact(contact) - self.eventHandler.onCreateContact( - Contact(messenger: self, model: try await self.decrypt(contact)), + await self.eventHandler.onCreateContact( + Contact(messenger: self, model: try self.decrypt(contact)), messenger: self ) return .newIdentity } - func _createDeviceIdentity(from device: UserDeviceConfig, forUsername username: Username) async throws -> DecryptedModel { + @CryptoActor + @discardableResult + func _createDeviceIdentity( + from device: UserDeviceConfig, + forUsername username: Username, + serverVerified: Bool = true + ) async throws -> _DecryptedModel { let deviceIdentities = try await cachedStore.fetchDeviceIdentities() + var knownSenderIds = [Int]() for deviceIdentity in deviceIdentities { - let deviceIdentity = try await self.decrypt(deviceIdentity) + let deviceIdentity = try self._decrypt(deviceIdentity) if deviceIdentity.props.username == username, deviceIdentity.props.deviceId == device.deviceId { + guard + deviceIdentity.publicKey == device.publicKey, + deviceIdentity.identity.data == device.identity.data + else { + throw CypherSDKError.invalidSignature + } + return deviceIdentity } + + knownSenderIds.append(deviceIdentity.senderId) } if username == self.username && device.deviceId == self.deviceId { throw CypherSDKError.badInput } + var newSenderId: Int + knownSenderIds.append(deviceIdentityId) + + repeat { + newSenderId = .random(in: 1..] - ) async throws -> [DecryptedModel] { + knownDevices: [_DecryptedModel] + ) async throws -> [_DecryptedModel] { let userConfig = try await self.transport.readKeyBundle(forUsername: username) + return try await _processDeviceConfig( + userConfig, + forUername: username, + knownDevices: knownDevices + ) + } + + @CryptoActor + @discardableResult + func _processDeviceConfig( + _ userConfig: UserConfig, + forUername username: Username, + knownDevices: [_DecryptedModel] + ) async throws -> [_DecryptedModel] { + if rediscoveredUsernames.contains(username) { + return knownDevices + } + + rediscoveredUsernames.insert(username) + let identityState = try await self._updateUserIdentity( of: username, to: userConfig @@ -192,10 +262,11 @@ internal extension CypherMessenger { switch identityState { case .changedIdentity: - self.eventHandler.onContactIdentityChange(username: username, messenger: self) + await self.eventHandler.onContactIdentityChange(username: username, messenger: self) + // TODO: Remove unknown devices? fallthrough case .consistent, .newIdentity: - var models = [DecryptedModel]() + var models = [_DecryptedModel]() for device in try userConfig.readAndValidateDevices() { if let knownDevice = knownDevices.first(where: { $0.props.deviceId == device.deviceId }) { @@ -217,11 +288,13 @@ internal extension CypherMessenger { } } + @CryptoActor func _receiveMultiRecipientMessage( _ message: MultiRecipientCypherMessage, messageId: String, sender: Username, - senderDevice: DeviceId + senderDevice: DeviceId, + createdAt: Date? ) async throws { guard let key = message.keys.first(where: { key in return key.user == self.username && key.deviceId == self.deviceId @@ -234,24 +307,78 @@ internal extension CypherMessenger { multiRecipientContainer: message.container, messageId: messageId, sender: sender, - senderDevice: senderDevice + senderDevice: senderDevice, + createdAt: createdAt + ) + } + + private func requestResendMessage( + messageId: String, + sender: Username, + senderDevice: DeviceId + ) async throws { + // Request message is resent + let resendRequest = SingleCypherMessage( + messageType: .magic, + messageSubtype: "_/resend/message", + text: messageId, + metadata: [:], + destructionTimer: nil, + sentDate: nil, + preferredPushType: PushType.none, + order: 0, + target: .otherUser(sender) + ) + + try await _queueTask( + .sendMessage( + SendMessageTask( + message: CypherMessage(message: resendRequest), + recipient: sender, + recipientDeviceId: senderDevice, + localId: nil, + pushType: .none, + messageId: UUID().uuidString + ) + ) ) } + @CryptoActor func _receiveMessage( _ inbound: RatchetedCypherMessage, multiRecipientContainer: MultiRecipientContainer?, messageId: String, sender: Username, - senderDevice: DeviceId + senderDevice: DeviceId, + createdAt: Date? ) async throws { // Receive message always retries, do we need to deal with decryption errors as a successful task execution // Otherwise the task will infinitely run // However, replies to this message may fail, and must then be retried let deviceIdentity = try await self._fetchDeviceIdentity(for: sender, deviceId: senderDevice) + + if + let createdAt = createdAt, + let lastRekey = deviceIdentity.props.lastRekey, + createdAt <= lastRekey + { + // Ignore message, since it was sent in a previous conversation. + return try await requestResendMessage(messageId: messageId, sender: sender, senderDevice: senderDevice) + } + let message: CypherMessage do { - let data = try await deviceIdentity._readWithRatchetEngine(ofUser: sender, deviceId: senderDevice, message: inbound, messenger: self) + guard let data = try await deviceIdentity._readWithRatchetEngine( + message: inbound, + messenger: self + ) else { + return try await self.requestResendMessage( + messageId: messageId, + sender: sender, + senderDevice: senderDevice + ) + } if let multiRecipientContainer = multiRecipientContainer { guard data.count == 32 else { @@ -268,9 +395,13 @@ internal extension CypherMessenger { } else { message = try BSONDecoder().decode(CypherMessage.self, from: Document(data: data)) } + + if inbound.rekey { + try deviceIdentity.setProp(at: \.lastRekey, to: createdAt ?? Date()) + } } catch { // Message was corrupt or unusable - return + return try await requestResendMessage(messageId: messageId, sender: sender, senderDevice: senderDevice) } func processMessage(_ message: SingleCypherMessage) async throws { @@ -292,23 +423,27 @@ internal extension CypherMessenger { } } + @CryptoActor func _processMessage( message: SingleCypherMessage, remoteMessageId: String, - sender: DecryptedModel + sender: _DecryptedModel ) async throws { switch message.target { case .currentUser: guard sender.username == self.username, - sender.deviceId != self.deviceId, - sender.isMasterDevice + sender.deviceId != self.deviceId else { throw CypherSDKError.badInput } switch (message.messageType, message.messageSubtype ?? "") { case (.magic, "_/devices/announce"): + guard sender.isMasterDevice else { + throw CypherSDKError.badInput + } + let deviceConfig = try BSONDecoder().decode( UserDeviceConfig.self, from: message.metadata @@ -316,18 +451,32 @@ internal extension CypherMessenger { if deviceConfig.deviceId == self.deviceId { // We're not going to add ourselves as a conversation partner - // But we will mark ourselves as a child device + // But we will mark ourselves as a registered device try await self.updateConfig { config in - config.registeryMode = .childDevice + config.registeryMode = deviceConfig.isMasterDevice ? .masterDevice : .childDevice } return } - _ = try await self._createDeviceIdentity( + try await self._createDeviceIdentity( from: deviceConfig, forUsername: self.username ) return + case (.magic, let subType) where subType == "_/devices/rename": + let rename = try BSONDecoder().decode( + MagicPackets.RenameDevice.self, + from: message.metadata + ) + + guard rename.deviceId != self.deviceId else { + // We don't rename ourselves, our identity is not stored like tat + return + } + + let device = try await _fetchDeviceIdentity(for: username, deviceId: rename.deviceId) + try device.updateDeviceName(to: rename.name) + try await cachedStore.updateDeviceIdentity(device.encrypted) case (.magic, let subType) where subType.hasPrefix("_/p2p/0/"): if let sentDate = message.sentDate, @@ -342,7 +491,36 @@ internal extension CypherMessenger { remoteMessageId: remoteMessageId, sender: sender ) - case (.magic, let subType) where subType == "_/ignore": + case (.magic, "_/ignore"): + return + case (.magic, "_/resend/message"): + guard let encryptedMessage = try? await self.cachedStore.fetchChatMessage(byRemoteId: message.text) else { + return + } + + let message = try await self.decrypt(encryptedMessage) + + guard message.encrypted.senderId == self.deviceIdentityId else { + // We're not the origin! + debugLog("\(sender.username) requested a message not sent by us") + return + } + + // Check if this message was targetted at that useer + // We're the current user, so answer is automatically 'yes' + try await _queueTask( + .sendMessage( + SendMessageTask( + message: CypherMessage(message: message.message), + recipient: sender.username, + recipientDeviceId: sender.deviceId, + localId: nil, + pushType: .none, + messageId: message.encrypted.remoteId + ) + ) + ) + return default: guard message.messageSubtype?.hasPrefix("_/") != true else { @@ -366,7 +544,7 @@ internal extension CypherMessenger { return case .save: let conversation = try await self.getInternalConversation() - let chatMessage = try await conversation._saveMessage( + guard let chatMessage = try await conversation._saveMessage( senderId: sender.props.senderId, order: message.order, props: .init( @@ -376,9 +554,12 @@ internal extension CypherMessenger { senderDeviceId: sender.props.deviceId ), remoteId: remoteMessageId - ) + ) else { + // Message was not saved, probably duplicate + return + } - if chatMessage.senderUser == self.username { + if await chatMessage.senderUser == self.username { // Send by our device in this chat return } @@ -390,6 +571,52 @@ internal extension CypherMessenger { if let subType = message.messageSubtype, subType.hasPrefix("_/") { switch subType { case "_/ignore": + // Do nothing, it's like a `ping` message without `pong` reply + return + case "_/resend/message": + guard let group = try await getGroupChat(byId: groupId) else { + debugLog("\(sender.username) requested a message from an unknown group \(groupId)") + return + } + + // 1. Check if the user is a member + guard await group.conversation.members.contains(sender.username) else { + debugLog("\(sender.username) requested a message from group \(groupId) which they're not a member of") + return + } + + // TODO: 2. Check if the user has had access, I.E. participation date + guard let encryptedMessage = try? await self.cachedStore.fetchChatMessage(byRemoteId: message.text) else { + return + } + + let message = try await self.decrypt(encryptedMessage) + + guard message.encrypted.senderId == self.deviceIdentityId else { + // We're not the origin! + debugLog("\(sender.username) requested a message not sent by us") + return + } + + guard group.conversation.encrypted.id == message.encrypted.conversationId else { + debugLog("\(sender.username) requested a message from an unrelated chat") + return + } + + // Check if this message was targetted at that useer + // We're the current user, so answer is automatically 'yes' + try await _queueTask( + .sendMessage( + SendMessageTask( + message: CypherMessage(message: message.message), + recipient: sender.username, + recipientDeviceId: sender.deviceId, + localId: nil, + pushType: .none, + messageId: message.encrypted.remoteId + ) + ) + ) return default: debugLog("Unknown message subtype in cypher messenger namespace: ", message.messageSubtype as Any) @@ -411,7 +638,7 @@ internal extension CypherMessenger { case .ignore: return case .save: - let chatMessage = try await group._saveMessage( + guard let chatMessage = try await group._saveMessage( senderId: sender.props.senderId, order: message.order, props: .init( @@ -421,9 +648,12 @@ internal extension CypherMessenger { senderDeviceId: sender.props.deviceId ), remoteId: remoteMessageId - ) + ) else { + // Message was not saved, probably duplicate + return + } - if chatMessage.senderUser != self.username { + if await chatMessage.senderUser != self.username { try await self.jobQueue.queueTask( CypherTask.sendMessageDeliveryStateChangeTask( SendMessageDeliveryStateChangeTask( @@ -445,11 +675,57 @@ internal extension CypherMessenger { remoteMessageId: remoteMessageId, sender: sender ) - case (.magic, let subType) where subType == "_/ignore": + case (.magic, "_/ignore"): return - case (.magic, let subType) where subType == "_/devices/announce": + case (.magic, "_/devices/announce"): try await self._refreshDeviceIdentities(for: sender.username) return + case (.magic, "_/resend/message"): + guard let encryptedMessage = try? await self.cachedStore.fetchChatMessage(byRemoteId: message.text) else { + return + } + + let message = try await self.decrypt(encryptedMessage) + + guard message.encrypted.senderId == self.deviceIdentityId else { + // We're not the origin! + debugLog("\(sender.username) requested a message not sent by us") + return + } + + // Check if this message was targetted at that useer + guard let privateChat = try await getPrivateChat(with: recipient) else { + debugLog("\(sender.username) requested a message from an unknown private chat") + return + } + + let conversationPartner = await privateChat.conversationPartner + guard + sender.username == conversationPartner // They requested our message + || sender.username == self.username // We requested our own message + else { + debugLog("\(sender.username) requested a message from an unrelated chat") + return + } + + guard privateChat.conversation.encrypted.id == message.encrypted.conversationId else { + debugLog("\(sender.username) requested a message from an unrelated chat") + return + } + + try await _queueTask( + .sendMessage( + SendMessageTask( + message: CypherMessage(message: message.message), + recipient: sender.username, + recipientDeviceId: sender.deviceId, + localId: nil, + pushType: .none, + messageId: message.encrypted.remoteId + ) + ) + ) + return case (.magic, let subType), (.media, let subType), (.text, let subType): if subType.hasPrefix("_/") { debugLog("Unknown message subtype in cypher messenger namespace: ", message.messageSubtype as Any) @@ -477,7 +753,7 @@ internal extension CypherMessenger { return case .save: debugLog("Received message is to be saved") - let chatMessage = try await privateChat._saveMessage( + guard let chatMessage = try await privateChat._saveMessage( senderId: sender.props.senderId, order: message.order, props: .init( @@ -487,9 +763,12 @@ internal extension CypherMessenger { senderDeviceId: sender.props.deviceId ), remoteId: remoteMessageId - ) + ) else { + // Message was not saved, probably duplicate + return + } - if chatMessage.senderUser != self.username { + if await chatMessage.senderUser != self.username { try await self.jobQueue.queueTask( CypherTask.sendMessageDeliveryStateChangeTask( SendMessageDeliveryStateChangeTask( @@ -506,11 +785,12 @@ internal extension CypherMessenger { } } + @CryptoActor func _fetchKnownDeviceIdentities( for username: Username - ) async throws -> [DecryptedModel] { + ) async throws -> [_DecryptedModel] { try await cachedStore.fetchDeviceIdentities().asyncCompactMap { deviceIdentity in - let deviceIdentity = try await self.decrypt(deviceIdentity) + let deviceIdentity = try self._decrypt(deviceIdentity) if deviceIdentity.username == username { return deviceIdentity @@ -520,10 +800,27 @@ internal extension CypherMessenger { } } + @CryptoActor + func _fetchKnownDeviceIdentity( + for username: Username, + deviceId: DeviceId + ) async throws -> _DecryptedModel? { + for deviceIdentity in try await cachedStore.fetchDeviceIdentities() { + let deviceIdentity = try self._decrypt(deviceIdentity) + + if deviceIdentity.username == username, deviceIdentity.deviceId == deviceId { + return deviceIdentity + } + } + + return nil + } + + @CryptoActor func _fetchDeviceIdentity( for username: Username, deviceId: DeviceId - ) async throws -> DecryptedModel { + ) async throws -> _DecryptedModel { let knownDevices = try await self._fetchKnownDeviceIdentities(for: username) if let device = knownDevices.first(where: { $0.props.deviceId == deviceId }) { return device @@ -537,23 +834,25 @@ internal extension CypherMessenger { } } + @CryptoActor func _fetchDeviceIdentities( for username: Username - ) async throws -> [DecryptedModel] { + ) async throws -> [_DecryptedModel] { let knownDevices = try await self._fetchKnownDeviceIdentities(for: username) - if knownDevices.isEmpty, username != self.username { + if knownDevices.isEmpty && username != self.username && isOnline { return try await self._rediscoverDeviceIdentities(for: username, knownDevices: knownDevices) } return knownDevices } - @Sendable func _fetchDeviceIdentities( + @CryptoActor + func _fetchDeviceIdentities( forUsers usernames: Set - ) async throws -> [DecryptedModel] { + ) async throws -> [_DecryptedModel] { let devices = try await cachedStore.fetchDeviceIdentities() - let knownDevices = try await devices.asyncCompactMap { deviceIdentity -> DecryptedModel? in - let deviceIdentity = try await self.decrypt(deviceIdentity) + let knownDevices = try await devices.asyncCompactMap { deviceIdentity -> _DecryptedModel? in + let deviceIdentity = try self._decrypt(deviceIdentity) if usernames.contains(deviceIdentity.username) { return deviceIdentity @@ -562,11 +861,11 @@ internal extension CypherMessenger { } } - var newDevices = [DecryptedModel]() + var newDevices = [_DecryptedModel]() for username in usernames { - if username != self.username, await !knownDevices.asyncContains(where: { + if username != self.username, !knownDevices.contains(where: { $0.props.username == username - }) { + }), isOnline { let rediscovered = try await self._rediscoverDeviceIdentities(for: username, knownDevices: knownDevices) newDevices.append(contentsOf: rediscovered) } diff --git a/Sources/CypherMessaging/_Internal/Logging.swift b/Sources/CypherMessaging/_Internal/Logging.swift index 6cde4b3..a8a4394 100644 --- a/Sources/CypherMessaging/_Internal/Logging.swift +++ b/Sources/CypherMessaging/_Internal/Logging.swift @@ -1,7 +1,16 @@ import Foundation -enum LogDomain: String { - case none, webrtc, crypto +public struct LogDomain { + fileprivate enum Raw: String { + case none, webrtc, crypto, transport + } + + fileprivate let raw: Raw + + public static let none = LogDomain(raw: .none) + public static let webrtc = LogDomain(raw: .webrtc) + public static let transport = LogDomain(raw: .transport) + internal static let crypto = LogDomain(raw: .crypto) } fileprivate let formatter: ISO8601DateFormatter = { @@ -12,9 +21,9 @@ fileprivate let formatter: ISO8601DateFormatter = { // TODO: Swift-log // This way this is a NO-OP in release -@inline(__always) func debugLog(domain: LogDomain = .none, _ args: Any...) { +@inline(__always) public func debugLog(domain: LogDomain = .none, _ args: Any...) { #if DEBUG - print(domain.rawValue, formatter.string(from: Date()), args) + print(domain.raw.rawValue, formatter.string(from: Date()), args) guard var url = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first else { return diff --git a/Sources/CypherMessaging/_Internal/Models+Protocol.swift b/Sources/CypherMessaging/_Internal/Models+Protocol.swift index c87cb50..8ca9061 100644 --- a/Sources/CypherMessaging/_Internal/Models+Protocol.swift +++ b/Sources/CypherMessaging/_Internal/Models+Protocol.swift @@ -5,54 +5,76 @@ public protocol MetadataProps { var metadata: Document { get set } } -public protocol Model: Codable { - associatedtype SecureProps: Codable +public protocol Model: Codable, Sendable { + associatedtype SecureProps: Codable & Sendable var id: UUID { get } - var props: Encrypted { get set } -// func setProps(to props: Encrypted) async - -// func save(on store: CypherMessengerStore) async throws + var props: Encrypted { get } } // TODO: Re-enable cache, and reuse the cache globally -public final class DecryptedModel { - private let lock = NSLock() +public final class DecryptedModel: @unchecked Sendable { public let encrypted: M public var id: UUID { encrypted.id } - public private(set) var props: M.SecureProps + @MainActor public private(set) var props: M.SecureProps private let encryptionKey: SymmetricKey - public func withLock(_ run: () async throws -> T) async rethrows -> T { - lock.lock() - do { - let result = try await run() - lock.unlock() - return result - } catch { - lock.unlock() - throw error + @MainActor + public func withProps(get: @Sendable (M.SecureProps) async throws -> T) async throws -> T { + let props = try encrypted.props.decrypt(using: encryptionKey) + return try await get(props) + } + + @MainActor + public func modifyProps(run: @MainActor @Sendable (inout M.SecureProps) throws -> T) throws -> T { + let value = try run(&props) + try encrypted.props.update(to: props, using: encryptionKey) + return value + } + + @MainActor + public func setProp(at keyPath: WritableKeyPath, to value: T) throws { + try modifyProps { props in + props[keyPath: keyPath] = value } } - public func withProps(get: (M.SecureProps) async throws -> T) async throws -> T { + @MainActor + init(model: M, encryptionKey: SymmetricKey) throws { + self.encrypted = model + self.encryptionKey = encryptionKey + self.props = try model.props.decrypt(using: encryptionKey) + } +} + +internal final class _DecryptedModel: @unchecked Sendable { + internal let encrypted: M + internal var id: UUID { encrypted.id } + @CryptoActor public private(set) var props: M.SecureProps + private let encryptionKey: SymmetricKey + + @CryptoActor + internal func withProps(get: @Sendable (M.SecureProps) async throws -> T) async throws -> T { let props = try encrypted.props.decrypt(using: encryptionKey) return try await get(props) } - public func modifyProps(run: (inout M.SecureProps) async throws -> T) async throws -> T { - let value = try await run(&props) - try await encrypted.props.update(to: props, using: encryptionKey) + @CryptoActor + internal func modifyProps(run: @CryptoActor @Sendable (inout M.SecureProps) throws -> T) throws -> T { + let value = try run(&props) + try encrypted.props.update(to: props, using: encryptionKey) return value } - public func setProp(at keyPath: WritableKeyPath, to value: T) async throws { - try await modifyProps { props in + @CryptoActor + internal func setProp(at keyPath: WritableKeyPath, to value: T) throws { + try modifyProps { props in props[keyPath: keyPath] = value } } - init(model: M, encryptionKey: SymmetricKey) async throws { + @CryptoActor + init(model: M, encryptionKey: SymmetricKey) throws { self.encrypted = model self.encryptionKey = encryptionKey self.props = try model.props.decrypt(using: encryptionKey) diff --git a/Sources/CypherMessaging/_Internal/Models.swift b/Sources/CypherMessaging/_Internal/Models.swift index f6fa2ba..866d400 100644 --- a/Sources/CypherMessaging/_Internal/Models.swift +++ b/Sources/CypherMessaging/_Internal/Models.swift @@ -3,16 +3,19 @@ import BSON import Foundation import CypherProtocol -public final class ConversationModel: Model { - public struct SecureProps: Codable, MetadataProps { +public final class ConversationModel: Model, @unchecked Sendable { + public struct SecureProps: Codable, @unchecked Sendable, MetadataProps { + // TODO: Shorter CodingKeys + public var members: Set + public var kickedMembers: Set public var metadata: Document public var localOrder: Int } public let id: UUID - public var props: Encrypted + public let props: Encrypted public init(id: UUID, props: Encrypted) { self.id = id @@ -29,25 +32,37 @@ public final class ConversationModel: Model { } extension DecryptedModel where M == ConversationModel { - public var members: Set { + @MainActor public var members: Set { get { props.members } } - public var metadata: Document { + @MainActor public var kickedMembers: Set { + get { props.kickedMembers } + } + @MainActor public var allHistoricMembers: Set { + get { + var members = members + members.formUnion(kickedMembers) + return members + } + } + @MainActor public var metadata: Document { get { props.metadata } } - public var localOrder: Int { + @MainActor public var localOrder: Int { get { props.localOrder } } - func getNextLocalOrder() async throws -> Int { - let order = localOrder + @CryptoActor func getNextLocalOrder() async throws -> Int { + let order = await localOrder try await setProp(at: \.localOrder, to: order &+ 1) return order } } -public final class DeviceIdentityModel: Model { - public struct SecureProps: Codable { +public final class DeviceIdentityModel: Model, @unchecked Sendable { + public struct SecureProps: Codable, @unchecked Sendable { + // TODO: Shorter CodingKeys + let username: Username let deviceId: DeviceId let senderId: Int @@ -55,11 +70,16 @@ public final class DeviceIdentityModel: Model { let identity: PublicSigningKey let isMasterDevice: Bool var doubleRatchet: DoubleRatchetHKDF.State? + var deviceName: String? + + // TODO: Verify identity on the server later when possible + var serverVerified: Bool? + var lastRekey: Date? } public let id: UUID - public var props: Encrypted + public let props: Encrypted public init(id: UUID, props: Encrypted) { self.id = id @@ -75,35 +95,43 @@ public final class DeviceIdentityModel: Model { } } -extension DecryptedModel where M == DeviceIdentityModel { - public var username: Username { +extension _DecryptedModel where M == DeviceIdentityModel { + @CryptoActor var username: Username { get { props.username } } - public var deviceId: DeviceId { + @CryptoActor var deviceId: DeviceId { get { props.deviceId } } - public var isMasterDevice: Bool { + @CryptoActor var isMasterDevice: Bool { get { props.isMasterDevice } } - public var senderId: Int { + @CryptoActor var senderId: Int { get { props.senderId } } - public var publicKey: PublicKey { + @CryptoActor var publicKey: PublicKey { get { props.publicKey } } - public var identity: PublicSigningKey { + @CryptoActor var identity: PublicSigningKey { get { props.identity } } - public var doubleRatchet: DoubleRatchetHKDF.State? { + @CryptoActor var doubleRatchet: DoubleRatchetHKDF.State? { get { props.doubleRatchet } } - func updateDoubleRatchetState(to newValue: DoubleRatchetHKDF.State?) async throws { - try await setProp(at: \.doubleRatchet, to: newValue) + @CryptoActor var deviceName: String? { + get { props.deviceName } + } + @CryptoActor func updateDoubleRatchetState(to newValue: DoubleRatchetHKDF.State?) throws { + try setProp(at: \.doubleRatchet, to: newValue) + } + @CryptoActor func updateDeviceName(to newValue: String?) throws { + try setProp(at: \.deviceName, to: newValue) } } -public final class ContactModel: Model { - public struct SecureProps: Codable, MetadataProps { +public final class ContactModel: Model, @unchecked Sendable { + public struct SecureProps: Codable, @unchecked Sendable, MetadataProps { + // TODO: Shorter CodingKeys + public let username: Username public internal(set) var config: UserConfig public var metadata: Document @@ -111,7 +139,7 @@ public final class ContactModel: Model { public let id: UUID - public var props: Encrypted + public let props: Encrypted public init(id: UUID, props: Encrypted) { self.id = id @@ -128,27 +156,27 @@ public final class ContactModel: Model { } extension DecryptedModel where M == ContactModel { - public var username: Username { + @MainActor public var username: Username { get { props.username } } - public var config: UserConfig { + @MainActor public var config: UserConfig { get { props.config } } - public var metadata: Document { + @MainActor public var metadata: Document { get { props.metadata } } - func updateConfig(to newValue: UserConfig) async throws { + @CryptoActor func updateConfig(to newValue: UserConfig) async throws { try await self.setProp(at: \.config, to: newValue) } } -public enum MarkMessageResult { +public enum MarkMessageResult: Sendable { case success, error, notModified } -@available(macOS 12, iOS 15, *) -public final class ChatMessageModel: Model { - public enum DeliveryState: Int, Codable { +@available(macOS 10.15, iOS 13, *) +public final class ChatMessageModel: Model, @unchecked Sendable { + public enum DeliveryState: Int, Codable, Sendable { case none = 0 case undelivered = 1 case received = 2 @@ -169,7 +197,7 @@ public final class ChatMessageModel: Model { } } - public struct SecureProps: Codable { + public struct SecureProps: Codable, @unchecked Sendable { private enum CodingKeys: String, CodingKey { case sendDate = "a" case receiveDate = "b" @@ -177,6 +205,7 @@ public final class ChatMessageModel: Model { case message = "d" case senderUser = "e" case senderDeviceId = "f" + case deliveryStates = "g" } public let sendDate: Date @@ -185,6 +214,7 @@ public final class ChatMessageModel: Model { public var message: SingleCypherMessage public let senderUser: Username public let senderDeviceId: DeviceId + public internal(set) var deliveryStates: Document? init( sending message: SingleCypherMessage, @@ -197,6 +227,7 @@ public final class ChatMessageModel: Model { self.message = message self.senderUser = senderUser self.senderDeviceId = senderDeviceId + self.deliveryStates = [:] } init( @@ -211,6 +242,7 @@ public final class ChatMessageModel: Model { self.message = message self.senderUser = senderUser self.senderDeviceId = senderDeviceId + self.deliveryStates = [:] } } @@ -224,7 +256,7 @@ public final class ChatMessageModel: Model { // `remoteId` must be unique, or rejected when saving public let remoteId: String - public var props: Encrypted + public let props: Encrypted public init( id: UUID, @@ -259,38 +291,71 @@ public final class ChatMessageModel: Model { } } +public struct DeliveryStates { + var document: Document + + public subscript(username: Username) -> ChatMessageModel.DeliveryState { + get { + if + let currentStateCode = document[username.raw] as? Int, + let currentState = ChatMessageModel.DeliveryState(rawValue: currentStateCode) + { + return currentState + } else { + return .none + } + } + set { + document[username.raw] = newValue.rawValue + } + } +} + extension DecryptedModel where M == ChatMessageModel { - public var sendDate: Date { + @MainActor public var sendDate: Date { get { props.sendDate } } - public var receiveDate: Date { + @MainActor public var receiveDate: Date { get { props.receiveDate } } - public var deliveryState: ChatMessageModel.DeliveryState { + @MainActor public var deliveryState: ChatMessageModel.DeliveryState { get { props.deliveryState } } - public var message: SingleCypherMessage { + @MainActor var _deliveryStates: Document { + get { props.deliveryStates ?? [:] } + } + @MainActor var deliveryStates: DeliveryStates { + get { DeliveryStates(document: _deliveryStates) } + } + @MainActor public var message: SingleCypherMessage { get { props.message } } - public var senderUser: Username { + @MainActor public var senderUser: Username { get { props.senderUser } } - public var senderDeviceId: DeviceId { + @MainActor public var senderDeviceId: DeviceId { get { props.senderDeviceId } } @discardableResult - func transitionDeliveryState(to newState: ChatMessageModel.DeliveryState) async throws -> MarkMessageResult { - var state = self.deliveryState - let result = state.transition(to: newState) - try await setProp(at: \.deliveryState, to: state) + @CryptoActor func transitionDeliveryState(to newState: ChatMessageModel.DeliveryState, forUser user: Username, messenger: CypherMessenger) async throws -> MarkMessageResult { + if user != messenger.username { + var state = await self.deliveryState + state.transition(to: newState) + try await setProp(at: \.deliveryState, to: state) + } + + var allStates = await self.deliveryStates + let result = allStates[user].transition(to: newState) + try await setProp(at: \.deliveryStates, to: allStates.document) + return result } } -@available(macOS 12, iOS 15, *) -public final class JobModel: Model { - public struct SecureProps: Codable { +@available(macOS 10.15, iOS 13, *) +public final class JobModel: Model, @unchecked Sendable { + public struct SecureProps: Codable, @unchecked Sendable { private enum CodingKeys: String, CodingKey { case taskKey = "a" case task = "b" @@ -307,18 +372,18 @@ public final class JobModel: Model { var attempts: Int let isBackgroundTask: Bool - init(task: T) throws { + init(task: T, scheduledAt: Date = Date()) throws { self.taskKey = task.key.rawValue self.isBackgroundTask = task.isBackgroundTask self.task = try BSONEncoder().encode(task) - self.scheduledAt = Date() + self.scheduledAt = scheduledAt self.attempts = 0 } } // The concrete type is used to avoid collision with Identifiable public let id: UUID - public var props: Encrypted + public let props: Encrypted public init(id: UUID, props: Encrypted) { self.id = id @@ -331,27 +396,27 @@ public final class JobModel: Model { } } -extension DecryptedModel where M == JobModel { - public var taskKey: String { +extension _DecryptedModel where M == JobModel { + @CryptoActor var taskKey: String { get { props.taskKey } } - public var task: Document { + @CryptoActor var task: Document { get { props.task } } - public var delayedUntil: Date? { + @CryptoActor var delayedUntil: Date? { get { props.delayedUntil } } - public var scheduledAt: Date { + @CryptoActor var scheduledAt: Date { get { props.scheduledAt } } - public var attempts: Int { + @CryptoActor var attempts: Int { get { props.attempts } } - public var isBackgroundTask: Bool { + @CryptoActor var isBackgroundTask: Bool { get { props.isBackgroundTask } } - func delayExecution(retryDelay: TimeInterval) async throws { - try await setProp(at: \.delayedUntil, to: Date().addingTimeInterval(retryDelay)) - try await setProp(at: \.attempts, to: self.attempts + 1) + @CryptoActor func delayExecution(retryDelay: TimeInterval) throws { + try setProp(at: \.delayedUntil, to: Date().addingTimeInterval(retryDelay)) + try setProp(at: \.attempts, to: self.attempts + 1) } } diff --git a/Sources/CypherProtocol/CryptoPrimitives.swift b/Sources/CypherProtocol/CryptoPrimitives.swift index cf0dcda..743ab87 100644 --- a/Sources/CypherProtocol/CryptoPrimitives.swift +++ b/Sources/CypherProtocol/CryptoPrimitives.swift @@ -2,7 +2,7 @@ import NIOFoundationCompat import BSON import NIO import Foundation -import CryptoKit +import Crypto typealias PrivateSigningKeyAlg = Curve25519.Signing.PrivateKey typealias PublicSigningKeyAlg = Curve25519.Signing.PublicKey @@ -17,7 +17,7 @@ enum CypherProtocolError: Error { /// /// Private keys are used to sign data, as to authenticate that it was sent by the owner of this private key. /// The `publicKey` can be shared, and can then be used to verify the signature's validity. -public struct PrivateSigningKey: Codable { +public struct PrivateSigningKey: Codable, @unchecked Sendable { fileprivate let privateKey: PrivateSigningKeyAlg /// The public key that can verify signatures of this private key, wrapped in a Codable container. @@ -46,13 +46,21 @@ public struct PrivateSigningKey: Codable { /// A wrapper around Curve25519 public _signing_ keys that provides Codable support using `Foundation.Data` /// /// Public signing keys are used to verify signatures by the matching private key. -public struct PublicSigningKey: Codable { +public struct PublicSigningKey: Codable, @unchecked Sendable { fileprivate let publicKey: PublicSigningKeyAlg fileprivate init(publicKey: PublicSigningKeyAlg) { self.publicKey = publicKey } + public init?(data: Data) { + do { + publicKey = try PublicSigningKeyAlg(rawRepresentation: data) + } catch { + return nil + } + } + public func encode(to encoder: Encoder) throws { try Binary(buffer: ByteBuffer(data: publicKey.rawRepresentation)).encode(to: encoder) } @@ -97,7 +105,7 @@ public struct PublicSigningKey: Codable { /// A wrapper around Curve25519 private keys that provides Codable support using `Foundation.Data` /// /// This Private Key type is used for handshakes, to establish a shared secret key over unsafe communication channels. -public struct PrivateKey: Codable { +public struct PrivateKey: Codable, @unchecked Sendable { fileprivate let privateKey: PrivateKeyAgreementKeyAlg /// Derives a `PublicKey` that can be sent to a third party. @@ -142,13 +150,25 @@ public struct PrivateKey: Codable { /// A key that is derived from `PrivateKey`. /// Used to create a shared secret, known only to the owner of the PrivateKeys that shared their PublicKey. -public struct PublicKey: Codable, Equatable { +public struct PublicKey: Codable, Equatable, @unchecked Sendable { fileprivate let publicKey: PublicKeyAgreementKeyAlg fileprivate init(publicKey: PublicKeyAgreementKeyAlg) { self.publicKey = publicKey } + public init?(data: Data) { + do { + publicKey = try PublicKeyAgreementKeyAlg(rawRepresentation: data) + } catch { + return nil + } + } + + public var data: Data { + publicKey.rawRepresentation + } + public func encode(to encoder: Encoder) throws { try publicKey.rawRepresentation.encode(to: encoder) } diff --git a/Sources/CypherProtocol/DeviceId.swift b/Sources/CypherProtocol/DeviceId.swift index ebdb8ed..f16ea47 100644 --- a/Sources/CypherProtocol/DeviceId.swift +++ b/Sources/CypherProtocol/DeviceId.swift @@ -1,7 +1,7 @@ import Foundation /// A helper wrapper around `String`, so that the type cannot be used interchangably with other String based types -public struct DeviceId: CustomStringConvertible, Identifiable, Codable, Hashable, Equatable, Comparable { +public struct DeviceId: CustomStringConvertible, Identifiable, Codable, Hashable, Equatable, Comparable, Sendable { public let raw: String public static func ==(lhs: DeviceId, rhs: DeviceId) -> Bool { diff --git a/Sources/CypherProtocol/DoubleRatchet.swift b/Sources/CypherProtocol/DoubleRatchet.swift index e33dd1c..db7bc72 100644 --- a/Sources/CypherProtocol/DoubleRatchet.swift +++ b/Sources/CypherProtocol/DoubleRatchet.swift @@ -2,7 +2,7 @@ // TODO: Header encryption import Foundation -import CryptoKit +import Crypto /// A symmetric key encryption & decryption helper public protocol RatchetSymmetricEncryption { @@ -184,7 +184,7 @@ public struct SkippedKey: Codable { } public struct DoubleRatchetHKDF { - public struct State: Codable { + public struct State: Codable, @unchecked Sendable { private enum CodingKeys: String, CodingKey { case rootKey = "a" case localPrivateKey = "b" @@ -328,7 +328,10 @@ public struct DoubleRatchetHKDF { ) throws -> (DoubleRatchetHKDF, Data) { let state = try State(secretKey: secretKey, localPrivateKey: localPrivateKey, configuration: configuration) var engine = DoubleRatchetHKDF(state: state, configuration: configuration) - let plaintext = try engine.ratchetDecrypt(initialMessage) + guard case let .success(plaintext) = try engine.ratchetDecrypt(initialMessage) else { + throw DoubleRatchetError.uninitializedRecipient + } + return (engine, plaintext) } @@ -378,7 +381,7 @@ public struct DoubleRatchetHKDF { /// The message used as input _must_ be created by the remote party `ratchetEncrypt` /// /// - Returns: The decrypted data - public mutating func ratchetDecrypt(_ message: RatchetMessage) throws -> Data { + public mutating func ratchetDecrypt(_ message: RatchetMessage) throws -> RatchetDecryptionResult { var skippedKeys = state.skippedKeys defer { state.skippedKeys = skippedKeys @@ -407,7 +410,7 @@ public struct DoubleRatchetHKDF { } } - func decodeUsingSkippedMessageKeys() throws -> Data? { + func decodeUsingSkippedMessageKeys() throws -> RatchetDecryptionResult? { for i in 0.. { } // 1. Try skipped message keys - if let plaintext = try decodeUsingSkippedMessageKeys() { - return plaintext + if let result = try decodeUsingSkippedMessageKeys() { + return result } // 2. Check if the publicKey matches the current key @@ -452,6 +455,8 @@ public struct DoubleRatchetHKDF { try skipMessageKeys(until: message.header.previousChainLength) state.skippedKeys = skippedKeys try diffieHellmanRatchet() + } else if message.header.messageNumber < state.receivedMessages { + return .keyExpiry } // 3.a. On-mismatch, Skip ahead in message keys until max. Store all the inbetween message keys in a history @@ -469,7 +474,7 @@ public struct DoubleRatchetHKDF { return try decryptMessage(message, usingKey: messageKey) } - private func decryptMessage(_ message: RatchetMessage, usingKey messageKey: SymmetricKey) throws -> Data { + private func decryptMessage(_ message: RatchetMessage, usingKey messageKey: SymmetricKey) throws -> RatchetDecryptionResult { let headerData = try configuration.headerEncoder.encodeRatchetHeader(message.header) let nonce = configuration.headerEncoder.concatenate( authenticatedData: configuration.headerAssociatedDataGenerator.generateAssociatedData(), @@ -480,11 +485,11 @@ public struct DoubleRatchetHKDF { throw DoubleRatchetError.invalidNonceLength } - return try configuration.symmetricEncryption.decrypt( + return .success(try configuration.symmetricEncryption.decrypt( message.ciphertext, nonce: nonce, usingKey: messageKey - ) + )) } } @@ -515,6 +520,11 @@ public struct RatchetMessage: Codable { let ciphertext: Data } +public enum RatchetDecryptionResult { + case success(Data) + case keyExpiry +} + enum DoubleRatchetError: Error { case invalidRootKeySize, uninitializedRecipient, tooManySkippedMessages, invalidNonceLength } diff --git a/Sources/CypherProtocol/Message.swift b/Sources/CypherProtocol/Message.swift index e01339e..44fb984 100644 --- a/Sources/CypherProtocol/Message.swift +++ b/Sources/CypherProtocol/Message.swift @@ -136,9 +136,13 @@ extension RatchetMessage { public func decrypt( as type: D.Type, using engine: inout DoubleRatchetHKDF - ) throws -> D { - let data = try engine.ratchetDecrypt(self) - return try BSONDecoder().decode(type, from: Document(data: data)) + ) throws -> D? { + switch try engine.ratchetDecrypt(self) { + case .success(let data): + return try BSONDecoder().decode(type, from: Document(data: data)) + case .keyExpiry: + return nil + } } public init( diff --git a/Sources/CypherProtocol/Username.swift b/Sources/CypherProtocol/Username.swift index 0615d20..b32f721 100644 --- a/Sources/CypherProtocol/Username.swift +++ b/Sources/CypherProtocol/Username.swift @@ -1,5 +1,5 @@ /// A helper wrapper around `String`, so that the type cannot be used interchangably with other String based types -public struct Username: CustomStringConvertible, Identifiable, Codable, Hashable, Equatable, Comparable, ExpressibleByStringLiteral { +public struct Username: CustomStringConvertible, Identifiable, Codable, Hashable, Equatable, Comparable, ExpressibleByStringLiteral, Sendable { public let raw: String public static func ==(lhs: Username, rhs: Username) -> Bool { diff --git a/Sources/MessagingHelpers/Plugin.swift b/Sources/MessagingHelpers/Plugin.swift index ddbb0a5..74dc1ec 100644 --- a/Sources/MessagingHelpers/Plugin.swift +++ b/Sources/MessagingHelpers/Plugin.swift @@ -1,6 +1,6 @@ import CypherMessaging -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) public protocol Plugin { static var pluginIdentifier: String { get } @@ -21,7 +21,9 @@ public protocol Plugin { func onP2PClientClose(messenger: CypherMessenger) func onRemoveContact(_ contact: Contact) func onRemoveChatMessage(_ message: AnyChatMessage) - func onDeviceRegistery(_ deviceId: DeviceId, messenger: CypherMessenger) async throws + func onDeviceRegistery(_ deviceId: DeviceId, messenger: CypherMessenger) + func onOtherUserDeviceRegistery(username: Username, deviceId: DeviceId, messenger: CypherMessenger) + func onCustomConfigChange() } extension Plugin { @@ -42,78 +44,51 @@ extension Plugin { public func onP2PClientClose(messenger: CypherMessenger) {} public func onRemoveContact(_ contact: Contact) {} public func onRemoveChatMessage(_ message: AnyChatMessage) {} - public func onDeviceRegistery(_ deviceId: DeviceId, messenger: CypherMessenger) async throws {} + public func onDeviceRegistery(_ deviceId: DeviceId, messenger: CypherMessenger) {} + public func onOtherUserDeviceRegistery(username: Username, deviceId: DeviceId, messenger: CypherMessenger) {} + public func onCustomConfigChange() {} } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) extension Plugin { public var pluginIdentifier: String { Self.pluginIdentifier } } -@available(macOS 12, iOS 15, *) -extension DecryptedModel where M == ContactModel { - public func getProp( - fromMetadata type: C.Type, - forPlugin plugin: P.Type, - run: (C) throws -> Result - ) throws -> Result { - let pluginStorage = metadata[plugin.pluginIdentifier] ?? Document() - let pluginMetadata = try BSONDecoder().decode(type, fromPrimitive: pluginStorage) - return try run(pluginMetadata) - } - - public func withMetadata( - ofType type: C.Type, - forPlugin plugin: P.Type, - run: (inout C) throws -> Result - ) async throws -> Result { - var metadata = self.metadata - let pluginStorage = metadata[plugin.pluginIdentifier] ?? Document() - var pluginMetadata = try BSONDecoder().decode(type, fromPrimitive: pluginStorage) - let result = try run(&pluginMetadata) - metadata[plugin.pluginIdentifier] = try BSONEncoder().encode(pluginMetadata) - try await self.setProp(at: \.metadata, to: metadata) - - return result - } -} - extension Contact { - public func modifyMetadata( + @MainActor public func modifyMetadata( ofType type: C.Type, forPlugin plugin: P.Type, run: (inout C) throws -> Result ) async throws -> Result { - let result = try await model.withMetadata(ofType: type, forPlugin: plugin, run: run) - + let result = try model.withMetadata(ofType: type, forPlugin: plugin, run: run) try await self.save() return result } } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) extension DecryptedModel where M.SecureProps: MetadataProps { - public func getProp( + @MainActor public func getProp( ofType type: C.Type, forPlugin plugin: P.Type, - run: (C) throws -> Result + run: @Sendable (C) throws -> Result ) throws -> Result { let pluginStorage = props.metadata[plugin.pluginIdentifier] ?? Document() let pluginMetadata = try BSONDecoder().decode(type, fromPrimitive: pluginStorage) return try run(pluginMetadata) } - public func withMetadata( + @MainActor public func withMetadata( ofType type: C.Type, forPlugin plugin: P.Type, run: (inout C) throws -> Result - ) async throws -> Result { + ) throws -> Result { var metadata = self.props.metadata let pluginStorage = metadata[plugin.pluginIdentifier] ?? Document() var pluginMetadata = try BSONDecoder().decode(type, fromPrimitive: pluginStorage) let result = try run(&pluginMetadata) metadata[plugin.pluginIdentifier] = try BSONEncoder().encode(pluginMetadata) - try await self.setProp(at: \.metadata, to: metadata) + try self.setProp(at: \.metadata, to: metadata) return result } @@ -123,7 +98,7 @@ extension AnyConversation { public func modifyMetadata( ofType type: C.Type, forPlugin plugin: P.Type, - run: (inout C) throws -> Result + run: @Sendable (inout C) throws -> Result ) async throws -> Result { let result = try await conversation.withMetadata(ofType: type, forPlugin: plugin, run: run) try await self.save() @@ -131,7 +106,7 @@ extension AnyConversation { } } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) extension CypherMessenger { public func withCustomConfig( ofType type: C.Type, diff --git a/Sources/MessagingHelpers/PluginEventHandler.swift b/Sources/MessagingHelpers/PluginEventHandler.swift index 9612947..59322fd 100644 --- a/Sources/MessagingHelpers/PluginEventHandler.swift +++ b/Sources/MessagingHelpers/PluginEventHandler.swift @@ -1,6 +1,6 @@ import CypherMessaging -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) public struct PluginEventHandler: CypherMessengerEventHandler { private var plugins: [Plugin] @@ -30,8 +30,6 @@ public struct PluginEventHandler: CypherMessengerEventHandler { public func onReceiveMessage( _ message: ReceivedMessageContext ) async throws -> ProcessMessageAction { - // TODO: Parse synchronisation messages - for plugin in plugins { if let result = try await plugin.onReceiveMessage(message) { return result @@ -167,11 +165,21 @@ public struct PluginEventHandler: CypherMessengerEventHandler { } } - public func onDeviceRegistery(_ deviceId: DeviceId, messenger: CypherMessenger) async throws { + public func onDeviceRegistery(_ deviceId: DeviceId, messenger: CypherMessenger) { for plugin in plugins { - try await plugin.onDeviceRegistery(deviceId, messenger: messenger) + plugin.onDeviceRegistery(deviceId, messenger: messenger) + } + } + + public func onOtherUserDeviceRegistery(username: Username, deviceId: DeviceId, messenger: CypherMessenger) { + for plugin in plugins { + plugin.onOtherUserDeviceRegistery(username: username, deviceId: deviceId, messenger: messenger) + } + } + + public func onCustomConfigChange() { + for plugin in plugins { + plugin.onCustomConfigChange() } - - // TODO: Synchronise state to new device } } diff --git a/Sources/MessagingHelpers/Plugins/ChatActivityPlugin/ChatActivityPlugin.swift b/Sources/MessagingHelpers/Plugins/ChatActivityPlugin/ChatActivityPlugin.swift index 5002e73..508bfc2 100644 --- a/Sources/MessagingHelpers/Plugins/ChatActivityPlugin/ChatActivityPlugin.swift +++ b/Sources/MessagingHelpers/Plugins/ChatActivityPlugin/ChatActivityPlugin.swift @@ -8,7 +8,7 @@ fileprivate struct ChatActivityMetadata: Codable { // TODO: Use synchronisation framework for own devices // TODO: Select contacts to share the profile changes with // TODO: Broadcast to a user that doesn't have a private chat -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) public struct ChatActivityPlugin: Plugin { public static let pluginIdentifier = "@/chats/activity" @@ -47,9 +47,9 @@ public struct ChatActivityPlugin: Plugin { } } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) extension AnyConversation { - public var lastActivity: Date? { + @MainActor public var lastActivity: Date? { try? self.conversation.getProp( ofType: ChatActivityMetadata.self, forPlugin: ChatActivityPlugin.self, diff --git a/Sources/MessagingHelpers/Plugins/ContactProfile/UserProfilePlugin.swift b/Sources/MessagingHelpers/Plugins/ContactProfile/UserProfilePlugin.swift index dee24a4..b79646f 100644 --- a/Sources/MessagingHelpers/Plugins/ContactProfile/UserProfilePlugin.swift +++ b/Sources/MessagingHelpers/Plugins/ContactProfile/UserProfilePlugin.swift @@ -4,13 +4,16 @@ import NIO public struct ContactMetadata: Codable { public var status: String? public var nickname: String? + public var firstName: String? + public var lastName: String? + public var email: String? + public var phone: String? public var image: Data? } -// TODO: Use synchronisation framework for own devices // TODO: Select contacts to share the profile changes with // TODO: Broadcast to a user that doesn't have a private chat -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) public struct UserProfilePlugin: Plugin { enum RekeyAction { case none, resetProfile @@ -20,14 +23,88 @@ public struct UserProfilePlugin: Plugin { public init() {} - public func onContactIdentityChange(username: Username, messenger: CypherMessenger) { - Task.detached { - let contact = try await messenger.createContact(byUsername: username) - try await contact.modifyMetadata( + public func onDeviceRegistery(_ deviceId: DeviceId, messenger: CypherMessenger) { + Task { + let internalChat = try await messenger.getInternalConversation() + + try await messenger.withCustomConfig( ofType: ContactMetadata.self, forPlugin: Self.self ) { metadata in - metadata = .init() + if let status = metadata.status { + try await internalChat.sendMagicPacket( + messageSubtype: "@/contacts/profile/status/update", + text: status, + toDeviceId: deviceId + ) + } + + if let firstName = metadata.firstName, let lastName = metadata.lastName { + try await internalChat.sendMagicPacket( + messageSubtype: "@/contacts/profile/name/update", + text: "", + metadata: [ + "firstName": firstName, + "lastName": lastName, + ], + toDeviceId: deviceId + ) + } + + if let image = metadata.image { + try await internalChat.sendMagicPacket( + messageSubtype: "@/contacts/profile/picture/update", + text: "", + metadata: [ + "blob": Binary(buffer: ByteBuffer(data: image)) + ], + toDeviceId: deviceId + ) + } + } + } + } + + public func onOtherUserDeviceRegistery(username: Username, deviceId: DeviceId, messenger: CypherMessenger) { + Task { + // TODO: Select contacts to share the profile changes with + // TODO: Broadcast to a user that doesn't have a private chat + let chat = try await messenger.createPrivateChat(with: username) + + try await messenger.withCustomConfig( + ofType: ContactMetadata.self, + forPlugin: Self.self + ) { metadata in + if let status = metadata.status { + try await chat.sendMagicPacket( + messageSubtype: "@/contacts/profile/status/update", + text: status, + toDeviceId: deviceId + ) + } + + if let firstName = metadata.firstName, let lastName = metadata.lastName { + try await chat.sendMagicPacket( + messageSubtype: "@/contacts/profile/name/update", + text: "", + metadata: [ + "firstName": firstName, + "lastName": lastName, + ], + toDeviceId: deviceId + ) + } + + if let image = metadata.image { + try await chat.sendMagicPacket( + messageSubtype: "@/contacts/profile/picture/update", + text: "", + metadata: [ + "blob": Binary(buffer: ByteBuffer(data: image)) + ], + toDeviceId: deviceId + ) + } } } } @@ -46,51 +123,50 @@ public struct UserProfilePlugin: Plugin { let sender = message.sender.username let username = messenger.username - switch subType { - case "status/update": + func withMetadata(perform: @escaping (inout ContactMetadata) -> ()) async throws -> ProcessMessageAction { if sender == username { return try await messenger.modifyCustomConfig( ofType: ContactMetadata.self, forPlugin: Self.self ) { metadata in - metadata.status = message.message.text + perform(&metadata) return .ignore } - } - - let contact = try await messenger.createContact(byUsername: sender) - return try await contact.modifyMetadata( - ofType: ContactMetadata.self, - forPlugin: Self.self - ) { metadata in - metadata.status = message.message.text - return .ignore - } - case "picture/update": - guard let imageBlob = message.message.metadata["blob"] as? Binary else { - return .ignore - } - - let image = imageBlob.data - - if sender == username { - return try await messenger.modifyCustomConfig( + } else { + let contact = try await messenger.createContact(byUsername: sender) + return try await contact.modifyMetadata( ofType: ContactMetadata.self, forPlugin: Self.self ) { metadata in - metadata.image = image + perform(&metadata) return .ignore } } + } + + switch subType { + case "status/update": + return try await withMetadata { $0.status = message.message.text } + case "name/update": + guard + let firstName = message.message.metadata["firstName"] as? String, + let lastName = message.message.metadata["lastName"] as? String + else { + return .ignore + } - let contact = try await messenger.createContact(byUsername: sender) - return try await contact.modifyMetadata( - ofType: ContactMetadata.self, - forPlugin: Self.self - ) { metadata in - metadata.image = image + return try await withMetadata { metadata in + metadata.firstName = firstName + metadata.lastName = lastName + } + case "picture/update": + guard let imageBlob = message.message.metadata["blob"] as? Binary else { return .ignore } + + return try await withMetadata { metadata in + metadata.image = imageBlob.data + } default: return .ignore } @@ -111,33 +187,73 @@ public struct UserProfilePlugin: Plugin { } } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) extension Contact { - public var status: String? { + @MainActor public var status: String? { try? self.model.getProp( - fromMetadata: ContactMetadata.self, + ofType: ContactMetadata.self, forPlugin: UserProfilePlugin.self, run: \.status ) } - public var image: Data? { + @MainActor public var image: Data? { try? self.model.getProp( - fromMetadata: ContactMetadata.self, + ofType: ContactMetadata.self, forPlugin: UserProfilePlugin.self, run: \.image ) } - public var nickname: String { + @MainActor public var firstName: String? { + try? self.model.getProp( + ofType: ContactMetadata.self, + forPlugin: UserProfilePlugin.self, + run: \.firstName + ) + } + + @MainActor public var lastName: String? { + try? self.model.getProp( + ofType: ContactMetadata.self, + forPlugin: UserProfilePlugin.self, + run: \.lastName + ) + } + + @MainActor public var email: String? { + try? self.model.getProp( + ofType: ContactMetadata.self, + forPlugin: UserProfilePlugin.self, + run: \.email + ) + } + + @MainActor public var phone: String? { + try? self.model.getProp( + ofType: ContactMetadata.self, + forPlugin: UserProfilePlugin.self, + run: \.phone + ) + } + + @MainActor public var nickname: String? { (try? self.model.getProp( - fromMetadata: ContactMetadata.self, + ofType: ContactMetadata.self, forPlugin: UserProfilePlugin.self, run: \.nickname - )) ?? self.username.raw + )) + } + + @MainActor public var contactMetadata: ContactMetadata? { + try? self.model.getProp( + ofType: ContactMetadata.self, + forPlugin: UserProfilePlugin.self, + run: { $0 } + ) } - public func setNickname(to nickname: String) async throws { + @CryptoActor public func setNickname(to nickname: String) async throws { try await self.model.withMetadata( ofType: ContactMetadata.self, forPlugin: UserProfilePlugin.self @@ -147,14 +263,14 @@ extension Contact { } } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) extension CypherMessenger { public func changeProfileStatus( to status: String ) async throws { for contact in try await listContacts() { let chat = try await createPrivateChat(with: contact.model.username) - _ = try await chat.sendRawMessage( + try await chat.sendRawMessage( type: .magic, messageSubtype: "@/contacts/profile/status/update", text: status, @@ -163,7 +279,7 @@ extension CypherMessenger { } let chat = try await getInternalConversation() - _ = try await chat.sendRawMessage( + try await chat.sendRawMessage( type: .magic, messageSubtype: "@/contacts/profile/status/update", text: status, @@ -178,32 +294,53 @@ extension CypherMessenger { } } - public func changeProfilePicture( - to data: Data - ) async throws { + // TODO: Update firstName, lastName, phone, email + // TODO: Allow app to control which contacts get this info + + private func sendProfileUpdate(subtype: String, text: String, metadata: Document = [:]) async throws { + // TODO: limit who can see your changes? for contact in try await listContacts() { let chat = try await createPrivateChat(with: contact.model.username) - _ = try await chat.sendRawMessage( - type: .magic, - messageSubtype: "@/contacts/profile/picture/update", - text: "", - metadata: [ - "blob": data - ], + try await chat.sendMagicPacketMessage( + messageSubtype: "@/contacts/profile/\(subtype)", + text: text, + metadata: metadata, preferredPushType: .none ) } let chat = try await getInternalConversation() - _ = try await chat.sendRawMessage( - type: .magic, - messageSubtype: "@/contacts/profile/picture/update", - text: "", - metadata: [ - "blob": data - ], - preferredPushType: .none + try await chat.sendMagicPacket( + messageSubtype: "@/contacts/profile/\(subtype)", + text: text, + metadata: metadata ) + } + + public func changeName( + firstName: String, + lastName: String + ) async throws { + try await sendProfileUpdate(subtype: "name/update", text: "", metadata: [ + "firstName": firstName, + "lastName": lastName + ]) + + try await self.modifyCustomConfig( + ofType: ContactMetadata.self, + forPlugin: UserProfilePlugin.self + ) { metadata in + metadata.firstName = firstName + metadata.lastName = lastName + } + } + + public func changeProfilePicture( + to data: Data + ) async throws { + try await sendProfileUpdate(subtype: "picture/update", text: "", metadata: [ + "blob": data + ]) try await self.modifyCustomConfig( ofType: ContactMetadata.self, diff --git a/Sources/MessagingHelpers/Plugins/FriendshipPlugin/FriendshipPlugin.swift b/Sources/MessagingHelpers/Plugins/FriendshipPlugin/FriendshipPlugin.swift index 2976ece..005a0b2 100644 --- a/Sources/MessagingHelpers/Plugins/FriendshipPlugin/FriendshipPlugin.swift +++ b/Sources/MessagingHelpers/Plugins/FriendshipPlugin/FriendshipPlugin.swift @@ -44,7 +44,7 @@ fileprivate struct ChangeFriendshipState: Codable { let subject: Username } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) public struct FriendshipPlugin: Plugin { public static let pluginIdentifier = "@/contacts/friendship" public let ruleset: FriendshipRuleset @@ -53,10 +53,10 @@ public struct FriendshipPlugin: Plugin { self.ruleset = ruleset } - public func onReceiveMessage(_ message: ReceivedMessageContext) async throws -> ProcessMessageAction? { + @MainActor public func onReceiveMessage(_ message: ReceivedMessageContext) async throws -> ProcessMessageAction? { let senderUsername = message.sender.username let target = await message.conversation.getTarget() - let username: Username = ""// = await message.messenger.username + let username = message.messenger.username if case .groupChat = target { if ruleset.blockAffectsGroupChats, senderUsername != username { @@ -67,7 +67,7 @@ public struct FriendshipPlugin: Plugin { return nil } - if senderUsername == username{ + if senderUsername == username { guard case .currentUser = target else { return nil } @@ -94,9 +94,9 @@ public struct FriendshipPlugin: Plugin { default: () } - - return .ignore } + + return nil } let contact = try await message.messenger.createContact(byUsername: senderUsername) @@ -122,7 +122,7 @@ public struct FriendshipPlugin: Plugin { case "query": let changedState = try BSONEncoder().encode( ChangeFriendshipState( - newState: contact.ourState, + newState: contact.ourFriendshipState, subject: contact.username ) ) @@ -140,7 +140,7 @@ public struct FriendshipPlugin: Plugin { } } - switch (contact.ourState, contact.theirState) { + switch (contact.ourFriendshipState, contact.theirFriendshipState) { case (.blocked, _), (_, .blocked): return .ignore case (.undecided, _), (_, .undecided): @@ -156,6 +156,14 @@ public struct FriendshipPlugin: Plugin { } } + public func onDeviceRegistery(_ deviceId: DeviceId, messenger: CypherMessenger) { + // TODO: Sync states to new device + } + + public func onOtherUserDeviceRegistery(username: Username, deviceId: DeviceId, messenger: CypherMessenger) { + // TODO: Sync states to new device + } + public func createContactMetadata(for username: Username, messenger: CypherMessenger) async throws -> Document { let metadata = FriendshipMetadata( ourState: .undecided, @@ -166,9 +174,9 @@ public struct FriendshipPlugin: Plugin { } } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) extension Contact { - public var ourState: FriendshipStatus { + @MainActor public var ourFriendshipState: FriendshipStatus { (try? self.model.getProp( ofType: FriendshipMetadata.self, forPlugin: FriendshipPlugin.self, @@ -176,7 +184,7 @@ extension Contact { )) ?? .undecided } - public var theirState: FriendshipStatus { + @MainActor public var theirFriendshipState: FriendshipStatus { (try? self.model.getProp( ofType: FriendshipMetadata.self, forPlugin: FriendshipPlugin.self, @@ -184,7 +192,7 @@ extension Contact { )) ?? .undecided } - public var isMutualFriendship: Bool { + @MainActor public var isMutualFriendship: Bool { (try? self.model.getProp( ofType: FriendshipMetadata.self, forPlugin: FriendshipPlugin.self, @@ -192,7 +200,7 @@ extension Contact { )) ?? false } - public var isBlocked: Bool { + @MainActor public var isBlocked: Bool { (try? self.model.getProp( ofType: FriendshipMetadata.self, forPlugin: FriendshipPlugin.self, @@ -200,21 +208,21 @@ extension Contact { )) ?? false } - public func block() async throws { + @MainActor public func block() async throws { try await changeOurState(to: .blocked) } - public func befriend() async throws { + @MainActor public func befriend() async throws { try await changeOurState(to: .friend) } - public func unfriend() async throws { + @MainActor public func unfriend() async throws { try await changeOurState(to: .notFriend) } - public func query() async throws { + @MainActor public func query() async throws { let privateChat = try await self.messenger.createPrivateChat(with: self.username) - _ = try await privateChat.sendRawMessage( + try await privateChat.sendRawMessage( type: .magic, messageSubtype: "@/contacts/friendship/query", text: "", @@ -222,12 +230,12 @@ extension Contact { ) } - public func unblock() async throws { - guard ourState == .blocked else { + @MainActor public func unblock() async throws { + guard ourFriendshipState == .blocked else { return } - let oldState = (try? await self.model.withMetadata( + let oldState = (try? self.model.withMetadata( ofType: FriendshipMetadata.self, forPlugin: FriendshipPlugin.self, run: \.ourPreBlockedState @@ -236,7 +244,7 @@ extension Contact { return try await changeOurState(to: oldState) } - fileprivate func changeOurState(to newState: FriendshipStatus) async throws { + @MainActor fileprivate func changeOurState(to newState: FriendshipStatus) async throws { try await self.modifyMetadata( ofType: FriendshipMetadata.self, forPlugin: FriendshipPlugin.self @@ -259,17 +267,8 @@ extension Contact { ) ) - let internalChat = try await self.messenger.getInternalConversation() - _ = try await internalChat.sendRawMessage( - type: .magic, - messageSubtype: "@/contacts/friendship/change-state", - text: "", - metadata: message, - preferredPushType: .none - ) - let privateChat = try await self.messenger.createPrivateChat(with: self.username) - _ = try await privateChat.sendRawMessage( + try await privateChat.sendRawMessage( type: .magic, messageSubtype: "@/contacts/friendship/change-state", text: "", diff --git a/Sources/MessagingHelpers/Plugins/GroupMembershipPlugin/GroupMembershipPlugin.swift b/Sources/MessagingHelpers/Plugins/GroupMembershipPlugin/GroupMembershipPlugin.swift index b4abe44..3d06ef0 100644 --- a/Sources/MessagingHelpers/Plugins/GroupMembershipPlugin/GroupMembershipPlugin.swift +++ b/Sources/MessagingHelpers/Plugins/GroupMembershipPlugin/GroupMembershipPlugin.swift @@ -1,8 +1 @@ -// -// File.swift -// -// -// Created by Joannis Orlandos on 04/06/2021. -// - -import Foundation +// TODO: Group name, notification preferences diff --git a/Sources/MessagingHelpers/Plugins/ModifyMessagePlugin/ModifyMessagePlugin.swift b/Sources/MessagingHelpers/Plugins/ModifyMessagePlugin/ModifyMessagePlugin.swift index 3733343..4cdbb9d 100644 --- a/Sources/MessagingHelpers/Plugins/ModifyMessagePlugin/ModifyMessagePlugin.swift +++ b/Sources/MessagingHelpers/Plugins/ModifyMessagePlugin/ModifyMessagePlugin.swift @@ -1,10 +1,11 @@ import CypherMessaging -@available(macOS 12, iOS 15, *) +// TODO: Edit message history? +@available(macOS 10.15, iOS 13, *) public struct ModifyMessagePlugin: Plugin { public static let pluginIdentifier = "@/messaging/mutate-history" - public func onReceiveMessage(_ message: ReceivedMessageContext) async throws -> ProcessMessageAction? { + @MainActor public func onReceiveMessage(_ message: ReceivedMessageContext) async throws -> ProcessMessageAction? { guard message.message.messageType == .magic, var subType = message.message.messageSubtype, @@ -31,7 +32,7 @@ public struct ModifyMessagePlugin: Plugin { } } - public func onSendMessage( + @CryptoActor public func onSendMessage( _ message: SentMessageContext ) async throws -> SendMessageAction? { guard @@ -46,11 +47,11 @@ public struct ModifyMessagePlugin: Plugin { } } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) extension AnyChatMessage { - public func revoke() async throws { + @CryptoActor public func revoke() async throws { let chat = try await self.target.resolve(in: self.messenger) - _ = try await chat.sendRawMessage( + try await chat.sendRawMessage( type: .magic, messageSubtype: "@/messaging/mutate-history/revoke", text: self.raw.encrypted.remoteId, diff --git a/Sources/MessagingHelpers/Plugins/SwiftUIEventEmitterPlugin.swift b/Sources/MessagingHelpers/Plugins/SwiftUIEventEmitterPlugin.swift index 4bb9d1b..7637d8a 100644 --- a/Sources/MessagingHelpers/Plugins/SwiftUIEventEmitterPlugin.swift +++ b/Sources/MessagingHelpers/Plugins/SwiftUIEventEmitterPlugin.swift @@ -1,4 +1,4 @@ -//#if canImport(SwiftUI) && canImport(Combine) +#if canImport(SwiftUI) && canImport(Combine) && (os(macOS) || os(iOS)) import SwiftUI import CypherMessaging import Combine @@ -11,6 +11,8 @@ public final class SwiftUIEventEmitter: ObservableObject { public let chatMessageRemoved = PassthroughSubject() public let conversationChanged = PassthroughSubject() public let contactChanged = PassthroughSubject() + public let userDevicesChanged = PassthroughSubject() + public let customConfigChanged = PassthroughSubject() public let p2pClientConnected = PassthroughSubject() @@ -19,16 +21,25 @@ public final class SwiftUIEventEmitter: ObservableObject { @Published public private(set) var conversations = [TargetConversation.Resolved]() @Published public fileprivate(set) var contacts = [Contact]() - let sortChats: (TargetConversation.Resolved, TargetConversation.Resolved) -> Bool + let sortChats: @MainActor @Sendable (TargetConversation.Resolved, TargetConversation.Resolved) -> Bool - public init(sortChats: @escaping (TargetConversation.Resolved, TargetConversation.Resolved) -> Bool) { + public init(sortChats: @escaping @Sendable @MainActor (TargetConversation.Resolved, TargetConversation.Resolved) -> Bool) { self.sortChats = sortChats } - public func boot(for messenger: CypherMessenger) async { + @MainActor public func boot(for messenger: CypherMessenger) async { do { self.conversations = try await messenger.listConversations(includingInternalConversation: true, increasingOrder: sortChats) self.contacts = try await messenger.listContacts() + + Task { + while !messenger.isOnline { + try await Task.sleep(nanoseconds: NSEC_PER_SEC) + } + + try await Task.sleep(nanoseconds: NSEC_PER_SEC) + await messenger.resumeJobQueue() + } } catch {} } } @@ -51,62 +62,58 @@ public struct SwiftUIEventEmitterPlugin: Plugin { } } + public func onDeviceRegistery(_ deviceId: DeviceId, messenger: CypherMessenger) async throws { + DispatchQueue.main.async { + emitter.userDevicesChanged.send() + } + } + public func onMessageChange(_ message: AnyChatMessage) { DispatchQueue.main.async { emitter.chatMessageChanged.send(message) } } - public func onConversationChange(_ conversation: AnyConversation) { + public func onConversationChange(_ viewModel: AnyConversation) { Task.detached { - let conversation = await conversation.resolveTarget() + let viewModel = await viewModel.resolveTarget() DispatchQueue.main.async { - emitter.conversationChanged.send(conversation) + emitter.conversationChanged.send(viewModel) } } } public func onContactChange(_ contact: Contact) { - DispatchQueue.main.async { - emitter.contactChanged.send(contact) - } + emitter.contactChanged.send(contact) } public func onCreateContact(_ contact: Contact, messenger: CypherMessenger) { - DispatchQueue.main.async { - emitter.contacts.append(contact) - emitter.contactAdded.send(contact) - } + emitter.contacts.append(contact) + emitter.contactAdded.send(contact) } - public func onCreateConversation(_ conversation: AnyConversation) { - DispatchQueue.main.async { - emitter.conversationAdded.send(conversation) - } + public func onCreateConversation(_ viewModel: AnyConversation) { + emitter.conversationAdded.send(viewModel) } public func onCreateChatMessage(_ chatMessage: AnyChatMessage) { - DispatchQueue.main.async { - self.emitter.savedChatMessages.send(chatMessage) - } + self.emitter.savedChatMessages.send(chatMessage) } public func onRemoveContact(_ contact: Contact) { - DispatchQueue.main.async { - self.emitter.contacts.removeAll { $0.id == contact.id } - } + self.emitter.contacts.removeAll { $0.id == contact.id } } public func onRemoveChatMessage(_ message: AnyChatMessage) { - DispatchQueue.main.async { - self.emitter.chatMessageRemoved.send(message) - } + self.emitter.chatMessageRemoved.send(message) } public func onP2PClientOpen(_ client: P2PClient, messenger: CypherMessenger) { - DispatchQueue.main.async { - emitter.p2pClientConnected.send(client) - } + emitter.p2pClientConnected.send(client) + } + + public func onCustomConfigChange() { + emitter.customConfigChanged.send() } } -//#endif +#endif diff --git a/Sources/MessagingHelpers/Plugins/UserVerificationPlugin/VerificationPlugin.swift b/Sources/MessagingHelpers/Plugins/UserVerificationPlugin/VerificationPlugin.swift new file mode 100644 index 0000000..23dc554 --- /dev/null +++ b/Sources/MessagingHelpers/Plugins/UserVerificationPlugin/VerificationPlugin.swift @@ -0,0 +1,85 @@ +import CypherMessaging +import NIO + +fileprivate struct UserVerificationMetadata: Codable { + var isVerified: Bool +} + +@available(macOS 10.15, iOS 13, *) +public struct UserVerificationPlugin: Plugin { + public static let pluginIdentifier = "@/user-verification" + + public init() {} + + public func onDeviceRegistery(_ deviceId: DeviceId, messenger: CypherMessenger) { + Task { + var verifiedFlags = Document() + let internalChat = try await messenger.getInternalConversation() + + for contact in try await messenger.listContacts() { + await verifiedFlags[contact.username.raw] = contact.isVerified + } + + try await internalChat.sendMagicPacket( + messageSubtype: Self.pluginIdentifier, + text: "", + metadata: verifiedFlags, + toDeviceId: deviceId + ) + } + } + + public func onReceiveMessage(_ message: ReceivedMessageContext) async throws -> ProcessMessageAction? { + guard message.message.messageType == .magic, message.message.messageSubtype == Self.pluginIdentifier else { + return nil + } + + for (username, value) in message.message.metadata { + let username = Username(username) + guard let isVerified = value as? Bool else { + // Unknown operation + debugLog("Unknown verification change") + continue + } + + let contact = try await message.messenger.createContact(byUsername: username) + try await contact.modifyMetadata( + ofType: UserVerificationMetadata.self, + forPlugin: UserVerificationPlugin.self + ) { metadata in + metadata.isVerified = isVerified + } + } + + return .ignore + } +} + +@available(macOS 10.15, iOS 13, *) +extension Contact { + @MainActor public var isVerified: Bool { + (try? self.model.getProp( + ofType: UserVerificationMetadata.self, + forPlugin: UserVerificationPlugin.self, + run: \.isVerified + )) ?? false + } + + @MainActor func setVerification(to isVerified: Bool) async throws { + try await modifyMetadata( + ofType: UserVerificationMetadata.self, + forPlugin: UserVerificationPlugin.self + ) { metadata in + metadata.isVerified = isVerified + } + + let internalChat = try await messenger.getInternalConversation() + try await internalChat.sendMagicPacket( + messageSubtype: UserVerificationPlugin.pluginIdentifier, + text: "", + metadata: [ + self.username.raw: isVerified + ] + ) + } +} diff --git a/Sources/MessagingHelpers/VaporTransport.swift b/Sources/MessagingHelpers/VaporTransport.swift index 464a5e7..f7f0664 100644 --- a/Sources/MessagingHelpers/VaporTransport.swift +++ b/Sources/MessagingHelpers/VaporTransport.swift @@ -1,3 +1,4 @@ +#if os(iOS) || os(macOS) import BSON import Foundation import CypherProtocol @@ -25,8 +26,19 @@ extension TransportCreationRequest: JWTAlgorithm { } public struct UserDeviceId: Hashable, Codable { - let user: Username - let device: DeviceId + public let user: Username + public let device: DeviceId + + public init(user: Username, device: DeviceId) { + self.user = user + self.device = device + } +} + +public struct Blob: Codable { + public let _id: String + public let creator: Username + public var document: C } struct Token: JWTPayload { @@ -52,7 +64,7 @@ public struct UserProfile: Decodable { enum MessageType: String, Codable { case message = "a" case multiRecipientMessage = "b" - case readReceipt = "c" + case receipt = "c" case ack = "d" } @@ -74,22 +86,23 @@ struct ChatMultiRecipientMessagePacket: Codable { let multiRecipientMessage: MultiRecipientCypherMessage } -struct ReadReceiptPacket: Codable { +struct ReadReceipt: Codable { enum State: Int, Codable { case received = 0 case displayed = 1 } - let _id: ObjectId let messageId: String let state: State - let sender: UserDeviceId + let sender: Username + let senderDevice: UserDeviceId let recipient: UserDeviceId + let receivedAt: Date } -let maxBodySize = 500_000 +let maxBodySize = 4_000_000 -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) extension URLSession { func getBSON( httpHost: String, @@ -110,6 +123,7 @@ extension URLSession { return try BSONDecoder().decode(type, from: Document(data: data)) } + @discardableResult func postBSON( httpHost: String, url: String, @@ -121,6 +135,7 @@ extension URLSession { var request = URLRequest(url: URL(string: "\(httpHost)/\(url)")!) request.httpMethod = "POST" request.addValue("application/bson", forHTTPHeaderField: "Content-Type") + request.addValue("application/bson", forHTTPHeaderField: "Accept") request.addValue(username.raw, forHTTPHeaderField: "X-API-User") request.addValue(deviceId.raw, forHTTPHeaderField: "X-API-Device") if let token = token { @@ -177,6 +192,8 @@ public final class VaporTransport: CypherServerTransportClient { let host: String var httpHost: String { "https://\(host)" } var appleToken: String? + public let isConnected = true + public private(set) var authenticated = AuthenticationState.unauthenticated private var wantsConnection = true private var webSocket: WebSocket? private(set) var signer: TransportCreationRequest @@ -284,8 +301,6 @@ public final class VaporTransport: CypherServerTransportClient { return transport } - public private(set) var authenticated = AuthenticationState.unauthenticated - private func makeToken() -> String? { return try? JWTSigner(algorithm: signer).sign( Token( @@ -300,7 +315,6 @@ public final class VaporTransport: CypherServerTransportClient { public func disconnect() async { do { - self.authenticated = .unauthenticated self.wantsConnection = false return try await (webSocket?.close() ?? eventLoop.makeSucceededVoidFuture()).get() } catch {} @@ -308,7 +322,11 @@ public final class VaporTransport: CypherServerTransportClient { public func reconnect() async { do { - if authenticated == .authenticated { + if + authenticated == .authenticated, + let webSocket = webSocket, + !webSocket.isClosed + { // Already connected return } @@ -327,13 +345,13 @@ public final class VaporTransport: CypherServerTransportClient { try await WebSocket.connect( to: "wss://\(host)/websocket", headers: headers, - configuration: .init(maxFrameSize: 512_000), + configuration: .init(maxFrameSize: maxBodySize), on: eventLoop ) { webSocket in self.webSocket = webSocket self.authenticated = .authenticated - _ = webSocket.eventLoop.scheduleRepeatedTask( + webSocket.eventLoop.scheduleRepeatedTask( initialDelay: .seconds(15), delay: .seconds(15) ) { task in @@ -356,6 +374,11 @@ public final class VaporTransport: CypherServerTransportClient { let body: Document } + struct Receipt: Codable { + let id: ObjectId + let type: MessageType + } + struct Ack: Codable { let type: MessageType let id: ObjectId @@ -379,7 +402,8 @@ public final class VaporTransport: CypherServerTransportClient { message.message, id: message.messageId, byUser: message.sender.user, - deviceId: message.sender.device + deviceId: message.sender.device, + createdAt: message.createdAt ) ) case .multiRecipientMessage: @@ -390,34 +414,34 @@ public final class VaporTransport: CypherServerTransportClient { message.multiRecipientMessage, id: message.messageId, byUser: message.sender.user, - deviceId: message.sender.device + deviceId: message.sender.device, + createdAt: message.createdAt ) ) - case .readReceipt: - let receipt = try BSONDecoder().decode(ReadReceiptPacket.self, from: packet.body) + case .receipt: + let receipt = try BSONDecoder().decode(ReadReceipt.self, from: packet.body) switch receipt.state { case .displayed: - // delegate.receiveServerEvent(.) - () + try await delegate.receiveServerEvent(.messageDisplayed(by: receipt.sender, deviceId: receipt.senderDevice.device, id: receipt.messageId, receivedAt: receipt.receivedAt)) case .received: - // delegate.receiveServerEvent(.messageReceived(by: receipt., deviceId: receipt.sender, id: receipt.messageId)) - () + try await delegate.receiveServerEvent(.messageReceived(by: receipt.sender, deviceId: receipt.senderDevice.device, id: receipt.messageId, receivedAt: receipt.receivedAt)) } case .ack: () } let ack = try BSONEncoder().encode(Ack(id: packet.id)).makeData() - webSocket.send(raw: ack, opcode: .binary) + try await webSocket.send(raw: ack, opcode: .binary) } catch { - _ = await transport.disconnect() + await transport.disconnect() } } } webSocket.onClose.whenComplete { [weak self] _ in if let transport = self, transport.wantsConnection == true { + transport.authenticated = .unauthenticated Task.detached { await transport.reconnect() } @@ -448,7 +472,7 @@ public final class VaporTransport: CypherServerTransportClient { } public func publishKeyBundle(_ data: UserConfig) async throws { - _ = try await self.httpClient.postBSON( + try await self.httpClient.postBSON( httpHost: httpHost, url: "current-user/config", username: self.username, @@ -459,7 +483,7 @@ public final class VaporTransport: CypherServerTransportClient { } public func registerAPNSToken(_ token: Data) async throws { - _ = try await self.httpClient.postBSON( + try await self.httpClient.postBSON( httpHost: httpHost, url: "current-device/token", username: self.username, @@ -478,11 +502,43 @@ public final class VaporTransport: CypherServerTransportClient { } public func publishBlob(_ blob: C) async throws -> ReferencedBlob where C : Decodable, C : Encodable { - fatalError() + let (data, response) = try await httpClient.postBSON( + httpHost: httpHost, + url: "blobs", + username: self.username, + deviceId: self.deviceId, + token: self.makeToken(), + body: blob + ) + + if let response = response as? HTTPURLResponse { + guard response.statusCode >= 200 && response.statusCode < 300 else { + debugLog("Status code \(response.statusCode)") + throw VaporTransportError.sendMessageFailed + } + } + + let document = Document(data: data) + + guard document.validate().isValid else { + throw VaporTransportError.sendMessageFailed + } + + let blob = try BSONDecoder().decode(Blob.self, from: document) + return ReferencedBlob(id: blob._id, blob: blob.document) } - public func readPublishedBlob(byId id: String, as type: C.Type) async throws -> ReferencedBlob? where C : Decodable, C : Encodable { - fatalError() + public func readPublishedBlob(byId id: String, as type: C.Type) async throws -> ReferencedBlob? { + let blob = try await httpClient.getBSON( + httpHost: httpHost, + url: "blobs/\(id)", + username: username, + deviceId: deviceId, + token: self.makeToken(), + as: Blob.self + ) + + return ReferencedBlob(id: blob._id, blob: blob.document) } public func sendMessage( @@ -492,7 +548,7 @@ public final class VaporTransport: CypherServerTransportClient { pushType: PushType, messageId: String ) async throws { - _ = try await self.httpClient.postBSON( + try await self.httpClient.postBSON( httpHost: httpHost, url: "users/\(username.raw)/devices/\(deviceId.raw)/send-message", username: self.username, @@ -511,7 +567,7 @@ public final class VaporTransport: CypherServerTransportClient { pushType: PushType, messageId: String ) async throws { - _ = try await self.httpClient.postBSON( + let (_, response) = try await self.httpClient.postBSON( httpHost: httpHost, url: "actions/send-message", username: self.username, @@ -523,6 +579,13 @@ public final class VaporTransport: CypherServerTransportClient { messageId: messageId ) ) + + if let response = response as? HTTPURLResponse { + guard response.statusCode >= 200 && response.statusCode < 300 else { + debugLog("Status code \(response.statusCode)") + throw VaporTransportError.sendMessageFailed + } + } } } @@ -550,3 +613,4 @@ extension DataProtocol { return String(bytesNoCopy: ptr, length: hexLen, encoding: .utf8, freeWhenDone: true)! } } +#endif diff --git a/Tests/CypherMessagingHelpersTests/ChatActivityPluginTests.swift b/Tests/CypherMessagingHelpersTests/ChatActivityPluginTests.swift index e506dc2..1a88c56 100644 --- a/Tests/CypherMessagingHelpersTests/ChatActivityPluginTests.swift +++ b/Tests/CypherMessagingHelpersTests/ChatActivityPluginTests.swift @@ -2,13 +2,13 @@ import XCTest import CypherMessaging import MessagingHelpers -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) final class ChatActivityPluginTests: XCTestCase { override func setUpWithError() throws { SpoofTransportClient.resetServer() } - func testPrivateChat() async throws { + @MainActor func testPrivateChat() async throws { let m0 = try await CypherMessenger.registerMessenger( username: "m0", authenticationMethod: .password("m0"), @@ -50,7 +50,7 @@ final class ChatActivityPluginTests: XCTestCase { XCTAssertNotNil(m1Chat.lastActivity) } - func testGroupChat() async throws { + @MainActor func testGroupChat() async throws { let m0 = try await CypherMessenger.registerMessenger( username: "m0", authenticationMethod: .password("m0"), diff --git a/Tests/CypherMessagingHelpersTests/FriendshipPluginTests.swift b/Tests/CypherMessagingHelpersTests/FriendshipPluginTests.swift index 0680199..fb49ef4 100644 --- a/Tests/CypherMessagingHelpersTests/FriendshipPluginTests.swift +++ b/Tests/CypherMessagingHelpersTests/FriendshipPluginTests.swift @@ -2,7 +2,7 @@ import XCTest import CypherMessaging import MessagingHelpers -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) struct Synchronisation { let apps: [CypherMessenger] @@ -24,11 +24,11 @@ struct Synchronisation { } } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) struct CustomMagicPacketPlugin: Plugin { static let pluginIdentifier = "custom-magic-packet" - let onInput: () -> () - let onOutput: () -> () + let onInput: @Sendable () async -> () + let onOutput: @Sendable () async -> () func onDeviceRegisteryRequest(_ config: UserDeviceConfig, messenger: CypherMessenger) async throws { try await messenger.addDevice(config) @@ -36,7 +36,7 @@ struct CustomMagicPacketPlugin: Plugin { func onReceiveMessage(_ message: ReceivedMessageContext) async throws -> ProcessMessageAction? { if message.message.messageSubtype == "custom-magic-packet" { - onInput() + await onInput() } return nil @@ -44,20 +44,20 @@ struct CustomMagicPacketPlugin: Plugin { func onSendMessage(_ message: SentMessageContext) async throws -> SendMessageAction? { if message.message.messageSubtype == "custom-magic-packet" { - onOutput() + await onOutput() } return nil } } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) final class FriendshipPluginTests: XCTestCase { override func setUpWithError() throws { SpoofTransportClient.resetServer() } - func testIgnoreUndecided() async throws { + @MainActor func testIgnoreUndecided() async throws { var ruleset = FriendshipRuleset() ruleset.ignoreWhenUndecided = true ruleset.blockAffectsGroupChats = false @@ -340,13 +340,20 @@ final class FriendshipPluginTests: XCTestCase { await XCTAssertAsyncEqual(try await m1GroupChat.allMessages(sortedBy: .descending).count, 1) } - func testBlockingCanPreventOtherPlugins() async throws { + @MainActor func testBlockingCanPreventOtherPlugins() async throws { var ruleset = FriendshipRuleset() ruleset.ignoreWhenUndecided = true ruleset.blockAffectsGroupChats = false ruleset.canIgnoreMagicPackets = true ruleset.preventSendingDisallowedMessages = false - var inputCount = 0 + actor Result { + var inputCount = 0 + + func inc() { + inputCount += 1 + } + } + let result = Result() let m0 = try await CypherMessenger.registerMessenger( username: "m0", @@ -357,7 +364,7 @@ final class FriendshipPluginTests: XCTestCase { eventHandler: PluginEventHandler(plugins: [ FriendshipPlugin(ruleset: ruleset), CustomMagicPacketPlugin(onInput: { - inputCount += 1 + await result.inc() }, onOutput: {}) ]) ) @@ -371,7 +378,7 @@ final class FriendshipPluginTests: XCTestCase { eventHandler: PluginEventHandler(plugins: [ FriendshipPlugin(ruleset: ruleset), CustomMagicPacketPlugin(onInput: { - inputCount += 1 + await result.inc() }, onOutput: {}) ]) ) @@ -392,7 +399,7 @@ final class FriendshipPluginTests: XCTestCase { try await sync.synchronise() - await XCTAssertAsyncEqual(inputCount, 0) + await XCTAssertAsyncEqual(await result.inputCount, 0) let m1Contact = try await m1.createContact(byUsername: "m0") try await m0Contact.befriend() @@ -411,7 +418,7 @@ final class FriendshipPluginTests: XCTestCase { try await sync.synchronise() - await XCTAssertAsyncEqual(inputCount, 1) + await XCTAssertAsyncEqual(await result.inputCount, 1) _ = m0 _ = m1 } diff --git a/Tests/CypherMessagingHelpersTests/UserProfilePluginTests.swift b/Tests/CypherMessagingHelpersTests/UserProfilePluginTests.swift index e7ff068..2e898a8 100644 --- a/Tests/CypherMessagingHelpersTests/UserProfilePluginTests.swift +++ b/Tests/CypherMessagingHelpersTests/UserProfilePluginTests.swift @@ -54,7 +54,7 @@ func XCTAssertAsyncTrue(_ run: @autoclosure () async throws -> Bool) async { } } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) struct AcceptAllDeviceRegisteriesPlugin: Plugin { static let pluginIdentifier = "accept-all-device-registeries" @@ -63,13 +63,13 @@ struct AcceptAllDeviceRegisteriesPlugin: Plugin { } } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) final class UserProfilePluginTests: XCTestCase { override func setUpWithError() throws { SpoofTransportClient.resetServer() } - func testChangeStatus() async throws { + @MainActor func testChangeStatus() async throws { let m0 = try await CypherMessenger.registerMessenger( username: "m0", authenticationMethod: .password("m0"), diff --git a/Tests/CypherMessagingTests/SDKTests.swift b/Tests/CypherMessagingTests/SDKTests.swift index d42eede..d525fc3 100644 --- a/Tests/CypherMessagingTests/SDKTests.swift +++ b/Tests/CypherMessagingTests/SDKTests.swift @@ -6,11 +6,25 @@ import CypherMessaging import SystemConfiguration import CypherProtocol -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) struct Synchronisation { let apps: [CypherMessenger] - func synchronise() async throws { + func flush(untilEmpty: Bool = false) async throws { + var hasWork = true + + repeat { + hasWork = false + + for app in apps { + if try await app.processJobQueue(untilEmpty: untilEmpty) == .synchronised { + hasWork = true + } + } + } while hasWork + } + + func synchronise(untilEmpty: Bool = false) async throws { var hasWork = true repeat { @@ -20,7 +34,7 @@ struct Synchronisation { } for app in apps { - if try await app.processJobQueue() == .synchronised { + if try await app.processJobQueue(untilEmpty: untilEmpty) == .synchronised { hasWork = true } } @@ -71,12 +85,16 @@ func XCTAssertAsyncEqual(_ run: @autoclosure () async throws -> T, } } -@available(macOS 12, iOS 15, *) +@available(macOS 10.15, iOS 13, *) final class CypherSDKTests: XCTestCase { override func setUpWithError() throws { SpoofTransportClient.resetServer() } + func testDisableMultiRecipientMessage() async throws { + + } + func testPrivateChatWithYourself() async throws { let m0 = try await CypherMessenger.registerMessenger( username: "m0", @@ -90,22 +108,489 @@ final class CypherSDKTests: XCTestCase { await XCTAssertThrowsAsyncError(try await m0.createPrivateChat(with: "m0")) } + @CypherTextKitActor func testOfflineSupport() async throws { + let m0 = try await CypherMessenger.registerMessenger( + username: "m0", + authenticationMethod: .password("m0"), + appPassword: "", + usingTransport: SpoofTransportClient.self, + database: MemoryCypherMessengerStore(), + eventHandler: SpoofCypherEventHandler() + ) + + let m1 = try await CypherMessenger.registerMessenger( + username: "m1", + authenticationMethod: .password("m1"), + appPassword: "", + usingTransport: SpoofTransportClient.self, + database: MemoryCypherMessengerStore(), + eventHandler: SpoofCypherEventHandler() + ) + + let sync = Synchronisation(apps: [m0, m1]) + try await sync.synchronise() + + try await m1.transport.disconnect() + let m0Chat = try await m0.createPrivateChat(with: "m1") + + _ = try await m0Chat.sendRawMessage( + type: .text, + text: "Hello", + preferredPushType: .none + ) + + _ = try await m0Chat.sendRawMessage( + type: .text, + text: "Hello", + preferredPushType: .none + ) + + try await sync.flush() + if (try? await m1.getPrivateChat(with: "m0")) != nil { + XCTFail() + } + + try await m1.transport.reconnect() + try await sync.synchronise(untilEmpty: true) + guard let m1Chat = try await m1.getPrivateChat(with: "m0") else { + return XCTFail() + } + + try await sync.synchronise() + await XCTAssertAsyncEqual(try await m0Chat.allMessages(sortedBy: .descending).count, 2) + await XCTAssertAsyncEqual(try await m1Chat.allMessages(sortedBy: .descending).count, 2) + + try await m1.transport.disconnect() + + for i in 0..<200 { + _ = try await m0Chat.sendRawMessage( + type: .text, + text: "Hello \(i)", + preferredPushType: .none + ) + } + + try await sync.flush() + await XCTAssertAsyncEqual(try await m0Chat.allMessages(sortedBy: .descending).count, 202) + await XCTAssertAsyncEqual(try await m1Chat.allMessages(sortedBy: .descending).count, 2) + + try await m0.transport.disconnect() + try await m1.transport.reconnect() + try await sync.flush() + await XCTAssertAsyncEqual(try await m0Chat.allMessages(sortedBy: .descending).count, 202) + await XCTAssertAsyncEqual(try await m1Chat.allMessages(sortedBy: .descending).count, 202) + + for i in 0..<150 { + _ = try await m1Chat.sendRawMessage( + type: .text, + text: "Hello \(i)", + preferredPushType: .none + ) + } + + await XCTAssertAsyncEqual(try await m0Chat.allMessages(sortedBy: .descending).count, 202) + await XCTAssertAsyncEqual(try await m1Chat.allMessages(sortedBy: .descending).count, 352) + + try await m0.transport.reconnect() + try await sync.synchronise() + await XCTAssertAsyncEqual(try await m0Chat.allMessages(sortedBy: .descending).count, 352) + await XCTAssertAsyncEqual(try await m1Chat.allMessages(sortedBy: .descending).count, 352) + } + + @CypherTextKitActor func testParallelOfflineSupport() async throws { + let m0 = try await CypherMessenger.registerMessenger( + username: "m0", + authenticationMethod: .password("m0"), + appPassword: "", + usingTransport: SpoofTransportClient.self, + database: MemoryCypherMessengerStore(), + eventHandler: SpoofCypherEventHandler() + ) + + let m1 = try await CypherMessenger.registerMessenger( + username: "m1", + authenticationMethod: .password("m1"), + appPassword: "", + usingTransport: SpoofTransportClient.self, + database: MemoryCypherMessengerStore(), + eventHandler: SpoofCypherEventHandler() + ) + + let sync = Synchronisation(apps: [m0, m1]) + try await sync.synchronise() + + try await m1.transport.disconnect() + let m0Chat = try await m0.createPrivateChat(with: "m1") + + _ = try await m0Chat.sendRawMessage( + type: .text, + text: "Hello", + preferredPushType: .none + ) + + _ = try await m0Chat.sendRawMessage( + type: .text, + text: "Hello", + preferredPushType: .none + ) + + try await sync.flush() + if (try? await m1.getPrivateChat(with: "m0")) != nil { + XCTFail() + } + + try await m1.transport.reconnect() + try await sync.synchronise(untilEmpty: true) + guard let m1Chat = try await m1.getPrivateChat(with: "m0") else { + return XCTFail() + } + + try await sync.synchronise() + await XCTAssertAsyncEqual(try await m0Chat.allMessages(sortedBy: .descending).count, 2) + await XCTAssertAsyncEqual(try await m1Chat.allMessages(sortedBy: .descending).count, 2) + + try await m1.transport.disconnect() + + for i in 0..<200 { + _ = try await m0Chat.sendRawMessage( + type: .text, + text: "Hello \(i)", + preferredPushType: .none + ) + } + + for i in 0..<150 { + _ = try await m1Chat.sendRawMessage( + type: .text, + text: "Hello \(i)", + preferredPushType: .none + ) + } + + while try await m0.processJobQueue(untilEmpty: true) == .synchronised {} + await XCTAssertAsyncEqual(try await m0Chat.allMessages(sortedBy: .descending).count, 202) + await XCTAssertAsyncEqual(try await m1Chat.allMessages(sortedBy: .descending).count, 152) + + try await m0.transport.disconnect() + try await m1.transport.reconnect() + while try await m1.processJobQueue(untilEmpty: true) == .synchronised {} + + await XCTAssertAsyncEqual(try await m0Chat.allMessages(sortedBy: .descending).count, 202) + await XCTAssertAsyncEqual(try await m1Chat.allMessages(sortedBy: .descending).count, 352) + + try await m0.transport.reconnect() + try await sync.synchronise() + await XCTAssertAsyncEqual(try await m0Chat.allMessages(sortedBy: .descending).count, 352) + await XCTAssertAsyncEqual(try await m1Chat.allMessages(sortedBy: .descending).count, 352) + } + + @CypherTextKitActor func testParallelHalfBackloggedOfflineSupport() async throws { + let m0 = try await CypherMessenger.registerMessenger( + username: "m0", + authenticationMethod: .password("m0"), + appPassword: "", + usingTransport: SpoofTransportClient.self, + database: MemoryCypherMessengerStore(), + eventHandler: SpoofCypherEventHandler() + ) + + let m1 = try await CypherMessenger.registerMessenger( + username: "m1", + authenticationMethod: .password("m1"), + appPassword: "", + usingTransport: SpoofTransportClient.self, + database: MemoryCypherMessengerStore(), + eventHandler: SpoofCypherEventHandler() + ) + + let sync = Synchronisation(apps: [m0, m1]) + try await sync.synchronise() + + try await m1.transport.disconnect() + let m0Chat = try await m0.createPrivateChat(with: "m1") + + _ = try await m0Chat.sendRawMessage( + type: .text, + text: "Hello", + preferredPushType: .none + ) + + _ = try await m0Chat.sendRawMessage( + type: .text, + text: "Hello", + preferredPushType: .none + ) + + try await sync.flush() + if (try? await m1.getPrivateChat(with: "m0")) != nil { + XCTFail() + } + + try await m1.transport.reconnect() + try await sync.synchronise(untilEmpty: true) + guard let m1Chat = try await m1.getPrivateChat(with: "m0") else { + return XCTFail() + } + + try await sync.synchronise() + await XCTAssertAsyncEqual(try await m0Chat.allMessages(sortedBy: .descending).count, 2) + await XCTAssertAsyncEqual(try await m1Chat.allMessages(sortedBy: .descending).count, 2) + + try await m1.transport.disconnect() + + for i in 0..<200 { + _ = try await m0Chat.sendRawMessage( + type: .text, + text: "Hello \(i)", + preferredPushType: .none + ) + } + + for i in 0..<150 { + _ = try await m1Chat.sendRawMessage( + type: .text, + text: "Hello \(i)", + preferredPushType: .none + ) + } + + while try await m0.processJobQueue(untilEmpty: true) == .synchronised {} + let poppedBacklog = SpoofTransportClientSettings.removeBacklog() + + for i in 0..<200 { + _ = try await m0Chat.sendRawMessage( + type: .text, + text: "Hello \(i)", + preferredPushType: .none + ) + } + + while try await m0.processJobQueue(untilEmpty: true) == .synchronised {} + SpoofTransportClientSettings.addBacklog(poppedBacklog) + + await XCTAssertAsyncEqual(try await m0Chat.allMessages(sortedBy: .descending).count, 402) + await XCTAssertAsyncEqual(try await m1Chat.allMessages(sortedBy: .descending).count, 152) + + try await m0.transport.disconnect() + try await m1.transport.reconnect() + while try await m1.processJobQueue(untilEmpty: true) == .synchronised {} + + try await m0.transport.reconnect() + try await sync.synchronise() + // Eventually consistent chats + await XCTAssertAsyncEqual(try await m0Chat.allMessages(sortedBy: .descending).count, 552) + await XCTAssertAsyncEqual(try await m1Chat.allMessages(sortedBy: .descending).count, 552) + } + + @CypherTextKitActor func testMeshSupport() async throws { + defer { SpoofP2PTransportFactory.clearMesh() } + + SpoofTransportClientSettings.isOffline = true + defer { SpoofTransportClientSettings.isOffline = false } + + let m0 = try await CypherMessenger.registerMessenger( + username: "m0", + authenticationMethod: .password("m0"), + appPassword: "", + usingTransport: SpoofTransportClient.self, + p2pFactories: [ + SpoofP2PTransportFactory(meshId: "m0") + ], + database: MemoryCypherMessengerStore(), + eventHandler: SpoofCypherEventHandler() + ) + + let m1_0 = try await CypherMessenger.registerMessenger( + username: "m1_0", + authenticationMethod: .password("m1"), + appPassword: "", + usingTransport: SpoofTransportClient.self, + p2pFactories: [ + SpoofP2PTransportFactory(meshId: "m1_0") + ], + database: MemoryCypherMessengerStore(), + eventHandler: SpoofCypherEventHandler() + ) + + let m1_1 = try await CypherMessenger.registerMessenger( + username: "m1_1", + authenticationMethod: .password("m1"), + appPassword: "", + usingTransport: SpoofTransportClient.self, + p2pFactories: [ + SpoofP2PTransportFactory(meshId: "m1_1") + ], + database: MemoryCypherMessengerStore(), + eventHandler: SpoofCypherEventHandler() + ) + + let m2 = try await CypherMessenger.registerMessenger( + username: "m2", + authenticationMethod: .password("m1"), + appPassword: "", + usingTransport: SpoofTransportClient.self, + p2pFactories: [ + SpoofP2PTransportFactory(meshId: "m2") + ], + database: MemoryCypherMessengerStore(), + eventHandler: SpoofCypherEventHandler() + ) + + let sync = Synchronisation(apps: [m0, m1_0, m1_1, m2]) + try await sync.synchronise() + + try await SpoofP2PTransportFactory.connectMesh(from: "m0", to: "m1_0") + try await SpoofP2PTransportFactory.connectMesh(from: "m0", to: "m1_1") + try await SpoofP2PTransportFactory.connectMesh(from: "m1_0", to: "m2") + try await SpoofP2PTransportFactory.connectMesh(from: "m1_1", to: "m2") + + let m0Card = try await m0.createContactCard() + let m2Card = try await m2.createContactCard() + + try await m2.importContactCard(m0Card) + try await m0.importContactCard(m2Card) + + let m0Chat = try await m0.createPrivateChat(with: "m2") + + _ = try await m0Chat.sendRawMessage( + type: .text, + text: "Hello", + preferredPushType: .none + ) + + try await sync.synchronise() + + guard let m2Chat = try await m2.getPrivateChat(with: "m0") else { + return XCTFail() + } + + await XCTAssertAsyncEqual(try await m0Chat.allMessages(sortedBy: .descending).count, 1) + await XCTAssertAsyncEqual(try await m2Chat.allMessages(sortedBy: .descending).count, 1) + + try await sync.synchronise() + + _ = try await m0Chat.sendRawMessage( + type: .text, + text: "Hello", + preferredPushType: .none + ) + + try await sync.synchronise() + + await XCTAssertAsyncEqual(try await m0Chat.allMessages(sortedBy: .descending).count, 2) + await XCTAssertAsyncEqual(try await m2Chat.allMessages(sortedBy: .descending).count, 2) + } + + @CypherTextKitActor func testP2PMeshNetworkTransport() async throws { + let m0 = try await CypherMessenger.registerMessenger( + username: "m0", + authenticationMethod: .password("m0"), + appPassword: "", + usingTransport: SpoofTransportClient.self, + p2pFactories: [ + SpoofP2PTransportFactory(meshId: "m0") + ], + database: MemoryCypherMessengerStore(), + eventHandler: SpoofCypherEventHandler() + ) + + let m1_0 = try await CypherMessenger.registerMessenger( + username: "m1_0", + authenticationMethod: .password("m1"), + appPassword: "", + usingTransport: SpoofTransportClient.self, + p2pFactories: [ + SpoofP2PTransportFactory(meshId: "m1_0") + ], + database: MemoryCypherMessengerStore(), + eventHandler: SpoofCypherEventHandler() + ) + + let m1_1 = try await CypherMessenger.registerMessenger( + username: "m1_1", + authenticationMethod: .password("m1"), + appPassword: "", + usingTransport: SpoofTransportClient.self, + p2pFactories: [ + SpoofP2PTransportFactory(meshId: "m1_1") + ], + database: MemoryCypherMessengerStore(), + eventHandler: SpoofCypherEventHandler() + ) + + let m2 = try await CypherMessenger.registerMessenger( + username: "m2", + authenticationMethod: .password("m1"), + appPassword: "", + usingTransport: SpoofTransportClient.self, + p2pFactories: [ + SpoofP2PTransportFactory(meshId: "m2") + ], + database: MemoryCypherMessengerStore(), + eventHandler: SpoofCypherEventHandler() + ) + + let sync = Synchronisation(apps: [m0, m1_0, m1_1, m2]) + try await sync.synchronise() + + let m0Chat = try await m0.createPrivateChat(with: "m2") + + _ = try await m0Chat.sendRawMessage( + type: .text, + text: "Hello", + preferredPushType: .none + ) + + try await sync.synchronise() + + let m2Chat = try await m2.getPrivateChat(with: "m0")! + + await XCTAssertAsyncEqual(try await m0Chat.allMessages(sortedBy: .descending).count, 1) + await XCTAssertAsyncEqual(try await m2Chat.allMessages(sortedBy: .descending).count, 1) + + SpoofTransportClientSettings.isOffline = true + defer { SpoofTransportClientSettings.isOffline = false } + + defer { SpoofP2PTransportFactory.clearMesh() } + try await SpoofP2PTransportFactory.connectMesh(from: "m0", to: "m1_0") + try await SpoofP2PTransportFactory.connectMesh(from: "m0", to: "m1_1") + try await SpoofP2PTransportFactory.connectMesh(from: "m1_0", to: "m2") + try await SpoofP2PTransportFactory.connectMesh(from: "m1_1", to: "m2") + + try await sync.synchronise() + + _ = try await m0Chat.sendRawMessage( + type: .text, + text: "Hello", + preferredPushType: .none + ) + + try await sync.synchronise() + + await XCTAssertAsyncEqual(try await m0Chat.allMessages(sortedBy: .descending).count, 2) + await XCTAssertAsyncEqual(try await m2Chat.allMessages(sortedBy: .descending).count, 2) + } + func testIPv6P2P() async throws { - try await runP2PTests(IPv6TCPP2PTransportClientFactory()) + try await runP2PTests { + IPv6TCPP2PTransportClientFactory() + } } func testInMemoryP2P() async throws { - try await runP2PTests(SpoofP2PTransportFactory()) + try await runP2PTests { + SpoofP2PTransportFactory() + } } - func runP2PTests(_ factory: Factory) async throws { + func runP2PTests(_ factory: () -> Factory) async throws { let m0 = try await CypherMessenger.registerMessenger( username: "m0", authenticationMethod: .password("m0"), appPassword: "", usingTransport: SpoofTransportClient.self, p2pFactories: [ - factory + factory() ], database: MemoryCypherMessengerStore(), eventHandler: SpoofCypherEventHandler() @@ -117,7 +602,7 @@ final class CypherSDKTests: XCTestCase { appPassword: "", usingTransport: SpoofTransportClient.self, p2pFactories: [ - factory + factory() ], database: MemoryCypherMessengerStore(), eventHandler: SpoofCypherEventHandler() @@ -155,7 +640,6 @@ final class CypherSDKTests: XCTestCase { preferredPushType: .none ) - try await sync.synchronise() await XCTAssertAsyncEqual(try await m0Chat.allMessages(sortedBy: .descending).count, 3) @@ -350,7 +834,16 @@ final class CypherSDKTests: XCTestCase { func testMultiDevicePrivateChatUnderLoad() async throws { struct DroppedPacket: Error {} - var droppedMessageIds = Set() + actor Result { + var droppedMessageIds = Set() + func addMessagedId(_ id: String) throws { + if !droppedMessageIds.contains(id) { + droppedMessageIds.insert(id) + throw DroppedPacket() + } + } + } + let result = Result() // Always retry, because we don't want the test to take forever _CypherTaskConfig.sendMessageRetryMode = .always SpoofTransportClientSettings.shouldDropPacket = { username, type in @@ -364,10 +857,7 @@ final class CypherSDKTests: XCTestCase { .readReceipt(remoteId: let id, otherUser: _), .receiveReceipt(remoteId: let id, otherUser: _): // Cause as much chaos as possible - if !droppedMessageIds.contains(id) { - droppedMessageIds.insert(id) - throw DroppedPacket() - } + try await result.addMessagedId(id) case .readKeyBundle, .publishBlob, .readBlob: if Bool.random() { throw DroppedPacket()