feat(groups): group chats, and a mesh rather than a star; release v6.1.1
CodeQL Analysis / Analyze CodeQL (push) Waiting to run
Deploy Application / deploy (push) Waiting to run
Mirror to Codeberg / mirror (push) Waiting to run
Mirror to PrivacyGuides / mirror (push) Waiting to run

A group is an orchestration layer over the pairwise sessions the app already
holds. It owns no transport and no shared key: every frame leaves over a chat
that is already SAS-verified and already ratcheted, so a removed member simply
stops being sent anything. Membership is a roster the admin signs, ordered by
epoch, and the safety code is a commit-then-reveal round over every member's
fingerprint and nonce.

Delivery was the part that did not match its own description. The admin held a
link to everyone and nobody else held a link to anybody, so the relay path — the
documented fallback — was in fact the entire topology, and the admin going away
partitioned the group. Now, once the code is confirmed, each pair without a link
dials one over that relay path. The descriptors are compact enough to ride a
group frame and are signed with the sender's group identity key, so the relaying
member can drop a dial but cannot substitute one. The member with the smaller
fingerprint dials, which is the whole glare protocol.

Mesh links are released without a human comparing digits. Twenty-eight codes for
a group of eight is not a check anyone performs; the guarantee moves rather than
disappears, since the descriptor was signed by a key the signed roster names and
the group code covers. markGroupLinkVerified refuses any session whose in-band
exchange has not completed and whose peer has not proved possession of that key.

An existing 1:1 chat between two members is adopted instead of re-dialled, via a
probe bound to that session's own key fingerprint so it cannot be replayed onto
another chat to impersonate its author.

Security fix: g_hello was accepted on any session from anyone who knew the group
id, so any member could publish an identity the admin never invited and have the
admin sign and broadcast a roster containing it. It is now accepted only on a
session an invitation went out on, which also confines it to a direct link.

