Group chat specification
Abstract
Prerequisites
This design specification is dependent on the BACAP and Pigeonhole protocol designs from our paper Echomix: A Strong Anonymity System with Messaging, which describes
-
the BACAP (blinded and capability) in §4 and
-
the Pigeonhole protocol in §5.
The source code and API docs for BACAP are available on pkg.go.dev.
Introduction
The Pigeonhole protocol establishes anonymous cryptographic communication channels which have a readcap (read capability) and a writecap (write capability). For example, if Alice and Bob want to communicate, they can each create their own Pigeonhole/BACAP channels and exchange readcaps on those channels. Now when Bob writes to his channel, Alice can read those messages because she has Bob’s readcap. Likewise, when Alice writes to her channel, Bob can read those messages because he has Alice’s readcap. This is the most basic construction using BACAP and Pigeonhole.
Here we extend this basic design to work as a minimal group-chat protocol, without key rotation.
BACAP primitives give us two message types:
-
SingleMessage -
AllOrNothingMessage(used for big messages: upload n chunks to a temporary stream and then put a pointer to that in your own stream as a single message.)
The group state consists of:
-
a
MembershipCapfor each member, containing:-
a BACAP readcap
-
the index of the next box to read, kept separately from the readcap
-
a nickname
-
-
a
MembershipHash(a hash over the members’ read caps)
The group chat is completely decentralized. Each member must keep track of every other member.
Group chat message types
All messages are SingleMessage if they fit in one BACAP slot, or an
AllOrNothingMessage if they are too big.
Texttype payloads are normal chat text messages.
// TextPayload encapsulates a normal text message.
type TextPayload struct {
// Payload contains a normal UTF-8 text message to be displayed inline.
Payload []byte
}
Introductiontype messages introduce new group members.
// Introduction introduces a new member to the group.
type Introduction struct {
// DisplayName is the party's name to be displayed in chat clients.
DisplayName string
// UniversalReadCap is the BACAP UniversalReadCap
// which lets you read all messages posted by this user.
UniversalReadCap *bacap.UniversalReadCap
}
FileUploadtype
A FileUpload can be used for various purposes such as uploading an image to
be displayed inline by the chat client. Likewise, a sound bite could be made visible
in the
chat along with a play-button. Beyond that, we can support arbitrary file attachments.
// FileUpload encapsulates several file types
// which result in different client behaviors.
type FileUpload struct {
// Payload contains the file payload.
Payload []byte
// FileType is the identifier for each file type.
// Valid file types are:
// "image"
// "sound"
// "arbitrary"
FileType string
}
Whotype
The Who message type is used to query who is currently in the group.
// Who is used to query the group chat to find out the member read capabilities.
type Who struct {}
ReplyWhotype
The ReplyWho message answers the Who query with an
AllOrNothingMessage BACAP stream containing readcaps for all group chat
members, each with the index to start reading at.
type ReplyWho struct {
Payload *bacap.BacapStream
}
GroupChatMessagetype
The GroupChatMessage message encapsulates all of the above-mentioned message
types and is serialized with CBOR.
// GroupChatMessage encapsulates all chat message types.
type GroupChatMessage struct {
// Version is used to ensure we can change this message type in the future.
Version int
// MembershipHash is the sender's view of who is in the group.
MembershipHash [32]byte
TextPayload *TextPayload
Introduction *Introduction
FileUpload *FileUpload
Who *Who
ReplyWho *ReplyWho
}
Every message carries the sender’s MembershipHash: SHA-256 over the
domain separator KP:membership:v1 followed by the 32-byte public key of
each active member’s read cap, the sender’s own included, without
repeats and in ascending byte order. An Introduction carries the hash of
the group as it stood before the member it introduces, and a reader
compares it with its own view less that member, whether or not it holds the
member yet: the reader may be the member introduced. A reader reports a
mismatch; it never drops a message for one. It is the one sign that two
members disagree about who is in the group.
Protocol flow
The protocol flow for making a new group from scratch (using whatever authentication
protocol) is essentially for everybody to exchange PleaseAdd messages.
// PleaseAdd is a message used by a client to try and gain access to a chat group.
type PleaseAdd struct {
// DisplayName is the party's name to be displayed in chat clients.
DisplayName string
// UniversalReadCap is the BACAP UniversalReadCap
// which lets you read all messages posted by this user.
UniversalReadCap *bacap.UniversalReadCap
}
type SignedPleaseAdd struct {
// PleaseAdd contains the CBOR serialized PleaseAdd struct.
PleaseAdd []byte
// Signature contains the cryptographic signature over the PleaseAdd field.
Signature []byte
}
For introduction to an existing group over an existing channel between an introducer
member and new member, an Invitation message is used.
type Invitation struct {
GroupName string
}
The Invitation protocol flow works as follows.
-
There exists a group called YoloGroup. A member of the group invites a potential new member with an
Invitationmessage. -
If the invited party wants to join, then they reply with a
SignedPleaseAddmessage meaning “I want to join your group.” This provides the invited party’s BACAP universal readcap, their display name, and a cryptographic signature produced by their BACAP writecap. -
The introducer receives the
SignedPleaseAddmessage.-
If the introducer does not like the DisplayName, they reply to the invited party with a
PleaseReviseDisplayNamemessage that contains the originalSignedPleaseAdd. Then they wait for a newSignedPleaseAdd. -
If the introducer approves of the
DisplayName, then:-
Because existing members need the new member’s readcap, the introducer publishes the
SignedPleaseAddto their own BACAP stream for the rest of the group to read. -
Because the new member needs existing members’ readcaps, the introducer replies to the new member with
ReplyWhomessage containing readcaps for all existing members, each with the index to start reading at.IMPORTANT: The content of both replies must be sent in the same
AllOrNothingMessage, despite theSignedPleaseAddbeing written to the introducer’s own BACAP stream for the group and theReplyWhobeing written to the BACAP stream the introducer is using to communicate with the new member.NOTE: We could send all of this information as part of the initial
Invitation, but that would allow silent members to read other members’ streams without them knowing it, which is an anti-goal.
-
-
Opportunistic acknowledgements and backfill
Pigeonhole boxes are garbage-collected after one to two weeks (see “Ephemeral” in Understanding Pigeonhole). After that, only the author has the content. Acknowledgements tell the author what to restore.
Any group message may carry acknowledgements. None is sent on its own.
GroupChatMessage gains one field:
type GroupChatMessage struct {
Version int
MembershipHash [32]byte
TextPayload *TextPayload
Introduction *Introduction
FileUpload *FileUpload
Who *Who
ReplyWho *ReplyWho
Acks []byte
Numbers []byte
IntroducedAt []byte
}
This new Acks field is a mapping from roster indexes (see Rosters below) to BACAP
Idx64 values. Its encoding is described in
Acks wire format.
Acks is nil when we don’t have anything to acknowledge. Numbers and
IntroducedAt keep rosters in step; they are described under Rosters below.
TextPayload and FileUpload are content. Introduction, Numbers and
IntroducedAt are control. A message carries either content or control,
never both. The retention policy deletes content only (see
Disappearing messages), so
a message that changes a roster stays readable for as long as its Sent-box
record lasts.
Throughout this specification we use roster indexes as the channel identities. Alice keeps a list of the channels she has numbered and refers to each by its place in that list, counting from 0: 0 is Alice herself, 1 is the next member she numbered, and so on. One byte names a member, which keeps acknowledgements small.
- Each value in the key-value mapping is the
Idx64of a box on that member’s stream. It acknowledges that box and every earlier box from where the acknowledging member started reading the stream, so a member acknowledges a box only once it has read it and every box before it: the end of the unbroken run it has read. A box read as a tombstone counts as read, since its plaintext is gone for every reader. A box a scan stepped past without reading (see Rewrite and scan) is not read until a recheck reads it, and until then the member’s acknowledgements of that stream stop just before it. - The acknowledging member is whoever’s stream carried the message.
- A stream owner checks each claimed
Idx64against its Sent-box records and ignores one matching no box it wrote.
A MessageBoxIndex is the whole BACAP derivation state for one box. Its
first 8 bytes, little-endian, are the box’s Idx64. BACAP’s derivation adds
one to the Idx64 at each step and never wraps: a stream ends rather than
reach 264. On one stream an Idx64 therefore names exactly one
box, and orders the stream’s boxes as they were written. Every member holds
every member’s read cap, and so each stream’s first index, from which
BACAP’s AdvanceIndexTo derives the box at any later Idx64. An
acknowledgement names a box by its Idx64, 8 bytes instead of 104. An
Idx64 means nothing without the stream it belongs to, so a reader MUST
resolve an acknowledgement’s roster index before reading its value.
IntroducedAt and the reply to a new member name a box by its whole
MessageBoxIndex.
Rosters. Members are named by one-byte roster indexes, not public keys, to save bytes on constrained transports such as LoRa.
-
Roster. Each member keeps an ordered list of the members it has numbered, itself included. A member’s roster index is its position in that list. Entries are never moved or reused, so a roster holds at most 256 members ever. Every member keeps a copy of every other member’s roster. A member’s identity is its read-cap public key.
-
Starting. A lone founder’s roster holds only itself. Co-founders each start with all founders in ascending order of read-cap public key. A new member starts with a copy of its introducer’s roster as of the
Introductionannouncing it. -
Numbering. A member numbers another by appending it to its roster, taking its next unused index, and announcing the assignment in the
Numbersfield of a control message. Members readIntroductions on different streams in different orders, so only the member itself knows which index it gave whom;Numberssays so. A message numbers members at consecutive indexes and names each by the first 8 bytes of its read-cap public key: enough to tell a group’s members apart, and too many for a joiner to choose a key matching another member’s. An introducer numbers the new member in the message carrying itsIntroduction, which needs only the index assigned. Every other member numbers a new member after reading itsIntroduction, and MUST do so before acknowledging the new member’s stream. -
Watching. A reader learns each sender’s roster from that sender’s
Numbers. Until the reader knows a member whose key begins with an entry’s prefix, from anIntroductionit has read or from the reply that inducted it, the member at that index is unresolved. -
Introduced at. A new member learns where its
Introductionlanded by reading its introducer’s stream, and its next message after that is a control message carryingIntroducedAt: its introducer’s roster index and theMessageBoxIndexof theIntroductionit joined through. A member can be introduced more than once, so readers take the new member’s starting roster from theIntroductionthatIntroducedAtnames: the introducer’s roster up to and including the index thatIntroductionassigned. Indexes are assigned in order and never change, so that is the introducer’s roster as it stood then. Until readers have read thatIntroduction, the new member’s roster is unknown and its acknowledgements are skipped. -
Repeats. A member already in a roster is never added again.
-
Reply to a new member. The introducer hands the new member the group privately, in its last message of the contact voucher protocol: the
VoucherReply, written in the same all-or-nothing commit as theIntroduction. ItsWhoReply(ReplyWhohere) is built before the new member’sIntroductionhas aMessageBoxIndex, so it names the new member only by the roster index it will take. It carries:- every member’s read cap, each with its start index (see Joining), in the introducer’s roster order, so a member’s place in the list is its roster index. A removed member’s slot is sent empty.
- every member’s roster, as the introducer has followed it.
- every
Introductionthe introducer has read: the roster index of the member that wrote it, itsMessageBoxIndex, and the roster index of the member it introduced. AnIntroducedAtnames anIntroduction, and the new member never reads the boxes before its start indexes, so without these it could never resolve one naming anIntroductionwritten before it joined.
// Rosters holds each member's roster, in introducer roster order. // Each byte is the introducer's roster index for that entry. Rosters [][]byte // Introductions holds each Introduction the introducer has read. Introductions []IntroductionRef type IntroductionRef struct { // Introducer is the roster index of the member that wrote it. Introducer byte // Index is the MessageBoxIndex of the box it was written in. Index [104]byte // Member is the roster index of the member it introduced. Member byte }Roster indexes in the reply are the introducer’s.
-
Reading. Resolve each index against the sender’s roster. Skip values at unresolved indexes. Roster indexes MUST NOT be compared across senders.
-
Skipped boxes. A reader that skips a box may miss a
Numbersfield. Every field states its first index, so later ones still land in place, and the missed indexes stay unresolved. -
Removal. A member may silently stop reading a stream. It keeps the roster entry, MUST NOT acknowledge that index again, and MUST NOT reuse it. An entry still named only by a key prefix is the removed member’s only if the prefix matches no other member it knows. It no longer waits for the removed member’s acknowledgements before discarding plaintext. The removed member keeps its read caps.
Sent-box records. A stream owner records, for each box it wrote and
under its Idx64, its MessageBoxIndex and its plaintext until every member
has acknowledged it or its retention policy deletes the plaintext (see
Disappearing messages).
How far each member has acknowledged the owner’s stream is the Idx64 of
the last box it acknowledged.
- Retention. Once every other active member has acknowledged a box, its plaintext MAY be discarded, but its record is kept. A record is kept for as long as the retention policy keeps sent content, and for good when the policy keeps it for good, so an acknowledgement that arrives late still finds its box. Under a policy that deletes sent content, a record MUST be discarded after a bounded window exceeding one replica epoch.
- Backfill. Once per replica epoch the owner rewrites every recorded box some active member has not acknowledged: with its plaintext if it still has it, otherwise as a tombstone, so the box still reads as written. A box every member has acknowledged is written once more as a tombstone when its plaintext is gone, to erase it from the replicas, and never again. When nobody is behind, nothing is written. Rewrites are idempotent since Pigeonhole writes are content-idempotent and BACAP encryption is deterministic. Acknowledgements never trigger a rewrite.
- Rate limit. Implementations SHOULD NOT rewrite a box more than once per replica epoch.
- Timing. A client takes the replica epoch from the PKI document, not its clock: the epoch most storage replicas publish envelope keys around. It takes up each epoch at a point in the epoch’s first half that only it can work out, so that members do not all rewrite at the same moment, and at once if it has missed a whole epoch.
Joining. A new member receives its introducer’s view of the group as of
the Introduction, privately in the introducer’s VoucherReply: a read cap
and a start index for every member’s stream in the introducer’s roster, the
rosters the introducer knew about, and the Introductions it had read (see
Reply to a new member). Read caps are sent unchanged; the index travels
beside them and is advanced separately.
- Catching up. Before an invitation begins, the introducer reads every stream as far as it can.
- Start indexes. Each start index is the index of the next box the
introducer had to read on that stream when the invitation began, and the
introducer’s own is the next box it writes, no later than the
Introduction. - Holding back. From the start of an invitation until the
all-or-nothing commit that writes the
Introduction, the introducer sends no acknowledgements and numbers no one. Its published acknowledgements then stop short of every start index, and its roster stays as the reply describes it. - Waiting. Before building the reply, the introducer waits until every message it has composed has been written. One composed earlier may number a member, taking the index the reply would otherwise give the new member.
- Retention. A member that reads an
Introductionkeeps every box of its own stream that the introducer had not acknowledged at that point, until the new member acknowledges it or the member’s retention policy deletes it. - No race. Members read the introducer’s stream in order, so each sees
the
Introductionbefore any acknowledgement that would let it discard a box the new member was given. - History. Older history is sent from the introducer’s own copy, never by starting a stream earlier.
Acks wire format
We encode the Acks mapping by hand as a byte slice rather than as a CBOR map. The
motivation is to let this protocol run over constrained pipes, such as
LoRa radio links, where every byte counts.
Let n be the number of entries. Every value is an 8-byte Idx64, so the
values cost 8n bytes in any encoding. Only the cost of naming the keys
varies.
- Simplest. A list of (roster index, value) pairs. The keys cost one
byte each,
nbytes in total. - Bitmap. A bitmap with one bit per roster index, followed by the
values in ascending roster index order. Let
bbe the bytes a bitmap needs to reach the highest acknowledged index;bis at most 32. The keys costbbytes. This beats the list when many members are acknowledged at once. - Most compact. Use whichever of the two key encodings is shorter:
min(n, b)bytes, the list when they tie. Keys can be longer than one value, so the field length alone no longer says how many values follow: the field begins with one byte holdingn - 1. This is the encodingAcksuses.
For example, if the highest acknowledged index is 19, the bitmap is 3
bytes. Acknowledging 3 members then costs 3 key bytes either way;
acknowledging 10 costs 10 as a list but only 3 as a bitmap, and the whole
field 1 + 3 + 80 bytes.
The most compact encoding is defined as follows.
- Layout.
Acksis one byte holdingn - 1, then the keys, then the values in ascending roster index order, each little-endian. Ifn <= b, the keys are a list ofnindex bytes in ascending order. Otherwise they are ab-byte bitmap where indexiis biti mod 8, counting from the most significant bit, of bytei div 8. - Parsing. The first byte plus one is
n. The key length is the field length less one, less8n. A key length equal tonmeans a list; smaller means a bitmap. A parser MUST ignore the whole field if the key length is negative or exceedsn, a list is not strictly ascending, a bitmap’s last byte is zero, a bitmap does not have exactlynbits set, or a bitmap exceeds 32 bytes.
Numbers and IntroducedAt wire format
Numbers and IntroducedAt are encoded by hand for the same constrained
pipes as Acks.
- Numbers. In a message carrying an
Introduction, a single byte: the roster index assigned to the member thatIntroductionintroduces. Otherwise the first roster index assigned (1 byte), then the first 8 bytes of each member’s read-cap public key, one per consecutive index from the first:1 + 8nbytes. Numbering alice’s next two members, erin and dave, takes 17 bytes. A parser MUST ignore the whole field if its length is neither of these, if it would assign an index above 255, or if a prefix appears twice. - IntroducedAt. One entry without its first byte: 105 bytes.
Rewrite and scan
A reader cannot tell a quiet stream from one stuck behind a
garbage-collected box by reading it: both return BoxIDNotFound. Nor can it
tell when a missing box will read again: that happens only once its owner,
who may be offline, rewrites it. So a client never scans on its own.
-
Periodic rewrite. Well within each replica epoch, the stream owner rewrites due boxes (see Backfill), keeping every recorded box populated.
-
Kept indexes. A reader keeps the
MessageBoxIndexof each box a scan stepped past without reading it, until a recheck reads it. BACAP indexes cannot be derived backward, so an index is kept as it is passed. A reader MAY drop kept indexes to bound what it keeps. A box whose index it drops is lost to it, and its acknowledgements of that stream never pass that box. -
Scan. The reader rechecks its kept indexes and ingests any box now readable, data or tombstone. If members have acknowledged boxes the reader has not yet reached, it advances its stream from the box it expects next to the furthest acknowledged
Idx64, keeping the indexes it passes. The advance MUST be bounded: an acknowledgedIdx64beyond the bound is ignored, and not looked for again. The reader then probes forward without waiting between boxes: data is ingested, a tombstone is skipped, and the firstBoxIDNotFoundbecomes the box it expects next, ending the scan. -
When to scan. A scan runs only when the user asks for one, one stream at a time. The client MAY show how long a stream has been quiet, whether other members have acknowledged boxes on it the reader has not reached, and how many boxes a scan stepped past are still unread.
A reader’s acknowledgements of a stream stop just before the first box it has not read, so the owner keeps that box’s plaintext and goes on rewriting it until its Sent-box record is discarded. A scan the user asks for before then delivers it. Two members stuck behind gaps in each other’s streams resynchronise once both scan within the owners’ retention windows. A box gone for good is lost to the reader: after a scan it reads on past it, and its acknowledgements of that stream stay before it.
Disappearing messages
Disappearing messages is the feature that causes a user’s client to remove messages from the client’s state database based on a user specified message retention policy. For example, a client’s message retention policy could keep decrypted plaintext messages from other members for X number of days. Likewise policy could retain sent message plaintext for Y number of days.
The retention policy applies to content only. Control messages are kept for as long as their Sent-box records last, so a message that disappears never takes a roster change with it.
Once a client makes a message “disappear” as per this feature, then after that point in time the client can never retransmit that box. Backfill rewrites it as a tombstone instead. The tombstone in place of the box is still very useful for resyncing. A member that reaches a tombstone in place of a box it has not yet read can also tell from it that it didn’t read your message in time before your client’s retention policy purged the plaintext data.
Addenda
GOOD QUESTION: If we are adding a lot of people at once,do we really need to upload all of the members n times?
OPEN PROBLEM: Our BACAP API defines a ReadCap type as containing the master public key AND the start index. Therefore whenever we send a readcap over the wire we are needlessly sending a start index even when we don’t need it anymore. We should probably fix this, especially given that our goal is to be as bandwidth efficient as possible.
FUTURE WORK: Forward secrecy. We can add two extensions that allow transmitting public keys + stuff encrypted under those public keys. We can also refer to the Reunion protocol which is a n-way PAKE with strong anonymity properties. Reunion is described in Communication in a world of pervasive surveillance: Sources and methods: Counter-strategies against pervasive surveillance architecture. Currently, Python and Go implementations of Reunion are available.