Files
Haven/test/backupSecrets.test.js

120 lines
5.7 KiB
JavaScript

/**
* A backup carries no two-factor secrets or one-time codes. With someone's
* secret, whoever held the backup could make their codes, and an admin could
* reset that person's password and sign in as them.
*
* node --test test/backupSecrets.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 OTPAuth = require('otpauth');
const yauzl = require('yauzl');
const Database = require('better-sqlite3');
const PORT = 3411;
const BASE = `http://localhost:${PORT}`;
const DATA = path.join(os.tmpdir(), `haven-backup-secrets-${Date.now()}`);
let server;
const call = (method, p, body, token) => new Promise((res, rej) => {
const d = body ? JSON.stringify(body) : '';
const headers = { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(d) };
if (token) headers.Authorization = `Bearer ${token}`;
const r = http.request({ host: 'localhost', port: PORT, path: p, method, headers },
(x) => { let b = ''; x.on('data', (c) => (b += c)); x.on('end', () => { let j = null; try { j = JSON.parse(b); } catch { j = { raw: b }; } res({ status: x.statusCode, body: j }); }); });
r.on('error', rej); if (d) r.write(d); r.end();
});
const download = (p, token, file) => new Promise((res, rej) => {
http.get({ host: 'localhost', port: PORT, path: p, headers: { Authorization: `Bearer ${token}` } }, (x) => {
if (x.statusCode !== 200) return rej(new Error(`HTTP ${x.statusCode}`));
const out = fs.createWriteStream(file);
x.pipe(out);
out.on('finish', res);
}).on('error', rej);
});
const extract = (zip, name, dest) => new Promise((res, rej) => {
yauzl.open(zip, { lazyEntries: true }, (err, z) => {
if (err) return rej(err);
z.on('entry', (e) => {
if (e.fileName !== name) return z.readEntry();
z.openReadStream(e, (err2, s) => {
if (err2) return rej(err2);
const out = fs.createWriteStream(dest);
s.pipe(out);
out.on('finish', () => { z.close(); res(true); });
});
});
z.on('end', () => res(false));
z.readEntry();
});
});
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
test.before(async () => {
fs.mkdirSync(DATA, { recursive: true });
server = spawn(process.execPath, ['server.js'], {
cwd: path.join(__dirname, '..'),
env: { ...process.env, PORT: String(PORT), HAVEN_DATA_DIR: DATA, ADMIN_USERNAME: 'admin', FORCE_HTTP: 'true' },
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(async () => {
if (server && server.exitCode === null) {
const exited = new Promise((r) => server.once('exit', r));
server.kill();
await exited;
}
fs.rmSync(DATA, { recursive: true, force: true, maxRetries: 5 });
});
test('backups leave out two-factor secrets and codes', async () => {
const PW = 'backuptest123';
const reg = (u) => call('POST', '/api/auth/register', { username: u, password: PW, eulaVersion: '2.0', ageVerified: true });
const admin = (await reg('admin')).body;
const bob = (await reg('bob')).body;
const setup = await call('POST', '/api/auth/totp/setup', {}, bob.token);
const totpCode = new OTPAuth.TOTP({ secret: OTPAuth.Secret.fromBase32(setup.body.base32Secret), digits: 6, period: 30 }).generate();
const on = await call('POST', '/api/auth/totp/verify-setup', { code: totpCode, password: PW }, bob.token);
assert.strictEqual(on.status, 200, JSON.stringify(on.body));
const zip = path.join(DATA, 'backup.zip');
// A download needs the password again, for a one-time ticket.
const noPw = await call('POST', '/api/admin/download-ticket', { purpose: 'backup', include: 'messages' }, admin.token);
assert.strictEqual(noPw.status, 401, 'no ticket without the password');
const old = await new Promise((r) => http.get({ host: 'localhost', port: PORT, path: `/api/admin/backup?include=messages&token=${admin.token}` }, (x) => { x.resume(); r(x.statusCode); }));
assert.strictEqual(old, 401, 'the session token in the URL no longer downloads a backup');
const ticket = (await call('POST', '/api/admin/download-ticket', { purpose: 'backup', include: 'messages', password: PW }, admin.token)).body.ticket;
assert.ok(ticket, 'ticket issued');
await download(`/api/admin/backup?ticket=${ticket}`, admin.token, zip);
const reused = await new Promise((r) => http.get({ host: 'localhost', port: PORT, path: `/api/admin/backup?ticket=${ticket}` }, (x) => { x.resume(); r(x.statusCode); }));
assert.strictEqual(reused, 401, 'a ticket works once');
const dbFile = path.join(DATA, 'restored.db');
assert.ok(await extract(zip, 'haven.db', dbFile), 'the backup has a database');
const copy = new Database(dbFile, { readonly: true });
try {
const row = copy.prepare("SELECT totp_secret, totp_enabled, password_hash FROM users WHERE username = 'bob'").get();
assert.strictEqual(row.totp_secret, null, 'no 2FA secret');
assert.strictEqual(row.totp_enabled, 0, '2FA off in the copy');
assert.ok(row.password_hash, 'the password hash stays, so people can still log in after a restore');
assert.strictEqual(copy.prepare('SELECT COUNT(*) AS n FROM totp_backup_codes').get().n, 0, 'no backup codes');
} finally { copy.close(); }
const live = new Database(path.join(DATA, 'haven.db'), { readonly: true });
try {
assert.ok(live.prepare("SELECT totp_secret FROM users WHERE username = 'bob'").get().totp_secret, 'the live server keeps it');
} finally { live.close(); }
});