Files
securebit-chat/tests/group-mesh.test.mjs
T

594 lines
25 KiB
JavaScript
Raw Normal View History

// 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');