From 5a059670f6b009b017d0d72af439497a84f4be52 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Sat, 22 Aug 2026 10:20:23 +0200 Subject: [PATCH] feat: add encrypted online backup keyring --- lib/online-backup-keyring.js | 162 +++++++++++++++++++++++++ tests/online-backup-keyring.test.js | 177 ++++++++++++++++++++++++++++ 2 files changed, 339 insertions(+) create mode 100644 lib/online-backup-keyring.js create mode 100644 tests/online-backup-keyring.test.js diff --git a/lib/online-backup-keyring.js b/lib/online-backup-keyring.js new file mode 100644 index 0000000..fc9203d --- /dev/null +++ b/lib/online-backup-keyring.js @@ -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 }; diff --git a/tests/online-backup-keyring.test.js b/tests/online-backup-keyring.test.js new file mode 100644 index 0000000..35b80f8 --- /dev/null +++ b/tests/online-backup-keyring.test.js @@ -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); + }); +});