diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 600638780..7b7396401 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -1193,6 +1193,11 @@ final class ConversationController { case .deleted: return nil + + case .encrypted: + // No plaintext to preview -- the row still surfaces (it's still the newest activity), + // just with a blank subtitle, same as an empty text body above. + return nil } } @@ -1556,10 +1561,27 @@ final class ConversationController { /// so a double-tap (or a tap during a slow in-flight retry) can't fire concurrent sends. func retry(clientMessageID: UUID, in conversationID: ConversationID) async { guard let pending = store.pendingMessage(clientMessageID: clientMessageID, in: conversationID), - pending.status == .failed, - case .text(let text) = pending.content else { return } - store.markPending(clientMessageID: clientMessageID, status: .sending, in: conversationID) - _ = await deliver(clientMessageID: clientMessageID, text: text, repliedTo: pending.repliedTo, to: conversationID) + pending.status == .failed else { return } + // Only `.text` is ever sent by this client today -- `deliver(text:)` is the only send path, + // and `send(_:to:)` only ever creates a `.text` pending row -- so this is unreachable in + // practice. It's a `switch` rather than the narrow `guard case .text` it replaces so a future + // pending shape (e.g. an outbox that can hold `.encrypted`) fails loudly via the log below + // instead of silently never retrying, and so `Content.asProto()`'s own crash-free contract + // (no fatalError/force-unwrap for `.encrypted`/`.cash`/`.deleted`) is exercised here too. + switch pending.content { + case .text(let text): + store.markPending(clientMessageID: clientMessageID, status: .sending, in: conversationID) + _ = await deliver(clientMessageID: clientMessageID, text: text, repliedTo: pending.repliedTo, to: conversationID) + case .encrypted, .cash, .deleted: + if case .failure(let error) = Result(catching: { try pending.content.asProto() }) { + logger.error("Cannot retry a send this client has no path to re-send", metadata: [ + "conversationID": "\(conversationID)", + "error": "\(error)", + ]) + } + // Nothing to resend over the existing text-only send RPC; leave it `.failed` rather than + // looping forever or crashing. + } } private func deliver(clientMessageID: UUID, text: String, repliedTo: MessageID?, to conversationID: ConversationID) async -> Bool { diff --git a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift index d364948bf..7727afc03 100644 --- a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift +++ b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift @@ -118,7 +118,7 @@ extension ChatItem { func isEmojiOnlyBody(_ message: ConversationMessage) -> Bool { switch message.content { case .text(let text): EmojiOnlyDetector.isEmojiOnly(text) - case .cash, .deleted: false + case .cash, .deleted, .encrypted: false } } func rendersBare(_ message: ConversationMessage) -> Bool { @@ -131,7 +131,7 @@ extension ChatItem { let layouts = messages.map { message in switch message.content { case .text(let text): Self.rows(for: text, preview: detectedLink(in: text, card: linkCard)) - case .cash, .deleted: [RowLayout(part: nil, text: nil, preview: nil)] + case .cash, .deleted, .encrypted: [RowLayout(part: nil, text: nil, preview: nil)] } } // A card row breaks the bubble run the way bare emoji do, so what faces a neighbour is the @@ -202,6 +202,11 @@ extension ChatItem { ? "You deleted this message" : "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 same non-interactive placeholder bubble a + // tombstone does, with copy matching Android's unsupported-content bubble. + content = .deleted(ChatMessage.unsupportedContentCopy) } // The status line rides on the bubble itself (not a separate row, so a send is a clean @@ -334,6 +339,16 @@ extension ChatItem { kind: .unavailable, authorID: original.senderID ) + case .encrypted: + // No plaintext to preview -- same unavailable treatment as a quote whose original the + // local database never saw. + return ChatQuote( + stableID: nil, + authorName: authorName, + snippet: ChatQuote.unavailableSnippet, + kind: .unavailable, + authorID: original.senderID + ) } } diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 363009aa5..2d045a9f1 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -895,6 +895,8 @@ struct ConversationScreen: View { ) case .deleted: (ChatQuote.deletedSnippet, .unavailable) + case .encrypted: + (ChatQuote.unavailableSnippet, .unavailable) } } @@ -980,7 +982,7 @@ struct ConversationScreen: View { case .cash(let fiat): Analytics.tokenInfoOpened(from: .openedFromChat, mint: fiat.mint) router.push(.currencyInfo(fiat.mint)) - case .text, .deleted: + case .text, .deleted, .encrypted: break } } diff --git a/Flipcash/Core/Spotlight/ChatSpotlightItem.swift b/Flipcash/Core/Spotlight/ChatSpotlightItem.swift index 761c70d6c..79542d376 100644 --- a/Flipcash/Core/Spotlight/ChatSpotlightItem.swift +++ b/Flipcash/Core/Spotlight/ChatSpotlightItem.swift @@ -59,7 +59,7 @@ nonisolated struct ChatSpotlightItem { switch message?.content { case .text(let text): text case .cash(let amount): "Cash · \(amount.nativeAmount.formatted())" - case .deleted, nil: nil + case .deleted, .encrypted, nil: nil } } diff --git a/FlipcashAPI/Package.swift b/FlipcashAPI/Package.swift index ddc50f5d7..10c2da2ab 100644 --- a/FlipcashAPI/Package.swift +++ b/FlipcashAPI/Package.swift @@ -55,8 +55,8 @@ enum ContractPackage: String, CaseIterable { /// The pinned version consumed when this package isn't building against a local checkout. var version: Version { switch self { - case .ocp: return "0.5.0" - case .flipcash2: return "0.11.0" + case .ocp: return "0.6.0" + case .flipcash2: return "0.12.0" } } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift index 64dfd97d1..a4769dee6 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift @@ -224,7 +224,7 @@ final class ChatMessagingService: Sendable { await MainActor.run { completion(.success(MessageMutation(message: message, isConflict: error == .conflict))) } - case .denied, .messageNotFound, .cannotEdit, .unknown, .transportFailure, .cancelled, .rejected: + case .denied, .messageNotFound, .cannotEdit, .encryptionNotAllowed, .unknown, .transportFailure, .cancelled, .rejected: logger.error("Failed to edit message") await MainActor.run { completion(.failure(error)) } } @@ -353,6 +353,8 @@ public enum ErrorGetDelta: Int, Error { public enum ErrorSendMessage: Int, Error { case ok case denied + /// The content is EncryptedContent and the chat is not a DM. + case encryptionNotAllowed case unknown = -1 case transportFailure = -2 case cancelled = -3 @@ -365,6 +367,8 @@ public enum ErrorEditMessage: Int, Error { case messageNotFound case cannotEdit case conflict + /// The content is EncryptedContent and the chat is not a DM. + case encryptionNotAllowed case unknown = -1 case transportFailure = -2 case cancelled = -3 @@ -441,7 +445,10 @@ extension ErrorSendMessage: ServerError, TransportClassifiableError { switch self { case .ok, .transportFailure: .suppressed case .cancelled: .info + // Denied is an expected membership/business outcome; encryptionNotAllowed is a client-side + // contract violation (sending EncryptedContent outside a DM), not a server hiccup. case .denied: .info + case .encryptionNotAllowed: .error case .unknown, .rejected: .error } } @@ -455,6 +462,8 @@ extension ErrorEditMessage: ServerError, TransportClassifiableError { // `conflict` is the concurrency guard doing its job, and the rest are expected // membership/business outcomes — none is a client defect. case .denied, .messageNotFound, .cannotEdit, .conflict: .info + // A client-side contract violation (sending EncryptedContent outside a DM), not a server hiccup. + case .encryptionNotAllowed: .error case .unknown, .rejected: .error } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift index a1eb1ea5c..900f4252c 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift @@ -95,6 +95,12 @@ public struct ChatMessage: Hashable, Sendable, Codable, Identifiable { } } + /// Placeholder copy for content this client has no way to render -- today, an encrypted + /// message it cannot decrypt. Reuses the tombstone's `.deleted` display case (same + /// non-interactive bubble style) with wording that says "unsupported" rather than "deleted", + /// matching Android's copy for the same content. + public static let unsupportedContentCopy = "This message isn't supported on this version" + /// Whether this row draws as the link card on its own, with no bubble behind it. /// /// The card is already a surface with its own rounded shape, so a bubble behind it would draw diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift index c7fcfa0d9..f6477b18a 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift @@ -77,6 +77,10 @@ extension ChatItem { iconURL: branding?.iconURL, 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 = .deleted(ChatMessage.unsupportedContentCopy) case .deleted: continue // filtered out above; unreachable, kept for switch exhaustiveness } @@ -86,8 +90,8 @@ extension ChatItem { // at most three rows and never groups them. let isEmojiOnly: Bool switch message.content { - case .text(let text): isEmojiOnly = EmojiOnlyDetector.isEmojiOnly(text) - case .cash, .deleted: isEmojiOnly = false + case .text(let text): isEmojiOnly = EmojiOnlyDetector.isEmojiOnly(text) + case .cash, .deleted, .encrypted: isEmojiOnly = false } items.append(.message(ChatMessage( diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift index 2b92936f6..169eebd75 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift @@ -49,7 +49,16 @@ public struct Conversation: Identifiable, Hashable, Sendable { /// server has reported one. See ``ConversationViewerState``. public var viewerState: ConversationViewerState? - public init(id: ConversationID, members: [ConversationMember], lastMessage: ConversationMessage?, lastActivity: Date, type: ConversationType = .contactDm, isHidden: Bool = false, title: String? = nil, latestEventSequence: UInt64 = 0, picture: ProfilePicture? = nil, rosterSummary: ConversationRosterSummary = ConversationRosterSummary(memberCount: 0, version: 0), rules: ConversationRules? = nil, viewerState: ConversationViewerState? = nil) { + /// The chat's creator. Only ever set for group chats; `nil` for DMs. + public var creator: UserID? + + /// Whether messages in this chat are end-to-end encrypted. DMs only, always `false` for group + /// chats. A transitional migration flag — see `Flipcash_Chat_V1_Metadata.useE2Ee` — that this + /// client does not yet act on: E2EE send/receive is a cross-platform parity hotspot with its + /// own implementation decision still pending. + public var useE2Ee: Bool + + public init(id: ConversationID, members: [ConversationMember], lastMessage: ConversationMessage?, lastActivity: Date, type: ConversationType = .contactDm, isHidden: Bool = false, title: String? = nil, latestEventSequence: UInt64 = 0, picture: ProfilePicture? = nil, rosterSummary: ConversationRosterSummary = ConversationRosterSummary(memberCount: 0, version: 0), rules: ConversationRules? = nil, viewerState: ConversationViewerState? = nil, creator: UserID? = nil, useE2Ee: Bool = false) { self.id = id self.members = members self.lastMessage = lastMessage @@ -62,6 +71,8 @@ public struct Conversation: Identifiable, Hashable, Sendable { self.rosterSummary = rosterSummary self.rules = rules self.viewerState = viewerState + self.creator = creator + self.useE2Ee = useE2Ee } } @@ -145,6 +156,8 @@ extension Conversation { self.rosterSummary = ConversationRosterSummary(proto.rosterSummary) self.rules = proto.hasRules ? ConversationRules(proto.rules) : nil self.viewerState = proto.hasViewerState ? ConversationViewerState(proto.viewerState) : nil + self.creator = proto.hasCreator ? (try? UUID(data: proto.creator.value)) : nil + self.useE2Ee = proto.useE2Ee } /// The member that isn't the signed-in user, used to title the conversation. diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift index acafa174a..481ea0ec5 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift @@ -48,6 +48,12 @@ 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. + case encrypted(scheme: Int, nonce: Data, ciphertext: Data) } public let id: MessageID @@ -196,6 +202,19 @@ extension ConversationMessage { self.content = .text(textContent.text) 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. + self.content = .encrypted( + scheme: encryptedContent.scheme.rawValue, + nonce: encryptedContent.nonce, + ciphertext: encryptedContent.ciphertext + ) + self.cashAction = nil + repliedTo = nil + // `.media`/`.system` are dropped by design: the message is not stored and not shown. case .media, .system, .none: return nil } @@ -212,3 +231,34 @@ extension ConversationMessage { self.redacted = proto.redacted } } + +/// Content this client has no way to turn back into a proto `Content` to send. Thrown rather than +/// asserted: `.cash` and `.deleted` are never round-tripped this way (cash has its own send path; +/// a tombstone is a mutation result, never resent), but a caller that tries should get a normal +/// error, not a crash. +public enum ConversationMessageContentEncodingError: Error, Sendable { + case unsupported(ConversationMessage.Content) +} + +extension ConversationMessage.Content { + /// Encodes this content back to the wire `Content` it would be sent as. For `.encrypted` this + /// is a byte-for-byte round trip of the stored scheme/nonce/ciphertext -- not encryption, since + /// this client never decrypted them in the first place -- so a retried/re-sent encrypted message + /// reaches the server unchanged rather than being dropped or crashing the sender. + public func asProto() throws -> Flipcash_Messaging_V1_Content { + switch self { + case .text(let text): + return .with { $0.type = .text(.with { $0.text = text }) } + case .encrypted(let scheme, let nonce, let ciphertext): + return .with { + $0.type = .encrypted(.with { + $0.scheme = Flipcash_Messaging_V1_EncryptedContent.Scheme(rawValue: scheme) ?? .unknown + $0.nonce = nonce + $0.ciphertext = ciphertext + }) + } + case .cash, .deleted: + throw ConversationMessageContentEncodingError.unsupported(self) + } + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift index 81c55ed37..70d4438ea 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift @@ -89,8 +89,9 @@ extension MessageCapability { now: Date ) -> Set { switch message.content { - case .deleted: - // Nothing is left to act on, and a tombstone must not be re-deleted. + case .deleted, .encrypted: + // Nothing is left to act on: a tombstone must not be re-deleted, and this client has no + // plaintext to copy, quote, or edit for an encrypted message it cannot decrypt. return [] case .cash: // Reply is a cash message's only capability: there is no text to copy, the server diff --git a/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift b/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift index dbd5f1784..a016fdc03 100644 --- a/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift +++ b/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift @@ -47,18 +47,30 @@ public enum NotificationPayload { } /// The message a CHAT push carries inline, or `nil` when the push isn't a chat message, carries - /// no chat metadata, predates the server embedding the message, or carries content this client - /// can't represent. + /// no chat metadata, predates the server embedding the message, carries content this client + /// can't represent, or only carries the message's id (a long message — see + /// `Flipcash_Push_V1_ChatMetadata.messageRef`). /// /// The embedded message is the only part of a push that needs no network to become store rows. /// 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. public static func chatMessage(_ userInfo: [AnyHashable: Any]) -> ConversationMessage? { guard let payload = decode(userInfo), payload.category == .chat, payload.hasChatMetadata else { return nil } - guard payload.chatMetadata.hasMessage else { return nil } - return ConversationMessage(payload.chatMetadata.message) + switch payload.chatMetadata.messageRef { + case .message(let message): + return ConversationMessage(message) + case .messageID, nil: + return nil + } } /// Whether the recipient had the chat muted when a CHAT push was sent. The push is still diff --git a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift index a194ab760..e912460eb 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift @@ -99,7 +99,9 @@ nonisolated extension Database { version: row[c.rosterVersion] ), rules: conversationRules(from: row), - viewerState: conversationViewerState(from: row) + viewerState: conversationViewerState(from: row), + creator: row[c.creator], + useE2Ee: row[c.useE2Ee] ) } } @@ -426,6 +428,8 @@ nonisolated extension Database { c.rosterVersion <- conversation.rosterSummary.version, c.rules <- conversation.rules.flatMap { try? JSONEncoder().encode($0) }, c.viewerState <- conversation.viewerState.flatMap { try? JSONEncoder().encode($0) }, + c.creator <- conversation.creator, + c.useE2Ee <- conversation.useE2Ee, onConflictOf: c.id ) ) @@ -491,6 +495,9 @@ nonisolated extension Database { var mint: PublicKey? var deletedBy: UUID? var deletedAt: Double? + var encryptedScheme: Int? + var encryptedNonce: Data? + var encryptedCiphertext: Data? switch message.content { case .text(let value): @@ -506,6 +513,11 @@ nonisolated extension Database { kind = 2 deletedBy = deletion.deletedBy deletedAt = deletion.deletedAt.timeIntervalSinceReferenceDate + case .encrypted(let scheme, let nonce, let ciphertext): + kind = 3 + encryptedScheme = scheme + encryptedNonce = nonce + encryptedCiphertext = ciphertext } let cashAction: Int? = switch message.cashAction { @@ -534,7 +546,10 @@ nonisolated extension Database { m.repliedToId <- message.repliedTo?.value, m.lastEditedTs <- message.lastEditedTs?.timeIntervalSinceReferenceDate, m.deletedBy <- deletedBy, - m.deletedAt <- deletedAt + m.deletedAt <- deletedAt, + m.encryptedScheme <- encryptedScheme, + m.encryptedNonce <- encryptedNonce, + m.encryptedCiphertext <- encryptedCiphertext ) ) } @@ -638,6 +653,13 @@ nonisolated extension Database { deletedAt: row[m.deletedAt].map(Date.init(timeIntervalSinceReferenceDate:)) ?? date ) ) + case 3: + guard let scheme = row[m.encryptedScheme], + let nonce = row[m.encryptedNonce], + let ciphertext = row[m.encryptedCiphertext] else { + return nil + } + content = .encrypted(scheme: scheme, nonce: nonce, ciphertext: ciphertext) default: return nil } diff --git a/FlipcashCore/Sources/FlipcashStore/Database.swift b/FlipcashCore/Sources/FlipcashStore/Database.swift index b7e1b9815..73769d417 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 = 39 + public static let schemaVersion = 41 /// 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 88eae5045..b978e61d2 100644 --- a/FlipcashCore/Sources/FlipcashStore/Schema.swift +++ b/FlipcashCore/Sources/FlipcashStore/Schema.swift @@ -273,6 +273,12 @@ nonisolated public struct ConversationTable: Sendable { // FlipcashAPI, so the proto can't be stored; ConversationRules is Codable // for exactly this. public let rules = Expression ("rules") + // The group's creator, group chats only; nil for a DM or a group whose metadata predates the + // field. Nil-able rather than defaulted, since "no creator" and "not yet known" are both real. + public let creator = Expression ("creator") + // Whether the chat is end-to-end encrypted. Defaults false: every conversation before this + // field existed, and every DM/group the server hasn't opted in, reads as not-E2EE. + public let useE2Ee = Expression ("useE2Ee") // The signed-in viewer's per-chat state (currently mute) as JSON, same // reason as `rules`. The whole struct is stored, version included, so a // cold-start reload can't beat a fresher stream update; the mute is stored @@ -349,6 +355,11 @@ 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. + public let encryptedScheme = Expression ("encryptedScheme") + public let encryptedNonce = Expression ("encryptedNonce") + public let encryptedCiphertext = Expression ("encryptedCiphertext") // 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. @@ -569,6 +580,8 @@ nonisolated extension Database { t.column(conversationTable.rosterVersion, defaultValue: 0) t.column(conversationTable.rules) t.column(conversationTable.viewerState) + t.column(conversationTable.creator) + t.column(conversationTable.useE2Ee, defaultValue: false) }) } @@ -622,6 +635,9 @@ nonisolated extension Database { t.column(conversationMessageTable.lastEditedTs) t.column(conversationMessageTable.deletedBy) t.column(conversationMessageTable.deletedAt) + t.column(conversationMessageTable.encryptedScheme) + t.column(conversationMessageTable.encryptedNonce) + t.column(conversationMessageTable.encryptedCiphertext) t.primaryKey(conversationMessageTable.conversationId, conversationMessageTable.id) }) } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift index cf3a290a9..3542720fa 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift @@ -150,6 +150,33 @@ struct ConversationModelMappingTests { #expect(Conversation(proto).type == .contactDm) } + @Test("Metadata maps the group's creator and use_e2ee") + func dmMetadataMapsCreatorAndUseE2Ee() { + let creatorUUID = UUID() + let proto = Flipcash_Chat_V1_Metadata.with { + $0.chatID = .with { $0.value = Data(repeating: 0xAB, count: 32) } + $0.type = .group + $0.creator = .with { $0.value = creatorUUID.data } + $0.useE2Ee = true + } + + let conversation = Conversation(proto) + #expect(conversation.creator == creatorUUID) + #expect(conversation.useE2Ee) + } + + @Test("Metadata without a creator or use_e2ee maps to nil/false") + func dmMetadataWithoutCreatorOrUseE2Ee() { + let proto = Flipcash_Chat_V1_Metadata.with { + $0.chatID = .with { $0.value = Data(repeating: 0xAB, count: 32) } + $0.type = .contactDm + } + + let conversation = Conversation(proto) + #expect(conversation.creator == nil) + #expect(conversation.useE2Ee == false) + } + @Test("Metadata maps the group chat type and title") func dmMetadataMapsGroupTypeAndTitle() { let proto = Flipcash_Chat_V1_Metadata.with { @@ -510,6 +537,31 @@ struct ConversationMessageMetadataTests { #expect(message.lastEditedTs == nil) } + @Test("Encrypted content maps to .encrypted, keeping scheme, nonce, and ciphertext") + func encryptedMessageParses() throws { + let nonce = Data(repeating: 0xCD, count: 24) + let ciphertext = Data([0x0A, 0x0B, 0x0C]) + let proto = Flipcash_Messaging_V1_Message.with { + $0.messageID = .with { $0.value = 14 } + $0.content = [.with { + $0.encrypted = .with { + $0.scheme = .x25519Xchacha20Poly1305 + $0.nonce = nonce + $0.ciphertext = ciphertext + } + }] + } + + let message = try #require(ConversationMessage(proto)) + guard case .encrypted(let scheme, let gotNonce, let gotCiphertext) = message.content else { + Issue.record("Expected encrypted content") + return + } + #expect(scheme == Flipcash_Messaging_V1_EncryptedContent.Scheme.x25519Xchacha20Poly1305.rawValue) + #expect(gotNonce == nonce) + #expect(gotCiphertext == ciphertext) + } + @Test("replacingContent preserves identity and ordering") func replacingContentPreservesIdentity() { let original = ConversationMessage( @@ -590,3 +642,68 @@ struct ConversationMessageReplyMappingTests { #expect(edited.repliedTo == MessageID(value: 9)) } } + +@Suite("ConversationMessage.Content -> proto encoding") +struct ConversationMessageContentEncodingTests { + + @Test("Text content encodes to a proto text body") + func textEncodes() throws { + let proto = try ConversationMessage.Content.text("hi").asProto() + guard case .text(let body) = proto.type else { + Issue.record("Expected text content") + return + } + #expect(body.text == "hi") + } + + @Test("Encrypted content round-trips scheme, nonce, and ciphertext byte for byte -- not decrypted, just re-encoded") + func encryptedContentRoundTrips() throws { + let nonce = Data(repeating: 0xEF, count: 24) + let ciphertext = Data([0x01, 0x02, 0x03]) + let content = ConversationMessage.Content.encrypted( + scheme: Flipcash_Messaging_V1_EncryptedContent.Scheme.x25519Xchacha20Poly1305.rawValue, + nonce: nonce, + ciphertext: ciphertext + ) + + let proto = try content.asProto() + guard case .encrypted(let encrypted) = proto.type else { + Issue.record("Expected encrypted content") + return + } + #expect(encrypted.scheme == .x25519Xchacha20Poly1305) + #expect(encrypted.nonce == nonce) + #expect(encrypted.ciphertext == ciphertext) + + // And decoding that proto back gives the identical domain value -- a full round trip. + let roundTripped = Flipcash_Messaging_V1_Message.with { + $0.messageID = .with { $0.value = 1 } + $0.content = [proto] + } + #expect(try #require(ConversationMessage(roundTripped)).content == content) + } + + @Test("An unrecognized encrypted scheme raw value encodes as .UNRECOGNIZED rather than crashing") + func unrecognizedSchemeFallsBackToUnknown() throws { + let content = ConversationMessage.Content.encrypted(scheme: 99, nonce: Data(), ciphertext: Data()) + let proto = try content.asProto() + guard case .encrypted(let encrypted) = proto.type else { + Issue.record("Expected encrypted content") + return + } + #expect(encrypted.scheme == .UNRECOGNIZED(99)) + } + + @Test("Cash and deleted content throw rather than crash -- there is no wire shape a client resends for either") + func cashAndDeletedThrow() { + let cash = ConversationMessage.Content.cash(ExchangedFiat( + onChainAmount: TokenAmount(quarks: 1, mint: .usdf), + nativeAmount: FiatAmount(value: 1, currency: .usd), + currencyRate: Rate(fx: 1, currency: .usd) + )) + #expect(throws: ConversationMessageContentEncodingError.self) { try cash.asProto() } + + let deleted = ConversationMessage.Content.deleted(.init(deletedBy: nil, deletedAt: .now)) + #expect(throws: ConversationMessageContentEncodingError.self) { try deleted.asProto() } + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationStoreMutationTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationStoreMutationTests.swift index 484cbac5b..33d7fa008 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationStoreMutationTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationStoreMutationTests.swift @@ -31,6 +31,7 @@ struct ConversationStoreMutationTests { case .text(let value): value case .deleted: "" case .cash: "" + case .encrypted: "" } } } diff --git a/FlipcashTests/Database/Database+ConversationsTests.swift b/FlipcashTests/Database/Database+ConversationsTests.swift index 787808327..ce86cf129 100644 --- a/FlipcashTests/Database/Database+ConversationsTests.swift +++ b/FlipcashTests/Database/Database+ConversationsTests.swift @@ -103,7 +103,7 @@ struct DatabaseConversationsTests { switch loadedMessage.content { case .cash(let loadedExchanged): #expect(loadedExchanged.nativeAmount.value == amount) - case .text, .deleted: + case .text, .deleted, .encrypted: Issue.record("Expected cash message content") } } @@ -413,6 +413,33 @@ struct DatabaseConversationsTests { #expect(loaded.last?.isDeleted == true) } + @Test("An encrypted message round-trips scheme, nonce, and ciphertext byte for byte") + func encryptedMessageRoundTrip() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let id = ConversationID.test(1) + let message = ConversationMessage( + id: MessageID(value: 1), + senderID: otherID, + content: .encrypted(scheme: 1, nonce: Data(repeating: 0xAB, count: 24), ciphertext: Data([0x01, 0x02, 0x03, 0x04])), + date: Date(timeIntervalSince1970: 10), + unreadSeq: 1, + eventSequence: 3 + ) + + try database.upsertConversationMessages([message], conversationID: id) + + let loaded = try database.getConversationMessages(conversationID: id) + #expect(loaded == [message]) + guard case .encrypted(let scheme, let nonce, let ciphertext) = loaded.first?.content else { + Issue.record("expected encrypted content") + return + } + #expect(scheme == 1) + #expect(nonce == Data(repeating: 0xAB, count: 24)) + #expect(ciphertext == Data([0x01, 0x02, 0x03, 0x04])) + } + @Test("the catch-up cursor round-trips and survives a feed replace") func catchupCursorRoundTrip() throws { let (database, url) = try Database.makeTemp() @@ -485,6 +512,31 @@ struct DatabaseConversationsTests { #expect(loaded.lastMessage == preview) } + @Test("A group's creator and use_e2ee round-trip; a DM's nil creator round-trips as nil") + func creatorAndUseE2EeRoundTrip() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + var group = conversation(byte: 1) + group.creator = otherID + group.useE2Ee = true + try database.upsertConversation(group) + + let loadedGroup = try #require(try database.getConversations().first) + #expect(loadedGroup.creator == otherID) + #expect(loadedGroup.useE2Ee == true) + } + + @Test("A conversation with no stored creator/use_e2ee round-trips as nil/false") + func creatorAndUseE2EeDefaultRoundTrip() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + try database.upsertConversation(conversation(byte: 1)) + + let loaded = try #require(try database.getConversations().first) + #expect(loaded.creator == nil) + #expect(loaded.useE2Ee == false) + } + @Test("Mute state round-trips as its expiry and version, not as a boolean") func viewerStateRoundTrip() throws { let (database, url) = try Database.makeTemp()