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. |
peerIdon the wire is a string of exactly 16 lowercase hex chars (^[0-9a-f]{16}$); anything else is malformed. DuplicatepeerIds in one room are rejected.- A group
joinnotifies 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
topeer only, withfromset to the sender'speerId. A message addressed to apeerIdthat 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,idPubBare the two members' identity public keys in 65-byte uncompressed SEC1 form;min/maxorder 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:
signatureis IEEE P1363 (r ‖ s, 64 bytes, protocol-v1 §2.2) over all preceding bytes of the payload —groupIdthrough the lastmemberEntry— 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.memberCountis the number ofmemberEntryrecords that follow; decoders validatememberCount ≤the configured member cap and the encoded length before allocating — validate-before-allocate per v1 §12.epochis auint32big-endian.group_membership_requesthas 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 withgroup_membership.
4.2 Epoch and link rules
- Epoch strictly increases. The author bumps
epochon 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_membershipwith 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
textframe sent on every connected link. - A group file is a normal
file_manifest+file_chunktransfer 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.