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:
Sucukdeluxe
2026-08-22 13:28:47 +02:00
parent 39e0dd104e
commit 49fa4518c9
12 changed files with 1626 additions and 505 deletions
+385 -191
View File
@@ -1,7 +1,7 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const { execFileSync } = require('node:child_process');
const { execFileSync, spawnSync } = require('node:child_process');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
@@ -63,28 +63,28 @@ test('Windows compositor paints the full hidden surface with an RDP session envi
fs.writeFileSync(preloadPath, `
const { contextBridge } = require('electron');
const managedOnlineBackupProbeCalls = [];
const managedOnlineBackupIds = {
a: 'AAAAAAAAAAAAAAAAAAAAAA',
b: 'AQEBAQEBAQEBAQEBAQEBAQ',
c: 'AgICAgICAgICAgICAgICAg',
d: 'AwMDAwMDAwMDAwMDAwMDAw'
};
const managedOnlineBackupListResponses = [
{ ok: true, entries: [
{ id: 'AAAAAAAAAAAAAAAAAAAAAA', displayKey: 'MHU2-ABCD…1234', createdAt: '2026-08-20T08:00:00.000Z' },
{ id: 'BBBBBBBBBBBBBBBBBBBBBB', displayKey: 'MHU2-ZYXW…9876', createdAt: '2026-08-22T10:00:00.000Z' }
{ ok: true, warningCode: 'KEYRING_DECRYPT_FAILED', warning: 'Gespeicherter Online-Sicherungsschlüssel konnte nicht entschlüsselt werden', entries: [
{ id: managedOnlineBackupIds.a, displayKey: 'MHU2-ABCD…1234', createdAt: '2026-08-20T08:00:00.000Z' },
{ id: managedOnlineBackupIds.b, displayKey: 'MHU2-ZYXW…9876', createdAt: '2026-08-22T10:00:00.000Z' },
{ id: 'BBBBBBBBBBBBBBBBBBBBBB', displayKey: 'MHU2-FAIL…1111', createdAt: '2026-08-23T10:00:00.000Z' },
{ id: managedOnlineBackupIds.c, displayKey: 'invalid', createdAt: '2026-08-24T10:00:00.000Z' }
] },
{ ok: false, entries: [], code: 'KEYRING_DECRYPT_FAILED', error: 'Gespeicherter Online-Sicherungsschlüssel konnte nicht entschlüsselt werden' },
{ ok: false, entries: [], code: 'KEYRING_DECRYPT_FAILED', error: 'Gespeicherter Online-Sicherungsschlüssel konnte nicht entschlüsselt werden' },
{ ok: true, entries: [
{ id: 'DDDDDDDDDDDDDDDDDDDDDD', displayKey: 'MHU2-DFGH…2468', createdAt: '2026-08-25T10:00:00.000Z' },
{ id: 'AAAAAAAAAAAAAAAAAAAAAA', displayKey: 'MHU2-ABCD…1234', createdAt: '2026-08-20T08:00:00.000Z' }
{ id: managedOnlineBackupIds.c, displayKey: 'MHU2-QWER…4321', createdAt: '2026-08-23T12:00:00.000Z' },
{ id: managedOnlineBackupIds.a, displayKey: 'MHU2-ABCD…1234', createdAt: '2026-08-20T08:00:00.000Z' }
] },
{ ok: true, entries: [
{ id: 'CCCCCCCCCCCCCCCCCCCCCC', displayKey: 'MHU2-QWER…4321', createdAt: '2026-08-23T12:00:00.000Z' },
{ id: 'EEEEEEEEEEEEEEEEEEEEEE', displayKey: 'MHU2-ETYU…1357', createdAt: '2026-08-26T10:00:00.000Z' },
{ id: 'DDDDDDDDDDDDDDDDDDDDDD', displayKey: 'MHU2-DFGH…2468', createdAt: '2026-08-25T10:00:00.000Z' }
] },
{ ok: false, error: 'Online-Sicherungen konnten nicht geladen werden' }
null,
{ ok: false, entries: [], code: 'KEYRING_ID_MISMATCH', error: 'Gespeicherte Online-Sicherungskennung stimmt nicht mit dem Schlüssel überein' }
];
const managedOnlineBackupAdditionalListResponses = new Map([
[6, { ok: true, entries: [{ id: 'KKKKKKKKKKKKKKKKKKKKKK', displayKey: 'MHU2-KLMN…1111', createdAt: '2026-08-30T08:00:00.000Z' }] }],
[8, { ok: true, entries: [{ id: 'NNNNNNNNNNNNNNNNNNNNNN', displayKey: 'MHU2-NMNB…2222', createdAt: '2026-08-30T10:00:00.000Z' }] }],
[9, { ok: true, entries: [{ id: 'PPPPPPPPPPPPPPPPPPPPPP', displayKey: 'MHU2-POIU…3333', createdAt: '2026-08-30T11:00:00.000Z' }] }],
[10, { ok: true, entries: [{ id: 'JJJJJJJJJJJJJJJJJJJJJJ', displayKey: 'MHU2-JKLO…4444', createdAt: '2026-08-30T12:00:00.000Z' }] }]
]);
let managedOnlineBackupListIndex = 0;
let managedOnlineBackupCreateIndex = 0;
const pendingManagedOnlineBackupLists = new Map();
@@ -98,26 +98,26 @@ contextBridge.exposeInMainWorld('api', {
listManagedOnlineBackups() {
managedOnlineBackupListIndex++;
managedOnlineBackupProbeCalls.push(['list', managedOnlineBackupListIndex]);
if (managedOnlineBackupListIndex === 5 || managedOnlineBackupListIndex === 7) {
if (managedOnlineBackupListIndex === 5) {
return new Promise(resolve => { pendingManagedOnlineBackupLists.set(managedOnlineBackupListIndex, resolve); });
}
return Promise.resolve(managedOnlineBackupListResponses[managedOnlineBackupListIndex - 1] || managedOnlineBackupAdditionalListResponses.get(managedOnlineBackupListIndex));
return Promise.resolve(managedOnlineBackupListResponses[managedOnlineBackupListIndex - 1]);
},
releaseManagedOnlineBackupList(index) {
const resolve = pendingManagedOnlineBackupLists.get(index);
pendingManagedOnlineBackupLists.delete(index);
const entry = index === 5
? { id: 'XXXXXXXXXXXXXXXXXXXXXX', displayKey: 'MHU2-XCVB…9753', createdAt: '2026-08-28T12:00:00.000Z' }
: { id: 'HHHHHHHHHHHHHHHHHHHHHH', displayKey: 'MHU2-HJKL…8642', createdAt: '2026-08-30T09:00:00.000Z' };
resolve({ ok: true, entries: [entry] });
resolve({ ok: true, entries: [
{ id: managedOnlineBackupIds.d, displayKey: 'MHU2-DFGH…2468', createdAt: '2026-08-25T10:00:00.000Z' },
{ id: managedOnlineBackupIds.c, displayKey: 'MHU2-QWER…4321', createdAt: '2026-08-23T12:00:00.000Z' },
{ id: managedOnlineBackupIds.a, displayKey: 'MHU2-ABCD…1234', createdAt: '2026-08-20T08:00:00.000Z' }
] });
},
createManagedOnlineBackup() {
managedOnlineBackupCreateIndex++;
managedOnlineBackupProbeCalls.push(['create', managedOnlineBackupCreateIndex]);
if (managedOnlineBackupCreateIndex === 3 || managedOnlineBackupCreateIndex === 5) return Promise.resolve({ ok: false, error: 'Online-Sicherung konnte nicht erstellt werden' });
const entry = managedOnlineBackupCreateIndex === 1
? { id: 'CCCCCCCCCCCCCCCCCCCCCC', displayKey: 'MHU2-QWER…4321', createdAt: '2026-08-23T12:00:00.000Z' }
: { id: 'FFFFFFFFFFFFFFFFFFFFFF', displayKey: 'MHU2-FGHJ…8642', createdAt: '2026-08-27T12:00:00.000Z' };
? { id: managedOnlineBackupIds.c, displayKey: 'MHU2-QWER…4321', createdAt: '2026-08-23T12:00:00.000Z' }
: { id: managedOnlineBackupIds.d, displayKey: 'MHU2-DFGH…2468', createdAt: '2026-08-25T10:00:00.000Z' };
return Promise.resolve({ ok: true, entry });
},
copyManagedOnlineBackup(id) {
@@ -142,16 +142,24 @@ contextBridge.exposeInMainWorld('api', {
});
`, 'utf8');
const onlineBackupBehaviorScript = `(async () => {
const ids = {
a: 'AAAAAAAAAAAAAAAAAAAAAA',
b: 'AQEBAQEBAQEBAQEBAQEBAQ',
c: 'AgICAgICAgICAgICAgICAg',
d: 'AwMDAwMDAwMDAwMDAwMDAw'
};
const fixture = document.createElement('section');
fixture.innerHTML = '<div id="managedOnlineBackupList"></div><div id="onlineBackupStatus"></div><button id="createOnlineBackupBtn"></button><input id="onlineBackupKeyInput"><button id="restoreOnlineBackupBtn"></button>';
fixture.innerHTML = '<div id="managedOnlineBackupList"></div><div id="managedOnlineBackupRefreshStatus" hidden><span id="managedOnlineBackupRefreshMessage"></span><button id="reloadManagedOnlineBackupsBtn" type="button">Erneut laden</button></div><div id="onlineBackupStatus"></div><button id="createOnlineBackupBtn"></button><input id="onlineBackupKeyInput"><button id="restoreOnlineBackupBtn"></button>';
document.body.append(fixture);
document.documentElement.lang = 'en';
const waitFor = async predicate => {
for (let attempt = 0; attempt < 80; attempt++) {
for (let attempt = 0; attempt < 120; attempt++) {
if (await predicate()) return true;
await new Promise(resolve => setTimeout(resolve, 0));
}
return false;
};
const calls = async type => (await window.api.getManagedOnlineBackupProbeCalls()).filter(call => call[0] === type);
let confirmation = null;
let resolveConfirmation = null;
showAppConfirm = options => {
@@ -159,128 +167,97 @@ contextBridge.exposeInMainWorld('api', {
return new Promise(resolve => { resolveConfirmation = resolve; });
};
flushPendingSettingsSaves = async () => {};
openOnlineBackupView = () => {};
await loadManagedOnlineBackups();
await new Promise(resolve => setTimeout(resolve, 0));
const initialKeys = [...document.querySelectorAll('.online-backup-managed-key')].map(element => element.textContent);
const maliciousEntry = { id: 'DDDDDDDDDDDDDDDDDDDDDD', displayKey: 'prefix MHU2-' + 'X'.repeat(70) + ' suffix', createdAt: '2026-08-24T12:00:00.000Z' };
replaceManagedOnlineBackups([...managedOnlineBackups, maliciousEntry]);
const secretRejected = !document.body.textContent.includes(maliciousEntry.displayKey) && managedOnlineBackups.every(entry => entry.displayKey !== maliciousEntry.displayKey);
const originalRow = document.querySelector('.online-backup-managed-row');
originalRow.querySelector('.online-backup-delete-btn').click();
const initialWarning = {
hidden: document.getElementById('managedOnlineBackupRefreshStatus')?.hidden,
text: document.getElementById('managedOnlineBackupRefreshMessage')?.textContent
};
const exactSanitizedState = managedOnlineBackups.every(entry => /^[A-Za-z0-9_-]{22}$/.test(entry.id) && /^MHU2-[A-Za-z0-9_-]{4}…[A-Za-z0-9_-]{4}$/.test(entry.displayKey));
const ariaDescriptions = [...document.querySelectorAll('.online-backup-managed-row')].every(row => {
const keyId = row.querySelector('.online-backup-managed-key')?.id;
return Boolean(keyId) && [...row.querySelectorAll('button')].every(button => button.getAttribute('aria-describedby') === keyId);
});
const deleteButton = document.querySelector('.online-backup-delete-btn');
deleteButton.focus();
deleteButton.click();
await waitFor(() => typeof resolveConfirmation === 'function');
renderManagedOnlineBackups();
const rowReplacedDuringConfirmation = !originalRow.isConnected;
document.querySelector('.online-backup-copy-btn').click();
await waitFor(async () => (await window.api.getManagedOnlineBackupProbeCalls()).some(call => call[0] === 'copy'));
const copyPendingControlsDisabled = [...document.querySelector('.online-backup-managed-row').querySelectorAll('button')].every(button => button.disabled);
resolveConfirmation(true);
await waitFor(async () => (await calls('delete')).length === 1);
window.api.releaseManagedOnlineBackupDelete();
await waitFor(async () => (await calls('list')).length === 2);
await waitFor(() => document.getElementById('onlineBackupStatus').textContent === 'Key deleted');
const afterDelete = {
keys: [...document.querySelectorAll('.online-backup-managed-key')].map(element => element.textContent),
status: document.getElementById('onlineBackupStatus').textContent,
statusState: document.getElementById('onlineBackupStatus').dataset.state,
warning: document.getElementById('managedOnlineBackupRefreshMessage')?.textContent,
retryVisible: document.getElementById('reloadManagedOnlineBackupsBtn')?.offsetParent !== null,
focusAction: document.activeElement?.dataset.managedOnlineBackupAction,
focusId: document.activeElement?.dataset.managedOnlineBackupId
};
await doOnlineBackupCreate();
await new Promise(resolve => setTimeout(resolve, 0));
const deleteStartedBeforeCopyFinished = (await window.api.getManagedOnlineBackupProbeCalls()).some(call => call[0] === 'delete');
window.api.releaseManagedOnlineBackupCopy();
const deleteStartedAfterCopy = await waitFor(async () => (await window.api.getManagedOnlineBackupProbeCalls()).filter(call => call[0] === 'delete').length === 1);
const deletePendingControlsDisabled = deleteStartedAfterCopy && [...document.querySelector('.online-backup-managed-row').querySelectorAll('button')].every(button => button.disabled);
if (deleteStartedAfterCopy) window.api.releaseManagedOnlineBackupDelete();
await waitFor(async () => (await window.api.getManagedOnlineBackupProbeCalls()).filter(call => call[0] === 'list').length === 2);
const afterDelete = [...document.querySelectorAll('.online-backup-managed-key')].map(element => element.textContent);
const deletePostRefreshCount = (await window.api.getManagedOnlineBackupProbeCalls()).filter(call => call[0] === 'list').length - 1;
const listCallsBeforeCreate = (await window.api.getManagedOnlineBackupProbeCalls()).filter(call => call[0] === 'list').length;
await doOnlineBackupCreate();
const afterCreate = [...document.querySelectorAll('.online-backup-managed-key')].map(element => element.textContent);
const authoritativeStateFields = [...new Set(managedOnlineBackups.flatMap(entry => Object.keys(entry)))].sort();
const callsAfterCreate = await window.api.getManagedOnlineBackupProbeCalls();
const createPostRefreshCount = callsAfterCreate.filter(call => call[0] === 'list').length - listCallsBeforeCreate;
const listCallsBeforeFailedCreate = callsAfterCreate.filter(call => call[0] === 'list').length;
await doOnlineBackupCreate();
const failedCreatePostRefreshCount = (await window.api.getManagedOnlineBackupProbeCalls()).filter(call => call[0] === 'list').length - listCallsBeforeFailedCreate;
const refreshFailure = {
rowCount: document.querySelectorAll('.online-backup-managed-row').length,
stateCount: managedOnlineBackups.length,
emptyStateVisible: Boolean(document.querySelector('.online-backup-managed-empty')),
keys: [...document.querySelectorAll('.online-backup-managed-key')].map(element => element.textContent),
status: document.getElementById('onlineBackupStatus').textContent,
statusState: document.getElementById('onlineBackupStatus').dataset.state
statusState: document.getElementById('onlineBackupStatus').dataset.state,
warning: document.getElementById('managedOnlineBackupRefreshMessage')?.textContent,
retryVisible: document.getElementById('reloadManagedOnlineBackupsBtn')?.offsetParent !== null
};
const navigation = document.createElement('button');
navigation.dataset.settingsPage = 'backup';
navigation.addEventListener('click', () => { loadManagedOnlineBackups(); });
fixture.append(navigation);
replaceManagedOnlineBackups([
{ id: 'GGGGGGGGGGGGGGGGGGGGGG', displayKey: 'MHU2-GHJK…7531', createdAt: '2026-08-29T12:00:00.000Z' }
]);
setOnlineBackupStatus('', '');
const failedCreate = doOnlineBackupCreate();
await waitFor(async () => (await window.api.getManagedOnlineBackupProbeCalls()).filter(call => call[0] === 'list').length === 5);
await failedCreate;
const statusBeforeStaleNavigationResponse = {
text: document.getElementById('onlineBackupStatus').textContent,
state: document.getElementById('onlineBackupStatus').dataset.state
const beforeRetryCalls = (await calls('list')).length;
document.getElementById('reloadManagedOnlineBackupsBtn')?.click();
const retryTriggeredLoad = await waitFor(async () => (await calls('list')).length === beforeRetryCalls + 1);
if (!retryTriggeredLoad) await loadManagedOnlineBackups();
await new Promise(resolve => setTimeout(resolve, 0));
const afterRetry = {
keys: [...document.querySelectorAll('.online-backup-managed-key')].map(element => element.textContent),
warningHidden: document.getElementById('managedOnlineBackupRefreshStatus')?.hidden
};
const racingCreate = doOnlineBackupCreate();
await waitFor(async () => (await calls('list')).length === 5);
const copyButton = [...document.querySelectorAll('.online-backup-copy-btn')].find(button => button.dataset.managedOnlineBackupId === ids.a) || document.querySelector('.online-backup-copy-btn');
copyButton.focus();
copyButton.click();
await waitFor(async () => (await calls('copy')).length === 1);
window.api.releaseManagedOnlineBackupCopy();
const copyFocusRestored = await waitFor(() => document.activeElement?.dataset.managedOnlineBackupAction === 'copy' && document.activeElement?.dataset.managedOnlineBackupId === ids.a);
window.api.releaseManagedOnlineBackupList(5);
await racingCreate;
await new Promise(resolve => setTimeout(resolve, 0));
const staleNavigationResponse = {
const raceResult = {
keys: [...document.querySelectorAll('.online-backup-managed-key')].map(element => element.textContent),
status: document.getElementById('onlineBackupStatus').textContent,
statusState: document.getElementById('onlineBackupStatus').dataset.state
statusState: document.getElementById('onlineBackupStatus').dataset.state,
warningHidden: document.getElementById('managedOnlineBackupRefreshStatus')?.hidden
};
const concurrentCreate = doOnlineBackupCreate();
await waitFor(async () => (await window.api.getManagedOnlineBackupProbeCalls()).filter(call => call[0] === 'list').length === 7);
const statusBeforeConcurrentNavigation = {
text: document.getElementById('onlineBackupStatus').textContent,
state: document.getElementById('onlineBackupStatus').dataset.state
};
navigation.click();
await waitFor(async () => (await window.api.getManagedOnlineBackupProbeCalls()).filter(call => call[0] === 'list').length === 8);
managedOnlineBackups = [];
managedOnlineBackupsAuthoritative = false;
renderManagedOnlineBackups(null);
await loadManagedOnlineBackups();
await new Promise(resolve => setTimeout(resolve, 0));
const concurrentNavigationState = {
keys: [...document.querySelectorAll('.online-backup-managed-key')].map(element => element.textContent),
status: document.getElementById('onlineBackupStatus').textContent,
statusState: document.getElementById('onlineBackupStatus').dataset.state
const hardCorruption = {
emptyStateVisible: Boolean(document.querySelector('.online-backup-managed-empty')),
rowCount: document.querySelectorAll('.online-backup-managed-row').length,
warning: document.getElementById('managedOnlineBackupRefreshMessage')?.textContent,
retryVisible: document.getElementById('reloadManagedOnlineBackupsBtn')?.offsetParent !== null
};
window.api.releaseManagedOnlineBackupList(7);
await concurrentCreate;
const authoritativeCreateResult = {
keys: [...document.querySelectorAll('.online-backup-managed-key')].map(element => element.textContent),
status: document.getElementById('onlineBackupStatus').textContent,
statusState: document.getElementById('onlineBackupStatus').dataset.state
};
const failedCreateBeforeNavigation = doOnlineBackupCreate();
await failedCreateBeforeNavigation;
const completedOperationError = {
text: document.getElementById('onlineBackupStatus').textContent,
state: document.getElementById('onlineBackupStatus').dataset.state
};
navigation.click();
await waitFor(async () => (await window.api.getManagedOnlineBackupProbeCalls()).filter(call => call[0] === 'list').length === 10);
await new Promise(resolve => setTimeout(resolve, 0));
const navigationAfterOperationError = {
keys: [...document.querySelectorAll('.online-backup-managed-key')].map(element => element.textContent),
status: document.getElementById('onlineBackupStatus').textContent,
statusState: document.getElementById('onlineBackupStatus').dataset.state
};
const calls = await window.api.getManagedOnlineBackupProbeCalls();
return {
initialKeys,
secretRejected,
rowReplacedDuringConfirmation,
copyPendingControlsDisabled,
deleteStartedBeforeCopyFinished,
deleteStartedAfterCopy,
deletePendingControlsDisabled,
deleteCallCount: calls.filter(call => call[0] === 'delete').length,
initialWarning,
exactSanitizedState,
ariaDescriptions,
afterDelete,
deletePostRefreshCount,
afterCreate,
authoritativeStateFields,
createPostRefreshCount,
failedCreatePostRefreshCount,
refreshFailure,
statusBeforeStaleNavigationResponse,
staleNavigationResponse,
statusBeforeConcurrentNavigation,
concurrentNavigationState,
authoritativeCreateResult,
completedOperationError,
navigationAfterOperationError,
retryTriggeredLoad,
afterRetry,
copyFocusRestored,
raceResult,
hardCorruption,
confirmation,
calls,
calls: await window.api.getManagedOnlineBackupProbeCalls(),
secretInBody: /MHU2-[A-Za-z0-9_-]{70}/.test(document.body.textContent)
};
})()`;
@@ -419,59 +396,46 @@ app.whenReady().then(async () => {
assert.ok(result.liveSpeedChart.canvasWidth > 0);
assert.equal(result.liveSpeedChart.baselinePresent, false);
assert.deepEqual(result.onlineBackupBehavior.initialKeys, ['MHU2-ZYXW…9876', 'MHU2-ABCD…1234']);
assert.equal(result.onlineBackupBehavior.secretRejected, true);
assert.equal(result.onlineBackupBehavior.rowReplacedDuringConfirmation, true);
assert.equal(result.onlineBackupBehavior.copyPendingControlsDisabled, true);
assert.equal(result.onlineBackupBehavior.deleteStartedBeforeCopyFinished, false);
assert.equal(result.onlineBackupBehavior.deleteStartedAfterCopy, true);
assert.equal(result.onlineBackupBehavior.deletePendingControlsDisabled, true);
assert.equal(result.onlineBackupBehavior.deleteCallCount, 1);
assert.deepEqual(result.onlineBackupBehavior.afterDelete, ['MHU2-DFGH…2468', 'MHU2-ABCD…1234']);
assert.equal(result.onlineBackupBehavior.deletePostRefreshCount, 1);
assert.deepEqual(result.onlineBackupBehavior.afterCreate, ['MHU2-ETYU…1357', 'MHU2-DFGH…2468', 'MHU2-QWER…4321']);
assert.deepEqual(result.onlineBackupBehavior.authoritativeStateFields, ['createdAt', 'displayKey', 'id']);
assert.equal(result.onlineBackupBehavior.createPostRefreshCount, 1);
assert.equal(result.onlineBackupBehavior.failedCreatePostRefreshCount, 1);
assert.deepEqual(result.onlineBackupBehavior.initialWarning, {
hidden: false,
text: 'Stored online backup key could not be decrypted'
});
assert.equal(result.onlineBackupBehavior.exactSanitizedState, true);
assert.equal(result.onlineBackupBehavior.ariaDescriptions, true);
assert.deepEqual(result.onlineBackupBehavior.afterDelete, {
keys: ['MHU2-ABCD…1234'],
status: 'Key deleted',
statusState: 'success',
warning: 'Stored online backup key could not be decrypted',
retryVisible: true,
focusAction: 'delete',
focusId: 'AAAAAAAAAAAAAAAAAAAAAA'
});
assert.deepEqual(result.onlineBackupBehavior.refreshFailure, {
rowCount: 0,
stateCount: 0,
keys: ['MHU2-QWER…4321', 'MHU2-ABCD…1234'],
status: 'New key created.',
statusState: 'success',
warning: 'Stored online backup key could not be decrypted',
retryVisible: true
});
assert.equal(result.onlineBackupBehavior.retryTriggeredLoad, true);
assert.deepEqual(result.onlineBackupBehavior.afterRetry, {
keys: ['MHU2-QWER…4321', 'MHU2-ABCD…1234'],
warningHidden: true
});
assert.equal(result.onlineBackupBehavior.copyFocusRestored, true);
assert.deepEqual(result.onlineBackupBehavior.raceResult, {
keys: ['MHU2-DFGH…2468', 'MHU2-QWER…4321', 'MHU2-ABCD…1234'],
status: 'New key created.',
statusState: 'success',
warningHidden: true
});
assert.deepEqual(result.onlineBackupBehavior.hardCorruption, {
emptyStateVisible: false,
status: 'Online backups could not be loaded',
statusState: 'error'
rowCount: 0,
warning: 'Stored online backup ID does not match its key',
retryVisible: true
});
assert.deepEqual(result.onlineBackupBehavior.statusBeforeStaleNavigationResponse, {
text: 'Online backup could not be created',
state: 'error'
});
assert.deepEqual(result.onlineBackupBehavior.staleNavigationResponse, {
keys: ['MHU2-GHJK…7531'],
status: 'Online backup could not be created',
statusState: 'error'
});
assert.deepEqual(result.onlineBackupBehavior.statusBeforeConcurrentNavigation, {
text: 'Encrypting and saving settings…',
state: 'busy'
});
assert.deepEqual(result.onlineBackupBehavior.concurrentNavigationState, {
keys: ['MHU2-GHJK…7531'],
status: 'Encrypting and saving settings…',
statusState: 'busy'
});
assert.deepEqual(result.onlineBackupBehavior.authoritativeCreateResult, {
keys: ['MHU2-HJKL…8642'],
status: 'New key created. Older keys remain valid.',
statusState: 'success'
});
assert.deepEqual(result.onlineBackupBehavior.completedOperationError, {
text: 'Online backup could not be created',
state: 'error'
});
assert.deepEqual(result.onlineBackupBehavior.navigationAfterOperationError, {
keys: ['MHU2-JKLO…4444'],
status: '',
statusState: ''
});
assert.equal(result.onlineBackupBehavior.afterCreate.length, 3);
assert.deepEqual(result.onlineBackupBehavior.confirmation, {
title: 'Online-Backup löschen',
message: 'Dieses verschlüsselte Online-Backup wird dauerhaft vom Server gelöscht.',
@@ -480,22 +444,15 @@ app.whenReady().then(async () => {
});
assert.deepEqual(result.onlineBackupBehavior.calls, [
['list', 1],
['copy', 'BBBBBBBBBBBBBBBBBBBBBB'],
['delete', 'BBBBBBBBBBBBBBBBBBBBBB'],
['delete', 'AQEBAQEBAQEBAQEBAQEBAQ'],
['list', 2],
['create', 1],
['list', 3],
['create', 2],
['list', 4],
['create', 2],
['list', 5],
['create', 3],
['list', 6],
['create', 4],
['list', 7],
['list', 8],
['list', 9],
['create', 5],
['list', 10]
['copy', 'AAAAAAAAAAAAAAAAAAAAAA'],
['list', 6]
]);
assert.equal(result.onlineBackupBehavior.secretInBody, false);
assert.ok(Math.abs(result.onlineBackupLayout.german.createRight - result.onlineBackupLayout.german.contentRight) <= 1);
@@ -519,6 +476,243 @@ app.whenReady().then(async () => {
}
});
test('Windows DPAPI key management composes through hidden real IPC and local transport', { skip: process.platform !== 'win32' }, () => {
const projectRoot = path.join(__dirname, '..');
const probeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-dpapi-ipc-'));
const probePath = path.join(probeRoot, 'probe.cjs');
const preloadPath = path.join(probeRoot, 'preload.cjs');
const rendererPath = path.join(probeRoot, 'renderer.html');
const outputPath = path.join(probeRoot, 'result.json');
const userDataPath = path.join(probeRoot, 'user-data');
const serverDataPath = path.join(probeRoot, 'server-data');
fs.writeFileSync(preloadPath, `
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('managedBackup', {
list: () => ipcRenderer.invoke('online-backup:list-managed'),
create: () => ipcRenderer.invoke('online-backup:create-managed'),
copy: id => ipcRenderer.invoke('online-backup:copy-managed', id),
delete: id => ipcRenderer.invoke('online-backup:delete-managed', id)
});
`, 'utf8');
fs.writeFileSync(rendererPath, `<!doctype html><html><body><script>
(async () => {
const created = await window.managedBackup.create();
const listed = await window.managedBackup.list();
const id = listed.entries[0].id;
const copied = await window.managedBackup.copy(id);
const deleted = await window.managedBackup.delete(id);
const after = await window.managedBackup.list();
window.__managedBackupResult = { created, listed, copied, deleted, after, id };
})().catch(error => { window.__managedBackupResult = { error: error.message || String(error) }; });
</script></body></html>`, 'utf8');
const probeSource = `
const { app, BrowserWindow, clipboard, ipcMain, safeStorage } = require('electron');
const fs = require('node:fs');
const path = require('node:path');
const { pathToFileURL } = require('node:url');
const { createOnlineBackupKeyring } = require(${JSON.stringify(path.join(projectRoot, 'lib', 'online-backup-keyring.js'))});
const { createOnlineBackupManager } = require(${JSON.stringify(path.join(projectRoot, 'lib', 'online-backup-manager.js'))});
const { deleteOnlineBackup, parseOnlineBackupKey, uploadOnlineBackup } = require(${JSON.stringify(path.join(projectRoot, 'lib', 'online-backup.js'))});
const secretStore = require(${JSON.stringify(path.join(projectRoot, 'lib', 'secret-store.js'))});
const outputPath = process.env.MHU_DPAPI_OUTPUT;
const userDataPath = process.env.MHU_DPAPI_USER_DATA;
const serverDataPath = process.env.MHU_DPAPI_SERVER_DATA;
const rendererPath = process.env.MHU_DPAPI_RENDERER;
const preloadPath = process.env.MHU_DPAPI_PRELOAD;
const serverModulePath = process.env.MHU_DPAPI_SERVER_MODULE;
app.setPath('userData', userDataPath);
let server = null;
let window = null;
let previousClipboard = '';
const logs = [];
const originalConsole = { log: console.log, warn: console.warn, error: console.error };
for (const method of Object.keys(originalConsole)) console[method] = (...values) => { logs.push(values.map(String).join(' ')); };
function canonicalId(value) {
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{22}$/.test(value)) return false;
const decoded = Buffer.from(value, 'base64url');
return decoded.length === 16 && decoded.toString('base64url') === value;
}
function trusted(event) {
return Boolean(window && !window.isDestroyed() && event.sender === window.webContents && event.senderFrame === window.webContents.mainFrame);
}
function requireId(value) {
if (!canonicalId(value)) throw new Error('invalid id');
return value;
}
async function closeServer() {
if (!server) return;
await new Promise(resolve => server.close(resolve));
server = null;
}
app.whenReady().then(async () => {
previousClipboard = clipboard.readText();
if (!safeStorage.isEncryptionAvailable()) throw new Error('safeStorage unavailable');
const { createBackupServer } = await import(pathToFileURL(serverModulePath).href);
fs.mkdirSync(serverDataPath, { recursive: true });
server = createBackupServer({
rootDir: serverDataPath,
allowedOrigins: [],
rateLimit: { max: 100, windowMs: 60000 },
uploadRateLimit: { max: 100, windowMs: 60000 },
requestRateLimit: { max: 100, windowMs: 60000 }
});
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
const baseUrl = 'http://127.0.0.1:' + server.address().port;
const keyringPath = path.join(userDataPath, 'online-backup-keys.json');
const keyring = createOnlineBackupKeyring({ filePath: keyringPath });
const transport = [];
let fullKey = '';
let diskBeforeDelete = '';
let clipboardMatched = false;
let serverRecordBeforeDelete = false;
const manager = createOnlineBackupManager({
keyring,
loadSettings: async () => ({ globalSettings: { language: 'de' }, hosters: {}, hosterSettings: {} }),
appVersion: () => '2.1.31',
uploadBackup: async record => {
transport.push({ operation: 'upload', id: record.id });
return uploadOnlineBackup(record, baseUrl);
},
deleteBackup: async value => {
const parsed = parseOnlineBackupKey(value);
transport.push({ operation: 'delete', id: parsed.id });
return deleteOnlineBackup(value, baseUrl);
},
copyText: value => clipboard.writeText(value)
});
ipcMain.handle('online-backup:list-managed', event => trusted(event) ? manager.listManaged() : { ok: false, error: 'rejected' });
ipcMain.handle('online-backup:create-managed', async event => {
if (!trusted(event)) return { ok: false, error: 'rejected' };
const result = await manager.createManaged();
if (result.ok) {
fullKey = await keyring.getKey(result.entry.id);
diskBeforeDelete = fs.readFileSync(keyringPath, 'utf8');
serverRecordBeforeDelete = fs.existsSync(path.join(serverDataPath, result.entry.id + '.json'));
}
return result;
});
ipcMain.handle('online-backup:copy-managed', async (event, id) => {
if (!trusted(event)) return { ok: false, error: 'rejected' };
const result = await manager.copyManaged(requireId(id));
clipboardMatched = clipboard.readText() === fullKey;
return result;
});
ipcMain.handle('online-backup:delete-managed', (event, id) => trusted(event) ? manager.deleteManaged(requireId(id)) : { ok: false, error: 'rejected' });
window = new BrowserWindow({
show: false,
width: 640,
height: 480,
webPreferences: { contextIsolation: true, nodeIntegration: false, preload: preloadPath }
});
await window.loadFile(rendererPath);
let rendererResult = null;
for (let attempt = 0; attempt < 200; attempt++) {
rendererResult = await window.webContents.executeJavaScript('window.__managedBackupResult || null');
if (rendererResult) break;
await new Promise(resolve => setTimeout(resolve, 10));
}
if (!rendererResult || rendererResult.error) throw new Error(rendererResult?.error || 'renderer timed out');
const stored = JSON.parse(diskBeforeDelete);
const encryptedKey = stored.keys[0].encryptedKey;
const ipcJson = JSON.stringify(rendererResult);
const afterDocument = JSON.parse(fs.readFileSync(keyringPath, 'utf8'));
const remainingServerRecords = fs.readdirSync(serverDataPath).filter(name => name.endsWith('.json'));
const result = {
platform: process.platform,
safeStorageAvailable: safeStorage.isEncryptionAvailable(),
hidden: window.isVisible() === false,
canonicalId: canonicalId(rendererResult.id),
createOk: rendererResult.created.ok === true,
copyOk: rendererResult.copied.ok === true,
deleteOk: rendererResult.deleted.ok === true,
afterEmpty: rendererResult.after.ok === true && rendererResult.after.entries.length === 0,
diskUsesKeysSchema: Array.isArray(stored.keys) && !Object.hasOwn(stored, 'entries'),
diskHadCiphertext: secretStore.isEncrypted(encryptedKey) && encryptedKey !== fullKey && !diskBeforeDelete.includes(fullKey),
dpapiRoundTrip: safeStorage.decryptString(Buffer.from(encryptedKey.slice('enc:v1:'.length), 'base64')) === fullKey,
rendererSecretAbsent: !ipcJson.includes(fullKey) && !/MHU2-[A-Za-z0-9_-]{70}/.test(ipcJson),
logSecretAbsent: logs.every(line => !line.includes(fullKey)) && !logs.join(' ').match(/MHU2-[A-Za-z0-9_-]{70}/),
clipboardMatched,
serverRecordBeforeDelete,
serverEmptyAfterDelete: remainingServerRecords.length === 0,
keyringEmptyAfterDelete: afterDocument.keys.length === 0,
transport
};
fs.writeFileSync(outputPath, JSON.stringify(result), 'utf8');
}).catch(error => {
fs.writeFileSync(outputPath, JSON.stringify({ error: error.stack || String(error) }), 'utf8');
process.exitCode = 1;
}).finally(async () => {
clipboard.writeText(previousClipboard);
if (fs.existsSync(outputPath)) {
const result = JSON.parse(fs.readFileSync(outputPath, 'utf8'));
result.clipboardRestored = clipboard.readText() === previousClipboard;
fs.writeFileSync(outputPath, JSON.stringify(result), 'utf8');
}
for (const channel of ['online-backup:list-managed', 'online-backup:create-managed', 'online-backup:copy-managed', 'online-backup:delete-managed']) ipcMain.removeHandler(channel);
if (window && !window.isDestroyed()) window.destroy();
await closeServer();
for (const [method, value] of Object.entries(originalConsole)) console[method] = value;
app.exit(process.exitCode || 0);
});
`;
fs.writeFileSync(probePath, probeSource, 'utf8');
try {
const electronPath = path.join(projectRoot, 'node_modules', 'electron', 'dist', 'electron.exe');
const probeEnvironment = {
...process.env,
MHU_DPAPI_OUTPUT: outputPath,
MHU_DPAPI_USER_DATA: userDataPath,
MHU_DPAPI_SERVER_DATA: serverDataPath,
MHU_DPAPI_RENDERER: rendererPath,
MHU_DPAPI_PRELOAD: preloadPath,
MHU_DPAPI_SERVER_MODULE: path.join(projectRoot, 'services', 'backup-api', 'src', 'server.mjs')
};
delete probeEnvironment.RUN_UI_SMOKE;
const execution = spawnSync(electronPath, [probePath, `--user-data-dir=${userDataPath}`], {
cwd: projectRoot,
env: probeEnvironment,
encoding: 'utf8',
windowsHide: true,
timeout: 30000
});
assert.equal(execution.status, 0, `${execution.stdout}\n${execution.stderr}`);
const result = JSON.parse(fs.readFileSync(outputPath, 'utf8'));
assert.equal(result.error, undefined);
assert.deepEqual(result, {
platform: 'win32',
safeStorageAvailable: true,
hidden: true,
canonicalId: true,
createOk: true,
copyOk: true,
deleteOk: true,
afterEmpty: true,
diskUsesKeysSchema: true,
diskHadCiphertext: true,
dpapiRoundTrip: true,
rendererSecretAbsent: true,
logSecretAbsent: true,
clipboardMatched: true,
clipboardRestored: true,
serverRecordBeforeDelete: true,
serverEmptyAfterDelete: true,
keyringEmptyAfterDelete: true,
transport: [
{ operation: 'upload', id: result.transport[0].id },
{ operation: 'delete', id: result.transport[0].id }
]
});
assert.match(result.transport[0].id, /^[A-Za-z0-9_-]{22}$/u);
assert.doesNotMatch(`${execution.stdout}\n${execution.stderr}\n${fs.readFileSync(outputPath, 'utf8')}`, /MHU2-[A-Za-z0-9_-]{70}/u);
} finally {
fs.rmSync(probeRoot, { recursive: true, force: true });
assert.equal(fs.existsSync(probeRoot), false);
}
});
test('resolveStartupLanguage accepts only the supported persisted language', () => {
assert.equal(resolveStartupLanguage({ globalSettings: { language: 'de' } }), 'de');
assert.equal(resolveStartupLanguage({ globalSettings: { language: 'en' } }), 'en');