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
+186
View File
@@ -0,0 +1,186 @@
// Group frames against the transport's real rate limit.
//
// This is the test that was missing when group formation kept dying. The manager
// allows a burst of ten sends per second, but one group frame spends TWO of those
// slots — sendMessage checks the shared limiter and then sendSecureMessage checks
// it again — so the six frames formation sends back to back asked for twelve.
// The overflow was rejected as a plain Error with no code, which reached the user
// as a meaningless "frame_rejected", while the peer that never got the dropped
// frame waited until "ceremony_timed_out".
//
// The stand-in manager below reproduces that accounting exactly. Time is
// injected, so the pacing is asserted rather than waited for.
import assert from 'node:assert/strict';
const { createGroupSender, GROUP_SEND_GAP_MS } = await import('../src/group/groupSender.js');
/** A virtual clock: sleeps advance it instead of blocking. */
function makeClock() {
let t = 0;
return {
now: () => t,
sleep: async (ms) => { t += Math.max(0, ms); },
advance: (ms) => { t += ms; },
};
}
/**
* A manager with the real limiter's shape: ten slots per rolling second, and two
* slots consumed per outbound message.
*/
function makeManager(clock, { burst = 10, slotsPerSend = 2, connected = true } = {}) {
let windowStart = clock.now();
let used = 0;
const sent = [];
return {
sent,
isConnected: () => connected,
async sendMessage(payload) {
if (clock.now() - windowStart >= 1000) { windowStart = clock.now(); used = 0; }
if (used + slotsPerSend > burst) {
// Verbatim from EnhancedSecureWebRTCManager: a plain Error, no code.
throw new Error('Rate limit exceeded for message sending');
}
used += slotsPerSend;
sent.push({ at: clock.now(), payload });
return true;
},
};
}
// ---------------------------------------------------------------------------
// the six frames of group formation all arrive
// ---------------------------------------------------------------------------
{
const clock = makeClock();
const manager = makeManager(clock);
const send = createGroupSender({
getManager: () => manager, now: clock.now, sleep: clock.sleep,
});
// Exactly what forming a group puts on one session.
const frames = ['g_invite', 'g_member', 'g_member', 'g_roster', 'g_commit', 'g_reveal']
.map((type, i) => ({ type, gid: 'a'.repeat(32), seq: i }));
await Promise.all(frames.map((f) => send('s1', f)));
assert.equal(manager.sent.length, 6, 'every formation frame must reach the transport');
// Order is protocol-critical: a commitment has to arrive before the reveal
// that opens it. Concurrent sends must not reorder.
assert.deepEqual(
manager.sent.map((s) => JSON.parse(s.payload).type),
['g_invite', 'g_member', 'g_member', 'g_roster', 'g_commit', 'g_reveal'],
'frames keep the order they were queued in',
);
// And they are spaced, which is what keeps them inside the burst budget.
for (let i = 1; i < manager.sent.length; i++) {
const gap = manager.sent[i].at - manager.sent[i - 1].at;
assert.ok(gap >= GROUP_SEND_GAP_MS,
`frame ${i} came ${gap}ms after the last, under the ${GROUP_SEND_GAP_MS}ms budget`);
}
}
// ---------------------------------------------------------------------------
// without pacing the same run fails — the bug this exists to prevent
// ---------------------------------------------------------------------------
{
const clock = makeClock();
const manager = makeManager(clock);
const send = createGroupSender({
getManager: () => manager, now: clock.now, sleep: clock.sleep,
gapMs: 0, attempts: 1, // pacing and retries disabled
});
const results = await Promise.allSettled(
Array.from({ length: 6 }, (_, i) => send('s1', { type: 'g_commit', seq: i })),
);
const rejected = results.filter((r) => r.status === 'rejected');
assert.ok(rejected.length > 0, 'unpaced, the burst limit really does reject frames');
assert.match(rejected[0].reason.message, /Rate limit exceeded/);
// And the rejection carries no `code`, which is why it surfaced as the
// generic frame_rejected rather than anything a user could act on.
assert.equal(rejected[0].reason.code, undefined);
}
// ---------------------------------------------------------------------------
// a rate-limited frame is retried, not dropped
// ---------------------------------------------------------------------------
{
const clock = makeClock();
let calls = 0;
const manager = {
isConnected: () => true,
async sendMessage() {
calls++;
if (calls <= 2) throw new Error('Rate limit exceeded for secure message sending');
return true;
},
};
const send = createGroupSender({ getManager: () => manager, now: clock.now, sleep: clock.sleep });
await send('s1', { type: 'g_commit' });
assert.equal(calls, 3, 'the frame is retried until it lands — losing one strands every member');
}
// ---------------------------------------------------------------------------
// failures that will not improve are surfaced, not retried
// ---------------------------------------------------------------------------
{
const clock = makeClock();
let calls = 0;
const manager = {
isConnected: () => true,
async sendMessage() { calls++; throw new Error('Data channel not ready'); },
};
const send = createGroupSender({ getManager: () => manager, now: clock.now, sleep: clock.sleep });
await assert.rejects(() => send('s1', { type: 'g_commit' }), /Data channel not ready/);
assert.equal(calls, 1, 'a closed channel is reported at once rather than retried');
// One failure must not wedge the session: later frames still go out.
const ok = { isConnected: () => true, sent: 0, async sendMessage() { this.sent++; return true; } };
const send2 = createGroupSender({
getManager: (id) => (id === 'dead' ? manager : ok), now: clock.now, sleep: clock.sleep,
});
await assert.rejects(() => send2('dead', { type: 'g_commit' }));
await send2('dead', { type: 'g_reveal' }).catch(() => {});
await send2('live', { type: 'g_reveal' });
assert.equal(ok.sent, 1, 'a rejected frame does not block the queue behind it');
}
// ---------------------------------------------------------------------------
// a link that is gone is refused before anything is queued
// ---------------------------------------------------------------------------
{
const clock = makeClock();
const send = createGroupSender({ getManager: () => null, now: clock.now, sleep: clock.sleep });
await assert.rejects(() => send('s1', { type: 'g_commit' }), /no such link/);
const offline = createGroupSender({
getManager: () => ({ isConnected: () => false, sendMessage: async () => true }),
now: clock.now, sleep: clock.sleep,
});
await assert.rejects(() => offline('s1', { type: 'g_commit' }), /link is down/);
}
// ---------------------------------------------------------------------------
// separate sessions do not queue behind each other
// ---------------------------------------------------------------------------
{
const clock = makeClock();
const a = makeManager(clock);
const b = makeManager(clock);
const send = createGroupSender({
getManager: (id) => (id === 'a' ? a : b), now: clock.now, sleep: clock.sleep,
});
await Promise.all([send('a', { type: 'g_commit' }), send('b', { type: 'g_commit' })]);
assert.equal(a.sent.length, 1);
assert.equal(b.sent.length, 1);
assert.equal(a.sent[0].at, b.sent[0].at, 'each session paces independently');
}
console.log('group-sender.test.mjs: all assertions passed');