fuck-eu-chat-control docs

Protocol v2 — group chats

Protocol v2 — Group Conversations

The normative specification for group chats. This document layers a membership and addressing protocol on top of protocol-v1.md, which remains the frozen source of truth for everything it covers: the handshake transcript, key schedule, nonce derivation, frame format, AAD, primitive encodings, and all v1 limits are byte-for-byte unchanged. Nothing in this document relaxes, redefines, or extends a v1 wire item. The only new wire items are optional broker join fields, addressed relay fields, and two new control subtypes that are valid exclusively on group links. See ADR 003 for the decision and the threat model for the security consequences.

A group conversation is a full mesh of pairwise v1 sessions. Every member↔member pair runs an ordinary protocol-v1 conversation — Hello → Signature → optional SPAKE2 — over its own WebRTC data channel, keyed by a derived pairwise conversation ID (§3). Group semantics live in the membership layer: an author-signed, epoch-numbered member list gossiped over pairwise control frames (§4), plus peerId-addressed broker signaling for rooms of more than two seats (§2).

1. Terminology

Term Width Meaning
groupId 16 bytes Group identifier. CSPRNG-generated by the author, exactly like a v1 conversation ID; is the broker room ID; carried in the URL fragment.
peerId 8 bytes Ephemeral signaling label. Client-chosen per broker join; rendered and validated as 16 lowercase hex chars. Addresses a seated socket inside a group room. Not a cryptographic identity — the identity public key is.
epoch uint32 BE Membership list version. Bumped on every membership change; strictly increasing within a group.
author — The group creator's identity key pair. The only key that may sign group_membership lists; the group's sole admin.
memberEntry variable One row of the membership payload: an identity public key plus flags and a display name (§4.1).
pairwiseConvId 16 bytes The v1 conversation ID of one member↔member link, derived deterministically per §3.
group link — A pairwise v1 session whose pairwiseConvId was derived from a groupId. Carries normal v1 frames plus group control subtypes.

peerId exists only so the broker can route signaling between more than two sockets. It is chosen by the client at each join, has no relation to the identity key, and is never trusted for authentication: sender attribution and all membership checks are against the link's authenticated identity public key.

2. Broker protocol v2

The broker message set (join, leave, offer, answer, ice as JSON objects tagged by t) is unchanged in kind. Group support is additive: new optional fields, a second room mode, and join/leave notifications that name the peer they concern.

2.1 Message changes

