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
+381 -86
View File
@@ -5,102 +5,261 @@ const secretStore = require('./secret-store');
const { parseOnlineBackupKey } = require('./online-backup');
const STORED_ENTRY_KEYS = ['createdAt', 'encryptedKey', 'id'];
const STORED_DOCUMENT_KEYS = ['keys', 'version'];
const DIRECTORY_SYNC_UNSUPPORTED = new Set(['EACCES', 'EBADF', 'EISDIR', 'EINVAL', 'ENOTSUP', 'EPERM']);
const KEYRING_ERROR_CODES = Object.freeze({
structure: 'KEYRING_STRUCTURE_INVALID',
unavailable: 'KEYRING_SECURE_STORAGE_UNAVAILABLE',
decrypt: 'KEYRING_DECRYPT_FAILED',
mismatch: 'KEYRING_ID_MISMATCH',
duplicate: 'KEYRING_DUPLICATE_ID',
recovered: 'KEYRING_RECOVERED',
encrypt: 'KEYRING_ENCRYPT_FAILED',
plan: 'KEYRING_REMOVE_PLAN_INVALID'
});
const ERROR_MESSAGES = Object.freeze({
[KEYRING_ERROR_CODES.structure]: 'Gespeicherter Online-Schlüsselbund ist beschädigt',
[KEYRING_ERROR_CODES.unavailable]: 'Sichere Schlüsselspeicherung ist nicht verfügbar',
[KEYRING_ERROR_CODES.decrypt]: 'Gespeicherter Online-Sicherungsschlüssel konnte nicht entschlüsselt werden',
[KEYRING_ERROR_CODES.mismatch]: 'Gespeicherte Online-Sicherungskennung stimmt nicht mit dem Schlüssel überein',
[KEYRING_ERROR_CODES.duplicate]: 'Gespeicherte Online-Sicherungskennung ist mehrdeutig',
[KEYRING_ERROR_CODES.recovered]: 'Online-Schlüsselbund wurde aus einer Wiederherstellungsdatei geladen',
[KEYRING_ERROR_CODES.encrypt]: 'Online-Sicherungsschlüssel konnte nicht sicher vorbereitet werden',
[KEYRING_ERROR_CODES.plan]: 'Online-Sicherung konnte lokal nicht eindeutig entfernt werden'
});
const ISSUE_ORDER = Object.freeze([
KEYRING_ERROR_CODES.unavailable,
KEYRING_ERROR_CODES.duplicate,
KEYRING_ERROR_CODES.decrypt,
KEYRING_ERROR_CODES.mismatch,
KEYRING_ERROR_CODES.structure,
KEYRING_ERROR_CODES.recovered
]);
class OnlineBackupKeyringError extends Error {
constructor(code) {
super(ERROR_MESSAGES[code] || ERROR_MESSAGES[KEYRING_ERROR_CODES.structure]);
this.name = 'OnlineBackupKeyringError';
this.code = code;
}
}
function isObject(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function hasExactKeys(value, expected) {
if (!isObject(value)) return false;
const keys = Object.keys(value).sort();
return keys.length === expected.length && keys.every((key, index) => key === expected[index]);
}
function isCanonicalId(value) {
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{22}$/u.test(value)) return false;
const decoded = Buffer.from(value, 'base64url');
return decoded.length === 16 && decoded.toString('base64url') === value;
}
function normalizeTimestamp(value) {
const timestamp = new Date(value);
if (!Number.isFinite(timestamp.getTime())) throw new Error('Ungültiger Erstellungszeitpunkt');
if (!Number.isFinite(timestamp.getTime())) throw new OnlineBackupKeyringError(KEYRING_ERROR_CODES.structure);
return timestamp.toISOString();
}
function uniqueIssues(issues) {
const values = [...new Set(issues)];
values.sort((left, right) => ISSUE_ORDER.indexOf(left) - ISSUE_ORDER.indexOf(right));
return values;
}
function createOnlineBackupKeyring({
filePath,
encryptField = secretStore.encryptField,
decryptField = secretStore.decryptField,
isEncrypted = secretStore.isEncrypted,
parseKey = parseOnlineBackupKey,
fsImpl = fs.promises
}) {
const directory = path.dirname(filePath);
const basename = path.basename(filePath);
const backupPath = `${filePath}.bak`;
const temporaryPrefix = `.${basename}.`;
const removalPlans = new WeakMap();
let mutation = Promise.resolve();
function validateEntry(entry) {
if (!isObject(entry)) return null;
const keys = Object.keys(entry).sort();
if (
keys.length !== STORED_ENTRY_KEYS.length
|| !keys.every((key, index) => key === STORED_ENTRY_KEYS[index])
|| typeof entry.id !== 'string'
|| !entry.id
|| typeof entry.encryptedKey !== 'string'
|| !entry.encryptedKey
|| typeof entry.createdAt !== 'string'
) {
return null;
}
let key;
let parsed;
try {
key = decryptField(entry.encryptedKey);
parsed = parseKey(key);
} catch {
return null;
}
if (typeof key !== 'string' || parsed?.id !== entry.id) return null;
let createdAt;
try {
createdAt = normalizeTimestamp(entry.createdAt);
} catch {
return null;
}
if (createdAt !== entry.createdAt) return null;
return { id: entry.id, encryptedKey: entry.encryptedKey, createdAt, key };
function issueError(code) {
return new OnlineBackupKeyringError(code);
}
async function readEntries(rejectInvalidEntries = false) {
let contents;
try {
contents = await fsImpl.readFile(filePath, 'utf8');
} catch (error) {
if (error?.code === 'ENOENT') return [];
throw error;
function isTemporaryFileName(value) {
return value.startsWith(temporaryPrefix)
&& /^\d+\.[0-9a-f-]+\.(?:primary|recovery)\.tmp$/u.test(value.slice(temporaryPrefix.length));
}
function encryptionError(error) {
return issueError(error?.code === 'SECRET_STORE_UNAVAILABLE' ? KEYRING_ERROR_CODES.unavailable : KEYRING_ERROR_CODES.encrypt);
}
function decryptionIssue(error) {
return error?.code === 'SECRET_STORE_UNAVAILABLE' ? KEYRING_ERROR_CODES.unavailable : KEYRING_ERROR_CODES.decrypt;
}
function parseDocument(contents) {
let document;
try {
document = JSON.parse(contents);
} catch {
throw new Error('Gespeicherter Online-Schlüsselbund ist ungültig');
throw issueError(KEYRING_ERROR_CODES.structure);
}
if (!isObject(document) || document.version !== 1 || !Array.isArray(document.entries)) {
throw new Error('Gespeicherter Online-Schlüsselbund ist ungültig');
if (!hasExactKeys(document, STORED_DOCUMENT_KEYS) || document.version !== 1 || !Array.isArray(document.keys)) {
throw issueError(KEYRING_ERROR_CODES.structure);
}
const entries = document.entries.map(validateEntry);
if (rejectInvalidEntries && entries.some((entry) => !entry)) {
throw new Error('Gespeicherter Online-Schlüsselbund ist ungültig');
}
return entries.filter(Boolean);
return document;
}
async function writeEntries(entries) {
const directory = path.dirname(filePath);
const temporaryPath = path.join(directory, `.${path.basename(filePath)}.${process.pid}.${crypto.randomUUID()}.tmp`);
const document = JSON.stringify({
version: 1,
entries: entries.map(({ id, encryptedKey, createdAt }) => ({ id, encryptedKey, createdAt }))
});
await fsImpl.mkdir(directory, { recursive: true });
async function readCandidate(candidatePath) {
try {
await fsImpl.writeFile(temporaryPath, document, { encoding: 'utf8', flag: 'wx' });
await fsImpl.rename(temporaryPath, filePath);
const contents = await fsImpl.readFile(candidatePath, 'utf8');
return { status: 'valid', path: candidatePath, contents, document: parseDocument(contents) };
} catch (error) {
if (error?.code === 'ENOENT') return { status: 'missing', path: candidatePath };
return { status: 'invalid', path: candidatePath };
}
}
async function recoveryCandidates() {
const candidates = new Set([backupPath]);
try {
await fsImpl.unlink(temporaryPath);
} catch (cleanupError) {
if (cleanupError?.code !== 'ENOENT') throw cleanupError;
const entries = await fsImpl.readdir(directory, { withFileTypes: true });
for (const entry of entries) {
if (entry.isFile() && isTemporaryFileName(entry.name)) candidates.add(path.join(directory, entry.name));
}
throw error;
} catch (error) {
if (error?.code !== 'ENOENT') throw issueError(KEYRING_ERROR_CODES.structure);
}
const ranked = [];
for (const candidatePath of candidates) {
try {
const stats = await fsImpl.stat(candidatePath);
ranked.push({ candidatePath, modified: stats.mtimeMs });
} catch (error) {
if (error?.code !== 'ENOENT') ranked.push({ candidatePath, modified: 0 });
}
}
ranked.sort((left, right) => right.modified - left.modified);
return ranked.map(candidate => candidate.candidatePath);
}
function validateEntry(entry) {
if (
!hasExactKeys(entry, STORED_ENTRY_KEYS)
|| !isCanonicalId(entry.id)
|| typeof entry.encryptedKey !== 'string'
|| !isEncrypted(entry.encryptedKey)
|| typeof entry.createdAt !== 'string'
) {
return { issue: KEYRING_ERROR_CODES.structure, id: typeof entry?.id === 'string' ? entry.id : null };
}
let createdAt;
try {
createdAt = normalizeTimestamp(entry.createdAt);
} catch {
return { issue: KEYRING_ERROR_CODES.structure, id: entry.id };
}
if (createdAt !== entry.createdAt) return { issue: KEYRING_ERROR_CODES.structure, id: entry.id };
let key;
try {
key = decryptField(entry.encryptedKey);
} catch (error) {
return { issue: decryptionIssue(error), id: entry.id };
}
if (typeof key !== 'string') return { issue: KEYRING_ERROR_CODES.decrypt, id: entry.id };
let parsed;
try {
parsed = parseKey(key);
} catch {
return { issue: KEYRING_ERROR_CODES.decrypt, id: entry.id };
}
if (parsed?.id !== entry.id) return { issue: KEYRING_ERROR_CODES.mismatch, id: entry.id };
return {
entry: {
id: entry.id,
encryptedKey: entry.encryptedKey,
createdAt,
key
}
};
}
function inspectSource(source) {
const idCounts = new Map();
for (const entry of source.document.keys) {
if (isCanonicalId(entry?.id)) idCounts.set(entry.id, (idCounts.get(entry.id) || 0) + 1);
}
const duplicateIds = new Set([...idCounts].filter(([, count]) => count > 1).map(([id]) => id));
const entries = [];
const problems = [];
for (const entry of source.document.keys) {
if (duplicateIds.has(entry?.id)) continue;
const result = validateEntry(entry);
if (result.entry) entries.push(result.entry);
else problems.push({ code: result.issue, id: result.id });
}
for (const id of duplicateIds) problems.push({ code: KEYRING_ERROR_CODES.duplicate, id });
const issues = uniqueIssues([
...problems.map(problem => problem.code),
...(source.recovered ? [KEYRING_ERROR_CODES.recovered] : [])
]);
return { source, entries, problems, duplicateIds, issues };
}
async function readState() {
const primary = await readCandidate(filePath);
if (primary.status === 'valid') {
let primaryModified = 0;
try {
primaryModified = (await fsImpl.stat(filePath)).mtimeMs;
} catch {}
const candidates = await recoveryCandidates();
for (const candidatePath of candidates) {
if (!isTemporaryFileName(path.basename(candidatePath))) continue;
let candidateModified = 0;
try {
candidateModified = (await fsImpl.stat(candidatePath)).mtimeMs;
} catch {}
if (candidateModified <= primaryModified) continue;
const candidate = await readCandidate(candidatePath);
if (candidate.status !== 'valid') continue;
const state = inspectSource({ ...candidate, recovered: true });
if (!firstBlockingIssue(state)) return state;
}
return inspectSource({ ...primary, recovered: false });
}
const candidates = await recoveryCandidates();
let fallback = null;
for (const candidatePath of candidates) {
const candidate = await readCandidate(candidatePath);
if (candidate.status !== 'valid') continue;
const state = inspectSource({ ...candidate, recovered: true });
fallback ||= state;
if (!firstBlockingIssue(state)) return state;
}
if (fallback) return fallback;
if (primary.status === 'missing' && candidates.length === 0) {
const document = { version: 1, keys: [] };
return inspectSource({
status: 'valid',
path: filePath,
contents: JSON.stringify(document),
document,
recovered: false
});
}
throw issueError(KEYRING_ERROR_CODES.structure);
}
function firstBlockingIssue(state) {
return state.issues.find(issue => issue !== KEYRING_ERROR_CODES.recovered) || null;
}
function serialize(operation) {
@@ -109,29 +268,134 @@ function createOnlineBackupKeyring({
return next;
}
function temporaryPath(kind) {
return path.join(directory, `.${basename}.${process.pid}.${crypto.randomUUID()}.${kind}.tmp`);
}
async function removeFile(target) {
try {
await fsImpl.unlink(target);
} catch (error) {
if (error?.code !== 'ENOENT') return false;
}
return true;
}
async function writeAndSync(target, contents) {
let handle;
let failure;
try {
handle = await fsImpl.open(target, 'wx', 0o600);
await handle.writeFile(contents, { encoding: 'utf8' });
await handle.sync();
} catch (error) {
failure = error;
}
if (handle) {
try {
await handle.close();
} catch (error) {
failure ||= error;
}
}
if (failure) throw failure;
}
async function syncDirectory() {
let handle;
let failure;
try {
handle = await fsImpl.open(directory, 'r');
await handle.sync();
} catch (error) {
if (!DIRECTORY_SYNC_UNSUPPORTED.has(error?.code)) failure = error;
}
if (handle) {
try {
await handle.close();
} catch (error) {
if (!DIRECTORY_SYNC_UNSUPPORTED.has(error?.code)) failure ||= error;
}
}
if (failure) throw failure;
}
async function cleanupTemporaryFiles(except = null) {
let entries;
try {
entries = await fsImpl.readdir(directory, { withFileTypes: true });
} catch (error) {
if (error?.code === 'ENOENT') return;
throw error;
}
for (const entry of entries) {
const candidatePath = path.join(directory, entry.name);
if (!entry.isFile() || !isTemporaryFileName(entry.name) || candidatePath === except) continue;
await removeFile(candidatePath);
}
}
async function writeEntries(entries) {
const contents = JSON.stringify({
version: 1,
keys: entries.map(({ id, encryptedKey, createdAt }) => ({ id, encryptedKey, createdAt }))
});
const primaryTemporaryPath = temporaryPath('primary');
const recoveryTemporaryPath = temporaryPath('recovery');
await fsImpl.mkdir(directory, { recursive: true });
let primaryPublished = false;
try {
await writeAndSync(primaryTemporaryPath, contents);
await writeAndSync(recoveryTemporaryPath, contents);
await fsImpl.rename(primaryTemporaryPath, filePath);
primaryPublished = true;
} catch (error) {
await removeFile(primaryTemporaryPath);
await removeFile(recoveryTemporaryPath);
throw error;
}
let recoveryPath = recoveryTemporaryPath;
try {
await syncDirectory();
await fsImpl.rename(recoveryTemporaryPath, backupPath);
recoveryPath = null;
await syncDirectory();
} catch {
if (!primaryPublished) throw issueError(KEYRING_ERROR_CODES.structure);
}
await cleanupTemporaryFiles(recoveryPath);
}
async function list() {
const entries = await readEntries();
const sanitized = entries
const state = await readState();
const entries = state.entries
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))
.map(({ id, key, createdAt }) => Object.freeze({
id,
displayKey: `${key.slice(0, 9)}${key.slice(-4)}`,
createdAt
}));
return Object.freeze(sanitized);
return Object.freeze({
entries: Object.freeze(entries),
issues: Object.freeze([...state.issues])
});
}
function prepare(key, createdAt) {
let parsed;
let encryptedKey;
try {
parsed = parseKey(key);
encryptedKey = encryptField(key);
} catch {
throw new Error('Online-Sicherungsschlüssel konnte nicht sicher vorbereitet werden');
throw issueError(KEYRING_ERROR_CODES.structure);
}
if (typeof encryptedKey !== 'string' || !encryptedKey || encryptedKey === key) {
throw new Error('Online-Sicherungsschlüssel konnte nicht sicher vorbereitet werden');
let encryptedKey;
try {
encryptedKey = encryptField(key);
} catch (error) {
throw encryptionError(error);
}
if (typeof encryptedKey !== 'string' || encryptedKey === key || !isEncrypted(encryptedKey)) {
throw issueError(KEYRING_ERROR_CODES.encrypt);
}
return Object.freeze({
id: parsed.id,
@@ -143,29 +407,60 @@ function createOnlineBackupKeyring({
function commit(entry) {
return serialize(async () => {
const validated = validateEntry(entry);
if (!validated) throw new Error('Online-Sicherungsschlüssel ist ungültig');
const entries = await readEntries(true);
if (entries.some((current) => current.id === validated.id)) return;
await writeEntries([...entries, validated]);
});
}
function remove(id) {
return serialize(async () => {
const entries = await readEntries(true);
const remaining = entries.filter((entry) => entry.id !== id);
if (remaining.length === entries.length) return false;
await writeEntries(remaining);
if (!validated.entry) throw issueError(validated.issue);
const state = await readState();
const blockingIssue = firstBlockingIssue(state);
if (blockingIssue) throw issueError(blockingIssue);
if (state.entries.some(current => current.id === validated.entry.id)) return false;
await writeEntries([...state.entries, validated.entry]);
return true;
});
}
async function getKey(id) {
const entries = await readEntries();
return entries.find((entry) => entry.id === id)?.key ?? null;
const state = await readState();
if (state.duplicateIds.has(id)) throw issueError(KEYRING_ERROR_CODES.duplicate);
const entry = state.entries.find(current => current.id === id);
if (entry) return entry.key;
const matchingProblem = state.problems.find(problem => problem.id === id);
if (matchingProblem) throw issueError(matchingProblem.code);
const blockingIssue = firstBlockingIssue(state);
if (blockingIssue) throw issueError(blockingIssue);
return null;
}
return Object.freeze({ list, prepare, commit, remove, getKey });
async function prepareRemove(id) {
const state = await readState();
const blockingIssue = firstBlockingIssue(state);
if (blockingIssue) throw issueError(blockingIssue);
const entry = state.entries.find(current => current.id === id);
if (!entry) return null;
const plan = Object.freeze({ id: entry.id, key: entry.key });
removalPlans.set(plan, state.entries.filter(current => current.id !== id));
return plan;
}
function commitRemove(plan) {
return serialize(async () => {
const remaining = removalPlans.get(plan);
if (!remaining) throw issueError(KEYRING_ERROR_CODES.plan);
await writeEntries(remaining);
removalPlans.delete(plan);
return true;
});
}
async function remove(id) {
const plan = await prepareRemove(id);
if (!plan) return false;
return commitRemove(plan);
}
return Object.freeze({ list, prepare, commit, remove, getKey, prepareRemove, commitRemove });
}
module.exports = { createOnlineBackupKeyring };
module.exports = {
KEYRING_ERROR_CODES,
OnlineBackupKeyringError,
createOnlineBackupKeyring
};
+48 -15
View File
@@ -3,6 +3,7 @@ const {
deleteOnlineBackup,
uploadOnlineBackup
} = require('./online-backup');
const { KEYRING_ERROR_CODES } = require('./online-backup-keyring');
const ERRORS = Object.freeze({
list: 'Online-Sicherungen konnten nicht geladen werden',
@@ -11,6 +12,16 @@ const ERRORS = Object.freeze({
delete: 'Online-Sicherung konnte nicht gelöscht werden',
notFound: 'Online-Sicherungsschlüssel wurde nicht gefunden'
});
const KEYRING_MESSAGES = Object.freeze({
[KEYRING_ERROR_CODES.structure]: 'Gespeicherter Online-Schlüsselbund ist beschädigt',
[KEYRING_ERROR_CODES.unavailable]: 'Sichere Schlüsselspeicherung ist nicht verfügbar',
[KEYRING_ERROR_CODES.decrypt]: 'Gespeicherter Online-Sicherungsschlüssel konnte nicht entschlüsselt werden',
[KEYRING_ERROR_CODES.mismatch]: 'Gespeicherte Online-Sicherungskennung stimmt nicht mit dem Schlüssel überein',
[KEYRING_ERROR_CODES.duplicate]: 'Gespeicherte Online-Sicherungskennung ist mehrdeutig',
[KEYRING_ERROR_CODES.recovered]: 'Online-Schlüsselbund wurde aus einer Wiederherstellungsdatei geladen',
[KEYRING_ERROR_CODES.encrypt]: 'Online-Sicherungsschlüssel konnte nicht sicher vorbereitet werden',
[KEYRING_ERROR_CODES.plan]: 'Online-Sicherung konnte lokal nicht eindeutig entfernt werden'
});
function sanitizeEntry(entry) {
return {
@@ -28,6 +39,12 @@ function sanitizeCreatedEntry(entry, key) {
};
}
function keyringFailure(error, fallback) {
const message = KEYRING_MESSAGES[error?.code];
if (!message) return { ok: false, error: fallback };
return { ok: false, code: error.code, error: message };
}
function createOnlineBackupManager({
keyring,
loadSettings,
@@ -46,8 +63,12 @@ function createOnlineBackupManager({
}
async function listEntries() {
const entries = await keyring.list();
return entries.map(sanitizeEntry);
const snapshot = await keyring.list();
if (!snapshot || !Array.isArray(snapshot.entries) || !Array.isArray(snapshot.issues)) throw new Error(ERRORS.list);
return {
entries: snapshot.entries.map(sanitizeEntry),
issues: snapshot.issues
};
}
async function createTransaction() {
@@ -68,27 +89,39 @@ function createOnlineBackupManager({
}
async function deleteTransaction(id) {
const key = await keyring.getKey(id);
if (!key) return { ok: false, notFound: true, error: ERRORS.notFound };
const outcome = await deleteBackup(key);
const plan = await keyring.prepareRemove(id);
if (!plan) return { ok: false, notFound: true, error: ERRORS.notFound };
const outcome = await deleteBackup(plan.key);
if (!outcome?.deleted && !outcome?.notFound) throw new Error(ERRORS.delete);
await keyring.remove(id);
await keyring.commitRemove(plan);
return { ok: true, removedId: id, notFound: outcome.notFound };
}
async function listManaged() {
try {
return { ok: true, entries: await listEntries() };
} catch {
return { ok: false, error: ERRORS.list };
const snapshot = await listEntries();
const issue = snapshot.issues[0];
if (!issue) return { ok: true, entries: snapshot.entries };
const message = KEYRING_MESSAGES[issue] || ERRORS.list;
if (snapshot.entries.length || issue === KEYRING_ERROR_CODES.recovered) {
return {
ok: true,
entries: snapshot.entries,
warningCode: issue,
warning: message
};
}
return { ok: false, entries: [], code: issue, error: message };
} catch (error) {
return keyringFailure(error, ERRORS.list);
}
}
async function createManaged() {
try {
return await serialize(createTransaction);
} catch {
return { ok: false, error: ERRORS.create };
} catch (error) {
return keyringFailure(error, ERRORS.create);
}
}
@@ -98,16 +131,16 @@ function createOnlineBackupManager({
if (!key) return { ok: false, notFound: true, error: ERRORS.notFound };
await copyText(key);
return { ok: true };
} catch {
return { ok: false, error: ERRORS.copy };
} catch (error) {
return keyringFailure(error, ERRORS.copy);
}
}
async function deleteManaged(id) {
try {
return await serialize(() => deleteTransaction(id));
} catch {
return { ok: false, error: ERRORS.delete };
} catch (error) {
return keyringFailure(error, ERRORS.delete);
}
}
+11 -2
View File
@@ -35,7 +35,11 @@ function getSafeStorage() {
}
function isEncrypted(value) {
return typeof value === 'string' && value.startsWith(SENTINEL);
if (typeof value !== 'string' || !value.startsWith(SENTINEL)) return false;
const encoded = value.slice(SENTINEL.length);
if (!encoded || encoded.length % 4 !== 0) return false;
const decoded = Buffer.from(encoded, 'base64');
return decoded.length > 0 && decoded.toString('base64') === encoded;
}
function encryptField(value) {
@@ -55,7 +59,12 @@ function encryptField(value) {
function decryptField(value) {
if (!value || typeof value !== 'string') return value;
if (!isEncrypted(value)) return value;
if (!isEncrypted(value)) {
if (value.startsWith(SENTINEL)) {
throw new SecretStoreError('SECRET_STORE_DECRYPT_FAILED', 'Gespeicherte Zugangsdaten konnten nicht entschlüsselt werden');
}
return value;
}
const ss = getSafeStorage();
if (!ss) {
throw new SecretStoreError('SECRET_STORE_UNAVAILABLE', 'Sicherer Zugangsdaten-Speicher ist nicht verfügbar');
+169 -68
View File
@@ -42,6 +42,7 @@ function refreshLocalizedRuntimeUi() {
const hint = document.getElementById('recentFilesHint');
if (hint && activeRecentTab) hint.textContent = localizeUiText(activeRecentTab.dataset.panel === 'statsTab' ? 'Upload-Statistiken' : 'Zuletzt erzeugte Upload-Links');
renderManagedOnlineBackups();
renderManagedOnlineBackupRefreshIssue();
}
// Dropdown options for "Add Account" modal: value -> label
@@ -64,11 +65,12 @@ let uploading = false;
let healthCheckRunning = false;
let managedOnlineBackups = [];
let managedOnlineBackupsAuthoritative = false;
let managedOnlineBackupOperationGeneration = 0;
let managedOnlineBackupMutationGeneration = 0;
let managedOnlineBackupNavigationLoadGeneration = 0;
let managedOnlineBackupAuthoritativeLoadGeneration = 0;
let managedOnlineBackupActiveOperations = 0;
let managedOnlineBackupActiveMutations = 0;
let onlineBackupStatusContextGeneration = 0;
let managedOnlineBackupRefreshIssue = null;
const managedOnlineBackupOperationQueues = new Map();
let _rLongTasks = 0, _rLongTaskMax = 0, _rFrameLast = 0, _rFrameWorst = 0, _rFrameCount = 0, _rFrameJank = 0, _rPerfLastLog = 0, _rPerfWindowStart = 0;
@@ -2647,15 +2649,18 @@ function beginOnlineBackupStatusContext() {
return ++onlineBackupStatusContextGeneration;
}
function beginManagedOnlineBackupOperation() {
managedOnlineBackupActiveOperations++;
managedOnlineBackupOperationGeneration++;
function beginManagedOnlineBackupMutation() {
managedOnlineBackupActiveMutations++;
managedOnlineBackupMutationGeneration++;
managedOnlineBackupNavigationLoadGeneration++;
return beginOnlineBackupStatusContext();
return Object.freeze({
mutationGeneration: managedOnlineBackupMutationGeneration,
statusContext: beginOnlineBackupStatusContext()
});
}
function endManagedOnlineBackupOperation() {
managedOnlineBackupActiveOperations = Math.max(0, managedOnlineBackupActiveOperations - 1);
function endManagedOnlineBackupMutation() {
managedOnlineBackupActiveMutations = Math.max(0, managedOnlineBackupActiveMutations - 1);
}
function setOnlineBackupStatus(message, state = '', statusContext = null) {
@@ -2675,34 +2680,84 @@ function syncOnlineBackupRestoreButton(busy = false) {
restoreButton.disabled = busy || !/^MHU2-[A-Za-z0-9_-]{70}$/.test(document.getElementById('onlineBackupKeyInput')?.value.trim() || '');
}
function normalizeManagedOnlineBackups(entries) {
const sanitized = new Map();
for (const entry of Array.isArray(entries) ? entries : []) {
if (!entry || typeof entry !== 'object') continue;
if (typeof entry.id !== 'string' || !/^[A-Za-z0-9_-]{22}$/.test(entry.id)) continue;
if (typeof entry.displayKey !== 'string' || !entry.displayKey || /MHU2-[A-Za-z0-9_-]{70}/.test(entry.displayKey)) continue;
const createdAt = new Date(entry.createdAt);
if (Number.isNaN(createdAt.getTime())) continue;
sanitized.set(entry.id, { id: entry.id, displayKey: entry.displayKey, createdAt: createdAt.toISOString() });
function isCanonicalManagedOnlineBackupId(value) {
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{22}$/.test(value)) return false;
try {
const decoded = window.atob(`${value.replace(/-/g, '+').replace(/_/g, '/')}==`);
if (decoded.length !== 16) return false;
const encoded = window.btoa(decoded).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
return encoded === value;
} catch {
return false;
}
return [...sanitized.values()].sort((left, right) => right.createdAt.localeCompare(left.createdAt));
}
function replaceManagedOnlineBackups(entries) {
function normalizeManagedOnlineBackups(entries) {
const candidates = [];
for (const entry of Array.isArray(entries) ? entries : []) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
if (Object.keys(entry).sort().join(',') !== 'createdAt,displayKey,id') continue;
if (!isCanonicalManagedOnlineBackupId(entry.id)) continue;
if (typeof entry.displayKey !== 'string' || !/^MHU2-[A-Za-z0-9_-]{4}…[A-Za-z0-9_-]{4}$/.test(entry.displayKey)) continue;
const createdAt = new Date(entry.createdAt);
if (Number.isNaN(createdAt.getTime()) || createdAt.toISOString() !== entry.createdAt) continue;
candidates.push({ id: entry.id, displayKey: entry.displayKey, createdAt: entry.createdAt });
}
const counts = new Map();
for (const entry of candidates) counts.set(entry.id, (counts.get(entry.id) || 0) + 1);
return candidates
.filter(entry => counts.get(entry.id) === 1)
.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
}
function managedOnlineBackupFocusTarget() {
const active = document.activeElement;
const id = active?.dataset?.managedOnlineBackupId;
const action = active?.dataset?.managedOnlineBackupAction;
if (!id || !action) return null;
return { id, action, fallbackIndex: managedOnlineBackups.findIndex(entry => entry.id === id) };
}
function restoreManagedOnlineBackupFocus(target) {
if (!target) return;
let button = [...document.querySelectorAll('[data-managed-online-backup-action]')]
.find(candidate => candidate.dataset.managedOnlineBackupId === target.id && candidate.dataset.managedOnlineBackupAction === target.action);
if (!button && Number.isInteger(target.fallbackIndex)) {
const rows = [...document.querySelectorAll('.online-backup-managed-row')];
const row = rows[Math.min(Math.max(0, target.fallbackIndex), Math.max(0, rows.length - 1))];
button = row?.querySelector(`[data-managed-online-backup-action="${target.action}"]`);
}
button ||= document.getElementById('createOnlineBackupBtn');
if (button && !button.disabled) button.focus();
}
function replaceManagedOnlineBackups(entries, focusTarget = undefined) {
const target = focusTarget === undefined ? managedOnlineBackupFocusTarget() : focusTarget;
managedOnlineBackups = normalizeManagedOnlineBackups(entries);
managedOnlineBackupsAuthoritative = true;
renderManagedOnlineBackups();
renderManagedOnlineBackups(target);
}
function invalidateManagedOnlineBackups() {
managedOnlineBackups = [];
managedOnlineBackupsAuthoritative = false;
renderManagedOnlineBackups();
function upsertManagedOnlineBackup(entry) {
const normalized = normalizeManagedOnlineBackups([entry]);
if (normalized.length !== 1) return false;
replaceManagedOnlineBackups([
...managedOnlineBackups.filter(current => current.id !== normalized[0].id),
normalized[0]
]);
return true;
}
function renderManagedOnlineBackups() {
function removeManagedOnlineBackup(id, focusTarget = null) {
if (!hasManagedOnlineBackup(id)) return false;
replaceManagedOnlineBackups(managedOnlineBackups.filter(entry => entry.id !== id), focusTarget);
return true;
}
function renderManagedOnlineBackups(focusTarget = undefined) {
const list = document.getElementById('managedOnlineBackupList');
if (!list) return;
const target = focusTarget === undefined ? managedOnlineBackupFocusTarget() : focusTarget;
const content = document.createDocumentFragment();
if (!managedOnlineBackupsAuthoritative) {
list.replaceChildren(content);
@@ -2717,8 +2772,10 @@ function renderManagedOnlineBackups() {
for (const entry of managedOnlineBackups) {
const row = document.createElement('article');
row.className = 'online-backup-managed-row';
row.dataset.managedOnlineBackupId = entry.id;
const key = document.createElement('span');
key.className = 'online-backup-managed-key';
key.id = `managedOnlineBackupKey-${entry.id}`;
key.textContent = entry.displayKey;
const created = document.createElement('span');
created.className = 'online-backup-managed-created';
@@ -2729,10 +2786,16 @@ function renderManagedOnlineBackups() {
copyButton.type = 'button';
copyButton.className = 'btn btn-secondary online-backup-copy-btn';
copyButton.textContent = localizeUiText('Schlüssel kopieren');
copyButton.dataset.managedOnlineBackupId = entry.id;
copyButton.dataset.managedOnlineBackupAction = 'copy';
copyButton.setAttribute('aria-describedby', key.id);
const deleteButton = document.createElement('button');
deleteButton.type = 'button';
deleteButton.className = 'btn btn-danger online-backup-delete-btn';
deleteButton.textContent = localizeUiText('Online-Backup löschen');
deleteButton.dataset.managedOnlineBackupId = entry.id;
deleteButton.dataset.managedOnlineBackupAction = 'delete';
deleteButton.setAttribute('aria-describedby', key.id);
const pending = managedOnlineBackupOperationQueues.has(entry.id);
copyButton.disabled = pending;
deleteButton.disabled = pending;
@@ -2744,39 +2807,61 @@ function renderManagedOnlineBackups() {
}
}
list.replaceChildren(content);
restoreManagedOnlineBackupFocus(target);
}
async function loadManagedOnlineBackups({ statusContext = null } = {}) {
const authoritative = statusContext !== null;
const operationGeneration = managedOnlineBackupOperationGeneration;
const startedDuringOperation = managedOnlineBackupActiveOperations > 0;
function renderManagedOnlineBackupRefreshIssue() {
const container = document.getElementById('managedOnlineBackupRefreshStatus');
const message = document.getElementById('managedOnlineBackupRefreshMessage');
const retry = document.getElementById('reloadManagedOnlineBackupsBtn');
if (!container || !message || !retry) return;
const issue = managedOnlineBackupRefreshIssue;
container.hidden = !issue;
container.dataset.state = issue?.state || '';
message.textContent = issue ? localizeUiText(issue.message) : '';
retry.textContent = localizeUiText('Erneut laden');
retry.onclick = issue ? async () => {
retry.disabled = true;
await loadManagedOnlineBackups();
if (!retry.isConnected) return;
retry.disabled = false;
if (!container.hidden) retry.focus();
else restoreManagedOnlineBackupFocus({ id: managedOnlineBackups[0]?.id, action: 'copy', fallbackIndex: 0 });
} : null;
}
function setManagedOnlineBackupRefreshIssue(message = '', state = 'warning') {
const sanitized = String(message || '').replace(/MHU2-[A-Za-z0-9_-]{70}/gu, localizeUiText('Geschützter Schlüssel'));
managedOnlineBackupRefreshIssue = sanitized ? { message: sanitized, state } : null;
renderManagedOnlineBackupRefreshIssue();
}
async function loadManagedOnlineBackups({ mutationGeneration = null } = {}) {
const authoritative = mutationGeneration !== null;
const capturedMutationGeneration = managedOnlineBackupMutationGeneration;
const startedDuringMutation = managedOnlineBackupActiveMutations > 0;
const generation = authoritative
? ++managedOnlineBackupAuthoritativeLoadGeneration
: ++managedOnlineBackupNavigationLoadGeneration;
const loaderStatusContext = authoritative || !startedDuringOperation ? statusContext ?? beginOnlineBackupStatusContext() : null;
const mayUpdateStatus = loaderStatusContext !== null;
try {
const result = await window.api.listManagedOnlineBackups();
const stale = authoritative
? generation !== managedOnlineBackupAuthoritativeLoadGeneration || operationGeneration !== managedOnlineBackupOperationGeneration
: generation !== managedOnlineBackupNavigationLoadGeneration || operationGeneration !== managedOnlineBackupOperationGeneration || startedDuringOperation;
? generation !== managedOnlineBackupAuthoritativeLoadGeneration || mutationGeneration !== managedOnlineBackupMutationGeneration
: generation !== managedOnlineBackupNavigationLoadGeneration || capturedMutationGeneration !== managedOnlineBackupMutationGeneration || startedDuringMutation;
if (stale) return false;
if (!result?.ok || !Array.isArray(result.entries)) {
invalidateManagedOnlineBackups();
if (mayUpdateStatus) setOnlineBackupStatus('Online-Sicherungen konnten nicht geladen werden', 'error', loaderStatusContext);
setManagedOnlineBackupRefreshIssue(result?.error || 'Online-Sicherungen konnten nicht geladen werden', 'error');
return false;
}
replaceManagedOnlineBackups(result.entries);
if (mayUpdateStatus) setOnlineBackupStatus('', '', loaderStatusContext);
if (result.warning) setManagedOnlineBackupRefreshIssue(result.warning, 'warning');
else setManagedOnlineBackupRefreshIssue();
return true;
} catch {
const current = authoritative
? generation === managedOnlineBackupAuthoritativeLoadGeneration && operationGeneration === managedOnlineBackupOperationGeneration
: generation === managedOnlineBackupNavigationLoadGeneration && operationGeneration === managedOnlineBackupOperationGeneration && !startedDuringOperation;
if (current) {
invalidateManagedOnlineBackups();
if (mayUpdateStatus) setOnlineBackupStatus('Online-Sicherungen konnten nicht geladen werden', 'error', loaderStatusContext);
}
? generation === managedOnlineBackupAuthoritativeLoadGeneration && mutationGeneration === managedOnlineBackupMutationGeneration
: generation === managedOnlineBackupNavigationLoadGeneration && capturedMutationGeneration === managedOnlineBackupMutationGeneration && !startedDuringMutation;
if (current) setManagedOnlineBackupRefreshIssue('Online-Sicherungen konnten nicht geladen werden', 'error');
return false;
}
}
@@ -2785,15 +2870,21 @@ function hasManagedOnlineBackup(id) {
return managedOnlineBackups.some(entry => entry.id === id);
}
function enqueueManagedOnlineBackupOperation(id, action, operation) {
const statusContext = beginManagedOnlineBackupOperation();
function enqueueManagedOnlineBackupOperation(id, action, operation, mutation = false) {
const previous = managedOnlineBackupOperationQueues.get(id)?.promise || Promise.resolve();
const promise = previous.catch(() => {}).then(() => operation(statusContext));
const state = { action, promise };
const state = { action, promise: null };
const promise = previous.catch(() => {}).then(async () => {
const authority = mutation ? beginManagedOnlineBackupMutation() : null;
try {
return await operation(authority);
} finally {
if (authority) endManagedOnlineBackupMutation();
}
});
state.promise = promise;
managedOnlineBackupOperationQueues.set(id, state);
renderManagedOnlineBackups();
return promise.finally(() => {
endManagedOnlineBackupOperation();
if (managedOnlineBackupOperationQueues.get(id) !== state) return;
managedOnlineBackupOperationQueues.delete(id);
renderManagedOnlineBackups();
@@ -2803,18 +2894,20 @@ function enqueueManagedOnlineBackupOperation(id, action, operation) {
async function copyManagedOnlineBackup(entry) {
const id = entry.id;
if (!hasManagedOnlineBackup(id) || managedOnlineBackupOperationQueues.has(id)) return;
await enqueueManagedOnlineBackupOperation(id, 'copy', async (statusContext) => {
const focusTarget = { id, action: 'copy', fallbackIndex: managedOnlineBackups.findIndex(current => current.id === id) };
await enqueueManagedOnlineBackupOperation(id, 'copy', async () => {
try {
const result = await window.api.copyManagedOnlineBackup(id);
if (!result?.ok) {
setOnlineBackupStatus('Online-Sicherung konnte nicht kopiert werden', 'error', statusContext);
showCopyToast(result?.error || 'Online-Sicherung konnte nicht kopiert werden');
return;
}
showCopyToast('Online-Schlüssel kopiert');
} catch {
setOnlineBackupStatus('Online-Sicherung konnte nicht kopiert werden', 'error', statusContext);
showCopyToast('Online-Sicherung konnte nicht kopiert werden');
}
});
renderManagedOnlineBackups(focusTarget);
}
async function deleteManagedOnlineBackup(entry) {
@@ -2827,32 +2920,35 @@ async function deleteManagedOnlineBackup(entry) {
});
if (!confirmed) return;
if (!hasManagedOnlineBackup(id)) {
const statusContext = beginManagedOnlineBackupOperation();
const statusContext = beginOnlineBackupStatusContext();
setOnlineBackupStatus('Online-Sicherung konnte nicht gelöscht werden', 'error', statusContext);
endManagedOnlineBackupOperation();
return;
}
if (managedOnlineBackupOperationQueues.get(id)?.action === 'delete') {
setOnlineBackupStatus('Online-Sicherung konnte nicht gelöscht werden', 'error');
return;
}
await enqueueManagedOnlineBackupOperation(id, 'delete', async (statusContext) => {
const fallbackIndex = managedOnlineBackups.findIndex(current => current.id === id);
let removed = false;
await enqueueManagedOnlineBackupOperation(id, 'delete', async (authority) => {
if (!hasManagedOnlineBackup(id)) {
setOnlineBackupStatus('Online-Sicherung konnte nicht gelöscht werden', 'error', statusContext);
setOnlineBackupStatus('Online-Sicherung konnte nicht gelöscht werden', 'error', authority.statusContext);
return;
}
try {
const result = await window.api.deleteManagedOnlineBackup(id);
if (!result?.ok || result.removedId !== id) {
setOnlineBackupStatus('Online-Sicherung konnte nicht gelöscht werden', 'error', statusContext);
setOnlineBackupStatus(result?.error || 'Online-Sicherung konnte nicht gelöscht werden', 'error', authority.statusContext);
return;
}
if (!await loadManagedOnlineBackups({ statusContext })) return;
setOnlineBackupStatus('Schlüssel gelöscht', 'success', statusContext);
removed = removeManagedOnlineBackup(id, { id, action: 'delete', fallbackIndex });
await loadManagedOnlineBackups({ mutationGeneration: authority.mutationGeneration });
setOnlineBackupStatus('Schlüssel gelöscht', 'success', authority.statusContext);
} catch {
setOnlineBackupStatus('Online-Sicherung konnte nicht gelöscht werden', 'error', statusContext);
setOnlineBackupStatus('Online-Sicherung konnte nicht gelöscht werden', 'error', authority.statusContext);
}
});
}, true);
renderManagedOnlineBackups({ id, action: 'delete', fallbackIndex: removed ? fallbackIndex : managedOnlineBackups.findIndex(current => current.id === id) });
}
async function doOnlineBackupCreate() {
@@ -2863,29 +2959,29 @@ async function doOnlineBackupCreate() {
openOnlineBackupView();
} catch {
openOnlineBackupView();
const statusContext = beginManagedOnlineBackupOperation();
const statusContext = beginOnlineBackupStatusContext();
setOnlineBackupStatus('Nicht alle Einstellungen konnten gespeichert werden', 'error', statusContext);
endManagedOnlineBackupOperation();
doOnlineBackupCreate.busy = false;
return;
}
const statusContext = beginManagedOnlineBackupOperation();
const authority = beginManagedOnlineBackupMutation();
const createButton = document.getElementById('createOnlineBackupBtn');
if (createButton) createButton.disabled = true;
setOnlineBackupStatus('Verschlüssele und speichere Einstellungen…', 'busy', statusContext);
setOnlineBackupStatus('Verschlüssele und speichere Einstellungen…', 'busy', authority.statusContext);
try {
const result = await window.api.createManagedOnlineBackup();
if (!result?.ok) {
setOnlineBackupStatus('Online-Sicherung konnte nicht erstellt werden', 'error', statusContext);
setOnlineBackupStatus(result?.error || 'Online-Sicherung konnte nicht erstellt werden', 'error', authority.statusContext);
return;
}
if (!await loadManagedOnlineBackups({ statusContext })) return;
setOnlineBackupStatus('Neuer Schlüssel erstellt. Ältere Schlüssel bleiben gültig.', 'success', statusContext);
if (!upsertManagedOnlineBackup(result.entry)) setManagedOnlineBackupRefreshIssue('Online-Sicherungen konnten nicht geladen werden', 'error');
await loadManagedOnlineBackups({ mutationGeneration: authority.mutationGeneration });
setOnlineBackupStatus('Neuer Schlüssel erstellt.', 'success', authority.statusContext);
showCopyToast('Online-Schlüssel erstellt');
} catch {
setOnlineBackupStatus('Online-Sicherung konnte nicht erstellt werden', 'error', statusContext);
setOnlineBackupStatus('Online-Sicherung konnte nicht erstellt werden', 'error', authority.statusContext);
} finally {
endManagedOnlineBackupOperation();
endManagedOnlineBackupMutation();
if (createButton?.isConnected) createButton.disabled = false;
doOnlineBackupCreate.busy = false;
}
@@ -5088,6 +5184,10 @@ function renderSettings() {
<section class="online-backup-managed" aria-labelledby="managedOnlineBackupHeading">
<h4 id="managedOnlineBackupHeading">Auf diesem Gerät erstellt</h4>
<div class="online-backup-managed-list" id="managedOnlineBackupList"></div>
<div class="online-backup-refresh-status" id="managedOnlineBackupRefreshStatus" role="status" hidden>
<span id="managedOnlineBackupRefreshMessage"></span>
<button class="btn btn-secondary" id="reloadManagedOnlineBackupsBtn" type="button">Erneut laden</button>
</div>
</section>
<div class="online-backup-status" id="onlineBackupStatus" role="status" aria-live="polite"></div>
<footer class="online-backup-footer">
@@ -5401,6 +5501,7 @@ function renderSettings() {
});
document.getElementById('createOnlineBackupBtn').addEventListener('click', () => doOnlineBackupCreate());
renderManagedOnlineBackups();
renderManagedOnlineBackupRefreshIssue();
document.getElementById('onlineBackupKeyInput').addEventListener('input', (event) => {
const valid = /^MHU2-[A-Za-z0-9_-]{70}$/.test(event.target.value.trim());
document.getElementById('restoreOnlineBackupBtn').disabled = document.getElementById('restoreOnlineBackupBtn').dataset.busy === 'true' || !valid;
+9 -1
View File
@@ -313,6 +313,7 @@
['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'],
['Erneut laden', 'Reload'],
['Jeder Export erzeugt einen neuen Schlüssel. Ältere Schlüssel bleiben gültig.', 'Each export creates a new key. Older keys remain valid.'],
['Dein neuer Schlüssel', 'Your new key'],
['Neuer Schlüssel', 'Your new key'],
@@ -459,7 +460,7 @@
['Log-Datei', 'Log file'],
['MB/s · 0 = unbegrenzt', 'MB/s · 0 = unlimited'],
['Log-Pfad automatisch auf funktionierenden Ordner gesetzt', 'Log path automatically changed to a writable folder'],
['Neuer Schlüssel erstellt. Ältere Schlüssel bleiben gültig.', 'New key created. Older keys remain valid.'],
['Neuer Schlüssel erstellt.', 'New key created.'],
['Nicht alle Einstellungen konnten gespeichert werden', 'Not all settings could be saved'],
['Online-Schlüssel erstellt', 'Online key created'],
['Online-Schlüssel kopiert', 'Online key copied'],
@@ -542,6 +543,13 @@
['Online-Sicherungs-ID ist ungültig', 'Online backup ID is invalid'],
['Ungültiger Erstellungszeitpunkt', 'Invalid creation timestamp'],
['Gespeicherter Online-Schlüsselbund ist ungültig', 'Stored online keyring is invalid'],
['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'],
['Online-Sicherung konnte lokal nicht eindeutig entfernt werden', 'Online backup could not be removed from this device unambiguously'],
['Online-Sicherungsschlüssel konnte nicht sicher vorbereitet werden', 'Online backup key could not be prepared securely'],
['Geschützter Schlüssel', 'Protected key'],
['Backup hat eine ungültige Struktur', 'The backup has an invalid structure'],
+27
View File
@@ -1983,6 +1983,28 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
text-align: center;
}
.online-backup-refresh-status {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border: 1px solid rgba(245, 158, 11, 0.35);
border-radius: 8px;
color: #fbbf24;
background: rgba(245, 158, 11, 0.08);
}
.online-backup-refresh-status[hidden] {
display: none;
}
.online-backup-refresh-status[data-state="error"] {
border-color: rgba(239, 68, 68, 0.35);
color: #fca5a5;
background: rgba(239, 68, 68, 0.08);
}
.online-backup-footer {
display: flex;
justify-content: flex-end;
@@ -3694,6 +3716,11 @@ input[type="checkbox"] {
.online-backup-footer .btn {
width: 100%;
}
.online-backup-refresh-status {
align-items: stretch;
flex-direction: column;
}
}
#settings-view {
+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) {
+357 -112
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 });
it('requires a canonical encrypted envelope before decrypting stored values', async () => {
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);
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.');