feat(groups): group chats, and a mesh rather than a star; release v6.1.1
CodeQL Analysis / Analyze CodeQL (push) Waiting to run
Deploy Application / deploy (push) Waiting to run
Mirror to Codeberg / mirror (push) Waiting to run
Mirror to PrivacyGuides / mirror (push) Waiting to run

A group is an orchestration layer over the pairwise sessions the app already
holds. It owns no transport and no shared key: every frame leaves over a chat
that is already SAS-verified and already ratcheted, so a removed member simply
stops being sent anything. Membership is a roster the admin signs, ordered by
epoch, and the safety code is a commit-then-reveal round over every member's
fingerprint and nonce.

Delivery was the part that did not match its own description. The admin held a
link to everyone and nobody else held a link to anybody, so the relay path — the
documented fallback — was in fact the entire topology, and the admin going away
partitioned the group. Now, once the code is confirmed, each pair without a link
dials one over that relay path. The descriptors are compact enough to ride a
group frame and are signed with the sender's group identity key, so the relaying
member can drop a dial but cannot substitute one. The member with the smaller
fingerprint dials, which is the whole glare protocol.

Mesh links are released without a human comparing digits. Twenty-eight codes for
a group of eight is not a check anyone performs; the guarantee moves rather than
disappears, since the descriptor was signed by a key the signed roster names and
the group code covers. markGroupLinkVerified refuses any session whose in-band
exchange has not completed and whose peer has not proved possession of that key.

An existing 1:1 chat between two members is adopted instead of re-dialled, via a
probe bound to that session's own key fingerprint so it cannot be replayed onto
another chat to impersonate its author.

Security fix: g_hello was accepted on any session from anyone who knew the group
id, so any member could publish an identity the admin never invited and have the
admin sign and broadcast a roster containing it. It is now accepted only on a
session an invitation went out on, which also confines it to a direct link.