Message Direction Change
join client → broker Gains optional mode ("pair" default or "group") and peerId. peerId is required when mode is "group" and must not appear in pair mode.
join notification broker → client Gains peerId (the joining member's), sent in group mode only.
offer / answer / ice client → broker Gain optional to: the target member's peerId. Required in group mode; absent in pair mode.
offer / answer / ice broker → client Relayed messages gain from: the sender's peerId. Group mode only.
leave notification broker → client Gains peerId (the departing member's), sent in group mode only.
  • peerId on the wire is a string of exactly 16 lowercase hex chars (^[0-9a-f]{16}$); anything else is malformed. Duplicate peerIds in one room are rejected.
  • A group join notifies every seated member with {t:"join", roomId, peerId} — this is what tells seated members to offer to the newcomer. In group mode, glare does not arise: only seated members offer, always to the newcomer, so the offer/answer direction is deterministic.
  • On socket close or an explicit leave, the broker broadcasts {t:"leave", roomId, peerId} to every remaining member.
  • In group mode an addressed message is relayed to the to peer only, with from set to the sender's peerId. A message addressed to a peerId that is not seated is dropped silently — it raced a leave.

2.2 Room modes

A room's mode is fixed by the first join that creates it: a join without mode (or mode:"pair") creates a pair room; mode:"group" creates a group room. A join whose mode mismatches the existing room's mode is rejected (RoomModeMismatch), as is a peerId that duplicates a seated member's (DuplicatePeerId).

Property Pair room Group room
Seat cap 2 GROUP_MAX_MEMBERS (env, default 8, hard ceiling 16)
Seat addressing "the other seat" (unchanged v1 lookup) peerId-keyed
Join notification {t:"join", roomId} to the first peer {t:"join", roomId, peerId} to all seated peers
Leave notification {t:"leave", roomId} to the peer {t:"leave", roomId, peerId} to remaining peers
Relay unaddressed, to the other seat to-addressed, from attached
Seat lifetime peers leave once the data channel opens (signalP2pOpen) seats persist for the life of the group
Wire behavior byte-for-byte unchanged fields in §2.1

Group members stay seated in the broker room while the group is active. Unlike the pair flow — where both peers vacate their seats once the data channel opens — group seats persist so late joiners can be admitted and dropped links can re-signal. The broker still relays only signaling; no content ever transits it.

3. Pairwise conversation ID derivation

Each member↔member pair's v1 conversation ID is derived, not exchanged:

pairwiseConvId = SHA-256("fck-group-v1/pair" || groupId(16) ||
                         min(idPubA, idPubB) || max(idPubA, idPubB))[0..16]
  • idPubA, idPubB are the two members' identity public keys in 65-byte uncompressed SEC1 form; min/max order them by the same lexicographic byte comparison as v1 role derivation (protocol-v1 §3).
  • [0..16] takes the first 16 bytes of the digest — a full-width v1 conversation ID.
  • The "fck-group-v1/pair" domain separator (ASCII, no trailing NUL) keeps derived IDs disjoint from CSPRNG-minted v1 conversation IDs and from any future label.

The derivation is deterministic and symmetric: both sides compute the same ID from the two identity keys alone, so no negotiation round exists and the frozen v1 handshake codec runs untouched. Because the ID is bound to the pair's keys, each link also gets a stable, pair-specific v1 safety number. The pairwiseConvId is what the link's transcript records as its conversation ID — the transcript never knows it belongs to a group.

4. Membership layer

The author maintains the canonical member list, stamps each version with the next epoch, signs it with the author identity key, and gossips it to every member over the pairwise links as a v1 control frame (frame type 0x04).

4.1 Group control subtypes

Two new control subtypes extend the table of protocol-v1 §8.3. They are valid on group links only: on a pair-mode v1 link both are a terminal protocol error, exactly like any other unknown subtype value. The codec accepts them only when the caller has opted the conversation into group mode; the v1 decoder's rejection of unlisted subtypes is not weakened.

Value Name Payload
0x07 group_membership groupId(16) ‖ epoch(4BE) ‖ memberCount(1) ‖ memberEntry[] ‖ signature(64)
0x08 group_membership_request empty

memberEntry layout (fixed order, no padding):

Field Width (B) Encoding
identityPub 65 Uncompressed SEC1 P-256 public key (protocol-v1 §2.1).
flags 1 Bit 0 = author. Bits 1–7 reserved; must be zero.
displayNameLen 1 Byte length of displayName, 0..MAX_MEMBER_DISPLAY_NAME.
displayName variable UTF-8 bytes, displayNameLen of them.

Rules for group_membership:

  • signature is IEEE P1363 (r ‖ s, 64 bytes, protocol-v1 §2.2) over all preceding bytes of the payload — groupId through the last memberEntry — made with the author's identity private key and verified against the member entry flagged as author. A list that fails signature verification is discarded entirely.
  • memberCount is the number of memberEntry records that follow; decoders validate memberCount ≤ the configured member cap and the encoded length before allocating — validate-before-allocate per v1 §12.
  • epoch is a uint32 big-endian.
  • group_membership_request has an empty payload; a joiner sends it on an established link to ask for the current list. The author (or any member holding a current list) answers with group_membership.

4.2 Epoch and link rules

  • Epoch strictly increases. The author bumps epoch on every membership change (add or remove). A received list whose epoch is less than or equal to the current epoch is discarded — replayed or regressed lists are never adopted.
  • Link identity must be listed. The identity public key a link authenticated at handshake must appear in the current member list. A link whose peer is absent is flagged; once a newer list is in hand, links to removed members are closed.
  • Newest list wins. On receipt of a verified group_membership with a greater epoch, the receiver adopts it wholesale: added members become candidates for new links, removed members' links are torn down.

5. Group safety number

The group has a single safety number covering the whole member list at one epoch. It is computed identically on every member's client and rendered with the existing 12-digit display (six 2-digit pairs, space-separated).

material  = "fck-group-v1/safety" || groupId(16) || epoch(4BE) ||
            sorted(memberIdPubs)
digest    = SHA-256(material)
truncated = uint40BigEndian(digest[0..5])
value     = truncated mod 10^12
display   = pairs(format("%012d", value), 2)

sorted(memberIdPubs) is the concatenation of all member identity public keys in the list — the author's included — each in 65-byte uncompressed SEC1 form, ordered by lexicographic byte comparison. The number changes on every epoch bump, so an out-of-band comparison authenticates the member list as of that epoch; any subsequent join or removal requires re-verification. Each pairwise link additionally retains its own v1 safety number, derived from the pairwiseConvId per protocol-v1 §10.

6. Group content

Group content uses unmodified v1 frames on each pairwise link:

  • A group text message is a text frame sent on every connected link.
  • A group file is a normal file_manifest + file_chunk transfer per link; there is no group-wide transfer ID space.
  • Sender attribution is the link's authenticated peer identity — the identity public key the v1 handshake proved, which the member list maps to a display name. Frames carry no in-payload sender field.
  • Per-sender ordering comes free from each link's sequence numbers; there is no total ordering across senders. The UI merges per-link streams and sorts by timestamp.
  • There is no offline delivery and no history sync: a member sees messages from join-time forward, and a member who is offline misses them.

7. Limits

Exposed as readonly constants in protocol/limits.ts alongside the v1 limits, plus one server environment variable.

Constant Value Meaning
DEFAULT_MAX_GROUP_MEMBERS 8 Default maximum member count per group (seats in a group room, entries in a member list).
MAX_GROUP_MEMBERS_HARD 16 Absolute ceiling on the member cap. Bounds GROUP_MAX_MEMBERS itself and all decoders.
MAX_MEMBER_DISPLAY_NAME 32 Maximum displayName byte length in a memberEntry.
GROUP_MAX_MEMBERS env Server env var; optional integer in 2..16 overriding DEFAULT_MAX_GROUP_MEMBERS.

The broker registry and the client-side group session each take the configured cap as an option; /ice-config serves it to the client. Codec validation of memberCount enforces the configured cap at that layer, while MAX_GROUP_MEMBERS_HARD bounds the env var and is the last-resort decoder limit so no operator setting can mint an unbounded room or list.

8. Invitation fragment

The invitation fragment grammar is unchanged from v1:

#<hex>[~NNNNNN]

<hex> is the 16-byte groupId as 32 lowercase hex chars — byte-for-byte the same slot a v1 conversation ID occupies — and the optional ~NNNNNN tail is the author-minted 6-digit SPAKE2 code, reused as the PAKE password on every pairwise handshake in the group. A group invitation is indistinguishable from a pair invitation: pair versus group is decided by which join flow the client runs (which broker mode it requests), not by the link bytes. Joining UIs must call the matching entry point; the fragment carries no mode marker.