From a895ec6a4bb0958eb93ee71a83b7db9544f8fafc Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 29 Sep 2026 13:39:27 -0400 Subject: [PATCH 1/4] refactor(chat): take the E2EE send rule from shared-core E2eePolicy now asks shared-core's ChatEncryptionPolicy, which carries the @flipcash user id, so the app's own nil constant and its injection seam go. Both DM types count as direct messages. The conversation doesn't say which member is self, so each member is checked as the peer; self is never @flipcash, so only the @flipcash chat fails. A member without a user id has no key to encrypt to and keeps the chat in plaintext. Needs a SharedCoreKit build with ChatCipher (code-android-app#1616); until it's released, build with FLIPCASH_SHARED_CORE_LOCAL. --- .../Models/Conversation/E2eePolicy.swift | 33 +++++++++---------- .../FlipcashCoreTests/E2eePolicyTests.swift | 16 +++------ 2 files changed, 21 insertions(+), 28 deletions(-) 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/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)) } } From c53a180ebe007b86451d5702096a0737531dd339 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 29 Sep 2026 15:06:23 -0400 Subject: [PATCH 2/4] feat(chat): encrypt and decrypt DM text with shared-core's ChatCipher Text and Reply(Text) in a DM are sealed into EncryptedContent when ChatEncryptionPolicy says so. EncryptedChatClient wraps FlipClient so send, retry and edit all encrypt, and every message read back from the server (fetch, delta, stream) is decrypted before it reaches the store. The server's echo of a sent message is decrypted the same way. The peer's key comes from Resolver.Resolve(user_id) and is kept per user. Decrypted text is stored next to the ciphertext (schema 43). A message that fails to open keeps its cause, which picks the unavailable hint: an unknown scheme or type asks to update Flipcash, an auth failure asks the sender to send it again, or asks you to try again for your own message. A message whose key couldn't be fetched is hidden from the transcript and the chat-list preview until a later load decrypts it. ENCRYPTION_NOT_ALLOWED leaves a retryable failed bubble and drops the cached chat, so the retry reads use_e2ee again. The transcript gets an Encrypted marker before the first loaded encrypted message, placed from the messages rather than use_e2ee; tapping it opens the DM learn-more sheet. The notification service extension decrypts the push body, fetching the message by id when the push carries only that, and keeps the server's body on any failure. The quick reply from the notification encrypts. --- .../Controllers/EncryptedChatClient.swift | 229 ++++++++++++++++++ .../Controllers/FlipClient+Protocols.swift | 2 +- .../Conversation/ChatItem+Conversation.swift | 32 ++- .../ChatScreenRepresentable.swift | 4 + .../ConversationLoadCoordinator.swift | 3 +- .../Conversation/ConversationScreen.swift | 6 + .../Core/Session/SessionAuthenticator.swift | 16 +- .../Clients/Chat/ChatNotificationClient.swift | 70 +++++- .../Clients/Flip API/FlipClient+Chat.swift | 7 +- .../Services/ChatMessagingService.swift | 54 +++-- .../FlipcashCore/Models/Chat/ChatItem.swift | 6 +- .../Models/Chat/ChatMessage.swift | 23 ++ .../Models/Chat/ChatPreviewMapping.swift | 16 +- .../Models/Conversation/ChatKeyring.swift | 172 +++++++++++++ .../Models/Conversation/ChatSeal.swift | 127 ++++++++++ .../Conversation/ConversationMessage.swift | 107 +++++++- .../Conversation/ConversationStore.swift | 7 +- .../Push/NotificationPayload.swift | 23 +- .../Database+Conversations.swift | 22 +- .../Sources/FlipcashStore/Database.swift | 2 +- .../Sources/FlipcashStore/Schema.swift | 10 +- .../FlipcashCoreTests/ChatKeyringTests.swift | 148 +++++++++++ .../ChatPreviewDecryptionTests.swift | 63 +++++ .../FlipcashCoreTests/ChatSealTests.swift | 130 ++++++++++ .../Chat/ChatMessageMappingTests.swift | 84 ++++++- .../ConversationTypingIndicatorTests.swift | 4 +- .../Chat/ChatEncryptionMarkerCell.swift | 66 +++++ .../Chat/ChatItem+Differentiable.swift | 2 + .../Chat/ChatScreenViewController.swift | 6 + .../FlipcashUI/Chat/ChatViewController.swift | 18 +- .../NotificationTranscriptView.swift | 2 +- .../NotificationViewController.swift | 27 ++- NotificationService/NotificationService.swift | 62 ++++- 33 files changed, 1454 insertions(+), 96 deletions(-) create mode 100644 Flipcash/Core/Controllers/EncryptedChatClient.swift create mode 100644 FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatKeyring.swift create mode 100644 FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatSeal.swift create mode 100644 FlipcashCore/Tests/FlipcashCoreTests/ChatKeyringTests.swift create mode 100644 FlipcashCore/Tests/FlipcashCoreTests/ChatPreviewDecryptionTests.swift create mode 100644 FlipcashCore/Tests/FlipcashCoreTests/ChatSealTests.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Chat/ChatEncryptionMarkerCell.swift 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/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/Push/NotificationPayload.swift b/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift index a016fdc03..6fbfd46af 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,22 @@ 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 + } + } + /// 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/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/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..e88123a64 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 = 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. @@ -376,6 +415,19 @@ final class NotificationService: UNNotificationServiceExtension { } } + /// The plaintext of the pushed message when it arrived encrypted and decrypted to text. + private 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 + } + } + /// The messages to write, preferring the fetched copy of any message the push also embedded. /// /// The two sources overlap by exactly one message in the ordinary case — the push embeds what it From a35e150fa7ee460a9abca8c9bf253521b860f516 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 29 Sep 2026 16:16:59 -0400 Subject: [PATCH 3/4] test(push): cover how the NSE picks an encrypted push's body The NSE target has no tests, so the rule that swaps in a decrypted body moves to NotificationPayload.decryptedBody in FlipcashCore. Tests pin it: plaintext only for an encrypted message that decrypted to text, and nil (keep the server's body) when the message is missing, was never encrypted, or failed to decrypt. chatMessageID gets tests for embedded, id-only, and absent message references. --- .../Push/NotificationPayload.swift | 15 +++ .../Push/NotificationPayloadTests.swift | 93 ++++++++++++++++++- NotificationService/NotificationService.swift | 15 +-- 3 files changed, 108 insertions(+), 15 deletions(-) diff --git a/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift b/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift index 6fbfd46af..2882f80cc 100644 --- a/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift +++ b/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift @@ -84,6 +84,21 @@ public enum NotificationPayload { } } + /// 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/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/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift index e88123a64..70b376ca2 100644 --- a/NotificationService/NotificationService.swift +++ b/NotificationService/NotificationService.swift @@ -373,7 +373,7 @@ final class NotificationService: UNNotificationServiceExtension { ) let messages = Array(opened.prefix(fetched.count)) if embedded != nil { embedded = opened.last } - if let body = decryptedBody(of: messageID, in: messages + (embedded.map { [$0] } ?? [])) { + if let body = NotificationPayload.decryptedBody(of: messageID, in: messages + (embedded.map { [$0] } ?? [])) { replaceBody(body) } guard !messages.isEmpty else { @@ -415,19 +415,6 @@ final class NotificationService: UNNotificationServiceExtension { } } - /// The plaintext of the pushed message when it arrived encrypted and decrypted to text. - private 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 - } - } - /// The messages to write, preferring the fetched copy of any message the push also embedded. /// /// The two sources overlap by exactly one message in the ordinary case — the push embeds what it From 8da5edc48e0c95f79667b0017e513b8e39bb21fd Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 29 Sep 2026 16:38:40 -0400 Subject: [PATCH 4/4] chore(deps): bump SharedCoreKit to 0.10.0 for ChatCipher --- .../xcshareddata/swiftpm/Package.resolved | 6 +++--- CrossPlatformVectors/Package.swift | 2 +- FlipcashCore/Package.swift | 2 +- FlipcashUI/Package.swift | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) 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/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/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(