// The mesh: how a group stops being a star. // // A group is created as a star — the admin holds a link to everyone, nobody // else holds a link to anybody — and every message between two non-admins is // carried by the admin. This file covers the step that ends that: each pair // without a link dials one, over the relay path that already exists. // // What is asserted, in order of how much it matters: // 1. a pair with no link between them ends up with a DIRECT one, and their // messages stop being relayed; // 2. a relayed descriptor that was tampered with is refused, so the member // carrying it cannot put itself in the middle of the link; // 3. an answer replayed from a different dial is refused; // 4. exactly one side of each pair dials, so there is no glare to resolve; // 5. a pair that cannot connect keeps working over the relay, and is not // retried forever; // 6. a chat two members already held is adopted rather than re-dialled, and // a probe replayed onto a different chat does not bind; // 7. the group survives the admin going away once the mesh is up — which is // the whole point of not being a star. import assert from 'node:assert/strict'; const { GroupSession, GROUP_FRAMES, groupFrameType, decodeEnvelope, encodeEnvelope } = await import('../src/group/GroupSession.js'); const { GROUP_PHASE, MEMBER_STATE } = await import('../src/state/groupsStore.js'); const { toB64, generateGroupIdentity, signLinkProbe } = await import('../src/group/groupCrypto.js'); const subtle = crypto.subtle; /** Let queued timers and promise chains settle. The mesh runs on setTimeout(0). */ async function tick(rounds = 12) { for (let i = 0; i < rounds; i++) await new Promise((r) => setTimeout(r, 0)); } // --------------------------------------------------------------------------- // a virtual network // --------------------------------------------------------------------------- // // Unlike the e2e harness, session ids here are LOCAL to each node — which is // what they are in the app, and what makes the mesh's own bookkeeping testable: // a dial produces one id on the caller and a different one on the answerer. function makeNet() { const nodes = new Map(); /** `${node}|${sessionId}` -> { peer, peerSessionId, up, linkFp } */ const chans = new Map(); let counter = 0; const key = (n, s) => `${n}|${s}`; /** Two endpoints of one channel. Each side addresses it by its own id. */ function link(a, aSid, b, bSid, { linkFp = null } = {}) { const fp = linkFp || `linkfp-${++counter}`; chans.set(key(a, aSid), { peer: b, peerSessionId: bSid, up: true, linkFp: fp }); chans.set(key(b, bSid), { peer: a, peerSessionId: aSid, up: true, linkFp: fp }); return fp; } function cut(a, aSid) { const ch = chans.get(key(a, aSid)); if (!ch) return; ch.up = false; const back = chans.get(key(ch.peer, ch.peerSessionId)); if (back) back.up = false; } /** Links whose transport has come up but whose group has not been told yet. */ const pendingUp = []; function node(name, { refuseDials = false, tamper = null } = {}) { const events = []; const n = { name, events, session: null, dropRelays: false, meshCalls: { offers: 0, answers: 0, closed: [] }, }; n.send = async (sessionId, frame) => { const ch = chans.get(key(name, sessionId)); if (!ch || !ch.up) throw new Error('no such link'); if (n.dropRelays && groupFrameType(frame) === GROUP_FRAMES.RELAY) return; const target = nodes.get(ch.peer); if (!target) return; let wire = JSON.parse(JSON.stringify(frame)); if (tamper) wire = tamper(wire) || wire; if (groupFrameType(wire) === GROUP_FRAMES.INVITE && !target.session) { const invite = decodeEnvelope(wire); target.session = new GroupSession({ groupId: invite.gid, name: invite.name, isAdmin: false, subtle, send: target.send, emit: target.emit, mesh: target.mesh, }); await target.session.acceptInvite(ch.peerSessionId, invite); return; } if (!target.session) return; await target.session.handleFrame(ch.peerSessionId, wire); }; n.emit = (event, payload) => events.push({ event, payload }); // The transport half. A descriptor is a string that names the node and // the local session it belongs to, which is all the wiring below needs. n.mesh = { createOffer: async (fp) => { if (refuseDials) throw new Error('no network'); n.meshCalls.offers += 1; const sessionId = `m:${name}:${++counter}`; return { sessionId, descriptor: `OFF|${name}|${sessionId}|${fp.slice(0, 8)}` }; }, createAnswer: async (fp, descriptor) => { if (refuseDials) throw new Error('no network'); n.meshCalls.answers += 1; const [, offerNode, offerSid] = String(descriptor).split('|'); const sessionId = `m:${name}:${++counter}`; return { sessionId, descriptor: `ANS|${name}|${sessionId}|${offerNode}|${offerSid}` }; }, acceptAnswer: async (sessionId, descriptor) => { const [tag, ansNode, ansSid, offerNode, offerSid] = String(descriptor).split('|'); if (tag !== 'ANS') throw new Error('not an answer'); // The answer has to name the dial it belongs to. A real transport // enforces this through the descriptor's binding tag. if (offerNode !== name || offerSid !== sessionId) { throw new Error('answer does not match the dial'); } link(name, sessionId, ansNode, ansSid); pendingUp.push([name, sessionId], [ansNode, ansSid]); }, close: (sessionId) => { n.meshCalls.closed.push(sessionId); chans.delete(key(name, sessionId)); }, linkFingerprint: (sessionId) => chans.get(key(name, sessionId))?.linkFp || '', }; nodes.set(name, n); return n; } /** Tell both ends of every freshly built link that it is up, then settle. */ async function settle(rounds = 12) { for (let i = 0; i < rounds; i++) { await new Promise((r) => setTimeout(r, 0)); while (pendingUp.length) { const [who, sid] = pendingUp.shift(); try { nodes.get(who)?.session?.setSessionState(sid, true); } catch (_) {} } } } /** * Tear every group down. * * Not tidiness: a group holds live timers — dial deadlines, and the backoff * that re-arms a maintenance pass — and in Node those keep the process * alive long after the assertions are done. destroy() clears them, which is * the same thing the app does when a group is closed. */ function shutdown() { for (const n of nodes.values()) { try { n.session?.destroy(); } catch (_) {} } } return { nodes, node, link, cut, settle, shutdown, chans, key }; } const last = (node, event) => [...node.events].reverse().find((e) => e.event === event)?.payload; const all = (node, event) => node.events.filter((e) => e.event === event).map((e) => e.payload); const memberOf = (node, fp) => node.session.members.get(fp); /** * A ready three-member group over a star: Alice is the admin and holds a link to * Bob and to Carol; Bob and Carol have no link to each other. */ async function readyGroup(opts = {}) { const net = makeNet(); const gid = GroupSession.newId(); const alice = net.node('alice', opts.alice); const bob = net.node('bob', opts.bob); const carol = net.node('carol', opts.carol); net.link('alice', 'A>B', 'bob', 'B>A'); net.link('alice', 'A>C', 'carol', 'C>A'); alice.session = new GroupSession({ groupId: gid, name: 'Field team', isAdmin: true, subtle, send: alice.send, emit: alice.emit, mesh: alice.mesh, }); await alice.session.init(); await alice.session.invite([ { sessionId: 'A>B', name: 'Bob' }, { sessionId: 'A>C', name: 'Carol' }, ]); for (const n of [alice, bob, carol]) { assert.equal(n.session.phase, GROUP_PHASE.AWAITING_SAS, `${n.name} must reach the code step`); } // The humans compare and confirm. This is the gate the mesh waits behind. for (const n of [alice, bob, carol]) n.session.confirmSas(); return { net, gid, alice, bob, carol }; } // --------------------------------------------------------------------------- // 1. a star becomes a mesh // --------------------------------------------------------------------------- { const { net, alice, bob, carol } = await readyGroup(); const bobFp = bob.session.selfFp; const carolFp = carol.session.selfFp; // Before the mesh runs, Bob and Carol only know each other through Alice. assert.equal(memberOf(bob, carolFp).state, MEMBER_STATE.PENDING, 'Carol starts out with no direct link to Bob'); assert.equal(memberOf(bob, carolFp).sessionId, null); await net.settle(); assert.equal(memberOf(bob, carolFp).state, MEMBER_STATE.LINKED, 'Bob must end up directly linked to Carol'); assert.equal(memberOf(carol, bobFp).state, MEMBER_STATE.LINKED, 'Carol must end up directly linked to Bob'); assert.ok(memberOf(bob, carolFp).sessionId, 'the link must be bound to a session'); // Exactly one side dialled: the smaller fingerprint. const dialer = bobFp < carolFp ? bob : carol; const answerer = bobFp < carolFp ? carol : bob; assert.equal(dialer.meshCalls.offers, 1, 'the smaller fingerprint dials, once'); assert.equal(dialer.meshCalls.answers, 0, 'the dialer does not also answer'); assert.equal(answerer.meshCalls.offers, 0, 'the larger fingerprint does not dial'); assert.equal(answerer.meshCalls.answers, 1, 'the larger fingerprint answers, once'); // Alice already had links to both, so nothing was dialled for her. assert.equal(alice.meshCalls.offers, 0, 'the admin dials nobody: it is already linked to everyone'); // And now a message between them goes direct rather than through Alice. bob.events.length = 0; carol.events.length = 0; alice.events.length = 0; await bob.session.sendText('the mesh is up'); await tick(); const heard = last(carol, 'message'); assert.ok(heard, 'Carol must receive Bob\'s message'); assert.equal(heard.body, 'the mesh is up'); assert.equal(heard.relayed, false, 'and it must arrive over the direct link, not relayed'); // Alice still gets her own copy, directly, as a member. assert.equal(last(alice, 'message').relayed, false); net.shutdown(); } // --------------------------------------------------------------------------- // 2. the relay cannot substitute a descriptor // --------------------------------------------------------------------------- // // Alice carries every mesh dial between Bob and Carol. If she could swap the // descriptor for her own, she would sit inside the link built to route around // her. The signature over the descriptor is what stops that. { const net = makeNet(); const gid = GroupSession.newId(); const alice = net.node('alice', { // Alice rewrites the descriptor inside every relayed mesh dial. She has // to open the envelope to do it, which is exactly what a relaying member // is able to do — and exactly why the signature is inside. tamper: (wire) => { if (groupFrameType(wire) !== GROUP_FRAMES.RELAY) return wire; let frame; try { frame = decodeEnvelope(wire); } catch (_) { return wire; } const inner = frame?.inner; if (!inner) return wire; if (inner.type !== GROUP_FRAMES.MESH_OFFER && inner.type !== GROUP_FRAMES.MESH_ANSWER) return wire; inner.d = 'OFF|alice|m:alice:evil|00000000'; return encodeEnvelope(frame); }, }); const bob = net.node('bob'); const carol = net.node('carol'); net.link('alice', 'A>B', 'bob', 'B>A'); net.link('alice', 'A>C', 'carol', 'C>A'); alice.session = new GroupSession({ groupId: gid, name: 'Tampered', isAdmin: true, subtle, send: alice.send, emit: alice.emit, mesh: alice.mesh, }); await alice.session.init(); await alice.session.invite([ { sessionId: 'A>B', name: 'Bob' }, { sessionId: 'A>C', name: 'Carol' }, ]); for (const n of [alice, bob, carol]) n.session.confirmSas(); // The dial fails rather than completing against a substituted descriptor. // handleFrame rejects, so the rejection surfaces through the caller. const rejections = []; const guard = (n) => { const original = n.session.handleFrame.bind(n.session); n.session.handleFrame = (...args) => original(...args).catch((e) => { rejections.push(e.code); }); }; guard(bob); guard(carol); await net.settle(); assert.ok(rejections.includes('bad_signature'), 'a tampered mesh descriptor must fail its signature check'); const bobFp = bob.session.selfFp; const carolFp = carol.session.selfFp; assert.notEqual(memberOf(bob, carolFp).state, MEMBER_STATE.LINKED, 'no link may be built from a descriptor the sender did not sign'); // The group still works — over the relay, exactly as before the dial. bob.events.length = 0; carol.events.length = 0; await bob.session.sendText('still talking'); await tick(); const heard = last(carol, 'message'); assert.ok(heard, 'a failed mesh dial must not cost the group its relay path'); assert.equal(heard.relayed, true, 'and that copy is relayed, which the reader is told'); net.shutdown(); } // --------------------------------------------------------------------------- // 3. an answer from another dial is refused // --------------------------------------------------------------------------- { const { net, bob, carol } = await readyGroup(); await net.settle(); const bobFp = bob.session.selfFp; const carolFp = carol.session.selfFp; const dialer = bobFp < carolFp ? bob : carol; const answerer = bobFp < carolFp ? carol : bob; const peerFp = dialer === bob ? carolFp : bobFp; // Reach into the completed dial and replay its answer under a fresh nonce. // A nonce that is not the one this dial published must not be accepted, or // an answer captured from any earlier attempt could be pushed into a later // one. const forged = { type: GROUP_FRAMES.MESH_ANSWER, gid: dialer.session.groupId, epoch: dialer.session.epoch, from: peerFp, to: dialer.session.selfFp, d: 'ANS|x|m:x:1|y|m:y:1', n: toB64(new Uint8Array(16)), sig: toB64(new Uint8Array(96)), }; // The pair is already linked, so the dial is gone and the frame is dropped // before any signature work — which is itself the assertion: a settled pair // has nothing left for a replayed answer to attach to. await dialer.session._onMeshAnswer(forged); assert.equal(memberOf(dialer, peerFp).state, MEMBER_STATE.LINKED, 'a replayed answer must not disturb a link that is already up'); assert.equal(answerer.meshCalls.answers, 1, 'and must not provoke a second answer'); net.shutdown(); } // --------------------------------------------------------------------------- // 4. a pair that cannot connect stays on the relay, and stops trying // --------------------------------------------------------------------------- { // Carol's transport refuses to build anything. const { net, bob, carol } = await readyGroup({ bob: { refuseDials: true }, carol: { refuseDials: true } }); await net.settle(6); const bobFp = bob.session.selfFp; const carolFp = carol.session.selfFp; assert.equal(memberOf(bob, carolFp).state, MEMBER_STATE.PENDING, 'a pair that cannot dial stays on the relay path'); assert.equal(memberOf(bob, carolFp).sessionId, null, 'and is not left bound to a session that was never built'); // The group is unharmed: messages still flow through Alice. bob.events.length = 0; carol.events.length = 0; await bob.session.sendText('relayed after all'); await tick(); assert.equal(last(carol, 'message')?.body, 'relayed after all'); assert.equal(last(carol, 'message')?.relayed, true); // The failure is recorded with a backoff rather than retried in a loop. const dialer = bobFp < carolFp ? bob : carol; const peerFp = dialer === bob ? carolFp : bobFp; const failure = dialer.session._meshFailures.get(peerFp); assert.ok(failure, 'a failed dial must be recorded'); assert.ok(failure.attempts >= 1); assert.ok(failure.nextAt > Date.now(), 'and must not be retried immediately'); net.shutdown(); } // --------------------------------------------------------------------------- // 5. a chat the two already had is adopted, not re-dialled // --------------------------------------------------------------------------- { const { net, bob, carol } = await readyGroup(); // Bob and Carol already hold a verified 1:1 chat with each other, formed // before the group existed. Its key fingerprint is the same on both ends, // which is what a probe is signed against. net.link('bob', 'B>C', 'carol', 'C>B', { linkFp: 'shared-link-fingerprint' }); // The app drives probing; here we do it directly. Only one side has to send // — the other answers in kind, or the adoption would be one-sided. await bob.session.probeSession('B>C'); await tick(); const bobFp = bob.session.selfFp; const carolFp = carol.session.selfFp; assert.equal(memberOf(bob, carolFp).state, MEMBER_STATE.LINKED, 'the existing chat is adopted'); assert.equal(memberOf(bob, carolFp).sessionId, 'B>C', 'and bound by its own session id'); assert.equal(memberOf(carol, bobFp).state, MEMBER_STATE.LINKED, 'on both sides'); assert.equal(memberOf(carol, bobFp).sessionId, 'C>B'); // Whatever the mesh had started is abandoned in favour of the chat that // already worked — the pair ends up on THAT one, not on a second connection. await net.settle(); assert.equal(memberOf(bob, carolFp).sessionId, 'B>C', 'the pre-existing chat wins over anything the mesh was building'); assert.equal(memberOf(carol, bobFp).sessionId, 'C>B'); // A second probe on the same session is not sent again. assert.equal(await bob.session.probeSession('B>C'), false); // And messages between them travel over it, direct. bob.events.length = 0; carol.events.length = 0; await bob.session.sendText('over the chat we already had'); await tick(); assert.equal(last(carol, 'message')?.body, 'over the chat we already had'); assert.equal(last(carol, 'message')?.relayed, false); net.shutdown(); } // --------------------------------------------------------------------------- // 6. a probe replayed onto a different chat does not bind // --------------------------------------------------------------------------- // // This is the attack the link fingerprint exists for. Without it, any member // could capture another member's probe and present it on their own chat, and // group traffic for that member would then be encrypted to the impersonator. { const { net, alice, bob, carol } = await readyGroup(); const carolFp = carol.session.selfFp; // Carol signs a probe for the chat SHE holds with Alice... net.link('carol', 'C>X', 'alice', 'A>X', { linkFp: 'carols-own-link' }); const sig = await signLinkProbe(subtle, carol.session.identity.keyPair.privateKey, { groupId: carol.session.groupId, epoch: carol.session.epoch, fp: carolFp, linkFp: 'carols-own-link', }); const probe = { type: GROUP_FRAMES.PROBE, gid: carol.session.groupId, epoch: carol.session.epoch, fp: carolFp, sig: toB64(sig), }; // ...and Bob replays it on a chat of his own, claiming to be Carol. net.link('bob', 'B>M', 'alice', 'A>M', { linkFp: 'bobs-own-link' }); // Bob's group has no link to Carol yet, so the claim would otherwise take. await assert.rejects( () => bob.session._onProbe('B>M', probe), (e) => e.code === 'bad_signature', 'a probe signed for one chat must not bind another', ); const bobsCarol = memberOf(bob, carolFp); assert.notEqual(bobsCarol.sessionId, 'B>M', 'the replayed probe must bind nothing'); net.shutdown(); } // --------------------------------------------------------------------------- // 7. the group outlives the admin once the mesh is up // --------------------------------------------------------------------------- // // In a star, the admin going away partitions everyone else. That is the failure // the mesh exists to remove. { const { net, alice, bob, carol } = await readyGroup(); await net.settle(); const bobFp = bob.session.selfFp; const carolFp = carol.session.selfFp; assert.equal(memberOf(bob, carolFp).state, MEMBER_STATE.LINKED, 'precondition: the mesh formed'); // Alice's links drop on both sides. net.cut('bob', 'B>A'); net.cut('carol', 'C>A'); bob.session.setSessionState('B>A', false); carol.session.setSessionState('C>A', false); await tick(); bob.events.length = 0; carol.events.length = 0; const { delivered, total } = await bob.session.sendText('still here without the admin'); await tick(); assert.equal(total, 2, 'Bob still has two other members on the roster'); assert.equal(delivered, 1, 'the admin is offline and is reported as not reached'); assert.equal(last(carol, 'message')?.body, 'still here without the admin', 'but Carol receives it over the direct link the mesh built'); assert.equal(last(carol, 'message')?.relayed, false); net.shutdown(); } // --------------------------------------------------------------------------- // 8. an unsolicited hello cannot add a member // --------------------------------------------------------------------------- // // The admin publishes a roster when every invitee it is waiting on has replied. // A hello that answers no invitation must not reach that branch, or any member // who knows the group id could put an identity of their choosing into the group // and have the admin sign it. { const { net, alice, bob } = await readyGroup(); const before = alice.session.members.size; const beforeEpoch = alice.session.epoch; const stranger = await generateGroupIdentity(subtle); await alice.session.handleFrame('A>B', { type: GROUP_FRAMES.HELLO, gid: alice.session.groupId, epoch: alice.session.epoch, spki: toB64(stranger.spki), }); assert.equal(alice.session.members.size, before, 'a hello that answers no invitation must not create a member'); assert.equal(alice.session.members.has(stranger.fingerprint), false); assert.equal(alice.session.epoch, beforeEpoch, 'and must not push the group into a new epoch'); // The same frame wrapped in a relay — the route a member without a direct // link would have to use — is refused for the same reason. await bob.session.handleFrame('B>A', { type: GROUP_FRAMES.RELAY, gid: alice.session.groupId, to: alice.session.selfFp, hopped: false, inner: { type: GROUP_FRAMES.HELLO, gid: alice.session.groupId, epoch: alice.session.epoch, spki: toB64(stranger.spki), }, }); assert.equal(alice.session.members.has(stranger.fingerprint), false, 'and a relayed one is refused too'); net.shutdown(); } // --------------------------------------------------------------------------- // 9. a mesh link that dies is dialled again // --------------------------------------------------------------------------- // // A link dying is not the same as the member going away. If the connection is // beyond repair the pair goes back to the relay and dials again — which is the // difference between a mesh that heals and one that only ever degrades. { const { net, bob, carol } = await readyGroup(); await net.settle(); const bobFp = bob.session.selfFp; const carolFp = carol.session.selfFp; assert.equal(memberOf(bob, carolFp).state, MEMBER_STATE.LINKED, 'precondition: the mesh formed'); const dialer = bobFp < carolFp ? bob : carol; const answerer = bobFp < carolFp ? carol : bob; const offersBefore = dialer.meshCalls.offers; // What the app does when a mesh manager exhausts its own ICE restarts. dialer.session.unbindSession(dialer === bob ? carolFp : bobFp); answerer.session.unbindSession(answerer === bob ? carolFp : bobFp); assert.equal(memberOf(bob, carolFp).state, MEMBER_STATE.PENDING, 'a released member falls back to the relay, not to offline'); await net.settle(); assert.equal(dialer.meshCalls.offers, offersBefore + 1, 'the pair is dialled again'); assert.equal(memberOf(bob, carolFp).state, MEMBER_STATE.LINKED, 'and the mesh heals'); assert.equal(memberOf(carol, bobFp).state, MEMBER_STATE.LINKED); net.shutdown(); } console.log('group-mesh.test.mjs: all assertions passed');