feat: add encrypted online backup keyring

This commit is contained in:
Sucukdeluxe
2026-08-22 10:20:23 +02:00
parent 1edc571686
commit 5a059670f6
2 changed files with 339 additions and 0 deletions
+162
View File
@@ -0,0 +1,162 @@
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const secretStore = require('./secret-store');
const { parseOnlineBackupKey } = require('./online-backup');
function isObject(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function normalizeTimestamp(value) {
const timestamp = new Date(value);
if (!Number.isFinite(timestamp.getTime())) throw new Error('Ungültiger Erstellungszeitpunkt');
return timestamp.toISOString();
}
function createOnlineBackupKeyring({
filePath,
encryptField = secretStore.encryptField,
decryptField = secretStore.decryptField,
parseKey = parseOnlineBackupKey,
fsImpl = fs.promises
}) {
let mutation = Promise.resolve();
function validateEntry(entry) {
if (
!isObject(entry)
|| typeof entry.id !== 'string'
|| !entry.id
|| typeof entry.encryptedKey !== 'string'
|| !entry.encryptedKey
|| typeof entry.createdAt !== 'string'
) {
return null;
}
let key;
let parsed;
try {
key = decryptField(entry.encryptedKey);
parsed = parseKey(key);
} catch {
return null;
}
if (typeof key !== 'string' || parsed?.id !== entry.id) return null;
let createdAt;
try {
createdAt = normalizeTimestamp(entry.createdAt);
} catch {
return null;
}
if (createdAt !== entry.createdAt) return null;
return { id: entry.id, encryptedKey: entry.encryptedKey, createdAt, key };
}
async function readEntries() {
let contents;
try {
contents = await fsImpl.readFile(filePath, 'utf8');
} catch (error) {
if (error?.code === 'ENOENT') return [];
throw error;
}
let document;
try {
document = JSON.parse(contents);
} catch {
throw new Error('Gespeicherter Online-Schlüsselbund ist ungültig');
}
if (!isObject(document) || document.version !== 1 || !Array.isArray(document.entries)) {
throw new Error('Gespeicherter Online-Schlüsselbund ist ungültig');
}
return document.entries.map(validateEntry).filter(Boolean);
}
async function writeEntries(entries) {
const directory = path.dirname(filePath);
const temporaryPath = path.join(directory, `.${path.basename(filePath)}.${process.pid}.${crypto.randomUUID()}.tmp`);
const document = JSON.stringify({
version: 1,
entries: entries.map(({ id, encryptedKey, createdAt }) => ({ id, encryptedKey, createdAt }))
});
await fsImpl.mkdir(directory, { recursive: true });
try {
await fsImpl.writeFile(temporaryPath, document, { encoding: 'utf8', flag: 'wx' });
await fsImpl.rename(temporaryPath, filePath);
} catch (error) {
try {
await fsImpl.unlink(temporaryPath);
} catch (cleanupError) {
if (cleanupError?.code !== 'ENOENT') throw cleanupError;
}
throw error;
}
}
function serialize(operation) {
const next = mutation.catch(() => {}).then(operation);
mutation = next;
return next;
}
async function list() {
const entries = await readEntries();
const sanitized = entries
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))
.map(({ id, key, createdAt }) => Object.freeze({
id,
displayKey: `${key.slice(0, 9)}${key.slice(-4)}`,
createdAt
}));
return Object.freeze(sanitized);
}
function prepare(key, createdAt) {
let parsed;
let encryptedKey;
try {
parsed = parseKey(key);
encryptedKey = encryptField(key);
} catch {
throw new Error('Online-Sicherungsschlüssel konnte nicht sicher vorbereitet werden');
}
if (typeof encryptedKey !== 'string' || !encryptedKey || encryptedKey === key) {
throw new Error('Online-Sicherungsschlüssel konnte nicht sicher vorbereitet werden');
}
return Object.freeze({
id: parsed.id,
encryptedKey,
createdAt: normalizeTimestamp(createdAt)
});
}
function commit(entry) {
return serialize(async () => {
const validated = validateEntry(entry);
if (!validated) throw new Error('Online-Sicherungsschlüssel ist ungültig');
const entries = await readEntries();
if (entries.some((current) => current.id === validated.id)) return;
await writeEntries([...entries, validated]);
});
}
function remove(id) {
return serialize(async () => {
const entries = await readEntries();
const remaining = entries.filter((entry) => entry.id !== id);
if (remaining.length === entries.length) return false;
await writeEntries(remaining);
return true;
});
}
async function getKey(id) {
const entries = await readEntries();
return entries.find((entry) => entry.id === id)?.key ?? null;
}
return Object.freeze({ list, prepare, commit, remove, getKey });
}
module.exports = { createOnlineBackupKeyring };
+177
View File
@@ -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);
});
});