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:
+381
-86
@@ -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;
|
||||
}
|
||||
|
||||
module.exports = { createOnlineBackupKeyring };
|
||||
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 = {
|
||||
KEYRING_ERROR_CODES,
|
||||
OnlineBackupKeyringError,
|
||||
createOnlineBackupKeyring
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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'],
|
||||
|
||||
@@ -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
@@ -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
@@ -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')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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.');
|
||||
|
||||
Reference in New Issue
Block a user