diff --git a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 51479c5c7..62d393ed9 100644 --- a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "1ec33718e75235b70c55ff8a05e587db3244dcf3319fd2f60ddc5ea87b669ed6", + "originHash" : "6f0637657b374e64fd9edcfcb8211b36877184451b87307b4390da357261eb90", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/code-payments/flipcash-shared-core-spm", "state" : { - "revision" : "6a9412dd54afce08ba7eec5fc707a51de20467ed", - "version" : "0.9.0" + "revision" : "4a1084833b1bece14663cdcf4317ee0a31ec3f7e", + "version" : "0.10.0" } }, { diff --git a/CrossPlatformVectors/Package.swift b/CrossPlatformVectors/Package.swift index 3f81b3c51..c4066561f 100644 --- a/CrossPlatformVectors/Package.swift +++ b/CrossPlatformVectors/Package.swift @@ -18,7 +18,7 @@ let sharedCore: Package.Dependency = { if let sharedCoreLocalRoot { return .package(path: "\(sharedCoreLocalRoot)/kmp/shared-core/spm") } - return .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.9.0")) + return .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.10.0")) }() // Cross-platform test-vector GATE (Track-B/C safety net). Asserts the ed25519 this app signs diff --git a/Flipcash/Core/Controllers/EncryptedChatClient.swift b/Flipcash/Core/Controllers/EncryptedChatClient.swift new file mode 100644 index 000000000..d749822d0 --- /dev/null +++ b/Flipcash/Core/Controllers/EncryptedChatClient.swift @@ -0,0 +1,229 @@ +// +// EncryptedChatClient.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import os +import FlipcashCore + +/// The chat surface `ConversationController` talks to, with DM end-to-end encryption applied at +/// the edge: every message read from the server comes back decrypted (or marked with why it +/// couldn't be), and every text sent into an encrypting chat goes out sealed. +/// +/// The controller never sees ciphertext it could decrypt, so the store, the database and the UI +/// work on plaintext. A message whose peer key can't be fetched yet passes through undecrypted +/// and unmarked; the transcript hides it until a later read decrypts it. +final class EncryptedChatClient: Sendable { + + private let client: FlipClient + private let keyring: ChatKeyring + + /// Every chat seen on its way through, so a message can be opened without a `GetChat`. + private let conversations = OSAllocatedUnfairLock<[ConversationID: Conversation]>(initialState: [:]) + + init(client: FlipClient, keyring: ChatKeyring) { + self.client = client + self.keyring = keyring + } + + private func remember(_ conversation: Conversation) { + conversations.withLock { $0[conversation.id] = conversation } + } + + /// The chat `conversationID` names, fetched when it hasn't passed through yet. + private func conversation(_ conversationID: ConversationID, owner: KeyPair) async throws -> Conversation { + if let known = conversations.withLock({ $0[conversationID] }) { return known } + let fetched = try await client.getChat(owner: owner, conversationID: conversationID) + remember(fetched) + return fetched + } + + /// How `conversationID`'s messages open; waits on the key when the chat can't be fetched. + private func opener(_ conversationID: ConversationID, owner: KeyPair) async -> ChatOpener { + guard let conversation = try? await conversation(conversationID, owner: owner) else { return .awaitingKey } + return await keyring.opener(for: conversation) + } + + /// `conversation` remembered, with its preview message opened. + private func opened(_ conversation: Conversation) async -> Conversation { + remember(conversation) + guard let lastMessage = conversation.lastMessage, lastMessage.isAwaitingDecryption else { return conversation } + var conversation = conversation + conversation.lastMessage = await keyring.open(lastMessage, in: conversation) + return conversation + } + + /// The seal for a send into `conversationID`, nil when it goes out in plaintext. + private func sealForSending(_ conversationID: ConversationID, owner: KeyPair) async throws -> ChatSeal? { + try await keyring.sealForSending(in: conversation(conversationID, owner: owner)) + } + + /// Runs a send, forgetting `conversationID` when the server refuses its encryption choice so a + /// retry decides again from a fresh copy of the chat. + private func refetchingOnRefusal(_ conversationID: ConversationID, _ send: () async throws -> T) async throws -> T { + do { + return try await send() + } catch ErrorSendMessage.encryptionNotAllowed { + conversations.withLock { _ = $0.removeValue(forKey: conversationID) } + throw ErrorSendMessage.encryptionNotAllowed + } catch ErrorEditMessage.encryptionNotAllowed { + conversations.withLock { _ = $0.removeValue(forKey: conversationID) } + throw ErrorEditMessage.encryptionNotAllowed + } + } +} + +// MARK: - ConversationFetching - + +extension EncryptedChatClient: ConversationFetching { + + func getDmChatFeed(owner: KeyPair, type: ConversationType) async throws -> [Conversation] { + var feed: [Conversation] = [] + for conversation in try await client.getDmChatFeed(owner: owner, type: type) { + feed.append(await opened(conversation)) + } + return feed + } + + func getGroupChatFeed(owner: KeyPair) async throws -> [Conversation] { + let feed = try await client.getGroupChatFeed(owner: owner) + feed.forEach(remember) + return feed + } + + func getChat(owner: KeyPair, conversationID: ConversationID) async throws -> Conversation { + await opened(try await client.getChat(owner: owner, conversationID: conversationID)) + } +} + +// MARK: - ConversationMessaging - + +extension EncryptedChatClient: ConversationMessaging { + + func getMessage(owner: KeyPair, conversationID: ConversationID, messageID: MessageID) async throws -> ConversationMessage? { + guard let message = try await client.getMessage(owner: owner, conversationID: conversationID, messageID: messageID) else { return nil } + guard message.isAwaitingDecryption else { return message } + return await opener(conversationID, owner: owner).open(message) + } + + func getMessages(owner: KeyPair, conversationID: ConversationID, before: MessageID?) async throws -> [ConversationMessage] { + let messages = try await client.getMessages(owner: owner, conversationID: conversationID, before: before) + guard messages.contains(where: \.isAwaitingDecryption) else { return messages } + return await opener(conversationID, owner: owner).open(messages) + } + + func getDelta( + owner: KeyPair, + conversationID: ConversationID, + afterSequence: UInt64, + onBatch: @MainActor @Sendable @escaping (_ messages: [ConversationMessage], _ checkpoint: UInt64?) -> Void + ) async throws -> UInt64 { + // Batches arrive on a synchronous callback, so the chat's keys are fetched before it starts. + let opener = await opener(conversationID, owner: owner) + return try await client.getDelta(owner: owner, conversationID: conversationID, afterSequence: afterSequence) { messages, checkpoint in + onBatch(opener.open(messages), checkpoint) + } + } + + func sendMessage(owner: KeyPair, conversationID: ConversationID, text: String, repliedTo: MessageID?, clientMessageID: UUID) async throws -> ConversationMessage { + try await refetchingOnRefusal(conversationID) { + let seal = try await sealForSending(conversationID, owner: owner) + return try await client.sendMessage(owner: owner, conversationID: conversationID, text: text, repliedTo: repliedTo, seal: seal, clientMessageID: clientMessageID) + } + } + + func editMessage(owner: KeyPair, conversationID: ConversationID, messageID: MessageID, text: String, expectedEventSequence: UInt64) async throws -> MessageMutation { + try await refetchingOnRefusal(conversationID) { + let seal = try await sealForSending(conversationID, owner: owner) + return try await client.editMessage(owner: owner, conversationID: conversationID, messageID: messageID, text: text, seal: seal, expectedEventSequence: expectedEventSequence) + } + } + + func deleteMessage(owner: KeyPair, conversationID: ConversationID, messageID: MessageID, expectedEventSequence: UInt64) async throws -> MessageMutation { + try await client.deleteMessage(owner: owner, conversationID: conversationID, messageID: messageID, expectedEventSequence: expectedEventSequence) + } + + func addReaction(owner: KeyPair, conversationID: ConversationID, messageID: MessageID, emoji: String) async throws -> EmojiReaction { + try await client.addReaction(owner: owner, conversationID: conversationID, messageID: messageID, emoji: emoji) + } + + func removeReaction(owner: KeyPair, conversationID: ConversationID, messageID: MessageID, emoji: String) async throws -> EmojiReaction { + try await client.removeReaction(owner: owner, conversationID: conversationID, messageID: messageID, emoji: emoji) + } + + func getReactors(owner: KeyPair, conversationID: ConversationID, messageID: MessageID, emoji: String, pageSize: Int, pagingToken: Data?) async throws -> ReactorPage { + try await client.getReactors(owner: owner, conversationID: conversationID, messageID: messageID, emoji: emoji, pageSize: pageSize, pagingToken: pagingToken) + } + + func getReactionSummaries(owner: KeyPair, conversationID: ConversationID, messageIDs: [MessageID]) async throws -> [MessageID: ReactionState] { + try await client.getReactionSummaries(owner: owner, conversationID: conversationID, messageIDs: messageIDs) + } + + func markRead(owner: KeyPair, conversationID: ConversationID, messageID: MessageID) async throws { + try await client.markRead(owner: owner, conversationID: conversationID, messageID: messageID) + } + + func notifyIsTyping(owner: KeyPair, conversationID: ConversationID, state: TypingState) async throws { + try await client.notifyIsTyping(owner: owner, conversationID: conversationID, state: state) + } +} + +// MARK: - ConversationEventStreaming - + +extension EncryptedChatClient: ConversationEventStreaming { + + func subscribeConversationStream(owner: KeyPair) async -> (events: AsyncStream, connectionState: AsyncStream) { + let (events, connectionState) = await client.subscribeConversationStream(owner: owner) + let (opened, continuation) = AsyncStream.makeStream(of: ConversationStreamEvent.self) + // One event at a time, so the stream's order survives a key fetch. + let task = Task { [self] in + for await event in events { + continuation.yield(await open(event, owner: owner)) + } + continuation.finish() + } + continuation.onTermination = { _ in task.cancel() } + return (opened, connectionState) + } + + func ensureConversationStreamConnected() { + client.ensureConversationStreamConnected() + } + + func closeConversationStream() { + client.closeConversationStream() + } + + private func open(_ event: ConversationStreamEvent, owner: KeyPair) async -> ConversationStreamEvent { + switch event { + case .chatEvents(let conversationID, let events): + guard events.contains(where: { $0.mutations.contains { $0.message.isAwaitingDecryption } }) else { return event } + let opener = await opener(conversationID, owner: owner) + return .chatEvents(conversationID: conversationID, events: events.map { chatEvent in + DecodedChatEvent( + sequence: chatEvent.sequence, + count: chatEvent.count, + mutations: chatEvent.mutations.map { $0.opened(by: opener) } + ) + }) + case .metadataRefresh(let conversation): + return .metadataRefresh(await opened(conversation)) + case .lastActivityChanged, .readPointersChanged, .typingChanged, .rosterChanged, + .viewerStateChanged, .titleChanged, .pictureChanged, .reactionsChanged: + return event + } + } +} + +private extension DecodedMutation { + func opened(by opener: ChatOpener) -> DecodedMutation { + switch self { + case .sent(let message): .sent(opener.open(message)) + case .edited(let message): .edited(opener.open(message)) + case .deleted(let message): .deleted(message) + } + } +} diff --git a/Flipcash/Core/Controllers/FlipClient+Protocols.swift b/Flipcash/Core/Controllers/FlipClient+Protocols.swift index 9941aa40b..915f94229 100644 --- a/Flipcash/Core/Controllers/FlipClient+Protocols.swift +++ b/Flipcash/Core/Controllers/FlipClient+Protocols.swift @@ -166,5 +166,5 @@ extension FlipClient { } extension FlipClient: ContactVerifying, OnrampAuthorizing, ContactSyncing, - ConversationFetching, ConversationMembership, ConversationMessaging, + ConversationFetching, ConversationMembership, ConversationViewerSettings, ConversationEventStreaming {} diff --git a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift index 6341111ea..7e543c347 100644 --- a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift +++ b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift @@ -89,14 +89,24 @@ extension ChatItem { linkCard: ([DetectedLink]) -> LinkCard? = { _ in nil }, /// Where the viewer's unread messages began at open; the divider heads the first message /// after it that someone else sent. - unreadBoundary: UnreadBoundary = .none + unreadBoundary: UnreadBoundary = .none, + /// Whether `messages` starts at the chat's first message. The encryption marker heads an + /// all-encrypted transcript only then, since older history may still be plaintext. + headsHistory: Bool = true ) -> [ChatItem] { // Tombstoned (deleted) messages are retained in the store for gapless ordering. Under // `.hidden` they are dropped up front so they never skew a date separator, group an adjacent // bubble to an invisible row, or steal the "Delivered"/"Read" receipt anchor below. + // A message still waiting on the peer's key is left out until it decrypts. let messages: [ConversationMessage] = switch deletedPresentation { - case .hidden: messages.filter { !$0.isDeleted } - case .placeholder: messages + case .hidden: messages.filter { !$0.isDeleted && !$0.isAwaitingDecryption } + case .placeholder: messages.filter { !$0.isAwaitingDecryption } + } + + // The marker goes where the ciphertext starts: above the first encrypted message with + // plaintext before it, or at the head of a history that is encrypted from the start. + let markerIndex = messages.firstIndex(where: \.isEncrypted).flatMap { index in + index > 0 || headsHistory ? index : nil } // "Delivered"/"Read" rides the latest *confirmed* self message, so an in-flight or failed send @@ -181,6 +191,10 @@ extension ChatItem { // A separator opens the transcript and breaks any run longer than the gap. let showsSeparator = previous.map { separates(message, from: $0) } ?? true + let showsMarker = index == markerIndex + if showsMarker { + items.append(.encryptionMarker) + } if showsSeparator { items.append(.dateSeparator(id: "sep-\(message.stableID)", text: message.date.formattedChatSeparator())) } @@ -200,10 +214,11 @@ extension ChatItem { // separator is already the heading of what follows it, so a second, shorter threshold // would flatten runs the transcript still draws as continuous. let groupedAbove = previous.map { - $0.senderID == message.senderID && !showsSeparator && !showsDivider + $0.senderID == message.senderID && !showsSeparator && !showsDivider && !showsMarker } ?? false let groupedBelow = next.map { $0.senderID == message.senderID && !separates($0, from: message) && !divides($0, from: message) + && index + 1 != markerIndex } ?? false // The bubble run, which is not the author run. A bubble stacked above a bare emoji would @@ -237,9 +252,12 @@ extension ChatItem { : "This message was deleted" ) case .encrypted: - // Decryption isn't implemented on this client -- a cross-platform parity hotspot -- - // so an encrypted message renders as the unavailable bubble, asking for an update. - content = .unavailable(.updateApp) + // Still encrypted here means decryption failed; the awaiting ones were dropped above. + content = .unavailable(.decryptFailure( + message.decryptFailure, + isFromSelf: isFromSelf, + senderName: counterpartName + )) } // The status line rides on the bubble itself (not a separate row, so a send is a clean diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index 691f6dbcd..32ebb9717 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -47,6 +47,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { /// Fired when the user taps the group head card's "Invite People". The card draws the /// button only when it both asks for it and this is set. let onGroupInvite: (() -> Void)? + /// Fired when the user taps the "Encrypted" marker above a DM's first encrypted message. + let onEncryptionMarkerTap: () -> Void /// Fired when the user taps an author's face in a group's gutter, with that author's user id. let onAuthorTap: (UserID) -> Void /// Fired when a context-menu action is chosen on a row, with the row's stable id. Copy never @@ -137,6 +139,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.onContactAction = onContactAction screen.onProfileTap = onProfileTap screen.onGroupInvite = onGroupInvite + screen.onEncryptionMarkerTap = onEncryptionMarkerTap screen.onAuthorTap = onAuthorTap screen.onMessageAction = keyboardFollowing(onMessageAction, screen: screen) screen.onQuoteTap = { [weak screen] stableID in @@ -179,6 +182,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.onContactAction = onContactAction screen.onProfileTap = onProfileTap screen.onGroupInvite = onGroupInvite + screen.onEncryptionMarkerTap = onEncryptionMarkerTap screen.onAuthorTap = onAuthorTap screen.onMessageAction = keyboardFollowing(onMessageAction, screen: screen) screen.onQuoteTap = { [weak screen] stableID in diff --git a/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift b/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift index f3ecdf36c..023243c2e 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift @@ -331,7 +331,8 @@ final class ConversationLoadCoordinator { // card is the link's identity, and the card view looks it up for itself. So nothing // here touches the network, and an answer landing cannot re-diff this window. linkCard: { links in classifier.firstCard(in: links) }, - unreadBoundary: inputs.unreadBoundary + unreadBoundary: inputs.unreadBoundary, + headsHistory: inputs.headsHistory ) if !inputs.typists.isEmpty { // Only a group draws faces ahead of the dots; a DM's bubble stays as it was. A typist no diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index aeab882ae..82dac0e4f 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -89,6 +89,8 @@ struct ConversationScreen: View { /// Whether the invite sheet is up. A link is the only way into a group, so a member's head card /// hands one out. @State private var isInviting = false + /// Whether the encryption explainer opened from the transcript's marker is showing. + @State private var isShowingEncryptionInfo = false @State private var messageReport: MessageReportRequest? /// Whether `startSendCash` has been acted on, so a re-render or a return from the sheet it /// opened doesn't start it again. @@ -484,6 +486,7 @@ struct ConversationScreen: View { onContactAction: openContactCard, onProfileTap: profileTapAction, onGroupInvite: openGroupInvite, + onEncryptionMarkerTap: { isShowingEncryptionInfo = true }, onAuthorTap: openAuthorProfile, onMessageAction: handleMessageAction, onQuoteTap: jumpToQuote, @@ -719,6 +722,9 @@ struct ConversationScreen: View { .sheet(item: $startChattingRequest) { request in StartChattingSheet(target: request.target, fee: request.fee) } + .sheet(isPresented: $isShowingEncryptionInfo) { + E2eeLearnMoreSheet(kind: .dm, isPresented: $isShowingEncryptionInfo) + } .sheet(isPresented: $isInviting) { if let conversationID { GroupInviteSheet(conversationID: conversationID, isPresented: $isInviting) { chatID in diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index bc613f79e..2c72c929d 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -646,12 +646,22 @@ final class SessionContainer { ) self.chatDrafts = chatDrafts + let chatClient = EncryptedChatClient( + client: flipClient, + keyring: ChatKeyring( + owner: session.ownerKeyPair, + selfUserID: session.userID, + resolveKey: { [flipClient, owner = session.ownerKeyPair] userID in + try await flipClient.resolveUserID(userID, owner: owner) + } + ) + ) let conversationController = ConversationController( - fetching: flipClient, + fetching: chatClient, membership: flipClient, viewerSettings: flipClient, - messaging: flipClient, - streaming: flipClient, + messaging: chatClient, + streaming: chatClient, contactNaming: contactSyncController, database: database, owner: session.ownerKeyPair, diff --git a/FlipcashCore/Package.swift b/FlipcashCore/Package.swift index c19a9f358..dead6f60a 100644 --- a/FlipcashCore/Package.swift +++ b/FlipcashCore/Package.swift @@ -49,7 +49,7 @@ let sharedCore: Package.Dependency = { if let sharedCoreLocalRoot { return .package(path: "\(sharedCoreLocalRoot)/kmp/shared-core/spm") } - return .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.9.0")) + return .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.10.0")) }() let package = Package( diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Chat/ChatNotificationClient.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Chat/ChatNotificationClient.swift index 6384a84fe..a4e462773 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Chat/ChatNotificationClient.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Chat/ChatNotificationClient.swift @@ -20,6 +20,8 @@ public final class ChatNotificationClient: Sendable { private let paymentsHost: String private let port: Int private let messagingService: ChatMessagingService + private let chatService: ChatService + private let resolverService: ResolverService private let coreClient: GRPCClient /// The core client's connection loop; retained for its lifetime — dropping it makes the client /// inert and every RPC hangs. @@ -43,6 +45,8 @@ public final class ChatNotificationClient: Sendable { catch { logger.error("Core connection loop terminated", metadata: ["error": "\(error)"]) } } self.messagingService = ChatMessagingService(client: coreClient) + self.chatService = ChatService(client: coreClient) + self.resolverService = ResolverService(client: coreClient) } deinit { @@ -52,6 +56,56 @@ public final class ChatNotificationClient: Sendable { coreConnectionTask.cancel() } + // MARK: - Chats - + + /// Fetches one chat, with its members. + public func getChat(owner: KeyPair, conversationID: ConversationID) async throws -> Conversation { + try await withCheckedThrowingContinuation { continuation in + chatService.getChat(owner: owner, conversationID: conversationID, viewMode: .full) { + continuation.resume(with: $0) + } + } + } + + /// A ``ChatKeyring`` for `owner` that fetches peers' keys over this client's connection. + public func keyring(owner: KeyPair, selfUserID: UserID) -> ChatKeyring { + ChatKeyring(owner: owner, selfUserID: selfUserID) { [resolverService] userID in + try await withCheckedThrowingContinuation { continuation in + resolverService.resolveUserID(userID, owner: owner) { continuation.resume(with: $0) } + } + } + } + + // MARK: - Encryption - + + /// `messages` with those in an encrypted DM decrypted or marked with why they couldn't be. When + /// the chat or the peer's key can't be fetched they come back still awaiting decryption. + public func open( + _ messages: [ConversationMessage], + in conversationID: ConversationID, + owner: KeyPair, + selfUserID: UserID + ) async -> [ConversationMessage] { + guard + messages.contains(where: \.isAwaitingDecryption), + let conversation = try? await getChat(owner: owner, conversationID: conversationID) + else { + return messages + } + return await keyring(owner: owner, selfUserID: selfUserID).open(messages, in: conversation) + } + + /// The seal to send into `conversationID` with, or nil when it is sent in plaintext. Throws when + /// the chat or the peer's key can't be fetched, since the message must not go out in plaintext. + public func sealForSending( + in conversationID: ConversationID, + owner: KeyPair, + selfUserID: UserID + ) async throws -> ChatSeal? { + let conversation = try await getChat(owner: owner, conversationID: conversationID) + return try await keyring(owner: owner, selfUserID: selfUserID).sealForSending(in: conversation) + } + // MARK: - Messages - /// Thrown internally when a retry-on-empty read comes back empty, so `Task.retry` re-fetches. @@ -95,13 +149,24 @@ public final class ChatNotificationClient: Sendable { } } - /// Sends a text message and returns the server-confirmed `ConversationMessage`. `clientMessageID` - /// must stay stable across retries so the server dedups the send — generate it once at the call site. + /// Fetches one message by id, or nil when the chat has no such message. + public func getMessage(owner: KeyPair, conversationID: ConversationID, messageID: MessageID) async throws -> ConversationMessage? { + try await withCheckedThrowingContinuation { continuation in + messagingService.getMessage(owner: owner, conversationID: conversationID, messageID: messageID, viewMode: .full) { + continuation.resume(with: $0) + } + } + } + + /// Sends a text message, encrypted with `seal` when set, and returns the server-confirmed + /// `ConversationMessage`. `clientMessageID` must stay stable across retries so the server dedups + /// the send — generate it once at the call site. @discardableResult public func sendMessage( owner: KeyPair, conversationID: ConversationID, text: String, + seal: ChatSeal?, clientMessageID: UUID ) async throws -> ConversationMessage { try await withCheckedThrowingContinuation { continuation in @@ -112,6 +177,7 @@ public final class ChatNotificationClient: Sendable { // A notification quick-reply is a plain message: the extension has no transcript // to quote from. repliedTo: nil, + seal: seal, clientMessageID: clientMessageID ) { continuation.resume(with: $0) } } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift index a685876b7..66e517cf0 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift @@ -174,10 +174,11 @@ extension FlipClient { } } + /// Sends `text`, encrypted with `seal` when set, and returns the server's copy (decrypted). @discardableResult - public func sendMessage(owner: KeyPair, conversationID: ConversationID, text: String, repliedTo: MessageID?, clientMessageID: UUID) async throws -> ConversationMessage { + public func sendMessage(owner: KeyPair, conversationID: ConversationID, text: String, repliedTo: MessageID?, seal: ChatSeal?, clientMessageID: UUID) async throws -> ConversationMessage { try await withCheckedThrowingContinuation { c in - chatMessagingService.sendMessage(owner: owner, conversationID: conversationID, text: text, repliedTo: repliedTo, clientMessageID: clientMessageID) { c.resume(with: $0) } + chatMessagingService.sendMessage(owner: owner, conversationID: conversationID, text: text, repliedTo: repliedTo, seal: seal, clientMessageID: clientMessageID) { c.resume(with: $0) } } } @@ -186,6 +187,7 @@ extension FlipClient { conversationID: ConversationID, messageID: MessageID, text: String, + seal: ChatSeal?, expectedEventSequence: UInt64 ) async throws -> MessageMutation { try await withCheckedThrowingContinuation { c in @@ -194,6 +196,7 @@ extension FlipClient { conversationID: conversationID, messageID: messageID, text: text, + seal: seal, expectedEventSequence: expectedEventSequence ) { c.resume(with: $0) } } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift index 4c5a1e435..7fee21989 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift @@ -157,21 +157,22 @@ final class ChatMessagingService: Sendable { } } - func sendMessage(owner: KeyPair, conversationID: ConversationID, text: String, repliedTo: MessageID?, clientMessageID: UUID, completion: @Sendable @escaping (Result) -> Void) { + /// Sends `text`, as a reply to `repliedTo` when set. With a `seal` the text goes out as + /// `EncryptedContent` and the server's copy comes back decrypted. + func sendMessage(owner: KeyPair, conversationID: ConversationID, text: String, repliedTo: MessageID?, seal: ChatSeal?, clientMessageID: UUID, completion: @Sendable @escaping (Result) -> Void) { + let content: Flipcash_Messaging_V1_Content + do { + content = try seal?.seal(text: text, repliedTo: repliedTo) ?? .plaintext(text: text, repliedTo: repliedTo) + } catch { + logger.error("Failed to encrypt message") + completion(.failure(.encryptionFailed)) + return + } let request = Flipcash_Messaging_V1_SendMessageRequest.with { $0.chatID = conversationID.proto - // A reply wraps the same text one level deeper on the wire. The domain model keeps it - // flat — see `ConversationMessage.init?(_:)`, which unwraps it back. - if let repliedTo { - $0.content = [.with { - $0.reply = .with { - $0.repliedMessageID = repliedTo.proto - $0.content = [.with { $0.text = .with { $0.text = text } }] - } - }] - } else { - $0.content = [.with { $0.text = .with { $0.text = text } }] - } + // A reply wraps the text one level deeper on the wire. The domain model keeps it flat — + // see `ConversationMessage.init?(_:)`, which unwraps it back. + $0.content = [content] $0.clientMessageID = .with { $0.value = clientMessageID.data } $0.auth = owner.authFor(message: $0) } @@ -181,7 +182,8 @@ final class ChatMessagingService: Sendable { let response = try await service.sendMessage(request, options: .unaryDefault) let error = ErrorSendMessage(rawValue: response.result.rawValue) ?? .unknown if error == .ok, response.hasMessage, let message = ConversationMessage(response.message) { - await MainActor.run { completion(.success(message)) } + let opened = seal?.open(message) ?? message + await MainActor.run { completion(.success(opened)) } } else { logger.error("Failed to send message") await MainActor.run { completion(.failure(error == .ok ? .unknown : error)) } @@ -199,13 +201,22 @@ final class ChatMessagingService: Sendable { conversationID: ConversationID, messageID: MessageID, text: String, + seal: ChatSeal?, expectedEventSequence: UInt64, completion: @Sendable @escaping (Result) -> Void ) { + let content: Flipcash_Messaging_V1_Content + do { + content = try seal?.seal(text: text, repliedTo: nil) ?? .plaintext(text: text, repliedTo: nil) + } catch { + logger.error("Failed to encrypt edited message") + completion(.failure(.encryptionFailed)) + return + } let request = Flipcash_Messaging_V1_EditMessageRequest.with { $0.chatID = conversationID.proto $0.messageID = messageID.proto - $0.content = [.with { $0.text = .with { $0.text = text } }] + $0.content = [content] $0.expectedEventSequence = expectedEventSequence $0.auth = owner.authFor(message: $0) } @@ -221,10 +232,11 @@ final class ChatMessagingService: Sendable { await MainActor.run { completion(.failure(error == .ok ? .unknown : error)) } return } + let opened = seal?.open(message) ?? message await MainActor.run { - completion(.success(MessageMutation(message: message, isConflict: error == .conflict))) + completion(.success(MessageMutation(message: opened, isConflict: error == .conflict))) } - case .denied, .messageNotFound, .cannotEdit, .encryptionNotAllowed, .unknown, .transportFailure, .cancelled, .rejected: + case .denied, .messageNotFound, .cannotEdit, .encryptionNotAllowed, .encryptionFailed, .unknown, .transportFailure, .cancelled, .rejected: logger.error("Failed to edit message") await MainActor.run { completion(.failure(error)) } } @@ -515,6 +527,8 @@ public enum ErrorSendMessage: Int, Error { case transportFailure = -2 case cancelled = -3 case rejected = -4 + /// This client could not encrypt the message, so it was not sent. + case encryptionFailed = -5 } public enum ErrorEditMessage: Int, Error { @@ -529,6 +543,8 @@ public enum ErrorEditMessage: Int, Error { case transportFailure = -2 case cancelled = -3 case rejected = -4 + /// This client could not encrypt the edit, so it was not sent. + case encryptionFailed = -5 } public enum ErrorDeleteMessage: Int, Error { @@ -605,6 +621,8 @@ extension ErrorSendMessage: ServerError, TransportClassifiableError { // contract violation (sending EncryptedContent outside a DM), not a server hiccup. case .denied: .info case .encryptionNotAllowed: .error + // Shared-core refused to encrypt, which only malformed keys cause. + case .encryptionFailed: .error case .unknown, .rejected: .error } } @@ -620,6 +638,8 @@ extension ErrorEditMessage: ServerError, TransportClassifiableError { case .denied, .messageNotFound, .cannotEdit, .conflict: .info // A client-side contract violation (sending EncryptedContent outside a DM), not a server hiccup. case .encryptionNotAllowed: .error + // Shared-core refused to encrypt, which only malformed keys cause. + case .encryptionFailed: .error case .unknown, .rejected: .error } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatItem.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatItem.swift index 0d7c1c341..b0262ca51 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatItem.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatItem.swift @@ -27,6 +27,9 @@ public enum ChatItem: Hashable, Sendable, Codable, Identifiable { /// The group's own card at the head of a short group transcript — picture, title, and the /// rule the chat runs on. case groupCard(ChatGroupCard) + /// The "Encrypted" line above the first end-to-end-encrypted message in the transcript, or at + /// its head when every message is encrypted. At most one per transcript. + case encryptionMarker public var id: String { switch self { @@ -37,6 +40,7 @@ public enum ChatItem: Hashable, Sendable, Codable, Identifiable { case .typingIndicator: "typing-indicator" case .profileCard: "profile-card" case .groupCard: "group-card" + case .encryptionMarker: "encryption-marker" } } @@ -48,7 +52,7 @@ public enum ChatItem: Hashable, Sendable, Codable, Identifiable { public var messageID: String? { switch self { case .message(let message): message.messageID - case .dateSeparator, .unreadDivider, .typingIndicator, .profileCard, .groupCard: nil + case .dateSeparator, .unreadDivider, .typingIndicator, .profileCard, .groupCard, .encryptionMarker: nil } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift index 447acee01..8302053b5 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift @@ -35,11 +35,34 @@ public struct ChatMessage: Hashable, Sendable, Codable, Identifiable { public enum UnavailableHint: Hashable, Sendable, Codable { /// A newer client can read it: "Update Flipcash to see it". case updateApp + /// It failed to decrypt and only a resend can fix it: "Ask {first name} to send it again". + case askToResend(firstName: String) + /// The viewer's own message failed to decrypt: "Try sending it again". + case resend /// The hint's copy. public var text: String { switch self { case .updateApp: "Update Flipcash to see it" + case .askToResend(let firstName): "Ask \(firstName) to send it again" + case .resend: "Try sending it again" + } + } + + /// The hint under a message that failed to decrypt for `failure`, sent by the viewer or by + /// `senderName`. + public static func decryptFailure( + _ failure: ConversationMessage.DecryptFailure?, + isFromSelf: Bool, + senderName: String + ) -> Self { + switch failure { + case .unsupported, nil: + return .updateApp + case .authentication: + if isFromSelf { return .resend } + let firstName = senderName.split(separator: " ").first.map(String.init) ?? "" + return .askToResend(firstName: firstName.isEmpty ? "them" : firstName) } } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift index 5401a3c6a..23a5a764f 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift @@ -35,17 +35,20 @@ extension ChatItem { /// - mintBranding: Resolved token branding (name + coin icon) keyed by mint, used to label /// and illustrate cash rows (e.g. "Jeffy" with its icon). The caller resolves these over /// the network; a cash row whose mint is absent shows no token label or icon. + /// - counterpartName: The other party's name, for the hint under a message that failed to decrypt. public static func preview( from messages: [ConversationMessage], selfUserID: UserID, limit: Int = 3, - mintBranding: [PublicKey: MintBrandingInfo] = [:] + mintBranding: [PublicKey: MintBrandingInfo] = [:], + counterpartName: String = "" ) -> [ChatItem] { // Drop tombstones before slicing so the preview keeps the newest `limit` *visible* messages — // filtering after `.suffix` would let a recent delete crowd out a real message (or blank the // preview) and leave an orphaned leading separator. let sorted = messages - .filter { if case .deleted = $0.content { false } else { true } } + // Nor messages still waiting on the peer's key, which the transcript doesn't show either. + .filter { if case .deleted = $0.content { false } else { !$0.isAwaitingDecryption } } .sorted { $0.id < $1.id } let slice = sorted.suffix(limit) @@ -78,9 +81,12 @@ extension ChatItem { isTip: message.cashAction == .tipped )) case .encrypted: - // Not filtered above (only tombstones are): an encrypted message stays a real, - // visible row, same as the in-app transcript, just with no plaintext to preview. - content = .unavailable(.updateApp) + // Only one that failed to decrypt gets here; it stays a visible row, as in the transcript. + content = .unavailable(.decryptFailure( + message.decryptFailure, + isFromSelf: message.senderID == selfUserID, + senderName: counterpartName + )) case .deleted: continue // filtered out above; unreachable, kept for switch exhaustiveness } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatKeyring.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatKeyring.swift new file mode 100644 index 000000000..025e31fd1 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatKeyring.swift @@ -0,0 +1,172 @@ +// +// ChatKeyring.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import SharedCore + +/// A peer's public key could not be fetched, so their chat can't be encrypted or decrypted yet. +/// Transient: a later attempt may succeed. +public struct PeerKeyUnavailable: Error, Sendable { + public let userID: UserID +} + +/// Where peers' Ed25519 public keys are kept between launches, shared with the extensions. +public protocol PeerKeyStore: Sendable { + /// The stored key for `userID`, or nil when none is stored. + func key(for userID: UserID) -> PublicKey? + /// Stores `key` for `userID`. + func store(_ key: PublicKey, for userID: UserID) +} + +/// A ``PeerKeyStore`` holding one small file per peer in the App Group container. A user's key +/// never changes, so an entry is never invalidated. +public struct AppGroupPeerKeyStore: PeerKeyStore { + + public init() {} + + public func key(for userID: UserID) -> PublicKey? { + guard let url = fileURL(for: userID), let data = try? Data(contentsOf: url) else { return nil } + return try? PublicKey(data) + } + + public func store(_ key: PublicKey, for userID: UserID) { + guard let url = fileURL(for: userID) else { return } + try? FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try? key.data.write(to: url, options: .atomic) + } + + private func fileURL(for userID: UserID) -> URL? { + FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: NotificationPreviewCache.appGroup)? + .appendingPathComponent("PeerKeys", isDirectory: true) + .appendingPathComponent(userID.uuidString) + } +} + +/// Holds the signed-in user's DM encryption: which chats encrypt, each peer's public key, and each +/// chat's ``ChatSeal``. The single place outgoing text is encrypted and incoming text decrypted. +public actor ChatKeyring { + + /// Fetches a user's Ed25519 public key from the server. + public typealias ResolveKey = @Sendable (UserID) async throws -> PublicKey + + private let cipher: any ChatCipher + private let owner: KeyPair + private let selfUserID: UserID + private let store: any PeerKeyStore + private let resolveKey: ResolveKey + + private var peerKeys: [UserID: PublicKey] = [:] + private var seals: [ConversationID: ChatSeal] = [:] + + public init( + cipher: any ChatCipher = DefaultChatCipher.shared, + owner: KeyPair, + selfUserID: UserID, + store: any PeerKeyStore = AppGroupPeerKeyStore(), + resolveKey: @escaping ResolveKey + ) { + self.cipher = cipher + self.owner = owner + self.selfUserID = selfUserID + self.store = store + self.resolveKey = resolveKey + } + + /// The seal to send into `conversation` with, or nil when it is sent in plaintext. + /// + /// Throws ``PeerKeyUnavailable`` when the peer's key can't be fetched, and shared-core's error + /// when the key is unusable; either way the message must not go out in plaintext. + public func sealForSending(in conversation: Conversation) async throws -> ChatSeal? { + guard E2eePolicy.shouldEncrypt(conversation) else { return nil } + return try await seal(for: conversation) + } + + /// How `conversation`'s encrypted messages open right now, fetching the peer's key if needed. + public func opener(for conversation: Conversation) async -> ChatOpener { + do { + return .seal(try await seal(for: conversation)) + } catch is PeerKeyUnavailable { + return .awaitingKey + } catch { + // No peer to decrypt with (a group), or a key shared-core rejects: nothing will open these. + return .unsupported + } + } + + /// `messages` from `conversation`, opened as ``ChatOpener/open(_:)`` does. + public func open(_ messages: [ConversationMessage], in conversation: Conversation) async -> [ConversationMessage] { + guard messages.contains(where: \.isAwaitingDecryption) else { return messages } + return await opener(for: conversation).open(messages) + } + + /// `message` from `conversation`, opened as ``ChatOpener/open(_:)`` does. + public func open(_ message: ConversationMessage, in conversation: Conversation) async -> ConversationMessage { + await open([message], in: conversation)[0] + } + + private func seal(for conversation: Conversation) async throws -> ChatSeal { + if let seal = seals[conversation.id] { return seal } + switch conversation.type { + case .group: + throw NoPeer() + case .contactDm, .tipDm: + break + } + guard let peerID = conversation.members.lazy.compactMap(\.userID).first(where: { $0 != selfUserID }) else { + throw NoPeer() + } + let peerKey = try await publicKey(for: peerID) + let seal = try ChatSeal(cipher: cipher, owner: owner, peerPublicKey: peerKey, conversationID: conversation.id, selfUserID: selfUserID) + seals[conversation.id] = seal + return seal + } + + private func publicKey(for userID: UserID) async throws -> PublicKey { + if let key = peerKeys[userID] { return key } + if let key = store.key(for: userID) { + peerKeys[userID] = key + return key + } + let key: PublicKey + do { + key = try await resolveKey(userID) + } catch { + throw PeerKeyUnavailable(userID: userID) + } + peerKeys[userID] = key + store.store(key, for: userID) + return key + } + + private struct NoPeer: Error {} +} + +/// Decrypts one chat's messages synchronously, once ``ChatKeyring/opener(for:)`` has fetched what it needs. +public enum ChatOpener: Sendable { + /// The chat's keys are in hand. + case seal(ChatSeal) + /// The peer's key couldn't be fetched; messages stay undecrypted and unmarked, to open later. + case awaitingKey + /// Nothing will ever decrypt this chat's messages on this client. + case unsupported + + /// `messages` with every one awaiting decryption decrypted or marked with why it couldn't be. + public func open(_ messages: [ConversationMessage]) -> [ConversationMessage] { + messages.map(open) + } + + /// `message` decrypted or marked with why it couldn't be; unchanged unless awaiting decryption. + public func open(_ message: ConversationMessage) -> ConversationMessage { + guard message.isAwaitingDecryption else { return message } + switch self { + case .seal(let seal): return seal.open(message) + case .awaitingKey: return message + case .unsupported: return message.opened(.failure(.unsupported)) + } + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatSeal.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatSeal.swift new file mode 100644 index 000000000..108c2568b --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatSeal.swift @@ -0,0 +1,127 @@ +// +// ChatSeal.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashAPI +import SharedCore + +/// One DM's end-to-end encryption: the chat key both members derive, bound to the chat and both +/// public keys. Encrypts outgoing text and decrypts incoming `EncryptedContent`. +/// +/// The cipher is shared-core's `ChatCipher` (scheme `X25519_XCHACHA20POLY1305`), so both apps agree +/// byte for byte. Stateless after construction. +public struct ChatSeal: @unchecked Sendable { + + /// The wire `EncryptedContent.Scheme` this client reads and writes. + public static let scheme = Flipcash_Messaging_V1_EncryptedContent.Scheme.x25519Xchacha20Poly1305 + + private let cipher: any ChatCipher + private let chatKey: KotlinByteArray + private let ownPublicKey: KotlinByteArray + private let peerPublicKey: KotlinByteArray + private let chatID: KotlinByteArray + private let selfUserID: UserID + + /// Derives the chat key for `owner` and `peerPublicKey` in `conversationID`. + /// + /// Throws when shared-core rejects the peer key (not a valid point, low-order). + public init( + cipher: any ChatCipher, + owner: KeyPair, + peerPublicKey: PublicKey, + conversationID: ConversationID, + selfUserID: UserID + ) throws { + let bytes = SharedBytes.shared + let ownPublicKey = bytes.byteArray(data: owner.publicKey.data) + let ownKeyPair = SharedCore.KeyPair(publicKey: ownPublicKey, privateKey: bytes.byteArray(data: owner.privateKey.data)) + let chatID = bytes.byteArray(data: conversationID.data) + let peer = bytes.byteArray(data: peerPublicKey.data) + self.cipher = cipher + self.chatKey = try cipher.chatKey(ownKeyPair: ownKeyPair, peerPublicKey: peer, chatId: chatID) + self.ownPublicKey = ownPublicKey + self.peerPublicKey = peer + self.chatID = chatID + self.selfUserID = selfUserID + } + + /// Encrypts `text`, as a reply to `repliedTo` when set, into the content to send in its place. + public func seal(text: String, repliedTo: MessageID?) throws -> Flipcash_Messaging_V1_Content { + let plaintext = Flipcash_Messaging_V1_Content.plaintext(text: text, repliedTo: repliedTo) + let payload = try cipher.encrypt( + content: SharedBytes.shared.byteArray(data: try plaintext.serializedData()), + chatKey: chatKey, + senderPk: ownPublicKey, + recipientPk: peerPublicKey, + chatId: chatID + ) + return .with { + $0.encrypted = .with { + $0.scheme = Self.scheme + $0.nonce = SharedBytes.shared.data(bytes: payload.nonce) + $0.ciphertext = SharedBytes.shared.data(bytes: payload.ciphertext) + } + } + } + + /// `message` with its content decrypted, or carrying why it could not be. A message that isn't + /// `.encrypted` comes back unchanged. + public func open(_ message: ConversationMessage) -> ConversationMessage { + guard case .encrypted(let scheme, let nonce, let ciphertext) = message.content else { return message } + guard scheme == Self.scheme.rawValue else { return message.opened(.failure(.unsupported)) } + + let isFromSelf = message.senderID == selfUserID + let bytes = SharedBytes.shared + let plaintext: KotlinByteArray + do { + plaintext = try cipher.decrypt( + payload: EncryptedPayload(nonce: bytes.byteArray(data: nonce), ciphertext: bytes.byteArray(data: ciphertext)), + chatKey: chatKey, + senderPk: isFromSelf ? ownPublicKey : peerPublicKey, + recipientPk: isFromSelf ? peerPublicKey : ownPublicKey, + chatId: chatID + ) + } catch { + return message.opened(.failure(.authentication)) + } + + // Authenticated, so anything this client can't read is a newer client's content. + guard let content = try? Flipcash_Messaging_V1_Content(serializedBytes: bytes.data(bytes: plaintext)), + let readable = content.readableText else { + return message.opened(.failure(.unsupported)) + } + return message.opened(.success(readable)) + } +} + +extension Flipcash_Messaging_V1_Content { + + /// The plaintext content for `text`, wrapped as a reply to `repliedTo` when set. + static func plaintext(text: String, repliedTo: MessageID?) -> Self { + let body = Self.with { $0.text = .with { $0.text = text } } + guard let repliedTo else { return body } + return .with { + $0.reply = .with { + $0.repliedMessageID = repliedTo.proto + $0.content = [body] + } + } + } + + /// The text and replied-to message of decrypted Text or Reply(Text) content; nil for any other. + fileprivate var readableText: (text: String, repliedTo: MessageID?)? { + switch type { + case .text(let text): + return (text.text, nil) + case .reply(let reply): + guard reply.content.count == 1, case .text(let text) = reply.content[0].type else { return nil } + return (text.text, MessageID(reply.repliedMessageID)) + case .cash, .media, .system, .deleted, .encrypted, nil: + return nil + } + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift index 7da509ea8..bb5a8bddf 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift @@ -48,14 +48,35 @@ public struct ConversationMessage: Identifiable, Hashable, Sendable { case text(String) case cash(ExchangedFiat) case deleted(Deletion) - /// End-to-end-encrypted content this client cannot decrypt (decryption isn't implemented - /// yet -- a cross-platform parity hotspot). `scheme` is the wire `EncryptedContent.Scheme` - /// raw value, kept as `Int` so this model doesn't depend on the generated proto enum. - /// Stored verbatim -- nonce and ciphertext are never inspected -- so the message round-trips - /// byte for byte back to the wire on re-send/edit, and renders as an "unsupported" bubble. + /// End-to-end-encrypted content not yet decrypted: either decryption failed (see + /// ``ConversationMessage/decryptFailure``) or the peer's key hasn't been fetched yet. `scheme` + /// is the wire `EncryptedContent.Scheme` raw value, kept as `Int` so this model doesn't depend + /// on the generated proto enum. A decrypted message carries `.text` instead. case encrypted(scheme: Int, nonce: Data, ciphertext: Data) } + /// The `EncryptedContent` a message arrived as, kept beside its decrypted text. + public struct Sealed: Hashable, Sendable { + /// The wire `EncryptedContent.Scheme` raw value. + public let scheme: Int + public let nonce: Data + public let ciphertext: Data + + public init(scheme: Int, nonce: Data, ciphertext: Data) { + self.scheme = scheme + self.nonce = nonce + self.ciphertext = ciphertext + } + } + + /// Why an encrypted message could not be decrypted. + public enum DecryptFailure: Int, Error, Hashable, Sendable { + /// An unknown scheme, or plaintext of a type this client can't show; a newer client can read it. + case unsupported = 0 + /// Authentication failed on a supported scheme; only a resend can fix it. + case authentication = 1 + } + public let id: MessageID public let senderID: UserID? public let content: Content @@ -92,6 +113,12 @@ public struct ConversationMessage: Identifiable, Hashable, Sendable { /// The message's reactions; nil when this copy carries no reaction summary, so a store merging /// it keeps the reactions it already holds. public var reactionState: ReactionState? + /// The ciphertext this message arrived as, or nil for a message sent in plaintext. Set on a + /// decrypted message too, whose `content` is the plaintext. + public let sealed: Sealed? + /// Why `content` is still `.encrypted`, or nil when it isn't, or when decryption is waiting on + /// the peer's key. + public let decryptFailure: DecryptFailure? public init( id: MessageID, @@ -106,7 +133,9 @@ public struct ConversationMessage: Identifiable, Hashable, Sendable { status: SendStatus = .sent, clientMessageID: UUID? = nil, redacted: Bool = false, - reactionState: ReactionState? = nil + reactionState: ReactionState? = nil, + sealed: Sealed? = nil, + decryptFailure: DecryptFailure? = nil ) { self.id = id self.senderID = senderID @@ -121,6 +150,13 @@ public struct ConversationMessage: Identifiable, Hashable, Sendable { self.clientMessageID = clientMessageID self.redacted = redacted self.reactionState = reactionState + // Content still encrypted is its own ciphertext, so it always carries it as `sealed`. + if sealed == nil, case .encrypted(let scheme, let nonce, let ciphertext) = content { + self.sealed = Sealed(scheme: scheme, nonce: nonce, ciphertext: ciphertext) + } else { + self.sealed = sealed + } + self.decryptFailure = decryptFailure } } @@ -154,7 +190,53 @@ extension ConversationMessage { status: status, clientMessageID: clientMessageID, redacted: redacted, - reactionState: reactionState + reactionState: reactionState, + sealed: sealed, + decryptFailure: decryptFailure + ) + } + + /// Whether this message was sent end-to-end encrypted, decrypted or not. + public var isEncrypted: Bool { sealed != nil } + + /// Whether this message is encrypted and still waiting on the peer's key to decrypt. Such a + /// message is stored but not shown. + public var isAwaitingDecryption: Bool { + if case .encrypted = content { decryptFailure == nil } else { false } + } + + /// A copy carrying the outcome of decrypting it: the plaintext and the message it replies to, + /// or the reason it failed. Everything else is kept, including the ciphertext. + public func opened(_ outcome: Result<(text: String, repliedTo: MessageID?), DecryptFailure>) -> ConversationMessage { + let content: Content + let repliedTo: MessageID? + let failure: DecryptFailure? + switch outcome { + case .success(let plaintext): + content = .text(plaintext.text) + repliedTo = plaintext.repliedTo + failure = nil + case .failure(let reason): + content = self.content + repliedTo = self.repliedTo + failure = reason + } + return ConversationMessage( + id: id, + senderID: senderID, + content: content, + cashAction: cashAction, + date: date, + unreadSeq: unreadSeq, + eventSequence: eventSequence, + lastEditedTs: lastEditedTs, + repliedTo: repliedTo, + status: status, + clientMessageID: clientMessageID, + redacted: redacted, + reactionState: reactionState, + sealed: sealed, + decryptFailure: failure ) } } @@ -209,10 +291,7 @@ extension ConversationMessage { self.cashAction = nil repliedTo = replyContent.hasRepliedMessageID ? MessageID(replyContent.repliedMessageID) : nil case .encrypted(let encryptedContent): - // EncryptedContent is a cross-platform parity hotspot (X25519/HKDF/XChaCha20); decrypting - // it is not implemented here. Unlike `.media`/`.system`, the message is kept -- stored - // verbatim and rendered as an "unsupported" bubble -- so it doesn't silently vanish from - // the transcript the way Android's client no longer does either. + // Kept undecrypted here: decryption needs the chat's keys, which `ChatSeal.open` applies. self.content = .encrypted( scheme: encryptedContent.scheme.rawValue, nonce: encryptedContent.nonce, @@ -236,6 +315,12 @@ extension ConversationMessage { self.clientMessageID = nil self.redacted = proto.redacted self.reactionState = proto.hasReactions ? ReactionState(proto.reactions) : nil + if case .encrypted(let scheme, let nonce, let ciphertext) = content { + self.sealed = Sealed(scheme: scheme, nonce: nonce, ciphertext: ciphertext) + } else { + self.sealed = nil + } + self.decryptFailure = nil } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift index 0c75fcb0b..84f34c8d4 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift @@ -314,15 +314,16 @@ public struct ConversationStore: Sendable { } /// Set the feed row's preview to the caller-supplied newest visible message (computed from the - /// database), never regressing: an older or equal-versioned candidate is ignored, so a stale - /// re-delivery can't overwrite a newer preview. `force` bypasses the guard for the one legitimate + /// database), never regressing: an older or equal-versioned candidate is ignored, unless it decrypts + /// the current one, so a stale re-delivery can't overwrite a newer preview. `force` bypasses the guard for the one legitimate /// regression — the newest message was tombstoned, so the preview must fall back to the previous /// visible one instead of showing deleted content. Never touches `lastActivity` or the feed order. public mutating func setFeedPreview(_ newest: ConversationMessage?, in conversationID: ConversationID, force: Bool = false) { guard let newest, let index = conversations.firstIndex(where: { $0.id == conversationID }) else { return } if !force, let current = conversations[index].lastMessage, - (current.id, current.eventSequence) >= (newest.id, newest.eventSequence) { return } + (current.id, current.eventSequence) >= (newest.id, newest.eventSequence), + !(current.isAwaitingDecryption && (current.id, current.eventSequence) == (newest.id, newest.eventSequence)) { return } conversations[index].lastMessage = newest } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/E2eePolicy.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/E2eePolicy.swift index 360a529ce..4a20016df 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/E2eePolicy.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/E2eePolicy.swift @@ -6,36 +6,35 @@ // import Foundation +import SharedCore /// Decides whether this client encrypts a conversation's messages, and so whether its UI may claim /// end-to-end encryption. /// -/// The only reader of ``Conversation/useE2Ee``: once E2EE launches the server flag is ignored, and -/// dropping it is a change to this file alone. +/// The only reader of ``Conversation/useE2Ee``. The rule itself, including the @flipcash exemption, +/// is shared-core's `ChatEncryptionPolicy`, so both apps agree on which chats encrypt. public enum E2eePolicy { - /// The official @flipcash account's user id. Its chat stays plaintext because the backend sends - /// onboarding messages there and reads the replies. - // TODO: set @flipcash user id - public static let flipcashAccountID: UserID? = nil - /// Whether messages in `conversation` are sent encrypted: a DM with the flag on that is not the /// @flipcash chat. Groups never encrypt. public static func shouldEncrypt(_ conversation: Conversation) -> Bool { - shouldEncrypt(conversation, flipcashAccountID: flipcashAccountID) - } - - static func shouldEncrypt(_ conversation: Conversation, flipcashAccountID: UserID?) -> Bool { + let isDirectMessage: Bool switch conversation.type { case .group: - return false + isDirectMessage = false case .contactDm, .tipDm: - break + isDirectMessage = true } - guard conversation.useE2Ee else { return false } - if let flipcashAccountID, conversation.members.contains(where: { $0.userID == flipcashAccountID }) { - return false + // Self is never @flipcash, so requiring every member to pass as the peer exempts the + // @flipcash chat without knowing which member is self. A member without an id has no key + // to encrypt to. + return !conversation.members.isEmpty && conversation.members.allSatisfy { member in + guard let userID = member.userID else { return false } + return ChatEncryptionPolicy.shared.shouldEncrypt( + isDirectMessage: isDirectMessage, + useE2ee: conversation.useE2Ee, + peerUserId: SharedBytes.shared.byteArray(data: userID.data) + ) } - return true } } diff --git a/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift b/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift index a016fdc03..2882f80cc 100644 --- a/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift +++ b/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift @@ -55,12 +55,7 @@ public enum NotificationPayload { /// It carries the same `eventSequence` the transcript fetch would return for it, so it merges /// with a fetched message rather than competing with one. /// - /// TODO(push/v1/model.proto ChatMetadata.message_ref): when only `messageID` is present, this - /// returns nil rather than fetching the message via `Messaging.GetMessage`. The notification - /// service extension's transcript prefetch (`NotificationService.cachePreview`) already fetches - /// the chat's recent messages independently of this value, so the id-only case is not silently - /// dropped in practice — it just doesn't get the "needs no network" fast path this doc comment - /// describes. Wire up a `GetMessage` fetch here (or at the call site) if that gap matters. + /// When only the message's id is present, ``chatMessageID(_:)`` names it for a `GetMessage` fetch. public static func chatMessage(_ userInfo: [AnyHashable: Any]) -> ConversationMessage? { guard let payload = decode(userInfo), payload.category == .chat, payload.hasChatMetadata else { return nil @@ -73,6 +68,37 @@ public enum NotificationPayload { } } + /// The id of the message a CHAT push is about, whether the push embeds it or only names it. + /// Nil when the push isn't a chat message or carries no message reference. + public static func chatMessageID(_ userInfo: [AnyHashable: Any]) -> MessageID? { + guard let payload = decode(userInfo), payload.category == .chat, payload.hasChatMetadata else { + return nil + } + switch payload.chatMetadata.messageRef { + case .message(let message): + return MessageID(message.messageID) + case .messageID(let id): + return MessageID(id) + case nil: + return nil + } + } + + /// The banner body for an end-to-end-encrypted push: the plaintext of message `messageID` when it + /// arrived encrypted and decrypted to text. Nil keeps the server's body — the message is missing, + /// was never encrypted, isn't text, or didn't decrypt. + public static func decryptedBody(of messageID: MessageID?, in messages: [ConversationMessage]) -> String? { + guard let messageID, let message = messages.first(where: { $0.id == messageID }), message.isEncrypted else { + return nil + } + switch message.content { + case .text(let text): + return text + case .cash, .deleted, .encrypted: + return nil + } + } + /// Whether the recipient had the chat muted when a CHAT push was sent. The push is still /// delivered so the client can store the message, but the client must not present a /// notification for it. `false` when the push isn't a chat message or carries no chat metadata diff --git a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift index ebffbcf6d..d6f051cea 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift @@ -116,7 +116,8 @@ nonisolated extension Database { private func latestMessage(conversationId: Data) throws -> ConversationMessage? { let m = ConversationMessageTable() guard let row = try reader.pluck( - m.table.filter(m.conversationId == conversationId && m.kind != 2).order(m.id.desc) + // Neither a tombstone nor an encrypted row still waiting on the peer's key. + m.table.filter(m.conversationId == conversationId && m.kind != 2 && (m.kind != 3 || m.decryptFailure != nil)).order(m.id.desc) ) else { return nil } @@ -491,7 +492,9 @@ nonisolated extension Database { } return } - if existingSequence == message.eventSequence { + // A copy that decrypts a row stored while waiting on the peer's key is not a re-delivery. + let opensStoredRow = existing[m.kind] == 3 && existing[m.decryptFailure] == nil && !message.isAwaitingDecryption + if existingSequence == message.eventSequence && !opensStoredRow { // Equal version: keep the stored row, adopting only a client id it lacks (a reconcile // copy landing after a stream echo, or vice versa) so identity stays stable. if existing[m.clientMessageID] == nil, let clientMessageID = message.clientMessageID { @@ -520,6 +523,11 @@ nonisolated extension Database { var encryptedScheme: Int? var encryptedNonce: Data? var encryptedCiphertext: Data? + if let sealed = message.sealed { + encryptedScheme = sealed.scheme + encryptedNonce = sealed.nonce + encryptedCiphertext = sealed.ciphertext + } switch message.content { case .text(let value): @@ -572,6 +580,7 @@ nonisolated extension Database { m.encryptedScheme <- encryptedScheme, m.encryptedNonce <- encryptedNonce, m.encryptedCiphertext <- encryptedCiphertext, + m.decryptFailure <- message.decryptFailure?.rawValue, m.reactionsJson <- Self.encodeReactions(message.reactionState) ) ) @@ -741,6 +750,11 @@ nonisolated extension Database { return nil } + let sealed: ConversationMessage.Sealed? = switch (row[m.encryptedScheme], row[m.encryptedNonce], row[m.encryptedCiphertext]) { + case let (scheme?, nonce?, ciphertext?): ConversationMessage.Sealed(scheme: scheme, nonce: nonce, ciphertext: ciphertext) + default: nil + } + return ConversationMessage( id: MessageID(value: row[m.id]), senderID: row[m.senderId], @@ -752,7 +766,9 @@ nonisolated extension Database { lastEditedTs: row[m.lastEditedTs].map(Date.init(timeIntervalSinceReferenceDate:)), repliedTo: row[m.repliedToId].map(MessageID.init(value:)), clientMessageID: row[m.clientMessageID], - reactionState: Self.decodeReactions(row[m.reactionsJson]) + reactionState: Self.decodeReactions(row[m.reactionsJson]), + sealed: sealed, + decryptFailure: row[m.decryptFailure].flatMap(ConversationMessage.DecryptFailure.init(rawValue:)) ) } } diff --git a/FlipcashCore/Sources/FlipcashStore/Database.swift b/FlipcashCore/Sources/FlipcashStore/Database.swift index 63828cfb1..d1d35bba8 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database.swift @@ -197,7 +197,7 @@ nonisolated open class Database: @unchecked Sendable { /// the notification service extension needs the same number to decide whether the store on disk /// is one it understands, and an extension cannot read the app's `Info.plist` — separate bundles. /// Both targets link this module, so they cannot disagree. - public static let schemaVersion = 42 + public static let schemaVersion = 43 /// Removes the store and the write-ahead log files beside it. /// diff --git a/FlipcashCore/Sources/FlipcashStore/Schema.swift b/FlipcashCore/Sources/FlipcashStore/Schema.swift index 38629211d..b31225e64 100644 --- a/FlipcashCore/Sources/FlipcashStore/Schema.swift +++ b/FlipcashCore/Sources/FlipcashStore/Schema.swift @@ -355,11 +355,16 @@ nonisolated public struct ConversationMessageTable: Sendable { // because the schema version can only be bumped once per rebuild, and adding it later would // cost users a second full resync. public let repliedToId = Expression ("repliedToId") - // `.encrypted` content, decomposed the way cash amounts are. All three nil for a non-encrypted - // row. `encryptedScheme` is the wire `EncryptedContent.Scheme` raw value. + // The `EncryptedContent` a message arrived as, decomposed the way cash amounts are. All three nil + // for a message sent in plaintext. A decrypted row is `kind` 0 with its plaintext in `text` and + // these still set; an undecrypted one is `kind` 3. `encryptedScheme` is the wire + // `EncryptedContent.Scheme` raw value. public let encryptedScheme = Expression ("encryptedScheme") public let encryptedNonce = Expression ("encryptedNonce") public let encryptedCiphertext = Expression ("encryptedCiphertext") + // `ConversationMessage.DecryptFailure` raw value for a `kind` 3 row that failed to decrypt; nil + // for one still waiting on the peer's key, and for every other row. + public let decryptFailure = Expression ("decryptFailure") // When the sender last edited this message; nil if never edited. public let lastEditedTs = Expression ("lastEditedTs") // Tombstone detail. Both nil for a message that has not been deleted. @@ -640,6 +645,7 @@ nonisolated extension Database { t.column(conversationMessageTable.encryptedScheme) t.column(conversationMessageTable.encryptedNonce) t.column(conversationMessageTable.encryptedCiphertext) + t.column(conversationMessageTable.decryptFailure) t.column(conversationMessageTable.reactionsJson) t.primaryKey(conversationMessageTable.conversationId, conversationMessageTable.id) }) diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ChatKeyringTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ChatKeyringTests.swift new file mode 100644 index 000000000..7fa27d80f --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/ChatKeyringTests.swift @@ -0,0 +1,148 @@ +// +// ChatKeyringTests.swift +// FlipcashCoreTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import Foundation +import FlipcashAPI +import SharedCore +@testable import FlipcashCore + +/// SharedCore exports its own `KeyPair`; these tests mean the app's. +private typealias KeyPair = FlipcashCore.KeyPair + +@Suite("ChatKeyring") +struct ChatKeyringTests { + + private final class MemoryKeyStore: PeerKeyStore, @unchecked Sendable { + private let lock = NSLock() + private var keys: [UserID: PublicKey] = [:] + func key(for userID: UserID) -> PublicKey? { lock.withLock { keys[userID] } } + func store(_ key: PublicKey, for userID: UserID) { lock.withLock { keys[userID] = key } } + } + + private final class Resolver: @unchecked Sendable { + private let lock = NSLock() + private var _calls = 0 + var calls: Int { lock.withLock { _calls } } + let result: Result + init(_ result: Result) { self.result = result } + func resolve(_: UserID) throws -> PublicKey { + lock.withLock { _calls += 1 } + return try result.get() + } + } + + private struct Offline: Error {} + + private let alice = KeyPair.generate()! + private let bob = KeyPair.generate()! + private let aliceID = UUID() + private let bobID = UUID() + + private func dm(type: ConversationType = .contactDm, useE2Ee: Bool = true, members: [UUID]? = nil) -> Conversation { + Conversation( + id: ConversationID(data: Data(repeating: 3, count: 32)), + members: (members ?? [aliceID, bobID]).map { ConversationMember(userID: $0, displayName: "m") }, + lastMessage: nil, + lastActivity: Date(timeIntervalSince1970: 0), + type: type, + useE2Ee: useE2Ee + ) + } + + private func keyring(_ resolver: Resolver, store: PeerKeyStore = MemoryKeyStore(), owner: KeyPair? = nil, selfID: UUID? = nil) -> ChatKeyring { + ChatKeyring(cipher: DefaultChatCipher.shared, owner: owner ?? alice, selfUserID: selfID ?? aliceID, store: store) { try resolver.resolve($0) } + } + + /// An encrypted message from Bob to Alice in `dm()`. + private func fromBob(_ text: String) async throws -> ConversationMessage { + let bobRing = keyring(Resolver(.success(alice.publicKey)), owner: bob, selfID: bobID) + let seal = try #require(try await bobRing.sealForSending(in: dm())) + let proto = Flipcash_Messaging_V1_Message.with { + $0.messageID = MessageID(value: 1).proto + $0.senderID = bobID.proto + $0.content = [try! seal.seal(text: text, repliedTo: nil)] + } + return try #require(ConversationMessage(proto)) + } + + @Test("A chat the policy exempts is sent in plaintext without fetching a key") + func plaintextChat() async throws { + let resolver = Resolver(.success(bob.publicKey)) + let ring = keyring(resolver) + + #expect(try await ring.sealForSending(in: dm(useE2Ee: false)) == nil) + #expect(try await ring.sealForSending(in: dm(type: .group)) == nil) + #expect(resolver.calls == 0) + } + + @Test("An encrypting DM seals with the peer's resolved key, fetched once and stored") + func encryptingDM() async throws { + let resolver = Resolver(.success(bob.publicKey)) + let store = MemoryKeyStore() + let ring = keyring(resolver, store: store) + + #expect(try await ring.sealForSending(in: dm()) != nil) + #expect(try await ring.sealForSending(in: dm()) != nil) + #expect(resolver.calls == 1) + #expect(store.key(for: bobID) == bob.publicKey) + } + + @Test("A stored key is used without resolving") + func storedKey() async throws { + let resolver = Resolver(.failure(Offline())) + let store = MemoryKeyStore() + store.store(bob.publicKey, for: bobID) + + #expect(try await keyring(resolver, store: store).sealForSending(in: dm()) != nil) + #expect(resolver.calls == 0) + } + + @Test("Sending fails rather than going out in plaintext when the key can't be fetched") + func sendOffline() async { + let ring = keyring(Resolver(.failure(Offline()))) + + await #expect(throws: PeerKeyUnavailable.self) { try await ring.sealForSending(in: dm()) } + } + + @Test("Received messages decrypt with the peer's key") + func receive() async throws { + let ring = keyring(Resolver(.success(bob.publicKey))) + let opened = await ring.open(try await fromBob("hello"), in: dm()) + + #expect(opened.content == .text("hello")) + } + + @Test("A key-fetch failure leaves messages waiting, not failed, and a later open decrypts them") + func receiveOffline() async throws { + let message = try await fromBob("hello") + let store = MemoryKeyStore() + + let waiting = await keyring(Resolver(.failure(Offline())), store: store).open(message, in: dm()) + #expect(waiting.isAwaitingDecryption) + #expect(waiting.decryptFailure == nil) + + let opened = await keyring(Resolver(.success(bob.publicKey)), store: store).open(waiting, in: dm()) + #expect(opened.content == .text("hello")) + } + + @Test("Encrypted content in a group can't be decrypted and is unsupported") + func group() async throws { + let opened = await keyring(Resolver(.success(bob.publicKey))).open(try await fromBob("hello"), in: dm(type: .group)) + + #expect(opened.decryptFailure == .unsupported) + } + + @Test("Plaintext messages pass through untouched") + func plaintext() async { + let resolver = Resolver(.failure(Offline())) + let message = ConversationMessage(id: MessageID(value: 1), senderID: bobID, content: .text("hi"), date: .now, unreadSeq: 1) + + #expect(await keyring(resolver).open(message, in: dm()) == message) + #expect(resolver.calls == 0) + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ChatPreviewDecryptionTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ChatPreviewDecryptionTests.swift new file mode 100644 index 000000000..bf2d53444 --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/ChatPreviewDecryptionTests.swift @@ -0,0 +1,63 @@ +// +// ChatPreviewDecryptionTests.swift +// FlipcashCoreTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import Foundation +@testable import FlipcashCore + +@Suite("Notification preview of encrypted messages") +struct ChatPreviewDecryptionTests { + + private let me = UUID() + private let them = UUID() + private let sealed = ConversationMessage.Sealed(scheme: 1, nonce: Data([1]), ciphertext: Data([2])) + + private func encrypted(_ id: UInt64, from sender: UUID, text: String? = nil, failure: ConversationMessage.DecryptFailure? = nil) -> ConversationMessage { + ConversationMessage( + id: MessageID(value: id), + senderID: sender, + content: text.map { .text($0) } ?? .encrypted(scheme: sealed.scheme, nonce: sealed.nonce, ciphertext: sealed.ciphertext), + date: Date(timeIntervalSince1970: 1_000_000 + TimeInterval(id)), + unreadSeq: id, + sealed: sealed, + decryptFailure: failure + ) + } + + private func contents(_ items: [ChatItem]) -> [ChatMessage.Content] { + items.compactMap { if case .message(let message) = $0 { message.content } else { nil } } + } + + @Test("A decrypted message previews as its plaintext") + func decryptedPreviewsPlaintext() { + let items = ChatItem.preview(from: [encrypted(1, from: them, text: "hello")], selfUserID: me) + #expect(contents(items) == [.text("hello")]) + } + + @Test("A message waiting on the peer's key is left out of the preview") + func awaitingIsLeftOut() { + let items = ChatItem.preview(from: [encrypted(1, from: them, text: "hi"), encrypted(2, from: them)], selfUserID: me) + #expect(contents(items) == [.text("hi")]) + } + + @Test("A failed message previews with the hint its cause calls for") + func failureHints() { + func hint(_ sender: UUID, _ failure: ConversationMessage.DecryptFailure) -> [ChatMessage.Content] { + contents(ChatItem.preview(from: [encrypted(1, from: sender, failure: failure)], selfUserID: me, counterpartName: "Ada Lovelace")) + } + #expect(hint(them, .unsupported) == [.unavailable(.updateApp)]) + #expect(hint(them, .authentication) == [.unavailable(.askToResend(firstName: "Ada"))]) + #expect(hint(me, .authentication) == [.unavailable(.resend)]) + } + + @Test("The hint's copy") + func hintCopy() { + #expect(ChatMessage.UnavailableHint.updateApp.text == "Update Flipcash to see it") + #expect(ChatMessage.UnavailableHint.askToResend(firstName: "Ada").text == "Ask Ada to send it again") + #expect(ChatMessage.UnavailableHint.resend.text == "Try sending it again") + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ChatSealTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ChatSealTests.swift new file mode 100644 index 000000000..8f2256b5c --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/ChatSealTests.swift @@ -0,0 +1,130 @@ +// +// ChatSealTests.swift +// FlipcashCoreTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import Foundation +import FlipcashAPI +import SharedCore +@testable import FlipcashCore + +/// SharedCore exports its own `KeyPair`; these tests mean the app's. +private typealias KeyPair = FlipcashCore.KeyPair + +@Suite("ChatSeal") +struct ChatSealTests { + + private let alice = KeyPair.generate()! + private let bob = KeyPair.generate()! + private let aliceID = UUID() + private let bobID = UUID() + private let chatID = ConversationID(data: Data(repeating: 7, count: 32)) + + private func seal(owner: KeyPair, peer: KeyPair, selfID: UUID) throws -> ChatSeal { + try ChatSeal(cipher: DefaultChatCipher.shared, owner: owner, peerPublicKey: peer.publicKey, conversationID: chatID, selfUserID: selfID) + } + + /// The message `content` arrives as, sent by `sender`. + private func message(_ content: Flipcash_Messaging_V1_Content, from sender: UUID) throws -> ConversationMessage { + let proto = Flipcash_Messaging_V1_Message.with { + $0.messageID = MessageID(value: 42).proto + $0.senderID = sender.proto + $0.content = [content] + } + return try #require(ConversationMessage(proto)) + } + + @Test("The peer decrypts what was sealed") + func roundTrip() throws { + let sealed = try seal(owner: alice, peer: bob, selfID: aliceID).seal(text: "hi bob", repliedTo: nil) + let opened = try seal(owner: bob, peer: alice, selfID: bobID).open(message(sealed, from: aliceID)) + + #expect(opened.content == .text("hi bob")) + #expect(opened.decryptFailure == nil) + #expect(opened.isEncrypted) + } + + @Test("The sender decrypts their own message") + func ownMessage() throws { + let aliceSeal = try seal(owner: alice, peer: bob, selfID: aliceID) + let opened = aliceSeal.open(try message(aliceSeal.seal(text: "note to self", repliedTo: nil), from: aliceID)) + + #expect(opened.content == .text("note to self")) + } + + @Test("A reply decrypts to its text and the message it quotes") + func reply() throws { + let sealed = try seal(owner: alice, peer: bob, selfID: aliceID).seal(text: "yes", repliedTo: MessageID(value: 9)) + let opened = try seal(owner: bob, peer: alice, selfID: bobID).open(message(sealed, from: aliceID)) + + #expect(opened.content == .text("yes")) + #expect(opened.repliedTo == MessageID(value: 9)) + } + + @Test("Tampered ciphertext fails authentication") + func tampered() throws { + var sealed = try seal(owner: alice, peer: bob, selfID: aliceID).seal(text: "hi", repliedTo: nil) + sealed.encrypted.ciphertext[0] ^= 0xFF + let opened = try seal(owner: bob, peer: alice, selfID: bobID).open(message(sealed, from: aliceID)) + + #expect(opened.decryptFailure == .authentication) + #expect(opened.isAwaitingDecryption == false) + } + + @Test("A key that isn't the sender's fails authentication") + func wrongKey() throws { + let sealed = try seal(owner: alice, peer: bob, selfID: aliceID).seal(text: "hi", repliedTo: nil) + let opened = try seal(owner: bob, peer: KeyPair.generate()!, selfID: bobID).open(message(sealed, from: aliceID)) + + #expect(opened.decryptFailure == .authentication) + } + + @Test("An unknown scheme is unsupported") + func unknownScheme() throws { + var sealed = try seal(owner: alice, peer: bob, selfID: aliceID).seal(text: "hi", repliedTo: nil) + sealed.encrypted.scheme = .UNRECOGNIZED(99) + let opened = try seal(owner: bob, peer: alice, selfID: bobID).open(message(sealed, from: aliceID)) + + #expect(opened.decryptFailure == .unsupported) + } + + @Test("Authenticated content of a type this client can't show is unsupported") + func unknownContentType() throws { + let bytes = SharedBytes.shared + let plaintext = Flipcash_Messaging_V1_Content.with { $0.media = .init() } + let owner = SharedCore.KeyPair(publicKey: bytes.byteArray(data: alice.publicKey.data), privateKey: bytes.byteArray(data: alice.privateKey.data)) + let chatKey = try DefaultChatCipher.shared.chatKey(ownKeyPair: owner, peerPublicKey: bytes.byteArray(data: bob.publicKey.data), chatId: bytes.byteArray(data: chatID.data)) + let payload = try DefaultChatCipher.shared.encrypt( + content: bytes.byteArray(data: try plaintext.serializedData()), + chatKey: chatKey, + senderPk: bytes.byteArray(data: alice.publicKey.data), + recipientPk: bytes.byteArray(data: bob.publicKey.data), + chatId: bytes.byteArray(data: chatID.data) + ) + let content = Flipcash_Messaging_V1_Content.with { + $0.encrypted = .with { + $0.scheme = ChatSeal.scheme + $0.nonce = bytes.data(bytes: payload.nonce) + $0.ciphertext = bytes.data(bytes: payload.ciphertext) + } + } + let opened = try seal(owner: bob, peer: alice, selfID: bobID).open(message(content, from: aliceID)) + + #expect(opened.decryptFailure == .unsupported) + } + + @Test("Sealed content is EncryptedContent under the supported scheme") + func wireShape() throws { + let sealed = try seal(owner: alice, peer: bob, selfID: aliceID).seal(text: "hi", repliedTo: nil) + + guard case .encrypted(let encrypted) = sealed.type else { + Issue.record("Expected encrypted content") + return + } + #expect(encrypted.scheme == .x25519Xchacha20Poly1305) + #expect(encrypted.nonce.count == 24) + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/E2eePolicyTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/E2eePolicyTests.swift index 7c02ab699..175e85178 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/E2eePolicyTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/E2eePolicyTests.swift @@ -12,7 +12,7 @@ import Foundation @Suite("E2eePolicy") struct E2eePolicyTests { - private static let flipcashID = UUID() + private static let flipcashID = UUID(uuidString: "70c4a3df-54af-439a-88fe-a7de606d04cb")! private func conversation(type: ConversationType, useE2Ee: Bool, memberIDs: [UUID] = [UUID(), UUID()]) -> Conversation { Conversation( @@ -27,28 +27,22 @@ struct E2eePolicyTests { @Test("A DM with the flag on encrypts", arguments: [ConversationType.contactDm, .tipDm]) func dmFlagOn(type: ConversationType) { - #expect(E2eePolicy.shouldEncrypt(conversation(type: type, useE2Ee: true), flipcashAccountID: Self.flipcashID)) + #expect(E2eePolicy.shouldEncrypt(conversation(type: type, useE2Ee: true))) } @Test("A DM with the flag off does not encrypt", arguments: [ConversationType.contactDm, .tipDm]) func dmFlagOff(type: ConversationType) { - #expect(!E2eePolicy.shouldEncrypt(conversation(type: type, useE2Ee: false), flipcashAccountID: Self.flipcashID)) + #expect(!E2eePolicy.shouldEncrypt(conversation(type: type, useE2Ee: false))) } @Test("A group never encrypts, even with the flag on") func groupFlagOn() { - #expect(!E2eePolicy.shouldEncrypt(conversation(type: .group, useE2Ee: true), flipcashAccountID: Self.flipcashID)) + #expect(!E2eePolicy.shouldEncrypt(conversation(type: .group, useE2Ee: true))) } @Test("The @flipcash chat never encrypts, even with the flag on") func flipcashFlagOn() { let chat = conversation(type: .contactDm, useE2Ee: true, memberIDs: [UUID(), Self.flipcashID]) - #expect(!E2eePolicy.shouldEncrypt(chat, flipcashAccountID: Self.flipcashID)) - } - - @Test("Until the @flipcash id is set, no chat is exempt") - func flipcashIDUnset() { - let chat = conversation(type: .contactDm, useE2Ee: true, memberIDs: [UUID(), Self.flipcashID]) - #expect(E2eePolicy.shouldEncrypt(chat, flipcashAccountID: nil)) + #expect(!E2eePolicy.shouldEncrypt(chat)) } } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/Push/NotificationPayloadTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/Push/NotificationPayloadTests.swift index 55ebd06a2..424052693 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/Push/NotificationPayloadTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/Push/NotificationPayloadTests.swift @@ -170,13 +170,15 @@ struct NotificationPayloadTests { private static func chatPush( category: Flipcash_Push_V1_Payload.Category = .chat, - message: Flipcash_Messaging_V1_Message? + message: Flipcash_Messaging_V1_Message?, + messageID: UInt64? = nil ) throws -> [String: String] { let payload = Flipcash_Push_V1_Payload.with { $0.category = category $0.chatMetadata = .with { $0.type = .contactDm if let message { $0.message = message } + if let messageID { $0.messageID = .with { $0.value = messageID } } } } return [NotificationPayload.userInfoKey: try Self.base64(for: payload)] @@ -245,4 +247,93 @@ struct NotificationPayloadTests { func chatMessageNilWhenNoPayload() { #expect(NotificationPayload.chatMessage([:]) == nil) } + + // MARK: - chatMessageID - + + @Test("chatMessageID names the embedded message") + func chatMessageIDFromEmbeddedMessage() throws { + let embedded = Flipcash_Messaging_V1_Message.with { + $0.messageID = .with { $0.value = 42 } + $0.content = [.with { $0.text = .with { $0.text = "hi" } }] + } + #expect(NotificationPayload.chatMessageID(try Self.chatPush(message: embedded)) == MessageID(value: 42)) + } + + /// An encrypted DM's push can carry only the id; the extension fetches the message by it. + @Test("chatMessageID names a message the push only references") + func chatMessageIDFromReference() throws { + let userInfo = try Self.chatPush(message: nil, messageID: 7) + #expect(NotificationPayload.chatMessage(userInfo) == nil) + #expect(NotificationPayload.chatMessageID(userInfo) == MessageID(value: 7)) + } + + @Test("chatMessageID is nil when the push references no message") + func chatMessageIDNilWhenAbsent() throws { + #expect(NotificationPayload.chatMessageID(try Self.chatPush(message: nil)) == nil) + } + + @Test("chatMessageID is nil for a non-chat category") + func chatMessageIDNilForNonChatCategory() throws { + #expect(NotificationPayload.chatMessageID(try Self.chatPush(category: .default, message: nil, messageID: 7)) == nil) + } + + @Test("chatMessageID is nil when no payload is present") + func chatMessageIDNilWhenNoPayload() { + #expect(NotificationPayload.chatMessageID([:]) == nil) + } + + // MARK: - decryptedBody - + + private static let sealed = ConversationMessage.Sealed(scheme: 1, nonce: Data([1]), ciphertext: Data([2])) + + private static func message( + _ id: UInt64, + _ content: ConversationMessage.Content, + sealed: ConversationMessage.Sealed? = Self.sealed, + failure: ConversationMessage.DecryptFailure? = nil + ) -> ConversationMessage { + ConversationMessage( + id: MessageID(value: id), + senderID: UUID(), + content: content, + date: Date(timeIntervalSince1970: 1_000_000), + unreadSeq: id, + sealed: sealed, + decryptFailure: failure + ) + } + + private static let stillSealed = ConversationMessage.Content.encrypted(scheme: 1, nonce: Data([1]), ciphertext: Data([2])) + + @Test("decryptedBody is the plaintext of the pushed message once it decrypts") + func decryptedBodyIsPlaintext() { + let messages = [Self.message(1, .text("earlier")), Self.message(2, .text("see you there"))] + #expect(NotificationPayload.decryptedBody(of: MessageID(value: 2), in: messages) == "see you there") + } + + /// The server's body is already right for a plaintext message; replacing it gains nothing. + @Test("decryptedBody is nil for a message that was never encrypted") + func decryptedBodyNilForPlaintext() { + let messages = [Self.message(1, .text("hi"), sealed: nil)] + #expect(NotificationPayload.decryptedBody(of: MessageID(value: 1), in: messages) == nil) + } + + @Test("decryptedBody keeps the server's body when the message didn't decrypt") + func decryptedBodyNilWhenUndecrypted() { + let messages = [ + Self.message(1, Self.stillSealed), + Self.message(2, Self.stillSealed, failure: .authentication), + Self.message(3, Self.stillSealed, failure: .unsupported), + ] + for id in 1...3 { + #expect(NotificationPayload.decryptedBody(of: MessageID(value: UInt64(id)), in: messages) == nil) + } + } + + @Test("decryptedBody is nil when the pushed message isn't among those fetched") + func decryptedBodyNilWhenMissing() { + let messages = [Self.message(1, .text("hi"))] + #expect(NotificationPayload.decryptedBody(of: MessageID(value: 9), in: messages) == nil) + #expect(NotificationPayload.decryptedBody(of: nil, in: messages) == nil) + } } diff --git a/FlipcashTests/Chat/ChatMessageMappingTests.swift b/FlipcashTests/Chat/ChatMessageMappingTests.swift index 8ed828555..cefc0b4ed 100644 --- a/FlipcashTests/Chat/ChatMessageMappingTests.swift +++ b/FlipcashTests/Chat/ChatMessageMappingTests.swift @@ -72,15 +72,81 @@ struct ChatMessageMappingTests { ConversationMessage(id: MessageID(value: id), senderID: sender, content: .deleted(.init(deletedBy: deletedBy ?? sender, deletedAt: base.addingTimeInterval(offset))), date: base.addingTimeInterval(offset), unreadSeq: id, eventSequence: id) } - @Test("an encrypted message draws as unavailable, asking for an update") - func encryptedMessageIsUnavailable() { - let encrypted = ConversationMessage( - id: MessageID(value: 1), senderID: them, - content: .encrypted(scheme: 1, nonce: Data(), ciphertext: Data()), - date: base, unreadSeq: 1 - ) - let rows = messageRows(ChatItem.from([encrypted], selfUserID: me)) - #expect(rows.map(\.content) == [.unavailable(.updateApp)]) + /// A message that arrived encrypted: decrypted to `body`, failed for `failure`, or still + /// waiting on the peer's key when both are nil. + private func encrypted( + _ id: UInt64, + _ sender: UUID, + _ body: String? = nil, + failure: ConversationMessage.DecryptFailure? = nil, + after offset: TimeInterval = 0 + ) -> ConversationMessage { + let sealed = ConversationMessage.Sealed(scheme: 1, nonce: Data([1]), ciphertext: Data([2])) + return ConversationMessage( + id: MessageID(value: id), + senderID: sender, + content: body.map { .text($0) } ?? .encrypted(scheme: sealed.scheme, nonce: sealed.nonce, ciphertext: sealed.ciphertext), + date: base.addingTimeInterval(offset), + unreadSeq: id, + sealed: sealed, + decryptFailure: failure + ) + } + + private func markerIndex(_ items: [ChatItem]) -> Int? { + items.firstIndex { if case .encryptionMarker = $0 { true } else { false } } + } + + private func index(of body: String, in items: [ChatItem]) -> Int? { + items.firstIndex { if case .message(let message) = $0 { message.content == .text(body) } else { false } } + } + + @Test("a message that failed to decrypt draws as unavailable, with the hint its cause calls for") + func decryptFailureHints() { + func hint(_ sender: UUID, _ failure: ConversationMessage.DecryptFailure) -> [ChatMessage.Content] { + messageRows(ChatItem.from([encrypted(1, sender, failure: failure)], selfUserID: me, counterpartName: "Ada Lovelace")) + .map(\.content) + } + #expect(hint(them, .unsupported) == [.unavailable(.updateApp)]) + #expect(hint(me, .unsupported) == [.unavailable(.updateApp)]) + #expect(hint(them, .authentication) == [.unavailable(.askToResend(firstName: "Ada"))]) + #expect(hint(me, .authentication) == [.unavailable(.resend)]) + } + + @Test("a message waiting on the peer's key is left out, not drawn as unavailable") + func awaitingDecryptionIsHidden() { + let items = ChatItem.from([text(1, them, "hi", after: 0), encrypted(2, them, after: 30)], selfUserID: me) + #expect(messageRows(items).map(\.content) == [.text("hi")]) + #expect(markerIndex(items) == nil) + } + + @Test("the marker sits above the first encrypted message, below the plaintext before it") + func markerWhereCiphertextStarts() throws { + let items = ChatItem.from( + [text(1, them, "old", after: 0), encrypted(2, them, "new", after: 30), encrypted(3, me, "newer", after: 60)], + selfUserID: me, + headsHistory: false + ) + let marker = try #require(markerIndex(items)) + let old = try #require(index(of: "old", in: items)) + let new = try #require(index(of: "new", in: items)) + #expect(old < marker && marker < new) + #expect(items.filter { if case .encryptionMarker = $0 { true } else { false } }.count == 1) + // The marker breaks the run: the first encrypted message doesn't group onto the plaintext above. + #expect(messageRows(items).first { $0.content == .text("new") }?.isContinuationFromPrevious == false) + } + + @Test("an all-encrypted transcript gets the marker at its head only once the head is loaded") + func markerAtHeadOfHistory() { + let messages = [encrypted(1, them, "a", after: 0), encrypted(2, me, "b", after: 30)] + let whole = ChatItem.from(messages, selfUserID: me, headsHistory: true) + #expect(markerIndex(whole) == 0) + #expect(markerIndex(ChatItem.from(messages, selfUserID: me, headsHistory: false)) == nil) + } + + @Test("a plaintext transcript has no marker, whatever the chat's flag says") + func noMarkerWithoutCiphertext() { + #expect(markerIndex(ChatItem.from([text(1, them, "hi", after: 0)], selfUserID: me)) == nil) } @Test("a deleted tombstone is dropped: no stray separator, no grouping to an invisible row, receipt intact") diff --git a/FlipcashTests/Chat/ConversationTypingIndicatorTests.swift b/FlipcashTests/Chat/ConversationTypingIndicatorTests.swift index 241880942..acd85457f 100644 --- a/FlipcashTests/Chat/ConversationTypingIndicatorTests.swift +++ b/FlipcashTests/Chat/ConversationTypingIndicatorTests.swift @@ -77,7 +77,7 @@ struct ConversationTypingIndicatorTests { switch item { case .typingIndicator(let typists): return typists - case .message, .dateSeparator, .unreadDivider, .profileCard, .groupCard: + case .message, .dateSeparator, .unreadDivider, .profileCard, .groupCard, .encryptionMarker: continue } } @@ -183,7 +183,7 @@ struct ConversationTypingIndicatorTests { for item in items { switch item { case .typingIndicator(let typists): return typists - case .message, .dateSeparator, .unreadDivider, .profileCard, .groupCard: continue + case .message, .dateSeparator, .unreadDivider, .profileCard, .groupCard, .encryptionMarker: continue } } return nil diff --git a/FlipcashUI/Package.swift b/FlipcashUI/Package.swift index 7f8799b0f..9ba21b942 100644 --- a/FlipcashUI/Package.swift +++ b/FlipcashUI/Package.swift @@ -20,7 +20,7 @@ let sharedCore: Package.Dependency = { if let sharedCoreLocalRoot { return .package(path: "\(sharedCoreLocalRoot)/kmp/shared-core/spm") } - return .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.9.0")) + return .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.10.0")) }() let package = Package( diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatEncryptionMarkerCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatEncryptionMarkerCell.swift new file mode 100644 index 000000000..917f85f01 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatEncryptionMarkerCell.swift @@ -0,0 +1,66 @@ +// +// ChatEncryptionMarkerCell.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +#if canImport(UIKit) +import UIKit +import SwiftUI + +/// The centered "🔒 Encrypted ›" line where a DM's end-to-end-encrypted messages begin. Tapping it +/// calls `onTap`. +public final class ChatEncryptionMarkerCell: UICollectionViewCell { + + public static let reuseIdentifier = "ChatEncryptionMarkerCell" + + private let button = UIButton(type: .system) + private var onTap: (() -> Void)? + + public override init(frame: CGRect) { + super.init(frame: frame) + button.setAttributedTitle(Self.title(), for: .normal) + button.addTarget(self, action: #selector(tapped), for: .touchUpInside) + button.accessibilityLabel = "Encrypted" + button.accessibilityHint = "Learn how your messages are protected" + button.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(button) + NSLayoutConstraint.activate([ + button.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 4), + button.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + button.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + button.leadingAnchor.constraint(greaterThanOrEqualTo: contentView.leadingAnchor, constant: 16), + ]) + } + + @available(*, unavailable) + public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + /// Sets what a tap does; nil leaves the line inert. + public func configure(onTap: (() -> Void)?) { + self.onTap = onTap + button.isUserInteractionEnabled = onTap != nil + } + + @objc private func tapped() { + onTap?() + } + + private static func title() -> NSAttributedString { + let font = UIFont.default(size: 12, weight: .bold) + let color = UIColor(Color.textSecondary) + let symbol = UIImage.SymbolConfiguration(pointSize: 10, weight: .semibold) + func glyph(_ name: String) -> NSAttributedString { + let attachment = NSTextAttachment() + attachment.image = UIImage(systemName: name, withConfiguration: symbol)? + .withTintColor(color, renderingMode: .alwaysOriginal) + return NSAttributedString(attachment: attachment) + } + let result = NSMutableAttributedString(attributedString: glyph("lock.fill")) + result.append(NSAttributedString(string: " Encrypted ", attributes: [.font: font, .foregroundColor: color])) + result.append(glyph("chevron.right")) + return result + } +} +#endif diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatItem+Differentiable.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatItem+Differentiable.swift index b65c5844f..afb8fdab8 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatItem+Differentiable.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatItem+Differentiable.swift @@ -35,6 +35,8 @@ extension ChatItem { ChatDateSeparatorCell.reuseIdentifier case .unreadDivider: ChatUnreadDividerCell.reuseIdentifier + case .encryptionMarker: + ChatEncryptionMarkerCell.reuseIdentifier case .message(let message): switch message.content { case .text: diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index 10e088d1f..08b14fbef 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -203,6 +203,12 @@ public final class ChatScreenViewController: UIViewController { set { transcript.onGroupInvite = newValue } } + /// Forwards the "Encrypted" marker's tap from the transcript to the owner. + public var onEncryptionMarkerTap: (() -> Void)? { + get { transcript.onEncryptionMarkerTap } + set { transcript.onEncryptionMarkerTap = newValue } + } + /// Forwards gutter-avatar taps from the transcript to the owner — see /// ``ChatViewController/onAuthorTap``. public var onAuthorTap: ((UserID) -> Void)? { diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index 6dec382aa..4ee703cff 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -76,6 +76,8 @@ public final class ChatViewController: UICollectionViewController { /// Called when the user taps the group card's "Invite People"; the owner hands out the /// chat's invite link. nil leaves the card without the offer. public var onGroupInvite: (() -> Void)? + /// Called when the "Encrypted" marker is tapped; nil leaves it inert. + public var onEncryptionMarkerTap: (() -> Void)? /// Called when the user taps an author's face in the gutter; the argument is that author's user /// id. Never fires in a DM, where no row draws one. @@ -122,7 +124,7 @@ public final class ChatViewController: UICollectionViewController { configure(cell, with: message) case .typingIndicator(let typists): (cell as? ChatTypingIndicatorCell)?.configure(typists: typists, imageData: authorAvatars) - case .dateSeparator, .unreadDivider, .profileCard, .groupCard: + case .dateSeparator, .unreadDivider, .profileCard, .groupCard, .encryptionMarker: continue } } @@ -323,6 +325,7 @@ public final class ChatViewController: UICollectionViewController { collectionView.register(ChatCashCardCell.self, forCellWithReuseIdentifier: ChatCashCardCell.reuseIdentifier) collectionView.register(ChatDateSeparatorCell.self, forCellWithReuseIdentifier: ChatDateSeparatorCell.reuseIdentifier) collectionView.register(ChatUnreadDividerCell.self, forCellWithReuseIdentifier: ChatUnreadDividerCell.reuseIdentifier) + collectionView.register(ChatEncryptionMarkerCell.self, forCellWithReuseIdentifier: ChatEncryptionMarkerCell.reuseIdentifier) collectionView.register(ChatTypingIndicatorCell.self, forCellWithReuseIdentifier: ChatTypingIndicatorCell.reuseIdentifier) collectionView.register(ChatProfileCardCell.self, forCellWithReuseIdentifier: ChatProfileCardCell.reuseIdentifier) collectionView.register(ChatGroupCardCell.self, forCellWithReuseIdentifier: ChatGroupCardCell.reuseIdentifier) @@ -514,7 +517,7 @@ public final class ChatViewController: UICollectionViewController { case .other: return start case .me: return nil } - case .typingIndicator, .dateSeparator, .unreadDivider, .profileCard, .groupCard: + case .typingIndicator, .dateSeparator, .unreadDivider, .profileCard, .groupCard, .encryptionMarker: return nil } } @@ -522,7 +525,7 @@ public final class ChatViewController: UICollectionViewController { private static func isTypingIndicator(_ item: ChatItem) -> Bool { switch item { case .typingIndicator: true - case .message, .dateSeparator, .unreadDivider, .profileCard, .groupCard: false + case .message, .dateSeparator, .unreadDivider, .profileCard, .groupCard, .encryptionMarker: false } } @@ -530,7 +533,7 @@ public final class ChatViewController: UICollectionViewController { guard items.indices.contains(index) else { return nil } switch items[index] { case .message(let message): return message - case .typingIndicator, .dateSeparator, .unreadDivider, .profileCard, .groupCard: return nil + case .typingIndicator, .dateSeparator, .unreadDivider, .profileCard, .groupCard, .encryptionMarker: return nil } } @@ -585,6 +588,9 @@ public final class ChatViewController: UICollectionViewController { (cell as! ChatDateSeparatorCell).configure(text: text) case .unreadDivider(let count): (cell as! ChatUnreadDividerCell).configure(count: count) + case .encryptionMarker: + let tap: (() -> Void)? = onEncryptionMarkerTap == nil ? nil : { [weak self] in self?.onEncryptionMarkerTap?() } + (cell as! ChatEncryptionMarkerCell).configure(onTap: tap) case .message(let message): configure(cell, with: message) } @@ -1168,7 +1174,7 @@ extension ChatViewController: ChatLayoutDelegate { private func isHeading(at indexPath: IndexPath) -> Bool { guard items.indices.contains(indexPath.item) else { return false } switch items[indexPath.item] { - case .dateSeparator, .unreadDivider: return true + case .dateSeparator, .unreadDivider, .encryptionMarker: return true case .message, .typingIndicator, .profileCard, .groupCard: return false } } @@ -1186,7 +1192,7 @@ extension ChatViewController: ChatLayoutDelegate { switch items[indexPath.item] { case .message(let message): return message.sender case .typingIndicator: return .other - case .dateSeparator, .unreadDivider, .profileCard, .groupCard: return nil + case .dateSeparator, .unreadDivider, .profileCard, .groupCard, .encryptionMarker: return nil } } } diff --git a/NotificationContent/NotificationTranscriptView.swift b/NotificationContent/NotificationTranscriptView.swift index e7c61f50e..b253a3537 100644 --- a/NotificationContent/NotificationTranscriptView.swift +++ b/NotificationContent/NotificationTranscriptView.swift @@ -35,7 +35,7 @@ struct NotificationTranscriptView: View { case .dateSeparator(_, let text): NotificationDateSeparator(text: text) .transition(.opacity) - case .typingIndicator, .profileCard, .groupCard, .unreadDivider: + case .typingIndicator, .profileCard, .groupCard, .unreadDivider, .encryptionMarker: // Typing is live, ephemeral state; the profile card and the // unread divider are in-app chrome — none belongs in a static // notification snapshot and the preview mapping never emits diff --git a/NotificationContent/NotificationViewController.swift b/NotificationContent/NotificationViewController.swift index 1d67df485..2a30cad4e 100644 --- a/NotificationContent/NotificationViewController.swift +++ b/NotificationContent/NotificationViewController.swift @@ -150,13 +150,19 @@ final class NotificationViewController: UIViewController, UNNotificationContentE // would post duplicate messages. let clientMessageID = UUID() _ = try await Task.retry(maxAttempts: 3, delay: .milliseconds(400)) { - try await client.sendMessage(owner: ownerKeyPair, conversationID: conversationID, text: text, clientMessageID: clientMessageID) + let seal = try await client.sealForSending(in: conversationID, owner: ownerKeyPair, selfUserID: selfUserID) + return try await client.sendMessage(owner: ownerKeyPair, conversationID: conversationID, text: text, seal: seal, clientMessageID: clientMessageID) } // Re-fetch so the sent message appears, and refresh the cache for the next open. - let messages = try await client.getMessages( + let messages = await client.open( + try await client.getMessages( + owner: ownerKeyPair, + conversationID: conversationID, + limit: NotificationPreviewCache.previewLimit + ), + in: conversationID, owner: ownerKeyPair, - conversationID: conversationID, - limit: NotificationPreviewCache.previewLimit + selfUserID: selfUserID ) // An empty post-send read (lost the read-after-write race) would otherwise blank the // transcript and overwrite the good cache with []; keep what's already shown instead. @@ -186,11 +192,16 @@ final class NotificationViewController: UIViewController, UNNotificationContentE guard let conversationID, let ownerKeyPair, let selfUserID else { return } do { let client = try ChatNotificationClient() - let messages = try await client.getMessages( + let messages = await client.open( + try await client.getMessages( + owner: ownerKeyPair, + conversationID: conversationID, + limit: NotificationPreviewCache.previewLimit, + retryingEmpty: true + ), + in: conversationID, owner: ownerKeyPair, - conversationID: conversationID, - limit: NotificationPreviewCache.previewLimit, - retryingEmpty: true + selfUserID: selfUserID ) if messages.isEmpty { showStatusLabel("No messages") diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift index c7e1982d3..70b376ca2 100644 --- a/NotificationService/NotificationService.swift +++ b/NotificationService/NotificationService.swift @@ -57,6 +57,15 @@ final class NotificationService: UNNotificationServiceExtension { } } + /// Replaces the body of the content still waiting to be delivered; a no-op once delivered. + func replaceBody(_ body: String) { + lock.withLock { + guard contentHandler != nil, let mutable = content?.mutableCopy() as? UNMutableNotificationContent else { return } + mutable.body = body + content = mutable + } + } + func setPrefetchTask(_ task: Task) { lock.withLock { prefetchTask = task } } @@ -197,7 +206,8 @@ final class NotificationService: UNNotificationServiceExtension { Self.startPrefetch( into: delivery, for: conversationID, - embedded: NotificationPayload.chatMessage(request.content.userInfo) + embedded: NotificationPayload.chatMessage(request.content.userInfo), + messageID: NotificationPayload.chatMessageID(request.content.userInfo) ) } @@ -207,10 +217,17 @@ final class NotificationService: UNNotificationServiceExtension { private nonisolated static func startPrefetch( into delivery: DeliveryBox, for conversationID: ConversationID, - embedded: ConversationMessage? + embedded: ConversationMessage?, + messageID: MessageID? ) { let task = Task { - await cachePreview(for: conversationID, embedded: embedded, deliver: { delivery.deliver() }) + await cachePreview( + for: conversationID, + embedded: embedded, + messageID: messageID, + replaceBody: { delivery.replaceBody($0) }, + deliver: { delivery.deliver() } + ) } delivery.setPrefetchTask(task) } @@ -323,20 +340,42 @@ final class NotificationService: UNNotificationServiceExtension { /// connection. Calls `deliver` once the transcript is cached (or the fetch can't proceed) so the /// banner isn't gated on the slower branding round-trip. Best-effort: any failure just leaves the /// content extension to fetch live. + /// + /// An end-to-end-encrypted DM's push arrives with the server's generic body; once the message it + /// is about decrypts, `replaceBody` swaps in the plaintext. On any failure the server's body stays. private static func cachePreview( for conversationID: ConversationID, embedded: ConversationMessage?, + messageID: MessageID?, + replaceBody: @Sendable (String) -> Void, deliver: @Sendable () -> Void ) async { guard let account = OwnerKeyStore.loadOwnerAccount() else { return deliver() } + let owner = account.keyAccount.owner + var embedded = embedded do { let client = try ChatNotificationClient() - let messages = try await client.getMessages( - owner: account.keyAccount.owner, + let fetched = try await client.getMessages( + owner: owner, conversationID: conversationID, limit: NotificationPreviewCache.previewLimit, retryingEmpty: true ) + // The pushed message, fetched by id when the push only named it and the preview missed it. + if embedded == nil, let messageID, !fetched.contains(where: { $0.id == messageID }) { + embedded = try? await client.getMessage(owner: owner, conversationID: conversationID, messageID: messageID) + } + let opened = await client.open( + fetched + (embedded.map { [$0] } ?? []), + in: conversationID, + owner: owner, + selfUserID: account.userID + ) + let messages = Array(opened.prefix(fetched.count)) + if embedded != nil { embedded = opened.last } + if let body = NotificationPayload.decryptedBody(of: messageID, in: messages + (embedded.map { [$0] } ?? [])) { + replaceBody(body) + } guard !messages.isEmpty else { // The fetch came back empty but the push still carried a message. Write that one // rather than nothing.