core: support message signing in p2p groups

This commit is contained in:
Evgeny Poberezkin
2026-07-26 13:53:57 +01:00
parent 1108e87ee2
commit bd123f4ec5
11 changed files with 79 additions and 47 deletions
+3 -2
View File
@@ -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
+17 -11
View File
@@ -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"
+12 -18
View File
@@ -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
+6 -4
View File
@@ -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
@@ -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
+4 -4
View File
@@ -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_)) =
+9 -2
View File
@@ -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)