Files
safetynet/tests/vault.test.mjs
T

103 lines
7.3 KiB
JavaScript
Raw Normal View History

2026-10-13 10:33:31 +02:00
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import crypto from 'node:crypto';
import { createRequire } from 'node:module';
const { createVault, generatePassword } = createRequire(import.meta.url)('../apps/browser/vault.cjs');
const master = 'A long separate master password 2026!';
function fixture(t) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'safetynet-vault-test-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const platformKey = crypto.randomBytes(32);
const options = { filePath: path.join(directory, 'vault.json'), protect(data) {
const nonce = crypto.randomBytes(12), cipher = crypto.createCipheriv('aes-256-gcm', platformKey, nonce);
const body = Buffer.concat([cipher.update(data), cipher.final()]);
return Buffer.concat([nonce, cipher.getAuthTag(), body]);
}, unprotect(data) {
const decipher = crypto.createDecipheriv('aes-256-gcm', platformKey, data.subarray(0, 12));
decipher.setAuthTag(data.subarray(12, 28)); return Buffer.concat([decipher.update(data.subarray(28)), decipher.final()]);
} };
return { options, engine: createVault(options) };
}
test('vault encrypts full metadata and roundtrips through actual ML-KEM', t => {
const { engine, options } = fixture(t);
t.after(engine.lock);
engine.create(master);
const { id } = engine.upsert({ origin: 'https://company.example', username: 'private-user-name', password: 'top-secret-credential', label: 'Confidential label' });
const stored = fs.readFileSync(options.filePath, 'utf8');
for (const secret of ['company.example', 'private-user-name', 'top-secret-credential', 'Confidential label', master]) assert.equal(stored.includes(secret), false);
assert.equal(Buffer.from(JSON.parse(stored).kemCiphertext, 'base64').length, 1568);
assert.equal('password' in engine.list()[0], false);
engine.lock(); assert.throws(() => engine.list()); assert.throws(() => engine.credential(id, 'https://company.example'));
engine.unlock(master); assert.deepEqual(engine.credential(id, 'https://company.example'), { username: 'private-user-name', password: 'top-secret-credential' });
assert.throws(() => engine.credential(id, 'https://company.example:444'));
assert.throws(() => engine.credential(id, 'https://evil.example'));
assert.throws(() => engine.credential(id, 'http://company.example'));
assert.throws(() => engine.credential(id, 'https://company.example/path'));
engine.delete(id); engine.lock(); engine.unlock(master); assert.deepEqual(engine.list(), []);
});
test('wrong master password and wrong platform factor fail closed', t => {
const { engine, options } = fixture(t); t.after(engine.lock);
engine.create(master); engine.lock();
assert.throws(() => engine.unlock('Wrong long master password!!!'), /Vault operation refused/);
assert.equal(engine.status().locked, true);
const other = createVault({ ...options, unprotect: () => { throw new Error('DPAPI failed'); } });
assert.throws(() => other.unlock(master), /Vault operation refused/); assert.equal(other.status().locked, true);
engine.unlock(master); assert.equal(engine.status().locked, false);
});
test('ciphertext, header and key envelope tampering is refused', t => {
const { engine, options } = fixture(t); t.after(engine.lock);
engine.create(master); engine.lock(); const pristine = fs.readFileSync(options.filePath, 'utf8');
for (const mutate of [record => { record.payload.ciphertext = record.payload.ciphertext.replace(/^./, char => char === 'A' ? 'B' : 'A'); },
record => { record.vaultId = crypto.randomUUID(); }, record => { record.salt = crypto.randomBytes(32).toString('base64'); },
record => { record.wrappedKey.tag = crypto.randomBytes(16).toString('base64'); }, record => { record.kemCiphertext = crypto.randomBytes(1568).toString('base64'); }]) {
const record = JSON.parse(pristine); mutate(record); fs.writeFileSync(options.filePath, JSON.stringify(record));
assert.throws(() => engine.unlock(master), /Vault operation refused/); assert.equal(engine.status().locked, true);
}
});
test('malformed oversized file and untrusted KDF parameters never weaken security', t => {
const { engine, options } = fixture(t); t.after(engine.lock);
engine.create(master); engine.lock();
const record = JSON.parse(fs.readFileSync(options.filePath, 'utf8')); record.scrypt = { N: 2, r: 1, p: 1 };
fs.writeFileSync(options.filePath, JSON.stringify(record)); engine.unlock(master); engine.lock();
fs.writeFileSync(options.filePath, Buffer.alloc(8 * 1024 * 1024 + 1)); assert.throws(() => engine.unlock(master));
fs.writeFileSync(options.filePath, '{bad json'); assert.throws(() => engine.unlock(master));
});
test('master requirements, idle bounds and credential validation', t => {
const { engine, options } = fixture(t); t.after(engine.lock);
assert.throws(() => engine.create('short')); assert.equal(engine.status().exists, false);
assert.throws(() => createVault({ ...options, idleSeconds: 59 })); assert.throws(() => createVault({ ...options, idleSeconds: 901 }));
engine.create(master); assert.throws(() => engine.create(master)); assert.equal(engine.status().locked, true); engine.unlock(master);
assert.throws(() => engine.upsert({ origin: 'https://user:[email protected]', username: 'user', password: 'secret' }));
assert.throws(() => engine.upsert({ origin: 'http://company.example', username: 'user', password: 'secret' }));
assert.throws(() => engine.upsert({ origin: 'https://company.example', username: 'user', password: '' }));
assert.deepEqual(engine.list(), []);
assert.equal(generatePassword().length, 32); assert.throws(() => generatePassword(23)); assert.notEqual(generatePassword(), generatePassword());
});
test('idle expiry locks credentials and atomic write failure preserves the last committed vault', t => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { engine, options } = fixture(t); t.after(engine.lock);
engine.create(master); const { id } = engine.upsert({ origin: 'https://company.example', username: 'user', password: 'original' });
const original = fs.readFileSync(options.filePath);
const rename = t.mock.method(fs, 'renameSync', () => { throw new Error('Disk write failed'); });
assert.throws(() => engine.upsert({ id, origin: 'https://company.example', username: 'user', password: 'uncommitted' }));
assert.equal(engine.credential(id, 'https://company.example').password, 'original');
assert.deepEqual(fs.readFileSync(options.filePath), original);
assert.equal(fs.readdirSync(path.dirname(options.filePath)).some(name => name.endsWith('.tmp')), false);
rename.mock.restore();
t.mock.timers.tick(300_001); assert.equal(engine.status().locked, true);
assert.throws(() => engine.credential(id, 'https://company.example'));
engine.unlock(master); assert.equal(engine.credential(id, 'https://company.example').password, 'original');
});
test('background metadata polling cannot postpone the idle lock deadline', t => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { engine } = fixture(t); t.after(engine.lock);
engine.create(master);
for (let elapsed = 0; elapsed < 300_000; elapsed += 5000) {
assert.deepEqual(engine.list(), []); t.mock.timers.tick(5000);
}
assert.equal(engine.status().locked, true);
assert.throws(() => engine.list());
});