mirror of
https://github.com/ancsemi/Haven.git
synced 2026-10-27 09:05:01 +00:00
120 lines
5.7 KiB
JavaScript
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(); }
|
|
});
|