Files
Haven/test/group-e2e-server.test.js

414 lines
20 KiB
JavaScript

/**
* Group DM key distribution: server rule tests.
*
* These cover what the crypto tests cannot: the structural rules only the
* server can enforce, because no client can see the whole membership list or
* another member's blobs.
*
* Needs a running Haven server. Boots one on a scratch port and data dir:
* node --test test/group-e2e-server.test.js
*/
const test = require('node:test');
const assert = require('node:assert');
const http = require('node:http');
const path = require('node:path');
const fs = require('node:fs');
const os = require('node:os');
const { spawn } = require('node:child_process');
const { io } = require('socket.io-client');
const PORT = 3444;
const BASE = `http://localhost:${PORT}`;
const DATA = path.join(os.tmpdir(), `haven-group-e2e-${Date.now()}`);
let server;
const post = (p, body) => new Promise((res, rej) => {
const d = JSON.stringify(body);
const r = http.request({ host: 'localhost', port: PORT, path: p, method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(d) } },
(x) => { let b = ''; x.on('data', (c) => (b += c)); x.on('end', () => { try { res(JSON.parse(b)); } catch { res({ raw: b }); } }); });
r.on('error', rej); r.write(d); r.end();
});
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const register = (username) =>
post('/api/auth/register', { username, password: 'grouptest123', eulaVersion: '1.0', ageVerified: true });
function connect(token) {
const s = io(BASE, { auth: { token }, transports: ['websocket'], forceNew: true });
return new Promise((res, rej) => { s.on('connect', () => res(s)); s.on('connect_error', rej); });
}
/** Wait for one of several events, whichever lands first. */
function next(sock, events, ms = 4000) {
return new Promise((res) => {
const t = setTimeout(() => { cleanup(); res({ event: null, data: null }); }, ms);
const handlers = events.map((e) => {
const h = (data) => { cleanup(); res({ event: e, data }); };
sock.on(e, h); return [e, h];
});
function cleanup() { clearTimeout(t); handlers.forEach(([e, h]) => sock.off(e, h)); }
});
}
// Any well-formed key blob will do; these tests are about the rules around the
// blobs, not the crypto, which test/e2e-group.test.js covers.
const fakeKey = (id) => JSON.stringify({ v: 1, iv: 'AAAAAAAAAAAAAAAA', ct: `wrapped-for-${id}` });
const jwk = (x) => ({ kty: 'EC', crv: 'P-256', x, y: `y${x}` });
// Wrapped keys plus the publisher's signed statement naming the same members.
const epochFor = (users) => ({
keys: users.map((u) => ({ recipientId: u.user.id, wrappedKey: fakeKey(u.user.id) })),
sig: 'c2lnbmVk',
roster: users.map((u) => ({ id: u.user.id, ecdhJwk: jwk(`e${u.user.id}`), signJwk: jwk(`s${u.user.id}`) })),
});
test.before(async () => {
fs.mkdirSync(DATA, { recursive: true });
server = spawn(process.execPath, ['server.js'], {
cwd: path.join(__dirname, '..'),
// Plain HTTP on purpose: without FORCE_HTTP a certless Haven now makes its own certificate.
env: { ...process.env, PORT: String(PORT), HAVEN_DATA_DIR: DATA, FORCE_HTTP: 'true', ADMIN_USERNAME: 'admin' },
stdio: 'ignore',
});
for (let i = 0; i < 60; i++) {
try {
await new Promise((res, rej) => http.get(`${BASE}/api/health`, (r) => (r.statusCode === 200 ? res() : rej())).on('error', rej));
return;
} catch { await wait(500); }
}
throw new Error('server did not start');
});
test.after(() => { server?.kill(); try { fs.rmSync(DATA, { recursive: true, force: true }); } catch {} });
test('group DM rules', async (t) => {
const alice = await register('alice');
const bob = await register('bob');
const carol = await register('carol');
const dave = await register('dave');
assert.ok(alice.token && bob.token && carol.token && dave.token, 'all users registered');
const A = await connect(alice.token);
const B = await connect(bob.token);
const C = await connect(carol.token);
const D = await connect(dave.token);
// Every member needs both keys published or the group cannot be formed.
for (const [sock, tag] of [[A, 'a'], [B, 'b'], [C, 'c'], [D, 'd']]) {
sock.emit('publish-public-key', { jwk: jwk(tag) });
sock.emit('publish-signing-key', { jwk: jwk(`s${tag}`) });
}
await wait(600);
await t.test('a signing key cannot be silently overwritten', async () => {
const p = next(A, ['signing-key-conflict', 'signing-key-published']);
A.emit('publish-signing-key', { jwk: jwk('attacker') });
const { event } = await p;
assert.strictEqual(event, 'signing-key-conflict', 'a changed signing key must raise a conflict, not replace silently');
});
await t.test('a signing key and its backup are stored in one step, and replies name their request', async () => {
const erin = await register('erin');
const E = await connect(erin.token);
const ok = next(E, ['signing-key-conflict', 'signing-key-published']);
E.emit('publish-signing-key', { jwk: jwk('e1'), backup: 'backup-1', rid: 'r1' });
const first = await ok;
assert.strictEqual(first.event, 'signing-key-published');
assert.strictEqual(first.data.rid, 'r1');
const lost = next(E, ['signing-key-conflict', 'signing-key-published']);
E.emit('publish-signing-key', { jwk: jwk('e2'), backup: 'backup-2', rid: 'r2' });
const second = await lost;
assert.strictEqual(second.event, 'signing-key-conflict');
assert.strictEqual(second.data.rid, 'r2');
const got = next(E, ['encrypted-key-result']);
E.emit('get-encrypted-key');
const { data } = await got;
assert.strictEqual(data.signingBackup, 'backup-1', 'a losing publish must not replace the backup');
assert.strictEqual(data.signingKey.x, 'e1');
E.close();
});
await t.test('a replaced signing key stays on record for the messages it signed', async () => {
const frank = await register('frank');
const F = await connect(frank.token);
F.emit('publish-signing-key', { jwk: jwk('f1') });
await next(F, ['signing-key-published']);
F.emit('publish-signing-key', { jwk: jwk('f2'), force: true });
await next(F, ['signing-key-published']);
const r = next(A, ['signing-key-result']);
A.emit('get-signing-key', { userId: frank.user.id });
const { data } = await r;
assert.strictEqual(data.jwk.x, 'f2', 'the current key is the newest');
assert.deepStrictEqual(data.keys.map((k) => k.x), ['f2', 'f1'], 'every key ever published, newest first');
F.close();
});
let code;
await t.test('a group DM invites rather than silently joining people', async () => {
const opened = next(A, ['group-dm-opened', 'error-msg']);
const bobInvite = next(B, ['group-dm-invite', 'group-dm-opened']);
const carolInvite = next(C, ['group-dm-invite', 'group-dm-opened']);
A.emit('start-group-dm', { userIds: [bob.user.id, carol.user.id], name: 'Test Group' });
const { event, data } = await opened;
assert.strictEqual(event, 'group-dm-opened', `expected group-dm-opened, got ${event}`);
assert.strictEqual(data.members.length, 1, 'only the creator is a member until others accept');
assert.strictEqual(data.pending.length, 2);
code = data.code;
assert.strictEqual((await bobInvite).event, 'group-dm-invite');
assert.strictEqual((await carolInvite).event, 'group-dm-invite');
});
await t.test('the same membership reuses the existing group', async () => {
const opened = next(A, ['group-dm-opened', 'error-msg']);
A.emit('start-group-dm', { userIds: [carol.user.id, bob.user.id], name: 'Another name' });
const { event, data } = await opened;
assert.strictEqual(event, 'group-dm-opened');
assert.strictEqual(data.code, code);
});
await t.test('invitees join only after they accept', async () => {
const bobOpened = next(B, ['group-dm-opened', 'error-msg']);
B.emit('accept-group-dm', { code });
assert.strictEqual((await bobOpened).event, 'group-dm-opened');
const carolOpened = next(C, ['group-dm-opened', 'error-msg']);
C.emit('accept-group-dm', { code });
const { data } = await carolOpened;
assert.strictEqual(data.members.length, 3);
});
await t.test('a two-person group is refused', async () => {
const r = next(A, ['group-dm-opened', 'error-msg']);
A.emit('start-group-dm', { userIds: [bob.user.id] });
const { event } = await r;
assert.strictEqual(event, 'error-msg');
});
await t.test('an epoch without a signed statement for the same members is REJECTED', async () => {
const unsigned = next(A, ['group-epoch-published', 'error-msg', 'group-epoch-conflict']);
const { keys } = epochFor([alice, bob, carol]);
A.emit('publish-group-epoch', { code, epoch: 1, keys });
assert.strictEqual((await unsigned).event, 'error-msg');
const short = next(A, ['group-epoch-published', 'error-msg', 'group-epoch-conflict']);
A.emit('publish-group-epoch', { code, epoch: 1, ...epochFor([alice, bob, carol]), roster: epochFor([alice, bob]).roster });
assert.strictEqual((await short).event, 'error-msg', 'the statement must name exactly the members the key goes to');
});
await t.test('epoch 1 publishes when it covers every member', async () => {
const ok = next(A, ['group-epoch-published', 'error-msg', 'group-epoch-conflict']);
A.emit('publish-group-epoch', { code, epoch: 1, ...epochFor([alice, bob, carol]) });
const { event, data } = await ok;
assert.strictEqual(event, 'group-epoch-published', `expected publish, got ${event}`);
assert.strictEqual(data.epoch, 1);
});
await t.test('an epoch that omits a member is REJECTED', async () => {
// The attack this rule exists to stop: Carol is silently cut out while the
// UI still lists her as a participant.
const r = next(A, ['group-epoch-published', 'error-msg', 'group-epoch-conflict']);
A.emit('publish-group-epoch', { code, epoch: 2, ...epochFor([alice, bob]) });
const { event, data } = await r;
assert.strictEqual(event, 'error-msg', `omitting a member must be rejected, got ${event}`);
assert.match(String(data), /exactly one key per current member/i);
});
await t.test('an epoch naming a non-member is REJECTED', async () => {
const r = next(A, ['group-epoch-published', 'error-msg', 'group-epoch-conflict']);
A.emit('publish-group-epoch', { code, epoch: 2, ...epochFor([alice, bob, carol, dave]) });
const { event } = await r;
assert.strictEqual(event, 'error-msg');
});
await t.test('a non-member cannot publish an epoch', async () => {
const r = next(D, ['group-epoch-published', 'error-msg', 'group-epoch-conflict']);
D.emit('publish-group-epoch', { code, epoch: 2, ...epochFor([alice, bob, carol]) });
const { event } = await r;
assert.strictEqual(event, 'error-msg');
});
await t.test('epochs must be strictly sequential', async () => {
const r = next(A, ['group-epoch-published', 'error-msg', 'group-epoch-conflict']);
A.emit('publish-group-epoch', { code, epoch: 7, ...epochFor([alice, bob, carol]) });
const { event, data } = await r;
assert.strictEqual(event, 'group-epoch-conflict', 'a skipped epoch must conflict');
assert.strictEqual(data.currentEpoch, 1);
});
await t.test('a member receives only their own wrapped key', async () => {
const p = next(B, ['group-keys']);
B.emit('get-group-keys', { code, sinceEpoch: 0 });
const { data } = await p;
assert.ok(data, 'bob got a key list');
assert.strictEqual(data.keys.length, 1);
assert.match(data.keys[0].wrappedKey, new RegExp(`wrapped-for-${bob.user.id}`));
assert.strictEqual(data.keys[0].publishedBy, alice.user.id, 'the statement comes with the key');
assert.strictEqual(data.keys[0].sig, 'c2lnbmVk');
assert.deepStrictEqual(data.keys[0].roster.map((m) => m.id).sort(), [alice, bob, carol].map((u) => u.user.id).sort());
// The decisive check: nothing addressed to anyone else came back.
const others = [alice.user.id, carol.user.id].map((id) => `wrapped-for-${id}`);
for (const o of others) assert.ok(!JSON.stringify(data.keys).includes(o), `must not leak ${o}`);
});
await t.test('a non-member cannot read group keys at all', async () => {
const r = next(D, ['group-keys', 'error-msg']);
D.emit('get-group-keys', { code, sinceEpoch: 0 });
const { event } = await r;
assert.strictEqual(event, 'error-msg');
});
await t.test('a rewrap without a request from that member is REJECTED', async () => {
const r = next(A, ['group-key-rewrapped', 'error-msg', 'public-key-conflict']);
A.emit('rewrap-group-key', {
code, epoch: 1, recipientId: carol.user.id, wrappedKey: fakeKey('nope'),
recipientPublicKey: JSON.stringify(jwk('c')),
});
const { event } = await r;
assert.strictEqual(event, 'error-msg');
});
await t.test('a rewrap reaches only the intended recipient after they ask', async () => {
C.emit('request-group-rewrap', { code });
await wait(200);
const forCarol = next(C, ['group-key-rewrapped']);
const forBob = next(B, ['group-key-rewrapped'], 1500);
A.emit('rewrap-group-key', {
code, epoch: 1, recipientId: carol.user.id, wrappedKey: fakeKey('carol-rewrapped'),
recipientPublicKey: JSON.stringify(jwk('c')),
});
assert.ok((await forCarol).data, 'carol was told');
assert.strictEqual((await forBob).event, null, 'bob was not');
});
await t.test('the roster lists members with both keys and the current epoch', async () => {
const r = next(B, ['group-roster', 'error-msg']);
B.emit('get-group-roster', { code });
const { event, data } = await r;
assert.strictEqual(event, 'group-roster');
assert.strictEqual(data.epoch, 1);
assert.deepStrictEqual(data.members.map((m) => m.id).sort(), [alice, bob, carol].map((u) => u.user.id).sort());
assert.ok(data.members.every((m) => m.publicKey && m.signingKey));
assert.deepStrictEqual(data.pending, []);
const k = next(B, ['group-keys']);
B.emit('get-group-keys', { code, sinceEpoch: 0 });
assert.strictEqual((await k).data.needsRotation, false);
});
await t.test('a group refuses plaintext and pairwise envelopes', async () => {
for (const content of ['hello in the clear', JSON.stringify({ v: 1, iv: 'AAAAAAAAAAAAAAAA', ct: 'x' })]) {
const r = next(A, ['error-msg'], 1500);
const leak = next(B, ['new-message'], 1500);
A.emit('send-message', { code, content });
assert.match(String((await r).data), /end-to-end encrypted/);
assert.strictEqual((await leak).event, null, 'nothing reached the other members');
}
});
await t.test('a signed group envelope is delivered', async () => {
const env = JSON.stringify({ v: 3, e: 1, prev: null, iv: 'AAAAAAAAAAAAAAAA', ct: 'Y2lwaGVy', sig: 'c2ln' });
const got = next(C, ['new-message']);
A.emit('send-message', { code, content: env });
const { data } = await got;
assert.strictEqual(data?.message?.content, env);
const bad = next(A, ['error-msg'], 1500);
A.emit('edit-message', { messageId: data.message.id, content: 'edited in the clear', channelCode: code });
assert.match(String((await bad).data), /end-to-end encrypted/);
});
await t.test('only members can invite, and invitees see it on reconnect', async () => {
const denied = next(D, ['error-msg']);
D.emit('invite-group-dm', { code, userIds: [dave.user.id] });
assert.strictEqual((await denied).event, 'error-msg');
const invite = next(D, ['group-dm-invite']);
const updated = next(B, ['group-dm-updated']);
A.emit('invite-group-dm', { code, userIds: [dave.user.id] });
assert.strictEqual((await invite).data.code, code);
assert.strictEqual((await updated).event, 'group-dm-updated');
const list = next(D, ['group-dm-invites']);
D.emit('get-group-invites');
assert.deepStrictEqual((await list).data.invites.map((i) => i.code), [code]);
const roster = next(A, ['group-roster']);
A.emit('get-group-roster', { code });
assert.deepStrictEqual((await roster).data.pending.map((m) => m.id), [dave.user.id]);
});
await t.test('a new member flags the group for rotation', async () => {
const opened = next(D, ['group-dm-opened', 'error-msg']);
D.emit('accept-group-dm', { code });
assert.strictEqual((await opened).event, 'group-dm-opened');
const k = next(A, ['group-keys']);
A.emit('get-group-keys', { code, sinceEpoch: 0 });
assert.strictEqual((await k).data.needsRotation, true);
});
await t.test('leaving removes access and tells the rest', async () => {
const left = next(C, ['group-dm-left']);
const gone = next(C, ['channel-deleted']);
const told = next(A, ['group-dm-member-left']);
C.emit('leave-group-dm', { code });
assert.strictEqual((await left).data.code, code);
assert.strictEqual((await gone).data.code, code);
assert.strictEqual((await told).data.code, code);
const r = next(C, ['group-roster', 'group-keys', 'error-msg']);
C.emit('get-group-keys', { code, sinceEpoch: 0 });
assert.strictEqual((await r).event, 'error-msg');
});
await t.test('deleting a group from the DM list leaves it instead of deleting it for everyone', async () => {
const left = next(D, ['group-dm-left']);
D.emit('delete-dm', { code });
assert.strictEqual((await left).data.code, code);
const r = next(A, ['group-roster']);
A.emit('get-group-roster', { code });
assert.deepStrictEqual((await r).data.members.map((m) => m.id).sort(), [alice, bob].map((u) => u.user.id).sort());
});
await t.test('a group that shrinks to two is not reused as their 1:1 DM', async () => {
const opened = next(A, ['dm-opened', 'error-msg']);
A.emit('start-dm', { targetUserId: bob.user.id });
const { event, data } = await opened;
assert.strictEqual(event, 'dm-opened');
assert.notStrictEqual(data.code, code, 'the 1:1 DM must be its own channel, not the group');
});
await t.test('the last member out deletes the group', async () => {
B.emit('leave-group-dm', { code });
await wait(300);
const gone = next(A, ['channel-deleted']);
A.emit('leave-group-dm', { code, attachments: [] });
assert.strictEqual((await gone).data.code, code);
const Database = require('better-sqlite3');
const db = new Database(path.join(DATA, 'haven.db'), { readonly: true });
const id = db.prepare('SELECT id FROM channels WHERE code = ?').get(code);
const left = ['messages', 'channel_members', 'dm_group_keys', 'dm_group_epochs', 'dm_group_invites'].map((tb) => db.prepare(`SELECT COUNT(*) AS n FROM ${tb} m WHERE m.channel_id IN (SELECT id FROM channels WHERE code = ?)`).get(code).n);
db.close();
assert.strictEqual(id, undefined);
assert.deepStrictEqual(left, [0, 0, 0, 0, 0]);
});
await t.test('an admin who is not in a group can delete it, and its key rows go with it', async () => {
const admin = await register('admin');
const Z = await connect(admin.token);
const opened = next(B, ['group-dm-opened', 'error-msg']);
B.emit('start-group-dm', { userIds: [carol.user.id, dave.user.id] });
const { data } = await opened;
const published = next(B, ['group-epoch-published', 'error-msg']);
B.emit('publish-group-epoch', { code: data.code, epoch: 1, ...epochFor([bob]) });
assert.strictEqual((await published).event, 'group-epoch-published');
const gone = next(B, ['channel-deleted']);
Z.emit('delete-dm', { code: data.code });
assert.strictEqual((await gone).data.code, data.code);
const Database = require('better-sqlite3');
const db = new Database(path.join(DATA, 'haven.db'), { readonly: true });
const left = ['dm_group_keys', 'dm_group_epochs', 'dm_group_invites', 'dm_group_rewrap_requests'].map((tb) => db.prepare(`SELECT COUNT(*) AS n FROM ${tb} WHERE channel_id = ?`).get(data.id).n);
db.close();
assert.deepStrictEqual(left, [0, 0, 0, 0]);
Z.close();
});
await t.test('the signing key backup round-trips and is size-capped', async () => {
const stored = next(B, ['signing-backup-stored', 'error-msg']);
B.emit('store-signing-backup', { backup: 'opaque-backup' });
assert.strictEqual((await stored).event, 'signing-backup-stored');
const got = next(B, ['encrypted-key-result']);
B.emit('get-encrypted-key');
const { data } = await got;
assert.strictEqual(data.signingBackup, 'opaque-backup');
assert.ok(data.signingKey);
const big = next(B, ['signing-backup-stored', 'error-msg']);
B.emit('store-signing-backup', { backup: 'x'.repeat(5000) });
assert.strictEqual((await big).event, 'error-msg');
});
[A, B, C, D].forEach((s) => s.close());
});