// GroupSession — the orchestrator that turns N pairwise sessions into a group. // // WHAT THIS IS AND IS NOT // ----------------------- // It owns no transport. Every byte it sends leaves through an existing // EnhancedSecureWebRTCManager, over a channel that is already SAS-verified and // already ratcheted, and arrives having been authenticated by that session. This // class adds the group layer on top: who is a member, what epoch it is, what // code the humans compare, and which of the N-1 links a given frame should take. // // It holds the non-serializable half of a group — the ECDSA identity key pair, // the running commit/reveal ceremony, the imported verifying keys — which is // exactly why it lives in a ref-held Map in the app and never inside the // reducer. See src/state/groupsStore.js. // // DELIVERY: DIRECT WHERE POSSIBLE, RELAYED WHERE NOT // -------------------------------------------------- // A full mesh needs N(N-1)/2 pairwise links, and at the moment a group is // created only the admin holds a link to everyone. Rather than block the group // until every pair is introduced, a frame for a member we cannot reach directly // is handed to a member who can reach both of us. // // That is the STARTING state, not the resting one. Once the group is ready and // the safety code has been confirmed, every pair that has no link between it // builds one — see "the mesh" below — and the relay becomes the exception this // paragraph always described rather than the way the whole group runs. Until // then, and for any pair that cannot connect directly, the relay carries it. // // This is safe because it is not a trust decision. Group messages and membership // operations are signed with the sender's group identity key, and every member // holds every other member's verifying key from the signed roster. A relaying // member can therefore drop a frame or read a frame — they are a member of the // group, so reading it is what membership already entitles them to — but they // cannot forge one, alter one, or attribute one to somebody else. What relaying // costs is metadata (the relay learns who is talking to the group and when) and // availability (a relay that goes away takes the indirect paths with it), which // is why a direct link is always preferred and the UI shows how many exist. // // Relaying is single-hop by construction. A frame carries `to`; a member that is // not the addressee forwards it once, marked, and a marked frame is never // forwarded again. There is no routing table to poison and no loop to form. // // ORDER OF OPERATIONS // ------------------- // 1. invite admin sends the group's name and its own identity key // 2. hello each invitee replies with its identity key // 3. roster admin signs the full member set for this epoch and broadcasts it // 4. commit every member commits to a secret nonce // 5. reveal ONLY once every commitment has arrived, nonces are published // 6. code every member computes the same digits and the humans compare // 7. ready group traffic flows // 8. mesh every pair without a link dials one, over the relay path // // Step 8 is the only one that can fail without the group noticing, and that is // deliberate: a pair that cannot connect directly keeps working exactly as it // did in step 7. // // Steps 4 and 5 are what make a seven-digit code safe; the reasoning is in // groupCrypto.js and the ordering is enforced by GroupSasCeremony, not here. import { GROUP_LIMITS, MEMBER_OPS, MESH_KINDS, GroupSasCeremony, generateGroupIdentity, importMemberIdentity, signMemberOp, verifyMemberOp, hashBody, signGroupMessage, verifyGroupMessage, computeGroupSas, signMeshDescriptor, verifyMeshDescriptor, signLinkProbe, verifyLinkProbe, CALL_ACTIONS, newCallId, assertCallId, signGroupCall, verifyGroupCall, randomBytes, canonicalFingerprints, assertGroupId, assertFingerprint, assertEpoch, assertName, newGroupId, toB64, fromB64, } from './groupCrypto.js'; import { GROUP_PHASE, MEMBER_STATE } from '../state/groupsStore.js'; /** Wire frame types. All group traffic rides the ordinary chat message path. */ export const GROUP_FRAMES = Object.freeze({ INVITE: 'g_invite', HELLO: 'g_hello', MEMBER: 'g_member', ROSTER: 'g_roster', COMMIT: 'g_commit', REVEAL: 'g_reveal', MESSAGE: 'g_msg', RELAY: 'g_relay', LEAVE: 'g_leave', // Mesh link establishment. These carry SBQ2 descriptors between two members // who have no link yet, over the relay path of a member who can reach both. MESH_OFFER: 'g_moffer', MESH_ANSWER: 'g_manswer', MESH_ABORT: 'g_mabort', // "The pairwise chat this arrived on is me, member ." PROBE: 'g_probe', // Call control: who opened a call, who is in it, who has left. Media never // travels here — see the call section below. CALL: 'g_call', }); /** The outer wrapper every group frame travels inside. See encodeEnvelope. */ export const GROUP_ENVELOPE = 'g_env'; /** Every type this module will act on. Anything else is dropped by default. */ export const GROUP_FRAME_TYPES = Object.freeze(new Set(Object.values(GROUP_FRAMES))); export function isGroupFrame(parsed) { if (!parsed || typeof parsed !== 'object') return false; return parsed.type === GROUP_ENVELOPE || GROUP_FRAME_TYPES.has(parsed.type); } /** * The inner type of a frame, without decoding it. * * Callers outside this module need it for exactly one decision — whether an * arriving frame is an invitation to a group they do not have yet — and that * decision has to be made before any group state exists to decode with. It is a * routing hint only; decodeEnvelope re-checks it against the frame it wraps. */ export function groupFrameType(parsed) { if (!parsed || typeof parsed !== 'object') return null; if (parsed.type === GROUP_ENVELOPE) return typeof parsed.t === 'string' ? parsed.t : null; return GROUP_FRAME_TYPES.has(parsed.type) ? parsed.type : null; } /** * Wrap a frame so the pairwise chat path cannot alter it. * * Group frames ride sendMessage, which sanitises its payload before encrypting: * DOMPurify escapes `<`, `>` and `&`, control characters are stripped, runs of * blank lines are collapsed, the string is trimmed, and the result is cut to * 2000 characters. Every one of those is correct for chat text and fatal for a * signed frame — a body that came back HTML-escaped no longer matches the hash * its signature covers, and the message would be rejected as forged. * * Base64 sidesteps all of it: its alphabet contains nothing DOMPurify rewrites * and nothing the sanitiser strips, and it has no whitespace to trim. The group * id and the inner type stay outside the encoding so a frame can be routed — * and an invitation recognised — without decoding anything first. Neither * reveals more than the peer on that link already knows. */ export function encodeEnvelope(frame) { const json = JSON.stringify(frame); const encoded = toB64(new TextEncoder().encode(json)); if (encoded.length > GROUP_LIMITS.FRAME_BUDGET_CHARS) { throw new GroupSessionError('group frame exceeds the transport budget', 'frame_too_large'); } return { type: GROUP_ENVELOPE, gid: frame.gid, t: frame.type, d: encoded }; } export function decodeEnvelope(envelope) { if (!envelope || envelope.type !== GROUP_ENVELOPE) return envelope; const raw = String(envelope.d || ''); if (raw.length > GROUP_LIMITS.FRAME_BUDGET_CHARS) { throw new GroupSessionError('group frame exceeds the transport budget', 'frame_too_large'); } const bytes = fromB64(raw, { max: GROUP_LIMITS.FRAME_BUDGET_CHARS }); const frame = JSON.parse(new TextDecoder().decode(bytes)); if (!frame || typeof frame !== 'object' || !GROUP_FRAME_TYPES.has(frame.type)) { throw new GroupSessionError('envelope carried no recognisable frame', 'bad_envelope'); } // The routing hints outside the encoding are conveniences, not authority: // if they disagree with the frame they wrap, the frame was tampered with. if (envelope.gid && frame.gid !== envelope.gid) { throw new GroupSessionError('envelope group id does not match its frame', 'bad_envelope'); } if (envelope.t && frame.type !== envelope.t) { throw new GroupSessionError('envelope type does not match its frame', 'bad_envelope'); } return frame; } const TIMEOUTS = Object.freeze({ // A member that has not committed by now is treated as absent and the // ceremony fails. It must FAIL rather than proceed: proceeding without a // commitment is exactly the grinding freedom the commit round removes. CEREMONY_MS: 60_000, // How long the admin waits for invitees to publish their identity keys. HELLO_MS: 45_000, // How long one mesh dial may stay in flight. It covers a descriptor going // out over a relay hop, an answer coming back, and the whole SBQ2 in-band // exchange completing on the new channel. Generous, because failing early // costs a direct link and gains nothing: the pair keeps working over the // relay the entire time the dial is running. MESH_DIAL_MS: 45_000, // Gap before a failed pair is dialled again, doubling per failure. A pair // that cannot connect is usually a network that will not allow it, and // retrying hard turns one unreachable member into a permanent load. MESH_RETRY_MS: 20_000, }); /** How many mesh dials one member will have in flight at once. */ const MESH_MAX_CONCURRENT_DIALS = 2; /** After this many consecutive failures a pair is left on the relay for good. */ const MESH_MAX_ATTEMPTS = 3; export class GroupSessionError extends Error { constructor(message, code = 'group_session') { super(message); this.name = 'GroupSessionError'; this.code = code; } } export class GroupSession { /** * @param {object} opts * @param {string} opts.groupId * @param {string} opts.name * @param {boolean} opts.isAdmin * @param {SubtleCrypto} opts.subtle * @param {(sessionId: string, frame: object) => Promise} opts.send * @param {(event: string, payload: object) => void} opts.emit */ constructor({ groupId, name, isAdmin, subtle, send, emit, mesh = null, log = () => {} }) { this.groupId = assertGroupId(groupId); this.name = assertName(name); this.isAdmin = !!isAdmin; this.subtle = subtle; this._send = send; this._emit = emit; this._log = log; /** * How this group builds a transport it does not have. * * Injected rather than imported, for the same reason SubtleCrypto is: * this class stays free of WebRTC, of the app's manager registry, and of * anything that needs a browser to exist. A group constructed without it * runs exactly as it did before — every indirect pair simply stays on * the relay path. * * createOffer(fp) -> { sessionId, descriptor } * createAnswer(fp, descriptor) -> { sessionId, descriptor } * acceptAnswer(sessionId, descriptor) -> void * close(sessionId) -> void * linkFingerprint(sessionId) -> string (pairwise session's key fp) */ this._mesh = mesh; this.identity = null; // { keyPair, spki, fingerprint } this.epoch = 1; this.adminFp = ''; this.phase = GROUP_PHASE.FORMING; /** fp -> { fp, name, spki, publicKey, sessionId, state } */ this.members = new Map(); /** sessionId -> fp, so an inbound frame can be attributed to a member. */ this.sessionToFp = new Map(); this.ceremony = null; this.sasCode = ''; this.sasConfirmed = false; this.seq = 0; /** * Sender fingerprint -> Map(seq -> body hash). Does double duty: it * absorbs the duplicates that fan-out and relay inevitably produce, and * it is what catches a member sending two different bodies under one * sequence number. See _onMessage for why one structure has to do both. */ this.transcript = new Map(); this._timers = new Set(); this._destroyed = false; /** Invitees the admin is still waiting on, by sessionId. */ this._awaitingHello = new Map(); /** * Commit and reveal frames that arrived before our own ceremony existed. * * Members start their ceremony when the roster reaches them, and the * roster does not reach everyone at the same instant — a member on a fast * link routinely broadcasts its commitment before a member on a slow one * has even adopted the roster. Dropping those frames deadlocks the round * for everyone, because the commit set never completes and the reveal * gate never opens. They are held here and drained when the ceremony * starts. Bounded, because the sender of these frames chooses how many to * send. */ this._pendingCeremony = []; this._draining = false; /** * Member identity keys that arrived ahead of the roster that names them. * * A key frame on its own is an unverified claim, so nothing is applied * from here until the admin's signed roster says which fingerprints are * actually members. Bounded for the same reason as _pendingCeremony. */ this._pendingKeys = new Map(); /** * An in-flight "add members" round: which operation it is, the epoch it * will open, and who was already in the group when it started. Held so a * round that nobody answers can be abandoned without having touched the * epoch or the member set — the group carries on exactly as it was. */ this._pendingAdd = null; /** * Mesh dials in flight, by peer fingerprint. * * fp -> { role, sessionId, nonce, epoch, timer }. One entry per PAIR, * never per direction: the dialling rule below means only one side of a * pair ever opens a dial, so a second entry for the same peer is a * duplicate to be refused rather than a second attempt to run. */ this._meshDials = new Map(); /** fp -> { attempts, nextAt }. Backoff for pairs that will not connect. */ this._meshFailures = new Map(); /** Sessions this group built itself, so destroy() can close them. */ this._meshSessions = new Set(); /** Sessions a probe has already been sent on, so it is sent once. */ this._probed = new Set(); /** The coalescing timer for _meshMaintain, or null when none is armed. */ this._meshPass = null; /** * The call this group is currently holding, or null. * * { callId, startedBy, withVideo, startedAt, participants: Set, joined } * * `joined` is about US specifically: a call can be running with three * people in it while we have not picked up, and the difference decides * whether this device is capturing a microphone. It is never inferred * from the participant set, because a member could otherwise put us in a * call by naming us in one. */ this.call = null; /** Our own counter over call frames. Monotonic; never reset within an epoch. */ this.callSeq = 0; /** fp -> highest call sequence seen, so a captured frame cannot be replayed. */ this._callSeen = new Map(); } // ----------------------------------------------------------------------- // lifecycle // ----------------------------------------------------------------------- static newId() { return newGroupId(); } async init() { if (this.identity) return this.identity; this.identity = await generateGroupIdentity(this.subtle); this.members.set(this.identity.fingerprint, { fp: this.identity.fingerprint, name: 'You', spki: this.identity.spki, publicKey: null, // our own key verifies nothing inbound sessionId: null, state: MEMBER_STATE.SELF, }); if (this.isAdmin) this.adminFp = this.identity.fingerprint; return this.identity; } get selfFp() { return this.identity?.fingerprint || ''; } destroy() { this._destroyed = true; for (const t of this._timers) clearTimeout(t); this._timers.clear(); try { this.ceremony?.destroy(); } catch (_) {} this.ceremony = null; // Connections this group opened are this group's to close. They exist // nowhere in the app's chat list, so nothing else would ever reap them // and a torn-down group would leave live peer connections behind. for (const sessionId of this._meshSessions) { try { this._mesh?.close(sessionId); } catch (_) {} } this._meshSessions.clear(); this._meshDials.clear(); this._meshFailures.clear(); this._probed.clear(); this.call = null; this._callSeen.clear(); this.members.clear(); this.sessionToFp.clear(); this.transcript.clear(); this._pendingCeremony = []; this._pendingKeys.clear(); this._pendingAdd = null; this.identity = null; } _timer(fn, ms) { const t = setTimeout(() => { this._timers.delete(t); if (!this._destroyed) fn(); }, ms); this._timers.add(t); return t; } _fail(code) { if (this._destroyed) return; this._log('warn', 'group ceremony failed', { code }); this.phase = GROUP_PHASE.FAILED; try { this.ceremony?.destroy(); } catch (_) {} this.ceremony = null; this._emit('error', { error: code }); } _setPhase(phase) { if (this.phase === phase) return; this.phase = phase; // Any phase other than READY means nothing is currently confirmed. if (phase !== GROUP_PHASE.READY) this.sasConfirmed = false; // The code itself only survives from the moment it is computed // (AWAITING_SAS) until the group leaves READY. Clearing it on the way // INTO AWAITING_SAS would erase the digits _maybeFinish just derived. if (phase !== GROUP_PHASE.READY && phase !== GROUP_PHASE.AWAITING_SAS) { this.sasCode = ''; } this._emit('phase', { phase }); } /** The member list in the shape the reducer stores. */ _memberSnapshot() { return [...this.members.values()].map((m) => ({ fp: m.fp, name: m.name, sessionId: m.sessionId, state: m.state, })); } _emitMembers() { this._pruneCall(); this._emit('members', { members: this._memberSnapshot(), epoch: this.epoch }); } /** * Drop anyone from the current call who is no longer a member. * * Membership can change under a call — the admin removes somebody, a roster * for a new epoch arrives — and a participant list that outlives the roster * would show a person in the call who is not in the group, which is exactly * the kind of stale claim a group must not make about who can hear it. */ _pruneCall() { if (!this.call) return; let changed = false; for (const fp of [...this.call.participants]) { if (!this.members.has(fp)) { this.call.participants.delete(fp); changed = true; } } if (this.call.participants.size === 0) { this.call = null; changed = true; } if (changed) this._emit('call', { call: this.getCallSnapshot() }); } // ----------------------------------------------------------------------- // routing // ----------------------------------------------------------------------- /** Members we can reach over their own pairwise link right now. */ _directPeers() { return [...this.members.values()].filter( (m) => m.state === MEMBER_STATE.LINKED && m.sessionId, ); } /** * Whoever can carry a frame to a member we cannot reach ourselves. * * The admin is preferred because by construction it holds a link to every * member; any other directly-linked member is a fallback for when the admin * is the one that has gone away. */ _relayFor(toFp) { const admin = this.members.get(this.adminFp); if (admin && admin.state === MEMBER_STATE.LINKED && admin.sessionId && admin.fp !== toFp) { return admin; } return this._directPeers().find((m) => m.fp !== toFp) || null; } /** * Put a frame on the wire, wrapped. * * Every outbound frame goes through here so the envelope is applied in * exactly one place — a frame sent raw would be silently mangled by the * chat path's sanitiser rather than rejected, which is the worst way for * this to fail. */ async _wire(sessionId, frame) { return this._send(sessionId, encodeEnvelope(frame)); } /** Send one frame to one member, directly if we can and relayed if we cannot. */ async _sendTo(toFp, frame) { const member = this.members.get(toFp); if (!member || member.fp === this.selfFp) return false; if (member.state === MEMBER_STATE.LINKED && member.sessionId) { await this._wire(member.sessionId, frame); return true; } const relay = this._relayFor(toFp); if (!relay) return false; await this._wire(relay.sessionId, { type: GROUP_FRAMES.RELAY, gid: this.groupId, to: toFp, hopped: false, inner: frame, }); // A relay hop is unacknowledged — we hand the frame to a member who may // or may not be able to reach the target, and nothing comes back either // way. For a member we have never held a link to, that is simply the // normal path and the best that can be said. For one whose link we LOST, // it is a guess: reporting it as delivered would tell the sender their // message arrived when there is no reason to believe it did. The frame // still goes — the target may be reachable from elsewhere in the mesh — // it just does not count toward the delivery the sender is shown. return member.state !== MEMBER_STATE.LOST; } /** * Fan a frame out to every other member. Failures are per-recipient. * * Returns WHO could not be reached as well as how many could, because a * count on its own cannot tell "Alice is offline" from "Bob is offline" — * and the sender is the only person in a position to know the difference. */ async _broadcast(frame, { exclude = [] } = {}) { const targets = [...this.members.keys()].filter( (fp) => fp !== this.selfFp && !exclude.includes(fp), ); const results = await Promise.allSettled(targets.map((fp) => this._sendTo(fp, frame))); const unreachable = []; let delivered = 0; results.forEach((result, i) => { if (result.status === 'fulfilled' && result.value === true) { delivered += 1; return; } const member = this.members.get(targets[i]); unreachable.push({ fp: targets[i], name: member?.name || 'A member' }); }); return { delivered, unreachable }; } // ----------------------------------------------------------------------- // link bookkeeping (driven by the app as pairwise sessions come and go) // ----------------------------------------------------------------------- /** * Bind a pairwise session to a member. Called for the admin's own invites and * whenever a member is reached over a session for the first time. */ bindSession(fp, sessionId, state = MEMBER_STATE.LINKED) { const member = this.members.get(fp); if (!member) return false; // A member never holds two links at once. Rebinding to a new session — // a mesh dial that succeeded where an old link had dropped, or a chat // the user rebuilt by hand — has to drop the stale mapping, or a frame // arriving on the dead session id would still be attributed to them. if (member.sessionId && member.sessionId !== sessionId) { this.sessionToFp.delete(member.sessionId); this._closeMeshSession(member.sessionId); } member.sessionId = sessionId; member.state = state; if (sessionId) this.sessionToFp.set(sessionId, fp); this._emitMembers(); return true; } /** * Detach a member from whatever link it was on, back to the relay path. * * Used when a mesh dial fails: the half-built session is closed and the * member returns to being reachable only through someone else, which is * where they were before the dial started. Deliberately does NOT change the * member's state to LOST — they are not offline, we just have no direct * route to them. */ unbindSession(fp) { const member = this.members.get(fp); if (!member || !member.sessionId) return false; const sessionId = member.sessionId; this.sessionToFp.delete(sessionId); member.sessionId = null; if (member.state === MEMBER_STATE.LINKED || member.state === MEMBER_STATE.LOST) { member.state = MEMBER_STATE.PENDING; } this._closeMeshSession(sessionId); this._emitMembers(); // The member has no route of their own now, so the mesh should look at // building one. This is what makes a link dying recoverable rather than // permanent: the pair goes back to the relay and dials again. this._scheduleMeshMaintain(); return true; } /** A pairwise session changed state; reflect it on whichever member owns it. */ setSessionState(sessionId, connected) { const fp = this.sessionToFp.get(sessionId); if (!fp) return; const member = this.members.get(fp); if (!member || member.state === MEMBER_STATE.SELF) return; const next = connected ? MEMBER_STATE.LINKED : MEMBER_STATE.LOST; if (member.state === next) return; member.state = next; this._emitMembers(); // A dial that reached LINKED is finished; stop its timer before it fires // and tears down the very link it was guarding. A link that DROPPED is // also settled — the dial is over either way — but the failure counter // is left alone, because a link that worked and then died says nothing // about whether the pair can connect. if (connected) this._settleDial(fp); // Either edge changes the picture: a member who came up may be the relay // some other pair was waiting for, and a member who went down may have // been the only route to somebody. this._scheduleMeshMaintain(); } // ----------------------------------------------------------------------- // the mesh // ----------------------------------------------------------------------- // // Every pair that has no link between it dials one, so that the relay path // becomes the exception it was always described as rather than the way the // whole group runs. // // WHO DIALS // --------- // The member with the smaller fingerprint. That is the entire glare // protocol: both sides compute it from the roster they already agree on, so // exactly one side opens each pair and there is no simultaneous-offer case // to resolve. A member that receives an offer from someone it should have // been dialling ITSELF refuses it — either the peer is confused or somebody // is trying to get two half-open dials fighting over one pair. // // WHEN // ---- // Only once the group is READY and the safety code is confirmed. Before // that, the roster's identity keys are keys nobody has vouched for yet, and // a link authenticated by an unconfirmed key is a link authenticated by // nothing. Waiting costs a few seconds of relayed traffic. // // HOW IT IS AUTHENTICATED // ----------------------- // The descriptors travel through a relay, so they are signed with the // sender's group identity key and checked against the roster — see // meshDescriptorPayload in groupCrypto.js for why that is enough and what // it deliberately does not defend against. Once the transport is up, the // SBQ2 in-band exchange runs on it exactly as it does for a 1:1 chat, with // one difference: nobody is asked to compare digits, because the group code // already authenticated the key that signed the descriptor. The app closes // that loop by marking the link verified on the group's authority. /** * Is this dial still worth finishing? * * Re-checked after EVERY await inside a dial, and that is not defensive * padding — both awaits are long. Building a descriptor means gathering ICE, * and verifying a signature is a trip through WebCrypto; either is ample time * for the answer to change. * * The case that made this necessary: a link probe adopts a chat the two * members already had while a dial for the same pair is mid-flight. The * probe binds a link that works. The dial then came back and bound its own * half-built session over the top, downgrading a live link to a pending one * and leaving the pair relaying to each other over a connection that was * never needed. Checking the member's session — not just our own dial * bookkeeping — is what catches that. */ _dialStillWanted(fp, dial) { if (this._destroyed) return false; if (this._meshDials.get(fp) !== dial) return false; if (this.epoch !== dial.epoch) return false; const member = this.members.get(fp); if (!member) return false; if (member.sessionId && member.sessionId !== dial.sessionId) return false; return true; } /** Drop a dial that is no longer wanted, without recording it as a failure. */ _abandonDial(fp, dial, sessionId) { if (this._meshDials.get(fp) === dial) { if (dial.timer) { clearTimeout(dial.timer); this._timers.delete(dial.timer); } this._meshDials.delete(fp); } this._meshSessions.delete(sessionId); try { this._mesh?.close(sessionId); } catch (_) {} } /** Close and forget a connection this group opened. Never touches a chat. */ _closeMeshSession(sessionId) { if (!sessionId || !this._meshSessions.has(sessionId)) return; this._meshSessions.delete(sessionId); try { this._mesh?.close(sessionId); } catch (_) {} } /** A dial is over, one way or another. Stops its timer and frees the slot. */ _settleDial(fp) { const dial = this._meshDials.get(fp); if (!dial) return; if (dial.timer) { clearTimeout(dial.timer); this._timers.delete(dial.timer); } this._meshDials.delete(fp); this._meshFailures.delete(fp); } /** * Give up on one pair, for now. * * The half-built connection is closed and the member goes back to being * reached through somebody else — which is where they were before the dial * started, so nothing the user can see gets worse. The backoff doubles per * attempt because a pair that cannot connect is usually a network that will * not allow it, and hammering at that produces load rather than links. */ _meshFail(fp, code, { tellPeer = true } = {}) { const dial = this._meshDials.get(fp); if (dial) { if (dial.timer) { clearTimeout(dial.timer); this._timers.delete(dial.timer); } this._meshDials.delete(fp); const member = this.members.get(fp); // Only unbind if the member is still on THIS dial's session. If a // real link arrived in the meantime, it is not ours to tear down. if (dial.sessionId && member && member.sessionId === dial.sessionId) { this.unbindSession(fp); } else { this._closeMeshSession(dial.sessionId); } } const failure = this._meshFailures.get(fp) || { attempts: 0, nextAt: 0 }; failure.attempts += 1; failure.nextAt = Date.now() + TIMEOUTS.MESH_RETRY_MS * 2 ** (failure.attempts - 1); this._meshFailures.set(fp, failure); this._log('warn', 'mesh dial failed', { code, attempts: failure.attempts }); // Tell the peer so their half of the dial does not sit until it times // out. Best effort by definition — if we could reach them reliably we // would not be failing. if (tellPeer && this.members.has(fp)) { this._sendTo(fp, { type: GROUP_FRAMES.MESH_ABORT, gid: this.groupId, epoch: this.epoch, from: this.selfFp, to: fp, }).catch(() => {}); } this._scheduleMeshMaintain(); } /** * Cancel every dial in flight and forget every backoff. * * Called when the epoch moves. A dial signed against the old epoch will not * verify against the new one, and a pair that could not connect under the * old membership deserves a fresh chance under the new one. Links that are * already up are untouched — _onRoster carries them across. */ _meshReset() { for (const fp of [...this._meshDials.keys()]) { const dial = this._meshDials.get(fp); if (dial?.timer) { clearTimeout(dial.timer); this._timers.delete(dial.timer); } this._meshDials.delete(fp); const member = this.members.get(fp); if (dial?.sessionId && member && member.sessionId === dial.sessionId) { this.unbindSession(fp); } else { this._closeMeshSession(dial?.sessionId); } } this._meshFailures.clear(); this._probed.clear(); } /** * Ask for a maintenance pass soon rather than now. * * Every edge that could change the answer calls this — a link coming up, a * dial failing, the code being confirmed — and several of them fire in a * burst. Coalescing them means one pass sees the settled picture instead of * several passes each acting on a half-updated one. */ _scheduleMeshMaintain() { if (this._destroyed || !this._mesh || this._meshPass) return; this._meshPass = this._timer(() => { this._meshPass = null; this._meshMaintain(); }, 0); } /** Open dials for whoever still has no link, within the concurrency limit. */ _meshMaintain() { if (this._destroyed || !this._mesh) return; if (this.phase !== GROUP_PHASE.READY || !this.sasConfirmed) return; const now = Date.now(); let inFlight = this._meshDials.size; let soonest = Infinity; // Fingerprint order, so every member walks the same list and the load of // being dialled is spread the same way everywhere. for (const fp of canonicalFingerprints([...this.members.keys()])) { if (inFlight >= MESH_MAX_CONCURRENT_DIALS) break; const member = this.members.get(fp); if (!member || member.state === MEMBER_STATE.SELF) continue; if (member.sessionId) continue; // already on a link, or one is being built if (this._meshDials.has(fp)) continue; if (!(this.selfFp < fp)) continue; // their turn to dial, not ours const failure = this._meshFailures.get(fp); if (failure) { if (failure.attempts >= MESH_MAX_ATTEMPTS) continue; if (now < failure.nextAt) { soonest = Math.min(soonest, failure.nextAt); continue; } } // Nothing can carry the offer, so there is no dial to make. When a // relay appears, that link coming up schedules another pass. if (!this._relayFor(fp)) continue; inFlight += 1; this._meshDial(fp).catch(() => {}); } // Re-arm for the earliest pair whose backoff has not expired yet. if (soonest !== Infinity && !this._meshPass) { this._meshPass = this._timer(() => { this._meshPass = null; this._meshMaintain(); }, Math.max(0, soonest - now) + 50); } } /** Build a descriptor for one peer, sign it, and put it on the relay path. */ async _meshDial(fp) { const member = this.members.get(fp); if (!member || member.sessionId || this._meshDials.has(fp)) return; const epoch = this.epoch; const nonce = randomBytes(GROUP_LIMITS.MESH_NONCE_BYTES); // The slot is reserved BEFORE the first await. Creating an offer means // gathering ICE, which takes long enough for a second maintenance pass // to run and start a duplicate dial for the same peer. const dial = { role: 'offer', sessionId: null, nonce, epoch, timer: null }; this._meshDials.set(fp, dial); try { const link = await this._mesh.createOffer(fp); const sessionId = link && link.sessionId; const descriptor = String((link && link.descriptor) || ''); if (!sessionId || !descriptor) throw new GroupSessionError('mesh transport produced no descriptor', 'no_descriptor'); // The world may have moved while ICE was gathering. if (!this._dialStillWanted(fp, dial)) return this._abandonDial(fp, dial, sessionId); dial.sessionId = sessionId; this._meshSessions.add(sessionId); // Bound now, as PENDING: it makes this member's link state // addressable the moment the transport reports in, and PENDING keeps // every frame on the relay path until it actually comes up. this.bindSession(fp, sessionId, MEMBER_STATE.PENDING); const sig = await signMeshDescriptor(this.subtle, this.identity.keyPair.privateKey, { groupId: this.groupId, epoch, kind: MESH_KINDS.OFFER, fromFp: this.selfFp, toFp: fp, descriptor, nonce, }); const sent = await this._sendTo(fp, { type: GROUP_FRAMES.MESH_OFFER, gid: this.groupId, epoch, from: this.selfFp, to: fp, d: descriptor, n: toB64(nonce), sig: toB64(sig), }); if (!sent) throw new GroupSessionError('no route to carry the dial', 'unreachable'); dial.timer = this._timer(() => this._meshFail(fp, 'dial_timeout'), TIMEOUTS.MESH_DIAL_MS); } catch (error) { this._meshFail(fp, error?.code || 'dial_failed'); } } async _onMeshOffer(frame) { if (!this._mesh || this._destroyed) return; const from = assertFingerprint(String(frame.from || '')); if (assertFingerprint(String(frame.to || '')) !== this.selfFp) return; if (assertEpoch(frame.epoch) !== this.epoch) return; if (this.phase !== GROUP_PHASE.READY || !this.sasConfirmed) return; const member = this.members.get(from); if (!member || !member.publicKey) { throw new GroupSessionError('mesh dial from a non-member', 'not_a_member'); } if (member.sessionId) return; // already reachable directly if (this._meshDials.has(from)) return; // one dial per pair // We are the smaller fingerprint, so dialling this pair is OUR job. An // offer arriving the wrong way round is refused rather than answered. if (this.selfFp < from) return; const descriptor = String(frame.d || ''); if (!descriptor || descriptor.length > GROUP_LIMITS.MAX_DESCRIPTOR_CHARS) { throw new GroupSessionError('mesh descriptor is missing or oversized', 'bad_descriptor'); } const nonce = fromB64(String(frame.n || ''), { max: GROUP_LIMITS.MESH_NONCE_BYTES }); const ok = await verifyMeshDescriptor(this.subtle, member.publicKey, { groupId: this.groupId, epoch: this.epoch, kind: MESH_KINDS.OFFER, fromFp: from, toFp: this.selfFp, descriptor, nonce, }, fromB64(String(frame.sig || ''), { max: GROUP_LIMITS.MAX_SIG_BYTES })); if (!ok) throw new GroupSessionError('mesh dial signature did not verify', 'bad_signature'); // Re-checked after the signature work: verifying is a trip through // WebCrypto, and a probe can adopt an existing chat for this member in // that window. Answering then would build a second connection to // somebody we are already talking to. if (this._destroyed || member.sessionId || this._meshDials.has(from)) return; if (assertEpoch(frame.epoch) !== this.epoch) return; const epoch = this.epoch; const dial = { role: 'answer', sessionId: null, nonce, epoch, timer: null }; this._meshDials.set(from, dial); try { const link = await this._mesh.createAnswer(from, descriptor); const sessionId = link && link.sessionId; const answer = String((link && link.descriptor) || ''); if (!sessionId || !answer) throw new GroupSessionError('mesh transport produced no answer', 'no_descriptor'); if (!this._dialStillWanted(from, dial)) return this._abandonDial(from, dial, sessionId); dial.sessionId = sessionId; this._meshSessions.add(sessionId); this.bindSession(from, sessionId, MEMBER_STATE.PENDING); const sig = await signMeshDescriptor(this.subtle, this.identity.keyPair.privateKey, { groupId: this.groupId, epoch, kind: MESH_KINDS.ANSWER, fromFp: this.selfFp, toFp: from, descriptor: answer, nonce, }); const sent = await this._sendTo(from, { type: GROUP_FRAMES.MESH_ANSWER, gid: this.groupId, epoch, from: this.selfFp, to: from, d: answer, n: toB64(nonce), sig: toB64(sig), }); if (!sent) throw new GroupSessionError('no route to carry the answer', 'unreachable'); dial.timer = this._timer(() => this._meshFail(from, 'answer_timeout'), TIMEOUTS.MESH_DIAL_MS); } catch (error) { this._meshFail(from, error?.code || 'answer_failed'); } } async _onMeshAnswer(frame) { if (!this._mesh || this._destroyed) return; const from = assertFingerprint(String(frame.from || '')); if (assertFingerprint(String(frame.to || '')) !== this.selfFp) return; if (assertEpoch(frame.epoch) !== this.epoch) return; const dial = this._meshDials.get(from); if (!dial || dial.role !== 'offer' || !dial.sessionId || dial.epoch !== this.epoch) return; const member = this.members.get(from); if (!member || !member.publicKey) { throw new GroupSessionError('mesh answer from a non-member', 'not_a_member'); } const descriptor = String(frame.d || ''); if (!descriptor || descriptor.length > GROUP_LIMITS.MAX_DESCRIPTOR_CHARS) { throw new GroupSessionError('mesh descriptor is missing or oversized', 'bad_descriptor'); } // The nonce we generated for THIS dial, or the answer belongs to another // attempt and is being replayed into this one. const nonce = fromB64(String(frame.n || ''), { max: GROUP_LIMITS.MESH_NONCE_BYTES }); if (nonce.length !== dial.nonce.length || !nonce.every((b, i) => b === dial.nonce[i])) { throw new GroupSessionError('mesh answer does not match the dial it claims', 'bad_mesh_nonce'); } const ok = await verifyMeshDescriptor(this.subtle, member.publicKey, { groupId: this.groupId, epoch: this.epoch, kind: MESH_KINDS.ANSWER, fromFp: from, toFp: this.selfFp, descriptor, nonce, }, fromB64(String(frame.sig || ''), { max: GROUP_LIMITS.MAX_SIG_BYTES })); if (!ok) throw new GroupSessionError('mesh answer signature did not verify', 'bad_signature'); try { await this._mesh.acceptAnswer(dial.sessionId, descriptor); } catch (error) { this._meshFail(from, error?.code || 'answer_rejected'); } // From here the transport finishes its own handshake; the link is // declared up by setSessionState when it does. } _onMeshAbort(frame) { const from = assertFingerprint(String(frame.from || '')); if (assertFingerprint(String(frame.to || '')) !== this.selfFp) return; if (!this._meshDials.has(from)) return; // No abort back — that is how two peers keep telling each other to stop. this._meshFail(from, 'peer_aborted', { tellPeer: false }); } // ----------------------------------------------------------------------- // link probes // ----------------------------------------------------------------------- /** * Claim a pairwise chat we already hold as this group's link to a member. * * Two people who were already talking do not need a second connection built * between them, and dialling one anyway would spend a WebRTC negotiation to * arrive back where we started. The app calls this for every verified chat * that is not already carrying a member; whoever is on the other end and is * in this group binds it, and the pair is meshed without dialling anything. * * Sent once per session per epoch. It is a claim about identity, not a * request, so there is nothing to retry. */ async probeSession(sessionId) { if (this._destroyed || !this._mesh || !sessionId) return false; if (this.phase !== GROUP_PHASE.READY || !this.sasConfirmed) return false; if (this.sessionToFp.has(sessionId)) return false; return this._sendProbe(sessionId); } /** * Sign and send one probe. Once per session per epoch, whatever asked for it. * * Split from probeSession because the two callers disagree about one check: * the app only offers sessions that carry nobody, while an ANSWERING probe * goes out on a session that has just been bound — by the very probe it is * answering. */ async _sendProbe(sessionId) { if (this._destroyed || this._probed.has(sessionId)) return false; const linkFp = this._linkFingerprint(sessionId); if (!linkFp) return false; this._probed.add(sessionId); const sig = await signLinkProbe(this.subtle, this.identity.keyPair.privateKey, { groupId: this.groupId, epoch: this.epoch, fp: this.selfFp, linkFp, }); await this._wire(sessionId, { type: GROUP_FRAMES.PROBE, gid: this.groupId, epoch: this.epoch, fp: this.selfFp, sig: toB64(sig), }); return true; } _linkFingerprint(sessionId) { try { const fp = this._mesh?.linkFingerprint?.(sessionId); return typeof fp === 'string' && fp.length > 0 ? fp : ''; } catch (_) { return ''; } } async _onProbe(sessionId, frame) { if (this._destroyed) return; if (this.phase !== GROUP_PHASE.READY || !this.sasConfirmed) return; if (assertEpoch(frame.epoch) !== this.epoch) return; const fp = assertFingerprint(String(frame.fp || '')); if (fp === this.selfFp) return; const member = this.members.get(fp); if (!member || !member.publicKey) return; if (this.sessionToFp.has(sessionId)) return; // this session carries someone else if (member.sessionId) { // A link that is already carrying traffic is not replaced. if (member.state === MEMBER_STATE.LINKED) return; // But a dial still being built loses to a chat that already works. // The dial is abandoned below; bindSession closes its half-open // connection. Without this the probe would arrive a moment too late // and we would finish building a second connection to somebody we // were already talking to. if (!this._meshDials.has(fp)) return; } // The fingerprint of the session the probe ARRIVED on, read locally. // Taking it from the frame would defeat the whole point — see // linkProbePayload for what this binding is defending against. const linkFp = this._linkFingerprint(sessionId); if (!linkFp) return; const ok = await verifyLinkProbe(this.subtle, member.publicKey, { groupId: this.groupId, epoch: this.epoch, fp, linkFp, }, fromB64(String(frame.sig || ''), { max: GROUP_LIMITS.MAX_SIG_BYTES })); if (!ok) throw new GroupSessionError('link probe signature did not verify', 'bad_signature'); // A probe only ever arrives on a link that is already up and verified — // it travelled over it — so this one is LINKED, not PENDING. this._settleDial(fp); this.bindSession(fp, sessionId, MEMBER_STATE.LINKED); // Answer in kind, or the adoption is one-sided. The peer now routes to // us over this link, but we are the only ones who know that: without a // probe back they never learn who is on their end, and they would go on // relaying to us through somebody else forever. The once-per-session // guard in _sendProbe is what stops the two sides answering each other // indefinitely. this._sendProbe(sessionId).catch(() => { /* the link works either way */ }); this._scheduleMeshMaintain(); } // ----------------------------------------------------------------------- // step 1-2: invite / hello // ----------------------------------------------------------------------- /** * Admin: invite peers we already hold verified 1:1 sessions with. * @param {{sessionId: string, name: string}[]} peers */ async invite(peers) { if (!this.isAdmin) throw new GroupSessionError('only the admin invites', 'not_admin'); await this.init(); if (peers.length + 1 > GROUP_LIMITS.MAX_MEMBERS) { throw new GroupSessionError(`a group is limited to ${GROUP_LIMITS.MAX_MEMBERS} members`, 'too_many_members'); } this._setPhase(GROUP_PHASE.FORMING); for (const peer of peers) this._awaitingHello.set(peer.sessionId, peer.name || 'Member'); const frame = { type: GROUP_FRAMES.INVITE, gid: this.groupId, epoch: this.epoch, name: this.name, adminSpki: toB64(this.identity.spki), }; // Report what actually left the device. // // Swallowing these failures turned a dead link into forty-five seconds of // nothing followed by a generic timeout, which is indistinguishable from // an invitee who simply has not answered. A send that fails for everyone // failed now, and saying so now is the difference between a bug someone // can act on and a group that mysteriously never forms. const results = await Promise.allSettled(peers.map((p) => this._wire(p.sessionId, frame))); const failed = results.filter((r) => r.status === 'rejected'); if (failed.length === peers.length) { this._fail('invitations_could_not_be_sent'); throw new GroupSessionError( 'the invitation could not be sent — the chat with that peer is not connected', 'invitations_could_not_be_sent', ); } if (failed.length > 0) { // Some links are down. Those invitees will never send a hello, so the // roster would wait on them forever; drop them from the round. const reachable = peers.filter((_, i) => results[i].status === 'fulfilled'); for (const p of peers) { if (!reachable.includes(p)) this._awaitingHello.delete(p.sessionId); } this._emit('partial_invite', { sent: reachable.length, total: peers.length }); } this._timer(() => { if (this.phase === GROUP_PHASE.FORMING) this._fail('invitees_did_not_respond'); }, TIMEOUTS.HELLO_MS); } /** Invitee: adopt an invitation and publish our own identity key back. */ async acceptInvite(sessionId, envelope) { // The app receives an invitation before any group exists to decode it // with, so it may hand back either the envelope or the frame inside. const frame = decodeEnvelope(envelope); await this.init(); const adminSpki = fromB64(String(frame.adminSpki || '')); const { publicKey, fingerprint } = await importMemberIdentity(this.subtle, adminSpki); this.adminFp = fingerprint; this.epoch = assertEpoch(frame.epoch); this.name = assertName(frame.name); this.members.set(fingerprint, { fp: fingerprint, name: 'Admin', spki: adminSpki, publicKey, sessionId, state: MEMBER_STATE.LINKED, }); this.sessionToFp.set(sessionId, fingerprint); this._setPhase(GROUP_PHASE.FORMING); this._emitMembers(); await this._wire(sessionId, { type: GROUP_FRAMES.HELLO, gid: this.groupId, epoch: this.epoch, spki: toB64(this.identity.spki), }); this._timer(() => { if (this.phase === GROUP_PHASE.FORMING) this._fail('roster_never_arrived'); }, TIMEOUTS.HELLO_MS); } async _onHello(sessionId, frame) { if (!this.isAdmin) return; // only the admin collects identity keys // A hello is only ever an ANSWER to an invitation this admin sent, on // the very session it was sent over. Without that check the frame is an // open door: any member — or anyone else who holds a verified chat with // the admin and has learnt the group id — could publish an identity key // the admin never invited, and the branch at the end of this method // would then sign and broadcast a roster containing it. "Only the admin // invites" has to be enforced here, because this is the only place a // member is created from something that arrived on the wire. // // It also confines the frame to a DIRECT link. A hello can be wrapped in // a relay and handed to the admin by a third party, and _onRelay hands // the inner frame on with the RELAY's session id — which is never a // session an invitation went out on, so such a frame lands here and // stops. if (!this._awaitingHello.has(sessionId)) { this._log('warn', 'dropped an unsolicited group hello', { groupId: this.groupId }); return; } const spki = fromB64(String(frame.spki || '')); const { publicKey, fingerprint } = await importMemberIdentity(this.subtle, spki); if (this.members.has(fingerprint) && fingerprint !== this.selfFp) return; if (this.members.size >= GROUP_LIMITS.MAX_MEMBERS) throw new GroupSessionError('group is full', 'too_many_members'); this.members.set(fingerprint, { fp: fingerprint, name: this._awaitingHello.get(sessionId) || 'Member', spki, publicKey, sessionId, state: MEMBER_STATE.LINKED, }); this.sessionToFp.set(sessionId, fingerprint); this._awaitingHello.delete(sessionId); this._emitMembers(); if (this._awaitingHello.size === 0) { if (this._pendingAdd) return this._finishAdd(); await this.publishRoster(MEMBER_OPS.CREATE); } } // ----------------------------------------------------------------------- // step 3: the signed roster // ----------------------------------------------------------------------- /** Admin: sign the current member set for this epoch and broadcast it. */ async publishRoster(op = MEMBER_OPS.ADD) { if (!this.isAdmin) throw new GroupSessionError('only the admin publishes the roster', 'not_admin'); const memberFps = canonicalFingerprints([...this.members.keys()]); const fields = { groupId: this.groupId, epoch: this.epoch, op, memberFps, name: this.name }; const sig = await signMemberOp(this.subtle, this.identity.keyPair.privateKey, fields); // Member keys go out one frame each, BEFORE the roster that names them. // // They cannot ride inside the roster: eight members' SPKI would put the // frame past the 2000-character ceiling the chat path truncates at, and a // truncated roster fails in the most confusing way possible. Splitting // them costs nothing in security, because the signed roster commits to // the FINGERPRINTS — a key that arrives separately is checked against the // fingerprint it claims, so a substituted key is refused whichever frame // carried it. for (const fp of memberFps) { const m = this.members.get(fp); await this._broadcast({ type: GROUP_FRAMES.MEMBER, gid: this.groupId, epoch: this.epoch, fp, name: m.name === 'You' ? 'Admin' : m.name, spki: toB64(m.spki), }); } await this._broadcast({ type: GROUP_FRAMES.ROSTER, gid: this.groupId, epoch: this.epoch, op, name: this.name, adminSpki: toB64(this.identity.spki), members: memberFps, sig: toB64(sig), }); await this._startCeremony(); } /** * A member's identity key, published ahead of the roster that names them. * * Held in a staging area rather than applied: until the admin's signed roster * arrives, a key frame is an unverified claim about who is in the group. The * fingerprint is derived from the bytes, never taken from the frame, so a * member cannot register a key under someone else's name. */ async _onMemberKey(frame) { const epoch = assertEpoch(frame.epoch); if (epoch < this.epoch) return; if (this._pendingKeys.size > GROUP_LIMITS.MAX_MEMBERS * 2) return; const spki = fromB64(String(frame.spki || '')); const { publicKey, fingerprint } = await importMemberIdentity(this.subtle, spki); if (assertFingerprint(String(frame.fp || '')) !== fingerprint) { throw new GroupSessionError('member key does not match its fingerprint', 'fingerprint_mismatch'); } this._pendingKeys.set(fingerprint, { spki, publicKey, name: assertName(frame.name) }); } /** * Member: adopt a roster. * * The admin's signature is checked against the key whose fingerprint IS the * admin fingerprint we recorded at invite time — not against whatever key the * frame happens to carry — so a member cannot promote itself by attaching its * own key to a roster. The epoch must move forward, which refuses both a * replay and a rollback to a membership that used to be valid. */ async _onRoster(sessionId, frame) { const epoch = assertEpoch(frame.epoch); if (this.isAdmin) return; // we authored it if (epoch < this.epoch) throw new GroupSessionError('roster epoch went backwards', 'stale_epoch'); const adminSpki = fromB64(String(frame.adminSpki || '')); const { publicKey: adminKey, fingerprint: adminFp } = await importMemberIdentity(this.subtle, adminSpki); if (this.adminFp && adminFp !== this.adminFp) { throw new GroupSessionError('roster was signed by someone other than the admin', 'wrong_admin'); } if (!Array.isArray(frame.members)) throw new GroupSessionError('roster carries no member list', 'bad_roster'); if (frame.members.length > GROUP_LIMITS.MAX_MEMBERS) throw new GroupSessionError('roster exceeds the member limit', 'too_many_members'); const memberFps = canonicalFingerprints(frame.members.map((fp) => String(fp || ''))); if (!memberFps.includes(this.selfFp)) throw new GroupSessionError('roster does not include us', 'not_a_member'); if (!memberFps.includes(adminFp)) throw new GroupSessionError('roster does not include its author', 'bad_roster'); // Match each named member to the key frame that arrived ahead of the // roster. A member the admin names but whose key never arrived is a // member we could not verify a single message from, so the roster is // refused outright rather than adopted with a hole in it. const imported = []; for (const fp of memberFps) { if (fp === this.selfFp) { imported.push({ fp, name: 'You', spki: this.identity.spki, publicKey: null }); continue; } const staged = this._pendingKeys.get(fp) || (fp === adminFp ? { spki: adminSpki, publicKey: adminKey, name: 'Admin' } : null); if (!staged) throw new GroupSessionError('roster names a member whose key never arrived', 'missing_member_key'); imported.push({ fp, name: staged.name, spki: staged.spki, publicKey: staged.publicKey }); } const ok = await verifyMemberOp(this.subtle, adminKey, { groupId: this.groupId, epoch, op: String(frame.op || ''), memberFps, name: assertName(frame.name), }, fromB64(String(frame.sig || ''), { max: GROUP_LIMITS.MAX_SIG_BYTES })); if (!ok) throw new GroupSessionError('roster signature did not verify', 'bad_signature'); // Adopt. Existing links are preserved: the session we already hold with // the admin (and with anyone else) stays bound to the same fingerprint. this.epoch = epoch; this.name = assertName(frame.name); this.adminFp = adminFp; const previous = this.members; this.members = new Map(); for (const m of imported) { const old = previous.get(m.fp); this.members.set(m.fp, { fp: m.fp, name: m.fp === this.selfFp ? 'You' : m.name, spki: m.spki, publicKey: m.fp === this.selfFp ? null : m.publicKey, sessionId: old?.sessionId || null, state: m.fp === this.selfFp ? MEMBER_STATE.SELF : (old?.state === MEMBER_STATE.LINKED ? MEMBER_STATE.LINKED : MEMBER_STATE.PENDING), }); } // Drop session bindings for members who left. for (const [sid, fp] of [...this.sessionToFp]) { if (!this.members.has(fp)) this.sessionToFp.delete(sid); } this._emit('roster', { name: this.name, epoch: this.epoch, adminFp }); this._emitMembers(); await this._startCeremony(); } // ----------------------------------------------------------------------- // steps 4-6: the safety code ceremony // ----------------------------------------------------------------------- async _startCeremony() { try { this.ceremony?.destroy(); } catch (_) {} // A new round means a new epoch, and every dial in flight was signed // against the old one. Abandon them rather than let them arrive as // signatures that cannot verify; links already up are kept. this._meshReset(); this.ceremony = new GroupSasCeremony({ groupId: this.groupId, epoch: this.epoch, selfFingerprint: this.selfFp, memberFingerprints: [...this.members.keys()], }); this._setPhase(GROUP_PHASE.COMMITTING); const commitment = await this.ceremony.ownCommitment(this.subtle); // Our commitment goes out BEFORE anything held is replayed, and the order // is load-bearing. // // Draining first can complete the commit round on the spot — every peer // commitment may already be waiting — which reveals our nonce and puts a // reveal on the wire ahead of our own commitment. A peer then has a reveal // it cannot check yet and has to hold it until our commitment turns up, // so the round only completes if that one later frame arrives. It made // correctness depend on a peer's buffer instead of on the protocol, and a // single dropped commitment left every member waiting on a nonce that had // already been sent. await this._broadcast({ type: GROUP_FRAMES.COMMIT, gid: this.groupId, epoch: this.epoch, fp: this.selfFp, commit: toB64(commitment), }); const epochAtStart = this.epoch; this._timer(() => { if (this.epoch !== epochAtStart) return; if (this.phase === GROUP_PHASE.COMMITTING || this.phase === GROUP_PHASE.REVEALING) { this._fail('ceremony_timed_out'); } }, TIMEOUTS.CEREMONY_MS); // Only now replay what arrived before this ceremony existed. Our // commitment is already on the wire, so a reveal this produces can never // outrun it. await this._drainPendingCeremony(); await this._maybeReveal(); } /** Hold a ceremony frame that outran our own roster. See _pendingCeremony. */ _holdCeremonyFrame(frame) { // Two frames per member per epoch is all that can legitimately be // outstanding; the cap keeps a chatty member from growing this without // bound while we wait for a roster. if (this._pendingCeremony.length >= GROUP_LIMITS.MAX_MEMBERS * 2) return; this._pendingCeremony.push(frame); } async _drainPendingCeremony() { // Re-entrancy guard: draining calls the same handlers that hold frames, // and a frame that is still premature is put back for the next drain. if (this._draining) return; this._draining = true; try { const held = this._pendingCeremony; this._pendingCeremony = []; for (const frame of held) { try { if (frame.type === GROUP_FRAMES.COMMIT) await this._onCommit(frame); else if (frame.type === GROUP_FRAMES.REVEAL) await this._onReveal(frame); } catch (error) { // A held frame that no longer makes sense (wrong epoch, a // member dropped from the roster) is discarded, not fatal. this._log('warn', 'held ceremony frame discarded', { code: error?.code }); } } } finally { this._draining = false; } } async _onCommit(frame) { if (!this.ceremony) return this._holdCeremonyFrame(frame); if (assertEpoch(frame.epoch) !== this.epoch) return; this.ceremony.acceptCommitment( assertFingerprint(String(frame.fp || '')), fromB64(String(frame.commit || ''), { max: GROUP_LIMITS.COMMIT_BYTES }), ); // A reveal we had to hold may now have the commitment it needs. await this._drainPendingCeremony(); await this._maybeReveal(); } /** * Publish our nonce, but only once every commitment is in. * * The check lives in GroupSasCeremony.reveal(), which throws otherwise. This * method only asks whether the round is complete — it must never be changed * to reveal on a timer or on a partial round. */ async _maybeReveal() { if (!this.ceremony || this.ceremony.revealed) return; if (!this.ceremony.commitmentsComplete) return; this._setPhase(GROUP_PHASE.REVEALING); const nonce = this.ceremony.reveal(); await this._broadcast({ type: GROUP_FRAMES.REVEAL, gid: this.groupId, epoch: this.epoch, fp: this.selfFp, nonce: toB64(nonce), }); await this._maybeFinish(); } async _onReveal(frame) { if (!this.ceremony) return this._holdCeremonyFrame(frame); if (assertEpoch(frame.epoch) !== this.epoch) return; // A reveal can also outrun the commitment it opens, on a link where the // two frames took different paths. Hold it rather than failing the // ceremony for an ordering the network chose. if (!this.ceremony.commitments.has(assertFingerprint(String(frame.fp || '')))) { return this._holdCeremonyFrame(frame); } await this.ceremony.acceptReveal( this.subtle, assertFingerprint(String(frame.fp || '')), fromB64(String(frame.nonce || ''), { max: GROUP_LIMITS.NONCE_BYTES }), ); await this._maybeFinish(); } async _maybeFinish() { if (!this.ceremony || !this.ceremony.revealsComplete) return; // Reached from both _onReveal and _maybeReveal, so it must be idempotent: // the round produces one code and computing it twice is not an error. if (this.sasCode) return; const code = await this.ceremony.finish(this.subtle); this._setPhase(GROUP_PHASE.AWAITING_SAS); this.sasCode = code; this._emit('sas', { code }); } /** * The humans compared the digits and they matched. * * This is the only path to READY, and it is driven by a user action — never * by a frame arriving. It mirrors the 1:1 rule: completing a handshake proves * somebody completed it, and only the out-of-band comparison proves who. */ confirmSas() { if (this.phase !== GROUP_PHASE.AWAITING_SAS || !this.sasCode) { throw new GroupSessionError('there is no group code to confirm', 'no_code'); } this.sasConfirmed = true; this.phase = GROUP_PHASE.READY; try { this.ceremony?.destroy(); } catch (_) {} this.ceremony = null; this._emit('confirmed', { members: this._memberSnapshot() }); // The moment the human vouches for the code, the roster's identity keys // become keys worth authenticating a transport with. This is the gate // the whole mesh waits behind. this._scheduleMeshMaintain(); } /** Recompute the code for the current epoch — used only by tests and diagnostics. */ async _recomputeSas(contributions) { return computeGroupSas(this.subtle, { groupId: this.groupId, epoch: this.epoch, contributions, }); } // ----------------------------------------------------------------------- // step 7: messages // ----------------------------------------------------------------------- async sendText(text) { if (this.phase !== GROUP_PHASE.READY || !this.sasConfirmed) { throw new GroupSessionError('the group code has not been confirmed', 'not_ready'); } const body = String(text ?? ''); if (!body.trim()) throw new GroupSessionError('empty message', 'empty'); const seq = ++this.seq; const bodyHash = await hashBody(this.subtle, body); const sig = await signGroupMessage(this.subtle, this.identity.keyPair.privateKey, { groupId: this.groupId, epoch: this.epoch, seq, senderFp: this.selfFp, bodyHash, }); const frame = { type: GROUP_FRAMES.MESSAGE, gid: this.groupId, epoch: this.epoch, seq, fp: this.selfFp, ts: Date.now(), body, sig: toB64(sig), }; const { delivered, unreachable } = await this._broadcast(frame); return { seq, delivered, total: this.members.size - 1, unreachable }; } async _onMessage(frame, { relayed = false } = {}) { const epoch = assertEpoch(frame.epoch); const seq = assertEpoch(frame.seq); const senderFp = assertFingerprint(String(frame.fp || '')); if (senderFp === this.selfFp) return; // our own frame came back around const member = this.members.get(senderFp); if (!member || !member.publicKey) throw new GroupSessionError('message from a non-member', 'not_a_member'); // A message from an epoch we have left is not applied: its signature is // valid but it belongs to a membership that no longer holds. if (epoch !== this.epoch) throw new GroupSessionError('message from another epoch', 'stale_epoch'); const body = String(frame.body ?? ''); const bodyHash = await hashBody(this.subtle, body); const ok = await verifyGroupMessage(this.subtle, member.publicKey, { groupId: this.groupId, epoch, seq, senderFp, bodyHash, }, fromB64(String(frame.sig || ''), { max: GROUP_LIMITS.MAX_SIG_BYTES })); if (!ok) throw new GroupSessionError('message signature did not verify', 'bad_signature'); // Transcript consistency AND duplicate suppression, in that order — // which is the whole point. Fan-out plus relay means the same frame // legitimately arrives twice, so a repeat has to be absorbed silently. // But "same sender, same sequence number" is NOT enough to call something // a duplicate: a member telling two halves of the group different things // does exactly that, and deduplicating on the key alone would hide the // split this record exists to catch. The body hash is what separates the // two cases. const byMember = this.transcript.get(senderFp) || new Map(); const hashHex = toB64(bodyHash); const previous = byMember.get(seq); if (previous !== undefined) { if (previous === hashHex) return; // the same message, arriving again this._emit('inconsistency', { fp: senderFp, seq, name: member.name }); throw new GroupSessionError('member sent conflicting messages under one sequence number', 'transcript_split'); } byMember.set(seq, hashHex); // Bounded per sender. The pairwise ratchet already refuses genuinely old // frames, so this window only has to outlast fan-out and one relay hop. if (byMember.size > 512) byMember.delete(byMember.keys().next().value); this.transcript.set(senderFp, byMember); this._emit('message', { fp: senderFp, name: member.name, body, seq, ts: Number.isFinite(frame.ts) ? frame.ts : Date.now(), // Whether this particular copy came straight from its author or was // carried by another member. Worth showing: a relayed message is one // a third member knew the timing of, and the reader is the only one // in a position to notice that is still happening. relayed, }); } // ----------------------------------------------------------------------- // calls // ----------------------------------------------------------------------- // // WHAT TRAVELS HERE AND WHAT DOES NOT // ----------------------------------- // Only the roster of a call: somebody opened one, somebody joined it, // somebody left. No SDP, no ICE, no audio, no video. Media is carried by the // pairwise sessions themselves — each member places an ordinary encrypted // call to each other member over the link they already share, so a group // call is N-1 of the 1:1 calls this app already makes, on transports that // were already SAS-verified. There is no mixer, no conference server and no // point at which two people's audio meets anywhere but on a device. // // That is why call control is separate from the media path. Control has to // reach every member, including one who is currently reachable only through // a relay; media can only flow where a direct link exists. Splitting them // means a member with no direct link still SEES the call and can be dialled // into it as the mesh completes, instead of silently missing it. // // WHY THESE FRAMES ARE SIGNED // --------------------------- // A relaying member carries call control for pairs that cannot reach each // other. Unsigned, that member could add somebody to a call they never // joined, or drop somebody who is in one, and nobody could tell it had // happened. Signed with the group identity key, a relay can still refuse to // carry a frame — the same availability cost relaying always has — but it // cannot write one. /** What the app renders. Null when there is no call. */ getCallSnapshot() { if (!this.call) return null; const starter = this.members.get(this.call.startedBy); return { callId: this.call.callId, startedBy: this.call.startedBy, startedByName: this.call.startedBy === this.selfFp ? 'You' : (starter?.name || 'A member'), withVideo: this.call.withVideo, startedAt: this.call.startedAt, joined: this.call.joined, participants: [...this.call.participants].map((fp) => { const member = this.members.get(fp); return { fp, name: fp === this.selfFp ? 'You' : (member?.name || 'A member'), self: fp === this.selfFp, sessionId: member?.sessionId || null, state: member?.state || MEMBER_STATE.LOST, }; }).sort((a, b) => (a.fp < b.fp ? -1 : a.fp > b.fp ? 1 : 0)), }; } _emitCall() { this._emit('call', { call: this.getCallSnapshot() }); } _requireReady() { if (this.phase !== GROUP_PHASE.READY || !this.sasConfirmed) { throw new GroupSessionError('the group code has not been confirmed', 'not_ready'); } } /** Sign and fan out one call-control frame. */ async _sendCallFrame(action, callId, withVideo) { const seq = ++this.callSeq; const sig = await signGroupCall(this.subtle, this.identity.keyPair.privateKey, { groupId: this.groupId, epoch: this.epoch, callId, action, fp: this.selfFp, seq, withVideo, }); return this._broadcast({ type: GROUP_FRAMES.CALL, gid: this.groupId, epoch: this.epoch, callId, action, fp: this.selfFp, seq, v: withVideo === true, ts: Date.now(), sig: toB64(sig), }); } /** * Open a call and put ourselves in it. * * Refused while one is already running: joining the call that exists is what * the user means, and a second concurrent call would split the group into two * rooms that cannot hear each other. */ async startCall({ withVideo = false, prepare = null } = {}) { this._requireReady(); if (this.call) throw new GroupSessionError('a call is already running in this group', 'call_in_progress'); const callId = newCallId(); this.call = { callId, startedBy: this.selfFp, withVideo: withVideo === true, startedAt: Date.now(), participants: new Set([this.selfFp]), joined: true, }; this._emitCall(); // Media needs a direct link, so a call is the moment it is most worth // having one. The mesh would get there on its own; this stops the first // seconds of the call being spent waiting for a maintenance pass. this._scheduleMeshMaintain(); try { // `prepare` is where the caller opens its microphone, and it runs // BEFORE the group is told anything. Announcing first would ring // everybody else's device for a call this one turns out not to be // able to make — a denied permission, no microphone, another // application holding it. Failing here costs nobody but the person // who pressed the button. if (typeof prepare === 'function') await prepare(this.getCallSnapshot()); const { unreachable } = await this._sendCallFrame(CALL_ACTIONS.START, callId, this.call.withVideo); return { callId, unreachable }; } catch (error) { this.call = null; this._emitCall(); throw error; } } /** Join the call that is already running. */ async joinCall() { this._requireReady(); if (!this.call) throw new GroupSessionError('there is no call to join', 'no_call'); if (this.call.joined) return { callId: this.call.callId, unreachable: [] }; this.call.joined = true; this.call.participants.add(this.selfFp); this._emitCall(); this._scheduleMeshMaintain(); const { unreachable } = await this._sendCallFrame(CALL_ACTIONS.JOIN, this.call.callId, this.call.withVideo); return { callId: this.call.callId, unreachable }; } /** * Leave the call. * * Leaving is always local first: the frame is best effort, because a member * who cannot be reached must not be able to keep us in a call by being * unreachable. */ async leaveCall() { if (!this.call) return; const callId = this.call.callId; const withVideo = this.call.withVideo; this.call.joined = false; this.call.participants.delete(this.selfFp); if (this.call.participants.size === 0) this.call = null; this._emitCall(); try { await this._sendCallFrame(CALL_ACTIONS.LEAVE, callId, withVideo); } catch (_) { /* leaving is best effort */ } } /** * A member is gone (left the call, left the group, or was removed). * * A call with NOBODY in it is over. A call with only us in it is not: that * is exactly the state every call is in for the seconds between opening it * and the first person joining, and ending it there would hang up on * somebody who is on their way in. Leaving is the user's decision, and * leaveCall is the only thing that makes it. */ _dropFromCall(fp) { if (!this.call || !this.call.participants.has(fp)) return; this.call.participants.delete(fp); if (this.call.participants.size === 0) this.call = null; this._emitCall(); } async _onCall(frame) { const epoch = assertEpoch(frame.epoch); const seq = assertEpoch(frame.seq); const senderFp = assertFingerprint(String(frame.fp || '')); const callId = assertCallId(String(frame.callId || '')); const action = String(frame.action || ''); const withVideo = frame.v === true; if (senderFp === this.selfFp) return; const member = this.members.get(senderFp); if (!member || !member.publicKey) throw new GroupSessionError('call frame from a non-member', 'not_a_member'); if (epoch !== this.epoch) throw new GroupSessionError('call frame from another epoch', 'stale_epoch'); // Replay window first, so a captured frame is dropped before its action // is considered at all. Equal counts as a replay: a sender never reuses // a sequence number, and fan-out duplicates of the same frame are // exactly what this absorbs. const seen = this._callSeen.get(senderFp); if (seen !== undefined && seq <= seen) return; const ok = await verifyGroupCall(this.subtle, member.publicKey, { groupId: this.groupId, epoch, callId, action, fp: senderFp, seq, withVideo, }, fromB64(String(frame.sig || ''), { max: GROUP_LIMITS.MAX_SIG_BYTES })); if (!ok) throw new GroupSessionError('call frame signature did not verify', 'bad_signature'); this._callSeen.set(senderFp, seq); // Nothing about a call may be acted on before the group itself is // usable: a call that arrives mid-ceremony would be a ringing phone for // a group nobody has authenticated yet. if (this.phase !== GROUP_PHASE.READY || !this.sasConfirmed) return; switch (action) { case CALL_ACTIONS.START: { if (this.call && this.call.callId !== callId) { // Two calls opened at once. The lower id wins for everyone, // because every member compares the same two values and gets // the same answer — so the group converges on one room // instead of splitting into two that cannot hear each other. if (callId >= this.call.callId) return; // We are being moved off a call we may be in. Say so, so the // media layer tears the old one down before building the new. this.call = null; } if (!this.call) { this.call = { callId, startedBy: senderFp, withVideo, startedAt: Date.now(), participants: new Set([senderFp]), joined: false, }; } else { this.call.participants.add(senderFp); } this._emitCall(); this._scheduleMeshMaintain(); return; } case CALL_ACTIONS.JOIN: { if (!this.call || this.call.callId !== callId) return; if (this.call.participants.has(senderFp)) return; this.call.participants.add(senderFp); // A member joining with video turns the call into one that has // video in it; nobody's own camera is turned on by this. if (withVideo) this.call.withVideo = true; this._emitCall(); this._scheduleMeshMaintain(); return; } case CALL_ACTIONS.LEAVE: { if (!this.call || this.call.callId !== callId) return; this._dropFromCall(senderFp); return; } default: return; } } // ----------------------------------------------------------------------- // inbound dispatch // ----------------------------------------------------------------------- /** * Handle one frame that arrived on a pairwise session. * * Everything here is attacker-supplied in the sense that matters: it comes * from a verified peer, but a group member is only as trustworthy as the * group makes them. Types are matched against an explicit list and anything * unrecognised is dropped rather than passed on. */ async handleFrame(sessionId, envelope, { relayed = false } = {}) { if (this._destroyed) return; if (!isGroupFrame(envelope)) return; const frame = decodeEnvelope(envelope); if (!frame || !GROUP_FRAME_TYPES.has(frame.type)) return; if (assertGroupId(String(frame.gid || '')) !== this.groupId) return; switch (frame.type) { case GROUP_FRAMES.RELAY: return this._onRelay(sessionId, frame); case GROUP_FRAMES.HELLO: return this._onHello(sessionId, frame); case GROUP_FRAMES.MEMBER: return this._onMemberKey(frame); case GROUP_FRAMES.ROSTER: return this._onRoster(sessionId, frame); case GROUP_FRAMES.COMMIT: return this._onCommit(frame); case GROUP_FRAMES.REVEAL: return this._onReveal(frame); case GROUP_FRAMES.MESSAGE: return this._onMessage(frame, { relayed }); case GROUP_FRAMES.LEAVE: return this._onLeave(frame); case GROUP_FRAMES.MESH_OFFER: return this._onMeshOffer(frame); case GROUP_FRAMES.MESH_ANSWER: return this._onMeshAnswer(frame); case GROUP_FRAMES.MESH_ABORT: return this._onMeshAbort(frame); case GROUP_FRAMES.CALL: return this._onCall(frame); case GROUP_FRAMES.PROBE: // A probe is a claim about the link it arrived on, so it is only // meaningful on a direct one. Relayed, it says nothing. return relayed ? undefined : this._onProbe(sessionId, frame); case GROUP_FRAMES.INVITE: return; // handled by the app, which decides whether to join at all default: return; } } /** * Single-hop relay. * * A frame addressed to us is unwrapped and handled. A frame addressed to * someone else is forwarded exactly once — `hopped` makes a second forward * impossible, so there is no loop to form and no path to lengthen. */ async _onRelay(sessionId, frame) { const to = assertFingerprint(String(frame.to || '')); const inner = frame.inner; if (!isGroupFrame(inner)) return; if (inner.type === GROUP_FRAMES.RELAY) return; // never nest if (to === this.selfFp) return this.handleFrame(sessionId, inner, { relayed: true }); if (frame.hopped === true) return; // already relayed once; do not forward again const member = this.members.get(to); if (!member || member.state !== MEMBER_STATE.LINKED || !member.sessionId) return; await this._wire(member.sessionId, { ...frame, hopped: true }); } async _onLeave(frame) { const fp = assertFingerprint(String(frame.fp || '')); const member = this.members.get(fp); if (!member || fp === this.selfFp) return; this._emit('left', { fp, name: member.name }); this._dropFromCall(fp); // The admin leaving ends the group for everyone else. Nobody else can // sign a roster, so there is no next epoch and no safety code to compare // again — carrying on would leave a group that looks alive but can never // change membership. Say so and let the app tear it down, so the slot is // free when someone starts a new group with the same people. if (!this.isAdmin && fp === this.adminFp) { this.members.delete(fp); for (const [sid, f] of [...this.sessionToFp]) if (f === fp) this.sessionToFp.delete(sid); this._emit('ended', { reason: 'admin_left' }); return; } // Only the admin rewrites membership; everyone else waits for the roster // that the admin will publish for the new epoch. if (!this.isAdmin) return; this.members.delete(fp); for (const [sid, f] of [...this.sessionToFp]) if (f === fp) this.sessionToFp.delete(sid); // Tell the UI immediately. Dropping the member from this map without // emitting left them on the admin's member strip until some later event // happened to refresh it — a member who had visibly left still shown as // present, which is exactly the wrong thing for a group to be vague about. this._emitMembers(); // A group of one is not a group. Publishing a roster for it would throw // out of canonicalFingerprints and abort mid-teardown, leaving the group // stuck in a failed state that still occupies the member. if (this.members.size < GROUP_LIMITS.MIN_MEMBERS) { this._emit('ended', { reason: 'last_member_left' }); return; } this.epoch += 1; await this.publishRoster(MEMBER_OPS.REMOVE); } /** * Tell the group we are leaving, best effort. * * Await this before destroy(): teardown clears the member map this walks to * find recipients, so a leave that is merely started can end up addressed to * nobody — and a peer that never hears it keeps a group nobody is in. */ async leave() { try { await this._broadcast({ type: GROUP_FRAMES.LEAVE, gid: this.groupId, fp: this.selfFp }); } catch (_) { /* leaving is best effort */ } } /** * Admin: invite more people into a group that is already running. * * The group stays usable throughout. Nothing about the membership changes * until the new members have published their identity keys and a roster for * the next epoch actually goes out — at which point every member, 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 says * anything about who is in the room. * * If nobody answers, 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 sent. * * @param {{sessionId: string, name: string}[]} peers */ async addMembers(peers) { if (!this.isAdmin) throw new GroupSessionError('only the admin invites', 'not_admin'); if (!Array.isArray(peers) || peers.length === 0) return 0; if (this._pendingAdd) throw new GroupSessionError('an invitation round is already running', 'add_in_flight'); if (this.members.size + peers.length > GROUP_LIMITS.MAX_MEMBERS) { throw new GroupSessionError(`a group is limited to ${GROUP_LIMITS.MAX_MEMBERS} members`, 'too_many_members'); } // Refuse a session that is already carrying a member: inviting someone // twice would have them answer with a second identity key and occupy two // slots in the safety code. for (const peer of peers) { if (this.sessionToFp.has(peer.sessionId)) { throw new GroupSessionError('that chat is already a member of this group', 'already_a_member'); } } this._pendingAdd = { op: MEMBER_OPS.ADD, epoch: this.epoch + 1, before: new Set(this.members.keys()), }; for (const peer of peers) this._awaitingHello.set(peer.sessionId, peer.name || 'Member'); // The invitation already names the epoch the new roster will open, so an // invitee adopts it before anything is signed against it. const frame = { type: GROUP_FRAMES.INVITE, gid: this.groupId, epoch: this._pendingAdd.epoch, name: this.name, adminSpki: toB64(this.identity.spki), }; const round = this._pendingAdd; const results = await Promise.allSettled(peers.map((p) => this._wire(p.sessionId, frame))); const sent = results.filter((r) => r.status === 'fulfilled').length; // Judge "nothing was sent" by the SEND RESULTS, never by what is left in // _awaitingHello. On a fast link the invitee's hello comes back — and the // whole round completes — inside the very call that sent the invitation, // so by the time this line runs the queue is legitimately empty. Reading // emptiness as failure aborted rounds that had already succeeded. if (sent === 0) { for (const peer of peers) this._awaitingHello.delete(peer.sessionId); if (this._pendingAdd === round) this._pendingAdd = null; throw new GroupSessionError( 'the invitation could not be sent — that chat is not connected', 'invitations_could_not_be_sent', ); } peers.forEach((peer, i) => { if (results[i].status === 'rejected') this._awaitingHello.delete(peer.sessionId); }); // The round may already be finished; only arm the timer if it is not. if (this._pendingAdd === round) { this._timer(() => { if (this._pendingAdd === round) this._finishAdd(); }, TIMEOUTS.HELLO_MS); } return sent; } /** * Close an add round: publish the new roster, or abandon it. * * Reached either when every invitee has answered or when the wait runs out. * A partial answer is still worth publishing — the people who did join are * in — but if nobody joined, the group is left untouched rather than pushed * through a re-keying that would achieve nothing except making everyone * compare a new code. */ async _finishAdd() { const round = this._pendingAdd; if (!round) return false; this._pendingAdd = null; this._awaitingHello.clear(); const joined = [...this.members.keys()].filter((fp) => !round.before.has(fp)); if (joined.length === 0) { this._emit('add_failed', { reason: 'nobody_joined' }); return false; } this.epoch = round.epoch; this._emitMembers(); await this.publishRoster(round.op); return true; } /** * Admin: remove a member and re-key the group. * * The new epoch is what makes the removal effective — a new safety code every * remaining member must compare again, and a member set the removed member is * not in. There is no shared group key to rotate because there never was one: * every message travels over pairwise ratchets, so a removed member simply * stops being sent anything. */ async removeMember(fp) { if (!this.isAdmin) throw new GroupSessionError('only the admin removes members', 'not_admin'); if (fp === this.selfFp) throw new GroupSessionError('the admin cannot remove themselves', 'bad_target'); if (!this.members.has(fp)) return false; if (this.members.size - 1 < GROUP_LIMITS.MIN_MEMBERS) { throw new GroupSessionError('a group cannot drop below two members — leave it instead', 'would_empty_group'); } this.members.delete(fp); for (const [sid, f] of [...this.sessionToFp]) if (f === fp) this.sessionToFp.delete(sid); this.epoch += 1; this._emitMembers(); await this.publishRoster(MEMBER_OPS.REMOVE); return true; } }