feat: add encrypted online backup keyring
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { afterEach, describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { createOnlineBackup, parseOnlineBackupKey } = require('../lib/online-backup');
|
||||
|
||||
const directories = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of directories.splice(0)) fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function fixture(options = {}) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-keyring-'));
|
||||
directories.push(directory);
|
||||
const filePath = path.join(directory, 'online-backup-keyring.json');
|
||||
const encryptField = options.encryptField || ((value) => `enc:v1:${Buffer.from(value).toString('base64')}`);
|
||||
const decryptField = options.decryptField || ((value) => Buffer.from(value.slice('enc:v1:'.length), 'base64').toString('utf8'));
|
||||
const { createOnlineBackupKeyring } = require('../lib/online-backup-keyring');
|
||||
return {
|
||||
directory,
|
||||
filePath,
|
||||
keyring: createOnlineBackupKeyring({ filePath, encryptField, decryptField, fsImpl: options.fsImpl })
|
||||
};
|
||||
}
|
||||
|
||||
function validKey() {
|
||||
return createOnlineBackup({}, '2.1.31').key;
|
||||
}
|
||||
|
||||
describe('encrypted online backup keyring', () => {
|
||||
it('persists only encrypted keys and returns frozen sanitized entries plus the decrypted key', async () => {
|
||||
const { filePath, keyring } = fixture();
|
||||
const key = validKey();
|
||||
const prepared = keyring.prepare(key, '2026-08-22T10:00:00.000Z');
|
||||
|
||||
await keyring.commit(prepared);
|
||||
|
||||
const listed = await keyring.list();
|
||||
assert.equal(fs.readFileSync(filePath, 'utf8').includes(key), false);
|
||||
assert.deepEqual(listed, [{
|
||||
id: parseOnlineBackupKey(key).id,
|
||||
displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`,
|
||||
createdAt: '2026-08-22T10:00:00.000Z'
|
||||
}]);
|
||||
assert.equal(Object.isFrozen(listed), true);
|
||||
assert.equal(Object.isFrozen(listed[0]), true);
|
||||
assert.equal(await keyring.getKey(prepared.id), key);
|
||||
});
|
||||
|
||||
it('throws during prepare without writing when safe encryption is unavailable or fails', () => {
|
||||
for (const failure of [
|
||||
() => { throw new Error('Sicherer Zugangsdaten-Speicher ist nicht verfügbar'); },
|
||||
(value) => { throw new Error(`Verschlüsselung fehlgeschlagen: ${value}`); }
|
||||
]) {
|
||||
const { filePath, keyring } = fixture({ encryptField: failure });
|
||||
const key = validKey();
|
||||
assert.throws(() => keyring.prepare(key, '2026-08-22T10:00:00.000Z'), (error) => !error.message.includes(key));
|
||||
assert.equal(fs.existsSync(filePath), false);
|
||||
}
|
||||
});
|
||||
|
||||
it('sorts newer entries first', async () => {
|
||||
const { keyring } = fixture();
|
||||
const older = validKey();
|
||||
const newer = validKey();
|
||||
|
||||
await keyring.commit(keyring.prepare(older, '2026-08-22T10:00:00.000Z'));
|
||||
await keyring.commit(keyring.prepare(newer, '2026-08-22T11:00:00.000Z'));
|
||||
|
||||
assert.deepEqual((await keyring.list()).map((entry) => entry.id), [
|
||||
parseOnlineBackupKey(newer).id,
|
||||
parseOnlineBackupKey(older).id
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not replace the encrypted value or creation timestamp for duplicate IDs', async () => {
|
||||
let encryptionCount = 0;
|
||||
const encryptField = (value) => `enc:v1:${++encryptionCount}:${Buffer.from(value).toString('base64')}`;
|
||||
const decryptField = (value) => Buffer.from(value.split(':').slice(3).join(':'), 'base64').toString('utf8');
|
||||
const { filePath, keyring } = fixture({ encryptField, decryptField });
|
||||
const key = validKey();
|
||||
const first = keyring.prepare(key, '2026-08-22T10:00:00.000Z');
|
||||
const duplicate = keyring.prepare(key, '2026-08-22T12:00:00.000Z');
|
||||
|
||||
await keyring.commit(first);
|
||||
const original = fs.readFileSync(filePath, 'utf8');
|
||||
await keyring.commit(duplicate);
|
||||
|
||||
assert.equal(fs.readFileSync(filePath, 'utf8'), original);
|
||||
assert.deepEqual(await keyring.list(), [{
|
||||
id: parseOnlineBackupKey(key).id,
|
||||
displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`,
|
||||
createdAt: '2026-08-22T10:00:00.000Z'
|
||||
}]);
|
||||
});
|
||||
|
||||
it('removes entries atomically and reports whether an entry existed', async () => {
|
||||
const { directory, filePath, keyring } = fixture();
|
||||
const key = validKey();
|
||||
const prepared = keyring.prepare(key, '2026-08-22T10:00:00.000Z');
|
||||
await keyring.commit(prepared);
|
||||
|
||||
assert.equal(await keyring.remove(prepared.id), true);
|
||||
assert.equal(await keyring.remove(prepared.id), false);
|
||||
assert.deepEqual(await keyring.list(), []);
|
||||
assert.deepEqual(fs.readdirSync(directory), [path.basename(filePath)]);
|
||||
});
|
||||
|
||||
it('skips invalid entry shapes while listing but blocks mutation for invalid JSON', async () => {
|
||||
const { filePath, keyring } = fixture();
|
||||
const key = validKey();
|
||||
const prepared = keyring.prepare(key, '2026-08-22T10:00:00.000Z');
|
||||
fs.writeFileSync(filePath, JSON.stringify({
|
||||
version: 1,
|
||||
entries: [
|
||||
prepared,
|
||||
null,
|
||||
{ id: 7, encryptedKey: 'enc:v1:value', createdAt: '2026-08-22T10:00:00.000Z' },
|
||||
{ id: prepared.id, encryptedKey: '', createdAt: 'invalid' }
|
||||
]
|
||||
}));
|
||||
|
||||
assert.equal((await keyring.list()).length, 1);
|
||||
fs.writeFileSync(filePath, '{invalid-json');
|
||||
const next = keyring.prepare(validKey(), '2026-08-22T11:00:00.000Z');
|
||||
await assert.rejects(keyring.commit(next));
|
||||
assert.equal(fs.readFileSync(filePath, 'utf8'), '{invalid-json');
|
||||
});
|
||||
|
||||
it('keeps the previous valid file readable and removes the temporary file after rename fails', async () => {
|
||||
const firstFixture = fixture();
|
||||
const firstKey = validKey();
|
||||
await firstFixture.keyring.commit(firstFixture.keyring.prepare(firstKey, '2026-08-22T10:00:00.000Z'));
|
||||
const original = fs.readFileSync(firstFixture.filePath, 'utf8');
|
||||
const fsImpl = {
|
||||
...fs.promises,
|
||||
rename: async () => { throw new Error('rename failed'); }
|
||||
};
|
||||
const { createOnlineBackupKeyring } = require('../lib/online-backup-keyring');
|
||||
const keyring = createOnlineBackupKeyring({
|
||||
filePath: firstFixture.filePath,
|
||||
encryptField: (value) => `enc:v1:${Buffer.from(value).toString('base64')}`,
|
||||
decryptField: (value) => Buffer.from(value.slice('enc:v1:'.length), 'base64').toString('utf8'),
|
||||
fsImpl
|
||||
});
|
||||
const secondKey = validKey();
|
||||
|
||||
await assert.rejects(keyring.commit(keyring.prepare(secondKey, '2026-08-22T11:00:00.000Z')), /rename failed/);
|
||||
assert.equal(fs.readFileSync(firstFixture.filePath, 'utf8'), original);
|
||||
assert.deepEqual(fs.readdirSync(firstFixture.directory), [path.basename(firstFixture.filePath)]);
|
||||
assert.equal(await firstFixture.keyring.getKey(parseOnlineBackupKey(firstKey).id), firstKey);
|
||||
});
|
||||
|
||||
it('never includes complete plaintext keys in persisted files or mutation errors', async () => {
|
||||
const { filePath, keyring } = fixture();
|
||||
const firstKey = validKey();
|
||||
const secondKey = validKey();
|
||||
await keyring.commit(keyring.prepare(firstKey, '2026-08-22T10:00:00.000Z'));
|
||||
fs.writeFileSync(filePath, '{invalid-json');
|
||||
|
||||
let error;
|
||||
try {
|
||||
await keyring.commit(keyring.prepare(secondKey, '2026-08-22T11:00:00.000Z'));
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
assert.ok(error);
|
||||
assert.equal(error.message.includes(firstKey), false);
|
||||
assert.equal(error.message.includes(secondKey), false);
|
||||
assert.equal(fs.readFileSync(filePath, 'utf8').includes(firstKey), false);
|
||||
assert.equal(fs.readFileSync(filePath, 'utf8').includes(secondKey), false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user