From bd123f4ec5fe47f29cf907c00ddd1553d4a93553 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sun, 26 Jul 2026 09:51:00 +0100 Subject: [PATCH] core: support message signing in p2p groups --- bots/api/TYPES.md | 13 ++++++-- bots/src/API/Docs/Types.hs | 2 ++ .../types/typescript/src/types.ts | 8 +++-- .../src/simplex_chat/types/_types.py | 7 +++-- src/Simplex/Chat/Library/Commands.hs | 5 ++-- src/Simplex/Chat/Library/Internal.hs | 28 ++++++++++------- src/Simplex/Chat/Library/Subscriber.hs | 30 ++++++++----------- src/Simplex/Chat/Store/Groups.hs | 10 ++++--- .../SQLite/Migrations/chat_query_plans.txt | 4 +++ src/Simplex/Chat/Store/Shared.hs | 8 ++--- src/Simplex/Chat/Types.hs | 11 +++++-- 11 files changed, 79 insertions(+), 47 deletions(-) diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 79338930c8..fc8d356bb3 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -157,6 +157,7 @@ This file is generated automatically. - [ProxyError](#proxyerror) - [PublicGroupAccess](#publicgroupaccess) - [PublicGroupData](#publicgroupdata) +- [PublicGroupKeys](#publicgroupkeys) - [PublicGroupProfile](#publicgroupprofile) - [RCErrorType](#rcerrortype) - [RatchetSyncState](#ratchetsyncstate) @@ -2362,8 +2363,7 @@ MemberSupport: ## GroupKeys **Record type**: -- publicGroupId: string -- groupRootKey: [GroupRootKey](#grouprootkey) +- publicGroupKeys: [PublicGroupKeys](#publicgroupkeys)? - memberPrivKey: string @@ -3232,6 +3232,15 @@ NO_SESSION: - publicMemberCount: int64 +--- + +## PublicGroupKeys + +**Record type**: +- publicGroupId: string +- groupRootKey: [GroupRootKey](#grouprootkey) + + --- ## PublicGroupProfile diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index 5e1e2bb082..bf0f4692f7 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -340,6 +340,7 @@ chatTypesDocsData = (sti @ProxyError, STUnion, "", [], "", ""), (sti @PublicGroupAccess, STRecord, "", [], "", ""), (sti @PublicGroupData, STRecord, "", [], "", ""), + (sti @PublicGroupKeys, STRecord, "", [], "", ""), (sti @PublicGroupProfile, STRecord, "", [], "", ""), (sti @RatchetSyncState, STEnum, "RS", [], "", ""), (sti @RCErrorType, STUnion, "RCE", [], "", ""), @@ -573,6 +574,7 @@ deriving instance Generic ProxyClientError deriving instance Generic ProxyError deriving instance Generic PublicGroupAccess deriving instance Generic PublicGroupData +deriving instance Generic PublicGroupKeys deriving instance Generic PublicGroupProfile deriving instance Generic RatchetSyncState deriving instance Generic RCErrorType diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index e2e30d43bc..8170786c89 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -2642,8 +2642,7 @@ export interface GroupInfo { } export interface GroupKeys { - publicGroupId: string - groupRootKey: GroupRootKey + publicGroupKeys?: PublicGroupKeys memberPrivKey: string } @@ -3490,6 +3489,11 @@ export interface PublicGroupData { publicMemberCount: number // int64 } +export interface PublicGroupKeys { + publicGroupId: string + groupRootKey: GroupRootKey +} + export interface PublicGroupProfile { groupType: GroupType groupLink: string 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 96d3f4dea4..88f87b3028 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -1852,8 +1852,7 @@ class GroupInfo(TypedDict): groupDomainVerified: NotRequired[bool] class GroupKeys(TypedDict): - publicGroupId: str - groupRootKey: "GroupRootKey" + publicGroupKeys: NotRequired["PublicGroupKeys"] memberPrivKey: str class GroupLink(TypedDict): @@ -2444,6 +2443,10 @@ class PublicGroupAccess(TypedDict): class PublicGroupData(TypedDict): publicMemberCount: int # int64 +class PublicGroupKeys(TypedDict): + publicGroupId: str + groupRootKey: "GroupRootKey" + class PublicGroupProfile(TypedDict): groupType: "GroupType" groupLink: str diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 2226f80fab..857a9061cc 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1189,7 +1189,7 @@ processChatCommand cxt nm = \case Nothing -> throwCmdError "not a public group" Just PublicGroupProfile {groupLink} -> do let signingKeys = case (memberRole, groupKeys) of - (GROwner, Just gk@GroupKeys {groupRootKey = GRKPrivate _}) -> Just gk + (GROwner, Just gk@GroupKeys {publicGroupKeys = Just PublicGroupKeys {groupRootKey = GRKPrivate _}}) -> Just gk _ -> Nothing ownerSig <- pure signingKeys $>>= \GroupKeys {memberPrivKey} -> @@ -2693,7 +2693,8 @@ processChatCommand cxt nm = \case userLinkData = UserContactLinkData UserContactData {direct = False, owners = [ownerAuth], relays = [], userData} -- create connection with prepared link (single network call) connId <- withAgent $ \a -> createConnectionForLink a nm (aUserId user) True ccLink preparedParams userLinkData IKPQOff subMode - let groupKeys = GroupKeys {publicGroupId = B64UrlByteString entityId, groupRootKey = GRKPrivate rootPrivKey, memberPrivKey} + let groupKeys = GroupKeys {publicGroupKeys, memberPrivKey} + publicGroupKeys = Just PublicGroupKeys {publicGroupId = B64UrlByteString entityId, groupRootKey = GRKPrivate rootPrivKey} setupLink gInfo = do -- TODO [relays] starting role should be communicated in protocol from owner to relays subRole <- asks $ channelSubscriberRole . config diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 6fb002eaee..e06151fb37 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -96,7 +96,7 @@ import Simplex.Messaging.Compression (compressionLevel, limitDecompress') import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF -import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport (..), pattern IKPQOff, pattern PQEncOff, pattern PQEncOn, pattern PQSupportOff, pattern PQSupportOn) +import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport (..), pattern PQEncOff, pattern PQEncOn, pattern PQSupportOff, pattern PQSupportOn) import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Encoding (smpEncode) import Simplex.Messaging.Encoding.String @@ -1547,7 +1547,7 @@ groupLinkData gInfo@GroupInfo {groupProfile, groupSummary = GroupSummary {public publicGroupData_ = PublicGroupData <$> publicMemberCount userData = encodeShortLinkData $ GroupShortLinkData {groupProfile, publicGroupData = publicGroupData_} owners = case groupKeys of - Just GroupKeys {groupRootKey = GRKPrivate rootPrivKey, memberPrivKey} -> + Just GroupKeys {publicGroupKeys = Just PublicGroupKeys {groupRootKey = GRKPrivate rootPrivKey}, memberPrivKey} -> let ownerId = unMemberId memberId ownerKey = C.publicKey memberPrivKey authOwnerSig = C.sign' rootPrivKey (ownerId <> C.encodePubKey ownerKey) @@ -2212,13 +2212,18 @@ createSndMessages idsEvents = do encodeChatMessage maxEncodedMsgLength ChatMessage {chatVRange = vr, msgId = Just sharedMsgId, chatMsgEvent = evnt} 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 || (sign && signableContent tag) -groupMsgSigning _ _ _ = Nothing +groupMsgSigning sign GroupInfo {membership = GroupMember {memberId}, groupKeys} evt = case groupKeys of + Just gks@GroupKeys {memberPrivKey} | shouldSign -> Just $ MsgSigning CBGroup bindingData KRMember memberPrivKey + where + tag = toCMEventTag evt + shouldSign = requiresSignature tag || (sign && signableContent tag) + bindingData = groupBindingData (Just gks) memberId (C.publicKey memberPrivKey) + _ -> Nothing + +groupBindingData :: Maybe GroupKeys -> MemberId -> C.PublicKeyEd25519 -> ByteString +groupBindingData gks memberId memberKey = case gks >>= publicGroupKeys of + Just PublicGroupKeys {publicGroupId} -> smpEncode (publicGroupId, memberId) + Nothing -> smpEncode (memberId, memberKey) sendGroupMemberMessages :: forall e. MsgEncodingI e => User -> GroupInfo -> Connection -> NonEmpty (ChatMsgEvent e) -> CM () sendGroupMemberMessages user gInfo@GroupInfo {groupId} conn events = do @@ -2292,9 +2297,10 @@ encodeSignedConnInfo signing chatMsgEvent = do encodeXMemberConnInfo :: GroupInfo -> MemberId -> Profile -> CM ByteString encodeXMemberConnInfo GroupInfo {membership = GroupMember {memberId}, groupKeys} relayMemberId profileToSend = case groupKeys of - Just GroupKeys {publicGroupId, memberPrivKey} -> + Just gks@GroupKeys {memberPrivKey} -> let xMemberEvt = XMember profileToSend memberId (MemberKey $ C.publicKey memberPrivKey) (Just relayMemberId) - signing = MsgSigning CBGroup (smpEncode (publicGroupId, memberId)) KRMember memberPrivKey + bindingData = groupBindingData (Just gks) memberId (C.publicKey memberPrivKey) + signing = MsgSigning CBGroup bindingData KRMember memberPrivKey in encodeSignedConnInfo signing xMemberEvt Nothing -> throwChatError $ CEInternalError "no group keys for channel membership" diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index a65bed75a0..0029a02fa5 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -111,11 +111,11 @@ import UnliftIO.STM smallGroupsRcptsMemLimit :: Int smallGroupsRcptsMemLimit = 20 --- Verifies member signatures over CBGroup <> (publicGroupId, memberId) <> signedBody under the given key. +-- Verifies member signatures over CBGroup <> (publicGroupId, memberId) or (memberId, pubKey) <> signedBody under the given key. -- signatures is NonEmpty so the verification can't be vacuously true. -verifyGroupSig :: C.PublicKeyEd25519 -> B64UrlByteString -> MemberId -> NonEmpty MsgSignature -> ByteString -> Bool -verifyGroupSig key publicGroupId memberId signatures signedBody = - let prefix = smpEncode CBGroup <> smpEncode (publicGroupId, memberId) +verifyGroupSig :: C.PublicKeyEd25519 -> Maybe GroupKeys -> MemberId -> NonEmpty MsgSignature -> ByteString -> Bool +verifyGroupSig key gks memberId signatures signedBody = + let prefix = encodeChatBinding CBGroup $ groupBindingData gks memberId key in all (\case (MsgSignature KRMember sig) -> C.verify (C.APublicVerifyKey C.SEd25519 key) sig (prefix <> signedBody)) signatures processAgentMessage :: ACorrId -> ConnId -> AEvent 'AEConn -> CM () @@ -1683,9 +1683,9 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = where -- replay defense: the viaRelay == own memberId check (viaRelay is in the signed body); without it a sibling relay could replay a privileged member's signed join verifyKey gInfo rosterMem = case (signedMsg_, groupKeys gInfo) of - (Just SignedMsg {chatBinding = CBGroup, signatures, signedBody}, Just GroupKeys {publicGroupId}) -> + (Just SignedMsg {chatBinding = CBGroup, signatures, signedBody}, Just gks) -> memberPubKey rosterMem == Just joiningKey - && verifyGroupSig joiningKey publicGroupId joiningMemberId signatures signedBody + && verifyGroupSig joiningKey (Just gks) joiningMemberId signatures signedBody && viaRelay == Just (memberId' (membership gInfo)) _ -> False acceptJoin gInfo existingMem_ acceptRole = do @@ -3928,7 +3928,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = Nothing -> messageError $ "x.grp.msg.forward: event " <> tshow tag <> " requires author" withVerifiedMsg :: MsgEncodingI e => GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> ParsedMsg e -> UTCTime -> (VerifiedMsg e -> CM a) -> CM (Maybe a) - withVerifiedMsg gInfo@GroupInfo {membership} scopeInfo member (ParsedMsg _ signedMsg_ chatMsg@ChatMessage {chatMsgEvent}) ts action = + withVerifiedMsg gInfo@GroupInfo {membership, groupKeys} scopeInfo member@GroupMember {memberPubKey, memberId} (ParsedMsg _ signedMsg_ chatMsg@ChatMessage {chatMsgEvent}) ts action = case verified of Just verifiedMsg -> Just <$> action verifiedMsg Nothing -> do @@ -3936,17 +3936,11 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = pure Nothing where verified = case signedMsg_ of - Just sm@SignedMsg {chatBinding, signatures, signedBody} - | GroupMember {memberPubKey = Just pubKey, memberId} <- member -> - case chatBinding of - CBGroup - | Just GroupKeys {publicGroupId} <- groupKeys gInfo -> - signed MSSVerified <$ guard (verifyGroupSig pubKey publicGroupId memberId signatures signedBody) - | otherwise -> - let prefix = smpEncode chatBinding <> smpEncode (memberId, pubKey) -- forward compatibility for verifying signed messages in p2p groups - in signed MSSVerified <$ guard (all (\case (MsgSignature KRMember sig) -> C.verify (C.APublicVerifyKey C.SEd25519 pubKey) sig (prefix <> signedBody)) signatures) - _ -> signed MSSSignedNoKey <$ guard signatureOptional - | otherwise -> signed MSSSignedNoKey <$ guard (signatureOptional || unverifiedAllowed membership member tag) + Just sm@SignedMsg {chatBinding, signatures, signedBody} -> case memberPubKey of + Just pubKey -> case chatBinding of + CBGroup -> signed MSSVerified <$ guard (verifyGroupSig pubKey groupKeys memberId signatures signedBody) + _ -> signed MSSSignedNoKey <$ guard signatureOptional + Nothing -> signed MSSSignedNoKey <$ guard (signatureOptional || unverifiedAllowed membership member tag) where signed status = VMSigned status sm chatMsg Nothing -> VMUnsigned chatMsg <$ guard signatureOptional diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index d607541bda..e73da14a13 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -392,10 +392,12 @@ createNewGroup db cxt user@User {userId} groupProfile incognitoProfile useRelays withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do let (rootPrivKey_, rootPubKey_, memberPrivKey_) = case groupKeys of Nothing -> (Nothing, Nothing, Nothing) - Just GroupKeys {groupRootKey, memberPrivKey} -> - let (rpk, rpub) = case groupRootKey of - GRKPrivate pk -> (Just pk, Nothing) - GRKPublic k -> (Nothing, Just k) + Just GroupKeys {publicGroupKeys, memberPrivKey} -> + let (rpk, rpub) = case publicGroupKeys of + Just PublicGroupKeys {groupRootKey} -> case groupRootKey of + GRKPrivate pk -> (Just pk, Nothing) + GRKPublic k -> (Nothing, Just k) + Nothing -> (Nothing, Nothing) in (rpk, rpub, Just memberPrivKey) groupId <- liftIO $ do DB.execute 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 14e2299514..03e29f0016 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -7170,6 +7170,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 group_scope_tag = 'member_support' ORDER BY chat_item_id DESC LIMIT 1 +Plan: +SCAN chat_items + Query: SELECT chat_item_id FROM chat_items WHERE item_text LIKE '%' || ? || '%' ORDER BY chat_item_id DESC LIMIT 1 Plan: SCAN chat_items diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 050cf7dc97..1e85533d88 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -731,10 +731,10 @@ toPublicGroupAccess (groupWebPage, groupDomain_, domainWebPage_, allowEmbedding_ allowEmbedding = maybe False unBI allowEmbedding_ toGroupKeys :: Maybe B64UrlByteString -> GroupKeysRow -> Maybe GroupKeys -toGroupKeys (Just publicGroupId) (rootPrivKey_, rootPubKey_, Just memberPrivKey) = - (\grk -> GroupKeys {publicGroupId, groupRootKey = grk, memberPrivKey}) - <$> (GRKPrivate <$> rootPrivKey_ <|> GRKPublic <$> rootPubKey_) -toGroupKeys _ _ = Nothing +toGroupKeys publicGroupId (rootPrivKey, rootPubKey, memberPrivKey) = + let publicGroupKeys = PublicGroupKeys <$> publicGroupId <*> groupRootKey + groupRootKey = GRKPrivate <$> rootPrivKey <|> GRKPublic <$> rootPubKey + in GroupKeys publicGroupKeys <$> memberPrivKey toGroupMember :: UTCTime -> Int64 -> GroupMemberRow -> GroupMember toGroupMember now userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. profileRow :. (createdAt, updatedAt) :. (supportChatTs_, supportChatUnread, supportChatMemberAttention, supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink, memberCode_, memberCodeVerifiedAt_)) = diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index dce1a2a9c9..a7731b9150 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -468,12 +468,17 @@ groupRootPubKey (GRKPrivate pk) = C.publicKey pk groupRootPubKey (GRKPublic pk) = pk data GroupKeys = GroupKeys - { publicGroupId :: B64UrlByteString, - groupRootKey :: GroupRootKey, + { publicGroupKeys :: Maybe PublicGroupKeys, memberPrivKey :: C.PrivateKeyEd25519 } deriving (Eq, Show) +data PublicGroupKeys = PublicGroupKeys + { publicGroupId :: B64UrlByteString, + groupRootKey :: GroupRootKey + } + deriving (Eq, Show) + data GroupInfo = GroupInfo { groupId :: GroupId, useRelays :: BoolDef, @@ -2290,6 +2295,8 @@ instance FromJSON GroupSummary where $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "GRK") ''GroupRootKey) +$(JQ.deriveJSON defaultJSON ''PublicGroupKeys) + $(JQ.deriveJSON defaultJSON ''GroupKeys) $(JQ.deriveJSON defaultJSON ''GroupInfo)