feat(groups): group chats, and a mesh rather than a star; release v6.1.1
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:
@@ -0,0 +1,103 @@
|
||||
// Paced, serialised delivery of group frames over pairwise sessions.
|
||||
//
|
||||
// WHY THIS EXISTS
|
||||
// ---------------
|
||||
// A group frame rides `EnhancedSecureWebRTCManager.sendMessage`, which is the
|
||||
// right call — it inherits the session's encryption, ratchet, replay protection
|
||||
// and verification gate without adding a second path through the transport. But
|
||||
// that path is rate limited, and the accounting is not what it looks like:
|
||||
// `sendMessage` checks the limiter and then hands off to `sendSecureMessage`,
|
||||
// which checks the SAME shared counter again. One frame therefore spends two of
|
||||
// the ten burst slots available per second.
|
||||
//
|
||||
// Forming a group sends six frames back to back on one session — invite, two
|
||||
// member keys, roster, commit, reveal — which asks for twelve slots out of ten.
|
||||
// The overflow was rejected, and rejected as a plain `Error` with no code, so it
|
||||
// surfaced to the user as a meaningless `frame_rejected`; the peer that never
|
||||
// received the dropped frame simply waited until the ceremony timed out. Two
|
||||
// different symptoms, one cause.
|
||||
//
|
||||
// The fix belongs here rather than in the limiter. Widening the burst allowance
|
||||
// would loosen a control that exists for the 1:1 chat, to suit a caller that can
|
||||
// perfectly well wait: five frames a second makes group formation take about a
|
||||
// second and a half, which nobody notices.
|
||||
//
|
||||
// Sends are also SERIALISED per session. The protocol is order-dependent — a
|
||||
// commitment must reach a peer before the reveal that opens it — and firing
|
||||
// several `sendMessage` calls concurrently at one channel puts that ordering at
|
||||
// the mercy of the manager's internal mutex.
|
||||
//
|
||||
// Time is injected so the pacing can be tested without waiting for it.
|
||||
|
||||
/**
|
||||
* Minimum gap between two group frames on one session, in milliseconds.
|
||||
* Two limiter slots per frame against a ten-per-second burst means five frames
|
||||
* per second is the real budget; 260ms leaves a little headroom.
|
||||
*/
|
||||
export const GROUP_SEND_GAP_MS = 260;
|
||||
|
||||
/** How many times a rate-limited frame is retried before giving up. */
|
||||
export const GROUP_SEND_ATTEMPTS = 4;
|
||||
|
||||
const isRateLimit = (error) => /rate limit/i.test(error?.message || '');
|
||||
|
||||
/**
|
||||
* Build the `send` function a GroupSession is constructed with.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {(sessionId: string) => object|null} opts.getManager resolve a live manager
|
||||
* @param {number} [opts.gapMs]
|
||||
* @param {number} [opts.attempts]
|
||||
* @param {() => number} [opts.now]
|
||||
* @param {(ms: number) => Promise<void>} [opts.sleep]
|
||||
*/
|
||||
export function createGroupSender({
|
||||
getManager,
|
||||
gapMs = GROUP_SEND_GAP_MS,
|
||||
attempts = GROUP_SEND_ATTEMPTS,
|
||||
now = () => Date.now(),
|
||||
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
||||
} = {}) {
|
||||
/** sessionId -> { chain, lastAt } */
|
||||
const queues = new Map();
|
||||
|
||||
return async function sendGroupFrame(sessionId, frame) {
|
||||
const manager = getManager(sessionId);
|
||||
if (!manager || typeof manager.sendMessage !== 'function') throw new Error('no such link');
|
||||
if (typeof manager.isConnected === 'function' && !manager.isConnected()) {
|
||||
throw new Error('link is down');
|
||||
}
|
||||
|
||||
const queue = queues.get(sessionId) || { chain: Promise.resolve(), lastAt: 0 };
|
||||
const payload = JSON.stringify(frame);
|
||||
|
||||
const run = queue.chain.then(async () => {
|
||||
const wait = gapMs - (now() - queue.lastAt);
|
||||
if (wait > 0) await sleep(wait);
|
||||
|
||||
let lastError = null;
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
try {
|
||||
await manager.sendMessage(payload);
|
||||
queue.lastAt = now();
|
||||
return true;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
// Only a rate-limit rejection is worth retrying. A closed
|
||||
// channel or a refused verification gate will not improve by
|
||||
// being asked again, and retrying would just delay the error
|
||||
// the caller needs to see.
|
||||
if (!isRateLimit(error)) throw error;
|
||||
await sleep(gapMs * (attempt + 1));
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
});
|
||||
|
||||
// The chain has to survive a failure. Leaving a rejected promise in it
|
||||
// would wedge every later frame on that session behind the first error.
|
||||
queue.chain = run.catch(() => {});
|
||||
queues.set(sessionId, queue);
|
||||
return run;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user