fix: harden managed online backup key handling
Require canonical encrypted envelopes and surface typed sanitized keyring corruption states. Persist crash-durable primary and recovery files, prevalidate removal plans, isolate renderer refresh authority, and cover the real hidden Windows DPAPI and IPC composition.
This commit is contained in:
+358
-113
@@ -1,28 +1,51 @@
|
||||
const crypto = require('node:crypto');
|
||||
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 secretStore = require('../lib/secret-store');
|
||||
const { createOnlineBackup, parseOnlineBackupKey } = require('../lib/online-backup');
|
||||
|
||||
const directories = [];
|
||||
const timestamp = '2026-08-22T10:00:00.000Z';
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of directories.splice(0)) fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function encrypt(value) {
|
||||
return `enc:v1:${Buffer.from(value).toString('base64')}`;
|
||||
}
|
||||
|
||||
function decrypt(value) {
|
||||
return Buffer.from(value.slice('enc:v1:'.length), 'base64').toString('utf8');
|
||||
}
|
||||
|
||||
function isCanonicalEnvelope(value) {
|
||||
if (typeof value !== 'string' || !value.startsWith('enc:v1:')) return false;
|
||||
const encoded = value.slice('enc:v1:'.length);
|
||||
if (!encoded || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(encoded)) return false;
|
||||
return Buffer.from(encoded, 'base64').toString('base64') === encoded;
|
||||
}
|
||||
|
||||
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 })
|
||||
backupPath: `${filePath}.bak`,
|
||||
keyring: createOnlineBackupKeyring({
|
||||
filePath,
|
||||
encryptField: options.encryptField || encrypt,
|
||||
decryptField: options.decryptField || decrypt,
|
||||
isEncrypted: options.isEncrypted || isCanonicalEnvelope,
|
||||
fsImpl: options.fsImpl
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,59 +53,201 @@ function validKey() {
|
||||
return createOnlineBackup({}, '2.1.31').key;
|
||||
}
|
||||
|
||||
function keyWithRecordId(sourceKey, fill) {
|
||||
const idBytes = parseOnlineBackupKey(sourceKey).idBytes;
|
||||
const masterKey = Buffer.alloc(32, fill);
|
||||
const context = Buffer.from('MHU2-ONLINE-KEY-V1', 'utf8');
|
||||
const checksum = crypto.createHash('sha256').update(context).update(idBytes).update(masterKey).digest().subarray(0, 4);
|
||||
return `MHU2-${Buffer.concat([idBytes, masterKey, checksum]).toString('base64url')}`;
|
||||
}
|
||||
|
||||
function writeKeyring(filePath, keys) {
|
||||
fs.writeFileSync(filePath, JSON.stringify({ version: 1, keys }));
|
||||
}
|
||||
|
||||
describe('encrypted online backup keyring', () => {
|
||||
it('persists only encrypted keys and returns frozen sanitized entries plus the decrypted key', async () => {
|
||||
it('persists the spec keys schema without plaintext and returns frozen sanitized entries', async () => {
|
||||
const { filePath, keyring } = fixture();
|
||||
const key = validKey();
|
||||
const prepared = keyring.prepare(key, '2026-08-22T10:00:00.000Z');
|
||||
const prepared = keyring.prepare(key, timestamp);
|
||||
|
||||
await keyring.commit(prepared);
|
||||
|
||||
const listed = await keyring.list();
|
||||
const document = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
const snapshot = await keyring.list();
|
||||
assert.deepEqual(Object.keys(document).sort(), ['keys', 'version']);
|
||||
assert.equal(document.keys.length, 1);
|
||||
assert.equal(fs.readFileSync(filePath, 'utf8').includes(key), false);
|
||||
assert.deepEqual(listed, [{
|
||||
assert.deepEqual(snapshot.issues, []);
|
||||
assert.deepEqual(snapshot.entries, [{
|
||||
id: parseOnlineBackupKey(key).id,
|
||||
displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`,
|
||||
createdAt: '2026-08-22T10:00:00.000Z'
|
||||
createdAt: timestamp
|
||||
}]);
|
||||
assert.equal(Object.isFrozen(listed), true);
|
||||
assert.equal(Object.isFrozen(listed[0]), true);
|
||||
assert.equal(Object.isFrozen(snapshot), true);
|
||||
assert.equal(Object.isFrozen(snapshot.entries), true);
|
||||
assert.equal(Object.isFrozen(snapshot.entries[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('requires a canonical encrypted envelope before decrypting stored values', async () => {
|
||||
const key = validKey();
|
||||
const id = parseOnlineBackupKey(key).id;
|
||||
for (const encryptedKey of [key, 'enc:v2:YWJjZA==', 'enc:v1:YWJjZA']) {
|
||||
let decryptCalls = 0;
|
||||
const { filePath, keyring } = fixture({
|
||||
decryptField: value => {
|
||||
decryptCalls++;
|
||||
return value;
|
||||
}
|
||||
});
|
||||
writeKeyring(filePath, [{ id, encryptedKey, createdAt: timestamp }]);
|
||||
|
||||
const snapshot = await keyring.list();
|
||||
|
||||
assert.deepEqual(snapshot.entries, []);
|
||||
assert.deepEqual(snapshot.issues, ['KEYRING_STRUCTURE_INVALID']);
|
||||
assert.equal(decryptCalls, 0);
|
||||
}
|
||||
});
|
||||
|
||||
it('sorts newer entries first', async () => {
|
||||
const { keyring } = fixture();
|
||||
const older = validKey();
|
||||
const newer = validKey();
|
||||
it('rejects plaintext even when the real secret store would pass legacy values through', async () => {
|
||||
const { filePath, keyring } = fixture({
|
||||
decryptField: secretStore.decryptField,
|
||||
isEncrypted: secretStore.isEncrypted
|
||||
});
|
||||
const key = validKey();
|
||||
writeKeyring(filePath, [{ id: parseOnlineBackupKey(key).id, encryptedKey: key, createdAt: timestamp }]);
|
||||
|
||||
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
|
||||
]);
|
||||
assert.equal(secretStore.decryptField(key), key);
|
||||
const snapshot = await keyring.list();
|
||||
assert.deepEqual(snapshot.entries, []);
|
||||
assert.deepEqual(snapshot.issues, ['KEYRING_STRUCTURE_INVALID']);
|
||||
await assert.rejects(
|
||||
keyring.getKey(parseOnlineBackupKey(key).id),
|
||||
error => error.code === 'KEYRING_STRUCTURE_INVALID' && !error.message.includes(key)
|
||||
);
|
||||
});
|
||||
|
||||
it('does not replace the encrypted value or creation timestamp for duplicate IDs', async () => {
|
||||
it('types secure-storage, decryption, ID-mismatch, and structure failures without secrets', async () => {
|
||||
const key = validKey();
|
||||
const otherKey = validKey();
|
||||
const cases = [
|
||||
{
|
||||
expected: 'KEYRING_SECURE_STORAGE_UNAVAILABLE',
|
||||
decryptField: () => { throw new secretStore.SecretStoreError('SECRET_STORE_UNAVAILABLE', `unavailable ${key}`); },
|
||||
entry: { id: parseOnlineBackupKey(key).id, encryptedKey: encrypt(key), createdAt: timestamp }
|
||||
},
|
||||
{
|
||||
expected: 'KEYRING_DECRYPT_FAILED',
|
||||
decryptField: () => { throw new secretStore.SecretStoreError('SECRET_STORE_DECRYPT_FAILED', `decrypt ${key}`); },
|
||||
entry: { id: parseOnlineBackupKey(key).id, encryptedKey: encrypt(key), createdAt: timestamp }
|
||||
},
|
||||
{
|
||||
expected: 'KEYRING_ID_MISMATCH',
|
||||
decryptField: decrypt,
|
||||
entry: { id: parseOnlineBackupKey(otherKey).id, encryptedKey: encrypt(key), createdAt: timestamp }
|
||||
},
|
||||
{
|
||||
expected: 'KEYRING_STRUCTURE_INVALID',
|
||||
decryptField: decrypt,
|
||||
entry: { id: parseOnlineBackupKey(key).id, encryptedKey: encrypt(key), createdAt: 'not-a-date' }
|
||||
}
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const { filePath, keyring } = fixture({ decryptField: testCase.decryptField });
|
||||
writeKeyring(filePath, [testCase.entry]);
|
||||
const snapshot = await keyring.list();
|
||||
assert.deepEqual(snapshot.entries, []);
|
||||
assert.deepEqual(snapshot.issues, [testCase.expected]);
|
||||
assert.equal(JSON.stringify(snapshot).includes(key), false);
|
||||
assert.equal(JSON.stringify(snapshot).includes(testCase.entry.encryptedKey), false);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps readable entries available while reporting neighboring corruption', async () => {
|
||||
const { filePath, keyring } = fixture();
|
||||
const readable = validKey();
|
||||
const broken = validKey();
|
||||
writeKeyring(filePath, [
|
||||
{ id: parseOnlineBackupKey(readable).id, encryptedKey: encrypt(readable), createdAt: timestamp },
|
||||
{ id: parseOnlineBackupKey(broken).id, encryptedKey: 'enc:v1:YWJjZA', createdAt: timestamp }
|
||||
]);
|
||||
|
||||
const snapshot = await keyring.list();
|
||||
|
||||
assert.deepEqual(snapshot.entries.map(entry => entry.id), [parseOnlineBackupKey(readable).id]);
|
||||
assert.deepEqual(snapshot.issues, ['KEYRING_STRUCTURE_INVALID']);
|
||||
assert.equal(await keyring.getKey(parseOnlineBackupKey(readable).id), readable);
|
||||
await assert.rejects(
|
||||
keyring.getKey(parseOnlineBackupKey(broken).id),
|
||||
error => error.code === 'KEYRING_STRUCTURE_INVALID'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not expose duplicate IDs and blocks an ambiguous removal plan', async () => {
|
||||
const { filePath, keyring } = fixture();
|
||||
const first = validKey();
|
||||
const second = keyWithRecordId(first, 0x5a);
|
||||
const id = parseOnlineBackupKey(first).id;
|
||||
writeKeyring(filePath, [
|
||||
{ id, encryptedKey: encrypt(first), createdAt: timestamp },
|
||||
{ id, encryptedKey: encrypt(second), createdAt: '2026-08-22T11:00:00.000Z' }
|
||||
]);
|
||||
|
||||
const snapshot = await keyring.list();
|
||||
|
||||
assert.deepEqual(snapshot.entries, []);
|
||||
assert.deepEqual(snapshot.issues, ['KEYRING_DUPLICATE_ID']);
|
||||
await assert.rejects(keyring.getKey(id), error => error.code === 'KEYRING_DUPLICATE_ID');
|
||||
await assert.rejects(keyring.prepareRemove(id), error => error.code === 'KEYRING_DUPLICATE_ID');
|
||||
});
|
||||
|
||||
it('fully validates a unique removal plan before committing exactly that plan', async () => {
|
||||
let decryptAllowed = true;
|
||||
const { keyring } = fixture({
|
||||
decryptField: value => {
|
||||
if (!decryptAllowed) throw new Error('late revalidation');
|
||||
return decrypt(value);
|
||||
}
|
||||
});
|
||||
const removed = validKey();
|
||||
const retained = validKey();
|
||||
await keyring.commit(keyring.prepare(removed, timestamp));
|
||||
await keyring.commit(keyring.prepare(retained, '2026-08-22T11:00:00.000Z'));
|
||||
|
||||
const plan = await keyring.prepareRemove(parseOnlineBackupKey(removed).id);
|
||||
decryptAllowed = false;
|
||||
assert.equal(plan.id, parseOnlineBackupKey(removed).id);
|
||||
assert.equal(plan.key, removed);
|
||||
assert.equal(await keyring.commitRemove(plan), true);
|
||||
decryptAllowed = true;
|
||||
assert.deepEqual((await keyring.list()).entries.map(entry => entry.id), [parseOnlineBackupKey(retained).id]);
|
||||
});
|
||||
|
||||
it('blocks removal before remote work when any neighboring entry is corrupt', async () => {
|
||||
const { filePath, keyring } = fixture();
|
||||
const valid = validKey();
|
||||
const invalid = validKey();
|
||||
writeKeyring(filePath, [
|
||||
{ id: parseOnlineBackupKey(valid).id, encryptedKey: encrypt(valid), createdAt: timestamp },
|
||||
{ id: parseOnlineBackupKey(invalid).id, encryptedKey: 'enc:v1:YWJjZA', createdAt: timestamp }
|
||||
]);
|
||||
|
||||
await assert.rejects(
|
||||
keyring.prepareRemove(parseOnlineBackupKey(valid).id),
|
||||
error => error.code === 'KEYRING_STRUCTURE_INVALID'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not replace the encrypted value or creation timestamp for duplicate commits', 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 encryptField = value => `enc:v1:${Buffer.from(`${++encryptionCount}:${value}`).toString('base64')}`;
|
||||
const decryptField = value => Buffer.from(value.slice('enc:v1:'.length), 'base64').toString('utf8').replace(/^\d+:/u, '');
|
||||
const { filePath, keyring } = fixture({ encryptField, decryptField });
|
||||
const key = validKey();
|
||||
const first = keyring.prepare(key, '2026-08-22T10:00:00.000Z');
|
||||
const first = keyring.prepare(key, timestamp);
|
||||
const duplicate = keyring.prepare(key, '2026-08-22T12:00:00.000Z');
|
||||
|
||||
await keyring.commit(first);
|
||||
@@ -90,103 +255,183 @@ describe('encrypted online backup keyring', () => {
|
||||
await keyring.commit(duplicate);
|
||||
|
||||
assert.equal(fs.readFileSync(filePath, 'utf8'), original);
|
||||
assert.deepEqual(await keyring.list(), [{
|
||||
assert.deepEqual((await keyring.list()).entries, [{
|
||||
id: parseOnlineBackupKey(key).id,
|
||||
displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`,
|
||||
createdAt: '2026-08-22T10:00:00.000Z'
|
||||
createdAt: timestamp
|
||||
}]);
|
||||
});
|
||||
|
||||
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);
|
||||
it('sorts newer entries first', async () => {
|
||||
const { keyring } = fixture();
|
||||
const older = validKey();
|
||||
const newer = validKey();
|
||||
|
||||
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)]);
|
||||
await keyring.commit(keyring.prepare(older, timestamp));
|
||||
await keyring.commit(keyring.prepare(newer, '2026-08-22T11:00:00.000Z'));
|
||||
|
||||
assert.deepEqual((await keyring.list()).entries.map(entry => entry.id), [
|
||||
parseOnlineBackupKey(newer).id,
|
||||
parseOnlineBackupKey(older).id
|
||||
]);
|
||||
});
|
||||
|
||||
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('rejects entries with additional properties without legitimizing the unsafe file during mutation', async () => {
|
||||
const { filePath, keyring } = fixture();
|
||||
const key = validKey();
|
||||
const prepared = keyring.prepare(key, '2026-08-22T10:00:00.000Z');
|
||||
const contents = JSON.stringify({
|
||||
version: 1,
|
||||
entries: [{ ...prepared, plaintextKey: key }]
|
||||
});
|
||||
fs.writeFileSync(filePath, contents);
|
||||
|
||||
await assert.rejects(keyring.commit(prepared));
|
||||
assert.equal(fs.readFileSync(filePath, 'utf8'), contents);
|
||||
assert.deepEqual(await keyring.list(), []);
|
||||
});
|
||||
|
||||
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');
|
||||
it('syncs complete temporary files before atomic replacement and syncs directory metadata', async () => {
|
||||
const events = [];
|
||||
const fsImpl = {
|
||||
...fs.promises,
|
||||
rename: async () => { throw new Error('rename failed'); }
|
||||
open: async (target, flags, mode) => {
|
||||
const handle = await fs.promises.open(target, flags, mode);
|
||||
const label = path.basename(String(target));
|
||||
return {
|
||||
writeFile: async (...args) => {
|
||||
events.push(`write:${label}`);
|
||||
return handle.writeFile(...args);
|
||||
},
|
||||
sync: async () => {
|
||||
events.push(`sync:${label}`);
|
||||
return handle.sync();
|
||||
},
|
||||
close: async () => {
|
||||
events.push(`close:${label}`);
|
||||
return handle.close();
|
||||
}
|
||||
};
|
||||
},
|
||||
rename: async (source, target) => {
|
||||
events.push(`rename:${path.basename(source)}>${path.basename(target)}`);
|
||||
return fs.promises.rename(source, target);
|
||||
}
|
||||
};
|
||||
const { filePath, backupPath, keyring } = fixture({ fsImpl });
|
||||
|
||||
await keyring.commit(keyring.prepare(validKey(), timestamp));
|
||||
|
||||
const renames = events.filter(event => event.startsWith('rename:'));
|
||||
assert.equal(renames.length, 2);
|
||||
for (const rename of renames) {
|
||||
const source = rename.slice('rename:'.length).split('>')[0];
|
||||
assert.ok(events.indexOf(`write:${source}`) < events.indexOf(`sync:${source}`));
|
||||
assert.ok(events.indexOf(`sync:${source}`) < events.indexOf(`close:${source}`));
|
||||
assert.ok(events.indexOf(`close:${source}`) < events.indexOf(rename));
|
||||
}
|
||||
assert.equal(fs.existsSync(filePath), true);
|
||||
assert.equal(fs.existsSync(backupPath), true);
|
||||
assert.ok(events.filter(event => event === `sync:${path.basename(path.dirname(filePath))}`).length >= 2);
|
||||
});
|
||||
|
||||
it('recovers a validated backup without presenting corruption as an empty keyring', async () => {
|
||||
const first = fixture();
|
||||
const key = validKey();
|
||||
await first.keyring.commit(first.keyring.prepare(key, timestamp));
|
||||
fs.writeFileSync(first.filePath, '{damaged-primary');
|
||||
const { createOnlineBackupKeyring } = require('../lib/online-backup-keyring');
|
||||
const recovered = createOnlineBackupKeyring({
|
||||
filePath: first.filePath,
|
||||
encryptField: encrypt,
|
||||
decryptField: decrypt,
|
||||
isEncrypted: isCanonicalEnvelope
|
||||
});
|
||||
|
||||
const snapshot = await recovered.list();
|
||||
|
||||
assert.deepEqual(snapshot.entries.map(entry => entry.id), [parseOnlineBackupKey(key).id]);
|
||||
assert.deepEqual(snapshot.issues, ['KEYRING_RECOVERED']);
|
||||
});
|
||||
|
||||
it('skips a newer cryptographically invalid recovery temp in favor of the validated backup', async () => {
|
||||
const first = fixture();
|
||||
const key = validKey();
|
||||
await first.keyring.commit(first.keyring.prepare(key, timestamp));
|
||||
fs.writeFileSync(first.filePath, '{damaged-primary');
|
||||
const invalidTemp = path.join(first.directory, `.online-backup-keyring.json.${process.pid}.${crypto.randomUUID()}.recovery.tmp`);
|
||||
writeKeyring(invalidTemp, [{
|
||||
id: parseOnlineBackupKey(key).id,
|
||||
encryptedKey: 'enc:v1:YWJjZA',
|
||||
createdAt: timestamp
|
||||
}]);
|
||||
const future = new Date(Date.now() + 60_000);
|
||||
fs.utimesSync(invalidTemp, future, future);
|
||||
const { createOnlineBackupKeyring } = require('../lib/online-backup-keyring');
|
||||
const recovered = createOnlineBackupKeyring({
|
||||
filePath: first.filePath,
|
||||
encryptField: encrypt,
|
||||
decryptField: decrypt,
|
||||
isEncrypted: isCanonicalEnvelope
|
||||
});
|
||||
|
||||
const snapshot = await recovered.list();
|
||||
|
||||
assert.deepEqual(snapshot.entries.map(entry => entry.id), [parseOnlineBackupKey(key).id]);
|
||||
assert.deepEqual(snapshot.issues, ['KEYRING_RECOVERED']);
|
||||
});
|
||||
|
||||
it('recovers a newer fully synced commit temp after power loss before primary replacement', async () => {
|
||||
const first = fixture();
|
||||
const older = validKey();
|
||||
const newer = validKey();
|
||||
const olderEntry = first.keyring.prepare(older, timestamp);
|
||||
const newerEntry = first.keyring.prepare(newer, '2026-08-22T11:00:00.000Z');
|
||||
await first.keyring.commit(olderEntry);
|
||||
const recoveryTemp = path.join(first.directory, `.online-backup-keyring.json.${process.pid}.${crypto.randomUUID()}.recovery.tmp`);
|
||||
writeKeyring(recoveryTemp, [olderEntry, newerEntry]);
|
||||
const future = new Date(Date.now() + 60_000);
|
||||
fs.utimesSync(recoveryTemp, future, future);
|
||||
const { createOnlineBackupKeyring } = require('../lib/online-backup-keyring');
|
||||
const recovered = createOnlineBackupKeyring({
|
||||
filePath: first.filePath,
|
||||
encryptField: encrypt,
|
||||
decryptField: decrypt,
|
||||
isEncrypted: isCanonicalEnvelope
|
||||
});
|
||||
|
||||
const snapshot = await recovered.list();
|
||||
|
||||
assert.deepEqual(snapshot.entries.map(entry => entry.id), [
|
||||
parseOnlineBackupKey(newer).id,
|
||||
parseOnlineBackupKey(older).id
|
||||
]);
|
||||
assert.deepEqual(snapshot.issues, ['KEYRING_RECOVERED']);
|
||||
});
|
||||
|
||||
it('keeps the prior valid state and cleans temporary files when primary replacement fails', async () => {
|
||||
const first = fixture();
|
||||
const firstKey = validKey();
|
||||
await first.keyring.commit(first.keyring.prepare(firstKey, timestamp));
|
||||
const original = fs.readFileSync(first.filePath, 'utf8');
|
||||
const fsImpl = {
|
||||
...fs.promises,
|
||||
rename: async (source, target) => {
|
||||
if (target === first.filePath) throw new Error('rename failed');
|
||||
return fs.promises.rename(source, target);
|
||||
}
|
||||
};
|
||||
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'),
|
||||
filePath: first.filePath,
|
||||
encryptField: encrypt,
|
||||
decryptField: decrypt,
|
||||
isEncrypted: isCanonicalEnvelope,
|
||||
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);
|
||||
await assert.rejects(keyring.commit(keyring.prepare(validKey(), '2026-08-22T11:00:00.000Z')), /rename failed/u);
|
||||
assert.equal(fs.readFileSync(first.filePath, 'utf8'), original);
|
||||
assert.equal(fs.readdirSync(first.directory).some(name => name.endsWith('.tmp')), false);
|
||||
assert.equal(await first.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');
|
||||
it('types invalid documents and never includes plaintext or ciphertext in errors', async () => {
|
||||
const { filePath, backupPath, keyring } = fixture();
|
||||
const key = validKey();
|
||||
fs.writeFileSync(filePath, `{invalid-${key}`);
|
||||
fs.writeFileSync(backupPath, '{invalid-backup');
|
||||
|
||||
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);
|
||||
await assert.rejects(
|
||||
keyring.list(),
|
||||
error => error.code === 'KEYRING_STRUCTURE_INVALID'
|
||||
&& !error.message.includes(key)
|
||||
&& !error.message.includes('invalid-backup')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user