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
+9 -1
View File
@@ -31,7 +31,15 @@ test('translates managed online backup controls in both directions', () => {
['Online-Backup löschen', 'Delete online backup'],
['Dieses verschlüsselte Online-Backup wird dauerhaft vom Server gelöscht.', 'This encrypted online backup will be permanently deleted from the server.'],
['Schlüssel gelöscht', 'Key deleted'],
['Importieren', 'Import']
['Importieren', 'Import'],
['Neuer Schlüssel erstellt.', 'New key created.'],
['Erneut laden', 'Reload'],
['Gespeicherter Online-Schlüsselbund ist beschädigt', 'Stored online keyring is damaged'],
['Sichere Schlüsselspeicherung ist nicht verfügbar', 'Secure key storage is unavailable'],
['Gespeicherter Online-Sicherungsschlüssel konnte nicht entschlüsselt werden', 'Stored online backup key could not be decrypted'],
['Gespeicherte Online-Sicherungskennung stimmt nicht mit dem Schlüssel überein', 'Stored online backup ID does not match its key'],
['Gespeicherte Online-Sicherungskennung ist mehrdeutig', 'Stored online backup ID is ambiguous'],
['Online-Schlüsselbund wurde aus einer Wiederherstellungsdatei geladen', 'Online keyring was loaded from a recovery file']
];
for (const [german, english] of pairs) {
+358 -113
View File
@@ -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')
);
});
});
+186 -14
View File
@@ -1,12 +1,23 @@
const { describe, it } = require('node:test');
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 = [];
@@ -22,10 +33,15 @@ function createFixture(overrides = {}) {
}
let createdAt;
let createArguments;
const removalPlans = new WeakMap();
const keyring = {
list: async () => {
events.push('list');
return [...entries.values()];
if (overrides.listError) throw overrides.listError;
return {
entries: [...entries.values()],
issues: overrides.listIssues || []
};
},
prepare: (value, timestamp) => {
events.push('prepare');
@@ -43,15 +59,29 @@ function createFixture(overrides = {}) {
createdAt: entry.createdAt
});
},
remove: async (entryId) => {
events.push('remove');
const existed = state.delete(entryId);
entries.delete(entryId);
return existed;
},
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({
@@ -88,6 +118,55 @@ function createFixture(overrides = {}) {
};
}
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 });
@@ -191,12 +270,28 @@ describe('transactional online backup manager', () => {
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, ['getKey', `delete:${key}`, 'remove']);
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);
@@ -207,7 +302,7 @@ describe('transactional online backup manager', () => {
const result = await fixture.manager.deleteManaged(id);
assert.deepEqual(fixture.events, ['getKey', `delete:${key}`, 'remove']);
assert.deepEqual(fixture.events, ['prepareRemove', `delete:${key}`, 'commitRemove']);
assert.deepEqual(result, { ok: true, removedId: id, notFound: true });
assert.equal(fixture.state.has(id), false);
});
@@ -217,19 +312,73 @@ describe('transactional online backup manager', () => {
const result = await fixture.manager.deleteManaged(id);
assert.deepEqual(fixture.events, ['getKey', `delete:${key}`]);
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', 'getKey']);
assert.deepEqual(fixture.events, ['getKey', 'prepareRemove']);
assert.deepEqual(copied, {
ok: false,
notFound: true,
@@ -282,9 +431,9 @@ describe('transactional online backup manager', () => {
'upload:start',
'upload:end',
'commit',
'getKey',
'prepareRemove',
`delete:${key}`,
'remove'
'commitRemove'
]);
});
@@ -306,4 +455,27 @@ describe('transactional online backup manager', () => {
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'
});
});
});
+29 -1
View File
@@ -36,6 +36,17 @@ test('encrypts and decrypts fields when secure storage is available', () => {
});
});
test('recognizes only canonical enc:v1 envelopes as encrypted', () => {
withSecretStore(availableSafeStorage(), secretStore => {
const canonical = `enc:v1:${Buffer.from('protected:secret').toString('base64')}`;
assert.equal(secretStore.isEncrypted(canonical), true);
assert.equal(secretStore.isEncrypted('secret'), false);
assert.equal(secretStore.isEncrypted(canonical.replace('enc:v1:', 'enc:v2:')), false);
assert.equal(secretStore.isEncrypted(canonical.replace(/=+$/u, '')), false);
assert.equal(secretStore.isEncrypted(`${canonical}\n`), false);
});
});
test('refuses plaintext storage by default when secure storage is unavailable', () => {
withSecretStore(null, secretStore => {
assert.throws(
@@ -94,7 +105,7 @@ test('throws an identifiable error when decryption fails', () => {
const failure = new Error('decryption failed');
withSecretStore(availableSafeStorage({ decryptString: () => { throw failure; } }), secretStore => {
assert.throws(
() => secretStore.decryptField('enc:v1:invalid'),
() => secretStore.decryptField('enc:v1:aW52YWxpZA=='),
error => error instanceof secretStore.SecretStoreError
&& error.code === 'SECRET_STORE_DECRYPT_FAILED'
&& error.cause === failure
@@ -102,6 +113,23 @@ test('throws an identifiable error when decryption fails', () => {
});
});
test('rejects malformed enc:v1 values instead of treating them as legacy plaintext', () => {
let decryptCalls = 0;
withSecretStore(availableSafeStorage({
decryptString: () => {
decryptCalls++;
return 'unexpected';
}
}), secretStore => {
assert.throws(
() => secretStore.decryptField('enc:v1:YWJjZA'),
error => error instanceof secretStore.SecretStoreError
&& error.code === 'SECRET_STORE_DECRYPT_FAILED'
);
assert.equal(decryptCalls, 0);
});
});
test('keeps legacy plaintext values readable without secure storage', () => {
withSecretStore(null, secretStore => {
assert.equal(secretStore.decryptField('legacy-secret'), 'legacy-secret');
+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');
+11 -10
View File
@@ -63,7 +63,7 @@ let initialConfigReadDelayed = false;
let startupLanguagePendingSnapshot = null;
let managedOnlineBackupEntries = [
{ 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' }
{ id: 'AQEBAQEBAQEBAQEBAQEBAQ', displayKey: 'MHU2-ZYXW…9876', createdAt: '2026-08-22T10:00:00.000Z' }
];
const managedOnlineBackupCopyIds = [];
const managedOnlineBackupDeleteIds = [];
@@ -74,7 +74,7 @@ const managedOnlineBackupHandlers = {
'online-backup:list-managed': async () => ({ ok: true, entries: managedOnlineBackupEntries.map(entry => ({ ...entry })) }),
'online-backup:create-managed': async () => {
managedOnlineBackupCreateCalls++;
const entry = { id: 'CCCCCCCCCCCCCCCCCCCCCC', displayKey: 'MHU2-QWER…4321', createdAt: '2026-08-23T12:00:00.000Z' };
const entry = { id: 'AgICAgICAgICAgICAgICAg', displayKey: 'MHU2-QWER…4321', createdAt: '2026-08-23T12:00:00.000Z' };
managedOnlineBackupEntries = [...managedOnlineBackupEntries, entry];
return { ok: true, entry: { ...entry } };
},
@@ -1405,7 +1405,7 @@ setTimeout(async () => {
check('Global parallel uploads default 0', parallel === '0');
await wc.executeJavaScript('document.querySelector("[data-settings-page=\\'backup\\']")?.click()');
const onlineBackupControls = await wc.executeJavaScript('["createOnlineBackupBtn", "managedOnlineBackupHeading", "managedOnlineBackupList", "onlineBackupKeyInput", "restoreOnlineBackupBtn", "onlineBackupStatus"].every(id => Boolean(document.getElementById(id))) && !document.getElementById("onlineBackupKeyOutput") && !document.getElementById("copyOnlineBackupKeyBtn")');
const onlineBackupControls = await wc.executeJavaScript('["createOnlineBackupBtn", "managedOnlineBackupHeading", "managedOnlineBackupList", "managedOnlineBackupRefreshStatus", "reloadManagedOnlineBackupsBtn", "onlineBackupKeyInput", "restoreOnlineBackupBtn", "onlineBackupStatus"].every(id => Boolean(document.getElementById(id))) && !document.getElementById("onlineBackupKeyOutput") && !document.getElementById("copyOnlineBackupKeyBtn")');
check('Online backup controls exist', onlineBackupControls);
const onlineBackupKeyContract = await wc.executeJavaScript('document.getElementById("onlineBackupKeyInput")?.maxLength + "|" + document.getElementById("onlineBackupKeyInput")?.getAttribute("pattern")');
@@ -1415,12 +1415,13 @@ setTimeout(async () => {
check('Online backup uses a narrow managed preload bridge', onlineBackupBridge === 'function|function|function|function|function|undefined');
const managedOnlineBackupLoaded = await waitUntil(() => wc.executeJavaScript('document.querySelectorAll(".online-backup-managed-row").length === 2'));
const managedOnlineBackupInitialState = await wc.executeJavaScript('(() => ({ keys: [...document.querySelectorAll(".online-backup-managed-key")].map(element => element.textContent), secretInBody: /MHU2-[A-Za-z0-9_-]{70}/.test(document.body.textContent) }))()');
check('Managed online backups render newest first without a complete key in the DOM', managedOnlineBackupLoaded === true && managedOnlineBackupInitialState.keys.join('|') === 'MHU2-ZYXW…9876|MHU2-ABCD…1234' && managedOnlineBackupInitialState.secretInBody === false);
const managedOnlineBackupInitialState = await wc.executeJavaScript('(() => ({ keys: [...document.querySelectorAll(".online-backup-managed-key")].map(element => element.textContent), described: [...document.querySelectorAll(".online-backup-managed-row")].every(row => [...row.querySelectorAll("button")].every(button => button.getAttribute("aria-describedby") === row.querySelector(".online-backup-managed-key").id)), secretInBody: /MHU2-[A-Za-z0-9_-]{70}/.test(document.body.textContent) }))()');
check('Managed online backups render canonical masked IDs with accessible actions', managedOnlineBackupLoaded === true && managedOnlineBackupInitialState.keys.join('|') === 'MHU2-ZYXW…9876|MHU2-ABCD…1234' && managedOnlineBackupInitialState.described === true && managedOnlineBackupInitialState.secretInBody === false);
await wc.executeJavaScript('document.querySelector(".online-backup-managed-row .online-backup-copy-btn").click()');
await waitUntil(() => managedOnlineBackupCopyIds.length === 1);
check('Managed online backup copy passes only the selected sanitized entry ID', managedOnlineBackupCopyIds.length === 1 && managedOnlineBackupCopyIds[0] === 'BBBBBBBBBBBBBBBBBBBBBB');
const managedCopyFocus = await waitUntil(() => wc.executeJavaScript('document.activeElement?.dataset.managedOnlineBackupAction === "copy" && document.activeElement?.dataset.managedOnlineBackupId === "AQEBAQEBAQEBAQEBAQEBAQ"'));
check('Managed online backup copy passes only the selected ID and restores keyboard focus', managedOnlineBackupCopyIds.length === 1 && managedOnlineBackupCopyIds[0] === 'AQEBAQEBAQEBAQEBAQEBAQ' && managedCopyFocus === true);
await wc.executeJavaScript('document.querySelector(".online-backup-managed-row .online-backup-delete-btn").click()');
await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertModal").style.display === "flex"'));
@@ -1439,13 +1440,13 @@ setTimeout(async () => {
const managedDeletePendingState = await wc.executeJavaScript('(() => { const row = document.querySelector(".online-backup-managed-row"); return { count: document.querySelectorAll(".online-backup-managed-row").length, controlsDisabled: [...row.querySelectorAll("button")].every(button => button.disabled) }; })()');
releaseManagedOnlineBackupDelete();
const managedDeleteSucceeded = await waitUntil(() => wc.executeJavaScript('document.querySelectorAll(".online-backup-managed-row").length === 1'));
const managedDeleteSuccessState = await wc.executeJavaScript('(() => ({ key: document.querySelector(".online-backup-managed-key")?.textContent, status: document.getElementById("onlineBackupStatus")?.textContent, secretInBody: /MHU2-[A-Za-z0-9_-]{70}/.test(document.body.textContent) }))()');
check('Managed online backup row disappears only after successful deletion', managedDeletePendingState.count === 2 && managedDeletePendingState.controlsDisabled === true && managedDeleteSucceeded === true && managedDeleteSuccessState.key === 'MHU2-ABCD…1234' && managedDeleteSuccessState.status === 'Schlüssel gelöscht' && managedDeleteSuccessState.secretInBody === false);
const managedDeleteSuccessState = await wc.executeJavaScript('(() => ({ key: document.querySelector(".online-backup-managed-key")?.textContent, status: document.getElementById("onlineBackupStatus")?.textContent, focusAction: document.activeElement?.dataset.managedOnlineBackupAction, focusId: document.activeElement?.dataset.managedOnlineBackupId, secretInBody: /MHU2-[A-Za-z0-9_-]{70}/.test(document.body.textContent) }))()');
check('Managed online backup row disappears only after successful deletion and focuses the neighboring action', managedDeletePendingState.count === 2 && managedDeletePendingState.controlsDisabled === true && managedDeleteSucceeded === true && managedDeleteSuccessState.key === 'MHU2-ABCD…1234' && managedDeleteSuccessState.status === 'Schlüssel gelöscht' && managedDeleteSuccessState.focusAction === 'delete' && managedDeleteSuccessState.focusId === 'AAAAAAAAAAAAAAAAAAAAAA' && managedDeleteSuccessState.secretInBody === false);
await wc.executeJavaScript('document.getElementById("createOnlineBackupBtn").click()');
const managedCreateSucceeded = await waitUntil(() => wc.executeJavaScript('document.querySelectorAll(".online-backup-managed-row").length === 2'));
const managedCreateState = await wc.executeJavaScript('(() => ({ first: document.querySelector(".online-backup-managed-key")?.textContent, secretInBody: /MHU2-[A-Za-z0-9_-]{70}/.test(document.body.textContent) }))()');
check('Creating a managed online backup inserts the returned sanitized entry newest first', managedOnlineBackupCreateCalls === 1 && managedCreateSucceeded === true && managedCreateState.first === 'MHU2-QWER…4321' && managedCreateState.secretInBody === false);
const managedCreateState = await wc.executeJavaScript('(() => ({ first: document.querySelector(".online-backup-managed-key")?.textContent, status: document.getElementById("onlineBackupStatus")?.textContent, secretInBody: /MHU2-[A-Za-z0-9_-]{70}/.test(document.body.textContent) }))()');
check('Creating a managed online backup inserts the returned entry with the exact success text', managedOnlineBackupCreateCalls === 1 && managedCreateSucceeded === true && managedCreateState.first === 'MHU2-QWER…4321' && managedCreateState.status === 'Neuer Schlüssel erstellt.' && managedCreateState.secretInBody === false);
const invalidOnlineBackup = await wc.executeJavaScript('document.getElementById("onlineBackupKeyInput").value = "MHU2-short"; document.getElementById("onlineBackupKeyInput").dispatchEvent(new Event("input", { bubbles: true })); document.getElementById("restoreOnlineBackupBtn").disabled + "|" + document.getElementById("onlineBackupStatus").textContent');
check('Invalid online backup keys stay blocked with visible guidance', invalidOnlineBackup === 'true|Der Schlüssel muss exakt 75 Zeichen lang sein.');