Mesh connections are kept out of the chat registry and muted from the document
events the header listens to, so a routing detail cannot tear down the display of
a conversation the user actually opened.
This commit is contained in:
lockbitchat
2026-08-25 16:27:08 -04:00
parent 6a98e2eb00
commit e00c3bd413
30 changed files with 11635 additions and 421 deletions
+22
View File
@@ -45,6 +45,12 @@ function closableChannel() {
};
const timer = setTimeout(() => {}, 10_000);
const manager = {
// A manager announces its lifecycle through _dispatchAppEvent rather
// than touching `document` directly, so that a connection with no window
// of its own — a group's mesh link — can be muted. The real method is
// borrowed here so this test still exercises the path the app uses.
_emitGlobalEvents: true,
_dispatchAppEvent: EnhancedSecureWebRTCManager.prototype._dispatchAppEvent,
intentionalDisconnect: false,
fileTransferSystem: { cleanup() { transferCleanups += 1; } },
dataChannel,
@@ -117,4 +123,20 @@ function closableChannel() {
assert.ok(dispatchedEvents.some(event => event.type === 'connection-cleaned'));
}
// A connection with no window of its own must not speak to the application.
//
// A group's mesh link is a routing detail, not a chat: it has no transcript and
// no header. Letting it broadcast peer-disconnect would have a link the user
// never opened reset the display of the chat they are actually looking at.
{
const dispatch = EnhancedSecureWebRTCManager.prototype._dispatchAppEvent;
const before = dispatchedEvents.length;
assert.equal(dispatch.call({ _emitGlobalEvents: false }, { type: 'peer-disconnect' }), false);
assert.equal(dispatchedEvents.length, before, 'a muted connection dispatches nothing');
dispatch.call({ _emitGlobalEvents: true }, { type: 'peer-disconnect' });
assert.equal(dispatchedEvents.length, before + 1, 'an ordinary chat still announces itself');
}
console.log('Disconnect cleanup tests passed');
+202
View File
@@ -0,0 +1,202 @@
// The seam between GroupSession and the reducer.
//
// Every other group test drives GroupSession directly and reads its fields. That
// left the path the UI actually renders from — emitted event, dispatched action,
// reducer state — completely uncovered, and it is where two bugs hid: a group
// name that the create dialog accepted but the protocol rejected, and a failure
// that reached the store but was rendered as "still working".
//
// This file mirrors app.jsx's groupEmitter exactly, so a change there that stops
// the code reaching the store fails here rather than on a user's screen.
import assert from 'node:assert/strict';
const { GroupSession, GROUP_FRAMES, groupFrameType, decodeEnvelope } =
await import('../src/group/GroupSession.js');
const {
groupsReducer, createInitialGroupState, createGroupEntry,
GROUP_ACTIONS: GA, GROUP_PHASE,
} = await import('../src/state/groupsStore.js');
const { GROUP_LIMITS, assertName } = await import('../src/group/groupCrypto.js');
const subtle = crypto.subtle;
const bytes = (s) => new TextEncoder().encode(s).length;
/**
* A store plus the emitter app.jsx installs on every group. Kept structurally
* identical to the real one: same actions, same order, same SET_ACTIVE_GROUP on
* a code arriving.
*/
function makeStore() {
let state = createInitialGroupState();
const dispatch = (action) => { state = groupsReducer(state, action); };
const emitterFor = (gid) => (event, payload = {}) => {
switch (event) {
case 'phase': dispatch({ type: GA.SET_PHASE, id: gid, phase: payload.phase }); break;
case 'members': dispatch({ type: GA.SET_MEMBERS, id: gid, members: payload.members, epoch: payload.epoch }); break;
case 'roster': dispatch({ type: GA.RENAME, id: gid, name: payload.name }); break;
case 'sas':
dispatch({ type: GA.SET_SAS, id: gid, code: payload.code });
dispatch({ type: GA.SET_ACTIVE_GROUP, id: gid });
break;
case 'confirmed': dispatch({ type: GA.CONFIRM_SAS, id: gid }); break;
case 'error': dispatch({ type: GA.SET_ERROR, id: gid, error: payload.error }); break;
default: break;
}
};
return { get: () => state, dispatch, emitterFor };
}
/** Two peers on one link, each with its own store, formed end to end. */
async function formPair(groupName) {
const links = new Map();
const nodes = new Map();
const gid = GroupSession.newId();
const make = (name, isAdmin) => {
const store = makeStore();
const node = { name, store, session: null, errors: [] };
node.send = async (sid, frame) => {
const pair = links.get(sid);
if (!pair) return;
const other = pair[0] === name ? pair[1] : pair[0];
const target = nodes.get(other);
if (!target) return;
const wire = JSON.parse(JSON.stringify(frame));
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.store.emitterFor(invite.gid),
});
await target.session.init();
// Exactly what handleAcceptInvite does, in the same order.
target.store.dispatch({
type: GA.CREATE_GROUP,
entry: createGroupEntry({ id: invite.gid, name: invite.name, selfFp: target.session.selfFp }),
});
await target.session.acceptInvite(sid, invite);
return;
}
if (!target.session) return;
// The app does not await this and swallows nothing — it routes the
// rejection into SET_ERROR. Mirror that.
try {
await target.session.handleFrame(sid, wire);
} catch (error) {
target.errors.push(error?.code || 'unknown');
target.store.dispatch({ type: GA.SET_ERROR, id: wire.gid, error: error?.code || 'frame_rejected' });
}
};
if (isAdmin) {
node.session = new GroupSession({
groupId: gid, name: groupName, isAdmin: true, subtle,
send: node.send, emit: store.emitterFor(gid),
});
}
nodes.set(name, node);
return node;
};
const admin = make('admin', true);
const joiner = make('joiner', false);
links.set('A-B', ['admin', 'joiner']);
await admin.session.init();
admin.store.dispatch({
type: GA.CREATE_GROUP,
entry: createGroupEntry({
id: gid, name: groupName, selfFp: admin.session.selfFp,
adminFp: admin.session.selfFp, isAdmin: true,
members: admin.session._memberSnapshot(),
}),
});
await admin.session.invite([{ sessionId: 'A-B', name: 'Peer' }]);
return { gid, admin, joiner };
}
// ---------------------------------------------------------------------------
// the code reaches the store, on BOTH sides
// ---------------------------------------------------------------------------
{
const { gid, admin, joiner } = await formPair('Field team');
for (const node of [admin, joiner]) {
const group = node.store.get().groups[gid];
assert.ok(group, `${node.name} must have the group in its store`);
assert.deepEqual(node.errors, [], `${node.name} saw no rejected frames`);
assert.equal(group.phase, GROUP_PHASE.AWAITING_SAS,
`${node.name}: the store must reach the safety-code step`);
// The two things the modal reads to decide whether to show the digits and
// enable the confirm button. Either one missing is the reported bug.
assert.match(group.sasCode, /^\d{7}$/, `${node.name}: the code must be IN THE STORE, not just in the session`);
assert.equal(group.sasConfirmed, false, `${node.name}: shown, not yet confirmed`);
assert.equal(group.members.length, 2, `${node.name}: both members are in the store`);
}
// Both stores hold the same digits — the whole point of the ceremony.
assert.equal(
admin.store.get().groups[gid].sasCode,
joiner.store.get().groups[gid].sasCode,
'both sides must render the same code',
);
// The modal's own gate: with a code present, confirming is allowed and lands.
for (const node of [admin, joiner]) {
node.session.confirmSas();
node.store.dispatch({ type: GA.CONFIRM_SAS, id: gid });
const group = node.store.get().groups[gid];
assert.equal(group.phase, GROUP_PHASE.READY, `${node.name}: confirmed group is ready`);
assert.equal(group.sasConfirmed, true);
}
}
// ---------------------------------------------------------------------------
// a group name in a non-Latin script
// ---------------------------------------------------------------------------
{
// The bug: the create dialog capped input at MAX_NAME_BYTES *characters*, so
// this 36-character name (68 bytes) passed the dialog and then threw inside
// the admin's roster signing. Formation died with nothing on screen.
const cyrillic = 'Наша секретная группа для обсуждений';
assert.ok(bytes(cyrillic) > cyrillic.length, 'the test name really is multi-byte');
assert.ok(bytes(cyrillic) > 64, 'and it really did exceed the old limit');
assert.doesNotThrow(() => assertName(cyrillic), 'the protocol must accept a normal Cyrillic name');
const { gid, admin, joiner } = await formPair(cyrillic);
for (const node of [admin, joiner]) {
const group = node.store.get().groups[gid];
assert.deepEqual(node.errors, [], `${node.name}: a Cyrillic name must not break formation`);
assert.equal(group.phase, GROUP_PHASE.AWAITING_SAS, `${node.name}: reached the code`);
assert.match(group.sasCode, /^\d{7}$/);
}
// The limit still exists — it is just counted in the same unit everywhere.
const tooLong = 'я'.repeat(GROUP_LIMITS.MAX_NAME_BYTES);
assert.ok(bytes(tooLong) > GROUP_LIMITS.MAX_NAME_BYTES);
assert.throws(() => assertName(tooLong), /too long/);
}
// ---------------------------------------------------------------------------
// a failure is visible in the store, not disguised as progress
// ---------------------------------------------------------------------------
{
const { gid, admin } = await formPair('Broken');
admin.store.dispatch({ type: GA.SET_ERROR, id: gid, error: 'ceremony_timed_out' });
const group = admin.store.get().groups[gid];
assert.equal(group.phase, GROUP_PHASE.FAILED);
assert.equal(group.error, 'ceremony_timed_out');
// FAILED is distinguishable from the working phases, which is what the modal
// needs in order to stop claiming it is still exchanging nonces.
assert.notEqual(group.phase, GROUP_PHASE.REVEALING);
assert.notEqual(group.phase, GROUP_PHASE.COMMITTING);
// And a failed group can never be confirmed into readiness.
admin.store.dispatch({ type: GA.CONFIRM_SAS, id: gid });
assert.notEqual(admin.store.get().groups[gid].phase, GROUP_PHASE.READY,
'a failed group must not be confirmable');
}
console.log('group-app-integration.test.mjs: all assertions passed');
+322
View File
@@ -0,0 +1,322 @@
// Group cryptography: the safety code, membership operations and message
// signatures.
//
// The assertions that matter most are the ones about ORDER. A group safety code
// of seven digits is only safe because no member can reveal their nonce before
// every commitment is in — otherwise a member who introduces two others can
// grind their own keys until both victims see the same digits. That gate is
// asserted directly here, not inferred from the code shape, and so is the
// mismatch a real man-in-the-middle would produce.
import assert from 'node:assert/strict';
const {
GROUP_LIMITS,
MEMBER_OPS,
GroupSasCeremony,
generateGroupIdentity,
fingerprintSpki,
importMemberIdentity,
buildCommitment,
verifyCommitment,
computeGroupSas,
canonicalFingerprints,
memberOpPayload,
signMemberOp,
verifyMemberOp,
hashBody,
signGroupMessage,
verifyGroupMessage,
newGroupId,
randomBytes,
toHex,
fromHex,
toB64,
fromB64,
} = await import('../src/group/groupCrypto.js');
const subtle = crypto.subtle;
const GID = newGroupId();
// ---------------------------------------------------------------------------
// identity keys
// ---------------------------------------------------------------------------
{
const alice = await generateGroupIdentity(subtle);
assert.equal(alice.fingerprint.length, 64, 'a fingerprint is SHA-256 in hex');
assert.equal(alice.keyPair.privateKey.extractable, false, 'the signing key must not be extractable');
// The fingerprint a peer computes from the published bytes must equal ours.
const imported = await importMemberIdentity(subtle, alice.spki);
assert.equal(imported.fingerprint, alice.fingerprint, 'both sides must name a member identically');
// A member is named by what their key hashes to, never by what they claim.
const bob = await generateGroupIdentity(subtle);
assert.notEqual(bob.fingerprint, alice.fingerprint);
// Garbage SPKI is refused rather than producing a usable member.
await assert.rejects(
() => importMemberIdentity(subtle, new Uint8Array(120).fill(7)),
/valid P-384 public key/,
);
await assert.rejects(
() => fingerprintSpki(subtle, new Uint8Array(8)),
/SPKI length out of range/,
);
}
// ---------------------------------------------------------------------------
// commitments
// ---------------------------------------------------------------------------
{
const fp = toHex(randomBytes(32));
const nonce = randomBytes(32);
const fields = { groupId: GID, epoch: 1, fingerprint: fp, nonce };
const commitment = await buildCommitment(subtle, fields);
assert.equal(commitment.length, GROUP_LIMITS.COMMIT_BYTES);
assert.equal(await verifyCommitment(subtle, commitment, fields), true);
// Every bound field is really bound.
assert.equal(await verifyCommitment(subtle, commitment, { ...fields, epoch: 2 }), false, 'epoch is bound');
assert.equal(await verifyCommitment(subtle, commitment, { ...fields, groupId: newGroupId() }), false, 'group id is bound');
assert.equal(await verifyCommitment(subtle, commitment, { ...fields, fingerprint: toHex(randomBytes(32)) }), false, 'member is bound');
assert.equal(await verifyCommitment(subtle, commitment, { ...fields, nonce: randomBytes(32) }), false, 'nonce is bound');
// Malformed input returns false rather than throwing into the caller.
assert.equal(await verifyCommitment(subtle, new Uint8Array(4), fields), false);
await assert.rejects(() => buildCommitment(subtle, { ...fields, nonce: randomBytes(8) }), /nonce must be 32 bytes/);
}
// ---------------------------------------------------------------------------
// the ordering gate — the reason seven digits is enough
// ---------------------------------------------------------------------------
{
const [a, b, c] = await Promise.all([
generateGroupIdentity(subtle), generateGroupIdentity(subtle), generateGroupIdentity(subtle),
]);
const members = [a.fingerprint, b.fingerprint, c.fingerprint];
const ceremony = new GroupSasCeremony({
groupId: GID, epoch: 1, selfFingerprint: a.fingerprint, memberFingerprints: members,
});
await ceremony.ownCommitment(subtle);
assert.equal(ceremony.commitmentsComplete, false);
// THE gate: no nonce leaves this device while a commitment is outstanding.
assert.throws(() => ceremony.reveal(), /cannot reveal before every member has committed/);
const bCeremony = new GroupSasCeremony({
groupId: GID, epoch: 1, selfFingerprint: b.fingerprint, memberFingerprints: members,
});
ceremony.acceptCommitment(b.fingerprint, await bCeremony.ownCommitment(subtle));
assert.throws(() => ceremony.reveal(), /cannot reveal/, 'two of three is still not all');
const cCeremony = new GroupSasCeremony({
groupId: GID, epoch: 1, selfFingerprint: c.fingerprint, memberFingerprints: members,
});
ceremony.acceptCommitment(c.fingerprint, await cCeremony.ownCommitment(subtle));
assert.equal(ceremony.commitmentsComplete, true);
assert.doesNotThrow(() => ceremony.reveal(), 'a complete commitment round unlocks the reveal');
// A member may not move after committing.
const other = new GroupSasCeremony({
groupId: GID, epoch: 1, selfFingerprint: b.fingerprint, memberFingerprints: members,
});
assert.throws(
() => ceremony.acceptCommitment(b.fingerprint, new Uint8Array(32).fill(9)),
/member changed their commitment/,
);
void other;
// Outsiders are refused outright.
assert.throws(
() => ceremony.acceptCommitment(toHex(randomBytes(32)), new Uint8Array(32)),
/commitment from a non-member/,
);
// A nonce that does not open its commitment fails the ceremony.
await assert.rejects(
() => ceremony.acceptReveal(subtle, b.fingerprint, randomBytes(32)),
/does not match the commitment/,
);
}
// ---------------------------------------------------------------------------
// an honest group converges on one code
// ---------------------------------------------------------------------------
/** Run a full commit -> reveal -> finish round between n honest members. */
async function honestCeremony(identities, { groupId = GID, epoch = 1 } = {}) {
const members = identities.map((i) => i.fingerprint);
const ceremonies = identities.map((i) => new GroupSasCeremony({
groupId, epoch, selfFingerprint: i.fingerprint, memberFingerprints: members,
}));
const commitments = [];
for (const c of ceremonies) commitments.push(await c.ownCommitment(subtle));
for (let i = 0; i < ceremonies.length; i++) {
for (let j = 0; j < ceremonies.length; j++) {
if (i !== j) ceremonies[i].acceptCommitment(members[j], commitments[j]);
}
}
const nonces = ceremonies.map((c) => c.reveal());
for (let i = 0; i < ceremonies.length; i++) {
for (let j = 0; j < ceremonies.length; j++) {
if (i !== j) await ceremonies[i].acceptReveal(subtle, members[j], nonces[j]);
}
}
return Promise.all(ceremonies.map((c) => c.finish(subtle)));
}
{
const identities = await Promise.all(
Array.from({ length: 5 }, () => generateGroupIdentity(subtle)),
);
const codes = await honestCeremony(identities);
assert.equal(new Set(codes).size, 1, 'every honest member must read the same digits');
assert.match(codes[0], /^\d{7}$/, 'the group code is seven digits, like the pairwise SAS');
}
// ---------------------------------------------------------------------------
// a man in the middle produces a MISMATCH — which is the whole point
// ---------------------------------------------------------------------------
{
// Bob and Carol are introduced by Mallory, who presents a different key to
// each of them. Neither can detect that from their own view alone; the group
// code is what differs when they compare it out loud.
const bob = await generateGroupIdentity(subtle);
const carol = await generateGroupIdentity(subtle);
const malloryToBob = await generateGroupIdentity(subtle);
const malloryToCarol = await generateGroupIdentity(subtle);
const bobsView = await honestCeremony([bob, carol, malloryToBob]);
const carolsView = await honestCeremony([bob, carol, malloryToCarol]);
assert.notEqual(
bobsView[0], carolsView[0],
'substituted key material must change the digits the victims read',
);
}
// ---------------------------------------------------------------------------
// the code does not depend on the order members were listed in
// ---------------------------------------------------------------------------
{
const fps = Array.from({ length: 4 }, () => toHex(randomBytes(32)));
const contributions = fps.map((fingerprint) => ({ fingerprint, nonce: randomBytes(32) }));
const forward = await computeGroupSas(subtle, { groupId: GID, epoch: 3, contributions });
const reversed = await computeGroupSas(subtle, { groupId: GID, epoch: 3, contributions: [...contributions].reverse() });
assert.equal(forward, reversed, 'member ordering must not change the code');
// But the epoch and the group do.
const nextEpoch = await computeGroupSas(subtle, { groupId: GID, epoch: 4, contributions });
assert.notEqual(forward, nextEpoch, 'a new epoch must produce a new code');
const otherGroup = await computeGroupSas(subtle, { groupId: newGroupId(), epoch: 3, contributions });
assert.notEqual(forward, otherGroup, 'the code is bound to the group');
// canonicalFingerprints is where the ordering and the limits are enforced.
assert.deepEqual(canonicalFingerprints([...fps].reverse()), [...fps].sort());
assert.throws(() => canonicalFingerprints([fps[0], fps[0]]), /duplicate member/);
assert.throws(() => canonicalFingerprints([fps[0]]), /at least two members/);
assert.throws(
() => canonicalFingerprints(Array.from({ length: 9 }, () => toHex(randomBytes(32)))),
/limited to 8 members/,
);
}
// ---------------------------------------------------------------------------
// membership operations
// ---------------------------------------------------------------------------
{
const admin = await generateGroupIdentity(subtle);
const bob = await generateGroupIdentity(subtle);
const carol = await generateGroupIdentity(subtle);
const { publicKey: adminKey } = await importMemberIdentity(subtle, admin.spki);
const fields = {
groupId: GID, epoch: 2, op: MEMBER_OPS.ADD,
memberFps: [admin.fingerprint, bob.fingerprint, carol.fingerprint],
name: 'Field team',
};
const sig = await signMemberOp(subtle, admin.keyPair.privateKey, fields);
assert.equal(await verifyMemberOp(subtle, adminKey, fields, sig), true);
// Every signed field is bound.
assert.equal(await verifyMemberOp(subtle, adminKey, { ...fields, epoch: 3 }, sig), false, 'epoch is signed');
assert.equal(await verifyMemberOp(subtle, adminKey, { ...fields, op: MEMBER_OPS.REMOVE }, sig), false, 'the operation is signed');
assert.equal(await verifyMemberOp(subtle, adminKey, { ...fields, name: 'Field teams' }, sig), false, 'the name is signed');
assert.equal(
await verifyMemberOp(subtle, adminKey, { ...fields, memberFps: [admin.fingerprint, bob.fingerprint] }, sig),
false, 'dropping a member invalidates the operation',
);
assert.equal(await verifyMemberOp(subtle, adminKey, { ...fields, groupId: newGroupId() }, sig), false, 'the group is signed');
// Someone else's key does not verify the admin's operation.
const { publicKey: bobKey } = await importMemberIdentity(subtle, bob.spki);
assert.equal(await verifyMemberOp(subtle, bobKey, fields, sig), false, 'only the admin can author membership');
// Reordering the member list is NOT a different operation — canonical order.
const shuffled = { ...fields, memberFps: [carol.fingerprint, admin.fingerprint, bob.fingerprint] };
assert.equal(await verifyMemberOp(subtle, adminKey, shuffled, sig), true, 'member order is canonicalised before signing');
// Malformed signatures are rejected without throwing.
assert.equal(await verifyMemberOp(subtle, adminKey, fields, new Uint8Array(4)), false);
assert.equal(await verifyMemberOp(subtle, adminKey, fields, 'not bytes'), false);
// Length-prefixed encoding: no two field sets can collide.
const a = memberOpPayload({ ...fields, name: 'ab' });
const b = memberOpPayload({ ...fields, name: 'a' });
assert.notEqual(toHex(a), toHex(b));
}
// ---------------------------------------------------------------------------
// group message signatures
// ---------------------------------------------------------------------------
{
const sender = await generateGroupIdentity(subtle);
const { publicKey } = await importMemberIdentity(subtle, sender.spki);
const body = 'meet at the usual place';
const bodyHash = await hashBody(subtle, body);
const fields = { groupId: GID, epoch: 1, seq: 7, senderFp: sender.fingerprint, bodyHash };
const sig = await signGroupMessage(subtle, sender.keyPair.privateKey, fields);
assert.equal(await verifyGroupMessage(subtle, publicKey, fields, sig), true);
// A different body under the same signature is what a tampering relay would
// have to produce, and it does not verify.
const otherHash = await hashBody(subtle, 'meet at the other place');
assert.equal(await verifyGroupMessage(subtle, publicKey, { ...fields, bodyHash: otherHash }, sig), false);
// Replaying one message under another sequence number or epoch fails too.
assert.equal(await verifyGroupMessage(subtle, publicKey, { ...fields, seq: 8 }, sig), false, 'seq is signed');
assert.equal(await verifyGroupMessage(subtle, publicKey, { ...fields, epoch: 2 }, sig), false, 'epoch is signed');
assert.equal(await verifyGroupMessage(subtle, publicKey, { ...fields, senderFp: toHex(randomBytes(32)) }, sig), false);
// Oversized bodies are refused before they are hashed.
await assert.rejects(
() => hashBody(subtle, 'x'.repeat(GROUP_LIMITS.MAX_BODY_BYTES + 1)),
/exceeds the group limit/,
);
}
// ---------------------------------------------------------------------------
// codecs bound their input
// ---------------------------------------------------------------------------
{
const bytes = randomBytes(48);
assert.equal(toHex(fromHex(toHex(bytes))), toHex(bytes));
assert.deepEqual(fromB64(toB64(bytes)), bytes);
assert.throws(() => fromHex('zz'), /not a hex string/);
assert.throws(() => fromHex('abc'), /not a hex string/);
// The base64 bound is applied before decoding, so a huge string cannot force
// a huge allocation.
assert.throws(() => fromB64('A'.repeat(100000)), /exceeds its limit/);
assert.throws(() => fromB64('!!!!', { max: 64 }), /malformed base64/);
}
console.log('group-crypto.test.mjs: all assertions passed');
+593
View File
@@ -0,0 +1,593 @@
// 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');
+186
View File
@@ -0,0 +1,186 @@
// Group frames against the transport's real rate limit.
//
// This is the test that was missing when group formation kept dying. The manager
// allows a burst of ten sends per second, but one group frame spends TWO of those
// slots — sendMessage checks the shared limiter and then sendSecureMessage checks
// it again — so the six frames formation sends back to back asked for twelve.
// The overflow was rejected as a plain Error with no code, which reached the user
// as a meaningless "frame_rejected", while the peer that never got the dropped
// frame waited until "ceremony_timed_out".
//
// The stand-in manager below reproduces that accounting exactly. Time is
// injected, so the pacing is asserted rather than waited for.
import assert from 'node:assert/strict';
const { createGroupSender, GROUP_SEND_GAP_MS } = await import('../src/group/groupSender.js');
/** A virtual clock: sleeps advance it instead of blocking. */
function makeClock() {
let t = 0;
return {
now: () => t,
sleep: async (ms) => { t += Math.max(0, ms); },
advance: (ms) => { t += ms; },
};
}
/**
* A manager with the real limiter's shape: ten slots per rolling second, and two
* slots consumed per outbound message.
*/
function makeManager(clock, { burst = 10, slotsPerSend = 2, connected = true } = {}) {
let windowStart = clock.now();
let used = 0;
const sent = [];
return {
sent,
isConnected: () => connected,
async sendMessage(payload) {
if (clock.now() - windowStart >= 1000) { windowStart = clock.now(); used = 0; }
if (used + slotsPerSend > burst) {
// Verbatim from EnhancedSecureWebRTCManager: a plain Error, no code.
throw new Error('Rate limit exceeded for message sending');
}
used += slotsPerSend;
sent.push({ at: clock.now(), payload });
return true;
},
};
}
// ---------------------------------------------------------------------------
// the six frames of group formation all arrive
// ---------------------------------------------------------------------------
{
const clock = makeClock();
const manager = makeManager(clock);
const send = createGroupSender({
getManager: () => manager, now: clock.now, sleep: clock.sleep,
});
// Exactly what forming a group puts on one session.
const frames = ['g_invite', 'g_member', 'g_member', 'g_roster', 'g_commit', 'g_reveal']
.map((type, i) => ({ type, gid: 'a'.repeat(32), seq: i }));
await Promise.all(frames.map((f) => send('s1', f)));
assert.equal(manager.sent.length, 6, 'every formation frame must reach the transport');
// Order is protocol-critical: a commitment has to arrive before the reveal
// that opens it. Concurrent sends must not reorder.
assert.deepEqual(
manager.sent.map((s) => JSON.parse(s.payload).type),
['g_invite', 'g_member', 'g_member', 'g_roster', 'g_commit', 'g_reveal'],
'frames keep the order they were queued in',
);
// And they are spaced, which is what keeps them inside the burst budget.
for (let i = 1; i < manager.sent.length; i++) {
const gap = manager.sent[i].at - manager.sent[i - 1].at;
assert.ok(gap >= GROUP_SEND_GAP_MS,
`frame ${i} came ${gap}ms after the last, under the ${GROUP_SEND_GAP_MS}ms budget`);
}
}
// ---------------------------------------------------------------------------
// without pacing the same run fails — the bug this exists to prevent
// ---------------------------------------------------------------------------
{
const clock = makeClock();
const manager = makeManager(clock);
const send = createGroupSender({
getManager: () => manager, now: clock.now, sleep: clock.sleep,
gapMs: 0, attempts: 1, // pacing and retries disabled
});
const results = await Promise.allSettled(
Array.from({ length: 6 }, (_, i) => send('s1', { type: 'g_commit', seq: i })),
);
const rejected = results.filter((r) => r.status === 'rejected');
assert.ok(rejected.length > 0, 'unpaced, the burst limit really does reject frames');
assert.match(rejected[0].reason.message, /Rate limit exceeded/);
// And the rejection carries no `code`, which is why it surfaced as the
// generic frame_rejected rather than anything a user could act on.
assert.equal(rejected[0].reason.code, undefined);
}
// ---------------------------------------------------------------------------
// a rate-limited frame is retried, not dropped
// ---------------------------------------------------------------------------
{
const clock = makeClock();
let calls = 0;
const manager = {
isConnected: () => true,
async sendMessage() {
calls++;
if (calls <= 2) throw new Error('Rate limit exceeded for secure message sending');
return true;
},
};
const send = createGroupSender({ getManager: () => manager, now: clock.now, sleep: clock.sleep });
await send('s1', { type: 'g_commit' });
assert.equal(calls, 3, 'the frame is retried until it lands — losing one strands every member');
}
// ---------------------------------------------------------------------------
// failures that will not improve are surfaced, not retried
// ---------------------------------------------------------------------------
{
const clock = makeClock();
let calls = 0;
const manager = {
isConnected: () => true,
async sendMessage() { calls++; throw new Error('Data channel not ready'); },
};
const send = createGroupSender({ getManager: () => manager, now: clock.now, sleep: clock.sleep });
await assert.rejects(() => send('s1', { type: 'g_commit' }), /Data channel not ready/);
assert.equal(calls, 1, 'a closed channel is reported at once rather than retried');
// One failure must not wedge the session: later frames still go out.
const ok = { isConnected: () => true, sent: 0, async sendMessage() { this.sent++; return true; } };
const send2 = createGroupSender({
getManager: (id) => (id === 'dead' ? manager : ok), now: clock.now, sleep: clock.sleep,
});
await assert.rejects(() => send2('dead', { type: 'g_commit' }));
await send2('dead', { type: 'g_reveal' }).catch(() => {});
await send2('live', { type: 'g_reveal' });
assert.equal(ok.sent, 1, 'a rejected frame does not block the queue behind it');
}
// ---------------------------------------------------------------------------
// a link that is gone is refused before anything is queued
// ---------------------------------------------------------------------------
{
const clock = makeClock();
const send = createGroupSender({ getManager: () => null, now: clock.now, sleep: clock.sleep });
await assert.rejects(() => send('s1', { type: 'g_commit' }), /no such link/);
const offline = createGroupSender({
getManager: () => ({ isConnected: () => false, sendMessage: async () => true }),
now: clock.now, sleep: clock.sleep,
});
await assert.rejects(() => offline('s1', { type: 'g_commit' }), /link is down/);
}
// ---------------------------------------------------------------------------
// separate sessions do not queue behind each other
// ---------------------------------------------------------------------------
{
const clock = makeClock();
const a = makeManager(clock);
const b = makeManager(clock);
const send = createGroupSender({
getManager: (id) => (id === 'a' ? a : b), now: clock.now, sleep: clock.sleep,
});
await Promise.all([send('a', { type: 'g_commit' }), send('b', { type: 'g_commit' })]);
assert.equal(a.sent.length, 1);
assert.equal(b.sent.length, 1);
assert.equal(a.sent[0].at, b.sent[0].at, 'each session paces independently');
}
console.log('group-sender.test.mjs: all assertions passed');
+707
View File
@@ -0,0 +1,707 @@
// A whole group, formed end to end over a virtual mesh.
//
// The topology is deliberately INCOMPLETE: Alice (the admin) holds a link to
// Bob and a link to Carol, and Bob and Carol have no link to each other. That is
// the real shape of a group at the moment it is created, and it is what forces
// the relay path — so this file covers both the happy case and the case the
// design actually has to survive.
//
// What is asserted, in order of how much it matters:
// 1. every member computes the SAME safety code, and only after a complete
// commit round;
// 2. a member the admin relays for still receives and verifies messages;
// 3. a forged or tampered message does not verify;
// 4. a member who sends two different bodies under one sequence number is
// caught.
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, fromB64, hashBody, signGroupMessage, generateGroupIdentity } =
await import('../src/group/groupCrypto.js');
const subtle = crypto.subtle;
// ---------------------------------------------------------------------------
// a virtual mesh
// ---------------------------------------------------------------------------
/**
* Nodes are wired by named links. `send(sessionId, frame)` delivers to whichever
* endpoint of that link is not the sender, awaiting the handler so the whole
* cascade settles before the test continues.
*/
function makeMesh() {
const links = new Map(); // sessionId -> [nodeA, nodeB]
const nodes = new Map(); // name -> node
const events = new Map(); // name -> [{ event, payload }]
function connect(a, b, sessionId) {
links.set(sessionId, [a, b]);
}
function makeNode(name, { isAdmin, groupId, groupName }) {
const log = [];
events.set(name, log);
const node = {
name,
session: null,
dropRelays: false,
events: log,
};
const send = async (sessionId, frame) => {
const pair = links.get(sessionId);
// A link that does not exist rejects, exactly as sendGroupFrame does
// for a session with no manager. Returning quietly would let a test
// pass on a send that never happened.
if (!pair) throw new Error('no such link');
const other = pair[0] === name ? pair[1] : pair[0];
const target = nodes.get(other);
if (!target) return;
if (node.dropRelays && groupFrameType(frame) === GROUP_FRAMES.RELAY) return;
// A frame is JSON on the wire; round-trip it so nothing in the test
// shares an object reference the real transport would have severed.
const wire = JSON.parse(JSON.stringify(frame));
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,
});
await target.session.acceptInvite(sessionId, invite);
return;
}
if (!target.session) return;
await target.session.handleFrame(sessionId, wire);
};
const emit = (event, payload) => { log.push({ event, payload }); };
node.send = send;
node.emit = emit;
if (isAdmin) {
node.session = new GroupSession({ groupId, name: groupName, isAdmin: true, subtle, send, emit });
}
nodes.set(name, node);
return node;
}
return { connect, makeNode, nodes };
}
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);
// ---------------------------------------------------------------------------
// form a three-member group over an incomplete mesh
// ---------------------------------------------------------------------------
async function formGroup() {
const mesh = makeMesh();
const gid = GroupSession.newId();
const alice = mesh.makeNode('alice', { isAdmin: true, groupId: gid, groupName: 'Field team' });
const bob = mesh.makeNode('bob', {});
const carol = mesh.makeNode('carol', {});
// Alice knows both. Bob and Carol do not know each other — the relay case.
mesh.connect('alice', 'bob', 'A-B');
mesh.connect('alice', 'carol', 'A-C');
await alice.session.init();
await alice.session.invite([
{ sessionId: 'A-B', name: 'Bob' },
{ sessionId: 'A-C', name: 'Carol' },
]);
return { mesh, gid, alice, bob, carol };
}
{
const { alice, bob, carol } = await formGroup();
// Everyone adopted the roster and reached the code.
for (const node of [alice, bob, carol]) {
assert.equal(node.session.phase, GROUP_PHASE.AWAITING_SAS,
`${node.name} must reach the safety-code step`);
assert.equal(node.session.members.size, 3, `${node.name} must see three members`);
}
// THE assertion: one group, one code.
const codes = [alice, bob, carol].map((n) => n.session.sasCode);
assert.equal(new Set(codes).size, 1, 'every member must read the same digits');
assert.match(codes[0], /^\d{7}$/);
// Bob and Carol learned about each other only through the admin's signed
// roster, and neither has a direct link to the other.
const bobsCarol = [...bob.session.members.values()].find((m) => m.fp === carol.session.selfFp);
assert.ok(bobsCarol, 'Bob must know Carol from the roster');
assert.equal(bobsCarol.state, MEMBER_STATE.PENDING, 'with no direct link, Carol is pending for Bob');
assert.equal(bobsCarol.sessionId, null);
// Nothing may be sent before the humans confirm.
await assert.rejects(() => bob.session.sendText('too early'), /group code has not been confirmed/);
// Confirm everywhere.
for (const node of [alice, bob, carol]) node.session.confirmSas();
for (const node of [alice, bob, carol]) {
assert.equal(node.session.phase, GROUP_PHASE.READY);
assert.equal(node.session.sasConfirmed, true);
assert.ok(last(node, 'confirmed'), 'the app is told the group is ready');
}
// ---- delivery over a direct link ----
await alice.session.sendText('roll call');
assert.equal(last(bob, 'message').body, 'roll call');
assert.equal(last(carol, 'message').body, 'roll call');
assert.equal(last(bob, 'message').fp, alice.session.selfFp, 'attributed to the signer');
// ---- delivery THROUGH the admin, between two members with no link ----
const result = await bob.session.sendText('on my way');
assert.equal(result.delivered, 2, 'Bob reaches both members: Alice directly, Carol relayed');
assert.equal(last(alice, 'message').body, 'on my way');
assert.equal(last(carol, 'message').body, 'on my way',
'a relayed message must arrive and verify');
assert.equal(last(carol, 'message').fp, bob.session.selfFp,
'the relay does not become the author');
// The relay is single-hop: Carol never forwards what she received.
assert.equal(carol.session._pendingCeremony.length, 0);
// ---- a duplicate is absorbed, not shown twice ----
const before = all(carol, 'message').length;
await bob.session._sendTo(carol.session.selfFp, {
type: GROUP_FRAMES.MESSAGE,
gid: bob.session.groupId,
epoch: bob.session.epoch,
seq: result.seq,
fp: bob.session.selfFp,
ts: Date.now(),
body: 'on my way',
sig: toB64(await signGroupMessage(subtle, bob.session.identity.keyPair.privateKey, {
groupId: bob.session.groupId, epoch: bob.session.epoch, seq: result.seq,
senderFp: bob.session.selfFp, bodyHash: await hashBody(subtle, 'on my way'),
})),
});
assert.equal(all(carol, 'message').length, before, 'a repeated frame is absorbed');
}
// ---------------------------------------------------------------------------
// the reveal gate really is closed until every commitment is in
// ---------------------------------------------------------------------------
{
const mesh = makeMesh();
const gid = GroupSession.newId();
const alice = mesh.makeNode('alice', { isAdmin: true, groupId: gid, groupName: 'Slow' });
const bob = mesh.makeNode('bob', {});
const carol = mesh.makeNode('carol', {});
mesh.connect('alice', 'bob', 'A-B');
mesh.connect('alice', 'carol', 'A-C');
// Alice refuses to carry anything between Bob and Carol, so Carol's
// commitment never reaches Bob and Bob's never reaches Carol.
alice.dropRelays = true;
await alice.session.init();
await alice.session.invite([
{ sessionId: 'A-B', name: 'Bob' },
{ sessionId: 'A-C', name: 'Carol' },
]);
assert.equal(bob.session.phase, GROUP_PHASE.COMMITTING,
'an incomplete commit round leaves the member waiting, never revealing');
assert.equal(bob.session.ceremony.revealed, false, 'no nonce left the device');
assert.equal(bob.session.sasCode, '', 'and no code was produced');
assert.throws(() => bob.session.confirmSas(), /no group code to confirm/);
}
// ---------------------------------------------------------------------------
// forged and tampered messages
// ---------------------------------------------------------------------------
{
const { alice, bob, carol } = await formGroup();
for (const node of [alice, bob, carol]) node.session.confirmSas();
const gid = bob.session.groupId;
const epoch = bob.session.epoch;
// A body swapped after signing does not verify.
const bodyHash = await hashBody(subtle, 'the real text');
const sig = await signGroupMessage(subtle, bob.session.identity.keyPair.privateKey, {
groupId: gid, epoch, seq: 40, senderFp: bob.session.selfFp, bodyHash,
});
await assert.rejects(
() => carol.session._onMessage({
type: GROUP_FRAMES.MESSAGE, gid, epoch, seq: 40, fp: bob.session.selfFp,
ts: Date.now(), body: 'the SWAPPED text', sig: toB64(sig),
}),
/signature did not verify/,
);
// Alice cannot sign as Bob, however well placed she is to relay for him.
const aliceSig = await signGroupMessage(subtle, alice.session.identity.keyPair.privateKey, {
groupId: gid, epoch, seq: 41, senderFp: bob.session.selfFp, bodyHash,
});
await assert.rejects(
() => carol.session._onMessage({
type: GROUP_FRAMES.MESSAGE, gid, epoch, seq: 41, fp: bob.session.selfFp,
ts: Date.now(), body: 'the real text', sig: toB64(aliceSig),
}),
/signature did not verify/,
);
// An outsider with a perfectly valid signature is still not a member.
const outsider = await generateGroupIdentity(subtle);
const outsiderSig = await signGroupMessage(subtle, outsider.keyPair.privateKey, {
groupId: gid, epoch, seq: 1, senderFp: outsider.fingerprint, bodyHash,
});
await assert.rejects(
() => carol.session._onMessage({
type: GROUP_FRAMES.MESSAGE, gid, epoch, seq: 1, fp: outsider.fingerprint,
ts: Date.now(), body: 'the real text', sig: toB64(outsiderSig),
}),
/message from a non-member/,
);
// A message from an epoch the group has left is refused even though it verifies.
await assert.rejects(
() => carol.session._onMessage({
type: GROUP_FRAMES.MESSAGE, gid, epoch: epoch + 5, seq: 42, fp: bob.session.selfFp,
ts: Date.now(), body: 'the real text', sig: toB64(sig),
}),
/another epoch/,
);
}
// ---------------------------------------------------------------------------
// a member telling two halves of the group different things is detectable
// ---------------------------------------------------------------------------
{
const { alice, bob, carol } = await formGroup();
for (const node of [alice, bob, carol]) node.session.confirmSas();
const gid = bob.session.groupId;
const epoch = bob.session.epoch;
const seq = 7;
const signed = async (body) => toB64(await signGroupMessage(
subtle, bob.session.identity.keyPair.privateKey,
{ groupId: gid, epoch, seq, senderFp: bob.session.selfFp, bodyHash: await hashBody(subtle, body) },
));
const frame = (body, sig) => ({
type: GROUP_FRAMES.MESSAGE, gid, epoch, seq, fp: bob.session.selfFp,
ts: Date.now(), body, sig,
});
const sellFrame = frame('sell', await signed('sell'));
const buyFrame = frame('buy', await signed('buy'));
await carol.session._onMessage(sellFrame);
assert.equal(last(carol, 'message').body, 'sell');
// The second, differently-signed body under the same sequence number is the
// split. Both signatures are valid, which is exactly what makes it provable.
await assert.rejects(
() => carol.session._onMessage(buyFrame),
/conflicting messages under one sequence number/,
);
const flagged = last(carol, 'inconsistency');
assert.ok(flagged, 'the app is told which member did it');
assert.equal(flagged.fp, bob.session.selfFp);
assert.equal(flagged.seq, seq);
}
// ---------------------------------------------------------------------------
// removing a member re-keys the group
// ---------------------------------------------------------------------------
{
const { alice, bob, carol } = await formGroup();
for (const node of [alice, bob, carol]) node.session.confirmSas();
const codeBefore = alice.session.sasCode;
const epochBefore = alice.session.epoch;
await alice.session.removeMember(carol.session.selfFp);
assert.equal(alice.session.epoch, epochBefore + 1, 'removal opens a new epoch');
assert.equal(alice.session.members.size, 2);
assert.equal(bob.session.members.size, 2, 'the remaining member adopted the new roster');
assert.ok(!bob.session.members.has(carol.session.selfFp), 'Carol is gone from Bobs roster');
// A new epoch means a new code, and it must be compared again before
// anything is sent.
assert.equal(alice.session.phase, GROUP_PHASE.AWAITING_SAS);
assert.equal(bob.session.phase, GROUP_PHASE.AWAITING_SAS);
assert.notEqual(alice.session.sasCode, codeBefore, 're-keying must change the digits');
assert.equal(alice.session.sasCode, bob.session.sasCode, 'and both remaining members agree');
assert.equal(alice.session.sasConfirmed, false);
await assert.rejects(() => alice.session.sendText('still here?'), /has not been confirmed/);
}
// ---------------------------------------------------------------------------
// relay hygiene
// ---------------------------------------------------------------------------
{
const { alice, bob, carol } = await formGroup();
for (const node of [alice, bob, carol]) node.session.confirmSas();
// A relay envelope already marked as hopped is not forwarded again.
let forwarded = 0;
const realSend = alice.session._send;
alice.session._send = async (sid, frame) => { forwarded++; return realSend(sid, frame); };
await alice.session._onRelay('A-B', {
type: GROUP_FRAMES.RELAY, gid: alice.session.groupId, to: carol.session.selfFp,
hopped: true,
inner: { type: GROUP_FRAMES.MESSAGE, gid: alice.session.groupId },
});
assert.equal(forwarded, 0, 'a second hop is refused');
// A nested relay is refused outright.
await alice.session._onRelay('A-B', {
type: GROUP_FRAMES.RELAY, gid: alice.session.groupId, to: carol.session.selfFp,
hopped: false,
inner: { type: GROUP_FRAMES.RELAY, gid: alice.session.groupId, to: bob.session.selfFp },
});
assert.equal(forwarded, 0, 'relays do not nest');
alice.session._send = realSend;
// A frame for another group is ignored entirely.
const otherGid = GroupSession.newId();
await alice.session.handleFrame('A-B', { type: GROUP_FRAMES.MESSAGE, gid: otherGid, epoch: 1, seq: 1 });
// (no throw, no message emitted)
assert.equal(all(alice, 'message').length, 0);
}
// ---------------------------------------------------------------------------
// the envelope: what keeps a signature intact through the chat path
// ---------------------------------------------------------------------------
{
/**
* Stand-in for EnhancedSecureCryptoUtils.sanitizeMessage, which every group
* frame passes through on its way out: DOMPurify escapes the HTML-significant
* characters, control characters go, blank runs collapse, the string is
* trimmed and then cut to 2000 characters.
*/
const sanitizeLikeTheChatPath = (s) => String(s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, '')
.replace(/\r\n?/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
.substring(0, 2000);
const { alice, bob, carol } = await formGroup();
for (const node of [alice, bob, carol]) node.session.confirmSas();
// A body full of exactly the characters that path rewrites.
const hostile = 'if a < b && c > d then "quote" \n\n\n trailing ';
const gid = bob.session.groupId;
const epoch = bob.session.epoch;
const bodyHash = await hashBody(subtle, hostile);
const sig = await signGroupMessage(subtle, bob.session.identity.keyPair.privateKey, {
groupId: gid, epoch, seq: 99, senderFp: bob.session.selfFp, bodyHash,
});
const frame = {
type: GROUP_FRAMES.MESSAGE, gid, epoch, seq: 99, fp: bob.session.selfFp,
ts: Date.now(), body: hostile, sig: toB64(sig),
};
// Without the envelope the chat path would rewrite the body and the
// signature would no longer match — the failure this design exists to avoid.
const bare = JSON.parse(sanitizeLikeTheChatPath(JSON.stringify(frame)));
assert.notEqual(bare.body, hostile, 'the chat path really does rewrite a raw body');
// With it, every byte survives.
const wrapped = encodeEnvelope(frame);
const throughTheWire = JSON.parse(sanitizeLikeTheChatPath(JSON.stringify(wrapped)));
assert.deepEqual(throughTheWire, wrapped, 'the envelope passes through untouched');
const recovered = decodeEnvelope(throughTheWire);
assert.equal(recovered.body, hostile, 'the body arrives byte for byte');
// And it verifies on the far side.
await carol.session._onMessage(recovered);
assert.equal(last(carol, 'message').body, hostile);
// The routing hints outside the encoding carry no authority.
assert.throws(() => decodeEnvelope({ ...wrapped, gid: GroupSession.newId() }),
/group id does not match/);
assert.throws(() => decodeEnvelope({ ...wrapped, t: GROUP_FRAMES.ROSTER }),
/type does not match/);
assert.throws(() => decodeEnvelope({ type: 'g_env', gid, t: 'g_msg', d: 'bm90IGpzb24=' }),
/Unexpected token|not valid JSON|no recognisable frame/);
// A frame that would be truncated by the chat path is refused at the source,
// where it is still a clear error rather than a corrupt payload.
assert.throws(
() => encodeEnvelope({ ...frame, body: 'x'.repeat(4000) }),
/exceeds the transport budget/,
);
// Every frame the protocol actually emits fits the budget, including the
// largest one: a roster for a full group.
const bigRoster = {
type: GROUP_FRAMES.ROSTER, gid, epoch: 1, op: 'create', name: 'x'.repeat(64),
adminSpki: toB64(new Uint8Array(120)),
members: Array.from({ length: 8 }, () => 'ab'.repeat(32)),
sig: toB64(new Uint8Array(96)),
};
assert.doesNotThrow(() => encodeEnvelope(bigRoster), 'a full 8-member roster must fit');
}
// ---------------------------------------------------------------------------
// nobody reveals before their own commitment is on the wire
// ---------------------------------------------------------------------------
{
// Ordering regression. Draining held frames before broadcasting our own
// commitment could complete the round on the spot and put a reveal out ahead
// of the commitment it belongs to. The peer then had a reveal it could not
// check and had to hold it, so the ceremony only finished if that one later
// frame arrived — and when it did not, every member sat at "exchanging
// nonces" waiting on a nonce that had already been sent.
const mesh = makeMesh();
const gid = GroupSession.newId();
const alice = mesh.makeNode('alice', { isAdmin: true, groupId: gid, groupName: 'Pair' });
const bob = mesh.makeNode('bob', {});
mesh.connect('alice', 'bob', 'A-B');
const wire = [];
for (const node of [alice, bob]) {
const inner = node.send;
node.send = async (sid, frame) => {
wire.push(`${node.name}:${groupFrameType(frame)}`);
return inner(sid, frame);
};
}
// Bob's session is built later, from `bob.send`, so it picks the wrapper up
// on its own. Alice's already exists and captured the original at
// construction — spy on the session itself.
alice.session._send = alice.send;
await alice.session.init();
await alice.session.invite([{ sessionId: 'A-B', name: 'Bob' }]);
for (const node of [alice, bob]) {
const commit = wire.indexOf(`${node.name}:${GROUP_FRAMES.COMMIT}`);
const reveal = wire.indexOf(`${node.name}:${GROUP_FRAMES.REVEAL}`);
assert.ok(commit >= 0, `${node.name} must broadcast a commitment`);
assert.ok(reveal >= 0, `${node.name} must broadcast a reveal`);
assert.ok(commit < reveal, `${node.name} must commit before revealing (wire: ${wire.join(' ')})`);
assert.equal(node.session._pendingCeremony.length, 0,
`${node.name} should not need to hold any ceremony frame`);
}
// A two-member group is the smallest one, and it must still converge.
assert.equal(alice.session.phase, GROUP_PHASE.AWAITING_SAS);
assert.equal(bob.session.phase, GROUP_PHASE.AWAITING_SAS);
assert.equal(alice.session.sasCode, bob.session.sasCode);
assert.match(alice.session.sasCode, /^\d{7}$/);
}
// ---------------------------------------------------------------------------
// leaving a group frees its members for the next one
// ---------------------------------------------------------------------------
{
const { alice, bob, carol } = await formGroup();
for (const node of [alice, bob, carol]) node.session.confirmSas();
// A group of three losing one is still a group; losing two is not, and the
// admin must be told it ended rather than throwing mid-teardown.
await alice.session.removeMember(carol.session.selfFp);
assert.equal(alice.session.members.size, 2);
await assert.rejects(
() => alice.session.removeMember(bob.session.selfFp),
/cannot drop below two members/,
);
// When the admin leaves, the remaining member's group is over: nobody else
// can sign a roster, so there is no next epoch and no code to compare again.
await alice.session.leave();
assert.ok(last(bob, 'ended'), 'the remaining member is told the group ended');
assert.equal(last(bob, 'ended').reason, 'admin_left');
assert.ok(!bob.session.members.has(alice.session.selfFp));
}
// ---------------------------------------------------------------------------
// a member who leaves disappears from everyone's list
// ---------------------------------------------------------------------------
{
const { alice, bob, carol } = await formGroup();
for (const node of [alice, bob, carol]) node.session.confirmSas();
const carolFp = carol.session.selfFp;
await carol.session.leave();
// The admin's own view is what regressed: it deleted the member internally
// but never told the UI, so a member who had visibly left stayed on screen.
assert.ok(!alice.session.members.has(carolFp), 'the admin drops the departed member');
const adminView = last(alice, 'members');
assert.ok(adminView, 'the admin must emit a member list when someone leaves');
assert.ok(!adminView.members.some((m) => m.fp === carolFp),
'the departed member must be gone from the list the admin renders');
assert.equal(adminView.members.length, 2);
// And the remaining member learns it through the signed roster.
assert.ok(!bob.session.members.has(carolFp), 'the other member drops her too');
assert.ok(!last(bob, 'members').members.some((m) => m.fp === carolFp));
// Membership changed, so the code must be compared again.
assert.equal(alice.session.phase, GROUP_PHASE.AWAITING_SAS);
assert.equal(alice.session.sasCode, bob.session.sasCode);
}
// ---------------------------------------------------------------------------
// the admin can invite into a group that is already running
// ---------------------------------------------------------------------------
{
const mesh = makeMesh();
const gid = GroupSession.newId();
const alice = mesh.makeNode('alice', { isAdmin: true, groupId: gid, groupName: 'Field team' });
const bob = mesh.makeNode('bob', {});
const dana = mesh.makeNode('dana', {});
mesh.connect('alice', 'bob', 'A-B');
mesh.connect('alice', 'dana', 'A-D');
await alice.session.init();
await alice.session.invite([{ sessionId: 'A-B', name: 'Bob' }]);
alice.session.confirmSas();
bob.session.confirmSas();
const firstCode = alice.session.sasCode;
const firstEpoch = alice.session.epoch;
assert.equal(alice.session.members.size, 2);
// The group stays usable while the invitation is outstanding — nothing about
// the membership has changed yet.
await alice.session.sendText('before dana');
assert.equal(last(bob, 'message').body, 'before dana');
await alice.session.addMembers([{ sessionId: 'A-D', name: 'Dana' }]);
for (const node of [alice, bob, dana]) {
assert.equal(node.session.members.size, 3, `${node.name} sees three members`);
assert.equal(node.session.epoch, firstEpoch + 1, `${node.name} moved to the next epoch`);
assert.equal(node.session.phase, GROUP_PHASE.AWAITING_SAS, `${node.name} must compare a new code`);
}
// A changed member set means a changed code — the old one no longer says
// anything about who is in the room.
const codes = [alice, bob, dana].map((n) => n.session.sasCode);
assert.equal(new Set(codes).size, 1, 'all three agree on the new code');
assert.notEqual(codes[0], firstCode, 'adding a member must change the code');
// And nothing flows until it is confirmed again, including for the member
// who was already verified a moment ago.
await assert.rejects(() => bob.session.sendText('too early'), /has not been confirmed/);
for (const node of [alice, bob, dana]) node.session.confirmSas();
await alice.session.sendText('welcome dana');
assert.equal(last(dana, 'message').body, 'welcome dana');
assert.equal(last(bob, 'message').body, 'welcome dana');
// Dana can reach Bob even with no direct link, relayed by the admin.
const result = await dana.session.sendText('hello bob');
assert.equal(result.delivered, 2);
assert.equal(last(bob, 'message').body, 'hello bob');
assert.equal(last(bob, 'message').fp, dana.session.selfFp);
}
// ---------------------------------------------------------------------------
// invitation rounds that should not happen
// ---------------------------------------------------------------------------
{
const { alice, bob, carol } = await formGroup();
for (const node of [alice, bob, carol]) node.session.confirmSas();
// Only the admin invites.
await assert.rejects(
() => bob.session.addMembers([{ sessionId: 'X', name: 'Nope' }]),
/only the admin invites/,
);
// A session already carrying a member cannot be invited again — it would
// answer with a second identity key and take two slots in the safety code.
const bobsSession = [...alice.session.members.values()].find((m) => m.fp === bob.session.selfFp).sessionId;
await assert.rejects(
() => alice.session.addMembers([{ sessionId: bobsSession, name: 'Bob again' }]),
/already a member/,
);
// The ceiling is enforced before anything is sent.
await assert.rejects(
() => alice.session.addMembers(Array.from({ length: 7 }, (_, i) => ({ sessionId: `s${i}`, name: 'x' }))),
/limited to 8 members/,
);
// An add nobody answers leaves the group exactly as it was.
const epochBefore = alice.session.epoch;
const membersBefore = alice.session.members.size;
mesh_unreachable: {
// A link the mesh does not know about: the send fails, so the round is
// abandoned at once rather than half-applied.
await assert.rejects(
() => alice.session.addMembers([{ sessionId: 'nowhere', name: 'Ghost' }]),
/could not be sent/,
);
}
assert.equal(alice.session.epoch, epochBefore, 'a failed invitation does not open an epoch');
assert.equal(alice.session.members.size, membersBefore, 'and does not change the membership');
assert.equal(alice.session._pendingAdd, null, 'the round is cleared');
}
// ---------------------------------------------------------------------------
// a member going offline is not a member leaving
// ---------------------------------------------------------------------------
{
const { alice, bob, carol } = await formGroup();
for (const node of [alice, bob, carol]) node.session.confirmSas();
const carolFp = carol.session.selfFp;
const carolsSession = [...alice.session.members.values()].find((m) => m.fp === carolFp).sessionId;
const epochBefore = alice.session.epoch;
const codeBefore = alice.session.sasCode;
alice.session.setSessionState(carolsSession, false);
// Still a member. Membership is a signed, epoch-ordered fact and a dropped
// connection does not change it — re-keying the group every time someone's
// network hiccups would make everyone re-compare a code for nothing.
assert.ok(alice.session.members.has(carolFp), 'an offline member is still a member');
assert.equal(alice.session.epoch, epochBefore, 'no new epoch for a dropped link');
assert.equal(alice.session.sasCode, codeBefore, 'and no new code to compare');
assert.equal(alice.session.phase, GROUP_PHASE.READY, 'the group stays usable');
// But unmistakably unreachable, and the UI is told.
assert.equal(alice.session.members.get(carolFp).state, MEMBER_STATE.LOST);
const view = last(alice, 'members');
assert.equal(view.members.find((m) => m.fp === carolFp).state, MEMBER_STATE.LOST,
'the rendered list marks them lost rather than dropping or hiding them');
// Sending reports the shortfall instead of pretending it reached everyone,
// and it names WHO was missed. A count alone cannot tell one absent member
// from another, which is what the app needs to know to stop repeating
// itself under every message for as long as somebody stays away.
const result = await alice.session.sendText('anyone there?');
assert.equal(result.total, 2);
assert.ok(result.delivered < result.total, 'delivery to an offline member is reported, not assumed');
assert.deepEqual(result.unreachable.map((m) => m.fp), [carolFp],
'the member who was missed is named, not just counted');
// The same shortfall reported twice is the same shortfall: the app compares
// these sets, so they have to be stable for an unchanged situation.
const again = await alice.session.sendText('still anyone there?');
assert.deepEqual(again.unreachable.map((m) => m.fp), [carolFp]);
// Coming back restores the link with no ceremony at all.
alice.session.setSessionState(carolsSession, true);
assert.equal(alice.session.members.get(carolFp).state, MEMBER_STATE.LINKED);
assert.equal(alice.session.epoch, epochBefore, 'reconnecting does not re-key either');
// Removing them, by contrast, IS a membership change and does re-key.
await alice.session.removeMember(carolFp);
assert.equal(alice.session.epoch, epochBefore + 1);
assert.ok(!alice.session.members.has(carolFp));
assert.equal(alice.session.phase, GROUP_PHASE.AWAITING_SAS, 'a real removal makes everyone compare again');
}
console.log('group-session-e2e.test.mjs: all assertions passed');
+271
View File
@@ -0,0 +1,271 @@
// The groups reducer: isolation between groups, and the two transitions that
// carry security meaning — a group may not become "verified" without a code the
// user could have compared, and any membership change must drop that
// confirmation rather than carry it into a new epoch.
import assert from 'node:assert/strict';
const {
groupsReducer,
createInitialGroupState,
createGroupEntry,
GROUP_ACTIONS: A,
GROUP_PHASE,
MEMBER_STATE,
decorateGroup,
groupSub,
groupDot,
groupInitials,
linkedCount,
} = await import('../src/state/groupsStore.js');
const { GROUP_LIMITS } = await import('../src/group/groupCrypto.js');
const fp = (n) => String(n).repeat(64).slice(0, 64);
const SELF = fp(1);
const BOB = fp(2);
const CAROL = fp(3);
function members(...states) {
const fps = [SELF, BOB, CAROL];
return fps.map((f, i) => ({
fp: f,
name: ['You', 'Bob', 'Carol'][i],
sessionId: i === 0 ? null : `s${i}`,
state: states[i] || (i === 0 ? MEMBER_STATE.SELF : MEMBER_STATE.LINKED),
}));
}
function withTwoGroups() {
let state = createInitialGroupState();
state = groupsReducer(state, {
type: A.CREATE_GROUP,
entry: createGroupEntry({ id: 'g1', name: 'Field team', selfFp: SELF, adminFp: SELF, isAdmin: true, members: members() }),
});
state = groupsReducer(state, {
type: A.CREATE_GROUP,
entry: createGroupEntry({ id: 'g2', name: 'Review', selfFp: SELF, members: members() }),
});
return state;
}
// CREATE_GROUP activates the newest and preserves order.
{
const state = withTwoGroups();
assert.deepEqual(state.order, ['g1', 'g2']);
assert.equal(state.activeGroupId, 'g2');
assert.equal(state.groups.g1.phase, GROUP_PHASE.FORMING, 'a new group starts unformed');
assert.equal(state.groups.g1.sasConfirmed, false);
// A duplicate id is ignored rather than clobbering the live group.
const again = groupsReducer(state, { type: A.CREATE_GROUP, entry: createGroupEntry({ id: 'g1', name: 'Impostor' }) });
assert.equal(again, state, 'a repeated group id is a no-op');
}
// Isolation: touching g2 leaves g1 referentially untouched.
{
const before = withTwoGroups();
const g1Ref = before.groups.g1;
const after = groupsReducer(before, { type: A.ADD_MESSAGE, id: 'g2', message: { id: 1, message: 'hi', type: 'sent' } });
assert.equal(after.groups.g1, g1Ref, 'group 1 must be the same reference after editing group 2');
assert.equal(after.groups.g2.messages.length, 1);
assert.equal(before.groups.g2.messages.length, 0, 'the reducer is immutable');
}
// ---------------------------------------------------------------------------
// CONFIRM_SAS is the group's verification gate
// ---------------------------------------------------------------------------
{
let state = withTwoGroups();
// Without a computed code there is nothing the user could have compared, so
// the transition is refused. This is the group analogue of the 1:1 rule that
// verified state is never set by an inbound message.
const refused = groupsReducer(state, { type: A.CONFIRM_SAS, id: 'g1' });
assert.equal(refused, state, 'a group cannot be confirmed before a code exists');
assert.equal(state.groups.g1.sasConfirmed, false);
assert.equal(state.groups.g1.phase, GROUP_PHASE.FORMING);
// A code alone is not enough either: the group must be waiting on THIS code.
state = groupsReducer(state, { type: A.SET_SAS, id: 'g1', code: '4820193' });
assert.equal(state.groups.g1.sasCode, '4820193');
assert.equal(state.groups.g1.sasConfirmed, false, 'showing a code is not confirming it');
assert.equal(
groupsReducer(state, { type: A.CONFIRM_SAS, id: 'g1' }), state,
'a group still forming cannot be confirmed, even holding a code',
);
// Waiting on the code is what makes confirmation meaningful.
state = groupsReducer(state, { type: A.SET_PHASE, id: 'g1', phase: GROUP_PHASE.AWAITING_SAS });
state = groupsReducer(state, { type: A.CONFIRM_SAS, id: 'g1' });
assert.equal(state.groups.g1.sasConfirmed, true);
assert.equal(state.groups.g1.phase, GROUP_PHASE.READY);
// A new code (new epoch) always lands unconfirmed.
state = groupsReducer(state, { type: A.SET_SAS, id: 'g1', code: '9911002' });
assert.equal(state.groups.g1.sasConfirmed, false, 'a fresh code must be compared again');
}
// A ceremony that reached a code and THEN failed cannot be confirmed.
{
let state = withTwoGroups();
state = groupsReducer(state, { type: A.SET_PHASE, id: 'g1', phase: GROUP_PHASE.AWAITING_SAS });
state = groupsReducer(state, { type: A.SET_SAS, id: 'g1', code: '5550001' });
state = groupsReducer(state, { type: A.SET_ERROR, id: 'g1', error: 'commitment_mismatch' });
assert.equal(state.groups.g1.phase, GROUP_PHASE.FAILED);
assert.equal(state.groups.g1.sasCode, '5550001', 'the code survives so the UI can explain what failed');
// The dangerous case: a failed verification must not be promotable to ready
// just because a code is still lying around from before it failed.
assert.equal(
groupsReducer(state, { type: A.CONFIRM_SAS, id: 'g1' }), state,
'a failed ceremony must never be confirmable',
);
}
// Leaving READY drops the confirmation and the stale code.
{
let state = withTwoGroups();
state = groupsReducer(state, { type: A.SET_PHASE, id: 'g1', phase: GROUP_PHASE.AWAITING_SAS });
state = groupsReducer(state, { type: A.SET_SAS, id: 'g1', code: '1234567' });
state = groupsReducer(state, { type: A.CONFIRM_SAS, id: 'g1' });
assert.equal(state.groups.g1.sasConfirmed, true);
state = groupsReducer(state, { type: A.SET_PHASE, id: 'g1', phase: GROUP_PHASE.COMMITTING });
assert.equal(state.groups.g1.sasConfirmed, false, 'a membership change un-verifies the group');
assert.equal(state.groups.g1.sasCode, '', 'and clears the code it was confirmed against');
assert.equal(state.groups.g2.sasConfirmed, false, 'sibling untouched');
}
// SET_ERROR fails the group; the phase follows.
{
let state = withTwoGroups();
state = groupsReducer(state, { type: A.SET_ERROR, id: 'g1', error: 'commitment_mismatch' });
assert.equal(state.groups.g1.phase, GROUP_PHASE.FAILED);
assert.equal(state.groups.g1.error, 'commitment_mismatch');
// Moving to any non-failed phase clears the error.
state = groupsReducer(state, { type: A.SET_PHASE, id: 'g1', phase: GROUP_PHASE.FORMING });
assert.equal(state.groups.g1.error, null);
}
// ---------------------------------------------------------------------------
// members are kept in canonical order, whatever order they arrive in
// ---------------------------------------------------------------------------
{
let state = createInitialGroupState();
const shuffled = [
{ fp: CAROL, name: 'Carol', sessionId: 's2', state: MEMBER_STATE.LINKED },
{ fp: SELF, name: 'You', sessionId: null, state: MEMBER_STATE.SELF },
{ fp: BOB, name: 'Bob', sessionId: 's1', state: MEMBER_STATE.LINKED },
];
state = groupsReducer(state, { type: A.CREATE_GROUP, entry: createGroupEntry({ id: 'g', name: 'X', members: shuffled }) });
assert.deepEqual(state.groups.g.members.map((m) => m.fp), [SELF, BOB, CAROL], 'sorted on create');
state = groupsReducer(state, { type: A.SET_MEMBERS, id: 'g', members: [...shuffled].reverse(), epoch: 4 });
assert.deepEqual(state.groups.g.members.map((m) => m.fp), [SELF, BOB, CAROL], 'sorted on update');
assert.equal(state.groups.g.epoch, 4);
}
// PATCH_MEMBER is a no-op when nothing actually moves (link state churns).
{
let state = withTwoGroups();
const before = state.groups.g1;
state = groupsReducer(state, { type: A.PATCH_MEMBER, id: 'g1', fp: BOB, patch: { state: MEMBER_STATE.LINKED } });
assert.equal(state.groups.g1, before, 'setting a member state to what it already is must not re-render');
state = groupsReducer(state, { type: A.PATCH_MEMBER, id: 'g1', fp: BOB, patch: { state: MEMBER_STATE.LOST } });
assert.notEqual(state.groups.g1, before);
assert.equal(state.groups.g1.members.find((m) => m.fp === BOB).state, MEMBER_STATE.LOST);
// An unknown fingerprint changes nothing.
const after = groupsReducer(state, { type: A.PATCH_MEMBER, id: 'g1', fp: fp(9), patch: { state: MEMBER_STATE.LOST } });
assert.equal(after, state);
}
// ---------------------------------------------------------------------------
// unread and active pointer
// ---------------------------------------------------------------------------
{
let state = withTwoGroups();
state = groupsReducer(state, { type: A.INCREMENT_UNREAD, id: 'g1' });
state = groupsReducer(state, { type: A.INCREMENT_UNREAD, id: 'g1' });
assert.equal(state.groups.g1.unreadCount, 2);
assert.equal(state.groups.g2.unreadCount, 0, 'unread does not leak between groups');
state = groupsReducer(state, { type: A.CLEAR_UNREAD, id: 'g1' });
assert.equal(state.groups.g1.unreadCount, 0);
assert.equal(groupsReducer(state, { type: A.CLEAR_UNREAD, id: 'g1' }), state, 'clearing twice is a no-op');
// Removing the active group re-points to its neighbour.
state = groupsReducer(state, { type: A.SET_ACTIVE_GROUP, id: 'g2' });
state = groupsReducer(state, { type: A.REMOVE_GROUP, id: 'g2' });
assert.equal(state.activeGroupId, 'g1');
assert.deepEqual(state.order, ['g1']);
// A 1:1 session taking the foreground clears the active group.
state = groupsReducer(state, { type: A.SET_ACTIVE_GROUP, id: null });
assert.equal(state.activeGroupId, null);
}
// ---------------------------------------------------------------------------
// derivation for rendering — partial connectivity is surfaced, not hidden
// ---------------------------------------------------------------------------
{
const ready = createGroupEntry({ id: 'g', name: 'Field team', selfFp: SELF, members: members() });
ready.phase = GROUP_PHASE.READY;
ready.sasCode = '1234567';
ready.sasConfirmed = true;
assert.equal(linkedCount(ready), 3);
assert.equal(groupSub(ready), '3 members · P2P mesh');
assert.equal(groupDot(ready), '#3ecf8e');
const degraded = { ...ready, members: members(MEMBER_STATE.SELF, MEMBER_STATE.LOST, MEMBER_STATE.LINKED) };
assert.equal(linkedCount(degraded), 2);
assert.equal(groupSub(degraded), '2 of 3 connected', 'an unreachable member is a member not getting your messages');
assert.equal(groupDot(degraded), '#e3b341', 'partial connectivity reads amber, not green');
const forming = createGroupEntry({ id: 'h', name: 'New', members: members() });
assert.equal(groupSub(forming), 'Forming…');
assert.equal(groupDot(forming), '#e3b341');
const failed = { ...forming, phase: GROUP_PHASE.FAILED };
assert.equal(groupDot(failed), '#e5727a');
const d = decorateGroup(ready, 'other');
assert.equal(d.kind, 'group');
assert.equal(d.mono, 'FT');
assert.equal(d.verified, true);
assert.equal(d.active, false);
assert.equal(d.memberCount, 3);
assert.equal(d.preview, '3 members · P2P mesh', 'with no messages the preview shows the group state');
// A confirmed code alone is not "verified" if the phase regressed.
assert.equal(decorateGroup({ ...ready, phase: GROUP_PHASE.COMMITTING }, 'g').verified, false);
assert.equal(groupInitials('Field team'), 'FT');
assert.equal(groupInitials('Ops'), 'OP');
assert.equal(groupInitials(''), '##');
}
// RENAME trims and bounds.
{
let state = withTwoGroups();
state = groupsReducer(state, { type: A.RENAME, id: 'g1', name: ' Ops ' });
assert.equal(state.groups.g1.name, 'Ops');
state = groupsReducer(state, { type: A.RENAME, id: 'g1', name: ' ' });
assert.equal(state.groups.g1.name, 'Ops', 'a blank rename keeps the old name');
state = groupsReducer(state, { type: A.RENAME, id: 'g1', name: 'x'.repeat(400) });
assert.equal(state.groups.g1.name.length, GROUP_LIMITS.MAX_NAME_BYTES, 'the name is bounded');
// Bounded in BYTES, not characters: a Cyrillic name is two bytes per letter,
// so it must be cut at half the character count. Getting this wrong is what
// let a name through the UI that the roster signing then rejected.
state = groupsReducer(state, { type: A.RENAME, id: 'g1', name: 'я'.repeat(400) });
const renamed = state.groups.g1.name;
assert.ok(new TextEncoder().encode(renamed).length <= GROUP_LIMITS.MAX_NAME_BYTES,
'a multi-byte name is clamped by bytes');
assert.equal(renamed.length, GROUP_LIMITS.MAX_NAME_BYTES / 2);
}
console.log('groups-reducer.test.mjs: all assertions passed');
+5
View File
@@ -55,6 +55,11 @@ function makeManager(overrides = {}) {
const sent = [];
const ui = [];
const mgr = {
// Lifecycle announcements go through _dispatchAppEvent rather than
// straight to `document`, so a connection with no window of its own — a
// group's mesh link — can be muted. An ordinary session is not.
_emitGlobalEvents: true,
_dispatchAppEvent: EnhancedSecureWebRTCManager.prototype._dispatchAppEvent,
isVerified: true,
isInitiator: true,
intentionalDisconnect: false,
+29
View File
@@ -176,4 +176,33 @@ function withTwoSessions() {
assert.equal(d.inactive, true);
}
// The rail preview shows conversation, not system notices.
{
let state = createInitialState();
state = sessionsReducer(state, { type: A.CREATE_SESSION, entry: createSessionEntry({ id: 'a', peerLabel: 'work laptop' }) });
state = sessionsReducer(state, { type: A.SET_STATUS, id: 'a', status: 'connected' });
state = sessionsReducer(state, { type: A.ADD_MESSAGE, id: 'a', message: { id: 1, message: 'see you at six', type: 'received' } });
assert.equal(decorateSession(state.sessions.a, 'a').preview, 'see you at six');
// A closing notice arriving after it must not take the preview over: the
// status line next to it already says the connection is gone, and the last
// thing the peer actually said is what belongs there.
state = sessionsReducer(state, {
type: A.ADD_MESSAGE, id: 'a',
message: { id: 2, message: '🔌 Enhanced secure connection closed. Check connection status.', type: 'system' },
});
assert.equal(
decorateSession(state.sessions.a, 'a').preview, 'see you at six',
'a system notice must not become the chat preview',
);
// With nothing but system messages the preview falls back to the status text.
let bare = createInitialState();
bare = sessionsReducer(bare, { type: A.CREATE_SESSION, entry: createSessionEntry({ id: 'b', peerLabel: 'x' }) });
bare = sessionsReducer(bare, { type: A.SET_STATUS, id: 'b', status: 'disconnected' });
bare = sessionsReducer(bare, { type: A.ADD_MESSAGE, id: 'b', message: { id: 1, message: 'Peer manually disconnected.', type: 'system' } });
assert.equal(decorateSession(bare.sessions.b, 'b').preview, 'Disconnected');
}
console.log('sessions-reducer.test.mjs: all assertions passed');