From 51ad366e8b79b07b6fecc89aea3212434bec608b Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:55:10 +0000 Subject: [PATCH] core, ui: message signing (#7115) --- apps/ios/Shared/Model/AppAPITypes.swift | 6 +- apps/ios/Shared/Model/SimpleXAPI.swift | 4 +- .../Views/Chat/ChatItem/CIFileView.swift | 17 +- .../Views/Chat/ChatItem/CIMetaView.swift | 28 +- .../Views/Chat/ChatItem/FramedItemView.swift | 4 +- .../Views/Chat/ChatItem/MsgContentView.swift | 3 +- .../Shared/Views/Chat/ChatItemInfoView.swift | 28 + .../Chat/ComposeMessage/ComposeView.swift | 33 +- .../Chat/ComposeMessage/SendMessageView.swift | 26 + .../Chat/Group/GroupPreferencesView.swift | 1 + .../Views/ChatList/ChatPreviewView.swift | 2 +- .../Views/Onboarding/CreateProfile.swift | 2 + .../Views/UserSettings/PrivacySettings.swift | 8 + .../Views/UserSettings/SettingsView.swift | 5 + apps/ios/SimpleXChat/ChatTypes.swift | 65 ++- apps/ios/SimpleXChat/ChatUtils.swift | 1 + .../chat/simplex/common/model/ChatModel.kt | 24 +- .../chat/simplex/common/model/SimpleXAPI.kt | 34 +- .../chat/simplex/common/views/WelcomeView.kt | 2 + .../common/views/chat/ChatItemInfoView.kt | 16 +- .../simplex/common/views/chat/ComposeView.kt | 20 +- .../simplex/common/views/chat/SendMsgView.kt | 27 + .../views/chat/group/GroupPreferences.kt | 8 + .../common/views/chat/item/CIFileView.kt | 2 +- .../common/views/chat/item/CIMetaView.kt | 28 +- .../common/views/chat/item/ChatItemView.kt | 2 +- .../views/usersettings/PrivacySettings.kt | 6 + .../commonMain/resources/MR/base/strings.xml | 13 + .../resources/MR/images/ic_verified.svg | 1 + .../MR/images/ic_verified_filled.svg | 1 + .../MR/images/ic_verified_missing.svg | 1 + bots/api/COMMANDS.md | 7 +- bots/api/TYPES.md | 23 +- bots/src/API/Docs/Commands.hs | 2 +- bots/src/API/Docs/Types.hs | 2 + bots/src/API/TypeInfo.hs | 3 +- .../types/typescript/src/commands.ts | 3 +- .../types/typescript/src/types.ts | 28 +- .../typescript/src/client.ts | 2 +- packages/simplex-chat-nodejs/src/api.ts | 3 +- .../src/simplex_chat/api.py | 1 + .../src/simplex_chat/types/_commands.py | 3 +- .../src/simplex_chat/types/_types.py | 20 +- plans/2026-06-04-channel-message-signing.md | 389 ++++++++----- plans/2026-07-07-signed-files-and-history.md | 64 +++ ...-07-09-channel-sign-messages-preference.md | 62 +++ simplex-chat.cabal | 2 + src/Simplex/Chat/Bot.hs | 4 +- src/Simplex/Chat/Controller.hs | 2 +- src/Simplex/Chat/Library/Commands.hs | 110 ++-- src/Simplex/Chat/Library/Internal.hs | 81 ++- src/Simplex/Chat/Library/Subscriber.hs | 104 +++- src/Simplex/Chat/Messages.hs | 16 +- src/Simplex/Chat/Protocol.hs | 8 + src/Simplex/Chat/Store/Delivery.hs | 3 +- src/Simplex/Chat/Store/Files.hs | 15 +- src/Simplex/Chat/Store/Messages.hs | 26 +- src/Simplex/Chat/Store/Postgres/Migrations.hs | 4 +- .../Migrations/M20260707_file_digest.hs | 19 + .../Store/Postgres/Migrations/chat_schema.sql | 5 +- src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../Migrations/M20260707_file_digest.hs | 18 + .../SQLite/Migrations/chat_query_plans.txt | 27 +- .../Store/SQLite/Migrations/chat_schema.sql | 3 +- src/Simplex/Chat/Types/Preferences.hs | 65 ++- src/Simplex/Chat/Types/Shared.hs | 30 +- src/Simplex/Chat/View.hs | 28 +- tests/ChatTests/Groups.hs | 523 +++++++++++++++++- tests/ChatTests/Utils.hs | 1 + tests/ProtocolTests.hs | 2 +- 70 files changed, 1742 insertions(+), 388 deletions(-) create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_verified.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_verified_filled.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_verified_missing.svg create mode 100644 plans/2026-07-07-signed-files-and-history.md create mode 100644 plans/2026-07-09-channel-sign-messages-preference.md create mode 100644 src/Simplex/Chat/Store/Postgres/Migrations/M20260707_file_digest.hs create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20260707_file_digest.hs diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index 1a66156a49..2b2d747284 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -45,7 +45,7 @@ enum ChatCommand: ChatCmdProtocol { case apiGetChat(chatId: ChatId, scope: GroupChatScope?, contentTag: MsgContentTag?, pagination: ChatPagination, search: String) case apiGetChatContentTypes(chatId: ChatId, scope: GroupChatScope?) case apiGetChatItemInfo(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64) - case apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, sendAsGroup: Bool, live: Bool, ttl: Int?, composedMessages: [ComposedMessage]) + case apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, sendAsGroup: Bool, live: Bool, ttl: Int?, sign: Bool, composedMessages: [ComposedMessage]) case apiCreateChatTag(tag: ChatTagData) case apiSetChatTags(type: ChatType, id: Int64, tagIds: [Int64]) case apiDeleteChatTag(tagId: Int64) @@ -240,11 +240,11 @@ enum ChatCommand: ChatCmdProtocol { return "/_get chat \(chatId)\(scopeRef(scope))\(tag) \(pagination.cmdString)" + (search == "" ? "" : " search=\(search)") case let .apiGetChatContentTypes(chatId, scope): return "/_get content types \(chatId)\(scopeRef(scope))" case let .apiGetChatItemInfo(type, id, scope, itemId): return "/_get item info \(ref(type, id, scope: scope)) \(itemId)" - case let .apiSendMessages(type, id, scope, sendAsGroup, live, ttl, composedMessages): + case let .apiSendMessages(type, id, scope, sendAsGroup, live, ttl, sign, composedMessages): let msgs = encodeJSON(composedMessages) let ttlStr = ttl != nil ? "\(ttl!)" : "default" let asGroup = sendAsGroup ? "(as_group=on)" : "" - return "/_send \(ref(type, id, scope: scope))\(asGroup) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)" + return "/_send \(ref(type, id, scope: scope))\(asGroup) live=\(onOff(live)) ttl=\(ttlStr) sign=\(onOff(sign)) json \(msgs)" case let .apiCreateChatTag(tag): return "/_create tag \(encodeJSON(tag))" case let .apiSetChatTags(type, id, tagIds): return "/_tags \(ref(type, id, scope: nil)) \(tagIds.map({ "\($0)" }).joined(separator: ","))" case let .apiDeleteChatTag(tagId): return "/_delete tag \(tagId)" diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 5e957d68dc..642efc894f 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -542,8 +542,8 @@ func apiReorderChatTags(tagIds: [Int64]) async throws { try await sendCommandOkResp(.apiReorderChatTags(tagIds: tagIds)) } -func apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, sendAsGroup: Bool = false, live: Bool = false, ttl: Int? = nil, composedMessages: [ComposedMessage]) async -> [ChatItem]? { - let cmd: ChatCommand = .apiSendMessages(type: type, id: id, scope: scope, sendAsGroup: sendAsGroup, live: live, ttl: ttl, composedMessages: composedMessages) +func apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, sendAsGroup: Bool = false, live: Bool = false, ttl: Int? = nil, sign: Bool = false, composedMessages: [ComposedMessage]) async -> [ChatItem]? { + let cmd: ChatCommand = .apiSendMessages(type: type, id: id, scope: scope, sendAsGroup: sendAsGroup, live: live, ttl: ttl, sign: sign, composedMessages: composedMessages) return await processSendMessageCmd(toChatType: type, cmd: cmd) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index 75a5baafee..fc46669cee 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -14,8 +14,13 @@ import SimpleXChat struct CIFileView: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme + @Environment(\.showTimestamp) var showTimestamp: Bool + @AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false + @AppStorage(DEFAULT_PRIVACY_SHOW_SIGNATURE) private var showSignature = true + @AppStorage(DEFAULT_PRIVACY_SHOW_FILE_ENCRYPTION) private var showFileEncryption = true + @ObservedObject var chat: Chat let file: CIFile? - let edited: Bool + let meta: CIMeta let senderProfile: LocalProfile? var smallViewSize: CGFloat? @@ -24,9 +29,9 @@ struct CIFileView: View { fileIndicator() .simultaneousGesture(TapGesture().onEnded(fileAction)) } else { - let metaReserve = edited - ? " " - : " " + // reserve exact space for the overlaid meta (timestamp + all icons), rendered transparently - matches MsgContentView + let encrypted: Bool? = if let fileSource = file?.fileSource { fileSource.cryptoArgs != nil } else { nil } + let metaReserve = Text(verbatim: " ") + ciMetaText(meta, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: encrypted, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp, signedFileVerified: file?.loaded, showSignature: showSignature, showFileEncryption: showFileEncryption) HStack(alignment: .bottom, spacing: 6) { fileIndicator() .padding(.top, 5) @@ -38,14 +43,14 @@ struct CIFileView: View { .lineLimit(1) .multilineTextAlignment(.leading) .foregroundColor(theme.colors.onBackground) - Text(prettyFileSize + metaReserve) + (Text(prettyFileSize) + metaReserve) .font(.caption) .lineLimit(1) .multilineTextAlignment(.leading) .foregroundColor(theme.colors.secondary) } } else { - Text(metaReserve) + metaReserve.font(.caption) } } .padding(.top, 4) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift index e3bc654ac9..2c9f0fefef 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift @@ -23,6 +23,8 @@ struct CIMetaView: View { var invertedMaterial = false @AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false + @AppStorage(DEFAULT_PRIVACY_SHOW_SIGNATURE) private var showSignature = true + @AppStorage(DEFAULT_PRIVACY_SHOW_FILE_ENCRYPTION) private var showFileEncryption = true var body: some View { if chatItem.isDeletedContent { @@ -41,7 +43,10 @@ struct CIMetaView: View { showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy, - showTimesamp: showTimestamp + showTimesamp: showTimestamp, + signedFileVerified: chatItem.file?.loaded, + showSignature: showSignature, + showFileEncryption: showFileEncryption ).invertedForegroundStyle(enabled: invertedMaterial) if invertedMaterial { ciMetaText( @@ -53,7 +58,10 @@ struct CIMetaView: View { showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy, - showTimesamp: showTimestamp + showTimesamp: showTimestamp, + signedFileVerified: chatItem.file?.loaded, + showSignature: showSignature, + showFileEncryption: showFileEncryption ) } } @@ -102,7 +110,10 @@ func ciMetaText( showStatus: Bool = true, showEdited: Bool = true, showViaProxy: Bool, - showTimesamp: Bool + showTimesamp: Bool, + signedFileVerified: Bool? = nil, + showSignature: Bool = true, + showFileEncryption: Bool = true ) -> Text { var r = Text("") var space: Text? = nil @@ -142,11 +153,20 @@ func ciMetaText( } space = textSpace } - if let enc = encrypted { + if let enc = encrypted, showFileEncryption { appendSpace() r = r + statusIconText(enc ? "lock" : "lock.open", resolved) space = textSpace } + if showSignature, meta.msgVerified.verified && signedFileVerified != false { + appendSpace() + r = r + colored(Text(Image(systemName: "checkmark.seal")), resolved) + space = textSpace + } else if meta.msgVerified == .sigMissing { + appendSpace() + r = r + colored(Text(Image(systemName: "xmark.seal")), colorMode.resolve(.red)) + space = textSpace + } if showTimesamp { appendSpace() r = r + colored(meta.timestampText, resolved) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index 372c7df8a3..3682c039bb 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -99,7 +99,7 @@ struct FramedItemView: View { .background { chatItemFrameColorMaybeImageOrVideo(chatItem, theme).modifier(ChatTailPadding()) } .onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 } - if let (title, text) = chatItem.meta.itemStatus.statusInfo { + if let (title, text) = chatItem.meta.itemStatus.statusInfo ?? chatItem.meta.msgVerified.sigMissingInfo { v.simultaneousGesture(TapGesture().onEnded { AlertManager.shared.showAlert( Alert( @@ -349,7 +349,7 @@ struct FramedItemView: View { } @ViewBuilder private func ciFileView(_ ci: ChatItem, _ text: String) -> some View { - CIFileView(file: chatItem.file, edited: chatItem.meta.itemEdited, senderProfile: ciSenderProfile(chatItem, chat.chatInfo)) + CIFileView(chat: chat, file: chatItem.file, meta: chatItem.meta, senderProfile: ciSenderProfile(chatItem, chat.chatInfo)) .overlay(DetermineWidth()) if text != "" || ci.meta.isLive { ciMsgContentView (chatItem) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index 5cb93ad279..e8a5ee96e2 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -48,6 +48,7 @@ struct MsgContentView: View { @State private var phase: CGFloat = 0 @AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false + @AppStorage(DEFAULT_PRIVACY_SHOW_SIGNATURE) private var showSignature = true var body: some View { let v = msgContentView() @@ -131,7 +132,7 @@ struct MsgContentView: View { @inline(__always) private func reserveSpaceForMeta(_ mt: CIMeta) -> Text { - (rightToLeft ? textNewLine : Text(verbatim: " ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) + (rightToLeft ? textNewLine : Text(verbatim: " ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp, showSignature: showSignature) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift index bd0e549d38..33a5bf641b 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift @@ -162,7 +162,28 @@ struct ChatItemInfoView: View { if let deleteAt = meta.itemTimed?.deleteAt { infoRow("Disappears at", localTimestamp(deleteAt)) } + if meta.msgVerified.verified { + let signedText: LocalizedStringKey = ci.chatDir.sent ? "Signed" : "Signed & verified" + HStack { + Label { + Text(signedText) + } icon: { + Text(Image(systemName: "checkmark.seal")).foregroundColor(.secondary) + } + Spacer() + } + } else if meta.msgVerified == .sigMissing { + HStack { + Label { + Text("Signature missing") + } icon: { + Text(Image(systemName: "xmark.seal")).foregroundColor(.red) + } + Spacer() + } + } if developerTools { + Divider().padding(.vertical) infoRow("Database ID", "\(meta.itemId)") infoRow("Record updated at", localTimestamp(meta.updatedAt)) let msv = infoRow("Message status", ci.meta.itemStatus.id) @@ -507,6 +528,13 @@ struct ChatItemInfoView: View { if let deleteAt = meta.itemTimed?.deleteAt { shareText += [String.localizedStringWithFormat(NSLocalizedString("Disappears at: %@", comment: "copied message info"), localTimestamp(deleteAt))] } + if meta.msgVerified.verified { + shareText += [ci.chatDir.sent + ? NSLocalizedString("Signed", comment: "copied message info") + : NSLocalizedString("Signed & verified", comment: "copied message info")] + } else if meta.msgVerified == .sigMissing { + shareText += [NSLocalizedString("Signature missing", comment: "copied message info")] + } if developerTools { shareText += [ String.localizedStringWithFormat(NSLocalizedString("Database ID: %d", comment: "copied message info"), meta.itemId), diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index aaf8a30120..734ccc083c 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -1044,6 +1044,10 @@ struct ComposeView: View { sendMessage(ttl: ttl) resetLinkPreview() }, + sendSignedMessage: { + sendMessage(ttl: nil, sign: true) + resetLinkPreview() + }, sendLiveMessage: chat.chatInfo.chatType != .local ? sendLiveMessage : nil, updateLiveMessage: updateLiveMessage, cancelLiveMessage: { @@ -1063,6 +1067,7 @@ struct ComposeView: View { finishVoiceMessageRecording: finishVoiceMessageRecording, allowVoiceMessagesToContact: allowVoiceMessagesToContact, timedMessageAllowed: chat.chatInfo.featureEnabled(.timedMessages), + showSign: chat.chatInfo.groupInfo?.useRelays == true, onMediaAdded: { media in if !media.isEmpty { chosenMedia = media }}, keyboardVisible: $keyboardVisible, keyboardHiddenDate: $keyboardHiddenDate, @@ -1461,16 +1466,16 @@ struct ComposeView: View { } // Spec: spec/client/compose.md#sendMessage - private func sendMessage(ttl: Int?) { + private func sendMessage(ttl: Int?, sign: Bool = false) { logger.debug("ChatView sendMessage") Task { logger.debug("ChatView sendMessage: in Task") - _ = await sendMessageAsync(nil, live: false, ttl: ttl) + _ = await sendMessageAsync(nil, live: false, ttl: ttl, sign: sign) } } // Spec: spec/client/compose.md#sendMessageAsync - private func sendMessageAsync(_ text: String?, live: Bool, ttl: Int?) async -> ChatItem? { + private func sendMessageAsync(_ text: String?, live: Bool, ttl: Int?, sign: Bool = false) async -> ChatItem? { var sent: ChatItem? let msgText = text ?? composeState.message let liveMessage = composeState.liveMessage @@ -1483,7 +1488,7 @@ struct ComposeView: View { // Composed text is send as a reply to the last forwarded item sent = await forwardItems(chatItems, fromChatInfo, ttl).last if !composeState.message.isEmpty { - _ = await send(checkLinkPreview(), quoted: sent?.id, live: false, ttl: ttl, mentions: mentions) + _ = await send(checkLinkPreview(), quoted: sent?.id, live: false, ttl: ttl, mentions: mentions, sign: sign) } } else if case let .editingItem(ci) = composeState.contextItem { sent = await updateMessage(ci, live: live) @@ -1499,13 +1504,13 @@ struct ComposeView: View { switch (composeState.preview) { case .noPreview: - sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl, mentions: mentions) + sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl, mentions: mentions, sign: sign) case .linkPreview: - sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl, mentions: mentions) + sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl, mentions: mentions, sign: sign) case let .chatLinkPreview(chatLink, ownerSig): let linkStr = chatLink.connLinkStr let text = msgText.isEmpty ? linkStr : msgText + "\n" + linkStr - sent = await send(.chat(text: text, chatLink: chatLink, ownerSig: ownerSig), quoted: quoted, live: live, ttl: ttl, mentions: mentions) + sent = await send(.chat(text: text, chatLink: chatLink, ownerSig: ownerSig), quoted: quoted, live: live, ttl: ttl, mentions: mentions, sign: sign) case let .mediaPreviews(media): // TODO: CHECK THIS let last = media.count - 1 @@ -1527,15 +1532,15 @@ struct ComposeView: View { if msgs.isEmpty { msgs = [ComposedMessage(quotedItemId: quoted, msgContent: .text(msgText))] } - sent = await send(msgs, live: live, ttl: ttl).last + sent = await send(msgs, live: live, ttl: ttl, sign: sign).last case let .voicePreview(recordingFileName, duration): stopPlayback.toggle() let file = voiceCryptoFile(recordingFileName) - sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: file, ttl: ttl, mentions: mentions) + sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: file, ttl: ttl, mentions: mentions, sign: sign) case let .filePreview(_, file): if let savedFile = saveFileFromURL(file) { - sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live, ttl: ttl, mentions: mentions) + sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live, ttl: ttl, mentions: mentions, sign: sign) } } } @@ -1669,15 +1674,16 @@ struct ComposeView: View { ) } - func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?, mentions: [String: Int64]) async -> ChatItem? { + func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?, mentions: [String: Int64], sign: Bool = false) async -> ChatItem? { await send( [ComposedMessage(fileSource: file, quotedItemId: quoted, msgContent: mc, mentions: mentions)], live: live, - ttl: ttl + ttl: ttl, + sign: sign ).first } - func send(_ msgs: [ComposedMessage], live: Bool, ttl: Int?) async -> [ChatItem] { + func send(_ msgs: [ComposedMessage], live: Bool, ttl: Int?, sign: Bool = false) async -> [ChatItem] { if let chatItems = chat.chatInfo.chatType == .local ? await apiCreateChatItems(noteFolderId: chat.chatInfo.apiId, composedMessages: msgs) : await apiSendMessages( @@ -1687,6 +1693,7 @@ struct ComposeView: View { sendAsGroup: chat.chatInfo.sendAsGroup, live: live, ttl: ttl, + sign: sign, composedMessages: msgs ) { await MainActor.run { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index 713f462c27..8d6d8dc1e4 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -19,6 +19,7 @@ struct SendMessageView: View { @EnvironmentObject var theme: AppTheme @Environment(\.isEnabled) var isEnabled var sendMessage: (Int?) -> Void + var sendSignedMessage: () -> Void = {} var sendLiveMessage: (() async -> Void)? = nil var updateLiveMessage: (() async -> Void)? = nil var cancelLiveMessage: (() -> Void)? = nil @@ -32,6 +33,7 @@ struct SendMessageView: View { var finishVoiceMessageRecording: (() -> Void)? = nil var allowVoiceMessagesToContact: (() -> Void)? = nil var timedMessageAllowed: Bool = false + var showSign: Bool = false var onMediaAdded: ([UploadContent]) -> Void @State private var holdingVMR = false @Namespace var namespace @@ -46,6 +48,7 @@ struct SendMessageView: View { @State private var showCustomTimePicker = false @State private var selectedDisappearingMessageTime: Int? = customDisappearingMessageTimeDefault.get() @UserDefault(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false + @UserDefault(DEFAULT_SIGN_MESSAGE_ALERT_SHOWN) private var signMessageAlertShown = false var body: some View { let composeShape = RoundedRectangle(cornerSize: CGSize(width: 20, height: 20)) @@ -243,6 +246,13 @@ struct SendMessageView: View { Label("Disappearing message", systemImage: "stopwatch") } } + if showSign { + Button { + startSignedMessage() + } label: { + Label("Sign message", systemImage: "checkmark.seal") + } + } } } @@ -352,6 +362,22 @@ struct SendMessageView: View { .padding([.bottom, .horizontal], 4) } + private func startSignedMessage() { + if signMessageAlertShown { + sendSignedMessage() + } else { + AlertManager.shared.showAlert(Alert( + title: Text("Sign message"), + message: Text("Signing proves you authored this message and can't be denied later."), + primaryButton: .default(Text("Send")) { + signMessageAlertShown = true + sendSignedMessage() + }, + secondaryButton: .cancel() + )) + } + } + private func startLiveMessage(send: @escaping () async -> Void, update: @escaping () async -> Void) { if liveMessageAlertShown { start() diff --git a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift index cc2feef706..292d6e1e42 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift @@ -50,6 +50,7 @@ struct GroupPreferencesView: View { featureSection(.history, $preferences.history.enable) featureSection(.support, $preferences.support.enable, disabled: true) } else { + featureSection(.signMessages, $preferences.signMessages.enable) featureSection(.timedMessages, $preferences.timedMessages.enable) featureSection(.fullDelete, $preferences.fullDelete.enable) featureSection(.reactions, $preferences.reactions.enable) diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index a6e7fc5870..59b92a265b 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -438,7 +438,7 @@ struct ChatPreviewView: View { } case .file: smallContentPreviewFile(size: dynamicMediaSize) { - CIFileView(file: ci.file, edited: ci.meta.itemEdited, senderProfile: ciSenderProfile(ci, chat.chatInfo), smallViewSize: dynamicMediaSize) + CIFileView(chat: chat, file: ci.file, meta: ci.meta, senderProfile: ciSenderProfile(ci, chat.chatInfo), smallViewSize: dynamicMediaSize) } case let .chat(_, chatLink, ownerSig): smallContentPreview(size: dynamicMediaSize, borderColor: chatLink.image != nil ? .secondary : .clear) { diff --git a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift index 3c33546436..bd9530c4ee 100644 --- a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift +++ b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift @@ -372,6 +372,8 @@ struct CreateFirstProfile: View { do { AppChatState.shared.set(.active) m.currentUser = try apiCreateActiveUser(profile) + // new users don't need the local file encryption indicator (all files are encrypted); existing users keep it on + UserDefaults.standard.set(false, forKey: DEFAULT_PRIVACY_SHOW_FILE_ENCRYPTION) try startChat(onboarding: true) onboardingStageDefault.set(.step3_ChooseServerOperators) nextStepNavLinkActive = true diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift index f1bacee425..cd572ffc78 100644 --- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift +++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift @@ -18,6 +18,8 @@ struct PrivacySettings: View { @AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true @AppStorage(DEFAULT_PRIVACY_VERIFY_SIMPLEX_NAMES) private var verifySimplexNames = false @AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true + @AppStorage(DEFAULT_PRIVACY_SHOW_SIGNATURE) private var showSignature = true + @AppStorage(DEFAULT_PRIVACY_SHOW_FILE_ENCRYPTION) private var showFileEncryption = true @AppStorage(GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES, store: groupDefaults) private var encryptLocalFiles = true @AppStorage(GROUP_DEFAULT_PRIVACY_ASK_TO_APPROVE_RELAYS, store: groupDefaults) private var askToApproveRelays = true @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @@ -197,6 +199,9 @@ struct PrivacySettings: View { settingsRow("number", color: theme.colors.secondary) { Toggle("Verify SimpleX names", isOn: $verifySimplexNames) } + settingsRow("checkmark.seal", color: theme.colors.secondary) { + Toggle("Show signature", isOn: $showSignature) + } } header: { Text("Chats") .foregroundColor(theme.colors.secondary) @@ -212,6 +217,9 @@ struct PrivacySettings: View { settingsRow("network.badge.shield.half.filled", color: theme.colors.secondary) { Toggle("Protect IP address", isOn: $askToApproveRelays) } + settingsRow("lock", color: theme.colors.secondary) { + Toggle("Show encryption", isOn: $showFileEncryption) + } } header: { Text("Files") .foregroundColor(theme.colors.secondary) diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 057ce5a227..4bd5f7db1b 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -33,6 +33,8 @@ let DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews" // deprecated, moved t let DEFAULT_PRIVACY_SIMPLEX_LINK_MODE = "privacySimplexLinkMode" let DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS = "privacyShowChatPreviews" let DEFAULT_PRIVACY_VERIFY_SIMPLEX_NAMES = "privacyVerifySimplexNames" +let DEFAULT_PRIVACY_SHOW_SIGNATURE = "privacyShowSignature" +let DEFAULT_PRIVACY_SHOW_FILE_ENCRYPTION = "privacyShowEncryption" let DEFAULT_PRIVACY_SAVE_LAST_DRAFT = "privacySaveLastDraft" let DEFAULT_PRIVACY_PROTECT_SCREEN = "privacyProtectScreen" let DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET = "privacyDeliveryReceiptsSet" @@ -57,6 +59,7 @@ let DEFAULT_ADDRESS_CREATION_CARD_SHOWN = "addressCreationCardShown" let DEFAULT_TOOLBAR_MATERIAL = "toolbarMaterial" let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab" let DEFAULT_LIVE_MESSAGE_ALERT_SHOWN = "liveMessageAlertShown" +let DEFAULT_SIGN_MESSAGE_ALERT_SHOWN = "signMessageAlertShown" let DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE = "showHiddenProfilesNotice" let DEFAULT_SHOW_MUTE_PROFILE_ALERT = "showMuteProfileAlert" let DEFAULT_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT = "showReportsInSupportChatAlert" @@ -117,6 +120,7 @@ let appDefaults: [String: Any] = [ DEFAULT_TOOLBAR_MATERIAL: ToolbarMaterial.defaultMaterial, DEFAULT_CONNECT_VIA_LINK_TAB: ConnectViaLinkTab.scan.rawValue, DEFAULT_LIVE_MESSAGE_ALERT_SHOWN: false, + DEFAULT_SIGN_MESSAGE_ALERT_SHOWN: false, DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE: true, DEFAULT_SHOW_MUTE_PROFILE_ALERT: true, DEFAULT_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT: true, @@ -145,6 +149,7 @@ let hintDefaults = [ DEFAULT_ONE_HAND_UI_CARD_SHOWN, DEFAULT_ADDRESS_CREATION_CARD_SHOWN, DEFAULT_LIVE_MESSAGE_ALERT_SHOWN, + DEFAULT_SIGN_MESSAGE_ALERT_SHOWN, DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE, DEFAULT_SHOW_MUTE_PROFILE_ALERT, DEFAULT_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT, diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index eeda535994..c377288566 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -946,6 +946,7 @@ public enum GroupFeature: String, Decodable, Feature, Hashable { case reports case history case support + case signMessages public var id: Self { self } @@ -968,6 +969,7 @@ public enum GroupFeature: String, Decodable, Feature, Hashable { case .reports: false case .history: false case .support: false + case .signMessages: false } } @@ -987,6 +989,7 @@ public enum GroupFeature: String, Decodable, Feature, Hashable { : NSLocalizedString("Member reports", comment: "chat feature") case .history: return NSLocalizedString("Visible history", comment: "chat feature") case .support: return NSLocalizedString("Chat with admins", comment: "chat feature") + case .signMessages: return NSLocalizedString("Sign messages", comment: "chat feature") } } @@ -1002,6 +1005,7 @@ public enum GroupFeature: String, Decodable, Feature, Hashable { case .reports: return "flag" case .history: return "clock" case .support: return "questionmark.circle" + case .signMessages: return "checkmark.seal" } } @@ -1017,6 +1021,7 @@ public enum GroupFeature: String, Decodable, Feature, Hashable { case .reports: return "flag.fill" case .history: return "clock.fill" case .support: return "questionmark.circle.fill" + case .signMessages: return "checkmark.seal.fill" } } @@ -1090,6 +1095,11 @@ public enum GroupFeature: String, Decodable, Feature, Hashable { : "Allow members to chat with admins." case .off: return "Prohibit chats with admins." } + case .signMessages: + switch enabled { + case .on: return "Require signing messages." + case .off: return "Do not require signing messages." + } } } else { switch self { @@ -1167,6 +1177,11 @@ public enum GroupFeature: String, Decodable, Feature, Hashable { : "Members can chat with admins." case .off: return "Chats with admins are prohibited." } + case .signMessages: + switch enabled { + case .on: return "Message signing is required." + case .off: return "Message signing is not required." + } } } } @@ -1322,6 +1337,7 @@ public struct FullGroupPreferences: Decodable, Equatable, Hashable { public var reports: GroupPreference public var history: GroupPreference public var support: GroupPreference + public var signMessages: GroupPreference public var commands: [ChatBotCommand] public init( @@ -1335,6 +1351,7 @@ public struct FullGroupPreferences: Decodable, Equatable, Hashable { reports: GroupPreference, history: GroupPreference, support: GroupPreference, + signMessages: GroupPreference, commands: [ChatBotCommand] ) { self.timedMessages = timedMessages @@ -1347,6 +1364,7 @@ public struct FullGroupPreferences: Decodable, Equatable, Hashable { self.reports = reports self.history = history self.support = support + self.signMessages = signMessages self.commands = commands } @@ -1361,6 +1379,7 @@ public struct FullGroupPreferences: Decodable, Equatable, Hashable { reports: GroupPreference(enable: .on), history: GroupPreference(enable: .on), support: GroupPreference(enable: .on), + signMessages: GroupPreference(enable: .off), commands: [] ) } @@ -1376,6 +1395,7 @@ public struct GroupPreferences: Codable, Hashable { public var reports: GroupPreference? public var history: GroupPreference? public var support: GroupPreference? + public var signMessages: GroupPreference? public var commands: [ChatBotCommand]? public init( @@ -1389,6 +1409,7 @@ public struct GroupPreferences: Codable, Hashable { reports: GroupPreference? = nil, history: GroupPreference? = nil, support: GroupPreference? = nil, + signMessages: GroupPreference? = nil, commands: [ChatBotCommand]? = nil ) { self.timedMessages = timedMessages @@ -1401,6 +1422,7 @@ public struct GroupPreferences: Codable, Hashable { self.reports = reports self.history = history self.support = support + self.signMessages = signMessages self.commands = commands } @@ -1430,6 +1452,7 @@ public func toGroupPreferences(_ fullPreferences: FullGroupPreferences) -> Group simplexLinks: fullPreferences.simplexLinks, reports: fullPreferences.reports, history: fullPreferences.history, + signMessages: fullPreferences.signMessages, commands: fullPreferences.commands ) } @@ -2765,6 +2788,32 @@ public struct GroupShortLinkData: Codable, Hashable { public var publicGroupData: PublicGroupData? } +public enum MsgSigStatus: String, Decodable, Equatable, Hashable { + case verified + case signedNoKey +} + +public enum MsgVerified: Decodable, Equatable, Hashable { + case signed(sigStatus: MsgSigStatus) + case sigMissing + case unsigned + + public var verified: Bool { + if case let .signed(sigStatus) = self { return sigStatus == .verified } + return false + } + + public var sigMissingInfo: (String, String)? { + switch self { + case .sigMissing: return ( + NSLocalizedString("Signature missing", comment: "alert title"), + NSLocalizedString("The channel required this message to be signed, but the signature is missing.", comment: "alert message") + ) + default: return nil + } + } +} + public enum RelayStatus: String, Decodable, Equatable, Hashable { case new case invited @@ -3741,7 +3790,8 @@ public struct ChatItem: Identifiable, Decodable, Hashable { userMention: false, deletable: false, editable: false, - showGroupAsSender: false + showGroupAsSender: false, + msgVerified: .unsigned ), content: .sndMsgContent(msgContent: .report(text: text, reason: reason)), quotedItem: CIQuote.getSample(item.id, item.meta.createdAt, item.text, chatDir: item.chatDir), @@ -3765,7 +3815,8 @@ public struct ChatItem: Identifiable, Decodable, Hashable { userMention: false, deletable: false, editable: false, - showGroupAsSender: false + showGroupAsSender: false, + msgVerified: .unsigned ), content: .rcvDeleted(deleteMode: .cidmBroadcast), quotedItem: nil, @@ -3789,7 +3840,8 @@ public struct ChatItem: Identifiable, Decodable, Hashable { userMention: false, deletable: false, editable: false, - showGroupAsSender: false + showGroupAsSender: false, + msgVerified: .unsigned ), content: .sndMsgContent(msgContent: .text("")), quotedItem: nil, @@ -3868,6 +3920,7 @@ public struct CIMeta: Decodable, Hashable { public var deletable: Bool public var editable: Bool public var showGroupAsSender: Bool + public var msgVerified: MsgVerified public var timestampText: Text { Text(formatTimestampMeta(itemTs)) } public var recent: Bool { updatedAt + 10 > .now } @@ -3893,7 +3946,8 @@ public struct CIMeta: Decodable, Hashable { userMention: false, deletable: deletable, editable: editable, - showGroupAsSender: false + showGroupAsSender: false, + msgVerified: .unsigned ) } @@ -3911,7 +3965,8 @@ public struct CIMeta: Decodable, Hashable { userMention: false, deletable: false, editable: false, - showGroupAsSender: false + showGroupAsSender: false, + msgVerified: .unsigned ) } } diff --git a/apps/ios/SimpleXChat/ChatUtils.swift b/apps/ios/SimpleXChat/ChatUtils.swift index 7de7f3704d..45320c5b93 100644 --- a/apps/ios/SimpleXChat/ChatUtils.swift +++ b/apps/ios/SimpleXChat/ChatUtils.swift @@ -27,6 +27,7 @@ extension ChatLike { case .history: p.history.on case .support: p.support.on case .reports: p.reports.on + case .signMessages: p.signMessages.on } } else { return true diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index b959b02272..0e7725c82b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -2261,6 +2261,7 @@ data class GroupInfo ( GroupFeature.Reports -> p.reports.on GroupFeature.History -> p.history.on GroupFeature.Support -> p.support.on + GroupFeature.SignMessages -> p.signMessages.on } } @@ -2422,6 +2423,26 @@ data class GroupShortLinkData ( val publicGroupData: PublicGroupData? = null ) +@Serializable +enum class MsgSigStatus { + @SerialName("verified") Verified, + @SerialName("signedNoKey") SignedNoKey; +} + +@Serializable +sealed class MsgVerified { + @Serializable @SerialName("signed") data class Signed(val sigStatus: MsgSigStatus): MsgVerified() + @Serializable @SerialName("sigMissing") object SigMissing: MsgVerified() + @Serializable @SerialName("unsigned") object Unsigned: MsgVerified() + + val verified: Boolean get() = this is Signed && sigStatus == MsgSigStatus.Verified + + val sigMissingInfo: Pair? get() = when (this) { + is SigMissing -> generalGetString(MR.strings.signature_missing_alert_title) to generalGetString(MR.strings.signature_missing_alert_desc) + else -> null + } +} + @Serializable enum class RelayStatus { @SerialName("new") New, @@ -3555,7 +3576,8 @@ data class CIMeta ( val userMention: Boolean, val deletable: Boolean, val editable: Boolean, - val showGroupAsSender: Boolean + val showGroupAsSender: Boolean, + val msgVerified: MsgVerified = MsgVerified.Unsigned ) { val timestampText: String get() = getTimestampText(itemTs, true) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 5c9993eae5..ec3e58ca54 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -129,6 +129,8 @@ class AppPreferences { val privacyChatListOpenLinks = mkEnumPreference(SHARED_PREFS_PRIVACY_CHAT_LIST_OPEN_LINKS, PrivacyChatListOpenLinksMode.ASK) { PrivacyChatListOpenLinksMode.values().firstOrNull { it.name == this } } val simplexLinkMode: SharedPreference = mkSafeEnumPreference(SHARED_PREFS_PRIVACY_SIMPLEX_LINK_MODE, SimplexLinkMode.default) val privacyShowChatPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_SHOW_CHAT_PREVIEWS, true) + val privacyShowSignature = mkBoolPreference(SHARED_PREFS_PRIVACY_SHOW_SIGNATURE, true) + val privacyShowEncryption = mkBoolPreference(SHARED_PREFS_PRIVACY_SHOW_FILE_ENCRYPTION, true) val privacySaveLastDraft = mkBoolPreference(SHARED_PREFS_PRIVACY_SAVE_LAST_DRAFT, true) val privacyDeliveryReceiptsSet = mkBoolPreference(SHARED_PREFS_PRIVACY_DELIVERY_RECEIPTS_SET, false) val privacyEncryptLocalFiles = mkBoolPreference(SHARED_PREFS_PRIVACY_ENCRYPT_LOCAL_FILES, true) @@ -186,6 +188,7 @@ class AppPreferences { val networkTCPKeepCnt = mkIntPreference(SHARED_PREFS_NETWORK_TCP_KEEP_CNT, KeepAliveOpts.defaults.keepCnt) val incognito = mkBoolPreference(SHARED_PREFS_INCOGNITO, false) val liveMessageAlertShown = mkBoolPreference(SHARED_PREFS_LIVE_MESSAGE_ALERT_SHOWN, false) + val signMessageAlertShown = mkBoolPreference(SHARED_PREFS_SIGN_MESSAGE_ALERT_SHOWN, false) val showHiddenProfilesNotice = mkBoolPreference(SHARED_PREFS_SHOW_HIDDEN_PROFILES_NOTICE, true) val oneHandUICardShown = mkBoolPreference(SHARED_PREFS_ONE_HAND_UI_CARD_SHOWN, false) val addressCreationCardShown = mkBoolPreference(SHARED_PREFS_ADDRESS_CREATION_CARD_SHOWN, false) @@ -271,6 +274,7 @@ class AppPreferences { hintPref(oneHandUICardShown, false), hintPref(addressCreationCardShown, false), hintPref(liveMessageAlertShown, false), + hintPref(signMessageAlertShown, false), hintPref(showHiddenProfilesNotice, true), hintPref(showMuteProfileAlert, true), hintPref(showReportsInSupportChatAlert, true), @@ -404,6 +408,8 @@ class AppPreferences { private const val SHARED_PREFS_PRIVACY_CHAT_LIST_OPEN_LINKS = "ChatListOpenLinks" // TODO remove private const val SHARED_PREFS_PRIVACY_SIMPLEX_LINK_MODE = "PrivacySimplexLinkMode" private const val SHARED_PREFS_PRIVACY_SHOW_CHAT_PREVIEWS = "PrivacyShowChatPreviews" + private const val SHARED_PREFS_PRIVACY_SHOW_SIGNATURE = "PrivacyShowSignature" + private const val SHARED_PREFS_PRIVACY_SHOW_FILE_ENCRYPTION = "PrivacyShowEncryption" private const val SHARED_PREFS_PRIVACY_SAVE_LAST_DRAFT = "PrivacySaveLastDraft" private const val SHARED_PREFS_PRIVACY_DELIVERY_RECEIPTS_SET = "PrivacyDeliveryReceiptsSet" private const val SHARED_PREFS_PRIVACY_ENCRYPT_LOCAL_FILES = "PrivacyEncryptLocalFiles" @@ -454,6 +460,7 @@ class AppPreferences { private const val SHARED_PREFS_NETWORK_TCP_KEEP_CNT = "NetworkTCPKeepCnt" private const val SHARED_PREFS_INCOGNITO = "Incognito" private const val SHARED_PREFS_LIVE_MESSAGE_ALERT_SHOWN = "LiveMessageAlertShown" + private const val SHARED_PREFS_SIGN_MESSAGE_ALERT_SHOWN = "SignMessageAlertShown" private const val SHARED_PREFS_SHOW_HIDDEN_PROFILES_NOTICE = "ShowHiddenProfilesNotice" private const val SHARED_PREFS_ONE_HAND_UI_CARD_SHOWN = "OneHandUICardShown" private const val SHARED_PREFS_ADDRESS_CREATION_CARD_SHOWN = "AddressCreationCardShown" @@ -1096,8 +1103,8 @@ object ChatController { suspend fun apiReorderChatTags(rh: Long?, tagIds: List) = sendCommandOkResp(rh, CC.ApiReorderChatTags(tagIds)) - suspend fun apiSendMessages(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, sendAsGroup: Boolean = false, live: Boolean = false, ttl: Int? = null, composedMessages: List): List? { - val cmd = CC.ApiSendMessages(type, id, scope, sendAsGroup, live, ttl, composedMessages) + suspend fun apiSendMessages(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, sendAsGroup: Boolean = false, live: Boolean = false, ttl: Int? = null, sign: Boolean = false, composedMessages: List): List? { + val cmd = CC.ApiSendMessages(type, id, scope, sendAsGroup, live, ttl, sign, composedMessages) return processSendMessageCmd(rh, cmd) } @@ -3784,7 +3791,7 @@ sealed class CC { class ApiGetChat(val type: ChatType, val id: Long, val scope: GroupChatScope?, val contentTag: MsgContentTag?, val pagination: ChatPagination, val search: String = ""): CC() class ApiGetChatContentTypes(val type: ChatType, val id: Long, val scope: GroupChatScope?): CC() class ApiGetChatItemInfo(val type: ChatType, val id: Long, val scope: GroupChatScope?, val itemId: Long): CC() - class ApiSendMessages(val type: ChatType, val id: Long, val scope: GroupChatScope?, val sendAsGroup: Boolean, val live: Boolean, val ttl: Int?, val composedMessages: List): CC() + class ApiSendMessages(val type: ChatType, val id: Long, val scope: GroupChatScope?, val sendAsGroup: Boolean, val live: Boolean, val ttl: Int?, val sign: Boolean, val composedMessages: List): CC() class ApiCreateChatTag(val tag: ChatTagData): CC() class ApiSetChatTags(val type: ChatType, val id: Long, val tagIds: List): CC() class ApiDeleteChatTag(val tagId: Long): CC() @@ -3982,7 +3989,7 @@ sealed class CC { is ApiSendMessages -> { val msgs = json.encodeToString(composedMessages) val ttlStr = if (ttl != null) "$ttl" else "default" - "/_send ${chatRef(type, id, scope)}${if (sendAsGroup) "(as_group=on)" else ""} live=${onOff(live)} ttl=${ttlStr} json $msgs" + "/_send ${chatRef(type, id, scope)}${if (sendAsGroup) "(as_group=on)" else ""} live=${onOff(live)} ttl=${ttlStr} sign=${onOff(sign)} json $msgs" } is ApiCreateChatTag -> "/_create tag ${json.encodeToString(tag)}" is ApiSetChatTags -> "/_tags ${chatRef(type, id, scope = null)} ${tagIds.joinToString(",")}" @@ -5857,7 +5864,8 @@ enum class GroupFeature: Feature { @SerialName("simplexLinks") SimplexLinks, @SerialName("reports") Reports, @SerialName("history") History, - @SerialName("support") Support; + @SerialName("support") Support, + @SerialName("signMessages") SignMessages; override val hasParam: Boolean get() = when(this) { TimedMessages -> true @@ -5876,6 +5884,7 @@ enum class GroupFeature: Feature { Reports -> false History -> false Support -> false + SignMessages -> false } override val text: String get() = text(isChannel = false) @@ -5891,6 +5900,7 @@ enum class GroupFeature: Feature { Reports -> generalGetString(if (isChannel) MR.strings.group_reports_subscriber_reports else MR.strings.group_reports_member_reports) History -> generalGetString(MR.strings.recent_history) Support -> generalGetString(MR.strings.chat_with_admins) + SignMessages -> generalGetString(MR.strings.sign_messages) } val icon: Painter @@ -5905,6 +5915,7 @@ enum class GroupFeature: Feature { Reports -> painterResource(MR.images.ic_flag) History -> painterResource(MR.images.ic_schedule) Support -> painterResource(MR.images.ic_help) + SignMessages -> painterResource(MR.images.ic_verified) } @Composable @@ -5919,6 +5930,7 @@ enum class GroupFeature: Feature { Reports -> painterResource(MR.images.ic_flag_filled) History -> painterResource(MR.images.ic_schedule_filled) Support -> painterResource(MR.images.ic_help_filled) + SignMessages -> painterResource(MR.images.ic_verified_filled) } fun enableDescription(enabled: GroupFeatureEnabled, canEdit: Boolean, isChannel: Boolean = false): String = @@ -5964,6 +5976,10 @@ enum class GroupFeature: Feature { GroupFeatureEnabled.ON -> generalGetString(if (isChannel) MR.strings.allow_chat_with_admins_channel else MR.strings.allow_chat_with_admins) GroupFeatureEnabled.OFF -> generalGetString(MR.strings.prohibit_chat_with_admins) } + SignMessages -> when(enabled) { + GroupFeatureEnabled.ON -> generalGetString(MR.strings.require_message_signatures) + GroupFeatureEnabled.OFF -> generalGetString(MR.strings.do_not_require_message_signatures) + } } } else { when(this) { @@ -6007,6 +6023,10 @@ enum class GroupFeature: Feature { GroupFeatureEnabled.ON -> generalGetString(if (isChannel) MR.strings.members_can_chat_with_admins_channel else MR.strings.members_can_chat_with_admins) GroupFeatureEnabled.OFF -> generalGetString(MR.strings.chat_with_admins_is_prohibited) } + SignMessages -> when(enabled) { + GroupFeatureEnabled.ON -> generalGetString(MR.strings.message_signatures_are_required) + GroupFeatureEnabled.OFF -> generalGetString(MR.strings.message_signatures_are_not_required) + } } } } @@ -6133,6 +6153,7 @@ data class FullGroupPreferences( val reports: GroupPreference, val history: GroupPreference, val support: GroupPreference, + val signMessages: GroupPreference, val commands: List, ) { fun toGroupPreferences(): GroupPreferences = @@ -6147,6 +6168,7 @@ data class FullGroupPreferences( reports = reports, history = history, support = support, + signMessages = signMessages, commands = commands, ) @@ -6162,6 +6184,7 @@ data class FullGroupPreferences( reports = GroupPreference(GroupFeatureEnabled.ON), history = GroupPreference(GroupFeatureEnabled.ON), support = GroupPreference(GroupFeatureEnabled.ON), + signMessages = GroupPreference(GroupFeatureEnabled.OFF), commands = listOf() ) } @@ -6179,6 +6202,7 @@ data class GroupPreferences( val reports: GroupPreference? = null, val history: GroupPreference? = null, val support: GroupPreference? = null, + val signMessages: GroupPreference? = null, val commands: List? = null ) { companion object { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt index 3e4b8ce1db..49c2f95667 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt @@ -392,6 +392,8 @@ fun createProfileOnboarding(chatModel: ChatModel, displayName: String, close: () null, Profile(displayName.trim(), "", null, null) ) ?: return@withBGApi chatModel.localUserCreated.value = true + // new users don't need the local file encryption indicator (all files are encrypted); existing users keep it on + chatModel.controller.appPrefs.privacyShowEncryption.set(false) val onboardingStage = chatModel.controller.appPrefs.onboardingStage // No users or no visible users if (chatModel.users.none { u -> !u.user.hidden }) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt index affe5ce326..0c2978d622 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt @@ -272,7 +272,16 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools if (deleteAt != null) { InfoRow(stringResource(MR.strings.info_row_disappears_at), localTimestamp(deleteAt)) } - if (devTools) { + if (ci.meta.msgVerified.verified) { + val signedRes = if (sent) MR.strings.info_row_signed else MR.strings.info_row_signed_verified + InfoRow(stringResource(signedRes), "", icon = painterResource(MR.images.ic_verified)) + } else if (ci.meta.msgVerified is MsgVerified.SigMissing) { + InfoRow(stringResource(MR.strings.signature_missing_alert_title), "", icon = painterResource(MR.images.ic_verified_missing), iconTint = Color.Red) + } + } + if (devTools) { + SectionDividerSpaced() + SectionView { InfoRow(stringResource(MR.strings.info_row_database_id), ci.meta.itemId.toString()) InfoRow(stringResource(MR.strings.info_row_updated_at), localTimestamp(ci.meta.updatedAt)) ExpandableInfoRow(stringResource(MR.strings.info_row_message_status), jsonShort.encodeToString(ci.meta.itemStatus)) @@ -559,6 +568,11 @@ fun itemInfoShareText(chatModel: ChatModel, ci: ChatItem, chatItemInfo: ChatItem if (deleteAt != null) { shareText.add(String.format(generalGetString(MR.strings.share_text_disappears_at), localTimestamp(deleteAt))) } + if (ci.meta.msgVerified.verified) { + shareText.add(generalGetString(if (sent) MR.strings.info_row_signed else MR.strings.info_row_signed_verified)) + } else if (ci.meta.msgVerified is MsgVerified.SigMissing) { + shareText.add(generalGetString(MR.strings.signature_missing_alert_title)) + } if (devTools) { shareText.add(String.format(generalGetString(MR.strings.share_text_database_id), meta.itemId)) shareText.add(String.format(generalGetString(MR.strings.share_text_updated_at), meta.updatedAt)) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index 865c690c3c..5580424a17 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -539,7 +539,7 @@ fun ComposeView( } } - suspend fun send(chat: Chat, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?, mentions: Map): ChatItem? { + suspend fun send(chat: Chat, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?, mentions: Map, sign: Boolean = false): ChatItem? { val cInfo = chat.chatInfo val chatItems = if (chat.chatInfo.chatType == ChatType.Local) chatModel.controller.apiCreateChatItems( @@ -556,6 +556,7 @@ fun ComposeView( sendAsGroup = cInfo.sendAsGroup, live = live, ttl = ttl, + sign = sign, composedMessages = listOf(ComposedMessage(file, quoted, mc, mentions)) ) if (!chatItems.isNullOrEmpty()) { @@ -672,7 +673,7 @@ fun ComposeView( } } - suspend fun sendMessageAsync(text: String?, live: Boolean, ttl: Int?): List? { + suspend fun sendMessageAsync(text: String?, live: Boolean, ttl: Int?, sign: Boolean = false): List? { val cs = composeState.value var sent: List? var lastMessageFailedToSend: ComposeState? = null @@ -797,7 +798,7 @@ fun ComposeView( if (cs.message.text.isNotEmpty()) { sent?.mapIndexed { index, message -> if (index == sent!!.lastIndex) { - send(chat, checkLinkPreview(), quoted = message.id, live = false, ttl = ttl, mentions = cs.memberMentions) + send(chat, checkLinkPreview(), quoted = message.id, live = false, ttl = ttl, mentions = cs.memberMentions, sign = sign) } else { message } @@ -914,7 +915,8 @@ fun ComposeView( val sendResult = send(chat, content, if (index == 0) quotedItemId else null, file, live = if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false, ttl = ttl, - mentions = cs.memberMentions + mentions = cs.memberMentions, + sign = sign ) sent = if (sendResult != null) listOf(sendResult) else null if (sent == null && index == msgs.lastIndex && cs.liveMessage == null) { @@ -941,9 +943,9 @@ fun ComposeView( return sent } - fun sendMessage(ttl: Int?) { + fun sendMessage(ttl: Int?, sign: Boolean = false) { withLongRunningApi(slow = 120_000) { - sendMessageAsync(null, false, ttl) + sendMessageAsync(null, false, ttl, sign) } } @@ -1387,12 +1389,18 @@ fun ComposeView( allowVoiceToContact = ::allowVoiceToContact, sendButtonColor = sendButtonColor, timedMessageAllowed = timedMessageAllowed, + showSign = (chat.chatInfo as? ChatInfo.Group)?.groupInfo?.useRelays == true, + signMessageAlertShown = chatModel.controller.appPrefs.signMessageAlertShown, customDisappearingMessageTimePref = chatModel.controller.appPrefs.customDisappearingMessageTime, placeholder = if (userCantSendReason.value != null) "" else placeholder ?: composeState.value.placeholder, sendMessage = { ttl -> sendMessage(ttl) resetLinkPreview() }, + sendSignedMessage = { + sendMessage(null, sign = true) + resetLinkPreview() + }, sendLiveMessage = if (chat.chatInfo.chatType != ChatType.Local) ::sendLiveMessage else null, updateLiveMessage = ::updateLiveMessage, cancelLiveMessage = { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt index 0948551c7e..fef15e13cc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt @@ -49,9 +49,12 @@ fun SendMsgView( sendButtonColor: Color = MaterialTheme.colors.primary, allowVoiceToContact: () -> Unit, timedMessageAllowed: Boolean = false, + showSign: Boolean = false, + signMessageAlertShown: SharedPreference = SharedPreference(get = { false }, set = {}), customDisappearingMessageTimePref: SharedPreference? = null, placeholder: String, sendMessage: (Int?) -> Unit, + sendSignedMessage: () -> Unit = {}, sendLiveMessage: (suspend () -> Unit)? = null, updateLiveMessage: (suspend () -> Unit)? = null, cancelLiveMessage: (() -> Unit)? = null, @@ -207,6 +210,30 @@ fun SendMsgView( ) } } + if (showSign && !cs.editing) { + menuItems.add { + ItemAction( + generalGetString(MR.strings.sign_message), + painterResource(MR.images.ic_verified), + onClick = { + if (signMessageAlertShown.state.value) { + sendSignedMessage() + } else { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.sign_message), + text = generalGetString(MR.strings.sign_message_desc), + confirmText = generalGetString(MR.strings.send_verb), + onConfirm = { + signMessageAlertShown.set(true) + sendSignedMessage() + } + ) + } + showDropdown.value = false + } + ) + } + } } return menuItems diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt index d8be4998be..7ba7dfa13a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt @@ -178,6 +178,12 @@ private fun GroupPreferencesLayout( } } } + @Composable fun SignMessagesPreference() { + val enableSignMessages = remember(preferences) { mutableStateOf(preferences.signMessages.enable) } + FeatureSection(GroupFeature.SignMessages, enableSignMessages, null, groupInfo, preferences, onTTLUpdated) { enable, _ -> + applyPrefs(preferences.copy(signMessages = GroupPreference(enable = enable))) + } + } ColumnWithScrollBar { val titleId = if (groupInfo.useRelays) MR.strings.channel_preferences else if (groupInfo.businessChat == null) MR.strings.group_preferences @@ -210,6 +216,8 @@ private fun GroupPreferencesLayout( SectionDividerSpaced() SupportPreference(disabled = true) } else { + SignMessagesPreference() + SectionDividerSpaced() TimedMessagesPreference() SectionDividerSpaced() FullDeletePreference() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index b2b89ca041..28afc0132f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -211,7 +211,7 @@ fun CIFileView( val encrypted = if (file?.fileSource == null) null else file.fileSource.cryptoArgs != null val metaReserve = buildAnnotatedString { withStyle(reserveTimestampStyle) { - append(reserveSpaceForMeta(meta, chatTTL, encrypted, secondaryColor = secondaryColor, showViaProxy = showViaProxy, showTimestamp = showTimestamp)) + append(reserveSpaceForMeta(meta, chatTTL, encrypted, secondaryColor = secondaryColor, showViaProxy = showViaProxy, showTimestamp = showTimestamp, signedFileVerified = file?.loaded)) } } if (file != null) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt index 4ec2a885e7..c3b72b762b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt @@ -13,6 +13,7 @@ import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* +import chat.simplex.common.platform.appPreferences import chat.simplex.common.ui.theme.isInDarkTheme import chat.simplex.res.MR import kotlinx.datetime.Clock @@ -56,7 +57,8 @@ fun CIMetaView( showStatus = showStatus, showEdited = showEdited, showViaProxy = showViaProxy, - showTimestamp = showTimestamp + showTimestamp = showTimestamp, + signedFileVerified = chatItem.file?.loaded ) } } @@ -74,7 +76,10 @@ private fun CIMetaText( showEdited: Boolean = true, showTimestamp: Boolean, showViaProxy: Boolean, + signedFileVerified: Boolean?, ) { + val showSignature = appPreferences.privacyShowSignature.state.value + val showEncryption = appPreferences.privacyShowEncryption.state.value if (showEdited && meta.itemEdited) { StatusIconText(painterResource(MR.images.ic_edit), color) } @@ -103,10 +108,17 @@ private fun CIMetaText( StatusIconText(painterResource(MR.images.ic_circle_filled), Color.Transparent) } } - if (encrypted != null) { + if (encrypted != null && showEncryption) { Spacer(Modifier.width(4.dp)) StatusIconText(painterResource(if (encrypted) MR.images.ic_lock else MR.images.ic_lock_open_right), color) } + if (showSignature && meta.msgVerified.verified && signedFileVerified != false) { + Spacer(Modifier.width(4.dp)) + StatusIconText(painterResource(MR.images.ic_verified), color) + } else if (meta.msgVerified is MsgVerified.SigMissing) { + Spacer(Modifier.width(4.dp)) + StatusIconText(painterResource(MR.images.ic_verified_missing), Color.Red) + } if (showTimestamp) { Spacer(Modifier.width(4.dp)) @@ -123,8 +135,11 @@ fun reserveSpaceForMeta( showStatus: Boolean = true, showEdited: Boolean = true, showViaProxy: Boolean = false, - showTimestamp: Boolean + showTimestamp: Boolean, + signedFileVerified: Boolean? = null ): String { + val showSignature = appPreferences.privacyShowSignature.state.value + val showEncryption = appPreferences.privacyShowEncryption.state.value val iconSpace = " \u00A0\u00A0\u00A0" val whiteSpace = "\u00A0" var res = if (showTimestamp) "" else iconSpace @@ -162,7 +177,12 @@ fun reserveSpaceForMeta( space = whiteSpace } - if (encrypted != null) { + if (encrypted != null && showEncryption) { + appendSpace() + res += iconSpace + space = whiteSpace + } + if ((showSignature && meta.msgVerified.verified && signedFileVerified != false) || meta.msgVerified is MsgVerified.SigMissing) { appendSpace() res += iconSpace space = whiteSpace diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index 4f6cde9a4f..348a97470a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -127,7 +127,7 @@ fun ChatItemView( modifier = (if (fillMaxWidth) Modifier.fillMaxWidth() else Modifier), contentAlignment = alignment, ) { - val info = cItem.meta.itemStatus.statusInto + val info = cItem.meta.itemStatus.statusInto ?: cItem.meta.msgVerified.sigMissingInfo val onClick = if (info != null) { { AlertManager.shared.showAlertMsg( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index f8bb2d64f9..1ff946ab11 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -139,6 +139,11 @@ fun MorePrivacyView(chatModel: ChatModel) { stringResource(MR.strings.verify_simplex_names), chatModel.controller.appPrefs.privacyVerifySimplexNames ) + SettingsPreferenceItem( + painterResource(MR.images.ic_verified), + stringResource(MR.strings.show_signature), + chatModel.controller.appPrefs.privacyShowSignature + ) } SectionDividerSpaced() @@ -147,6 +152,7 @@ fun MorePrivacyView(chatModel: ChatModel) { withBGApi { chatModel.controller.apiSetEncryptLocalFiles(enable) } }) SettingsPreferenceItem(painterResource(MR.images.ic_security), stringResource(MR.strings.protect_ip_address), chatModel.controller.appPrefs.privacyAskToApproveRelays) + SettingsPreferenceItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.show_encryption), chatModel.controller.appPrefs.privacyShowEncryption) } SectionTextFooter( if (chatModel.controller.appPrefs.privacyAskToApproveRelays.state.value) { diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 5cf764afd3..db74058097 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -754,6 +754,19 @@ 5 minutes Custom time Send + Sign message + Signing proves you authored this message and can\'t be denied later. + Signed + Signed & verified + Sign messages + Require signing messages. + Do not require signing messages. + Message signing is required. + Message signing is not required. + Show signature + Show encryption + Signature missing + The channel required this message to be signed, but the signature is missing. Live message! Send a live message - it will update for the recipient(s) as you type it Send diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_verified.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_verified.svg new file mode 100644 index 0000000000..96e0c66254 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_verified.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_verified_filled.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_verified_filled.svg new file mode 100644 index 0000000000..e46361c721 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_verified_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_verified_missing.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_verified_missing.svg new file mode 100644 index 0000000000..87397c3d36 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_verified_missing.svg @@ -0,0 +1 @@ + diff --git a/bots/api/COMMANDS.md b/bots/api/COMMANDS.md index 28ece08908..463dd4088b 100644 --- a/bots/api/COMMANDS.md +++ b/bots/api/COMMANDS.md @@ -283,20 +283,21 @@ Send messages. - sendRef: [ChatRef](./TYPES.md#chatref) - liveMessage: bool - ttl: int? +- signMessages: bool - composedMessages: [[ComposedMessage](./TYPES.md#composedmessage)] **Syntax**: ``` -/_send [ live=on][ ttl=] json +/_send [ live=on][ ttl=][ sign=on] json ``` ```javascript -'/_send ' + ChatRef.cmdString(sendRef) + (liveMessage ? ' live=on' : '') + (ttl ? ' ttl=' + ttl : '') + ' json ' + JSON.stringify(composedMessages) // JavaScript +'/_send ' + ChatRef.cmdString(sendRef) + (liveMessage ? ' live=on' : '') + (ttl ? ' ttl=' + ttl : '') + (signMessages ? ' sign=on' : '') + ' json ' + JSON.stringify(composedMessages) // JavaScript ``` ```python -'/_send ' + ChatRef_cmd_string(sendRef) + (' live=on' if liveMessage else '') + ((' ttl=' + str(ttl)) if ttl is not None else '') + ' json ' + json.dumps(composedMessages) # Python +'/_send ' + ChatRef_cmd_string(sendRef) + (' live=on' if liveMessage else '') + ((' ttl=' + str(ttl)) if ttl is not None else '') + (' sign=on' if signMessages else '') + ' json ' + json.dumps(composedMessages) # Python ``` **Responses**: diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 79af05066b..3984eb3a5e 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -139,6 +139,7 @@ This file is generated automatically. - [MsgReaction](#msgreaction) - [MsgReceiptStatus](#msgreceiptstatus) - [MsgSigStatus](#msgsigstatus) +- [MsgVerified](#msgverified) - [NameErrorType](#nameerrortype) - [NetworkError](#networkerror) - [NewUser](#newuser) @@ -873,7 +874,7 @@ Group: - editable: bool - forwardedByMember: int64? - showGroupAsSender: bool -- msgSigned: [MsgSigStatus](#msgsigstatus)? +- msgVerified: [MsgVerified](#msgverified) - createdAt: UTCTime - updatedAt: UTCTime @@ -2219,6 +2220,7 @@ Phone: - support: [SupportGroupPreference](#supportgrouppreference) - sessions: [RoleGroupPreference](#rolegrouppreference) - comments: [CommentsGroupPreference](#commentsgrouppreference) +- signMessages: [GroupPreference](#grouppreference) - commands: [[ChatBotCommand](#chatbotcommand)] @@ -2311,6 +2313,7 @@ MemberSupport: - "support" - "sessions" - "comments" +- "signMessages" --- @@ -2552,6 +2555,7 @@ UpdateRequired: - support: [SupportGroupPreference](#supportgrouppreference)? - sessions: [RoleGroupPreference](#rolegrouppreference)? - comments: [CommentsGroupPreference](#commentsgrouppreference)? +- signMessages: [GroupPreference](#grouppreference)? - commands: [[ChatBotCommand](#chatbotcommand)]? @@ -2952,6 +2956,23 @@ Unknown: - "signedNoKey" +--- + +## MsgVerified + +**Discriminated union type**: + +Signed: +- type: "signed" +- sigStatus: [MsgSigStatus](#msgsigstatus) + +SigMissing: +- type: "sigMissing" + +Unsigned: +- type: "unsigned" + + --- ## NameErrorType diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index ddfc817fa2..3aa054af0c 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -86,7 +86,7 @@ chatCommandsDocsData = ), ( "Message commands", "Commands to send, update, delete, moderate messages and set message reactions", - [ ("APISendMessages", [], "Send messages.", ["CRNewChatItems", "CRChatCmdError"], [], Just UNBackground, "/_send " <> Param "sendRef" <> OnOffParam "live" "liveMessage" (Just False) <> Optional "" (" ttl=" <> Param "$0") "ttl" <> " json " <> Json "composedMessages"), + [ ("APISendMessages", [], "Send messages.", ["CRNewChatItems", "CRChatCmdError"], [], Just UNBackground, "/_send " <> Param "sendRef" <> OnOffParam "live" "liveMessage" (Just False) <> Optional "" (" ttl=" <> Param "$0") "ttl" <> OnOffParam "sign" "signMessages" (Just False) <> " json " <> Json "composedMessages"), ( "APIUpdateChatItem", [], "Update message.", diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index 35ea9fe261..5e1e2bb082 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -322,6 +322,7 @@ chatTypesDocsData = (sti @MsgReaction, STUnion, "MR", [], "", ""), (sti @MsgReceiptStatus, STEnum, "MR", [], "", ""), (sti @MsgSigStatus, STEnum, "MSS", [], "", ""), + (sti @MsgVerified, STUnion, "MV", [], "", ""), (sti @NameErrorType, STUnion, "", [], "", ""), (sti @NetworkError, STUnion, "NE", [], "", ""), (sti @NewUser, STRecord, "", [], "", ""), @@ -554,6 +555,7 @@ deriving instance Generic MsgFilter deriving instance Generic MsgReaction deriving instance Generic MsgReceiptStatus deriving instance Generic MsgSigStatus +deriving instance Generic MsgVerified deriving instance Generic NameErrorType deriving instance Generic NetworkError deriving instance Generic NewUser diff --git a/bots/src/API/TypeInfo.hs b/bots/src/API/TypeInfo.hs index f949a2f92f..e225659c4d 100644 --- a/bots/src/API/TypeInfo.hs +++ b/bots/src/API/TypeInfo.hs @@ -240,7 +240,8 @@ toTypeInfo tr = [ "FullDeleteGroupPreference", "ReactionsGroupPreference", "ReportsGroupPreference", - "HistoryGroupPreference" + "HistoryGroupPreference", + "SignMessagesGroupPreference" ] roleGroupPrefTypes = [ "DirectMessagesGroupPreference", diff --git a/packages/simplex-chat-client/types/typescript/src/commands.ts b/packages/simplex-chat-client/types/typescript/src/commands.ts index 31d6597b3f..3f4e3ad7ee 100644 --- a/packages/simplex-chat-client/types/typescript/src/commands.ts +++ b/packages/simplex-chat-client/types/typescript/src/commands.ts @@ -89,6 +89,7 @@ export interface APISendMessages { sendRef: T.ChatRef liveMessage: boolean ttl?: number // int + signMessages: boolean composedMessages: T.ComposedMessage[] // non-empty } @@ -96,7 +97,7 @@ export namespace APISendMessages { export type Response = CR.NewChatItems | CR.ChatCmdError export function cmdString(self: APISendMessages): string { - return '/_send ' + T.ChatRef.cmdString(self.sendRef) + (self.liveMessage ? ' live=on' : '') + (self.ttl ? ' ttl=' + self.ttl : '') + ' json ' + JSON.stringify(self.composedMessages) + return '/_send ' + T.ChatRef.cmdString(self.sendRef) + (self.liveMessage ? ' live=on' : '') + (self.ttl ? ' ttl=' + self.ttl : '') + (self.signMessages ? ' sign=on' : '') + ' json ' + JSON.stringify(self.composedMessages) } } diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 63f74f5814..d52d3fb5e6 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -842,7 +842,7 @@ export interface CIMeta { editable: boolean forwardedByMember?: number // int64 showGroupAsSender: boolean - msgSigned?: MsgSigStatus + msgVerified: MsgVerified createdAt: string // ISO-8601 timestamp updatedAt: string // ISO-8601 timestamp } @@ -2530,6 +2530,7 @@ export interface FullGroupPreferences { support: SupportGroupPreference sessions: RoleGroupPreference comments: CommentsGroupPreference + signMessages: GroupPreference commands: ChatBotCommand[] } @@ -2604,6 +2605,7 @@ export enum GroupFeature { Support = "support", Sessions = "sessions", Comments = "comments", + SignMessages = "signMessages", } export enum GroupFeatureEnabled { @@ -2813,6 +2815,7 @@ export interface GroupPreferences { support?: SupportGroupPreference sessions?: RoleGroupPreference comments?: CommentsGroupPreference + signMessages?: GroupPreference commands?: ChatBotCommand[] } @@ -3208,6 +3211,29 @@ export enum MsgSigStatus { SignedNoKey = "signedNoKey", } +export type MsgVerified = MsgVerified.Signed | MsgVerified.SigMissing | MsgVerified.Unsigned + +export namespace MsgVerified { + export type Tag = "signed" | "sigMissing" | "unsigned" + + interface Interface { + type: Tag + } + + export interface Signed extends Interface { + type: "signed" + sigStatus: MsgSigStatus + } + + export interface SigMissing extends Interface { + type: "sigMissing" + } + + export interface Unsigned extends Interface { + type: "unsigned" + } +} + export type NameErrorType = NameErrorType.NO_RESOLVER | NameErrorType.NOT_FOUND | NameErrorType.RESOLVER export namespace NameErrorType { diff --git a/packages/simplex-chat-client/typescript/src/client.ts b/packages/simplex-chat-client/typescript/src/client.ts index 12fa150fc4..10c4ee0a81 100644 --- a/packages/simplex-chat-client/typescript/src/client.ts +++ b/packages/simplex-chat-client/typescript/src/client.ts @@ -121,7 +121,7 @@ export class ChatClient { async apiSendMessages(chatType: T.ChatType, chatId: number, messages: T.ComposedMessage[]): Promise { const r = await this.sendChatCmd( - CC.APISendMessages.cmdString({sendRef: {chatType, chatId}, composedMessages: messages, liveMessage: false}) + CC.APISendMessages.cmdString({sendRef: {chatType, chatId}, composedMessages: messages, liveMessage: false, signMessages: false}) ) if (r.type === "newChatItems") return r.chatItems throw new ChatCommandError("unexpected response", r) diff --git a/packages/simplex-chat-nodejs/src/api.ts b/packages/simplex-chat-nodejs/src/api.ts index 386752b80d..958304a8ea 100644 --- a/packages/simplex-chat-nodejs/src/api.ts +++ b/packages/simplex-chat-nodejs/src/api.ts @@ -423,7 +423,8 @@ export class ChatApi { CC.APISendMessages.cmdString({ sendRef, composedMessages: messages, - liveMessage + liveMessage, + signMessages: false }) ) if (r.type === "newChatItems") return r.chatItems diff --git a/packages/simplex-chat-python/src/simplex_chat/api.py b/packages/simplex-chat-python/src/simplex_chat/api.py index f1a1853293..51de063329 100644 --- a/packages/simplex-chat-python/src/simplex_chat/api.py +++ b/packages/simplex-chat-python/src/simplex_chat/api.py @@ -193,6 +193,7 @@ class ChatApi: "sendRef": send_ref, "composedMessages": messages, "liveMessage": live_message, + "signMessages": False, } ) ) diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_commands.py b/packages/simplex-chat-python/src/simplex_chat/types/_commands.py index 9fb1a6c916..a7f4a56465 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_commands.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_commands.py @@ -80,11 +80,12 @@ class APISendMessages(TypedDict): sendRef: "T.ChatRef" liveMessage: bool ttl: NotRequired[int] # int + signMessages: bool composedMessages: list["T.ComposedMessage"] # non-empty def APISendMessages_cmd_string(self: APISendMessages) -> str: - return '/_send ' + T.ChatRef_cmd_string(self['sendRef']) + (' live=on' if self['liveMessage'] else '') + ((' ttl=' + str(self.get('ttl'))) if self.get('ttl') is not None else '') + ' json ' + json.dumps(self['composedMessages']) + return '/_send ' + T.ChatRef_cmd_string(self['sendRef']) + (' live=on' if self['liveMessage'] else '') + ((' ttl=' + str(self.get('ttl'))) if self.get('ttl') is not None else '') + (' sign=on' if self['signMessages'] else '') + ' json ' + json.dumps(self['composedMessages']) APISendMessages_Response = CR.NewChatItems | CR.ChatCmdError diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_types.py b/packages/simplex-chat-python/src/simplex_chat/types/_types.py index b3edf77111..062ebd6902 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -588,7 +588,7 @@ class CIMeta(TypedDict): editable: bool forwardedByMember: NotRequired[int] # int64 showGroupAsSender: bool - msgSigned: NotRequired["MsgSigStatus"] + msgVerified: "MsgVerified" createdAt: str # ISO-8601 timestamp updatedAt: str # ISO-8601 timestamp @@ -1776,6 +1776,7 @@ class FullGroupPreferences(TypedDict): support: "SupportGroupPreference" sessions: "RoleGroupPreference" comments: "CommentsGroupPreference" + signMessages: "GroupPreference" commands: list["ChatBotCommand"] class FullPreferences(TypedDict): @@ -1819,7 +1820,7 @@ class GroupDirectInvitation(TypedDict): fromGroupMemberConnId_: NotRequired[int] # int64 groupDirectInvStartedConnection: bool -GroupFeature = Literal["timedMessages", "directMessages", "fullDelete", "reactions", "voice", "files", "simplexLinks", "reports", "history", "support", "sessions", "comments"] +GroupFeature = Literal["timedMessages", "directMessages", "fullDelete", "reactions", "voice", "files", "simplexLinks", "reports", "history", "support", "sessions", "comments", "signMessages"] GroupFeatureEnabled = Literal["on", "off"] @@ -1967,6 +1968,7 @@ class GroupPreferences(TypedDict): support: NotRequired["SupportGroupPreference"] sessions: NotRequired["RoleGroupPreference"] comments: NotRequired["CommentsGroupPreference"] + signMessages: NotRequired["GroupPreference"] commands: NotRequired[list["ChatBotCommand"]] class GroupProfile(TypedDict): @@ -2244,6 +2246,20 @@ MsgReceiptStatus = Literal["ok", "badMsgHash"] MsgSigStatus = Literal["verified", "signedNoKey"] +class MsgVerified_signed(TypedDict): + type: Literal["signed"] + sigStatus: "MsgSigStatus" + +class MsgVerified_sigMissing(TypedDict): + type: Literal["sigMissing"] + +class MsgVerified_unsigned(TypedDict): + type: Literal["unsigned"] + +MsgVerified = MsgVerified_signed | MsgVerified_sigMissing | MsgVerified_unsigned + +MsgVerified_Tag = Literal["signed", "sigMissing", "unsigned"] + class NameErrorType_NO_RESOLVER(TypedDict): type: Literal["NO_RESOLVER"] diff --git a/plans/2026-06-04-channel-message-signing.md b/plans/2026-06-04-channel-message-signing.md index f3828018b9..be7cc53a92 100644 --- a/plans/2026-06-04-channel-message-signing.md +++ b/plans/2026-06-04-channel-message-signing.md @@ -1,233 +1,316 @@ # Plan: optional signing of channel content messages (`XMsgNew` / `XMsgUpdate`) +Anchored on branch `f/msg-signing` (master merged in); all symbols re-verified 2026-06-25 โ€” see **ยงVerification status** at the end for the current line map and the few mislabeled anchors. Re-confirm by symbol before editing; line numbers are advisory. The public-groups roster already provides everything this builds on: `GroupKeys {publicGroupId, memberPrivKey}` (`Types.hs:465`), `verifyGroupSig` (`Subscriber.hs:116`), per-member public-key distribution via the signed roster, and optional signing of group-state events (`requiresSignature`, `Protocol.hs:1334`). Content messages are *not* signed today. + +**PR 1** (this plan): a member can optionally sign their own channel posts and edits so recipients holding the signed roster can verify authorship + integrity; and โ€” once an item is held signed โ€” its edits and deletes are **enforced** to be signed (an unsigned mutation of a signed item is rejected at receive), closing the edit/delete-downgrade spoof at the source. The remaining hard part, history signature preservation (so catch-up members also hold posts signed), is **PR 2** (at the end). + ## Goal / user problem -In relay-based channels, content (`XMsgNew`) is forwarded by relays and is **not** signed today (only group-state events are โ€” `requiresSignature`, `Protocol.hs:1251`), so a relay can forge or alter content attributed to a member. This feature lets a member *optionally* attach their member signature, so recipients holding the (signed) roster can verify authorship + integrity. +In relay channels, content (`XMsgNew`) is forwarded by relays and is not signed โ€” only group-state events are. A relay can therefore forge or alter content attributed to a member. This feature lets a member optionally attach their member signature; recipients with the roster verify it. -Decisions: -- **UI: both** โ€” device-stored default ("sign my channel messages", off) + per-send long-press override (mirrors custom disappearing-message TTL). -- **Default: off**, with an in-UI tradeoff explanation (signing = non-repudiable, transferable proof of authorship). -- **Recipient indicator: in scope** (iOS + Kotlin) โ€” signing is useless if invisible to readers. -- **Event scope: `XMsgNew` + `XMsgUpdate` only**; edits reuse the original's setting. `XMsgReact`/`XMsgDel` stay unsigned in v1. +## Decisions -## Prerequisites / sequencing - -Lands after #7017 (signed roster) and #7048 (roster over inline files; `GRMember` role). Neither merged yet (branch `f/allow-sign-new-msg`; `git log` tops at #7043). Dependency is specific: *verification* needs the sender's member public key, distributed via the roster; without it a signed message degrades to `MSSSignedNoKey` rather than `MSSVerified`. Integration tests must use the roster/channel setup from those PRs. - -**Line numbers are pre-rebase** (grounded against #7043); #7017/#7048 shift every anchor, so **re-locate by symbol**. The dependency PRs add no 6th `updateGroupChatItem` caller, but other branches are queued (`f/channel-comments`, `f/public-groups-members-in-roster`) โ€” hence the caller re-check gate below. - -## What already exists (so the change stays small) - -Wire format, signing, verification, DB persistence, and CLI display are present and reused unchanged: -- **Send signing:** `groupMsgSigning` (`Internal.hs:1963`) โ†’ `createSndMessages` threads `Maybe MsgSigning` (`:1950`) โ†’ `createNewSndMessage` Ed25519-signs `encodeChatBinding CBGroup (publicGroupId, memberId) <> msgBody`, storing `SignedMsg` in `SndMessage.signedMsg_` (`Store/Messages.hs:234`; `Messages.hs:1156`). -- **Wire:** `batchMessages` prepends the signature via `encodeBatchElement` (`Batch.hs:46,65`); relay groups always batch (`memberSendAction` โ†’ only `MSASendBatched` under `useRelays'`, `Internal.hs:2222,2228`). -- **Receive verify:** `withVerifiedMsg` (`Subscriber.hs:3469`) runs for all group messages (`:1004`, forwarded `:3431`); `XMsgNew_`/`XMsgUpdate_` โˆ‰ `requiresSignature` โ‡’ `signatureOptional` (`:3491`), so signed โ†’ `MSSVerified`/`MSSSignedNoKey`, unsigned โ†’ accepted. **No protocol-version bump.** -- **Sent-item persistence:** `createNewSndChatItem` sets `msgSigned = MSSVerified <$ signedMsg_` (`Store/Messages.hs:550`) โ€” own item auto-marked, readable by the edit path. -- **Received-item persistence:** `createNewRcvChatItem` records `RcvMessage.msgSigned` (`Store/Messages.hs:565,567`); `CIMeta.msgSigned :: Maybe MsgSigStatus` (`Messages.hs:520`). -- **CLI:** `sigStatusStr` (`View.hs:388`) appends `" (signed)"` / `" (signed, no key to verify)"`. - -Missing: (1) the *decision* to sign content (`groupMsgSigning` returns `Nothing` for content today); (2) per-send plumbing from the API; (3) reuse on edit; (4) the ยง7 stale-badge fix; (5) the ยง5 anonymity gate (HIGH); (6) the apps. +- UI: **per-send long-press override only** โ€” no device-stored default preference. Signing is opt-in for each send. +- Default off, with an in-UI explanation of the tradeoff (a signature is transferable, non-repudiable proof of authorship). +- Recipient indicator in scope (iOS + Kotlin), **chat view only** โ€” not the conversation list. Glyph: `checkmark.seal`. +- Scope: `XMsgNew` + `XMsgUpdate` + `XMsgDel`, **including as-channel posts** (see ยง5 โ€” signing an as-channel post is verifiable and de-anonymizing, a deliberate team-accepted tradeoff). Edits sign iff the original was signed; deletes sign per-item (self-delete iff the target was signed; moderation/admin delete always-signs โ€” ยง7). Mutations of a held-signed item are enforced to be signed (ยง7). Reactions stay unsigned. ## Threat model -Actors: member (sender), recipients, and **chat relays** that forward content + roster. Relays are untrusted for content authenticity. +Actors: the sending member, recipients, and untrusted **chat relays** that forward content + roster. -- **Forgery of member content.** Signing closes it for signed messages: relay lacks the Ed25519 key; signature binds `(publicGroupId, memberId, body)` โ€” no forgery, cross-bind, or alteration. -- **Downgrade / stripping (residual, by design).** Optional signing lets a relay strip a signature and deliver unsigned. Absence of a badge is **not** proof of forgery โ€” only *presence* of a verified badge is a guarantee. A future "required signing" group setting would close it; out of scope. -- **Stale-badge spoof on edits (fixed โ€” ยง7).** An in-place edit must not keep a `verified` badge over content from an unsigned, relay-forged `XMsgUpdate`. -- **Publish-as-channel de-anonymization (structurally prevented โ€” ยง5).** Channels allow "publish as the channel" (`showGroupAsSender`/`asGroup`): subscribers see a post as *from the channel*, not the specific owner (Design Objective 6, `docs/protocol/channels-overview.md:214`); today a relay revealing the owner is only a *deniable* leak (`channels-overview.md:~237`). `groupMsgSigning` (`Internal.hs:1963-1967`) is blind to `showGroupAsSender`, so it would sign with binding `(publicGroupId, ownerMemberId)`, broadcast on the wire even for `FwdChannel` (`encodeFwdElement` โ†’ `encodeBatchElement signedMsg_`, `Batch.hs:108`). A malicious relay sets the live-forward `fwdSender` freely (it is derived from stored `sentAsGroup`, `Store/Delivery.hs:158`), so every subscriber verifies it as `MSSVerified` โ€” turning the deniable leak into **non-repudiable proof** of which owner authored an intentionally anonymous post; the device-default toggle would trigger this silently. For an anonymity property this must be structurally impossible: signing is never applied to as-channel content (ยง5), the app option is hidden for as-channel sends (ยงC), and a defense-in-depth guard keeps `encodeFwdElement` signature-free for `FwdChannel` (Edge cases). (`processContentItem:1302` is the *history* path and rebuilds content unsigned โ€” not the vector.) -- **Non-repudiation (tradeoff, by design).** A verified signature is transferable proof of authorship โ€” a deniability loss; hence opt-in/off-by-default with UI explanation. For *as-channel* posts the loss is unacceptable, not a tradeoff โ€” hence the ยง5 exclusion. -- **What "verified" means.** Signed input is `encodeChatBinding CBGroup (publicGroupId, memberId) <> msgBody`, with `msgBody` embedding `sharedMsgId`, `MsgScope`, content (`Store/Messages.hs:242`). It proves **authorship + integrity + group/member/scope/message binding** โ€” and nothing else: not `fwdBrokerTs` (relay-controlled, `Protocol.hs:382-387`), ordering, or completeness. Surface this in UI/help. -- **Signed content is still relay-suppressible.** `XMsgDel_` โˆ‰ `requiresSignature` (`Protocol.hs:1252-1262`), so an unsigned relay-forged owner-attributed delete is accepted (role-based check vs. the relay-chosen author, `Subscriber.hs:~2269`). Pre-existing, within the relay's drop power; bounds signing's value (proves *what was said*, not that all is delivered). -- **Replay.** Binding covers `sharedMsgId` + `MsgScope`; cross-scope/group replay is blocked, same-message replay is a dedup duplicate. -- **Bad-signature spam (fail-closed, pre-existing).** Failed verification drops content with an `RGEMsgBadSignature` item per occurrence (`Subscriber.hs:3473-3475,3483`); a tampering relay can spam these. Inherited from state-event behavior. +- **Forgery of member content** โ€” closed for signed messages: the relay lacks the Ed25519 key, and the signature binds `(publicGroupId, memberId, body)`, so no forgery, cross-bind, or alteration. +- **Downgrade / stripping** (residual, narrowed) โ€” optional signing lets a relay strip the signature from an *original* post and deliver it unsigned (the recipient never holds it signed, so nothing is enforced). Absence of a badge is *not* proof of forgery; only the presence of a verified badge is a guarantee. Once a recipient holds an item signed, its edits/deletes are enforced (next bullet), so the residual is now only the original-delivery case. A future "required signing" group setting would close even that (out of scope). +- **Signed-mutation enforcement** (fail-closed) โ€” an `XMsgUpdate`/`XMsgDel` targeting an item the recipient holds signed (`msgSigned = Just _`) MUST itself carry a verifying signature; an unsigned mutation is rejected (drop + `RGEMsgBadSignature`). Closes the edit/delete-downgrade spoof (a relay forging an unsigned edit/delete to overwrite or censor a signed post) at the source. The legitimate sender produces the required signature: edits sign iff the original was signed; self-deletes iff the target was signed; moderation deletes always sign (ยง7). Coverage is for items the recipient already holds signed โ€” catch-up members holding the post unsigned are outside it until PR 2 history preservation. +- **As-channel posts: anonymity for unsigned, accepted de-anonymization for signed.** An owner can "publish as the channel"; Design Objective 6 (`docs/protocol/channels-overview.md:214`) hides *which* owner authored a post from subscribers, and owners are "cryptographically indistinguishable to subscribers" (`:159`). This anonymity holds for **unsigned** as-channel posts: they forward via `FwdChannel` (no `memberId`), and a relay revealing the owner is only a deniable, detectable leak (`:237`). **Signing** an as-channel post is opt-in and deliberately gives it up: to be verifiable it forwards via `FwdMember` (ยง5), so every subscriber's device receives, verifies, and holds non-repudiable proof of the authoring owner. The owner is trading anonymity + deniability (`:198`, `:221`, `:103`) for verifiability on that post; the UI must say so. Verifiable-*and*-anonymous (ring signature / channel-level key) is out of scope. +- **Non-repudiation** (tradeoff, by design) โ€” a verified signature is transferable proof of authorship; hence opt-in/off. +- **What "verified" proves** โ€” the signed input is `encodeChatBinding CBGroup (publicGroupId, memberId) <> msgBody`, and `msgBody` embeds `sharedMsgId`, `MsgScope`, `asGroup`, content. It proves authorship + integrity + group/member/scope/message binding, and nothing else โ€” not `fwdBrokerTs` (relay-controlled), ordering, or completeness. Surface in help. +- **Bad signature is fail-closed** โ€” a signature that fails to verify drops the message and creates an `RGEMsgBadSignature` item (`Subscriber.hs:3828`). Member keys do **not** rotate (communicated once on join, no rotation planned), so a have-the-key-but-mismatch can only mean forgery, tampering, or corruption โ€” a genuine signal, and dropping is correct. New consequence for content (higher-volume, user-visible): such a message is **dropped**, not shown unsigned, exactly as state events already behave. The lagging-roster case is *not* a drop โ€” if the recipient's roster lacks the author's key, that is the `MSSSignedNoKey` path (accepted, shown without badge), not the mismatch path. So there is no honest false-positive for the drop. Needs an edge-case test and a help note. +- **As-channel spoofing** โ€” because signed as-channel posts arrive as `FwdMember`, the recipient MUST verify the (verified) author is an owner before rendering as-channel (ยง5); otherwise a non-owner's signed `asGroup=True` post would display as "from the channel". +- **Replay** โ€” the binding covers `sharedMsgId` + `MsgScope`; cross-scope/group replay is blocked, same-message replay is a dedup duplicate. + +## What already exists (reused unchanged) + +- **Send / sign**: `groupMsgSigning` (`Internal.hs:2110`) โ†’ `createSndMessages` threads `Maybe MsgSigning` โ†’ `createNewSndMessage` Ed25519-signs `encodeChatBinding CBGroup (publicGroupId, memberId) <> msgBody`, storing `signedMsg_` in `SndMessage` (`Messages.hs:1156`). +- **Wire**: `batchMessages` prepends the signature via `encodeBatchElement` (`Batch.hs:45,69`); relay groups always batch. +- **Forward**: live delivery preserves the original signed bytes by reconstructing `VMSigned` from the stored `msg_chat_binding`/`msg_signatures` (`Store/Delivery.hs:155-165`); `fwdSender` is derived from the stored `showGroupAsSender` (`:158`). +- **Receive / verify**: `withVerifiedMsg` (`Subscriber.hs:3819`) wraps member-authored messages (non-forwarded path `:1037`, forwarded `FwdMember` path `:3780`). `XMsgNew_`/`XMsgUpdate_`/`XMsgDel_` are not in `requiresSignature` โ‡’ `signatureOptional` (`:3848`): signed โ‡’ `MSSVerified` (key present) / `MSSSignedNoKey` (no roster key), unsigned โ‡’ accepted (ยง7 adds the held-signed enforcement on top). `FwdMember` verifies against the author's key (`verifyGroupSig`, `:3833`); `FwdChannel` is delivered as `VMUnsigned` (`:3783`). No protocol-version bump. +- **Persistence**: own item โ€” `createNewSndChatItem` sets `MSSVerified <$ signedMsg_` (`Store/Messages.hs:548`); received item โ€” `RcvMessage.msgSigned` (`Messages.hs:1174`) is stored by `createNewRcvChatItem` (`Store/Messages.hs:563`); `CIMeta.msgSigned` (`Messages.hs:520`). +- **CLI**: `sigStatusStr` (`View.hs:389`) renders "(signed)" / "(signed, no key to verify)". + +Missing: (1) the decision to sign content; (2) per-send plumbing from the API; (3) as-channel forward/display/guard (ยง5); (4) edit reuse; (5) the badge fix (ยง7); (6) the apps. ## Core changes (Haskell) -### 1. Signable-content predicate +### 1. `signableContent` predicate + +Next to `requiresSignature` (`Protocol.hs:1334`): -`Protocol.hs`, next to `requiresSignature` (`:1251`): ```haskell --- | Content events whose authorship a member may optionally prove by signing. +-- | Content events a member may sign (XMsgNew opt-in; XMsgUpdate/XMsgDel when the target was signed). signableContent :: CMEventTag e -> Bool signableContent = \case XMsgNew_ -> True XMsgUpdate_ -> True + XMsgDel_ -> True _ -> False ``` -### 2. Signing decision carries the opt-in +### 2. Signing decision takes the opt-in flag + +`groupMsgSigning` (`Internal.hs:2110`) gains a leading `Bool` โ€” it stays blind to `showGroupAsSender` (as-channel posts sign with the owner's `CBGroup` binding like any member post): -Named type near `MsgSigning` (`Protocol.hs:426`) โ€” not a bare `Bool`: ```haskell --- | Whether opt-in content signing applies to this group send. --- Independent of mandatory state-event signing (requiresSignature), --- which always applies in relay groups regardless of this value. -data ContentSig = SignContent | DontSignContent - deriving (Eq, Show) -``` -Extend `groupMsgSigning` (`Internal.hs:1963`): -```haskell -groupMsgSigning :: ContentSig -> GroupInfo -> ChatMsgEvent e -> Maybe MsgSigning -groupMsgSigning csig gInfo@GroupInfo {membership = GroupMember {memberId}, groupKeys = Just GroupKeys {publicGroupId, memberPrivKey}} evt +groupMsgSigning :: Bool -> GroupInfo -> ChatMsgEvent e -> Maybe MsgSigning +groupMsgSigning sign gInfo@GroupInfo {membership = GroupMember {memberId}, groupKeys = Just GroupKeys {publicGroupId, memberPrivKey}} evt | useRelays' gInfo && shouldSign = Just $ MsgSigning CBGroup (smpEncode (publicGroupId, memberId)) KRMember memberPrivKey where tag = toCMEventTag evt - shouldSign = requiresSignature tag || (csig == SignContent && signableContent tag) + shouldSign = requiresSignature tag || (sign && signableContent tag) groupMsgSigning _ _ _ = Nothing ``` -- `useRelays'`/`groupKeys = Just` guards unchanged: in non-relay groups or keyless members, `SignContent` is a no-op (`Nothing`). -- Mandatory state-event signing unaffected (`requiresSignature` branch preserved). -### 3. Thread `ContentSig` through the send functions +Three call sites โ€” all but the content/edit chain pass `False`: `sendGroupMemberMessages` (`Internal.hs:2119`, hardcode `False`), `sendGroupMessages_` (`:2364`, passes its threaded flag), and the direct `XGrpLeave` send (`Commands.hs:3019`, `False`). -`groupMsgSigning` is called only in `sendGroupMessages_` (`Internal.hs:2134`) and `sendGroupMemberMessages` (`:1972`). Add a `ContentSig` param to `sendGroupMessages_` (`:2132`, used in `idsEvts`), `sendGroupMessages` (`:2100`, pass-through), `sendGroupMessage` (`:2088`, pass-through). Keep `sendGroupMessage'` (`:2094`) and `sendGroupMemberMessages` (`:1969`) unchanged by hardcoding `DontSignContent` internally. +### 3. Thread `sign :: Bool` through the send functions -Behavior-preserving (all existing callers pass `DontSignContent`) โ‡’ its own commit. Call sites to pass `DontSignContent` (grep-verified): -- `sendGroupMessages`: `Subscriber.hs:1370`; `Commands.hs:793,800,2778,2909`. -- `sendGroupMessage`: `Commands.hs:889,2690,3272,3812,3815,3819`. -- `sendGroupMessages_` direct: `Commands.hs:2826,3849`. +Add the flag to `sendGroupMessages` (`Internal.hs:2329`), `sendGroupMessages_` (`:2362`), `sendGroupMessage` (`:2255`), and the content wrappers `sendGroupContentMessages` / `sendGroupContentMessages_` (`Commands.hs:4430` / `:4439`). Keep `sendGroupMessage'` (`Internal.hs:2261`) and `sendGroupMemberMessages` (`:2116`) unchanged by hardcoding `False`. -The two variable-`ContentSig` sites are the feature (next commit): content send (`Commands.hs:4405`) and group edit (`Commands.hs:732`). +Behavior-preserving (every existing caller passes `False`) โ‡’ its own commit. The only two variable-flag sites are content send (`Commands.hs:4469`) and edit (`:751`). Other callers pass `False`: `sendGroupMessages` โ€” `Commands.hs:812,819,2821,2958`; `sendGroupMessages_` โ€” `Commands.hs:2869,3912`; `sendGroupMessage` โ€” `Commands.hs:908,2721,3327,3875,3878,3882`. + +`sendGroupContentMessages` has three callers, all reached via ยง4 except the first: the content-send handler (`Commands.hs:667`, real flag), `APIReportMessage` (`:698`, `False`), and `APIForwardChatItems` (`:994`, `False`). + +`Bool`-choice note: `sign` joins `showGroupAsSender :: ShowGroupAsSender (= Bool)` and `live :: Bool` in `sendGroupContentMessages`/`_`. Place `sign` away from the other two in each signature (e.g. after `itemTTL`) to reduce silent transposition. ### 4. API: per-send `sign` flag -Add a field to `APISendMessages` (`Controller.hs:332`): +Add a field to `APISendMessages` (`Controller.hs:382`): + ```haskell | APISendMessages {sendRef :: SendRef, liveMessage :: Bool, ttl :: Maybe Int, signMessages :: Bool, composedMessages :: NonEmpty ComposedMessage} ``` -Parser (`Commands.hs:5006`), mirroring `liveMessageP`/`sendMessageTTLP`, defaulting off so old command strings still parse: + +Parser (`Commands.hs:5094`), defaulting off so old command strings still parse: + ```haskell "/_send " *> (APISendMessages <$> sendRefP <*> liveMessageP <*> sendMessageTTLP <*> signMessagesP <*> (" json " *> jsonP <|> " text " *> composedMessagesTextP)) --- with: signMessagesP = " sign=" *> onOffP <|> pure False (place after sendMessageTTLP, before " json ") +-- signMessagesP = " sign=" *> onOffP <|> pure False (after sendMessageTTLP) ``` -Wire: `/_send live=.. ttl=.. sign=on|off json ...`. Per-send granularity (like `ttl`), not per-`ComposedMessage`. API boundary (appโ†”core, same bundle) โ‡’ not a protocol-compat concern. -### 5. Content send path +The nine internal positional constructors of `APISendMessages` must gain the field (`False`): `Commands.hs:2394,2403,2423,2443,2451,2496,3238,3279,3288`. Compiler-caught, part of the behavior-preserving commit. + +The handler (`Commands.hs:654-667`) flows `signMessages` to `sendGroupContentMessages` for group sends and ignores it for direct sends. `APIReportMessage` (`:693-698`) passes `False`. + +### 5. As-channel signing: forward with member id, display from `asGroup`, enforce owner + +An owner may sign an as-channel post (no gate on `showGroupAsSender`). Three pieces make it verifiable while displaying as the channel: + +- **Forward (`Store/Delivery.hs:158`)**: route signed as-channel posts via `FwdMember` so subscribers can verify; keep unsigned as-channel anonymous via `FwdChannel`: + ```haskell + fwdSender = if showGroupAsSender && isNothing chatBinding_ then FwdChannel else FwdMember senderMemberId senderMemberName + ``` + (`chatBinding_`/`sigs_` are already in scope here for `verifiedMsg`; `isNothing chatBinding_` โ‡” unsigned. Non-as-channel posts already use `FwdMember`, unchanged.) +- **Display**: the recipient already derives "as channel" from the signed `asGroup` flag, independent of `fwdSender` โ€” `newGroupContentMessage` `sentAsGroup = asGroup_ == Just True` (`Subscriber.hs:2177`), `groupMessageUpdate` `showGroupAsSender = fromMaybe (isNothing m_) asGroup_` (`:2235`). So a `FwdMember` + `asGroup=True` post verifies against the owner and renders as the channel with the verified badge. +- **Owner guard (security, MUST)**: the forwarded `XMsgNew` path (`xGrpMsgForward` `Subscriber.hs:3771` โ†’ `FwdMember` branch `:3775` โ†’ `withVerifiedMsg` `:3784` โ†’ `newGroupContentMessage` `:3795`) MUST reject as-channel display unless the verified author is an owner โ€” parity with `groupMessageUpdate`'s owner guards (`:2236` send-time, `:2282` store-time) and the direct send path's `checkSendAsGroup` (`Subscriber.hs:1057`, `asGroup == Just True && memberRole' m'' < GROwner โ‡’ messageError`); reuse the `validSender โ€ฆ CIChannelRcv โ‡’ GROwner` pattern (`:2135`). Without it a non-owner's signed `asGroup=True` post renders as "from the channel". The guard is reliable for *legitimate* owner posts: `GROwner` identity is established from root-key-verifiable link data at connect time (`createLinkOwnerMember`, `Store/Groups.hs:3381`), and `isRosterRole` (`Internal.hs:1257`) excludes `GROwner` โ€” so owner identity is never carried by the roster or `XGrpMemRole` and is therefore independent of the known role-propagation issue. Single owner today. +- **Invariant**: `FwdChannel` never carries a signature (signed posts always route via `FwdMember`). Assert this in `encodeFwdElement` (`Batch.hs:108`) as a regression guard. + +`sendGroupContentMessages_` passes the API `sign` straight through (no `&& not showGroupAsSender` gate). The owner's own as-channel item is marked signed/verified like any signed send. + +### 6. Edit reuse (`XMsgUpdate`) + +Group edit, `Commands.hs:739-757`. The own sent item is loaded with `CIMeta` (`:739`); add `msgSigned` to the pattern and reuse it: -`sendGroupContentMessages` (`Commands.hs:4366`) and `sendGroupContentMessages_` (`:4375`) gain a `ContentSig` param. `showGroupAsSender` is in scope at the send site (`:4405`); **as-channel posts are never signed** (anonymity gate โ€” see threat model): ```haskell -let csig' = if showGroupAsSender then DontSignContent else csig -(msgs_, gsr) <- sendGroupMessages user gInfo Nothing showGroupAsSender recipients csig' chatMsgEvents +let reuseSign = isJust msgSigned +SndMessage {msgId, signedMsg_} <- sendGroupMessage user gInfo scope recipients reuseSign event ``` -This gate is structural (must live here, not only in UI); it also keeps the sender's own as-channel item unsigned and keeps ยง6 edit-reuse consistent. -- `APISendMessages` handler (`:637-650`): `signMessages` โ†’ `SignContent`/`DontSignContent`, passed down (both `SRGroup` and `SRDirect`; direct ignores it โ€” `sendContactContentMessages` doesn't sign). The `:4405` gate then forces `DontSignContent` for as-channel sends regardless of the flag. -- `APIReportMessage` (`:679`): `DontSignContent` (reports unsigned in v1). +For own sent items, `msgSigned` is `Just MSSVerified` iff signed (`createNewSndChatItem`, `Store/Messages.hs:548`), so `isJust` is the right test. An edit is signed exactly when the original was โ€” now **mandatory**, not optional: recipients enforce it (ยง7). The author's own item is authoritative, so the author always emits a signature exactly when recipients require one (no divergence). Direct/local edits need no change. -### 6. Edit / restore reuse (the `XMsgUpdate` requirement) +### 7. Enforcement: a held-signed item's mutations must be signed (security) -Group edit, `Commands.hs:710-742`. Own sent item loaded with `CIMeta` at `:720`; add `msgSigned` to the pattern and reuse it: -```haskell -... meta = CIMeta {itemSharedMsgId, itemTimed, itemLive, editable, showGroupAsSender, msgSigned} -... -let reuseSig = if isJust msgSigned then SignContent else DontSignContent -SndMessage {msgId} <- sendGroupMessage user gInfo scope recipients reuseSig event -``` -`msgSigned` is loaded via `mkCIMeta`/`toGroupChatItem` (`Store/Messages.hs:2412`); for own items it is `Just MSSVerified` iff signed (`createNewSndChatItem` stores only `MSSVerified <$ signedMsg_`, `:550`), so `isJust` is the right test. This makes an edit (including the recipient-deleted-restore case) signed exactly when the original was; it is automatically consistent with ยง5 (as-channel originals are never signed โ‡’ edits stay unsigned). +**Replaces the earlier "badge refresh" approach.** Principle: if a recipient holds an item as signed (`msgSigned = Just _`), any subsequent `XMsgUpdate` or `XMsgDel` targeting it MUST itself carry a verifying signature; an unsigned mutation of a signed item is **rejected** (drop, reusing `RGEMsgBadSignature`), not applied. This closes the edit/delete-downgrade spoof at the source (fail-closed) instead of applying attacker content and relabelling the badge. -Direct edit (`:697-704`) and local edit (`:745`) need no change (never signed). +`updateGroupChatItem` / `updateGroupChatItem_` (`Store/Messages.hs:2749`/`:2758`) and their five callers are left **unchanged** โ€” no `Maybe MsgSigStatus` param, no `msg_signed` in the `UPDATE`, no override of `ci'`. A legitimate enforced-signed edit keeps the original `MSSVerified`, so the badge stays correct without a refresh. (The one thing a refresh would have added โ€” reflecting a rare `MSSSignedNoKey โ†’ MSSVerified` transition after a lagging roster catches up โ€” is deliberately dropped.) -### 7. Security fix: refresh `msg_signed` on in-place content update +**Receive check (both paths; the data is already in scope):** -**Finding:** `updateGroupChatItem_` (`Store/Messages.hs:2755`) updates content/status/timed fields but **not `msg_signed`** (`UPDATE` at `:2760-2767`); `updatedChatItem` (`:2749`) carries the original `meta.msgSigned`. Today invisible (content never signed); once content is signed and badged, an in-place edit from an **unsigned, relay-forged `XMsgUpdate`** would keep a stale `MSSVerified` badge over attacker content. +- **Update** โ€” `groupMessageUpdate` (`Subscriber.hs:2225`): it already loads the target item (`:2277`) and has the incoming `RcvMessage` (`:2226`). Before applying the update, if the target's `meta.msgSigned` is `Just _` and the incoming `rcvMsg.msgSigned` is `Nothing`, reject (`messageError` โ†’ drop โ†’ `RGEMsgBadSignature`). +- **Delete** โ€” `groupMessageDelete` (`Subscriber.hs:2307`): the target `CChatItem` from `findItem` (`:2357`) carries `meta.msgSigned`; add `msgSigned` to the `RcvMessage` pattern (`:2356`) to get the incoming verdict. Same check. **One site covers self-delete, moderation, and forwarded delivery** (all route through `groupMessageDelete`). It composes with โ€” does not touch โ€” the existing `checkRole` WHO-gate (`:2382`): a moderation delete verifies against the *moderator* (its sender) via `withVerifiedMsg`, and `checkRole` independently enforces authority. -**Why pass it in:** the `MSSVerified` vs `MSSSignedNoKey` outcome is computed at receive by `withVerifiedMsg` and lives only on the chat item; the stored `messages` row holds signature bytes but not the verification *outcome*. So the status must come from receive-time `RcvMessage.msgSigned`, not be re-derived. +"Signed" for the requirement means `MSSVerified` **or** `MSSSignedNoKey` (the sender signed); only `Nothing` (unsigned) is rejected. Since keys do not rotate, an item held `MSSVerified` verifies its mutations too, so this never spuriously rejects a legitimate live-held mutation. -**Fix (contained to the group helper):** add a `Maybe MsgSigStatus` param to `updateGroupChatItem` (`:2746`); after `let ci' = updatedChatItem โ€ฆ` (`:2749`) override `ci'`'s `meta.msgSigned`, and add `msg_signed = ?` to `updateGroupChatItem_`'s `UPDATE` (`:2755`/`:2760-2767`). `updateGroupChatItem_` is called *only* from `updateGroupChatItem` (grep), so this is self-contained. **Leave `updatedChatItem` (`:2544`) unchanged** โ€” it serves the unsigned direct/local paths (`:2540`, `:3210`). +**Send side โ€” produce the required signature so legitimate mutations are accepted:** -All **five** callers pass an explicit value (no implicit "preserve"): -- `Commands.hs:738` (sender edit): `MSSVerified <$ signedMsg_` from the returned `SndMessage` (mirrors `:550`; equals the reused setting). -- `Subscriber.hs:2212` (recipient in-place edit โ€” *the spoof path*): `msgSigned` from the handler's `RcvMessage msg`. Unsigned forged edit โ‡’ `Nothing` โ‡’ badge removed; verified โ‡’ kept. -- `Subscriber.hs:2172` (recipient restore in-place, after `saveRcvChatItem'`): same `msgSigned` from `msg`. -- `Subscriber.hs:1152` (`mdeUpdatedCI` decryption-error marker): `Nothing` โ€” local marker, badge correctly cleared. -- `Subscriber.hs:1509` (`upsertBusinessRequestItem` business-chat welcome): `Nothing` โ€” never a relay channel, safely preserves `Nothing`. (Sibling direct path `:1480` uses `updateDirectChatItem'`, unaffected.) +- **Edits** (`XMsgUpdate`): ยง6 already signs the edit iff the original was signed (`reuseSign = isJust msgSigned`); under enforcement this is mandatory. The author's own item is authoritative, so the author emits a signature exactly when recipients require one โ€” no divergence. +- **Deletes** (`XMsgDel`) โ€” per-item, because deletes batch a heterogeneous set of targets (`Commands.hs:811/818/3909`) and signing is currently keyed off event *type*, not instance. Compute the signer per delete at the send sites (the target `items` and their `msgSigned` are already in scope) and thread it in. **Do not** add `XMsgDel_` to `requiresSignature` (over-signs, ignores the per-item condition). Generalize the batch send to carry a per-event `Maybe MsgSigning` rather than recomputing one `groupMsgSigning` per batch โ€” parameterize the existing body, do **not** duplicate it; keep a uniform-sign wrapper so non-delete callers are unchanged (this generalization lands in commit 1, behavior-preserving). Two cases: + - **Self-delete** (`memberId = Nothing`, `Commands.hs:811/818`): sign iff the deleter's own copy of the target was signed. The self-deleter's view is authoritative, so signed-holders and catch-up-unsigned-holders both accept โ€” no divergence; and it preserves the deniability choice (an unsigned, deniable post's self-delete stays unsigned/deniable). + - **Moderation/admin delete** (`memberId = Just`, `Commands.hs:3909`): **always sign** in relay channels. A moderator who holds the target unsigned (joined late, caught up via unsigned history) would otherwise emit an unsigned delete that members holding the post signed would reject โ€” silently failing moderation. Moderation deletes already carry the target `memberId` (attributable), so always-signing costs no deniability and removes the divergence. -Net: signed status is set explicitly from the source of current content in every group create/update path, so a stale badge cannot exist. +`signableContent` (ยง1) includes `XMsgDel_` so `groupMsgSigning sign โ€ฆ` produces a signer when the per-item/per-action decision is to sign. ### 8. Paths deliberately left unsigned -- Auto-reply welcome content (`Subscriber.hs:1267` `XMsgUpdate`, `:1269` `XMsgNew`) via `sendGroupMessage'` โ‡’ `DontSignContent`. -- `XMsgReact` (`Commands.hs:889`), `XMsgDel` (`Commands.hs:792-799`): unsigned in v1. Asymmetry: a post is verifiable, its reactions/deletes are not โ€” and a signed post is still relay-suppressible (threat model). Later, extending `signableContent` could let recipients reject unsigned deletes of signed posts. +- Reactions (`XMsgReact`, `Commands.hs:908`): pass `False` โ€” never signed (a reaction does not mutate the post's content/integrity and is not enforced). +- Auto-reply welcome content via `sendGroupMessage'` โ‡’ `False`. +- Deletes are signed conditionally and enforced (ยง7) โ€” no longer left unsigned. ## App changes (iOS + Kotlin) -### A. Decode the signature status -- **JSON tags:** core uses `enumJSON (dropPrefix "MSS")` โ‡’ `MSSVerified โ†’ "verified"`, `MSSSignedNoKey โ†’ "signedNoKey"` (lower-cases first letter). **Not** the DB/text strings (`"verified"`/`"no_key"`). -- iOS: `enum MsgSigStatus: String, Decodable { case verified, signedNoKey }`; add `public var msgSigned: MsgSigStatus?` to `CIMeta` (`apps/ios/SimpleXChat/ChatTypes.swift:3721-3737`). -- Kotlin: `@Serializable enum class MsgSigStatus { @SerialName("verified") Verified, @SerialName("signedNoKey") SignedNoKey }`; add `val msgSigned: MsgSigStatus? = null` to `CIMeta` (`apps/multiplatform/.../model/ChatModel.kt:3434-3450`). -- Optional field โ‡’ backward-safe decode of old core JSON. +Locate by symbol โ€” app line numbers drift independently. -### B. Device preference (default off) -- iOS: `@AppStorage(DEFAULT_PRIVACY_SIGN_CHANNEL_MESSAGES) private var signChannelMessages = false` + toggle in `PrivacySettings.swift` (pattern: `protectScreen`, `:68-70`) with a non-repudiation footer. -- Kotlin: `val privacySignChannelMessages = mkBoolPreference(SHARED_PREFS_PRIVACY_SIGN_CHANNEL_MESSAGES, false)` (`SimpleXAPI.kt:314`; declarations near `:122-125`) + `SettingsPreferenceItem` in `PrivacySettings.kt` with explanation. -- App-side only (like `customDisappearingMessageTime`), not core `AppSettings`. +- **A. Decode the status.** JSON tags come from `enumJSON (dropPrefix "MSS")`: `MSSVerified โ†’ "verified"`, `MSSSignedNoKey โ†’ "signedNoKey"` โ€” not the DB strings "verified"/"no_key". Add an optional `msgSigned: MsgSigStatus?` to `CIMeta` on both platforms (iOS `ChatTypes.swift`; Kotlin `ChatModel.kt`), decoding `verified`/`signedNoKey`. Optional โ‡’ old core JSON decodes safely. +- **B. Composer long-press option + thread `sign` to the API.** No device preference โ€” the long-press is the only entry. Add `sign: Bool` to the send closure (default off) and a long-press item next to "Disappearing message" ("Sign message" / "Send without signing", iOS `SendMessageView.swift`; Kotlin `SendMsgView.kt`). Show it for any relay channel, gated on the app's existing relay-channel indicator (`useRelays'` โ€” the same signal that drives the as-channel composer). No key-derived flag is needed: every sendable member holds a signing key (the only keyless state, prepared/`GSMemUnknown`, is non-current and cannot send, so the composer is not available there). Confirm the app's `GroupInfo` exposes a member-agnostic relay-channel boolean (the as-channel toggle is owner-scoped; signing is offered to all members) โ€” if the only existing signal is owner-scoped, add a plain non-secret relay-channel boolean, not a `memberPrivKey`-derived one. It is shown for as-channel sends too, with an explicit note that signing an as-channel post reveals you as the author (ยง5 tradeoff). Append `sign=on|off` in `apiSendMessages` on both platforms. +- **C. Recipient indicator.** In the message meta row (`CIMetaView`, chat view only), show `checkmark.seal` when `meta.msgSigned == verified`, in the trust cluster next to `lock` and before the timestamp. iOS: append `statusIconText("checkmark.seal", color)` in `ciMetaText`. Kotlin: add an `Icon` branch in `CIMetaText` **and** the matching `iconSpace` branch in `reserveSpaceForMeta` (the in-file contract requires the two to match); add the matching seal vector to `MR.images`. Omit `signedNoKey` (only `.verified` is badged). Own signed items use the same glyph. Conversation list (`ChatPreviewView`) unchanged. Surface the "verified โ‰  timestamp/ordering/completeness" caveat in help. -### C. Composer option (per-send override) + thread `sign` to the API -- Change the send closure to `(_ ttl: Int?, _ sign: Bool?)` (iOS `SendMessageView.swift:21`; Kotlin `SendMsgView.kt:54`), `sign == nil` โ‡’ use device default; composer passes effective `sign = override ?? default`. -- Long-press item next to "Disappearing message" (iOS `SendMessageView.swift:224-247`; Kotlin `SendMsgView.kt:198-209`): "Sign message" (default off) / "Send without signing" (default on). -- **Gate visibility** on relay channel + membership has a signing key + **not as-channel** (the UI half of ยง5 โ€” never offer it for as-channel publication). If app `GroupInfo` lacks relay/key state, add a derived `memberSigningAvailable` boolean to its JSON; AND it with the composer's as-channel state. Mirror `timedMessageAllowed`. -- `apiSendMessages`: add `sign: Bool`, append `sign=on|off` โ€” iOS `ChatCommand.apiSendMessages` (`AppAPITypes.swift:48`, encode `:239`) + `SimpleXAPI.swift:545`; Kotlin `CC.ApiSendMessages` (`SimpleXAPI.kt:3676`, encode `:3867`) + `SimpleXAPI.kt:1097`. +## Compatibility -### D. Recipient indicator -- Show a "signed by author" indicator when `meta.msgSigned == .verified` in the meta row: iOS `CIMetaView.swift` `ciMetaText` (`:93-160`); Kotlin `CIMetaView.kt` `CIMetaText` (`:67-115`) + update `reserveSpaceForMeta` (`:118-175`) for icon width. -- `signedNoKey`: show muted or nothing so it isn't read as `verified` (design). Surface the "verified โ‰  timestamp/ordering/completeness" caveat (threat model) in help. -- Own signed items use the same indicator (core sets `MSSVerified` on signed sends). - -## Compatibility analysis -- **Protocol wire format:** unchanged; existing batch-element signature prefix. No `chatVRange` bump; pre-feature relay-capable peers verify/accept correctly. -- **API command:** `sign=` additive with default; app+core ship together. -- **DB:** no migration. `chat_items.msg_signed` exists (added `M20260222_chat_relays`; in both schema files; written by `createNewChatItem_:603`). -- **App JSON:** new optional `msgSigned` decodes as absent on older cores. +- Wire format unchanged (the batch-element signature prefix already exists); no `chatVRange` bump; pre-feature relay-capable peers verify/accept. +- API command `sign=` is additive with a default; app + core ship together. +- No DB migration โ€” `chat_items.msg_signed` already exists (written by `createNewChatItem_`, `Store/Messages.hs:614`; read by `mkCIMeta`). +- The new optional app `msgSigned` decodes as absent on older cores. ## Edge cases, races, correctness -- **Member without keys** (`groupKeys = Nothing`): `groupMsgSigning` returns `Nothing` even with `SignContent` โ‡’ silent unsigned send. UI gate should prevent offering it; document the silent degrade. -- **Non-relay groups:** `useRelays'` guard โ‡’ never signed; UI must not offer it. -- **Live messages:** initial `XMsgNew` then repeated `XMsgUpdate`, each reusing the item's `msgSigned` โ‡’ every increment signed. Extra cost per keystroke-batch; acceptable. -- **Separate (non-batched) path drops signatures** (`sndMessageMBR` uses raw `msgBody`, `Internal.hs:2199`, vs the batched path's `encodeBatchElement`). Never reached in relay groups (`memberSendAction` โ†’ `MSASendBatched`). Add a test-asserted invariant; optionally make `sndMessageMBR` use `encodeBatchElement signedMsg_` too, so routing changes can't silently drop channel signatures. -- **Defense-in-depth: no signature on `FwdChannel`.** `encodeFwdElement` (`Batch.hs:108`) includes `signedMsg_` unconditionally; ยง5 makes it `Nothing` for `FwdChannel` in normal flow. Add a guard/assertion that `encodeFwdElement` carries no signature when `fwdSender = FwdChannel`, so no future upstream path can reintroduce the de-anonymization. -- **History re-send strips signatures (badge non-determinism, by design).** Relay history catch-up rebuilds content via `prepareGroupMsg` into plain `XGrpMsgForward` events (`processContentItem`, `Internal.hs:1279-1305`) and lacks the private key โ‡’ unsigned. So for the same message, a live-forward recipient sees a badge while a history-catch-up recipient does not. Graceful (absence โ‰  forgery); document in UI/help and test. -- **Concurrency:** signing/verification are pure given keys; no new shared state. Send holds `withGroupLock`; receive update runs under existing receive-loop serialization. No new races. + +- **Bad signature โ†’ drop** (threat model): a signed content message whose signature doesn't verify against the recipient's roster key (forgery/tamper โ€” keys don't rotate) is dropped + `RGEMsgBadSignature`, not shown unsigned. Test it; note it in help. Distinct from ยง7 enforcement, which rejects an *unsigned* mutation of a *signed* item. +- **Member without keys** (`groupKeys = Nothing`): only the prepared/`GSMemUnknown` relay-channel state is keyless (`createPreparedGroup`, `Store/Groups.hs:654`, with the `TODO [member keys]` marker), and that state is non-current/non-active โ€” it cannot send content, so the composer is unavailable. Any sendable membership has `groupKeys = Just` (key written before the membership becomes current). `groupMsgSigning` returning `Nothing` for `groupKeys = Nothing` is thus a harmless backstop, never reached on a real content send. +- **Non-relay groups**: the `useRelays'` guard โ‡’ never signed; UI must not offer it. +- **Live messages**: each `XMsgUpdate` reuses the item's `msgSigned`, so every increment is signed iff the original was โ€” exactly what ยง7 enforcement requires. Acceptable cost. +- **Mutation enforcement (ยง7)**: a forged *unsigned* `XMsgUpdate`/`XMsgDel` of a held-signed item is rejected (`RGEMsgBadSignature`), content/visibility unchanged โ€” not applied-then-unbadged. A legitimate signed edit/delete of a signed item is accepted. Test both, on both paths. +- **Moderation-delete divergence**: a moderator holding the target *unsigned* (caught up via unsigned history) must still emit a *signed* delete so members holding the post signed accept it โ€” hence moderation always-sign (ยง7). Without it, moderation of a signed post silently fails for those members. Test a moderation delete from a catch-up moderator. +- **Non-batched path**: `sndMessageMBR` (`Internal.hs:2428`) uses raw `msgBody`, never reached in relay groups (`memberSendAction โ†’ MSASendBatched`). Add a test-asserted invariant; optionally route it through `encodeBatchElement signedMsg_`. +- **History downgrade (posts, by design this PR)**: relay history catch-up rebuilds content unsigned and as-channel via `FwdChannel` (`sendHistory` / `processContentItem`, `Internal.hs:1278` / `:1349`; the relay re-encodes from `MsgContent` and has no author key). So a signed post (channel or member) is delivered unsigned on catch-up โ€” no badge, and an as-channel post re-anonymizes. Graceful (absence โ‰  forgery); document and test. Consequently PR 1's mutation enforcement (ยง7) does not fire for catch-up members (they hold the post unsigned), so a forged unsigned edit/delete still lands for them โ€” the documented honest limit. PR 2 preserves signatures through history. +- **Concurrency**: signing/verification are pure given keys; no new shared state. Send holds `withGroupLock`; receive runs under existing serialization. No new races. ## Tests -Protocol (`tests/ProtocolTests.hs`, extending `:112-312`): -- Round-trip signed `XMsgNew`/`XMsgUpdate` through `SignedMsg`; assert binding `CBGroup <> (publicGroupId, memberId)`; `verify` accepts the right key, rejects wrong key / altered body / altered binding. +- Protocol (`tests/ProtocolTests.hs`): round-trip signed `XMsgNew`/`XMsgUpdate`/`XMsgDel`; assert binding `CBGroup <> (publicGroupId, memberId)`; verify accepts the right key, rejects wrong key / altered body / altered binding. +- Integration (`tests/ChatTests/Groups.hs`, relay/channel setup): sign+verify โ‡’ "(signed)"; off/default โ‡’ none; missing roster key โ‡’ "(signed, no key to verify)"; edit reuse keeps/omits the badge; **edit enforcement** โ€” unsigned forged `XMsgUpdate` over a signed item โ‡’ rejected (`RGEMsgBadSignature`), content unchanged (ยง7), and a legitimate signed edit of a signed item โ‡’ accepted; **delete enforcement** โ€” unsigned forged `XMsgDel` of a signed item โ‡’ rejected, item not deleted (ยง7); signed self-delete of a signed item โ‡’ deletes; unsigned self-delete of an unsigned item โ‡’ deletes (no requirement); **moderation delete** โ€” moderator's signed delete of a signed post โ‡’ accepted + role-checked, and moderation always-sign holds even when the moderator holds the target unsigned; **as-channel signed** โ€” owner `as_group=on sign=on` โ‡’ recipient verifies and shows "(signed)" while displaying as the channel; **as-channel unsigned** โ€” forwards via `FwdChannel`, no member id on the wire; **as-channel spoof** โ€” non-owner `asGroup=on sign=on` โ‡’ rejected (ยง5 guard); **history downgrade** โ€” live recipient "(signed)", catch-up recipient not, and enforcement does not fire for the catch-up recipient; **bad-signature drop**; forgery rejection โ‡’ `RGEMsgBadSignature`. +- App: minimal decode test that `"verified"` / `"signedNoKey"` parse to the right enum on both platforms. -Integration (`tests/ChatTests/`, using `setupRelay`/`prepareChannel1Relay`/`createChannel1Relay`/`memberJoinChannel`, `Groups.hs:8621-8750`): -- **Sign + verify:** `sign=on` โ‡’ recipient and sender items are `(signed)` (`sigStatusStr`). -- **Off / opt-out:** `sign=off`/default โ‡’ no `(signed)`. -- **No key:** missing roster key โ‡’ `(signed, no key to verify)` (`MSSSignedNoKey`). -- **Edit reuse:** signed message edit stays `(signed)`; unsigned stays unsigned. -- **Edit downgrade (security):** unsigned `XMsgUpdate` for a previously-signed item (forging-relay, cf. `ChatRelays.hs:220-230`) โ‡’ badge **removed** (ยง7). -- **As-channel never signed (anonymity):** owner posts `as_group=on sign=on` โ‡’ no item is `(signed)` and no signature on the wire/stored message (guards ยง5). -- **History downgrade:** live-forward recipient sees `(signed)`; later history-catch-up recipient sees the same message without it (Edge cases). -- **Forgery rejection:** mismatched-binding replay/fabrication โ‡’ signature stripped / `RGEMsgBadSignature`. +## Commit plan (PR 1) -App: minimal decode test that `"verified"`/`"signedNoKey"` parse to the right enum on both platforms (guards the ยงA tag mismatch). +1. **Structural (behavior-preserving)**: add `signableContent` (`XMsgNew_`, `XMsgUpdate_`, `XMsgDel_`); parameterize `groupMsgSigning` with `sign :: Bool`; thread `sign` through the content/edit send chain; generalize the batch send to carry a per-event `Maybe MsgSigning` (uniform-sign wrapper so existing callers are unchanged); add `signMessages :: Bool` to `APISendMessages` + its nine positional constructors. Every caller passes `False`/`Nothing` โ‡’ no behavior change. +2. **Content signing + update enforcement (core)**: `APISendMessages` parser; content send passes the real flag; edit signs iff the original was signed (ยง6); reject an unsigned `XMsgUpdate` of a signed item in `groupMessageUpdate` (ยง7); as-channel forward/display/owner-guard (ยง5). After this commit the update path is complete and spoof-free. +3. **Delete signing + delete enforcement (core)**: per-item delete signing at the three send sites โ€” self-delete conditional, moderation always-sign (ยง7); reject an unsigned `XMsgDel` of a signed item in `groupMessageDelete` (ยง7). After this commit the delete path is complete. +4. **App**: decode `msgSigned` + recipient indicator (ยงC). +5. **App**: composer long-press option + `apiSendMessages` wiring (ยงB). +6. **Tests** (may accompany 2/3). -## Commit / diff plan +Each commit builds and passes tests independently. ยง7's earlier `updateGroupChatItem` plumbing is dropped โ€” that helper and its five callers are untouched, so there is no badge-fix commit. -1. **Structural (behavior-preserving):** add `ContentSig`, `signableContent`, parameterize `groupMsgSigning` + the three send functions, update all callers with `DontSignContent`. Reviewable as "no behavior change". -2. **Security fix (independent, behavioral no-op today):** add `Maybe MsgSigStatus` to `updateGroupChatItem`, override `meta.msgSigned` after `updatedChatItem`, add `msg_signed` to `updateGroupChatItem_`'s `UPDATE`, update all five callers (ยง7). Until commit 3 every call passes `Nothing`/unchanged, so no observable change yet โ€” but correct on its own, with a regression test that bites once signing exists. -3. **Feature behavior (core):** `APISendMessages` field + parser; content send and edit pass the real `ContentSig` (with the ยง5 as-channel gate); report path `DontSignContent`. -4. **App โ€” decode + recipient indicator.** -5. **App โ€” device preference + composer option + `apiSendMessages` wiring.** -6. **Tests** (protocol + integration) โ€” may accompany commits 2/3. +### Pre-implementation gates -Each commit builds and passes tests independently (bisect/rollback). +- **MUST**: the mutation-enforcement check is on **both** `groupMessageUpdate` and `groupMessageDelete` โ€” an unsigned `XMsgUpdate`/`XMsgDel` of a `Just`-signed target is rejected via `RGEMsgBadSignature`. A missed path reopens the spoof. +- **MUST**: per-item delete signing is wired at all three delete send sites (`Commands.hs:811/818` self-delete conditional; `:3909` moderation always-sign), and `XMsgDel_` is **not** added to `requiresSignature`. +- **MUST**: the as-channel owner guard (ยง5) is on the forwarded `XMsgNew` path, and `FwdChannel` carries no signature. +- **SHOULD**: re-grep `groupMsgSigning` / `sendGroupMessage` / `sendGroupMessages` / `sendGroupMessages_` callers; the batch send is generalized (per-event `Maybe MsgSigning`) without duplicating its body; only content-send, edit, and delete pass a variable signer. +- **SHOULD**: the "verified" caveats (no timestamp/ordering; history downgrade; bad-signature drop) and the as-channel de-anonymization warning are surfaced in UI/help, and those tests exist. -### Pre-implementation gates (after rebasing onto #7017 + #7048) -- **MUST:** the as-channel gate (`showGroupAsSender โ‡’ DontSignContent`, ยง5) lives in the *core* send path, and the app option is hidden for as-channel sends (ยงC) โ€” not UI-only. -- **MUST:** re-run `grep -rn 'updateGroupChatItem\b'` and confirm **every** caller passes an explicit `Maybe MsgSigStatus` โ€” a missed caller silently re-introduces the ยง7 spoof. (Pre-rebase set: `Commands.hs:738`; `Subscriber.hs:1152,1509,2172,2212`.) -- **SHOULD:** re-run the `sendGroupMessages`/`sendGroupMessage`/`sendGroupMessages_` caller greps; only content-send and edit pass a variable `ContentSig`, all others `DontSignContent`. -- **SHOULD:** the three "verified"-meaning caveats (no timestamp/ordering; history downgrade; relay-suppressible) are surfaced in UI/help, and the history-downgrade test exists. +## Deferred to PR 2: history signature preservation + +Signable deletes and recipient enforcement moved into PR 1 (ยง7). What remains is the hard part PR 1's enforcement degrades around. + +**History signature preservation for posts.** On catch-up the relay rebuilds content unsigned (`processContentItem` re-encodes from `MsgContent`, has no author key; re-encoding invalidates the original signature โ€” `Store/Delivery.hs:162`). So a catch-up member holds an originally-signed post as `Nothing`, and PR 1's enforcement does not fire for them (target held unsigned โ‡’ no signature required). Two residual gaps that only preservation closes: + +- a relay can forge an unsigned delete/edit of a signed post for catch-up members (they hold it unsigned, so nothing is enforced); +- the deeper inconsistency behind the moderation-divergence handling โ€” members disagreeing on a post's signed status โ€” is fully resolved only when all members hold the post signed. + +Design question (unchanged): does the `messages` row survive long enough to forward the original signed bytes on catch-up, or must signed bytes be persisted on the chat item (migration)? + +Honest limit until then: enforcement protects a post a recipient already holds verified; a relay that delivered the original unsigned sidesteps it (visible as a missing badge, not prevented). ## Out of scope / future -- Group-level "expected/required signing" owner setting (closes the optional-downgrade gap). -- Signing reactions/deletes; signing auto-reply content; verifiable reports (signed `MCReport`). -## Open assumptions to confirm during implementation -- App `GroupInfo` exposes relay+key state for the UI gate, or a derived boolean is added to its JSON. -- Visual treatment of `signedNoKey` vs `verified`, and how to surface the "verified โ‰  timestamp/ordering/completeness" caveat (threat model) in help. +- Group-level "required signing" owner setting โ€” rejects unsigned messages group-wide, closing the optional-downgrade gap. +- **Verifiable-anonymous as-channel** โ€” a channel-level signature (ring signature over the owner set, or a shared/authorization-chain channel key) that proves "a valid owner" without revealing which, so an as-channel post is verifiable *and* keeps sender anonymity. This PR's per-member signing cannot do both; signing an as-channel post reveals the owner (ยง5). +- Signing reactions; signing auto-reply content; verifiable reports (signed `MCReport`). + +## Verification status (2026-06-25, branch `f/msg-signing`) + +Every symbol the plan names was located and its logic re-checked against current code. **No logic drift** was found in any anchored function โ€” the plan's described behavior still holds everywhere. Three classes of correction below: mislabeled anchors (fix before relying on them), one structural clarification (threading is slightly larger than the prose implies), and a current line map (most anchors moved a little; re-confirm by symbol regardless). + +### Mislabeled / materially-moved anchors + +- `MsgSigStatus` is defined in **`src/Simplex/Chat/Types/Shared.hs:134`** (`MSSVerified | MSSSignedNoKey`), not in `Types.hs`/`Messages.hs`. DB encoding: `verified` / `no_key`; JSON tags (`enumJSON $ dropPrefix "MSS"`): `verified` / `signedNoKey`. The JSON/DB divergence the app section (ยงA) relies on is confirmed. Any core edit to the type itself targets `Types/Shared.hs`. +- ยง5 "the direct path (`:1064`)" is wrong: the non-forwarded as-channel owner enforcement lives in **`checkSendAsGroup` (`Subscriber.hs:1057`)**; `:1064` is just the `XMsgNew โ†’ newGroupContentMessage` dispatch (fixed inline in ยง5). +- ยง6 wording "add `msgSigned` to the pattern" โ€” confirmed the own-item `CIMeta` pattern at `Commands.hs:739` does **not** bind `msgSigned` today; it must be added to the record pattern (the field exists on `CIMeta`, `Messages.hs:520`). +- ยง3 "`sendGroupMessage` โ€ฆ 6 callers" โ€” there are **7** `sendGroupMessage` sites; the 7th is the edit path itself (`Commands.hs:751`, the only variable-`sign` one). The six that pass `False`: `Commands.hs:908,2719,3325,3873,3876,3880`. +- `signatureOptional` is at `Subscriber.hs:3848` (plan said ~:3844); `RGEMsgBadSignature` creation at `:3828` (plan said :3824). Behavior unchanged. + +### Structural clarification (affects ยง2/ยง3 threading) + +`groupMsgSigning` is invoked at **three** sites, two of them inside functions that do **not** currently receive any as-channel/sign flag: + +- `Internal.hs:2122` โ€” inside `sendGroupMemberMessages` (hardcode `False`). +- `Internal.hs:2367` โ€” inside `sendGroupMessages_`. +- `Commands.hs:3017` โ€” direct `XGrpLeave` via `createSndMessages` (hardcode `False`). + +`sendGroupMessages_` has **no** `ShowGroupAsSender`/`asGroup` parameter, and `sendGroupMessages` (`:2332`) consumes its `ShowGroupAsSender` only for `shouldSendProfileUpdate` โ€” it does **not** pass it down. So threading `sign` to the `groupMsgSigning` call inside `sendGroupMessages_` is genuinely new wiring: `sendGroupContentMessages_`/`sendGroupMessage` โ†’ `sendGroupMessages` โ†’ **(new param)** โ†’ `sendGroupMessages_` โ†’ `groupMsgSigning sign โ€ฆ`. The plan's ยง3 list already includes all these functions; this note just flags that the param add to `sendGroupMessages_` is load-bearing, not a pass-through that already exists. + +### Current line map (re-confirm by symbol) + +| Symbol | File:line | +|---|---| +| `requiresSignature` (insert `signableContent` next to it) | Protocol.hs:1334 | +| `MsgSigning` (4-field record, applied positionally) | Protocol.hs:469 | +| `encodeChatBinding` / `CBGroup` / `signChatMsgBody` | Protocol.hs:476 / 442 / 479 | +| `FwdSender` (`FwdMember MemberId ContactName` / `FwdChannel`) | Protocol.hs:372 | +| `groupMsgSigning` | Internal.hs:2113 (calls: 2122, 2367, Commands.hs:3017) | +| `sendGroupMessages` / `_` | Internal.hs:2332 / 2365 | +| `sendGroupMessage` / `'` | Internal.hs:2258 / 2264 | +| `sendGroupMemberMessages` | Internal.hs:2119 | +| `sndMessageMBR` (non-batched; never hit in relay groups) | Internal.hs:2431 (`memberSendAction` 2455, `MSASendBatched` 2453) | +| `sendHistory` / `processContentItem` (as-channel โ†’ `FwdChannel` at 1375) | Internal.hs:1281 / 1352 | +| `createNewSndMessage` (`signedMsg_`) | Store/Messages.hs:235 | +| `APISendMessages` ctor | Controller.hs:382 | +| `/_send` parser / `onOffP` | Commands.hs:5111 / 5490 | +| 9 positional `APISendMessages` ctors (add `False`; `:2449` passes `live=True`) | Commands.hs:2392,2401,2421,2441,2449,2494,3236,3277,3286 | +| `APISendMessages` handler / group send | Commands.hs:654 / 667 | +| `sendGroupContentMessages` / `_` | Commands.hs:4447 / 4456 (callers 667, 698, 994) | +| group edit (own-item pattern / send) | Commands.hs:739 / 751 | +| `XGrpLeave` group send (`sendGroupMessage'`) | Commands.hs:3027 | +| `verifyGroupSig` / `withVerifiedMsg` / `signatureOptional` | Subscriber.hs:116 / 3823 / 3848 | +| `RGEMsgBadSignature` | Subscriber.hs:3828 | +| forwarded `XMsgNew`: `xGrpMsgForward` / `FwdMember` / `withVerifiedMsg` / `newGroupContentMessage` / `FwdChannelโ†’VMUnsigned` | Subscriber.hs:3771 / 3775 / 3784 / 3795 / 3787 | +| `newGroupContentMessage` (`sentAsGroup` 2177) / `groupMessageUpdate` (owner guards 2236, 2282) | Subscriber.hs:2142 / 2225 | +| `validSender` (`CIChannelRcv โ‡’ GROwner`) / `checkSendAsGroup` | Subscriber.hs:2135 / 1057 | +| `updateGroupChatItem` / `_` / `UPDATE` / `updatedChatItem` | Store/Messages.hs:2749 / 2758 / 2766 / 2547 | +| ยง7 `updateGroupChatItem` callers (all 5) | Commands.hs:757; Subscriber.hs:1200,1566,2258,2298 | +| `createNewSndChatItem` (`MSSVerified <$ signedMsg_`) / `createNewRcvChatItem` / `createNewChatItem_` (`msg_signed`) / `mkCIMeta` | Store/Messages.hs:548 / 562 / 614 / Messages.hs:528 | +| `Store/Delivery.hs` `fwdSender` / `VMSigned` reconstruction | Delivery.hs:158 / 162โ€“164 | +| `sigStatusStr` | View.hs:389 | +| iOS `CIMeta` / composer menu / `ciMetaText` / `/_send` build | ChatTypes.swift:3825 / SendMessageView.swift:238 / CIMetaView.swift:93 / AppAPITypes.swift:243 | +| Kotlin `CIMeta` / composer menu / `CIMetaText` + `reserveSpaceForMeta` / `/_send` build | ChatModel.kt:3529 / SendMsgView.kt:198 / CIMetaView.kt:67 + 118 / SimpleXAPI.kt:3870 | +| channels-overview.md anchors (:103,:159,:198,:214,:221,:237) | all confirmed | + +## Open design questions (2026-06-25) + +Status as of 2026-06-29: all resolved (Q1/Q3/Q5 by the team; Q2/Q4 by code verification). **Design change (2026-06-29):** ยง7's "badge refresh" was replaced by receive-time **enforcement** โ€” a held-signed item's `XMsgUpdate`/`XMsgDel` must be signed or is rejected (`RGEMsgBadSignature`); signed deletes + enforcement moved from PR 2 into PR 1 (update *and* delete); ยง7's `updateGroupChatItem` plumbing dropped. One sub-rule baked in pending confirmation: moderation/admin deletes **always** sign in relay channels (self-deletes sign conditionally) to avoid the catch-up-moderator divergence โ€” see ยง7. + +1. **RESOLVED โ€” keep drop + `RGEMsgBadSignature`.** Member keys do not rotate (one key per member, communicated on join), so a have-the-key-but-mismatch is a genuine forgery/tamper signal, and a downgrade-to-unsigned would buy nothing against a malicious relay (which can simply drop the whole message). Behavior stays identical to existing signed events; the lagging-roster case is the `MSSSignedNoKey` accept path, not the drop path, so there is no honest false-positive. Threat-model bullet updated accordingly. Action: edge-case test + help note only. + +2. **RESOLVED โ€” gate on `useRelays'` alone, no key-derived flag.** Verified: the only relay-channel state with `groupKeys = Nothing` is prepared/`GSMemUnknown` (`createPreparedGroup`, `Store/Groups.hs:654`), which is non-current/non-active and cannot send โ€” the key is written before the membership becomes sendable. So every sendable member has `groupKeys = Just`. ยงB simplified to a `useRelays'` gate; ยงB asks the app task to confirm a member-agnostic relay-channel boolean exists (the as-channel toggle is owner-scoped), adding a plain non-secret one if not. The "member without keys" edge case is now documented as a harmless backstop. + +3. **RESOLVED โ€” keep raw `Bool`.** No `SignMessages` newtype. Retain ยง3's placement guidance (put `sign` away from `showGroupAsSender`/`live` in each signature) as the transposition mitigation. + +4. **RESOLVED โ€” guard reads stable, link-data owner identity; no false-negative.** Verified: `GROwner` is established from root-key-verifiable link data at connect time (`createLinkOwnerMember`, `Store/Groups.hs:3381`), and `isRosterRole` (`Internal.hs:1257`) excludes `GROwner`, so owner identity is never carried by the roster or `XGrpMemRole`. The as-channel guard is therefore independent of the known role-propagation bug, and (single owner today) a legitimate owner post never fails it. The ยง5 propagation note is retracted. The guard stays a MUST โ€” it blocks a non-owner's signed `asGroup=True` post from rendering as the channel. + +5. **WITHDRAWN โ€” non-issue.** A send's `sign` flag applies to its whole composed-message batch; `signableContent` filters to `XMsgNew`/`XMsgUpdate` and content sends do not interleave non-signable events. + +## Implementation divergences from plan (2026-07-06, branch `f/msg-signing`) + +Where the shipped implementation departs from the sections above. Each entry supersedes the referenced section for that detail; everything else was implemented as described. Re-confirm by symbol โ€” line numbers are advisory. + +**D1 โ€” ยง7 enforcement is verified-only, not signature-presence (supersedes ยง7's "'Signed' for the requirement means `MSSVerified` **or** `MSSSignedNoKey`").** The receive-time guard requires `MSSVerified` on **both** sides: an item held `MSSVerified` accepts a mutation only if the incoming mutation is itself `MSSVerified` (`itemSigned == Just MSSVerified && msgSigned /= Just MSSVerified` โ‡’ reject). Reason: `withVerifiedMsg` verifies **only** `CBGroup` bindings โ€” a signature under any other binding (`CBDirect`/`CBChannel`) resolves to `MSSSignedNoKey` *without verification*, even for a recipient that holds the author's key. So a presence-based check (`isNothing msgSigned`) would accept a relay-forged mutation carrying a garbage non-`CBGroup` signature โ€” including against a badged `MSSVerified` item. Enforcement is therefore real only for `MSSVerified`; `MSSSignedNoKey` items get no enforcement (they are never badged, and authorship is unverifiable without the key, so nothing can be protected). The two helpers are `requireVerifiedEdit` / `requireVerifiedDelete` (`Subscriber.hs`; `requireVerifiedEdit` renamed from `requireVerifiedMutation` per review); both create the `RGEMsgBadSignature` item. + +**D2 โ€” ยง7 channel history-delete always-signs (supersedes ยง7/ยง8 "self/history deletes sign conditionally").** `delEventSigned` (`Commands.hs`) signs on `onlyHistory || isJust msgSigned`: **channel history-delete (`CIDMHistory`) always signs**, matching moderation; only self-delete (`CIDMEBroadcast`) stays conditional (to preserve deniability of unsigned posts). Reason: the same catch-up divergence the plan fixed for moderation โ€” a future non-authoring owner who caught up via unsigned history would otherwise emit an unsigned history-delete that live members holding the post verified would reject (D3/ยง7). Latent today (single-owner channels). + +**D3 โ€” preemptive-moderation verification added (extends ยง7).** ยง7's receive check only guards items the recipient **already holds**. The `Left e` (item-not-found) branch of `groupMessageDelete` โ€” which plants a `CIModeration` that auto-applies when the post later arrives โ€” was unguarded, so a relay could deliver a forged **unsigned** moderation-delete *before* the signed post to pre-censor it. Added: in relay channels, reject an unverified moderation-delete of a not-yet-received item before planting the moderation (`useRelays' gInfo && msgSigned /= Just MSSVerified`, `Subscriber.hs`). Legitimate moderation always-signs (ยง7) and any recipient that passes `checkRole` holds the moderator's key, so no legitimate case is rejected. + +**D4 โ€” ยง5 `encodeFwdElement` regression guard NOT added (supersedes ยง5's "Assert this in `encodeFwdElement` โ€ฆ as a regression guard" and the matching Edge-cases bullet).** `encodeFwdElement` (`Batch.hs`) is left as the original one-liner. The asserted invariant (a `FwdChannel` element never carries a signature) is already structural: `Store/Delivery.hs` derives `FwdChannel` only when `isNothing chatBinding_`, and `signedMsg_` is derived from the same value, so `FwdChannel โŸน unsigned` by construction. An `error` guard would be unreachable and would only risk crashing the delivery worker; a `FwdMember` fallback is impossible there (`FwdChannel` carries no `memberId`). + +**D5 โ€” CLI recipient indicator extended to content events (extends ยงC).** ยงC scopes the indicator to the apps (`CIMetaView`). The CLI (`View.hs`) additionally shows `(signed)` on content-message events โ€” new messages, edits, and deletes โ€” by appending `sigStatusStr msgSigned` in `viewReceivedMessage_` / `viewSentMessage` (new `appendLast` helper). Because `viewItemDelete` renders via `viewReceivedMessage`, delete events show the deleted post's signed status too. Unsigned content is unaffected (`sigStatusStr Nothing = ""`), so only the signing tests' event assertions changed. + +**D6 โ€” bot/client API fixed at the generator (extends ยง4).** Beyond adding `signMessages` to core `APISendMessages`, the bot/client API was aligned at its source: the flag was added to the generator (`bots/src/API/Docs/Commands.hs`, `OnOffParam "sign" "signMessages" (Just False)`) and the outputs regenerated (`bots/api/COMMANDS.md`, `packages/simplex-chat-client/types/typescript/src/commands.ts`, `packages/simplex-chat-python/src/simplex_chat/types/_commands.py`) so the `cmdString` builders now emit `sign=on`; and `signMessages` was threaded through the three hand-written wrappers (`packages/simplex-chat-python/.../api.py`, TS `client.ts`, nodejs `api.ts`) whose `APISendMessages` construction otherwise omitted the now-required field. diff --git a/plans/2026-07-07-signed-files-and-history.md b/plans/2026-07-07-signed-files-and-history.md new file mode 100644 index 0000000000..63084542c5 --- /dev/null +++ b/plans/2026-07-07-signed-files-and-history.md @@ -0,0 +1,64 @@ +# Signed file integrity + signed history preservation + +Extends [channel message signing](2026-06-04-channel-message-signing.md). Part A must land **before** Part B, because forwarding a signed file post is only meaningful once the signature actually binds the file bytes. Related: [roster catch-up subscribers](2026-06-22-roster-catchup-subscribers.md) (verification depends on the recipient holding the author's key). + +## Part A โ€” Sign the file digest (bug, fix first) + +### Problem + +A "signed" XFTP file message signs nothing about the file itself. The signature covers `XMsgNew`, whose `FileInvitation` is built with `fileDigest = Nothing` (`Types.hs:1524`, `xftpFileInvitation`) and an empty embedded description (`dummyFileDescr`, `Internal.hs:389,410`) โ€” because at send time the file is still uploading async. The real digest/key/servers live only in the later, **unsigned** `XMsgFileDescr` events. So the signature attests only `fileName + fileSize`. A malicious relay can pair the genuine signed `XMsgNew` with a substituted description pointing at different content of the same size, and the recipient displays it as "signed & verified". The file signature is currently meaningless. This affects **live** signed messages, not only history. + +### Fix + +Sign a **plaintext** content digest in `FileInvitation.fileDigest` (already a field, inside the signed `XMsgNew`), and verify the decrypted file against it on receive โ€” **only for signed file messages** (when `sign` is passed to the send API). + +- Scope: signed file messages only. For an unsigned message a relay can rewrite both the file and the digest, so verifying it buys nothing; restricting to signed also avoids the extra hashing pass on ordinary sends. +- Sender: when a signed message carries an XFTP file, chat computes the sha512 of the unencrypted source and sets `fileDigest` before building `XMsgNew`, so it is covered by the signature. +- Receiver: for a signed file message, after download+decrypt, chat hashes the decrypted file and compares to the signed `fileDigest`; on mismatch, drop the file and surface a violation event. +- Compatibility: `fileDigest` is an optional field older clients already ignore โ€” forward-compatible; verification runs only on clients that support it. + +### Why plaintext, and produced chat-side (verified in simplexmq) + +The XFTP description's `digest` is `sha512Hash` of the **encrypted** file (`Agent.hs:449`, `Client/Main.hs:290`), produced with a per-send random `key`+`nonce`; the recipient verifies the reassembled **ciphertext** against it (`Agent.hs:295`). It is therefore upload-specific โ€” any re-encryption/re-upload changes it, so it can never be signed once and preserved. A **plaintext** digest is encryption-independent and verifies the content the recipient actually consumes. + +simplexmq computes **no** plaintext hash, and its only free (fused) read is the **async** encrypt pass (`encryptFileForUpload`, `Agent.hs:433`) โ€” too late for the synchronous `FileInvitation` (built in `xftpSndFileTransfer_`, `Internal.hs:389`, before the async upload). So chat computes the plaintext sha512 itself, in the send path off the UI thread, before building the invitation, and hashes the decrypted file on receive to verify โ€” **chat-only, no simplexmq change** (option A). It is one extra full read (decrypt-at-rest + sha512) per side, ~1-3 s for a file near the ~1 GB cap, one-time and only for signed files. Rejected alternative: fusing the digest into the async encrypt/decrypt and deferring the send until it is ready โ€” single-read, but it couples the send to the file pipeline and risks an unresponsive send, which we will not do. + +### Threat model + +Author honest, relay/forwarder malicious. The relay controls the unsigned description but not the signed invitation. Signing the content digest binds the file end-to-end from author to recipient, independent of any relay. + +## Part B โ€” Preserve signatures in history + +### Problem + +`sendHistory` โ†’ `processContentItem` (`Internal.hs:1370`) re-encodes each item's current content via `prepareGroupMsg` and sends it unsigned (`groupMsgSigning False`; the relay has no author key). Catch-up members therefore hold **all** history unsigned, so ยง7 enforcement (`requireVerifiedEdit`/`requireVerifiedDelete`) never protects their items โ€” and can't heal later, because `updateGroupChatItem_` (`Messages.hs:2766`) does not write `msg_signed`, so a subsequent signed edit does not upgrade the item. Only delivering history signed at creation closes this. + +### Design + +- **Storage.** Two nullable columns on `chat_items`: `item_msg_body`, `item_signatures`. Written by relays only, for content items only (same `msg_content_tag` / `include_in_history` filter history uses). `chat_binding` is not stored โ€” it is derived as `smpEncode(publicGroupId, authorMemberId)` at send time. +- **Capture.** Write the columns in `createNewChatItem_` when the item is signed; overwrite them in `updateGroupChatItem_` when a signed edit is applied. This keeps the stored bytes tracking the **latest** signed event, so history always forwards current content. Thread the raw `(msg_body, signatures)` onto `RcvMessage` (from `saveGroupFwdRcvMsg`'s `verifiedMsgParts`) and use `SndMessage`'s existing `signedMsg_`/`msgBody`; today both carry only the `msgSigned` status. +- **Forward.** In `sendHistory`, for a signed content item with stored bytes, forward the original bytes as a `VMSigned` `XGrpMsgForward` (reuse the live path's `encodeFwdElement` / `sendFwdMemberMessage`) instead of re-encoding; unsigned items keep the current re-encode path. +- **Edits need only the last event.** Forwarding the latest signed event โ€” `XMsgNew` if never edited, else the latest `XMsgUpdate` โ€” is sufficient: on the recipient a forwarded `XMsgUpdate` for a not-yet-existing item hits the create fallback in `groupMessageUpdate` (`Subscriber.hs:2234` `catchCINotFound` โ†’ `saveRcvChatItem'` โ†’ `createNewRcvChatItem`), which creates the item with the edit's `msgSigned` and content, marked edited. So one `(body, signatures)` per item, no original+edit replay. Self-consistent because a verified item only ever accepts verified edits (`requireVerifiedEdit`). +- **Files.** No special branch. The forwarded signed `XMsgNew` carries `name + size + digest` (from Part A); the description follows via the existing `XMsgFileDescr` path and may be re-forwarded/re-uploaded freely โ€” it is not covered by the signature, and integrity now comes from the signed digest. +- **Compatibility.** Same wire format as live signed messages. Signing is tied to relay channels (`groupMsgSigning` gates on `useRelays'`, not a member version), so any relay subscriber already receives signed live messages; signed history is identical. No new version gate. + +### Result + +Catch-up members hold non-edited and edited signed posts as verified with current content, so ยง7 enforcement protects their edits/deletes โ€” closing the residual documented in the signing plan. Retention is no longer bounded by the 30-day `messages` pruning. + +## Implementation steps + +Part A (chat-only, no simplexmq change): +1. On send, for a signed message with an XFTP file, hash the source off the UI thread and set `FileInvitation.fileDigest` before building `XMsgNew`. +2. On receive of a signed file message, hash the decrypted file and compare to the signed `fileDigest`; on mismatch, drop the file and surface a violation event. +3. Test (one, in the channel test harness): a signed file message verifies end-to-end; and โ€” reusing the forged-message injection from the existing signature tests โ€” a forged file (content not matching the signed digest) fails the check and is dropped. + +Part B: +4. Migration: add `item_msg_body`, `item_signatures` to `chat_items` (SQLite + Postgres modules; register in `Migrations.hs`; add to `.cabal`; schema files regenerate via tests). +5. Thread raw signed bytes onto `RcvMessage`; store/overwrite in `createNewChatItem_` and `updateGroupChatItem_` (relay + content-item + signed). +6. `sendHistory`: signed content item with stored bytes โ†’ forward `VMSigned`; else re-encode. +7. Test (one): a catch-up subscriber receives history exercising all transitions โ€” a signed post that was edited (arrives signed, current content), another signed post that was deleted (excluded from history), and a signed post with a file (arrives signed, digest verified) โ€” and a forged unsigned edit/delete of a catch-up-held signed item is rejected. + +## Docs to update on implementation + +Signing/files/history spec + product docs; move the digest gap from `product/gaps.md` to fixed; cross-link this plan from [channel message signing](2026-06-04-channel-message-signing.md). diff --git a/plans/2026-07-09-channel-sign-messages-preference.md b/plans/2026-07-09-channel-sign-messages-preference.md new file mode 100644 index 0000000000..3d6601ff16 --- /dev/null +++ b/plans/2026-07-09-channel-sign-messages-preference.md @@ -0,0 +1,62 @@ +# Channel SignMessages preference (recipient side, stage 1) + +## Problem + +Per-message opt-in signing (the current channel-message-signing PR) puts the signing decision on each sender, so a channel cannot express or enforce an expectation that its content is signed. The chosen direction is a channel-wide preference under which all content in the channel is expected to be signed. This is a two-stage rollout so clients have time to update and process the preference. Stage 1 ships **recipient support only**, default off, channel-only. Stage 2 (later) makes the preference affect sending. The existing Part-A signing/verification/ยง7 enforcement stays as-is (used for testing the recipient side until sending is built). + +## Design + +New group feature `GFSignMessages` (on/off, no-role, default off), channel-only. When on in a channel, the recipient marks a received **new content item** whose signature is absent as "signature required but missing" rather than plain unsigned. + +`CIMeta.msgSigned :: Maybe MsgSigStatus` becomes `msgVerified :: MsgVerified`: + +``` +data MsgVerified = MVSigned MsgSigStatus | MVSigMissing | MVUnsigned +``` + +- `MVSigned s` โ€” a signature is present (verified or no-key), as today. +- `MVSigMissing` โ€” pref requires a signature but it's absent (red warning in meta). +- `MVUnsigned` โ€” pref doesn't require a signature and it's absent (legacy unsigned). + +Computed at content-item creation from `(RcvMessage.msgSigned, channel SignMessages pref)`: +- `Just s` โ†’ `MVSigned s` +- `Nothing` โ†’ `MVSigMissing` if the channel requires signing, else `MVUnsigned` + +Only **new content items** get this; edits/deletes keep today's behavior. `RcvMessage.msgSigned :: Maybe MsgSigStatus` (raw signature result) is unchanged. Sent items: `MVSigned MSSVerified` if signed, else `MVUnsigned` (no `MVSigMissing` for own items in stage 1). + +### Encoding + +- **DB (`msg_signed`, unchanged column):** `MVSigned MSSVerified`โ†’`"verified"`, `MVSigned MSSSignedNoKey`โ†’`"no_key"`, `MVSigMissing`โ†’`"sig_missing"`, `MVUnsigned`โ†’`"unsigned"`. Decode: those strings, plus **NULL โ†’ `MVUnsigned`** (legacy rows). No migration. +- **JSON (APIโ†’UI):** tagged encoding; `omittedField = MVUnsigned` for forward-compat (older clients / missing field). + +### Feature exclusion from regular groups + +`GFSignMessages` added with `groupFeatureInChannel = True`. New predicate `groupFeatureInRegularGroup :: GroupFeature -> Bool` (False for `GFSignMessages`, True otherwise); a `regularGroupFeatures = filter groupFeatureInRegularGroup allGroupFeatures` used where regular groups generate feature items / list features, so `SignMessages` is channel-only (no group items, not in group preference UI). + +### UI + +- `msgVerified` field + `MsgVerified` type on Kotlin/iOS. +- Meta badge: `MVSigned MSSVerified` โ†’ signature badge (as now, gated on file loaded); `MVSigMissing` โ†’ red `exclamationmark.triangle`, tap โ†’ alert "signature required but missing" (mirror the AUTH-error alert on the status X); `MVUnsigned` โ†’ nothing. +- Add the `SignMessages` channel preference to the channel preferences UI (will be default-off / hidden at release; shown for now for testing). + +## Steps + +1. `Types/Shared.hs`: `MsgVerified` + TextEncoding/ToField/FromField (NULLโ†’MVUnsigned) + JSON (+omittedField). +2. `Types/Preferences.hs`: `GFSignMessages` (enum, SGADT, name, instances, allGroupFeatures, `groupFeatureInChannel`=True, `groupFeatureInRegularGroup`, preference plumbing, default off); update regular-group feature usage to `regularGroupFeatures`. +3. `Messages.hs`: rename `msgSigned`โ†’`msgVerified`, type `MsgVerified`; `CIMeta` + `JCIMeta` + `mkCIMeta`. +4. Store: `createNewChatItem_` takes `MsgVerified`; write/read `msg_signed`. `createNewSndChatItem`/`createNewRcvChatItem` compute it; thread a `signMessagesRequired :: Bool` where needed. +5. `Subscriber.hs` `newGroupContentMessage`: compute the required flag from the channel pref; adapt ยง7 enforcement to `MsgVerified`. +6. `View.hs` + other Haskell usages of `msgSigned`. +7. Build core; iterate. +8. UI (Kotlin + iOS): type, field, badge + warning + alert, preference. +9. Tests (channel: pref on + unsigned content โ†’ MVSigMissing; pref off โ†’ MVUnsigned; signed โ†’ MVSigned). + +## Progress / divergences (2026-07-09) + +- **Haskell core: DONE, library builds clean.** Type `MsgVerified` + encodings; `GFSignMessages` feature (channel-only via `groupFeatureInRegularGroup`/`regularGroupFeatures`); `signMessagesRequired` + `toMsgVerified` threaded through send/receive/store; ยง7 enforcement adapted; CLI renderer `msgVerifiedStr`. +- **Kotlin: DONE (review-verified; gradle not run).** `MsgVerified` sealed class; `CIMeta.msgVerified`; badge + red `ic_warning` for `SigMissing`; tap-alert via `sigMissingInfo`; `GroupFeature.SignMessages` + all switches; `FullGroupPreferences`/`GroupPreferences.signMessages`; channel-gated toggle in `GroupPreferences.kt`; strings. +- **iOS: delegated (in progress).** +- **Divergence 1 (scope):** requirement applies to BOTH as-channel posts (`CDChannelRcv`) and member posts (`CDGroupRcv`); regular groups are never affected because the preference is off there by construction (default off, excluded from the group UI, business chats set it off). Rationale: "everything in the channel signed." Confirm if as-channel-only is wanted. +- **Divergence 2 (Kotlin pref placement):** toggle gated on `groupInfo.isChannel` at the top of the preferences screen (existing feature toggles there are gated on `!useRelays`, and relay-channels showed none). Confirm placement. +- **No migration:** `msg_signed` column reused. `MVUnsigned`โ†’`"unsigned"`; decode `"unsigned"` and NULL โ†’ `MVUnsigned`. +- **Remaining:** iOS review; Haskell recipient test; product/spec doc updates (multiplatform + iOS doc protocol). diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 6403dd19b2..54462f941a 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -147,6 +147,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260602_group_roster Simplex.Chat.Store.Postgres.Migrations.M20260603_simplex_name Simplex.Chat.Store.Postgres.Migrations.M20260629_roster_catchup + Simplex.Chat.Store.Postgres.Migrations.M20260707_file_digest else exposed-modules: Simplex.Chat.Archive @@ -311,6 +312,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260602_group_roster Simplex.Chat.Store.SQLite.Migrations.M20260603_simplex_name Simplex.Chat.Store.SQLite.Migrations.M20260629_roster_catchup + Simplex.Chat.Store.SQLite.Migrations.M20260707_file_digest other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Bot.hs b/src/Simplex/Chat/Bot.hs index 3aca687ec5..7284b72d62 100644 --- a/src/Simplex/Chat/Bot.hs +++ b/src/Simplex/Chat/Bot.hs @@ -86,14 +86,14 @@ sendComposedMessages cc sendRef = sendComposedMessages_ cc sendRef . L.map (Noth sendComposedMessages_ :: ChatController -> SendRef -> NonEmpty (Maybe ChatItemId, MsgContent) -> IO () sendComposedMessages_ cc sendRef qmcs = do let cms = L.map (\(qiId, mc) -> ComposedMessage {fileSource = Nothing, quotedItemId = qiId, msgContent = mc, mentions = M.empty}) qmcs - sendChatCmd cc (APISendMessages sendRef False Nothing cms) >>= \case + sendChatCmd cc (APISendMessages sendRef False Nothing False cms) >>= \case Right (CRNewChatItems {}) -> printLog cc CLLInfo $ "sent " <> show (length cms) <> " messages to " <> show sendRef r -> putStrLn $ "unexpected send message response: " <> show r sendComposedMessageFile :: ChatController -> SendRef -> Maybe ChatItemId -> MsgContent -> CryptoFile -> IO () sendComposedMessageFile cc sendRef qiId mc file = do let cm = ComposedMessage {fileSource = Just file, quotedItemId = qiId, msgContent = mc, mentions = M.empty} - sendChatCmd cc (APISendMessages sendRef False Nothing (cm :| [])) >>= \case + sendChatCmd cc (APISendMessages sendRef False Nothing False (cm :| [])) >>= \case Right (CRNewChatItems {}) -> printLog cc CLLInfo $ "sent file message to " <> show sendRef r -> putStrLn $ "unexpected send message response: " <> show r diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 48f4ec9eec..693a467bbf 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -379,7 +379,7 @@ data ChatCommand | APIGetChatContentTypes ChatRef | APIGetChatItems {chatPagination :: ChatPagination, search :: Maybe Text} | APIGetChatItemInfo {chatRef :: ChatRef, chatItemId :: ChatItemId} - | APISendMessages {sendRef :: SendRef, liveMessage :: Bool, ttl :: Maybe Int, composedMessages :: NonEmpty ComposedMessage} + | APISendMessages {sendRef :: SendRef, liveMessage :: Bool, ttl :: Maybe Int, signMessages :: Bool, composedMessages :: NonEmpty ComposedMessage} | APICreateChatTag ChatTagData | APISetChatTags ChatRef (Maybe (NonEmpty ChatTagId)) | APIDeleteChatTag ChatTagId diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 49e91704aa..b4553d531f 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -653,7 +653,7 @@ processChatCommand cxt nm = \case -- TODO [knocking] getAChatItem doesn't differentiate how to read based on scope - it should, instead of using group filter Just <$> withFastStore (\db -> getAChatItem db cxt user (ChatRef CTGroup gId Nothing) fwdItemId) _ -> pure Nothing - APISendMessages sendRef live itemTTL cms -> withUser $ \user -> mapM_ assertAllowedContent' cms >> case sendRef of + APISendMessages sendRef live itemTTL sign cms -> withUser $ \user -> mapM_ assertAllowedContent' cms >> case sendRef of SRDirect chatId -> do mapM_ assertNoMentions cms withContactLock "sendMessage" chatId $ @@ -666,7 +666,7 @@ processChatCommand cxt nm = \case (gInfo, cmrs) <- withFastStore $ \db -> do g <- getGroupInfo db cxt user chatId (g,) <$> mapM (composedMessageReqMentions db user g) cms - sendGroupContentMessages user gInfo gsScope asGroup live itemTTL cmrs + sendGroupContentMessages user gInfo gsScope asGroup live itemTTL sign cmrs APICreateChatTag (ChatTagData emoji text) -> withUser $ \user -> withFastStore' $ \db -> do _ <- createChatTag db user emoji text CRChatTags user <$> getUserChatTags db user @@ -697,7 +697,7 @@ processChatCommand cxt nm = \case gInfo <- withFastStore $ \db -> getGroupInfo db cxt user gId let mc = MCReport reportText reportReason cm = ComposedMessage {fileSource = Nothing, quotedItemId = Just reportedItemId, msgContent = mc, mentions = M.empty} - sendGroupContentMessages user gInfo (Just $ GCSMemberSupport Nothing) False False Nothing [composedMessageReq cm] + sendGroupContentMessages user gInfo (Just $ GCSMemberSupport Nothing) False False Nothing False [composedMessageReq cm] ReportMessage {groupName, contactName_, reportReason, reportedMessage} -> withUser $ \user -> do gId <- withFastStore $ \db -> getGroupIdByName db user groupName reportedItemId <- withFastStore $ \db -> getGroupChatItemIdByText db user gId contactName_ reportedMessage @@ -738,7 +738,7 @@ processChatCommand cxt nm = \case -- TODO [knocking] check chat item scope? cci <- withFastStore $ \db -> getGroupCIWithReactions db user gInfo itemId case cci of - CChatItem SMDSnd ci@ChatItem {meta = CIMeta {itemSharedMsgId, itemTimed, itemLive, editable, showGroupAsSender}, content = ciContent} -> do + CChatItem SMDSnd ci@ChatItem {meta = CIMeta {itemSharedMsgId, itemTimed, itemLive, editable, showGroupAsSender, msgVerified}, content = ciContent} -> do case (ciContent, itemSharedMsgId, editable) of (CISndMsgContent oldMC, Just itemSharedMId, True) -> do chatScopeInfo <- mapM (getChatScopeInfo cxt user) scope @@ -750,7 +750,8 @@ processChatCommand cxt nm = \case let msgScope = toMsgScope gInfo <$> chatScopeInfo mentions' = M.map (\CIMention {memberId} -> MsgMention {memberId}) ciMentions event = XMsgUpdate itemSharedMId mc mentions' (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive) msgScope (Just showGroupAsSender) - SndMessage {msgId} <- sendGroupMessage user gInfo scope recipients event + reuseSign = case msgVerified of MVSigned _ -> True; _ -> False + SndMessage {msgId} <- sendGroupMessage user gInfo scope recipients reuseSign event ci' <- withFastStore' $ \db -> do currentTs <- liftIO getCurrentTime when changed $ @@ -809,16 +810,14 @@ processChatCommand cxt nm = \case recipients <- getGroupRecipients cxt user gInfo chatScopeInfo groupKnockingVersion assertDeletable items assertUserGroupRole gInfo GRObserver -- can still delete messages sent earlier - let msgIds = itemsMsgIds items - events = L.nonEmpty $ map (\msgId -> XMsgDel msgId Nothing (toMsgScope gInfo <$> chatScopeInfo) False) msgIds - mapM_ (sendGroupMessages user gInfo Nothing False recipients) events + let signedEvents = L.nonEmpty $ mapMaybe (delEventSigned gInfo chatScopeInfo False) items + mapM_ (sendGroupSignedMessages user gInfo Nothing False recipients) signedEvents delGroupChatItems user gInfo chatScopeInfo items False CIDMHistory -> do unless (publicGroupEditor gInfo (membership gInfo)) $ throwChatError CEInvalidChatItemDelete recipients <- getGroupRecipients cxt user gInfo chatScopeInfo groupKnockingVersion - let msgIds = itemsMsgIds items - events = L.nonEmpty $ map (\msgId -> XMsgDel msgId Nothing (toMsgScope gInfo <$> chatScopeInfo) True) msgIds - mapM_ (sendGroupMessages user gInfo Nothing False recipients) events + let signedEvents = L.nonEmpty $ mapMaybe (delEventSigned gInfo chatScopeInfo True) items + mapM_ (sendGroupSignedMessages user gInfo Nothing False recipients) signedEvents delGroupChatItems user gInfo chatScopeInfo items False pure $ CRChatItemsDeleted user deletions True False CTLocal -> do @@ -840,6 +839,15 @@ processChatCommand cxt nm = \case SMDRcv -> False itemsMsgIds :: [CChatItem c] -> [SharedMsgId] itemsMsgIds = mapMaybe (\(CChatItem _ ChatItem {meta = CIMeta {itemSharedMsgId}}) -> itemSharedMsgId) + -- history delete always signs (attributable owner action); self-delete signs iff the target was held signed (deniability) + delEventSigned :: GroupInfo -> Maybe GroupChatScopeInfo -> Bool -> CChatItem 'CTGroup -> Maybe (Maybe MsgSigning, ChatMsgEvent 'Json) + delEventSigned gInfo chatScopeInfo onlyHistory (CChatItem _ ChatItem {meta = CIMeta {itemSharedMsgId, msgVerified}}) = + delEvent <$> itemSharedMsgId + where + delEvent msgId = + let evt = XMsgDel msgId Nothing (toMsgScope gInfo <$> chatScopeInfo) onlyHistory + in (groupMsgSigning (onlyHistory || itemSigned) gInfo evt, evt) + itemSigned = case msgVerified of MVSigned _ -> True; _ -> False APIDeleteMemberChatItem gId itemIds -> withUser $ \user -> withGroupLock "deleteChatItem" gId $ do (gInfo, items) <- getCommandGroupChatItems user gId itemIds -- TODO [knocking] check scope is Nothing for all items? (prohibit moderation in support chats?) @@ -907,7 +915,7 @@ processChatCommand cxt nm = \case let itemMemberId = memberId' <$> chatItemMember g ci rs <- withFastStore' $ \db -> getGroupReactions db g membership itemMemberId itemSharedMId True checkReactionAllowed rs - SndMessage {msgId} <- sendGroupMessage user g scope recipients (XMsgReact itemSharedMId itemMemberId (toMsgScope g <$> chatScopeInfo) reaction add) + SndMessage {msgId} <- sendGroupMessage user g scope recipients False (XMsgReact itemSharedMId itemMemberId (toMsgScope g <$> chatScopeInfo) reaction add) createdAt <- liftIO getCurrentTime reactions <- withFastStore' $ \db -> do setGroupReaction db g membership itemMemberId itemSharedMId True reaction add msgId createdAt @@ -993,7 +1001,7 @@ processChatCommand cxt nm = \case Just cmrs' -> withGroupLock "forwardChatItem, to group" toChatId $ do gInfo <- withFastStore $ \db -> getGroupInfo db cxt user toChatId - sendGroupContentMessages user gInfo toScope sendAsGroup False itemTTL cmrs' + sendGroupContentMessages user gInfo toScope sendAsGroup False itemTTL False cmrs' Nothing -> pure $ CRNewChatItems user [] CTLocal -> do cmrs <- prepareForward user @@ -2429,7 +2437,7 @@ processChatCommand cxt nm = \case _ -> throwCmdError "unsupported share target" processChatCommand cxt nm (APIShareChatMsgContent (ChatRef CTGroup groupId Nothing) sendRef) >>= \case CRChatMsgContent _ mc -> - processChatCommand cxt nm $ APISendMessages sendRef False Nothing [composedMessage Nothing mc] + processChatCommand cxt nm $ APISendMessages sendRef False Nothing False [composedMessage Nothing mc] r -> pure r SendMessage sendName msg -> withUser $ \user -> do let mc = MCText msg @@ -2438,7 +2446,7 @@ processChatCommand cxt nm = \case withFastStore' (\db -> runExceptT $ getContactIdByName db user name) >>= \case Right ctId -> do let sendRef = SRDirect ctId - processChatCommand cxt nm $ APISendMessages sendRef False Nothing [composedMessage Nothing mc] + processChatCommand cxt nm $ APISendMessages sendRef False Nothing False [composedMessage Nothing mc] Left _ -> withFastStore' (\db -> runExceptT $ getActiveMembersByName db cxt user name) >>= \case Right [(gInfo, member)] -> do @@ -2458,7 +2466,7 @@ processChatCommand cxt nm = \case GCSMemberSupport <$> mapM (getGroupMemberIdByName db user gId) mName_ (gInfo, cScope_,) <$> liftIO (getMessageMentions db user gId msg) let sendRef = SRGroup (groupId' gInfo) cScope_ (sendAsGroup' gInfo cScope_) - processChatCommand cxt nm $ APISendMessages sendRef False Nothing [ComposedMessage Nothing Nothing mc mentions] + processChatCommand cxt nm $ APISendMessages sendRef False Nothing False [ComposedMessage Nothing Nothing mc mentions] SNLocal -> do folderId <- withFastStore (`getUserNoteFolderId` user) processChatCommand cxt nm $ APICreateChatItems folderId [composedMessage Nothing mc] @@ -2478,7 +2486,7 @@ processChatCommand cxt nm = \case cr -> pure cr Just ctId -> do let sendRef = SRDirect ctId - processChatCommand cxt nm $ APISendMessages sendRef False Nothing [composedMessage Nothing mc] + processChatCommand cxt nm $ APISendMessages sendRef False Nothing False [composedMessage Nothing mc] AcceptMemberContact cName -> withUser $ \user -> do contactId <- withFastStore $ \db -> getContactIdByName db user cName processChatCommand cxt nm $ APIAcceptMemberContact contactId @@ -2486,7 +2494,7 @@ processChatCommand cxt nm = \case (chatRef, mentions) <- getChatRefAndMentions user chatName msg withSendRef user chatRef $ \sendRef -> do let mc = MCText msg - processChatCommand cxt nm $ APISendMessages sendRef True Nothing [ComposedMessage Nothing Nothing mc mentions] + processChatCommand cxt nm $ APISendMessages sendRef True Nothing False [ComposedMessage Nothing Nothing mc mentions] SendMessageBroadcast mc -> withUser $ \user -> do contacts <- withFastStore' $ \db -> getUserContacts db cxt user withChatLock "sendMessageBroadcast" $ do @@ -2531,7 +2539,7 @@ processChatCommand cxt nm = \case contactId <- withFastStore $ \db -> getContactIdByName db user cName quotedItemId <- withFastStore $ \db -> getDirectChatItemIdByText db userId contactId msgDir quotedMsg let mc = MCText msg - processChatCommand cxt nm $ APISendMessages (SRDirect contactId) False Nothing [ComposedMessage Nothing (Just quotedItemId) mc M.empty] + processChatCommand cxt nm $ APISendMessages (SRDirect contactId) False Nothing False [ComposedMessage Nothing (Just quotedItemId) mc M.empty] DeleteMessage chatName deletedMsg -> withUser $ \user -> do chatRef <- getChatRef user chatName deletedItemId <- getSentChatItemIdByText user chatRef deletedMsg @@ -2756,7 +2764,7 @@ processChatCommand cxt nm = \case modMs <- withFastStore' $ \db -> getGroupModerators db cxt user gInfo let rcpModMs' = filter memberCurrent modMs msg = XGrpLinkAcpt GAAccepted role (memberId' m) - void $ sendGroupMessage user gInfo scope ([m] <> rcpModMs') msg + void $ sendGroupMessage user gInfo scope ([m] <> rcpModMs') False msg when (maxVersion (memberChatVRange m) < groupKnockingVersion) $ forM_ (memberConn m) $ \mConn -> do let msg2 = XMsgNew $ mcSimple (MCText acceptedToGroupMessage) @@ -2858,7 +2866,7 @@ processChatCommand cxt nm = \case let mKey m = if isJust rosterVer then MemberKey <$> memberPubKey m else Nothing events = L.map (\m@GroupMember {memberId} -> XGrpMemRole memberId newRole (mKey m) rosterVer) memsToChange' recipients = filter memberCurrent members - (msgs_, _gsr) <- sendGroupMessages user gInfo Nothing False recipients events + (msgs_, _gsr) <- sendGroupMessages user gInfo Nothing False recipients False events let signed = any (either (const False) (isJust . signedMsg_)) msgs_ itemsData = zipWith (fmap . sndItemData) memsToChange (L.toList msgs_) cis_ <- saveSndChatItems user (CDGroupSnd gInfo Nothing) False itemsData Nothing False @@ -2906,7 +2914,7 @@ processChatCommand cxt nm = \case let mrs = if blockFlag then MRSBlocked else MRSUnrestricted events = L.map (\GroupMember {memberId} -> XGrpMemRestrict memberId MemberRestrictions {restriction = mrs}) blockMems' recipients = filter memberCurrent remainingMems - (msgs_, _gsr) <- sendGroupMessages_ user gInfo recipients events + (msgs_, _gsr) <- sendGroupMessages_ user gInfo recipients False events let msgSigned = any (either (const False) (isJust . signedMsg_)) msgs_ itemsData = zipWith (fmap . sndItemData) blockMems (L.toList msgs_) cis_ <- saveSndChatItems user (CDGroupSnd gInfo Nothing) False itemsData Nothing False @@ -2999,7 +3007,7 @@ processChatCommand cxt nm = \case Just memsToDelete' -> do let chatScope = toChatScope <$> chatScopeInfo events = L.map (\GroupMember {memberId} -> XGrpMemDel memberId withMessages rosterVer) memsToDelete' - (msgs_, _gsr) <- sendGroupMessages user gInfo chatScope False recipients events + (msgs_, _gsr) <- sendGroupMessages user gInfo chatScope False recipients False events let signed = any (either (const False) (isJust . signedMsg_)) msgs_ itemsData_ = zipWith (fmap . sndItemData) memsToDelete (L.toList msgs_) skipUnwantedItem = \case @@ -3060,7 +3068,7 @@ processChatCommand cxt nm = \case -- Relay leaving channel: create delivery job for cursor-based sending and async connection cleanup. leaveChannelRelay gInfo = do msg@SndMessage {msgBody, signedMsg_} <- - liftEither . runIdentity =<< lift (createSndMessages $ Identity (GroupId groupId, groupMsgSigning gInfo XGrpLeave, XGrpLeave)) + liftEither . runIdentity =<< lift (createSndMessages $ Identity (GroupId groupId, groupMsgSigning False gInfo XGrpLeave, XGrpLeave)) let body = encodeBatchElement signedMsg_ msgBody withFastStore' $ \db -> do deleteGroupDeliveryTasks db gInfo @@ -3286,7 +3294,7 @@ processChatCommand cxt nm = \case qiId <- getGroupChatItemIdByText db user gId cName quotedMsg (gInfo, qiId,) <$> liftIO (getMessageMentions db user gId msg) let mc = MCText msg - processChatCommand cxt nm $ APISendMessages (SRGroup (groupId' gInfo) Nothing (sendAsGroup' gInfo Nothing)) False Nothing [ComposedMessage Nothing (Just quotedItemId) mc mentions] + processChatCommand cxt nm $ APISendMessages (SRGroup (groupId' gInfo) Nothing (sendAsGroup' gInfo Nothing)) False Nothing False [ComposedMessage Nothing (Just quotedItemId) mc mentions] ClearNoteFolder -> withUser $ \user -> do folderId <- withFastStore (`getUserNoteFolderId` user) processChatCommand cxt nm $ APIClearChat (ChatRef CTLocal folderId Nothing) @@ -3327,7 +3335,7 @@ processChatCommand cxt nm = \case chatRef <- getChatRef user chatName case chatRef of ChatRef CTLocal folderId _ -> processChatCommand cxt nm $ APICreateChatItems folderId [composedMessage (Just f) (MCFile "")] - _ -> withSendRef user chatRef $ \sendRef -> processChatCommand cxt nm $ APISendMessages sendRef False Nothing [composedMessage (Just f) (MCFile "")] + _ -> withSendRef user chatRef $ \sendRef -> processChatCommand cxt nm $ APISendMessages sendRef False Nothing False [composedMessage (Just f) (MCFile "")] SendImage chatName f@(CryptoFile fPath _) -> withUser $ \user -> do chatRef <- getChatRef user chatName withSendRef user chatRef $ \sendRef -> do @@ -3336,7 +3344,7 @@ processChatCommand cxt nm = \case fileSize <- getFileSize filePath unless (fileSize <= maxImageSize) $ throwChatError CEFileImageSize {filePath} -- TODO include file description for preview - processChatCommand cxt nm $ APISendMessages sendRef False Nothing [composedMessage (Just f) (MCImage "" fixedImagePreview)] + processChatCommand cxt nm $ APISendMessages sendRef False Nothing False [composedMessage (Just f) (MCImage "" fixedImagePreview)] ForwardFile chatName fileId -> forwardFile chatName fileId SendFile ForwardImage chatName fileId -> forwardFile chatName fileId SendImage SendFileDescription _chatName _f -> throwCmdError "TODO" @@ -3375,7 +3383,7 @@ processChatCommand cxt nm = \case (gInfo, sharedMsgId) <- withFastStore $ \db -> (,) <$> getGroupInfo db cxt user groupId <*> getSharedMsgIdByFileId db userId fileId chatScopeInfo <- mapM (getChatScopeInfo cxt user) scope recipients <- getGroupRecipients cxt user gInfo chatScopeInfo groupKnockingVersion - void . sendGroupMessage user gInfo scope recipients $ XFileCancel sharedMsgId + void . sendGroupMessage user gInfo scope recipients False $ XFileCancel sharedMsgId pure $ CRSndFileCancelled user (Just aci) ftm fts (Just _, _) -> throwChatError $ CEFileInternal "invalid chat ref for file transfer" where @@ -3432,10 +3440,10 @@ processChatCommand cxt nm = \case let prefs' = setPreference f allowed_ $ Just userPreferences updateContactPrefs user ct prefs' SetGroupFeature (AGFNR f) gName enabled -> - updateGroupProfileByName gName $ \p -> + updateGroupProfileByName_ (Just $ toGroupFeature f) gName $ \p -> p {groupPreferences = Just . setGroupPreference f enabled $ groupPreferences p} SetGroupFeatureRole (AGFR f) gName enabled role -> - updateGroupProfileByName gName $ \p -> + updateGroupProfileByName_ (Just $ toGroupFeature f) gName $ \p -> p {groupPreferences = Just . setGroupPreferenceRole f enabled role $ groupPreferences p} SetGroupMemberAdmissionReview gName reviewAdmissionApplication -> updateGroupProfileByName gName $ \p@GroupProfile {memberAdmission} -> @@ -3921,14 +3929,14 @@ processChatCommand cxt nm = \case withStore $ \db -> getGroupMemberByMemberId db cxt user gInfo' businessId let p'' = p' {displayName, fullName, shortDescr, image} :: GroupProfile recipients = filter memberCurrentOrPending oldMs - void $ sendGroupMessage user gInfo' Nothing recipients (XGrpInfo p'') + void $ sendGroupMessage user gInfo' Nothing recipients False (XGrpInfo p'') let ps' = fromMaybe defaultBusinessGroupPrefs $ groupPreferences p' recipients = filter memberCurrentOrPending newMs - sendGroupMessage user gInfo' Nothing recipients $ XGrpPrefs ps' + sendGroupMessage user gInfo' Nothing recipients False $ XGrpPrefs ps' Nothing -> do void $ setGroupLinkData' nm user gInfo' recipients <- getRecipients - sendGroupMessage user gInfo' Nothing recipients (XGrpInfo p') + sendGroupMessage user gInfo' Nothing recipients False (XGrpInfo p') where getRecipients | useRelays' gInfo' = withFastStore' $ \db -> getGroupRelayMembers db cxt user gInfo' @@ -3957,8 +3965,9 @@ processChatCommand cxt nm = \case assertDeletable gInfo items assertUserGroupRole gInfo GRModerator let msgMemIds = itemsMsgMemIds gInfo items - events = L.nonEmpty $ map (\(msgId, memId) -> XMsgDel msgId memId (toMsgScope gInfo <$> chatScopeInfo) False) msgMemIds - mapM_ (sendGroupMessages_ user gInfo ms) events + -- moderation deletes always sign (attributable; avoids the catch-up-moderator divergence) + signedEvents = L.nonEmpty $ map (\(msgId, memId) -> let evt = XMsgDel msgId memId (toMsgScope gInfo <$> chatScopeInfo) False in (groupMsgSigning True gInfo evt, evt)) msgMemIds + mapM_ (sendGroupSignedMessages_ gInfo ms) signedEvents delGroupChatItems user gInfo chatScopeInfo items True where assertDeletable :: GroupInfo -> [CChatItem 'CTGroup] -> CM () @@ -3992,9 +4001,16 @@ processChatCommand cxt nm = \case then deleteGroupCIs user gInfo chatScopeInfo items m deletedTs else markGroupCIsDeleted user gInfo chatScopeInfo items m deletedTs updateGroupProfileByName :: GroupName -> (GroupProfile -> GroupProfile) -> CM ChatResponse - updateGroupProfileByName gName update = withUser $ \user -> do + updateGroupProfileByName = updateGroupProfileByName_ Nothing + updateGroupProfileByName_ :: Maybe GroupFeature -> GroupName -> (GroupProfile -> GroupProfile) -> CM ChatResponse + updateGroupProfileByName_ feature_ gName update = withUser $ \user -> do gInfo@GroupInfo {groupProfile = p} <- withStore $ \db -> getGroupIdByName db user gName >>= getGroupInfo db cxt user + forM_ feature_ $ \feature -> do + let channel = useRelays' gInfo + applicable = if channel then groupFeatureInChannel feature else groupFeatureInRegularGroup feature + unless applicable $ + throwCmdError $ T.unpack (groupFeatureNameText feature) <> " is not available in " <> (if channel then "channels" else "groups") runUpdateGroupProfile user gInfo $ update p withCurrentCall :: ContactId -> (User -> Contact -> Call -> CM (Maybe Call)) -> CM ChatResponse withCurrentCall ctId action = do @@ -4592,17 +4608,17 @@ processChatCommand cxt nm = \case quoteData ChatItem {content = CISndMsgContent qmc} = pure (qmc, CIQDirectSnd, True) quoteData ChatItem {content = CIRcvMsgContent qmc} = pure (qmc, CIQDirectRcv, False) quoteData _ = throwError SEInvalidQuote - sendGroupContentMessages :: User -> GroupInfo -> Maybe GroupChatScope -> ShowGroupAsSender -> Bool -> Maybe Int -> NonEmpty ComposedMessageReq -> CM ChatResponse - sendGroupContentMessages user gInfo scope showGroupAsSender live itemTTL cmrs = do + sendGroupContentMessages :: User -> GroupInfo -> Maybe GroupChatScope -> ShowGroupAsSender -> Bool -> Maybe Int -> Bool -> NonEmpty ComposedMessageReq -> CM ChatResponse + sendGroupContentMessages user gInfo scope showGroupAsSender live itemTTL sign cmrs = do assertMultiSendable live cmrs chatScopeInfo <- mapM (getChatScopeInfo cxt user) scope recipients <- getGroupRecipients cxt user gInfo chatScopeInfo modsCompatVersion - sendGroupContentMessages_ user gInfo scope showGroupAsSender chatScopeInfo recipients live itemTTL cmrs + sendGroupContentMessages_ user gInfo scope showGroupAsSender chatScopeInfo recipients live itemTTL sign cmrs where hasReport = any (\(ComposedMessage {msgContent}, _, _, _) -> isReport msgContent) cmrs modsCompatVersion = if hasReport then contentReportsVersion else groupKnockingVersion - sendGroupContentMessages_ :: User -> GroupInfo -> Maybe GroupChatScope -> ShowGroupAsSender -> Maybe GroupChatScopeInfo -> [GroupMember] -> Bool -> Maybe Int -> NonEmpty ComposedMessageReq -> CM ChatResponse - sendGroupContentMessages_ user gInfo@GroupInfo {groupId, membership} scope showGroupAsSender chatScopeInfo recipients live itemTTL cmrs = do + sendGroupContentMessages_ :: User -> GroupInfo -> Maybe GroupChatScope -> ShowGroupAsSender -> Maybe GroupChatScopeInfo -> [GroupMember] -> Bool -> Maybe Int -> Bool -> NonEmpty ComposedMessageReq -> CM ChatResponse + sendGroupContentMessages_ user gInfo@GroupInfo {groupId, membership} scope showGroupAsSender chatScopeInfo recipients live itemTTL sign cmrs = do forM_ allowedRole $ assertUserGroupRole gInfo assertGroupContentAllowed processComposedMessages @@ -4631,7 +4647,7 @@ processChatCommand cxt nm = \case (fInvs_, ciFiles_) <- L.unzip <$> setupSndFileTransfers (length recipients) timed_ <- sndGroupCITimed live gInfo itemTTL (chatMsgEvents, quotedItems_) <- L.unzip <$> prepareMsgs (L.zip cmrs fInvs_) timed_ - (msgs_, gsr) <- sendGroupMessages user gInfo Nothing showGroupAsSender recipients chatMsgEvents + (msgs_, gsr) <- sendGroupMessages user gInfo Nothing showGroupAsSender recipients sign chatMsgEvents let itemsData = prepareSndItemsData (L.toList cmrs) (L.toList ciFiles_) (L.toList quotedItems_) (L.toList msgs_) cis_ <- saveSndChatItems user (CDGroupSnd gInfo chatScopeInfo) showGroupAsSender itemsData timed_ live when (length cis_ /= length cmrs) $ logError "sendGroupContentMessages: cmrs and cis_ length mismatch" @@ -4650,7 +4666,11 @@ processChatCommand cxt nm = \case let User {profile = LocalProfile {localBadge}} = user fileSize <- checkSndFile (if incognitoMembership gInfo then Nothing else localBadge) file (fInv, ciFile) <- xftpSndFileTransfer user file fileSize n $ CGGroup gInfo recipients - pure (Just fInv, Just ciFile) + fInv' <- + if sign && useRelays' gInfo + then (\d -> (fInv :: FileInvitation) {fileDigest = Just d}) <$> cryptoFileDigest file + else pure fInv + pure (Just fInv', Just ciFile) Nothing -> pure (Nothing, Nothing) prepareMsgs :: NonEmpty (ComposedMessageReq, Maybe FileInvitation) -> Maybe CITimed -> CM (NonEmpty (ChatMsgEvent 'Json, Maybe (CIQuote 'CTGroup))) prepareMsgs cmsFileInvs timed_ = withFastStore $ \db -> @@ -5301,7 +5321,7 @@ chatCommandP = "/_get content types " *> (APIGetChatContentTypes <$> chatRefP), "/_get items " *> (APIGetChatItems <$> chatPaginationP <*> optional (" search=" *> textP)), "/_get item info " *> (APIGetChatItemInfo <$> chatRefP <* A.space <*> A.decimal), - "/_send " *> (APISendMessages <$> sendRefP <*> liveMessageP <*> sendMessageTTLP <*> (" json " *> jsonP <|> " text " *> composedMessagesTextP)), + "/_send " *> (APISendMessages <$> sendRefP <*> liveMessageP <*> sendMessageTTLP <*> signMessagesP <*> (" json " *> jsonP <|> " text " *> composedMessagesTextP)), "/_create tag " *> (APICreateChatTag <$> jsonP), "/_tags " *> (APISetChatTags <$> chatRefP <*> optional _strP), "/_delete tag " *> (APIDeleteChatTag <$> A.decimal), @@ -5587,6 +5607,7 @@ chatCommandP = "/set disappear @" *> (SetContactTimedMessages <$> displayNameP <*> optional (A.space *> timedMessagesEnabledP)), "/set disappear " *> (SetUserTimedMessages <$> (("yes" $> True) <|> ("no" $> False))), "/set reports #" *> (SetGroupFeature (AGFNR SGFReports) <$> displayNameP <*> _strP), + "/set signatures #" *> (SetGroupFeature (AGFNR SGFSignMessages) <$> displayNameP <*> _strP), "/set support #" *> (SetGroupFeature (AGFNR SGFSupport) <$> displayNameP <*> (A.space *> strP)), "/set links #" *> (SetGroupFeatureRole (AGFR SGFSimplexLinks) <$> displayNameP <*> _strP <*> optional memberRole), "/set admission review #" *> (SetGroupMemberAdmissionReview <$> displayNameP <*> (A.space *> memberCriteriaP)), @@ -5679,6 +5700,7 @@ chatCommandP = pure [composedMessage Nothing text] updatedMessagesTextP = (`UpdatedMessage` []) <$> mcTextP liveMessageP = " live=" *> onOffP <|> pure False + signMessagesP = " sign=" *> onOffP <|> pure False sendMessageTTLP = " ttl=" *> ((Just <$> A.decimal) <|> ("default" $> Nothing)) <|> pure Nothing receiptSettings = do enable <- onOffP diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 894a17968b..d7e8df7fcf 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -397,6 +397,12 @@ xftpSndFileTransfer_ user file@(CryptoFile filePath cfArgs) fileSize n contactOr ciFile = CIFile {fileId, fileName, fileSize, fileSource, fileStatus = CIFSSndStored, fileProtocol = FPXFTP} pure (fInv, ciFile, ft) +cryptoFileDigest :: CryptoFile -> CM FD.FileDigest +cryptoFileDigest (CryptoFile filePath cfArgs) = do + fsPath <- lift $ toFSFilePath filePath + r <- liftIO $ runExceptT $ CF.readFile (CryptoFile fsPath cfArgs) + either (throwChatError . CEInternalError . show) (pure . FD.FileDigest . LC.sha512Hash) r + xftpSndFileRedirect :: User -> FileTransferId -> ValidFileDescription 'FRecipient -> CM FileTransferMeta xftpSndFileRedirect user ftId vfd = do let fileName = "redirect.yaml" @@ -2148,16 +2154,19 @@ createSndMessages idsEvents = do encodeMessage sharedMsgId = encodeChatMessage maxEncodedMsgLength ChatMessage {chatVRange = vr, msgId = Just sharedMsgId, chatMsgEvent = evnt} -groupMsgSigning :: GroupInfo -> ChatMsgEvent e -> Maybe MsgSigning -groupMsgSigning gInfo@GroupInfo {membership = GroupMember {memberId}, groupKeys = Just GroupKeys {publicGroupId, memberPrivKey}} evt - | useRelays' gInfo && requiresSignature (toCMEventTag evt) = +groupMsgSigning :: Bool -> GroupInfo -> ChatMsgEvent e -> Maybe MsgSigning +groupMsgSigning sign gInfo@GroupInfo {membership = GroupMember {memberId}, groupKeys = Just GroupKeys {publicGroupId, memberPrivKey}} evt + | useRelays' gInfo && shouldSign = Just $ MsgSigning CBGroup (smpEncode (publicGroupId, memberId)) KRMember memberPrivKey -groupMsgSigning _ _ = Nothing + where + tag = toCMEventTag evt + shouldSign = requiresSignature tag || (sign && signableContent tag) +groupMsgSigning _ _ _ = Nothing sendGroupMemberMessages :: forall e. MsgEncodingI e => User -> GroupInfo -> Connection -> NonEmpty (ChatMsgEvent e) -> CM () sendGroupMemberMessages user gInfo@GroupInfo {groupId} conn events = do when (connDisabled conn) $ throwChatError (CEConnectionDisabled conn) - let idsEvts = L.map (\evt -> (GroupId groupId, groupMsgSigning gInfo evt, evt)) events + let idsEvts = L.map (\evt -> (GroupId groupId, groupMsgSigning False gInfo evt, evt)) events mode = if useRelays' gInfo then BMBinary else BMJson (errs, msgs) <- lift $ partitionEithers . L.toList <$> createSndMessages idsEvts unless (null errs) $ toView $ CEvtChatErrors errs @@ -2293,15 +2302,15 @@ deliverMessagesB msgReqs = do where updatePQ = updateConnPQSndEnabled db connId pqSndEnabled' -sendGroupMessage :: MsgEncodingI e => User -> GroupInfo -> Maybe GroupChatScope -> [GroupMember] -> ChatMsgEvent e -> CM SndMessage -sendGroupMessage user gInfo gcScope members chatMsgEvent = do - sendGroupMessages user gInfo gcScope False members (chatMsgEvent :| []) >>= \case +sendGroupMessage :: MsgEncodingI e => User -> GroupInfo -> Maybe GroupChatScope -> [GroupMember] -> Bool -> ChatMsgEvent e -> CM SndMessage +sendGroupMessage user gInfo gcScope members sign chatMsgEvent = do + sendGroupMessages user gInfo gcScope False members sign (chatMsgEvent :| []) >>= \case ((Right msg) :| [], _) -> pure msg _ -> throwChatError $ CEInternalError "sendGroupMessage: expected 1 message" sendGroupMessage' :: MsgEncodingI e => User -> GroupInfo -> [GroupMember] -> ChatMsgEvent e -> CM SndMessage sendGroupMessage' user gInfo members chatMsgEvent = - sendGroupMessages_ user gInfo members (chatMsgEvent :| []) >>= \case + sendGroupMessages_ user gInfo members False (chatMsgEvent :| []) >>= \case ((Right msg) :| [], _) -> pure msg _ -> throwChatError $ CEInternalError "sendGroupMessage': expected 1 message" @@ -2382,12 +2391,22 @@ sendRelayCapIfNeeded user gInfo = do void $ sendGroupMessage' user gInfo capableOwners (XGrpRelayCap RelayCapabilities {webDomain = currentWebDomain}) withStore' $ \db -> updateRelaySentWebDomain db gInfo currentWebDomain -sendGroupMessages :: MsgEncodingI e => User -> GroupInfo -> Maybe GroupChatScope -> ShowGroupAsSender -> [GroupMember] -> NonEmpty (ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult) -sendGroupMessages user gInfo scope asGroup members events = do +sendGroupMessages :: MsgEncodingI e => User -> GroupInfo -> Maybe GroupChatScope -> ShowGroupAsSender -> [GroupMember] -> Bool -> NonEmpty (ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult) +sendGroupMessages user gInfo scope asGroup members sign events = do + sendGroupProfileUpdate user gInfo scope asGroup members + sendGroupMessages_ user gInfo members sign events + +-- per-item signer variant of sendGroupMessages (used for per-item delete signing); preserves the profile-update prelude +sendGroupSignedMessages :: MsgEncodingI e => User -> GroupInfo -> Maybe GroupChatScope -> ShowGroupAsSender -> [GroupMember] -> NonEmpty (Maybe MsgSigning, ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult) +sendGroupSignedMessages user gInfo scope asGroup members signedEvents = do + sendGroupProfileUpdate user gInfo scope asGroup members + sendGroupSignedMessages_ gInfo members signedEvents + +sendGroupProfileUpdate :: User -> GroupInfo -> Maybe GroupChatScope -> ShowGroupAsSender -> [GroupMember] -> CM () +sendGroupProfileUpdate user gInfo scope asGroup members = -- TODO [knocking] send current profile to pending member after approval? when shouldSendProfileUpdate $ sendProfileUpdate `catchAllErrors` eToView - sendGroupMessages_ user gInfo members events where User {profile = p, userMemberProfileUpdatedAt} = user GroupInfo {userMemberProfileSentAt} = gInfo @@ -2414,9 +2433,12 @@ data GroupSndResult = GroupSndResult forwarded :: [GroupMember] } -sendGroupMessages_ :: MsgEncodingI e => User -> GroupInfo -> [GroupMember] -> NonEmpty (ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult) -sendGroupMessages_ _user gInfo@GroupInfo {groupId} recipientMembers events = do - let idsEvts = L.map (\evt -> (GroupId groupId, groupMsgSigning gInfo evt, evt)) events +sendGroupMessages_ :: MsgEncodingI e => User -> GroupInfo -> [GroupMember] -> Bool -> NonEmpty (ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult) +sendGroupMessages_ _user gInfo recipientMembers sign events = + sendGroupSignedMessages_ gInfo recipientMembers $ L.map (\evt -> (groupMsgSigning sign gInfo evt, evt)) events + +sendGroupSignedMessages_ :: MsgEncodingI e => GroupInfo -> [GroupMember] -> NonEmpty (Maybe MsgSigning, ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult) +sendGroupSignedMessages_ gInfo@GroupInfo {groupId} recipientMembers signedEvents = do sndMsgs_ <- lift $ createSndMessages idsEvts recipientMembers' <- liftIO $ shuffleMembers recipientMembers let msgFlags = MsgFlags {notification = any (hasNotification . toCMEventTag) events} @@ -2437,6 +2459,8 @@ sendGroupMessages_ _user gInfo@GroupInfo {groupId} recipientMembers events = do pending = zipWith3 (\mId pReq r -> (mId, fmap snd pReq, r)) pendingMemIds pendingReqs stored pure (sndMsgs_, GroupSndResult {sentTo, pending, forwarded}) where + events = L.map snd signedEvents + idsEvts = L.map (\(signing, evt) -> (GroupId groupId, signing, evt)) signedEvents shuffleMembers :: [GroupMember] -> IO [GroupMember] shuffleMembers ms = do let (adminMs, otherMs) = partition isAdmin ms @@ -2691,7 +2715,7 @@ saveSndChatItems user cd showGroupAsSender itemsData itemTimed live = do let hasLink_ = ciContentHasLink content (snd itemTexts) ciId <- createNewSndChatItem db user cd showGroupAsSender msg content quotedItem itemForwarded itemTimed live hasLink_ createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt - let ci = mkChatItem_ cd showGroupAsSender ciId content itemTexts ciFile quotedItem (Just sharedMsgId) itemForwarded itemTimed live False hasLink_ createdAt Nothing (MSSVerified <$ signedMsg_) createdAt + let ci = mkChatItem_ cd showGroupAsSender ciId content itemTexts ciFile quotedItem (Just sharedMsgId) itemForwarded itemTimed live False hasLink_ createdAt Nothing (toMsgVerified False (MSSVerified <$ signedMsg_)) createdAt Right <$> case cd of CDGroupSnd g _scope | not (null itemMentions) -> createGroupCIMentions db g ci itemMentions _ -> pure ci @@ -2722,7 +2746,7 @@ saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, msgSigned, forwardedByMem hasLink_ = ciContentHasLink content ft_ (ciId, quotedItem, itemForwarded) <- createNewRcvChatItem db user cd msg sharedMsgId_ content itemTimed live userMention hasLink_ brokerTs createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt - let ci = mkChatItem_ cd showAsGroup ciId content (t, ft_) ciFile quotedItem sharedMsgId_ itemForwarded itemTimed live userMention hasLink_ brokerTs forwardedByMember msgSigned createdAt + let ci = mkChatItem_ cd showAsGroup ciId content (t, ft_) ciFile quotedItem sharedMsgId_ itemForwarded itemTimed live userMention hasLink_ brokerTs forwardedByMember (toMsgVerified (signMessagesRequired cd) msgSigned) createdAt ci' <- case toChatInfo cd of GroupChat g _ | not (null mentions') -> createGroupCIMentions db g ci mentions' _ -> pure ci @@ -2746,16 +2770,16 @@ saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, msgSigned, forwardedByMem _ -> Nothing -- TODO [mentions] optimize by avoiding unnecessary parsing -mkChatItem :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ShowGroupAsSender -> ChatItemId -> CIContent d -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> Maybe MsgSigStatus -> UTCTime -> ChatItem c d -mkChatItem cd showGroupAsSender ciId content file quotedItem sharedMsgId itemForwarded itemTimed live userMention itemTs forwardedByMember msgSigned currentTs = +mkChatItem :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ShowGroupAsSender -> ChatItemId -> CIContent d -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> MsgVerified -> UTCTime -> ChatItem c d +mkChatItem cd showGroupAsSender ciId content file quotedItem sharedMsgId itemForwarded itemTimed live userMention itemTs forwardedByMember msgVerified currentTs = let ts@(_, ft_) = ciContentTexts content hasLink_ = ciContentHasLink content ft_ - in mkChatItem_ cd showGroupAsSender ciId content ts file quotedItem sharedMsgId itemForwarded itemTimed live userMention hasLink_ itemTs forwardedByMember msgSigned currentTs + in mkChatItem_ cd showGroupAsSender ciId content ts file quotedItem sharedMsgId itemForwarded itemTimed live userMention hasLink_ itemTs forwardedByMember msgVerified currentTs -mkChatItem_ :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ShowGroupAsSender -> ChatItemId -> CIContent d -> (Text, Maybe MarkdownList) -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> Maybe MsgSigStatus -> UTCTime -> ChatItem c d -mkChatItem_ cd showGroupAsSender ciId content (itemText, formattedText) file quotedItem sharedMsgId itemForwarded itemTimed live userMention hasLink_ itemTs forwardedByMember msgSigned currentTs = +mkChatItem_ :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ShowGroupAsSender -> ChatItemId -> CIContent d -> (Text, Maybe MarkdownList) -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> MsgVerified -> UTCTime -> ChatItem c d +mkChatItem_ cd showGroupAsSender ciId content (itemText, formattedText) file quotedItem sharedMsgId itemForwarded itemTimed live userMention hasLink_ itemTs forwardedByMember msgVerified currentTs = let itemStatus = ciCreateStatus content - meta = mkCIMeta ciId content itemText itemStatus Nothing sharedMsgId itemForwarded Nothing False itemTimed (justTrue live) userMention hasLink_ currentTs itemTs forwardedByMember showGroupAsSender msgSigned currentTs currentTs + meta = mkCIMeta ciId content itemText itemStatus Nothing sharedMsgId itemForwarded Nothing False itemTimed (justTrue live) userMention hasLink_ currentTs itemTs forwardedByMember showGroupAsSender msgVerified currentTs currentTs in ChatItem {chatDir = toCIDirection cd, meta, content, mentions = M.empty, formattedText, quotedItem, reactions = [], file} ciContentHasLink :: CIContent d -> Maybe MarkdownList -> Bool @@ -2974,7 +2998,7 @@ createContactsFeatureItems user cts chatDir ciFeature ciOffer getPref = do cup' = getContactUserPreference f cups' groupFeatures :: GroupInfo -> [AGroupFeature] -groupFeatures g = if useRelays' g then channelGroupFeatures else allGroupFeatures +groupFeatures g = if useRelays' g then channelGroupFeatures else regularGroupFeatures createGroupFeatureChangedItems :: MsgDirectionI d => User -> ChatDirection 'CTGroup d -> (GroupFeature -> GroupPreference -> Maybe Int -> Maybe GroupMemberRole -> CIContent d) -> GroupInfo -> GroupInfo -> CM () createGroupFeatureChangedItems user cd ciContent GroupInfo {fullGroupPreferences = gps} g'@GroupInfo {fullGroupPreferences = gps'} = @@ -3042,8 +3066,9 @@ createChatItems user itemTs_ dirsCIContents = do where createACI (content, sharedMsgId, msgSigned) = do let hasLink_ = ciContentHasLink content Nothing - ciId <- createNewChatItemNoMsg db user cd showGroupAsSender content sharedMsgId hasLink_ msgSigned itemTs createdAt - let ci = mkChatItem cd showGroupAsSender ciId content Nothing Nothing Nothing Nothing Nothing False False itemTs Nothing msgSigned createdAt + msgVerified = toMsgVerified False msgSigned + ciId <- createNewChatItemNoMsg db user cd showGroupAsSender content sharedMsgId hasLink_ msgVerified itemTs createdAt + let ci = mkChatItem cd showGroupAsSender ciId content Nothing Nothing Nothing Nothing Nothing False False itemTs Nothing msgVerified createdAt pure $ AChatItem (chatTypeI @c) (msgDirection @d) (toChatInfo cd) ci -- rcvMem_ Nothing means message from channel - treated same as message from moderator, @@ -3076,9 +3101,9 @@ createLocalChatItems user cd itemsData createdAt = do createItem :: DB.Connection -> (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, Maybe MarkdownList)) -> IO (ChatItem 'CTLocal 'MDSnd) createItem db (content, ciFile, itemForwarded, ts@(_, ft_)) = do let hasLink_ = ciContentHasLink content ft_ - ciId <- createNewChatItem_ db user cd False Nothing Nothing content (Nothing, Nothing, Nothing, Nothing, Nothing) itemForwarded Nothing False False hasLink_ createdAt Nothing Nothing createdAt + ciId <- createNewChatItem_ db user cd False Nothing Nothing content (Nothing, Nothing, Nothing, Nothing, Nothing) itemForwarded Nothing False False hasLink_ createdAt Nothing MVUnsigned createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt - pure $ mkChatItem_ cd False ciId content ts ciFile Nothing Nothing itemForwarded Nothing False False hasLink_ createdAt Nothing Nothing createdAt + pure $ mkChatItem_ cd False ciId content ts ciFile Nothing Nothing itemForwarded Nothing False False hasLink_ createdAt Nothing MVUnsigned createdAt withUser' :: (User -> CM ChatResponse) -> CM ChatResponse withUser' action = diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 52d70f1fae..85118ce2ca 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -349,13 +349,24 @@ processAgentMsgRcvFile _corrId aFileId msg = do Just targetPath -> do fsTargetPath <- lift $ toFSFilePath targetPath renameFile xftpPath fsTargetPath - ci_ <- withStore $ \db -> do - liftIO $ do - updateRcvFileStatus db fileId FSComplete - updateCIFileStatus db user fileId CIFSRcvComplete - lookupChatItemByFileId db cxt user fileId - agentXFTPDeleteRcvFile aFileId fileId - toView $ maybe (CEvtRcvStandaloneFileComplete user fsTargetPath ft) (CEvtRcvFileComplete user) ci_ + badDigest <- case ft of + RcvFileTransfer {fileInvitation = FileInvitation {fileDigest = Just d}, cryptoArgs} -> + (/= d) <$> cryptoFileDigest (CryptoFile fsTargetPath cryptoArgs) + _ -> pure False + if badDigest + then do + aci_ <- resetRcvCIFileStatus user fileId (CIFSRcvError $ FileErrOther "file digest") + forM_ aci_ cleanupACIFile + agentXFTPDeleteRcvFile aFileId fileId + forM_ aci_ $ \aci -> toView $ CEvtChatItemUpdated user aci + else do + ci_ <- withStore $ \db -> do + liftIO $ do + updateRcvFileStatus db fileId FSComplete + updateCIFileStatus db user fileId CIFSRcvComplete + lookupChatItemByFileId db cxt user fileId + agentXFTPDeleteRcvFile aFileId fileId + toView $ maybe (CEvtRcvStandaloneFileComplete user fsTargetPath ft) (CEvtRcvFileComplete user) ci_ RFWARN e -> do ci <- withStore $ \db -> do liftIO $ updateCIFileStatus db user fileId (CIFSRcvWarning $ agentFileError e) @@ -1422,7 +1433,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = allRelayMembers events = XGrpRelayNew <$> newlyActive unless (null recipients) $ - void $ sendGroupMessages user gInfo Nothing False recipients events + void $ sendGroupMessages user gInfo Nothing False recipients False events where updateRelay :: DB.Connection -> GroupRelay -> ([GroupRelay], Bool, [ShortLinkContact]) -> IO ([GroupRelay], Bool, [ShortLinkContact]) updateRelay db relay@GroupRelay {relayLink, relayStatus} (acc, changed, newlyActiveLinks) = @@ -2145,21 +2156,25 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = createContentItem gInfo Nothing Nothing -- no delivery task - message already forwarded by relay pure Nothing - Just m@GroupMember {memberId} -> do - (gInfo', m', scopeInfo) <- mkGetMessageChatScope cxt user gInfo m content msgScope_ - if blockedByAdmin m' - then createBlockedByAdmin gInfo' (Just m') scopeInfo $> Nothing - else case prohibitedGroupContent gInfo' m' scopeInfo content ft_ fInv_ False of - Just f -> rejected gInfo' (Just m') scopeInfo f $> Nothing - Nothing -> - withStore' (\db -> getCIModeration db cxt user gInfo' memberId sharedMsgId_) >>= \case - Just ciModeration -> do - applyModeration gInfo' m' scopeInfo ciModeration - withStore' $ \db -> deleteCIModeration db gInfo' memberId sharedMsgId_ - pure Nothing - Nothing -> do - createContentItem gInfo' (Just m') scopeInfo - pure $ Just $ infoToDeliveryContext gInfo' scopeInfo sentAsGroup + Just m@GroupMember {memberId} + -- only an owner may post as the channel; a non-owner's signed asGroup post (e.g. relay-injected) must not render as the channel + | sentAsGroup && memberRole' m < GROwner -> + messageError "x.msg.new: member is not allowed to send as group" $> Nothing + | otherwise -> do + (gInfo', m', scopeInfo) <- mkGetMessageChatScope cxt user gInfo m content msgScope_ + if blockedByAdmin m' + then createBlockedByAdmin gInfo' (Just m') scopeInfo $> Nothing + else case prohibitedGroupContent gInfo' m' scopeInfo content ft_ fInv_ False of + Just f -> rejected gInfo' (Just m') scopeInfo f $> Nothing + Nothing -> + withStore' (\db -> getCIModeration db cxt user gInfo' memberId sharedMsgId_) >>= \case + Just ciModeration -> do + applyModeration gInfo' m' scopeInfo ciModeration + withStore' $ \db -> deleteCIModeration db gInfo' memberId sharedMsgId_ + pure Nothing + Nothing -> do + createContentItem gInfo' (Just m') scopeInfo + pure $ Just $ infoToDeliveryContext gInfo' scopeInfo sentAsGroup where rejected gInfo' m' scopeInfo f = newChatItem gInfo' m' scopeInfo (ciContentNoParse $ CIRcvGroupFeatureRejected f) Nothing Nothing False timed_ gInfo' = if forwarded then rcvCITimed_ (Just Nothing) itemTTL else rcvGroupCITimed gInfo' itemTTL @@ -2223,7 +2238,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = groupMsgToView cInfo ci' {reactions} groupMessageUpdate :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> MsgContent -> Map MemberName MsgMention -> Maybe MsgScope -> RcvMessage -> UTCTime -> Maybe Int -> Maybe Bool -> Maybe Bool -> CM (Maybe DeliveryTaskContext) - groupMessageUpdate gInfo@GroupInfo {groupId} m_ sharedMsgId mc mentions msgScope_ msg@RcvMessage {msgId} brokerTs ttl_ live_ asGroup_ + groupMessageUpdate gInfo@GroupInfo {groupId} m_ sharedMsgId mc mentions msgScope_ msg@RcvMessage {msgId, msgSigned} brokerTs ttl_ live_ asGroup_ | Just m <- m_, prohibitedSimplexLinks gInfo m mc ft_ = messageWarning ("x.msg.update ignored: feature not allowed " <> groupFeatureNameText GFSimplexLinks) $> Nothing | otherwise = do @@ -2275,15 +2290,24 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = Nothing -> getGroupChatItemBySharedMsgId db user gInfo Nothing sharedMsgId (cci,) <$> getGroupChatScopeInfoForItem db cxt user gInfo (cChatItemId cci) case cci of - CChatItem SMDRcv ci@ChatItem {chatDir = CIGroupRcv m', meta = CIMeta {itemLive}, content = CIRcvMsgContent oldMC} - | isSender m' -> updateCI False ci scopeInfo oldMC itemLive (Just $ memberId' m') + CChatItem SMDRcv ci@ChatItem {chatDir = CIGroupRcv m', meta = CIMeta {itemLive, msgVerified = itemVerified}, content = CIRcvMsgContent oldMC} + | isSender m' -> requireVerifiedEdit (CDGroupRcv gInfo scopeInfo m') itemVerified $ updateCI False ci scopeInfo oldMC itemLive (Just $ memberId' m') | otherwise -> messageError "x.msg.update: group member attempted to update a message of another member" $> Nothing - CChatItem SMDRcv ci@ChatItem {chatDir = CIChannelRcv, meta = CIMeta {itemLive}, content = CIRcvMsgContent oldMC} - | maybe True (\m -> memberRole' m == GROwner) m_ -> updateCI True ci scopeInfo oldMC itemLive Nothing + CChatItem SMDRcv ci@ChatItem {chatDir = CIChannelRcv, meta = CIMeta {itemLive, msgVerified = itemVerified}, content = CIRcvMsgContent oldMC} + | maybe True (\m -> memberRole' m == GROwner) m_ -> requireVerifiedEdit (CDChannelRcv gInfo scopeInfo) itemVerified $ updateCI True ci scopeInfo oldMC itemLive Nothing | otherwise -> messageError "x.msg.update: member attempted to update channel message" $> Nothing _ -> messageError "x.msg.update: invalid message update" $> Nothing where isSender m' = maybe False (\m -> sameMemberId (memberId' m) m') m_ + -- a verified item requires a verified edit (fail-closed): unsigned is a forgery (bad-signature item); signed-but-no-key is unverifiable (drop with a log) + requireVerifiedEdit :: ChatDirection 'CTGroup 'MDRcv -> MsgVerified -> CM (Maybe DeliveryTaskContext) -> CM (Maybe DeliveryTaskContext) + requireVerifiedEdit cd itemVerified action + | itemVerified == MVSigned MSSVerified = + case msgSigned of + Just MSSVerified -> action + Just MSSSignedNoKey -> logWarn "x.msg.update: unverified update of a signed item (no key to verify), dropped" $> Nothing + Nothing -> createInternalChatItem user cd (CIRcvGroupEvent RGEMsgBadSignature) (Just brokerTs) $> Nothing + | otherwise = action updateCI :: ShowGroupAsSender -> ChatItem 'CTGroup 'MDRcv -> Maybe GroupChatScopeInfo -> MsgContent -> Maybe Bool -> Maybe MemberId -> CM (Maybe DeliveryTaskContext) updateCI showGroupAsSender ci scopeInfo oldMC itemLive memberId = do let changed = mc /= oldMC @@ -2307,7 +2331,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = groupMessageDelete :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> Maybe MemberId -> Maybe MsgScope -> Bool -> RcvMessage -> UTCTime -> CM (Maybe DeliveryTaskContext) groupMessageDelete gInfo@GroupInfo {membership} m_ sharedMsgId sndMemberId_ scope_ onlyHistory rcvMsg brokerTs = findItem >>= \case - Right cci@(CChatItem _ ci@ChatItem {chatDir}) -> case (chatDir, m_) of + Right cci@(CChatItem _ ci@ChatItem {chatDir}) -> requireVerifiedDelete cci $ case (chatDir, m_) of (CIGroupRcv mem, Just m@GroupMember {memberId}) -> let msgMemberId = fromMaybe memberId sndMemberId_ isAuthor = sameMemberId memberId mem @@ -2340,6 +2364,9 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = | senderRole < GRModerator -> do messageError $ "x.msg.del: message not found, message of another member with insufficient member permissions, " <> tshow e pure Nothing + -- a forged unsigned moderation would pre-censor a not-yet-received post via CIModeration; require verified (relay moderation always signs) + | useRelays' gInfo && msgSigned /= Just MSSVerified -> + messageError ("x.msg.del: unverified moderation of message not yet received, " <> tshow e) $> Nothing | otherwise -> case scope_ of Just (MSMember scopeMemberId) -> withStore $ \db -> do @@ -2353,7 +2380,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = messageError ("x.msg.del: channel message not found, " <> tshow e) $> Nothing where isOwner = maybe True (\m -> memberRole' m == GROwner) m_ - RcvMessage {msgId} = rcvMsg + RcvMessage {msgId, msgSigned} = rcvMsg findItem = do let tryMemberLookup mId = withStore' (\db -> runExceptT $ getGroupMemberCIBySharedMsgId db user gInfo mId sharedMsgId) @@ -2383,6 +2410,23 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = | senderRole < GRModerator || senderRole < memberRole = messageError "x.msg.del: message of another member with insufficient member permissions" $> Nothing | otherwise = a + -- a verified item requires a verified delete (fail-closed): unsigned is a forgery (bad-signature item); signed-but-no-key is unverifiable (drop with a log) + requireVerifiedDelete :: CChatItem 'CTGroup -> CM (Maybe DeliveryTaskContext) -> CM (Maybe DeliveryTaskContext) + requireVerifiedDelete cci@(CChatItem _ ChatItem {chatDir, meta = CIMeta {msgVerified = itemVerified}}) action + | itemVerified == MVSigned MSSVerified = + case msgSigned of + Just MSSVerified -> action + Just MSSSignedNoKey -> logWarn "x.msg.del: unverified delete of a signed item (no key to verify), dropped" $> Nothing + Nothing -> do + scopeInfo <- withStore $ \db -> getGroupChatScopeInfoForItem db cxt user gInfo (cChatItemId cci) + let cd :: ChatDirection 'CTGroup 'MDRcv + cd = case chatDir of + CIGroupRcv mem -> CDGroupRcv gInfo scopeInfo mem + CIChannelRcv -> CDChannelRcv gInfo scopeInfo + CIGroupSnd -> CDGroupRcv gInfo scopeInfo membership + createInternalChatItem user cd (CIRcvGroupEvent RGEMsgBadSignature) (Just brokerTs) + pure Nothing + | otherwise = action delete :: CChatItem 'CTGroup -> Bool -> Maybe GroupMember -> CM (Maybe DeliveryTaskContext) delete cci asGroup byGroupMember = do scopeInfo <- withStore $ \db -> getGroupChatScopeInfoForItem db cxt user gInfo (cChatItemId cci) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 5800ab5bdd..ae0bbd0637 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -417,6 +417,12 @@ toChatInfo = \case CDLocalSnd l -> LocalChat l CDLocalRcv l -> LocalChat l +signMessagesRequired :: ChatDirection c d -> Bool +signMessagesRequired = \case + CDChannelRcv g _ -> groupFeatureAllowed SGFSignMessages g + CDGroupRcv g _ _ -> groupFeatureAllowed SGFSignMessages g + _ -> False + contactChatDeleted :: ChatDirection c d -> Bool contactChatDeleted = \case CDDirectSnd Contact {chatDeleted} -> chatDeleted @@ -517,7 +523,7 @@ data CIMeta (c :: ChatType) (d :: MsgDirection) = CIMeta editable :: Bool, forwardedByMember :: Maybe GroupMemberId, showGroupAsSender :: ShowGroupAsSender, - msgSigned :: Maybe MsgSigStatus, + msgVerified :: MsgVerified, createdAt :: UTCTime, updatedAt :: UTCTime } @@ -525,12 +531,12 @@ data CIMeta (c :: ChatType) (d :: MsgDirection) = CIMeta type ShowGroupAsSender = Bool -mkCIMeta :: forall c d. ChatTypeI c => ChatItemId -> CIContent d -> Text -> CIStatus d -> Maybe Bool -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe (CIDeleted c) -> Bool -> Maybe CITimed -> Maybe Bool -> Bool -> Bool -> UTCTime -> ChatItemTs -> Maybe GroupMemberId -> Bool -> Maybe MsgSigStatus -> UTCTime -> UTCTime -> CIMeta c d -mkCIMeta itemId itemContent itemText itemStatus sentViaProxy itemSharedMsgId itemForwarded itemDeleted itemEdited itemTimed itemLive userMention hasLink_ currentTs itemTs forwardedByMember showGroupAsSender msgSigned createdAt updatedAt = +mkCIMeta :: forall c d. ChatTypeI c => ChatItemId -> CIContent d -> Text -> CIStatus d -> Maybe Bool -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe (CIDeleted c) -> Bool -> Maybe CITimed -> Maybe Bool -> Bool -> Bool -> UTCTime -> ChatItemTs -> Maybe GroupMemberId -> Bool -> MsgVerified -> UTCTime -> UTCTime -> CIMeta c d +mkCIMeta itemId itemContent itemText itemStatus sentViaProxy itemSharedMsgId itemForwarded itemDeleted itemEdited itemTimed itemLive userMention hasLink_ currentTs itemTs forwardedByMember showGroupAsSender msgVerified createdAt updatedAt = let deletable = deletable' itemContent itemDeleted itemTs nominalDay currentTs editable = deletable && isNothing itemForwarded hasLink = BoolDef hasLink_ - in CIMeta {itemId, itemTs, itemText, itemStatus, sentViaProxy, itemSharedMsgId, itemForwarded, itemDeleted, itemEdited, itemTimed, itemLive, userMention, hasLink, deletable, editable, forwardedByMember, showGroupAsSender, msgSigned, createdAt, updatedAt} + in CIMeta {itemId, itemTs, itemText, itemStatus, sentViaProxy, itemSharedMsgId, itemForwarded, itemDeleted, itemEdited, itemTimed, itemLive, userMention, hasLink, deletable, editable, forwardedByMember, showGroupAsSender, msgVerified, createdAt, updatedAt} deletable' :: forall c d. ChatTypeI c => CIContent d -> Maybe (CIDeleted c) -> UTCTime -> NominalDiffTime -> UTCTime -> Bool deletable' itemContent itemDeleted itemTs allowedInterval currentTs = @@ -561,7 +567,7 @@ dummyMeta itemId ts itemText = editable = False, forwardedByMember = Nothing, showGroupAsSender = False, - msgSigned = Nothing, + msgVerified = MVUnsigned, createdAt = ts, updatedAt = ts } diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 2fb8361fdb..2863b20b4b 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -1350,6 +1350,14 @@ requiresSignature = \case XInfo_ -> True _ -> False +-- | Content events a member may sign (XMsgNew opt-in; XMsgUpdate/XMsgDel when the target was signed). +signableContent :: CMEventTag e -> Bool +signableContent = \case + XMsgNew_ -> True + XMsgUpdate_ -> True + XMsgDel_ -> True + _ -> False + -- TODO [relays] can be tightened โ€” sender keys are now disseminated via -- TODO prepended XGrpMemNew before forwarded XInfo/XGrpLeave reach the recipient. -- Allow signed but unverified XGrpLeave/XInfo between subscribers when sender's key is unknown. diff --git a/src/Simplex/Chat/Store/Delivery.hs b/src/Simplex/Chat/Store/Delivery.hs index 4600c13004..add8c64e7f 100644 --- a/src/Simplex/Chat/Store/Delivery.hs +++ b/src/Simplex/Chat/Store/Delivery.hs @@ -33,6 +33,7 @@ import qualified Data.Aeson as J import Data.ByteString.Char8 (ByteString) import Data.Int (Int64) import qualified Data.List.NonEmpty as L +import Data.Maybe (isNothing) import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock (UTCTime, getCurrentTime) @@ -155,7 +156,7 @@ getMsgDeliveryTask_ db taskId = toTask ((Only taskId') :. jobScopeRow :. (senderGMId, senderMemberId, senderMemberName, brokerTs, Binary msgBody, chatBinding_, sigs_, BI showGroupAsSender)) = case (toJobScope_ jobScopeRow, J.eitherDecodeStrict' msgBody) of (Just jobScope, Right chatMsg) -> - let fwdSender = if showGroupAsSender then FwdChannel else FwdMember senderMemberId senderMemberName + let fwdSender = if showGroupAsSender && isNothing chatBinding_ then FwdChannel else FwdMember senderMemberId senderMemberName -- Re-parsed from msg_body: validates stored content against current code. -- Signed: original bytes preserved (re-encoding would invalidate signature). -- Unsigned: re-encoded from parsed ChatMessage on forward (sanitizes content). diff --git a/src/Simplex/Chat/Store/Files.hs b/src/Simplex/Chat/Store/Files.hs index dee72731a8..762583b7a5 100644 --- a/src/Simplex/Chat/Store/Files.hs +++ b/src/Simplex/Chat/Store/Files.hs @@ -96,6 +96,7 @@ import Simplex.Chat.Messages.CIContent import Simplex.Chat.Store.Messages import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared +import Simplex.FileTransfer.Description (FileDigest) import Simplex.Chat.Types import Simplex.Messaging.Agent.Protocol (AgentMsgId, UserId) import Simplex.Messaging.Agent.Store.AgentStore (firstRow, firstRow', maybeFirstRow) @@ -447,7 +448,7 @@ createRcvFileTransfer db userId Contact {contactId, localDisplayName = c} f@File pure RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = f, fileStatus = RFSNew, fileType = FTNormal, rcvFileInline, senderDisplayName = c, chunkSize, cancelled = False, grpMemberId = Nothing, cryptoArgs = Nothing} createRcvGroupFileTransfer :: DB.Connection -> UserId -> GroupInfo -> Maybe GroupMember -> FileType -> Maybe SharedMsgId -> FileInvitation -> Maybe InlineFileMode -> Integer -> ExceptT StoreError IO RcvFileTransfer -createRcvGroupFileTransfer db userId GroupInfo {groupId, localDisplayName = gName} m_ fileType sharedMsgId_ f@FileInvitation {fileName, fileSize, fileConnReq, fileInline, fileDescr} rcvFileInline chunkSize = do +createRcvGroupFileTransfer db userId GroupInfo {groupId, localDisplayName = gName} m_ fileType sharedMsgId_ f@FileInvitation {fileName, fileSize, fileDigest, fileConnReq, fileInline, fileDescr} rcvFileInline chunkSize = do currentTs <- liftIO getCurrentTime rfd_ <- mapM (createRcvFD_ db userId currentTs) fileDescr let rfdId = (\RcvFileDescr {fileDescrId} -> fileDescrId) <$> rfd_ @@ -459,8 +460,8 @@ createRcvGroupFileTransfer db userId GroupInfo {groupId, localDisplayName = gNam fileId <- liftIO $ do DB.execute db - "INSERT INTO files (user_id, group_id, file_name, file_size, chunk_size, file_inline, ci_file_status, protocol, file_type, shared_msg_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)" - (userId, groupId, fileName, fileSize, chunkSize, fileInline, CIFSRcvInvitation, fileProtocol, fileType, sharedMsgId_, currentTs, currentTs) + "INSERT INTO files (user_id, group_id, file_name, file_size, chunk_size, file_inline, ci_file_status, protocol, file_type, shared_msg_id, created_at, updated_at, file_digest) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)" + ((userId, groupId, fileName, fileSize, chunkSize, fileInline, CIFSRcvInvitation, fileProtocol, fileType, sharedMsgId_, currentTs, currentTs) :. Only fileDigest) insertedRowId db liftIO $ DB.execute @@ -632,7 +633,7 @@ getRcvFileTransfer_ db userId fileId = do SELECT r.file_status, r.file_queue_info, r.group_member_id, f.file_name, f.file_size, f.chunk_size, f.cancelled, cs.local_display_name, m.local_display_name, f.file_path, f.file_crypto_key, f.file_crypto_nonce, r.file_inline, r.rcv_file_inline, - r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays, g.local_display_name, f.file_type + r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays, g.local_display_name, f.file_type, f.file_digest FROM rcv_files r JOIN files f USING (file_id) LEFT JOIN contacts cs ON cs.contact_id = f.contact_id @@ -646,9 +647,9 @@ getRcvFileTransfer_ db userId fileId = do where rcvFileTransfer :: Maybe RcvFileDescr -> - (FileStatus, Maybe ConnReqInvitation, Maybe Int64, String, Integer, Integer, Maybe BoolInt) :. (Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe InlineFileMode, Maybe InlineFileMode, Maybe AgentRcvFileId, BoolInt, BoolInt) :. (Maybe ContactName, FileType) -> + (FileStatus, Maybe ConnReqInvitation, Maybe Int64, String, Integer, Integer, Maybe BoolInt) :. (Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe InlineFileMode, Maybe InlineFileMode, Maybe AgentRcvFileId, BoolInt, BoolInt) :. (Maybe ContactName, FileType, Maybe FileDigest) -> ExceptT StoreError IO RcvFileTransfer - rcvFileTransfer rfd_ ((fileStatus', fileConnReq, grpMemberId, fileName, fileSize, chunkSize, cancelled_) :. (contactName_, memberName_, filePath_, fileKey, fileNonce, fileInline, rcvFileInline, agentRcvFileId, BI agentRcvFileDeleted, BI userApprovedRelays) :. (groupName_, fileType)) = + rcvFileTransfer rfd_ ((fileStatus', fileConnReq, grpMemberId, fileName, fileSize, chunkSize, cancelled_) :. (contactName_, memberName_, filePath_, fileKey, fileNonce, fileInline, rcvFileInline, agentRcvFileId, BI agentRcvFileDeleted, BI userApprovedRelays) :. (groupName_, fileType, fileDigest_)) = case contactName_ <|> memberName_ <|> groupName_ <|> standaloneName_ of Nothing -> throwError $ SERcvFileInvalid fileId Just name -> @@ -663,7 +664,7 @@ getRcvFileTransfer_ db userId fileId = do (Just _, Just _) -> Just "" -- filePath marks files that are accepted from contact or, in this case, set by createRcvDirectFileTransfer _ -> Nothing ft senderDisplayName fileStatus = - let fileInvitation = FileInvitation {fileName, fileSize, fileDigest = Nothing, fileConnReq, fileInline, fileDescr = Nothing} + let fileInvitation = FileInvitation {fileName, fileSize, fileDigest = fileDigest_, fileConnReq, fileInline, fileDescr = Nothing} cryptoArgs = CFArgs <$> fileKey <*> fileNonce xftpRcvFile = (\rfd -> XFTPRcvFile {rcvFileDescription = rfd, agentRcvFileId, agentRcvFileDeleted, userApprovedRelays}) <$> rfd_ in RcvFileTransfer {fileId, xftpRcvFile, fileInvitation, fileStatus, fileType, rcvFileInline, senderDisplayName, chunkSize, cancelled, grpMemberId, cryptoArgs} diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 3ddc04253a..3d0a92bd86 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -545,7 +545,7 @@ setSupportChatMemberAttention db cxt user g m memberAttention = do createNewSndChatItem :: DB.Connection -> User -> ChatDirection c 'MDSnd -> ShowGroupAsSender -> SndMessage -> CIContent 'MDSnd -> Maybe (CIQuote c) -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> UTCTime -> IO ChatItemId createNewSndChatItem db user chatDirection showGroupAsSender SndMessage {msgId, sharedMsgId, signedMsg_} ciContent quotedItem itemForwarded timed live hasLink createdAt = - createNewChatItem_ db user chatDirection showGroupAsSender createdByMsgId (Just sharedMsgId) ciContent quoteRow itemForwarded timed live False hasLink createdAt Nothing (MSSVerified <$ signedMsg_) createdAt + createNewChatItem_ db user chatDirection showGroupAsSender createdByMsgId (Just sharedMsgId) ciContent quoteRow itemForwarded timed live False hasLink createdAt Nothing (toMsgVerified False (MSSVerified <$ signedMsg_)) createdAt where createdByMsgId = if msgId == 0 then Nothing else Just msgId quoteRow :: NewQuoteRow @@ -562,7 +562,7 @@ createNewSndChatItem db user chatDirection showGroupAsSender SndMessage {msgId, createNewRcvChatItem :: ChatTypeQuotable c => DB.Connection -> User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> CIContent 'MDRcv -> Maybe CITimed -> Bool -> Bool -> Bool -> UTCTime -> UTCTime -> IO (ChatItemId, Maybe (CIQuote c), Maybe CIForwardedFrom) createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, msgSigned, forwardedByMember} sharedMsgId_ ciContent timed live userMention hasLink itemTs createdAt = do let showAsGroup = case chatDirection of CDChannelRcv {} -> True; _ -> False - ciId <- createNewChatItem_ db user chatDirection showAsGroup (Just msgId) sharedMsgId_ ciContent quoteRow itemForwarded timed live userMention hasLink itemTs forwardedByMember msgSigned createdAt + ciId <- createNewChatItem_ db user chatDirection showAsGroup (Just msgId) sharedMsgId_ ciContent quoteRow itemForwarded timed live userMention hasLink itemTs forwardedByMember (toMsgVerified (signMessagesRequired chatDirection) msgSigned) createdAt quotedItem <- mapM (getChatItemQuote_ db user chatDirection) quotedMsg pure (ciId, quotedItem, itemForwarded) where @@ -581,15 +581,15 @@ createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, msgS CDChannelRcv GroupInfo {membership = GroupMember {memberId = userMemberId}} _ -> (Just $ Just userMemberId == memberId, memberId) -createNewChatItemNoMsg :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> ShowGroupAsSender -> CIContent d -> Maybe SharedMsgId -> Bool -> Maybe MsgSigStatus -> UTCTime -> UTCTime -> IO ChatItemId -createNewChatItemNoMsg db user chatDirection showGroupAsSender ciContent sharedMsgId_ hasLink msgSigned itemTs = - createNewChatItem_ db user chatDirection showGroupAsSender Nothing sharedMsgId_ ciContent quoteRow Nothing Nothing False False hasLink itemTs Nothing msgSigned +createNewChatItemNoMsg :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> ShowGroupAsSender -> CIContent d -> Maybe SharedMsgId -> Bool -> MsgVerified -> UTCTime -> UTCTime -> IO ChatItemId +createNewChatItemNoMsg db user chatDirection showGroupAsSender ciContent sharedMsgId_ hasLink msgVerified itemTs = + createNewChatItem_ db user chatDirection showGroupAsSender Nothing sharedMsgId_ ciContent quoteRow Nothing Nothing False False hasLink itemTs Nothing msgVerified where quoteRow :: NewQuoteRow quoteRow = (Nothing, Nothing, Nothing, Nothing, Nothing) -createNewChatItem_ :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> ShowGroupAsSender -> Maybe MessageId -> Maybe SharedMsgId -> CIContent d -> NewQuoteRow -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> Bool -> UTCTime -> Maybe GroupMemberId -> Maybe MsgSigStatus -> UTCTime -> IO ChatItemId -createNewChatItem_ db User {userId} chatDirection showGroupAsSender msgId_ sharedMsgId ciContent quoteRow itemForwarded timed live userMention hasLink itemTs forwardedByMember msgSigned createdAt = do +createNewChatItem_ :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> ShowGroupAsSender -> Maybe MessageId -> Maybe SharedMsgId -> CIContent d -> NewQuoteRow -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> Bool -> UTCTime -> Maybe GroupMemberId -> MsgVerified -> UTCTime -> IO ChatItemId +createNewChatItem_ db User {userId} chatDirection showGroupAsSender msgId_ sharedMsgId ciContent quoteRow itemForwarded timed live userMention hasLink itemTs forwardedByMember msgVerified createdAt = do DB.execute db [sql| @@ -610,8 +610,8 @@ createNewChatItem_ db User {userId} chatDirection showGroupAsSender msgId_ share forM_ msgId_ $ \msgId -> insertChatItemMessage_ db ciId msgId createdAt pure ciId where - itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, Text, CIStatus d, Maybe MsgContentTag, Maybe SharedMsgId, Maybe GroupMemberId, BoolInt) :. (UTCTime, UTCTime, Maybe BoolInt, BoolInt, BoolInt, BoolInt, BoolInt, Maybe MsgSigStatus) :. (Maybe Int, Maybe UTCTime) - itemRow = (msgDirection @d, itemTs, ciContent, toCIContentTag ciContent, ciContentToText ciContent, ciCreateStatus ciContent, mcTag_, sharedMsgId, forwardedByMember, BI includeInHistory) :. (createdAt, createdAt, BI <$> justTrue live, BI userMention, BI hasLink, BI itemViewed, BI showGroupAsSender, msgSigned) :. ciTimedRow timed + itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, Text, CIStatus d, Maybe MsgContentTag, Maybe SharedMsgId, Maybe GroupMemberId, BoolInt) :. (UTCTime, UTCTime, Maybe BoolInt, BoolInt, BoolInt, BoolInt, BoolInt, MsgVerified) :. (Maybe Int, Maybe UTCTime) + itemRow = (msgDirection @d, itemTs, ciContent, toCIContentTag ciContent, ciContentToText ciContent, ciCreateStatus ciContent, mcTag_, sharedMsgId, forwardedByMember, BI includeInHistory) :. (createdAt, createdAt, BI <$> justTrue live, BI userMention, BI hasLink, BI itemViewed, BI showGroupAsSender, msgVerified) :. ciTimedRow timed quoteRow' = let (a, b, c, d, e) = quoteRow in (a, b, c, BI <$> d, e) idsRow :: (Maybe ContactId, Maybe GroupId, Maybe GroupMemberId, Maybe NoteFolderId) idsRow = case chatDirection of @@ -1116,7 +1116,7 @@ toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentTex _ -> Just (CIDeleted @'CTLocal deletedTs) itemEdited' = maybe False unBI itemEdited itemForwarded = toCIForwardedFrom forwardedFromRow - in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) userMention hasLink currentTs itemTs Nothing False msgSigned createdAt updatedAt + in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) userMention hasLink currentTs itemTs Nothing False (fromMaybe MVUnsigned msgSigned) createdAt updatedAt ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} @@ -2266,7 +2266,7 @@ updateLocalChatItemsRead db User {userId} noteFolderId = do type MaybeCIFIleRow = (Maybe Int64, Maybe String, Maybe Integer, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe ACIFileStatus, Maybe FileProtocol) -type ChatItemModeRow = (Maybe Int, Maybe UTCTime, Maybe BoolInt, BoolInt, BoolInt, Maybe MsgSigStatus) +type ChatItemModeRow = (Maybe Int, Maybe UTCTime, Maybe BoolInt, BoolInt, BoolInt, Maybe MsgVerified) type ChatItemForwardedFromRow = (Maybe CIForwardedFromTag, Maybe Text, Maybe MsgDirection, Maybe Int64, Maybe Int64, Maybe Int64) @@ -2323,7 +2323,7 @@ toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentT _ -> Just (CIDeleted @'CTDirect deletedTs) itemEdited' = maybe False unBI itemEdited itemForwarded = toCIForwardedFrom forwardedFromRow - in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) userMention hasLink currentTs itemTs Nothing False msgSigned createdAt updatedAt + in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) userMention hasLink currentTs itemTs Nothing False (fromMaybe MVUnsigned msgSigned) createdAt updatedAt ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} @@ -2412,7 +2412,7 @@ toGroupChatItem _ -> Just (maybe (CIDeleted @'CTGroup deletedTs) (CIModerated deletedTs) deletedByGroupMember_) itemEdited' = maybe False unBI itemEdited itemForwarded = toCIForwardedFrom forwardedFromRow - in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) userMention hasLink currentTs itemTs forwardedByMember showGroupAsSender msgSigned createdAt updatedAt + in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) userMention hasLink currentTs itemTs forwardedByMember showGroupAsSender (fromMaybe MVUnsigned msgSigned) createdAt updatedAt ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index a3335640a2..2e62542c4e 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -40,6 +40,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260601_relay_sent_web_domain import Simplex.Chat.Store.Postgres.Migrations.M20260602_group_roster import Simplex.Chat.Store.Postgres.Migrations.M20260603_simplex_name import Simplex.Chat.Store.Postgres.Migrations.M20260629_roster_catchup +import Simplex.Chat.Store.Postgres.Migrations.M20260707_file_digest import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -79,7 +80,8 @@ schemaMigrations = ("20260601_relay_sent_web_domain", m20260601_relay_sent_web_domain, Just down_m20260601_relay_sent_web_domain), ("20260602_group_roster", m20260602_group_roster, Just down_m20260602_group_roster), ("20260603_simplex_name", m20260603_simplex_name, Just down_m20260603_simplex_name), - ("20260629_roster_catchup", m20260629_roster_catchup, Just down_m20260629_roster_catchup) + ("20260629_roster_catchup", m20260629_roster_catchup, Just down_m20260629_roster_catchup), + ("20260707_file_digest", m20260707_file_digest, Just down_m20260707_file_digest) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260707_file_digest.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260707_file_digest.hs new file mode 100644 index 0000000000..56e4f00db7 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260707_file_digest.hs @@ -0,0 +1,19 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260707_file_digest where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260707_file_digest :: Text +m20260707_file_digest = + [r| +ALTER TABLE files ADD COLUMN file_digest BYTEA; +|] + +down_m20260707_file_digest :: Text +down_m20260707_file_digest = + [r| +ALTER TABLE files DROP COLUMN file_digest; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index 3b8e7530c8..930430d43d 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -758,7 +758,8 @@ CREATE TABLE test_chat_schema.files ( redirect_file_id bigint, shared_msg_id bytea, file_type text DEFAULT 'normal'::text NOT NULL, - roster_transfer_id bigint + roster_transfer_id bigint, + file_digest bytea ); @@ -985,7 +986,7 @@ CREATE TABLE test_chat_schema.groups ( public_member_count bigint, relay_request_retries bigint DEFAULT 0 NOT NULL, relay_request_delay bigint DEFAULT 0 NOT NULL, - relay_request_execute_at timestamp with time zone DEFAULT '1970-01-01 01:00:00+01'::timestamp with time zone NOT NULL, + relay_request_execute_at timestamp with time zone DEFAULT '1970-01-01 04:00:00+04'::timestamp with time zone NOT NULL, relay_inactive_at timestamp with time zone, relay_sent_web_domain text, roster_version bigint, diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index d135c0f742..c6ae71a58d 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -163,6 +163,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260601_relay_sent_web_domain import Simplex.Chat.Store.SQLite.Migrations.M20260602_group_roster import Simplex.Chat.Store.SQLite.Migrations.M20260603_simplex_name import Simplex.Chat.Store.SQLite.Migrations.M20260629_roster_catchup +import Simplex.Chat.Store.SQLite.Migrations.M20260707_file_digest import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -325,7 +326,8 @@ schemaMigrations = ("20260601_relay_sent_web_domain", m20260601_relay_sent_web_domain, Just down_m20260601_relay_sent_web_domain), ("20260602_group_roster", m20260602_group_roster, Just down_m20260602_group_roster), ("20260603_simplex_name", m20260603_simplex_name, Just down_m20260603_simplex_name), - ("20260629_roster_catchup", m20260629_roster_catchup, Just down_m20260629_roster_catchup) + ("20260629_roster_catchup", m20260629_roster_catchup, Just down_m20260629_roster_catchup), + ("20260707_file_digest", m20260707_file_digest, Just down_m20260707_file_digest) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260707_file_digest.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260707_file_digest.hs new file mode 100644 index 0000000000..086932aa46 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260707_file_digest.hs @@ -0,0 +1,18 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260707_file_digest where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20260707_file_digest :: Query +m20260707_file_digest = + [sql| +ALTER TABLE files ADD COLUMN file_digest BLOB; +|] + +down_m20260707_file_digest :: Query +down_m20260707_file_digest = + [sql| +ALTER TABLE files DROP COLUMN file_digest; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 5d7b56fc8f..19f7c83171 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -1705,7 +1705,7 @@ Query: SELECT r.file_status, r.file_queue_info, r.group_member_id, f.file_name, f.file_size, f.chunk_size, f.cancelled, cs.local_display_name, m.local_display_name, f.file_path, f.file_crypto_key, f.file_crypto_nonce, r.file_inline, r.rcv_file_inline, - r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays, g.local_display_name, f.file_type + r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays, g.local_display_name, f.file_type, f.file_digest FROM rcv_files r JOIN files f USING (file_id) LEFT JOIN contacts cs ON cs.contact_id = f.contact_id @@ -3796,7 +3796,16 @@ Query: SELECT g.group_id, g.conn_full_link_to_connect, g.conn_short_link_to_connect FROM groups g JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id WHERE g.user_id = ? AND gp.group_domain = ? AND g.group_domain_verified = 1 - + AND g.business_chat IS NOT NULL +Plan: +SEARCH g USING INDEX sqlite_autoindex_groups_2 (user_id=?) +SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT g.group_id, g.conn_full_link_to_connect, g.conn_short_link_to_connect FROM groups g + JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id + WHERE g.user_id = ? AND gp.group_domain = ? AND g.group_domain_verified = 1 + AND g.business_chat IS NULL Plan: SEARCH g USING INDEX sqlite_autoindex_groups_2 (user_id=?) SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) @@ -6966,7 +6975,7 @@ Plan: Query: INSERT INTO files (user_id, file_name, file_path, file_size, chunk_size, ci_file_status, protocol, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?) Plan: -Query: INSERT INTO files (user_id, group_id, file_name, file_size, chunk_size, file_inline, ci_file_status, protocol, file_type, shared_msg_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) +Query: INSERT INTO files (user_id, group_id, file_name, file_size, chunk_size, file_inline, ci_file_status, protocol, file_type, shared_msg_id, created_at, updated_at, file_digest) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: Query: INSERT INTO files (user_id, group_id, file_name, file_size, chunk_size, file_inline, ci_file_status, protocol, file_type, shared_msg_id, roster_transfer_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) @@ -7119,6 +7128,10 @@ Plan: SCAN m USING COVERING INDEX idx_group_members_user_id_local_display_name SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) +Query: SELECT chat_item_id FROM chat_items WHERE item_text LIKE '%' || ? || '%' ORDER BY chat_item_id DESC LIMIT 1 +Plan: +SCAN chat_items + Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND contact_id = ? AND shared_msg_id = ? AND item_sent = ? Plan: SEARCH chat_items USING INDEX idx_chat_items_direct_shared_msg_id (user_id=? AND contact_id=? AND shared_msg_id=?) @@ -7227,6 +7240,10 @@ Query: SELECT count(1) FROM chat_items WHERE chat_item_id > ? Plan: SEARCH chat_items USING INTEGER PRIMARY KEY (rowid>?) +Query: SELECT count(1) FROM files WHERE file_digest IS NOT NULL +Plan: +SCAN files + Query: SELECT count(1) FROM group_members Plan: SCAN group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id @@ -7431,6 +7448,10 @@ Query: SELECT sent_inv_queue_info FROM group_members WHERE group_member_id = ? A Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: SELECT shared_msg_id FROM chat_items WHERE shared_msg_id IS NOT NULL ORDER BY chat_item_id DESC LIMIT 1 +Plan: +SCAN chat_items + Query: SELECT should_sync FROM connections_sync WHERE connections_sync_id = 1 Plan: SEARCH connections_sync USING INTEGER PRIMARY KEY (rowid=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 01d53ad000..676dd7e312 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -296,7 +296,8 @@ CREATE TABLE files( redirect_file_id INTEGER REFERENCES files ON DELETE CASCADE, shared_msg_id BLOB, file_type TEXT NOT NULL DEFAULT 'normal', - roster_transfer_id INTEGER + roster_transfer_id INTEGER, + file_digest BLOB ) STRICT; CREATE TABLE snd_files( file_id INTEGER NOT NULL REFERENCES files ON DELETE CASCADE, diff --git a/src/Simplex/Chat/Types/Preferences.hs b/src/Simplex/Chat/Types/Preferences.hs index 64eaab2a4e..c26f187343 100644 --- a/src/Simplex/Chat/Types/Preferences.hs +++ b/src/Simplex/Chat/Types/Preferences.hs @@ -179,6 +179,7 @@ data GroupFeature | GFSupport | GFSessions | GFComments + | GFSignMessages deriving (Show) data SGroupFeature (f :: GroupFeature) where @@ -194,6 +195,7 @@ data SGroupFeature (f :: GroupFeature) where SGFSupport :: SGroupFeature 'GFSupport SGFSessions :: SGroupFeature 'GFSessions SGFComments :: SGroupFeature 'GFComments + SGFSignMessages :: SGroupFeature 'GFSignMessages deriving instance Show (SGroupFeature f) @@ -223,6 +225,7 @@ groupFeatureNameText = \case GFSupport -> "Chat with admins" GFSessions -> "Chat sessions" GFComments -> "Comments" + GFSignMessages -> "Sign messages" groupFeatureNameText' :: SGroupFeature f -> Text groupFeatureNameText' = groupFeatureNameText . toGroupFeature @@ -249,7 +252,8 @@ allGroupFeatures = AGF SGFSimplexLinks, AGF SGFReports, AGF SGFHistory, - AGF SGFSupport + AGF SGFSupport, + AGF SGFSignMessages ] -- Channels (public groups) show a subset of group features. Direct messages, voice, @@ -271,9 +275,31 @@ groupFeatureInChannel = \case GFSupport -> True GFSessions -> False GFComments -> False + GFSignMessages -> True + +-- Regular groups show a subset of group features. Signing is channel-only for now +-- (keys are not shared between members in regular groups), so it is excluded. +regularGroupFeatures :: [AGroupFeature] +regularGroupFeatures = filter (\(AGF f) -> groupFeatureInRegularGroup (toGroupFeature f)) allGroupFeatures + +groupFeatureInRegularGroup :: GroupFeature -> Bool +groupFeatureInRegularGroup = \case + GFTimedMessages -> True + GFDirectMessages -> True + GFFullDelete -> True + GFReactions -> True + GFVoice -> True + GFFiles -> True + GFSimplexLinks -> True + GFReports -> True + GFHistory -> True + GFSupport -> True + GFSessions -> False + GFComments -> False + GFSignMessages -> False groupPrefSel :: SGroupFeature f -> GroupPreferences -> Maybe (GroupFeaturePreference f) -groupPrefSel f GroupPreferences {timedMessages, directMessages, fullDelete, reactions, voice, files, simplexLinks, reports, history, support, sessions, comments} = case f of +groupPrefSel f GroupPreferences {timedMessages, directMessages, fullDelete, reactions, voice, files, simplexLinks, reports, history, support, sessions, comments, signMessages} = case f of SGFTimedMessages -> timedMessages SGFDirectMessages -> directMessages SGFFullDelete -> fullDelete @@ -286,6 +312,7 @@ groupPrefSel f GroupPreferences {timedMessages, directMessages, fullDelete, reac SGFSupport -> support SGFSessions -> sessions SGFComments -> comments + SGFSignMessages -> signMessages toGroupFeature :: SGroupFeature f -> GroupFeature toGroupFeature = \case @@ -301,6 +328,7 @@ toGroupFeature = \case SGFSupport -> GFSupport SGFSessions -> GFSessions SGFComments -> GFComments + SGFSignMessages -> GFSignMessages class GroupPreferenceI p where getGroupPreference :: SGroupFeature f -> p -> GroupFeaturePreference f @@ -312,7 +340,7 @@ instance GroupPreferenceI (Maybe GroupPreferences) where getGroupPreference pt prefs = fromMaybe (getGroupPreference pt defaultGroupPrefs) (groupPrefSel pt =<< prefs) instance GroupPreferenceI FullGroupPreferences where - getGroupPreference f FullGroupPreferences {timedMessages, directMessages, fullDelete, reactions, voice, files, simplexLinks, reports, history, support, sessions, comments} = case f of + getGroupPreference f FullGroupPreferences {timedMessages, directMessages, fullDelete, reactions, voice, files, simplexLinks, reports, history, support, sessions, comments, signMessages} = case f of SGFTimedMessages -> timedMessages SGFDirectMessages -> directMessages SGFFullDelete -> fullDelete @@ -325,6 +353,7 @@ instance GroupPreferenceI FullGroupPreferences where SGFSupport -> support SGFSessions -> sessions SGFComments -> comments + SGFSignMessages -> signMessages {-# INLINE getGroupPreference #-} -- collection of optional group preferences @@ -341,6 +370,7 @@ data GroupPreferences = GroupPreferences support :: Maybe SupportGroupPreference, sessions :: Maybe SessionsGroupPreference, comments :: Maybe CommentsGroupPreference, + signMessages :: Maybe SignMessagesGroupPreference, commands :: Maybe [ChatBotCommand] } deriving (Eq, Show) @@ -393,6 +423,7 @@ setGroupPreference_ f pref prefs = SGFSupport -> prefs {support = pref} SGFSessions -> prefs {sessions = pref} SGFComments -> prefs {comments = pref} + SGFSignMessages -> prefs {signMessages = pref} setGroupTimedMessagesPreference :: TimedMessagesGroupPreference -> Maybe GroupPreferences -> GroupPreferences setGroupTimedMessagesPreference pref prefs_ = @@ -437,6 +468,7 @@ data FullGroupPreferences = FullGroupPreferences support :: SupportGroupPreference, sessions :: SessionsGroupPreference, comments :: CommentsGroupPreference, + signMessages :: SignMessagesGroupPreference, commands :: ListDef ChatBotCommand } deriving (Eq, Show) @@ -508,11 +540,12 @@ defaultGroupPrefs = support = SupportGroupPreference {enable = FEOn}, sessions = SessionsGroupPreference {enable = FEOff, role = Nothing}, comments = CommentsGroupPreference {enable = FEOff, duration = Nothing}, + signMessages = SignMessagesGroupPreference {enable = FEOff}, commands = ListDef [] } emptyGroupPrefs :: GroupPreferences -emptyGroupPrefs = GroupPreferences Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing +emptyGroupPrefs = GroupPreferences Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing businessGroupPrefs :: Preferences -> GroupPreferences businessGroupPrefs Preferences {timedMessages, fullDelete, reactions, voice, files, sessions, commands} = @@ -546,6 +579,7 @@ defaultBusinessGroupPrefs = support = Just $ SupportGroupPreference FEOn, sessions = Just $ SessionsGroupPreference FEOn Nothing, comments = Just $ CommentsGroupPreference FEOff Nothing, + signMessages = Just $ SignMessagesGroupPreference FEOff, commands = Nothing } @@ -672,6 +706,10 @@ data ReportsGroupPreference = ReportsGroupPreference {enable :: GroupFeatureEnabled} deriving (Eq, Show) +data SignMessagesGroupPreference = SignMessagesGroupPreference + {enable :: GroupFeatureEnabled} + deriving (Eq, Show) + data HistoryGroupPreference = HistoryGroupPreference {enable :: GroupFeatureEnabled} deriving (Eq, Show) @@ -729,6 +767,9 @@ instance HasField "enable" SimplexLinksGroupPreference GroupFeatureEnabled where instance HasField "enable" ReportsGroupPreference GroupFeatureEnabled where hasField p@ReportsGroupPreference {enable} = (\e -> p {enable = e}, enable) +instance HasField "enable" SignMessagesGroupPreference GroupFeatureEnabled where + hasField p@SignMessagesGroupPreference {enable} = (\e -> p {enable = e}, enable) + instance HasField "enable" HistoryGroupPreference GroupFeatureEnabled where hasField p@HistoryGroupPreference {enable} = (\e -> p {enable = e}, enable) @@ -789,6 +830,12 @@ instance GroupFeatureI 'GFReports where groupPrefParam _ = Nothing groupPrefRole _ = Nothing +instance GroupFeatureI 'GFSignMessages where + type GroupFeaturePreference 'GFSignMessages = SignMessagesGroupPreference + sGroupFeature = SGFSignMessages + groupPrefParam _ = Nothing + groupPrefRole _ = Nothing + instance GroupFeatureI 'GFHistory where type GroupFeaturePreference 'GFHistory = HistoryGroupPreference sGroupFeature = SGFHistory @@ -821,6 +868,8 @@ instance GroupFeatureNoRoleI 'GFReactions instance GroupFeatureNoRoleI 'GFReports +instance GroupFeatureNoRoleI 'GFSignMessages + instance GroupFeatureNoRoleI 'GFHistory instance GroupFeatureNoRoleI 'GFSupport @@ -1020,6 +1069,7 @@ mergeGroupPreferences groupPreferences = support = pref SGFSupport, sessions = pref SGFSessions, comments = pref SGFComments, + signMessages = pref SGFSignMessages, commands = ListDef $ fromMaybe [] $ groupPreferences >>= commands_ } where @@ -1041,6 +1091,7 @@ toGroupPreferences groupPreferences@FullGroupPreferences {commands = ListDef cmd support = pref SGFSupport, sessions = pref SGFSessions, comments = pref SGFComments, + signMessages = pref SGFSignMessages, commands = Just cmds } where @@ -1167,6 +1218,12 @@ $(J.deriveJSON defaultJSON ''SimplexLinksGroupPreference) $(J.deriveJSON defaultJSON ''ReportsGroupPreference) +$(J.deriveToJSON defaultJSON ''SignMessagesGroupPreference) + +instance FromJSON SignMessagesGroupPreference where + parseJSON v = $(J.mkParseJSON defaultJSON ''SignMessagesGroupPreference) v + omittedField = Just SignMessagesGroupPreference {enable = FEOff} + $(J.deriveJSON defaultJSON ''HistoryGroupPreference) $(J.deriveToJSON defaultJSON ''SupportGroupPreference) diff --git a/src/Simplex/Chat/Types/Shared.hs b/src/Simplex/Chat/Types/Shared.hs index 296268b58f..2768ccd55b 100644 --- a/src/Simplex/Chat/Types/Shared.hs +++ b/src/Simplex/Chat/Types/Shared.hs @@ -13,7 +13,7 @@ import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Messaging.Agent.Store.DB (fromTextField_) import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix, enumJSON) +import Simplex.Messaging.Parsers (dropPrefix, enumJSON, sumTypeJSON) import Simplex.Messaging.Util ((<$?>)) data GroupMemberRole @@ -148,3 +148,31 @@ instance ToField MsgSigStatus where toField = toField . textEncode instance FromField MsgSigStatus where fromField = fromTextField_ textDecode $(JQ.deriveJSON (enumJSON $ dropPrefix "MSS") ''MsgSigStatus) + +data MsgVerified = MVSigned {sigStatus :: MsgSigStatus} | MVSigMissing | MVUnsigned + deriving (Eq, Show) + +instance TextEncoding MsgVerified where + textEncode = \case + MVSigned s -> textEncode s + MVSigMissing -> "sig_missing" + MVUnsigned -> "unsigned" + textDecode = \case + "sig_missing" -> Just MVSigMissing + "unsigned" -> Just MVUnsigned + s -> MVSigned <$> textDecode s + +instance ToField MsgVerified where toField = toField . textEncode + +instance FromField MsgVerified where fromField = fromTextField_ textDecode + +$(JQ.deriveToJSON (sumTypeJSON $ dropPrefix "MV") ''MsgVerified) + +instance FromJSON MsgVerified where + parseJSON = $(JQ.mkParseJSON (sumTypeJSON $ dropPrefix "MV") ''MsgVerified) + omittedField = Just MVUnsigned + +toMsgVerified :: Bool -> Maybe MsgSigStatus -> MsgVerified +toMsgVerified signRequired = \case + Just s -> MVSigned s + Nothing -> if signRequired then MVSigMissing else MVUnsigned diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 0edfff6d29..b47751350f 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -375,9 +375,9 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte Just CIFile {fileSource = Just (CryptoFile fp _)} -> Just fp _ -> Nothing testViewItem :: CChatItem c -> Maybe GroupMember -> Text - testViewItem (CChatItem _ ci@ChatItem {meta = CIMeta {itemText, msgSigned}}) membership_ = + testViewItem (CChatItem _ ci@ChatItem {meta = CIMeta {itemText, msgVerified}}) membership_ = let deleted_ = maybe "" (\t -> " [" <> t <> "]") (chatItemDeletedText ci membership_) - in itemText <> sigStatusStr msgSigned <> deleted_ + in itemText <> msgVerifiedStr msgVerified <> deleted_ unmuted :: User -> ChatInfo c -> ChatItem c d -> [StyledString] -> [StyledString] unmuted u chat ci@ChatItem {chatDir} = unmuted' u chat chatDir $ isUserMention ci unmutedReaction :: User -> ChatInfo c -> CIReaction c d -> [StyledString] -> [StyledString] @@ -395,6 +395,12 @@ sigStatusStr = \case Just MSSSignedNoKey -> " (signed, no key to verify)" Nothing -> "" +msgVerifiedStr :: IsString a => MsgVerified -> a +msgVerifiedStr = \case + MVSigned s -> sigStatusStr (Just s) + MVSigMissing -> " (signature missing)" + MVUnsigned -> "" + signedStr :: IsString a => Bool -> a signedStr signed = if signed then " (signed)" else "" @@ -677,7 +683,7 @@ viewChatItems ttyUser unmuted u chatItems ts tz testView | otherwise = ttyUser u [sShow (length chatItems) <> " new messages created"] viewChatItem :: forall c d. MsgDirectionI d => ChatInfo c -> ChatItem c d -> Bool -> CurrentTime -> TimeZone -> [StyledString] -viewChatItem chat ci@ChatItem {chatDir, meta = meta@CIMeta {itemForwarded, forwardedByMember, userMention, msgSigned}, content, quotedItem, file} doShow ts tz = +viewChatItem chat ci@ChatItem {chatDir, meta = meta@CIMeta {itemForwarded, forwardedByMember, userMention, msgVerified}, content, quotedItem, file} doShow ts tz = withGroupMsgForwarded . withItemDeleted <$> viewCI where viewCI = case chat of @@ -763,8 +769,8 @@ viewChatItem chat ci@ChatItem {chatDir, meta = meta@CIMeta {itemForwarded, forwa ("", Just _, []) -> [] ("", Just CIFile {fileName}, _) -> view dir context (MCText $ T.pack fileName) ts tz meta _ -> view dir context mc ts tz meta - showSndItem to = showItem $ sentWithTime_ ts tz [to <> plainContent content <> sigStatusStr msgSigned] meta - showRcvItem from = showItem $ receivedWithTime_ ts tz from [] meta [plainContent content <> sigStatusStr msgSigned] False + showSndItem to = showItem $ sentWithTime_ ts tz [to <> plainContent content <> msgVerifiedStr msgVerified] meta + showRcvItem from = showItem $ receivedWithTime_ ts tz from [] meta [plainContent content <> msgVerifiedStr msgVerified] False showSndItemProhibited to = showItem $ sentWithTime_ ts tz [to <> plainContent content <> " " <> prohibited] meta showRcvItemProhibited from = showItem $ receivedWithTime_ ts tz from [] meta [plainContent content <> " " <> prohibited] False showItem ss = if doShow then ss else [] @@ -2033,7 +2039,7 @@ viewGroupUpdated | null prefs = [] | otherwise = bold' "updated group preferences:" : prefs where - prefs = mapMaybe viewPref allGroupFeatures + prefs = mapMaybe viewPref (if useRelays' g' then channelGroupFeatures else regularGroupFeatures) viewPref (AGF f) | pref gps == pref gps' = Nothing | otherwise = Just . plain $ groupPreferenceText (pref gps') @@ -2061,7 +2067,7 @@ viewGroupProfile g@GroupInfo {groupProfile = GroupProfile {shortDescr, descripti <> maybe [] (\sd -> ["description: " <> plain sd]) shortDescr <> maybe [] (const ["has profile image"]) image <> maybe [] ((bold' "welcome message:" :) . map plain . T.lines) description - <> (bold' "group preferences:" : map viewPref allGroupFeatures) + <> (bold' "group preferences:" : map viewPref (if useRelays' g then channelGroupFeatures else regularGroupFeatures)) where viewPref (AGF f) = plain $ groupPreferenceText (pref gps) where @@ -2270,7 +2276,7 @@ viewReceivedUpdatedMessage :: StyledString -> [StyledString] -> MsgContent -> Cu viewReceivedUpdatedMessage = viewReceivedMessage_ True viewReceivedMessage_ :: Bool -> StyledString -> [StyledString] -> MsgContent -> CurrentTime -> TimeZone -> CIMeta c d -> [StyledString] -viewReceivedMessage_ updated from context mc ts tz meta = receivedWithTime_ ts tz from context meta (ttyMsgContent mc) updated +viewReceivedMessage_ updated from context mc ts tz meta@CIMeta {msgVerified} = receivedWithTime_ ts tz from context meta (appendLast (msgVerifiedStr msgVerified) $ ttyMsgContent mc) updated viewReceivedReaction :: StyledString -> [StyledString] -> StyledString -> CurrentTime -> TimeZone -> UTCTime -> [StyledString] viewReceivedReaction from styledMsg reactionText ts tz reactionTs = @@ -2308,7 +2314,7 @@ recent now tz time = do || (localNow < currentDay12 && localTime >= previousDay18 && localTimeDay < localNowDay) viewSentMessage :: StyledString -> [StyledString] -> MsgContent -> CurrentTime -> TimeZone -> CIMeta c d -> [StyledString] -viewSentMessage to context mc ts tz meta@CIMeta {itemEdited, itemDeleted, itemLive} = sentWithTime_ ts tz (prependFirst to $ context <> prependFirst (indent <> live) (ttyMsgContent mc)) meta +viewSentMessage to context mc ts tz meta@CIMeta {itemEdited, itemDeleted, itemLive, msgVerified} = sentWithTime_ ts tz (prependFirst to $ context <> prependFirst (indent <> live) (appendLast (msgVerifiedStr msgVerified) $ ttyMsgContent mc)) meta where indent = if null context then "" else " " live @@ -2365,6 +2371,10 @@ prependFirst :: StyledString -> [StyledString] -> [StyledString] prependFirst s [] = [s] prependFirst s (s' : ss) = (s <> s') : ss +appendLast :: StyledString -> [StyledString] -> [StyledString] +appendLast _ [] = [] +appendLast s ss = init ss <> [last ss <> s] + msgPlain :: Text -> [StyledString] msgPlain = map (styleMarkdownList . parseMarkdownList) . T.lines diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 18023ed12c..a3752881d9 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -36,7 +36,7 @@ import Simplex.Chat.Messages (CIMention (..), CIMentionMember (..), ChatItemId) import Simplex.Chat.Messages.Batch (encodeBinaryBatch, encodeFwdElement) import Simplex.Chat.Messages.CIContent (publicGroupNoE2EText) import Simplex.Chat.Options -import Simplex.Chat.Protocol (ChatMessage (ChatMessage), ChatMsgEvent (XGrpMemNew), FwdSender (FwdMember), GrpMsgForward (GrpMsgForward), MsgMention (..), MsgContent (..), VerifiedMsg (VMUnsigned), msgContentText) +import Simplex.Chat.Protocol (ChatMessage (ChatMessage), ChatMsgEvent (XGrpMemNew, XMsgUpdate, XMsgNew, XMsgDel), FwdSender (FwdMember), GrpMsgForward (GrpMsgForward), MsgContainer (..), MsgMention (..), MsgContent (..), VerifiedMsg (VMUnsigned), mcSimple, msgContentText) import Simplex.Chat.Types import Simplex.Chat.Types.MemberRelations (MemberRelation (..), getRelation, setRelation) import Simplex.Chat.Types.Shared (GroupMemberRole (..), GroupAcceptance (..)) @@ -332,6 +332,16 @@ chatGroupTests = do it "should compute sendAsGroup in CLI forward" testForwardCLISendAsGroup it "should update member message in channel" testChannelMemberMessageUpdate it "should delete member message in channel" testChannelMemberMessageDelete + describe "channel message signing" $ do + it "should sign member message and reuse signature on edit" testChannelMemberMessageSign + it "should reject unsigned update of a signed item" testChannelMemberUpdateEnforcement + it "should sign as-channel post and keep it displayed as the channel" testChannelAsGroupSign + it "should reject a non-owner posting as the channel" testChannelAsGroupSpoof + it "should sign self-delete of a signed item" testChannelMemberSelfDeleteSign + it "should reject unsigned delete of a signed item" testChannelMemberDeleteEnforcement + it "should always sign moderation delete" testChannelModerationDeleteSign + it "should verify signed file digest" testChannelSignedFile + it "should warn on missing signature when signing is required" testChannelSignMessagesRequired testGroupCheckMessages :: HasCallStack => TestParams -> IO () testGroupCheckMessages = @@ -10083,17 +10093,6 @@ testChannelRelayCannotForgePrivilegedMember ps = case rows of [Only mid] -> pure (MemberId mid) _ -> fail "expected exactly one owner member on the relay" - relayConnIdToMember :: TestCC -> T.Text -> IO ByteString - relayConnIdToMember cc name = do - rows <- withCCTransaction cc $ \db -> - DB.query - db - "SELECT c.agent_conn_id FROM connections c JOIN group_members m ON m.group_member_id = c.group_member_id WHERE m.local_display_name = ?" - (Only name) :: - IO [Only ByteString] - case rows of - (Only connId : _) -> pure connId - _ -> fail $ "no relay connection to member " <> T.unpack name forgedMemberRows :: TestCC -> T.Text -> IO [(T.Text, Maybe ByteString)] forgedMemberRows cc name = withCCTransaction cc $ \db -> @@ -12069,6 +12068,506 @@ testChannelMemberMessageDelete ps = eve <# "#team cath> [marked deleted] hello" ] +memberIdByName :: TestCC -> T.Text -> IO MemberId +memberIdByName cc name = do + rows <- withCCTransaction cc $ \db -> + DB.query db "SELECT member_id FROM group_members WHERE local_display_name = ?" (Only name) :: IO [Only ByteString] + case rows of + (Only mid : _) -> pure (MemberId mid) + _ -> fail $ "no member " <> T.unpack name + +relayConnIdToMember :: TestCC -> T.Text -> IO ByteString +relayConnIdToMember cc name = do + rows <- withCCTransaction cc $ \db -> + DB.query + db + "SELECT c.agent_conn_id FROM connections c JOIN group_members m ON m.group_member_id = c.group_member_id WHERE m.local_display_name = ?" + (Only name) :: + IO [Only ByteString] + case rows of + (Only connId : _) -> pure connId + _ -> fail $ "no relay connection to member " <> T.unpack name + +itemSharedMsgId :: TestCC -> IO SharedMsgId +itemSharedMsgId cc = do + rows <- withCCTransaction cc $ \db -> + DB.query_ db "SELECT shared_msg_id FROM chat_items WHERE shared_msg_id IS NOT NULL ORDER BY chat_item_id DESC LIMIT 1" :: IO [Only ByteString] + case rows of + (Only smid : _) -> pure (SharedMsgId smid) + _ -> fail "no shared_msg_id" + +testChannelMemberMessageSign :: HasCallStack => TestParams -> IO () +testChannelMemberMessageSign ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + promoteChannelMember "team" alice bob cath [dan, eve] + + -- member sends a signed message + cath ##> "/_send #1 sign=on text signed hello" + cath <# "#team signed hello (signed)" + bob <# "#team cath> signed hello (signed)" + concurrentlyN_ + [ alice <# "#team cath> signed hello (signed) [>>]", + do dan <### [EndsWith "updated to cath"] + dan <## "#team: bob introduced cath (Catherine) in the channel" + dan <# "#team cath> signed hello (signed) [>>]", + do eve <### [EndsWith "updated to cath"] + eve <## "#team: bob introduced cath (Catherine) in the channel" + eve <# "#team cath> signed hello (signed) [>>]" + ] + -- sender and recipient hold it signed + cath #$> ("/_get chat #1 count=100 search=signed hello", chat, [(1, "signed hello (signed)")]) + dan #$> ("/_get chat #1 count=100 search=signed hello", chat, [(0, "signed hello (signed)")]) + + -- editing a signed item reuses the signature + cathMsgId <- lastItemId cath + cath ##> ("/_update item #1 " <> cathMsgId <> " text signed hello edited") + cath <# "#team [edited] signed hello edited (signed)" + bob <# "#team cath> [edited] signed hello edited (signed)" + concurrentlyN_ + [ alice <# "#team cath> [edited] signed hello edited (signed)", + dan <# "#team cath> [edited] signed hello edited (signed)", + eve <# "#team cath> [edited] signed hello edited (signed)" + ] + cath #$> ("/_get chat #1 count=100 search=signed hello edited", chat, [(1, "signed hello edited (signed)")]) + dan #$> ("/_get chat #1 count=100 search=signed hello edited", chat, [(0, "signed hello edited (signed)")]) + + -- default send is unsigned, and holds no signature + cath #> "#team plain hello" + bob <# "#team cath> plain hello" + concurrentlyN_ + [ alice <# "#team cath> plain hello [>>]", + dan <# "#team cath> plain hello [>>]", + eve <# "#team cath> plain hello [>>]" + ] + cath #$> ("/_get chat #1 count=100 search=plain hello", chat, [(1, "plain hello")]) + dan #$> ("/_get chat #1 count=100 search=plain hello", chat, [(0, "plain hello")]) + +testChannelSignedFile :: HasCallStack => TestParams -> IO () +testChannelSignedFile ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> withXFTPServer $ do + xftpCLI ["rand", "./tests/tmp/testfile", "1mb"] `shouldReturn` ["File created: ./tests/tmp/testfile"] + createChannel1Relay "team" alice bob cath dan eve + promoteChannelMember "team" alice bob cath [dan, eve] + -- roster serves arrive as files that Postgres deletes without reusing the id (SQLite reuses + -- it), so ids run higher here. cath's send and the subscribers' (dan, eve) receive both + -- follow only their join roster (id 2). The relay (bob) also received the roster re-served + -- on cath's promotion, so its file is id 3. The owner (alice) has no roster file, so id 1. +#if defined(dbPostgres) + let fileId = 2 :: Int + relayFileId = 3 :: Int +#else + let fileId = 1 :: Int + relayFileId = 1 :: Int +#endif + + -- cath's first (signed) message introduces her to dan/eve + cath ##> "/_send #1 sign=on text hi" + cath <# "#team hi (signed)" + bob <# "#team cath> hi (signed)" + concurrentlyN_ + [ alice <# "#team cath> hi (signed) [>>]", + do dan <### [EndsWith "updated to cath"] + dan <## "#team: bob introduced cath (Catherine) in the channel" + dan <# "#team cath> hi (signed) [>>]", + do eve <### [EndsWith "updated to cath"] + eve <## "#team: bob introduced cath (Catherine) in the channel" + eve <# "#team cath> hi (signed) [>>]" + ] + + -- cath sends a signed file + cath ##> "/_send #1 sign=on json [{\"filePath\": \"./tests/tmp/testfile\", \"msgContent\": {\"text\":\"signed file\",\"type\":\"file\"}}]" + cath <# "#team signed file (signed)" + cath <# "/f #team ./tests/tmp/testfile" + cath <## ("use /fc " <> show fileId <> " to cancel sending") + cath <## ("completed uploading file " <> show fileId <> " (testfile) for #team") + + bob <# "#team cath> signed file (signed)" + bob <# "#team cath> sends file testfile (1.0 MiB / 1048576 bytes)" + bob <## ("use /fr " <> show relayFileId <> " [/ | ] to receive it") + + concurrentlyN_ + [ do alice <# "#team cath> signed file (signed) [>>]" + alice <# "#team cath> sends file testfile (1.0 MiB / 1048576 bytes) [>>]" + alice <## "use /fr 1 [/ | ] to receive it [>>]", + do dan <# "#team cath> signed file (signed) [>>]" + dan <# "#team cath> sends file testfile (1.0 MiB / 1048576 bytes) [>>]" + dan <## ("use /fr " <> show fileId <> " [/ | ] to receive it [>>]"), + do eve <# "#team cath> signed file (signed) [>>]" + eve <# "#team cath> sends file testfile (1.0 MiB / 1048576 bytes) [>>]" + eve <## ("use /fr " <> show fileId <> " [/ | ] to receive it [>>]") + ] + + -- dan downloads: the signed digest is verified and the file completes + dan ##> ("/fr " <> show fileId <> " ./tests/tmp") + dan + <### [ ConsoleString ("saving file " <> show fileId <> " from cath to ./tests/tmp/testfile_1"), + ConsoleString ("started receiving file " <> show fileId <> " (testfile) from cath") + ] + dan <## ("completed receiving file " <> show fileId <> " (testfile) from cath") + src <- B.readFile "./tests/tmp/testfile" + destDan <- B.readFile "./tests/tmp/testfile_1" + destDan `shouldBe` src + -- the signed digest was carried to dan and stored, so verification ran (not skipped) and passed + digestCount <- withCCTransaction dan $ \db -> + DB.query_ db "SELECT count(1) FROM files WHERE file_digest IS NOT NULL" :: IO [[Int]] + digestCount `shouldBe` [[1]] + +testChannelMemberUpdateEnforcement :: HasCallStack => TestParams -> IO () +testChannelMemberUpdateEnforcement ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + promoteChannelMember "team" alice bob cath [dan, eve] + + -- cath posts a signed message; dan holds it verified + cath ##> "/_send #1 sign=on text secret" + cath <# "#team secret (signed)" + bob <# "#team cath> secret (signed)" + concurrentlyN_ + [ alice <# "#team cath> secret (signed) [>>]", + do dan <### [EndsWith "updated to cath"] + dan <## "#team: bob introduced cath (Catherine) in the channel" + dan <# "#team cath> secret (signed) [>>]", + do eve <### [EndsWith "updated to cath"] + eve <## "#team: bob introduced cath (Catherine) in the channel" + eve <# "#team cath> secret (signed) [>>]" + ] + dan #$> ("/_get chat #1 count=100 search=secret", chat, [(0, "secret (signed)")]) + + -- the malicious relay forges an unsigned XMsgUpdate of cath's signed item to dan + cathMemId <- memberIdByName bob "cath" + sharedId <- itemSharedMsgId cath + connId <- relayConnIdToMember bob "dan" + ts <- getCurrentTime + let ChatController {smpAgent = bobAgent} = chatController bob + chatMsg = ChatMessage chatInitialVRange Nothing (XMsgUpdate sharedId (MCText "forged") M.empty Nothing Nothing Nothing Nothing) + fwd = GrpMsgForward (FwdMember cathMemId "cath") ts + body = encodeBinaryBatch [encodeFwdElement fwd (VMUnsigned chatMsg)] + sent <- runExceptT $ sendMessages bobAgent [(connId, PQEncOff, MsgFlags False, vrValue body)] + either (fail . show) (const $ pure ()) sent + -- dan rejects the unsigned mutation of the held-signed item (RGEMsgBadSignature, stored not shown live), + -- and the original signed content is not overwritten + threadDelay 2000000 + -- (critical) the forged content did NOT overwrite the original signed item + dan #$> ("/_get chat #1 count=100 search=secret", chat, [(0, "secret (signed)")]) + -- the rejection is recorded as a bad-signature item + dan #$> ("/_get chat #1 count=100 search=bad signature", chat, [(0, "message rejected: bad signature")]) + + -- a legitimate signed edit by cath is accepted + cathMsgId <- lastItemId cath + cath ##> ("/_update item #1 " <> cathMsgId <> " text secret edited") + cath <# "#team [edited] secret edited (signed)" + bob <# "#team cath> [edited] secret edited (signed)" + concurrentlyN_ + [ alice <# "#team cath> [edited] secret edited (signed)", + dan <# "#team cath> [edited] secret edited (signed)", + eve <# "#team cath> [edited] secret edited (signed)" + ] + dan #$> ("/_get chat #1 count=100 search=secret edited", chat, [(0, "secret edited (signed)")]) + dan #$> ("/_get chat #1 count=100 search=bad signature", chat, [(0, "message rejected: bad signature")]) + +testChannelAsGroupSign :: HasCallStack => TestParams -> IO () +testChannelAsGroupSign ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + -- owner posts as the channel, signed: verifiable AND displayed as the channel + alice ##> "/_send #1(as_group=on) sign=on text signed channel post" + alice <# "#team signed channel post (signed)" + bob <# "#team> signed channel post (signed)" + [cath, dan, eve] *<# "#team> signed channel post (signed) [>>]" + alice #$> ("/_get chat #1 count=100 search=signed channel post", chat, [(1, "signed channel post (signed)")]) + cath #$> ("/_get chat #1 count=100 search=signed channel post", chat, [(0, "signed channel post (signed)")]) + + -- owner posts as the channel, unsigned: anonymous (FwdChannel), no signature, still as the channel + alice ##> "/_send #1(as_group=on) text plain channel post" + alice <# "#team plain channel post" + bob <# "#team> plain channel post" + [cath, dan, eve] *<# "#team> plain channel post [>>]" + alice #$> ("/_get chat #1 count=100 search=plain channel post", chat, [(1, "plain channel post")]) + cath #$> ("/_get chat #1 count=100 search=plain channel post", chat, [(0, "plain channel post")]) + +testChannelSignMessagesRequired :: HasCallStack => TestParams -> IO () +testChannelSignMessagesRequired ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + promoteChannelMember "team" alice bob cath [dan, eve] + + -- owner requires signatures + alice ##> "/set signatures #team on" + alice <## "updated group preferences:" + alice <## "Sign messages: on" + concurrentlyN_ + [ do + bob <## "alice updated group #team: (signed)" + bob <## "updated group preferences:" + bob <## "Sign messages: on", + do + cath <## "alice updated group #team: (signed)" + cath <## "updated group preferences:" + cath <## "Sign messages: on", + do + dan <## "alice updated group #team: (signed)" + dan <## "updated group preferences:" + dan <## "Sign messages: on", + do + eve <## "alice updated group #team: (signed)" + eve <## "updated group preferences:" + eve <## "Sign messages: on" + ] + + -- owner posts as the channel, signed: verified for everyone, held signed in db + alice ##> "/_send #1(as_group=on) sign=on text signed by owner" + alice <# "#team signed by owner (signed)" + bob <# "#team> signed by owner (signed)" + [cath, dan, eve] *<# "#team> signed by owner (signed) [>>]" + alice #$> ("/_get chat #1 count=100 search=signed by owner", chat, [(1, "signed by owner (signed)")]) + dan #$> ("/_get chat #1 count=100 search=signed by owner", chat, [(0, "signed by owner (signed)")]) + + -- owner posts as the channel, unsigned: recipients warn (held in db), sender does not + alice ##> "/_send #1(as_group=on) text plain from owner" + alice <# "#team plain from owner" + bob <# "#team> plain from owner (signature missing)" + [cath, dan, eve] *<# "#team> plain from owner (signature missing) [>>]" + alice #$> ("/_get chat #1 count=100 search=plain from owner", chat, [(1, "plain from owner")]) + dan #$> ("/_get chat #1 count=100 search=plain from owner", chat, [(0, "plain from owner (signature missing)")]) + + -- promoted subscriber posts signed: verified for recipients (members are required to sign too) + cath ##> "/_send #1 sign=on text signed by member" + cath <# "#team signed by member (signed)" + bob <# "#team cath> signed by member (signed)" + concurrentlyN_ + [ alice <# "#team cath> signed by member (signed) [>>]", + do + dan <### [EndsWith "updated to cath"] + dan <## "#team: bob introduced cath (Catherine) in the channel" + dan <# "#team cath> signed by member (signed) [>>]", + do + eve <### [EndsWith "updated to cath"] + eve <## "#team: bob introduced cath (Catherine) in the channel" + eve <# "#team cath> signed by member (signed) [>>]" + ] + cath #$> ("/_get chat #1 count=100 search=signed by member", chat, [(1, "signed by member (signed)")]) + dan #$> ("/_get chat #1 count=100 search=signed by member", chat, [(0, "signed by member (signed)")]) + + -- promoted subscriber posts unsigned: recipients warn (held in db), sender does not + cath #> "#team plain from member" + bob <# "#team cath> plain from member (signature missing)" + concurrentlyN_ + [ alice <# "#team cath> plain from member (signature missing) [>>]", + dan <# "#team cath> plain from member (signature missing) [>>]", + eve <# "#team cath> plain from member (signature missing) [>>]" + ] + cath #$> ("/_get chat #1 count=100 search=plain from member", chat, [(1, "plain from member")]) + dan #$> ("/_get chat #1 count=100 search=plain from member", chat, [(0, "plain from member (signature missing)")]) + +testChannelAsGroupSpoof :: HasCallStack => TestParams -> IO () +testChannelAsGroupSpoof ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + promoteChannelMember "team" alice bob cath [dan, eve] + + -- cath posts legitimately (introduces cath to dan as a member) + cath #> "#team hi from cath" + bob <# "#team cath> hi from cath" + concurrentlyN_ + [ alice <# "#team cath> hi from cath [>>]", + do dan <### [EndsWith "updated to cath"] + dan <## "#team: bob introduced cath (Catherine) in the channel" + dan <# "#team cath> hi from cath [>>]", + do eve <### [EndsWith "updated to cath"] + eve <## "#team: bob introduced cath (Catherine) in the channel" + eve <# "#team cath> hi from cath [>>]" + ] + + -- the relay forges an asGroup=True post attributed to non-owner cath; dan rejects (owner guard, ยง2) + cathMemId <- memberIdByName bob "cath" + connId <- relayConnIdToMember bob "dan" + ts <- getCurrentTime + let ChatController {smpAgent = bobAgent} = chatController bob + container = (mcSimple (MCText "fake channel announcement")) {asGroup = Just True} + chatMsg = ChatMessage chatInitialVRange Nothing (XMsgNew container) + fwd = GrpMsgForward (FwdMember cathMemId "cath") ts + body = encodeBinaryBatch [encodeFwdElement fwd (VMUnsigned chatMsg)] + sent <- runExceptT $ sendMessages bobAgent [(connId, PQEncOff, MsgFlags False, vrValue body)] + either (fail . show) (const $ pure ()) sent + dan <##. "error: x.msg.new: member is not allowed to send as group" + -- not rendered as the channel: dan still holds only the legitimate member message + threadDelay 1000000 + dan #$> ("/_get chat #1 count=100 search=hi from cath", chat, [(0, "hi from cath")]) + +testChannelMemberSelfDeleteSign :: HasCallStack => TestParams -> IO () +testChannelMemberSelfDeleteSign ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + promoteChannelMember "team" alice bob cath [dan, eve] + + -- member sends a signed message; dan holds it verified + cath ##> "/_send #1 sign=on text signed hello" + cath <# "#team signed hello (signed)" + bob <# "#team cath> signed hello (signed)" + concurrentlyN_ + [ alice <# "#team cath> signed hello (signed) [>>]", + do dan <### [EndsWith "updated to cath"] + dan <## "#team: bob introduced cath (Catherine) in the channel" + dan <# "#team cath> signed hello (signed) [>>]", + do eve <### [EndsWith "updated to cath"] + eve <## "#team: bob introduced cath (Catherine) in the channel" + eve <# "#team cath> signed hello (signed) [>>]" + ] + dan #$> ("/_get chat #1 count=100 search=signed hello", chat, [(0, "signed hello (signed)")]) + + -- self-delete of the signed item: signed delete, dan (holding it signed) accepts + cathMsgId <- lastItemId cath + cath #$> ("/_delete item #1 " <> cathMsgId <> " broadcast", id, "message marked deleted") + bob <# "#team cath> [marked deleted] signed hello (signed)" + concurrentlyN_ + [ alice <# "#team cath> [marked deleted] signed hello (signed)", + dan <# "#team cath> [marked deleted] signed hello (signed)", + eve <# "#team cath> [marked deleted] signed hello (signed)" + ] + + -- self-delete of an unsigned item: unsigned delete, accepted (no enforcement) + cath #> "#team plain hello" + bob <# "#team cath> plain hello" + concurrentlyN_ + [ alice <# "#team cath> plain hello [>>]", + dan <# "#team cath> plain hello [>>]", + eve <# "#team cath> plain hello [>>]" + ] + cathMsgId2 <- lastItemId cath + cath #$> ("/_delete item #1 " <> cathMsgId2 <> " broadcast", id, "message marked deleted") + bob <# "#team cath> [marked deleted] plain hello" + concurrentlyN_ + [ alice <# "#team cath> [marked deleted] plain hello", + dan <# "#team cath> [marked deleted] plain hello", + eve <# "#team cath> [marked deleted] plain hello" + ] + +testChannelMemberDeleteEnforcement :: HasCallStack => TestParams -> IO () +testChannelMemberDeleteEnforcement ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + promoteChannelMember "team" alice bob cath [dan, eve] + + -- cath posts a signed message; dan holds it verified + cath ##> "/_send #1 sign=on text secret" + cath <# "#team secret (signed)" + bob <# "#team cath> secret (signed)" + concurrentlyN_ + [ alice <# "#team cath> secret (signed) [>>]", + do dan <### [EndsWith "updated to cath"] + dan <## "#team: bob introduced cath (Catherine) in the channel" + dan <# "#team cath> secret (signed) [>>]", + do eve <### [EndsWith "updated to cath"] + eve <## "#team: bob introduced cath (Catherine) in the channel" + eve <# "#team cath> secret (signed) [>>]" + ] + dan #$> ("/_get chat #1 count=100 search=secret", chat, [(0, "secret (signed)")]) + + -- the relay forges an unsigned XMsgDel of cath's signed item to dan + cathMemId <- memberIdByName bob "cath" + sharedId <- itemSharedMsgId cath + connId <- relayConnIdToMember bob "dan" + ts <- getCurrentTime + let ChatController {smpAgent = bobAgent} = chatController bob + chatMsg = ChatMessage chatInitialVRange Nothing (XMsgDel sharedId Nothing Nothing False) + fwd = GrpMsgForward (FwdMember cathMemId "cath") ts + body = encodeBinaryBatch [encodeFwdElement fwd (VMUnsigned chatMsg)] + sent <- runExceptT $ sendMessages bobAgent [(connId, PQEncOff, MsgFlags False, vrValue body)] + either (fail . show) (const $ pure ()) sent + -- dan rejects the unsigned delete of the held-signed item; item not deleted, rejection recorded + threadDelay 2000000 + dan #$> ("/_get chat #1 count=100 search=secret", chat, [(0, "secret (signed)")]) + dan #$> ("/_get chat #1 count=100 search=bad signature", chat, [(0, "message rejected: bad signature")]) + + -- a legitimate signed self-delete by cath is accepted + cathMsgId <- lastItemId cath + cath #$> ("/_delete item #1 " <> cathMsgId <> " broadcast", id, "message marked deleted") + bob <# "#team cath> [marked deleted] secret (signed)" + concurrentlyN_ + [ alice <# "#team cath> [marked deleted] secret (signed)", + dan <# "#team cath> [marked deleted] secret (signed)", + eve <# "#team cath> [marked deleted] secret (signed)" + ] + +testChannelModerationDeleteSign :: HasCallStack => TestParams -> IO () +testChannelModerationDeleteSign ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + promoteChannelMember "team" alice bob cath [dan, eve] + + -- cath posts a signed message; dan holds it verified + cath ##> "/_send #1 sign=on text moderated post" + cath <# "#team moderated post (signed)" + bob <# "#team cath> moderated post (signed)" + concurrentlyN_ + [ alice <# "#team cath> moderated post (signed) [>>]", + do dan <### [EndsWith "updated to cath"] + dan <## "#team: bob introduced cath (Catherine) in the channel" + dan <# "#team cath> moderated post (signed) [>>]", + do eve <### [EndsWith "updated to cath"] + eve <## "#team: bob introduced cath (Catherine) in the channel" + eve <# "#team cath> moderated post (signed) [>>]" + ] + dan #$> ("/_get chat #1 count=100 search=moderated post", chat, [(0, "moderated post (signed)")]) + + -- owner moderation-deletes cath's signed post; the always-signed delete is accepted by dan (holding it signed) + -- resolve alice's item id by text (not lastItemId) so a racing trailing event can't select the wrong item + catItemIdOnAlice <- itemIdByText alice "moderated post" + alice ##> ("/_delete member item #1 " <> catItemIdOnAlice) + alice <## "message marked deleted by you" + concurrentlyN_ + [ bob <# "#team cath> [marked deleted by alice] moderated post (signed)", + cath <# "#team cath> [marked deleted by alice] moderated post (signed)", + dan <# "#team cath> [marked deleted by alice] moderated post (signed)", + eve <# "#team cath> [marked deleted by alice] moderated post (signed)" + ] + where + itemIdByText :: TestCC -> T.Text -> IO String + itemIdByText cc t = do + rows <- withCCTransaction cc $ \db -> + DB.query db "SELECT chat_item_id FROM chat_items WHERE item_text LIKE '%' || ? || '%' ORDER BY chat_item_id DESC LIMIT 1" (Only t) :: IO [Only Int64] + case rows of + (Only i : _) -> pure (show i) + _ -> fail $ "no item with text " <> T.unpack t + testGroupLinkContentFilter :: HasCallStack => TestParams -> IO () testGroupLinkContentFilter = testChat3 aliceProfile bobProfile cathProfile $ diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 12511ea53b..a38333c2a2 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -322,6 +322,7 @@ groupFeatures_ dir isChannel = <> [((dir, "Member reports: on"), Nothing, Nothing) | not isChannel] <> [((dir, "Recent history: on"), Nothing, Nothing)] <> [((dir, "Chat with admins: " <> (if isChannel then "off" else "on")), Nothing, Nothing)] + <> [((dir, "Sign messages: off"), Nothing, Nothing) | isChannel] businessGroupFeatures :: [(Int, String)] businessGroupFeatures = map (\(a, _, _) -> a) $ businessGroupFeatures'' 0 diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 3cfb367382..42ab8c2589 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -105,7 +105,7 @@ testChatPreferences :: Maybe Preferences testChatPreferences = Just Preferences {voice = Just VoicePreference {allow = FAYes}, files = Nothing, fullDelete = Nothing, timedMessages = Nothing, calls = Nothing, reactions = Just ReactionsPreference {allow = FAYes}, sessions = Nothing, commands = Nothing} testGroupPreferences :: Maybe GroupPreferences -testGroupPreferences = Just GroupPreferences {timedMessages = Nothing, directMessages = Nothing, reactions = Just ReactionsGroupPreference {enable = FEOn}, voice = Just VoiceGroupPreference {enable = FEOn, role = Nothing}, files = Nothing, fullDelete = Nothing, simplexLinks = Nothing, history = Nothing, reports = Nothing, support = Nothing, sessions = Nothing, comments = Nothing, commands = Nothing} +testGroupPreferences = Just GroupPreferences {timedMessages = Nothing, directMessages = Nothing, reactions = Just ReactionsGroupPreference {enable = FEOn}, voice = Just VoiceGroupPreference {enable = FEOn, role = Nothing}, files = Nothing, fullDelete = Nothing, simplexLinks = Nothing, history = Nothing, reports = Nothing, support = Nothing, sessions = Nothing, comments = Nothing, signMessages = Nothing, commands = Nothing} testProfile :: Profile testProfile = Profile {displayName = "alice", fullName = "Alice", shortDescr = Nothing, image = Just (ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="), peerType = Nothing, contactLink = Nothing, preferences = testChatPreferences, badge = Nothing, contactDomain = Nothing}