This commit is contained in:
spaced4ndy
2026-05-08 14:22:42 +04:00
parent 1d36ebee84
commit 1b1d87f3ec
+370 -27
View File
@@ -84,15 +84,20 @@ Mostly inert. The `GroupType` ADT (`Types.hs:767-771`) already has `GTGroup` —
no change. The `PublicGroupProfile`/`GroupProfile` definitions
(`Types.hs:787-804`) are unchanged.
Two small additions:
Two helper additions, both included in this MVP and used everywhere the
audit touches a `groupType` decision (no inline `groupProfile.publicGroup
.groupType` walks anywhere in the new code):
- `Types.hs` — derive `groupType' :: GroupInfo -> Maybe GroupType` helper
alongside `useRelays' :: GroupInfo -> Bool` (line 494). Lets call sites read
governance without inlining the `groupProfile.publicGroup.groupType` walk.
Optional but reduces site-by-site verbosity in `Subscriber.hs`/`Commands.hs`.
- `Types.hs` — derive `isPublicGroup' :: GroupInfo -> Bool` (true iff
`useRelays' && groupType' == Just GTGroup`). One helper, used at the
joiner-role derivation site and the DM-invite gate.
- `Types.hs` — `groupType' :: GroupInfo -> Maybe GroupType`, alongside
`useRelays' :: GroupInfo -> Bool` (line 494). Returns `Nothing` for P2P
secret groups (no `publicGroup` field).
- `Types.hs` — `isPublicGroup' :: GroupInfo -> Bool` (true iff
`useRelays' && groupType' == Just GTGroup`). Used at the joiner-role
derivation site and the DM-invite gate.
Both helpers live in `Types.hs` (not `Subscriber/Roles.hs` or any new
module) — same file as `useRelays'`, same single-line definitions,
zero new module dependencies.
`Types/Shared.hs` (the `GroupMemberRole` ADT, lines 18-51) is unchanged.
`Protocol.hs` `requiresSignature` (line 1221) is unchanged for the MVP — see
@@ -125,8 +130,9 @@ two parsers, one constructor field, one downstream substitution.
`let subRole = subscriberRoleFor gType` where `subscriberRoleFor GTChannel
= GRObserver; subscriberRoleFor GTGroup = GRMember; subscriberRoleFor
(GTUnknown _) = GRObserver` (defensive: unknown means do not let posting
happen). Place `subscriberRoleFor` next to the helpers in `Types.hs` or in
a small `Subscriber/Roles.hs` if you want a single canonical site.
happen). `subscriberRoleFor` lives in `Types.hs`, alongside `useRelays'`
and the new `groupType'` / `isPublicGroup'` helpers — single canonical
site, no new module.
- Line 2024-2040, `APIPrepareGroup` handler. Line 2029:
`subRole <- if useRelays then asks $ channelSubscriberRole . config else
pure GRMember` becomes `subRole = if useRelays then subscriberRoleFor (case
@@ -153,6 +159,67 @@ two parsers, one constructor field, one downstream substitution.
post will create Public groups (`GTGroup`) explicitly; tests that exercise
channels keep `GTChannel` defaults.
#### 3.3.1 Default group preferences for Public groups
The CLI / API create handler builds the initial `GroupPreferences` that
ship in the channel link's `groupProfile`. Today there are two parsers:
`groupProfile` at `Library/Commands.hs:5371-5379` (used by `/group`,
secret-group default) and `channelProfile` at `Library/Commands.hs:5380-
5383` (used by `/public group`, channel default — overrides
`support = OFF` on top of the secret-group base). Public groups need a
third resolution.
Implementation: extend the existing parser by parameterization rather
than introducing a third sibling. `channelProfile` becomes
`relayGroupProfile :: GroupType -> Parser GroupProfile`, and the
overrides are decided from `gType`. The `/_public group` API path
threads the same `GroupType` from the new `APINewPublicGroup` field
(§3.3) into the prefs-resolution helper.
The table below names every preference, gives the secret-group, channel,
and Public-group defaults, and (where Public-group deviates from
secret-group) the reason. "Secret group" = output of the `groupProfile`
parser; "Channel" = output of `channelProfile`; both are grounded in
`Types/Preferences.hs:479-495` (`defaultGroupPrefs`) overlaid with the
parser-level overrides.
| Preference | Secret group | Channel | **Public group** | Notes |
|--------------------|--------------|---------|------------------|------------------------------------------------------------------------------------------------|
| `timedMessages` | OFF | OFF | **OFF** | Match secret group. Owners can turn on per-channel. |
| `directMessages` | ON | ON | **ON** | Default ON. Members expect to DM each other in a "group". Help text surfaces the metadata implication (see threat-model §6.A.1). |
| `fullDelete` | OFF | OFF | **OFF** | Match secret group. |
| `reactions` | ON | ON | **ON** | Match. |
| `voice` | ON | ON | **ON** | Match. |
| `files` | ON | ON | **ON** | Match. |
| `simplexLinks` | ON | ON | **ON** | Match. |
| `reports` | ON | ON | **ON** | Match secret group. Public groups can grow to thousands of members and need member-flagging from day one — disabling reports here would force owners to add them later. |
| `history` | ON | ON | **ON** | Match. New members joining via relay get recent history (relay path in `Subscriber.hs:888`). |
| `support` | ON | OFF | **ON** | **Deviates from channel.** Channels disable support because broadcast publishers do not want a subscriber-to-owner side channel by default. Public groups are governed groups with members; member-to-moderator escalation is expected. Match secret group. |
| `sessions` | OFF | OFF | **OFF** | Match. |
| `comments` | OFF | OFF | **OFF** | Channels-specific feature; not part of the Public-group MVP. |
Concretely: Public-group defaults equal secret-group defaults
(`directMessages`/`history` ON via parser overlay, `support` ON from
`defaultGroupPrefs`). The only Public-group-specific override needed at
the parser level is **none** — the existing `groupProfile` parser at
`Library/Commands.hs:5371-5379` already produces the correct shape.
The `relayGroupProfile` parameterization above collapses to:
```haskell
relayGroupProfile :: GroupType -> Parser GroupProfile
relayGroupProfile = \case
GTChannel -> channelProfile -- existing: support = OFF
GTGroup -> groupProfile -- existing: support = ON
GTUnknown _ -> groupProfile -- defensive
```
This is a one-binding change to the parser; the `APINewPublicGroup`
JSON path is unchanged because the caller (mobile client, §4.3 / §5.3)
constructs `groupPreferences` explicitly. Mobile callers must construct
the right preferences for their chosen `groupType`; the iOS / Kotlin
create-flow sub-sections (§4.3, §5.3) gain a `groupType`-aware default-
prefs builder that mirrors this table.
### 3.4 Message processing changes
**`src/Simplex/Chat/Library/Subscriber.hs`**
@@ -224,13 +291,45 @@ two parsers, one constructor field, one downstream substitution.
`Subscriber.hs:990-1027`, `XGrpDirectInv` arm).
Edge case: `xGrpDirectInv` (line 3249+) currently writes a `Connection`
record and creates a contact. When the message is forwarded and
arrives via `processForwardedMsg`, the `conn'` argument the existing
handler expects is the relay's connection — wrong. Either pass a
marker/None and adapt `xGrpDirectInv` to handle the forwarded case,
or introduce `xGrpDirectInvForwarded` that diverges only where it
needs to. Prefer the latter — keeps the existing direct path
unchanged.
record and creates a contact, using the `conn'` argument as the
member-side connection on which the invitation arrived. The
forwarded path has no such direct member connection — the message
arrived through the relay. The two paths share ~95% of their body
(preference gate, blocked-member check, contact creation, item
rendering), differing only in (i) the source of the `Connection`
record persisted with the new contact and (ii) which member-record
lookup applies.
Approach: **parameterize the existing handler.** Per `good-code-v4`
§`<good-diff>` ("extend existing functions by parameterization
rather than parallel implementations") — duplicating the body
doubles the surface for blocked-member, preference, and contact-
creation bugs. Introduce a discriminator parameter and route the
two cases at the one site that actually differs:
```haskell
data DirectInvSource
= DISDirect Connection -- existing path: A→B over group conn
| DISForwarded ForwardedMeta -- new path: A→relay→B
-- ForwardedMeta carries what
-- the forwarded path knows
-- instead of the direct conn
-- (sender memberId, broker ts,
-- relay member record, etc.)
xGrpDirectInv
:: GroupInfo -> GroupMember -> DirectInvSource -> ConnReqInvitation
-> Maybe MsgContent -> RcvMessage -> UTCTime -> CM ()
```
Existing callers at `Subscriber.hs:1026` pass `DISDirect conn'`;
the new arm in `processForwardedMsg` passes `DISForwarded
forwardedMeta`. The body branches once at the contact-creation
site (where `conn'` is consumed), and the rest of the function is
unchanged. If during implementation the `DirectInvSource` split
produces more conditionals than a duplicated body would (more than
~3 case-arms outside the contact-creation site), document the
discovery and split — but the default is parameterization.
- Line 1240-1249, `unverifiedAllowed`. Current behaviour: subscribers may
pass unsigned `XGrpLeave` and `XInfo` between each other when the
@@ -240,7 +339,27 @@ two parsers, one constructor field, one downstream substitution.
subscriber-to-subscriber message has a known sender key, and the
unverified path can be tightened. **For the Public groups MVP, leave
`unverifiedAllowed` as-is** — tightening it is gated on the
dissemination plan landing first. Track it as a follow-up.
dissemination plan landing first.
Concrete handoff: as part of this MVP, update the existing TODO
comment at `Protocol.hs:1233-1235` to reference both this plan and
the dissemination plan, naming the precondition for tightening:
```haskell
-- TODO [public-groups]: tighten unverifiedAllowed for GTGroup once
-- 2026-04-29-member-profile-sending-channels.md lands — keys for
-- all members will be known via XGrpMemNew sidecar (step 6),
-- making MSSSignedNoKey unnecessary for XGrpLeave/XInfo between
-- subscribers. See plans/2026-05-08-public-groups-via-relays.md
-- §3.4 and the dissemination plan §6.
```
No separate plan file is created — the precondition and the action
are both small, and the inline TODO with cross-references is enough
for a future contributor to pick up. If the team later wants a full
plan instead, name it `plans/{date-after-dissemination-lands}-
tighten-unverified-allowed.md` and update the comment to point to
it.
- Line 985-989 / 2087-2089, `checkSendAsGroup`. Already restricts
`asGroup = True` to `GROwner`. Public group members are `GRMember`, so
@@ -276,7 +395,7 @@ two parsers, one constructor field, one downstream substitution.
(3) it applies to channels equally — it is a generic "admission in
relay-mediated groups" gap, not a Public-groups gap.
Document this as a known limitation in the release notes and surface
in §7 Open questions. The first review-sensitive Public group
in §8 Open questions. The first review-sensitive Public group
deployment will force the design conversation.
### 3.5 Database migrations
@@ -339,6 +458,19 @@ a new sibling `describe "public groups"`):
11. **Older-client refusal.** Channel link with `groupType = "group"`;
older client (chat version 17) sees `GTUnknown "group"` and
refuses to join with a clear message.
12. **Incognito member posting.** Create a Public group; have a member
join with `incognito = on`; member posts a content message;
verify other members receive it attributed to the incognito
profile name (not the member's real profile). Mirror the
incognito-join helper used in `memberJoinChannelIncognito`
(`tests/ChatTests/Groups.hs:8690`).
13. **Incognito member-to-member DM.** With member-DMs enabled on the
Public group, member A (joined incognito) creates a direct contact
with member B via `XGrpDirectInv` (the test 4 path); verify the
resulting P2P connection presents A's incognito profile to B and
that A's subsequent direct messages preserve the incognito
profile (no leak of the real user profile through the new direct
contact, even after the connection moves off the relay).
## 4. iOS changes (Swift, in `apps/ios/Shared`)
@@ -441,6 +573,28 @@ existing call, around `Model/SimpleXAPI.swift:1880-1882`) gains a
`groupType: GroupType` parameter; default `.channel` for a one-line
diff at unaffected call sites.
The Public-group create-flow screen carries two pieces of help text
that surface the threat-model trade-offs (§6.A.1, §6.A.2):
- Beneath the "Create public group" title, in the same position the
"Create channel" screen uses for its description: *"In a Public
group, every member can post and DM. Messages are delivered through
relays you choose, which means a malicious relay could change or
fabricate messages from any member. Pick relays you trust."*
- On the `directMessages` preference toggle (in the prefs section of
the create flow and in `GroupPreferencesView.swift`, see §4.6), as
the off-state hint: *"If members can DM each other, your relay can
see who started a conversation with whom — but not what they say.
Turn off to keep DM-graph metadata private."*
Both strings are listed in §4.4 as new entries.
`groupPreferences` defaults builder: extend the existing builder used
by `AddChannelView.swift` to take a `groupType` and produce the
preferences from the table in §3.3.1 (`directMessages = ON`,
`history = ON`, `support = ON` for `groupType = .group`; existing
`support = OFF` override stays for `.channel`).
### 4.4 Strings
`apps/ios/Shared/en.lproj/Localizable.strings` — for each existing
@@ -456,6 +610,10 @@ strings, not 50. Strings to add (illustrative, names only):
- `public_group_no_active_relays_try_later`
(parallel to `channel_no_active_relays_try_later`)
- `public_group_temporarily_unavailable`
- `create_public_group_threat_model_note` — the create-flow paragraph
from §4.3 (relay-can-fabricate framing).
- `direct_messages_metadata_note` — the off-state hint on the
`directMessages` toggle from §4.3 (DM-graph metadata framing).
The connect-plan-resolved message ("ok to connect via relays") needs
a Public-group form. See §4.6.
@@ -582,10 +740,26 @@ Mirrors §4. Same order: model → audit → create flow → views.
- Pass `groupType` to `apiNewPublicGroup`. The Haskell command parser
defaults to channel if absent (§3.3), so the Kotlin call site
passes the chosen value directly.
- `groupPreferences` defaults: for Public groups,
`directMessages = ON`, `history = ON`, `support = OFF`. Today's
channel defaults are at line 115-117 — add a `when (groupType)`.
- `groupPreferences` defaults: drive from the table in §3.3.1.
Today's channel defaults are at line 115-117. Replace with a
`groupType`-keyed builder:
- `GroupType.Channel` → `directMessages = ON, history = ON,
support = OFF` (today's behaviour, unchanged).
- `GroupType.Group` → `directMessages = ON, history = ON,
support = ON`.
- Title string and progress messages: thread through the choice.
- **Help text on the create screen**, mirroring iOS (§4.3):
- Below the screen title, when `groupType = GroupType.Group`:
*"In a Public group, every member can post and DM. Messages are
delivered through relays you choose, which means a malicious
relay could change or fabricate messages from any member. Pick
relays you trust."* (See §6.A.2.)
- On the `directMessages` toggle in the prefs section and in
`views/chat/group/GroupPreferences.kt`: as the off-state hint,
*"If members can DM each other, your relay can see who started
a conversation with whom — but not what they say. Turn off to
keep DM-graph metadata private."* (See §6.A.1.)
Both strings are listed in §5.5 as new MR keys.
Either rename `AddChannelView` to `AddRelayedGroupView` or keep the
name and let it cover both kinds. Recommend keep the name to
@@ -627,6 +801,10 @@ strategy as iOS: ~5-10 new keys, mostly mirroring the channel ones with a
`public_group_no_active_relays_try_later`
- `public_group_link`,
`you_can_share_public_group_link_anybody_will_be_able_to_connect`
- `create_public_group_threat_model_note` — the create-flow
paragraph from §5.3 (relay-can-fabricate framing).
- `direct_messages_metadata_note` — the off-state hint on the
`directMessages` toggle from §5.3 (DM-graph metadata framing).
For "subscribers" → "members" framing, prefer reusing the existing
`group_members_*` strings rather than introducing new public-group
@@ -644,7 +822,172 @@ variants. Channels keep their `_subscriber*` strings.
- `!useRelays && businessChat == null` → existing
`ic_supervised_user_circle_filled`.
## 6. Migration / compatibility
## 6. Threat model: changes from channels
This section assumes the channel threat model
(`docs/protocol/channels-overview.md` §"Threat model"). Public groups
inherit every property listed there. Two threats are *new* (channels do
not have them) and one is *broader* (channels have a narrower form of
the same threat). The relay's "can / cannot" framing matches the
existing doc style; the items below are written so they can be folded
directly into a future revision of `channels-overview.md` once Public
groups ship.
### 6.A.1 A relay observes the member DM graph
When a Public-group member initiates a DM with another member, the
client emits `XGrpDirectInv` and the relay forwards it (§3.4 sub-section
on member-to-member DM forwarding). The relay sees the (sender memberId,
target memberId) pair on every initial DM invitation. The resulting
Contact establishes a peer-to-peer SMP connection — the *content* of
subsequent direct messages never crosses the relay — but the *fact that
A wanted to talk to B* does. Over time, the relay accumulates a partial
DM graph of the Public group.
This is metadata the relay does not see in channels (members do not DM
in channels) and that no operator sees in secret groups (DM
invitations travel between members directly, no relay in the path).
**A single compromised relay**
*can:*
- Build a partial DM graph of the Public group from forwarded
`XGrpDirectInv` events: every member who initiated a DM, every
target member, and the time of initiation.
- Correlate that DM-initiation graph with the content authorship the
relay already sees, deriving who-talks-to-whom signals beyond the
group's public messages.
- Drop or selectively forward DM invitations, partitioning members
who attempt to coordinate off-channel.
*cannot:*
- Read DM content. Once the recipient accepts the invitation, the
resulting Contact uses an ordinary SMP queue pair — end-to-end
encrypted at the agent layer, relay not in the data path.
- Observe DM activity after the initial invitation: subsequent
messages, edits, reactions on the direct contact pass through SMP
routers, not the relay.
- Determine the real-world identities of A or B. Each carries only
their group-member profile (or an incognito profile if the member
joined incognito — see Test 13 in §3.6). The relay sees member
IDs, not user identities.
- Forge a DM invitation as if from a different member. The forwarded
`XGrpDirectInv` is delivered with the original sender's memberId,
and the recipient's client validates the in-group membership before
accepting the contact.
**Mitigations.** Members who care about DM-graph privacy can join the
group incognito (the relay then sees only the incognito profile's
memberId, not anything correlatable across groups). Owners can
disable the `directMessages` group preference, removing the
forwarding path entirely (the relay rejects `XGrpDirectInv` at the
DM-preference gate, §3.4 sub-section step 4).
The owner-side default for `directMessages` in a new Public group is
**ON** (matches secret groups, §3.3.1). The create-flow help text
on iOS (§4.3, §4.4) and Kotlin (§5.3, §5.5) surfaces the metadata
implication in plain language: "If members can DM each other, your
relay can see who started a conversation with whom — but not what
they say. To prevent the relay from seeing this, turn off member-to-
member messages."
### 6.A.2 A relay can fabricate content as any member
Content messages (`XMsgNew`, `XMsgUpdate`, `XMsgDel`, `XMsgReact`,
`XFileCancel`) are unsigned in both channels and Public groups
(`Protocol.hs:1221`, `requiresSignature` lists only roster /
administrative events). In channels this gives a compromised relay
the ability to fabricate content attributed to owners — already
documented in `channels-overview.md` §"Threat model" ("Substitute
unsigned content or selectively drop messages for its subscribers").
In Public groups, the same property has a **broader blast radius**:
the relay can fabricate content attributed to *any* member, not just
to owners.
This matches the channel deniability property by design (see
`channels-overview.md` §"Signing scope: roster only, content
optional"): unsigned content is precisely what enables cryptographic
deniability — no third party can prove a member authored anything.
The trade-off is that the operator on the delivery path cannot be
prevented from forging in the same channel.
**A single compromised relay**
*can:*
- Fabricate content messages attributed to any member, not just to
owners. Detectable by other members through cross-relay
consistency (same TODO as the channel case: difference detection
not yet implemented).
- Modify the text or content of messages in transit and re-attribute
the modified message to its original author.
- Drop content messages selectively — same property as channels.
*cannot:*
- Forge signed administrative events: `XGrpInfo`, `XGrpPrefs`,
`XGrpMemRole`, `XGrpMemRestrict`, `XGrpMemDel`, `XGrpDel`,
`XGrpLeave`, `XInfo` (`Protocol.hs:1221`). Roster manipulation,
profile changes, and member-attributed leave / profile-update
events all require valid signatures.
- Substitute the channel profile or impersonate an owner — the
channel's entity ID and owner authorization chain are validated
by every recipient against the channel link.
- Alter authoritative state on owner devices.
**Mitigation.** No code change for the MVP. The future-work fix is
opt-in content signing per the channel roadmap
(`channels-overview.md` §"Future work" / "Transcript integrity" /
"Opt-in content signing"). When that ships, owners of Public groups
will be able to require all content (member or owner) to carry a
signature; member keys are already disseminated to other members
via the prior plan (`2026-04-29-member-profile-sending-channels.md`),
so verification on the recipient side is not a separate effort.
In the meantime, the create-flow help text for "Public group" on
both platforms (§4.3, §5.3) includes a one-line trade-off framing:
"In a Public group, the relay forwards messages on behalf of every
member. A compromised relay could change message text or attribute
fabricated messages to any member. Use a secret group if you need
non-repudiable peer-to-peer messaging." This is the same trade-off
that channels make for owner posts; making it explicit at create
time lets users choose Public-group-via-relay vs secret-group based
on whether they value scale or content integrity.
### 6.A.3 What is unchanged from channels
Every other property of the channel threat model carries over
without change. In particular:
- A relay cannot impersonate an owner or substitute the channel
profile (signed events, validated entity ID).
- A relay cannot determine subscriber / member real identity or
network address (inherited from SMP transport).
- All-relays-compromised-and-colluding cannot forge signed events
or alter owner-authoritative state.
- A passive network observer cannot determine which Public group a
member is in, or correlate Public-group activity with other
SimpleX activity.
Public-group members get the same participant-privacy guarantees as
channel subscribers, and Public-group owners get the same key-loss
risk profile as channel owners (see `channels-overview.md`
§"Compromise of owner keys" and §"Loss of all owner devices").
### 6.A.4 Release-notes line
For the Public-groups release notes, include a one-line summary of
both new properties:
> "In a Public group, the relay you choose can see who initiates
> direct conversations between members (but not message content),
> and could in principle alter or fabricate group messages
> attributed to any member. Pick relays you trust, or use a secret
> group if you need peer-to-peer message integrity."
## 7. Migration / compatibility
- **Existing channels are unaffected.** Channel profiles continue to
carry `groupType = "channel"`; the new code path produces
@@ -680,7 +1023,7 @@ variants. Channels keep their `_subscriber*` strings.
warning is not a hard block — the owner may proceed knowing that
some relays will reject member posts.
## 7. Open questions
## 8. Open questions
1. **Forwarding scope for `XGrpDirectInv`.** The relay needs to deliver a
single-target message. Reuse `MSMember` / `DJSMemberSupport`, or
@@ -731,7 +1074,7 @@ variants. Channels keep their `_subscriber*` strings.
is in flight; flip the flag once dissemination lands.
Recommend (A) — fewer states to support, less user confusion.
## 8. Sequencing
## 9. Sequencing
1. **Prerequisite: member-profile dissemination plan**
(`2026-04-29-member-profile-sending-channels.md`). Lands first,
@@ -754,7 +1097,7 @@ variants. Channels keep their `_subscriber*` strings.
iOS.
7. **Kotlin views, icons, ConnectPlan** (§5.4-§5.6).
8. **Older-client refusal, version bump release notes, docs
updates** (§3.1, §6).
updates** (§3.1, §7).
Steps 4-5 and 6-7 ship independently per platform — iOS can ship
Public groups without waiting on Kotlin and vice versa, as long as
@@ -764,7 +1107,7 @@ Steps 2 and 3 ship in either order; step 3 has no dependency on
step 2 other than the existence of `GTGroup` rows in the wild,
which step 2 enables.
## 9. Adjacent work (one paragraph, not planned here)
## 10. Adjacent work (one paragraph, not planned here)
Two pre-existing channel-protocol disparities are deliberately
untouched. (1) **Owner→relay communication of joiner role and