Mesh connections are kept out of the chat registry and muted from the document
events the header listens to, so a routing detail cannot tear down the display of
a conversation the user actually opened.
This commit is contained in:
lockbitchat
2026-08-25 16:27:08 -04:00
parent 6a98e2eb00
commit e00c3bd413
30 changed files with 11635 additions and 421 deletions
+345
View File
@@ -1,5 +1,350 @@
# Changelog
## v6.1.1 — Group chats now connect everyone to everyone
Group chats used to run through whoever created them. That person held a
connection to every member, and nobody else was connected to anybody — so when
you wrote to someone in the group, your message went to the creator first and
they passed it along. It worked, but it meant one person could see when everyone
else was talking, and if they closed the app the group went quiet.
Now every pair in a group connects directly, by themselves. It happens on its own
a few seconds after everyone has confirmed the group code, and you can watch it
in the header: it goes from "2 of 3 connected" to "3 members · P2P mesh". If two
of you already had a private chat open with each other, the group just uses that
instead of building a second connection.
The practical difference: whoever created the group is now an ordinary member.
They can close the app and everyone else keeps talking.
### Also in this release
- When somebody can't be reached directly, their messages still travel through
another member — and a message that arrived that way is now marked "relayed",
so you can see when it happens.
- When somebody goes offline, the group says so once and by name, instead of
repeating a notice under every message you send until they come back.
- A connection that drops and can't be repaired is now rebuilt through someone
else, rather than staying dead for the rest of the conversation.
- The warning about relayed messages no longer appears when nothing is being
relayed — it used to count people who were simply offline.
- Group connections no longer interfere with the display of whichever chat you
happen to have open.
### Security
- **Fixed: any member of a group could add anyone else to it.** Only the person
who created the group was ever meant to be able to invite, but the check for
that was missing — a reply to an invitation was accepted from anyone who knew
the group's id, whether or not they had been invited. You would have noticed,
because a new member appears in the list and everyone's group code changes, but
noticing should not have been the only thing standing in the way.
- The details two members exchange to find each other travel through a third
member, so they are now signed. That member can refuse to pass them along, but
they cannot swap them for their own and end up sitting in the middle of the
connection.
- Nobody is asked to compare a code for each of these new connections. For a
group of eight that would be twenty-eight codes, which is not a check anyone
actually performs — the group code everyone already compared covers them.
## v6.0.8 — An offline group member reads as offline
### Fixed
- **A regression from 6.0.7: closing a departed peer's chat could strand a group
member.** That release removed the 1:1 chat when a peer disconnected, but a
group is built out of those very sessions — one of them is the group's only
route to that member. Tearing it out made them permanently unreachable and left
nothing to re-bind when they came back. A session carrying a group member is
now kept, on both the peer-departure and recovery-exhausted paths.
- **An offline member is now unmistakable in the member strip** — struck through,
dimmed, marked `offline`, with a tooltip saying they will not receive messages
and that removing them re-keys the group. They stay listed, because a dropped
connection is not a departure: membership is a signed, epoch-ordered fact, and
re-keying the group every time somebody's network hiccups would make everyone
re-compare a code for nothing. Removing a member is still available to the
admin, on the member chip, and that one does open a new epoch.
- **A relayed message is no longer counted as delivered when the recipient is
offline.** A relay hop is unacknowledged: the frame is handed to a member who
may or may not reach the target, and nothing comes back either way. For someone
we have never held a link to that is the normal path. For someone whose link we
lost it is a guess, and counting it told the sender their message had arrived
when there was no reason to believe it. The frame is still relayed — the target
may be reachable elsewhere in the mesh — it just no longer inflates the count.
## v6.0.7 — Dead chats clear themselves; the group code button is gone
### Removed
- **The Code button in the group header.** It opened a dialog that had nothing to
say once the group was ready — the code arrives on its own when there is
something to compare, and the header already carries it beside the member
count. A control that reports "Working…" forever is worse than no control.
### Fixed
- **A chat the peer walked out of no longer lingers in the rail.** `peer_disconnected`
comes only from an explicit peer_disconnect frame, never from a transport drop
an ICE restart might repair, so it is terminal: there is nothing to reconnect
to, and the chat is now removed rather than sitting there advertising a closing
notice as its last message. A plain `disconnected` is deliberately left alone —
that one is recoverable and keeps its history.
- **System notices no longer hijack the chat preview.** The rail showed whatever
message came last, so "Enhanced secure connection closed. Check connection
status." replaced the last thing the peer actually said — while the status line
right next to it was already saying the same thing, better. The preview now
skips system messages and falls back to the connection status when a chat has
nothing else.
## v6.0.6 — Invite into a running group; departed members actually disappear
### Added
- **The admin can invite more people into a group that is already open.** An Add
control in the group header offers the verified 1:1 chats that are not already
members. The group keeps working while the invitation is outstanding — nothing
about the membership changes until the new members publish their identity keys
and a roster for the next epoch goes out. Then everyone, old and new, runs a
fresh commit/reveal round and compares a new code. That is not ceremony for its
own sake: the safety code covers the member set, so a set that has changed has
a different code and the old one no longer describes who is in the room.
- If nobody accepts, the round is abandoned and the group is left exactly as it
was — which is why the epoch is not touched until the roster is actually sent.
A partial answer still publishes, with whoever joined.
### Fixed
- **A member who left stayed on the admin's list.** The admin removed them from
its own member map and then never told the interface, so someone who had
visibly left was still shown as present until some later event happened to
refresh the view. A group should not be vague about who is in it.
- **A fast invitation could abort a round that had already succeeded.** Whether
anything had been sent was judged by what was left in the pending-hello queue,
but on a fast link the invitee's hello comes back — and the whole round
completes — inside the very call that sent the invitation, so the queue was
legitimately empty by then. It is now judged by the send results.
## v6.0.5 — Group formation stops hitting the rate limiter
`frame_rejected` on one side, `ceremony_timed_out` on the other: two symptoms,
one cause.
### Fixed
- **Forming a group exceeded the transport's burst limit and lost frames.** The
manager allows ten sends per second, but a group frame spends *two* of those
slots — `sendMessage` checks the shared limiter and then hands off to
`sendSecureMessage`, which checks the same counter again. Formation sends six
frames back to back on one session (invite, two member keys, roster, commit,
reveal), asking for twelve slots out of ten. The overflow was rejected as a
plain `Error` carrying no code, so it reached the user as the meaningless
`frame_rejected`, while the peer that never received the dropped frame simply
waited until the ceremony timed out.
Group frames now go through a per-session queue that serialises and paces them
at five a second. Formation takes about a second and a half. The limiter itself
is untouched: widening a control that exists for the 1:1 chat, to suit a caller
that can perfectly well wait, would have been the wrong trade.
Ordering is enforced by the same queue. The protocol depends on it — a
commitment has to reach a peer before the reveal that opens it — and firing
several sends concurrently at one channel left that to the manager's internal
mutex.
- **A rate-limited frame is retried instead of dropped.** Losing one frame of a
handshake strands every member of the group, so it is worth waiting for.
Failures that will not improve on their own — a closed channel, a refused
verification gate — are still reported immediately rather than retried.
- **An error with no code of its own keeps its message.** Collapsing every such
failure to a bare `frame_rejected` threw away the only clue about what actually
happened, which is what made this bug take three rounds to find.
### Added
- `group-sender.test.mjs` reproduces the limiter's real accounting — ten slots a
second, two per frame — and asserts that all six formation frames land, in
order, spaced. It also asserts the failure directly: with pacing disabled, the
same run is rejected, and the rejection carries no `code`, which is exactly how
it reached the user.
## v6.0.4 — The group code appears, and a failed group says so
Three bugs, one visible symptom: the safety code never showed and the confirm
button stayed disabled. They are listed worst-first.
### Fixed
- **A failed ceremony could be confirmed into a working group.** `CONFIRM_SAS`
required only that a code existed. A ceremony that reached the code step and
*then* failed — a commitment that did not match its reveal, a member that
vanished — kept that code, so confirming promoted a group whose verification had
demonstrably gone wrong straight to ready. Confirmation now also requires the
group to still be waiting on that code, which is the group's version of the 1:1
rule that verified state comes only from the user acting on something currently
true.
- **A group name in a non-Latin script broke formation silently.** The create
dialog capped input at 64 *characters* while the protocol enforces 64 *bytes*,
so a perfectly ordinary 36-character Cyrillic name is 68 bytes: accepted by the
dialog, then rejected inside the admin's roster signing. Formation died with
nothing on screen and the invitee waited for a member list that was never sent.
The budget is now 128 bytes and every place that trims a name — the dialog, the
rename action — counts bytes.
- **The safety-code dialog reported "Exchanging nonces…" for a group that had
already failed.** It only distinguished one phase from everything else, so a
dead group was indistinguishable from a working one; the disabled confirm button
was the only hint. It now names the real state and, on failure, says what went
wrong in words worth acting on and offers Close instead of a confirm button that
cannot do anything.
### Added
- **Coverage for the seam between the protocol and the screen.** Every previous
group test drove `GroupSession` and read its fields, leaving the path the UI
renders from — emitted event, dispatched action, reducer state — untested. That
is exactly where these bugs lived. `group-app-integration.test.mjs` mirrors the
app's emitter and asserts the code reaches the *store* on both sides, including
with a Cyrillic group name, and that a failed group is never confirmable.
## v6.0.3 — New connection animation, and a re-ordered roadmap
### Changed
- **The landing page's left panel has a new animation.** The old one was a single
static wire between two avatars with dots sliding along it. The replacement is
a 14-second loop that tells the whole story: a direct line to one peer, then
two more joining it — which is what v6.0 actually shipped. It is drawn as one
SVG and animated entirely in CSS, with packets moving along `offset-path` built
from the same geometry the lines are drawn from, so a packet can never drift
off its wire. No requestAnimationFrame loop, so a landing page left open costs
nothing. Under `prefers-reduced-motion` it holds on the finished state rather
than disappearing, so the picture still reads as a mesh of three.
- **Mobile Edition moved up to v6.5 and is marked in development**, swapping with
Quantum-Resistant Edition, which moves to v7.0.
## v6.0.2 — Groups form reliably, and leaving one frees its members
### Fixed
- **A group no longer sticks at "Exchanging nonces".** Held commitments were
replayed *before* our own commitment went out, and replaying them could
complete the commit round on the spot — which revealed our nonce and put a
reveal on the wire ahead of the commitment it belongs to. The peer then held a
reveal it could not check yet and waited for a commitment that was already
behind it in the queue, so both sides sat there: one at "exchanging nonces",
the other still collecting commitments. Every member now broadcasts its own
commitment first and replays held frames afterwards, so a reveal can never
outrun it. Asserted directly by a wire-order test on the smallest case, a group
of two, which is where it was reported.
- **Leaving a group actually ends it for the other side.** `leave()` was started
but not awaited before teardown, and teardown clears the member map that
`leave()` walks to find recipients. Worse, a member who did hear it kept the
group anyway: only the admin acted on a departure. When the admin leaves there
is nobody left who can sign a roster, so there is no next epoch and no code to
compare again — the group is over, and it is now removed instead of lingering
as a chat that can never send.
- **Removing the second-to-last member no longer breaks the group.** It tried to
publish a roster for a group of one, which threw out of the member-list
validator part-way through the removal and left the group in a failed state.
The floor is now checked first and reported.
- **An invitation that cannot be sent says so immediately.** Send failures were
swallowed, so inviting someone over a dead link produced forty-five seconds of
silence and then a generic timeout — indistinguishable from an invitee who had
not answered yet. A send that fails for every invitee now fails at once, naming
the cause; if only some links are down, the unreachable invitees are dropped
from the round instead of stalling the roster forever.
### Changed
- **The roadmap reflects what shipped.** Group Communications and Mobile Edition
swap places: groups are v6.0 and current, native mobile apps move to v7.0, and
Secure Voice & Calls is marked released rather than current. The group entry
also lists what was actually built instead of what was guessed at — there is no
Double Ratchet "for groups" and no ephemeral-group mode; there are pairwise
ratchets, a commit-then-reveal safety code, and signed membership.
## v6.0.1 — The group code now actually appears
### Fixed
- **The safety code opens on its own again.** Whether to show it was decided by
comparing the finishing group against a ref that React assigns during render.
The commit/reveal round completes in a run of microtasks that outpaces the
re-render, so that ref was still null and the modal never opened — leaving a new
group sitting silently at "Compare the group code" with the digits reachable
only through a button. The group that produced a code is now brought forward
directly, which both shows the modal and guarantees it is pointed at the right
group when more than one is forming.
## v6.0.0 — Group chats
Up to eight people in one peer-to-peer conversation, with no server and no shared
group key. A group is an orchestration layer over the 1:1 sessions that already
exist: every group message travels over a pairwise Double Ratchet that was already
verified, so the group inherits the forward secrecy and the transport of the chats
it is built from rather than introducing a second, weaker path.
### Added
- **Group chats, up to 8 members.** Create one from the 1:1 chats you have already
verified. Unverified chats are not offered: a group built on a session whose
safety code was never compared would inherit that open question and hide it
behind a group code that looks like it settled the matter.
- **A group safety code, compared once by everybody.** Seven digits, the same
length as the pairwise code, derived from every member's group identity key and
a secret nonce each of them contributes. Nothing is sent until the humans
confirm it.
- **Commit-then-reveal, which is what makes seven digits enough.** The obvious
construction — hash the member keys and show the digits — is not safe at a
length people will read aloud. A member who introduces two others controls what
each of them sees and can generate candidate keys until the two truncated hashes
collide; that is a birthday search of roughly 10^(d/2), and seven digits falls
in a few thousand tries. Signal answers this by making the safety number sixty
digits. Here every member instead publishes a hash of a secret nonce first, and
no nonce is revealed until every commitment has arrived — so an attacker must
fix both of their commitments before seeing a single honest nonce and is left
guessing once, at 10⁻⁷. The gate has one implementation and refuses to reveal
early, including on a timeout.
- **Signed membership, ordered by epochs.** Every change of membership is signed
by the group's admin over the resulting member set, and only a strictly greater
epoch is accepted — which refuses both a replay and a rollback to a membership
that used to be valid. Removing a member opens a new epoch, so the group re-keys
and everyone compares a fresh code.
- **Signed messages, so a split transcript is provable.** Messages fan out over
N-1 independent ratchets, so each recipient only learns that the sender's
session sent it. A signature over (group, epoch, sequence, body hash) means a
member who tells two halves of the group different things under one sequence
number produces two valid signatures — non-repudiable evidence rather than
suspicion. The offending message is discarded and named in the transcript.
- **Delivery over a direct link where one exists, relayed where it does not.** A
full mesh needs N(N-1)/2 pairwise connections and at creation only the admin
holds a link to everyone. Rather than block the group, a frame for an
unreachable member is handed to a member who can reach you both. Because every
frame is signed, a relaying member can drop or read a frame — reading is what
membership already entitles them to — but cannot forge, alter or reattribute
one. Relaying is single-hop by construction, and the member strip shows who is
direct and who is not.
- **Partial delivery is reported.** With no server there is nobody to hold a
message for an absent member, so a send that reached only some of the group says
so in the transcript instead of looking like it succeeded.
- **A warning before you create a group without relay-only.** In a mesh every
member connects to you directly and learns your address — including members
somebody else invited. That is a real step down from a 1:1 chat and it is said
before you commit, not after.
### Notes
- The group layer required **no changes to the WebRTC manager**. Group frames ride
`sendMessage`, the same authenticated and ratcheted path chat text takes, and
are lifted out before they can reach a 1:1 transcript.
- Frames travel base64-wrapped. The chat path sanitises what it sends — DOMPurify
escapes `<`, `>` and `&`, control characters are stripped, blank runs collapse,
and the result is cut to 2000 characters — all of which is correct for chat text
and fatal for a signed frame, whose body would no longer match the hash its
signature covers. Base64 has no character that path rewrites. A group message is
bounded at 1024 bytes so a frame always fits underneath the same ceiling.
- There is no message history for a member who joins: a group starts empty, and
without a server there is nothing to backfill it from.
- Group calls are not in this release.
## v5.9.2 — Responsive layout fixed for phones
The chat was not laying out correctly on phones, iPhone worst of all: the header