From 504ef253cbb53c165a8747a5facee9b810c28f2c Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sat, 25 Apr 2026 20:49:26 +0100 Subject: [PATCH] core, ui: item about no e2ee in public channels (#6886) * core, ui: item about no e2ee in public channels * fix, refactor * all tests * update bot api types --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- apps/ios/Shared/Views/Chat/ChatItemView.swift | 10 ++++++-- apps/ios/SimpleXChat/ChatTypes.swift | 11 +++++++-- .../chat/simplex/common/model/ChatModel.kt | 9 ++++--- .../common/views/chat/item/ChatItemView.kt | 24 ++++++++----------- .../commonMain/resources/MR/base/strings.xml | 1 + bots/api/TYPES.md | 1 + .../types/typescript/src/types.ts | 1 + src/Simplex/Chat/Library/Commands.hs | 4 ++-- src/Simplex/Chat/Library/Internal.hs | 6 ++--- src/Simplex/Chat/Library/Subscriber.hs | 8 +++---- src/Simplex/Chat/Messages/CIContent.hs | 20 ++++++++++++---- tests/ChatTests/Groups.hs | 3 ++- 12 files changed, 63 insertions(+), 35 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index d0ff1934ba..1839651daa 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -172,8 +172,8 @@ struct ChatItemContentView: View { case .rcvBlocked: deletedItemView() case let .sndDirectE2EEInfo(e2eeInfo): CIEventView(eventText: directE2EEInfoText(e2eeInfo)) case let .rcvDirectE2EEInfo(e2eeInfo): CIEventView(eventText: directE2EEInfoText(e2eeInfo)) - case .sndGroupE2EEInfo: CIEventView(eventText: e2eeInfoNoPQText()) - case .rcvGroupE2EEInfo: CIEventView(eventText: e2eeInfoNoPQText()) + case let .sndGroupE2EEInfo(e2eeInfo): CIEventView(eventText: groupE2EEInfoText(e2eeInfo)) + case let .rcvGroupE2EEInfo(e2eeInfo): CIEventView(eventText: groupE2EEInfoText(e2eeInfo)) case .chatBanner: EmptyView() case let .invalidJSON(json): CIInvalidJSONView(json: json) } @@ -257,6 +257,12 @@ struct ChatItemContentView: View { e2eeInfoText("Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery.") } + private func groupE2EEInfoText(_ info: E2EEInfo) -> Text { + info.public == true + ? e2eeInfoText("Messages in this channel are **not end-to-end encrypted**. Chat relays can see these messages.") + : e2eeInfoNoPQText() + } + private func e2eeInfoText(_ s: LocalizedStringKey) -> Text { Text(s) .font(.caption) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 83b8d61ea1..1dd2e5dd3f 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -4092,8 +4092,8 @@ public enum CIContent: Decodable, ItemContent, Hashable { case .rcvBlocked: return NSLocalizedString("blocked by admin", comment: "blocked chat item") case let .sndDirectE2EEInfo(e2eeInfo): return directE2EEInfoStr(e2eeInfo) case let .rcvDirectE2EEInfo(e2eeInfo): return directE2EEInfoStr(e2eeInfo) - case .sndGroupE2EEInfo: return e2eeInfoNoPQStr - case .rcvGroupE2EEInfo: return e2eeInfoNoPQStr + case let .sndGroupE2EEInfo(e2eeInfo): return groupE2EEInfoStr(e2eeInfo) + case let .rcvGroupE2EEInfo(e2eeInfo): return groupE2EEInfoStr(e2eeInfo) case .chatBanner: return "" case .invalidJSON: return NSLocalizedString("invalid data", comment: "invalid chat item") } @@ -4105,6 +4105,12 @@ public enum CIContent: Decodable, ItemContent, Hashable { : e2eeInfoNoPQStr } + private func groupE2EEInfoStr(_ e2eeInfo: E2EEInfo) -> String { + e2eeInfo.public == true + ? NSLocalizedString("Messages in this channel are not end-to-end encrypted. Chat relays can see these messages.", comment: "E2EE info chat item") + : e2eeInfoNoPQStr + } + private var e2eeInfoNoPQStr: String { NSLocalizedString("This chat is protected by end-to-end encryption.", comment: "E2EE info chat item") } @@ -5319,6 +5325,7 @@ public enum CIGroupInvitationStatus: String, Decodable, Hashable { public struct E2EEInfo: Decodable, Hashable { public var pqEnabled: Bool? + public var `public`: Bool? } public enum RcvDirectEvent: Decodable, Hashable { 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 bc81c958e7..ef3aa19267 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 @@ -3800,8 +3800,8 @@ sealed class CIContent: ItemContent { is RcvBlocked -> generalGetString(MR.strings.blocked_by_admin_item_description) is SndDirectE2EEInfo -> directE2EEInfoStr(e2eeInfo) is RcvDirectE2EEInfo -> directE2EEInfoStr(e2eeInfo) - is SndGroupE2EEInfo -> e2eeInfoNoPQStr - is RcvGroupE2EEInfo -> e2eeInfoNoPQStr + is SndGroupE2EEInfo -> groupE2EEInfoStr(e2eeInfo) + is RcvGroupE2EEInfo -> groupE2EEInfoStr(e2eeInfo) is ChatBanner -> "" is InvalidJSON -> "invalid data" } @@ -3838,6 +3838,9 @@ sealed class CIContent: ItemContent { private val e2eeInfoNoPQStr: String = generalGetString(MR.strings.e2ee_info_no_pq_short) + fun groupE2EEInfoStr(e2EEInfo: E2EEInfo): String = + if (e2EEInfo.public == true) generalGetString(MR.strings.e2ee_info_no_e2ee) else e2eeInfoNoPQStr + fun featureText(feature: Feature, enabled: String, param: Int?, role: GroupMemberRole? = null): String = (if (feature.hasParam) { "${feature.text}: ${timeText(param)}" @@ -4364,7 +4367,7 @@ enum class CIGroupInvitationStatus { } @Serializable -class E2EEInfo (val pqEnabled: Boolean?) {} +class E2EEInfo (val pqEnabled: Boolean?, val public: Boolean? = null) {} object MsgContentSerializer : KSerializer { override val descriptor: SerialDescriptor = buildSerialDescriptor("MsgContent", PolymorphicKind.SEALED) { 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 14afccee54..15b0a12822 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 @@ -680,21 +680,17 @@ fun ChatItemView( } @Composable - fun E2EEInfoNoPQText() { - e2eeInfoText(MR.strings.e2ee_info_no_pq) + fun DirectE2EEInfoText(e2EEInfo: E2EEInfo) { + e2eeInfoText(when (e2EEInfo.pqEnabled) { + true -> MR.strings.e2ee_info_pq + false -> MR.strings.e2ee_info_no_pq + null -> MR.strings.e2ee_info_e2ee + }) } @Composable - fun DirectE2EEInfoText(e2EEInfo: E2EEInfo) { - if (e2EEInfo.pqEnabled != null) { - if (e2EEInfo.pqEnabled) { - e2eeInfoText(MR.strings.e2ee_info_pq) - } else { - E2EEInfoNoPQText() - } - } else { - e2eeInfoText(MR.strings.e2ee_info_e2ee) - } + fun GroupE2EEInfoText(e2EEInfo: E2EEInfo) { + e2eeInfoText(if (e2EEInfo.public == true) MR.strings.e2ee_info_no_e2ee else MR.strings.e2ee_info_no_pq) } if (cItem.meta.itemDeleted != null && (!revealed.value || cItem.isDeletedContent)) { @@ -794,8 +790,8 @@ fun ChatItemView( is CIContent.RcvBlocked -> DeletedItem() is CIContent.SndDirectE2EEInfo -> DirectE2EEInfoText(c.e2eeInfo) is CIContent.RcvDirectE2EEInfo -> DirectE2EEInfoText(c.e2eeInfo) - is CIContent.SndGroupE2EEInfo -> E2EEInfoNoPQText() - is CIContent.RcvGroupE2EEInfo -> E2EEInfoNoPQText() + is CIContent.SndGroupE2EEInfo -> GroupE2EEInfoText(c.e2eeInfo) + is CIContent.RcvGroupE2EEInfo -> GroupE2EEInfoText(c.e2eeInfo) is CIContent.ChatBanner -> Spacer(modifier = Modifier.size(0.dp)) is CIContent.InvalidJSON -> { CIInvalidJSONView(c.json) 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 0be4ee4d2e..4c7241fb5f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -74,6 +74,7 @@ end-to-end encryption.]]> end-to-end encryption with perfect forward secrecy, repudiation and break-in recovery.]]> quantum resistant e2e encryption with perfect forward secrecy, repudiation and break-in recovery.]]> + not end-to-end encrypted. Chat relays can see these messages.]]> This chat is protected by end-to-end encryption. This chat is protected by quantum resistant end-to-end encryption. diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index ccef82eca9..93dbd560eb 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -1847,6 +1847,7 @@ connFullLink + ((' ' + connShortLink) if connShortLink is not None else '') # Py ## E2EInfo **Record type**: +- public: bool? - pqEnabled: bool? diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 7cc9205ff4..59329d3c0d 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -2094,6 +2094,7 @@ export interface DroppedMsg { } export interface E2EInfo { + public?: boolean pqEnabled?: boolean } diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 543014a346..5b6eda579d 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -2008,7 +2008,7 @@ processChatCommand vr nm = \case let cd = CDDirectRcv ct createItem sharedMsgId content = createChatItem user cd False content sharedMsgId Nothing cInfo = DirectChat ct - void $ createItem Nothing $ CIRcvDirectE2EEInfo $ E2EInfo $ connRequestPQEncryption cReq + void $ createItem Nothing $ CIRcvDirectE2EEInfo $ e2eInfoEncrypted $ connRequestPQEncryption cReq void $ createFeatureEnabledItems_ user ct aci <- mapM (createItem welcomeSharedMsgId . CIRcvMsgContent) message let chat = case aci of @@ -3858,7 +3858,7 @@ processChatCommand vr nm = \case createNewGroupItems user gInfo = do let cd = CDGroupSnd gInfo Nothing createInternalChatItem user cd CIChatBanner (Just epochStart) - createInternalChatItem user cd (CISndGroupE2EEInfo E2EInfo {pqEnabled = Just PQEncOff}) Nothing + createInternalChatItem user cd (CISndGroupE2EEInfo $ e2eInfoGroup gInfo) Nothing createGroupFeatureItems user cd CISndGroupFeature gInfo sendGrpInvitation :: User -> Contact -> GroupInfo -> GroupMember -> ConnReqInvitation -> CM () sendGrpInvitation user ct@Contact {contactId, localDisplayName} gInfo@GroupInfo {groupId, groupProfile, membership, businessChat} GroupMember {groupMemberId, memberId, memberRole = memRole} cReq = do diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index c6203b4b42..3be69bd949 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -1029,7 +1029,7 @@ acceptBusinessJoinRequestAsync createJoiningMemberConnection db user uclId connIds chatV cReqChatVRange groupMemberId subMode let cd = CDGroupSnd gInfo Nothing -- TODO [short links] move to profileContactRequest? - createInternalChatItem user cd (CISndGroupE2EEInfo E2EInfo {pqEnabled = Just PQEncOff}) Nothing + createInternalChatItem user cd (CISndGroupE2EEInfo $ e2eInfoGroup gInfo) Nothing createGroupFeatureItems user cd CISndGroupFeature gInfo -- TODO [short links] get updated business chat group and member? (currently not used) pure (gInfo, clientMember) @@ -1485,7 +1485,7 @@ createContactPQSndItem :: User -> Contact -> Connection -> PQEncryption -> CM (C createContactPQSndItem user ct conn@Connection {pqSndEnabled} pqSndEnabled' = flip catchAllErrors (const $ pure (ct, conn)) $ case (pqSndEnabled, pqSndEnabled') of (Just b, b') | b' /= b -> createPQItem $ CISndConnEvent (SCEPqEnabled pqSndEnabled') - (Nothing, PQEncOn) -> createPQItem $ CISndDirectE2EEInfo (E2EInfo $ Just pqSndEnabled') + (Nothing, PQEncOn) -> createPQItem $ CISndDirectE2EEInfo (e2eInfoEncrypted $ Just pqSndEnabled') _ -> pure (ct, conn) where createPQItem ciContent = do @@ -1500,7 +1500,7 @@ updateContactPQRcv :: User -> Contact -> Connection -> PQEncryption -> CM (Conta updateContactPQRcv user ct conn@Connection {connId, pqRcvEnabled} pqRcvEnabled' = flip catchAllErrors (const $ pure (ct, conn)) $ case (pqRcvEnabled, pqRcvEnabled') of (Just b, b') | b' /= b -> updatePQ $ CIRcvConnEvent (RCEPqEnabled pqRcvEnabled') - (Nothing, PQEncOn) -> updatePQ $ CIRcvDirectE2EEInfo (E2EInfo $ Just pqRcvEnabled') + (Nothing, PQEncOn) -> updatePQ $ CIRcvDirectE2EEInfo (e2eInfoEncrypted $ Just pqRcvEnabled') _ -> pure (ct, conn) where updatePQ ciContent = do diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 5d99491b01..3fef977f13 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -590,7 +590,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- [incognito] print incognito profile used for this contact incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) toView $ CEvtContactConnected user ct' (fmap fromLocalProfile incognitoProfile) - let createE2EItem = createInternalChatItem user (CDDirectRcv ct') (CIRcvDirectE2EEInfo $ E2EInfo $ Just pqEnc) Nothing + let createE2EItem = createInternalChatItem user (CDDirectRcv ct') (CIRcvDirectE2EEInfo $ e2eInfoEncrypted $ Just pqEnc) Nothing -- TODO [short links] get contact request by contactRequestId, check encryption (UserContactRequest.pqSupport)? when (directOrUsed ct') $ case (preparedContact ct', contactRequestId' ct') of (Nothing, Nothing) -> do @@ -842,7 +842,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = firstConnectedHost ( do let cd = CDGroupRcv gInfo'' scopeInfo m'' - createInternalChatItem user cd (CIRcvGroupE2EEInfo E2EInfo {pqEnabled = Just PQEncOff}) Nothing + createInternalChatItem user cd (CIRcvGroupE2EEInfo $ e2eInfoGroup gInfo'') Nothing let prepared = preparedGroup gInfo'' unless (isJust prepared) $ createGroupFeatureItems user cd CIRcvGroupFeature gInfo'' memberConnectedChatItem gInfo'' scopeInfo m'' @@ -1356,7 +1356,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = upsertDirectRequestItem cd (requestMsg_, prevSharedMsgId_) Nothing -> do void $ createChatItem user (CDDirectSnd ct) False CIChatBanner Nothing (Just epochStart) - let e2eContent = CIRcvDirectE2EEInfo $ E2EInfo $ Just $ CR.pqSupportToEnc $ reqPQSup + let e2eContent = CIRcvDirectE2EEInfo $ e2eInfoEncrypted $ Just $ CR.pqSupportToEnc $ reqPQSup void $ createChatItem user cd False e2eContent Nothing Nothing void $ createFeatureEnabledItems_ user ct forM_ (autoReply addressSettings) $ \mc -> forM_ welcomeSharedMsgId $ \sharedMsgId -> @@ -2546,7 +2546,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- create item in both scopes let gInfo' = gInfo {membership = membership'} cd = CDGroupRcv gInfo' Nothing m - createInternalChatItem user cd (CIRcvGroupE2EEInfo E2EInfo {pqEnabled = Just PQEncOff}) Nothing + createInternalChatItem user cd (CIRcvGroupE2EEInfo $ e2eInfoGroup gInfo') Nothing let prepared = preparedGroup gInfo' unless (isJust prepared) $ createGroupFeatureItems user cd CIRcvGroupFeature gInfo' let welcomeMsgId_ = (\PreparedGroup {welcomeSharedMsgId = mId} -> mId) <$> preparedGroup gInfo' diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index b08cc5a991..2dc751d6bb 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -177,9 +177,16 @@ data CIContent (d :: MsgDirection) where deriving instance Show (CIContent d) -data E2EInfo = E2EInfo {pqEnabled :: Maybe PQEncryption} +-- stored in database, all changed must be backward compatible +data E2EInfo = E2EInfo {public :: Maybe Bool, pqEnabled :: Maybe PQEncryption} deriving (Eq, Show) +e2eInfoEncrypted :: Maybe PQEncryption -> E2EInfo +e2eInfoEncrypted pqEnabled = E2EInfo {public = Nothing, pqEnabled} + +e2eInfoGroup :: GroupInfo -> E2EInfo +e2eInfoGroup g = E2EInfo {public = if useRelays' g then Just True else Nothing, pqEnabled = Just PQEncOff} + ciMsgContent :: CIContent d -> Maybe MsgContent ciMsgContent = \case CISndMsgContent mc -> Just mc @@ -315,9 +322,14 @@ directE2EInfoToText E2EInfo {pqEnabled} = case pqEnabled of Nothing -> simpleE2EText groupE2EInfoToText :: E2EInfo -> Text -groupE2EInfoToText E2EInfo {pqEnabled} = case pqEnabled of - Just _ -> e2eInfoNoPQText - Nothing -> simpleE2EText +groupE2EInfoToText E2EInfo {pqEnabled, public} = case public of + Just True -> publicGroupNoE2EText + _ -> case pqEnabled of + Just _ -> e2eInfoNoPQText + Nothing -> simpleE2EText + +publicGroupNoE2EText :: Text +publicGroupNoE2EText = "This channel or group is NOT end-to-end encrypted." simpleE2EText :: Text simpleE2EText = "This conversation is protected by end-to-end encryption" diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 1a1bc4b82f..3ff884d3d1 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -28,6 +28,7 @@ import Simplex.Chat.Controller (ChatConfig (..), ChatHooks (..), defaultChatHook import Simplex.Chat.Library.Internal (uniqueMsgMentions, updatedMentionNames) import Simplex.Chat.Markdown (parseMaybeMarkdownList) import Simplex.Chat.Messages (CIMention (..), CIMentionMember (..), ChatItemId) +import Simplex.Chat.Messages.CIContent (publicGroupNoE2EText) import Simplex.Chat.Options import Simplex.Chat.Protocol (MsgMention (..), MsgContent (..), msgContentText) import Simplex.Chat.Types @@ -8857,7 +8858,7 @@ testChannelLinkAfterWelcomeUpdate ps = shortLink' `shouldBe` shortLink fullLink' `shouldBe` fullLink memberJoinChannel "team" [bob] [alice] shortLink' fullLink' dan - dan #$> ("/_get chat #1 count=100", chat, groupFeaturesNoE2E <> [(0, "welcome to team"), (0, e2eeInfoNoPQStr), (0, "connected")]) + dan #$> ("/_get chat #1 count=100", chat, groupFeaturesNoE2E <> [(0, "welcome to team"), (0, T.unpack publicGroupNoE2EText), (0, "connected")]) alice #> "#team hi" bob <# "#team> hi"