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.
482 lines
17 KiB
JavaScript
482 lines
17 KiB
JavaScript
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 { createOnlineBackupManager } = require('../lib/online-backup-manager');
|
|
const { createOnlineBackupKeyring } = require('../lib/online-backup-keyring');
|
|
const { createOnlineBackup, parseOnlineBackupKey } = require('../lib/online-backup');
|
|
|
|
const key = `MHU2-${'K'.repeat(70)}`;
|
|
const id = 'managed-backup-id';
|
|
const record = { id, blob: 'encrypted-blob', deleteVerifier: 'delete-verifier' };
|
|
const settings = { globalSettings: { alwaysOnTop: true } };
|
|
const directories = [];
|
|
|
|
afterEach(() => {
|
|
for (const directory of directories.splice(0)) fs.rmSync(directory, { recursive: true, force: true });
|
|
});
|
|
|
|
function createFixture(overrides = {}) {
|
|
const events = [];
|
|
const state = new Map();
|
|
const entries = new Map();
|
|
if (overrides.initialKey !== null) {
|
|
state.set(id, overrides.initialKey || key);
|
|
entries.set(id, {
|
|
id,
|
|
displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`,
|
|
createdAt: '2026-08-22T10:00:00.000Z'
|
|
});
|
|
}
|
|
let createdAt;
|
|
let createArguments;
|
|
const removalPlans = new WeakMap();
|
|
const keyring = {
|
|
list: async () => {
|
|
events.push('list');
|
|
if (overrides.listError) throw overrides.listError;
|
|
return {
|
|
entries: [...entries.values()],
|
|
issues: overrides.listIssues || []
|
|
};
|
|
},
|
|
prepare: (value, timestamp) => {
|
|
events.push('prepare');
|
|
if (overrides.prepareError) throw overrides.prepareError;
|
|
createdAt = timestamp;
|
|
return { id, encryptedKey: 'encrypted-key', createdAt: timestamp };
|
|
},
|
|
commit: async (entry) => {
|
|
events.push('commit');
|
|
if (overrides.commitError) throw overrides.commitError;
|
|
state.set(entry.id, key);
|
|
entries.set(entry.id, {
|
|
id: entry.id,
|
|
displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`,
|
|
createdAt: entry.createdAt
|
|
});
|
|
},
|
|
getKey: async (entryId) => {
|
|
events.push('getKey');
|
|
if (overrides.getKeyError) throw overrides.getKeyError;
|
|
return state.get(entryId) || null;
|
|
},
|
|
prepareRemove: async (entryId) => {
|
|
events.push('prepareRemove');
|
|
if (overrides.prepareRemoveError) throw overrides.prepareRemoveError;
|
|
const value = state.get(entryId);
|
|
if (!value) return null;
|
|
const plan = { id: entryId, key: value };
|
|
removalPlans.set(plan, entryId);
|
|
return plan;
|
|
},
|
|
commitRemove: async (plan) => {
|
|
events.push('commitRemove');
|
|
if (overrides.commitRemoveError) throw overrides.commitRemoveError;
|
|
const entryId = removalPlans.get(plan);
|
|
if (!entryId) throw new Error('invalid plan');
|
|
removalPlans.delete(plan);
|
|
state.delete(entryId);
|
|
entries.delete(entryId);
|
|
return true;
|
|
}
|
|
};
|
|
const manager = createOnlineBackupManager({
|
|
keyring,
|
|
loadSettings: async () => settings,
|
|
appVersion: () => '2.1.31',
|
|
createBackup: (...args) => {
|
|
createArguments = args;
|
|
return { key, record };
|
|
},
|
|
uploadBackup: async (uploadedRecord) => {
|
|
events.push('upload');
|
|
assert.equal(uploadedRecord, record);
|
|
if (overrides.uploadError) throw overrides.uploadError;
|
|
},
|
|
deleteBackup: async (value) => {
|
|
events.push(`delete:${value}`);
|
|
if (overrides.deleteError) throw overrides.deleteError;
|
|
return overrides.deletionOutcome || { deleted: true, notFound: false };
|
|
},
|
|
copyText: (value) => events.push(`copy:${value}`)
|
|
});
|
|
return {
|
|
events,
|
|
keyring,
|
|
manager,
|
|
state,
|
|
get createdAt() {
|
|
return createdAt;
|
|
},
|
|
get createArguments() {
|
|
return createArguments;
|
|
}
|
|
};
|
|
}
|
|
|
|
function codedError(code, secret = key) {
|
|
const error = new Error(`failure ${secret}`);
|
|
error.code = code;
|
|
return error;
|
|
}
|
|
|
|
function encrypted(value) {
|
|
return `enc:v1:${Buffer.from(value).toString('base64')}`;
|
|
}
|
|
|
|
function realKeyring(keys) {
|
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-manager-keyring-'));
|
|
directories.push(directory);
|
|
const filePath = path.join(directory, 'online-backup-keys.json');
|
|
fs.writeFileSync(filePath, JSON.stringify({ version: 1, keys }));
|
|
return createOnlineBackupKeyring({
|
|
filePath,
|
|
encryptField: encrypted,
|
|
decryptField: value => Buffer.from(value.slice('enc:v1:'.length), 'base64').toString('utf8'),
|
|
isEncrypted: value => typeof value === 'string'
|
|
&& value.startsWith('enc:v1:')
|
|
&& Buffer.from(value.slice('enc:v1:'.length), 'base64').toString('base64') === value.slice('enc:v1:'.length)
|
|
});
|
|
}
|
|
|
|
function keyWithRecordId(sourceKey, fill) {
|
|
const idBytes = parseOnlineBackupKey(sourceKey).idBytes;
|
|
const masterKey = Buffer.alloc(32, fill);
|
|
const checksum = crypto.createHash('sha256')
|
|
.update(Buffer.from('MHU2-ONLINE-KEY-V1', 'utf8'))
|
|
.update(idBytes)
|
|
.update(masterKey)
|
|
.digest()
|
|
.subarray(0, 4);
|
|
return `MHU2-${Buffer.concat([idBytes, masterKey, checksum]).toString('base64url')}`;
|
|
}
|
|
|
|
function deleteVerifier(value) {
|
|
const parsed = parseOnlineBackupKey(value);
|
|
const deleteSecret = Buffer.from(crypto.hkdfSync(
|
|
'sha256',
|
|
parsed.masterKey,
|
|
parsed.idBytes,
|
|
Buffer.from('MHU-ONLINE-DELETE-V1', 'utf8'),
|
|
32
|
|
));
|
|
return crypto.createHash('sha256').update(deleteSecret).digest('base64url');
|
|
}
|
|
|
|
describe('transactional online backup manager', () => {
|
|
it('creates in prepare, upload, commit order and returns only the sanitized entry', async () => {
|
|
const fixture = createFixture({ initialKey: null });
|
|
|
|
const result = await fixture.manager.createManaged();
|
|
|
|
assert.deepEqual(fixture.events, ['prepare', 'upload', 'commit']);
|
|
assert.deepEqual(fixture.createArguments, [settings, '2.1.31', fixture.createdAt]);
|
|
assert.match(fixture.createdAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
|
assert.deepEqual(result, {
|
|
ok: true,
|
|
entry: {
|
|
id,
|
|
displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`,
|
|
createdAt: fixture.createdAt
|
|
}
|
|
});
|
|
assert.equal(JSON.stringify(result).includes(key), false);
|
|
});
|
|
|
|
it('keeps a committed create successful without a post-commit keyring read', async () => {
|
|
const fixture = createFixture({ initialKey: null });
|
|
fixture.keyring.list = async () => {
|
|
fixture.events.push('list');
|
|
throw new Error('list unavailable after commit');
|
|
};
|
|
|
|
const result = await fixture.manager.createManaged();
|
|
|
|
assert.deepEqual(fixture.events, ['prepare', 'upload', 'commit']);
|
|
assert.deepEqual(result, {
|
|
ok: true,
|
|
entry: {
|
|
id,
|
|
displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`,
|
|
createdAt: fixture.createdAt
|
|
}
|
|
});
|
|
});
|
|
|
|
it('does not upload when preparing encrypted local state fails', async () => {
|
|
const fixture = createFixture({
|
|
initialKey: null,
|
|
prepareError: new Error(`prepare failed with ${key}`)
|
|
});
|
|
|
|
const result = await fixture.manager.createManaged();
|
|
|
|
assert.deepEqual(fixture.events, ['prepare']);
|
|
assert.deepEqual(result, { ok: false, error: 'Online-Sicherung konnte nicht erstellt werden' });
|
|
assert.equal(JSON.stringify(result).includes(key), false);
|
|
});
|
|
|
|
it('does not commit when the upload fails', async () => {
|
|
const fixture = createFixture({
|
|
initialKey: null,
|
|
uploadError: new Error(`upload failed with ${key}`)
|
|
});
|
|
|
|
const result = await fixture.manager.createManaged();
|
|
|
|
assert.deepEqual(fixture.events, ['prepare', 'upload']);
|
|
assert.deepEqual(result, { ok: false, error: 'Online-Sicherung konnte nicht erstellt werden' });
|
|
assert.equal(JSON.stringify(result).includes(key), false);
|
|
});
|
|
|
|
it('rolls the remote backup back with the in-memory key when commit fails', async () => {
|
|
const fixture = createFixture({
|
|
initialKey: null,
|
|
commitError: new Error(`commit failed with ${key}`)
|
|
});
|
|
|
|
const result = await fixture.manager.createManaged();
|
|
|
|
assert.deepEqual(fixture.events, ['prepare', 'upload', 'commit', `delete:${key}`]);
|
|
assert.deepEqual(result, { ok: false, error: 'Online-Sicherung konnte nicht erstellt werden' });
|
|
assert.equal(JSON.stringify(result).includes(key), false);
|
|
});
|
|
|
|
it('keeps a commit failure sanitized when best-effort rollback also fails', async () => {
|
|
const fixture = createFixture({
|
|
initialKey: null,
|
|
commitError: new Error(`commit failed with ${key}`),
|
|
deleteError: new Error(`rollback failed with ${key}`)
|
|
});
|
|
|
|
const result = await fixture.manager.createManaged();
|
|
|
|
assert.deepEqual(fixture.events, ['prepare', 'upload', 'commit', `delete:${key}`]);
|
|
assert.deepEqual(result, { ok: false, error: 'Online-Sicherung konnte nicht erstellt werden' });
|
|
assert.equal(JSON.stringify(result).includes(key), false);
|
|
});
|
|
|
|
it('decrypts a managed key only inside copy and returns no key', async () => {
|
|
const fixture = createFixture();
|
|
|
|
const result = await fixture.manager.copyManaged(id);
|
|
|
|
assert.deepEqual(fixture.events, ['getKey', `copy:${key}`]);
|
|
assert.deepEqual(result, { ok: true });
|
|
assert.equal(JSON.stringify(result).includes(key), false);
|
|
});
|
|
|
|
it('returns typed sanitized keyring errors instead of a false not-found copy result', async () => {
|
|
const fixture = createFixture({
|
|
getKeyError: codedError('KEYRING_SECURE_STORAGE_UNAVAILABLE')
|
|
});
|
|
|
|
const result = await fixture.manager.copyManaged(id);
|
|
|
|
assert.deepEqual(result, {
|
|
ok: false,
|
|
code: 'KEYRING_SECURE_STORAGE_UNAVAILABLE',
|
|
error: 'Sichere Schlüsselspeicherung ist nicht verfügbar'
|
|
});
|
|
assert.equal(result.notFound, undefined);
|
|
assert.equal(JSON.stringify(result).includes(key), false);
|
|
});
|
|
|
|
it('removes the local entry after a successful remote deletion', async () => {
|
|
const fixture = createFixture();
|
|
|
|
const result = await fixture.manager.deleteManaged(id);
|
|
|
|
assert.deepEqual(fixture.events, ['prepareRemove', `delete:${key}`, 'commitRemove']);
|
|
assert.deepEqual(result, { ok: true, removedId: id, notFound: false });
|
|
assert.equal(fixture.state.has(id), false);
|
|
assert.equal(JSON.stringify(result).includes(key), false);
|
|
});
|
|
|
|
it('removes a stale local entry and reports a missing remote backup', async () => {
|
|
const fixture = createFixture({ deletionOutcome: { deleted: false, notFound: true } });
|
|
|
|
const result = await fixture.manager.deleteManaged(id);
|
|
|
|
assert.deepEqual(fixture.events, ['prepareRemove', `delete:${key}`, 'commitRemove']);
|
|
assert.deepEqual(result, { ok: true, removedId: id, notFound: true });
|
|
assert.equal(fixture.state.has(id), false);
|
|
});
|
|
|
|
it('keeps the local entry when remote deletion fails', async () => {
|
|
const fixture = createFixture({ deleteError: new Error(`network failed with ${key}`) });
|
|
|
|
const result = await fixture.manager.deleteManaged(id);
|
|
|
|
assert.deepEqual(fixture.events, ['prepareRemove', `delete:${key}`]);
|
|
assert.deepEqual(result, { ok: false, error: 'Online-Sicherung konnte nicht gelöscht werden' });
|
|
assert.equal(fixture.state.has(id), true);
|
|
assert.equal(JSON.stringify(result).includes(key), false);
|
|
});
|
|
|
|
it('blocks remote deletion when a valid target has a corrupt neighboring entry', async () => {
|
|
const valid = createOnlineBackup({}, '2.1.31').key;
|
|
const corrupt = createOnlineBackup({}, '2.1.31').key;
|
|
const keyring = realKeyring([
|
|
{ id: parseOnlineBackupKey(valid).id, encryptedKey: encrypted(valid), createdAt: '2026-08-22T10:00:00.000Z' },
|
|
{ id: parseOnlineBackupKey(corrupt).id, encryptedKey: 'enc:v1:YWJjZA', createdAt: '2026-08-22T11:00:00.000Z' }
|
|
]);
|
|
let remoteCalls = 0;
|
|
const manager = createOnlineBackupManager({
|
|
keyring,
|
|
loadSettings: async () => settings,
|
|
appVersion: () => '2.1.31',
|
|
deleteBackup: async () => { remoteCalls++; return { deleted: true, notFound: false }; },
|
|
copyText: () => {}
|
|
});
|
|
|
|
const result = await manager.deleteManaged(parseOnlineBackupKey(valid).id);
|
|
|
|
assert.deepEqual(result, {
|
|
ok: false,
|
|
code: 'KEYRING_STRUCTURE_INVALID',
|
|
error: 'Gespeicherter Online-Schlüsselbund ist beschädigt'
|
|
});
|
|
assert.equal(remoteCalls, 0);
|
|
});
|
|
|
|
it('blocks duplicate IDs with different delete verifiers before remote deletion', async () => {
|
|
const first = createOnlineBackup({}, '2.1.31').key;
|
|
const second = keyWithRecordId(first, 0x33);
|
|
const entryId = parseOnlineBackupKey(first).id;
|
|
assert.notEqual(deleteVerifier(first), deleteVerifier(second));
|
|
const keyring = realKeyring([
|
|
{ id: entryId, encryptedKey: encrypted(first), createdAt: '2026-08-22T10:00:00.000Z' },
|
|
{ id: entryId, encryptedKey: encrypted(second), createdAt: '2026-08-22T11:00:00.000Z' }
|
|
]);
|
|
let remoteCalls = 0;
|
|
const manager = createOnlineBackupManager({
|
|
keyring,
|
|
loadSettings: async () => settings,
|
|
appVersion: () => '2.1.31',
|
|
deleteBackup: async () => { remoteCalls++; return { deleted: true, notFound: false }; },
|
|
copyText: () => {}
|
|
});
|
|
|
|
const result = await manager.deleteManaged(entryId);
|
|
|
|
assert.deepEqual(result, {
|
|
ok: false,
|
|
code: 'KEYRING_DUPLICATE_ID',
|
|
error: 'Gespeicherte Online-Sicherungskennung ist mehrdeutig'
|
|
});
|
|
assert.equal(remoteCalls, 0);
|
|
});
|
|
|
|
it('does not call copy or remote deletion dependencies for unknown IDs', async () => {
|
|
const fixture = createFixture({ initialKey: null });
|
|
|
|
const copied = await fixture.manager.copyManaged('unknown-id');
|
|
const deleted = await fixture.manager.deleteManaged('unknown-id');
|
|
|
|
assert.deepEqual(fixture.events, ['getKey', 'prepareRemove']);
|
|
assert.deepEqual(copied, {
|
|
ok: false,
|
|
notFound: true,
|
|
error: 'Online-Sicherungsschlüssel wurde nicht gefunden'
|
|
});
|
|
assert.deepEqual(deleted, {
|
|
ok: false,
|
|
notFound: true,
|
|
error: 'Online-Sicherungsschlüssel wurde nicht gefunden'
|
|
});
|
|
});
|
|
|
|
it('serializes concurrent create and delete mutations in request order', async () => {
|
|
const fixture = createFixture({ initialKey: null });
|
|
let releaseUpload;
|
|
fixture.manager = createOnlineBackupManager({
|
|
keyring: fixture.keyring,
|
|
loadSettings: async () => settings,
|
|
appVersion: () => '2.1.31',
|
|
createBackup: (_settings, _version, exportedAt) => {
|
|
fixture.createdTimestamp = exportedAt;
|
|
return { key, record };
|
|
},
|
|
uploadBackup: async () => {
|
|
fixture.events.push('upload:start');
|
|
await new Promise((resolve) => {
|
|
releaseUpload = resolve;
|
|
});
|
|
fixture.events.push('upload:end');
|
|
},
|
|
deleteBackup: async (value) => {
|
|
fixture.events.push(`delete:${value}`);
|
|
return { deleted: true, notFound: false };
|
|
},
|
|
copyText: () => {}
|
|
});
|
|
|
|
const creating = fixture.manager.createManaged();
|
|
const deleting = fixture.manager.deleteManaged(id);
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
assert.deepEqual(fixture.events, ['prepare', 'upload:start']);
|
|
releaseUpload();
|
|
const [created, deleted] = await Promise.all([creating, deleting]);
|
|
|
|
assert.equal(created.ok, true);
|
|
assert.deepEqual(deleted, { ok: true, removedId: id, notFound: false });
|
|
assert.deepEqual(fixture.events, [
|
|
'prepare',
|
|
'upload:start',
|
|
'upload:end',
|
|
'commit',
|
|
'prepareRemove',
|
|
`delete:${key}`,
|
|
'commitRemove'
|
|
]);
|
|
});
|
|
|
|
it('lists sanitized entries and contains dependency failures at the public boundary', async () => {
|
|
const fixture = createFixture();
|
|
|
|
assert.deepEqual(await fixture.manager.listManaged(), {
|
|
ok: true,
|
|
entries: [{
|
|
id,
|
|
displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`,
|
|
createdAt: '2026-08-22T10:00:00.000Z'
|
|
}]
|
|
});
|
|
fixture.keyring.list = async () => {
|
|
throw new Error(`list failed with ${key}`);
|
|
};
|
|
const failed = await fixture.manager.listManaged();
|
|
assert.deepEqual(failed, { ok: false, error: 'Online-Sicherungen konnten nicht geladen werden' });
|
|
assert.equal(JSON.stringify(failed).includes(key), false);
|
|
});
|
|
|
|
it('keeps readable list entries with a typed warning and never reports damaged nonempty state as empty success', async () => {
|
|
const readable = createFixture({ listIssues: ['KEYRING_DECRYPT_FAILED'] });
|
|
|
|
assert.deepEqual(await readable.manager.listManaged(), {
|
|
ok: true,
|
|
entries: [{
|
|
id,
|
|
displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`,
|
|
createdAt: '2026-08-22T10:00:00.000Z'
|
|
}],
|
|
warningCode: 'KEYRING_DECRYPT_FAILED',
|
|
warning: 'Gespeicherter Online-Sicherungsschlüssel konnte nicht entschlüsselt werden'
|
|
});
|
|
|
|
const damaged = createFixture({ initialKey: null, listIssues: ['KEYRING_ID_MISMATCH'] });
|
|
assert.deepEqual(await damaged.manager.listManaged(), {
|
|
ok: false,
|
|
entries: [],
|
|
code: 'KEYRING_ID_MISMATCH',
|
|
error: 'Gespeicherte Online-Sicherungskennung stimmt nicht mit dem Schlüssel überein'
|
|
});
|
|
});
|
|
});
|