core: public groups - roster of privileged members (#7017)

This commit is contained in:
spaced4ndy
2026-06-22 10:15:41 +00:00
committed by GitHub
parent 5d3f016627
commit 0e09b38ea6
33 changed files with 2902 additions and 411 deletions
+94 -12
View File
@@ -48,13 +48,14 @@ import Data.Time.Clock (UTCTime)
import Data.Time.Clock.System (systemToUTCTime, utcToSystemTime)
import Data.Type.Equality
import Data.Typeable (Typeable)
import Data.Word (Word32)
import Data.Word (Word16, Word32)
import Simplex.Chat.Badges (LocalBadge)
import Simplex.Chat.Call
import Simplex.Chat.Options.DB (FromField (..), ToField (..))
import Simplex.Chat.Types
import Simplex.Chat.Types.Preferences
import Simplex.Chat.Types.Shared
import qualified Simplex.FileTransfer.Description as FD
import Simplex.Messaging.Agent.Protocol (VersionSMPA, pqdrSMPAgentVersion)
import Simplex.Messaging.Agent.Store.DB (blobFieldDecoder, fromTextField_)
import Simplex.Messaging.Compression (Compressed, compress1, decompress1, decompressedSize)
@@ -84,12 +85,13 @@ import Simplex.Messaging.Version hiding (version)
-- 16 - support short link data (2025-06-10)
-- 17 - allow host voice messages during member approval regardless of group voice setting (2026-02-10)
-- 18 - relay web capabilities (2026-05-31)
-- 19 - group roster (2026-06-18)
-- This should not be used directly in code, instead use `maxVersion chatVRange` from ChatConfig.
-- This indirection is needed for backward/forward compatibility testing.
-- Testing with real app versions is still needed, as tests use the current code with different version ranges, not the old code.
currentChatVersion :: VersionChat
currentChatVersion = VersionChat 18
currentChatVersion = VersionChat 19
-- This should not be used directly in code, instead use `chatVRange` from ChatConfig (see comment above)
supportedChatVRange :: VersionRangeChat
@@ -160,6 +162,11 @@ memberSupportVoiceVersion = VersionChat 17
relayWebCapVersion :: VersionChat
relayWebCapVersion = VersionChat 18
-- owner-signed roster (promoted members/moderators/admins) and the relay roster-ack handshake;
-- a relay below this version is published without the handshake (it can't ack a roster)
groupRosterVersion :: VersionChat
groupRosterVersion = VersionChat 19
agentToChatVersion :: VersionSMPA -> VersionChat
agentToChatVersion v
| v < pqdrSMPAgentVersion = initialChatVersion
@@ -373,6 +380,36 @@ data GrpMsgForward = GrpMsgForward
}
deriving (Eq, Show)
-- | Owner-signed roster header for the privileged (moderator/admin/member) set; owners
-- are not included, their keys come from the link. The member list itself is not
-- here: it is sent as a binary blob over the inline file transfer, and this header
-- carries only its inline-file invitation (size + owner-attested digest).
data GroupRoster = GroupRoster
{ version :: VersionRoster,
fileInv :: InlineFileInvitation
}
deriving (Eq, Show)
-- | Lean always-inline file invitation for the roster blob, carried in the signed
-- header. The digest authenticates the unsigned blob; integrity is entirely the digest.
data InlineFileInvitation = InlineFileInvitation
{ fileSize :: Integer,
fileDigest :: FD.FileDigest
}
deriving (Eq, Show)
data RosterMember = RosterMember
{ memberId :: MemberId,
key :: MemberKey, -- trust-on-first-use pinned per memberId
role :: GroupMemberRole,
privileges :: Word16 -- reserved: serialized as 0, parsed and ignored in v1
}
deriving (Eq, Show)
-- RosterMember is binary-only: it rides in the roster blob, never in a JSON message.
instance Encoding RosterMember where
smpEncode RosterMember {memberId, key, role, privileges} = smpEncode (memberId, key, role, privileges)
smpP = RosterMember <$> smpP <*> smpP <*> smpP <*> smpP
instance Encoding FwdSender where
smpEncode = \case
@@ -439,6 +476,11 @@ data MsgSigning = MsgSigning
encodeChatBinding :: ChatBinding -> ByteString -> ByteString
encodeChatBinding cb bindingData = smpEncode cb <> bindingData
signChatMsgBody :: MsgSigning -> ByteString -> SignedMsg
signChatMsgBody MsgSigning {bindingTag, bindingData, keyRef, privKey} msgBody =
let sig = C.ASignature C.SEd25519 $ C.sign' privKey (encodeChatBinding bindingTag bindingData <> msgBody)
in SignedMsg {chatBinding = bindingTag, signatures = MsgSignature keyRef sig L.:| [], signedBody = msgBody}
data ChatMsgEvent (e :: MsgEncoding) where
XMsgNew :: MsgContainer -> ChatMsgEvent 'Json
XMsgFileDescr :: {msgId :: SharedMsgId, fileDescr :: FileDescr} -> ChatMsgEvent 'Json
@@ -452,7 +494,7 @@ data ChatMsgEvent (e :: MsgEncoding) where
XFileCancel :: SharedMsgId -> ChatMsgEvent 'Json
XInfo :: Profile -> ChatMsgEvent 'Json
XContact :: {profile :: Profile, contactReqId :: Maybe XContactId, welcomeMsgId :: Maybe SharedMsgId, requestMsg :: Maybe (SharedMsgId, MsgContent)} -> ChatMsgEvent 'Json
XMember :: {profile :: Profile, newMemberId :: MemberId, newMemberKey :: MemberKey} -> ChatMsgEvent 'Json
XMember :: {profile :: Profile, newMemberId :: MemberId, newMemberKey :: MemberKey, viaRelay :: Maybe MemberId} -> ChatMsgEvent 'Json
XDirectDel :: ChatMsgEvent 'Json
XGrpInv :: GroupInvitation -> ChatMsgEvent 'Json
XGrpAcpt :: MemberId -> ChatMsgEvent 'Json
@@ -471,16 +513,18 @@ data ChatMsgEvent (e :: MsgEncoding) where
XGrpMemInv :: MemberId -> IntroInvitation -> ChatMsgEvent 'Json
XGrpMemFwd :: MemberInfo -> IntroInvitation -> ChatMsgEvent 'Json
XGrpMemInfo :: MemberId -> Profile -> ChatMsgEvent 'Json
XGrpMemRole :: MemberId -> GroupMemberRole -> ChatMsgEvent 'Json
XGrpMemRole :: MemberId -> GroupMemberRole -> Maybe MemberKey -> Maybe VersionRoster -> ChatMsgEvent 'Json
XGrpMemRestrict :: MemberId -> MemberRestrictions -> ChatMsgEvent 'Json
XGrpMemCon :: MemberId -> ChatMsgEvent 'Json
XGrpMemConAll :: MemberId -> ChatMsgEvent 'Json -- TODO not implemented
XGrpMemDel :: MemberId -> Bool -> ChatMsgEvent 'Json
XGrpMemDel :: MemberId -> Bool -> Maybe VersionRoster -> ChatMsgEvent 'Json
XGrpLeave :: ChatMsgEvent 'Json
XGrpDel :: ChatMsgEvent 'Json
XGrpInfo :: GroupProfile -> ChatMsgEvent 'Json
XGrpPrefs :: GroupPreferences -> ChatMsgEvent 'Json
XGrpDirectInv :: ConnReqInvitation -> Maybe MsgContent -> Maybe MsgScope -> ChatMsgEvent 'Json
XGrpRoster :: GroupRoster -> ChatMsgEvent 'Json
XGrpRosterAck :: VersionRoster -> Maybe Text -> ChatMsgEvent 'Json
XGrpMsgForward :: GrpMsgForward -> ChatMessage 'Json -> ChatMsgEvent 'Json
XInfoProbe :: Probe -> ChatMsgEvent 'Json
XInfoProbeCheck :: ProbeHash -> ChatMsgEvent 'Json
@@ -524,6 +568,7 @@ isForwardedGroupMsg ev = case ev of
XGrpDel -> True
XGrpInfo _ -> True
XGrpPrefs _ -> True
XGrpRoster _ -> True
_ -> False
data MsgReaction = MREmoji {emoji :: MREmojiChar} | MRUnknown {tag :: Text, json :: J.Object}
@@ -792,6 +837,8 @@ data MsgMention = MsgMention {memberId :: MemberId}
newtype MsgMentions = MsgMentions (Map MemberName MsgMention)
deriving (Eq, Show)
$(JQ.deriveJSON defaultJSON ''InlineFileInvitation)
$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "MCL") ''MsgChatLink)
$(JQ.deriveJSON defaultJSON ''LinkOwnerSig)
@@ -892,6 +939,28 @@ maxCompressedMsgLength = 13380
maxDecompressedMsgLength :: Int
maxDecompressedMsgLength = 65536
-- Defensive entry-count bound for the roster blob parser (rosterBlobP) and the
-- promotion cap over the promoted (member/moderator/admin) set.
maxGroupRosterSize :: Int
maxGroupRosterSize = 256
-- Receive-side byte bound: reject an owner-signed header whose claimed fileSize exceeds what
-- maxGroupRosterSize entries can occupy (128 B/entry is a generous worst case), before a file is created.
-- 128 B/entry ~ memberId + X.509 Ed25519 key (44 B) + role + privileges + 1-byte length prefixes (~2x the ~65 B typical).
maxGroupRosterBytes :: Integer
maxGroupRosterBytes = fromIntegral maxGroupRosterSize * 128
-- The byte sequence the owner-signed digest is computed over and verified against
-- before parsing. Word16 count (smpEncodeList's 1-byte count is too small for the future cap).
encodeRosterBlob :: [RosterMember] -> ByteString
encodeRosterBlob ms = smpEncode (fromIntegral (length ms) :: Word16) <> B.concat (map smpEncode ms)
rosterBlobP :: A.Parser [RosterMember]
rosterBlobP = do
n <- fromIntegral <$> smpP @Word16
when (n > maxGroupRosterSize) $ fail "roster: too many entries"
A.count n smpP
-- maxEncodedMsgLength - delta between MSG and INFO + 100 (returned for forward overhead)
-- delta between MSG and INFO = e2eEncUserMsgLength (no PQ) - e2eEncConnInfoLength (no PQ) = 1008
maxEncodedInfoLength :: Int
@@ -1028,6 +1097,8 @@ data CMEventTag (e :: MsgEncoding) where
XGrpInfo_ :: CMEventTag 'Json
XGrpPrefs_ :: CMEventTag 'Json
XGrpDirectInv_ :: CMEventTag 'Json
XGrpRoster_ :: CMEventTag 'Json
XGrpRosterAck_ :: CMEventTag 'Json
XGrpMsgForward_ :: CMEventTag 'Json
XInfoProbe_ :: CMEventTag 'Json
XInfoProbeCheck_ :: CMEventTag 'Json
@@ -1088,6 +1159,8 @@ instance MsgEncodingI e => StrEncoding (CMEventTag e) where
XGrpInfo_ -> "x.grp.info"
XGrpPrefs_ -> "x.grp.prefs"
XGrpDirectInv_ -> "x.grp.direct.inv"
XGrpRoster_ -> "x.grp.roster"
XGrpRosterAck_ -> "x.grp.roster.ack"
XGrpMsgForward_ -> "x.grp.msg.forward"
XInfoProbe_ -> "x.info.probe"
XInfoProbeCheck_ -> "x.info.probe.check"
@@ -1149,6 +1222,8 @@ instance StrEncoding ACMEventTag where
"x.grp.info" -> XGrpInfo_
"x.grp.prefs" -> XGrpPrefs_
"x.grp.direct.inv" -> XGrpDirectInv_
"x.grp.roster" -> XGrpRoster_
"x.grp.roster.ack" -> XGrpRosterAck_
"x.grp.msg.forward" -> XGrpMsgForward_
"x.info.probe" -> XInfoProbe_
"x.info.probe.check" -> XInfoProbeCheck_
@@ -1196,7 +1271,7 @@ toCMEventTag msg = case msg of
XGrpMemInv _ _ -> XGrpMemInv_
XGrpMemFwd _ _ -> XGrpMemFwd_
XGrpMemInfo _ _ -> XGrpMemInfo_
XGrpMemRole _ _ -> XGrpMemRole_
XGrpMemRole {} -> XGrpMemRole_
XGrpMemRestrict _ _ -> XGrpMemRestrict_
XGrpMemCon _ -> XGrpMemCon_
XGrpMemConAll _ -> XGrpMemConAll_
@@ -1206,6 +1281,8 @@ toCMEventTag msg = case msg of
XGrpInfo _ -> XGrpInfo_
XGrpPrefs _ -> XGrpPrefs_
XGrpDirectInv {} -> XGrpDirectInv_
XGrpRoster _ -> XGrpRoster_
XGrpRosterAck {} -> XGrpRosterAck_
XGrpMsgForward {} -> XGrpMsgForward_
XInfoProbe _ -> XInfoProbe_
XInfoProbeCheck _ -> XInfoProbeCheck_
@@ -1264,6 +1341,7 @@ requiresSignature = \case
XGrpMemRestrict_ -> True
XGrpLeave_ -> True
XGrpRelayNew_ -> True
XGrpRoster_ -> True
XInfo_ -> True
_ -> False
@@ -1332,7 +1410,7 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do
reqContent <- opt "content"
let requestMsg = (,) <$> reqMsgId <*> reqContent
pure XContact {profile, contactReqId, welcomeMsgId, requestMsg}
XMember_ -> XMember <$> p "profile" <*> p "newMemberId" <*> p "newMemberKey"
XMember_ -> XMember <$> p "profile" <*> p "newMemberId" <*> p "newMemberKey" <*> opt "viaRelay"
XDirectDel_ -> pure XDirectDel
XGrpInv_ -> XGrpInv <$> p "groupInvitation"
XGrpAcpt_ -> XGrpAcpt <$> p "memberId"
@@ -1354,16 +1432,18 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do
XGrpMemInv_ -> XGrpMemInv <$> p "memberId" <*> p "memberIntro"
XGrpMemFwd_ -> XGrpMemFwd <$> p "memberInfo" <*> p "memberIntro"
XGrpMemInfo_ -> XGrpMemInfo <$> p "memberId" <*> p "profile"
XGrpMemRole_ -> XGrpMemRole <$> p "memberId" <*> p "role"
XGrpMemRole_ -> XGrpMemRole <$> p "memberId" <*> p "role" <*> opt "memberKey" <*> opt "rosterVersion"
XGrpMemRestrict_ -> XGrpMemRestrict <$> p "memberId" <*> p "memberRestrictions"
XGrpMemCon_ -> XGrpMemCon <$> p "memberId"
XGrpMemConAll_ -> XGrpMemConAll <$> p "memberId"
XGrpMemDel_ -> XGrpMemDel <$> p "memberId" <*> Right (fromRight False $ p "messages")
XGrpMemDel_ -> XGrpMemDel <$> p "memberId" <*> Right (fromRight False $ p "messages") <*> opt "rosterVersion"
XGrpLeave_ -> pure XGrpLeave
XGrpDel_ -> pure XGrpDel
XGrpInfo_ -> XGrpInfo <$> p "groupProfile"
XGrpPrefs_ -> XGrpPrefs <$> p "groupPreferences"
XGrpDirectInv_ -> XGrpDirectInv <$> p "connReq" <*> opt "content" <*> opt "scope"
XGrpRoster_ -> XGrpRoster <$> (GroupRoster <$> p "version" <*> p "fileInv")
XGrpRosterAck_ -> XGrpRosterAck <$> p "version" <*> opt "error"
XGrpMsgForward_ -> do
fwdSender <- opt "memberId" >>= \case
Just memberId -> FwdMember memberId . fromMaybe "" <$> opt "memberName"
@@ -1405,7 +1485,7 @@ chatToAppMessage chatMsg@ChatMessage {chatVRange, msgId, chatMsgEvent} = case en
XFileCancel sharedMsgId -> o ["msgId" .= sharedMsgId]
XInfo profile -> o ["profile" .= profile]
XContact {profile, contactReqId, welcomeMsgId, requestMsg} -> o $ ("contactReqId" .=? contactReqId) $ ("welcomeMsgId" .=? welcomeMsgId) $ ("msgId" .=? (fst <$> requestMsg)) $ ("content" .=? (snd <$> requestMsg)) $ ["profile" .= profile]
XMember {profile, newMemberId, newMemberKey} -> o ["profile" .= profile, "newMemberId" .= newMemberId, "newMemberKey" .= newMemberKey]
XMember {profile, newMemberId, newMemberKey, viaRelay} -> o $ ("viaRelay" .=? viaRelay) ["profile" .= profile, "newMemberId" .= newMemberId, "newMemberKey" .= newMemberKey]
XDirectDel -> JM.empty
XGrpInv groupInv -> o ["groupInvitation" .= groupInv]
XGrpAcpt memId -> o ["memberId" .= memId]
@@ -1426,16 +1506,18 @@ chatToAppMessage chatMsg@ChatMessage {chatVRange, msgId, chatMsgEvent} = case en
XGrpMemInv memId memIntro -> o ["memberId" .= memId, "memberIntro" .= memIntro]
XGrpMemFwd memInfo memIntro -> o ["memberInfo" .= memInfo, "memberIntro" .= memIntro]
XGrpMemInfo memId profile -> o ["memberId" .= memId, "profile" .= profile]
XGrpMemRole memId role -> o ["memberId" .= memId, "role" .= role]
XGrpMemRole memId role memberKey rosterVersion -> o $ ("memberKey" .=? memberKey) $ ("rosterVersion" .=? rosterVersion) ["memberId" .= memId, "role" .= role]
XGrpMemRestrict memId memRestrictions -> o ["memberId" .= memId, "memberRestrictions" .= memRestrictions]
XGrpMemCon memId -> o ["memberId" .= memId]
XGrpMemConAll memId -> o ["memberId" .= memId]
XGrpMemDel memId messages -> o $ ("messages" .=? if messages then Just True else Nothing) ["memberId" .= memId]
XGrpMemDel memId messages rosterVersion -> o $ ("rosterVersion" .=? rosterVersion) $ ("messages" .=? if messages then Just True else Nothing) ["memberId" .= memId]
XGrpLeave -> JM.empty
XGrpDel -> JM.empty
XGrpInfo p -> o ["groupProfile" .= p]
XGrpPrefs p -> o ["groupPreferences" .= p]
XGrpDirectInv connReq content scope -> o $ ("content" .=? content) $ ("scope" .=? scope) ["connReq" .= connReq]
XGrpRoster GroupRoster {version, fileInv} -> o ["version" .= version, "fileInv" .= fileInv]
XGrpRosterAck version err -> o $ ("error" .=? err) ["version" .= version]
XGrpMsgForward GrpMsgForward {fwdSender, fwdBrokerTs} msg -> o $ encodeFwdSender fwdSender ["msg" .= msg, "msgTs" .= fwdBrokerTs]
where
encodeFwdSender = \case