Files
Haven/test/dmSendGate.test.js
T

135 lines
5.2 KiB
JavaScript

'use strict';
// Every DM send asks _dmSendGate first. A DM used to go out unencrypted,
// without asking, whenever a key was missing, and a partner's key could be
// swapped without anyone noticing. The gate asks before either happens and
// remembers each partner's key on this device.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const vm = require('node:vm');
const ROOT = path.join(__dirname, '..');
const SOURCE = fs.readFileSync(path.join(ROOT, 'public/js/modules/app-platform.js'), 'utf8');
function createStorage() {
const data = new Map();
return {
getItem: key => data.has(key) ? data.get(key) : null,
setItem: (key, value) => data.set(key, String(value)),
removeItem: key => data.delete(key),
};
}
function loadPlatform(localStorage) {
const context = vm.createContext({
module: { exports: {} },
localStorage,
window: { crypto: { subtle: {} } },
HavenE2E: function HavenE2E() {},
document: { getElementById: () => null },
t: (key, vars) => (vars && vars.name ? `${key}:${vars.name}` : key),
console,
});
vm.runInContext(SOURCE.replace(/^export default/m, 'module.exports ='), context, { filename: 'app-platform.js' });
return context.module.exports;
}
const KEY_A = { kty: 'EC', crv: 'P-256', x: 'aaa', y: 'AAA' };
const KEY_B = { kty: 'EC', crv: 'P-256', x: 'bbb', y: 'BBB' };
// A stand-in app: one DM with user 7, E2E ready unless told otherwise, and a
// question box that answers with whatever the test queues up.
function makeApp({ serverKey = KEY_A, ready = true, answers = [] } = {}) {
const methods = loadPlatform(createStorage());
const app = Object.assign(Object.create(null), methods, {
user: { id: 1 },
currentChannel: 'dm000001',
channels: [
{ code: 'dm000001', is_dm: 1, dm_target: { id: 7, username: 'sam' } },
{ code: 'general1', is_dm: 0 },
],
_dmPublicKeys: {},
_e2eNoKey: new Set(),
_e2eKeyNotices: new Map(),
_plainDmOk: new Set(),
_dmGateAsking: new Map(),
socket: {},
asked: [],
unlockPrompts: 0,
e2e: {
ready,
requestPartnerKey: async () => serverKey,
},
_askChoice(title, message, buttons) {
// Copied into this realm: the module runs in its own vm context.
this.asked.push({ title, message, buttons: Array.from(buttons, b => b.id) });
return Promise.resolve(answers.shift() ?? null);
},
_requireE2E() { this.unlockPrompts++; },
_showE2EVerification() { this.verified = true; },
_fmtDateTime: () => 'now',
});
return app;
}
test('a channel that is not a DM goes straight through', async () => {
const app = makeApp();
assert.equal((await app._dmSendGate('general1')).partner, null);
assert.equal(app.asked.length, 0);
});
test('a DM with a key is encrypted without asking, and the key is remembered', async () => {
const app = makeApp();
const gate = await app._dmSendGate('dm000001');
assert.equal(gate.partner.publicKeyJwk, KEY_A);
assert.equal(app.asked.length, 0);
assert.equal(app._e2ePinCheck(7, KEY_A), 'same');
});
test('a changed key is not used until the sender trusts it', async () => {
const app = makeApp({ answers: ['cancel', 'verify', 'trust'] });
app._e2ePinSet(7, KEY_A);
app.e2e.requestPartnerKey = async () => KEY_B;
assert.equal(await app._dmSendGate('dm000001'), null, 'cancel stops the send');
assert.equal(app.asked[0].title, 'platform.e2e.key_changed_title:sam');
assert.deepEqual(app.asked[0].buttons, ['cancel', 'verify', 'trust']);
assert.equal(await app._dmSendGate('dm000001'), null, 'checking codes stops the send too');
assert.equal(app.verified, true);
const gate = await app._dmSendGate('dm000001');
assert.equal(gate.partner.publicKeyJwk, KEY_B, 'trusted, it encrypts to the new key');
assert.equal(app._e2ePinCheck(7, KEY_B), 'same', 'and the new key is the remembered one');
assert.equal(app.asked.length, 3);
});
test('no key for the partner: ask, and remember a yes for the session', async () => {
const app = makeApp({ serverKey: null, answers: [null, 'send'] });
assert.equal(await app._dmSendGate('dm000001'), null, 'Escape or a click outside stops the send');
assert.equal(app.asked[0].message, 'platform.e2e.plain_no_key:sam');
assert.deepEqual(app.asked[0].buttons, ['cancel', 'send']);
assert.equal((await app._dmSendGate('dm000001')).partner, null);
assert.equal((await app._dmSendGate('dm000001')).partner, null);
assert.equal(app.asked.length, 2, 'asked once more, then not again');
});
test('locked encryption offers to unlock instead of sending', async () => {
const app = makeApp({ ready: false, answers: ['unlock'] });
assert.equal(await app._dmSendGate('dm000001'), null);
assert.equal(app.asked[0].message, 'platform.e2e.plain_locked');
assert.deepEqual(app.asked[0].buttons, ['cancel', 'unlock', 'send']);
assert.equal(app.unlockPrompts, 1);
});
test('a batch sent at once asks one question', async () => {
const app = makeApp({ serverKey: null, answers: ['send'] });
const results = await Promise.all([1, 2, 3].map(() => app._dmSendGate('dm000001')));
assert.equal(app.asked.length, 1);
for (const r of results) assert.equal(r.partner, null);
});