From 4bdeb68cc5ec001952e35b5fc0e51fdb63df197a Mon Sep 17 00:00:00 2001 From: "Evgeny @ SimpleX Chat" <259188159+evgeny-simplex@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:00:16 +0000 Subject: [PATCH] remove proof from cards, send owner public key for channel to relay --- .../Chat/ChatItem/CIChatLinkHeader.swift | 9 +- apps/ios/SimpleXChat/FileUtils.swift | 3 - .../views/chat/item/CIChatLinkHeader.kt | 18 +- .../simplex/common/views/helpers/Utils.kt | 3 - cabal.project | 10 +- docs/protocol/channels-protocol.md | 6 +- ...026-09-04-badge-binding-and-file-limits.md | 179 +++++++++--------- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Library/Commands.hs | 11 +- src/Simplex/Chat/Library/Internal.hs | 33 ++-- src/Simplex/Chat/Library/Subscriber.hs | 43 +++-- src/Simplex/Chat/Store/Groups.hs | 8 +- .../SQLite/Migrations/chat_query_plans.txt | 4 +- src/Simplex/Chat/Types.hs | 3 +- tests/ChatTests/Profiles.hs | 49 +---- tests/ChatTests/Utils.hs | 7 - 16 files changed, 155 insertions(+), 233 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIChatLinkHeader.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIChatLinkHeader.swift index 2008210c99..aaaa29929d 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIChatLinkHeader.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIChatLinkHeader.swift @@ -56,7 +56,7 @@ struct CIChatLinkHeader: View { ) .padding(.trailing, 4) VStack(alignment: .leading) { - NameWithBadge(Text(chatLink.displayName).font(.headline), linkBadge, .headline).lineLimit(2) + Text(chatLink.displayName).font(.headline).lineLimit(2) let fn = chatLink.fullName if fn != "" && fn != chatLink.displayName { Text(fn).font(.subheadline).lineLimit(2) @@ -65,11 +65,4 @@ struct CIChatLinkHeader: View { .frame(minHeight: 44) } } - - private var linkBadge: LocalBadge? { - guard case let .contact(_, profile, _) = chatLink, let b = profile.badge else { return nil } - let expired = Date.now.timeIntervalSince(b.badgeInfo.badgeExpiry) - let status: BadgeStatus = expired > BADGE_OLD_INTERVAL ? .expiredOld : expired > BADGE_GRACE_INTERVAL ? .expired : .active - return LocalBadge(badge: b.badgeInfo, status: status) - } } diff --git a/apps/ios/SimpleXChat/FileUtils.swift b/apps/ios/SimpleXChat/FileUtils.swift index 6cfd2c262e..a4d4e4ae0c 100644 --- a/apps/ios/SimpleXChat/FileUtils.swift +++ b/apps/ios/SimpleXChat/FileUtils.swift @@ -36,9 +36,6 @@ public let MAX_FILE_SIZE_XFTP_LEGEND: Int64 = 5_368_709_120 // 5GB // a badge raises the limit at send for this long after its expiry, shorter than the receiver's grace public let BADGE_SND_GRACE_INTERVAL = TimeInterval(86400) -public let BADGE_GRACE_INTERVAL = TimeInterval(7 * 86400) -public let BADGE_OLD_INTERVAL = TimeInterval(38 * 86400) - public let MAX_FILE_SIZE_LOCAL: Int64 = Int64.max public let MAX_FILE_SIZE_SMP: Int64 = 8000000 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatLinkHeader.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatLinkHeader.kt index a3d744672f..3c3e4baf49 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatLinkHeader.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatLinkHeader.kt @@ -10,13 +10,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.ui.theme.* -import chat.simplex.common.views.helpers.BADGE_GRACE_INTERVAL -import chat.simplex.common.views.helpers.BADGE_OLD_INTERVAL -import chat.simplex.common.views.helpers.NameWithBadge import chat.simplex.common.views.helpers.ProfileImage import chat.simplex.res.MR import dev.icerock.moko.resources.compose.stringResource -import kotlinx.datetime.Clock @Composable fun CIChatLinkHeader( @@ -43,9 +39,8 @@ fun CIChatLinkHeader( Modifier.defaultMinSize(minHeight = 54.dp), verticalArrangement = Arrangement.Center ) { - NameWithBadge( + Text( chatLink.displayName, - linkBadge(chatLink), style = MaterialTheme.typography.caption, fontWeight = FontWeight.Medium, maxLines = 2, @@ -82,14 +77,3 @@ fun CIChatLinkHeader( } } } - -private fun linkBadge(chatLink: MsgChatLink): LocalBadge? { - val b = (chatLink as? MsgChatLink.Contact)?.profile?.badge ?: return null - val expired = Clock.System.now() - b.badgeInfo.badgeExpiry - val status = when { - expired > BADGE_OLD_INTERVAL -> BadgeStatus.ExpiredOld - expired > BADGE_GRACE_INTERVAL -> BadgeStatus.Expired - else -> BadgeStatus.Active - } - return LocalBadge(b.badgeInfo, status) -} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index 25098190a5..2d6e70eae9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -133,9 +133,6 @@ const val MAX_FILE_SIZE_XFTP_LEGEND: Long = 5_368_709_120 // 5GB // a badge raises the limit at send for this long after its expiry, shorter than the receiver's grace val BADGE_SND_GRACE_INTERVAL: Duration = 1.days -val BADGE_GRACE_INTERVAL: Duration = 7.days -val BADGE_OLD_INTERVAL: Duration = 38.days - const val MAX_FILE_SIZE_LOCAL: Long = Long.MAX_VALUE expect fun getAppFileUri(fileName: String): URI diff --git a/cabal.project b/cabal.project index 310adca7e7..53501dda84 100644 --- a/cabal.project +++ b/cabal.project @@ -1,4 +1,4 @@ -packages: . ../simplexmq-4 +packages: . -- packages: . ../simplexmq -- packages: . ../simplexmq ../direct-sqlcipher ../sqlcipher-simple @@ -18,10 +18,10 @@ package cryptostore constraints: zip +disable-bzip2 +disable-zstd --- source-repository-package --- type: git --- location: https://github.com/simplex-chat/simplexmq.git --- tag: 965ac7b1e339d8c172e70685a845c62cb6b0ec87 +source-repository-package + type: git + location: https://github.com/simplex-chat/simplexmq.git + tag: 55b613a65822672b4bd1349304b30feff9fd3d02 source-repository-package type: git diff --git a/docs/protocol/channels-protocol.md b/docs/protocol/channels-protocol.md index 84753aa590..23e4308795 100644 --- a/docs/protocol/channels-protocol.md +++ b/docs/protocol/channels-protocol.md @@ -44,11 +44,11 @@ Creating a channel involves generating cryptographic material, creating the chan When a relay receives an invitation to serve a channel, it validates the channel and creates its own relay link. This flow is currently part of channel creation; adding relays to an existing channel is planned but not yet implemented. -1. Owner sends `x.grp.relay.inv` to the relay's contact address. This message includes the relay's member ID and role, the owner's profile, the channel's short link, and the channel's public group ID. +1. Owner sends `x.grp.relay.inv` to the relay's contact address. This message includes the relay's member ID and role, the owner's profile, the channel's short link, the channel's public group ID, and the owner's member key. -2. Relay receives the invitation and creates a relay request record. The owner's badge is verified under the channel binding of the public group ID from the invitation. A relay request worker processes it asynchronously. +2. Relay receives the invitation and creates a relay request record. The owner's badge is verified under the public group ID, the owner's member ID and the owner's member key from the invitation. A relay request worker processes it asynchronously. -3. The worker retrieves the channel's link data from the SMP server, extracts and validates the channel profile and owner authorization. The request fails when the link's entity ID differs from the public group ID in the invitation. +3. The worker retrieves the channel's link data from the SMP server, extracts and validates the channel profile and owner authorization. The request fails when the link's entity ID differs from the public group ID in the invitation, or when the owner's key in the link data differs from the owner's member key in the invitation. 4. The relay creates its own contact address link (the relay link) with the channel's entity ID in the immutable fixed data. diff --git a/plans/2026-09-04-badge-binding-and-file-limits.md b/plans/2026-09-04-badge-binding-and-file-limits.md index 54aa766e38..de10d1bab3 100644 --- a/plans/2026-09-04-badge-binding-and-file-limits.md +++ b/plans/2026-09-04-badge-binding-and-file-limits.md @@ -11,14 +11,14 @@ The same badge raises the size limit for files the user sends. Today each app de The changes: -1. Five new presentation headers: a profile shown in a chat, a file invitation, a file description, a contact request, and a link. The random header of released clients stays accepted, except in a shared address card. +1. Five new presentation headers: a profile shown in a chat, a file invitation, a file description, a contact request, and a link. The random header of released clients stays accepted. 2. Every profile badge is bound to the chat it is shown in: a direct chat, a contact request, a link, or a group membership (section 14). A proof bound to another chat is ignored. -3. In p2p groups a badge is accepted from a message signed by the member, from an introduction under the key it introduces, and from a join request under the request header. In channels it is accepted from any profile message. +3. In p2p groups a badge is accepted from a message signed by the member and from a join request under the request header; from an introduction only the random header is accepted. In channels it is accepted from a message signed by the member and from the relay's introduction under the member key in it. 4. Files above the default limit include a proof in the invitation and a proof in the description, in every chat type. The core library verifies both. The decision is stored on the file and shown by the apps from one field. 5. Forwarding a file above the forwarder's limit is refused with an alert before the forwarding sheet opens, and again, for the chosen destination, before anything is uploaded. 6. A received file keeps its two proofs, so a file re-sent to a new member as part of history keeps them; the sender's own files get fresh proofs from the credential. -Two new columns on `files`, and a new table `file_badge_proofs` holding the invitation proof and the description proof of a file, kept for history. The same table holds the proof of a group member, forwarded in introductions. A new column on `connections`, the request header of a prepared connection. The relay invitation includes the channel's public group id. In simplexmq: the hash of the fields shared by all descriptions of one upload, the verification codes of a connection, links and invitations before link data is signed, and the minimum SMP version raised to 15. +Two new columns on `files`, and a new table `file_badge_proofs` holding the invitation proof and the description proof of a file, kept for history. The same table holds the proof of a group member, forwarded in channel introductions. A new column on `connections`, the request header of a prepared connection. The relay invitation includes the channel's public group id and the owner's member key. In simplexmq: the hash of the fields shared by all descriptions of one upload, the verification codes of a connection, links and invitations before link data is signed, and the minimum SMP version raised to 15. ## Terms @@ -79,19 +79,20 @@ The file name is not part of either header: `validateFileInvitation` replaces it One constructor serves every chat type, because the chat binding already encodes the type of chat in its first byte. Each constructor gets a tag character in `ProofPresHeaderTag` and an encoding in the `StrEncoding` instance, in the same style as `PHTest`. The file expiration is optional, because a server may grant none; it is encoded as `strEncode` of the time, or one fixed byte when absent. The badge's own expiry is a time and is encoded with `strEncode` in the disclosed messages (`badgeInfoMessages`, `Badges.hs:296`). -`verifyBadgeWith` today verifies a proof with whatever header the proof contains. After this change the receiver first checks that the header names the sender as the receiver knows them, and only then runs BBS verification with that header. `proofPresHeaderAccepted` is removed; `verifyBadge` runs BBS verification only, and the header rule is applied by its callers: +`verifyBadgeWith` today verifies a proof with whatever header the proof contains. After this change the receiver first checks that the header names the sender as the receiver knows them, and only then runs BBS verification with that header. `proofPresHeaderAccepted` is removed; only BBS verification is run by `verifyBadge`, and the header rule is applied by its callers: - A contact request, link data, and the profile in a direct chat: the header of section 14. - A file in a direct chat: the receiver has the contact's connection and obtains its ratchet hash from the agent, as `newContentMessage` does for a contact card (`Subscriber.hs:1883`). The binding in the header must equal `encodeChatBinding CBDirect codeAD`. -- A profile or a file in a group: the receiver has the `GroupInfo` and the sender's `GroupMember`. The binding in the header must equal `groupBindingData` for that member — for a channel the group's public id and the member id; for a p2p group the member id and the member key. +- A file in a group: the receiver has the `GroupInfo` and the sender's `GroupMember`. The binding in the header must equal `groupBindingData` for that member — for a channel the group's public id and the member id; for a p2p group the member id and the member key. +- A profile in a group: for a channel the group's public id, the member id and the member key; for a p2p group the member id and the member key. **The key in a p2p binding.** For a file the receiver may not know the sender's key yet; if it knows one and it differs from the key in the header, the proof fails, otherwise the key in the header is used for this verification and never stored (`proofMemberKey`). For a profile the key is the one the message signature was verified under, section 3; a header key is never used. The file headers are checked the same way and then further: `PHFileInv` must also name the file size as received; `PHFileDescr` must also hold the hash of the received description and the expiration received with it. -`PHUnknown` fails every check. `PHTest`, presented by released clients, is accepted everywhere except in a shared address card. A released client that receives one of the new headers verifies it, because its `proofPresHeaderAccepted` admits unknown tags and BBS verification runs with the header bytes as sent. No protocol version change is needed. +`PHUnknown` fails every check. `PHTest`, presented by released clients, is accepted everywhere. A released client that receives one of the new headers verifies it, because its `proofPresHeaderAccepted` admits unknown tags and BBS verification runs with the header bytes as sent. No protocol version change is needed. -The expected header is computed in the chat layer and passed to the store, so `groupBindingData` and `profileBadgeVerified` stay where they are. One predicate is defined in `Badges.hs`: `acceptedProof`, true for `PHTest` and for a header equal to the expected one (section 14.3). +The expected header is computed in the chat layer and passed to the store; `groupBindingData` and `profileBadgeVerified` remain in `Internal.hs` and `Types.hs`. One predicate is defined in `Badges.hs`: `acceptedProof`, true for `PHTest` and for a header equal to the expected one (section 14.3). `SimplexDomainProof` (`Names.hs:37`) also uses `ProofPresHeader`, as an opaque value. Its verification is unchanged. @@ -99,9 +100,9 @@ The expected header is computed in the chat layer and passed to the store, so `g File: `src/Simplex/Chat/Library/Internal.hs`, `presentUserBadge` (`:2238`). -The proof for an outgoing profile is generated with `sndBadgeProof`, as file proofs are. The header, `Maybe ProofPresHeader`, is a parameter; with `Nothing`, no badge is presented. For a send into a group the header is `groupPresHeader`, `PHChat` of `sndGroupChatBinding gInfo False`, the user's own member binding in that group. The membership key is stored by `mkGroupKeys` when the group is read with its keys; for a membership without a stored public key, no badge is presented. The header of every call site is listed in section 14.3. +The proof for an outgoing profile is generated with `sndBadgeProof`, as file proofs are. The header, `Maybe ProofPresHeader`, is a parameter; with `Nothing`, no badge is presented. For a send into a group the header is `groupPresHeader`, `memberPresHeader` of the user's own member id and key in that group. The membership key is stored by `mkGroupKeys` when the group is read with its keys; for a membership without a stored public key, no badge is presented. The header of every call site is listed in section 14.3. -The two handshake sends (section 4) present the badge only when the peer version is at least `relayWebCapVersion`. +In the two handshake sends (section 4), the badge is presented only when the peer version is at least `relayWebCapVersion`. ## 3. Accepting the profile badge @@ -109,30 +110,32 @@ A received badge is verified today at seven places in the store layer, each veri The expected header, `Maybe ProofPresHeader`, is a parameter of the store functions and is checked with `acceptedProof` (section 14.3); `Nothing` is passed where a profile is built from a name alone. A proof rejected by `acceptedProof` is ignored: at creation no badge is stored, at update the stored badge is kept. A proof that fails BBS verification is stored as failed. -The chat layer computes the binding with `memberChatBinding gInfo memberId key_` (`Internal.hs`): for a channel `encodeChatBinding CBGroup (publicGroupId, memberId)`, whatever the key; for a p2p group `encodeChatBinding CBGroup (memberId, key)`, or `Nothing` without a key. `rcvGroupChatBinding` uses it for its member alternatives. The key passed at each site: +The header is computed in the chat layer by `memberPresHeader gInfo memberId key_` (`Internal.hs`): for a channel `PHChat (encodeChatBinding CBGroup (smpEncode (publicGroupId, memberId, key)))`; for a p2p group `PHChat (encodeChatBinding CBGroup (smpEncode (memberId, key)))`; `Nothing` without a key. File proofs keep the binding of message signatures (`rcvGroupChatBinding`). The key passed at each site: -- `xInfoMember` (`Subscriber.hs:2814`) and `xGrpLinkMem` (`:2820`): the stored key when it verifies the message signature, otherwise none. A key delivered by the message is stored first by `storeMemberKey` when it verifies the signature; `storeMemberKey` returns the member with the key. -- The member connection handshake, section 4: the stored key when it verifies the signature. -- Introductions — `createNewGroupMember`, `createIntroReMember`, `updateUnknownMemberAnnounced`: the key in the `MemberInfo`. -- Channel sites — `updateRosterMemberAnnounced`, `updatePreparedChannelMember`, a join via a relay (`createJoiningMember`, `updateMemberProfile`): no key; the channel binding is complete without it. +- `xInfoMember` (`Subscriber.hs:2798`) and `xGrpLinkMem` (`:2804`): the stored key when the message signature is verified with it, otherwise no key. A key delivered by the message is first stored by `storeMemberKey` when the signature is verified with it; the member with the stored key is returned by `storeMemberKey`. +- The member connection handshake, section 4: the stored key when the signature is verified with it. +- Introductions — `createNewGroupMember`, `createIntroReMember`, `updateUnknownMemberAnnounced`, `updateRosterMemberAnnounced`, `updatePreparedChannelMember`: in a channel the key in the `MemberInfo`; in a p2p group no header, and only `PHTest` is accepted. +- A join via a relay (`createJoiningMember`, `updateMemberProfile`): the key in `XMember` when the message signature is verified with it. +- The channel owner at a relay (`createRelayRequestGroup`, `relayInvPresHeader`): the owner's key claimed in the invitation. +- `XGrpAcpt` at the inviting host: the key delivered by the message, stored before the profile; the binding includes it when the message signature is verified with it. - A join by `XContact` to a p2p group: the request header (section 14.3). - A relay's profile, from its link data: `Nothing`. -In a channel a badge is accepted from any profile message: from `XMember` when the member joins, from the introduction by a relay, and from `XInfo`. In a p2p group a badge is accepted from a message whose signature was verified under the member's key, from an introduction under the key it introduces, and from a join request under the request header. +In a channel a badge is accepted from `XMember` and `XInfo` whose signature was verified under the member's key, and from the introduction by a relay under the key in it. In a p2p group a badge is accepted from a message whose signature was verified under the member's key and from a join request under the request header. ## 4. The member connection handshake When two p2p members connect, each sends `XGrpMemInfo` with its group profile. It is sent from two places: the reply on the member connection (`Subscriber.hs:843`) and the join of the member connection and of the direct connection to the same member (`:3336`, both joined with the same message at `:3345-3347`). The four receiving sites — `:615, 647` on the direct connection, `:837, 850` on the member connection — each have a "TODO update member profile" comment. -`XGrpMemInfo` is not in `requiresSignature`: the recipient enforces that list only in channels, where the handshake does not occur, and a signature needs the binary encoding, which the peer version decides. Instead `groupMsgSigning` signs `XGrpMemInfo` when its profile includes a badge, and the badge is presented only when the peer version is at least `relayWebCapVersion`, so a presented badge is always signed. The recipient keeps a badge only from a verified signature, so a profile without a badge needs none. +`XGrpMemInfo` is signed by `groupMsgSigning` when its profile includes a badge; the badge is presented only when the peer version is at least `relayWebCapVersion`. A `PHChat` proof is kept only from a verified signature. -- **Sign the join side.** `xGrpMemFwd` sends `encodeConnInfo $ XGrpMemInfo ...` (`:3336`), plain JSON. It takes `GroupInfoKeys` from the dispatch and encodes with `encodeSignedConnInfo` when `groupMsgSigning` returns a signing. The agreed version is computed below the send, as `chatV`; that computation moves above it, and the badge is presented when `chatV` is at least `relayWebCapVersion`. -- **The reply side** (`:843`) presents the badge when `peerChatVRange` of the connection allows, and `allowAgentConnectionAsync` signs by the same rule. -- **Parse the signature on CONF.** The member CONF site parses with `parseChatMessage` (`:781`), which discards the signature. Change it to `parseChatMessage'`, as INFO already does (`:850`). -- **Verify and store.** At `:853` and `:862` the signed message is passed to `processMemberProfileUpdate`, and the signature is verified with the member's stored key by `signedMemberPresHeader`; `XGrpMemInfo` names no key, and the handshake follows the introduction, which stored the key. The profile is stored with the binding from the verified key, or with no binding. -- `:615` and `:647` stay as they are. The profile there is the same group profile, received over the direct connection to the member. The contact for a member shares the member's profile row (`createIntroToMemberContact`), so storing it once, on the member connection, updates both. +- **Sign the join side.** In `xGrpMemFwd` (`:3336`), `XGrpMemInfo` is encoded with `encodeSignedConnInfo` when a signing is returned by `groupMsgSigning`; `GroupInfoKeys` is passed from the dispatch. The agreed version, `chatV`, is computed before the send, and the badge is presented when `chatV` is at least `relayWebCapVersion`. +- **The reply side** (`:843`): the badge is presented when the maximum of the connection's `peerChatVRange` is at least `relayWebCapVersion`, and the message is signed by `allowAgentConnectionAsync` by the same rule. +- **Parse the signature on CONF.** The member CONF site (`:781`) is parsed with `parseChatMessage'`, as INFO is (`:847`). +- **Verify and store.** At `:837` and `:850` the signed message is passed to `processMemberProfileUpdate`, and the signature is verified by `signedMemberPresHeader` with the stored member key: the key from the introduction, or, at `:850` for the inviting host, the key from `XGrpInv`. The profile is stored with the binding of the verified key, or with no binding. +- `:615` and `:647` are unchanged: the profile there is the same group profile, received over the direct connection to the member, and the member's profile row is used by the contact for the member (`createIntroToMemberContact`). -A member whose key was never introduced shows no badge until a signed `XInfo` that delivers the key arrives. +For a member whose key was never introduced, a `PHChat` proof is accepted only from a message that delivers the key and is signed with it. ## 5. The file size limit at send @@ -261,13 +264,13 @@ The six proof columns are the fields of `BadgeProof` — the proof, the presenta ## 13. Tests - `BadgeTests.hs`: each header encodes and decodes; a proof generated with one header fails with another; the key check accepts an unknown key, accepts an equal key, rejects a different one. -- `ChatTests/Profiles.hs`, beside the seven badge tests: a badge in a p2p group is shown at the other member from the introduction and from the connection handshake; a proof presented under another member's binding is ignored; a badge in a channel is shown on presentation. The tests of section 14.3 follow. +- `ChatTests/Profiles.hs`, beside the seven badge tests: a badge in a p2p group is shown at the other member from the connection handshake, and not from the introduction; a proof presented under another member's binding is ignored; a badge in a channel is shown on presentation. The tests of section 14.3 follow. - `ChatTests/Files.hs`, beside `testXFTPGroupFileTransfer`: a file above the default limit from a badge holder is received in a group and in a direct chat; an invitation whose proof was made for another member is refused; a description with a changed hash fails before download; a file above the limit received as history is received by the new member; a forward into an incognito membership above the default limit fails the command before any upload. - `ProtocolTests.hs`: the new fields in `FileInvitation` and `XMsgFileDescr`. ## 14. Bindings in every chat -Every profile badge is bound to the chat it is shown in. The sender presents the proof under the header of that chat; the receiver computes the expected header and accepts the proof only under it. Implementation starts in simplexmq, in `/code/simplexmq-4`; the chat then builds against it. +Every profile badge is bound to the chat it is shown in. The sender presents the proof under the header of that chat; the receiver computes the expected header and accepts the proof under it, and under `PHTest`. Implementation starts in simplexmq, in `/code/simplexmq-4`; the chat then builds against it. Line references in this section are to the branch; in sections 1-13, to master. Rules: @@ -282,9 +285,9 @@ Rules: | Request to an address with ratchet keys | address owner | `PHChat (encodeChatBinding CBDirect codeAD)` | | Request to an address without ratchet keys | address owner, group host | `PHRequest code` — the joining party's keys, the address queue | | One-time invitation link data | joining party | `PHLink linkKey` | -| Address link data, shared address card | anyone with the link | `PHLink linkKey` | +| Address link data | anyone with the link | `PHLink linkKey` | | P2p group | members | `PHChat (encodeChatBinding CBGroup (smpEncode (memberId, memberKey)))` | -| Channel | subscribers | `PHChat (encodeChatBinding CBGroup (smpEncode (publicGroupId, memberId)))` | +| Channel | subscribers | `PHChat (encodeChatBinding CBGroup (smpEncode (publicGroupId, memberId, memberKey)))` | An address is a contact address, a business address, or a group link. The link key is part of the link: `sha3_256` of the fixed link data — the agent version range, the root key, the connection request with its server and queue id, and the entity id of an address (`encodeSignFixedData`, `ShortLink.hs`). @@ -292,7 +295,7 @@ An address is a contact address, a business address, or a group link. The link k Merged in simplexmq `5294b7d8`. -**Second verification code.** `RatchetInitParams` gains `rcVerifyCodePQ` and `Ratchet` gains `rcVCPQ :: Maybe Str`. `pqX3dh` derives the code with a separate HKDF over the same inputs, info `SimpleXVerifyCode`, 32 bytes; the ratchet keys and `rcAD` are unchanged. It is not associated data itself — it is exported keying material, the fourth of the paper's mitigations, and it covers every handshake input, the KEM included. A ratchet created before this change decodes with `rcVCPQ = Nothing`, and the value cannot be computed for it afterwards — the handshake secrets are gone — so it appears at the next ratchet resync or not at all. For that reason the chat keeps using the AD code, `codeAD`, for the security code and for badge bindings; `codePQ` is stored now and used when connections have it. +**Second verification code.** `RatchetInitParams` gains `rcVerifyCodePQ` and `Ratchet` gains `rcVCPQ :: Maybe Str`. The code is derived in `pqX3dh` with a separate HKDF over the same inputs, info `SimpleXVerifyCode`, 32 bytes; the ratchet keys and `rcAD` are unchanged. The code is exported keying material over every handshake input, the KEM included — the fourth of the paper's mitigations. A ratchet created before this change is decoded with `rcVCPQ = Nothing`, and its code is set at the next ratchet resync. The chat uses the AD code, `codeAD`, for the security code and for badge bindings; `codePQ` is stored. **Columns.** Migration `M20260919_ratchet_verify_codes`, SQLite and Postgres, both schema dumps: @@ -301,9 +304,9 @@ ALTER TABLE ratchets ADD COLUMN rc_verify_code_ad BLOB; ALTER TABLE ratchets ADD COLUMN rc_verify_code_pq BLOB; ``` -`BYTEA` on Postgres. `createRatchet` and `createSndRatchet` write both, also through their `ON CONFLICT` update, which is how a resync recreates the ratchet. +`BYTEA` on Postgres. Both are written by `createRatchet` and `createSndRatchet`, also through their `ON CONFLICT` update, by which a ratchet is recreated at a resync. -One store function, `getRatchetVerifyCodes :: DB.Connection -> [ConnId] -> IO (Map ConnId ConnVerifyCodes)`, serves one id or many: +One store function, `getRatchetVerifyCodes :: DB.Connection -> [ConnId] -> IO (Map ConnId ConnVerifyCodes)`, is used for one id or many: ```sql SELECT conn_id, rc_verify_code_ad, rc_verify_code_pq, CASE WHEN rc_verify_code_ad IS NULL THEN ratchet_state END @@ -328,7 +331,7 @@ data ContactRequestBinding = CRBRatchet ConnVerifyCodes | CRBRequest ByteString - `CRContactUri` with ratchet keys: the same, from the address keys. - `CRContactUri` without keys: the x3dh keys are generated and stored (`generateRcvE2EParams`, `createRatchetX3dhKeys`); the binding is `CRBRequest (sha256 (smpEncode (k1, k2, kem, senderId)))` — the request's public keys and the queue id from the link's `SMPQueueUri`. -`prepareConnectionToAccept` returns the same pair: for `CRInvitation` it creates the ratchet from the invitation's keys, for `CRInvitationDR` it takes the ratchet stored in the invitation. `startJoinInvitation` reads the ratchet before creating one, as the contact path and its retry branch already do; `createConnReq` reads the x3dh keys before generating them, as `mkJoinInvitation` does. Async joins and accepts then find the ratchet in place. Nothing in the prepare step touches the network. +The same pair is returned by `prepareConnectionToAccept`: for `CRInvitation` the ratchet is created from the invitation's keys, for `CRInvitationDR` the ratchet stored in the invitation is used. In `startJoinInvitation` the ratchet is read before one is created, as in the contact path and its retry branch; in `createConnReq` the x3dh keys are read before they are generated, as in `mkJoinInvitation`. The stored ratchet is used by async joins and accepts. The prepare step is local. **Events.** A `ContactRequestBinding` field in `REQ`: `CRBRequest` in `smpInvitation`, computed from the received `CRInvitationUri` and the queue of the request; `CRBRatchet` in `smpContactRequest`, from the ratchet initialised there. `CONF` and `INFO` are unchanged: the receiver's ratchet is stored before the notification, so its codes are available to `getConnectionVerifyCodes`. @@ -340,9 +343,9 @@ Files: `Agent.hs`, `Agent/Client.hs`, `Agent/Protocol.hs`, `Crypto/ShortLink.hs` The chat pins the simplexmq commit in `cabal.project` and `scripts/nix/sha256map.nix`. -Every link, and every invitation, is available to the chat before its link data is signed; each creation makes one network request. +A link prepared by `prepareConnectionLink` is available to the chat before its link data is signed: a contact link with its short link, an invitation as its full link and its link key; one network request is made per creation. -**New links.** The link, and for an invitation the invitation, are returned by `prepareConnectionLink` before the user data is signed, in both connection modes: +**New links.** The contact link with its short link, or the invitation with its link key `plpLinkKey`, is returned by `prepareConnectionLink` before the user data is signed; the short link of an invitation, with the link id assigned by the server, is returned by `createConnectionForLink`: ```haskell prepareConnectionLink :: AgentClient -> UserId -> SConnectionMode c -> C.KeyPairEd25519 -> Maybe ByteString -> Bool -> Maybe CRClientData -> CR.InitialKeys -> UseRatchetKeys -> Maybe SMPServerWithAuth -> AE (CreatedConnLink c, PreparedLinkParams c) @@ -369,11 +372,10 @@ createConnectionForLink :: AgentClient -> NetworkRequestMode -> UserId -> Bool - - link data: `SL.encodeSignUserData SCMInvitation`, encrypted by `encryptInvLinkData` with `SL.invShortLinkKdf plpLinkKey`; `newRcvConnSrv` uses the same function - then the connection is created and the `PRKInvitation` keys are stored with `createRatchetX3dhKeys`; the connection is deleted when storing fails - queue request: `CQRMessaging (Just CQRData {linkKey, privSigKey, srvReq = (sndId, srvData)})` -- Both modes create the queue with the local `createLinkQueue`: +- In both modes the queue is created by the local `createLinkQueue`: - `createRcvQueue` - - the sender id check of `createConnectionForLink'` on master, error `sender ID mismatch` - - the returned link from `connReqWithShortLink`, moved from `newRcvConnSrv` to top level — `CSLInvitation` with the link id from the server, PQ keys removed from the full link for `IKPQOn` -- Tests: a connection via an invitation made by prepare and create; its link data is read by the joining party; `plpLinkKey` equals the key in the returned `CSLInvitation`; link data above the size limit fails with `CMD LARGE` and leaves no connection. The six existing test calls are updated for the mode and the pair. + - the returned link from `connReqWithShortLink`, moved from `newRcvConnSrv` to top level, with the created `RcvQueue` as a parameter — `CSLInvitation` with the link id from the server, PQ keys removed from the full link for `IKPQOn` +- Tests: a connection via an invitation made by prepare and create; its link data is read by the joining party; `plpLinkKey` equals the key in the returned `CSLInvitation`; for link data above the size limit, `CMD LARGE` is returned before the connection is created. The six existing test calls are updated for the mode and the pair. **Existing connections.** The link of a connection is returned without a network call: @@ -383,7 +385,7 @@ prepareConnShortLink :: AgentClient -> ConnId -> Maybe CRClientData -> AE (ConnS - A stored link is returned as is. - Otherwise the credentials are created and stored by `newContactLinkCreds :: AgentClient -> RcvQueue -> Maybe CRClientData -> AM ShortLinkCreds`: the signing key pair is generated, the fixed data is built from the connection request without ratchet keys, signed and encrypted (`SL.encryptFixedData`), and `ShortLinkCreds` are stored. -- `setConnShortLink` uses `newContactLinkCreds` for a connection without stored credentials, and then encrypts and uploads the user data with one `LSET`. +- In `setConnShortLink`, `newContactLinkCreds` is used for a connection without stored credentials, and the user data is then encrypted and uploaded with one `LSET`. - Tests: for an address without a short link, the link is the same from two `prepareConnShortLink` calls and from `setConnShortLink`; a requester connects via it. **SMP versions below 15.** The minimum SMP version of clients and servers is 15, and the code for older versions is removed: @@ -391,26 +393,27 @@ prepareConnShortLink :: AgentClient -> ConnId -> Maybe CRClientData -> AE (ConnS - `NEW` and `IDS` (`Protocol.hs`): the encodings and parsers for versions below 15, with `qReq` and `qm`. - `mkShortLinkCreds` (`Agent/Client.hs`): link data without a link id in the response is an error; the `THandleParams` parameter is removed. - `connReqWithShortLink`: absent short link credentials are an `INTERNAL` error. +- `createConnectionForLink'`: the sender id check of master, error `sender ID mismatch`, is removed; the sender id is compared by `mkShortLinkCreds` and by `connReqWithShortLink`. - `Transport.hs`: `shortLinksSMPVersion` is renamed `_shortLinksSMPVersion` and is not exported; `_proxyServerHandshakeSMPVersion` is removed. - Tests: `testInvitationShortLinkPrev` and `testProxyMatrixWithPrev` are removed. ### 14.3 Chat -Todo, in implementation order. At every direct send, a `Nothing` from `connPresHeader` or `connsPresHeaders`, and a retry without a stored request header, are replaced with `PHTest` by `sndPresHeader`. At a send into a group, a `Nothing` from `groupPresHeader` presents no badge. +In implementation order. At every direct send, a `Nothing` from `connPresHeader`, a header missing from the `connsPresHeaders` map, and a retry without a stored request header are replaced with `PHTest` by `sndPresHeader`. At a send into a group, no badge is presented for a `Nothing` from `groupPresHeader`. **1. Headers** — `Badges.hs` - `PHRequest ByteString` in `ProofPresHeader`, tag `'R'`. - `PHLink ByteString` in `ProofPresHeader`, tag `'L'`; payload: the link key bytes. -- `acceptedProof :: Maybe ProofPresHeader -> BadgeProof -> Bool`: true for `PHTest`, and for a header equal to `strEncode` of the expected one. `unboundProof` and `boundProof` are replaced by it. +- `acceptedProof :: Maybe ProofPresHeader -> BadgeProof -> Bool`: true for `PHTest`, and for a header equal to `strEncode` of the expected one. `proofPresHeaderAccepted` and `verifyBadge_` are removed. - `ToField` and `FromField` for `ProofPresHeader`: the `strEncode` bytes as a blob. **2. Ignored proofs** — `Types.hs`, store modules - A proof rejected by `acceptedProof` is ignored. - At creation no badge is stored: `createContact_`, `createContactRequest` (`ContactRequest.hs:170`), `createJoiningMember`, `createNewMemberProfile_`. -- At update the stored badge is kept: the badge to store and its verification are returned by `profileBadgeVerified` — for a rejected proof, the stored proof and its stored verification. Used in `updateContactProfile`, `updateMemberProfile`, `updateContactMemberProfile`, and the request update (`ContactRequest.hs:237`). -- The predicate is removed from `verifyBadge_`. `BSFailed` is stored only for a failed BBS verification. +- At update the stored badge is kept: the badge to store and its verification are returned by `profileBadgeVerified` — for a rejected proof, the stored proof, verified again when its stored status is `BSFailed` or `BSUnknownKey`. Used in `updateContactProfile`, `updateMemberProfile`, `updateContactMemberProfile`, and the request update (`ContactRequest.hs:239`). +- `proofPresHeaderAccepted` is removed from `verifyBadgeWith`, and `verifyBadge_` is removed; at the creation sites `profileBadgeVerified` is called with no stored profile. `BSFailed` is stored only for a failed BBS verification. **3. Expected header in the store** @@ -428,8 +431,9 @@ The expected header, `Maybe ProofPresHeader`, is a parameter of: - `updateUnknownMemberAnnounced` - `updateRosterMemberAnnounced` - `updatePreparedChannelMember` +- `createRelayRequestGroup` -`Nothing` is passed by `createContact`, the preset contact card, and by `setRelayLinkAccepted` and `updateRelayMemberData`. `processMemberProfileUpdate` takes the signed message and computes the header with `signedMemberPresHeader`. Group callers compute the header with `memberPresHeader` or `memberInfoPresHeader`. +`Nothing` is passed by `createContact` for the preset contact card, by `setRelayLinkAccepted` and `updateRelayMemberData` for a relay profile, by `updatePreparedUserAndHostMembers'` for a host profile built from a name, and by `acceptGroupJoinSendRejectAsync`. In `processMemberProfileUpdate` the header is computed from the signed message by `signedMemberPresHeader`. Other group headers are computed by `memberPresHeader` or `memberInfoPresHeader`; the request header is passed for a join by `XContact`, and `relayInvPresHeader` for a relay invitation. **4. Presenting** — `Internal.hs` @@ -439,18 +443,19 @@ presentUserBadge :: User -> Maybe i -> Maybe ProofPresHeader -> Profile -> CM Pr With `Nothing`, no badge is presented. A badge is presented only when `presentsUserBadge :: User -> Bool` holds: the user's own badge is active or expired. Header helpers: -- `groupPresHeader :: GroupInfo -> Maybe ProofPresHeader` — `PHChat <$> sndGroupChatBinding gInfo False` +- `groupPresHeader :: GroupInfo -> Maybe ProofPresHeader` — `memberPresHeader` of the membership's id and key - `directPresHeader :: ContactRequestBinding -> ProofPresHeader` — `PHChat (encodeChatBinding CBDirect codeAD)` for `CRBRatchet`, `PHRequest code` for `CRBRequest` - `linkPresHeader :: ConnShortLink c -> ProofPresHeader` — `PHLink` of the link key -- `memberPresHeader :: GroupInfo -> MemberId -> Maybe C.PublicKeyEd25519 -> Maybe ProofPresHeader` — `PHChat <$> memberChatBinding` -- `memberInfoPresHeader :: GroupInfo -> MemberInfo -> Maybe ProofPresHeader` — `memberPresHeader` of the id and key in the `MemberInfo` +- `memberPresHeader :: GroupInfo -> MemberId -> Maybe C.PublicKeyEd25519 -> Maybe ProofPresHeader` — `PHChat (encodeChatBinding CBGroup (smpEncode (publicGroupId, memberId, key)))` in a channel, `PHChat (encodeChatBinding CBGroup (smpEncode (memberId, key)))` in a p2p group, `Nothing` without a key +- `memberInfoPresHeader :: GroupInfo -> MemberInfo -> Maybe ProofPresHeader` — in a channel `memberPresHeader` of the id and key in the `MemberInfo`; `Nothing` in a p2p group +- `relayInvPresHeader :: GroupRelayInvitation -> Maybe ProofPresHeader` — the channel header of the owner's id and key in the invitation (item 12) - `sndPresHeader :: Maybe ProofPresHeader -> CM ProofPresHeader` — the header, or `PHTest` of 16 random bytes for `Nothing` - `connPresHeader :: Connection -> CM (Maybe ProofPresHeader)` — `directPresHeader . CRBRatchet` of `getConnectionVerifyCodes`; `Nothing` on error - `connsPresHeaders :: [Connection] -> CM (Map ConnId ProofPresHeader)` — the same from `getConnectionsVerifyCodes` **5. The binding from prepare steps** -Each returns `directPresHeader` of the agent binding: +The result of each includes `directPresHeader` of the agent binding: - `prepareContact :: User -> ConnReqContact -> PQSupport -> CM (ConnId, VersionChat, ProofPresHeader)` - `prepareAgentJoin :: User -> Bool -> ConnectionRequestUri c -> CM ((CommandId, ConnId), ProofPresHeader)` @@ -472,33 +477,33 @@ ALTER TABLE connections ADD COLUMN pres_header BLOB; **7. Direct sends** - `joinContact` (`Commands.hs:3979`): the request header, `ProofPresHeader`, is a parameter, set in `connect'`, `joinPreparedConn'` and `connectContactViaAddress` — the `prepareContact` header for a new connection, the stored header for a retry. The badge is presented with `groupPresHeader` in a relay group, and with the request header otherwise. -- `connectViaInvitation` (`Commands.hs:3819-3832`) and `connectMemberContact` (`:3398-3413`): the prepare binding for a new connection; `connPresHeader` for a prepared one. +- `connectViaInvitation` (`Commands.hs:3801-3833`) and `connectMemberContact` (`:3388-3415`): the prepare binding for a new connection; `connPresHeader` for a prepared one. - `joinMemberContactAsync` (`Subscriber.hs:3947`): the header is a parameter, set in `xGrpDirectInv` to the `prepareAgentJoin` header. -- `acceptContactRequest` (`Internal.hs:970-1000`): the prepare binding for a new connection; `connPresHeader` for an existing one. +- `acceptContactRequest` (`Internal.hs:974-1005`): the prepare binding for a new connection; `connPresHeader` for an existing one. - `acceptContactRequestAsync` (`Internal.hs:1007-1026`): the profile is built after `prepareAgentAccept`, from its header. - `CONF` replies (`Subscriber.hs:509` direct case, `:628`) and `updateContactPrefs` (`Commands.hs:4108`): `connPresHeader`. - `sendUpdateToContacts` (`Commands.hs:4039-4077`) and `presentUserBadgeToContacts` (`:5212-5228`): one `connsPresHeaders` call per command, when `presentsUserBadge` holds. **8. Direct receipts** -- `REQ` (`Subscriber.hs:1396`): `directPresHeader` of the `REQ` binding is a parameter of `profileContactRequest`, passed to `createOrUpdateContactRequest` and to `acceptGroupJoinRequestAsync`. -- `processContactProfileUpdate` (`Subscriber.hs:2771`) and `saveConnInfo` (`:3180`, for `createDirectContact`): the header is a parameter, `connPresHeader` of the connection, read by the caller. A `CONF` reply is presented with the same header. +- `REQ` (`Subscriber.hs:1404`): `directPresHeader` of the `REQ` binding is a parameter of `profileContactRequest`, passed to `createOrUpdateContactRequest` and to `acceptGroupJoinRequestAsync`. +- `processContactProfileUpdate` (`Subscriber.hs:2771`) and `saveConnInfo` (`:3180`, for `createDirectContact`): the header is a parameter, `connPresHeader` of the connection, read by the caller. In the direct case, the badge in the `CONF` reply is presented with the same header. **9. Groups** -- `groupPresHeader` at every send into a group: `Commands.hs:4319`; `Subscriber.hs:505` group case, `:637`, `:826`, `:843`, `:970`, `:1252`, `:3356`; `Internal.hs:2656`. -- `acceptGroupJoinRequestAsync`: the expected header is a parameter, in place of `binding_` (`Internal.hs:1028`) — `memberPresHeader gInfo joiningMemberId Nothing` in `memberJoinRequestViaRelay`. `Nothing` in `acceptGroupJoinSendRejectAsync`. +- `groupPresHeader` at every send into a group: `Commands.hs:3998` (`joinContact`, relay group), `:4329`; `Subscriber.hs:506` group case, `:642`, `:836`, `:978`, `:1260`, and `membershipHandshakeProfile` (`:3379-3384`) for `:799`, `:850` and `:3366`; `Internal.hs:2530` (`encodeXGrpAcpt`) and `:2715`. +- `acceptGroupJoinRequestAsync` (`Internal.hs:1028`): the expected header is a new parameter, passed to `createJoiningMember` and `updateMemberProfile` — in `memberJoinRequestViaRelay`, `memberPresHeader` of the joining member's id and the key in `XMember` when the message signature is verified with it. `Nothing` is passed to `createJoiningMember` in `acceptGroupJoinSendRejectAsync`. - Host `INFO` with `XInfo` (`Subscriber.hs:865-872`): after `storeMemberKey`, the profile is stored by `processMemberProfileUpdate` with `signedMemberPresHeader`, under the member's key. -- The profile is also stored by `processMemberProfileUpdate` when the received proof is accepted and differs from the stored member proof in its header or its disclosed information, and when a profile without a proof arrives for a member with a stored proof. +- The profile is also stored by `processMemberProfileUpdate` when the received proof is accepted and differs from the stored member proof in its header or its disclosed information, and when a profile without a proof is received for a member with a stored proof. - Introductions: - - In `memberInfo` (`Internal.hs:1335`) the stored proof (item 11) is included when `acceptedProof (memberPresHeader g memberId memberPubKey)` holds, and omitted otherwise. - - In `xGrpMemNew`, `xGrpMemIntro` and `xGrpMemFwd` (`Subscriber.hs:3207, 3290, 3342`): `memberInfoPresHeader`, with the member id and key from the `MemberInfo`. + - In `memberInfo` (`Internal.hs:1335`) the stored proof (item 11) is included when `acceptedProof` holds under `memberPresHeader` of the member's id and stored key in a channel, and under `Nothing` in a p2p group: in a p2p group only a `PHTest` proof is included. + - In `xGrpMemNew`, `xGrpMemIntro` and `xGrpMemFwd` (`Subscriber.hs:3207, 3290, 3342`): `memberInfoPresHeader`; in a p2p group only `PHTest` is accepted. - Invitation via contact: - `profile :: Maybe Profile` in `XGrpAcpt`, the optional JSON field `profile`. - - The invitee (`Commands.hs:2845`, `Subscriber.hs:2697`) includes its group profile, with `groupPresHeader`, when the maximum of the contact connection's `peerChatVRange` is at least `relayWebCapVersion`; the message is encoded with `encodeSignedConnInfo` when a signing is returned by `groupMsgSigning`. - - `XGrpAcpt` with a badge in its profile is signed by `groupMsgSigning` (`Internal.hs:2316-2319`). + - The invitee (`Commands.hs:2847`, `Subscriber.hs:2708`, both by `encodeXGrpAcpt`, `Internal.hs:2525-2533`) includes its group profile, with `groupPresHeader`, when the maximum of the contact connection's `peerChatVRange` is at least `relayWebCapVersion`; the message is encoded with `encodeSignedConnInfo` when a signing is returned by `groupMsgSigning`. + - `XGrpAcpt` with a badge in its profile is signed by `groupMsgSigning` (`Internal.hs:2358-2362`). - The host (`Subscriber.hs:792-798`) stores the key, then the profile, by `processMemberProfileUpdate` with the signed message. For an invitee whose contact is active, the profile row is kept by `canUpdateProfile` and the proof is stored on the membership (item 11). - - The host replies with `XGrpMemInfo` and its group profile in place of `XOk` (`Subscriber.hs:791`); the badge is presented when the invitee's version is at least `relayWebCapVersion`, as at `:840-846`. + - The host replies with `XGrpMemInfo` and its group profile in place of `XOk` (`Subscriber.hs:799-801`); the badge is presented when the invitee's version is at least `relayWebCapVersion`, as at `:847-852`. **10. Link data** @@ -510,14 +515,10 @@ ALTER TABLE connections ADD COLUMN pres_header BLOB; - `linkDataBadge` (`Internal.hs:2285`): the expected header is a parameter — `linkPresHeader` of the short link, for an invitation (`Commands.hs:4415`) and for an address (`:4469-4470`). For a rejected proof the display badge, `localBadge`, is cleared. - `createPreparedContact` (`Commands.hs:2206`): `linkPresHeader` of the short link in `accLink`; `Nothing` without a short link. - `updateContactFromLinkData` (`Internal.hs:1644`): the header of the address link is a parameter. -- Shared address card: - - `APIShareMyAddress` (`Commands.hs:1243-1255`): the badge is presented with `linkPresHeader` of `connLink`. - - Receipts, by `chatLinkBadge` (`Internal.hs:2294`): new messages and updates in direct chats and groups, the message of a contact request, a member contact invitation, and the welcome message of a prepared chat. The proof in `MCLContact.profile` is kept when its header equals `linkPresHeader` of `connLink` and it verifies, and removed otherwise; `PHTest` is not accepted. - - The badge from `profile.badge` is shown in `CIChatLinkHeader`, on iOS and in the multiplatform app. Its status is computed by the app from the expiry: active until seven days past it, expired until 38 days past it, hidden after. **11. Member proofs** — `Badges.hs`, `Types.hs`, `Types/Preferences.hs`, `Store/Shared.hs`, `Store/Groups.hs`, `Store/Connections.hs`, `Internal.hs`, `Subscriber.hs` -A member's accepted proof is stored on the membership, in `file_badge_proofs`, and forwarded in introductions. The profile row keeps its badge for display. +A member's accepted proof is stored on the membership, in `file_badge_proofs`, and forwarded in channel introductions. The badge in the profile row is kept for display. Migration `M20260925_member_badge_proofs`, SQLite: @@ -545,9 +546,9 @@ ALTER TABLE file_badge_proofs ADD COLUMN group_member_id BIGINT REFERENCES group CREATE UNIQUE INDEX idx_file_badge_proofs_group_member_id ON file_badge_proofs(group_member_id); ``` -The down migration deletes member rows before `file_id` is `NOT NULL` again. Both schema dumps and `chat_query_plans.txt` are updated. +In the down migration, member rows are deleted before `file_id` is `NOT NULL` again. Both schema dumps and `chat_query_plans.txt` are updated. -- A member row has `group_member_id` set, `file_id` NULL, and `proof_kind` `member`: `BPKMember` in `BadgeProofKind`. `getFileBadgeProofs` ignores it. +- A member row has `group_member_id` set, `file_id` NULL, and `proof_kind` `member`: `BPKMember` in `BadgeProofKind`. It is ignored by `getFileBadgeProofs`. - `MaybeBadgeProofRow` and `maybeRowToBadgeProof` in `Badges.hs`: the six proof columns of a `LEFT JOIN`. - `PrefsJSON` is generalised: @@ -557,41 +558,45 @@ The down migration deletes member rows before `file_id` is `NOT NULL` again. Bot type PrefsJSON = NoJSON Object ``` - `ToJSON` omits the field; `FromJSON` gives `NoJSON Nothing`. The construction sites use `NoJSON`. -- `GroupMember` gains `memberBadgeProof :: NoJSON BadgeProof`. The bot API docs remove it: `removeField "memberBadgeProof" $ sti @GroupMember`. + The field is omitted by `ToJSON`; `NoJSON Nothing` is returned by `FromJSON`. `NoJSON` is used at the construction sites. +- `GroupMember` gains `memberBadgeProof :: NoJSON BadgeProof`. It is removed from the bot API docs: `removeField "memberBadgeProof" $ sti @GroupMember`. - Reads: - - `groupMemberQuery` selects the proof columns after the connection columns, with `LEFT JOIN file_badge_proofs bp ON bp.group_member_id = m.group_member_id`; `toContactMember` sets the field. - - The member read of `getConnectionEntity` (`Connections.hs:145-194`) selects them the same way; `toGroupAndMember` sets the field. - - `toGroupMember` sets `NoJSON Nothing`, for the membership, chat item members and quoted members. + - The proof columns are selected by `groupMemberQuery` after the connection columns, with `LEFT JOIN file_badge_proofs bp ON bp.group_member_id = m.group_member_id`; the field is set by `toContactMember`. + - They are selected the same way by the member read of `getConnectionEntity` (`Connections.hs:145-194`); the field is set by `toGroupAndMember`. + - `NoJSON Nothing` is set by `toGroupMember`, for the membership, chat item members and quoted members. - Writes, for a received profile: - a badge accepted by `acceptedProof` under the expected header is upserted; - - a profile without a badge deletes the row; - - a badge not accepted leaves the row as it is. -- `setMemberBadgeProof :: DB.Connection -> GroupMember -> Maybe ProofPresHeader -> Profile -> IO GroupMember` applies these rules and returns the member with the field set. Used by `updateMemberProfile` and `updateContactMemberProfile`, and by `processMemberProfileUpdate` when `canUpdateProfile` is false. -- `processMemberProfileUpdate` passes the received profile to the store functions. The profile with the stored proof in place of a rejected one is used for the comparisons and the chat item only. + - for a profile without a badge, the row is deleted; + - for a badge not accepted, the row is kept. +- These rules are applied by `setMemberBadgeProof :: DB.Connection -> GroupMember -> Maybe ProofPresHeader -> Profile -> IO GroupMember`, and the member with the field set is returned. Used by `updateMemberProfile` and `updateContactMemberProfile`, and by `processMemberProfileUpdate` when `canUpdateProfile` is false. +- The received profile is passed to `updateMemberProfile` after `redactedMemberProfile`, and unredacted to `updateContactMemberProfile` and `setMemberBadgeProof`. The profile with the stored proof in place of a rejected one is used for the comparisons, the business chat profile and the chat item. - At creation the accepted proof is inserted: `createNewMember_` (for `createNewGroupMember` and `createIntroReMember`), `createJoiningMember`, and the owner in `createRelayRequestGroup`. -- `memberInfo` includes `memberBadgeProof`, or the profile's proof when it is absent, filtered by `acceptedProof` as before. +- `memberBadgeProof`, or the profile's proof when it is absent, is included by `memberInfo`, filtered by `acceptedProof` under `publicGroup' g *> memberPresHeader g memberId memberPubKey`. **12. The channel owner at the relay** — `Types.hs`, `Commands.hs`, `Subscriber.hs`, `Internal.hs`, `Store/Groups.hs`, `Store/Shared.hs` -- `GroupRelayInvitation` gains `publicGroupId :: Maybe B64UrlByteString`, the optional JSON field `publicGroupId`. The owner sets it in `addRelays` from `publicGroup' gInfo`. -- `relayInvPresHeader :: GroupRelayInvitation -> Maybe ProofPresHeader` — `PHChat (encodeChatBinding CBGroup (smpEncode (publicGroupId, memberId)))` with the owner's member id; `Nothing` without the claim. -- `createRelayRequestGroup` takes it as the expected header, at `xGrpRelayInv` and `rejectRelayInvitationAsync`. The owner's profile and membership proof are stored under it. -- The claim is written to the placeholder's `group_profiles.public_group_id`. `group_type` and `group_link` stay NULL, so `publicGroup` is `Nothing` and `mkGroupKeys` returns `GKRelayRequest`. +- `GroupRelayInvitation` gains `publicGroupId :: Maybe B64UrlByteString` and `fromMemberKey :: Maybe MemberKey`, the optional JSON fields `publicGroupId` and `fromMemberKey`. The owner sets them in `addRelays` from `publicGroup' gInfo` and from the membership key. +- `relayInvPresHeader :: GroupRelayInvitation -> Maybe ProofPresHeader` — `PHChat (encodeChatBinding CBGroup (smpEncode (publicGroupId, memberId, memberKey)))` with the owner's member id and key; `Nothing` without both claims. +- It is passed to `createRelayRequestGroup` as the expected header, at `xGrpRelayInv` and `rejectRelayInvitationAsync`. The owner's profile and membership proof are stored under it. +- The group id claim is written to the placeholder's `group_profiles.public_group_id`. `group_type` and `group_link` are NULL, so `publicGroup` is `Nothing` and `GKRelayRequest` is returned by `mkGroupKeys`. +- The key claim is written to the owner's `group_members.member_pub_key`. - `GroupKeysRow` gains the profile's `public_group_id`, set by `toGroupInfo` from the same row. `GKRelayRequest` gains `publicGroupId :: Maybe B64UrlByteString`, set by `mkGroupKeys`. -- `getLinkDataCreateRelayLink` fails the request when the claim differs from the link's entity id. The comparison is skipped for a request without a claim. -- `getGroupViaPublicGroupId` matches rows with a group link only. -- `updateRelayGroupKeys` writes the link's id over the claim. +- The owner member is read by `processRelayRequest` in the transaction that reads the group, and passed to `getLinkDataCreateRelayLink` and `acceptOwnerConnection`. +- The request is failed by `getLinkDataCreateRelayLink`: + - when the group id claim differs from the link's entity id; + - when the key claim differs from the key of the link data owner with the owner's member id. +- Each comparison is skipped for a request without that claim. +- Only rows with a group link are matched by `getGroupViaPublicGroupId`. +- Both claims are overwritten from the link data by `updateRelayGroupKeys`. **13. Tests** — `ChatTests/Profiles.hs`, `ChatTests/ChatRelays.hs` - Direct: the badge is shown after a request to an address with and without ratchet keys, after accepting, after joining a one-time link, after a retried join, and after a profile update. - A proof bound to another chat is ignored, and the stored badge is kept. -- P2p group: the joiner's badge is shown at the host at the request and after `INFO`; an introduced member's badge is shown at introduction; the badge is shown on both sides of an invitation via contact. -- Invitation via contact: the invitee's badge, stored on the membership at the host, is shown at a member introduced after the invitee joins. -- Channel: the owner's badge is shown at a subscriber, forwarded by the relay (`testChannelMemberBadges`). -- Link data: the badge is shown from an invitation link, an address, an address that gets its first short link, and a shared address card. -- A shared address card with a proof bound to another link, or with `PHTest`, is received without the badge, in a new message and in an update. +- P2p group: the joiner's badge is shown at the host at the request and after `INFO`; an introduced member's badge is shown after the handshake, and not from the introduction; the badge is shown on both sides of an invitation via contact. +- Invitation via contact: the invitee's badge is stored on the membership at the host, and a later member receives the introduction without it. +- Channel: the owner's badge is shown at a subscriber and the subscriber's badge at the owner, forwarded by the relay (`testChannelMemberBadges`). +- Link data: the badge is shown from an invitation link under `PHLink`, an address, and an address that gets its first short link. `PHTest` is still sent by released clients and, through `sndPresHeader`, on a retry of a connection prepared before this change. diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index b54f6ab354..3d581b2162 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."965ac7b1e339d8c172e70685a845c62cb6b0ec87" = "1c37fxbyk9nlwbqhnjzaavrvzvx7l6zprrx6cg9f72k0cyv210vh"; + "https://github.com/simplex-chat/simplexmq.git"."55b613a65822672b4bd1349304b30feff9fd3d02" = "0sjj0sq4y4ad122v0dwcrp7sr4lyhp92mf95m0ligwiii8cz1p4c"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 56e32d393c..1997392937 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1249,8 +1249,8 @@ processChatCommand cxt nm = \case ownerSig <- withAgent (`getConnLinkPrivKey` aConnId conn) $>>= \privKey -> mkLinkOwnerSig privKey connLink Nothing <$$> shareChatBinding user toSendRef - profile <- presentUserBadge user Nothing (Just $ linkPresHeader connLink) $ userProfileDirect user Nothing Nothing True let business = businessAddress addressSettings + profile = userProfileDirect user Nothing Nothing True text = safeDecodeUtf8 $ strEncode connLink pure $ CRChatMsgContent user MCChat {text, chatLink = MCLContact {connLink, profile, business}, ownerSig} APIUserRead userId -> withUserId userId $ \user -> withFastStore' (`setUserChatsRead` user) >> ok user @@ -2197,7 +2197,7 @@ processChatCommand cxt nm = \case createItem sharedMsgId content = createChatItem user cd True content sharedMsgId Nothing Nothing cInfo = GroupChat gInfo Nothing void $ createGroupFeatureItems_ user cd True CIRcvGroupFeature gInfo - aci <- mapM (createItem welcomeSharedMsgId . CIRcvMsgContent <=< chatLinkBadge) message + aci <- mapM (createItem welcomeSharedMsgId . CIRcvMsgContent) message let chat = case aci of Just (AChatItem SCTGroup dir _ ci) -> Chat cInfo [CChatItem dir ci] emptyChatStats {unreadCount = 1, minUnreadItemId = chatItemId' ci} _ -> Chat cInfo [] emptyChatStats @@ -2210,7 +2210,7 @@ processChatCommand cxt nm = \case cInfo = DirectChat ct void $ createItem Nothing $ CIRcvDirectE2EEInfo $ e2eInfoEncrypted $ connRequestPQEncryption cReq void $ createFeatureEnabledItems_ user ct - aci <- mapM (createItem welcomeSharedMsgId . CIRcvMsgContent <=< chatLinkBadge) message + aci <- mapM (createItem welcomeSharedMsgId . CIRcvMsgContent) message let chat = case aci of Just (AChatItem SCTDirect dir _ ci) -> Chat cInfo [CChatItem dir ci] emptyChatStats {unreadCount = 1, minUnreadItemId = chatItemId' ci} _ -> Chat cInfo [] emptyChatStats @@ -4324,7 +4324,7 @@ processChatCommand cxt nm = \case groupRelay <- createGroupRelayRecord db gInfo relayMember relay conn <- createRelayConnection db cxt user (groupMemberId' relayMember) connId ConnPrepared chatV subMode pure (relayMember, conn, groupRelay) - let GroupMember {memberRole = userRole, memberId = userMemberId} = membership + let GroupMember {memberRole = userRole, memberId = userMemberId, memberPubKey = userMemberKey} = membership GroupMember {memberId = relayMemberId} = relayMember membershipProfile <- presentUserBadge user (incognitoMembershipProfile gInfo) (groupPresHeader gInfo) $ redactedMemberProfile gInfo membership $ fromLocalProfile $ memberProfile membership let relayInv = GroupRelayInvitation { @@ -4332,7 +4332,8 @@ processChatCommand cxt nm = \case fromMemberProfile = membershipProfile, relayMemberId, groupLink = groupSLink, - publicGroupId = (\PublicGroupProfile {publicGroupId = gId} -> gId) <$> publicGroup' gInfo + publicGroupId = (\PublicGroupProfile {publicGroupId = gId} -> gId) <$> publicGroup' gInfo, + fromMemberKey = MemberKey <$> userMemberKey } dm <- encodeConnInfo $ XGrpRelayInv relayInv sqSecured <- withAgent $ \a -> joinConnection a nm (aUserId user) (aConnId conn) True cReq dm PQSupportOff subMode diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 573965561f..d53619d4ac 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -1338,7 +1338,7 @@ memberInfo g m@GroupMember {memberId, memberRole, memberProfile, memberPubKey, a { memberId, memberRole, v = ChatVersionRange . peerChatVRange <$> activeConn, - profile = (p :: Profile) {badge = mfilter (acceptedProof $ memberPresHeader g memberId memberPubKey) (unNoJSON memberBadgeProof <|> badge)}, + profile = (p :: Profile) {badge = mfilter (acceptedProof $ publicGroup' g *> memberPresHeader g memberId memberPubKey) (unNoJSON memberBadgeProof <|> badge)}, memberKey = MemberKey <$> memberPubKey } where @@ -2255,7 +2255,7 @@ presentsUserBadge User {profile = LocalProfile {localBadge}} = case localBadge o _ -> False groupPresHeader :: GroupInfo -> Maybe ProofPresHeader -groupPresHeader gInfo = PHChat <$> sndGroupChatBinding gInfo False +groupPresHeader gInfo@GroupInfo {membership = GroupMember {memberId, memberPubKey}} = memberPresHeader gInfo memberId memberPubKey directPresHeader :: ContactRequestBinding -> ProofPresHeader directPresHeader = \case @@ -2268,8 +2268,8 @@ linkPresHeader = \case CSLContact _ _ _ (LinkKey key) -> PHLink key relayInvPresHeader :: GroupRelayInvitation -> Maybe ProofPresHeader -relayInvPresHeader GroupRelayInvitation {fromMember = MemberIdRole {memberId}, publicGroupId} = - (\gId -> PHChat $ encodeChatBinding CBGroup $ smpEncode (gId, memberId)) <$> publicGroupId +relayInvPresHeader GroupRelayInvitation {fromMember = MemberIdRole {memberId}, publicGroupId, fromMemberKey} = + (\gId (MemberKey k) -> PHChat $ encodeChatBinding CBGroup $ smpEncode (gId, memberId, k)) <$> publicGroupId <*> fromMemberKey sndPresHeader :: Maybe ProofPresHeader -> CM ProofPresHeader sndPresHeader = maybe (PHTest <$> drgRandomBytes 16) pure @@ -2291,14 +2291,6 @@ linkDataBadge presHeader cld@ContactShortLinkData {profile = Profile {badge}} = now <- liftIO getCurrentTime pure (cld :: ContactShortLinkData) {localBadge = Just $ ShownBadge info (mkBadgeStatus now verified info)} -chatLinkBadge :: MsgContent -> CM MsgContent -chatLinkBadge = \case - MCChat {text, chatLink = chatLink@MCLContact {connLink, profile = p@Profile {badge = Just b@BadgeProof {presHeader = BBSPresHeader ph}}}, ownerSig} -> do - keys <- asks $ badgePublicKeys . config - verified <- if ph == strEncode (linkPresHeader connLink) then liftIO (verifyBadge keys b) else pure Nothing - pure MCChat {text, chatLink = if verified == Just True then chatLink else (chatLink :: MsgChatLink) {profile = (p :: Profile) {badge = Nothing}}, ownerSig} - c -> pure c - sendDirectContactMessage :: MsgEncodingI e => User -> Contact -> ChatMsgEvent e -> CM (SndMessage, Int64) sendDirectContactMessage user ct chatMsgEvent = do conn@Connection {connId} <- liftEither $ contactSendConn_ ct @@ -2379,20 +2371,19 @@ rcvGroupChatBinding gInfo m_ asGroup badge_ = case (publicGroup' gInfo, asGroup, m_) of (Just PublicGroupProfile {publicGroupId}, True, _) -> Just $ encodeChatBinding CBChannel $ smpEncode publicGroupId - (_, False, Just GroupMember {memberId, memberPubKey}) -> - memberChatBinding gInfo memberId (memberPubKey <|> proofMemberKey memberId badge_) + (Just PublicGroupProfile {publicGroupId}, False, Just GroupMember {memberId}) -> + Just $ encodeChatBinding CBGroup $ smpEncode (publicGroupId, memberId) + (Nothing, False, Just GroupMember {memberId, memberPubKey}) -> + (\k -> encodeChatBinding CBGroup $ smpEncode (memberId, k)) <$> (memberPubKey <|> proofMemberKey memberId badge_) _ -> Nothing -memberChatBinding :: GroupInfo -> MemberId -> Maybe C.PublicKeyEd25519 -> Maybe ByteString -memberChatBinding gInfo memberId key_ = case publicGroup' gInfo of - Just PublicGroupProfile {publicGroupId} -> Just $ encodeChatBinding CBGroup $ smpEncode (publicGroupId, memberId) - Nothing -> (\k -> encodeChatBinding CBGroup $ smpEncode (memberId, k)) <$> key_ - memberPresHeader :: GroupInfo -> MemberId -> Maybe C.PublicKeyEd25519 -> Maybe ProofPresHeader -memberPresHeader gInfo memberId = fmap PHChat . memberChatBinding gInfo memberId +memberPresHeader gInfo memberId = fmap $ \k -> PHChat $ encodeChatBinding CBGroup $ case publicGroup' gInfo of + Just PublicGroupProfile {publicGroupId} -> smpEncode (publicGroupId, memberId, k) + Nothing -> smpEncode (memberId, k) memberInfoPresHeader :: GroupInfo -> MemberInfo -> Maybe ProofPresHeader -memberInfoPresHeader gInfo MemberInfo {memberId, memberKey} = memberPresHeader gInfo memberId ((\(MemberKey k) -> k) <$> memberKey) +memberInfoPresHeader gInfo MemberInfo {memberId, memberKey} = publicGroup' gInfo *> memberPresHeader gInfo memberId ((\(MemberKey k) -> k) <$> memberKey) proofMemberKey :: MemberId -> Maybe BadgeProof -> Maybe C.PublicKeyEd25519 proofMemberKey memberId badge_ = do diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 14ea589976..9346c3ff1c 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -579,7 +579,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag case event of XMsgNew mc -> newContentMessage ct'' mc msg msgMeta XMsgFileDescr sharedMsgId fileDescr fileExpires fileBadge -> messageFileDescription ct'' sharedMsgId fileDescr fileExpires fileBadge - XMsgUpdate sharedMsgId mContent _ ttl live _msgScope _ -> chatLinkBadge mContent >>= \mc -> messageUpdate ct'' sharedMsgId mc msg msgMeta ttl live + XMsgUpdate sharedMsgId mContent _ ttl live _msgScope _ -> messageUpdate ct'' sharedMsgId mContent msg msgMeta ttl live XMsgDel sharedMsgId _ _ _ -> messageDelete ct'' sharedMsgId msg msgMeta XMsgReact sharedMsgId _ _ reaction add -> directMsgReaction ct'' sharedMsgId reaction add msg msgMeta -- TODO discontinue XFile @@ -1081,7 +1081,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag XMsgUpdate sharedMsgId mContent mentions ttl live msgScope asGroup_ -> checkSendAsGroup asGroup_ $ memberCanSend (Just m'') msgScope $ - chatLinkBadge mContent >>= \mc -> groupMessageUpdate gInfo' (Just m'') sharedMsgId mc mentions msgScope msg brokerTs ttl live asGroup_ + groupMessageUpdate gInfo' (Just m'') sharedMsgId mContent mentions msgScope msg brokerTs ttl live asGroup_ XMsgDel sharedMsgId memberId_ scope_ onlyHistory -> groupMessageDelete gInfo' (Just m'') sharedMsgId memberId_ scope_ onlyHistory msg brokerTs XMsgReact sharedMsgId memberId scope_ reaction add -> groupMsgReaction gInfo' m'' sharedMsgId memberId scope_ reaction add msg brokerTs @@ -1405,7 +1405,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag (signedMsg_, ChatMessage {chatVRange, chatMsgEvent}) <- parseChatMessage' conn connInfo let reqHeader = directPresHeader binding case chatMsgEvent of - XContact p memberKey_ xContactId_ welcomeMsgId_ requestMsg_ -> mapM (traverse chatLinkBadge) requestMsg_ >>= \requestMsg' -> profileContactRequest invId chatVRange reqHeader p memberKey_ xContactId_ welcomeMsgId_ requestMsg' pqSupport rejectionSupported + XContact p memberKey_ xContactId_ welcomeMsgId_ requestMsg_ -> profileContactRequest invId chatVRange reqHeader p memberKey_ xContactId_ welcomeMsgId_ requestMsg_ pqSupport rejectionSupported XMember p joiningMemberId joiningMemberKey viaRelay -> memberJoinRequestViaRelay invId chatVRange signedMsg_ p joiningMemberId joiningMemberKey viaRelay XInfo p _ -> profileContactRequest invId chatVRange reqHeader p Nothing Nothing Nothing Nothing pqSupport rejectionSupported XGrpRelayInv groupRelayInv -> xGrpRelayInv invId chatVRange groupRelayInv @@ -1701,7 +1701,8 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag && viaRelay == Just (memberId' (membership gInfo)) _ -> False acceptJoin g@(GIK gInfo _) existingMem_ acceptRole = do - mem <- acceptGroupJoinRequestAsync user uclId g invId chatVRange p (memberPresHeader gInfo joiningMemberId Nothing) Nothing (Just joiningMemberId) Nothing GAAccepted acceptRole Nothing (Just joiningMemberKey) existingMem_ + let presHeader_ = memberPresHeader gInfo joiningMemberId $ mfilter (\k -> memberSigned gInfo joiningMemberId k signedMsg_) (Just joiningKey) + mem <- acceptGroupJoinRequestAsync user uclId g invId chatVRange p presHeader_ Nothing (Just joiningMemberId) Nothing GAAccepted acceptRole Nothing (Just joiningMemberKey) existingMem_ (gInfo', mem', scopeInfo) <- mkGroupChatScope gInfo mem createInternalChatItem user (CDGroupRcv gInfo' scopeInfo mem') (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing toView $ CEvtAcceptingGroupJoinRequestMember user gInfo' mem' @@ -1915,7 +1916,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag newContentMessage :: Contact -> MsgContainer -> RcvMessage -> MsgMeta -> CM () newContentMessage ct mc msg@RcvMessage {sharedMsgId_} msgMeta = do let MsgContainer {content = c, file = fInv_} = mc - content <- chatLinkBadge =<< case c of + content <- case c of MCChat {text, chatLink, ownerSig = Just LinkOwnerSig {chatBinding = B64UrlByteString binding}} -> do keepSig <- case contactConn ct of Nothing -> pure False @@ -2265,12 +2266,10 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag -- m' is Maybe GroupMember createNonLive gInfo' m' scopeInfo file_ = do let mentions' = if maybe False memberBlocked m' then M.empty else mentions - content' <- chatLinkBadge content - saveRcvCI gInfo' m' scopeInfo (CIRcvMsgContent content', ts) (snd <$> file_) (timed_ gInfo') False mentions' + saveRcvCI gInfo' m' scopeInfo (CIRcvMsgContent content, ts) (snd <$> file_) (timed_ gInfo') False mentions' createContentItem gInfo' m' scopeInfo = do file_ <- processFileInv gInfo' m' - content' <- chatLinkBadge content - newChatItem gInfo' m' scopeInfo (CIRcvMsgContent content', ts) (snd <$> file_) (timed_ gInfo') live' + newChatItem gInfo' m' scopeInfo (CIRcvMsgContent content, ts) (snd <$> file_) (timed_ gInfo') live' unless (maybe False memberBlocked m') $ autoAcceptFile file_ processFileInv gInfo' m' = let fileMember_ = if sentAsGroup then Nothing else m' @@ -3954,7 +3953,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag (g', m'', scopeInfo) <- mkGroupChatScope g m' createInternalChatItem user (CDGroupRcv g' scopeInfo m'') (CIRcvGroupEvent RGEMemberCreatedContact) Nothing toView $ CEvtNewMemberContactReceivedInv user mCt' g' m'' - forM_ mContent_ $ chatLinkBadge >=> \mc -> do + forM_ mContent_ $ \mc -> do (ci, cInfo) <- saveRcvChatItem user (CDDirectRcv mCt') msg brokerTs (CIRcvMsgContent mc, msgContentTexts mc) toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci] @@ -4002,7 +4001,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag -- file description is always allowed, to allow sending files to support scope XMsgFileDescr sharedMsgId fileDescr fileExpires fileBadge -> void $ groupMessageFileDescription gInfo author_ sharedMsgId fileDescr fileExpires fileBadge XMsgUpdate sharedMsgId mContent mentions ttl live msgScope asGroup_ -> - void $ memberCanSend author_ msgScope $ chatLinkBadge mContent >>= \mc -> groupMessageUpdate gInfo author_ sharedMsgId mc mentions msgScope rcvMsg msgTs ttl live asGroup_ + void $ memberCanSend author_ msgScope $ groupMessageUpdate gInfo author_ sharedMsgId mContent mentions msgScope rcvMsg msgTs ttl live asGroup_ XMsgDel sharedMsgId memId scope_ _ -> void $ groupMessageDelete gInfo author_ sharedMsgId memId scope_ False rcvMsg msgTs XMsgReact sharedMsgId memId scope_ reaction add -> withAuthor XMsgReact_ $ \author -> void $ groupMsgReaction gInfo author sharedMsgId memId scope_ reaction add rcvMsg msgTs XFileCancel sharedMsgId -> void $ xFileCancelGroup gInfo author_ sharedMsgId @@ -4523,22 +4522,23 @@ runRelayRequestWorker a Worker {doWork} = do eToView e processRelayRequest :: GroupId -> RelayRequestData -> CM () processRelayRequest groupId rrd = do - (g@(GIK gInfo _), groupLink_) <- withStore $ \db -> do + (g@(GIK gInfo _), groupLink_, ownerMember) <- withStore $ \db -> do g@(GIK gInfo _) <- getGroupInfoKeys db cxt user groupId groupLink_ <- liftIO $ runExceptT $ getGroupLink db user gInfo - pure (g, groupLink_) + ownerMember <- getHostMember db cxt user groupId + pure (g, groupLink_, ownerMember) -- Check if relay link already exists (recovery case) case groupLink_ of Right GroupLink {connLinkContact = CCLink _ sLnk_} -> case sLnk_ of - Just sLnk -> acceptOwnerConnection rrd gInfo sLnk + Just sLnk -> acceptOwnerConnection rrd gInfo ownerMember sLnk Nothing -> throwChatError $ CEException "processRelayRequest: relay link doesn't have short link" Left _ -> do - (gInfo', sLnk) <- getLinkDataCreateRelayLink rrd g - acceptOwnerConnection rrd gInfo' sLnk + (gInfo', sLnk) <- getLinkDataCreateRelayLink rrd g ownerMember + acceptOwnerConnection rrd gInfo' ownerMember sLnk where - getLinkDataCreateRelayLink :: RelayRequestData -> GroupInfoKeys -> CM (GroupInfo, ShortLinkContact) - getLinkDataCreateRelayLink RelayRequestData {reqGroupLink} (GIK gInfo gks) = do + getLinkDataCreateRelayLink :: RelayRequestData -> GroupInfoKeys -> GroupMember -> CM (GroupInfo, ShortLinkContact) + getLinkDataCreateRelayLink RelayRequestData {reqGroupLink} (GIK gInfo gks) GroupMember {memberId = MemberId ownerMemberId, memberPubKey = claimedOwnerKey_} = do (memberPrivKey', claimedGroupId_) <- case gks of GKRelayRequest {memberPrivKey, publicGroupId} -> pure (memberPrivKey, publicGroupId) _ -> throwChatError $ CEException "getLinkDataCreateRelayLink: group is not a relay request" @@ -4550,6 +4550,8 @@ runRelayRequestWorker a Worker {doWork} = do (Just entityId, Just pg@PublicGroupProfile {publicGroupId}) | B64UrlByteString entityId == publicGroupId && all (== publicGroupId) claimedGroupId_ -> pure pg _ -> throwChatError $ CEException "getLinkDataCreateRelayLink: linkEntityId does not match publicGroupId of profile or invitation" + unless (all (\k -> any (\OwnerAuth {ownerId, ownerKey} -> ownerId == ownerMemberId && ownerKey == k) owners) claimedOwnerKey_) $ + throwChatError $ CEException "getLinkDataCreateRelayLink: owner key of invitation does not match link data" validateGroupProfile gp sLnk <- createRelayLink gInfo (C.publicKey memberPrivKey', memberPrivKey') gInfo' <- withStore $ \db -> do @@ -4584,7 +4586,6 @@ runRelayRequestWorker a Worker {doWork} = do subRole <- asks $ channelSubscriberRole . config void $ withFastStore $ \db -> createGroupLink db gVar user gi connId ccLink' groupLinkId subRole subMode pure sLnk - acceptOwnerConnection :: RelayRequestData -> GroupInfo -> ShortLinkContact -> CM () - acceptOwnerConnection RelayRequestData {relayInvId, reqChatVRange} gi relayLink = do - ownerMember <- withStore $ \db -> getHostMember db cxt user groupId + acceptOwnerConnection :: RelayRequestData -> GroupInfo -> GroupMember -> ShortLinkContact -> CM () + acceptOwnerConnection RelayRequestData {relayInvId, reqChatVRange} gi ownerMember relayLink = void $ acceptRelayJoinRequestAsync user uclId gi ownerMember relayInvId reqChatVRange relayLink diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 3088361752..f9ab998568 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -1898,7 +1898,7 @@ setGroupInProgressDone db GroupInfo {groupId} = do (currentTs, groupId) createRelayRequestGroup :: DB.Connection -> StoreCxt -> User -> GroupRelayInvitation -> Maybe ProofPresHeader -> InvitationId -> VersionRangeChat -> Int64 -> GroupMemberStatus -> RelayStatus -> ExceptT StoreError IO (GroupInfo, GroupMember) -createRelayRequestGroup db cxt user@User {userId} GroupRelayInvitation {fromMember, fromMemberProfile, relayMemberId, groupLink, publicGroupId} presHeader_ invId reqChatVRange initialDelay memberStatus relayStatus = do +createRelayRequestGroup db cxt user@User {userId} GroupRelayInvitation {fromMember, fromMemberProfile, relayMemberId, groupLink, publicGroupId, fromMemberKey} presHeader_ invId reqChatVRange initialDelay memberStatus relayStatus = do currentTs <- liftIO getCurrentTime -- Create group with placeholder profile let Profile {displayName = fromMemberLDN} = fromMemberProfile @@ -1954,12 +1954,12 @@ createRelayRequestGroup db cxt user@User {userId} GroupRelayInvitation {fromMemb INSERT INTO group_members ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, - peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + peer_chat_min_version, peer_chat_max_version, member_pub_key) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ( (groupId, indexInGroup, memberId, memberRole, GCHostMember, memberStatus, Binary B.empty) :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) - :. (minV, maxV) + :. (minV, maxV, (\(MemberKey k) -> k) <$> fromMemberKey) ) ownerMemberId <- insertedRowId db forM_ badge $ createMemberBadgeProof db ownerMemberId 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 c1387f1a67..33e7ed34c4 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -592,8 +592,8 @@ Query: INSERT INTO group_members ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, - peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + peer_chat_min_version, peer_chat_max_version, member_pub_key) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH file_badge_proofs USING COVERING INDEX idx_file_badge_proofs_group_member_id (group_member_id=?) diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 408f382fc9..6fb552ed4e 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -1007,7 +1007,8 @@ data GroupRelayInvitation = GroupRelayInvitation fromMemberProfile :: Profile, relayMemberId :: MemberId, groupLink :: ShortLinkContact, - publicGroupId :: Maybe B64UrlByteString + publicGroupId :: Maybe B64UrlByteString, + fromMemberKey :: Maybe MemberKey } deriving (Eq, Show) diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 81b23abfbd..2c90a42d8f 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -75,12 +75,11 @@ chatProfileTests = do it "supporter badge sent in a retried join of a one-time link" testUserBadgeInvitationConnectRetry it "supporter badge bound to another chat is ignored, stored badge is kept" testUserBadgeOtherBinding it "supporter badge of member joining via group link, at request and after handshake" testUserBadgeGroupLinkJoiner - it "supporter badge of introduced member" testUserBadgeIntroduced - it "supporter badge of member invited via contact, forwarded to introduced member" testUserBadgeInvitedIntroduced + it "supporter badge of introduced member is not taken from the introduction" testUserBadgeIntroduced + it "supporter badge of member invited via contact is not forwarded in the introduction" testUserBadgeInvitedIntroduced it "supporter badge of inviting host in the reply to the invited contact" testUserBadgeInvitingHost it "supporter badge in one-time link data" testUserBadgeInvitationLinkData it "supporter badge in data of address getting its first short link" testUserBadgeAddressFirstShortLink - it "supporter badge in shared address card" testUserBadgeAddressCard describe "user contact link" $ do it "create and connect via contact link" testUserContactLink it "rotate address ratchet keys" testRotateAddressRatchetKeys @@ -798,7 +797,7 @@ testUserBadgeIntroduced ps = do ] alice #> "#team hello" cath <# "#team alice> hello" - memberBadgeHeader cath "team" "bob" `shouldReturn` Just ("CG", BSActive) + memberBadgeHeader cath "team" "bob" `shouldReturn` Nothing testUserBadgeInvitedIntroduced :: HasCallStack => TestParams -> IO () testUserBadgeInvitedIntroduced ps = do @@ -826,7 +825,7 @@ testUserBadgeInvitedIntroduced ps = do cath <## "chat started" cath <## "subscribed 2 connections on server localhost" cath <## "#team: alice added bob (Bob) to the group (connecting...)" - memberBadgeHeader cath "team" "bob" `shouldReturn` Just ("CG", BSActive) + memberBadgeHeader cath "team" "bob" `shouldReturn` Nothing testUserBadgeInvitingHost :: HasCallStack => TestParams -> IO () testUserBadgeInvitingHost ps = do @@ -879,46 +878,6 @@ testUserBadgeAddressFirstShortLink ps = do sLinkData `shouldContain` "\"localBadge\":{\"badge\":{\"badgeType\":\"supporter\"" sLinkData `shouldContain` "\"status\":\"active\"" -testUserBadgeAddressCard :: HasCallStack => TestParams -> IO () -testUserBadgeAddressCard ps = do - Right (pk, sk) <- bbsKeyGen - testChatCfg3 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile cathProfile (test pk sk) ps - where - test pk sk alice bob cath = do - alice ##> "/ad" - _ <- getContactLinks alice True - connectUsers alice bob - connectUsers bob cath - addTestBadge alice =<< issueTestBadge sk futureDate - alice ##> "/share address @bob" - alice <# "@bob contact address of @alice (signed):" - _ <- getTermLine alice - _ <- getTermLine alice - bob <# "alice *> contact address of @alice (signed):" - bLink <- getTermLine bob - _ <- getTermLine bob - lastItemContent bob >>= (`shouldContain` "\"badgeType\":\"supporter\"") - cred <- issueTestBadge sk futureDate - Right otherProof <- badgeProof pk cred (PHLink "other link") - Right testProof <- badgeProof pk cred (PHTest "nonce") - let cLink = either error id $ strDecode (B.pack bLink) - card proof = MCChat (T.pack bLink) (MCLContact cLink (profileFromName "alice") {badge = Just proof} False) Nothing - bob ##> ("/_send @3 json [{\"msgContent\":" <> T.unpack (encodeJSON $ card otherProof) <> "}]") - bob <# "@cath contact address of @alice:" - _ <- getTermLine bob - cath <# "bob> contact address of @alice:" - _ <- getTermLine cath - lastItemContent cath >>= (`shouldNotContain` "\"badge\"") - bob #> "@cath hi" - cath <# "bob> hi" - msgId <- lastItemId bob - bob ##> ("/_update item @3 " <> msgId <> " json {\"msgContent\":" <> T.unpack (encodeJSON $ card testProof) <> ",\"mentions\":{}}") - bob <# "@cath [edited] contact address of @alice:" - _ <- getTermLine bob - cath <# "bob> [edited] contact address of @alice:" - _ <- getTermLine cath - lastItemContent cath >>= (`shouldNotContain` "\"badge\"") - testUpdateProfileImage :: HasCallStack => TestParams -> IO () testUpdateProfileImage = testChat2 aliceProfile bobProfile $ diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 3995a5c51e..35a3921915 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -748,13 +748,6 @@ getTestMember cc gName mName = ) >>= either (fail . show) pure -lastItemContent :: TestCC -> IO String -lastItemContent cc = - withCCTransaction cc $ \db -> - DB.query_ db "SELECT item_content FROM chat_items ORDER BY chat_item_id DESC LIMIT 1" >>= \case - [Only c] -> pure $ T.unpack c - _ -> fail "no chat items" - storedBadgeHeader :: LocalProfile -> Maybe (String, BadgeStatus) storedBadgeHeader LocalProfile {localBadge} = case localBadge of Just (PeerBadge b st) -> Just (proofHeaderTag b, st)